스플래시 5초를 0초로: 코드푸시 백그라운드 업데이트 설계
2026.04.03
1. 왜 지금 다시 설계했는가
기존 CodePush 라이브러리 버전에는 백그라운드 업데이트가 안 되는 버그가 있었습니다. 라이브러리를 올리면 해결되는 문제였지만, 이를 위해서는 RN 버전업이 선행되어야 해서 미뤄두고 있었습니다. 이번에 RN 0.79로 올리면서 그 제약이 풀렸고, 이 시점에 맞춰 CodePush 전략 자체를 전면 개편하기로 했습니다.
스플래시에서 평균 5초 대기
가장 먼저 정리하고 싶었던 건 데이터로 드러난 문제였습니다. Mixpanel로 측정해 보니 앱 실행 시 스플래시에서 CodePush 다운로드/설치를 기다리는 시간이 평균 5초였습니다. 모바일 앱의 콜드스타트 임계점이 흔히 2초 미만으로 잡히는 것을 감안하면, 사용자가 "느리다"고 인식하기에 충분한 시간입니다. 실제로 앱 스토어 리뷰에서도 업데이트가 잦다는 종류의 불만이 종종 보였습니다.

라이브러리 버그 때문에 iOS에서 백그라운드 업데이트를 안정적으로 쓸 수 없었고, 안전하게 IMMEDIATE 모드로 고정해 두었던 것입니다. 즉 코드는 항상 스플래시에서 다운로드/설치까지 기다린 뒤 사용자를 진입시키도록 설계되어 있었습니다.
IMMEDIATE 회피책의 구조
기존 코드는 forceCodePush Remote Config 값과 패치 버전이 "0" 인 경우 두 조건에서 IMMEDIATE로 빠지는 구조였습니다. 두 조건이 겹쳐 사실상 거의 모든 사용자가 항상 IMMEDIATE로 실행되고 있었지만, 이는 라이브러리 제약 하에서 안정성을 우선한 합리적인 회피 설계였습니다. 제약이 풀린 시점에는 더 이상 유지할 이유가 없는 코드이기도 했습니다.
라이브러리를 올리고 회피 분기를 걷어내면 5초 대기는 사라집니다. 하지만 그것만으로는 충분하지 않다고 판단해, CodePush 아키텍처 자체를 다시 설계하기로 했습니다.
목표는 다음과 같이 정리했습니다.
- 사용자가 대기 없이 즉시 앱에 진입하도록 한다
- 백그라운드에서 조용히 다운로드한 뒤, 자연스러운 시점에 새 번들을 적용한다 (사용 중 갑작스러운 리로드 방지 포함)
- 단, 긴급 핫픽스가 필요할 때는 즉시 회수할 수 있는 도구를 남겨둔다
이 세 가지 목표를 어떻게 세 가지 의사결정으로 풀어냈는지, 다음 섹션부터 차례대로 살펴보겠습니다.
2. 세 가지 의사결정
2-1. 기본 모드: ON_NEXT_SUSPEND
CodePush에서 새 번들을 언제 적용할지 결정하는 옵션이 installMode 입니다. 라이브러리가 제공하는 모드는 네 가지가 있습니다.
| InstallMode | 동작 시점 | 사용자 인지 | 적용 속도 |
|---|---|---|---|
| IMMEDIATE | 즉시 설치 + 재시작 | 높음 (블로킹) | 즉시 |
| ON_NEXT_RESTART | 다음 콜드스타트 | 없음 | 매우 느림 |
| ON_NEXT_RESUME | 포그라운드 복귀 시 | 있음 (리로드) | 보통 |
| ON_NEXT_SUSPEND | 백그라운드 n초 후 설치 | 없음 | 빠름 |
저희는 이 중 ON_NEXT_SUSPEND 를 기본 모드로 선택했습니다. 다른 모드를 선택하지 않은 이유부터 정리해 보겠습니다.
IMMEDIATE는 1번 섹션에서 다룬 5초 대기 문제를 그대로 남기기 때문에 일상 운영 default로는 적절하지 않습니다. 다만 긴급 패치 도구로는 여전히 필요하기에, 이후 섹션에서 다른 방식으로 활용하게 됩니다.
ON_NEXT_RESTART는 적용이 너무 느립니다. 사용자 대다수가 앱을 명시적으로 kill 하지 않고 백그라운드에 둔 채 사용한다고 보면, 새 번들이 며칠씩 적용되지 않는 상황도 충분히 발생할 수 있습니다. 핫픽스를 배포해도 사용자에게 도달하지 않으면 의미가 없다고 판단했습니다.
ON_NEXT_RESUME은 사용자가 입력 중인 데이터를 잃을 위험이 있습니다. 나만의닥터에서 진료 정보를 입력하다가 카카오나 메모 앱으로 잠깐 정보를 확인하고 돌아오는 동작은 흔한 사용 패턴입니다. 이때 포그라운드 복귀 시점에 새 번들이 적용되면서 앱이 리로드되면, 사용자가 입력하던 정보가 모두 사라집니다. 서비스에서 진료 정보 입력 중 데이터가 날아가는 일은 일반적인 UX 문제보다 영향이 크다고 보았습니다.
ON_NEXT_SUSPEND는 위 세 가지 문제를 모두 비껴갑니다. 백그라운드로 전환된 시점에 설치가 트리거되기 때문에 사용자는 새 번들이 적용되는 순간을 인지하지 못하고, 다음 진입 때 자연스럽게 새 번들로 시작합니다. 적용 속도도 ON_NEXT_RESTART보다 훨씬 빠릅니다.
minimumBackgroundDuration: 10분
ON_NEXT_SUSPEND 에는 minimumBackgroundDuration 이라는 옵션이 함께 따라옵니다. 백그라운드에 N초 이상 머문 뒤에만 설치를 트리거하는 옵션으로, 짧게 백그라운드를 다녀오는 경우에는 설치가 일어나지 않도록 막아줍니다.
저희는 이 값을 10분(600초)으로 설정했습니다. 10분 이상 백그라운드에 있었다면 사용자가 앱을 사실상 이탈한 상태로 볼 수 있고, 다시 돌아왔을 때 앱이 리로드되어도 컨텍스트가 이미 소멸된 상태이기 때문에 영향이 최소화된다고 판단했습니다.
다만 이 10분이 적정값인지는 데이터가 쌓여봐야 알 수 있다고 생각합니다. 실제 사용자 행동 패턴을 로깅으로 수집하면서 점진적으로 조정해 갈 계획입니다.
2-2. 버전별 installMode 서버 컨피그
기본 모드를 정했지만, 모든 상황에서 ON_NEXT_SUSPEND 를 강제하면 곤란한 경우가 있습니다. 예를 들어 새로 배포할 번들에 라이브러리 추가 등으로 번들이 크거나, 현재 코드푸시 버전에 버그가 발견되어 rollback을 진행하지 않고 새로 코드푸시 번들을 올려서 해결해야 한다는 의사결정을 한다면, ON_NEXT_SUSPEND 로는 적용 속도가 충분하지 않습니다. 이런 경우에는 IMMEDIATE로 빠르게 적용하는 것이 맞습니다.
그래서 installMode 를 클라이언트에 박아두지 않고, 서버 컨피그에서 버전별로 관리하는 구조로 설계했습니다.
기존 구조: forceCodePush boolean
기존 코드는 forceCodePush 라는 boolean 값 하나로 IMMEDIATE 여부를 결정하고 있었습니다. 이 값이 true 이면 모든 버전에서 IMMEDIATE로 동작하는 방식입니다. 단순하다는 장점은 있었지만, 특정 버전만 IMMEDIATE로 적용하고 싶다거나 일부 버전은 다른 모드로 적용하고 싶은 상황에서는 대응할 수 없습니다.
새 구조: 버전별 installMode
버전별로 installMode 를 지정할 수 있도록 구조를 바꿨습니다.
{
"myDoctorVersioning": [
{
"version": "6.43.1",
"installMode": "ON_NEXT_SUSPEND"
},
{
"version": "6.43.0",
"installMode": "ON_NEXT_SUSPEND"
},
{
"version": "6.42.8",
"installMode": "IMMEDIATE"
}
]
}이 구조의 장점은 운영 상황에 맞게 버전별로 다른 정책을 적용할 수 있다는 점입니다. 평소에는 모든 버전을 ON_NEXT_SUSPEND 로 두고, 앞서 말한 것처럼 큰 번들을 배포해야 하거나 특정 버전에서 코드푸시로 빠르게 수정해야 하는 상황이 발생하면 그 버전만 IMMEDIATE로 바꿔 적용할 수 있습니다.
관리 포인트가 늘어나는 것은 사실이지만, 선택지가 많을수록 운영에 유리하다고 판단했습니다. 또한 만약 컨피그가 제대로 관리되지 않더라도 fallback 동작이 있기 때문에 치명적인 문제로 이어지지 않습니다.
2-3. 2초 timeout과 IMMEDIATE fallback
서버 컨피그를 도입했으니, 이제 클라이언트가 서버에서 컨피그를 받아오는 과정을 설계해야 합니다. 여기서 두 가지 결정이 필요했습니다. 컨피그를 받아오는 동안 얼마나 기다릴 것인가, 그리고 컨피그를 받아오지 못했을 때 어떤 모드로 동작할 것인가입니다.
installMode 결정 우선순위
먼저 결정 우선순위를 정리해 보겠습니다.
| 우선순위 | 조건 | installMode |
|---|---|---|
| 1 | 컨피그 fetch 성공, 현재 버전이 목록에 있음 | 서버 값 사용 |
| 2 | 컨피그 fetch 실패 또는 timeout | IMMEDIATE (fallback) |
정상 케이스에서는 서버 컨피그의 값을 그대로 사용합니다. 하지만 네트워크 문제나 서버 장애로 컨피그를 받아오지 못하는 상황도 고려해야 합니다. 이 경우 클라이언트가 fallback으로 어떤 모드를 사용할지 결정해 두어야 합니다.
2초 timeout
저희는 timeout을 2초로 잡았습니다. 모바일 앱의 콜드스타트 임계점이 2초 미만이라는 점을 감안해, 사용자가 "느리다"고 인식하는 한계 안에서 컨피그를 받아오도록 한 것입니다. 2초 안에 응답이 오지 않으면 fallback으로 빠집니다.
이 시간이 너무 짧으면 정상 응답이 올 수 있는 상황에도 fallback으로 빠질 가능성이 높아지고, 너무 길면 사용자 진입이 그만큼 늦어집니다. 2초는 사용자 진입 속도와 정상 컨피그 적용률 사이의 균형점이라고 판단했습니다.
fallback을 IMMEDIATE로 둔 이유
fallback 후보를 생각하면 두 가지가 있었습니다. ON_NEXT_SUSPEND 로 두는 안과 IMMEDIATE로 두는 안입니다. mmkv에 마지막 컨피그를 캐싱해서 활용하는 방법도 고려해 봤습니다.
저희는 IMMEDIATE를 선택했습니다. 이유는 스플래시 대기보다 버그 노출이 UX에 더 치명적이라고 보았기 때문입니다.
서버 컨피그가 의도한 것 중 하나가 "특정 버전에서 빠르게 회수해야 할 때 IMMEDIATE로 바꿔 적용하는 것"이었습니다. 그런데 fallback이 ON_NEXT_SUSPEND 라면, 컨피그를 받아오지 못한 사용자는 회수 의도와 정반대로 백그라운드 적용 경로를 타게 됩니다. 회수해야 할 버그가 사용자에게 그대로 노출되는 것입니다.
mmkv 캐싱을 사용하지 않은 것도 같은 맥락입니다. 캐시된 값이 회수 의도와 어긋나는 상황을 막을 수 없기 때문입니다. 마지막으로 받은 컨피그가 ON_NEXT_SUSPEND 였다면, 그 다음 배포에서 IMMEDIATE로 회수하려고 해도 캐시 때문에 적용이 늦어질 수 있습니다.
IMMEDIATE를 fallback으로 두면 사용자는 잠깐 스플래시를 더 기다리게 되지만, 적어도 가장 최신 버전을 받아 실행하게 됩니다. 스플래시 대기는 일시적이고 회복 가능한 비용이지만, 버그가 그대로 사용자에게 노출되는 것은 그렇지 않다고 판단했습니다.
3. 코드푸시의 전체 플로우
코드푸시의 동작 과정은 크게 다섯 단계로 나눌 수 있습니다.
| 단계 | 동작 | 위치 |
|---|---|---|
| 1 | Release | 개발자 → 코드푸시 서버 |
| 2 | Check | 클라이언트가 서버에 업데이트 확인 |
| 3 | Download | 새 번들을 디바이스에 다운로드 |
| 4 | Install | 새 번들을 앱에 적용 |
| 5 | Verify | 새 번들이 정상 동작하는지 확인 |
1. Release: 새 번들을 서버에 올린다
개발자가 코드푸시 CLI 명령으로 JS 번들을 만들어 서버에 업로드합니다. 이때 metro 번들러가 JS 코드와 에셋을 묶어 하나의 번들 파일을 생성하고, 이 파일이 CDN에 업로드되며, 서버의 release history에 새 항목이 추가됩니다. release history에는 번들의 hash, 적용 대상 앱 버전, 적용 모드 등의 메타데이터가 기록됩니다.
2. Check: 클라이언트가 새 번들이 있는지 묻는다
클라이언트에서 CodePush.sync() 가 호출되면 서버에 현재 디바이스의 번들 hash와 앱 버전을 전송하고, 서버는 더 새로운 번들이 있는지 응답합니다. 이때 디바이스 hash와 다른 새로운 번들이 서버에 있다면 다음 단계인 Download로 넘어갑니다.
3. Download: 새 번들을 디바이스에 받는다
새 번들이 있다면 클라이언트는 그 번들을 다운로드합니다. 코드푸시의 다운로드는 백그라운드에서 일어나는 것이 아니라, sync() 가 호출된 시점의 포그라운드에서 일어납니다.
이는 라이브러리 내부에서 NSURLConnection 을 사용하고, 백그라운드 세션 설정을 사용하지 않기 때문입니다.
// CodePushDownloadHandler.m
NSURLRequest *request = [NSURLRequest requestWithURL:[NSURL URLWithString:url]
cachePolicy:NSURLRequestUseProtocolCachePolicy
timeoutInterval:60.0];
NSURLConnection *connection = [[NSURLConnection alloc] initWithRequest:request ...];NSURLSession.backgroundSessionConfiguration 을 사용하면 앱이 백그라운드에 있어도 시스템이 다운로드를 이어가지만, 코드푸시는 일반 NSURLConnection 을 사용합니다. 즉 다운로드는 앱이 포그라운드에 있는 동안에만 진행됩니다.
이로 인해 다음과 같은 상황이 발생합니다.
다운로드 중 사용자가 앱을 백그라운드로 보내면, iOS는 약 30초의 유예 시간을 준 뒤 연결을 중단시킵니다. 다운로드 중 사용자가 앱을 kill하면, 다음 실행 시 임시 다운로드 파일은 삭제되고 처음부터 다시 받게 됩니다. 이는 다운로드 시작 시점에 이전 데이터를 정리하고, 새 파일을 append:NO 로 열기 때문입니다.
// CodePushPackage.m - 다운로드 시작 시 이전 데이터 정리
if ([[NSFileManager defaultManager] fileExistsAtPath:newUpdateFolderPath]) {
[[NSFileManager defaultManager] removeItemAtPath:newUpdateFolderPath error:&error];
}// CodePushDownloadHandler.m - append:NO로 파일 스트림 열기
self.outputFileStream = [NSOutputStream outputStreamToFileAtPath:downloadFilePath
append:NO];append:NO 로 파일을 열면 기존 파일에 이어쓰지 않고 처음부터 덮어쓰기 때문에, 이어받기는 지원되지 않습니다. 다만 코드푸시는 변경된 부분만 받는 diff update를 지원하기 때문에, 실제 다운로드 사이즈는 전체 번들보다 훨씬 작은 경우가 많습니다.
다운로드가 완료되면 번들은 디바이스의 Application Support/CodePush/<packageHash>/ 경로에 저장됩니다. 이전 번들도 함께 보관되는데, 이는 롤백을 위한 것입니다.
4. Install: 새 번들을 앱에 적용한다
다운로드와 적용은 분리되어 있습니다. 다운로드는 새 번들을 디바이스에 저장하는 것이고, 적용은 그 번들을 실제로 실행 가능한 상태로 만드는 것입니다. 이 두 단계 사이에 시간 간격이 존재할 수 있고, 그 간격을 결정하는 것이 앞에서 다룬 installMode 입니다.
적용 시점에 라이브러리 내부에서는 다음 두 가지 작업이 일어납니다.
// installUpdate (CodePush.m)
[CodePushPackage installPackage:updatePackage ...]; // codepush.json의 currentPackage 갱신
[self savePendingUpdate:hash isLoading:NO]; // pending 상태 마킹첫 번째는 codepush.json 이라는 상태 파일에 새 번들의 hash를 currentPackage 로 기록하는 작업입니다. 코드푸시는 앱이 시작될 때 이 파일을 읽어 어느 폴더의 번들을 로드할지 결정하기 때문에, currentPackage 만 갱신해 두면 다음 콜드스타트 때 자연스럽게 새 번들이 로드됩니다. ON_NEXT_RESTART 가 별다른 동작 없이도 적용되는 이유입니다.
두 번째는 NSUserDefaults 에 pending 상태를 마킹하는 작업입니다. 이 마킹은 5단계 Verify에서 사용됩니다.
이후의 동작은 installMode 마다 다릅니다.
- IMMEDIATE: 즉시
loadBundle()을 호출해 앱을 재시작합니다. - ON_NEXT_RESTART: 추가 동작 없이 종료. 다음 콜드스타트 시 자연스럽게 새 번들이 로드됩니다.
- ON_NEXT_RESUME / ON_NEXT_SUSPEND: 적용을 위한 이벤트 리스너를 등록합니다.
ON_NEXT_SUSPEND 가 저희가 채택한 모드인데, 이 경우 UIApplicationWillResignActiveNotification 등의 백그라운드 진입 이벤트에 리스너가 걸립니다. 비슷하게 ON_NEXT_RESUME 은 포그라운드 복귀 이벤트에 리스너를 등록합니다.
if (_installMode == CodePushInstallModeOnNextResume) {
[[NSNotificationCenter defaultCenter] addObserver:self
selector:@selector(applicationWillEnterForeground)
name:UIApplicationWillEnterForegroundNotification
object:RCTSharedApplication()];
}이벤트가 발생하면 minimumBackgroundDuration 조건을 검사한 뒤 loadBundle() 을 호출합니다.
- (void)applicationWillEnterForeground
{
int durationInBackground = [self getDurationInBackground];
if (durationInBackground >= _minimumBackgroundDuration) {
[self restartAppInternal:NO];
}
}
- (void)loadBundle
{
[super.bridge setValue:[CodePush bundleURL] forKey:@"bundleURL"];
RCTTriggerReloadCommandListeners(@"react-native-code-push: Restart");
}loadBundle() 은 React Native bridge의 bundleURL 을 새 번들 경로로 교체하고 리로드를 트리거합니다. 사용자 입장에서는 앱이 순간적으로 재시작되는 것처럼 보입니다. 이 코드가 2-1 섹션에서 ON_NEXT_SUSPEND를 선택했을 때 실제로 동작하는 라이브러리 내부의 트리거입니다.
5. Verify: 새 번들이 정상 동작하는지 확인한다
새 번들이 적용된 뒤에도 한 단계가 더 남아 있습니다. 그 번들이 실제로 정상 동작하는지를 확인하는 단계입니다.
코드푸시는 이를 별도의 검증 함수로 처리하지 않고, 앱이 정상적으로 부팅되어 JS 코드가 notifyAppReady() 를 호출했는가 라는 간접적인 기준으로 판단합니다. 이를 위해 isLoading 이라는 플래그를 사용하는 2단계 커밋 패턴을 사용합니다.
먼저 4단계 Install에서 등장했던 코드를 다시 보겠습니다.
// installUpdate (CodePush.m)
[CodePushPackage installPackage:updatePackage ...];
[self savePendingUpdate:hash isLoading:NO]; // 설치됨, 아직 로딩 안 함새 번들이 설치되는 순간에는 "설치는 됐지만 아직 검증되지 않았다"는 의미로 isLoading: NO 로 마킹됩니다.
그리고 앱이 새 번들로 시작될 때, initializeUpdateAfterRestart 가 이 플래그의 상태를 검사합니다.
// initializeUpdateAfterRestart (CodePush.m)
if (pendingUpdate) {
BOOL updateIsLoading = [pendingUpdate[PendingUpdateIsLoadingKey] boolValue];
if (updateIsLoading) {
// 이미 한 번 로딩을 시도했는데 notifyAppReady가 안 불렸다 = 크래시 = 깨진 번들
needToReportRollback = YES;
[self rollbackPackage];
} else {
// 첫 시도: 지금부터 로딩 시작한다고 마킹
[self savePendingUpdate:hash isLoading:YES];
}
}isLoading: NO 였다면 "첫 시도"라는 의미이므로 isLoading: YES 로 전환하고 정상 부팅 흐름을 이어갑니다. 만약 부팅 도중 크래시가 나면 앱이 종료되고, 다음 실행 시 이 코드가 다시 실행되며 isLoading: YES 가 남아 있는 것을 발견합니다. 이것이 "이전 시도에서 부팅에 실패했다"는 신호이므로, 깨진 번들로 판단해 즉시 롤백을 트리거합니다.
부팅이 정상적으로 끝나면 JS에서 notifyAppReady() 가 호출되고, 이 함수는 pending 상태 자체를 제거합니다.
RCT_EXPORT_METHOD(notifyApplicationReady:...)
{
[CodePush removePendingUpdate]; // 마킹 자체를 제거 = "이 번들은 정상"
resolve(nil);
}코드푸시는 번들의 내용을 직접 검사하거나 해시를 다시 검증하는 방식이 아닙니다. 앱이 살아남았느냐 자체를 검증의 기준으로 삼는 방식이고, JS 코드까지 정상 실행되어야 검증이 통과되므로 깨진 번들이라면 자연스럽게 크래시 → 롤백 경로를 타게 됩니다.
전체 플로우를 한 줄로 요약하면, 개발자가 서버에 새 번들을 올리면 클라이언트가 sync() 시점에 서버에 묻고, 새 번들이 있으면 포그라운드에서 다운로드한 뒤 installMode 에 따라 적용 시점을 결정하고, 마지막으로 notifyAppReady() 호출 여부로 정상 동작을 판단하는 흐름입니다.
4. 롤백 시나리오
코드푸시는 새 번들에 문제가 생겼을 때를 대비해 자동 롤백 메커니즘을 가지고 있습니다. 3번 섹션 마지막에서 다룬 notifyAppReady() 와 isLoading 플래그가 그 핵심입니다. 이번 섹션에서는 이 자동 롤백이 실제로 어떤 경로로 동작하는지, 그리고 어디까지 커버하지 못하는지를 정리해 보겠습니다.
자동 롤백의 동작
자동 롤백이 트리거되는 시나리오를 처음부터 따라가 보겠습니다.
새 번들이 설치되면 isLoading: NO 로 마킹된 pending 상태가 됩니다. 앱이 새 번들로 시작되면 initializeUpdateAfterRestart 가 이 플래그를 isLoading: YES 로 전환하며 부팅을 시도합니다. 부팅 도중 크래시가 나면 앱은 종료되지만 isLoading: YES 마킹은 그대로 남아 있습니다. 사용자가 다시 앱을 실행하면 initializeUpdateAfterRestart 가 다시 실행되고, isLoading: YES 가 남아있는 것을 발견합니다.
if (updateIsLoading) {
needToReportRollback = YES;
[self rollbackPackage];
}이 시점에 rollbackPackage() 가 호출되어 이전 번들로 복원됩니다. 사용자 입장에서는 앱이 한 번 비정상 종료된 뒤 다시 열면 정상 동작하는 형태가 됩니다.
무한 루프 방지: FailedUpdatesKey
여기서 자연스럽게 드는 의문이 있습니다. 롤백은 됐지만 서버에는 여전히 그 깨진 번들이 최신으로 올라가 있습니다. 그렇다면 다음 sync() 호출 시 같은 번들을 또 다운로드받게 되는 것이 아닐까요? 다운로드 → 설치 → 크래시 → 롤백 → 다시 다운로드 무한 루프가 발생할 수 있을까요?
라이브러리는 이를 막기 위해 실패한 번들의 hash를 별도로 기록합니다. rollbackPackage() 직전에 호출되는 saveFailedUpdate 가 그 역할을 합니다.
// CodePush.m - rollbackPackage
NSDictionary *failedPackage = [CodePushPackage getCurrentPackage:&error];
if (failedPackage) {
[self saveFailedUpdate:failedPackage]; // FailedUpdatesKey에 추가
}
[CodePushPackage rollbackPackage];
[CodePush removePendingUpdate];saveFailedUpdate 는 깨진 번들의 hash를 NSUserDefaults 의 CODE_PUSH_FAILED_UPDATES 배열에 추가합니다. 그리고 sync() 가 새 번들을 발견하면, 다운로드 전에 이 배열을 조회해 같은 hash가 있는지 확인합니다.
+ (BOOL)isFailedHash:(NSString*)packageHash
{
NSMutableArray *failedUpdates = [preferences objectForKey:FailedUpdatesKey];
for (NSDictionary *failedPackage in failedUpdates) {
NSString *failedPackageHash = [failedPackage objectForKey:PackageHashKey];
if ([packageHash isEqualToString:failedPackageHash]) {
return YES;
}
}
return NO;
}isFailedHash 가 YES 를 반환하면 클라이언트는 해당 번들을 스킵합니다. 같은 hash의 번들은 다시 시도하지 않고, 서버에 새로운 hash로 수정된 번들이 올라오면 그때 다시 다운로드를 시도합니다. 결과적으로 무한 루프는 발생하지 않습니다.
자동 롤백이 커버하지 못하는 영역
여기서부터가 중요한 부분입니다. 자동 롤백은 모든 종류의 크래시를 잡아주지 않습니다.
자동 롤백의 트리거는 notifyAppReady() 가 호출되지 않는 것입니다. 다시 말해, notifyAppReady() 가 호출된 이후에 발생하는 크래시는 자동 롤백 대상이 아닙니다. 코드푸시는 그 시점에 이미 "이 번들은 정상"이라고 판단한 상태이기 때문입니다.
| 크래시 시점 | 자동 롤백 |
|---|---|
앱 시작 ~ notifyAppReady() 호출 전 |
O |
notifyAppReady() 호출 후 (특정 화면, 특정 기능) |
X |
즉 자동 롤백이 실질적으로 커버하는 영역은 새 번들이 아예 부팅조차 안 되는 치명적인 크래시뿐입니다. 특정 화면에서 발생하는 크래시나, 특정 사용자 플로우에서만 재현되는 버그는 Sentry 모니터링과 수동 대응이 필요합니다.
저희 코드의 경우 자동 롤백 커버리지는 더 좁습니다. codePush() HOC를 사용하지 않고 useCodePush 훅에서 sync() 를 호출하는 구조이기 때문입니다. 앱 시작 후 여러 Provider가 초기화되고, initialized: true 가 된 시점에 훅이 실행되며, 그 안에서 sync() 를 통해 notifyAppReady() 가 호출됩니다.
문제는 Provider 초기화 단계에서 크래시가 나는 경우입니다. 이 경우 sync() 까지 도달하지 못하므로 notifyAppReady() 도 호출되지 않습니다. 이론상 자동 롤백이 동작해야 할 시나리오인데, 만약 같은 초기화 경로에서 또다시 크래시가 난다면 initializeUpdateAfterRestart 가 실행되더라도 그 이후 코드까지 도달하지 못해 롤백이 트리거되지 않을 수 있습니다.
이 문제를 막기 위해서는 앱 진입 최초 시점에 notifyAppReady() 를 명시적으로 호출하거나, codePush() HOC를 적용해 마운트 시점에 자동 호출되도록 하는 방법이 있습니다. 이 부분은 후속 작업으로 정리해 두었습니다.
실제 롤백 수단
자동 롤백이 커버하지 못하는 영역을 정리했으니, 실제로 문제가 발생했을 때 어떤 수단으로 대응할 수 있는지도 함께 정리해 두는 것이 좋겠습니다.
저희가 사용하는 롤백 수단은 세 가지입니다.
| 종류 | 트리거 | 영향 범위 |
|---|---|---|
| Client-side 자동 롤백 | notifyAppReady 호출 실패 |
해당 디바이스 |
code-push rollback |
직전 release를 새 release로 재배포 | 모든 사용자 |
| 새 release 배포 (forward fix) | 수정된 번들을 새 release로 push | 모든 사용자 |
code-push rollback 명령은 이름과 달리 실제로는 직전 release를 새 release로 다시 push하는 동작입니다. 즉 내부 메커니즘은 forward fix와 같고, 새 hash의 번들이 사용자에게 배포됩니다.
여기서 짚어둘 점이 있습니다. 이미 정상 부팅하는 깨진 번들은 자동 롤백으로 회수되지 않습니다. 자동 롤백은 부팅 단계 크래시만 잡아주기 때문에, notifyAppReady() 가 한 번이라도 호출된 뒤 발견된 버그는 결국 새 release를 배포해 덮어쓰는 방법으로 해결해야 합니다.
이 사실이 2-2 섹션에서 IMMEDIATE를 서버 컨피그로 열어둔 결정의 배경이기도 합니다. 평소에는 ON_NEXT_SUSPEND 로 두지만, 깨진 번들을 새 release로 덮어쓸 때는 IMMEDIATE로 빠르게 적용시켜야 회수 효과가 보장되기 때문입니다.
5. 마치며
스플래시에서 평균 5초 동안 업데이트를 기다리던 구조에서, 사용자가 대기 없이 앱에 진입하고 새 번들은 자연스럽게 백그라운드에서 적용되는 구조로 옮겨왔습니다. 앱 스토어 리뷰에서 반복적으로 보였던 "업데이트가 너무 잦다"는 종류의 불만도 이 구조 안에서 자연스럽게 줄어들 것으로 기대하고 있습니다. 라이브러리 버전 제약으로 오랫동안 미뤄두었던 숙원 사업과도 같은 작업이었고, 그만큼 UX 측면에서 의미 있는 변화라고 생각합니다.
남은 팔로업
minimumBackgroundDuration 10분이 적정한지는 데이터로 검증이 필요합니다. 사용자 행동 패턴을 로깅으로 수집하면서 점진적으로 조정해 갈 계획입니다. notifyAppReady() 호출 시점을 앞당겨 자동 롤백 커버리지를 넓히는 것도 후속 작업으로 남아 있습니다. 그리고 RN 0.82부터는 New Architecture가 강제되면서 현재 사용 중인 react-native-code-push 라이브러리를 계속 쓸 수 없습니다. 이 부분에 대한 마이그레이션 전략은 다음 글에서 이어서 다루겠습니다.