프로그래밍/디자인패턴

빌더 패턴(Builder Pattern) 총정리

Jinwookoh 2025. 12. 7. 17:53

"생성자 인자가 너무 많아서 순서가 헷갈려요."

"어떤 필드는 필수고, 어떤 필드는 선택값으로 하고 싶어요."

이 문제를 해결하기 위해 우리는 빌더 패턴을 사용합니다. 하지만 우리가 흔히 쓰는 방식(Simple Builder)과 디자인 패턴 책에 나오는 정석 방식(Classic Builder)은 목적과 구조가 다릅니다. 이 글을 읽고 나면 "아, 내가 쓰던 건 빌더의 일부분이었구나"를 깨닫게 되실 겁니다.

Shutterstock
 

1. 문제 상황: 점층적 생성자 패턴 (Telescoping Constructor)

빌더 패턴이 없는 세상에서, 필드가 많은 객체를 생성하는 것은 고역입니다.

Java
// 인자가 5개인데, 3번째 null은 뭐고 4번째 0은 뭐지?
User user = new User("James", "Seoul", null, 0, "Developer");

이런 코드는 가독성이 최악이고, 실수하기도 딱 좋습니다. 이를 해결하기 위해 빌더 패턴이 등장했습니다.


2. 방식 1: 심플 빌더 (Simple Builder) - "Joshua Bloch 스타일"

현업에서 99% 사용하는 방식입니다. **"불변성(Immutability)"**과 **"가독성(Fluent API)"**을 확보하는 것이 주 목적입니다. Lombok의 @Builder가 바로 이 방식입니다.

특징

  • static 내부 클래스를 만들어 메서드 체이닝(return this)을 구현합니다.
  • 객체 생성을 한 줄의 직관적인 문장처럼 만듭니다.

코드 예시

Java
public class User {
    private final String name;
    private final int age;

    // 생성자는 private으로 닫음
    private User(Builder builder) {
        this.name = builder.name;
        this.age = builder.age;
    }

    public static class Builder {
        private String name;
        private int age;

        // 메서드 체이닝을 위해 this 반환
        public Builder name(String name) {
            this.name = name;
            return this;
        }

        public Builder age(int age) {
            this.age = age;
            return this;
        }

        public User build() {
            return new User(this);
        }
    }
}

// 사용 (Client)
User user = new User.Builder()
    .name("James")
    .age(30)
    .build();

장단점

  • 장점: 읽기 쉽고, 객체 생성 후 값이 변하지 않음(Immutable)을 보장할 수 있습니다.
  • 단점: 객체 하나 만들 때마다 별도의 Builder 객체를 먼저 만들어야 하므로 미세한 메모리 비용이 발생합니다.

3. 방식 2: 클래식 빌더 (Classic Builder) - "GoF 스타일"

디자인 패턴의 원형입니다. 단순히 객체를 예쁘게 만드는 게 아니라, **"복잡한 생성 과정을 단계별로 분리하고, 조립(Director)하는 것"**에 초점을 맞춥니다.

구조

  • Builder: 부품을 만드는 인터페이스
  • ConcreteBuilder: 실제 부품을 조립하는 구현체
  • Director (감독관): 빌더에게 "A 넣고, B 넣고, 완성해"라고 순서를 지시하는 관리자

코드 예시 (피자 만들기)

Java
 
// 1. 빌더 인터페이스
interface PizzaBuilder {
    void buildDough();
    void buildSauce();
    void buildTopping();
    Pizza getPizza();
}

// 2. 구체적 빌더 (하와이안 피자 요리사)
class HawaiianPizzaBuilder implements PizzaBuilder {
    private Pizza pizza = new Pizza();
    public void buildDough() { pizza.setDough("쫄깃한 도우"); }
    public void buildSauce() { pizza.setSauce("토마토 소스"); }
    public void buildTopping() { pizza.setTopping("파인애플"); }
    public Pizza getPizza() { return pizza; }
}

// 3. 디렉터 (매니저 - 만드는 순서를 알고 있음)
class Waiter { // Director
    private PizzaBuilder pizzaBuilder;

    public void setPizzaBuilder(PizzaBuilder pb) { this.pizzaBuilder = pb; }

    public Pizza constructPizza() {
        pizzaBuilder.buildDough();   // 1. 도우 깔고
        pizzaBuilder.buildSauce();   // 2. 소스 바르고
        pizzaBuilder.buildTopping(); // 3. 토핑 올림
        return pizzaBuilder.getPizza();
    }
}

사용하는 곳 (Client)

Java
Waiter waiter = new Waiter();
PizzaBuilder hawaiianBuilder = new HawaiianPizzaBuilder();

waiter.setPizzaBuilder(hawaiianBuilder);
Pizza pizza = waiter.constructPizza(); // "하와이안 피자 만들어줘"

장단점

  • 장점: "생성 알고리즘(순서)"과 "부품 구현(재료)"이 완전히 분리됩니다. 같은 Waiter(Director)에게 PepperoniBuilder만 쥐여주면 페퍼로니 피자가 나옵니다.
  • 단점: 구조가 복잡해지며, 단순한 DTO 생성에는 과합니다.

4. 요약: Simple vs Classic

구분 Simple Builder (Lombok) Classic Builder (GoF)
핵심 목적 가독성 및 불변 객체 생성 편의 생성 과정(순서)의 캡슐화 및 재사용
Director 유무 없음 (클라이언트가 직접 값 세팅) 있음 (Director가 조립 순서 제어)
형태 User.builder().set().build() director.construct()
추천 상황 DTO, Entity 등 데이터 객체 생성 시 문서 변환기(MD->HTML), 복잡한 조립 로직

결론

우리가 평소에 쓰는 @Builder는 Simple Builder입니다. 데이터 객체를 깔끔하게 만들기엔 이보다 좋은 건 없습니다.

하지만 면접이나 아키텍처 설계 시 "객체의 생성 순서가 중요하거나, 생성 과정 자체를 재사용해야 한다면?" 이라는 질문을 받는다면, Director가 존재하는 Classic Builder 패턴을 떠올려야 합니다.