| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | |||
| 5 | 6 | 7 | 8 | 9 | 10 | 11 |
| 12 | 13 | 14 | 15 | 16 | 17 | 18 |
| 19 | 20 | 21 | 22 | 23 | 24 | 25 |
| 26 | 27 | 28 | 29 | 30 | 31 |
- javascript
- Vite
- 배포
- python
- Android
- 온디바이스AI
- next.js
- 프론트엔드
- FastAPI
- REST API
- 메일자동화
- 아키텍처
- 보안
- contextBridge
- electron
- 인증
- 백엔드
- JWT
- 마이그레이션
- sqlite
- Kotlin
- 크롤링
- 상태관리
- typescript
- php
- playwright
- 도메인설계
- 소셜로그인
- github actions
- React
- Today
- Total
HM소프트
Room 마이그레이션과 단위 테스트 98개 — 안드로이드 (3) 본문
Room 스키마 마이그레이션과 단위 테스트 98개로 모바일 축구 커리어 게임을 안전하게 키운 기록. 순방향 누적 마이그레이션, 인덱스 이름·기본값까지 맞춰야 하는 스키마 검증, 순수 함수 TDD, 부동소수점 delta 비교, DataStore 주입과 가짜 DAO·테스트 디스패처로 DB 없이 ViewModel을 검증한 방법을 다룬다.
Room DB 마이그레이션과 단위 테스트 98개를 작성한 안드로이드 개발기다.

3편: 마이그레이션과 테스트
2편에서 게임 시스템과 원자적 SQL을 다뤘다. 이번 편은 그 로직들을 안전하게 떠받친 두 가지 — Room 스키마 마이그레이션과 단위 테스트(TDD)다. 게임처럼 상태가 많은 앱은 "DB 스키마가 바뀔 때"와 "로직이 맞는지"를 못 잡으면 금방 무너진다.
Room 마이그레이션 — 스키마와 코드의 불일치
게임을 만들며 테이블 구조가 계속 바뀌었다. 사기 컬럼 추가, 인덱스 변경 등. Room은 스키마 버전이 올라갈 때 마이그레이션을 명시해야 하는데, 여기서 스키마와 마이그레이션 코드가 어긋나는 전형적인 버그를 만났다.
val MIGRATION_3_4 = object : Migration(3, 4) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE player ADD COLUMN morale INTEGER NOT NULL DEFAULT 50")
db.execSQL("CREATE INDEX index_player_team_id ON player(team_id)")
}
}
문제는 마이그레이션이 만든 결과가 엔티티가 기대하는 스키마와 정확히 일치해야 한다는 점이다. 두 군데서 어긋났다.
- 인덱스 이름: 엔티티의
@Index가 자동 생성하는 인덱스 이름과, 마이그레이션에서 직접 만든 인덱스 이름이 달랐다. Room은 시작 시 스키마를 검증하는데, 이름이 다르면 "스키마 불일치"로 크래시한다. - DEFAULT 값:
morale컬럼의 DEFAULT가 엔티티 정의와 마이그레이션 SQL에서 달랐다.
Room의 스키마 검증은 엄격하다. 컬럼 타입·NULL 여부·DEFAULT·인덱스 이름까지 토씨 하나 다르면 막는다. 이건 사실 고마운 동작이다 — 마이그레이션이 틀렸다는 걸 출시 전에 크래시로 알려주니까. 인덱스 이름과 DEFAULT를 엔티티 정의와 정확히 맞춰 해결했다.
마이그레이션 버전 이력은 DatabaseModule에 주석으로 남겨, 어느 버전에서 무엇이 바뀌었는지 추적할 수 있게 했다.
단위 테스트 98개 — TDD
게임 로직(레거시 공식, 사기 효과, 회복 계산)은 순수 함수로 분리해 TDD로 만들었다. 예를 들어 레거시 보너스 공식은 테스트를 먼저 쓰고 구현했다.
class LegacyCalculatorTest {
@Test fun `보너스는 능력 총합에 비례한다`() {
val bonus = LegacyCalculator.statBonus(Stats(total = 600))
assertEquals(60, bonus) // LEGACY_RATE = 0.1 가정
}
@Test fun `보너스는 상한을 넘지 않는다`() {
val bonus = LegacyCalculator.statBonus(Stats(total = 9999))
assertEquals(MAX_LEGACY_BONUS, bonus) // 상한 가드
}
}
ViewModel과 Repository도 테스트했다. 여기서 1편의 Hilt 주입이 빛난다 — 테스트에서는 진짜 Repository 대신 가짜(fake)를 주입해, DB 없이 로직만 검증할 수 있었다.
테스트를 쌓다 보니 정밀도 문제도 드러났다. 부동소수 계산이 들어간 곳에서 assertEquals가 미세한 오차로 실패했다. 정수 기반으로 바꾸거나 허용 오차(delta)를 둬 안정화했다.
DataStore 주입 리팩터
설정(예: 진동 토글)은 DataStore에 저장한다. 처음엔 DataStore를 직접 참조했는데, 테스트하기 어려웠다. DataStore를 주입 가능하게 리팩터해, 테스트에서 가짜를 끼울 수 있게 했다. "테스트하기 어렵다"는 신호는 대개 "의존성이 하드코딩돼 있다"는 뜻이라, 주입으로 푸는 게 정답이었다.
@HiltViewModel
class SettingsViewModel @Inject constructor(
private val prefs: PreferencesStore, // 인터페이스로 주입 → 테스트 시 fake
) : ViewModel() { ... }
정리하며
- Room 스키마 검증은 엄격하다 — 인덱스 이름·DEFAULT까지 맞춰야 하고, 그 엄격함이 출시 전에 버그를 잡아준다.
- 순수 로직은 TDD로 — 게임 공식(레거시·사기·회복)은 테스트 먼저.
- 테스트하기 어려우면 주입으로 — DataStore를 주입 가능하게 리팩터해 테스트 가능성을 확보.
단위 테스트 98개가 쌓이니, 게임 밸런스를 만지거나 마이그레이션을 추가할 때 "뭔가 깨졌는지"를 즉시 알 수 있어 개발 속도가 오히려 빨라졌다. 다음 마지막 4편에서는 수익화(AdMob 보상형 광고)를 다룬다.
'외주 개발일지' 카테고리의 다른 글
| 웹 국제화(i18n)·SEO·접근성·성능 최적화 — 연구기관 사이트 (2) (0) | 2026.06.13 |
|---|---|
| pg_trgm 통합 검색과 콘텐츠 모델 설계 — PostgreSQL (1) (0) | 2026.06.13 |
| 게임 시스템 설계와 원자적 SQL — 안드로이드 게임 개발 (2) (0) | 2026.06.12 |
| Jetpack Compose + Hilt + Room 안드로이드 아키텍처 (1) (0) | 2026.06.12 |
| Docker 배포 운영과 다국어 전환 구현 — 과제 채점 플랫폼 (3) (0) | 2026.06.12 |