강제 업데이트가 필요한 순간
API 스키마를 바꿨는데 구 버전 앱에서 파싱이 깨지는 경우. 보안 취약점이 발견된 경우. 이럴 때 구 버전을 차단해야 하거든요.
그런데 무작정 차단하면 유저가 이탈합니다. 부드러운 강제 업데이트 UX가 필요해요.
그런데 무작정 차단하면 유저가 이탈합니다. 부드러운 강제 업데이트 UX가 필요해요.
Remote Config로 최소 버전 관리
Firebase Remote Config에 min_version 값을 넣고 앱 시작 시 체크합니다. 서버에서 값만 바꾸면 되니 앱 빌드 없이 제어 가능하죠.
Dart
// Remote Config 키 구조
// min_version: "2.1.0" (이 미만은 강제 업데이트)
// recommended_version: "2.3.0" (이 미만은 권장 업데이트)
// update_message_ko: "중요 보안 업데이트가 있습니다."
class VersionChecker {
Future<UpdateStatus> check() async {
final remoteConfig = FirebaseRemoteConfig.instance;
await remoteConfig.fetchAndActivate();
final minVersion = remoteConfig.getString('min_version');
final recommendedVersion = remoteConfig.getString('recommended_version');
final currentVersion = (await PackageInfo.fromPlatform()).version;
if (_isOlderThan(currentVersion, minVersion)) {
return UpdateStatus.forced; // 강제 업데이트
}
if (_isOlderThan(currentVersion, recommendedVersion)) {
return UpdateStatus.recommended; // 권장 업데이트
}
return UpdateStatus.upToDate;
}
bool _isOlderThan(String current, String target) {
final c = current.split('.').map(int.parse).toList();
final t = target.split('.').map(int.parse).toList();
for (var i = 0; i < 3; i++) {
if (c[i] < t[i]) return true;
if (c[i] > t[i]) return false;
}
return false;
}
}업데이트 다이얼로그 UX
강제 업데이트는 닫기 버튼 없이 스토어로 보내야 합니다. 권장 업데이트는 '나중에' 버튼을 주되 하루에 한 번만 표시합니다.
Dart
void _showForceUpdateDialog(BuildContext context) {
showDialog(
context: context,
barrierDismissible: false, // 바깥 터치로 닫기 불가
builder: (_) => PopScope(
canPop: false, // 뒤로가기 차단
child: AlertDialog(
title: const Text('업데이트 필요'),
content: const Text('새 버전이 출시되었습니다. 계속 사용하려면 업데이트해 주세요.'),
actions: [
FilledButton(
onPressed: () => _openStore(),
child: const Text('업데이트'),
),
],
),
),
);
}
void _openStore() {
final url = Platform.isIOS
? 'https://apps.apple.com/app/idXXXXXX'
: 'https://play.google.com/store/apps/details?id=com.example.app';
launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication);
}Firestore additive-only schema
DB 스키마를 바꿀 때 기존 필드를 삭제하면 구 버전 앱이 깨집니다. 필드는 추가만 하고 절대 삭제하지 않는 additive-only 전략을 씁니다.
필드 이름을 바꿔야 하면 새 필드를 추가하고 구 필드는 유지합니다. 마이그레이션 배치로 데이터를 복사해둡니다.
필드 이름을 바꿔야 하면 새 필드를 추가하고 구 필드는 유지합니다. 마이그레이션 배치로 데이터를 복사해둡니다.
JavaScript
// Firestore 문서 스키마 진화 예시
// v1: { name: "홍길동", score: 100 }
// v2: { name: "홍길동", score: 100, displayName: "홍길동", totalScore: 100 }
// ↑ 구 필드 유지 ↑ 새 필드 추가
// 마이그레이션 배치 (Cloud Functions)
export const migrateUserSchema = onRequest(async (req, res) => {
const users = await db.collection('users').get();
const batch = db.batch();
for (const doc of users.docs) {
const data = doc.data();
if (!data.displayName) {
batch.update(doc.ref, {
displayName: data.name,
totalScore: data.score,
schemaVersion: 2,
});
}
}
await batch.commit();
res.json({ migrated: users.size });
});레거시 API 병행 운영
API 버전을 URL에 넣고 구 버전과 신 버전을 병행해요. 구 버전 트래픽이 5% 이하로 떨어지면 deprecation 알림을 보내고, 1% 이하가 되면 종료해요.
JavaScript
// NestJS API 버전 관리
@Controller({ version: '1' })
export class GameControllerV1 {
@Get('games/:id')
findOne(@Param('id') id: string) {
// v1: 구 스키마 응답
return this.gameService.findOneV1(id);
}
}
@Controller({ version: '2' })
export class GameControllerV2 {
@Get('games/:id')
findOne(@Param('id') id: string) {
// v2: 새 스키마 응답
return this.gameService.findOneV2(id);
}
}
// main.ts
app.enableVersioning({
type: VersioningType.URI, // /v1/games/:id, /v2/games/:id
});단계적 전환 절차 정리
1단계: 새 API/스키마 배포, 구 버전 유지. 솔직히 2단계: Remote Config에서 recommended_version 올리기. 3단계: 구 버전 트래픽 모니터링. 4단계: min_version 올려서 강제 전환. 5단계: 충분한 기간 후 구 API 종료.
이 과정을 건너뛰면 반드시 사고가 납니다. 특히 4단계에서 유저 이탈이 생기니 업데이트 메시지를 신경 써서 작성하세요.
이 과정을 건너뛰면 반드시 사고가 납니다. 특히 4단계에서 유저 이탈이 생기니 업데이트 메시지를 신경 써서 작성하세요.