안드로이드 개발을 하다보면 iOS 에서의 코드를 봐야하는 경우가 종종 있습니다.
iOS 에서의 개발은 화면간의 데이터 공유 및 액션을 위해 Delegation Pattern 을 사용합니다.
제가 알고 있는 코틀린에서의 Delegation 은 늦은 초기화를 위한 by lazy 와 by viewModels() 정도가 거의 전부였기에 코틀린의 Delegation 을 알아보고 싶었습니다..
먼저, 상속과 조합.
Delegation Pattern 에 대해 파악하는 과정 중 유난히 많이 접한 것은 is-A 와 has-A 관계입니다.
is-A 관계
is-A 는 상속을 의미합니다. 예를 들자면 Developer is Job, Tiger is Animal 정도가 있겠습니다.
클래스가 임의의 클래스를 상속하게 되면, 클래스에 구현된 모든 메서드와 필드 변수들을 갖게 됩니다. 중복될 수 있는 코드들에 대한 별도의 구현이 필요하지 않게 되어, 개발에 소요되는 리소스를 줄일 수 있습니다. 다만, 장점만 있지는 않고 단점도 있습니다.
대표적인 단점들은 다음과 같습니다.
- 유연하지 않은 설계
- 캡슐화 위반
- 제약 조건 多
- 조상 클래스의 결함을 자손 클래스에게 전파
단점이 많기 때문에 상속을 사용하려면
- 확장성을 염두에 두
- 엄격한 판단 기준에 근거하여
사용하는 것이 좋아 보입니다.
무분별한 상속이 일으킬 수 있는 문제를 예시로 준비했습니다.
Developer 클래스는 goCompany(), work() 메서드를 가지고 있는 클래스입니다.
FreelancerDeveloper 클래스는 Developer 클래스를 상속받는 또 다른 클래스입니다.
그러므로, FreelancerDeveloper 클래스는 goCompany(), work() 메서드를 가집니다.
동시에, stayAtHome() 메서드를 추가로 가집니다.
다만, 프리랜서 개발자는 보통 출근하지 않고 집에서 일합니다. (아닌 경우도 있어서 예시가 잘못되긴 했습니다.)
상속 관계에 문제가 생깁니다. 상속은 기본적으로 is-A 관계가 맞는지 엄격하게 따져보고 나서 진행하여야 합니다.
has-A 관계
위와 같은 상황을 타개할만한 방법은 무엇이 있을까요?
눈물을 머금고 Developer 클래스를 변경하거나, 새로이 상속받을만한 또 다른 클래스를 찾아야 합니다. 이는 유지보수 측면에서 크게 비효율적입니다.
그럼 이제, 상속에 대한 집착을 버립시다. 위 오류를 해결하기 위한 대안으로 우리는 상속이 아닌 조합(Composition) 이라는 선택지를 갖고 있습니다. 조합은 FreelancerDeveloper 클래스 내부에 Developer 클래스의 인스턴스를 가지는 것을 의미합니다. 그럼 Developer 클래스의 인스턴스를 통해 work() 메서드를 호출할 수 있으니 FreelancerDeveloper 의 역할을 구현하는 데에는 문제가 없습니다.
Delegation Pattern
🤔 그냥 상속하지 않고 구성을 사용하라는 내용이군요. 그럼 Delegation Pattern 은 왜 필요한 거죠?
이를 이해하기 위해 재미있는 예시를 준비해 봤습니다.
interface RealMan {
fun changeBulb()
fun protectFamily()
fun saySomethingCool()
}
남자는 전구를 갈고, 가족들을 수많은 위험으로부터 지켜냅니다.
저희 가족의 이야기입니다😎 (당연히 아닌 경우도 있습니다.)
open class Father() : RealMan {
override fun changeBulb() {
println("I changed a bulb.")
}
override fun protectFamily() {
println("I protected my family from dangers.")
}
override fun saySomethingCool() {
println("I said something cool.")
}
}
아버지는 집의 전구를 갈고, 가족을 위험으로부터 지켜냅니다. 멋진 말도 하고요.
class Son() : Father() {
}
저는 그런 아버지를 보며 자랐기에, 같은 방식으로 전구를 갈고, 같은 방식으로 가족을 위험으로부터 지켜냅니다.
컴파일러 오류 없이 전구도 잘 갈고, 가족을 위험으로부터 잘 지켜내고, 멋진 말도 할 수 있습니다.
자, 오류가 하나 있습니다.
저는 할리우드 영화를 많이 봐서, 아버지보다 더 멋진 말을 할 수 있습니다.
간단합니다. 메서드를 오버라이드하면 됩니다.
해당 방식은 아주 간단하고 편리하지만, 상속은 여러 가지 문제가 있기 때문에 (위 예제는 짧아서 별 위험이 없지만) 조합을 사용하도록 훌륭한 프로그래머들은 권고합니다. 그렇다면 수정해 봅시다.
interface RealMan {
fun changeBulb()
fun protectFamily()
}
class Father() : RealMan {
override fun changeBulb() {
println("I changed a bulb.")
}
override fun protectFamily() {
println("I protected my family from dangers.")
}
override fun saySomethingCool() {
println("I said something cool.")
}
}
아버지는 남자고, 여전히 전구를 갈고 가족을 위험으로부터 지켜냅니다. 당연히 멋진 말씀도 곧잘 하십니다.
class Son() {
}
그러나 저는 아버지의 손에 자라지 못하고 길거리에서 자라, 전구 가는 방법도 모르고 가족을 위험에서 지켜내는 방법도 배우지 못했고, 멋진 말도 못 합니다.
전구도 갈아야 하고, 가족도 위험으로부터 지켜내야 하고, 멋진 말도 해야 하는데 컴파일러 오류가 나서 그럴 수가 없습니다. 이럴 때, 저에겐 진짜 남자 아버지가 계시기 때문에, 아버지에게 부탁할 수 있습니다.
아버지의 도움으로, 컴파일러 오류 없이 전구도 갈고 가족도 지켜내고, 멋진 말도 할 수 있습니다.
이를 Delegation Pattern 이라고 합니다. 위임 패턴이라 부르기도 하며, 특정 메서드를 다른 객체가 수행하도록 위임하는 디자인 패턴입니다.
그렇다면 왜 다른 객체에게 수행을 위임하는 걸까요?
위에서 언급하였듯, 상속이 아닌 합성을 하기 위함입니다. 상속이 가져다줄 수 있는 오류의 위험을 회피하기 위해서입니다.
추상 클래스를 상속받으라는 의견도 많은데, 추상 메서드에 대해서만 재정의할 수 있으므로 좋아 보이지만, 어쨌든 추상 메서드가 아닌 일반 메서드에 대해서는 상속받는 것이 같으므로, 상속이 줄 수 있는 위험을 회피할 수는 없습니다.
그런데 이상합니다. changeBulb(), protectFamily(), saySomethingCool() 메서드 모두 Father 클래스의 인스턴스만 있어도 충분합니다. 굳이 Son 클래스의 인스턴스가 필요하지 않습니다.
그렇다면 우리는 왜 위임해야 하나요? 상속이 아닌 구성을 위해서라면, 그냥 Father 클래스의 인스턴스만 구현해두고 사용하면 안 될까요?
위와 같은 궁금증이 생겨, money 필드를 곁들인 다른 예를 만들었습니다.
국민들은 세금을 내야 합니다.
저는 장성하여 세금을 내야 하는데, 내는 방법을 잘 모릅니다. 그러면 아버지가 하라시는 대로 제가 제 세금을 낼 수 있지 않을까요?
interface RealMan {
fun payTax(currentMoney: Int) : Int
}
class Father() : RealMan {
var money = 1000
override fun payTax(currentMoney: Int) : Int {
return currentMoney - 100
}
}
아버지께서는 100원만 세금으로 내면 된다고 하시네요.
class Son(father: Father) : RealMan by father {
var money = 500
}
저는 돈만 갖고 있고 세금 내는 법은 잘 모릅니다. 아버지가 하라는 대로 해보겠습니다.
fun main() {
val father = Father()
val son = Son(father)
son.money = son.payTax(son.money)
println(son.money)
}
// Console
System.out : 400
제가 가진 돈 500원에서 100원이 빠진 400원이 콘솔에 출력됩니다.
세금 내는 방법에 대해 전혀 모르는 저도, 아버지의 설루션대로 세금을 정확히 납부할 수 있습니다.
근데 여기서도 문제가 있습니다.
fun main() {
val father = Father()
val son = Son(father)
son.money = father.payTax(son.money)
println(son.money)
}
// Console
System.out : 400
son.payTax(son.money) 의 son 을 father 로 바꿨습니다. Son 클래스의 객체인 son 이 아니라 Father 의 객체인 father 가 직접 payTax() 메서드를 수행해도 아무런 문제가 없습니다. 즉, 또 다시 son 이 필요없는 상황이 된 겁니다. 이건 위임이 아니라 명령입니다. 즉, 그냥 제 돈으로 아버지가 제 세금을 납부하고 오신 겁니다.
이 경우, Son 클래스의 : RealMan by father 구문 없이도 해당 케이스가 정상 작동합니다.
class Son() {
var money = 500
}
fun main() {
val father = Father()
val son = Son()
son.money = father.payTax(son.money)
println(son.money)
}
// Console
System.out : 400
아무래도 이상합니다. 이럴 거면 그냥 son 과 father 를 구현하고 father 를 통해 payTax() 메서드를 수행하면 되니까요. 위임에 아무런 의미가 없습니다.
이래선 도무지 왜 위임이 필요한 지를 모르겠습니다.
왜 위임이 필요한 지에 대해 알기 위해서는 약간의 수정이 필요합니다. payTax() 메서드를 외부로 빼고, RealMan 인터페이스에는 calculateTaxToPay() 메서드를 구현하겠습니다.
interface RealMan {
fun calculateTaxToPay(currentMoney: Int) : Int
}
class Father() : RealMan {
var money = 1000
override fun calculateTaxToPay(currentMoney: Int) : Int {
return currentMoney - 100
}
}
fun payTax(realMan: RealMan, currentMoney: Int) : Int {
return realMan.calculateTaxToPay(currentMoney)
}
이 경우, payTax() 메서드를 수행하기 위해서는 RealMan 인터페이스의 구현체가 필요합니다.
즉, 특정 메서드의 파라미터 타입에 맞는 객체를 넣어줘야 할 때, 위임이 필요합니다. 상속은 하지 않되, 상속 관계를 유지하고 싶은 상황입니다.
fun main() {
val father = Father()
val son = Son(father)
son.money = payTax(father, son.money)
println(son.money)
}
이렇게 구현할 수 있겠습니다. 다만, 여전히 payTax() 메서드의 realMan 파라미터로 father 가 들어가고 있습니다. 여기서, son 을 외부로 빼겠습니다.
val son = Son(Father())
fun main() {
son.money = payTax(son, son.money)
println(son.money)
}
콘솔에는 400이 출력됩니다. 비로소 위임한 것입니다.
이하는 전체 코드입니다.
interface RealMan {
fun calculateTaxToPay(currentMoney: Int) : Int
}
class Father() : RealMan {
var money = 1000
override fun calculateTaxToPay(currentMoney: Int) : Int {
return currentMoney - 100
}
}
class Son(father: Father) : RealMan by father {
var money = 500
}
val son = Son(Father())
fun main() {
son.money = payTax(son, son.money)
println(son.money)
}
fun payTax(realMan: RealMan, currentMoney: Int) : Int {
return realMan.calculateTaxToPay(currentMoney)
}
by
by 키워드는 (provided) by 로 생각하면 이해하기 좋습니다. RealMan (provided) by father 인 것이죠.
Son 클래스의 RealMan 에 대한 구현을 father 가 위임받아 수행해주는 것입니다. 별도의 위임 없이 해당 기능을 구현하기 위해서는 수많은 보일러 플레이트 코드가 양산되어야만 합니다. 그 수고를 by 키워드가 덜어주는 것입니다.
Delegation Pattern 은 보셨듯이 굉장히 강력하고, 코드의 재사용성을 극한으로 끌어올릴 수 있습니다.
Delegation Pattern 은 다음과 같은 상황에 사용하시면 되겠습니다.
- 상속 관계는 유지하고 싶지만 클래스를 상속하고 싶지 않을 때 (다형성 유지 목적)
- 인터페이스를 상속받고 싶지만 같은 코드를 여러 번 구현하고 싶지 않을 때 (코드 재사용 목적)
위 두 가지를 모두 준수할 수 있는 상황이면 Best-Choice 가 될 수 있겠습니다.
Delegation Pattern 에 대해 궁금증이 생기고 난 뒤, 수많은 블로그 포스트를 보면서 학습했지만 영 속이 시원하지 못했습니다. 블로그들의 여러 예제 코드를 직접 돌려봤는데, 아무런 의미나 제대로 된 목적의식 없이 메서드만 대신 사용해주는 식의 포스트가 대부분이었습니다.
왜 Delegation Pattern 이 필요한지, 어떠한 배경으로 인해 Delegation Pattern 이 등장했는지에 대한 진지한 탐구가 필요했고, 수 시간 동안 예제를 만들고 지우고 고쳐내는 시행착오를 거치고 나서야, 왜 Delegation Pattern 이 생겨났고 필요한지 배울 수 있었습니다.
'Kotlin' 카테고리의 다른 글
Kotlin Closure (0) | 2023.05.04 |
---|---|
Generics 와 Reified 키워드 (0) | 2023.04.21 |
확장 함수 간단 정리 (0) | 2022.11.15 |
ChannelFlow, CallbackFlow 제대로 알기 (0) | 2022.11.03 |
Channel 제대로 알기 (0) | 2022.11.02 |