본문 바로가기

Android Architecture

FireBase 의존성 주입 생각해보기

👩‍💻 오늘의 할 일

오늘은 지난 글에서 개선점으로 꼽았던 불필요한 DataSource에 대한 의존성 분리와 실제 사용 코드에서 UseCase를 한 번 만들어보겠습니다. 또한 지난 프로젝트에서 사용했던 코드를 리뷰하면서 필요한 의존성을 왜 ? 어떻게 ? 주입을 했을까에 대한 생각을 적어보려 합니다. 물론 DI 라이브러리는 사용하지 않았지만 먼저 의존성 주입이란 무엇일까에 대해 알아보고 가겠습니다.

👩‍🏫 의존성 주입이란 ?

의존성이란, 어떤 대상이 참조하는 객체나 함수를 의미합니다. 다음 코드와 같이 Car Class에서 Engine( ) 클래스의 인스턴스를 생성하면 Car는 Engine에 의존하게 됩니다. 이때, Car Class는 Engine 인스턴스를 생성하는 책임을 갖고 있으며 Car Class는 Engine Class에 대한 의존성이 생기게 됩니다.

class Engine 
class Car { 
 val engine = Engine() 
}

 

이러한 관점에서 의존성 주입이란 대상 객체(client)에 의존성을 제공하는 기술을 의미합니다. 위 코드에서 Engine 인스턴스를 외부에서 전달하는 방식으로 변경해 보겠습니다.

class Car (val engine: Engine){ }

 

이렇게 되면 Engine을 외부에서 전달받을 수 있기 때문에 Car Class는 Engine 인스턴스생성에 대한 책임이 없어지는데, 이를 IoC(Inversion Of Control, 제어의 역전)이라고 합니다.

🔔 IoC(Inversion Of Control, 제어의 역전)
  소프트웨어 설계 원칙 중 하나로 프로그래밍에 있어 객체의 생성 및 관리에 대한 책임을 개발자에서 전체 어플리케이션 또는 프레임 워크에 위임하는 디자인 원칙입니다.

프레임 워크나 라이브러리 없이 개발할 때는 개발자가 인스턴스의 생성 및 관리 등의 흐름을 직접 제어합니다. IoC를 사용하면 이런 제어의 흐름역전시켜 객체의 생성과 관리를 외부 컨테이너나 프레임워크가 담당하도록 합니다. 안드로이드에선 대표적으로 DI(Dependency Injection)을 예로 들 수 있습니다. 

DI는 객체가 필요로 하는 의존성을 직접 생성하지 않고 외부에서 주입받는 방식을 사용합니다.

IoC의 핵심은 "어떻게"가 아니라 "누가" 객체를 생성하고 관리하는 책임을 가지느냐에 있습니다. 즉, 객체들이 어떻게 생성되고 관리되는지 보다 누구에 의해서 생성되고 관리되는지에 초점을 맞춰야 합니다.

 

위 코드와 같이 외부에서 의존성을 주입하게 하면 다음과 같이 다양한 형태의 인스턴스를 주입할 수 있습니다. 이렇게 될 경우 다양한 장점을 가지게 됩니다.

  • Car Source Code를 변경하지 않는다 -> 재사용성의 증가
  • Class 간의 결합도를 느슨하게 만들어준다 -> 디커플링
fun main(args:Array<String>){ 
 val gasolineCar = Car(GasolineEngine()) 
 val dieselCar = Car(DieselEngine()) 
}

 

또한 SOLID 원칙 중 하나인 단일 책임 원칙(SRP, Single Responsibility  Priciple)을 지킬 수 있습니다. 서두에 언급했던 것처럼 클래스 내부에서 인스턴스를 생성하게 될 경우 해당 인스턴스에 대한 책임이 생기게 되며 이는 클래스의 생성자에 의존성을 주입해 줌으로써 해결할 수 있습니다.

class Car( 
 val engine:Engine, 
 val wheels:Wheels, 
 val wiper:Wiper, 
 val battery:Battery 
){ }

 

테스트 코드를 작성하기 쉬워지는 장점도 있습니다.

class CarTest { 
 @Test
 fun `Car 성공 케이스 테스트`(){ 
 val car = Car(FakeEngine()) 
 // 중략
 } 
 
 @Test
 fun `Car 실패 케이스 테스트`(){ 
 val car = Car(FakeBrokenEngine()) 
 // 중략
 }
}

 

👩‍💻 DataSource 분리하기

아래 코드는 각각 Firabase FireStore, Cloud, Room Database Dao(Local DB)에 대한 의존성을 주입받고 있습니다. 제가 고민한 포인트는 이 Repository를 사용하려면 반드시 3가지 의존성을 주입해줘야 하는데 만약, 파이어 베이스만 사용하고 Local Database를 사용하지 않으면 불필요한 의존성이 생긴다고 생각했습니다.

class PetRepositoryImpl(
    private val reference: CollectionReference,
    private val storage: StorageReference,
    private val myPetDao: MyPetDao
)

 

위 문제를 해결하기 위해 우선 DataSource를 위한 클래스와 인터페이스를 만들어 캡슐화해 Repository에 삽입해 주기로 했습니다. 여기서 파이어 베이스 CollectionReference을 주입받지 않은 이유는 어차피 Firebase는 mocking 도 못하니, Firebase에 대한 의존성은 여기 클래스 안에서 직접 생성해 줘도 문제가 없다고 생각했습니다.

interface PetDataSource {
  suspend fun getPet(): Pet?
  suspend fun setPet(pet: Pet)
}

class FirebasePetDataSource: PetDataSource {
  private val petCollectionRef = Firebase.firesotre.collection("Pet")
  private val sotorageRef = Firebase.storage
  
  override suspend fun getPet(): Pet? {
    // petCollectionRef...
  }
  
  override suspend fun setPet(pet: Pet) {
    // petCollectionRef...
  }
}

class RoomPetDataSource(
  private val dao: MyPetDao
): PetDataSource {
  
  override suspend fun getPet(): Pet? {
    // dao...
  }
  
  override suspend fun setPet(pet: Pet) {
    // dao...
  }
}

 

이렇게 같은 인터페이스를 가지면, 어떤 DataSource 라도 같은 데이터에 대한 입출력 API 는 동일하다는 것을 보장해 줄 수 있습니다. 나중에 다른 DataSource 가 추가되더라도 혹은 DataSource 가 교체되더라도 ( Firebase → 자체 서버 REST API) Repository는 변경 없이 갈아 끼워줄 수 있습니다. 이를 Repository에서 활용하면 아래와 같은 형태가 됩니다.

class PetRepositoryImpl(
  private val remote: PetDataSource, // Firebase 인지 자체 서버 REST API 인지 모릅니다.
  private val local: PetDataSource, // Room 일수도, Preference 일수도 있죠.
): PetReopsitory {
  
  suspend fun getPet(): Pet? {
    val localData = local.getPet()
    if (localData != null) {
      // 로컬에 데이터가 이미 있으면 바로 반환하기 (빠르니까)
      return localData
    }
    val remoteData = remote.getPet()
    if (remoteData != null) {
      // 없으면 리모트에서 데이터 가져와서 로컬에 캐시하기 (이번엔 느렸지만 다음에 빠를테니까)
      local.setPet(remoteData)
    }
    return remoteData
  }
  
  suspend fun setPet(pet: Pet) {
    local.setPet(pet)
    remote.setPet(pet)
  }
}

 

이렇게 한번 DataSource로 캡슐화를 하고 나면, Repository에서 주입받는 DataSource가 정확히 어떤 소스인지 알 수 없습니다. 이를 통해서 관심사를 분리하고 결합도를 낮출 수 있습니다.

 

👩‍💻 UseCase 만들기

usecase는 비즈니스 요구사항을 반영한 작업 단위로, 앱의 비즈니스 로직을 구현합니다. Repository와의 차이점은 

Repository는 데이터 소스와의 상호작용을 추상화하여 데이터 접근 로직을 캡슐화합니다.

 

코드에서 UseCase로 만들면 딱 좋겠다고 생각한 부분이 있었습니다.

📑 조건 

게시판 글 목록을 보여주며 보여줘야 할 데이터는 게시글, 작성자 이름, 섬네일 이미지입니다.

  1. 게시글 정보를 가져옵니다.
    • 게시글 data class
@Parcelize
data class BoardModel(
    val boardIdx: Int = 0,
    val boardTitle: String = "",
    val boardContent: String = "",
    val boardImagePathList: List<String?> = listOf(),
    val boardWriterIdx: String = "",
    val boardWriteDate: String = "",
    val boardModifyDate: String = "",
    val boardLikeNumber: Int = 0,
    val boardState: Int = 0
): Parcelable

 

  2. 게시글 정보에 있는 boardWriterIdx로 사용자의 이름을 가져옵니다.

 

  3. 게시글 정보에 있는 boardImagePathList로 게시글 썸네일 이미지를 가져옵니다.

// ViewModel

class MainViewModel(
    private val freeBoardRepository: FreeBoardRepositoryImpl,
    private val userRepository: UserRepositoryImpl,
    private val petRepositoryImpl: PetRepositoryImpl,
    private val reviewRepository: ReviewRepositoryImpl,
): ViewModel() {
     private suspend fun fetchAllBoardDataWithUserInfo(){
        val response = freeBoardRepository.fetchAllBoardData()
        val contentList = mutableListOf<BoardAddUerInfoModel>()

       response.map {  boardModel ->
            val nickName = userRepository.fetchUserNickName(boardModel.boardWriterIdx)
            val imgUri = freeBoardRepository.fetchAllBoardImage(boardModel.boardIdx.toString(), boardModel.boardImagePathList[0].toString())
            val content = BoardAddUerInfoModel(boardModel, nickName, imgUri)
            contentList.add(content)
        }
        _boardContentList.value = contentList
    }
}

//FreeBoardRepository
class FreeBoardRepositoryImpl(
    private val reference: CollectionReference,
    private val storage: StorageReference
) : FreeBoardRepository {

    override suspend fun fetchAllBoardData(): List<BoardModel> {
        return withContext(Dispatchers.IO) {
            try {
                var query = reference.whereEqualTo("boardState", ContentState.CONTENT_STATE_NORMAL.number)
                query = query.orderBy("boardIdx", Query.Direction.DESCENDING)
                val querySnapshot = query.get().await()
                querySnapshot.map { it.toObject(BoardModel::class.java) }

            } catch (e: Exception) {
                Log.e("FirebaseResult", "Error fetching Board: ${e.message}")
                emptyList()
            }
        }
    }

    override suspend fun fetchAllBoardImage(boardIdx: String, imgName: String): URI {
        return withContext(Dispatchers.IO) {
            try {
                val path = "board/$boardIdx/$imgName"
                val response = storage.child(path).downloadUrl.await().toString()
                URI.create(response)
            } catch (e: Exception) {
                Log.e("FirebaseResult", "Error fetching BoardImage : ${e.message}")
                URI.create("")
            }
        }
    }
}

// UserRepository
class UserRepositoryImpl(
    private val reference: CollectionReference,
    private val storage: StorageReference
) : UserRepository {
	override suspend fun fetchUserNickName(uniqueNumber: String): String {
        return withContext(Dispatchers.IO) {
            val response = reference.whereEqualTo("uniqueNumber", uniqueNumber).get().await()
            response.documents[0].getString("userNickname").toString()
        }
    }
}

📑 UseCase로 만들기

class GetBoardDataWithUserInfoUseCase(
    private val freeBoardRepository: FreeBoardRepository,
    private val userRepository: UserRepository,
){
    suspend operator fun invoke(): List<BoardAddUerInfoModel> {
        return freeBoardRepository.fetchAllBoardData().map { boardModel ->
            BoardAddUerInfoModel(
                contentData = boardModel,
                writerNickName = userRepository.fetchUserNickName(boardModel.boardWriterIdx),
                imgUri = freeBoardRepository.fetchAllBoardImage(
                boardIdx = boardModel.boardIdx.toString(),
                imgName = boardModel.boardImagePathList[0].toString()
            ))
        }
    }
}

class MainViewModel(
    private val getAllBoardDataWithUserInfo: GetBoardDataWithUserInfoUseCase,
) : ViewModel() {

	private val _boardData = MutableStateFlow<List<BoardAddUerInfoModel>>(emptyList())
	val boardData: StateFlow<List<BoardAddUerInfoModel>> = _boardData.asStateFlow()
    
	init {
		viewModelScope.launch {
		_boardData.value = getAllBoardDataWithUserInfo()
	}            
}

📕 후기

이렇게 DataSource 의존성을 분리하고 UseCase도 사용해 봤습니다. 사실 앱 스쿨 멘토님께 도움을 받아 작성한 내용들인데 정말 갈 때까지 쪼갠다라는 느낌이 들더라고요.. 하지만 이렇게 의존성을 분리하다 보니까 재미있는 부분도 많고 무엇보다 DI에 대해 공부해보고 싶단 생각이 많이 들었습니다. 그래서 고심 끝에 DI 강의를 하나 질렀는데 앞으로는 이 강의에 대한 내용을 많이 정리하고 공부하려 합니다. 많이 어려울거 같긴 하지만 그래도 열심히 해봐야죠 🔥