이미지 로딩 중...
AI Generated
2025. 11. 13. · 4 Views
Flutter 마이그레이션 전략과 스키마 버전 관리 완벽 가이드
데이터베이스 스키마 변경이 필요할 때마다 고민되시나요? 이 가이드에서는 Flutter 앱 개발 시 필수적인 마이그레이션 전략과 스키마 버전 관리 방법을 실무 중심으로 다룹니다. Sqflite, Drift, Hive 등 다양한 저장소 솔루션에서 안전하게 데이터를 마이그레이션하는 방법을 배워보세요.
목차
- 데이터베이스 마이그레이션 기본 개념 - 왜 필요하고 어떻게 동작하는가
- 스키마 버전 관리 전략 - 체계적인 버전 번호 시스템
- Drift를 활용한 타입 안전 마이그레이션 - 컴파일 타임 검증
- 복잡한 데이터 변환 마이그레이션 - 스키마와 데이터 동시 변경
- Hive 마이그레이션 전략 - NoSQL 스타일 데이터 버전 관리
- 롤백 불가능 마이그레이션 대응 전략 - 백업과 복구 메커니즘
- 대용량 데이터 마이그레이션 최적화 - 배치 처리와 진행 상황 표시
- 다중 환경 마이그레이션 테스트 - 자동화된 검증 프레임워크
1. 데이터베이스 마이그레이션 기본 개념 - 왜 필요하고 어떻게 동작하는가
시작하며
여러분이 Flutter 앱을 출시한 후 몇 달이 지나서 새로운 기능을 추가하려는데, 기존 사용자들의 데이터베이스 구조를 변경해야 하는 상황을 겪어본 적 있나요? 테이블에 새로운 컬럼을 추가하거나, 데이터 타입을 변경해야 하는데, 이미 수만 명의 사용자가 앱을 사용하고 있다면 어떻게 해야 할까요?
이런 문제는 실제 개발 현장에서 반드시 마주치게 됩니다. 잘못된 마이그레이션은 사용자 데이터 손실로 이어지고, 앱 크래시를 발생시키며, 결국 부정적인 리뷰와 이탈률 증가로 연결됩니다.
특히 앱 업데이트 후 데이터가 사라진다면 사용자 신뢰를 완전히 잃게 됩니다. 바로 이럴 때 필요한 것이 체계적인 마이그레이션 전략입니다.
올바른 마이그레이션 전략을 수립하면 사용자 데이터를 안전하게 보존하면서도 새로운 기능을 자유롭게 추가할 수 있습니다.
개요
간단히 말해서, 데이터베이스 마이그레이션은 앱의 데이터 구조를 한 버전에서 다른 버전으로 안전하게 전환하는 프로세스입니다. 앱이 업데이트될 때마다 데이터베이스 스키마도 함께 변경될 수 있는데, 이때 기존 사용자의 데이터를 손실 없이 새로운 구조로 옮겨야 합니다.
예를 들어, 사용자 프로필에 생년월일 필드를 추가하거나, 문자열로 저장되던 날짜를 정수형 타임스탬프로 변경하는 경우에 매우 유용합니다. 기존에는 앱을 삭제하고 재설치하거나, 복잡한 백업-복원 절차를 거쳐야 했다면, 이제는 자동으로 데이터베이스가 새 버전으로 업그레이드됩니다.
마이그레이션의 핵심 특징은 버전 관리, 순차적 실행, 롤백 불가능성입니다. 버전 번호를 통해 현재 스키마 상태를 추적하고, 각 마이그레이션은 순서대로 실행되며, 한번 적용된 마이그레이션은 되돌릴 수 없기 때문에 신중한 설계가 필요합니다.
이러한 특징들이 데이터 일관성과 안정성을 보장하는 핵심 요소입니다.
코드 예제
// Sqflite를 사용한 기본 마이그레이션 예제
import 'package:sqflite/sqflite.dart';
// 데이터베이스 버전 상수
const int databaseVersion = 2;
Future<Database> initDatabase() async {
return await openDatabase(
'my_app.db',
version: databaseVersion,
onCreate: (db, version) async {
// 최초 설치 시 최신 스키마로 생성
await db.execute('''
CREATE TABLE users (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
email TEXT NOT NULL,
created_at INTEGER NOT NULL
)
''');
},
onUpgrade: (db, oldVersion, newVersion) async {
// 버전 1에서 2로 업그레이드: phone 컬럼 추가
if (oldVersion < 2) {
await db.execute('ALTER TABLE users ADD COLUMN phone TEXT');
}
},
);
}
설명
이것이 하는 일: 이 코드는 앱이 시작될 때 데이터베이스를 열면서 자동으로 현재 버전을 확인하고, 필요하면 마이그레이션을 실행합니다. 첫 번째로, openDatabase 함수의 version 매개변수는 현재 코드가 기대하는 데이터베이스 버전을 명시합니다.
Sqflite는 디바이스에 저장된 실제 데이터베이스 버전과 이 값을 비교합니다. 만약 사용자가 처음 앱을 설치하는 경우라면 onCreate 콜백이 실행되어 최신 스키마로 바로 데이터베이스를 생성합니다.
이렇게 하면 신규 사용자는 모든 마이그레이션 단계를 거칠 필요 없이 최종 형태의 테이블을 즉시 사용할 수 있습니다. 그 다음으로, 기존 사용자의 경우 onUpgrade 콜백이 실행되면서 oldVersion과 newVersion을 비교합니다.
예를 들어 사용자가 버전 1의 앱을 사용하다가 버전 2로 업데이트했다면, oldVersion은 1이고 newVersion은 2가 됩니다. 이때 if 조건문이 true가 되어 ALTER TABLE 명령이 실행되고, users 테이블에 phone 컬럼이 추가됩니다.
내부적으로 SQLite는 트랜잭션을 사용하여 마이그레이션이 성공하면 변경사항을 저장하고, 실패하면 모두 롤백합니다. 마지막으로, 마이그레이션이 완료되면 Sqflite는 내부 메타데이터에 새 버전 번호를 기록합니다.
이후 앱을 다시 시작해도 버전이 일치하므로 마이그레이션은 다시 실행되지 않습니다. 이렇게 함으로써 최종적으로 모든 사용자가 동일한 최신 스키마를 가지게 되어 일관된 앱 동작을 보장할 수 있습니다.
여러분이 이 코드를 사용하면 앱 업데이트 시 사용자 데이터를 자동으로 마이그레이션하여 데이터 손실을 방지하고, 새로운 기능을 안전하게 배포할 수 있습니다. 또한 버전별로 마이그레이션 로직을 명확하게 분리하여 유지보수가 쉬워지고, 향후 추가 업데이트 시에도 확장 가능한 구조를 갖출 수 있습니다.
실전 팁
💡 항상 oldVersion < targetVersion 형태의 조건문을 사용하여 여러 버전을 건너뛴 업데이트에도 대응하세요. 사용자가 버전 1에서 3으로 바로 업데이트할 수도 있습니다.
💡 onCreate에서는 최신 스키마만 생성하고, 과거의 모든 마이그레이션 단계를 포함하지 마세요. 신규 사용자는 최종 형태만 필요합니다.
💡 마이그레이션 코드를 작성할 때는 반드시 로컬 테스트 환경에서 여러 버전 시나리오를 시뮬레이션하여 검증하세요. 버전 1→2, 1→3, 2→3 모든 경로를 테스트해야 합니다.
💡 ALTER TABLE은 제한적인 명령이므로 복잡한 변경(컬럼 삭제, 타입 변경)은 새 테이블을 만들고 데이터를 복사한 후 기존 테이블을 삭제하는 방식을 사용하세요.
💡 마이그레이션 실패 시 대응을 위해 try-catch 블록을 추가하고, 에러 로깅을 구현하여 프로덕션에서 발생하는 문제를 추적할 수 있게 하세요.
2. 스키마 버전 관리 전략 - 체계적인 버전 번호 시스템
시작하며
여러분의 팀에서 여러 개발자가 동시에 데이터베이스 스키마를 수정하고 있다면, 버전 충돌 문제를 어떻게 해결하시나요? 한 개발자는 버전 5에서 테이블을 추가하고, 다른 개발자도 버전 5에서 컬럼을 추가했다면, 두 변경사항을 어떻게 통합할 수 있을까요?
이런 문제는 팀 규모가 커질수록 더욱 심각해집니다. 버전 번호가 충돌하면 한쪽의 마이그레이션이 누락되거나, 잘못된 순서로 실행되어 데이터베이스가 예상과 다른 상태가 될 수 있습니다.
결과적으로 특정 사용자 그룹에서만 발생하는 버그나, 재현하기 어려운 데이터 불일치 문제가 생깁니다. 바로 이럴 때 필요한 것이 체계적인 스키마 버전 관리 전략입니다.
명확한 버전 관리 규칙을 수립하면 팀원들이 충돌 없이 협업할 수 있고, 각 배포 버전에 포함된 스키마 변경사항을 명확하게 추적할 수 있습니다.
개요
간단히 말해서, 스키마 버전 관리는 데이터베이스 구조의 변경 이력을 추적하고 각 변경사항에 고유한 버전 번호를 부여하는 체계입니다. Git이 코드 변경사항을 커밋으로 관리하듯이, 스키마 버전 관리는 데이터베이스 구조 변경을 버전으로 관리합니다.
예를 들어, 새 기능 개발로 인해 테이블 구조가 변경될 때마다 버전 번호를 증가시키고, 해당 버전에서 수행할 마이그레이션 스크립트를 정의하는 경우에 매우 유용합니다. 기존에는 개발자마다 임의로 버전을 증가시켜 혼란이 발생했다면, 이제는 명확한 규칙에 따라 버전을 관리하여 일관성을 유지할 수 있습니다.
스키마 버전 관리의 핵심 특징은 단일 증가 원칙, 명명 규칙, 변경 이력 문서화입니다. 버전 번호는 항상 1씩 증가하며 건너뛰지 않고, 각 버전에는 명확한 설명이 포함되어야 하며, 모든 변경사항은 문서나 마이그레이션 파일에 기록됩니다.
이러한 특징들이 팀 협업과 장기적인 유지보수를 가능하게 만듭니다.
코드 예제
// 스키마 버전 관리 클래스
class DatabaseSchema {
// 현재 최신 버전
static const int currentVersion = 5;
// 버전별 설명 (문서화 목적)
static const Map<int, String> versionHistory = {
1: '초기 스키마: users 테이블 생성',
2: 'users 테이블에 phone 컬럼 추가',
3: 'posts 테이블 생성',
4: 'posts 테이블에 likes_count 컬럼 추가',
5: 'comments 테이블 생성 및 posts 외래키 설정',
};
// 특정 버전으로 업그레이드하는 마이그레이션 함수
static Future<void> migrate(Database db, int fromVersion, int toVersion) async {
// 순차적으로 각 버전의 마이그레이션 실행
for (int version = fromVersion + 1; version <= toVersion; version++) {
await _migrateToVersion(db, version);
print('Migrated to version $version: ${versionHistory[version]}');
}
}
static Future<void> _migrateToVersion(Database db, int version) async {
switch (version) {
case 2:
await db.execute('ALTER TABLE users ADD COLUMN phone TEXT');
break;
case 3:
await db.execute('''
CREATE TABLE posts (
id INTEGER PRIMARY KEY,
user_id INTEGER NOT NULL,
content TEXT NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id)
)
''');
break;
case 4:
await db.execute('ALTER TABLE posts ADD COLUMN likes_count INTEGER DEFAULT 0');
break;
case 5:
await db.execute('''
CREATE TABLE comments (
id INTEGER PRIMARY KEY,
post_id INTEGER NOT NULL,
user_id INTEGER NOT NULL,
content TEXT NOT NULL,
FOREIGN KEY (post_id) REFERENCES posts(id)
)
''');
break;
}
}
}
설명
이것이 하는 일: 이 코드는 데이터베이스의 모든 스키마 변경사항을 버전별로 정리하고, 현재 버전에서 최신 버전까지 순차적으로 업그레이드하는 체계적인 시스템을 제공합니다. 첫 번째로, DatabaseSchema 클래스는 currentVersion 상수를 통해 코드베이스에서 기대하는 최신 스키마 버전을 명시합니다.
이 값이 단일 진실 공급원(Single Source of Truth) 역할을 하여, 앱의 모든 부분에서 동일한 버전 정보를 참조하게 됩니다. versionHistory 맵은 각 버전에서 무엇이 변경되었는지 기록하여, 코드를 읽는 개발자나 디버깅 시 변경 이력을 쉽게 파악할 수 있게 합니다.
이는 문서화의 일부로서 장기적인 유지보수에 필수적입니다. 그 다음으로, migrate 함수는 fromVersion부터 toVersion까지 모든 중간 버전의 마이그레이션을 순차적으로 실행합니다.
예를 들어 사용자가 버전 2에서 5로 업데이트했다면, 버전 3, 4, 5의 마이그레이션이 차례대로 실행됩니다. 이 순차적 실행 방식이 매우 중요한 이유는, 각 마이그레이션이 이전 버전의 스키마 상태를 전제로 작성되기 때문입니다.
버전 4의 마이그레이션은 posts 테이블이 이미 존재한다고 가정하고 컬럼을 추가하므로, 버전 3을 건너뛰면 오류가 발생합니다. 마지막으로, _migrateToVersion 함수는 switch 문을 사용하여 각 버전별 마이그레이션 로직을 명확하게 분리합니다.
이 구조는 새로운 버전을 추가할 때 기존 코드를 수정하지 않고 case 문만 추가하면 되므로 개방-폐쇄 원칙(Open-Closed Principle)을 따릅니다. 또한 각 case가 독립적인 트랜잭션으로 실행되지 않고 전체 migrate 함수가 하나의 트랜잭션 내에서 실행되므로, 중간에 실패하면 모든 변경사항이 롤백됩니다.
여러분이 이 코드를 사용하면 여러 개발자가 동시에 작업할 때도 버전 충돌을 방지하고, 각 배포에 포함된 스키마 변경사항을 명확하게 추적할 수 있습니다. 또한 프로덕션 환경에서 버그가 발생했을 때 어느 버전에서 문제가 생겼는지 빠르게 파악하여 디버깅 시간을 크게 단축할 수 있습니다.
실전 팁
💡 버전 번호는 앱의 릴리스 버전과 분리하여 관리하세요. 앱 버전 2.1.0에 데이터베이스 버전 8이 포함될 수 있으며, 이렇게 하면 핫픽스 배포 시 스키마 버전을 건너뛰지 않아도 됩니다.
💡 Git 브랜치 병합 시 스키마 버전 충돌이 발생하면, 나중에 병합된 브랜치가 버전 번호를 다음 숫자로 변경하는 규칙을 팀 내에 수립하세요.
💡 마이그레이션 스크립트에 주석으로 Jira 티켓 번호나 PR 링크를 추가하여 변경 이유와 컨텍스트를 쉽게 찾을 수 있게 하세요.
💡 CI/CD 파이프라인에 스키마 버전 검증 단계를 추가하여 중복된 버전 번호나 순서가 맞지 않는 마이그레이션을 자동으로 감지하세요.
💡 프로덕션 배포 전에 스테이징 환경에서 실제 사용자 데이터의 복사본으로 마이그레이션을 테스트하여 데이터 양에 따른 성능 문제를 미리 발견하세요.
3. Drift를 활용한 타입 안전 마이그레이션 - 컴파일 타임 검증
시작하며
여러분이 Sqflite로 마이그레이션을 작성할 때 SQL 문자열에 오타를 내거나, 존재하지 않는 컬럼을 참조하는 실수를 한 적 있나요? 이런 오류는 컴파일 시점에 잡히지 않고 런타임에 사용자 기기에서 발생하여, 크래시 리포트를 통해 뒤늦게 발견됩니다.
이런 문제는 SQL을 문자열로 작성하는 접근 방식의 근본적인 한계입니다. IDE의 자동완성이나 정적 분석의 도움을 받을 수 없고, 리팩토링 시 관련된 마이그레이션 코드를 수동으로 찾아야 하며, 테이블이나 컬럼 이름을 변경하면 여러 곳의 문자열을 일일이 수정해야 합니다.
바로 이럴 때 필요한 것이 Drift(구 Moor)의 타입 안전 마이그레이션입니다. Drift는 Dart 코드로 스키마를 정의하고, 코드 생성을 통해 타입 안전한 API를 제공하여 컴파일 타임에 대부분의 오류를 잡아낼 수 있습니다.
개요
간단히 말해서, Drift는 SQL을 Dart 클래스로 표현하여 타입 안전성을 제공하고, 마이그레이션을 체계적으로 관리할 수 있는 고급 데이터베이스 라이브러리입니다. Drift를 사용하면 테이블 정의가 Dart 클래스가 되고, 컬럼은 클래스의 필드가 되므로 IDE의 모든 기능을 활용할 수 있습니다.
예를 들어, 테이블 이름을 변경하면 모든 참조 지점이 자동으로 업데이트되고, 존재하지 않는 컬럼을 참조하면 즉시 빨간 줄이 표시됩니다. 이는 대규모 리팩토링이나 복잡한 쿼리 작성 시 실수를 크게 줄여줍니다.
기존에는 SQL 문자열을 작성하고 런타임에 오류를 발견했다면, 이제는 Dart 코드로 작성하여 컴파일 시점에 오류를 발견할 수 있습니다. Drift의 핵심 특징은 스키마 버전 자동 관리, 마이그레이션 검증, 데이터 클래스 자동 생성입니다.
@DriftDatabase 어노테이션으로 스키마 버전을 명시하고, MigrationStrategy를 통해 마이그레이션 로직을 정의하며, build_runner가 모든 보일러플레이트 코드를 자동으로 생성합니다. 이러한 특징들이 개발 생산성을 높이고 버그를 줄여줍니다.
코드 예제
// Drift 테이블 정의
import 'package:drift/drift.dart';
part 'database.g.dart';
// Users 테이블 정의
class Users extends Table {
IntColumn get id => integer().autoIncrement()();
TextColumn get name => text().withLength(min: 1, max: 50)();
TextColumn get email => text().unique()();
TextColumn get phone => text().nullable()(); // 버전 2에서 추가
DateTimeColumn get createdAt => dateTime()();
}
// Posts 테이블 정의 (버전 3에서 추가)
class Posts extends Table {
IntColumn get id => integer().autoIncrement()();
IntColumn get userId => integer().references(Users, #id)();
TextColumn get content => text()();
IntColumn get likesCount => integer().withDefault(const Constant(0))(); // 버전 4에서 추가
DateTimeColumn get createdAt => dateTime()();
}
// 데이터베이스 클래스
@DriftDatabase(tables: [Users, Posts])
class AppDatabase extends _$AppDatabase {
AppDatabase() : super(_openConnection());
@override
int get schemaVersion => 4;
@override
MigrationStrategy get migration => MigrationStrategy(
onCreate: (Migrator m) async {
// 최초 설치 시 모든 테이블 생성
await m.createAll();
},
onUpgrade: (Migrator m, int from, int to) async {
// 버전별 마이그레이션
if (from < 2) {
await m.addColumn(users, users.phone);
}
if (from < 3) {
await m.createTable(posts);
}
if (from < 4) {
await m.addColumn(posts, posts.likesCount);
}
},
beforeOpen: (details) async {
// 외래키 제약조건 활성화
await customStatement('PRAGMA foreign_keys = ON');
},
);
}
LazyDatabase _openConnection() {
return LazyDatabase(() async {
final dbFolder = await getApplicationDocumentsDirectory();
final file = File(p.join(dbFolder.path, 'app.sqlite'));
return NativeDatabase(file);
});
}
설명
이것이 하는 일: 이 코드는 Drift의 선언적 스키마 정의를 사용하여 타입 안전한 데이터베이스를 구축하고, 각 버전별 마이그레이션을 명확하게 분리하여 관리합니다. 첫 번째로, Users와 Posts 클래스는 Drift의 Table 클래스를 상속하여 테이블 구조를 Dart 코드로 표현합니다.
각 메서드(id, name, email 등)는 테이블의 컬럼을 정의하며, 메서드 체인을 통해 제약조건을 추가합니다. 예를 들어 withLength(min: 1, max: 50)는 문자열 길이를 제한하고, unique()는 중복을 방지하며, nullable()은 NULL 값을 허용합니다.
이러한 제약조건들이 Dart 타입 시스템과 결합되어 컴파일 타임에 검증됩니다. references 메서드는 외래키를 타입 안전하게 정의하여, 존재하지 않는 테이블이나 컬럼을 참조하면 컴파일 오류가 발생합니다.
그 다음으로, @DriftDatabase 어노테이션이 포함된 AppDatabase 클래스에서 schemaVersion 프로퍼티는 현재 스키마 버전을 명시합니다. MigrationStrategy의 onCreate는 신규 사용자를 위해 m.createAll()을 호출하여 모든 테이블을 최신 상태로 즉시 생성합니다.
onUpgrade는 기존 사용자를 위한 마이그레이션 로직으로, Migrator 객체의 메서드들(addColumn, createTable 등)을 사용하여 타입 안전하게 스키마를 변경합니다. 이 메서드들은 문자열 대신 실제 테이블과 컬럼 객체를 참조하므로, 오타나 잘못된 참조가 불가능합니다.
마지막으로, build_runner가 실행되면 database.g.dart 파일이 생성되어 _$AppDatabase 클래스와 함께 모든 데이터 접근 코드가 자동으로 만들어집니다. 이 생성된 코드는 타입 안전한 쿼리 빌더, 데이터 클래스, JSON 직렬화 등을 포함합니다.
beforeOpen 콜백에서 외래키 제약조건을 활성화하여 데이터 무결성을 보장하고, LazyDatabase를 사용하여 실제로 데이터베이스가 필요할 때까지 연결을 지연시켜 앱 시작 시간을 최적화합니다. 여러분이 이 코드를 사용하면 SQL 문법 오류나 오타로 인한 런타임 크래시를 원천적으로 방지하고, IDE의 자동완성과 리팩토링 기능을 완벽하게 활용할 수 있습니다.
또한 복잡한 쿼리 작성 시에도 타입 시스템의 도움을 받아 안전하게 코드를 작성할 수 있으며, 테이블 구조 변경 시 컴파일러가 모든 영향받는 코드를 찾아주어 유지보수가 훨씬 쉬워집니다.
실전 팁
💡 build_runner를 watch 모드로 실행(flutter packages pub run build_runner watch)하여 스키마 변경 시 자동으로 코드가 재생성되도록 설정하세요.
💡 Drift의 @UseRowClass 어노테이션을 사용하면 생성된 데이터 클래스 대신 커스텀 클래스를 사용할 수 있어, 비즈니스 로직과 데이터 모델을 분리할 수 있습니다.
💡 마이그레이션 검증을 위해 drift_dev의 validateDatabaseSchema 함수를 테스트 코드에서 사용하여 실제 데이터베이스와 정의된 스키마가 일치하는지 자동으로 확인하세요.
💡 복잡한 마이그레이션은 customStatement를 사용하여 직접 SQL을 실행할 수 있지만, 가능한 Migrator의 메서드를 사용하는 것이 타입 안전성 측면에서 유리합니다.
💡 Drift Inspector를 사용하면 개발 중에 실시간으로 데이터베이스 내용을 확인하고 쿼리를 테스트할 수 있어 디버깅이 훨씬 쉬워집니다.
4. 복잡한 데이터 변환 마이그레이션 - 스키마와 데이터 동시 변경
시작하며
여러분이 단순히 컬럼을 추가하는 것이 아니라, 기존 데이터의 형식을 완전히 바꿔야 하는 상황을 맞닥뜨린 적 있나요? 예를 들어 날짜를 문자열로 저장하다가 Unix 타임스탬프로 변경하거나, JSON 문자열을 정규화된 별도 테이블로 분리해야 하는 경우입니다.
이런 문제는 간단한 ALTER TABLE로는 해결할 수 없습니다. 스키마 변경뿐만 아니라 모든 기존 레코드를 순회하며 데이터 형식을 변환해야 하고, 변환 중 오류가 발생할 수 있으며, 데이터 양이 많으면 마이그레이션 시간이 오래 걸려 앱 시작이 지연될 수 있습니다.
특히 잘못된 형식의 데이터가 포함되어 있으면 마이그레이션 자체가 실패할 위험이 있습니다. 바로 이럴 때 필요한 것이 데이터 변환을 포함한 복잡한 마이그레이션 전략입니다.
임시 테이블을 활용하고, 데이터를 안전하게 변환하며, 트랜잭션으로 원자성을 보장하는 방법을 알아야 합니다.
개요
간단히 말해서, 복잡한 데이터 변환 마이그레이션은 스키마 구조 변경과 함께 모든 기존 데이터를 새로운 형식으로 안전하게 변환하는 프로세스입니다. 단순 스키마 변경은 메타데이터만 수정하지만, 데이터 변환 마이그레이션은 실제 레코드의 내용을 수정해야 하므로 훨씬 복잡합니다.
예를 들어, 사용자 주소를 단일 TEXT 컬럼에서 address_line1, address_line2, city, postal_code로 분리하거나, 태그를 쉼표로 구분된 문자열에서 별도의 tags 테이블로 정규화하는 경우에 필요합니다. 기존에는 앱 업데이트 후 첫 실행 시 별도의 데이터 마이그레이션 로직을 실행했다면, 이제는 데이터베이스 마이그레이션 단계에서 스키마와 데이터를 동시에 처리할 수 있습니다.
복잡한 마이그레이션의 핵심 특징은 임시 테이블 활용, 트랜잭션 보장, 에러 핸들링입니다. 기존 테이블을 백업용 임시 테이블로 이름을 변경하고, 새 스키마로 테이블을 재생성한 후, 데이터를 변환하며 복사하고, 성공하면 임시 테이블을 삭제합니다.
모든 과정이 단일 트랜잭션 내에서 실행되어 실패 시 자동으로 롤백됩니다. 이러한 특징들이 데이터 안전성과 일관성을 보장합니다.
코드 예제
// 날짜 형식을 문자열에서 정수형 타임스탬프로 변환하는 마이그레이션
Future<void> migrateToVersion6(Database db) async {
await db.transaction((txn) async {
// 1. 기존 테이블을 임시 이름으로 변경
await txn.execute('ALTER TABLE posts RENAME TO posts_old');
// 2. 새 스키마로 테이블 재생성
await txn.execute('''
CREATE TABLE posts (
id INTEGER PRIMARY KEY,
user_id INTEGER NOT NULL,
content TEXT NOT NULL,
created_at INTEGER NOT NULL, -- 문자열에서 정수로 변경
updated_at INTEGER,
FOREIGN KEY (user_id) REFERENCES users(id)
)
''');
// 3. 기존 데이터 조회
final List<Map<String, dynamic>> oldPosts =
await txn.query('posts_old');
// 4. 데이터 변환 및 새 테이블에 삽입
for (final post in oldPosts) {
try {
// 문자열 날짜를 파싱하여 타임스탬프로 변환
final dateStr = post['created_at'] as String;
final dateTime = DateTime.parse(dateStr);
final timestamp = dateTime.millisecondsSinceEpoch;
await txn.insert('posts', {
'id': post['id'],
'user_id': post['user_id'],
'content': post['content'],
'created_at': timestamp,
'updated_at': post['updated_at'] != null
? DateTime.parse(post['updated_at'] as String).millisecondsSinceEpoch
: null,
});
} catch (e) {
// 변환 실패 시 로깅 후 기본값 사용
print('Failed to migrate post ${post['id']}: $e');
await txn.insert('posts', {
'id': post['id'],
'user_id': post['user_id'],
'content': post['content'],
'created_at': DateTime.now().millisecondsSinceEpoch,
'updated_at': null,
});
}
}
// 5. 임시 테이블 삭제
await txn.execute('DROP TABLE posts_old');
});
}
설명
이것이 하는 일: 이 코드는 기존 테이블의 데이터 형식을 안전하게 변경하기 위해 임시 테이블 기법과 트랜잭션을 활용하여 원자성을 보장합니다. 첫 번째로, ALTER TABLE ...
RENAME TO 명령으로 기존 posts 테이블을 posts_old로 이름을 변경합니다. 이는 원본 데이터를 보존하면서 새로운 스키마의 테이블을 같은 이름으로 생성하기 위한 준비 단계입니다.
원본을 삭제하지 않고 이름만 변경하는 이유는, 데이터 변환 중 오류가 발생하더라도 원본 데이터가 안전하게 보존되어 있어야 트랜잭션 롤백 시 복구가 가능하기 때문입니다. 그 후 CREATE TABLE 명령으로 새로운 스키마를 가진 posts 테이블을 생성하는데, created_at 컬럼의 타입이 TEXT에서 INTEGER로 변경되었습니다.
그 다음으로, posts_old 테이블의 모든 레코드를 조회한 후 for 루프로 순회하며 각 레코드를 변환합니다. DateTime.parse를 사용하여 문자열 날짜를 DateTime 객체로 파싱하고, millisecondsSinceEpoch를 호출하여 Unix 타임스탬프로 변환합니다.
이 과정에서 잘못된 형식의 날짜 문자열이 있으면 예외가 발생할 수 있으므로 try-catch 블록으로 감싸고, 변환 실패 시에는 현재 시간을 기본값으로 사용하여 마이그레이션이 중단되지 않도록 합니다. updated_at 컬럼은 nullable이므로 null 체크를 수행한 후 변환합니다.
마지막으로, 모든 데이터 변환이 성공적으로 완료되면 DROP TABLE 명령으로 posts_old 임시 테이블을 삭제합니다. 전체 프로세스가 db.transaction 블록 내에서 실행되므로, 중간에 어떤 단계에서든 오류가 발생하면 모든 변경사항이 자동으로 롤백되어 데이터베이스가 마이그레이션 이전 상태로 복구됩니다.
성공하면 commit이 자동으로 실행되어 변경사항이 영구적으로 저장됩니다. 여러분이 이 코드를 사용하면 복잡한 데이터 형식 변경을 안전하게 수행하면서도 사용자 데이터를 보호할 수 있습니다.
또한 변환 실패에 대한 대응 전략을 포함하여 예외 상황에서도 앱이 크래시되지 않고, 불완전한 데이터가 남지 않도록 보장할 수 있습니다. 대규모 데이터셋에서도 트랜잭션의 원자성 덕분에 일관성 있는 결과를 얻을 수 있습니다.
실전 팁
💡 대량의 데이터를 마이그레이션할 때는 배치 단위로 나누어 처리하고, 각 배치마다 progress indicator를 업데이트하여 사용자에게 진행 상황을 표시하세요.
💡 변환 로직이 복잡하면 먼저 읽기 전용 복사본으로 테스트하고, 변환 성공률을 측정하여 프로덕션 배포 전에 문제를 발견하세요.
💡 SQLite의 PRAGMA table_info(table_name)를 사용하여 마이그레이션 전후 스키마를 비교하고, 예상대로 변경되었는지 검증하는 테스트 코드를 작성하세요.
💡 타임스탬프 변환 시 타임존 이슈를 고려하세요. DateTime.parse는 로컬 시간으로 해석할 수 있으므로, UTC로 통일하려면 DateTime.parse(dateStr).toUtc()를 사용하세요.
💡 마이그레이션 시간이 오래 걸릴 것으로 예상되면, 첫 실행 시 스플래시 화면에 "데이터베이스 업그레이드 중..." 메시지를 표시하여 사용자 경험을 개선하세요.
5. Hive 마이그레이션 전략 - NoSQL 스타일 데이터 버전 관리
시작하며
여러분이 관계형 데이터베이스가 아닌 Hive 같은 키-값 저장소를 사용하면서, 데이터 모델을 변경해야 하는 상황을 겪어본 적 있나요? Hive는 SQL 기반 마이그레이션을 지원하지 않기 때문에, 전통적인 ALTER TABLE 방식을 사용할 수 없습니다.
이런 문제는 NoSQL 데이터베이스 특유의 유연성과 스키마리스 특성 때문에 발생합니다. Hive에서 저장된 객체의 구조를 변경하려면 모든 기존 객체를 읽어서 새로운 형식으로 변환하고 다시 저장해야 하는데, 이 과정에서 버전 관리가 제대로 되지 않으면 앱의 서로 다른 버전에서 호환되지 않는 데이터 형식을 사용하게 됩니다.
바로 이럴 때 필요한 것이 Hive를 위한 NoSQL 스타일 마이그레이션 전략입니다. 데이터 모델에 버전 필드를 포함시키고, 런타임에 버전을 확인하여 필요 시 자동으로 변환하는 방법을 알아야 합니다.
개요
간단히 말해서, Hive 마이그레이션은 데이터 모델 자체에 버전 정보를 포함시키고, 데이터를 읽을 때 버전을 확인하여 필요 시 자동으로 최신 형식으로 변환하는 전략입니다. SQL 데이터베이스는 스키마 버전을 메타데이터로 관리하지만, Hive는 각 객체가 독립적이므로 객체마다 버전을 저장해야 합니다.
예를 들어, 사용자 설정 모델에 새 필드를 추가할 때, 기존 객체는 버전 1로 저장되어 있고 새 형식은 버전 2가 되는데, 앱은 두 버전을 모두 읽을 수 있어야 하며 필요 시 버전 1을 2로 변환합니다. 기존에는 앱 업데이트 후 모든 Hive 박스를 열고 일괄적으로 변환했다면, 이제는 lazy migration 방식으로 데이터를 실제로 사용할 때 변환하여 앱 시작 시간을 최적화할 수 있습니다.
Hive 마이그레이션의 핵심 특징은 객체별 버전 관리, TypeAdapter 활용, lazy migration입니다. 각 HiveObject에 version 필드를 추가하고, TypeAdapter의 read 메서드에서 버전을 확인하여 자동 변환하며, 데이터 접근 시점에 마이그레이션을 수행하여 성능을 최적화합니다.
이러한 특징들이 NoSQL의 유연성을 유지하면서도 안전한 데이터 진화를 가능하게 합니다.
코드 예제
import 'package:hive/hive.dart';
part 'user_settings.g.dart';
// 사용자 설정 모델 (버전 2)
@HiveType(typeId: 0)
class UserSettings extends HiveObject {
@HiveField(0)
int version;
@HiveField(1)
String username;
@HiveField(2)
bool darkMode;
@HiveField(3)
String? language; // 버전 2에서 추가
@HiveField(4)
int? fontSize; // 버전 2에서 추가
UserSettings({
this.version = 2,
required this.username,
this.darkMode = false,
this.language,
this.fontSize,
});
// 버전 1에서 2로 마이그레이션
static UserSettings migrateFromV1(Map<String, dynamic> v1Data) {
return UserSettings(
version: 2,
username: v1Data['username'] as String,
darkMode: v1Data['darkMode'] as bool? ?? false,
language: 'ko', // 기본값
fontSize: 14, // 기본값
);
}
}
// 커스텀 TypeAdapter (자동 생성된 어댑터를 수동으로 override)
class UserSettingsAdapter extends TypeAdapter<UserSettings> {
@override
final int typeId = 0;
@override
UserSettings read(BinaryReader reader) {
final numOfFields = reader.readByte();
final fields = <int, dynamic>{
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
};
// 버전 확인 (버전 필드가 없으면 버전 1로 간주)
final version = fields[0] as int? ?? 1;
if (version == 1) {
// 버전 1 데이터를 버전 2로 마이그레이션
final migrated = UserSettings.migrateFromV1({
'username': fields[1] as String,
'darkMode': fields[2] as bool?,
});
// 마이그레이션된 데이터를 즉시 저장
migrated.save();
return migrated;
}
// 버전 2 데이터 읽기
return UserSettings(
version: version,
username: fields[1] as String,
darkMode: fields[2] as bool,
language: fields[3] as String?,
fontSize: fields[4] as int?,
);
}
@override
void write(BinaryWriter writer, UserSettings obj) {
writer
..writeByte(5) // 필드 개수
..writeByte(0)
..write(obj.version)
..writeByte(1)
..write(obj.username)
..writeByte(2)
..write(obj.darkMode)
..writeByte(3)
..write(obj.language)
..writeByte(4)
..write(obj.fontSize);
}
}
// 사용 예제
Future<void> loadUserSettings() async {
final box = await Hive.openBox<UserSettings>('settings');
final settings = box.get('user');
// settings가 null이거나 구버전이어도 TypeAdapter가 자동으로 처리
print('User: ${settings?.username}, Dark mode: ${settings?.darkMode}');
print('Language: ${settings?.language}, Font size: ${settings?.fontSize}');
}
설명
이것이 하는 일: 이 코드는 Hive의 TypeAdapter 메커니즘을 활용하여 데이터를 읽는 시점에 버전을 확인하고, 구버전 데이터를 자동으로 최신 형식으로 변환합니다. 첫 번째로, UserSettings 클래스는 version 필드를 첫 번째 필드로 포함하여 데이터 형식의 버전을 명시적으로 관리합니다.
@HiveField 어노테이션의 인덱스는 변경되면 안 되므로, 새 필드를 추가할 때는 항상 다음 인덱스를 사용해야 합니다. language와 fontSize는 nullable로 선언되어 버전 1 데이터에서는 존재하지 않지만 오류가 발생하지 않습니다.
migrateFromV1 정적 메서드는 버전 1의 데이터 구조를 받아서 버전 2 객체를 생성하는데, 새로 추가된 필드에는 적절한 기본값을 제공합니다. 그 다음으로, UserSettingsAdapter의 read 메서드는 Hive가 바이너리 데이터를 역직렬화할 때 호출됩니다.
먼저 fields[0]에서 버전 번호를 읽는데, 버전 1 데이터에는 version 필드가 없으므로 null이 반환되어 1로 간주됩니다. 버전이 1이면 migrateFromV1을 호출하여 변환하고, 중요한 점은 migrated.save()를 즉시 호출하여 변환된 데이터를 Hive에 다시 저장한다는 것입니다.
이렇게 하면 다음번에 이 데이터를 읽을 때는 이미 버전 2로 저장되어 있어 마이그레이션이 반복되지 않습니다. 이것이 lazy migration의 핵심입니다.
마지막으로, write 메서드는 UserSettings 객체를 바이너리로 직렬화할 때 사용됩니다. writeByte(5)는 총 5개의 필드가 있다고 선언하고, 각 필드를 인덱스와 값의 쌍으로 기록합니다.
이 순서와 인덱스는 read 메서드와 정확히 일치해야 하며, 한번 정의되면 변경하면 안 됩니다. 실제 사용 시점에서 box.get('user')를 호출하면 자동으로 TypeAdapter의 read가 실행되어 마이그레이션이 투명하게 처리됩니다.
여러분이 이 코드를 사용하면 Hive 기반 앱에서도 안전하게 데이터 모델을 진화시킬 수 있고, 사용자가 앱을 업데이트해도 데이터가 자동으로 최신 형식으로 변환됩니다. 또한 lazy migration 방식 덕분에 앱 시작 시 모든 데이터를 변환하는 오버헤드 없이, 실제로 사용되는 데이터만 변환하여 성능을 최적화할 수 있습니다.
실전 팁
💡 Hive의 TypeAdapter는 한번 등록된 typeId를 절대 재사용하면 안 됩니다. 삭제된 모델이라도 해당 ID는 영구적으로 예약된 것으로 간주하세요.
💡 복잡한 마이그레이션 로직은 별도의 MigrationService 클래스로 분리하여 테스트 가능하게 만들고, 각 버전별 변환 로직을 독립적으로 테스트하세요.
💡 버전 번호가 여러 단계 뒤떨어진 경우(예: 버전 1에서 5로) 중간 버전을 건너뛰지 말고 순차적으로 마이그레이션을 체인으로 연결하세요.
💡 Hive의 compactbox()를 주기적으로 호출하여 삭제되거나 업데이트된 데이터로 인한 공간 낭비를 정리하고 파일 크기를 최적화하세요.
💡 프로덕션 환경에서는 마이그레이션 성공/실패를 Firebase Analytics나 Sentry 같은 모니터링 도구로 추적하여 예상치 못한 버전 조합을 발견하세요.
6. 롤백 불가능 마이그레이션 대응 전략 - 백업과 복구 메커니즘
시작하며
여러분이 마이그레이션을 배포한 후 심각한 버그를 발견하여 이전 버전의 앱으로 롤백하려는데, 데이터베이스는 이미 새 버전으로 업그레이드되어 구버전 앱과 호환되지 않는 상황을 겪어본 적 있나요? 마이그레이션은 일방향 프로세스이기 때문에 자동으로 되돌릴 수 없습니다.
이런 문제는 모바일 앱의 특성상 더욱 심각합니다. 웹 애플리케이션은 서버에서 롤백하면 모든 사용자에게 즉시 적용되지만, 모바일 앱은 사용자가 이미 업데이트를 설치했다면 강제로 다운그레이드할 방법이 없습니다.
새 버전에서 테이블을 추가했다면 구버전 앱은 그 테이블을 인식하지 못하고, 컬럼을 삭제했다면 구버전 앱이 크래시됩니다. 바로 이럴 때 필요한 것이 백업과 복구 메커니즘을 포함한 안전망입니다.
마이그레이션 전 데이터베이스를 백업하고, 문제 발생 시 복구할 수 있는 기능을 구현하며, 앞뒤 호환성을 고려한 설계가 필요합니다.
개요
간단히 말해서, 롤백 대응 전략은 마이그레이션 전후의 데이터베이스 상태를 백업하고, 문제 발생 시 사용자가 수동으로 복구하거나 앱이 자동으로 복원할 수 있는 메커니즘입니다. 완벽한 자동 롤백은 불가능하지만, 적절한 백업 전략으로 데이터 손실을 방지할 수 있습니다.
예를 들어, 중요한 마이그레이션 전에 데이터베이스 파일을 복사하고, 사용자가 문제를 보고하면 설정 화면에서 백업을 복원할 수 있는 기능을 제공하거나, 클라우드에 데이터를 동기화하여 최악의 경우 서버에서 복구하는 방식입니다. 기존에는 마이그레이션 실패 시 사용자가 앱을 삭제하고 재설치해야 했다면, 이제는 백업에서 복구하거나 다운그레이드 마이그레이션을 제공할 수 있습니다.
롤백 대응의 핵심 특징은 자동 백업 생성, 복구 UI 제공, 하위 호환성 유지입니다. 마이그레이션 시작 전 현재 데이터베이스를 타임스탬프와 함께 백업하고, 설정 화면에서 사용 가능한 백업 목록을 표시하여 복원할 수 있게 하며, 가능한 경우 onDowngrade 콜백을 구현하여 제한적인 자동 롤백을 지원합니다.
이러한 특징들이 사용자 신뢰를 유지하고 치명적인 데이터 손실을 방지합니다.
코드 예제
import 'dart:io';
import 'package:path/path.dart' as p;
class DatabaseBackupManager {
static const String backupDir = 'db_backups';
static const int maxBackups = 5;
// 마이그레이션 전 자동 백업 생성
static Future<String?> createBackup(String dbPath, int version) async {
try {
final dbFile = File(dbPath);
if (!await dbFile.exists()) return null;
// 백업 디렉토리 생성
final appDir = Directory(p.dirname(dbPath));
final backupDirectory = Directory(p.join(appDir.path, backupDir));
if (!await backupDirectory.exists()) {
await backupDirectory.create(recursive: true);
}
// 타임스탬프와 버전을 포함한 백업 파일명
final timestamp = DateTime.now().millisecondsSinceEpoch;
final backupPath = p.join(
backupDirectory.path,
'backup_v${version}_$timestamp.db',
);
// 데이터베이스 파일 복사
await dbFile.copy(backupPath);
// 오래된 백업 정리
await _cleanOldBackups(backupDirectory);
print('Backup created: $backupPath');
return backupPath;
} catch (e) {
print('Failed to create backup: $e');
return null;
}
}
// 백업 목록 조회
static Future<List<BackupInfo>> listBackups(String dbPath) async {
final appDir = Directory(p.dirname(dbPath));
final backupDirectory = Directory(p.join(appDir.path, backupDir));
if (!await backupDirectory.exists()) return [];
final files = await backupDirectory
.list()
.where((entity) => entity is File && entity.path.endsWith('.db'))
.cast<File>()
.toList();
final backups = <BackupInfo>[];
for (final file in files) {
final filename = p.basename(file.path);
final match = RegExp(r'backup_v(\d+)_(\d+)\.db').firstMatch(filename);
if (match != null) {
final version = int.parse(match.group(1)!);
final timestamp = int.parse(match.group(2)!);
final size = await file.length();
backups.add(BackupInfo(
path: file.path,
version: version,
timestamp: DateTime.fromMillisecondsSinceEpoch(timestamp),
sizeBytes: size,
));
}
}
backups.sort((a, b) => b.timestamp.compareTo(a.timestamp));
return backups;
}
// 백업 복원
static Future<bool> restoreBackup(String backupPath, String dbPath) async {
try {
final backupFile = File(backupPath);
final dbFile = File(dbPath);
if (!await backupFile.exists()) {
print('Backup file not found: $backupPath');
return false;
}
// 현재 데이터베이스 삭제 후 백업 복사
if (await dbFile.exists()) {
await dbFile.delete();
}
await backupFile.copy(dbPath);
print('Database restored from: $backupPath');
return true;
} catch (e) {
print('Failed to restore backup: $e');
return false;
}
}
// 오래된 백업 정리 (최대 개수 유지)
static Future<void> _cleanOldBackups(Directory backupDirectory) async {
final files = await backupDirectory
.list()
.where((entity) => entity is File && entity.path.endsWith('.db'))
.cast<File>()
.toList();
if (files.length <= maxBackups) return;
// 생성 시간 기준 정렬
files.sort((a, b) => b.lastModifiedSync().compareTo(a.lastModifiedSync()));
// 오래된 파일 삭제
for (int i = maxBackups; i < files.length; i++) {
await files[i].delete();
print('Deleted old backup: ${files[i].path}');
}
}
}
class BackupInfo {
final String path;
final int version;
final DateTime timestamp;
final int sizeBytes;
BackupInfo({
required this.path,
required this.version,
required this.timestamp,
required this.sizeBytes,
});
String get formattedSize {
final kb = sizeBytes / 1024;
return kb < 1024 ? '${kb.toStringAsFixed(1)} KB' : '${(kb / 1024).toStringAsFixed(1)} MB';
}
}
// 사용 예제: 마이그레이션 래퍼
Future<Database> openDatabaseWithBackup(String path, int version) async {
// 마이그레이션 전 백업 생성
await DatabaseBackupManager.createBackup(path, version - 1);
return await openDatabase(
path,
version: version,
onUpgrade: (db, oldVersion, newVersion) async {
try {
// 실제 마이그레이션 로직
await performMigration(db, oldVersion, newVersion);
} catch (e) {
print('Migration failed: $e');
// 실패 시 사용자에게 백업 복원 옵션 제공
rethrow;
}
},
);
}
설명
이것이 하는 일: 이 코드는 마이그레이션 전에 데이터베이스 파일을 자동으로 백업하고, 사용자가 필요 시 이전 상태로 복원할 수 있는 완전한 백업-복구 시스템을 제공합니다. 첫 번째로, createBackup 메서드는 현재 데이터베이스 파일을 별도의 백업 디렉토리로 복사합니다.
파일명에 버전 번호와 타임스탬프를 포함시켜 어느 시점의 어느 버전 백업인지 명확하게 식별할 수 있게 합니다. 예를 들어 backup_v3_1699876543210.db는 버전 3에서 생성된 백업이며, 타임스탬프로 정확한 생성 시각을 알 수 있습니다.
File.copy를 사용하는 단순한 방식이지만, SQLite 데이터베이스는 단일 파일로 구성되므로 파일 복사만으로 완전한 백업이 가능합니다. 백업 후 _cleanOldBackups를 호출하여 오래된 백업을 자동으로 삭제함으로써 저장 공간을 관리합니다.
그 다음으로, listBackups 메서드는 백업 디렉토리를 스캔하여 사용 가능한 모든 백업 파일을 찾고, 정규표현식으로 파일명을 파싱하여 버전과 타임스탬프를 추출합니다. BackupInfo 객체로 변환하여 UI에서 사용하기 쉬운 형태로 제공하며, 파일 크기까지 포함하여 사용자가 백업을 선택할 때 충분한 정보를 제공합니다.
타임스탬프 기준 내림차순 정렬로 최신 백업이 맨 위에 표시되어 사용성을 높입니다. 마지막으로, restoreBackup 메서드는 선택된 백업 파일을 현재 데이터베이스 위치로 복사하여 복원합니다.
기존 데이터베이스를 먼저 삭제한 후 백업을 복사하는 간단한 구조이지만, 트랜잭션처럼 원자성을 보장하지는 않습니다. 실제 프로덕션에서는 임시 위치에 복사한 후 원본을 삭제하고 이름을 변경하는 방식으로 실패 시 복구 가능성을 높여야 합니다.
openDatabaseWithBackup 함수는 이 모든 것을 통합하여, 마이그레이션 전 자동으로 백업을 생성하고, 실패 시 사용자에게 알립니다. 여러분이 이 코드를 사용하면 마이그레이션 실패나 버그로 인한 데이터 손실을 방지할 수 있고, 사용자에게 문제 해결 수단을 제공하여 신뢰를 유지할 수 있습니다.
또한 자동 백업 정리 기능으로 저장 공간 문제를 예방하고, 설정 화면에 백업 관리 UI를 추가하면 고급 사용자가 직접 제어할 수 있어 만족도가 높아집니다.
실전 팁
💡 중요한 마이그레이션 전에는 백업뿐만 아니라 클라우드 동기화를 권장하는 인앱 메시지를 표시하여 이중 안전망을 구축하세요.
💡 백업 파일을 암호화하여 저장하면 민감한 사용자 데이터가 디바이스에 평문으로 남지 않아 보안이 강화됩니다.
💡 개발 중에는 각 마이그레이션 버전의 샘플 데이터베이스를 저장해두고, 모든 버전 조합의 업그레이드 경로를 자동 테스트하세요.
💡 백업 복원 후 앱을 재시작하도록 유도하여 메모리에 캐시된 데이터와 디스크의 데이터베이스가 불일치하는 문제를 방지하세요.
💡 프로덕션에서 마이그레이션 실패율을 모니터링하고, 특정 버전에서 실패율이 높으면 핫픽스로 마이그레이션 로직을 수정하거나 롤백하세요.
7. 대용량 데이터 마이그레이션 최적화 - 배치 처리와 진행 상황 표시
시작하며
여러분의 앱에 수만 개의 레코드가 저장되어 있는 사용자가 업데이트를 설치했을 때, 마이그레이션이 30초 이상 걸려서 ANR(Application Not Responding) 오류가 발생하거나 앱이 응답 없음 상태로 보이는 경험을 한 적 있나요? 이런 문제는 마이그레이션 코드가 모든 레코드를 한 번에 메모리에 로드하거나, 각 레코드를 개별 트랜잭션으로 업데이트하여 발생합니다.
특히 모바일 기기는 제한된 메모리와 CPU 성능을 가지므로, 대용량 데이터 처리 시 성능 저하가 두드러집니다. 사용자는 앱이 멈춘 것처럼 보이면 강제 종료하거나, 앱 스토어에 부정적인 리뷰를 남기게 됩니다.
바로 이럴 때 필요한 것이 대용량 데이터를 효율적으로 처리하는 최적화 기법입니다. 배치 단위로 데이터를 나누어 처리하고, 진행 상황을 사용자에게 표시하며, 메모리 사용량을 최소화하는 방법을 알아야 합니다.
개요
간단히 말해서, 대용량 마이그레이션 최적화는 데이터를 작은 배치로 나누어 순차적으로 처리하고, 각 배치 처리 후 진행률을 업데이트하여 사용자 경험을 개선하는 기법입니다. 한 번에 모든 데이터를 처리하면 메모리 부족이나 타임아웃이 발생할 수 있지만, 1000개씩 배치로 나누면 안정적으로 처리할 수 있습니다.
예를 들어, 10만 개의 메시지 레코드에 새로운 해시 값을 계산하여 추가해야 한다면, 전체를 한 번에 처리하는 대신 1000개씩 100번 나누어 처리하고, 각 배치마다 진행률을 계산하여 UI에 표시하는 방식입니다. 기존에는 마이그레이션 중 앱이 완전히 멈춘 것처럼 보였다면, 이제는 프로그레스 바와 함께 "데이터 업그레이드 중...
45%" 같은 메시지를 표시할 수 있습니다. 대용량 마이그레이션의 핵심 특징은 배치 처리, 스트리밍 방식, 진행률 콜백입니다.
LIMIT과 OFFSET을 사용하여 데이터를 페이지 단위로 읽고, 각 레코드를 처리한 후 즉시 업데이트하여 메모리에 축적되지 않도록 하며, 처리된 레코드 수를 추적하여 콜백으로 UI에 알립니다. 이러한 특징들이 대용량 데이터를 안정적으로 처리하고 사용자 경험을 크게 개선합니다.
코드 예제
// 대용량 데이터 마이그레이션을 위한 배치 프로세서
class BatchMigrationProcessor {
static const int batchSize = 1000;
// 진행 상황 콜백 타입
typedef ProgressCallback = void Function(int processed, int total, double percentage);
// 배치 단위로 데이터를 마이그레이션
static Future<void> migrateLargeTable({
required Database db,
required String tableName,
required String Function(Map<String, dynamic>) transformer,
ProgressCallback? onProgress,
}) async {
// 전체 레코드 수 조회
final countResult = await db.rawQuery('SELECT COUNT(*) as count FROM $tableName');
final totalRecords = Sqflite.firstIntValue(countResult) ?? 0;
if (totalRecords == 0) {
onProgress?.call(0, 0, 100.0);
return;
}
print('Starting migration of $totalRecords records from $tableName');
int processedRecords = 0;
// 배치 단위로 처리
while (processedRecords < totalRecords) {
await db.transaction((txn) async {
// 현재 배치 조회 (LIMIT, OFFSET 사용)
final batch = await txn.rawQuery(
'SELECT * FROM $tableName LIMIT $batchSize OFFSET $processedRecords',
);
// 각 레코드 변환 및 업데이트
for (final record in batch) {
try {
// 사용자 정의 변환 로직 실행
final updateSql = transformer(record);
await txn.rawUpdate(updateSql);
} catch (e) {
print('Failed to migrate record ${record['id']}: $e');
// 개별 레코드 실패는 건너뛰고 계속 진행
}
}
processedRecords += batch.length;
// 진행률 계산 및 콜백 호출
final percentage = (processedRecords / totalRecords) * 100;
onProgress?.call(processedRecords, totalRecords, percentage);
print('Processed $processedRecords / $totalRecords (${percentage.toStringAsFixed(1)}%)');
});
// UI 업데이트를 위한 짧은 대기 (선택적)
await Future.delayed(const Duration(milliseconds: 10));
}
print('Migration completed: $processedRecords records');
}
}
// 사용 예제: 메시지 테이블에 해시 컬럼 추가 및 값 계산
Future<void> migrateMessagesAddHash(
Database db,
ProgressCallback onProgress,
) async {
// 1. 스키마 변경: 새 컬럼 추가
await db.execute('ALTER TABLE messages ADD COLUMN content_hash TEXT');
// 2. 대용량 데이터 마이그레이션
await BatchMigrationProcessor.migrateLargeTable(
db: db,
tableName: 'messages',
transformer: (record) {
// 메시지 내용의 해시 계산
final content = record['content'] as String;
final hash = content.hashCode.toString();
final id = record['id'];
// 업데이트 SQL 반환
return "UPDATE messages SET content_hash = '$hash' WHERE id = $id";
},
onProgress: onProgress,
);
}
// UI에서 사용
class MigrationScreen extends StatefulWidget {
@override
_MigrationScreenState createState() => _MigrationScreenState();
}
class _MigrationScreenState extends State<MigrationScreen> {
double _progress = 0.0;
String _statusText = '준비 중...';
@override
void initState() {
super.initState();
_startMigration();
}
Future<void> _startMigration() async {
final db = await openDatabase('my_app.db');
await migrateMessagesAddHash(
db,
(processed, total, percentage) {
setState(() {
_progress = percentage / 100;
_statusText = '데이터 업그레이드 중... $processed / $total';
});
},
);
setState(() {
_statusText = '완료!';
});
// 2초 후 메인 화면으로 이동
await Future.delayed(const Duration(seconds: 2));
Navigator.of(context).pushReplacementNamed('/home');
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircularProgressIndicator(value: _progress),
const SizedBox(height: 20),
Text(_statusText, style: Theme.of(context).textTheme.titleMedium),
],
),
),
);
}
}
설명
이것이 하는 일: 이 코드는 대용량 데이터를 메모리 효율적으로 처리하기 위해 배치 단위로 나누고, 각 배치마다 진행 상황을 콜백으로 전달하여 UI에서 프로그레스 바를 업데이트합니다. 첫 번째로, migrateLargeTable 함수는 먼저 COUNT 쿼리로 전체 레코드 수를 조회하여 진행률 계산의 기준을 설정합니다.
이 총 개수가 있어야 "1000개 중 300개 처리"같은 의미 있는 진행 정보를 제공할 수 있습니다. while 루프는 processedRecords가 totalRecords에 도달할 때까지 반복하며, 각 반복마다 LIMIT과 OFFSET을 사용한 쿼리로 다음 배치를 가져옵니다.
예를 들어 첫 번째 반복에서는 OFFSET 0으로 0999번 레코드를, 두 번째는 OFFSET 1000으로 10001999번 레코드를 가져옵니다. 그 다음으로, 각 배치는 별도의 트랜잭션 내에서 처리되므로, 한 배치 처리가 실패해도 이전 배치는 이미 커밋되어 있어 처음부터 다시 시작할 필요가 없습니다.
transformer 콜백은 각 레코드를 받아서 필요한 UPDATE SQL 문을 생성하는 사용자 정의 로직입니다. 이 설계는 마이그레이션 프로세서를 재사용 가능하게 만들어, 다양한 테이블과 변환 로직에 적용할 수 있습니다.
try-catch로 개별 레코드 처리 실패를 격리하여, 하나의 잘못된 데이터가 전체 마이그레이션을 중단시키지 않도록 합니다. 마지막으로, 각 배치 처리 후 onProgress 콜백을 호출하여 현재까지 처리된 레코드 수, 전체 레코드 수, 백분율을 전달합니다.
UI 레이어의 setState가 호출되어 화면이 업데이트되고, 사용자는 실시간으로 진행 상황을 볼 수 있습니다. Future.delayed를 사용한 짧은 지연은 UI 스레드가 화면을 다시 그릴 시간을 주어 부드러운 애니메이션을 보장합니다.
마이그레이션 완료 후 자동으로 홈 화면으로 이동하여 사용자가 추가 조치 없이 앱을 계속 사용할 수 있습니다. 여러분이 이 코드를 사용하면 수십만 개의 레코드도 안정적으로 마이그레이션할 수 있고, 사용자는 앱이 멈춘 게 아니라 작업 중임을 명확히 알 수 있어 불안감이 줄어듭니다.
또한 배치 처리 방식은 메모리 사용량을 일정하게 유지하여 저사양 기기에서도 문제없이 동작하며, 중간에 앱이 종료되더라도 다음 실행 시 이미 처리된 배치는 건너뛸 수 있도록 확장할 수 있습니다.
실전 팁
💡 배치 크기는 데이터 복잡도와 기기 성능에 따라 조정하세요. 단순 레코드는 5000개, 복잡한 변환은 500개가 적절할 수 있습니다.
💡 마이그레이션 중 네트워크 연결이나 외부 API 호출이 필요하면 실패 시 재시도 로직과 함께 exponential backoff를 구현하세요.
💡 매우 큰 데이터셋(100만 개 이상)은 백그라운드 서비스나 WorkManager를 사용하여 앱이 백그라운드에 있을 때 처리하도록 예약하세요.
💡 진행률 표시와 함께 예상 남은 시간을 계산하여 표시하면 사용자 경험이 더욱 향상됩니다. 처리 속도를 측정하여 "약 2분 남음" 같은 정보를 제공하세요.
💡 개발 단계에서 대용량 테스트 데이터를 생성하는 스크립트를 만들어, 실제 프로덕션 데이터 규모에서 마이그레이션 성능을 미리 검증하세요.
8. 다중 환경 마이그레이션 테스트 - 자동화된 검증 프레임워크
시작하며
여러분이 마이그레이션 코드를 작성한 후 로컬에서 테스트했지만, 프로덕션에서 특정 사용자 그룹에서만 발생하는 마이그레이션 오류를 발견한 경험이 있나요? 사용자가 버전 1에서 5로 바로 업데이트하는 경로나, 베타 버전을 거쳐 업데이트하는 경로는 테스트하지 못했을 수 있습니다.
이런 문제는 마이그레이션 경로의 조합이 기하급수적으로 증가하기 때문에 발생합니다. 버전이 5개만 있어도 가능한 업그레이드 경로는 1→2, 1→3, 1→4, 1→5, 2→3, 2→4, 2→5 등 10가지 이상이며, 각 경로마다 데이터 상태가 다를 수 있습니다.
수동으로 모든 시나리오를 테스트하는 것은 현실적으로 불가능합니다. 바로 이럴 때 필요한 것이 자동화된 마이그레이션 테스트 프레임워크입니다.
각 버전의 샘플 데이터베이스를 준비하고, 모든 가능한 업그레이드 경로를 자동으로 실행하며, 결과를 검증하여 문제를 조기에 발견해야 합니다.
개요
간단히 말해서, 다중 환경 마이그레이션 테스트는 다양한 시작 버전과 목표 버전 조합에 대해 자동으로 마이그레이션을 실행하고, 최종 스키마와 데이터 무결성을 검증하는 자동화 시스템입니다. 각 데이터베이스 버전의 스냅샷을 저장해두고, 테스트 시 이 스냅샷을 로드하여 마이그레이션을 수행한 후 결과를 검증합니다.
예를 들어, 버전 1, 2, 3의 샘플 데이터베이스를 test/fixtures 디렉토리에 저장하고, 통합 테스트에서 각 버전을 최신 버전으로 마이그레이션한 후 스키마 구조와 데이터 내용이 예상과 일치하는지 확인하는 방식입니다. 기존에는 개발자가 마이그레이션 코드를 작성한 후 간단히 수동 테스트만 했다면, 이제는 CI/CD 파이프라인에서 모든 마이그레이션 경로를 자동으로 검증할 수 있습니다.
마이그레이션 테스트의 핵심 특징은 픽스처 기반 테스트, 스키마 검증, 데이터 무결성 확인입니다. 각 버전의 실제 프로덕션 데이터 구조를 반영한 샘플 데이터베이스를 준비하고, 마이그레이션 후 PRAGMA table_info로 스키마가 예상과 일치하는지 확인하며, 특정 쿼리로 데이터가 올바르게 변환되었는지 검증합니다.
이러한 특징들이 마이그레이션의 신뢰성을 크게 높이고 배포 전에 문제를 발견하게 합니다.
코드 예제
// 마이그레이션 테스트 헬퍼
import 'package:flutter_test/flutter_test.dart';
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
import 'dart:io';
class MigrationTestHelper {
static const String fixturesPath = 'test/fixtures/databases';
// 특정 버전의 픽스처 데이터베이스를 로드
static Future<Database> loadFixture(int version) async {
final fixturePath = '$fixturesPath/v$version.db';
final file = File(fixturePath);
if (!await file.exists()) {
throw Exception('Fixture not found for version $version');
}
// 임시 위치로 복사 (원본 보존)
final tempPath = '${Directory.systemTemp.path}/test_db_v${version}_${DateTime.now().millisecondsSinceEpoch}.db';
await file.copy(tempPath);
return await databaseFactoryFfi.openDatabase(tempPath);
}
// 데이터베이스의 현재 스키마 정보를 추출
static Future<Map<String, List<ColumnInfo>>> getSchema(Database db) async {
final tables = await db.rawQuery(
"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'",
);
final schema = <String, List<ColumnInfo>>{};
for (final table in tables) {
final tableName = table['name'] as String;
final columns = await db.rawQuery('PRAGMA table_info($tableName)');
schema[tableName] = columns.map((col) => ColumnInfo(
name: col['name'] as String,
type: col['type'] as String,
notNull: (col['notnull'] as int) == 1,
defaultValue: col['dflt_value'],
primaryKey: (col['pk'] as int) > 0,
)).toList();
}
return schema;
}
// 스키마 비교
static void assertSchemaEquals(
Map<String, List<ColumnInfo>> expected,
Map<String, List<ColumnInfo>> actual,
) {
expect(actual.keys.toSet(), equals(expected.keys.toSet()),
reason: 'Table names do not match');
for (final tableName in expected.keys) {
final expectedCols = expected[tableName]!;
final actualCols = actual[tableName]!;
expect(actualCols.length, equals(expectedCols.length),
reason: 'Column count mismatch in table $tableName');
for (int i = 0; i < expectedCols.length; i++) {
expect(actualCols[i].name, equals(expectedCols[i].name),
reason: 'Column name mismatch in $tableName');
expect(actualCols[i].type, equals(expectedCols[i].type),
reason: 'Column type mismatch in $tableName.${expectedCols[i].name}');
}
}
}
}
class ColumnInfo {
final String name;
final String type;
final bool notNull;
final dynamic defaultValue;
final bool primaryKey;
ColumnInfo({
required this.name,
required this.type,
required this.notNull,
this.defaultValue,
required this.primaryKey,
});
}
// 실제 마이그레이션 테스트
void main() {
setUpAll(() {
sqfliteFfiInit();
databaseFactory = databaseFactoryFfi;
});
group('Database Migration Tests', () {
test('Migrate from v1 to latest preserves data', () async {
// 1. 버전 1 픽스처 로드
final db = await MigrationTestHelper.loadFixture(1);
// 2. 마이그레이션 전 데이터 확인
final usersBefore = await db.query('users');
expect(usersBefore.length, greaterThan(0));
final firstUser = usersBefore.first;
// 3. 최신 버전으로 마이그레이션 실행
await db.close();
final migratedDb = await openDatabase(
db.path,
version: DatabaseSchema.currentVersion,
onUpgrade: DatabaseSchema.migrate,
);
// 4. 마이그레이션 후 스키마 검증
final schema = await MigrationTestHelper.getSchema(migratedDb);
expect(schema['users']!.any((col) => col.name == 'phone'), isTrue,
reason: 'phone column should exist after migration');
// 5. 데이터 무결성 확인
final usersAfter = await migratedDb.query('users');
expect(usersAfter.length, equals(usersBefore.length),
reason: 'No data should be lost during migration');
final migratedUser = usersAfter.firstWhere((u) => u['id'] == firstUser['id']);
expect(migratedUser['name'], equals(firstUser['name']),
reason: 'Existing data should remain unchanged');
await migratedDb.close();
});
test('Migrate from v3 to latest adds new tables', () async {
final db = await MigrationTestHelper.loadFixture(3);
await db.close();
final migratedDb = await openDatabase(
db.path,
version: DatabaseSchema.currentVersion,
onUpgrade: DatabaseSchema.migrate,
);
final schema = await MigrationTestHelper.getSchema(migratedDb);
expect(schema.containsKey('comments'), isTrue,
reason: 'comments table should exist after v5 migration');
await migratedDb.close();
});
test('Fresh install creates correct schema', () async {
final tempPath = '${Directory.systemTemp.path}/fresh_db_${DateTime.now().millisecondsSinceEpoch}.db';
final db = await openDatabase(
tempPath,
version: DatabaseSchema.currentVersion,
onCreate: (db, version) async {
await db.execute('''
CREATE TABLE users (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
email TEXT NOT NULL,
phone TEXT,
created_at INTEGER NOT NULL
)
''');
// ... 다른 테이블들
},
);
final schema = await MigrationTestHelper.getSchema(db);
expect(schema['users']!.length, equals(5),
reason: 'Fresh install should have all columns');
await db.close();
await File(tempPath).delete();
});
});
}
설명
이것이 하는 일: 이 코드는 각 데이터베이스 버전의 샘플 파일을 로드하여 마이그레이션을 수행하고, 결과 스키마와 데이터가 예상과 일치하는지 자동으로 검증하는 테스트 프레임워크를 제공합니다. 첫 번째로, loadFixture 메서드는 test/fixtures/databases 디렉토리에 저장된 특정 버전의 데이터베이스 파일을 임시 위치로 복사합니다.
원본을 보존하기 위해 복사본을 사용하는 것이 중요한데, 테스트가 데이터베이스를 수정하므로 다음 테스트 실행을 위해 원본이 필요하기 때문입니다. 타임스탬프를 파일명에 포함시켜 병렬 테스트 실행 시 충돌을 방지합니다.
이 픽스처 파일들은 실제 프로덕션 환경에서 추출한 데이터베이스를 익명화하거나, 각 버전의 onCreate로 생성한 후 샘플 데이터를 삽입하여 만듭니다. 그 다음으로, getSchema 메서드는 PRAGMA table_info를 사용하여 모든 테이블의 컬럼 정보를 추출합니다.
이 정보에는 컬럼명, 데이터 타입, NOT NULL 제약조건, 기본값, 프라이머리 키 여부가 포함되어 스키마를 완전하게 표현합니다. assertSchemaEquals는 예상 스키마와 실제 스키마를 비교하여 차이가 있으면 상세한 오류 메시지를 제공합니다.
이 검증 방식은 마이그레이션이 스키마를 올바르게 변경했는지 확인하는 가장 확실한 방법입니다. 마지막으로, 실제 테스트 케이스들은 다양한 시나리오를 커버합니다.
첫 번째 테스트는 버전 1에서 최신 버전으로 마이그레이션하여 모든 중간 버전의 변경사항이 순차적으로 적용되는지 확인합니다. 마이그레이션 전 데이터를 저장해두고, 마이그레이션 후 동일한 데이터가 존재하는지 검증하여 데이터 손실이 없음을 보장합니다.
두 번째 테스트는 중간 버전에서 시작하는 경로를 검증하고, 세 번째는 신규 설치 시 onCreate가 올바른 스키마를 생성하는지 확인합니다. 이 모든 테스트가 CI에서 자동으로 실행되어 풀 리퀘스트마다 검증됩니다.
여러분이 이 코드를 사용하면 프로덕션 배포 전에 모든 마이그레이션 경로의 정확성을 보장할 수 있고, 리팩토링이나 새 마이그레이션 추가 시 기존 경로가 깨지지 않았는지 즉시 확인할 수 있습니다. 또한 팀원들이 마이그레이션을 수정할 때 의도치 않은 사이드 이펙트를 조기에 발견하여 버그 수정 비용을 크게 줄일 수 있습니다.
실전 팁
💡 픽스처 데이터베이스를 생성할 때는 edge case를 포함하세요. 빈 테이블, 매우 긴 문자열, NULL 값, 특수 문자 등 다양한 데이터 패턴을 테스트해야 합니다.
💡 Drift를 사용한다면 validateDatabaseSchema 함수를 활용하여 코드에 정의된 스키마와 실제 데이터베이스가 일치하는지 자동으로 검증하세요.
💡 성능 테스트도 포함하여 대용량 픽스처(10만 개 레코드)로 마이그레이션 시간을 측정하고, 허용 가능한 시간을 초과하면 테스트가 실패하도록 설정하세요.
💡 실제 사용자 기기에서 발생한 마이그레이션 오류를 재현하기 위해 크래시 리포트에서 데이터베이스 버전과 오류 로그를 수집하고, 해당 시나리오를 테스트 케이스로 추가하세요.
💡 각 픽스처 파일에 README를 포함하여 어떤 상태를 나타내는지, 어떤 특이사항이 있는지 문서화하면 팀원들이 이해하기 쉽습니다.