👩💻 오늘의 할 일
오늘은 객체지향의 특징인 다형성이 안드로이드의 의존성 주입 관계에서 어떤 역할을 하는지 알아보겠습니다. 내용은 제가 프로젝트에서 사용한 코드를 바탕으로 작성하며 DI 같은 의존성 주입 라이브러리는 사용하지 않았습니다.
👩🏫 다형성
다형성은 말 그대로 다양한 형태를 가질 수 있는 성질입니다. 객체지향에선 이를 Interface를 통해 구현합니다. 인터페이스는 상속받은 클래스가 해야 할 일을 미리 명세하고 Class에선 Interface를 상속받아 실제로 해야 할 동작을 구현합니다. 이를 통해 Interface를 상속받는 Class마다 고유한 형태와 특징을 가질 수 있습니다.
interface Animal {
fun makeSound()
}
class Dog : Animal {
override fun makeSound() {
println("멍멍!")
}
}
class Cat : Animal {
override fun makeSound() {
println("야옹~")
}
}
업캐스팅
다형성의 또다른 장점은 바로 업캐스팅이 가능합니다. 업캐스팅이란 하위 타입을 상위 타입으로 변환하는 것 입니다. 아래 코드에서 Dog와 Cat Class는 상위 모듈로 Animal interface를 가지고 있습니다. 이런 상위 타입을 가진 하위 타입들은 상위 타입으로의 타입 캐스팅이 가능하고 이를 가능하게 하는 것이 바로 다형성입니다.
interface Animal {
fun makeSound()
}
class Dog : Animal {
override fun makeSound() {
println("멍멍!")
}
}
class Cat : Animal {
override fun makeSound() {
println("야옹~")
}
}
fun main() {
val dog: Animal = Dog() // Dog 객체를 Animal 인터페이스로 업캐스팅
val cat: Animal = Cat() // Cat 객체를 Animal 인터페이스로 업캐스팅
makeAnimalSound(dog)
makeAnimalSound(cat)
}
fun makeAnimalSound(animal: Animal) {
animal.makeSound()
}
👩🏫 프로젝트 활용 코드
위에서 살펴본 다형성과 업캐스팅을 바탕으로 실제 적용 코드를 리뷰해 보겠습니다. 프로젝트에선 기상청에서 제공하고 있는 날씨를 알려주는 공공 API를 사용하고 있습니다. 아직 초기 API 테스트 단계라 구현이 안된 부분이 있는 점 참고 부탁드립니다.
앱 아키텍쳐
- 2024.05.13 수정
- 아래 그림은 의존성 역전 원칙과 맞지 않지만 의존성 역전 원칙이 적용될 때의 변화를 알아보기 위해 참고 부탁 드립니다. 😀
Datasource와 Repository는 interface와 구현체로 분리되어 있습니다.
Repository Pattern의 특성 중 하나는 데이터를 가져온 Data의 출처가 어딘지 알 수 없다는 점입니다.
이를 위해서 Repository에서 동일한 Interface Type의 Datasource를 주입받고 있습니다.
그렇다면 왜 DataSource의 구현체가 아닌 Interface를 주입 받을까요 ?
interface VillageForecastRepository {
suspend fun getVillageForecast(request: Map<String, String>): VillageForecastResponse
}
class VillageForecastRepositoryImpl(
private val remote: VillageForecastDataSource,
private val local: VillageForecastDataSource
): VillageForecastRepository {
override suspend fun getVillageForecast(request: Map<String, String>): VillageForecastResponse {
return remote.getVillageForecast(request)
}
}
바로 다형성 덕분에 업캐스팅이 가능하기 때문입니다. 현재 VillageForecastDataSource Interface는 두 개의 자식 DataSource Class를 갖고 있습니다. 그리고 VillageForecastRepositoryImpl 클래스의 생성자에선 두 개의 VillageForecastDataSource 구현체를 전달받고 있습니다.
val VillageForecastRepositoryImpl = VillageForecastRepositoryImpl(RemoteVillageForecastDataSource(), LocalVillageForecastDataSource())
업캐스팅을 활용하면 Repository는 DataSource 구현체를 주입받아도 상위 타입인 DataSource Interface 업캐스팅 되기 때문에 이런 일이 가능해집니다. 또한 이를 통해 의존성 역전 원칙을 따를 수 있습니다.
🔔 의존성 역전 원칙 (DIP , Dependency Inversion Principle)
객체지향의 5대 원칙인 SOLID 원칙 중 하나로, 고수준 모듈은 저수준 모듈에 의존해서는 안되며 오직 추상화에만 의존해야 한다는 원칙이며 대상을 참조할 때는 추상화된 요소(interface, abstract class)를 참조합니다.
이때, 고수준 모듈이란 인터페이스나 추상 클래스를 말하며 저수준 모듈은 추상화된 요소를 구현한 내용이 모듈이 되며 고수준 모듈은 변경이 적고 저수준 모듈은 변경이 잦은 특징을 갖습니다.
고수준 모듈과 저수준 모듈이 직접적으로 의존하게 되면, 상위 모듈이 하위모듈의 변경에 취약해지지만
추상화된 인터페이스를 통해 의존하게 되면, 상위 모듈은 하위 모듈과는 독립적으로 동작할 수 있습니다.
의존성 역전 원칙을 적용하면 아래로만 흐르던 의존성의 방향이 그림과 같이 역전되는 것을 볼 수 있습니다.
의존성 역전 원칙의 이점
- 유연성과 확장성
- 추후 DataSource가 추가되거나 교체되더라도 Repository는 이에 영향을 받지 않습니다.
- 제 코드를 예로 들때, 현재 Interface에만 의존하기 때문에 Interface를 구현하는 구현체가 뭐가 됐던 Repository는 도메인에 따라 필요한 DataSource 구현체만 주입받으면 됩니다.
- 이를 통해 상위 모듈(Repository)이 하위 모듈(DataSource)에 영향을 받지 않는 의존성 역전 원칙을 지킬 수 있습니다.
- 테스트 용이성
- 의존성을 인터페이스에만 가지고 있기 때문에, 테스트를 위해 mock를 주입하여 테스트할 수 있습니다.
- 코드 결합도 감소
- Repository와 DataSource 간의 결합도가 낮아져 둘 간의 관계가 유연하며, 변경에 대한 영향이 최소화되어 코드 유지보수성이 향상됩니다.
- 단일 책임 원칙(SRP, Single Reposibility Principle) 준수
- SOLID 원칙의 또 다른 원칙인 단일 책임 원칙을 지킬 수 있습니다.
- Repository와 DataSource는 각각의 역할과 책임을 가지고 있습니다.
- Repository는 비즈니스 로직, DataSource는 데이터 접근을 담당하므로, 각각의 모듈은 자신의 책임에 집중할 수 있습니다.
Factory 함수
위에서 살펴본 VillageForecastRepository Interface에는 연관된 내용만을 설명하기 위해 생략되어 있었지만 실제로는
팩토리 함수가 선언되어 있습니다. 클래스의 인스턴스를 만들게 하는 가장 일반적인 방법은 기본 생성자를 사용하는 것인데 이 때 생성자의 역할을 대신해주는 함수가 바로 팩토리 함수입니다.
interface VillageForecastRepository {
suspend fun getVillageForecast(request: Map<String, String>): VillageForecastResponse
companion object {
fun create(): VillageForecastRepository {
return VillageForecastRepositoryImpl(
RemoteVillageForecastDataSource(
RetrofitInstance.provideService(
VILLAGE_FORECAST_BASE_URL,
VillageForecastService::class.java,
)
),
LocalVillageForecastDataSource(),
)
}
}
}
팩토리 함수를 사용한 이유
처음 코드를 작성할 땐 DataSource 부터 UseCase 까지의 필요한 의존성과 더불어 모든 인스턴스 주입을 결국엔 ViewModel Factory에서 생성을 해야 했습니다.
그렇다 보니 계단식 같은 뭔가 굉장히 우스꽝스런 형태가 되었습니다. 물론 가독성 문제도 있었지만 ViewModel에서 DataSource의 존재를 알게 되는걸 막고 싶어 Repository의 Interface에서 팩토리 함수를 통해 반환하게 만들었습니다.
또한 ViewModel이 가지고 있어야할 Repository 객체 생성의 책임을 팩토리 함수에 위임함으로써 상위 모듈이 가진 의존성 생성에 대한 책임을 하위 모듈에게 위임해 객체 생성의 책임이 역전됩니다.
val WeatherViewModelFactory = object : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T =
with(modelClass){
val getTouristSpotWeatherUseCase =
GetVillageForecastUseCase(
VillageForecastRepositoryImpl(
RemoteVillageForecastDataSource(
RetrofitInstance.provideService(
Constant.VILLAGE_FORECAST_BASE_URL,
VillageForecastService::class.java
)
),
LocalVillageForecastDataSource()
)
)
when{
isAssignableFrom(VillageForecastViewModel::class.java) ->
VillageForecastViewModel(getTouristSpotWeatherUseCase)
else -> throw IllegalArgumentException("Unknown ViewModel class: ${modelClass.name}")
}
} as T
}
ViewModel Factory에서의 사용
ViewModelFactory에서는 팩토리 함수를 통해 Repository를 생성하고 UseCase에 Repository를 주입해주기만 하면 되기 때문에 좋은 선택이었던 것 같습니다.
해당 ViewModel Factory 코드는 안드로이드 아키텍쳐 샘플 앱에서 가져왔습니다.
저는 이전 프로젝트에서까지 ViewModel Factory는 ViewModel당 하나씩 1대 1로 만들었었습니다. 이로 인해 ViewModel Class가 많아지면 많아질 수록 그에 대응하는 ViewModel Factory Class도 계속해서 늘어나는 문제가 있었죠.
이를 해결하기 위해 샘플 앱 코드를 열심히 파헤치다가 발견하게 되었는데요, ViewModel 생성을 간소화할 뿐만 아니라 UseCase를 재활용 할 수 있다는 점이 가장 맘에 들어서 채택하여 사용했습니다.
val SharedViewModelFactory = object : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T =
with(modelClass){
val getVillageForecastUseCase = GetVillageForecastUseCase(VillageForecastRepository.create())
when{
isAssignableFrom(VillageForecastViewModel::class.java) ->
VillageForecastViewModel(getVillageForecastUseCase)
else -> throw IllegalArgumentException("Unknown ViewModel class: ${modelClass.name}")
}
} as T
}
전체 코드
// DataSource
interface VillageForecastDataSource {
suspend fun getVillageForecast(request: Map<String, String>): VillageForecastResponse
suspend fun setVillageForecast()
}
class RemoteVillageForecastDataSource(
private val villageForecastService: VillageForecastService
): VillageForecastDataSource {
override suspend fun getVillageForecast(request: Map<String, String>): VillageForecastResponse {
return villageForecastService
.getVillageForecast(request)
.await()
}
override suspend fun setVillageForecast() {}
}
class LocalVillageForecastDataSource: VillageForecastDataSource {
override suspend fun getVillageForecast(request: Map<String, String>): VillageForecastResponse {
TODO("Not yet implemented")
}
override suspend fun setVillageForecast() {
TODO("Not yet implemented")
}
}
// Repository
interface VillageForecastRepository {
suspend fun getVillageForecast(request: Map<String, String>): VillageForecastResponse
companion object {
fun create(): VillageForecastRepository {
return VillageForecastRepositoryImpl(
RemoteVillageForecastDataSource(
RetrofitInstance.provideService(
VILLAGE_FORECAST_BASE_URL,
VillageForecastService::class.java,
)
),
LocalVillageForecastDataSource(),
)
}
}
}
class VillageForecastRepositoryImpl(
private val remote: VillageForecastDataSource,
private val local: VillageForecastDataSource
): VillageForecastRepository {
override suspend fun getVillageForecast(request: Map<String, String>): VillageForecastResponse {
return remote.getVillageForecast(request)
}
}
// UseCase
class GetVillageForecastUseCase(
private val villageForecastRepository: VillageForecastRepository
): BaseUseCase() {
suspend operator fun invoke(request: Map<String, String>): Result<VillageForecastResponse> = execute {
villageForecastRepository.getVillageForecast(request)
}
}
// ViewModel Factory
val SharedViewModelFactory = object : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T =
with(modelClass){
val getVillageForecastUseCase = GetVillageForecastUseCase(VillageForecastRepository.create())
when{
isAssignableFrom(VillageForecastViewModel::class.java) ->
VillageForecastViewModel(getVillageForecastUseCase)
else -> throw IllegalArgumentException("Unknown ViewModel class: ${modelClass.name}")
}
} as T
}
📑 후기
글 이란건 참 좋은 것 같습니다. 사실 의존성 역전 원칙에 대해 이해하지 못하고 있었는데 글을 쓰다보니 이해가 되고 또 글을 쓰면서 생각하지 못했던 부분을 더 발견할 수 있었어요. SOLID 원칙중에서도 특히나 의존성 역전 원칙은 이해를 못하고 있었는데 직접 사용하는게 항상 큰 발전을 이루어내는 것 같습니다.
모든 코드는 여기서 확인하실 수 있습니다. 저처럼 DI를 사용하지 않고 의존성 주입을 하는 방법을 고민하시는 분들에게 도움이 되셨으면 좋겠네요😊
'Android Architecture' 카테고리의 다른 글
Android MVI를 알아보자 (1) | 2024.10.12 |
---|---|
클린 아키텍처가 의존성 역전 원칙을 활용하는 원리 (0) | 2024.05.23 |
클린아키텍처를 지향하는 아키텍처 (0) | 2024.05.15 |
FireBase 의존성 주입 생각해보기 (0) | 2024.05.01 |
Android Repository 패턴 (0) | 2024.04.29 |