React Native 앱 종료 라이프사이클

eeennsu·2026년 6월 23일

React Native

목록 보기
55/84

개요

앱이 종료될 때는 한마디로 요약하면, 앱 프로세스 종료 → JS 런타임 종료 → 힙 삭제가 일어난다. 이 과정이 실제로 어떻게 일어나는지, 그리고 이걸 실무에서 어떻게 활용하는지 알아보자.



1.핵심 전제 : "종료는 예고 없이 온다"

먼저 가장 중요한 사실부터 못 박는다. 모바일 앱은 OS가 언제든, 사전 통보 없이 죽일 수 있다. 데스크톱처럼 "종료 직전에 정리할 기회"가 보장되지 않는다.

이 전제를 받아들이면 결론은 하나로 모인다 — "종료될 때 저장한다"가 아니라 "백그라운드로 갈 때, 또는 변경될 때마다 미리 저장한다" 가 정석이다. 종료 시점의 정리 콜백에 중요한 데이터를 맡기면 잃는다.



2. "종료"는 한 종류가 아니다

먼저 백그라운드 전환(backgrounding)종료(termination) 를 구분해야 한다. 많은 버그가 이 둘을 헷갈려서 생긴다.

  • 백그라운드 전환 : 홈 버튼을 누르거나 다른 앱으로 전환한 상태다. 프로세스는 살아 있고, JS 런타임과 힙도 그대로 있다. 다시 돌아오면 상태가 유지된 채(웜 스타트) 이어진다.
  • 종료 : 프로세스 자체가 죽는다. JS 런타임과 힙이 사라진다. 다시 열면 처음부터(콜드 스타트) 시작한다.

종료가 일어나는 경로는 여러 가지다.

  1. 사용자가 앱 스위처에서 스와이프로 닫음 — 명시적 종료다.
  2. OS가 백그라운드 앱을 메모리 회수 위해 죽임 — 가장 흔하고, 조용히 일어난다. 사용자는 자기가 닫지 않았는데 다시 열면 콜드 스타트인 상황을 겪는다.
  3. 크래시 — JS 에러(처리 안 된 예외), 네이티브 크래시, OOM 강제 종료다.
  4. 설정에서 강제 중지(Force Stop) — Android에서 사용자가 직접 죽임이다.
  5. 시스템 재부팅 / OS 업데이트 / 앱 업데이트.

2번이 핵심이다. 백그라운드에 있던 앱은 언제든 소리 없이 종료될 수 있다는 것이 모든 설계의 기준이 된다.



2. 종료 시 실제로 일어나는 일

프로세스 종료 → JS 런타임 종료 → 힙 삭제 의 과정을 정확히 풀면 이렇다.

1) 앱 프로세스 종료

OS가 앱 프로세스에 종료를 지시한다(또는 즉시 kill 한다). 이 프로세스 안에는 네이티브 코드, RN 런타임, Hermes 엔진, 모든 네이티브 뷰와 모듈이 들어 있다. 프로세스가 죽으면 그 안의 모든 것이 함께 사라진다.

2) JS 런타임(Hermes) 종료

Hermes VM은 이 프로세스 안에서 돌고 있었다. 프로세스가 죽으면 Hermes 런타임도 파괴된다. 실행 중이던 JS 코드, 이벤트 루프, 진행 중이던 타이머·Promise·네트워크 콜백이 전부 중단된다. 완료를 보장받지 못하며, 의도하지 않은 결과로 종료되어버릴 수 있다. 아래 코드는 예시이다.

const onSave = async () => {
  await fetch("/api/save", {
    method: "POST",
    body: JSON.stringify({ nickname: "John" })
  });

  Alert.alert("저장 완료");
};

사용자가 저장 버튼 누르고 API 요청 보내는 중에 앱을 강제 종료하면

  1. Hermes 런타임 종료
  2. JS 실행 중단
  3. await fetch() 이후 코드 실행 안 됨
  4. "저장 완료" Alert 안 뜸
  5. 네트워크 요청도 OS 상태에 따라 끝까지 못 갈 수 있음

3) 힙 삭제

JS 힙(Hermes가 관리하던 객체들)과 네이티브 힙(디코딩된 이미지, 네이티브 뷰 등)이 모두 해제된다.

여기서 정확히 알아야 할 점:

  • 종료 시점에 GC(가비지 컬렉션)는 돌지 않는다. OS는 객체 하나하나를 정리하는 게 아니라, 프로세스가 점유하던 메모리 페이지를 통째로 회수한다. 그래서 종료는 빠르다.
  • finalizer / 소멸자 / cleanup이 실행된다는 보장이 없다. "객체가 사라지기 전에 뭔가 정리하겠다"는 코드(JS의 어떤 정리 로직, 네이티브의 일부 소멸자)는 강제 kill 시 호출되지 않을 수 있다.
  • 따라서 "종료 직전에 디스크에 쓰겠다"는 코드는 실행 보장이 없다.

요약하면, 종료는 "정중한 정리"가 아니라 "전원 차단"에 가깝다. 메모리에만 있던 것은 전부 사라진다고 봐야 한다.



3. 프로세스 종료 ≠ JS 런타임 종료 (중요한 구분)

질문의 체인은 보통 "프로세스가 죽으면서 JS 런타임도 죽는" 흐름이지만, JS 런타임만 따로 종료·재생성되는 경우도 있다. 이걸 알아야 디버깅 때 헷갈리지 않는다.

  • 개발 중 Reload(Fast Refresh / Cmd+R) : 네이티브 프로세스는 그대로 살아 있고, JS 번들과 Hermes 런타임만 다시 초기화된다. 즉 JS 힙은 비워지지만 프로세스·네이티브 상태는 유지된다.
  • CodePush / OTA 업데이트 후 재시작 : 새 JS 번들을 적용하려고 JS 런타임을 재시작하는 경우다. 역시 프로세스 전체 종료와 다르다.
  • RN의 루트 뷰 언마운트 : 화면 트리가 정리되지만 런타임은 살아 있다.

정리하면 계층은 이렇다.

앱 프로세스
 └─ 네이티브 런타임 / 모듈 / 뷰
     └─ JS 런타임 (Hermes)
         └─ JS 힙 (앱 상태, 컴포넌트, 캐시)
  • 프로세스 종료 → 위 전부 사라짐 (질문의 체인)
  • JS 런타임만 종료 → JS 힙만 사라지고, 네이티브·프로세스는 유지 (dev reload, OTA)

dev에서 reload 했을 때 메모리에만 있던 상태가 날아가는 건 정상이다. 이걸 "버그"로 오해하지 않는다. 반대로, 진짜 콜드 스타트에서만 재현되는 문제(영속 저장 누락 등)는 reload로는 재현되지 않으니, 앱을 완전히 종료(스와이프)했다가 다시 켜서 테스트한다.



4. JS가 관찰할 수 있는 것 : AppState

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가 멈춰있기 때문이다.



5. "onTerminate"를 믿지 마라

네이티브에 종료 콜백이 있긴 하지만, 신뢰할 수 없다.

  • iOS applicationWillTerminate: 백그라운드 실행을 지원하는 현대 앱에서는, 시스템이 서스펜드된 앱을 죽일 때 호출되지 않는다. 즉 가장 흔한 종료(2번, OS의 조용한 회수)에서 안 불린다.
  • Android onDestroy: OS가 프로세스를 강제로 죽이면 호출이 보장되지 않는다. 메모리 회수나 강제 중지 시 건너뛸 수 있다.

따라서 이 콜백들에 "마지막 저장"을 맡기면 데이터를 잃는다. 신뢰 가능한 저장 시점은 background 전환과 "값이 바뀔 때마다"뿐이다.



6. 실무 응용

1) 저장은 "종료 시"가 아니라 "백그라운드 전환 시 + 변경 시"

  • 사용자 입력(작성 중인 글, 폼)은 변경될 때마다 또는 디바운스로 영속화한다. 종료를 기다리지 않는다.
  • 화면 상태·스크롤 위치·탭처럼 덜 중요한 건 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 + 디스크 쓰기가 돌면 프레임 드랍, 배터리 소모, 플래시 쓰기 마모로 돌아온다.

그 외에 항상 하지 말아야 할 이유들:

  • 재구성 가능한 상태는 영속화하지 않는다. 서버에서 다시 받거나 다른 값에서 파생되는 상태는 저장 대상이 아니다. 저장하면 stale 데이터 동기화 버그만 늘어난다.
  • 직렬화 비용을 무시하면 안 된다. 큰 객체를 변경마다 통째로 stringify 하면 그 자체가 부담이다. 바뀐 슬라이스만 골라 저장한다.
  • 디바운스는 안전장치가 아니라 트레이드오프다. delay 동안의 데이터는 "아직 디스크에 없는 상태"라서, 그 구간에 죽으면 잃는다. delay를 늘릴수록 쓰기는 줄지만 잃을 위험은 커진다. 정말 중요한 건 디바운스로 미루지 말고 즉시 쓰거나, 최소한 background flush로 막는다.

정리하면, "변경 시마다 영속화"는 사용자가 잃으면 아픈 데이터에 한정된 도구다. 모든 상태에 기계적으로 적용하는 게 아니라, 잃었을 때의 손해가 쓰기 비용보다 클 때만 켜는 스위치로 본다.

2) 오프라인 쓰기 큐는 반드시 영속화한다

앞서 다룬 outbox(전송 대기 큐)가 메모리에만 있으면, 전송 중 프로세스가 죽는 순간 사용자의 동작이 통째로 사라진다. 큐는 디스크에 영속화해서, 다음 콜드 스타트에 복구·재전송되게 한다. "종료는 예고 없이 온다"는 전제가 여기서 직접적으로 작동한다.

3) 멱등성과 "전송 중 종료"

요청을 보낸 직후 응답을 받기 전에 종료되면, 서버는 처리했는데 클라이언트는 모른다. 다음 실행에서 큐가 재전송하면 중복이 된다. 그래서 멱등성 키가 필요하다(오프라인 문서 참고). 종료 라이프사이클과 재시도 큐는 한 세트로 설계한다.

4) 상태 복원으로 "조용한 종료"를 감춘다

사용자가 닫지 않았는데 OS가 백그라운드에서 죽인 뒤 다시 열면, 사용자는 "왜 처음 화면으로 돌아왔지?"라고 느낀다. 이를 매끄럽게 만들려면:

  • 내비게이션 상태 복원: React Navigation의 상태를 영속화했다가 복원해, 보던 화면으로 돌려보낸다.
  • 작성 중 데이터 복원: 저장해 둔 드래프트를 채워 넣는다.
  • 캐시 우선 렌더: 영속 캐시로 콜드 스타트인데도 웜 스타트처럼 보이게 한다(콜드 스타트 문서 참고).

즉, 종료를 막을 수는 없으니, 종료가 일어나도 사용자가 눈치채지 못하게 복원으로 메운다.

6) 백그라운드 작업의 한계를 안다

"백그라운드로 가도 잠깐 일을 끝내고 싶다"면 OS의 제약을 받는다.

  • iOS : 백그라운드 태스크 어설션으로 짧은 유예 시간만 받는다. 길게 보장되지 않는다.
  • Android : 백그라운드 실행 제약이 강하다. 지속 작업은 적절한 백그라운드 메커니즘(Headless JS, WorkManager 연동 등)으로 넘긴다. 단순히 background에서 긴 작업을 돌리면 프로세스가 죽으며 잘린다.

7) 크래시도 "종료"다

처리되지 않은 JS 예외, 네이티브 크래시, OOM은 전부 같은 종료 경로다. 마찬가지로 정리 콜백이 안 돌 수 있으므로, 데이터 안전성은 증분 영속화 + 멱등 재시도로 확보하지 종료 콜백으로 확보하지 않는다.



7. 체크리스트

  1. 백그라운드 전환과 종료를 구분한다. background 전환을 "마지막 저장 기회"로 본다.
  2. 종료 시 GC·finalizer·정리 콜백은 실행 보장이 없다고 전제한다.
  3. applicationWillTerminate/onDestroy에 중요한 저장을 맡기지 않는다.
  4. 사용자 데이터는 변경 시마다(또는 디바운스) 영속화하되, 중요도 × 빈도로 판단한다. 모든 상태에 기계적으로 걸지 않는다.
  5. 오프라인 쓰기 큐를 디스크에 영속화하고, 멱등성 키로 재전송 중복을 막는다.
  6. 내비게이션·드래프트·캐시를 복원해 "조용한 종료"를 사용자에게 감춘다.
  7. 진짜 콜드 스타트 동작은 dev reload가 아니라 앱 완전 종료 후 재실행으로 검증한다.

핵심 원칙은 하나다. 종료는 예고 없는 전원 차단이다. 메모리에만 있던 것은 사라진다고 가정하고, 중요한 것은 미리·증분으로 디스크에 남기며, 다음 실행에서 복원한다.

profile
이력서 https://resume.eunsu.pro

0개의 댓글