💡 Intro
Kotlin in Action의 말을 인용하면 시스템을 취약하게 만드는 문제는 보통 구현 상속에 의해 발생한다. 하위 클래스가 상위 클래스의 메서드 중 일부를 오버라이드하면 하위 클래스는 상위 클래스의 세부 구현 사항에 의존하게 되는데, 상위 클래스의 구현이 바뀌거나 상위 클래스에 새로운 메서드가 추가되면 하위클래스의 동작이 잘못될 수 있다.
즉, 하위 클래스가 상위 클래스에 대해 갖고있던 가정이 깨져서 코드가 정상적으로 동작하지 못하는 경우가 발생할 수 있다는 것이다.
코틀린은 이런 문제를 인식하고 기본적으로 클래스를 final로 취급하고 상속을 위해서는 open 키워드를 통해 열어두어야 한다. 열린 상위 클래스의 소스코드를 변경할 때는 open 키워드를 보고 "다른 클래스가 이 클래스를 상속받겠구나!"를 생각할 수 있고, 소스코드를 변경할 때 더 신중을 기울일 수 있다.
상속을 허용하지 않는 클래스에 새로운 동작을 추가할 때 사용하는 일반적인 방법이 데코레이터(Decorator)패턴이다. 이 패턴의 핵심은 상속을 허용하지 않는 클래스(기존 클래스) 대신 사용할 수 있는 새로운 클래스(데코레이터)를 만들되 기존 클래스와 같은 인터페이스를 데코레이터가 제공하게 만들고, 기존 클래스를 데코레이터 내부에 필드로 유지하는 것이다. 이 때 새로 정의해야 하는 기능은 데코레이터의 메서드에 새로 정의하고 기존 기능이 그대로 필요한 부분은 데코레이터의 메서드가 기존 클래스의 메서드에게 요청을 전달한다.
는데 책의 내용을 한 번에 읽고 이해하기란 정말 어려운 일이다..
🚀 클래스 위임
class Lotto(val numbers: Set<LottoNumber>)
위와 같은 코드가 있다면 Lotto
클래스 내에 있는 numbers에 대해 연산을 하기 위해서는 (numbers의 사이즈를 가져오거나 어떤 값을 포함하는지 여부를 검사 또는 map, forEach의 사용) lotto.numbers.size
혹은 lotto.numbers.map
과 같이 사용해야 한다. 굉장히 불편하게 느껴질 수 있는데 이를 클래스 위임 방식을 통해 해결할 수 있다.
Delegation | Kotlin
kotlinlang.org
레퍼런스에 따르면 클래스 위임이란 하나의 클래스를 다른 클래스에 위임하도록 선언하여 위임된 클래스가 가지는 인터페이스 메소드를 참조 없이 호출할 수 있도록 생성해주는 기능이다. 이게 무슨 말일까?
interface A { ... }
class B : A { }
val b = B()
// C를 생성하고, A에서 정의하는 B의 모든 메서드를 C에 위임한다.
class C : A by b
인터페이스 A와 A를 구현한 B가 있다고 하자. class C : A by b
의 의미는 A에서 정의하는 모든 B의 모든 메서드를 C에 위임한다는 것이다. 이 때 b는 A타입이여야 하며 C에 저장되어 있는 프로퍼티여야 한다. B에서 구현된 모든 A의 메서드는 이를 참조하는 형태의 정적 메서드로 생성된다.
즉, C는 B가 갖고있는 모든 A의 메서드를 구현하지 않아도 가질 수 있게 되는 것이고 이것을 클래스 위임이라고 하는 것이다.
코드를 조금 더 자세하게 작성해보자.
interface A {
fun printX()
}
class B(val x: Int) : A {
override fun printX() {
println(x)
}
fun printHello() {
println("hello")
}
}
class C(b: B) : A by b
fun main() {
val b = B(3)
val c = C(b)
c.printX()
}
A라는 인터페이스는 printX()
를 갖고있고, B는 A를 구현한 클래스다. 때문에 printX()
를 오버라이딩 해야한다. C는 B가 갖고있는 모든 A의 메서드를 가질 수 있다. 하지만 B만 가지고 있는 메서드는 가질 수 없다. 즉, C는 printX()
는 사용할 수 있지만 printHello()
는 사용할 수 없다는 말이다.
위 코드를 디컴파일해보자.
public interface A {
void printX();
}
public final class B implements A {
private final int x;
public void printX() {
int var1 = this.x;
System.out.println(var1);
}
public final void printHello() {
String var1 = "hello";
System.out.println(var1);
}
public final int getX() {
return this.x;
}
public B(int x) {
this.x = x;
}
}
public final class C implements A {
// $FF: synthetic field
private final B $$delegate_0;
public C(@NotNull B b) {
Intrinsics.checkNotNullParameter(b, "b");
super();
this.$$delegate_0 = b;
}
public void printX() {
this.$$delegate_0.printX();
}
}
A와 B의 디컴파일 결과는 예상할 수 있을 것이다. 그러니 C에 주목해보자.
C는 $$delegate\_0
가 B타입의 본래 인스턴스를 참조할 수 있도록 생성되며 printX()
를 갖고 있는데 $$delegate\_0
을 통해 호출하는 것을 볼 수 있다. 즉 B에 구현된 printX()
를 사용하는 것이다.
그럼 이 것을 적용한 코드를 예시로 들어보자.
package domain
class Lotto(val numbers: Set<LottoNumber>) {
constructor(vararg numbers: Int) : this(numbers.map(::LottoNumber).toSet())
init {
require(numbers.size == LOTTO_SIZE) { ERROR_LOTTO_SIZE }
}
fun toList(): List<Int> {
return numbers.map { LottoNumber ->
LottoNumber.toInt()
}
}
fun contains(number: LottoNumber): Boolean {
return numbers.contains(number)
}
companion object {
private const val LOTTO_SIZE = 6
private const val ERROR_LOTTO_SIZE = "로또 번호는 6개여야 합니다."
}
}
Lotto
클래스에서 특정 로또 번호를 가지고 있는지 확인하기 위해 contains()
라는 메서드를 만들었다. Lotto
를 잘 보면 프로퍼티가 Set<>
인 것을 볼 수 있다. Set<>
은 인터페이스이며 이미 contains()
메서드를 가지고있다. 굳이 함수를 만들지 않아도 Set<>
에 있는 메서드를 가져와 사용할 수 있는 것이다.
바로 다음과 같이 말이다.
package domain
import java.util.Spliterator
class Lotto(val numbers: Set<LottoNumber>):Set<LottoNumber> {
constructor(vararg numbers: Int) : this(numbers.map(::LottoNumber).toSet())
init {
require(numbers.size == LOTTO_SIZE) { ERROR_LOTTO_SIZE }
}
fun contains(number: LottoNumber): Boolean {
return numbers.contains(number)
}
companion object {
private const val LOTTO_SIZE = 6
private const val ERROR_LOTTO_SIZE = "로또 번호는 6개여야 합니다."
}
override val size: Int
get() = TODO("Not yet implemented")
override fun isEmpty(): Boolean {
TODO("Not yet implemented")
}
override fun iterator(): Iterator<LottoNumber> {
TODO("Not yet implemented")
}
override fun spliterator(): Spliterator<LottoNumber> {
return super.spliterator()
}
override fun containsAll(elements: Collection<LottoNumber>): Boolean {
TODO("Not yet implemented")
}
}
Lotto
를 Set
을 구현한 클래스라고 명시하면 Set<>
에 있는 contains()
를 사용할 수 있게 된다.
하지만 이렇게 하는 경우 이외에도 필수적으로 오버라이드 해야하는 메서드들을 적어줘야 한다. 단지 contains()
를 사용하기 위해서 추가해야 하는 코드들이 너무 많아지지 않았는가?
이것을 by
를 통해 해결할 수 있다.
by 키워드
package domain
class Lotto(val numbers: Set<LottoNumber>):Set<LottoNumber> by numbers {
constructor(vararg numbers: Int) : this(numbers.map(::LottoNumber).toSet())
init {
require(numbers.size == LOTTO_SIZE) { ERROR_LOTTO_SIZE }
}
fun toList(): List<Int> {
return numbers.map { LottoNumber ->
LottoNumber.toInt()
}
}
companion object {
private const val LOTTO_SIZE = 6
private const val ERROR_LOTTO_SIZE = "로또 번호는 6개여야 합니다."
}
}
class Lotto(val numbers: Set):Set by numbers
와 같이 작성해주면 numbers가 갖고있는 메서드 중 Set<>
에 있는 메서드를 Lotto
가 사용할 수 있게 되는 것이다. 오버라이드하지 않아도.
위 코드를 디컴파일해보자.
...
public int getSize() {
return this.numbers.size();
}
// $FF: bridge method
public final int size() {
return this.getSize();
}
public boolean contains(@NotNull LottoNumber element) {
Intrinsics.checkNotNullParameter(element, "element");
return this.numbers.contains(element);
}
// $FF: bridge method
public final boolean contains(Object var1) {
return !(var1 instanceof LottoNumber) ? false : this.contains((LottoNumber)var1);
}
public boolean containsAll(@NotNull Collection elements) {
Intrinsics.checkNotNullParameter(elements, "elements");
return this.numbers.containsAll(elements);
}
...
실제로 구현하지 않은 size()
, contains()
등의 함수가 구현되어 있는 것을 볼 수 있다.
📋 여담
by에 관련하여 찾아보다가 아무 생각없이 사용하던 by lazy를 보게됐다. 여기서 사용하는 by 역시 위임을 하는 것이고 코트린에서 제공하는 위임 표현 중 가장 유명한 표현이라고 볼 수 있다.
그런데 by
를 사용하여 클래스 위임을 하는 것이 항상 좋다고 볼 수 있을까? 에 대해서도 고민을 해봐야한다.
컬렉션 타입을 위임으로 사용하면, 컬렉션처럼 사용할 수 있다는 장점이 있다.
하지만 그렇게 되면 필요한 기능 외에도 컬렉션의 모든 기능을 사용할 수 있기 때문에 프로퍼티만 접근하지 않을 뿐이지 결국 비즈니스 로직을 뺏어서 작성했다고 볼 수 있다.
로또 미션을 진행하면서 클래스 위임 전략을 사용했었는데, 이것이 어떤 부분에서 문제가 될 수 있고 어떤 단점이 있는지 위와 같은 리뷰어님의 말씀을 통해서 약간 깨닫게 된 것 같다.
위임 전략을 사용하면 비즈니스 로직을 외부에서 작성하기 쉬워진다. 원하는 기능을 여기저기서 구현할 수 있다. 하지만 그만큼 도메인 로직을 명확하게 분리할 수 없을 수 있고 점점 MVC패턴을 지키기 어려워지면서 여러 코드가 한 파일안에 혼재될 수 있지 않을까?
이러한 이유 때문인지는 모르겠지만 리뷰어님은 위임전략 보다는 객체에게 메시지를 던져서 원하는 결과를 반환 받는 방법으로 유도해주셨다. 두 방법을 모두 구현해보면서 느꼈던 것은 필요한 정보를 얻기 위해 객체에 메시지를 던지는 방식으로 구현하는 것이 딱 필요한 기능만을 구현함으로써 제한을 둘 수 있고, 해당 클래스가 어떤 상태로 구현되어 있는지 모르고 사용하는 쪽에서도 헷갈리지 않고 사용할 수 있지 않을까 하는 생각을 했다.
앞으로 조금 더 생각이 필요한 부분은 어느 곳에서 위임 전략을 사용하는 것이 유리한 것인가 이다.