클린아키텍처 학습 #1 - FirebaseService 구현

김민태·2025년 3월 22일
post-thumbnail

1. 개요

프로젝트 요소

  • MVVM
  • Multi Module (아키텍처 Layer 기반 분리)
  • Precompiled Scripts (buildSrc)

선행 gradle 작업

실질적으로 remote 모듈에서 SDK를 호출하지만, FirebaseApp 초기화 작업이 필요하기 때문에 다음과 같은 설정을 사전에 진행했습니다:

  • 프로젝트 수준의 build.gradle 에서 com.google.gms:google-services 플러그인 추가
  • app 수준의 build.gradle 에서 com.google.gms.google-services 플러그인 추가
  • app 모듈의 루트 경로google-services.json (Firebase Console 제공) 추가

2. 설명

왜 remote 모듈인가?

Firebase는 외부 데이터 소스이므로 API나 SDK와 같은 원격 데이터 소스를 관리하는 remote 모듈에서 작업을 시작하게 되었습니다. 추후 테스트 시 Mock 구현체를 만들기 용이하다는 점을 고려했을 때도 적절한 위치라고 판단했습니다.

굳이 ApiService를?

Firebase는 SDK 내부에서 자체적으로 API 호출을 처리하기 때문에 기존의 ApiService개념은 생략할 수도 있습니다. 그럼에도 비슷한 개념을 구현하려 하는건, 원격 데이터 소스(Remote Data Source)를 명확히 분리하고 설계함으로써 추후 실제 서버 API로 교체하는 등의 유지보수 또는 확장 작업에 유리한 구조을 설계하기 위해서 입니다.


3. 작성 및 설계

구현 전에 들었던 생각

어찌 보면 FirebaseAuth는 이미 메서드까지 완성된 구현체입니다. @Binds 작업이나 @Module 작업 없이 DataSource에 구현체를 바로 주입하면 단기적인 관점에서는 작업 소요를 줄일 수는 있습니다.

// 상위 책임자(Repository)에 값을 제공할 원격 데이터 소스 구현체
class AuthRemoteDataSourceImpl @Inject constructor(
    private val firebaseAuth: FirebaseAuth // SDK 구현체를 주입
) : AuthRemoteDataSource {

    // 인스턴스 생성 구현(getInstance()) 또는 외부에서 @Provides 작업

    override suspend fun register(email: String, password: String):
    firebaseAuth...
    // 생성된 인스턴스를 통해 바로 SDK 요청 로직 구현

하지만 조금 더 넓은 관점에서 본다면 다음과 같은 문제가 발생할 수 있습니다:

  • DIP: 추상체가 아닌 구현체(FirebaseAuth)에 의존하게 됨
  • OCP: 미래에 서버 API로 전환하려면 AuthRemoteDataSourceImpl을 직접 수정해야 함 → 확장에는 닫혀 있고 변경이 필요해짐

이러한 원칙을 위배하면 장기적으로 오히려 작업 소요가 늘어날 수 있습니다.

FirebaseService + Impl

ApiServiceFirebaseService는 모두 추상체이지만 다음과 같은 차이점이 있습니다:

구분ApiService (일반적인 REST API)FirebaseService (Firebase SDK)
역할엔드포인트를 추상화한 서비스Firebase SDK 요청을 추상화한 서비스
호출 방식@GET, @POST 등 어노테이션 기반 호출구현체에서 SDK 메서드를 직접 호출

두 방식 모두 요청을 추상화하지만, FirebaseService는 SDK를 직접 호출해야 하므로 별도의 구현체가 필요합니다.

// 추상체
interface FirebaseService {
    suspend fun login(email: String, password: String): String
}

// 구현체
class FirebaseServiceImpl @Inject constructor(
    private val firebaseAuth: FirebaseAuth
) : FirebaseService {
    override suspend fun register(email: String, password: String): String {
        return runCatching {
            firebaseAuth.createUserWithEmailAndPassword(email, password).await().user!!.uid
        }.getOrElse { throw Exception("회원가입 실패: ${it.message}") }
    }
    
    override fun another(){...}
}

구현체인 FirebaseServiceImpl에는 FirebaseAuth가 주입되어 있으며, 실질적인 로직이 작성되어 있습니다. 이 구조를 사용하려면 인스턴스 등록과 모듈 구성 작업이 필요합니다.

FirebaseModule

FirebaseAuth SDK를 사용하려면 인스턴스 초기화 작업이 필수이며, 외부 객체이기 때문에 수동으로 등록해야 합니다.

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

    @Provides
    @Singleton
    fun provideFirebaseAuth(): 
      FirebaseAuth = Firebase.auth
    
    @Provides
    @Singleton
    fun provideFirebaseMessaging(): 
      FirebaseMessaging = FirebaseMessaging.getInstance()
      
     // FirebaseDatabase, FirebaseFirestore 등등...
}

이처럼 바인딩 작업을 수행하면, FirebaseServiceImplFirebaseAuth를 주입할 수 있습니다. 만약 REST api 환경이였다면 유사한 방식으로 Retrofit.Builder()를 반환하는 ApiService와 연결된 모듈을 작성했을 것입니다.

추가로, Hilt DI 작업의 효과를 직접적으로 확인할 수 있는 부분이 바로 @Singleton 어노테이션입니다. 이 어노테이션을 사용함으로써 SDK 인스턴스를 한 번만 생성하고 필요할 때 재사용할 수 있어, 리소스를 절약하고 중앙 집중형 관리로 인한 유지보수성 향상 효과를 얻을 수 있습니다.

FirebaseBindModule

ApiService는 추상체이므로 바로 RemoteDataSource에 주입할 수 있었겠지만, FirebaseServiceImpl은 구현체이기 때문에 DIP를 지키기 위해 FirebaseService 인터페이스를 주입하도록 설정해야 합니다.

@Module
@InstallIn(SingletonComponent::class)
abstract class FirebaseBindModule {

    @Binds
    @Singleton
    abstract fun bindFirebaseService(
        impl: FirebaseServiceImpl
    ): FirebaseService
}

이렇게 Module을 생성하고 @Binds를 사용해 FirebaseServiceFirebaseServiceImpl 간의 관계를 명시함으로써, Hilt가 주입 가능한 상태를 구성하게 됩니다.

RemoteDataSource

지금까지의 설정을 바탕으로 외부 작업을 안전하게 내부에 주입할 수 있는 구조가 마련되었습니다. RemoteDataSource는 외부 통신의 진입점으로 동작하며 다음과 같이 작성됩니다.

// 추상체
interface AuthRemoteDataSource {
    suspend fun register(email: String, password: String): String
}

// 구현체
class AuthRemoteDataSourceImpl @Inject constructor(
    private val firebaseService: FirebaseService
) : AuthRemoteDataSource {
    override suspend fun register(email: String, password: String): String =
        firebaseService.register(email, password)
}

이처럼 ApiService 대신 동일한 역할을 수행하는 FirebaseService가 주입되며, 이 RemoteDataSourcedomain 계층에 위치한 Repository 인터페이스의 구현체 작성에 사용됩니다.

internal class AuthRepositoryImpl @Inject constructor(
    private val authRemoteDataSource: AuthRemoteDataSource,
) : AuthRepository {
    override fun register(
        email: String,
        password: String,
    ): Flow<DataResource<String>> =
        flowDataResource { authRemoteDataSource.register(email, password) }
}

이렇게 구현된 Repository는 같은 domain 계층의 UseCase에 의해 호출되며, 이후 ViewModel을 통해 사용자 UI와 연결됩니다.


4. 정리

  • FirebaseAuth와 같은 SDK를 직접 주입하는 대신, FirebaseService라는 추상화를 도입
  • FirebaseServiceImpl은 SDK 호출을 담당하며, 외부 라이브러리 의존을 한 곳으로 모아 관리
  • FirebaseServiceRemoteDataSource에서 주입받아 사용
  • 이를 통해 변경에 유연하고 테스트 가능한 구조를 갖추게 되었음

참고) 완성 소스

0개의 댓글