관리 메뉴

나만을 위한 블로그

[이펙티브 코틀린] 아이템 34. 기본 생성자에 이름 있는 옵션 아규먼트를 사용하라 본문

책/Effective Kotlin

[이펙티브 코틀린] 아이템 34. 기본 생성자에 이름 있는 옵션 아규먼트를 사용하라

참깨빵위에참깨빵_ 2023. 1. 30. 18:03
728x90
반응형

객체를 정의, 생성하는 방법을 지정할 때 사용하는 가장 기본적인 방법은 기본 생성자를 사용하는 것이다.

 

fun main() {
    val user = User("김", "철수")
}

class User(var name: String, var surname: String)

 

기본 생성자는 편리하다. 따라서 일반적으론 이를 활용해서 객체를 만드는 게 좋다. 기본 생성자로 객체를 만들 때는 객체의 초기 상태를 나타내는 아규먼트를 전달한다. 데이터를 표현하는 가장 기본적인 데이터 모델 객체는 생성자로 상태를 초기화한 뒤 그 프로퍼티를 유지한다.

 

data class Student(
    val name: String,
    val surname: String,
    val age: Int
)

 

아래 코드는 인덱스가 붙어 있는 글을 출력하는 프레젠터 객체다. 이런 객체는 기본 생성자를 써서 종속성을 주입할 수 있다.

 

class QuotationPresenter(
    private val view: QuotationView,
    private val repo: QuotationRepository
) {
    private var nextQuoteId = -1
    
    fun onStart() {
        onNext()
    }
    
    fun onNext() {
        nextQuoteId = (nextQuoteId + 1) % repo.quotesNumber
        val quote = repo.getQuote(nextQuoteId)
        view.showQuote(quote)
    }
}

 

QuotationPresenter는 기본 생성자에 선언돼 있는 프로퍼티보다 더 많은 프로퍼티를 갖고 있다. 현재 코드에서 nextQuoteId 프로퍼티는 항상 -1로 초기화된다. 이처럼 프로퍼티는 기본 생성자로 초기화되어도, 디폴트 값을 기반으로 초기화되도, 어떻게든 초기화만 되면 큰 문제가 없다.

일반적으로 기본 생성자가 좋은 방식인 이유를 이해하려면 일단 생성자와 관련된 자바 패턴을 이해하는 게 좋다.

 

  • 점층적 생성자 패턴
  • 빌더 패턴

 

점층적 생성자 패턴

 

이 패턴은 "여러 종류의 생성자를 사용하는" 굉장히 간단한 패턴이다.

 

class Pizza {
    val size: String
    val cheese: Int
    val olives: Int
    val bacon: Int

    constructor(size: String, cheese: Int, olives: Int, bacon: Int) {
        this.size = size
        this.cheese = cheese
        this.olives = olives
        this.bacon = bacon
    }

    constructor(size: String, cheese: Int, olives: Int):
            this(size, cheese, olives, 0)
    constructor(size: String, cheese: Int): this(size, cheese, 0)
    constructor(size: String): this(size, 0)
}

 

이 코드는 그렇게 좋은 코드가 아니다. 코틀린에선 일반적으로 아래처럼 디폴트 아규먼트를 사용한다.

 

class Pizza(
    val size: String,
    val cheese: Int = 0,
    val olives: Int = 0,
    val bacon: Int = 0,
)

 

이런 디폴트 아규먼트는 코드를 단순, 깔끔하게 만들어줄 뿐 아니라 점층적 생성자보다 훨씬 다양한 기능을 제공한다.

예를 들어 size, olives를 아래 같은 형태로 지정할 수도 있다.

 

fun main() {
    val myFavorite = Pizza("L", olives = 3)
}

 

이외에도 다른 이름 있는 아규먼트를 넣어서 아래처럼 초기화할 수도 있다.

 

fun main() {
    val myFavorite = Pizza("L", olives = 3, cheese = 1)
}

 

디폴트 아규먼트가 생성자보다 좋은 이유

 

  • 파라미터들의 값을 원하는 대로 지정할 수 있다
  • 아규먼트를 원하는 순서대로 지정할 수 있다
  • 명시적으로 이름을 붙여 아규먼트를 지정하므로 의미가 훨씬 명확하다

 

마지막 이유가 상당히 중요하다. 아래처럼 객체를 만든다고 친다.

 

fun main() {
    val villagePizza = Pizza("L", 1, 2, 3)
}

 

코드가 짧지만 무슨 의미인지 이해할 수 있는가? Pizza 클래스를 만든 사람도 사실 어떤 위치가 베이컨인지 치즈인지 올리브인지 구분 못할 것이다. 물론 IDE가 여러 설명을 해 줄 것이다. 하지만 깃허브 등으로 단순하게 코드를 읽는 사람은 그런 지원을 받을 수 없다.

만약 이름 있는 아규먼트를 활용해서 명시적으로 이름을 붙이면 의미가 훨씬 명확해진다.

 

fun main() {
    val villagePizza = Pizza(
        size = "L",
        cheese = 1,
        olives = 2,
        bacon = 3
    )
}

 

디폴트 아규먼트를 쓰는 생성자가 점층적 생성자 패턴보다 훨씬 강력하다. 자바는 객체를 만들 때 점층적 생성자 패턴 외에 빌더 패턴도 많이 사용한다.

 

빌더 패턴

 

자바에선 이름 있는 파라미터, 디폴트 아규먼트를 쓸 수 없다. 그래서 자바에선 빌더 패턴을 사용한다. 빌더 패턴을 쓰면 아래와 같은 장점이 있다.

 

  • 파라미터에 이름을 붙일 수 있다
  • 파라미터를 원하는 순서대로 지정할 수 있다
  • 디폴트 값을 지정할 수 있다

 

빌더 패턴을 코틀린으로 만들면 아래와 같다.

 

class Pizza private constructor(
    val size: String,
    val cheese: Int = 0,
    val olives: Int = 0,
    val bacon: Int = 0,
) {
    class Builder(private val size: String) {
        private var cheese: Int = 0
        private var olives: Int = 0
        private var bacon: Int = 0
        
        fun setCheese(value: Int): Builder = apply { 
            cheese = value
        }
        
        fun setOlives(value: Int): Builder = apply { 
            olives = value
        }
        
        fun setBacon(value: Int): Builder = apply { 
            bacon = value
        }
        
        fun build() = Pizza(size, cheese, olives, bacon)
    }
}

 

빌더 패턴을 활용하면 아래처럼 파라미터에 이름을 붙여 지정할 수 있다.

 

fun main() {
    val myFavorite = Pizza.Builder("L").setOlives(3).build()
    val villagePizza = Pizza.Builder("L")
        .setCheese(1)
        .setOlives(2)
        .setBacon(3)
        .build()
}

 

이런 2가지 장점은 코틀린의 디폴트 아규먼트, 이름 있는 파라미터도 갖고 있다.

 

fun main() {
    val villagePizza = Pizza(
        size = "L",
        cheese = 1,
        olives = 2,
        bacon = 3
    )
}

 

빌더 패턴을 쓰는 것보다 이름 있는 파라미터를 쓰는 게 좋은 이유를 정리하면 아래와 같다.

 

  • 더 짧다 : 디폴트 아규먼트가 있는 생성자 or 팩토리 메서드가 빌더 패턴보다 구현하기 훨씬 쉽다. 단순히 구현만 쉬운 게 아닌 읽는 사람의 입장에서 읽는 것도 쉽다. 빌더 패턴은 많은 코드를 입력해야 하므로 구현에 시간이 많이 걸린다. 또한 코드 수정도 어렵다. 파라미터의 이름을 변경해야 하는 경우 생성자 파라미터의 이름을 포함해서 각 함수명, 파라미터명, 본문, 내부 필드 등을 모두 바꿔야 한다
  • 더 명확하다 : 객체가 어떻게 생성되는지 확인하고 싶을 때 빌더 패턴은 여러 메서드들을 확인해야 한다. 하지만 디폴트 아규먼트가 있는 코드는 생성자 주변 부분만 확인하면 된다. 거대하게 빌더 패턴으로 만들어진 객체는 디폴트로 어떤 값을 갖는지, 내부적으로 어떤 추가적인 처리가 일어나는지 이해하기 어렵다
  • 더 사용하기 쉽다 : 기본 생성자는 기본적으로 언어에 내장된 개념이다. 하지만 빌더 패턴은 언어 위에 추가로 구현한 개념이므로 추가적인 knowledge가 필요하다. 빌더 패턴을 잘 모르는 개발자, 또는 현재 코드가 빌더 패턴으로 개발된 걸 모르는 개발자는 build()로 객체르 만들어야 한다는 걸 잘 모를 수 있다. 또한 빌더 패턴으로 개발됐단 걸 알아도 이후에 잊어버릴 수 있다
  • 동시성 관련 문제가 없다 : 코틀린의 함수 파라미터는 항상 immutable이다. 반면 대부분의 빌더 패턴에서 프로퍼티는 mutable이다. 따라서 빌더 패턴의 빌더 함수를 쓰레드 세이프하게 구현하는 건 어렵다

 

물론 무조건 빌더 패턴 대신 기본 생성자를 써야 한다는 건 아니다. 빌더 패턴이 좋은 경우를 간단하게 확인한다.

빌더 패턴은 값의 의미를 묶어서 지정할 수 있다(setPositiveButton, setNegativeButton, addRoute). 또한 특정 값을 누적하는 형태로 쓸 수 있다(addRoute)

 

val dialog = AlertDialog.Builder(context)
    .setMessage(R.string.fire_missiles)
    .setPositiveButton(R.string.fire, { d, id ->
        // 미사일 발사
    })
    .setNegativeButton(R.string.cancel, { d, id ->
        // 사용자가 취소를 누른 경우
    })
    .create()

val router = Router.Builder()
    .addRoute(path = "/home", ::showHome)
    .addRoute(path = "/users", ::showUsers)
    .build()

 

빌더 패턴을 쓰지 않고 이걸 구현하려면 추가 타입들을 만들고 활용해야 한다. 코드가 오히려 복잡해진다.

 

val dialog = AlertDialog.Builder(context,
    message = R.string.fire_missiles,
    positiveButtonDescription = 
        ButtonDescription(R.string.fire, { d, id ->
            // 미사일 발사
        }),
    negativeButtonDescription =
        ButtonDescription(R.string.cancel, { d, id ->
            // 사용자가 취소를 누른 경우
        })
)

val router = Router(
    routes = listOf(
        Route("/home", ::showHome),
        Route("/users", ::showUsers)
    )
)

 

이런 코드는 코틀린 커뮤니티에서 좋게 받아들여지지 않는다. 일반적으로 이런 코드는 아래처럼 DSL(Domain Specific Language) 빌더를 쓴다.

 

val dialog = context.alert(R.string.fire_missiles) {
    positiveButton(R.string.fire) {
        // 미사일 발사
    }
    negativeButton(R.string.cancel) {
        // 사용자가 취소를 누른 경우
    }
}

val route = router {
    "/home" directsTo :: showHome
    "/users" directsTo :: showUsers
}

 

DSL 빌더를 활용하는 패턴이 전통적인 빌더 패턴보다 훨씬 유연하고 명확해서 코틀린은 이런 형태의 코드를 많이 사용한다. 물론 DSL을 만드는 게 조금 어렵다. 그런데 빌더를 만드는 것도 그렇게 쉬운 일은 아니다. 시간을 조금 더 투자해서 더 유연하고 가독성이 좋은 코드를 만들 수 있다면 그 방법을 쓰는 게 더 좋을 것이다. 그래서 DSL을 많이 사용한다.

 

고전적인 빌더 패턴의 또 다른 장점은 팩토리로 쓸 수 있다는 것이다. 예를 들어 아래 코드는 앱의 기본적인 대화상자를 만드는 예다.

 

fun Context.makeDefaultDialogBuilder() =
    Alertdialog.Builder(this)
        .setIcon(R.drawable.ic_dialog)
        .setTitle(R.string.dialog_title)
        .setOnCancelListener { it.cancel() }

 

팩토리 메서드를 기본 생성자처럼 사용하게 만들려면 커링(currying)을 활용해야 한다. 하지만 코틀린은 커링을 지원하지 않는다. 대신 객체 설정을 데이터 클래스로 만들고, 데이터 클래스로 객체를 만들어 두고, 이를 copy로 복제한 뒤 필요한 설정들을 일부 수정해서 사용하는 형태로 만든다.

 

data class DialogConfig(
    val icon: Int = -1,
    val title: Int = -1,
    val onCancelListener: (() -> Unit)? = null
    // ...
)

fun makeDefaultDialogConfig() = DialogConfig(
    icon = R.drawable.ic_dialog,
    title = R.string.dialog_title,
    onCancelListener = { it.cancel() }
)

 

사실 2가지 모두 거의 실무에서 보기 어려운 형태의 코드다. 앱에서 쓰이는 기본 대화상자를 정의하려는 경우, 함수를 써서 만들고 모든 사용자 정의 요소를 옵션 아규먼트로 전달하는 방법을 사용하는 게 좋다. 그래서 빌더 패턴의 장점도 빌더 패턴을 쓸 이유가 되진 못한다.

결론적으로 코틀린에선 빌더 패턴을 거의 쓰지 않는다. 빌더 패턴은 아래와 같은 경우에만 쓴다.

 

  • 빌더 패턴을 쓰는 다른 언어로 작성된 라이브러리를 그대로 옮길 때
  • 디폴트 아규먼트, DSL을 지원하지 않는 다른 언어에서 쉽게 쓸 수 있게 API를 설계할 때

 

이를 제외하면 빌더 패턴 대신 디폴트 아규먼트를 갖는 기본 생성자 or DSL을 쓰는 게 좋다.

반응형
Comments