Study/kotlin

[코틀린 프로그래밍] Chapter.09 델리게이션을 통한 확장

에디개발자 2021. 5. 11. 07:00
반응형

나를 닮았다고 한다...

먼저 글을 작성하기 전에 자바에는 없는 기능을 알려드립니다.

 

상속과 델리게이션은 클래스를 다른 클래스로부터 확장시키는 개념입니다. 코틀린은 객체 지향 프로그래밍을 디자인하면서 두 가지 방법 중 선택하여 사용할 수 있습니다. 상속은 부모와 자식이 강력하게 묶이고 수정할 수 없습니다. 일단 상속을 받으면 클래스는 부모의 클래스에 귀속되어 버립니다. 

델리게이션은 상속보다 유연한 개념입니다. 객체는 객체 자신이 처리해야 할 일을 다른 클래스 인스턴스에게 위임하거나 넘길 수 있습니다. 서로 다른 클래스의 인스턴스끼리 위임할 수 있습니다.

 

 

상속 대신 델리게이션을 쓰는 상황

- 상속 : 클래스의 객체가 다른 클래스의 객체가 들어갈 자리에 쓰여야 할 때
- 델리게이션 : 클래스의 객체가 단순히 다른 클래스의 객체를 사용만 해야할 때

 

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

디자인 문제

매니저가 프로그래머에게 일과 휴가를 주는 경우를 생각해봅니다. 그럼 먼저 일과 휴가를 할 수 있는 인터페이스를 생성합니다.

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

 

그리고 실제 일을 할 개발자가 인터페이스를 상속받습니다.

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

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

 

그 후 매니저가 자바 프로그래머에게 일과 휴식을 주기 위해 상속을 받아 사용해보겠습니다.

class Manager : JavaProgrammer() {
    override fun work() {
        super.work()
    }

    override fun takeVacation() {
        super.takeVacation()
    }
}

 

그리고 CSharp 개발자에게 일과 휴식을 주기 위해 상속을 해야하지만 하나의 클래스는 2개의 클래스를 상속받을 수 없습니다. Manager 클래스가 JavaProgrammer 클래스를 상속 받는 순간 Manager 클래스는 JavaProgrammer에 종속되는 클래스로 한정됩니다. 이와 같은 디자인 문제가 발생합니다. 이런 경우 자바에서는 아래와 같이 코드를 작성합니다.

class Manager(val worker: Worker) {
    fun work() = worker.work()
    fun takeVacation() = worker.takeVacation()
}

 

이처럼 코드를 작성한다면 Manager는 JavaProgrammer 혹은 CsharpProgrammer에 묶이지 않고 JavaProgrammer, CSharpProgrammer 클래스에서 open을 하지 않아도 되는 장점을 가지고 있습니다.

 

하지만 좋은 코드는 아닙니다. 인터페이스에 새로운 메서드를 추가할 때마다 추적하여 한개씩 추가해줘야하고 OCP원칙에도 위배됩니다.

OCP란?
객체지향 5대 원칙 SOLID 중 하나

확장에 대해 열려 있다.

이것은 모듈의 동작을 확장할 수 있다는 것을 의미한다. 애플리케이션의 요구 사항이 변경될 때, 이 변경에 맞게 새로운 동작을 추가해 모듈을 확장할 수 있다. 즉, 모듈이 하는 일을 변경할 수 있다.

수정에 대해 닫혀 있다

모듈의 소스 코드나 바이너리 코드를 수정하지 않아도 모듈의 기능을 확장하거나 변경할 수 있다. 그 모듈의 실행 가능한 바이너리 형태나 링크 가능한 라이브러리(예를 들어 윈도의 DLL이나 자바의 .jar)를 건드릴 필요가 없다

이러한 디자인 문제를 델리게이션을 사용하여 해결할 수 있습니다.

 

델리게이션

코틀린의 by 키워드를 사용하여 델리게이션을 구현할 수 있습니다.

class Manager : Worker by JavaProgrammer()  // by 키워드 사용하여 델리게이션 구현

val doe = Manager()
doe.work()

  by 키워드의 왼쪽에는 인터페이스를 선언하고 오른쪽에는 해당 인터페이스를 구현한 클래스를 작성합니다. 그리고 해당 클래스를 변수로 선언하여 인터페이스의 메서드를 호출하여 사용할 수 있습니다.

 

파라미터에 위임하기

위처럼 by 키워드를 이용해서 인터페이스를 구현한 클래스를 받아 클래스에서 사용할 수 있는 것을 확인하였습니다. 하지만 위 처럼 작성한클래스는 오로지 JavaProgrammer 클래스에서 구현한 메서드만 사용할 수 있는 문제점이 있습니다. 이를 해결하기 위해 파라미터에 델리게이션을 위임할 수 있습니다.

// 파라미터에 델리게이터 위임하기
class Manager(val staff: Worker) : Worker by staff {
    fun meeting() = println("org meeting with ${staff.javaClass.simpleName}")
}

// Use...
val doe = Manager4(JavaProgrammer4())
val reo = Manager4(CSharpProgrammer4())

 

클래스에서 파라미터로 Worker를 상속받은 인스턴스를 전달 받았습니다. 그리고 전달 받은 인스턴스를 델리게이션하여 사용할 수 있는 것을 확인했습니다. 코틀린은 이처럼 델리게이션을 파라미터에 위임하여 좀 더 유연하게 작성할 수 있습니다.

 

메소드 충돌 관리

코틀린 컴파일러는 델리게이션에 사용되는 클래스마다 델리게이션 메소드를 위한 렙퍼를 만듭니다. 위에서 작성한 예시를 변형하여 코드를 작성해보겠습니다. 

Worker 인터페이스에서 work() 메소드는 프로그래머 클래스 파일에서 실행하지만 takeVacation() 메서드는 매니저가 실행하도록 코드를 변경해야한다고 가정해봅니다. 이러한 경우 override 키워드를 이용하여 구현할 수 있습니다.

class Manager(val staff: Worker) : Worker by staff {
    override fun takeVacation() {    // Worker의 메서드를 override하여 재정의
        println("of course")
    }
}

 

다른 예로 Worker 인터페이스에 메소드를 추가하고 내부에 로직을 구현해봅니다.

interface Worker {
    fun work()
    fun takeVacation()
    fun fileTimeSheet() = println("Why...?")  // 내부에 로직 구현
}

 

인터페이스 내부에 로직을 구현한 경우 인터페이스를 상속받는 클래스에서 필수적으로 메서드를 구현하지 않아도 됩니다.

class JavaProgrammer: Worker {
    override fun work() {
        TODO("Not yet implemented")
    }

    override fun takeVacation() {
        TODO("Not yet implemented")
    }
    
    // fileTimeSheet() 메소드 구현 필수 X
}

 

지금까지 1개의 델리게이션을 하는 경우를 살펴보았습니다. 하지만 코틀린은 델리게이션을 복수로도 작성할 수 있습니다. 다음 예제로 살펴보겠습니다. 위에서 구현한 Worker 인터페이스를 포함하여 새로운 인터페이스를 추가해보겠습니다.

interface Worker {
    fun work()
    fun takeVacation()
    fun fileTimeSheet() = println("Why...?")
}

class JavaProgrammer: Worker {
    override fun work() {
        println("java work..")
    }

    override fun takeVacation() {
        println("go vacation!!")
    }
}

// new interface
interface Assistant {
    fun doChores()
    fun fileTimeSheet() = println("No!!")
}

class DepartmentAssistant : Assistant {
    override fun doChores() {
        println("routine stuff")
    }
}

 

위에서 선언한 2개의 인터페이스를 사용하는 델리게이션 코드를 살펴보겠습니다.

class Manager(val staff: Worker, val assistant: Assistant) : Worker by staff, Assistant by assistant {
    override fun fileTimeSheet() {
        assistant.fileTimeSheet()
    }
}

 

2개의 인터페이스에서 동일한 명의 메소드인 fileTimeSheet가 존재합니다. 이러한 경우 델리게이션에서는 동일한 명의 메소드를 구현을 강제시키고 override하여 재정의를 필수적으로 하고 있습니다. 그리고 아래의 코드와 같이 사용할 수 있습니다.

val manager = Manager(JavaProgrammer5(), DepartmentAssistant())

manager.work()            // java work..
manager.takeVacation()    // go vacation!!
manager.doChores()        // routine stuff
manager.fileTimeSheet()   // No!!

 

델리게이션 주의사항

Manager는 JavaProgrammer의 인스턴스에게 델리게이션을 요청했다. 하지만 Manager의 참조는 JavaProgrammer의 참조에 할당될 수 없습니다. 즉 Manager의 참조는 JavaProgrammer를 사용할 수 있지만 Manager를 JavaProgrammer로 사용할 수 없다는 말이다.

val coder: JavaProgrammer = doe // Error

val employee: Workder = doe // 가능

 

Manager가 Worker의 한 종류라는 뜻이다. 델리게이션의 진짜 목적은 Manager가 Worker를 이용한다. 하지만 코틀린의 델리게이션 구현의 부작용으로 Manager는 Worker로 취급된다. 델리게이션을 사용할 때 val을 사용하는 것이 좋습니다. 이유는 var로 사용했을 때 문제가 생깁니다. 아래 코드로 살펴보겠습니다.

// 델리게이션의 주의할 점
interface Worker {
    fun work()
    fun takeVacation()
}

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 csharp")
    override fun takeVacation() = println("branch at the beach")
}

class Manager(var staff: Worker) : Worker by staff

val manager = Manager(JavaProgrammer())
println("Staff is ${manager.staff.javaClass.simpleName}")
manager.work()

println("change staff")

manager.staff = CSharpProgrammer()
println("Staff is ${manager.staff.javaClass.simpleName}")
manager.work()

 

위 코드에 대해서 간략히 정리해보겠습니다. Worker Interface를 생성하고 Worker를 상속받는 JavaProgrammer, CSharpProgrammer가 있습니다. 그리고 클래스 Manager에서 델리게이션을 사용하여 인터페이스 Worker를 사용합니다. 그리고 첫 번째로 Manager에 JavaProgrammer를 변수로 받아 print합니다. 그리고 Manager의 staff에 CSharpProgrammer를 할당합니다. 다시 print합니다. 결과는 아래와 같습니다.

 

/**
 * Staff is JavaProgrammer
 * write Java
 * change staff
 * Staff is CSharpProgrammer
 * write Java
 */

Manager의 Staff 값은 변경되었으나 work 메소드의 print의 값은 변경되지 않았습니다. 이렇게 나온 이유는 Manager 클래스의 델리게이션 참조값을 변경한게 아니라 필드값만 변경했기 때문입니다. 이것 외에도 다른 문제가 발생합니다. staff를 CSharpProgrammer의 인스턴스로 변경했을 때 원래 사용하던 JavaProgrammer의 인스턴스엔 더 이상 접근이 불가능해집니다. 하지만 델리게이션이 JavaProgrammer의 인스턴스를 사용 중이기 때문에 가비지 컬렉터가 수집하지 않습니다.  그래서 속성이 변경되더라도 델리게이션의 생명주기가 객체의 생명 주기와 동일해집니다

 

변수와 속성 델리게이션

델리게이션은 객체의 속성과 지역변수에 접근하기 위한 설정도 가능합니다. 속성이나 지역변수를 읽을 때 코틀린 내부에서는 getValue() 메서드를 호출합니다. 반대로 설정할때는 setValue() 메서드를 호출합니다. 객체의 델리게이션을 위의 두 메소드와 함께 제공함으로써 우리는 객체의 속성과 지역변수를 읽고 쓰는 요청을 가로챌 수 있습니다. 

 

변수 델리게이션

지역변수의 읽고 쓰기 접근과 리턴되는 변수를 가로챌 수 있습니다. String 변수에 대한 접근을 가로채는 커스텀 델리게이션을 만들어보겠습니다. "stupid"라는 단어를 "s*****"로 변경하는 클래스를 생성하겠습니다.

import kotlin.reflect.KProperty

class PoliteString(var content: String) {
    operator fun getValue(thisRef: Any?, property: KProperty<*>) =
        content.replace("stupid", "s*****")

    operator fun setValue(thisRef: Any, property: KProperty<*>, value: String) {
        content = value
    }
}

PoliteString 클래스를 델리게이션으로만 작동하도록 되어있습니다. 위처럼 getValue(), setValue()를 operator 표기로 작성하여 "=" 기호로 사용할 수 있습니다. 구현한 클래스를 델리게이션으로 사용해보겠습니다.

 

var comment: String by PoliteString("Some nice Message")   // 델리게이션 사용

println(comment)  // Some nice Message

comment = "This is stupid"

println(comment)  // This is s*****
println("comment is of length: ${comment.length})  // comment is of length: 14

 comment 변수를 델리게이션을 사용하여 선언하고 값을 할당하여 결과값을 확인하였습니다. 이보다 더 나은 방법으로 by 뒤에 나오는 클래스의 생성자를 호출하기 보다 델리게이션 인스턴스를 리턴해주는 함수를 사용하길 원한다면 더 쉽고 편리하게 사용할 수 있습니다. 아래와 같이 작성합니다.

 

fun beingpolite(content: String) = PoliteString(content)  // 선언

var comment: String by beingpolite("Some nice message")  // use

이처럼 PoliteString 클래스 대신 함수를 사용할 수 있습니다.

 

속성 델리게이션

델리게이션은 위에서 사용한 지역변수뿐만 아니라 객체의 속성에도 접근할 수 있습니다. 속성을 정의할 때 값을 할당하는 게 아니라 by를 사용하고 그 뒤에 델리게이션을 위치하면 됩니다. 위에서 생성한 PoliteString 델리게이션을 변경해보겠습니다.

Map과 MutableMap은 델리게이션을 사용할 수 있습니다. 이유는 Map은 get() 과 getValue() 메소드 둘 다 가지고 있기 때문입니다. MutableMap 또한 set(), setValue() 메소드를 가지고 있습니다. 이 두 메서드를 이용하여 속성 델리게이션을 구현해보겠습니다. 

// List<Map>에서 특정 Key값에 해당하는 value값을 내가 원하는 형태로 convert한다.
class PoliteString(private val dataSource: MutableMap<String, Any>) {

    operator fun getValue(thisRef: Any?, property: KProperty<*>) =
        (dataSource[property.name] as? String)?.replace("stupid", "s*****") ?: ""

    operator fun setValue(thisRef: Any, property: KProperty<*>, value: String) {
        dataSource[property.name] = value
    }
    
}

 

위 예제는 getValue 메서드 블록 내에서 Map의 value 값이 stupid 라면 s*****로 replace하는 예제입니다. 이 예제 소스를 이용한 PostComment 클래스를 생성해보겠습니다. 

class PostComment(dataSource: MutableMap<String, Any>) {
    val title: String by dataSource
    var likes: Int by dataSource

    val comment: String by PoliteString(dataSource)
    override fun toString() = "Title : $title Likes: $likes Comment: $comment"
}

 

위 클래스에서 title은 읽기 전용 속성이고 likes는 읽고 쓰기 전용입니다.

 

title 속성을 읽을 때 dataSource 속성읜 getValue() 메소드를 실행합니다. 따라서 map은 key인 title의 값이 존재할 경우 값을 리턴합니다. 

likes 속성을 읽을 때는 title과 동작이 유사합니다. 하지만 likes는 뮤터블이기 때문에 set 될 때 델리게이션의 속성 이름인 likes와 set할 값을 전달하면서 serValue()를 실행시킵니다. 이 동작은 Mutable<String, Any> 인 dataSource에 있는 key가 likes인 곳에 값을 저장하는 결과로 나타납니다.

실제로 사용하여 결과값을 살펴보겠습니다.

val data = listOf(
    mutableMapOf<String, Any>(
        "title" to "Using Delegation",
        "likes" to 2,
        "comment" to "Keep it simple, stupid"
    ),
    mutableMapOf<String, Any>(
        "title" to "Using Ingeritance",
        "likes" to 1,
        "comment" to "Prefer Delegation where possible"
    )
)

val forPost1 = PostComment(data[0])
val forPost2 = PostComment(data[1])

forPost1.likes++  // likes는 뮤터블이기 때문에 값 변경 가능

println(forPost1) // 1)
println(forPost2) // 2)
// 1) 
// Title: Using Delegation Likes : 3 Comment: Keep it simple, s*****

// 2)
// Title: Usin Ingeritance Likes : 1 Comment: Prefer Delegation where possible

 

빌트인 스탠다드 델리게이션

코틀린은 몇 가지 빌트인 델리게이션을 제공합니다.

  • 지연 델리게이션 : 객체 생성이나 연산 실행이 실제 필요할 때까지 실행을 지연
  • observable 델리게이션 : 속성의 값이 변하는 것을 지켜보게 해주는 기능
  • Vetoable 델리게이션 : 기본 규칙이나 비즈니스 로직에 기반한 속성이 변경되는 것을 막아줌

 

지연 델리게이션

하나의 가정을 두고 예제를 작성해보겠습니다. 먼저 외부 통신을 하여 값을 얻어오는 메서드가 있다고 가정합니다.

// 온도를 얻을 수 있는 외부 함수가 있다고 가정!!
fun getTemperature(city: String): Double {
    println("fetch from webservice for $city")
    return 30.0
}

 

당연히 이 메서드는 가능하다면 호출을 안하는게 이득입니다. 아래의 코드의 경우로 지연 델리게이션이 필요한 경우를 살펴보겠습니다. 

val showTemperature = false
val city = "Boulder"
if (showTemperature && getTemperature(city) > 20)
    println("Warm")
else
    println("Nothing to report")

 

이 코드는 showTemperature가 false 이기 때문에 getTemperature 메서드를 호출하지 않는 좋은 예제입니다. 하지만 약간의 리팩토링으로 인해 효율성을 떨어뜨려보겠습니다. 

// 위 코드를 효율성 떨어지게 작성한 예시다.
val temperature = getTemperature(city) // else 일 경우에도 호출한다.
if (showTemperature && temperature > 20)
    println("Warm")
else
    println("Nothing to report")

 

이 코드는 showTemperature의 값과는 상관없이 무조건 getTemperature 메서드를 호출하고 있습니다. 이와 같은 경우 지연 델리게이션을 사용하여 정말로 getTemperature 메서드가 필요할 때까지 지연시키고 필요할 때에 실행시킬 수 있습니다.

/**
 * 위처럼 효율성 떨어지는 코드는 lazy로 대체한다.
 * lazy를 사용하면 해당 코드가 필요한 시점에서 실행된다.
 * 아래의 코드같은 경우에는 showTemperature가 true일 경우에만 실행된다.
 */

val temperature1 by lazy { getTemperature(city) }
if (showTemperature && temperature1 > 20)
    println("Warm")
else
    println("Nothing to report")

 

observable 델리게이션

이 함수는 연관된 변수나 속성이 변화를 가로채는 ReadWriteProperty 델리게이션을 만듭니다. 변화가 발생하면 observable() 메서드에 등록한 이번트 핸들러를 호출합니다. 이벤트 핸들러는 속성, 이전 값, 새로운 값에 대한 메타데이터를 가지고 있는 KProperty 타입의 파라미터를 3개 받습니다. 그리고 아무것도 리턴하지 않습니다. 예제로 살펴보겠습니다. 

// 모니터링할 때 유용할 듯?
var count by observable(0) { property, oldValue, newValue -> println("Property: $property OldValue: $oldValue NewValue: $newValue") }

println("The value of count is: $count")
count++
println("The value of count is: $count")
count--
println("The value of count is: $count")

 

결과는 아래와 같습니다.

/*
The value of count is: 0
Property: var Observe.count: kotlin.Int OldValue: 0 NewValue: 1
The value of count is: 1
Property: var Observe.count: kotlin.Int OldValue: 1 NewValue: 0
The value of count is: 0
 */

지역변수나 객체의 속성의 변화를 지켜보려고 한다면 옵저버블 델리게이션을 사용하자. 모니터링과 디버깅 목적으로 유용합니다,  변경 여부를 설정하려면 vetoable 델리게이션을 사용합니다.

 

 

Vetoable 델리게이션

vetoable 핸들러를 등록하면 Boolean 결과를 리턴받을 수 있습니다. 

  • true : 변경을 허가
  • false : 변경을 불허가

예제로 살펴보겠습니다.

var count by vetoable(0) { _, oldValue, newValue -> newValue > oldValue }

println("The value of count is: $count")
count++
println("The value of count is: $count")
count--
println("The value of count is: $count")

 

새로운 값이 이전 값보다 클 경우에만 count를 변경할 수 있습니다. 결과는 아래와 같습니다. 

/*
The value of count is: 0
The value of count is: 1
The value of count is: 1
 */

 

정리

- 이번 Chapter에서는 델리게이션을 다뤘습니다.
- by 키워들르 사용하면 getValue(), setValue() 메소드를 구현한 모든 객체에 델리게이션을 사용할 수 있다.
- lowceremony 기능을 사용하면 개발자가 쉽게 커스텀 델리게이션을 만들 수 있다.
- 코틀린 스탠다드 라이브러리의 빌트인 스탠다드 델리게이션을 사용할 수 있다. ex) by lazy...

반응형