Today's

길을 나서지 않으면 그 길에서 만날 수 있는 사람을 만날 수 없다

모바일 앱(안드로이드)

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

Billcorea 2025. 7. 15. 15:50
반응형

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을 적극 활용하자
반응형