생명주기 — Activity·Fragment·생명주기 인지
왜 생명주기라는 게 존재하는가
데스크톱 프로그램은 내가 켜고 내가 끈다 — 프로그램이 메모리를 차지한 채 가만히 있어도 아무도 뭐라 하지 않는다. 그런데 휴대폰은 다르다. 화면은 한 번에 하나뿐이고, 메모리는 빠듯하며, 전화가 오거나 사용자가 홈 버튼을 누르거나 다른 앱으로 가는 일이 수시로 일어난다. 이런 환경에서 OS는 내 앱을 언제든 멈추고, 화면을 가리고, 심지어 프로세스를 통째로 죽일 수 있어야 한다 — 그래야 지금 사용자가 보는 앱에 자원을 몰아줄 수 있다.
문제는, OS가 말없이 그렇게 하면 내 앱은 엉망이 된다. 카메라를 켜 둔 채 화면이 가려지면 카메라가 낭비되고, 진행 중이던 다운로드가 끊기고, 화면에 채워 둔 데이터가 사라진다. 그래서 안드로이드는 OS와 앱 사이에 **계약(contract)**을 둔다 — *"내가 너의 화면을 이런 상태로 바꿀 거야, 그러니 너는 이 콜백에서 준비/정리를 해"*라고 알려주는 것이다. 이 계약이 **생명주기(lifecycle)**이고, 콜백(onCreate·onStart…)은 OS가 나에게 거는 전화다. 생명주기를 이해한다는 것은 결국 *"지금 화면이 어떤 상태이고, 그래서 나는 무엇을 켜고 꺼야 하는가"*를 늘 의식한다는 뜻이다. 이걸 놓치면 리소스 누수·상태 유실·크래시라는 세 가지 고전적 사고가 난다. (출처: Android — Activity lifecycle / Lifecycle-aware components.)
Activity 생명주기 — 일곱 콜백이 언제, 왜 불리는가
콜백을 외우기보다 한 사용자의 동선을 따라가며 언제 불리는지를 그려 보는 게 빠르다. 사용자가 앱을 켜면 — onCreate(화면 객체와 뷰를 만든다) → onStart(화면이 보이기 시작한다) → onResume(이제 터치를 받을 수 있다). 여기까지 와야 비로소 사용자가 앱을 쓴다.
이제 사용자가 홈 버튼을 누른다 — onPause(화면이 물러나기 시작) → onStop(이제 전혀 안 보인다). 앱은 백그라운드에 살아 있지만 보이지 않는 상태다. 사용자가 다시 앱으로 돌아오면 — onRestart → onStart → onResume으로 되살아난다(이때 onCreate는 다시 불리지 않는다 — 객체가 아직 살아 있으므로). 마지막으로 사용자가 뒤로 가기로 화면을 닫거나 OS가 종료하면 onDestroy가 불리고 객체가 사라진다.
각 콜백의 책임은 이 동선에서 자연스럽게 나온다.
onCreate— 화면 일생에 한 번. 뷰를 만들고(setContentView/setContent), ViewModel을 연결하고, 한 번만 하면 되는 초기화를 한다. 무겁고 한 번뿐인 일이 여기 온다.onStart/onStop— 화면이 보이기 시작 / 완전히 안 보임. 사용자에게 보일 때만 의미 있는 자원(예: 위치 업데이트 구독, 실시간 데이터 수집)을onStart에서 켜고onStop에서 끈다.onResume/onPause— 상호작용 가능 / 일부 가림. 다이얼로그가 뜨거나 분할 화면에서 포커스를 잃으면onPause까지만 가고onStop엔 안 갈 수 있다.onPause는 반드시 짧아야 한다 — 이 콜백이 끝나야 다음 화면이 그려지므로, 여기서 무거운 저장을 하면 화면 전환이 버벅인다.onDestroy— 종료 또는 *구성 변경(회전)*으로 소멸. 최후의 정리.
짝을 맞춰 자원을 다뤄라 — 안 그러면 누수
생명주기에서 가장 실수가 잦은 곳이 자원의 획득과 해제다. 원칙은 단순하다 — 짝이 맞는 콜백에서 켜고 끈다. onStart에서 켰으면 onStop에서 끄고, onResume에서 등록했으면 onPause에서 해제한다.
override fun onStart() { super.onStart()
sensorManager.registerListener(this, accel, SENSOR_DELAY_UI) // 보일 때 켠다
}
override fun onStop() { super.onStop()
sensorManager.unregisterListener(this) // 안 보이면 끈다
}
왜 이렇게까지 따져야 할까? 만약 센서 리스너를 onStart에서 등록하고 해제를 깜빡하면 — 화면이 백그라운드로 가도 센서가 계속 콜백을 쏴서 배터리를 먹고, 더 나쁘게는 리스너가 Activity를 참조한 채로 남아 Activity가 GC되지 못하는 메모리 누수가 된다([12 메모리]). 카메라·미디어 플레이어·브로드캐스트 리시버·위치 클라이언트 — 무거운 자원일수록 이 짝맞춤이 중요하다. *"이 자원은 화면이 어느 상태일 때 살아 있어야 하나?"*를 물어 그 구간의 콜백 짝에 묶는 것이 핵심이다.
구성 변경(회전) — 왜 멀쩡한 화면을 부수고 다시 만드나
화면을 가로로 돌리면 — 놀랍게도 안드로이드는 멀쩡히 살아 있는 Activity를 onDestroy로 파괴하고, 즉시 onCreate부터 새로 만든다. 처음 보면 비효율적으로 보이지만, 이유가 있다. 화면을 돌리거나 다크모드로 바꾸거나 언어를 바꾸면 — 써야 할 리소스(레이아웃·문자열·이미지)가 통째로 달라진다. 안드로이드는 그 새 구성에 맞는 리소스를 처음부터 다시 로드하는 가장 단순하고 확실한 방법으로 재생성을 택했다.
대가는 상태 유실이다. Activity가 파괴되니 그 멤버 변수에 담아 둔 모든 것이 사라진다. 사용자가 긴 폼을 절반 채우고 화면을 돌렸는데 입력이 다 날아가면 — 전형적인 회전 버그다. 그래서 상태를 Activity가 아니라 회전에 살아남는 자리에 두어야 한다.
해법은 층이 있다. (1) ViewModel — ViewModel은 회전으로 Activity가 재생성돼도 유지된다(같은 화면의 새 Activity가 기존 ViewModel을 다시 받는다). 그래서 네트워크로 받아 둔 목록·진행 중인 작업·화면 상태를 ViewModel에 두면 회전에 안전하다. (2) rememberSaveable/SavedStateHandle — *작은 UI 상태(스크롤 위치·입력 중인 텍스트)*는 Bundle에 저장해 복원한다([andarch 07]). 핵심 사고는 — 생명주기에 휘둘리는 자리(Activity 멤버)에서 휘둘리지 않는 자리(ViewModel/SavedState)로 상태를 옮기는 것이다.
프로세스 죽음 — ViewModel로도 못 막는 진짜 함정
회전보다 더 까다로운 시나리오가 있다. 사용자가 내 앱을 백그라운드에 두고 다른 무거운 앱들을 한참 쓰면, OS는 메모리가 부족할 때 백그라운드의 내 앱 프로세스를 통째로 죽인다. 이때 ViewModel도 함께 사라진다(메모리에 있던 것이니까). 그런데 사용자 입장에선 *"내 앱으로 돌아왔을 뿐"*이라 — OS는 마지막 화면을 다시 그려 줘야 한다(프로세스를 새로 띄우고 Activity를 재생성). 이때 ViewModel은 비어 있고, 사용자는 "방금 보던 그 화면"을 기대한다.
이 간극을 메우는 게 **SavedStateHandle**이다. 프로세스가 죽기 직전 OS가 작은 Bundle을 디스크에 저장하는데, SavedStateHandle은 그 Bundle에 묶여 프로세스 죽음 뒤에도 *복원할 작은 핵심(검색어·선택한 항목 id)*을 들고 돌아온다([andarch 07]). 정리하면 — 회전엔 ViewModel, 프로세스 죽음엔 SavedStateHandle이고, 큰 데이터는 어느 쪽에도 담지 말고 DB(SSOT)에서 다시 로드한다.
Fragment — 두 개의 생명주기라는 함정
Fragment는 Activity 안에 사는 모듈형 UI 조각인데, 고유의 함정이 하나 있다 — Fragment 자신의 생명주기와 그 Fragment가 가진 *뷰(View)*의 생명주기가 다르다는 것이다. 왜 둘일까? Fragment는 백스택에 남을 수 있다 — 사용자가 A 화면(Fragment)에서 B로 이동하면, A의 뷰는 파괴되지만(메모리 절약) Fragment 객체 자체는 백스택에 살아 있다. 나중에 뒤로 가기로 A로 돌아오면 같은 Fragment에 뷰만 새로 만든다(onDestroyView 뒤 onCreateView 재호출).
여기서 사고가 난다. 만약 Fragment 자신의 생명주기(this)로 데이터를 관찰하면 — 뷰는 파괴됐는데 Fragment는 살아 있으니 콜백이 계속 불리고, 그 콜백이 이미 파괴된 옛 뷰(binding)를 건드려 크래시가 난다(또는 옛 뷰를 붙잡아 누수). 그래서 뷰와 관련된 모든 작업·관찰은 Fragment가 아니라 viewLifecycleOwner(뷰의 생명주기)에 묶어야 한다.
override fun onViewCreated(view: View, s: Bundle?) {
viewLifecycleOwner.lifecycleScope.launch { // ★ 뷰 생명주기에 묶는다
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.uiState.collect { render(it) } // 뷰가 살아있을 때만 수집
}
}
}
viewLifecycleOwner.lifecycleScope로 시작한 코루틴은 뷰가 파괴되는 순간(onDestroyView) 자동 취소되므로, 파괴된 뷰를 건드릴 일이 없다. **뷰 작업엔 viewLifecycleOwner**가 Fragment의 제1 철칙이다.
생명주기 인지 컴포넌트 — 콜백을 직접 안 만지는 길
콜백을 일일이 오버라이드하다 보면 Activity가 다시 비대해진다(위치·카메라·애널리틱스의 시작/정지 코드가 다 모임). 그래서 현대 안드로이드는 방향을 뒤집는다 — 각 컴포넌트가 스스로 생명주기를 구독하게 만드는 것이다. **DefaultLifecycleObserver**를 구현한 컴포넌트는 lifecycle.addObserver(this)로 등록되면 자기 onStart/onStop을 알아서 받아 자율적으로 자원을 관리한다. Activity는 *"너 이 생명주기를 따라가"*라고 등록만 하고 손을 뗀다.
lifecycleScope·repeatOnLifecycle·collectAsStateWithLifecycle([05·09])이 모두 이 생명주기 인지의 산물이다 — 우리가 콜백을 직접 짝맞추지 않아도, 생명주기에 맞춰 코루틴을 시작/취소해 준다.
백스택과 태스크 — 화면이 쌓이는 방식
화면(Activity)들은 태스크(task)라는 스택에 쌓인다 — 새 화면을 열면 push, 뒤로 가기가 pop이다. 보통은 이 기본 동작이면 충분하지만, 알림을 눌렀을 때 화면이 중복 생성되거나, 로그인 후 로그인 화면이 백스택에 남는 문제가 생기면 — launchMode(singleTop·singleTask)나 Intent flags로 스택 동작을 조절한다([02 컴포넌트]). 그리고 앞서 본 프로세스 죽음은 이 백스택과 별개의 문제다 — OS는 백스택 구조는 복원해 주지만, 각 화면 안의 상태는 SavedStateHandle이 없으면 복원하지 못한다.
정리 — "이 작업은 화면이 어느 상태일 때 살아야 하나"
생명주기는 외울 콜백 목록이 아니라 OS와의 계약이다. 핵심은 넷이다. (1) Activity 콜백은 짝(start↔stop, resume↔pause)을 맞춰 자원을 켜고 끈다 — 안 그러면 누수·배터리 낭비. (2) 회전은 destroy→recreate라 상태를 ViewModel에, 프로세스 죽음엔 SavedStateHandle에 둔다. (3) Fragment는 뷰 생명주기가 따로라 뷰 작업은 *viewLifecycleOwner*에 묶는다. (4) 생명주기 인지 컴포넌트로 콜백을 직접 안 만지고 자율 관리한다. 매 코드마다 *"이 작업·자원·상태는 화면이 어느 상태일 때 살아 있어야 하는가"*를 묻는 것 — 그것이 안드로이드의 사고법이다.
한 줄 요약 — 생명주기는 OS와의 계약. Activity: onCreate(1회)/onStart↔onStop(보임)/onResume↔onPause(상호작용, onPause는 짧게), 자원은 짝맞는 콜백에서 획득/해제(안 그러면 누수). 회전=destroy→recreate→상태는 ViewModel, 프로세스 죽음→SavedStateHandle, 큰 데이터는 DB에서 재로드. Fragment는 뷰 생명주기 별도→뷰 작업은
viewLifecycleOwner. 생명주기 인지 컴포넌트로 자율 관리.