
앱을 조금만 운영해 보면 금방 이런 생각이 듭니다.
Expo를 쓴다면 이 두 가지는 이렇게 나뉩니다.
Link Dropper 앱에서는 이 두 가지를 동시에 가져가는 구조를 만들었습니다.
이 글에서는 그 구조를 처음부터 끝까지 한 번에 정리해 봅니다.
먼저, 어떤 변경을 어디서 처리해야 할지 기준을 세워야 합니다.
변경 가능 범위
배포 속도
언제 쓰는가
변경 가능 범위
배포 속도
언제 쓰는가
정리하면:
OTA로 가능한 건 일단 OTA로 빠르게.
네이티브/SDK/권한이 엮이면 스토어로 정식 배포.
OTA만으로는 해결할 수 없는 상황이 분명 존재합니다.
이럴 땐 결국 이렇게 해야 합니다.
이 글에서 설명하는 구조가 바로 이 부분을 담당합니다.
먼저, 앱 시작 시 전체 흐름은 이렇게 설계했습니다.
[앱 시작]
│
▼
1. 서버에서 최소 지원 버전 조회
(GET /app-config/version)
│
▼
2. 현재 앱 버전 < 최소 버전 ?
│
┌───┴──────────────┐
YES NO
│ │
▼ ▼
[강제 업데이트 알럿] 3. OTA 업데이트 확인
(스토어로 이동) (Updates.checkForUpdateAsync)
│
▼
[업데이트 있음? → 다음 실행 시 적용]
즉, “필수 업데이트 체크”를 먼저 하고, 통과하면 그 다음에 OTA 업데이트를 확인합니다.
{
"expo": {
"runtimeVersion": {
"policy": "appVersion"
},
"updates": {
"url": "https://u.expo.dev/[프로젝트 ID]"
}
}
}
runtimeVersion.policy: "appVersion"{
"cli": {
"appVersionSource": "remote"
},
"build": {
"production": {
"autoIncrement": true,
"channel": "production"
}
}
}
channel: OTA 업데이트를 적용할 채널 이름autoIncrement: 빌드 번호 자동 증가필수 업데이트를 위해서는 서버가 “이 버전 미만은 막아라”를 알고 있어야 합니다.
// GET /app-config/version
interface ServerVersionResponse {
ios: string; // iOS 최소 버전 (예: "1.2.0")
android: string; // Android 최소 버전 (예: "1.1.0")
updatedAt: string;
}
{
"ios": "1.2.0",
"android": "1.1.0",
"updatedAt": "2024-12-15T10:00:00Z"
}
버전 비교는 결국 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);
};
isVersionSufficientcurrent >= minimum 이면 trueisValidVersionx.y.z 패턴인지 간단히 검증이제 실제로 “앱 시작 시 한 번만 부르면 되는 함수”를 만드는 단계입니다.
// 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;
}
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,
});
};
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'),
};
}
};
업데이트 체크 중에 에러가 나면 어떻게 할까요?
Fail-Close
Fail-Open
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();
};
이유는 간단합니다.
보안 등급이 높은 서비스라면 반대 선택을 할 수도 있습니다.
중요한 건 정책을 의식적으로 선택하는 것.
이제 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();
이제 스토어 업데이트가 ‘필수’일 때 띄우는 알럿입니다.
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 }, // 뒤로가기/바깥 터치로 닫기 불가
);
};
포인트는 세 가지입니다.
이제 이 모든 걸 앱 시작 루틴에 한 줄로 넣습니다.
// 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();
}, []);
// ...
}
이렇게 하면:
까지 한 번에 처리됩니다.
이제 운영하면서 자주 만나는 상황별로 어떻게 대응하는지 정리해 봅니다.
# 코드 수정 후
eas update --channel production --message "긴급 버그 수정"
# 1. app.json에서 앱 버전 올리기
# 2. 새 빌드 생성
eas build --platform all --profile production
# 3. 스토어 제출
eas submit --platform all
# 4. 필요한 시점에 서버의 최소 버전 업데이트
ios / android 최소 버전을 새 버전으로 업데이트| 상황 | 어떻게 해결할까? |
|---|---|
| JS/UI/로직만 수정하면 되는 경우 | eas update (OTA) |
| 네이티브 코드/SDK/권한 변경 | 스토어 배포 (새 빌드 + 심사) |
| 구버전 더 이상 허용하면 안 됨 | 서버에서 “최소 지원 버전” 올리기 |
| 서버/API 장애가 날 수 있음 | Fail-Open 정책으로 앱은 계속 열리게 두기 |
Expo의 EAS Update와 서버 기반 버전 관리를 조합하면,
링크 드라퍼는 단순한 저장 툴이 아닙니다.
정리하고, 다시 꺼내보게 만드는 링크 관리 도구를 지향하고 있어요.
👉 링크 드라퍼 앱 다운로드 (iOS)
👉 링크 드라퍼 웹에서 사용하러 가기
👉 크롬 웹스토어에서 익스텐션 설치하기