Delegate Pattern

About

객체지향 프로그래밍의 디자인 방식인 상속과 델리게이션 중 델리게이션을 알아본다.

델리게이션(Delegation)이란?

델리게이션은 상속처럼 객체지향 프로그래밍의 디자인 방식이다. 두 방식 모두 클래스를 다른 클래스로부터 확장시킨다.

상속은 강력하고 자주 사용되기 때문에 강력하게 묶이고 수정하기가 어렵다. 상속이 가능한 대부분의 언어는 일단 상속하게 되면 클래스가 다른 베이스(부모) 클래스들 사이에서 선택할 권한을 주지 않는다.

델리게이션은 상속보다 유연하다. 객체가 처리할 일을 다른 클래스의 인스턴스에게 위임하거나 넘겨버릴 수 있다.

<GoF의 디자인 패턴>(프로텍미디어, 2015) 같은 설계론 책들은 가능한 경우 상속보다 델리게이션을 사용하라고 조언한다. <이펙티브 자바 3/E>(인사이트, 2018) 에서도 Java 프로그래머들에게 상속보다는 델리게이션을 사용할 것을 강하게 추천했다.

하지만 Java에서 델리게이션보다는 상속을 통한 재사용을 많이 봤을 것이다. 왜냐하면 Java는 상속에 대한 많은 지원을 해줬지만 델리게이션에 대한 지원은 약하기 때문이다. 반면 코틀린은 델리게이션을 위한 기능을 제공한다.

상속 대신 델리게이션을 써야 하는 상황

상속은 객체지향 언어에서 흔하고, 많이 사용되는 기능이다. 델리게이션은 더 유연하지만, 많은 객체지향 언어들은 따로 지원하지 않는다. 델리게이션을 사용하려면 상속보다 더 많은 노력이 들어가기 때문에 꺼려지기도 한다. 코틀린은 상속, 델리게이션 둘 다 지원하기 때문에 문제를 기반으로 적절한 해법을 선택하면 된다.

  • 클래스의 객체가 다른 클래스의 객체가 들어갈 자리에 쓰여야 한다면 상속을 사용해라.

  • 클래스의 객체가 단순히 다른 클래스의 객체를 사용만 해야 한다면 델리게이션을 사용해라.

Candidate 클래스는 왼쪽에서는 상속을 사용했고, 오른쪽에서는 델리게이션을 사용했다.

상속

왼쪽 그림에서 Candidate 클래스가 BaseClass를 상속 받을 때 Candidate 클래스의 인스턴스는 내부에 BaseClass의 인스턴스를 같이 갖고 다닌다고 볼 수 있다.

  • 베이스 인스턴스는(BaseClass)는 자식클래스(Candidate)와 분리시킬 수 없을 뿐 아니라 변경할 수도 없다.

  • 컴파일 시간에 BaseClass의 인스턴스를 참조하고 있는 Caller는 실행 시간에는 Candidate의 인스턴스로 사용될 수도 있다.

이것이 객체지향 언어에서는 상속을 통한 다형성의 매력이라고 생각하고 사용한다. 하지만 베이스 클래스에서 상속받은 인스턴스를 자식 클래스에서 마음대로 바꾸려는 행동은 오류를 일으킬 수 있다. <클린 소프트웨어>(제이펍, 2017)에서 설명된 리스코프 치환의 원칙(LSP)이 경고하듯 말이다. 문제는 자식 클래스에서 부모 클래스의 메소드를 오버라이드할 때 베이스 클래스의 외부 동작을 유지해야 한다는 것이다.

짧게 말해, 상속을 사용해 자식 클래스를 설계하면 많은 제약사항이 따른다.

델리게이션

오른쪽 그림에서 Candidate 클래스가 DelegateObject로 델리게이션을 하면 Candidate 클래스의 인스턴스는 델리게이션의 참조를 갖는다.

실제 델리게이션 클래스는 다양할 수 있다. Groovy, JavaScript 등 델리게이션을 지원하는 몇몇 언어에서는 런타임에서 Candidate 인스턴스에서 위임되는 객체를 변경할 수도 있다. 상속과는 다르게 인스턴스들은 분리가 가능하고, 덕분에 엄청난 유연성을 갖게 된다. CallerCandidate에게 요청을 보내고, Candidate는 적절하게 해당 요청을 한다.

선택

  • 클래스의 구현을 새로 하거나 한 클래스의 인스턴스를 '개는 동물이다'와 같이 포함 관계(IS-A)에 있는 다른 클래스로 대체할 때 상속을 사용하라.

  • 오직 다른 객체의 구현을 재사용하는 경우라면, 델리게이션을 사용하라.

델리게이션이 적절한 설계일 때 Java 같은 언어를 선택하면 많은 코드를 중복해서 작성해야 한다. 중복코드를 작성하게 되면 관리가 어려워진다. 코틀린은 델리게이션을 사용할 때 더 좋은 선언적인 접근 방식을 사용한다.

델리게이션을 사용한 디자인

상속이 방해 요소가 되는 시점을 알아보고, 문제 해결을 위해 델리게이션을 사용해본다.

디자인적 문제점

기업의 소프트웨어 프로젝트를 시뮬레이션하는 어플리케이션을 만들어 본다. 먼저 일을 할 작업자이다.

interface Worker {
    fun work()
    fun takeVacation()
}

작업자는 두 개의 일을 할 수 있다:

  1. 일을 한다.

  2. 가끔 휴가를 떠난다.

이제 Worker 중에서 각각의 언어에 특화된 두 개의 클래스를 구현해본다.

class JavaProgrammer : Worker {
    override fun work() = println("...write Java...")
    override fun takeVacation() = println("...code at the beach...")
}

class CSharpProgrammer : Worker {
    override fun work() = println("...write C#...")
    override fun takeVacation() = println("...branch at the ranch...")
}

회사에는 팀을 관리하기 위한 소프트웨어 개발 매니저가 필요하다.

class Manager

Manager 클래스는 아주 작고 효율적이며 코드에 쓰여있듯 아무 일도 하지 않는다. 매니저 인스턴스에서 work()를 호출해봐야 아무 의미가 없다. Manager에 로직을 넣기 위해 디자인을 해야 하는데, 이것은 쉬운 일이 아니다.

잘못된 경로로의 상속

회사는 프로젝트를 실행하고 전달하기 위해 Manager에 의존하게 된다. Manager는 프로그래머에게 일을 시킬 것이다. 가장 단순한 형태로 만들어보자면 Managerwork()를 호출하기 위해서는 Worker 내의 구현(work())을 실행하면 된다.

이를 위한 방법 중 하나는 상속을 사용하는 것이며, Java에서는 주로 상속을 사용한다. ManagerJavaProgrammer에서 상속받으면 Manager 클래스에서 구현을 다시 작성할 필요가 없다. 이런 점에서 상속이 매력적이다.

open class JavaProgrammer : Worker {
    // ...
}

그리고 Manager 클래스로 JavaProgrammer로부터 상속받는다.

class Manager : JavaProgrammer()

이제 새 Manager 인스턴스에서 work()를 사용할 수 있다.

val doe = Manager()
doe.work() // ...write Java...

잘 동작하지만 결함이 존재한다. Manager 클래스는 JavaProgrammer 클래스에 갇혀버려 CSharpProgrammer가 제공하는 구현을 사용할 수 없다.

이번에는 상속의 또 다른 예상치 못한 결과인 대체 가능성에 대해서 살펴보도록 한다.

우리는 ManagerJavaProgrammer나 다른 특정 언어의 프로그래머라고 단정지은 적 없다. 하지만 상속이 그렇게 만들었다.

val coder: JavaProgrammer = doe

의도된 디자인은 아니지만, 막을 방도가 없다. 우리가 의도하는 바는 JavaProgrammer 뿐 아니라 작업을 맡길 수 있는 모든 Worker에게 Manager가 의존하는 것이다.

어려운 델리게이션

Java 같은 언어는 상속을 위한 문법은 갖고 있지만 델리게이션에 대해서는 그렇지 않다.

델리게이션의 예제를 살펴본다. 아래의 코드는 Java에서 ManagerWorker에게 델리게이션을 사용하는 방식을 코틀린 코드로 나타낸 것이다.

class Manager(val worker: Worker) {
    fun work() = worker.work()
    fun takeVacation() = worker.work() // 쉬는게 쉬는게 아니다.
}

이제 사용해보자.

val doe = Manager(JavaProgrammer())
doe.work() // ...write Java...

이 방식이 일단 상속보다 좋은 점은:

  • ManagerJavaProgrammer의 클래스에 강하게 묶이지 않는다는 점이다. CSharpProgrammer는 물론 Worker를 구현하는 어떤 클래스의 인스턴스든 넘길 수 있다.

  • JavaProgrammer 클래스가 더 이상 상속을 해주지 않기 때문에 open을 입력할 필요가 없다.

하지만 이런 디자인은 바람직하지 못하다. 소프트웨어 디자인의 기본사항 몇 가지도 어기고 있다.

  • Manager 클래스에 work() 메소드를 구현했다. 해당 work() 메소드는 Manager 인스턴스가 참조로 가지고 있는 Worker의 인스턴스를 호출하는 기능만 갖고 있다. takeVacation() 메소드도 동일하게 worker의 참조를 호출한다. Worker에게 더 많은 메소드가 생긴다고 가정해보자. Manager에는 더 많은 호출 코드가 들어가야 할 것이다. 모든 호출 코드는 호출할 메소드명을 제외하고는 거의 비슷하다. 이는 <실용주의 프로그래머>(인사이트, 2014)에서 설명한 "DRY(Don't Repeat Yourself)" 원칙을 위반한다.

  • 그 외에도 <클린 소프트웨어>(제이펍, 2017)에서 논의된 바 있는 개방-폐쇄 원칙(OCP)도 지키지 못했다. OCP란 소프트웨어의 모듈(클래스, 메소드 등)은 확장에는 열려있어야 하고 변경에는 닫혀있어야 한다는 원칙이다. 클래스를 확장하기 위해서 클래스를 변경하면 안 된다는 뜻이다. 지금 구현된 디자인은 Worker 인터페이스에 deploy() 메소드를 추가하면 Manager가 해당 메소드를 위임하는 호출을 하기 위해 Manager 클래스도 변경하여 해당 메소드를 호출하는 메소드를 추가해야 한다. OCP의 위반이다.

이런 문제로 인해 Java 개발자들은 델리게이션보다는 상속을 사용하려는 경향이 있다. 델리게이션에 이런 문제가 생긴 이유는 언어의 지원이 부족하기 때문이다. 반대로 상속은 지원을 많이 받고 있지만, ManagerJavaProgrammer가 아니다(IS-A가 아니다). 상속을 사용한 모델링도 LSP를 위반한다.

코틀린은 이런 문제를 해결하기 위해 언어 수준의 델리게이션을 지원한다.

코틀린의 by 키워드를 사용한 델리게이션

위의 예제에서 델리게이션을 Java 방식으로 구현했다. Manager의 바디는 중복된 메소드 호출과 DRY, OCP 원칙 위반으로 지저분해졌다. 코틀린에서는 컴파일러에게 코드를 요청(라우팅)할 수 있다. 그래서 Manager는 보스답게 번거로운 작업 없이 일을 맡길 수 있다.

class Manager() : Worker by JavaProgrammer()

위의 예제에서 Manager는 어떤 메소드도 갖고 있지 않다. ManagerJavaProgrammer를 이용해 Worker 인터페이스를 구현하고 있다. 코틀린 컴파일러는 Worker에 속하는 Manager 클래스의 메소드를 바이트코드 수준에서 구현하고, by 키워드 뒤에 나오는 JavaProgrammer 클래스의 인스턴스로 호출을 요청한다. 즉, by 키워드를 통해 우리가 시간을 들여 수동으로 구현한 델리게이션을 대신 해준다.

val doe = Manager()
doe.work() // ...write Java...

언뜻 보기엔 상속을 이용한 방식과 아주 비슷해 보이지만, 주요한 차이점이 있다.

첫째, Manager 클래스는 JavaProgrammer를 상속받지 않았다. 이전에 상속을 이용한 예제에서는 Manager의 인스턴스를 JavaProgrammer 타입의 참조가 필요한 곳에 사용할 수 있었는데, 더 이상 그런 일은 발생하지 않고 오류를 발생시킨다.

val coder: JavaProgrammer = doe // ERROR: type mismatch

둘째, 상속을 사용한 솔루션에서 work() 메소드를 호출하는 것이 Manager 클래스에서는 구현되지 않았다. 대신 베이스 클래스로 요청을 넘겼다. 컴파일러가 내부적으로 Manager 클래스에 메소드를 생성하고 요청(라우팅)을 한다. 사실상 우리가 doe.work()를 호출하면 Manager 클래스의 보이지 않는 메소드인 work()를 호출하는 격이다. 이 메소드는 코틀린 컴파일러에 의해 합성되었고, 델리게이션에게 호출을 요청한다. 이 경우에는 클래스 선언 시 주어진 JavaProgrammer의 인스턴스에게 요청하게 된다.

위의 솔루션은 델리게이션의 가장 간단한 형태이다.

파라미터에 위임하기

worker by JavaProgrammer()라는 코드를 작성했다. 이런 구조로 작성하면 이슈가 존재한다.

  1. Manager 클래스는 오직 JavaProgrammer 인스턴스에만 요청할 수 있다.

  2. Manager의 인스턴스는 델리게이션에 접근할 수 없다. 즉, Manager 클래스 안에 다른 메소드를 작성하더라도 해당 메소드에서는 델리게이션에 접근할 수 없다는 뜻이다.

이런 제약은 인스턴스를 생성하면서 델리게이션을 지정하지 않고 생성자에 델리게이션 파라미터를 전달함으로써 해결할 수 있다.

class Manager(val staff: Worker) : Worker by staff {
    fun meeting() = println("organizing meeting with ${staff.javaClass.simpleName}")
}

val doe = Manager(CSharpProgrammer())
val roe = Manager(JavaProgrammer())
doe.work() // ...write C#...
doe.meeting() // organizing meeting with CSharpProgrammer
roe.work() // ...write Java...
roe.meeting() // organizing meeting with JavaProgrammer

Manager 클래스의 생성자는 staff라는 파라미터를 받고, val로 정의했기 때문에 속성이 된다. val이 제거된다면 파라미터로 남을 것이다. val이 사용되든 안되든 상관없이 클래스는 staff 파라미터를 델리게이션으로 사용한다.

Manager 클래스의 meeting() 메소드에서 staff에 접근할 수 있다. 왜냐면 staffManager 객체의 속성이기 때문이다. work() 같은 메소드를 호출하면 staff가 델리게이션이기 때문에 staff로 요청이 전달된다.

메소드 충돌 관리

코틀린 컴파일러는 델리게이션에 사용되는 클래스마다 델리게이션 메소드를 위한 랩퍼를 만든다. 사용하는 클래스와 델리게이션 클래스에 동일한 이름과 시그니처가 있는 메소드가 있더라도 코틀린이 해결하기 때문에 충돌이 일어나지 않는다.

이전 예제에서 Worker 인터페이스는 takeVacation() 메소드를 갖고 있고 Manager 클래스는 해당 메소드를 델리게이션인 Worker에게 위임했다. 비록 이것이

WIP

REF

거의 모든 내용은 다음 책에서 나왔다.

Last updated