Expo 앱에서 OTA와 강제 업데이트까지 한 번에 관리하기

LinkDropper·5일 전

Link Dropper

목록 보기
17/17
post-thumbnail

1. 앱을 운영하면 결국 부딪히는 두 가지 욕망

앱을 조금만 운영해 보면 금방 이런 생각이 듭니다.

  1. “아… 이 버그 오늘 안에라도 고치고 싶은데, 스토어 심사는 너무 느리다.”
  2. “이 구버전은 진짜 더 이상 쓰면 안 되는데… 강제로라도 막고 싶다.”

Expo를 쓴다면 이 두 가지는 이렇게 나뉩니다.

  • OTA(Over-The-Air) 업데이트
    → EAS Update로 JS 번들을 즉시 교체
  • 네이티브(스토어) 업데이트
    → App Store / Play Store 심사를 거치는 일반적인 업데이트

Link Dropper 앱에서는 이 두 가지를 동시에 가져가는 구조를 만들었습니다.

  • JS 코드, UI, 로직은 EAS Update(OTA)로 빠르게 수정
  • 꼭 막아야 하는 구버전은 서버에서 “최소 지원 버전”을 내려서 강제 업데이트

이 글에서는 그 구조를 처음부터 끝까지 한 번에 정리해 봅니다.


2. OTA vs 네이티브 업데이트, 경계 어디까지?

먼저, 어떤 변경을 어디서 처리해야 할지 기준을 세워야 합니다.

2-1. OTA 업데이트 (EAS Update)

  • 변경 가능 범위

    • JavaScript 코드
    • React 컴포넌트
    • 이미지 등 번들에 포함된 에셋
  • 배포 속도

    • 거의 즉시 (스토어 심사 없음)
  • 언제 쓰는가

    • 버그 수정
    • UI/UX 개선
    • 비즈니스 로직 수정

2-2. 네이티브 업데이트 (스토어 배포)

  • 변경 가능 범위

    • 네이티브 코드 (Swift, Kotlin, 네이티브 모듈)
    • Expo SDK 버전 업그레이드
    • 권한 추가/변경 (예: 푸시, 위치, 카메라)
  • 배포 속도

    • 심사 + 전파 시간: 1~7일 정도
  • 언제 쓰는가

    • 새로운 네이티브 기능 도입
    • SDK 버전 올리기
    • 빌드 세팅 변경

정리하면:
OTA로 가능한 건 일단 OTA로 빠르게.
네이티브/SDK/권한이 엮이면 스토어로 정식 배포.


3. “필수 업데이트”는 왜, 언제 필요한가

OTA만으로는 해결할 수 없는 상황이 분명 존재합니다.

  • 서버 API가 크게 바뀌어서
    이전 버전이 더 이상 정상 동작하지 않을 때
  • 심각한 보안 취약점이 발견되어
    특정 버전 이상만 쓰게 강제해야 할 때
  • 네이티브 기능이 필수인데
    해당 기능이 없는 옛 빌드를 막고 싶을 때

이럴 땐 결국 이렇게 해야 합니다.

  1. 스토어에 새로운 버전을 올리고
  2. 서버에서 “최소 지원 버전”을 올리고
  3. 그보다 낮은 버전의 앱은
    강제 업데이트 알럿만 띄우고, 정상 사용은 막는다

이 글에서 설명하는 구조가 바로 이 부분을 담당합니다.


4. 전체 흐름 한 번에 보기

먼저, 앱 시작 시 전체 흐름은 이렇게 설계했습니다.

[앱 시작]
      │
      ▼
1. 서버에서 최소 지원 버전 조회
   (GET /app-config/version)
      │
      ▼
2. 현재 앱 버전 < 최소 버전 ?
      │
  ┌───┴──────────────┐
 YES                  NO
  │                   │
  ▼                   ▼
[강제 업데이트 알럿]   3. OTA 업데이트 확인
(스토어로 이동)          (Updates.checkForUpdateAsync)
                      │
                      ▼
              [업데이트 있음? → 다음 실행 시 적용]

즉, “필수 업데이트 체크”를 먼저 하고, 통과하면 그 다음에 OTA 업데이트를 확인합니다.


5. EAS Update 기본 세팅

5-1. app.json / app.config 설정

{
  "expo": {
    "runtimeVersion": {
      "policy": "appVersion"
    },
    "updates": {
      "url": "https://u.expo.dev/[프로젝트 ID]"
    }
  }
}
  • runtimeVersion.policy: "appVersion"
    → “앱 버전(예: 1.2.0)이 같은 앱끼리만 같은 OTA 업데이트를 받는다”는 정책
    → 네이티브가 바뀌면 앱 버전을 올려야 한다고 이해하면 됩니다.

5-2. eas.json 설정

{
  "cli": {
    "appVersionSource": "remote"
  },
  "build": {
    "production": {
      "autoIncrement": true,
      "channel": "production"
    }
  }
}
  • channel: OTA 업데이트를 적용할 채널 이름
  • autoIncrement: 빌드 번호 자동 증가

6. 서버에서 “최소 지원 버전” 관리하기

필수 업데이트를 위해서는 서버가 “이 버전 미만은 막아라”를 알고 있어야 합니다.

6-1. 응답 타입 설계

// GET /app-config/version

interface ServerVersionResponse {
  ios: string;      // iOS 최소 버전 (예: "1.2.0")
  android: string;  // Android 최소 버전 (예: "1.1.0")
  updatedAt: string;
}

6-2. 예시 응답 JSON

{
  "ios": "1.2.0",
  "android": "1.1.0",
  "updatedAt": "2024-12-15T10:00:00Z"
}
  • 플랫폼별로 따로 두면
    → iOS/Android 배포 속도가 달라도 유연하게 대응할 수 있습니다.

7. 버전 비교 유틸리티

버전 비교는 결국 1.2.3 형식의 문자열을 숫자로 쪼개서 비교하는 방식으로 구현했습니다.

// libs/utils/version/index.ts

export const isVersionSufficient = (current: string, minimum: string): boolean => {
  const currentParts = current.split('.').map(Number);
  const minParts = minimum.split('.').map(Number);

  for (let i = 0; i < 3; i++) {
    const curr = currentParts[i] || 0;
    const min = minParts[i] || 0;

    if (curr > min) return true;
    if (curr < min) return false;
  }

  return true; // 같으면 충분
};

export const isValidVersion = (version: string): boolean => {
  const versionRegex = /^\d+\.\d+\.\d+$/;
  return versionRegex.test(version);
};
  • isVersionSufficient
    current >= minimum 이면 true
  • isValidVersion
    x.y.z 패턴인지 간단히 검증

8. UpdateManager: 업데이트 체크의 허브

이제 실제로 “앱 시작 시 한 번만 부르면 되는 함수”를 만드는 단계입니다.

8-1. 타입 정의

// types/config.ts

export type UpdateStatus =
  | 'UP_TO_DATE'
  | 'NATIVE_UPDATE_REQUIRED'
  | 'OTA_UPDATE_AVAILABLE'
  | 'ERROR';

export interface UpdateCheckResult {
  status: UpdateStatus;
  currentVersion?: string;
  minimumVersion?: string;
  error?: Error;
}

8-2. 현재 버전 & 최소 버전 가져오기

import * as Application from 'expo-application';
import { Platform } from 'react-native';

const getCurrentVersion = (): string => {
  return Application.nativeApplicationVersion || '0.0.0';
};

const selectMinimumVersion = (config: ServerVersionResponse): string | undefined => {
  return Platform.select({
    ios: config.ios,
    android: config.android,
  });
};

8-3. 네이티브(스토어) 업데이트 필요 여부 체크

const checkNativeUpdate = async (): Promise<UpdateCheckResult> => {
  try {
    const config = await fetchMinimumVersion(); // 서버 API 호출
    const minVersion = selectMinimumVersion(config);
    const currentVersion = getCurrentVersion();

    if (!minVersion || !isValidVersion(minVersion)) {
      // 서버가 이상한 값을 주면 일단 통과 (Fail-Open)
      return { status: 'UP_TO_DATE', currentVersion };
    }

    if (!isVersionSufficient(currentVersion, minVersion)) {
      return {
        status: 'NATIVE_UPDATE_REQUIRED',
        currentVersion,
        minimumVersion: minVersion,
      };
    }

    return { status: 'UP_TO_DATE', currentVersion, minimumVersion: minVersion };
  } catch (error) {
    // 네트워크 오류 시에도 앱은 사용 가능하게 둔다 (Fail-Open)
    return {
      status: 'ERROR',
      error: error instanceof Error ? error : new Error('Unknown error'),
    };
  }
};

9. Fail-Open 정책: 서버 장애에도 앱은 살아 있어야 한다

업데이트 체크 중에 에러가 나면 어떻게 할까요?

9-1. 두 가지 선택지

  • Fail-Close

    • “버전 체크에 실패했으니, 위험하니 앱을 막자”
    • 보안/규제 산업에서는 타당한 선택
  • Fail-Open

    • “버전 체크에 실패했지만, 일단 앱은 쓰게 하자”
    • UX와 가용성에 더 무게를 둔 선택

Link Dropper에서는 Fail-Open을 선택했습니다.

const checkAppUpdates = async (): Promise<void> => {
  const nativeResult = await checkNativeUpdate();

  if (nativeResult.status === 'NATIVE_UPDATE_REQUIRED') {
    showForceUpdateAlert();
    return;
  }

  // 에러가 나도 앱은 계속 사용 가능
  if (nativeResult.status === 'ERROR') {
    console.warn('Native version check failed, but continuing (Fail-Open)');
  }

  // OTA 업데이트 체크
  await checkAndApplyOTAUpdate();
};

이유는 간단합니다.

  • 서버가 잠깐 터졌다고 전체 앱이 “열리지 않는 서비스”가 되는 건
    → 작은 팀에게 치명적
  • “업데이트 강제”보다 더 큰 악영향은
    → “앱 자체가 안 열리는 경험”

보안 등급이 높은 서비스라면 반대 선택을 할 수도 있습니다.
중요한 건 정책을 의식적으로 선택하는 것.


10. OTA 업데이트 체크 & 적용

이제 OTA 쪽입니다.

import * as Updates from 'expo-updates';

const checkAndApplyOTAUpdate = async (): Promise<UpdateCheckResult> => {
  try {
    const update = await Updates.checkForUpdateAsync();

    if (!update.isAvailable) {
      return { status: 'UP_TO_DATE' };
    }

    // 백그라운드에서 다운로드
    await Updates.fetchUpdateAsync();

    // 기본 전략: 다음 앱 실행 시 적용
    return { status: 'OTA_UPDATE_AVAILABLE' };
  } catch (error) {
    return {
      status: 'ERROR',
      error: error instanceof Error ? error : new Error('Unknown error'),
    };
  }
};

만약 “지금 당장 바로 적용하고 싶다”면:

// 즉시 재시작 (사용자 경험에 주의)
await Updates.reloadAsync();
  • 스플래시 화면이 다시 뜨고 앱이 새로 부팅되는 느낌이 나기 때문에
    → 사용자에게 한 번은 안내해 주는 편이 좋습니다.

11. 강제 업데이트 알럿 UX

이제 스토어 업데이트가 ‘필수’일 때 띄우는 알럿입니다.

import { Alert, AppState, Linking } from 'react-native';

const STORE_URL = 'https://appstore.com/your-app'; // 실제 스토어 URL

const showForceUpdateAlert = (): void => {
  Alert.alert(
    '업데이트 필요',
    '새로운 버전이 출시되었습니다. 계속 사용하려면 앱을 업데이트해 주세요.',
    [
      {
        text: '업데이트',
        onPress: () => {
          // 스토어로 이동
          Linking.openURL(STORE_URL);

          // 스토어 다녀와서도 여전히 구버전이면 다시 알럿
          const subscription = AppState.addEventListener('change', async (nextState) => {
            if (nextState === 'active') {
              subscription.remove();
              const result = await checkNativeUpdate();
              if (result.status === 'NATIVE_UPDATE_REQUIRED') {
                showForceUpdateAlert();
              }
            }
          });
        },
      },
    ],
    { cancelable: false }, // 뒤로가기/바깥 터치로 닫기 불가
  );
};

포인트는 세 가지입니다.

  1. 취소 버튼이 없다
    → 이번 세션에서는 반드시 업데이트를 유도
  2. 스토어 → 다시 돌아온 뒤에도 재검사
    → 진짜로 업데이트가 되었는지 확인
  3. 최소 버전은 서버에서 관리
    → 비즈니스/CS 상황에 따라 언제든 조정 가능

12. 앱 시작 시 한 번만 호출하면 끝

이제 이 모든 걸 앱 시작 루틴에 한 줄로 넣습니다.

// app/_layout.tsx (또는 App.tsx)

import { useEffect } from 'react';
import { checkAppUpdates } from '@/services/UpdateManager';

export default function RootLayout() {
  useEffect(() => {
    const initialize = async () => {
      await initI18n();

      kakaoAuth.initialize();
      googleAuth.initialize();
      checkAuthStatus();

      // ✅ 여기서 한 번만 호출
      checkAppUpdates();

      setIsInitialized(true);
    };

    initialize();
  }, []);

  // ...
}

이렇게 하면:

  1. 앱이 켜질 때
  2. 서버에서 최소 버전을 받아와 강제 업데이트 여부 판단
  3. 통과하면 OTA 업데이트까지 확인

까지 한 번에 처리됩니다.


13. 실제 운영 시나리오별 대응 방법

이제 운영하면서 자주 만나는 상황별로 어떻게 대응하는지 정리해 봅니다.

13-1. JS 코드만 바꿔도 되는 “긴급 버그”

# 코드 수정 후
eas update --channel production --message "긴급 버그 수정"
  • 스토어 심사 없이 바로 배포
  • 사용자는 앱을 재시작하는 순간 최신 로직으로 동작

13-2. 네이티브 라이브러리 추가 / SDK 업그레이드

# 1. app.json에서 앱 버전 올리기
# 2. 새 빌드 생성
eas build --platform all --profile production

# 3. 스토어 제출
eas submit --platform all

# 4. 필요한 시점에 서버의 최소 버전 업데이트
  • “이전 버전은 절대 쓰면 안 된다” 수준이 아닐 땐
    → 최소 버전 업데이트 시점은 조금 여유롭게 잡을 수도 있습니다.

13-3. 보안 이슈로 특정 버전 강제 차단

  1. 새 버전 빌드 & 스토어 배포
  2. 스토어 전파가 어느 정도 완료되면
  3. 서버 DB에서 ios / android 최소 버전을 새 버전으로 업데이트
  4. 이후 구버전 사용자는 앱 실행 시 알럿만 보고 막히게

14. 한 장으로 정리

상황어떻게 해결할까?
JS/UI/로직만 수정하면 되는 경우eas update (OTA)
네이티브 코드/SDK/권한 변경스토어 배포 (새 빌드 + 심사)
구버전 더 이상 허용하면 안 됨서버에서 “최소 지원 버전” 올리기
서버/API 장애가 날 수 있음Fail-Open 정책으로 앱은 계속 열리게 두기

Expo의 EAS Update와 서버 기반 버전 관리를 조합하면,

  • 배포 속도
  • 버전 통제
    둘 다 어느 정도 만족시키는 구조를 만들 수 있습니다.

🧪 링크 드라퍼, 정식 출시!

링크 드라퍼는 단순한 저장 툴이 아닙니다.
정리하고, 다시 꺼내보게 만드는 링크 관리 도구를 지향하고 있어요.

  • 🔗 빠르고 간편한 링크 저장
    iOS/Android 앱, 웹, 크롬 익스텐션 어디서든 바로 저장
  • 🧠 폴더별로 깔끔하게 정리
    읽을 거리, 레퍼런스, 쇼핑 후보까지 주제별 정리
  • 🌐 폴더를 친구에게 공유
    같이 보는 자료는 폴더 단위로 링크 한 번에 공유
  • 크롬 익스텐션 원클릭 저장
    지금 보고 있는 페이지를 버튼 한 번으로 저장

👉 링크 드라퍼 앱 다운로드 (iOS)
👉 링크 드라퍼 웹에서 사용하러 가기
👉 크롬 웹스토어에서 익스텐션 설치하기

profile
“기록하는 습관을 도구로 만들다 — 두 개발자의 링크 드라퍼 구축기”

0개의 댓글