트렌드브리프
빌더 패턴, 생성자 인자가 많을 때 객체를 조립하는 법

빌더 패턴, 생성자 인자가 많을 때 객체를 조립하는 법

· 게시 · 약 7분 분량

빌더 패턴은 인자가 많은 복잡한 객체를 단계별로 조립하는 GoF 생성 패턴이다. 점층적 생성자의 가독성 문제를 풀며, 자바에서는 Effective Java 방식과 Lombok @Builder로, 코틀린에서는 이름 붙은 인자로 대체한다.

목차
  1. 생성자 인자가 많아지면 무엇이 문제인가
  2. GoF가 정의한 빌더의 구조
  3. Effective Java가 대중화한 정적 중첩 빌더
  4. Lombok으로 보일러플레이트를 없애기
  5. 언어마다 빌더가 필요한 정도가 다르다
  6. 빌더는 언제 쓰고 언제 피하나
  7. 빌더를 쓸 때 자주 놓치는 것

생성자에 넘길 인자가 대여섯 개를 넘어가면 객체를 만드는 코드부터 읽기 어려워진다. 빌더 패턴은 이런 복잡한 객체를 한 번에 다 넘기지 않고 .host(...).port(...)처럼 값을 하나씩 채운 뒤 build()로 완성하는 객체 생성 방식이다. GoF(Gang of Four)의 저서 『Design Patterns』(1994)가 정리한 23개 패턴 중 하나이며, 자바에서는 조슈아 블로크의 『Effective Java』 Item 2가 소개한 정적 중첩 빌더가 사실상 표준으로 쓰인다. 이 글은 자바·코틀린·스위프트 같은 객체 지향 언어의 빌더를 다루고, 문법 나열보다 ‘언제 이 패턴을 쓰고 언제 더 단순한 대안으로 갈아타는가’에 초점을 둔다.

생성자 인자가 많아지면 무엇이 문제인가

인자가 늘어날수록 점층적 생성자(telescoping constructor)와 세터(setter) 방식 둘 다 무너진다. 점층적 생성자는 선택 인자 조합마다 생성자를 하나씩 더 만드는 방식이다. 인자가 다섯 개면 조합이 금세 오버로드 여러 개로 불어난다.

// 점층적 생성자: 조합마다 오버로드가 늘어난다
public NutritionFacts(int servings, int calories) { ... }
public NutritionFacts(int servings, int calories, int fat) { ... }
public NutritionFacts(int servings, int calories, int fat, int sodium) { ... }

더 큰 문제는 호출부다. new NutritionFacts(240, 8, 0, 35)를 보면 각 숫자가 무엇인지 알 수 없고, 같은 int 인자의 순서를 바꿔 넣어도 컴파일러가 잡아주지 못한다. 반대로 자바빈즈(JavaBeans)식 세터는 읽기는 낫지만 객체가 여러 번의 세터 호출 중간에 ‘반쯤 만들어진 상태’로 존재한다. 그래서 불변(immutable) 객체로 만들 수 없고, 여러 스레드가 동시에 접근하면 안전을 따로 챙겨야 한다.

GoF가 정의한 빌더의 구조

GoF가 밝힌 빌더 패턴의 목적은 “복잡한 객체의 생성 과정과 표현을 분리해, 같은 생성 절차로 서로 다른 표현을 만들 수 있게 한다”이다. 빌더는 싱글턴·팩토리 메서드·추상 팩토리·프로토타입과 함께 다섯 개의 생성(creational) 패턴에 속한다.

원래 GoF 구조에는 두 축이 있다. 조립 단계를 정의하는 빌더(Builder)와, 그 단계를 어떤 순서로 호출할지 지휘하는 디렉터(Director)다. 같은 디렉터에 HTML 빌더를 물리면 HTML 문서가, 마크다운 빌더를 물리면 마크다운 문서가 나오는 식이다. 이 디렉터-빌더 분리는 문서 변환기처럼 ‘같은 절차, 다른 산출물’이 필요할 때 값어치를 하지만, 실무 자바 코드에서 매일 쓰는 형태는 아니다. 패턴의 구조와 참여자 정리는 Refactoring.Guru의 빌더 패턴 문서가 그림과 함께 설명한다.

Effective Java가 대중화한 정적 중첩 빌더

오늘날 자바에서 ‘빌더’라고 하면 대개 조슈아 블로크가 『Effective Java』 Item 2에서 제시한 형태를 가리킨다. 바깥 클래스 안에 정적 중첩(static nested) Builder 클래스를 두고, 필수 인자는 빌더 생성자로 받고 선택 인자는 메서드 체이닝으로 채운 뒤 build()에서 최종 객체를 만든다.

NutritionFacts cola = new NutritionFacts.Builder(240, 8) // 필수: 용량, 1회 제공량
  .calories(100)
  .sodium(35)
  .carbohydrate(27)
  .build();

이 방식의 이점은 세 가지다. 각 메서드에 이름이 있어 어떤 값을 넣는지 호출부에서 바로 읽힌다. build()가 불릴 때 비로소 객체가 생기므로 반쯤 만들어진 상태가 없고, 결과를 불변 객체로 굳힐 수 있다. 그리고 build() 안에서 불변식(예: 값의 범위)을 한꺼번에 검증할 수 있다. 블로크는 필수·선택 매개변수가 뒤섞여 개수가 많아질 때 이 패턴이 특히 잘 맞는다고 설명한다.

디렉터의 지시로 바닥→벽→지붕 순서로 부품을 얹어 집을 완성하는 조립 라인 선화 일러스트
바닥→벽→지붕, 정해진 순서로 부품을 얹어 복잡한 객체를 완성한다.

Lombok으로 보일러플레이트를 없애기

정적 중첩 빌더의 단점은 손으로 쓰는 코드가 많다는 점이다. 필드가 열 개면 빌더 메서드도 열 개를 직접 적어야 한다. 자바에서는 Lombok의 @Builder 애너테이션이 이런 반복 상용구 코드(보일러플레이트)를 컴파일 시점에 자동 생성한다. Lombok 공식 문서(projectlombok.org)에 따르면 클래스에 @Builder를 붙이면 내부 빌더 클래스와 build(), 각 필드의 설정 메서드가 함께 만들어진다.

@Builder
public class Server {
 private String host;
 private int port;
 private int timeoutSeconds;
}

Server s = Server.builder()
  .host("10.0.0.1")
  .port(8080)
  .timeoutSeconds(30)
  .build();

상속이 얽히면 자식 클래스에는 @SuperBuilder를 써야 부모 필드까지 빌더에 포함된다고 Lombok 문서는 안내한다. 편한 대신 대가도 있다. 생성되는 코드가 소스에 보이지 않아 디버깅 때 헷갈릴 수 있고, 필수 인자 강제나 값 검증 같은 로직은 애너테이션만으로 표현하기 어렵다. 자동 생성으로 충분한 단순 값 객체에는 잘 맞고, 복잡한 검증이 필요하면 빌더를 직접 쓰는 편이 낫다.

언어마다 빌더가 필요한 정도가 다르다

빌더 패턴은 자바처럼 이름 붙은 인자와 기본값이 없는 언어에서 특히 요긴하다. 언어가 그 기능을 문법으로 제공하면 클래식 빌더의 필요성은 크게 줄어든다. 코틀린이 대표적이다. 『Effective Kotlin』은 매개변수가 많을 때 빌더보다 ‘이름 붙은 선택 인자를 가진 기본 생성자’를 먼저 고려하라고 권한다.

data class Server(
 val host: String,
 val port: Int = 8080,
 val timeoutSeconds: Int = 30,
)

val s = Server(host = "10.0.0.1", timeoutSeconds = 5)

필요한 인자만 이름을 붙여 넘기면 되니 빌더 클래스 자체가 필요 없다. 언어별 대안을 정리하면 다음과 같다.

언어선택 인자를 다루는 기본 수단클래식 빌더가 유용한 경우
자바정적 중첩 빌더, Lombok @Builder사실상 표준 — 대부분 빌더 사용
코틀린이름 붙은 인자·기본값, data class, apply()다른 언어에서 호출할 API, 복잡한 DSL
스위프트기본값 있는 이니셜라이저, @resultBuilder(DSL용)단계가 많은 조립 절차
파이썬키워드 인자·기본값, @dataclass드묾

스위프트의 @resultBuilder는 SwiftUI의 뷰 선언 같은 DSL(도메인 특화 언어)을 만드는 기능으로, 값 객체를 조립하는 GoF 빌더와는 결이 다르다. 이름은 같아도 쓰임이 다르니 구분해서 봐야 한다.

빌더는 언제 쓰고 언제 피하나

선택 인자가 많고 결과를 불변으로 만들고 싶을 때 쓴다. 블로크는 생성자나 정적 팩토리의 매개변수가 네 개 이상이 될 것 같으면 빌더를 고려하라는 기준을 제시한다. 여기에 같은 타입 인자가 여러 개라 순서 실수가 잦거나, 인자 조합이 앞으로 더 늘어날 게 뻔한 경우도 빌더가 값을 한다.

반대로 인자가 두세 개뿐이고 앞으로도 늘 일이 없다면 빌더는 과한 장치다. 빌더 클래스를 만드는 비용이 얻는 가독성보다 크다. 코틀린·스위프트·파이썬처럼 이름 붙은 인자와 기본값을 문법으로 지원하는 언어라면, 클래식 빌더를 흉내 내기보다 그 문법을 먼저 쓰는 게 맞다. 클래식 빌더를 굳이 두는 경우는 기본 인자를 지원하지 않는 다른 언어에서 호출될 API를 설계하거나, 단계별 조립 자체가 도메인 로직인 상황이다.

빌더를 쓸 때 자주 놓치는 것

세 가지를 챙기면 대부분의 실수를 피한다. 첫째, 검증은 build() 안에서 한다. 각 설정 메서드에서 조각조각 검사하지 말고, 모든 값이 모인 마지막에 불변식을 확인해야 “포트는 있는데 호스트가 없는” 조합을 잡을 수 있다. 둘째, 하나의 빌더 인스턴스를 여러 번 build()해서 객체를 찍어낼 때는 빌더가 가변 상태를 들고 있다는 점을 기억한다. 앞서 채운 값이 남아 다음 객체로 새어 들어갈 수 있다. 셋째, 필수 인자는 빌더 생성자의 매개변수로, 선택 인자는 체이닝 메서드로 나눈다. 필수 값을 메서드로 빼면 빠뜨려도 컴파일러가 잡지 못한다.

상속 계층에서 빌더를 쓰려면 재귀적 제네릭(자기 자신을 타입 매개변수로 받는 self-type)으로 부모 빌더가 자식 타입을 반환하게 해야 체이닝이 끊기지 않는다. 자바에서 이 코드가 번거로우면 앞서 본 Lombok @SuperBuilder가 같은 일을 대신한다. 문자열을 이어 붙이는 자바 표준 라이브러리의 StringBuilder도 넓게 보면 빌더의 사고방식을 따른 도구다. 기본 용량 16글자로 시작해 append()를 체이닝하며 문자열을 조립하고, 마지막에 toString()으로 결과를 낸다.

더 많은 글 보기 RSS 구독