Notice
Recent Posts
Recent Comments
반응형
관리 메뉴

HM소프트

Jetpack Compose + Hilt + Room 안드로이드 아키텍처 (1) 본문

외주 개발일지

Jetpack Compose + Hilt + Room 안드로이드 아키텍처 (1)

HM소프트 2026. 6. 12. 01:22
반응형

오프라인 우선 모바일 축구 커리어 게임을 Jetpack Compose·Hilt·Room·MVVM으로 올린 아키텍처 기록. 단방향 의존, 읽기 전용 StateFlow UiState, 변하는 값은 Flow 한 번 값은 suspend 구분, Hilt 모듈 분리와 Room 단일 인스턴스, SQL UPDATE에 불변식을 새긴 동시성 처리, Navigation Compose 경로 관리를 다룬다.

Jetpack Compose + Hilt + Room으로 짠 안드로이드 아키텍처를 정리한다.

 

시리즈를 시작하며

선수를 만들어 훈련하고, 경기를 시뮬레이션하고, 시즌과 이적을 거쳐 은퇴하면 다음 세대로 능력이 이어지는 — 모바일 축구 커리어 게임을 안드로이드로 만들었다. 서버 시뮬레이션이 아니라, 게임 로직과 데이터가 대부분 단말 안에서 도는 오프라인 우선 게임이다.

네 편으로 나눠 기록한다.

  1. (이 글) 아키텍처 — Compose + Hilt + Room + MVVM
  2. 게임 시스템 설계 — 사기·부상·은퇴/레거시와 원자적 SQL
  3. Room 마이그레이션과 TDD(단위 테스트 98개)
  4. 수익화 — AdMob 보상형 광고와 게임 재화

기술 스택

  • 언어: Kotlin
  • UI: Jetpack Compose (선언형 UI)
  • DI: Hilt
  • 로컬 DB: Room
  • 설정 저장: DataStore
  • 아키텍처: MVVM + Repository
  • 빌드: Gradle 8.3 / Java 17

커리어 게임은 "상태가 많고, 그 상태가 화면 곳곳에서 바뀌는" 종류라, 상태 관리와 화면을 깔끔히 분리하는 게 관건이었다.

커리어 루프와 화면 구성

게임의 큰 흐름은 하나의 루프다.

캐릭터 생성 → 훈련/스킬 → 경기 시뮬레이션 → SP(성장 포인트) → 시즌·이적 → 은퇴·레거시 → (다음 세대로)

각 단계가 화면이 된다. 홈, 훈련, 경기 준비, 부상 센터, 이적, 국가대표 소집, 은퇴, 설정 등. 화면이 많지만 구조는 전부 동일하다: 화면(Composable) ↔ ViewModel ↔ Repository ↔ Room.

MVVM — 화면은 상태를 그리기만

각 화면은 ViewModel이 노출하는 UiState 하나를 구독해 그린다. 화면은 자기 안에 상태를 두지 않는다.

data class MatchPrepUiState(
    val morale: Int = 0,
    val isLoading: Boolean = true,
    val canStart: Boolean = false,
)

@HiltViewModel
class MatchPrepViewModel @Inject constructor(
    private val playerRepo: PlayerRepository,
) : ViewModel() {
    private val _ui = MutableStateFlow(MatchPrepUiState())
    val ui: StateFlow<MatchPrepUiState> = _ui.asStateFlow()

    init { load() }   // ← 여기서 한 가지 함정을 만났다 (아래)
}
@Composable
fun MatchPrepScreen(vm: MatchPrepViewModel = hiltViewModel()) {
    val ui by vm.ui.collectAsStateWithLifecycle()
    if (ui.isLoading) LoadingView()
    else MatchPrepContent(morale = ui.morale, canStart = ui.canStart)
}

초기화 레이스 컨디션

init { load() }에서 ViewModel 생성과 동시에 데이터를 불러오다가 레이스 컨디션을 만났다. 화면이 아직 구독을 시작하기 전에 상태가 바뀌거나, 코루틴 시작 순서가 꼬여 초기 상태가 잠깐 어긋났다. ViewModel의 init 흐름과 코루틴 시작 시점을 정리해 해결했다. ViewModel의 init은 편하지만, "언제 실행되는가"를 의식하지 않으면 이런 미묘한 버그가 생긴다.

Compose 크래시: Stack.pop과 return@Column

Compose로 짜며 만난 크래시 하나. 커리어/이적 화면에서 return@Column으로 컴포지션 도중에 빠져나가는 패턴이 내부 스택 처리와 충돌해 크래시가 났다. Compose는 선언형이라, 명령형 사고로 "중간에 return"하면 컴포지션 트리가 꼬인다. return@Column을 걷어내고 조건부 렌더링으로 바꿔 해결했다.

// 문제: 컴포지션 도중 탈출
Column {
    if (player == null) return@Column   // ← 크래시 유발
    PlayerCard(player)
}

// 해결: 조건부 렌더링
Column {
    if (player != null) PlayerCard(player)
    else EmptyState()
}

DI로 묶기 — Hilt

화면이 많고 ViewModel·Repository·DAO·DataStore가 얽혀서, 의존성 주입 없이는 금방 지저분해진다. Hilt로 Repository와 DataStore를 주입했다. 덕분에 화면은 "필요한 ViewModel을 받아 쓰기만" 하고, 테스트에서는 가짜 Repository를 주입할 수 있었다(3편의 테스트와 연결된다).

정리하며

1편의 뼈대는 단순하다.

  • MVVM + Repository로 화면/상태/데이터를 분리 — 화면은 UiState를 그리기만.
  • Hilt로 의존성을 묶어 화면을 가볍게, 테스트를 쉽게.
  • Compose 함정(init 레이스, return@Column 크래시)은 "선언형으로 사고하기"로 풀린다.

다음 2편에서는 이 게임의 재미를 만드는 게임 시스템(사기·부상·은퇴/레거시)과, 데이터 정합성을 지킨 원자적 SQL을 다룬다.

반응형