Flutter 개발

Flutter 앱 강제 업데이트와 버전 호환성 관리

Remote Config로 버전 체크하고 안전하게 마이그레이션하기

John Doe
2026년 1월 5일
8분 읽기
44

강제 업데이트가 필요한 순간

API 스키마를 바꿨는데 구 버전 앱에서 파싱이 깨지는 경우. 보안 취약점이 발견된 경우. 이럴 때 구 버전을 차단해야 하거든요.

그런데 무작정 차단하면 유저가 이탈합니다. 부드러운 강제 업데이트 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단계에서 유저 이탈이 생기니 업데이트 메시지를 신경 써서 작성하세요.
#Flutter
#강제 업데이트
#Remote Config
#버전 관리
#마이그레이션
#Firestore