해당글은 Marcus Held의 <Don't Use The Builder Pattern in Kotlin>을 읽고 번역하여 간단히 정리한 글입니다. 필요에 따라 의역/생략된 부분이 있을 수 있으며, 내용이 추후 변경될 수 있습니다.
배경
인터넷에서 본 패턴들을 실제 적용할 때는 신중해야 한다(아이러니하게 이 포스팅에도 적용되는 말이다). 나의 동료들은 Creational Baeldung 사이트에서 Design Patterns in Kotlin: Builder를 읽고 실무에 많이 적용했다. 그러나 해당 글에서 빌더 패턴을 사용하는 방식은 안티 패턴이다. 나는 이 글을 통해 빌더 패턴 대신 코틀린의 특성을 적절히 이용할 때 더 안전하고, 에러가 적게 발생하고, 보일러플레이트를 적게 작성하는 이유를 설명하려 한다.
빌더 패턴의 장점
초기화 시점에 불변이고 일관성 있는 객체를 만드는 것은 잘못된 API 처리 가능성을 크게 줄이며, 버그의 가능성 또한 줄인다. 이러한 이유에서 일관성 없는 객체 상태를 허용하지 않는 생성자만을 제공하는 아이디어가 도출된다. 일반적으로 당신은 특정 규칙을 체크하거는 생성자를 적절히 제공함으로써 이미 많은 것을 행하고 있을 뿐만 아니라 객체 규약을 파괴하지 않는 매개변수 조합만을 허용하는 생성자만을 제공하고 있다. 이는 서로 간에 많은 연관성을 지니는 특정한 양의 매개변수가 있을 때까지는 잘 작동한다. 이것이 바로 빌더 패턴이 편리해지는 지점이다. (빌더 패턴을 사용함으로써) 객체 생성을 인도하고 선택적이고 필수적인 객체를 쉽게 찾을 수 있게 만드는 API를 제공할 수 있다. 해당 주제는 이미 Distinguish Between optional and Mandatory Parameters in the Builder Pattern에서 다뤄졌다.
코틀린에서의 빌더 패턴 예제
다음과 같은 Customer 클래스가 존재한다.
data class Customer(
val id: UUID = UUID.randomUUID(),
val username: String,
val auth0Id: String
) {
class Builder {
var id: UUID? = null
var username: String? = null
var auth0Id: String? = null
fun id(id: UUID) = apply { this.id = id }
fun username(username: String) = apply { this.username = username }
fun auth0Id(auth0Id: String) = apply { this.auth0Id = auth0Id }
fun build() = Customer(id!!, username!!, auth0Id!!)
fun randomBuild() = id(id ?: UUID.randomUUID())
.username(username ?: RandomStringUtils.randomAlphanumeric(10))
.auth0Id(auth0Id ?: RandomStringUtils.randomAlphanumeric(10))
.build()
}
}
Customer 클래스의 인스턴스 생성 시 다음과 같은 형태로 빌더를 사용할 수 있다.
Customer.Builder()
.id(UUID.randomUUID())
.username("user name")
.auth0Id("auth0id")
.build()
문제1: NullPointerExceptions
이렇게 구현하게 되면 NullPointerException이 발생할 수 있다. 컴파일러는 몇몇 매개변수가 생략되는 것을 허용한다. 예를 들어, 다음과 같은 코드가 컴파일될 수 있다.
Customer.Builder()
.id(UUID.randomUUID())
.auth0Id("auth0id")
.build()
이 코드는 문제가 있다. 이 클래스를 다른 속성으로 확장할 때, 컴파일러는 객체의 생성을 조종할 필요를 얘기해주지 않지만, 당신은 NullPointerException을 런타임에 경험하게 된다. 이는 Robust Builder Pattern을 적용함으로써 해결할 수 있지만, 해당 패턴을 적용하는 데엔 부수적인 코드가 많이 필요하다.
문제2: 예측 불가능한 동작
아래 코드에서 클래스의 구현을 보지 않고, 어떤 객체가 생성되는지 알 수 있을까?
Customer.Builder()
.name("foo")
.randomBuild()
"foo"라는 name을 지니고, 다른 필드는 랜덤값으로 채워진 Customer 객체를 생성했을 것으로 예상할 수 있지만, 빌더가 완전한 랜덤 빌드(모든 필드를 랜덤값으로 채워서 생성)를 수행할 것이라고 예상하는 것도 무리는 아니다. 이것이 사실이 아님을 알아내는 유일한 방법은 구현을 살펴보는 것이다. 이는 호출자에게 불필요한 수고이며, API에서 지양해야 한다.
문제3: 누락된 기본값
객체의 생성자를 살펴보면 id가 UUID.randomUUID()라는 기본 값을 가지는 것을 알 수 있다. 하지만, 빌더를 사용하게 되면 id에 값을 재할당해야 한다. 그렇지 않으면 문제1에과 같이 NPE를 발생시킨다. 이 클래스가 생성자에 의해서만 초기화 될 수 있다고 가정할 때 기본값은 본질적으로 죽은 코드이다(생성자가 아니라 빌더 아닌가?).
문제4: Non-private 생성자
이 클래스를 사용하는 클라이언트는 빌더 대신 생성자를 사용하여 객체를 생성할 수 있다. 생성자를 사용하는 것은 객체를 초기화하는 자연스러운 방법이므로 다른 개발자는 이 방식을 택할 수 있으며 빌더를 처음에 우회할 수 있다. 이는 빌더에서 유효성 검사를 수행할 때 일관성 없는 객체로 이어질 수 있다.
문제5: 보일러플레이트
클래스의 단일 속성(username, auth0Id)에서 본 것처럼 많은 중복된 코드를 작성해야 한다.
해결책
앞서 설명한 문제들은 코틀린의 특성을 적절히 잘 이용하면 해결할 수 있다.
data class Customer(
val id: UUID = UUID.randomUUID(),
val username: String,
val auth0Id: String
)
다음과 같이 객체를 작성하게 되면, 빌더 패턴으로 해결할 수 있던 문제가 다시금 발생하게 된다. 즉, username과 auth0Id가 같은 String 타입이므로 순서를 바꿔서 생성자를 호출하더라도 정상적으로 컴파일된다.
여기서 named parameter를 적용한다면, 속성의 순서는 호출자에게 중요하지 않다.
Customer(
id = UUID.randomUUID(),
username = "username",
auth0Id = "auth0Id"
)
객체 생성 시 각 필드에 할당된 기본값을 이용할 수도 있다(아래 예제에선 id).
Customer(
username = "username",
auth0Id = "auth0Id"
)
또한, 이 API는 예측 가능하다는 장점이 있다. IDE는 어떤 매개변수가 필수적이거나 선택적인지 알려준다.
하지만, 이 방법을 사용하면 랜덤 빌드를 더 이상 수행할 수 없다. 이를 해결하기 위해선 static 팩터리 메서드를 companion object 안에 구현해주어야 한다.
data class Customer(
val id: UUID = UUID.randomUUID(),
val username: String,
val auth0Id: String
) {
companion object {
fun random() =
Customer(username = RandomStringUtils.randomAlphanumeric(10), auth0Id = RandomStringUtils.randomAlphanumeric(10))
}
}
Custom.random()을 호출함으로써 쉽고 덜 모호하게 랜덤 객체를 생성할 수 있다.
만약 여기서 매개변수의 유효성 검사를 추가하고 싶다면 코틀린의 init 블록을 사용하면 된다.
data class Customer(
val id: UUID = UUID.randomUUID(),
val username: String,
val auth0Id: String
) {
init {
require(username.length > 8) { "The username must be larger than 8 characters." }
}
앞서 코틀린의 특성에만 의존하여 동일한 기능을 달성했고, API를 향상시켰다. 빌더를 도입하는 유일한 이유는 속성 간에 많은 연관성을 지니는 복잡한 객체를 생성해야 하는 상황이 존재하기 때문이다. 이러한 경우우엔 Robust Builder Pattern가 좋은 예시가 된다. 하지만, 이 경우에도 보조 생성자가 이미 요구사항을 충족하는지 확인해야 한다.
'프로그래밍 언어 > Java + Kotlin' 카테고리의 다른 글
[Kotlin] scope function - let, run, also, apply, with 차이 (0) | 2022.08.13 |
---|---|
[Kotlin] 코틀린 유용한 함수 - padEnd (0) | 2022.07.19 |
[Java] Comparable과 Comparator (0) | 2022.04.27 |
[Java] 스터디 17주차: 자동차 경주 게임 만들기 (0) | 2021.10.04 |
[Java] 스터디 16주차: 문자열 계산기 만들어보기 (0) | 2021.09.28 |