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을 적극 활용하자
'모바일 앱(안드로이드)' 카테고리의 다른 글
| Google Nearby Connections API 완전 정복 가이드 (feat Claude.ai) (6) | 2025.07.23 |
|---|---|
| Android에서 Kakao 로컬 API로 주소/좌표 변환하기 : 앱에 적용해 보기. (3) | 2025.07.21 |
| 배드민터 리그 매니저 (가칭) 화면 구성 초안 (2) | 2025.07.09 |
| 배드민턴 리그 매니저(가칭) 앱 만들기 : 기능 설계 하기 (2) | 2025.07.07 |
| 배드민턴 리그 매니저(가칭) 앱 개발 계획서 (2) | 2025.07.05 |