Kotlin

[Kotlin] 클래스 위임과 by, Decorator(데코레이터) 패턴

sh1mj1 2024. 1. 10. 14:25

 

Kotlin in Action 을 공부하고 Effective kotlin 의 내용을 조금 참조하여 정리한 글입니다.

 

이미지 출처           https://commons.wikimedia.org/wiki/File:Kotlin_Icon.svg

 

 

 

보통 객체지향 시스템을 설계할 때 시스템을 취약하게 만드는 것은 구현 상속(implementation inheritance)에 의해 발생한다.

이와 관련해서는 이 글이 글에서 설명한다.

 

그래서 코틀린은 기본적으로 클래스를 `final` 로 하여 기본적으로 상속을 금지하고 있다.(not `open`)

Decorator(데코레이터) 패턴

하지만 종종 상속을 허용하지 않는 클래스에 새로운 동작을 추가해야 할 때가 있다.

이럴 때 사용하는 일반적인 방법이 Decorator(데코레이터) 패턴이다.

 

이 패턴의 핵심은 상속을 허용하지 않는 클래스(기존 클래스) 대신 사용할 수 있는 새로운 클래스(데코레이터)를 만들되, 기존 클래스와 같은 인터페이스를 데코레이터가 제공하게 만들고, 그 인터페이스(혹은 기존 클래스)를 데코레이터 내부에 필드로 유지하는 것이다.

이는 상속 대신 합성을 사용하는 것과 비슷한 원리이다.

 

새로 정의해야 하는 기능은 데코레이터의 메서드에 새로 정의하고, 기존 기능이 그대로 필요한 부분은 데코레이터의 메서드가 기존 클래스의 메서드에게 요청을 forwarding(전달) 하면 된다.

(데코레이터 패턴에 대해 더 자세히 알고 싶다면 이 글을 참조)  

 

아래 그림에서 데코레이터 패턴의 예시 그림을 볼 수 있다.

`BaseDecorator` 가 내부에 `ConcreteComponent` 의 인터페이스를 필드로 유지하고 있다.

 

그런데 이런 접근 방법의 단점이 있다.

준비 코드가 상당히 많이 필요하다는 점이다.

by 없이 데코레이터 패턴 구현하기

예를 들어보자.

`Collection` 인터페이스를 구현하는 `DelegatingCollection1` (동작 변경 X)

class DelegatingCollection1<T> : Collection<T> {
    private val innerList = arrayListOf<T>()

    override val size: Int = innerList.size
    override fun isEmpty(): Boolean = innerList.isEmpty()
    override fun iterator(): Iterator<T> = innerList.iterator()
    override fun contains(element: T): Boolean = innerList.contains(element)
    override fun containsAll(elements: Collection<T>): Boolean = innerList.containsAll(elements)
}

모든 동작이 그대로임에도 불구하고, 위처럼 복잡한 코드를 작성해야 한다.

 

그런데 코틀린에서는 이런 위임을 언어가 제공하는 일급 시민(일급 객체) 기능으로 지원한다.

인터페이스를 구현할 때 `by` 키워드를 통해서 그 인터페이스에 대한 구현을 다른 객체에 위임 중이라는 사실을 명시할 수 있다. 

(`ArryList` 인 `innerList` 에 위임하고 있다.)

 

`by` 키워드로 `Collection` 을 구현하는 `DelegatingCollection` (동작 변경 X)

class DelegatingCollection<T>(
    innerList: Collection<T> = ArrayList<T>()
) : Collection<T> by innerList { }

클래스 안에 있던 모든 메서드(forwarding 메서드) 정의가 사라졌다. 

컴파일러는 그 메서드들을 자동으로 생성해준다. 생성된 코드는 `DelegatingCollection1` 과 비슷하다.

 

일부 메서드 동작을 변경하고 싶다면, 그 메서드를 오버라이드하면 된다.

그렇다면, 컴파일러가 생성한 메서드 대신 오버라이드한 메서드가 쓰인다.

by 사용하여 Decorator 패턴 구현하기

이 기법을 사용하여 원소를 추가하려고 시도한 횟수를 기록하는 컬렉션을 구현해보자.

 

원소 추가 시도 횟수를 기록하는 `CoutingSet` (`MutableCollection` 을 decorate)

class CountingSet<T>(
    val innerSet: MutableCollection<T> = HashSet<T>(),
) : MutableCollection<T> by innerSet {
    var objectsAdded = 0

    override fun add(element: T): Boolean {
        objectsAdded++
        return innerSet.add(element)
    }

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

@Test
fun testCountingSet() {
    val cSet = CountingSet<Int>()
    cSet.addAll(listOf(1, 1, 2))
    assertTrue { cSet.objectsAdded == 3 && cSet.size == 2 }
}

`CoutingSet` 에서는 `MutableCollection` 의 구현을 `innerSet` 에게 위임하고 있다.

그리고 `add` 와 `addAll` 메서드에 대해서는 위임하지 않고 새로운 구현을 제공한다.

나머지 메서드는 내부 컨테이너(`HashSet` 객체인 `innerSet`)에게 위임한다.

 

여기서 `CountingSet` 에서 기본 컬렉션의 구현 방식에 대한 의존 관계가 생기지 않는다는 점이 중요하다.

클라이언트 코드가 `CountingSet` 의 코드를 호출할 때 발생하는 일은 `CountingSet` 안에서 마음대로 제어할 수 있지만,

`CountingSet` 코드는 위임 대상 내부 클래스인 `MutableCollection` 에 작성된 API 를 활용한다.

즉, 인터페이스 `MutableCollection` 이 변경되지 않는 한 `CountingSet` 코드가 계속 잘 작동할 것이다.

 

데이터를 암호화, 복호화, 압축, 압축풀기하는 예시

데이터를 암호화, 복호화하고 압축, 압축풀기하는 데코레이터의 예시를 자바로 구현한 적이 있다. (해당 코드와 글)

그렇다면 이 예시 코드를 코틀린으로 구현해보자. 각 클래스에 대한 설명은 위 글을 참고하세요.

 

`DataSource` 인터페이스

interface DataSource {
    fun writeData(data: String)
    fun readData(): String
}

 

`DataSourceDecorator`: Base Decorator 클래스

open class DataSourceDecorator(
    val dataSource: DataSource,
) : DataSource by dataSource

 

`FileDataSource`

class FileDataSource(val fileName: String) : DataSource {
    override fun writeData(data: String) = File(fileName).outputStream().use { it.write(data.toByteArray()) }
    override fun readData(): String =
        File(fileName).inputStream().bufferedReader().use { it.readText() }
}

 

`EncryptionDecorator`

class EncryptionDecorator(dataSource: DataSource) : DataSourceDecorator(dataSource) {
    override fun writeData(data: String) = super.writeData(encode(data))
    override fun readData(): String = decode(super.readData())
    private fun encode(data: String): String {
        val result = data.toByteArray().map { (it + 1).toByte() }.toByteArray()
        return Base64.getEncoder().encodeToString(result)
    }
    private fun decode(data: String): String {
        val result = Base64.getDecoder().decode(data).map { (it - 1).toByte() }.toByteArray()
        return String(result)
    }
}

 

`CompressionDecorator`

class CompressionDecorator(source: DataSource) : DataSourceDecorator(source) {
    private var compLevel = 6
    override fun writeData(data: String) = super.writeData(compress(data))
    override fun readData(): String = decompress(super.readData())
    private fun compress(data: String): String {
        val bos = ByteArrayOutputStream()
        DeflaterOutputStream(bos, Deflater(compLevel)).use { it.write(data.toByteArray()) }
        return Base64.getEncoder().encodeToString(bos.toByteArray())
    }
    private fun decompress(data: String): String {
        val bytes = Base64.getDecoder().decode(data)
        val ios = InflaterInputStream(ByteArrayInputStream(bytes))
        return ios.bufferedReader().use { it.readText() }
    }
}

이렇게 구현할 수 있다.

 

테스트(클라이언트) 코드

@Test
fun testDataSource() {
    val salaryRecords = "Name,Salary\nJohn Smith,100000\nSteven Jobs,912000"

    val encoded = CompressionDecorator(EncryptionDecorator(FileDataSource("out/OutputDemo.txt")))
    encoded.writeData(salaryRecords)
    val plain = FileDataSource("out/OutputDemo.txt")

    println("- Input ----------------")
    println(salaryRecords)
    println()

    println("- Encoded --------------")
    println(plain.readData())
    println()

    println("- Decoded --------------")
    println(encoded.readData())
}

// print
/*
- Input ----------------
Name,Salary
John Smith,100000
Steven Jobs,912000

- Encoded --------------
Zkt7e1Q5eU8yUm1Qe0ZsdHJ2VXp6dDBKVnhrUHtUe0sxRUYxQkJIdjVLTVZ0dVI5Q2IwOXFISmVUMU5rcENCQmdxRlByaD4+

- Decoded --------------
Name,Salary
John Smith,100000
Steven Jobs,912000
*/