상속과 델리게이션(위임) 모두 객체지향 프로그래밍의 디자인 방식입니다. 두 방식 모두 클래스를 다른 클래스로부터 확장하는데요. 개발자는 종종 두 방식 사이에서 선택을 해야 하는데, 언어적인 차원에서의 한계로 인해 선택을 제한하는 경우도 있지만 코틀린은 두 방식 모두를 지원해줍니다.
부모 클래스로부터 속성, 함수 등을 가지고 올 수 있는 상속이 가능한 대부분의 언어는 클래스가 다른 베이스 클래스들 사이에서 선택을 할 권한을 주지 않습니다. 일단 상속을 받으면, 해당 클래스에 귀속되어 버리게 됩니다.
반면에 델리게이션은 상속보다는 유연합니다. 객체는 객체 자신이 처리해야 할 일을 다른 클래스의 인스턴스에게 위임하거나 넘겨버릴 수 있습니다. 마치 한 부모를 가진 형제가 다른 친구를 가질 수 있는 것처럼 말이죠!
객체지향 디자인 패턴의 교과서라 불리는 <GoF의 디자인 패턴> 같은 책이나 <이펙티브 자바> 등 굉장히 유명한 저서에서는 상속(is-a)보다는 델리게이션(has-a)을 사용할 것을 강력하게 추천하고 있습니다. 아마 자바로 개발하신 분들이라면 델리게이션보다는 상속을 통한 재사용을 많이 보시고, 사용하셨을 텐데요. 이는 자바가 상속에 대해서는 많은 지원을 해줬지만 델리게이션에 대해서는 지원이 약하기 때문입니다. 코틀린은 자바와 달리 델리게이션을 위한 기능들을 문법적으로 제공합니다.
상속 대신 델리게이션을 써야 하는 상황
앞에 서론에서 다룬 내용만 본다면 상속은 마치 안티 패턴이고 무조건 델리게이션이 좋아보이는 것처럼 오해하실 수도 있는데요. 분명 상속과 델리게이션은 둘 다 유용합니다. 하지만 둘 중 하나가 다른 하나보다 유용한 경우엔 어떤 것을 사용할지 결정해야할 뿐인거죠.
상속은 객체지향 언어에서 흔하고, 많이 사용되는 최고의 기능입니다. 반면에 델리게이션은 상속보다 더 유연하지만, 많은 객체지향 언어에서 문법적으로 지원을 해주고 있진 않습니다. 그래서 델리게이션을 사용하려면 상속을 사용하는 것에 비해 더 많은 노력이 필요하기 때문에 사용을 꺼리기도 합니다. 코틀린은 델리게이션과 상속 모두를 지원하기 때문에 이런 고민으로부터 벗어나 직면한 문제를 기반으로 적절한 해법에 선택하기만 하면 됩니다.
상속과 델리게이션 중 어떤 것을 선택해야 할 지 고민이 될 때는 아래 규칙을 생각해보면 됩니다.
- 클래스의 객체가 다른 클래스의 객체가 들어갈 자리에 쓰여야 한다면 상속을 사용해라.
- 클래스의 객체가 단순히 다른 클래스의 객체를 사용만 해야 한다면 델리게이션을 사용해라.
상속을 사용하는 경우 부모 클래스에서 상속받은 인스턴스를 자식 클래스에서 마음대로 바꾸려는 행동은 오류를 일으킬 수 있습니다. 리스코프 치환 원칙에 의거하여 자식 클래스에서 부모 클래스의 메소드를 오버라이드할 때 부모 클래스의 외부 동작을 유지해야 한다는 것입니다. 이는 다시 말해, 상속을 사용해 자식 클래스를 설계하면 엄청난 제약사항이 따른다는 것을 의미합니다.
반면에 델리게이션 클래스는 다양할 수 있습니다. 상속과 다르게 인스턴스들은 분리가 가능하고, 그 덕분에 상속에 비해 구현에 제약사항이 줄어들어 엄청난 유연성을 갖게 됩니다.
만약 "개는 동물이다" 와 같이 포함 관계(is-a)에 있는 다른 클래스로 대체할 때는 상속을 사용하는게 맞습니다. 하지만 Manager 가 Worker 를 가지고 있고 Worker 에게 일을 넘기는 것처럼, 오직 다른 객체의 구현을 재사용 하는 경우라면 델리게이션을 사용하는게 좋습니다.
기존에 자바에서는 델리게이션이 적절한 설계일 때 많은 코드를 중복 작성해야 했습니다. 이는 언어적인 차원에서 델리게이션을 강력하게 지원해주지 않아 어쩔 수 없었는데요. 코틀린은 델리게이션을 사용할 때 더 좋은 선언적인 접근 방식을 사용합니다. 따라서 개발자는 컴파일러에게 편리하게 의도를 전달하고, 컴파일러는 필요한 코드를 생성하여 실행시킵니다.
델리게이션(Delegation)을 사용한 디자인
우선 델리게이션 문법을 배우기 전에, 상속이 아닌 델리게이션을 쓰는 이유를 더 잘 이해하기 위해서 상속을 이용해 작은 문제를 디자인 해보겠습니다. 지금부터 상속이 방해 요소로 변하는 시점과 문제 해결을 위해 델리게이션을 사용하는 이유를 알아볼 것입니다. 그 후에 코틀린에서 델리게이션을 상용해 디자인하는 법을 살포보도록 하겠습니다.
예제는 어느 한 소프트웨어 기업의 프로젝트를 시뮬레이션 해보도록 하겠습니다. 예제에서는 일을 할 작업자(Worker) 와 그들을 관리하는 관리자(Manager)가 필요합니다. Worker 중에서도 각각의 언어에 특화된 JavaProgrammer 와 PythonProgrammer 두 개의 클래스를 구현해보겠습니다.
interface Worker {
fun work()
fun takeVacation()
}
class JavaProgrammer : Worker {
override fun work() = println("code with Java")
override fun takeVacation() = println("code at the beach")
}
class PythonProgrammer : Worker {
override fun work() = println("code with Python")
override fun takeVacation() = println("code at the home")
}
그리고 회사에는 팀을 관리하기 위한 소프트웨어 개발 매니저가 필요합니다. 해당 클래스를 만들어보겠습니다.
class Manager
Manager 클래스에는 아직 아무런 정의가 되어 있지 않습니다. 이제 이 Manager 클래스에 로직을 넣기 위해 디자인을 해야 하는데 먼저 델리게이션이 아닌, 상속을 사용해 디자인해보도록 하겠습니다.
회사의 입장에서는 Programmer 도, Manager 도 모두 Worker 입니다. 회사는 프로젝트를 진행하기 위해 관리자에게 의존할 것이고, 관리자는 프로그래머에게 일을 시킬 것입니다. 가장 단순한 형태로 만들어보자면, Manager 가 work() 를 호출하기 위해서는 Manager 도 Worker 인터페이스를 구현하여 실행하면 됩니다.
이를 위한 방법 중 하나가 상속인 것이고, Java 에서는 주로 상속을 사용했습니다. Manager 를 JavaProgrammer 로부터 상속 받으면 Manager 클래스에서 구현을 다시 작성할 필요가 없게 됩니다.
open class JavaProgrammer : Worker {
override fun work() = println("code with Java")
override fun takeVacation() = println("code at the beach")
}
class Manager : JavaProgrammer()
상속을 위해 JavaProgrammer 클래스를 open class 로 변경해주고, Manager 클래스가 이를 상속하도록 변경했습니다.
그러면 이제 Manager 인스턴스에서 work() 를 사용할 수 있습니다.
val manager = Manager()
manager.work() // code with Java
위 코드를 실행하면 "code with Java" 라는 문자열을 출력하게 됩니다. 마치 관리자를 통해 자바 개발자에게 일을 시킨 것과 같이 동작하는 것으로 보여집니다.
하지만 이 디자인에는 문제점이 존재합니다. Manager 클래스는 JavaProgrammer 가 아님에도 불구하고 해당 클래스에 갇혀버리게 됩니다. 이제 Manager 에서는 PythonProgrammer 클래스가 제공하는 구현을 사용할 수 없습니다. 즉, JavaProgrammer 만을 위한 Manager 가 되어버렸습니다.
이것만이 문제가 아닙니다. 상속의 또 다른 예상치 못한 결과인 대체 가능성에 대해 살펴보겠습니다.
우리는 Manager 가 JavaProgrammer 나 특정 언어의 개발자라고 설정한 적이 없습니다. 하지만 상속이 그렇게 만들어버렸죠. 위 코드처럼 구현할 경우 아래와 같은 코드가 정상 동작하게 됩니다.
val coder: JavaProgrammer = manager
이는 명백하게 의도된 디자인이 아니지만, 막을 방도가 없습니다. 원래 우리가 의도했던 바는 JavaProgrammer 뿐만 아니라 작업을 맡길 수 있는 모든 Worker 객체에게 Manager 가 의존하는 것입니다. 그래서 우리는 Manager 의 인스턴스가 모든 종류의 Worker 인스턴스에게 일을 위임(Delegation)하게 만들길 원합니다.
그렇다면 이제 이것이 가능하도록 디자인해 보겠습니다. 그리고 어떻게 앞서 언급한 의도치 않은 동작을 발생시키지 않고 문제를 해결하는지 살펴보겠습니다.
Java 같은 언어는 상속을 위한 문법을 가지고 있어도, 델리게이션을 위한 문법은 없습니다. 아마 다른 객체를 참조할 수 있겠지만, 언어 차원에서 그런 디자인을 구현하는 부담을 모두 개발자에게 미뤄버립니다.
비록 코틀린이지만 잠시 동안 Java 에서 사용 가능한 기능만을 사용하여 변경해 보도록 하겠습니다. 아래의 코드는 Java 에서 Manager 가 Worker 에게 델리게이션을 사용하는 방식을 코틀린 코드로 나타낸 것입니다.
class Manager(val worker: Worker) {
fun work() = worker.work()
fun takeVacation() = worker.work() // 매니저가 쉬어도 작업자는 일을 해야하는...
}
fun main() {
val manager = Manager(JavaProgrammer())
manager.work() // code with Java
}
Manager 인스턴스를 만든 후 JavaProgrammer 인스턴스를 생성자로 전달했습니다. 이런 디자인이 상속을 이용하는 것보다 좋은 점은 Manager 가 JavaProgrammer 클래스에 강하게 묶이지 않아 언제든지 Manager 의 생성자에 PythonProgrammer 인스턴스 등 Worker 인터페이스를 구현하는 어떤 인스턴스라도 넘길 수 있게 됩니다.
이를 다르게 말하면, Manager 의 인스턴스는 Worker 인터페이스를 구현하는 클래스의 인스턴스에게 위임할 수 있다는 뜻입니다. 하지만 이런 디자인은 규모가 커짐에 따라 코드가 쉽게 장황해지고, 소프트웨어 디자인의 기본사항 몇 가지도 어길 수 있습니다.
Manager 클래스 안에 구현된 메소드들은 그저 Manager 인스턴스가 참조로 가지고 있는 Worker 의 인스턴스를 호출하는 기능만 가지고 있습니다. 만약 Worker 인터페이스에 더 많은 메소드가 있었다면 Manager 에는 더 많은 호출 코드가 들어가야 했을 것입니다. 모든 호출 코드는 호출할 메소드명도 그렇고 거의 비슷합니다. 이는 <실용주의 프로그래머> 저서에서 설명하는 DRY(Don't Repeat Yourself, 반복하지 말 것) 원칙 을 위반합니다. 뿐만 아니라 SOLID 원칙 중 확장에는 열려있어야 하고 변경에는 닫혀 있어야 한다는 개방-폐쇄 원칙(OCP, Open-Closed Principle) 도 지키지 못하게 됩니다. 다시 말해, 클래스를 확장하기 위해 클래스를 변경하면 안 된다는 것인데 만약 Worker 인터페이스에 deploy() 메소드를 추가한다면 Manager 클래스에서도 해당 메소드를 위임하는 호출을 하기 위해 메소드를 추가해야만 함으로 OCP 를 위반하게 됩니다.
코틀린은 이런 문제를 해결하기 위해 언어 수준에서 델리게이션을 지원합니다.
by 키워드를 사용한 델리게이션
이전 예제에서 Manager 클래스에 Worker 인터페이스로 요청을 위임하는 델리게이션을 구현했습니다. Manager 의 바디는 중복된 메소드 호출과 DRY, OCP 원칙 위반으로 다소 지저분해졌었습니다.
Java 에서는 저런 코딩만이 유일한 방법이지만 코틀린에서는 개발자가 직접 손대지 않고도 컴파일러에게 코드를 요청할 수 있습니다. 그래서 Manager 는 보스답게(?) 번거로운 작업 없이 일을 맡길 수 있습니다.
이제 이 문제를 해결하게 해주는 코틀린의 가장 간단한 델리게이션을 살펴보겠습니다.
class Manager() : Worker by JavaProgrammer()
이번 코드의 Manager 는 어떤 메소드도 따로 작성해주지 않았습니다. Manager 는 JavaProgrammer 를 이용해 Worker 인터페이스를 구현하고 있습니다. 코틀린 컴파일러는 Worker 에 속하는 Manager 클래스의 메소드를 바이트 코드 수준에서 구현하고, by 키워드 뒤에 나오는 JavaProgrammer 클래스의 인스턴스로 호출을 요청합니다. 다시 말하자면 위 예제의 by 키워드가 컴파일 시간에 이전 예제에서 우리가 시간을 들여서 수동으로 구현했던 델리게이션을 대신 해줍니다.
클래스 정의 수준에서 사용되는 코틀린의 by 키워드의 왼쪽에는 인터페이스가, 오른쪽엔 해당 인터페이스를 구현한 클래스가 필요합니다.
이제 잘 동작하는지 확인해보겠습니다.
val manager = Manager()
manager.work() // code with Java
언뜻 보기에는 상속을 이용한 구현과 아주 비슷하게 보입니다. 하지만 여기엔 몇 가지 주요한 차이점이 있습니다.
첫 째, Manager 클래스는 Worker 인터페이스를 구현하긴 하지만 JavaProgrammer 클래스를 상속받지 않습니다. 상속을 이용한 구현에서 우리는 Manager 의 인스턴스를 JavaProgrammer 타입의 변수에 저장할 수 있었습니다. 하지만 이제 그런 상황은 더 이상 발생하지 않으며, 아래와 같은 상황에서 오류가 발생합니다.
val coder: JavaProgrammer = manager // Error: type mismatch
둘 째, 상속을 사용한 솔루션에서 work() 같은 메소드를 위임하기 위해 그대로 호출하는 보일러플레이트 코드가 Manager 클래스에서는 작성되지 않았습니다. 대신 by 뒤에 오는 베이스 클래스로 요청을 넘겼을 뿐입니다. 사실상 우리가 manager.work() 를 호출할 때, 우리는 Manager 클래스의 보이지 않는 메소드인 work() 를 호출하는 격입니다. 이 함수는 코틀린 컴파일러에 의해서 합성되었고 델리게이션에게 호출을 요청합니다. 그래서 위 케이스에서는 클래스 선언 시 주어진 JavaProgrammer 의 인스턴스에게 요청하게 됩니다.
위의 구현은 델리게이션의 가장 간단한 형태였습니다.
다음 포스트에서는 이런 형태의 델리게이션이 갖는 제약사항과 그 해결책, 그리고 코틀린에서 다양한 형태로 지원되는 델리게이션에 대해 더 알아보겠습니다.
'Kotlin' 카테고리의 다른 글
[Kotlin] Kotlin Delegation 이해하기③ - 변수와 프로퍼티 델리게이션 (0) | 2021.07.27 |
---|---|
[Kotlin] Kotlin Delegation 이해하기② - 생성자 파라미터와 프로퍼티에서 사용하기 (3) | 2021.07.26 |
[Kotlin] 한 방에 정리하는 코틀린 제네릭(kotlin generic) - in, out, where, reified (4) | 2021.07.20 |
[Kotlin] 의외로 놓치기 쉬운 when, 제대로 알아보기 (0) | 2021.07.17 |
[Kotlin] 코틀린의 스마트한 타입 캐스팅(with. is) (0) | 2020.05.05 |