OTA 업데이트 적용 흐름 정리

함민혁·2026년 4월 24일

0. 실제로 있었던 사용자 불편

상용 앱을 운영하다 보면, 앱 심사를 다시 거치지 않고 빠르게 반영해야 하는 수정이 생긴다.
이럴 때 OTA 업데이트는 굉장히 유용하지만, 실제 사용자 입장에서는 아래 같은 불편이 발생할 수 있었다.

  • 수정사항이 배포되었는데도 앱을 껐다 켜기 전까지는 반영되지 않음
  • 어떤 경우에는 앱을 한 번 실행한 뒤, 다시 껐다 켜야 그제서야 OTA가 적용됨
  • 사용자는 이미 최신이라고 생각하는데 화면은 여전히 예전 UI를 보여줌
  • 운영자는 “배포는 끝났는데 왜 안 바뀌냐”는 문의를 받게 됨

예를 들어 운영 중 로그인 텍스트를 긴급하게 수정해 로그인 2로 반영했다고 해도, 사용자가 앱을 다시 실행하지 않으면 이전 번들이 계속 보일 수 있다.
심지어 앱을 새로 설치하거나 업데이트한 직후에도, 첫 실행에서는 이전 JS 번들이 올라오고 그 다음 실행에서야 최신 OTA가 적용되는 상황이 생길 수 있다.

이 부분이 UX 측면에서 특히 좋지 않았던 이유는, 사용자가 보기엔 “업데이트가 됐다면서 왜 화면은 안 바뀌지?” 혹은 “왜 한 번 더 껐다 켜야 하지?”처럼 느껴지기 때문이다.


1. 왜 이런 일이 생기는가

이 현상을 이해하려면 앱이 실행될 때 어떤 코드가 실제로 올라가는지를 먼저 봐야 한다.

React Native + Expo 환경에서 화면을 그리는 핵심은 결국 JS 번들(JavaScript bundle) 이다.
앱이 켜질 때는 이 JS 번들이 로드되어 실행되고, 사용자는 그 번들 기준의 화면을 보게 된다.

여기서 중요한 점은 다음과 같다.

1.1 앱은 이미 메모리에 올라간 JS 번들로 동작한다

앱이 한 번 실행되면, 그 시점에 로드된 JS 번들이 메모리에 올라가서 계속 동작한다.
즉, 앱이 켜진 뒤에 서버에 더 새로운 OTA가 생긴다고 해서, 현재 실행 중인 JS 런타임이 자동으로 그 코드로 바뀌지는 않는다.

정리하면:

  • 앱 실행 시점에 어떤 JS 번들이 올라왔는지가 중요함
  • 실행 도중 새 OTA를 받아도, 현재 런타임은 기존 번들로 계속 동작할 수 있음
  • 새 번들을 실제로 쓰려면 reload 또는 다음 앱 실행이 필요함

1.2 OTA는 “배포”와 “적용”이 같은 일이 아니다

OTA를 publish 했다고 해서 바로 사용자의 화면이 바뀌는 것은 아니다.

중간 단계가 필요하다.

  1. 앱이 OTA 서버에 새 업데이트가 있는지 확인
  2. 있으면 그 번들을 다운로드
  3. 그 번들을 실제로 사용하도록 앱을 다시 로드

즉, OTA는 크게 두 단계로 나뉜다.

  • 배포: 서버에 새 JS 번들을 올림
  • 적용: 사용자의 앱이 그 번들을 내려받고 실제로 다시 실행함

여기서 사용자가 “배포했는데 왜 안 바뀌냐”라고 느끼는 이유가 생긴다.
운영자 입장에서는 배포가 끝났지만, 사용자 앱 입장에서는 아직 적용이 끝난 게 아니기 때문이다.

const update = await Updates.checkForUpdateAsync();

if (update.isAvailable) {
  await Updates.fetchUpdateAsync();
  await Updates.reloadAsync();
}

1.3 앱 설치 직후 / 스토어 업데이트 직후에도 같은 문제가 생길 수 있다

이 부분도 헷갈리기 쉬웠다.

앱을 새로 설치하거나 스토어 업데이트를 받더라도, 앱이 처음 켜질 때는 보통 그 시점에 포함된 내장 번들(embedded bundle) 이나 이미 저장된 번들로 실행될 수 있다.
그리고 앱이 켜진 뒤 OTA를 확인해서 최신 JS 번들을 다운로드할 수 있다.

그런데 이 시점에서 바로 reload를 하지 않으면,

  • 첫 실행은 예전 번들
  • 두 번째 실행에서야 새 OTA 반영

같은 흐름이 생길 수 있다.

그래서 “업데이트를 했는데도 한 번 더 껐다 켜야 반영된다”는 상황이 실제로 발생할 수 있다.


2. 이번에 해결하려던 문제

이번 작업에서 해결하려던 건 단순히 “OTA를 쓰자”가 아니었다.
핵심은 사용자가 껐다 켜야만 반영되는 느낌을 줄이는 것이었다.

내가 정리한 문제는 크게 두 가지였다.

  • 앱 시작 시 새 OTA가 있으면 바로 적용되게 만들기
  • 앱이 백그라운드에 오래 있다가 다시 돌아왔을 때도 새 OTA가 있으면 적용되게 만들기

즉, 사용자가 굳이 수동으로 앱을 완전히 종료했다가 다시 실행하지 않아도, 가능한 자연스럽게 최신 번들을 보게 하는 흐름을 만드는 것이 목적이었다.


3. 먼저 이해한 expo-updates 기본 흐름

expo-updates는 대략 아래 순서로 동작한다.

  1. checkForUpdateAsync()
  2. 새 업데이트가 있으면 fetchUpdateAsync()
  3. 실제 반영은 reloadAsync()

이 흐름에서 핵심은, 업데이트 확인과 실제 반영은 별개라는 점이다.

  • checkForUpdateAsync()
    서버에 새로운 OTA가 있는지 확인

  • fetchUpdateAsync()
    새 OTA 번들을 내려받음

  • reloadAsync()
    현재 앱을 새 번들 기준으로 다시 실행

즉, 새 OTA를 다운로드했다고 해서 곧바로 화면이 바뀌는 것이 아니라,
결국 새 JS 번들을 쓰도록 다시 로드해야 한다.


4. dev / preview / production 차이도 중요했다

이 작업을 하면서 dev 환경과 실제 OTA 환경은 동일하지 않다는 점도 분명히 봐야 했다.

dev 환경

  • expo-updates가 실제 OTA처럼 동작하지 않을 수 있음
  • Updates.isEnabled가 false일 수 있음
  • checkForUpdateAsync()를 실제 OTA 검증 기준으로 그대로 믿기 어려움

preview / production 환경

  • 실제 EAS Update 채널 기준으로 OTA 동작 확인 가능
  • 내가 원하는 “배포 후 사용자 앱에서 내려받고 적용” 흐름은 여기서 봐야 함

즉, 진짜 OTA 검증은 preview / production에서 보는 게 맞다.


5. 처음 고민했던 UX 방향

처음에는 여러 선택지가 있었다.

  • 앱 시작 시 OTA 있으면 바로 적용
  • 백그라운드 오래 있었으면 그냥 리로드
  • 백그라운드 오래 있었으면 앱을 아예 종료
  • 앱 시작 후 메인 화면을 먼저 보여주고 나중에 OTA 체크

정리해보니 UX상 가장 자연스러운 방향은 아래였다.

  • 앱 시작 시 OTA를 먼저 확인
  • 백그라운드에 오래 있었다가 돌아오면 OTA를 다시 확인
  • 업데이트가 있을 때만 리로드
  • 업데이트가 없으면 앱은 그대로 유지
  • 앱을 강제로 종료하지는 않음

특히 “오래 있었으니 앱을 죽인다”는 방식은 일반 서비스 앱 UX로는 어색했다.
보안 앱이라면 가능하지만, 일반 서비스 앱에서는 사용자가 “왜 앱이 종료됐지?”라고 느낄 가능성이 크다.


6. 그래서 최종 구조는 이렇게 정리했다

A. 앱 시작 시 OTA 확인

useLaunchOtaReload

역할:

  • 앱 시작 시 1회 OTA 확인
  • 업데이트가 있으면 다운로드 후 리로드
  • 없으면 그냥 앱 진입
if (!Updates.isEnabled) {
  setIsLaunchGateVisible(false);
  return;
}

const update = await Updates.checkForUpdateAsync();

if (!update.isAvailable) {
  setIsLaunchGateVisible(false);
  return;
}

await Updates.fetchUpdateAsync();
await reloadApp();

B. 백그라운드 복귀 시 OTA 확인

useBackgroundTimeoutReset

역할:

  • 앱이 백그라운드로 내려갈 때 시간 기록
  • 다시 active가 되었을 때 체류 시간 계산
  • 기준 시간 초과 + OTA 있음 -> 다운로드 후 리로드
  • OTA 없음 -> 그대로 유지
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();
}

C. 시작 게이트 화면

LaunchGate

역할:

  • 앱 시작 직후 메인 UI를 바로 보여주지 않음
  • OTA 확인이 끝날 때까지 중간 게이트 역할
  • 사용자 입장에서 “앱이 두 번 켜지는 느낌”을 줄여줌

7. 왜 LaunchGate가 필요했는가

처음에는 useLaunchOtaReload만 있으면 된다고 생각했지만, 그렇게 하면 문제가 있었다.

흐름이 이렇게 된다.

  1. 기존 번들로 앱 화면이 먼저 뜸
  2. 그 뒤 OTA 체크
  3. 새 OTA 있으면 다운로드
  4. 다시 리로드

이 경우 사용자는 “앱이 한 번 떴다가 다시 켜지는 느낌”을 받을 수 있다.
그래서 메인 UI를 먼저 보여주지 않고, LaunchGate를 먼저 보여주면서 OTA를 확인하도록 바꿨다.

이렇게 하면 사용자는

  • 앱이 아직 준비 중이라고 느끼고
  • 메인 화면이 깜빡이며 두 번 뜨는 느낌은 줄어든다

8. showSplash 이미지에 의존하면 안 된다고 생각한 이유

처음엔 “기존 스플래시 이미지를 그대로 더 오래 보여주면 되지 않나?”라고 생각할 수 있었다.
하지만 장기적으로는 그 방식이 핵심은 아니라고 봤다.

이유:

  • 특정 스플래시 이미지는 나중에 바뀔 수 있음
  • OTA 로직이 특정 디자인 자산에 종속되면 유지보수가 애매해짐
  • 핵심은 이미지가 아니라 “메인 UI 진입 전에 잠깐 막아주는 gate” 구조임

즉, 개념적으로는 스플래시 이미지 연장이 아니라 런치 게이트가 핵심이다.
다만 UX상 자연스럽게 보이게 하기 위해 실제 화면은 기존 스플래시와 연결되는 형태로 맞췄다.


9. 백그라운드 시간 기록 자체는 부담이 큰가?

이 부분도 따로 생각해봤는데, 결론은 거의 부담이 없다였다.

이유:

  • AppState 이벤트는 상태가 바뀔 때만 발생
  • 백그라운드 진입 시 Date.now() 한 번 기록하는 정도
  • useRef에 저장하므로 리렌더도 없음

즉, 성능적으로 비용이 큰 쪽은 시간 기록이 아니라 아래였다.

  • Alert
  • checkForUpdateAsync()
  • fetchUpdateAsync()
  • reloadAsync()

그래서 “백그라운드에 갈 때마다 시간 기록” 자체는 부담이 크지 않다고 정리했다.


10. 안드로이드 첫 실행에서 background 훅이 잘못 동작한 이유

실제로 안드로이드에서 앱을 처음 설치하고 실행했는데도 백그라운드 복귀 감지 훅이 동작하는 현상이 있었다.

원인을 보니:

  • 앱 시작 중 발생하는 초기 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();
}

11. 안드로이드 3버튼 내비게이션 이슈도 있었다

LaunchGate에서 스플래시 이미지를 중앙 정렬했더니, 안드로이드 3버튼 내비게이션 환경에서는 이미지가 위로 밀려 보였다.

이건 완전히 어쩔 수 없는 현상은 아니고, safe area 기준 정렬 때문에 생기는 시각적 문제였다.

그래서 아래처럼 보정했다.

  • 안드로이드 bottom inset 값을 읽음
  • 스플래시 이미지를 그 값의 일부만큼 아래로 내림
  • 로딩 인디케이터는 bottom inset 위로 올림

즉, 기술적인 중앙이 아니라 사용자가 보기에 자연스러운 시각 중심으로 맞췄다.


12. 지금 구조에서 진짜 2번 리로드가 일어나는가

처음에는 이런 걱정이 생겼다.

  • 백그라운드 훅이 OTA 확인 후 리로드
  • 앱이 다시 시작됨
  • 다시 시작되면 useLaunchOtaReload도 실행됨
  • 그러면 리로드가 또 되는 것 아닌가?

정리해보면 보통은 2번 리로드가 아니다.

흐름:
1. 백그라운드 훅이 OTA 확인
2. 새 OTA 있으면 다운로드 후 리로드
3. 앱이 최신 번들로 다시 시작
4. 시작 훅이 다시 OTA 확인
5. 이미 최신이라면 “업데이트 없음”

즉, 1번 리로드 + 1번 재체크에 가깝다.
예외적으로 리로드 직후 더 새 OTA가 서버에 올라오면 한 번 더 리로드될 가능성은 있지만, 일반적인 흐름은 아니다.


13. preview 빌드에서 텍스트를 바꿔서 OTA 테스트할 때 헷갈린 점

예를 들어 로그인을 로그인 2로 바꿔서 확인한다고 했을 때, 여기서 중요한 건 “무엇을 테스트하는가”였다.

그냥 새 preview 빌드를 만드는 경우

  • 새 빌드 안에 이미 로그인 2 코드가 들어감
  • 설치하자마자 바로 로그인 2가 보임
  • 이건 OTA 확인이 아니라 “새 바이너리 빌드 반영 확인”

OTA 자체를 테스트하는 경우

  • 먼저 기존 코드가 들어간 preview 빌드를 설치
  • 그 다음 같은 채널/runtime에 로그인 2 코드로 OTA publish
  • 앱 실행 또는 복귀 시 OTA를 통해 로그인 2가 보이는지 확인

즉, 빌드 한 번으로 바로 로그인 2가 보인다면 그건 OTA가 아니라 새 빌드 결과일 가능성이 크다.


14. 현재 내가 이해한 최종 흐름

앱 시작 흐름

  1. 앱 시작
  2. LaunchGate 표시
  3. useLaunchOtaReload 실행
  4. OTA 확인
  5. 업데이트 있으면 다운로드 후 리로드
  6. 업데이트 없으면 게이트 종료 후 메인 UI 진입

백그라운드 복귀 흐름

  1. 앱이 background로 내려갈 때 시간 기록
  2. 다시 active가 되면 체류 시간 계산
  3. 기준 시간 이하 -> 아무 것도 안 함
  4. 기준 시간 초과 -> OTA 확인
  5. 업데이트 있으면 다운로드 후 리로드
  6. 업데이트 없으면 현재 화면 유지

15. 지금 구조가 UX상 괜찮다고 본 이유

최종적으로 이 구조가 괜찮다고 본 이유는 아래다.

  • 앱 시작 시 메인 화면이 먼저 뜨지 않아서 어색함이 줄어듦
  • 백그라운드 복귀 시에도 무조건 리로드하지 않고 OTA 있을 때만 반영
  • 앱을 강제로 종료하지 않아서 일반 서비스 앱 UX와 더 잘 맞음
  • 안드로이드 첫 실행 오탐과 하단 내비게이션 시각 이슈도 같이 잡음

즉, “무조건 새로고침”이 아니라
필요할 때만, 사용자가 덜 어색하게 느끼는 방식으로 새 JS 번들을 적용하는 구조라고 이해하면 될 것 같다.


16. 앞으로 남은 정리 포인트

지금은 테스트를 위해 디버깅용 Alert가 들어가 있다.
테스트가 끝나면 제거할 예정이다.

남은 정리 포인트:

  • 디버깅용 Alert 제거
  • 필요하면 background 리로드 직후 1회 launch OTA 체크 skip 여부 검토
  • 상용 기준 timeout 값 재조정

17. 한 줄 결론

이번에 정리한 방향은 이렇다.

OTA는 “배포했다고 바로 반영되는 것”이 아니라, 새 JS 번들을 확인하고 내려받은 뒤 다시 로드해야 적용된다. 그래서 앱 시작 시에는 LaunchGate에서 먼저 OTA를 확인하고, 백그라운드에서 오래 있었다가 돌아왔을 때도 OTA가 있을 때만 리로드하도록 구성해, 사용자가 굳이 여러 번 앱을 껐다 켜지 않아도 최대한 자연스럽게 최신 화면을 보게 하는 것이 목표였다.

profile
Born to be FE developer 🧑🏻‍💻

0개의 댓글