2026/04/27

오늘의 이야기

Android에서 Hilt + Room + Firebase Realtime Database를 함께 사용하는 구조 설계


앱의 사용자 정보 저장 화면 예씨



 


이 글은 Android 앱에서 Hilt를 사용한 의존성 주입, Room으로 로컬 DB를 구성하고, Firebase Realtime Database로 클라우드와 데이터를 연동하는 구조를 설계하는 방법을 다룹니다. 예시 코드마다 구체적인 설명과 함께, 주의사항과 실무 팁도 포함되어 있습니다.




🧱 프로젝트 구조 개요


📁 app/
├── di/ // Hilt 모듈 정의
├── data/
│ ├── local/ // Room 관련 코드
│ ├── remote/ // Firebase 관련 코드
│ ├── repository/ // Repository 패턴 구현
│ └── mapper/ // Entity ↔ Domain 변환
├── domain/ // 앱 전반에서 쓰이는 공통 데이터 모델
├── ui/ // Compose UI 화면
├── viewmodel/ // ViewModel 정의
└── MainActivity.kt

💡 팁: 레이어를 분리함으로써 유지보수가 쉬워지고, 테스트도 용이해집니다. 특히 Firebase와 Room을 함께 쓸 때는 'source of truth'를 명확히 구분해야 합니다.




📦 1. 도메인 모델 Member


data class Member(
val name: String = "",
val tokenId: String = "",
val role: String = "member",
val status: String = "",
val nextMatchIn: Int = -1,
val opponent: String = "",
val lat: Double = 0.0,
val lon: Double = 0.0
)

설명: 이 모델은 Room이나 Firebase에 의존하지 않는, 순수한 앱 로직 전용 데이터 모델입니다. ViewModel이나 Repository, Firebase 직렬화에 모두 사용될 수 있습니다.


주의: Firebase Realtime Database는 직렬화 시 기본 생성자와 모든 속성의 기본값을 요구하므로, = "", = -1 등을 반드시 지정해 주어야 합니다.




🏠 2. Room Entity MemberEntity


@Entity(tableName = "members")
data class MemberEntity(
@PrimaryKey(autoGenerate = true) val id: Int = 0,
val name: String,
val tokenId: String,
val role: String,
val status: String,
val nextMatchIn: Int,
val opponent: String,
val lat: Double,
val lon: Double
)

설명: Room은 반드시 @Entity@PrimaryKey가 필요합니다. 여기서는 id를 자동 생성 키로 사용하고, tokenId는 일반 필드로 처리합니다.


주의: Firebase의 tokenId는 앱 재설치 등으로 변경될 수 있기 때문에, 고유 식별자로 사용하지 말고 별도로 auto-generated ID를 쓰는 게 안전합니다.




🔁 3. Mapper 함수


fun Member.toEntity(): MemberEntity = MemberEntity(
name, tokenId, role, status, nextMatchIn, opponent, lat, lon
)

fun MemberEntity.toDomain(): Member = Member(
name, tokenId, role, status, nextMatchIn, opponent, lat, lon
)

설명: Room Entity와 앱 전용 모델 간에 변환을 책임지는 함수입니다. 이 함수를 통해 구조가 다르거나 어노테이션 충돌 없이 안전하게 변환할 수 있습니다.


팁: 이 함수를 mapper 패키지에 따로 두면 여러 레이어에서 재사용 가능합니다.




🧾 4. DAO 정의


@Dao
interface MemberDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(member: MemberEntity)

@Query("SELECT * FROM members")
fun getAll(): Flow<List>

@Delete
suspend fun delete(member: MemberEntity)
}

설명: Room에서 SQL 없이 데이터를 조작할 수 있는 핵심 인터페이스입니다. Flow를 리턴하여 Compose와 함께 reactive하게 사용할 수 있습니다.


주의: insert와 delete는 suspend로 지정해야 코루틴 안에서 호출 가능합니다. 비동기 안전성을 확보하세요.




📚 5. Room Database


@Database(entities = [MemberEntity::class], version = 1)
abstract class AppDatabase : RoomDatabase() {
abstract fun memberDao(): MemberDao
}

설명: Room에서 사용하는 DB 클래스입니다. DAO를 연결하고 전체 데이터베이스를 관리합니다.


주의: 데이터베이스 이름, 버전 변경 시에는 마이그레이션을 고려해야 합니다.




☁️ 6. FirebaseDataSource


class FirebaseDataSource @Inject constructor() {
private val db = FirebaseDatabase.getInstance().getReference("members")

fun saveMember(member: Member) {
db.child(member.tokenId).setValue(member)
}

fun getAllMembers(onComplete: (List<Member>) -> Unit) {
db.get().addOnSuccessListener {
val members = it.children.mapNotNull { snap ->
snap.getValue(Member::class.java)
}
onComplete(members)
}
}
}

설명: Firebase 연동을 담당하는 클래스입니다. 네트워크 I/O만 책임지며, UI나 DB 레이어와는 분리되어야 합니다.


주의: 콜백 기반이므로 suspend 함수로 감싸거나 Coroutine 내부에서 다루는 것이 안전합니다.




📦 7. Repository


class MemberRepository @Inject constructor(
private val dao: MemberDao,
private val firebase: FirebaseDataSource
) {
fun getLocalMembers(): Flow<List<Member>> =
dao.getAll().map { it.map { e -> e.toDomain() } }

suspend fun insert(member: Member) {
dao.insert(member.toEntity())
firebase.saveMember(member)
}

suspend fun syncFromFirebase() {
firebase.getAllMembers { members ->
CoroutineScope(Dispatchers.IO).launch {
members.forEach { dao.insert(it.toEntity()) }
}
}
}
}

설명: 로컬(RDB)과 원격(Firebase) 데이터를 모두 처리하는 Repository입니다. ViewModel이나 UI는 이 계층만 접근하도록 구성합니다.


주의: CoroutineScope를 직접 사용할 경우 lifecycle을 주의해야 하며, ViewModelScope 안에서 호출하는 것이 안전합니다.




🧠 8. ViewModel


@HiltViewModel
class MemberViewModel @Inject constructor(
private val repository: MemberRepository
) : ViewModel() {

val members = repository.getLocalMembers()
.stateIn(viewModelScope, SharingStarted.Lazily, emptyList())

fun insert(member: Member) {
viewModelScope.launch {
repository.insert(member)
}
}

fun sync() {
viewModelScope.launch {
repository.syncFromFirebase()
}
}
}

설명: UI 로직을 담당하는 ViewModel입니다. UI와 Repository 사이에서 데이터를 연결하며, LifecycleScope를 활용해 안전하게 비동기 작업을 처리합니다.




🧩 9. Hilt 모듈


@Module
@InstallIn(SingletonComponent::class)
object AppModule {

@Provides
fun provideDatabase(@ApplicationContext context: Context): AppDatabase =
Room.databaseBuilder(context, AppDatabase::class.java, "app_db").build()

@Provides
fun provideMemberDao(db: AppDatabase): MemberDao = db.memberDao()
}

설명: Hilt로 Room과 DAO를 의존성 주입하기 위한 설정입니다. Hilt가 컴파일 타임에 의존성 그래프를 구성합니다.


주의: @Provides는 SingletonComponent 범위에서 관리하므로, 앱 전체에서 인스턴스를 공유합니다.




🖼️ 10. UI 예제 (Jetpack Compose)


@Composable
fun MemberScreen(viewModel: MemberViewModel = hiltViewModel()) {
val members by viewModel.members.collectAsState()

Column {
members.forEach {
Text("🙋‍♂️ ${it.name} (${it.role})")
}

Button(onClick = {
val member = Member(name = "홍길동", tokenId = UUID.randomUUID().toString())
viewModel.insert(member)
}) {
Text("멤버 추가")
}
}
}

설명: Compose 화면에서 ViewModel의 데이터를 관찰하고 버튼으로 멤버를 추가하는 예제입니다.


팁: Compose에서는 collectAsState()를 활용해 Flow나 StateFlow를 쉽게 관찰할 수 있습니다.




✅ 마무리


Hilt, Room, Firebase를 조합하면 강력한 구조로 안정적인 앱을 만들 수 있습니다. 다만 각 기술의 동작 원리와 데이터 흐름을 분리하는 설계가 매우 중요합니다.



  • 💡 Entity ↔ Domain ↔ DTO 구조를 명확히 나누자

  • ☁️ Firebase는 항상 변할 수 있다는 전제하에 다루자

  • 🧠 ViewModel과 Repository에서 Flow와 Coroutine을 적극 활용하자





댓글 없음:

댓글 쓰기

오늘의 이야기

  🎾 Kotlin으로 복식 경기 Round-Robin 매칭 구성하기 라운드 로빈 구현해 보기   이 글은 Kotlin과 Jetpack Compose를 사용하는 Android 앱에서 복식 경기 매...