코틀린에서 자주 사용하는 클래스 패턴을 살펴보도록 한다. 이번 장에서는 다음의 내용에 대해서 생각 해 볼 수 있다.

  • ‘상속’을 활용해야 하는 경우
  • ‘data 클래스’를 사용해야 하는 경우
  • 하나의 메서드를 갖는 인터페이스를 함수 타입으로 사용해야 하는 경우
  • equals, hashCode 그리고 compareTo의 규약

상속보다는 컴포지션 사용하기

상속은 계층 구조를 만들기 위해 사용한다. 계층 간 관계가 명확하지 않을 때 상속을 사용하면 문제가 발생할 수 있다. 그러므로 단순히 코드 추출 또는 재사용을 위해 상속을 사용하기 보다는 컴포지션을 사용하는 것이 좋다.

간단한 행위(함수)의 재사용

맹목적인 상속은 다음과 같은 단점을 갖는다.

  • 상속을 통해 행위를 추출하다보면 많은 함수를 갖는 거대한 BaseXXX 클래스를 만들게 된다.
  • 상속은 클래스의 모든 것을 가져오므로, 불필요한 함수를 갖는 클래스가 되기도 한다.
  • 메서드(함수)의 작동방식을 이해하기 위해 수퍼클래스를 여러번 확인해야 할 수도 있다.

이러한 단점들 중 하나라도 해당 된다면, 컴포지션을 활용해보자.

컴포지션(Composition)이란 객체를 프로퍼티로 갖고, 함수를 호출하는 형태로 재사용 하는 것을 의미한다.

다음 예제 코드는 상속 구조를 컴포지션 구조로 변경한 것이다.

상속 구조로 푼 코드

abstract class Loader {

    abstract fun innerLoad()
    
    fun load(){
        // 프로그레스바 노출
        innerLoad()
        // 프로그레스바 숨김
    }
}

class ProfileLoader: Loader() {
    
    override fun innerLoad(){
        // 프로필 로드
    }
}

class ImageLoader: Loader() {
    
    override fun innerLoad(){
        // 이미지 로드
    }
}

상속 대신 컴포지션 사용

class Progress {

    fun show() {...} 
    
    fun hide() {...}
}

class ProfileLoader: Loader() {

    val progress = Progress()
    
    override fun innerLoad(){
        progress.show()
        // 프로필 로드
        progress.hide()
    }
}

class ImageLoader: Loader() {

    val progress = Progress()
    
    override fun innerLoad(){
        progress.show()
        // 이미지 로드
        progress.hide()
    }
}

컴포지션을 사용한 코드를 보면 기존 상속구조에서 벗어나 progress 객체를 함수내에서 사용하는 것을 확인할 수 있다.

상속은 모든 것을 가져온다.

상속은 수퍼 클래스의 모든 것을 가져온다. 그러므로 계층 구조를 나타 낼 때 굉장히 좋다. 다음 예제코드를 살펴보자

abstract class Dog {
    open fun bark() { ... }
    open fun sniff() { ... }
}
// 레브라도 리트리버
class Labrador: Dog()

// 로봇 개
class RobotDog: Dog() {
    override fun sniff(){
        // 로봇개는 냄새 맡는 기능(행위)이 없으므로, 이 함수는 불필요한 함수다.
        // 이는 인터페이스 분리 원칙 및 리스코프 치환원칙 위반이다.
    }
}

코틀린은 클래스의 다중상속을 허용하지 않으므로, 위의 로봇개의 경우 인터페이스로 개와 로봇이 갖는 특성을 추상화한 뒤 이를 모두 상속하여 RobotDog를 구현하거나, 컴포지션을 사용하여 구현할 수 있다.

캡슐화를 깨는 상속

다음과 같이 내부적인 구현 방법 변경에 의해 클래스의 캡슐화가 깨질 수도 있다.

class CounterSet<T>: HashSet<T>() {
    var elementsAdded: Int = 0
    private set
    
    override fun add(element: T): Boolean {
        elementsAdded++
        return super.add(element)
    }
    
    override fun addAll(elements: Collection<T>):Boolean {
        elementsAdded+=elements.size
        return super.addAll(elements)
    }
}
​
fun main() = runBlocking {
    val counterList = CounterSet<String>()
    counterList.addAll(listOf("A","B","C")) // 아이템 3개 넣었는데
    print(counterList.elementsAdded) // 결과는 6
}

컴포지션을 사용하여 CounterSet내부에서 HashSet객체를 관리하게 되면 문제를 해결할 수 있지만, 그렇게 하면 더이상 CounterSet이 Set이 아니게 된다. 그러므로 다음 예제와 같이 델리게이션 패턴을 사용할 수 있다.

class CounterSet<T>(
    private val innerSet: MutableSet<T> = mutableSetOf()
) : MutableSet<T> by innerSet{

    var elementsAdded: Int = 0
        private set

    override fun add(element: T): Boolean {
        elementsAdded++
        return innerSet.add(element)
    }
    
    override fun addAll(elements: Collection<T>):Boolean {
        elementsAdded += elements.size
        return innerSet.addAll(elements)
    }

}

코틀린의 ‘by’ 키워드를 사용했기때문에 코드가 짧아진 것이지, 컴포지션 구조 방식으로 구현시 오버라이드 한 메서드에서 내부 객체의 메서드를 델리게이팅 하는 경우 포워딩 메서드로 다 구현해줘야한다. 코드는 길어지지만 명확하며 가독성이 좋. 그러므로 코드가 짧은 간단히 코드를 작성하는 경우에만 컴포지션을 사용하자.

컴포지션은 재사용하기 쉽고, 더 많은 유연성을 제공한다.

오버라이딩 허용 및 제한

코틀린은 기본적으로 클래스의 상속과 메서드의 오버라이드(재정의)를 막아두었다. 오버라이드하기 위해서는 클래스 이름 또는 함수 선언 앞에 ‘open’ 키워드를 붙여야 한다. 서브 클래스의 상속을 막고 싶은 경우 final 키워드를 사용한다.

// Loader의 상속과 load 메서드 재정의를 허용
open class Loader{
    open fun load() { ... }
}

// Loader를 상속, 서브 클래스를 허용, load를 재정의
open class ImageLoader: Loader() {
    final override fun load(){...}
}

// load() 재정의 불가
class ProfileImageLoader: ImageLoader(){
    // 컴파일 오류
    override fun load(){...}
}

컴포지션과 상속의 특징

  • 컴포지션은 안전하다. 다른 클래스의 내부적인 구현에 의존하지 않고, 외부에서 관찰되는 동작에만 의존한다.
  • 컴포지션은 유연하다. 코틀린 클래스는 다중 상속이 불가하며, 컴포지션은 여러 클래스를 대상으로 사용 가능하다.
  • 컴포지션은 명시적이다. 리시버를 명시적으로 활용할 수 밖에 없으므로 메서드가 어디에 있는지 확실히 알 수 있다.
  • 컴포지션은 번거롭다. 상속을 사용할 때보다 코드를 수정해야 하는 경우가 더 많다.
  • 상속은 다형성을 활용한다. 이는 양날의 검이며, 상속을 할경우 수퍼클래스와 서브클래스의 규약을 잘 지켜야 한다.

일반 적인 경우 상속보다는 컴포지션을 먼저 고려하는 것이 좋다.

상속을 하는 경우 명확하게 ‘is-a’ 인 경우를 고려하자

“Labrado is a Dog”는 명확하게 참(true)이라고 말할 수 있지만 “RobotDog is a Dog”는 참 또는 거짓(false)이라고 명확하게 말하기 애매모호 하다. 그러므로 명확한 계층 구조를 갖기 힘들수 있다.

data 한정자 사용하기

데이터 셋을 전달해야 할 때가 있다. 이때 data 클래스를 사용한다.

data 클래스에는 몇가지 함수가 자동으로 생성된다.

  • toString : 클래스 이름, 모든 프로퍼티 출력
  • equals : 기본 생성자의 프로퍼티가 같은지 확인
  • hashCode : 비교한 두 객체가 동등하다면 동일한 버킷아이디 반환
  • copy : 기본 생성자 프로퍼티가 같은 새로운 객체로 (얕은)복제를 하며, 프로퍼티를 변경할 수도 있다.
  • componentN(component1, component2 등) : 위치를 기반으로 프로퍼티에 접근한다.

튜플 대신 데이터 클래스 사용하기

코틀린의 튜플은 Pair와 Triple이 있으며 Serializable을 기반으로 만들어 진다. 몇가지 케이스를 제외하면 일반적인 상황에서는 이러한 튜플 사용보다는 data 클래스를 사용하는 것이 좋다. 왜냐하면 Pair 또는 Triple 시 프로퍼티 이름이 없기 때문에 타입만 보고 인지하기 어려울 수 있기 때문이다.

data 클래스를 사용하는 것은 비용이 크지 않으며, 좁은 스코프에서만 사용하고 싶다면 가시성을 제한하면 된다. 튜플보다 data 클래스가 갖는 장점이 많으니 적극적으로 활용하는 것을 권장한다.

연산 또는 액션을 전달 할 때는 인터페이스 대신 함수 타입을 사용하기

연산 또는 액션을 전달할 때 메서드가 하나만 있는 인터페이스를 정의해서 활용한다. 이러한 인터페이스는 SAM(Single-Abstract Method)라고 부른다.

이러한 인터페이스를 사용하는 예제코드를 살펴보자

interface OnClick {
    fun onClick(view: View)
}

fun setOnClickListener(listener: OnClick){...}

setOnClickListener(object : OnClick {
    override fun onClick(view:View){...}
})

인터페이스 대신 함수타입을 사용하는 코드로 다음과 같이 변경해보자.

fun setOnClickListener(listener: (View) -> Unit) { ... }

함수 타입을 사용하면 다음과 같은 방법으로 파라미터를 전달할 수 있다.

setOnClickListener { ... } // trailing 람다 표현식
setOnClickListener(fun(view) {...}) // 익명 함수 전달
setOnClickListener(::println) // 함수 레퍼런스 전달

// 함수 타입을 구현한 객체로 전달
class ClickListener : (View) -> Unit {
    override fun invoke(view:View) { ... }
}
setOnClickListener(ClickListener())

타입별명(type-aliase)를 사용하는 방법도 있다.

typealias OnClick = (View) -> Unit
fun setOnClickListener(listener:OnClick) {...}

그렇다면 언제 SAM을 사용해야 할까?

정답: 코틀린이 아닌 다른 언어에서 사용할 클래스를 설계 할 때

일반적인 경우 함수 타입을 사용하는 것이 좋다.

태그 클래스보다는 클래스 계층 사용하기

태그 클래스(Tagged class)란 하나의 클래스 내에 Enum 타입, 상수, 각 태그별 필드, 분기코드 등 을 포함한 클래스를 일컫는다.

태그드 클래스는 다음과 같은 단점이 있다.

  • 한 클래스에 여러 보일러플레이트 코드가 추가된다.
  • 여러 목적으로 사용해야 하므로 프로퍼티가 일관적이지 않을 수 있다.
  • 객체가 제대로 생성되는지 보장받기 위해 팩토리 메서드를 사용해야 한다.

태그 클래스의 문제점을 해결하는 방법은 클래스 계층 구조로 변경하는 것이며, 코틀린에서는 sealed 클래스를 많이 사용한다.

sealed 한정자

sealed는 외부 파일에서 서브 클래스를 만드는 행위를 제한한다. 그러므로 when을 사용할 때 서브클래스별 분기를 만드는 경우 else를 선언할 필요가 없어진다. 왜냐하면 외부에서 서브클래스 타입이 추가되지 않을 것이라는게 보장되기 때문이다.

상태 패턴

상태 패턴은 객체의 내부 상태가 변화할 때, 객체의 동작이 변하는 디자인 패턴. MVC, MVP, MVVM 등과 같은 설계를 할 때 많이 사용된다.

equals 규약 지키기

자바에 Object가 있다면, 코틀린에는 Any가 있다. Any에는 다음과 같은 규약을 가진 메서드들이 있다.

  • equals: 동등성 확인
  • hashCode: 두객체가 동등하다면 버킷 아이디도 같다.
  • toString: 객체를 표현할 문자열

동등성

두가지 종류의 동등성이 있다.

  • 구조적 동등성 : ==연산자 또는 equals로 확인.
  • 레퍼런스적 동등성 : ===연산자로 확인
data class Player(
    val id:Int,
    val name: String,
    val points: Int
)

fun main() = runBlocking {
    val player1 = Player(0,"Charles", 9999)
    val player2 = Player(0,"Charles", 9999)
    
    println(player1.equals(player2)) // true
    println(player1 == player2) // true
    println(player1 === player2) // false
    
}

equals 규약

이 객체가 어떤 다른 객체와 같은지 확인할 때 equals를 사용한다. 다음의 요구사항을 충족해야 한다. 이미 아는 사실 이기도 하지만, 수학에서 ‘=’ 기호를 떠올리면 쉽게 받아들일 수 있다.

  • 반사적 동작: x가 null이 아니라면 x.equals(x) 은 항상 true
  • 대칭적 동작: x와 y가 null이 아니라면 x.equals(y), y.equals(x) 는 항상 같은 결과다
  • 연속적 동작: x, y, z null이 아니고, x.equals(y) 와 y.equals(z)가 true라면 x.equals(z)도 true다.(삼단논법)
  • 일관적 동작: x와 y가 null이 아닌 값이면 x.equals(y)는 항상 같은 결과를 반환한다. (멱등성)
  • 널과 관련된 동작: x가 null이 아닌 값이면 x.equals(null)는 항상 false를 반환한다.

URL과 관련된 equals 문제

java.net.URL은 equals를 잘못 설계 했다. 다음 예제 코드를 살펴보자

    val url1 = java.net.URL("https://www.charlezz.com")
    val url2 = java.net.URL("https://charlezz.com")
    println(url1==url2)

이 코드는 상황에 따라서 출력이 달라진다. java.net.URI를 사용해서 문제를 해결할 수 있다.

특별한 이유가 없는 이상 직접 equals를 좋지 않다. 기본적으로 제공되는 것을 그대로 쓰자.

hashCode 규약 지키기

hashCode함수는 해시 테이블을 구축할 때 사용된다. 해시테이블은 성능이 꽤 좋으며, 각 요소에 임의의 숫자를 부여하는 행위가 필요하다. 이를 해시 함수라고 하며 같은 요소라면 항상 같은 숫자를 반환한다. 자세한 내용은 생략한다.

가변성과 관련된 문제

요소가 추가 될 때만 해시 코드를 계산한다. 요소가 변경되어도 해시 코드는 계산되지 않고, 버킷 재배치도 이루어지지 않는다. 다음 예제 코드를 통해 통찰을 얻자.

data class FullName(
	var name: String,
    var surname: String
)

fun main() = runBlocking {
    val person = FullName("Charles", "com")
    val s = mutableSetOf<FullName>()
    s.add(person)
    println(person in s) // true
    person.surname = "Ok" // 여기서 person의 hashCode 값이 변경됨
    println(person) // FullName(name=Charles, surname=Ok)
    println(person in s) // false, 버킷의 재배치가 이루어 지지 않기 때문에 false 반환
    println(s.first() == person) // true
}

hashCode의 규약

hashCode와 equals는 일관성이 있는 동작을 해야한다. 다음의 규약을 살펴보자.

  • 어떤 객체를 변경하지 않았다면 hashCode는 여러 번 호출해도 그 결과가 항상 같아야 한다.
  • equals 함수 호출의 결과가 같다면 hashCode 메서드의 호출 결과도 같아야 한다.

만약 극단적으로 어떤 클래스의 hashCode함수가 항상 0을 반환한다면, 해당 클래스로 만들어진 객체는 모두 같은 버킷에 들어가므로 해시 테이블이 갖는 성능적인 측면의 이점을 활용하지 못하게 된다.

data 클래스를 사용하면 적당한 eqauls와 hashCode를 구현해주므로 이를 잘 활용하자. 그리고 기억하자. hashCode를 구현할 때 가장 중요한 규칙은 “언제나 equals와 일관된 결과가 나와야 한다는 것”을.

compareTo 규약 지키기

compareTo는 수학적인 부등식으로 변환되는 연산자다. 일반적으로 어느 객체를 다른 객체와 비교하여 순서를 지정하는데, 비교하는 객체와 같으면 0을 반환하고, 비교하는 객체보다 작으면 음수를, 크면 양수를 반환한다.

obj1 > obj2  // (obj1.compareTo(obj2) > 0) 으로 바뀐다.
obj1 < obj2  // (obj1.compareTo(obj2) < 0) 으로 바뀐다.  
obj1 >= obj2  // (obj1.compareTo(obj2) >= 0) 으로 바뀐다.  
obj1 <= obj2  // (obj1.compareTo(obj2) <= 0) 으로 바뀐다.  

compareTo는 다음과 같이 동작한다.

  • 비대칭적 동작: equals의 것과 유사
  • 연속적 동작: equals의 것과 유사
  • 코넥스적 동작: 두 요소는 확실한 관계를 갖고 있어야 한다. 즉, a >= b 또는 b >= a 중에 적어도 하나는 항상 true여야 한다. 코넥스 관계가 없으면 정렬 알고리즘을 사용할 수 없다.

compareTo를 직접 정의해야하는 경우는 거의 없다. 그러므로 예외에 대한 내용은 생략한다.

API의 필수적이지 않는 부분을 확장함수로 추출하기

클래스를 설계하다보면 메서드를 멤버로 정의할 지 확장함수로 따로 뺄지 고민하게 된다. 정답은 없으므로 두가지 방식의 장단점을 명확하게 파악하고 상황에 맞게 사용해야 한다.

  • 확장 함수는 일반적으로 다른 패키지에 위치한다.
  • 같은 이름으로 다른 동작을 하는 확장함수가 있을 경우 위험할 수 있다.
  • 멤버 메서드와 확장함수명이 같으면 멤버 메서드가 우선 순위를 갖는다.
  • 확장 함수는 클래스 위가 아니라 타입 위에 만들어지므로, 클래스 레퍼런스에 나오지 않는다
  • 확장 함수는 실제로 확장하는 클래스를 수정하지 않는다. 정적으로 전달된다.
  • 확장 함수는 가상(virtual)함수가 아니다. 그러므로 파생 클래스에서 오버라이드 할 수 없다. 즉, 상속을 목적으로 설계된 요소는 확장함수로 만들면 안된다.

가상(virtual)함수라는 것은 런타임에 결정된 함수를 말한다. 그렇기 때문에 함수가 호출되기 전에 오버라이드 할 수 있으며 코틀린에서는 open 또는 abstract 키워드를 붙여 가상함수를 만들 수 있다. c++에 익숙하다면 c++에서 사용하는 virtual 키워드와 일맥상통한다는 것을 눈치 챌 수 있다.

확장함수는 우리에게 더 많은 자유와 유연성을 준다. 확장 함수는 상속, 어노테이션 처리 등을 지원하지 않고, 클래스 내부에 없다. API의 필수적인 부분은 멤버로 두는 것이 좋지만, 필수적이지 않은 부분은 확장함수로 만드는 것이 여러모로 좋다.

멤버 확장함수의 사용을 피하라

멤버 확장을 피해야 하는 몇가지 이유

  • 함수 레퍼런스를 지원하지 않음
  • 암묵적 접근을 할 때 두 리시버 중에 어떤 리시버가 선택될지 혼란스럽다.
class A {
    val a = 10
}
class B {
    val a = 20
    val b = 30
    
    fun A.test() = a+b // 여기서 a는 10인가 20인가
}
  • 마찬가지로 확장함수가 외부에 있는 다른 클래스를 리시버로 받을 때, 해당 함수가 어떻게 동작하는지 애매모호 하다.
class A { ... }
class B { 
    
    fun A.update() = ... // 누굴 위한 업데이트인가
}

일반적으로는 멤버 확장함수 사용은 피하는 것이 좋고, 사용하는 경우 단점을 명확히 인지하자. 그리고 가시성 제한자를 적극 활용하여 외부에 노출되지 않도록 하자.

카테고리: Kotlin

0개의 댓글

답글 남기기

Avatar placeholder

이메일은 공개되지 않습니다. 필수 입력창은 * 로 표시되어 있습니다.