|

코틀린 디자인 패턴 (4) – Builder 패턴과 named argument는 어떻게 다를까

코틀린 Builder 패턴과 named argument 차이를 설명하는 대표 이미지
코틀린에서는 named argument, Builder, DSL을 문제 성격에 따라 나눠 써야 한다

코틀린 Builder 패턴을 볼 때 핵심은 Builder 패턴과 named argument를 언제 구분해야 하는지입니다. 결론부터 말하면, 값 몇 개를 읽기 좋게 넘기는 문제라면 named argumentdefault parameter로 끝나는 경우가 많습니다.

그렇다고 Builder가 끝난 것은 아닙니다. 생성 과정이 여러 단계로 나뉘거나, 필드끼리 함께 검증해야 하거나, 중첩 구조를 조립해야 하거나, Java에서도 쓰기 좋은 API를 제공해야 한다면 Builder는 아직 꽤 쓸모가 있습니다.

이번 글은 GoF 정의를 길게 반복하기보다, 실무에서 더 자주 나오는 질문에 집중합니다. 생성자가 길어질 때 언제 named argument면 충분한지, 언제 Builder를 남겨야 하는지, Kotlin DSL이나 apply 스타일은 Builder와 어떤 관계인지 차분히 정리해보겠습니다.


Builder가 코틀린에서 덜 필요해진 이유

전통적인 Builder 패턴이 자주 등장한 이유는 보통 이랬습니다. 생성자 인자가 많고, 일부는 선택값이고, null이나 true, false가 섞여서 호출부만 봐서는 의미가 잘 안 보였기 때문입니다.

자바에서는 이런 코드가 금방 읽기 어려워집니다.

UserQuery query = new UserQuery("backend", true, false, 1, 50, "createdAt");

코틀린에서는 같은 문제를 훨씬 가볍게 풀 수 있습니다. default parameternamed argument가 있기 때문입니다.

val query = UserQuery(
    keyword = "backend",
    includeInactive = true,
    page = 1,
    pageSize = 50,
    sortBy = "createdAt"
)

이 호출은 Builder 없이도 꽤 잘 읽힙니다. 각 값이 무엇인지 호출부에서 바로 보이고, 기본값이 있는 필드는 굳이 다 적지 않아도 됩니다. 즉, 코틀린에서는 Builder가 해결하던 문제 중 호출부 가독성 부분이 많이 줄어들었습니다.


named argument만으로 충분한 경우

실무에서 가장 흔한 경우는 사실 여기입니다. 객체가 단순한 데이터 묶음이고, 모든 값이 한 번에 준비되며, 생성 시점 검증도 복잡하지 않다면 Builder를 만들 이유가 크지 않습니다.

값이 flat하게 모여 있을 때

예를 들어 검색 조건처럼 필드 몇 개를 한 번에 전달하는 객체는 Builder보다 data class가 더 자연스럽습니다.

data class PostSearchCondition(
    val keyword: String,
    val category: String? = null,
    val authorId: Long? = null,
    val includeDrafts: Boolean = false,
    val page: Int = 1,
    val pageSize: Int = 20,
)

val condition = PostSearchCondition(
    keyword = "kotlin",
    category = "design",
    pageSize = 50,
)

이 경우 핵심은 생성 과정이 아니라 전달되는 값 자체입니다. 이런 객체에 Builder를 붙이면 보통 얻는 것보다 코드가 더 무거워집니다.

기본값이 합리적으로 정해질 때

Builder가 자주 필요했던 이유 중 하나는 생성자 오버로드가 늘어나는 문제였습니다. 코틀린에서는 기본값으로 이 문제를 많이 줄일 수 있습니다. page, pageSize, includeDrafts처럼 대부분 비슷한 기본값을 가지는 필드는, 필요한 값만 named argument로 적는 편이 더 단순합니다.

중간 상태를 숨길 이유가 없을 때

값이 모두 준비된 상태에서 곧바로 immutable 객체를 만들 수 있다면 Builder를 둘 이유가 약해집니다. Builder는 보통 덜 만들어진 상태를 잠시 들고 있다가 마지막에 build()로 결과를 내놓는데, 애초에 그런 중간 상태가 필요 없다면 named argument 쪽이 더 자연스럽습니다.

호출자가 Kotlin 중심일 때

Kotlin끼리 호출하는 내부 코드라면 named argument의 장점을 그대로 누릴 수 있습니다. 호출부가 이미 깔끔한데 Builder를 또 만드는 것은, 종종 자바 습관을 그대로 가져온 경우가 많습니다.

값이 한 번에 준비되고, 구조가 평평하고, 검증 규칙이 단순하면 named argument가 Builder보다 보통 더 낫습니다.


코틀린 Builder 패턴이 여전히 필요한 순간

생성이 단계적으로 진행될 때

값이 한 번에 오지 않고 여러 단계에서 조금씩 쌓이는 경우에는 Builder가 다시 힘을 발휘합니다. 예를 들어 알림 발송 요청처럼 수신자 목록은 먼저 모으고, 제목은 조건에 따라 나중에 붙고, 첨부 파일은 반복해서 추가하고, 마지막에 예약 발송 여부를 결정하는 구조라면 생성자 하나로 끝내기 어렵습니다.

class NotificationRequest private constructor(
    val channel: Channel,
    val recipients: List<String>,
    val title: String?,
    val body: String,
    val attachments: List<String>,
    val scheduledAt: String?,
) {
    class Builder(
        private val channel: Channel,
        private val body: String,
    ) {
        private val recipients = mutableListOf<String>()
        private val attachments = mutableListOf<String>()
        private var title: String? = null
        private var scheduledAt: String? = null

        fun addRecipient(recipient: String) = apply {
            recipients += recipient
        }

        fun title(title: String) = apply {
            this.title = title
        }

        fun addAttachment(path: String) = apply {
            attachments += path
        }

        fun schedule(at: String) = apply {
            scheduledAt = at
        }

        fun build(): NotificationRequest {
            require(recipients.isNotEmpty()) { "수신자는 최소 1명 이상이어야 합니다." }
            if (channel == Channel.EMAIL) {
                require(!title.isNullOrBlank()) { "이메일은 제목이 필요합니다." }
            }
            return NotificationRequest(
                channel = channel,
                recipients = recipients.toList(),
                title = title,
                body = body,
                attachments = attachments.toList(),
                scheduledAt = scheduledAt,
            )
        }
    }
}

이 Builder의 진짜 가치는 체이닝 모양 자체가 아닙니다. 중간의 mutable 조립 상태를 감춰두고, 마지막 build()에서 규칙을 확인한 뒤 완성된 immutable 객체만 바깥에 내놓는 데 있습니다.

필드 조합을 함께 검증해야 할 때

named argument는 값을 읽기 좋게 보여주지만, 값들 사이의 관계를 구조적으로 관리해주지는 않습니다. 예를 들어 channel이 EMAIL이면 title은 필수이고, 예약 발송이면 scheduledAt은 미래 시각이어야 하고, 첨부 파일이 있으면 특정 채널만 허용된다는 식의 규칙이 많아질수록 Builder의 build() 검증 지점이 더 자연스러워집니다.

반복 요소를 모아야 할 때

수신자 여러 명 추가하기, 첨부 파일 여러 개 추가하기, 항목을 조건에 따라 반복 삽입하기 같은 경우도 Builder가 잘 맞습니다. 이런 구조를 named argument로만 풀려면 결국 리스트를 바깥에서 미리 완성해야 하는데, 조립 로직이 호출부에 퍼지기 시작하면 Builder가 다시 이점을 가집니다.

Java에서도 써야 하는 API일 때

이 부분은 꽤 현실적입니다. Kotlin 공식 문서도 말하듯, JVM에서 Java 함수를 호출할 때는 named argument를 쓸 수 없습니다. Java 바이트코드에는 파라미터 이름이 항상 안정적으로 보존되지 않기 때문입니다. 그래서 Kotlin 전용 내부 API라면 named argument가 좋을 수 있지만, Java 호출자도 고려해야 하는 라이브러리라면 Builder가 더 일관된 공개 API가 될 수 있습니다.

중첩 구조를 조립해야 할 때

객체 한 개를 만드는 정도가 아니라, 트리나 계층 구조를 조립해야 한다면 Builder는 다시 다른 얼굴을 보여줍니다. 이 지점에서 Kotlin DSL과 자연스럽게 연결됩니다. HTML, UI, 라우팅, 메뉴 구조처럼 안에 또 안이 들어가는 데이터를 만들 때는 flat한 생성자보다 builder style이 훨씬 잘 읽힙니다.


apply는 Builder일까

코틀린을 쓰다 보면 apply로 객체를 채우는 코드를 자주 봅니다.

val request = NotificationRequestDraft().apply {
    channel = Channel.EMAIL
    body = "주간 리포트를 보냅니다."
    title = "5월 둘째 주 리포트"
    recipients += "team@bscodelab.com"
}

Kotlin 공식 문서도 apply의 대표 사용 사례를 object configuration이라고 설명합니다. 하지만 apply는 이미 있는 객체를 설정하는 문법에 가깝고, 반환값도 설정이 끝난 객체 자신입니다. 필수값 누락 방지나 마지막 검증을 자동으로 보장하지는 않습니다.

  • 작고 지역적인 mutable 객체를 빠르게 채울 때는 apply가 잘 맞는다
  • 공개 API이거나 검증 규칙이 중요하면 별도 Builder가 더 안전하다
  • 즉, apply는 편한 설정 문법이지 GoF Builder의 완전한 대체물은 아니다

Kotlin DSL은 Builder와 어떻게 연결될까

Kotlin의 type-safe builder 문서는 HTML 같은 계층형 구조를 예로 듭니다. 이 스타일은 전통적인 GoF Builder와 닮았지만, 목적이 조금 더 넓습니다. 체이닝 모양보다 읽기 좋은 도메인 문장처럼 구조를 조립하는 것에 더 가깝습니다.

html {
    head {
        title { +"Builder vs named argument" }
    }
    body {
        h1 { +"코틀린 Builder 패턴" }
        p { +"중첩 구조는 DSL이 더 잘 읽힐 때가 많습니다." }
    }
}

그래서 DSL builder는 HTML/XML 같은 문서 구조, UI 트리, 서버 라우팅, 메뉴 트리처럼 블록 안에 또 블록이 들어가는 API에서 특히 강합니다. 반대로 필드 여섯 개짜리 data class 하나 만드는 일이라면 DSL까지 가는 것은 과합니다.

named argument는 flat한 값을 읽기 좋게 넘기는 데 강하고, Builder/DSL은 조립 과정이나 계층 구조를 읽기 좋게 만드는 데 강합니다.


언제 무엇을 고르면 좋을까

named argument를 먼저 떠올려도 되는 경우

  • 필드 수는 좀 많아도 구조가 평평하다
  • 모든 값이 호출 시점에 이미 준비돼 있다
  • 기본값이 자연스럽다
  • 필드 간 교차 검증이 복잡하지 않다
  • Kotlin 호출자가 대부분이다

이 경우는 data class, 생성자, 팩토리 함수로 끝내는 편이 대체로 좋습니다.

Builder를 고려할 만한 경우

  • 값이 여러 단계에 걸쳐 모인다
  • 중간 mutable 상태를 감추고 싶다
  • build() 시점의 검증이 중요하다
  • 리스트나 중첩 요소를 반복해서 추가한다
  • Java 호출자까지 고려해야 한다

이 경우는 Builder가 단지 옛날 패턴이 아니라, 생성 책임을 정리하는 도구가 됩니다.

DSL/builder style이 더 어울리는 경우

  • 결과물이 트리나 계층 구조다
  • 블록 안에 또 블록이 들어간다
  • 호출부가 결과 구조를 그대로 닮는 편이 읽기 좋다
  • 도메인 언어처럼 보이는 API가 이득이다

이 경우는 전통적인 Builder보다 Kotlin DSL이 더 코틀린답습니다.


Builder를 남용하면 생기는 문제

코틀린에서 Builder를 쓰는 것 자체가 문제가 아니라, 필요 없는 곳까지 습관적으로 붙이는 것이 문제입니다. 단순 DTO, 테스트용 입력 데이터, 필드 몇 개짜리 설정 객체, 이미 immutable 생성자로 충분한 값 객체까지 전부 Builder로 만들기 시작하면 코드가 빠르게 무거워집니다.

이전 글에서 봤던 Factory Method나 Abstract Factory도 마찬가지였지만, 생성 패턴은 복잡해졌을 때 질서를 주는 도구이지 처음부터 다 붙이는 장식이 아닙니다.


마무리

코틀린에서 Builder 패턴을 볼 때 가장 먼저 해야 할 질문은 “이 패턴이 아직 필요한가”입니다. 그리고 그다음 질문은 “필요하다면 정확히 어떤 문제를 해결하고 있는가”입니다. 값 몇 개를 읽기 좋게 넘기는 것이 전부라면 named argument와 default parameter가 더 좋은 답인 경우가 많습니다.

반대로 생성 과정이 길고, 조립 규칙이 있고, 마지막 검증 지점이 필요하고, 중첩 구조를 읽기 좋게 만들어야 한다면 Builder는 아직 충분히 살아 있습니다. 중요한 것은 Builder를 버리는 것도, 무조건 지키는 것도 아닙니다. 코틀린이 줄여준 부분과 여전히 남는 설계 문제를 구분하는 것이 더 중요합니다.

이전 글인 코틀린 디자인 패턴(1) – Singleton 패턴은 언제 쓰고 object는 어떻게 다를까, 코틀린 디자인 패턴(2) – Factory Method 패턴으로 생성 책임 나누기, 코틀린 디자인 패턴(3) – Abstract Factory 패턴은 언제 필요할까를 먼저 읽었다면, 이번 Builder 글은 생성 패턴 파트의 마지막 퍼즐처럼 보일 것입니다.

그리고 다음 글에서는 Prototype 패턴과 data class copy로 자연스럽게 넘어가게 됩니다. 생성 자체를 더 쉽게 만드는 문법이 설계 판단을 어디까지 대신해주는지, 그 경계가 다음 글에서도 이어집니다. 공식 참고 자료로는 Kotlin functions 문서, scope functions 문서, type-safe builders 문서를 함께 보면 좋습니다.

함께보면 좋은 글