앱이 종료될 때는 한마디로 요약하면, 앱 프로세스 종료 → JS 런타임 종료 → 힙 삭제가 일어난다. 이 과정이 실제로 어떻게 일어나는지, 그리고 이걸 실무에서 어떻게 활용하는지 알아보자.
먼저 가장 중요한 사실부터 못 박는다. 모바일 앱은 OS가 언제든, 사전 통보 없이 죽일 수 있다. 데스크톱처럼 "종료 직전에 정리할 기회"가 보장되지 않는다.
이 전제를 받아들이면 결론은 하나로 모인다 — "종료될 때 저장한다"가 아니라 "백그라운드로 갈 때, 또는 변경될 때마다 미리 저장한다" 가 정석이다. 종료 시점의 정리 콜백에 중요한 데이터를 맡기면 잃는다.
먼저 백그라운드 전환(backgrounding) 과 종료(termination) 를 구분해야 한다. 많은 버그가 이 둘을 헷갈려서 생긴다.
종료가 일어나는 경로는 여러 가지다.
2번이 핵심이다. 백그라운드에 있던 앱은 언제든 소리 없이 종료될 수 있다는 것이 모든 설계의 기준이 된다.
프로세스 종료 → JS 런타임 종료 → 힙 삭제 의 과정을 정확히 풀면 이렇다.
OS가 앱 프로세스에 종료를 지시한다(또는 즉시 kill 한다). 이 프로세스 안에는 네이티브 코드, RN 런타임, Hermes 엔진, 모든 네이티브 뷰와 모듈이 들어 있다. 프로세스가 죽으면 그 안의 모든 것이 함께 사라진다.
Hermes VM은 이 프로세스 안에서 돌고 있었다. 프로세스가 죽으면 Hermes 런타임도 파괴된다. 실행 중이던 JS 코드, 이벤트 루프, 진행 중이던 타이머·Promise·네트워크 콜백이 전부 중단된다. 완료를 보장받지 못하며, 의도하지 않은 결과로 종료되어버릴 수 있다. 아래 코드는 예시이다.
const onSave = async () => {
await fetch("/api/save", {
method: "POST",
body: JSON.stringify({ nickname: "John" })
});
Alert.alert("저장 완료");
};
사용자가 저장 버튼 누르고 API 요청 보내는 중에 앱을 강제 종료하면
JS 힙(Hermes가 관리하던 객체들)과 네이티브 힙(디코딩된 이미지, 네이티브 뷰 등)이 모두 해제된다.
여기서 정확히 알아야 할 점:
요약하면, 종료는 "정중한 정리"가 아니라 "전원 차단"에 가깝다. 메모리에만 있던 것은 전부 사라진다고 봐야 한다.
질문의 체인은 보통 "프로세스가 죽으면서 JS 런타임도 죽는" 흐름이지만, JS 런타임만 따로 종료·재생성되는 경우도 있다. 이걸 알아야 디버깅 때 헷갈리지 않는다.
정리하면 계층은 이렇다.
앱 프로세스
└─ 네이티브 런타임 / 모듈 / 뷰
└─ JS 런타임 (Hermes)
└─ JS 힙 (앱 상태, 컴포넌트, 캐시)
dev에서 reload 했을 때 메모리에만 있던 상태가 날아가는 건 정상이다. 이걸 "버그"로 오해하지 않는다. 반대로, 진짜 콜드 스타트에서만 재현되는 문제(영속 저장 누락 등)는 reload로는 재현되지 않으니, 앱을 완전히 종료(스와이프)했다가 다시 켜서 테스트한다.
JS는 종료 자체를 직접 감지할 수 없다. 종료되면 JS가 더 이상 안 돌기 때문이다. JS가 볼 수 있는 마지막 신뢰 가능한 신호는 백그라운드 전환이다.
import { AppState } from 'react-native';
useEffect(() => {
const sub = AppState.addEventListener('change', (next) => {
// 'active' | 'background' | 'inactive'(iOS 전환 중)
if (next === 'background') {
saveStateNow(); // 여기가 저장의 골든 타임이다
}
});
return () => sub.remove();
}, []);
active : 포그라운드, 사용 중이다.inactive(iOS) : 전환 중간 상태(전화 수신, 멀티태스킹 전환 등)다.background : 백그라운드로 갔다. 여기가 "곧 종료될 수도 있다"고 가정하고 저장해야 하는 시점이다.background 이후에 OS가 언제 죽일지는 모른다. 그러니 background를 받은 순간을 마지막 저장 기회로 취급한다. 'terminated' 상태는 JS에 전달되지 않는다. 그 시점엔 이미 JS가 멈춰있기 때문이다.
네이티브에 종료 콜백이 있긴 하지만, 신뢰할 수 없다.
applicationWillTerminate: 백그라운드 실행을 지원하는 현대 앱에서는, 시스템이 서스펜드된 앱을 죽일 때 호출되지 않는다. 즉 가장 흔한 종료(2번, OS의 조용한 회수)에서 안 불린다.onDestroy: OS가 프로세스를 강제로 죽이면 호출이 보장되지 않는다. 메모리 회수나 강제 중지 시 건너뛸 수 있다.따라서 이 콜백들에 "마지막 저장"을 맡기면 데이터를 잃는다. 신뢰 가능한 저장 시점은 background 전환과 "값이 바뀔 때마다"뿐이다.
AppState === 'background'에서 한 번 저장한다.New Architecture 환경이면 JSI 기반 동기 저장소인 MMKV가 막판 저장까지 안전해서 권장이다.
import { MMKV } from 'react-native-mmkv';
import { useEffect, useRef } from 'react';
const storage = new MMKV();
// 변경 시마다 디바운스 저장하는 훅
function useDebouncedPersist(key, value, delay = 400) {
const timer = useRef(null);
useEffect(() => {
if (timer.current) clearTimeout(timer.current);
timer.current = setTimeout(() => {
storage.set(key, JSON.stringify(value));
}, delay);
return () => timer.current && clearTimeout(timer.current);
}, [key, value, delay]);
}
이걸 폼 화면에서 쓰면 이렇다.
import { useState, useEffect } from 'react';
import { AppState, TextInput } from 'react-native';
function ComposeScreen() {
const [text, setText] = useState(() => {
const saved = storage.getString('draft:compose');
return saved ? JSON.parse(saved) : '';
});
// 1) 입력이 바뀔 때마다 디바운스 저장
useDebouncedPersist('draft:compose', text, 400);
// 2) 백그라운드 전환 시 막판 flush (디바운스 대기분 손실 방지)
useEffect(() => {
const sub = AppState.addEventListener('change', (next) => {
if (next === 'background') {
storage.set('draft:compose', JSON.stringify(text));
}
});
return () => sub.remove();
}, [text]);
return <TextInput value={text} onChangeText={setText} multiline />;
}
핵심은 (1) 변경 시 디바운스 저장과 (2) background flush를 세트로 쓴다는 점이다. 디바운스만 두면 마지막 입력 직후 OS가 프로세스를 죽일 때 대기 중이던 쓰기가 통째로 날아간다. "종료는 예고 없이 온다"는 전제가 정확히 이 구멍을 노린다.
"변경 시마다 영속화"는 공짜가 아니라 비용이 있는 선택이다. 데이터의 성격에 따라 오히려 안 하는 게 맞는 경우가 있다. 판단 기준은 두 축, 중요도 × 변경 빈도다.
| 빈도 낮음 | 빈도 높음 | |
|---|---|---|
| 중요도 높음 | 변경 즉시 동기 쓰기(write-through). 설정 토글, 동의 여부, 결제 직전 단계 등. | 디바운스 + background flush. 작성 중 텍스트가 대표적. |
| 중요도 낮음 | background에서 한 번이면 충분. 마지막 탭 인덱스 등. | 변경 시 영속화 금지. 스크롤 위치, 드래그 좌표, 애니메이션 값. background에서 한 번만 저장. |
background에서 한 번 더 보장한다. 위 예시가 이 경우다.onScroll마다 JSON.stringify + 디스크 쓰기가 돌면 프레임 드랍, 배터리 소모, 플래시 쓰기 마모로 돌아온다.그 외에 항상 하지 말아야 할 이유들:
stringify 하면 그 자체가 부담이다. 바뀐 슬라이스만 골라 저장한다.background flush로 막는다.정리하면, "변경 시마다 영속화"는 사용자가 잃으면 아픈 데이터에 한정된 도구다. 모든 상태에 기계적으로 적용하는 게 아니라, 잃었을 때의 손해가 쓰기 비용보다 클 때만 켜는 스위치로 본다.
앞서 다룬 outbox(전송 대기 큐)가 메모리에만 있으면, 전송 중 프로세스가 죽는 순간 사용자의 동작이 통째로 사라진다. 큐는 디스크에 영속화해서, 다음 콜드 스타트에 복구·재전송되게 한다. "종료는 예고 없이 온다"는 전제가 여기서 직접적으로 작동한다.
요청을 보낸 직후 응답을 받기 전에 종료되면, 서버는 처리했는데 클라이언트는 모른다. 다음 실행에서 큐가 재전송하면 중복이 된다. 그래서 멱등성 키가 필요하다(오프라인 문서 참고). 종료 라이프사이클과 재시도 큐는 한 세트로 설계한다.
사용자가 닫지 않았는데 OS가 백그라운드에서 죽인 뒤 다시 열면, 사용자는 "왜 처음 화면으로 돌아왔지?"라고 느낀다. 이를 매끄럽게 만들려면:
즉, 종료를 막을 수는 없으니, 종료가 일어나도 사용자가 눈치채지 못하게 복원으로 메운다.
"백그라운드로 가도 잠깐 일을 끝내고 싶다"면 OS의 제약을 받는다.
background에서 긴 작업을 돌리면 프로세스가 죽으며 잘린다.처리되지 않은 JS 예외, 네이티브 크래시, OOM은 전부 같은 종료 경로다. 마찬가지로 정리 콜백이 안 돌 수 있으므로, 데이터 안전성은 증분 영속화 + 멱등 재시도로 확보하지 종료 콜백으로 확보하지 않는다.
background 전환을 "마지막 저장 기회"로 본다.applicationWillTerminate/onDestroy에 중요한 저장을 맡기지 않는다.핵심 원칙은 하나다. 종료는 예고 없는 전원 차단이다. 메모리에만 있던 것은 사라진다고 가정하고, 중요한 것은 미리·증분으로 디스크에 남기며, 다음 실행에서 복원한다.