상용 앱을 운영하다 보면, 앱 심사를 다시 거치지 않고 빠르게 반영해야 하는 수정이 생긴다.
이럴 때 OTA 업데이트는 굉장히 유용하지만, 실제 사용자 입장에서는 아래 같은 불편이 발생할 수 있었다.
예를 들어 운영 중 로그인 텍스트를 긴급하게 수정해 로그인 2로 반영했다고 해도, 사용자가 앱을 다시 실행하지 않으면 이전 번들이 계속 보일 수 있다.
심지어 앱을 새로 설치하거나 업데이트한 직후에도, 첫 실행에서는 이전 JS 번들이 올라오고 그 다음 실행에서야 최신 OTA가 적용되는 상황이 생길 수 있다.
이 부분이 UX 측면에서 특히 좋지 않았던 이유는, 사용자가 보기엔 “업데이트가 됐다면서 왜 화면은 안 바뀌지?” 혹은 “왜 한 번 더 껐다 켜야 하지?”처럼 느껴지기 때문이다.
이 현상을 이해하려면 앱이 실행될 때 어떤 코드가 실제로 올라가는지를 먼저 봐야 한다.
React Native + Expo 환경에서 화면을 그리는 핵심은 결국 JS 번들(JavaScript bundle) 이다.
앱이 켜질 때는 이 JS 번들이 로드되어 실행되고, 사용자는 그 번들 기준의 화면을 보게 된다.
여기서 중요한 점은 다음과 같다.
앱이 한 번 실행되면, 그 시점에 로드된 JS 번들이 메모리에 올라가서 계속 동작한다.
즉, 앱이 켜진 뒤에 서버에 더 새로운 OTA가 생긴다고 해서, 현재 실행 중인 JS 런타임이 자동으로 그 코드로 바뀌지는 않는다.
정리하면:
reload 또는 다음 앱 실행이 필요함OTA를 publish 했다고 해서 바로 사용자의 화면이 바뀌는 것은 아니다.
중간 단계가 필요하다.
즉, OTA는 크게 두 단계로 나뉜다.
여기서 사용자가 “배포했는데 왜 안 바뀌냐”라고 느끼는 이유가 생긴다.
운영자 입장에서는 배포가 끝났지만, 사용자 앱 입장에서는 아직 적용이 끝난 게 아니기 때문이다.
const update = await Updates.checkForUpdateAsync();
if (update.isAvailable) {
await Updates.fetchUpdateAsync();
await Updates.reloadAsync();
}
이 부분도 헷갈리기 쉬웠다.
앱을 새로 설치하거나 스토어 업데이트를 받더라도, 앱이 처음 켜질 때는 보통 그 시점에 포함된 내장 번들(embedded bundle) 이나 이미 저장된 번들로 실행될 수 있다.
그리고 앱이 켜진 뒤 OTA를 확인해서 최신 JS 번들을 다운로드할 수 있다.
그런데 이 시점에서 바로 reload를 하지 않으면,
같은 흐름이 생길 수 있다.
그래서 “업데이트를 했는데도 한 번 더 껐다 켜야 반영된다”는 상황이 실제로 발생할 수 있다.
이번 작업에서 해결하려던 건 단순히 “OTA를 쓰자”가 아니었다.
핵심은 사용자가 껐다 켜야만 반영되는 느낌을 줄이는 것이었다.
내가 정리한 문제는 크게 두 가지였다.
즉, 사용자가 굳이 수동으로 앱을 완전히 종료했다가 다시 실행하지 않아도, 가능한 자연스럽게 최신 번들을 보게 하는 흐름을 만드는 것이 목적이었다.
expo-updates 기본 흐름expo-updates는 대략 아래 순서로 동작한다.
checkForUpdateAsync()fetchUpdateAsync()reloadAsync()이 흐름에서 핵심은, 업데이트 확인과 실제 반영은 별개라는 점이다.
checkForUpdateAsync()
서버에 새로운 OTA가 있는지 확인
fetchUpdateAsync()
새 OTA 번들을 내려받음
reloadAsync()
현재 앱을 새 번들 기준으로 다시 실행
즉, 새 OTA를 다운로드했다고 해서 곧바로 화면이 바뀌는 것이 아니라,
결국 새 JS 번들을 쓰도록 다시 로드해야 한다.
이 작업을 하면서 dev 환경과 실제 OTA 환경은 동일하지 않다는 점도 분명히 봐야 했다.
expo-updates가 실제 OTA처럼 동작하지 않을 수 있음Updates.isEnabled가 false일 수 있음checkForUpdateAsync()를 실제 OTA 검증 기준으로 그대로 믿기 어려움즉, 진짜 OTA 검증은 preview / production에서 보는 게 맞다.
처음에는 여러 선택지가 있었다.
정리해보니 UX상 가장 자연스러운 방향은 아래였다.
특히 “오래 있었으니 앱을 죽인다”는 방식은 일반 서비스 앱 UX로는 어색했다.
보안 앱이라면 가능하지만, 일반 서비스 앱에서는 사용자가 “왜 앱이 종료됐지?”라고 느낄 가능성이 크다.
useLaunchOtaReload
역할:
if (!Updates.isEnabled) {
setIsLaunchGateVisible(false);
return;
}
const update = await Updates.checkForUpdateAsync();
if (!update.isAvailable) {
setIsLaunchGateVisible(false);
return;
}
await Updates.fetchUpdateAsync();
await reloadApp();
useBackgroundTimeoutReset
역할:
active가 되었을 때 체류 시간 계산if (nextAppState === 'background' && prevAppState !== 'background') {
backgroundEnteredAtRef.current = Date.now();
return;
}
if (
prevAppState !== 'background' ||
nextAppState !== 'active' ||
backgroundEnteredAtRef.current === null ||
isReloadingRef.current
) {
return;
}
const elapsedMs = Date.now() - backgroundEnteredAtRef.current;
backgroundEnteredAtRef.current = null;
if (elapsedMs <= timeoutMs) {
return;
}
const update = await Updates.checkForUpdateAsync();
if (update.isAvailable) {
await Updates.fetchUpdateAsync();
await reloadApp();
}
LaunchGate
역할:
LaunchGate가 필요했는가처음에는 useLaunchOtaReload만 있으면 된다고 생각했지만, 그렇게 하면 문제가 있었다.
흐름이 이렇게 된다.
이 경우 사용자는 “앱이 한 번 떴다가 다시 켜지는 느낌”을 받을 수 있다.
그래서 메인 UI를 먼저 보여주지 않고, LaunchGate를 먼저 보여주면서 OTA를 확인하도록 바꿨다.
이렇게 하면 사용자는
showSplash 이미지에 의존하면 안 된다고 생각한 이유처음엔 “기존 스플래시 이미지를 그대로 더 오래 보여주면 되지 않나?”라고 생각할 수 있었다.
하지만 장기적으로는 그 방식이 핵심은 아니라고 봤다.
이유:
즉, 개념적으로는 스플래시 이미지 연장이 아니라 런치 게이트가 핵심이다.
다만 UX상 자연스럽게 보이게 하기 위해 실제 화면은 기존 스플래시와 연결되는 형태로 맞췄다.
이 부분도 따로 생각해봤는데, 결론은 거의 부담이 없다였다.
이유:
AppState 이벤트는 상태가 바뀔 때만 발생Date.now() 한 번 기록하는 정도useRef에 저장하므로 리렌더도 없음즉, 성능적으로 비용이 큰 쪽은 시간 기록이 아니라 아래였다.
AlertcheckForUpdateAsync()fetchUpdateAsync()reloadAsync()그래서 “백그라운드에 갈 때마다 시간 기록” 자체는 부담이 크지 않다고 정리했다.
실제로 안드로이드에서 앱을 처음 설치하고 실행했는데도 백그라운드 복귀 감지 훅이 동작하는 현상이 있었다.
원인을 보니:
AppState 변화까지그래서 아래처럼 수정했다.
LaunchGate가 끝난 뒤에만 useBackgroundTimeoutReset이 활성화되도록 변경background -> active 전환일 때만 진짜 복귀로 인정즉, 지금은 실제로 백그라운드에 갔다 온 경우만 감지하도록 더 엄격해졌다.
if (nextAppState === 'background' && prevAppState !== 'background') {
backgroundEnteredAtRef.current = Date.now();
return;
}
if (
prevAppState !== 'background' ||
nextAppState !== 'active' ||
backgroundEnteredAtRef.current === null ||
isReloadingRef.current
) {
return;
}
const elapsedMs = Date.now() - backgroundEnteredAtRef.current;
backgroundEnteredAtRef.current = null;
if (elapsedMs <= timeoutMs) {
return;
}
const update = await Updates.checkForUpdateAsync();
if (update.isAvailable) {
await Updates.fetchUpdateAsync();
await reloadApp();
}
LaunchGate에서 스플래시 이미지를 중앙 정렬했더니, 안드로이드 3버튼 내비게이션 환경에서는 이미지가 위로 밀려 보였다.
이건 완전히 어쩔 수 없는 현상은 아니고, safe area 기준 정렬 때문에 생기는 시각적 문제였다.
그래서 아래처럼 보정했다.
즉, 기술적인 중앙이 아니라 사용자가 보기에 자연스러운 시각 중심으로 맞췄다.
처음에는 이런 걱정이 생겼다.
useLaunchOtaReload도 실행됨정리해보면 보통은 2번 리로드가 아니다.
흐름:
1. 백그라운드 훅이 OTA 확인
2. 새 OTA 있으면 다운로드 후 리로드
3. 앱이 최신 번들로 다시 시작
4. 시작 훅이 다시 OTA 확인
5. 이미 최신이라면 “업데이트 없음”
즉, 1번 리로드 + 1번 재체크에 가깝다.
예외적으로 리로드 직후 더 새 OTA가 서버에 올라오면 한 번 더 리로드될 가능성은 있지만, 일반적인 흐름은 아니다.
예를 들어 로그인을 로그인 2로 바꿔서 확인한다고 했을 때, 여기서 중요한 건 “무엇을 테스트하는가”였다.
로그인 2 코드가 들어감로그인 2가 보임로그인 2 코드로 OTA publish로그인 2가 보이는지 확인즉, 빌드 한 번으로 바로 로그인 2가 보인다면 그건 OTA가 아니라 새 빌드 결과일 가능성이 크다.
LaunchGate 표시useLaunchOtaReload 실행최종적으로 이 구조가 괜찮다고 본 이유는 아래다.
즉, “무조건 새로고침”이 아니라
필요할 때만, 사용자가 덜 어색하게 느끼는 방식으로 새 JS 번들을 적용하는 구조라고 이해하면 될 것 같다.
지금은 테스트를 위해 디버깅용 Alert가 들어가 있다.
테스트가 끝나면 제거할 예정이다.
남은 정리 포인트:
Alert 제거이번에 정리한 방향은 이렇다.
OTA는 “배포했다고 바로 반영되는 것”이 아니라, 새 JS 번들을 확인하고 내려받은 뒤 다시 로드해야 적용된다. 그래서 앱 시작 시에는 LaunchGate에서 먼저 OTA를 확인하고, 백그라운드에서 오래 있었다가 돌아왔을 때도 OTA가 있을 때만 리로드하도록 구성해, 사용자가 굳이 여러 번 앱을 껐다 켜지 않아도 최대한 자연스럽게 최신 화면을 보게 하는 것이 목표였다.