Android 면접 준비

Android 면접 질문

자주 묻는 Android 면접 질문 20개. Android 개발은 Kotlin 기반 모바일 앱을 만들고, Android SDK와 Jetpack을 활용하며, 수명 주기, 성능, 네트워킹, 저장소, 테스트, 릴리스 과제를 해결하는 데 초점을 맞춘 수요가 높은 분야입니다. 이 질문들은 여러 난이도를 다루며, 면접 트레이너에서 큰 소리로 답변하는 연습을 할 수 있습니다.

Android AI 면접 시작하기신용카드가 필요하지 않습니다. 무료 세션 1회 제공.
영어 기술 면접 연습비원어민이 기술 면접 통과를 연습할 수 있는 모드입니다.

초급 질문

1생성부터 소멸까지 핵심 Activity 수명 주기 콜백을 단계별로 살펴보고, 리소스 해제 관점에서 onPause(), onStop(), onDestroy()의 차이점을 설명해 주세요.

Activity는 생성부터 소멸까지 onCreate(), onStart(), onResume(), onPause(), onStop(), onDestroy()의 6가지 핵심 수명 주기 콜백을 거칩니다. onCreate()는 뷰 인플레이션과 같은 1회성 초기화 작업을 수행합니다. onStart()는 액티비티를 화면에 표시하고, onResume()은 포그라운드로 가져와 사용자와 상호작용할 수 있는 포커스를 부여합니다. 다른 화면으로 이동할 때 onPause()는 액티비티가 포커스를 잃었음을 나타내고, onStop()은 화면에서 더 이상 보이지 않음을 나타내며, onDestroy()는 액티비티 인스턴스의 최종 소멸을 나타냅니다. 리소스 해제 관점에서의 차이점은 다음과 같습니다: - onPause(): onPause()의 코드 실행은 다음에 열릴 액티비티의 시작을 직접적으로 차단하므로, UI 애니메이션 일시 정지나 가벼운 카메라 프리뷰 중지와 같이 포그라운드에 특화된 신속한 작업만 일시 정지해야 합니다. - onStop(): UI 가시성과 관련된 무거운 리소스(예: GPS/위치 리스너, 센서 피드, 미디어 플레이어 재생, 네트워크 폴링 등)를 해제하는 핵심 콜백입니다. 액티비티가 완전히 보이지 않는 상태에서 이러한 리소스를 계속 실행하면 배터리가 낭비되며, 시스템이 추가 알림 없이 백그라운드 프로세스를 강제 종료할 수 있습니다. - onDestroy(): 액티비티 인스턴스의 최종 정리(예: 로컬 스레드 또는 참조 해제)에 사용됩니다. 하지만 안드로이드 시스템은 메모리를 확보하기 위해 onDestroy()를 실행하지 않고 정지된(stopped) 앱 프로세스를 직접 종료할 수 있으므로, 핵심 리소스의 해제를 onDestroy()까지 미루어서는 안 됩니다.

class LocationActivity : AppCompatActivity() {
    private var locationClient: LocationClient? = null

    override fun onStart() {
        super.onStart()
        // Start listening when visible to the user
        locationClient?.startLocationUpdates()
    }

    override fun onStop() {
        super.onStop()
        // Release when invisible to save battery and prevent leaks on process kill
        locationClient?.stopLocationUpdates()
    }
}
AI 코치와 함께 이 질문에 답해 보세요

2Android 애플리케이션의 4대 핵심 컴포넌트는 무엇이며, 이들의 기본 콜백 메서드가 갖는 생명주기와 스레딩 모델은 어떠한가요?

Android의 4대 핵심 애플리케이션 컴포넌트는 Activity, Service, BroadcastReceiver, ContentProvider입니다. Activity는 화면 상호작용을 위한 사용자 인터페이스를 제공합니다. Service는 별도의 UI 없이 백그라운드 처리를 수행합니다. BroadcastReceiver는 시스템 전역 또는 앱 수준의 브로드캐스트 공지를 수신하고 이에 반응합니다. ContentProvider는 정형화된 데이터를 관리하고 다른 애플리케이션이나 내부 모듈에 노출합니다. 기본적으로 Activity(onCreate, onStart, onResume 등), Service(onCreate, onStartCommand, onBind 등), BroadcastReceiver(onReceive)의 주요 생명주기 콜백은 애플리케이션의 메인 스레드(UI 스레드)에서 동기적으로 실행됩니다. ContentProvider 메서드(onCreate 등) 역시 메인 스레드에서 초기화되지만, 프로세스 간 호출되는 query/insert 등의 작업은 Binder 스레드 풀의 스레드에서 실행됩니다. 이처럼 기본 콜백이 메인 스레드에서 실행되기 때문에, 네트워크 요청이나 대규모 데이터베이스 디스크 I/O와 같은 무거운 블로킹 작업을 콜백 내에서 직접 실행하면 UI 루프가 차단되어 ANR(Application Not Responding) 오류가 발생합니다.

class DataSyncService : Service() {
    private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob())

    override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
        // onStartCommand runs on the Main Thread; heavy work must be offloaded
        serviceScope.launch {
            performDiskAndNetworkSync()
            stopSelf(startId)
        }
        return START_NOT_STICKY
    }

    override fun onBind(intent: Intent?): IBinder? = null

    override fun onDestroy() {
        super.onDestroy()
        serviceScope.cancel()
    }

    private fun performDiskAndNetworkSync() {
        // Blocking I/O safely executed on Dispatchers.IO
    }
}
AI 코치와 함께 이 질문에 답해 보세요

3Android의 MVVM(Model-View-ViewModel) 아키텍처에서 Activity나 Fragment와 같은 UI(User Interface) 컨트롤러와 비교할 때 ViewModel의 주요 책임은 무엇이며, ViewModel에 Context를 전달하는 것이 권장되지 않는 이유는 무엇인가요?

Android의 MVVM 아키텍처에서 ViewModel은 UI 관련 상태를 보관 및 관리하고, 프레젠테이션 로직을 조율하며, 화면 회전과 같은 구성 변경(configuration change) 시에도 유지되는 역할을 담당합니다. 반면 UI 컨트롤러(Activity 및 Fragment)는 화면에 UI 요소를 렌더링하고, 상태 변화를 관찰하며, 클릭이나 제스처 같은 사용자의 직접적인 상호작용을 처리하고, Android 수명 주기 이벤트를 관리하는 책임을 집니다. ViewModel에 Activity Context나 View 참조를 전달하는 것은 강력히 권장되지 않습니다. ViewModel은 일반적으로 구성 변경 전반에 걸쳐 UI 컨트롤러의 수명 주기보다 오래 존속하기 때문입니다. 화면 회전 중에 Activity가 소멸되고 다시 생성될 때, ViewModel이 해당 Activity의 Context를 참조하고 있으면 기존 Activity가 가비지 컬렉션(GC)되지 않아 메모리 누수가 발생합니다. 시스템 수준 작업에 Android Context가 반드시 필요한 경우에는 애플리케이션 스코프의 Context(`AndroidViewModel` 사용 또는 ApplicationContext 주입)를 대신 사용해야 합니다.

// Bad: Storing Activity context or View reference causes leaks
class LeakyViewModel(private val activityContext: Context) : ViewModel() {
    fun showToast() { Toast.makeText(activityContext, "Hello", Toast.LENGTH_SHORT).show() }
}

// Good: ViewModel manages state; Activity observes and handles context-specific tasks
class SafeViewModel(private val repository: UserRepository) : ViewModel() {
    private val _uiState = MutableStateFlow<UserUiState>(UserUiState.Loading)
    val uiState: StateFlow<UserUiState> = _uiState.asStateFlow()
    
    fun loadUser(id: String) {
        viewModelScope.launch {
            _uiState.value = UserUiState.Success(repository.getUser(id))
        }
    }
}
AI 코치와 함께 이 질문에 답해 보세요

4Android의 일반 권한(normal permission)과 위험 권한(dangerous permission)은 어떻게 다르며, 앱이 런타임에 위험 권한을 요청할 때 어떤 동작이 발생하나요?

일반 권한(normal permission)은 사용자 개인정보나 기기 운영에 미치는 위험이 최소한인 권한으로, AndroidManifest.xml에 선언되어 있으면 앱 설치 시 시스템에 의해 자동으로 부여됩니다(예: ACCESS_NETWORK_STATE). 위험 권한(dangerous permission, 런타임 권한이라고도 함)은 카메라, 마이크, 위치, 연락처 등 사용자의 비공개 데이터나 제한된 기기 기능에 대한 접근을 제어합니다. 앱이 런타임에 위험 권한을 요청하면 Android 시스템은 사용자에게 접근을 허용하거나 거부하도록 요청하는 시스템 대화상자를 표시합니다. 사용자가 권한을 허용하면 앱은 요청된 기능을 실행합니다. 사용자가 권한을 거부하면 앱은 거부 콜백을 수신하며, 비정상 종료(crash) 없이 해당 기능의 동작을 비활성화하거나 제한하여 적절히 처리해야 하고, 필요에 따라 권한이 필요한 이유를 설명하는 UI(rationale)를 표시할 수 있습니다.

val requestPermissionLauncher = registerForActivityResult(
    ActivityResultContracts.RequestPermission()
) { isGranted: Boolean ->
    if (isGranted) {
        accessCameraFeature()
    } else {
        showPermissionDeniedExplanation()
    }
}

fun checkAndRequestPermission() {
    when {
        ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED -> {
            accessCameraFeature()
        }
        shouldShowRequestPermissionRationale(Manifest.permission.CAMERA) -> {
            showRationaleDialog { requestPermissionLauncher.launch(Manifest.permission.CAMERA) }
        }
        else -> {
            requestPermissionLauncher.launch(Manifest.permission.CAMERA)
        }
    }
}
AI 코치와 함께 이 질문에 답해 보세요

5안드로이드(Android) 빌드에서 디버그(debug)와 릴리스(release)의 차이점은 무엇이며, 빌드 타입(build type)과 프로덕트 플레이버(product flavor)가 결합하여 빌드 변형(build variant)을 구성하는 방식은 무엇인가요?

안드로이드 개발에서 빌드 타입(build type)은 개발의 각 단계에 맞춰 패키징 및 런타임 속성을 구성합니다. 디버그(debug) 빌드는 디버깅 플래그(`isDebuggable = true`)를 활성화하고, 기본 디버그 키스토어를 사용하며, 빠른 빌드 반복을 위해 일반적으로 코드 축소 및 난독화(R8/ProGuard)를 비활성화합니다. 릴리스(release) 빌드는 디버깅 플래그를 비활성화하고 최적화 및 난독화를 활성화하며, 배포를 위한 보안 릴리스 서명 키를 필요로 합니다. 프로덕트 플레이버(product flavor)는 동일한 핵심 코드베이스를 공유하면서 서로 다른 환경(예: staging 대 production)이나 제품 에디션(예: 무료 버전 대 유료 버전)과 같이 앱의 다양한 버전이나 타깃을 나타냅니다. Gradle에서 빌드 변형(build variant)은 프로덕트 플레이버와 빌드 타입의 데카르트 곱(Cartesian product)으로 형성됩니다(Build Variant = Product Flavor × Build Type). 예를 들어 `staging` 및 `production` 플레이버와 `debug` 및 `release` 빌드 타입을 결합하면 `stagingDebug`, `stagingRelease`, `productionDebug`, `productionRelease`의 네 가지 빌드 변형이 생성됩니다.

android {
    flavorDimensions += "environment"
    productFlavors {
        create("staging") {
            dimension = "environment"
            applicationIdSuffix = ".staging"
        }
        create("production") {
            dimension = "environment"
        }
    }
    buildTypes {
        getByName("debug") {
            isDebuggable = true
        }
        getByName("release") {
            isMinifyEnabled = true
            isShrinkResources = true
            proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
        }
    }
}
AI 코치와 함께 이 질문에 답해 보세요

6Kotlin에서 코루틴(coroutine)이란 무엇이며, Android의 런타임 환경에서 중단(suspension)은 스레드 차단(blocking)과 어떻게 다른가요?

Kotlin의 코루틴은 비동기 논블로킹(non-blocking) 코드를 순차적인 방식으로 작성할 수 있게 해 주는 경량 동시성 추상화입니다. OS 수준 스레드와 같은 무거운 메모리 및 컨텍스트 스위칭 오버헤드 없이, 단일 스레드 또는 스레드 풀 전반에서 여러 코루틴을 동시에 실행할 수 있습니다. 중단(suspension)과 차단(blocking)의 핵심적인 차이는 스레드 활용 방식에 있습니다. - 차단(예: `Thread.sleep()` 또는 동기 디스크/네트워크 I/O): 기저 스레드를 중지시켜 다른 작업을 처리하지 못하게 만듭니다. Android의 메인 스레드에서 차단이 발생하면 UI가 멈추고 ANR(Application Not Responding) 오류가 발생합니다. - 중단(예: `delay()`와 같은 `suspend` 함수 사용): 기저 스레드를 멈추지 않고 코루틴만 일시 정지합니다. 코루틴은 실행 상태를 `Continuation`에 캡처하고 스레드를 런타임에 반환하여 스레드가 다른 작업(예: UI 상호작용 처리)을 수행할 수 있게 합니다. 비동기 작업이 완료되면 코루틴은 다시 재개됩니다.

suspend fun fetchUserData(): String {
    delay(1000) // Suspends execution and yields the thread
    return "User Data"
}

fun fetchUserDataBlocking(): String {
    Thread.sleep(1000) // Blocks and locks the underlying thread
    return "User Data"
}
AI 코치와 함께 이 질문에 답해 보세요

7Jetpack Compose에서 remember와 rememberSaveable로 보존되는 상태의 차이점은 무엇이며, mutableStateOf는 리컴포지션 전반에서 UI (User Interface) 업데이트를 어떻게 유도하나요?

Jetpack Compose에서 `remember`는 컴포저블이 컴포지션 트리에 유지되는 동안 리컴포지션이 발생해도 객체를 메모리에 보존합니다. 하지만 `remember`로만 유지되는 상태는 화면 회전과 같은 구성 변경(configuration changes)이나 시스템에 의한 프로세스 종료 시 손실됩니다. 반면 `rememberSaveable`은 Android의 Saved Instance State 번들을 통해 값을 자동으로 저장하고 복원하므로(지원되지 않는 데이터 타입은 커스텀 `Saver` 구현을 통해 처리), 구성 변경 및 프로세스 종료가 발생해도 상태를 보존합니다. `mutableStateOf`는 Compose의 Snapshot 상태 시스템을 기반으로 값을 관찰 가능한 `MutableState` 객체로 감쌉니다. 컴포지션 도중 컴포저블이 `MutableState`의 값을 읽으면 Compose는 이 읽기 동작을 기록합니다. 이후 해당 값이 변경되면 Compose는 그 값을 읽었던 모든 컴포저블을 유효하지 않은 것으로 표시하고 리컴포지션을 예약하여 UI가 업데이트된 상태와 항상 동기화되도록 만듭니다.

@Composable
fun CounterExample() {
    // Resets to 0 on screen rotation
    var localCount by remember { mutableStateOf(0) }

    // Persists across screen rotation and process death
    var persistentCount by rememberSaveable { mutableStateOf(0) }

    Column {
        Button(onClick = { localCount++; persistentCount++ }) {
            Text("Local: $localCount | Saved: $persistentCount")
        }
    }
}
AI 코치와 함께 이 질문에 답해 보세요

8Kotlin의 널 안전성(null safety)은 Android 앱에서 비정상 종료를 방지하는 데 어떻게 도움이 되며, 널 허용 타입과 널 불허 타입은 각각 어떤 경우에 사용합니까?

Kotlin의 널 안전성은 널 가능 여부를 타입 시스템의 일부로 포함하여 수많은 Android 앱 비정상 종료를 방지합니다. `String`으로 선언된 값은 널 불허(non-null)로 취급되므로, 컴파일러가 `name.length`와 같은 직접 접근을 허용합니다. 반면 `String?`로 선언된 값은 널일 수 있으므로, Kotlin은 이를 사용하기 전에 반드시 해당 경우를 처리하도록 강제합니다. 이로 인해 선택적 Intent 엑스트라(extras), API 응답, Bundle 인자, Java/플랫폼 API 등에서 발생하는 우발적인 `NullPointerException`이 줄어듭니다. 객체나 함수가 유효하게 동작하기 위해 값이 반드시 필요한 경우에는 널 불허 타입을 사용합니다. 프로필 이미지 URL이 선택 사항이거나 서버 필드가 누락될 수 있는 경우처럼 값이 없는 상태가 예상되는 정상적인 상태라면 널 허용 타입을 사용합니다. 널 허용 값은 대개 `user?.name`과 같은 안전한 호출(safe call), `user?.name ?: "Guest"`와 같은 엘비스 연산자(Elvis operator), 또는 명시적 널 검사를 통해 처리합니다. `!!` 연산자는 널 안전성을 우회하며 값이 널일 경우 예외를 발생시키므로, 개발자가 해당 값이 절대 널이 아님을 확실히 증명할 수 있는 매우 드문 경우에만 제한적으로 사용해야 합니다.

val displayName: String? = intent.getStringExtra("display_name")

val greeting = "Hello, ${displayName ?: "Guest"}"
val lengthText = displayName?.length?.toString() ?: "No name provided"

// Risky: crashes if displayName is null
val riskyLength = displayName!!.length
AI 코치와 함께 이 질문에 답해 보세요

9수명이 긴 객체에 Activity Context 대신 Application Context를 전달할 때의 동작 메커니즘 차이는 무엇이며, 잘못 선택할 경우 어떻게 메모리 누수가 발생하나요?

Activity Context는 특정 화면 및 UI 계층 구조의 수명 주기와 직접 연결되어 있는 반면, Application Context는 앱 프로세스 전체의 수명 주기와 연결되어 있습니다. Activity가 종료되거나 화면 회전과 같은 구성 변경(configuration change) 등으로 다시 생성될 때, 해당 Activity의 수명 주기는 종료되며 메모리는 GC(Garbage Collector)에 의해 회수되어야 합니다. 싱글톤, 정적(static) 필드, 오래 실행되는 백그라운드 스레드와 같이 수명이 긴 객체에 Activity Context를 전달하면, 해당 객체가 Activity에 대한 강한 참조(strong reference)를 유지하게 됩니다. 수명이 긴 객체는 GC 루트(GC root) 역할을 하거나 GC 루트로부터 도달 가능하므로, 가비지 컬렉터는 소멸된 Activity를 수거할 수 없습니다. 이로 인해 Activity 인스턴스 자체뿐만 아니라 연결된 전체 View 계층 구조, 드로어블(drawable), 리소스까지 메모리에 남아 심각한 메모리 누수를 유발합니다. 반면에 `applicationContext`를 전달하는 것은 수명 주기가 프로세스 수명과 일치하므로 수명이 긴 컴포넌트에서도 안전합니다.

class LocationRepository private constructor(private val context: Context) {
    companion object {
        @Volatile
        private var INSTANCE: LocationRepository? = null

        fun getInstance(context: Context): LocationRepository {
            return INSTANCE ?: synchronized(this) {
                // SAFE: Using context.applicationContext prevents retaining an Activity reference
                INSTANCE ?: LocationRepository(context.applicationContext).also { INSTANCE = it }
            }
        }
    }
}
AI 코치와 함께 이 질문에 답해 보세요

10Google Play 앱 서명을 사용할 때 업로드 키와 앱 서명 키의 차이점은 무엇인가요?

Google Play 앱 서명(Google Play App Signing) 환경에서 업로드 키와 앱 서명 키는 서로 다른 두 가지 보안 역할을 수행합니다. 업로드 키는 개발자가 직접 보유하며 빌드 결과물(AAB)을 Google Play Console에 업로드하기 전에 서명하는 데 사용되어 Google에 개발자의 신원을 증명합니다. 반면 앱 서명 키는 Google의 클라우드 인프라 내에 안전하게 보관되며, 최종 사용자의 기기에 실제로 배포 및 설치되는 생성된 APK에 서명하는 데 사용되어 Android 시스템상에서 앱의 영구적인 암호학적 신원을 확립합니다. 이 구조의 주된 운영상 이점은 키 복구입니다. 개발자가 업로드 키를 분실하거나 키가 유출되더라도, 개발자 신원 확인을 거쳐 앱의 업데이트 경로를 깨뜨리지 않고 Google Play 지원팀을 통해 업로드 키를 재설정할 수 있습니다. 반면 개발자가 앱 서명 키를 직접 관리하던 레거시 방식에서는 해당 키를 분실하면 앱을 영구히 업데이트할 수 없었습니다.

android {
    signingConfigs {
        create("release") {
            storeFile file("my-upload-key.jks")
            storePassword System.getenv("KEYSTORE_PASSWORD")
            keyAlias "upload-alias"
            keyPassword System.getenv("KEY_PASSWORD")
        }
    }
    buildTypes {
        release {
            signingConfig signingConfigs.getByName("release")
            minifyEnabled true
        }
    }
}
AI 코치와 함께 이 질문에 답해 보세요

중급 질문

11기기 회전 후 화면에서 사용자가 입력한 폼 데이터가 유실됩니다. 이 문제가 상태 저장 누락, 잘못된 ViewModel 스코프 지정, 뷰 재바인딩 중 무엇 때문인지 어떻게 체계적으로 진단하겠습니까?

기기 회전 후 폼 데이터가 유실되는 원인을 체계적으로 진단하려면 세 가지 핵심 영역을 격리하여 점검해야 합니다. 1. **ViewModel 스코프 및 유지(Retention):** 구성 변경(configuration change) 시 ViewModel 인스턴스가 재생성되지 않고 실제로 유지되는지 확인합니다. 회전 전후로 ViewModel의 해시코드(`viewModel.hashCode()`)를 로그로 출력해 봅니다. 인스턴스가 `MyViewModel()`과 같은 생성자 직접 호출 방식이 아니라 `by viewModels()` / `by activityViewModels()` 또는 `ViewModelProvider(this)` 같은 위임 프로퍼티를 통해 획득되었는지 확인합니다. 2. **상태 저장 및 뷰 ID 복원:** 폼이 안드로이드의 기본 뷰 계층 복원 기능에만 의존하고 있는지 확인합니다. 뷰가 상태 자동 저장 및 복원에 참여하려면 레이아웃에 `android:id` 속성이 정의되어 있어야 합니다. 커스텀 뷰나 `SavedStateHandle`을 사용 중이라면 `onSaveInstanceState` 또는 `SavedStateHandle`이 해당 필드를 실제로 저장하고 복원하는지 확인합니다. 3. **뷰 재바인딩 및 옵저버 로직:** `onViewCreated` 또는 옵저버 구독(`StateFlow`, `LiveData`) 로직을 점검합니다. 새로 생성된 뷰가 올바르게 재구독하는지, 아니면 뷰 설정 코드에서 복원된 텍스트를 빈 기본값으로 덮어쓰고 있지 않은지 확인합니다. 추가로 양방향 데이터 바인딩이나 `TextWatcher` 루프로 인해 비어 있는 복원 뷰가 즉시 빈 업데이트를 ViewModel로 다시 내보내고 있지는 않은지 확인합니다.

class FormFragment : Fragment(R.layout.fragment_form) {
    // 1. Ensure correct lifecycle scoping
    private val viewModel: FormViewModel by viewModels()

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        Log.d("FormDiag", "VM instance hash: ${viewModel.hashCode()}")

        // 2. Observe state without clobbering input during view recreation
        viewLifecycleOwner.lifecycleScope.launch {
            viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
                viewModel.formText.collect { text ->
                    if (binding.inputField.text.toString() != text) {
                        binding.inputField.setText(text)
                    }
                }
            }
        }
    }
}
AI 코치와 함께 이 질문에 답해 보세요

12최신 Jetpack Activity Result API(Application Programming Interface)는 기존의 startActivityForResult를 어떻게 대체하며, Activity 재생성 시 콜백이 유실되는 문제를 어떻게 방지하나요?

최신 Jetpack Activity Result API는 기존의 `startActivityForResult()` 및 `onActivityResult()`를 타입 안전하고 결합도가 낮은 계약(contract) 기반 메커니즘으로 대체합니다. 임의의 정수 요청 코드와 방대한 `onActivityResult` switch 문을 관리하는 대신, 호출자는 `ActivityResultContract<I, O>`(입력 인텐트 매개변수와 기대되는 파싱된 출력 정의)를 선언하고 `registerForActivityResult()`를 사용해 타입이 지정된 콜백을 등록하여 `ActivityResultLauncher`를 얻습니다. 이 API는 엄격한 생명주기 등록을 통해 Activity 재생성이나 프로세스 종료(process death) 시 콜백이 유실되는 문제를 방지합니다. 1. 카메라나 문서 선택기 같은 외부 Activity가 실행될 때, 메모리 부족이나 구성 변경으로 인해 OS가 호출한 Activity를 소멸시킬 수 있습니다. 2. `registerForActivityResult()`를 Activity나 Fragment가 `STARTED` 상태에 도달하기 전(일반적으로 프로퍼티 초기화 시점이나 `onCreate` 내부)에 무조건 호출하도록 강제함으로써, 상태 복원이 이루어지기 전에 결과 콜백이 `ActivityResultRegistry`에 등록되도록 보장합니다. 3. 실행된 Activity가 반환되고 호스트 Activity가 재생성되면, `ActivityResultRegistry`는 시스템으로부터 전달된 대기 중인 결과를 새로 등록된 콜백과 일치시켜 안전하게 결과를 전달합니다.

class ProfileActivity : AppCompatActivity() {
    // Registered unconditionally during initialization / before onStart()
    private val takePictureLauncher = registerForActivityResult(
        ActivityResultContracts.TakePicturePreview()
    ) { bitmap: Bitmap? ->
        bitmap?.let {
            findViewById<ImageView>(R.id.avatarImage).setImageBitmap(it)
        }
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_profile)

        findViewById<Button>(R.id.btnCapture).setOnClickListener {
            takePictureLauncher.launch(null)
        }
    }
}
AI 코치와 함께 이 질문에 답해 보세요

13복잡한 화면을 기준으로 MVVM(Model-View-ViewModel)과 MVI(Model-View-Intent) 아키텍처를 비교해 주세요. MVI의 상태 리듀서(state reducer)와 인텐트 디스패칭은 MVVM에 비해 원자적 상태 업데이트를 어떻게 보장합니까?

전통적인 MVVM에서는 ViewModel이 여러 개의 독립적인 리액티브 스트림(예: `isLoading`, `userData`, `errorMessage`를 별도의 `StateFlow` 또는 `LiveData` 프로퍼티로 노출)이나 여러 개의 public 업데이트 메서드를 노출하는 경우가 많습니다. 여러 비동기 작업이 동시에 완료되면 이러한 스트림들이 독립적으로 업데이트되어 경쟁 상태(race condition), 불완전한 중간 상태, 또는 화면 깜빡임이 발생할 수 있습니다. MVI(Model-View-Intent)의 상태 관리는 다음 세 가지 요소를 중심으로 엄격한 단방향 데이터 흐름(UDF, Unidirectional Data Flow)을 따릅니다. 1. 전체 화면 상태를 나타내는 단일 불변 `UiState` 2. 모든 사용자 상호작용 및 시스템 이벤트를 표현하는 개별 `UiIntent`(또는 Action) 타입 3. 순수 함수 형태의 상태 리듀서: `(PreviousState, UiIntent) -> NewState` MVI는 모든 이벤트가 개별 인텐트로 디스패치되고 상태 리듀서를 통해 순차적으로 라우팅되기 때문에 원자적 상태 업데이트를 보장합니다. 리듀서는 기존 상태의 불변 스냅샷을 받아 모든 관련 프로퍼티가 동시에 업데이트된 완전히 새로운 상태 스냅샷을 생성합니다. 상태 업데이트가 중앙 집중화되고 상태 전이가 순차적으로 이루어지므로, UI는 일부만 업데이트되었거나 동기화가 어긋난 모순된 상태 스냅샷을 절대 관찰하지 않게 됩니다.

data class ScreenState(
    val isLoading: Boolean = false,
    val items: List<Item> = emptyList(),
    val error: String? = null
)

sealed interface ScreenIntent {
    data object Refresh : ScreenIntent
    data class DataLoaded(val items: List<Item>) : ScreenIntent
    data class LoadFailed(val message: String) : ScreenIntent
}

fun reduce(state: ScreenState, intent: ScreenIntent): ScreenState = when (intent) {
    is ScreenIntent.Refresh -> state.copy(isLoading = true, error = null)
    is ScreenIntent.DataLoaded -> state.copy(isLoading = false, items = intent.items, error = null)
    is ScreenIntent.LoadFailed -> state.copy(isLoading = false, error = intent.message)
}
AI 코치와 함께 이 질문에 답해 보세요

14사용자에게 강요하지 않으면서 최초 요청, 근거 설명 UI, 거부, '다시 묻지 않음', 설정 화면 이동을 처리하는 런타임 권한 플로우를 어떻게 설계하겠습니까?

강요 없이 사용자 친화적인 런타임 권한 플로우는 점진적 공개(progressive disclosure), 명확한 사유 전달, 모든 거부 상태에 대한 단계적 기능 축소(graceful degradation) 원칙을 따릅니다. 1. **맥락 기반 요청(최초 요청)**: 앱 시작 시 무조건 권한을 요청하지 않고, 사용자가 해당 기능을 직접 사용할 때(예: 카메라 권한이 필요한 '코드 스캔' 탭)에만 필요한 시점에 권한을 요청합니다. 2. **근거 설명 UI(Rationale UI)**: `shouldShowRequestPermissionRationale()`이 `true`를 반환하면 시스템 권한 팝업을 띄우기 전에 해당 권한이 왜 필요한지, 어떤 이점이 있는지 명확하게 설명하는 인앱 안내(바텀 시트나 다이얼로그 등)를 표시합니다. 3. **거부 시 우아한 기능 저하(Graceful Degradation)**: 사용자가 요청을 거부하더라도 해당 결정을 존중하며 다른 무관한 기능까지 차단하지 않습니다. 가능한 경우 대체 워크플로우를 제공합니다(예: 카메라 접근이 거부되면 텍스트 수동 입력 제공). 4. **영구 거부('다시 묻지 않음') 및 설정 이동(Settings Fallback)**: 권한이 거부되었고 `shouldShowRequestPermissionRationale()`이 `false`를 반환하는 경우(사용자가 '다시 묻지 않음'을 선택했거나 반복해서 거부한 경우), 해당 기능을 사용할 수 없는 이유를 알리고 `Settings.ACTION_APPLICATION_DETAILS_SETTINGS`를 통해 앱 설정으로 이동할 수 있는 선택적 버튼을 제공합니다. 사용자가 강제로 갇히지 않고 쉽게 취소하거나 뒤로 돌아갈 수 있도록 해야 합니다.

fun onScanButtonClicked() {
    when {
        ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED -> {
            openScanner()
        }
        activity.shouldShowRequestPermissionRationale(Manifest.permission.CAMERA) -> {
            showRationaleDialog(onConfirm = { cameraPermissionLauncher.launch(Manifest.permission.CAMERA) })
        }
        else -> {
            cameraPermissionLauncher.launch(Manifest.permission.CAMERA)
        }
    }
}

fun handlePermissionResult(isGranted: Boolean) {
    if (isGranted) {
        openScanner()
    } else if (!activity.shouldShowRequestPermissionRationale(Manifest.permission.CAMERA)) {
        showSettingsRedirectDialog()
    } else {
        showManualEntryFallback()
    }
}
AI 코치와 함께 이 질문에 답해 보세요

15풀 리퀘스트(PR) 검증부터 서명된 릴리스 후보(RC) 생성, 내부 테스트, Play 트랙 업로드 및 점진적 롤아웃 프로모션에 이르는 Android CI/CD (Continuous Integration/Continuous Deployment) 파이프라인을 어떻게 설계하시겠습니까?

프로덕션 Android CI/CD 파이프라인은 풀 리퀘스트 검증 단계의 빠른 자동화 검사로 시작합니다. 여기에는 정적 분석(ktlint, detekt, Android Lint), 단위 테스트, 디버그 빌드 컴파일이 포함됩니다. main 또는 release 브랜치에 병합되면 릴리스 파이프라인이 트리거되어, CI에서 관리하는 릴리스 키스토어 자격 증명, Proguard/R8 코드 축소, 자동 버전 넘버링을 적용하여 서명된 릴리스 후보(RC) Android App Bundle(`.aab`)을 생성합니다. 서명된 AAB와 난독화 해제 매핑 파일은 Gradle Play Publisher(GPP)나 Fastlane 같은 도구를 사용해 Google Play의 내부 테스트 트랙(또는 Firebase App Distribution)으로 업로드되며, 여기서 자동화된 스모크 테스트나 계측(instrumentation) 테스트가 실행됩니다. 내부 QA 및 이해관계자의 승인이 완료되면 소스에서 다시 빌드하지 않고 완전히 동일한 바이너리 아티팩트를 하위 단계(내부 -> 비공개 알파/베타 -> 프로덕션 롤아웃)로 승격합니다. 마지막으로 자동 크래시율 및 Vitals 모니터링과 결합된 단계적 롤아웃(예: 5% -> 20% -> 100%)을 통해 안전하게 배포합니다.

lane :promote_internal_to_beta do
  upload_to_play_store(
    track: 'internal',
    track_promote_to: 'beta',
    skip_upload_aab: true, # Promotes existing binary without rebuilding
    skip_upload_metadata: false,
    skip_upload_changelogs: false
  )
end
AI 코치와 함께 이 질문에 답해 보세요

16`CancellationException`을 다시 던지지 않고 일반적인 `Exception`이나 `Throwable`을 잡는(catch) 것이 코루틴 취소를 깨뜨리는 이유는 무엇이며, 취소 문제는 어떻게 진단하나요?

Kotlin Coroutines는 `CancellationException`을 통해 구현되는 협력적 취소(cooperative cancellation)에 의존합니다. 코루틴의 `Job`이 취소되면 일시 중단 함수(예: `delay`, `yield`)가 `CancellationException`을 던져 호출 스택을 언와인드하고 코루틴을 종료합니다. 코드가 `CancellationException`을 다시 던지지(rethrow) 않고 일반 `Exception`이나 `Throwable`을 잡으면 취소 신호가 삼켜집니다. 결과적으로 코루틴이 중단되지 않고 계속 실행되어 CPU와 메모리를 낭비하고 리소스를 누수하며, 이미 파괴된 UI 컴포넌트에 접근하여 유효하지 않은 상태 전이나 비정상 종료(crash)를 유발하는 '좀비 코루틴'이 생성됩니다. 취소 문제를 진단하는 방법은 다음과 같습니다: 1. 포괄적인 `Throwable`/`Exception` 대신 도메인별 예외만 잡거나, `CancellationException`을 명시적으로 다시 던지도록 예외 처리 방식을 검증합니다. 2. 디버그 빌드에서 Kotlin Coroutines Debugger(`-Dkotlinx.coroutines.debug`) 또는 Android Studio의 Coroutines Inspector를 사용하여 이미 종료되었어야 할 실행 중인 코루틴의 상태를 검사합니다. 3. 코루틴 수명주기 상태(예: `job.isActive`, `job.isCancelled`)를 로깅하거나 취소 완료 핸들러(`job.invokeOnCompletion`)에서 구조화된 로깅을 활용합니다.

import kotlinx.coroutines.CancellationException

// ANTI-PATTERN: Swallows cancellation, creating a zombie coroutine
try {
    doSuspendingWork()
} catch (e: Exception) {
    logError(e)
}

// CORRECT: Preserves cancellation propagation
try {
    doSuspendingWork()
} catch (e: Exception) {
    if (e is CancellationException) throw e
    logError(e)
}
AI 코치와 함께 이 질문에 답해 보세요

17Jetpack Compose에서 상태 끌어올리기(state hoisting)와 단방향 데이터 흐름(UDF, Unidirectional Data Flow)은 어떻게 동작하며, 상태를 말단 컴포저블, 상위 컴포저블, ViewModel 중 어디에 둘지 어떻게 결정하시겠습니까?

상태 끌어올리기(state hoisting)는 Jetpack Compose에서 컴포저블을 상태가 없는(stateless) 순수 UI 컴포넌트로 만들기 위해 상태를 컴포지션 계층 구조의 상위로 이동시키는 패턴입니다. 이는 상태는 아래로 흐르고(상위/ViewModel에서 하위 컴포저블로 인자를 통해 전달) 이벤트는 위로 흐르는(하위 컴포저블에서 상위/ViewModel로 콜백을 통해 전달) 단방향 데이터 흐름(UDF)을 가능하게 합니다. 상태를 어디에 둘지는 다음 기준에 따라 결정합니다. 1. 말단 컴포저블 (로컬 UI 상태): 상태가 완전히 일시적이고 시각적이며, 상위나 형제 컴포넌트에서 알 필요가 없는 경우(예: 내부 펼치기/접기 애니메이션 실행 여부나 로컬 리플 효과)에는 `remember`를 사용하여 말단 컴포저블 내부에 로컬 상태로 유지합니다. 2. 상위 컴포저블 (끌어올린 UI 상태): 형제 컴포저블들이 상태를 공유하거나 이에 반응해야 하는 경우, 또는 상위 컴포넌트가 해당 컴포넌트의 표시 여부나 유효성 검사를 제어해야 하는 경우에는 가장 가까운 공통 상위 컴포저블로 상태를 끌어올립니다. 이로 인해 말단 컴포넌트는 상태를 가지지 않게 됩니다(`value`와 `onValueChange` 수신). 3. ViewModel (화면 / 비즈니스 상태): 상태가 비즈니스 데이터를 표현하거나, 구성 변경(configuration change) 시에도 유지되어야 하거나, 내비게이션을 제어하거나, 도메인/리포지토리 계층과의 상호작용이 필요한 경우에는 관찰 가능한 상태(예: `StateFlow`)로 노출되는 ViewModel에 둡니다. ViewModel은 비즈니스 로직을 처리하고 이에 맞춰 UI 상태를 업데이트합니다.

// Stateless Leaf Composable
@Composable
fun SearchBar(query: String, onQueryChange: (String) -> Unit) {
    TextField(value = query, onValueChange = onQueryChange)
}

// Stateful Screen / ViewModel Owner
@Composable
fun SearchScreen(viewModel: SearchViewModel = viewModel()) {
    val uiState by viewModel.uiState.collectAsStateWithLifecycle()
    SearchBar(
        query = uiState.query,
        onQueryChange = { newQuery -> viewModel.onQueryChanged(newQuery) }
    )
}
AI 코치와 함께 이 질문에 답해 보세요

고급 질문

18프로덕션 환경에서 FragmentManager의 상태 손실(state loss) 및 내비게이션 경쟁 상태(race condition)를 감지, 진단, 방지하기 위한 텔레메트리, 크래시 브레드크럼, 아키텍처 가드레일을 어떻게 설계하시겠습니까?

FragmentManager의 상태 손실(`IllegalStateException: Can not perform this action after onSaveInstanceState`)과 비동기 내비게이션 경쟁 상태는 네트워크 콜백이나 리액티브 스트림 같은 비동기 작업이 호스트 수명주기가 `onSaveInstanceState()` 또는 `onStop()`을 지난 이후 UI 트랜잭션을 시도할 때 발생합니다. 프로덕션 환경에서 이를 감지하고 진단하기 위해 `Application.ActivityLifecycleCallbacks`와 `FragmentManager.FragmentLifecycleCallbacks`를 통해 수명주기 텔레메트리 및 브레드크럼(breadcrumbs)을 구현하여, 타임스탬프가 포함된 상태 전이, 대기 중인 백스택 수, 크래시 발생 직전의 실행 컨텍스트를 기록합니다. 아키텍처 관점에서 상태 손실을 방지하려면 원시 비동기 콜백 대신 수명주기를 인식하는 상태 관찰자(예: `repeatOnLifecycle(Lifecycle.State.RESUMED)` 또는 수명주기와 바인딩되어 수집되는 `StateFlow`)를 통해서만 내비게이션 및 UI 트랜잭션을 수행해야 합니다. 내비게이션 이벤트는 단방향 데이터 흐름(UDF, Unidirectional Data Flow) 기반의 개별 상태 전이나, 수명주기 상태가 최소 `STARTED` 또는 `RESUMED` 이상일 때만 소비되는 단발성(single-shot) 이벤트로 모델링해야 합니다. 아울러 아키텍처 가드레일을 구축하여 복원 불가능한 명시적 임시 컨텍스트에서만 `commitStateLoss()`를 사용하도록 제한하거나, 가급적 수명주기 경계가 엄격한 Jetpack Navigation 컴포넌트로 마이그레이션해야 합니다. 커스텀 Android Lint 규칙을 통한 정적 분석과 디버그 빌드에서의 런타임 강제(예: Fragment StrictMode)를 적용하면 이러한 위반 사항이 프로덕션에 배포되기 전에 조기에 차단할 수 있습니다.

class NavigationDispatcher @Inject constructor() {
    private val _events = Channel<NavigationCommand>(Channel.BUFFERED)
    val events: Flow<NavigationCommand> = _events.receiveAsFlow()

    fun navigate(command: NavigationCommand) {
        _events.trySend(command)
    }
}

// In Fragment / Activity
viewLifecycleOwner.lifecycleScope.launch {
    viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.RESUMED) {
        navigationDispatcher.events.collect { command ->
            CrashReporting.leaveBreadcrumb("Navigating to ${command.destination} at state ${lifecycle.currentState}")
            command.execute(parentFragmentManager)
        }
    }
}
AI 코치와 함께 이 질문에 답해 보세요

19Doze 모드, 앱 대기 버킷(App Standby Buckets), OEM(Original Equipment Manufacturer) 작업 킬러와 같은 시스템 수준의 전력 관리 기능이 백그라운드 스케줄링에 어떤 영향을 미치며, WorkManager를 사용하여 복원력 있는 동기화 엔진을 어떻게 설계하겠습니까?

시스템 수준의 배터리 최적화는 백그라운드 실행을 엄격하게 제한합니다. Doze 모드는 기기가 비활성 상태일 때 CPU, 네트워크 접근, 백그라운드 작업을 제한하며, 주기적인 유지보수 기간(maintenance window)에만 일시적으로 실행을 허용합니다. 앱 대기 버킷은 최근 앱 사용 이력(Active부터 Restricted 또는 Never까지)에 따라 백그라운드 작업 빈도와 네트워크 접근을 동적으로 제한합니다. 나아가 제조사의 공격적인 맞춤형 전력 관리 기능(예: MIUI, OneUI)은 순수 AOSP(Android Open Source Project) 동작과 무관하게 백그라운드 프로세스를 강제 종료하고 표준 알람을 무시하며 자동 시작 권한을 박탈하기도 합니다. 복원력 있는 동기화 엔진을 구축하려면 OS 제약 조건과 직접 연동되면서 JobScheduler, AlarmManager, BroadcastReceivers를 추상화하는 WorkManager가 표준 기반이 됩니다. WorkManager를 사용하면 엄격한 실행 전제 조건(예: NetworkType.CONNECTED, requiresBatteryNotLow(true))을 선언할 수 있어, Doze 유지보수 기간이나 네트워크 연결이 복원될 때까지 실행을 자동으로 지연시킬 수 있습니다. 예기치 않은 프로세스 종료, 일시적인 네트워크 끊김, 제조사의 강제 종료를 견뎌내기 위해 동기화 엔진은 지수 백오프(exponential backoff)와 엔드투엔드 멱등성이라는 두 가지 핵심 원칙을 따라야 합니다. Doze 상태에서 복구될 때 백엔드 서버에 트래픽이 일시에 몰리는 현상(thundering herd)을 방지하도록 WorkManager에 BackoffPolicy.EXPONENTIAL을 설정해야 합니다. 또한 워커 도중 갑자기 강제 종료되어 다시 큐에 들어가더라도 재시도로 인해 데이터가 중복되거나 로컬 데이터베이스가 손상되지 않도록, 결정론적 트랜잭션 ID, 로컬 상태 플래그, 서버 측 중복 제거 키를 활용하여 동기화 작업을 원자적이고 멱등하게 처리해야 합니다.

val syncConstraints = Constraints.Builder()
    .setRequiredNetworkType(NetworkType.CONNECTED)
    .setRequiresBatteryNotLow(true)
    .build()

val syncWorkRequest = OneTimeWorkRequestBuilder<SyncWorker>()
    .setConstraints(syncConstraints)
    .setBackoffCriteria(
        BackoffPolicy.EXPONENTIAL,
        WorkRequest.MIN_BACKOFF_MILLIS,
        TimeUnit.MILLISECONDS
    )
    .addTag("data_sync_tag")
    .build()

WorkManager.getInstance(context).enqueueUniqueWork(
    "unique_data_sync",
    ExistingWorkPolicy.KEEP,
    syncWorkRequest
)
AI 코치와 함께 이 질문에 답해 보세요

20Gradle 빌드 시간을 최적화하고 엄격한 계약(contract) 경계를 적용하기 위해, 'API-Implementation' 모듈 분리 패턴을 활용한 확장 가능한 멀티팀 Android 아키텍처를 어떻게 설계하시겠습니까?

엔터프라이즈 환경의 멀티팀 Android 코드베이스에서 API-Implementation(API-Impl) 패턴은 각 기능 또는 도메인 모듈을 두 개의 독립된 Gradle 서브프로젝트로 분리합니다. 즉, 공개 인터페이스, 모델 및 내비게이션 계약을 포함하는 경량의 `:feature:api` 모듈과 내부 비즈니스 로직, UI, 리포지토리 구현체를 포함하는 `:feature:impl` 모듈로 나눕니다. 이 기능을 사용하는 외부 모듈은 `implementation project(':feature:api')`를 통해 오직 `:feature:api`에만 의존하며, 루트 `:app` 모듈이나 전용 컴포지션 루트(composition root)에서 의존성 주입(예: Dagger/Hilt)을 통해 구체적인 구현체를 연결합니다. 이 패턴은 ABI(Application Binary Interface) 안정성과 컴파일 클래스패스 격리를 통해 Gradle 빌드 성능을 대폭 최적화합니다. 개발자가 `:feature:impl`의 내부 구현 세부사항을 수정하더라도 `:feature:api`의 공개 ABI는 변경되지 않습니다. 그 결과 Gradle은 `:feature:api`에만 의존하는 모든 다운스트림 모듈의 재컴파일을 건너뛰므로, Gradle 원격 빌드 캐시(Remote Build Cache)와 구성 캐시(configuration cache)의 효율이 극대화됩니다. 조직 및 거버넌스 관점에서도 CODEOWNERS와 같은 도구를 활용해 팀 간 소유권 경계를 명확히 설정할 수 있습니다. 각 팀은 비공개 클래스를 외부에 노출하지 않고 내부 구현을 안전하게 변경할 수 있으므로, 대규모 팀 간의 불필요한 결합도와 순환 의존성을 방지할 수 있습니다.

// :feature:checkout:api/src/main/java/com/example/checkout/api/CheckoutApi.kt
package com.example.checkout.api

interface CheckoutLauncher {
    fun launchCheckoutFlow(cartId: String)
}

// :feature:checkout:impl/src/main/java/com/example/checkout/impl/CheckoutLauncherImpl.kt
package com.example.checkout.impl

import com.example.checkout.api.CheckoutLauncher
import javax.inject.Inject

internal class CheckoutLauncherImpl @Inject constructor(
    private val paymentGateway: PaymentGateway
) : CheckoutLauncher {
    override fun launchCheckoutFlow(cartId: String) {
        // Concrete checkout implementation
    }
}

// :feature:checkout:impl/src/main/java/com/example/checkout/impl/di/CheckoutModule.kt
package com.example.checkout.impl.di

import com.example.checkout.api.CheckoutLauncher
import com.example.checkout.impl.CheckoutLauncherImpl
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent

@Module
@InstallIn(SingletonComponent::class)
internal abstract class CheckoutBindingModule {
    @Binds
    abstract fun bindCheckoutLauncher(impl: CheckoutLauncherImpl): CheckoutLauncher
}
AI 코치와 함께 이 질문에 답해 보세요