본문 바로가기

내일배움캠프 안드로이드 3기

[TIL] 24.05.10 Delegate 패턴 적용해보기

1. Delegate 패턴 적용해보기

 

 튜터님께 들었던 조언 중에, ViewModel 내에서 viewModelScope.launch { runCatching {}.onFailure {} }로 이뤄진 예외처리 보일러 플레이트 코드들을 delegate 패턴을 통해 제거해보라고 하셨다.

 Delegate 패턴은 어떤 기능을 직접 구현하는 것이 아니라 다른 객체에 위임해서 처리하는 패턴으로, 일종의 Modularization을 가능하게 한다.

 코틀린에서 결국 상속을 받을 수 있는 부모 클래스는 단 하나이고, 여러 기능을 상속을 받아 추가하려면 인터페이스를 이용하게 된다. 하지만 이런 상속 패턴은 단점이 존재하는데, 인터페이스 상속을 받는 클래스를 만들 때마다 하나하나 구현해줘야 한다. 설사 그 내부 로직이 같다고 해도 말이다.

 부모 클래스에 모든 필요한 기능을 다 만들어 두면 되지 않겠냐고 생각할 수도 있지만, 그 중 일부만 필요한 클래스가 있다면? 그리고 그런 패턴은 단일 책임 원칙도 제대로 지켜지지 않을 가능성이 농후하다.

 

 이럴 때 적용해 볼 수 있는 것이, delegate 패턴이다. 필요한 기능을 인터페이스로 정의한 이후에, 그것을 구현하는 클래스를 만들어두고, 해당 기능이 필요한 클래스를 생성할 때 방금 만들어둔 클래스에게 해당 기능을 위임해버리는 것이다. 관련 공부를 하다보니 대표적인 비유로 부동산 중개인 얘기가 많이 언급이 되고 있었다, 클래스와 인스턴스를 전형적으로 붕어빵틀과 붕어빵에 빗대어 표현하듯이. 

 

// 부동산 계약에 필요한 권한들
interface Authority {
    // 계약 성사
    fun makeContract()
    ...
}

// 고객
class Customer(): Authority {
    override fun makeContract() {
        // 도장찍고 어쩌구..
    }
}

// 부동산 중개인
class Broker(private val customer: Authority): Authority {
    override fun makeContract() {
        // 고객 권한 위임 받아 사용
        customer.makeContract()
    }
}

fun main() {
    val customerA = Customer()
    val broker = Broker(customerA)

    // 손님의 권한 위임 받아서, 대신 계약 업무 진행
    broker.makeContract()
}

 

 

 이런 예시가 많이 보였다. 고객이 해야하는 계약에 관한 일들을 부동산 중개인이 위임 받아 사용하고 있다. 이렇게만 보면 '어차피 이러면 위임 받는 쪽에서도, override 코드가 필요하니까 크게 의미가 없잖아?' 라는 생각이 들 수 있다. 물론 내부의 로직은 재차 구현하지 않아도 되지만, 뭔가 아쉬운 건 사실이다.

 하지만 코틀린에서는 이를 위한 강력한 기능을 언어 차원에서 지원한다. 바로 by 키워드인데, 위에서 작성한 불필요한 보일러 플레이트 코드를 아래와 같이 줄일 수 있다.

 

// 부동산 계약에 필요한 권한들
interface Authority {
    // 계약 성사
    fun makeContract()
    ...
}

// 고객
class Customer(): Authority {
    override fun makeContract() {
        // 도장찍고 어쩌구..
    }
}

// 부동산 중개인
// by 키워드 사용으로 위임 과정을 간소화 한다
val customerA = Customer()
class Broker(): Authority by customerA

fun main() {
    val broker = Broker()

    // 손님의 권한 위임 받아서, 대신 계약 업무 진행
    broker.makeContract()
}

 

 

 코틀린의 서포트를 받아 깔끔하게 구현된 모습이다. 하지만 기능(여기서는 부동산 계약)에 특정 프로퍼티가 함께 위임될 필요가 있는 경우도 있다. 예를 들자면, 위의 계약 과정에 이름이 필요한 경우다. 이럴 때는 간단하게 프로퍼티도 위임할 수있다.

 

// 부동산 계약에 필요한 권한들
interface Authority {
    // 계약자 이름
    val name: String
    // 계약 성사
    fun makeContract()
    ...
}

// 고객
class Customer(val myName: String): Authority {
    override fun name = myName
    override fun makeContract() {
        // name 값으로 도장찍고 어쩌구..
    }
}

// 부동산 중개인
// by 키워드 사용으로 위임 과정을 간소화 한다
val customerA = Customer("내이름")
class Broker(): Authority by customerA

fun main() {
    val broker = Broker()

    // 손님의 권한 위임 받아서, 대신 계약 업무 진행
    broker.makeContract()
}

 

 

 뿐만 아니라, 굳이 customerA와 같이 미리 생성된 변수를 사용하지 않고, 클래스 생성시에 주입 받은 구현체로부터 위임 받는 것도 가능하다. 이 때는 Broker 클래스를 아래와 같이 정의해 볼 수 있다.

class Broker(val customer: Customer): Authority by customer

 

 di를 이용한다면, 유용하게 이용할 수 있을 거라는 생각이 들었다.

 

 

 

 위 내용들을 바탕으로 나는 ViewModel 내에 있던, 반복되는 코루틴 스코프 호출 및 예외처리 코드들을 줄여보기로 했다.

fun searchListItem(query: String) {
    viewModelScope.launch(Dispatchers.IO) {
        runCatching {
            //각각의 API를 병렬로 받아올 수 있도록
            val searchImageDeferred = async { getSearchImageUseCase(query) }
            val searchVideoDeferred = async { getSearchVideoUseCase(query) }

            //모든 결과 받고 나서 진행
            val searchImageResult = searchImageDeferred.await()
            val searchVideoResult = searchVideoDeferred.await()

            //두 결과 합쳐서 시간순 정렬 후, View에서 사용하는 타입으로 변환
            val integratedList =
                (searchImageResult.documents.orEmpty() + searchVideoResult.documents.orEmpty())
                    .sortedByDescending { it.datetime }
                    .map { it.toListItem() }

            //race condition 방지하면서 pagingMeta에 저장
            pagingMetaMutex.withLock {
                pagingMeta = PagingMeta(
                    keyword = query,
                    imagePage = 1,
                    videoPage = 1,
                    imageIsEnd = searchImageResult.meta?.isEnd ?: true,
                    videoIsEnd = searchVideoResult.meta?.isEnd ?: true
                )
            }

            searchItemFlow.emit(integratedList)

        }.onFailure {
            _errorEvent.emit(it.toErrorEvent())
        }

    }
...

 

 내 ViewModel의 코드 내에서는 viewModelScope.launch() { runCatching {}.onFailure { 에러 state 전달 } } 가 보일러 플레이트 코드로 계속 작성되고 있었다. 그래서 이 과정들을 interface 및 그 구현체로 빼내었다. 그리고 이를 hilt로 ViewModel 생성 시에 주입해, by로 위임 받게 했다.

 

interface ErrorHandler {
    val _errorEvent: MutableSharedFlow<ErrorEvent>

    fun CoroutineScope.launchWithHandling(
        block: suspend () -> Unit
    )
}

class ErrorHandlerImpl: ErrorHandler {
    override val _errorEvent = MutableSharedFlow<ErrorEvent>()

    override fun CoroutineScope.launchWithHandling(block: suspend () -> Unit) {
        this.launch {
            runCatching {
                block.invoke()
            }.onFailure {
                _errorEvent.emit(it.toErrorEvent())
            }
        }
    }

}

 

@HiltViewModel
class SearchViewModel @Inject constructor(
	...
    private val errorHandler: ErrorHandler
) : ViewModel(), ErrorHandler by errorHandler {
...

 

 

 이 이후에는, 모든 메소드에서 단지 viewModelScope.launchWithHandling {} 내에서 필요한 동작을 구현함으로서, 아까의 코루틴 내 실행 및 예외처리 코드를 따로 구현할 필요가 없어졌다. 조금 더 섬세하게 생각해 볼 부분들이 남아있지만, 이에 대해서는 또 튜터님께 질문해 볼 생각이다.