생성자에 매개변수가 많다면 빌더를 고려하라

아이템 2. 생성자에 매개변수가 많다면 빌더를 고려하라

아이템 1이 객체를 어떻게 만들 것인가 에 대한 이야기였다면, 아이템 2는 객체를 어떻게 안전하게 완성시킬 것인가에 대한 이야기다.

생성자의 문제가 단순히 불편함이라고 생각하면 아이템 2의 절반만 이해한 것이다. 실무에서 진짜 문제는 객체가 불완전한 상태로 생성될 수 있다는 점이다.

이 아이템이 말하는 진짜 문제는 매개변수 수가 아니다

매개변수가 많아지면 불편한 건 맞는데, 이 아이템의 본질은 그보다 더 위험한 문제다.

객체 생성이 어려워지는 순간, 코드가 두 갈래로 망가진다.

  1. 호출부가 객체의 의미를 잃는다.
    new NutritionFacts(240, 8, 100, 0, 35, 27) 같은 코드는 읽는 사람에게 숫자 퀴즈를 내는 코드다. 잘못 넣어도 컴파일러가 잡아주지 못한다.

  2. 객체가 완성되기 전에 외부로 새어 나간다.
    이건 단순 가독성 문제가 아니라, 실무에서는 버그가 아니라 장애로 이어진다. 특히 멀티스레드, 캐시, 영속성 컨텍스트(JPA) 같은 요소가 끼면 복구가 어려워진다.

그래서 아이템 2는 단순히 편한 문법을 추천하는 게 아니라, 객체 생성의 안정성을 지키는 방식 하나를 선택하라는 이야기다.

1. 생성자 체이닝(점층적 생성자 패턴)이 왜 망가지는가

1.1 가장 흔한 형태

필수 파라미터만 받는 생성자에서 시작해서 선택 파라미터를 하나씩 늘려가며 this(...)로 이어가는 방식이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
public class NutritionFacts {
    private final int servingSize;  // 필수
    private final int servings;     // 필수
    private final int calories;     // 선택
    private final int fat;          // 선택
    private final int sodium;       // 선택
    private final int carbohydrate; // 선택

    public NutritionFacts(int servingSize, int servings) {
        this(servingSize, servings, 0);
    }

    public NutritionFacts(int servingSize, int servings, int calories) {
        this(servingSize, servings, calories, 0);
    }

    public NutritionFacts(int servingSize, int servings, int calories, int fat) {
        this(servingSize, servings, calories, fat, 0);
    }

    public NutritionFacts(int servingSize, int servings, int calories, int fat, int sodium) {
        this(servingSize, servings, calories, fat, sodium, 0);
    }

    public NutritionFacts(int servingSize, int servings, int calories, int fat, int sodium, int carbohydrate) {
        this.servingSize = servingSize;
        this.servings = servings;
        this.calories = calories;
        this.fat = fat;
        this.sodium = sodium;
        this.carbohydrate = carbohydrate;
    }
}

1.2 이 방식이 터지는 이유는 구조적이다

이 패턴이 나쁜 이유는 단순히 생성자가 많아서가 아니다. 핵심은 다음 두 가지가 동시에 발생한다.

  1. 호출부가 의미를 잃는다
    1
    
    NutritionFacts n = new NutritionFacts(240, 8, 100, 0, 35, 27);
    

    이 코드는 컴파일러에게는 완벽하다. 하지만 사람에게는 완전히 불친절하다. 순서가 바뀌는 순간, 버그는 조용히 들어온다.

더 나쁜 건 같은 타입이 반복될 때다. int, int, int...는 위치가 바뀌어도 컴파일이 되니까 실수 감지가 사실상 불가능해진다.

  1. 선택 파라미터 추가가 API 파괴로 이어진다

처음에는 괜찮다가, 시간이 지나 요구사항이 늘면 선택 파라미터가 하나씩 붙는다. 그 순간부터 생성자 오버로드가 폭증한다.

그리고 여기서 진짜 문제가 나온다. 생성자 시그니처를 건드리면 호출부를 전부 바꿔야 하고, 테스트가 많아질수록 이 변경 비용이 눈덩이처럼 커진다. 실무에서 이건 리팩토링이 아니라 이사 수준이다.

2. 자바빈(JavaBean) 패턴은 왜 편해 보이지만 위험한가

점층적 생성자 패턴의 대안으로 자바빈 패턴이 종종 등장한다.

2.1 자바빈의 형태

기본 생성자로 만들고 setter로 채우는 방식이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class NutritionFacts {
    private int servingSize;
    private int servings;
    private int calories;
    private int fat;
    private int sodium;
    private int carbohydrate;

    public NutritionFacts() {}

    public void setServingSize(int servingSize) { this.servingSize = servingSize; }
    public void setServings(int servings) { this.servings = servings; }
    public void setCalories(int calories) { this.calories = calories; }
    public void setFat(int fat) { this.fat = fat; }
    public void setSodium(int sodium) { this.sodium = sodium; }
    public void setCarbohydrate(int carbohydrate) { this.carbohydrate = carbohydrate; }
}

호출부는 깔끔해 보인다.

1
2
3
4
NutritionFacts n = new NutritionFacts();
n.setServingSize(240);
n.setServings(8);
n.setCalories(100);

2.2 하지만 자바빈은 객체가 완성되기 전까지 불완전 상태로 남는다

이 패턴의 핵심 리스크는 불변이 아니라 시간이다. 객체가 완성되기 전에 외부로 노출될 수 있다. 객체는 생성된 순간부터 외부에서 보일 수 있는데, 그 순간 객체는 아직 반쯤 만들어진 상태다.

예를 들어 이런 상황이 가능해진다.

1
2
3
4
5
- 객체를 생성
- 일부 필드만 set
- 그 사이에 다른 스레드가 읽어감
- 혹은 컬렉션/캐시에 들어가 버림
- 이후 나머지 set 진행
  • 일부만 set된 상태로 캐시에 들어감
  • 다른 스레드가 반쯤 만든 객체를 읽음
  • JPA에서 flush 타이밍과 엮여 원인 추적이 어려운 상태가 됨

결과는 재현이 어려운 버그다. 테스트에서는 잘 안 터지고, 운영에서만 터진다.

2.3 JPA/캐시/동시성이 끼면 더 위험해진다

자바빈 패턴에서 흔한 실수는 이거다.

  • 엔티티를 만든다
  • 저장한다
  • 나중에 setter로 중요한 값을 바꾼다

JPA에서는 변경 감지가 붙으니까 어떤 순간에 flush가 발생하느냐에 따라 DB에 반영되는 타이밍이 바뀌고, 심하면 트랜잭션 밖에서 변경되어 반영이 누락되기도 한다. 또 캐시 키로 사용되는 값이 setter로 바뀌면 캐시가 깨진다.

즉 자바빈은 편해 보이지만, 객체의 생명주기가 길수록, 시스템이 복잡할수록 위험해진다.

3. 그래서 빌더가 등장한다: 생성의 흐름을 한 점으로 모으기

빌더는 자바빈의 장점(가독성)과 점층적 생성자의 장점(불변)을 합친 형태다.

3.1 빌더의 기본 형태

빌더는 조립은 가변으로 하고, build 시점에 완성된 불변 객체를 만든다. 핵심은 문법이 아니라 완성 시점을 한 점으로 모으는 것이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
public class NutritionFacts {
    private final int servingSize;
    private final int servings;
    private final int calories;
    private final int fat;
    private final int sodium;
    private final int carbohydrate;

    public static class Builder {
        private final int servingSize;
        private final int servings;

        private int calories = 0;
        private int fat = 0;
        private int sodium = 0;
        private int carbohydrate = 0;

        public Builder(int servingSize, int servings) {
            this.servingSize = servingSize;
            this.servings = servings;
        }

        public Builder calories(int val) { this.calories = val; return this; }
        public Builder fat(int val) { this.fat = val; return this; }
        public Builder sodium(int val) { this.sodium = val; return this; }
        public Builder carbohydrate(int val) { this.carbohydrate = val; return this; }

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

    private NutritionFacts(Builder builder) {
        this.servingSize = builder.servingSize;
        this.servings = builder.servings;
        this.calories = builder.calories;
        this.fat = builder.fat;
        this.sodium = builder.sodium;
        this.carbohydrate = builder.carbohydrate;
    }
}

호출부는 조립 의도가 남는다.

1
2
3
4
5
NutritionFacts n = new NutritionFacts.Builder(240, 8)
    .calories(100)
    .sodium(35)
    .carbohydrate(27)
    .build();

여기서 중요한 건 보기 좋은 문법이 아니다. 객체가 완성되는 시점이 build()로 단일화되었다는 점이다.

4. 빌더의 핵심 장점

장점 1. 객체 생성의 의미가 호출부에 남는다

점층적 생성자는 매개변수 리스트가 길어질수록 의미가 사라진다. 빌더는 이름이 의미가 된다.

calories(100)은 100이라는 값에 의미를 붙인다. 실무에서 이 차이는 유지보수 비용을 크게 줄인다.

특히 같은 타입이 반복되는 경우에 압도적이다.

1
new Something(1, 1, 1, 1, 1);

이 코드에서 실수는 운이다. 반면 빌더는 실수를 구조적으로 어렵게 만든다.

장점 2: 불변식 검증을 build에 모을 수 있다

자바빈은 setter로 나눠 넣는 순간, 검증이 애매해진다. 왜냐하면 객체가 완성되기 전인데도 setter가 호출되기 때문이다.

빌더에서는 검증을 build 한 번에 몰아넣을 수 있다.

1
2
3
4
5
6
public NutritionFacts build() {
    if (servingSize <= 0) throw new IllegalArgumentException("servingSize must be positive");
    if (servings <= 0) throw new IllegalArgumentException("servings must be positive");
    if (calories < 0) throw new IllegalArgumentException("calories must be >= 0");
    return new NutritionFacts(this);
}

검증의 위치가 build에 있다는 건 실무에서 큰 의미다.

객체가 생성되는 그 순간, 유효성도 같이 확정된다. 이게 곧 객체의 신뢰도다.

5. 계층형 빌더: 상속 구조에서 빌더가 진짜 어려워지는 지점

상속이 들어가면 필드가 부모/자식으로 나뉘고, 빌더도 그 계층을 따라가야 한다. 문제는 여기서 builder.method().method() 체이닝이 타입을 잃어버리기 시작한다는 점이다. 즉, 체이닝 반환 타입이 부모로 굳어 버리는 문제가 발생한다.

5.1 왜 타입을 잃어버리나

부모 빌더 메서드가 Builder를 반환하면, 자식 빌더를 호출하고 싶어도 반환 타입이 부모로 굳어버린다.

그래서 계층형 빌더는 self-type(자기 자신 타입 반환) 트릭을 쓴다.

5.2 계층형 빌더의 정석 형태

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
public abstract class Pizza {
    public enum Topping { HAM, MUSHROOM, ONION, PEPPER, SAUSAGE }
    final java.util.Set<Topping> toppings;

    abstract static class Builder<T extends Builder<T>> {
        java.util.EnumSet<Topping> toppings = java.util.EnumSet.noneOf(Topping.class);

        public T addTopping(Topping topping) {
            toppings.add(java.util.Objects.requireNonNull(topping));
            return self();
        }

        abstract Pizza build();
        protected abstract T self();
    }

    Pizza(Builder<?> builder) {
        this.toppings = builder.toppings.clone();
    }
}

class NyPizza extends Pizza {
    public enum Size { SMALL, MEDIUM, LARGE }
    private final Size size;

    static class Builder extends Pizza.Builder<Builder> {
        private final Size size;

        Builder(Size size) {
            this.size = java.util.Objects.requireNonNull(size);
        }

        @Override public NyPizza build() {
            return new NyPizza(this);
        }

        @Override protected Builder self() { return this; }
    }

    private NyPizza(Builder builder) {
        super(builder);
        this.size = builder.size;
    }
}

호출부는 이렇게 자연스럽게 이어진다.

1
2
3
4
NyPizza p = new NyPizza.Builder(NyPizza.Size.SMALL)
    .addTopping(Pizza.Topping.HAM)
    .addTopping(Pizza.Topping.ONION)
    .build();

여기서 계층형 빌더의 가치는 딱 하나로 요약된다.

부모 기능을 체이닝해도, 반환 타입이 자식 빌더로 유지된다. 즉, 부모 메서드를 호출해도 반환 타입이 자식 빌더로 유지된다.

상속에서 이게 깨지면 호출부가 무너진다.

6. 객체 프리징(freezing): 가변은 빌더에서 끝내고, 객체는 굳혀라

객체 프리징은 단순히 final을 붙이라는 얘기가 아니다. 상태 변경 가능성을 빌더 단계로 몰아넣고, build 이후에는 상태 변경 통로를 없애겠다는 전략이다.

6.1 프리징이 중요한 이유

실무에서 객체는 다음과 같이 흘러간다.

  • 서비스 계층에서 생성
  • 캐시에 들어감
  • 다른 스레드에서 읽음
  • 로그/이벤트로 흘러감
  • 직렬화되어 전송됨

이 흐름에서 객체가 가변이면, 문제는 두 종류로 커진다.

  • 현재 값이 언제 바뀌었는지 추적 불가능
  • 참조 공유로 인해 예상치 못한 사이드 이펙트 발생

빌더는 이걸 막기 위한 구조다.

6.2 컬렉션 필드가 있으면 프리징은 더 중요해진다

final 필드라도 내부 컬렉션이 가변이면 프리징이 반쪽짜리가 된다.

1
2
3
4
5
6
7
8
public class Order {
    private final java.util.List<String> items;

    private Order(Builder b) {
        this.items = b.items; // 위험: 외부에서 b.items를 계속 수정할 수 있음
        // 따라서 외부 list가 바뀌면 Order 내부도 같이 바뀜
    }
}

이렇게 해야 한다.

1
2
3
private Order(Builder b) {
    this.items = java.util.List.copyOf(b.items); // 불변 리스트로 굳힘
}

프리징의 목적은 build 이후에 상태 변경 통로를 없애는 것이다. final은 시작일 뿐이고, 내부 가변 컬렉션까지 잠가야 끝난다.

List.copyOf는 방어적 복사이면서 불변 리스트를 돌려준다. 이걸 해주지 않으면 build 이후에도 객체 내부 상태가 바뀔 수 있다.

7. IllegalArgumentException: 예외를 던지는 위치가 설계다

아이템 2에서 예외는 부수적인 얘기가 아니다. 잘못된 인자를 언제 감지하느냐는 객체 생명주기와 직결된다.

7.1 빌더에서 예외는 보통 build에서 던지는 게 맞다

setter 단계에서 검증하면, 아직 객체가 완성되지 않았는데도 중간 상태에서 예외를 던지게 된다. 그럼 호출부는 어디까지 설정되었는지, 어떤 상태였는지 다시 추적해야 한다.

build에서 검증하면 흐름이 단순해진다.

  • 빌더는 조립 과정
  • build는 확정 순간
  • 확정 순간에 검증 실패면 IllegalArgumentException

이 구조는 객체의 신뢰를 높인다. 생성된 객체는 항상 유효하다는 보장이 생긴다.

7.2 IllegalArgumentException vs IllegalStateException

  • IllegalArgumentException: 입력이 잘못됐다
  • IllegalStateException: 객체 상태가 이미 잘못된 단계다

빌더에서는 보통 build 시점에 입력 검증을 하므로 IllegalArgumentException이 맞는 경우가 많다. 하지만 빌더가 순서를 강제하는 단계형 API라면, 순서 위반은 IllegalStateException이 더 자연스럽다.

예를 들어 필수 단계 누락 같은 경우다.

1
2
3
4
public Order build() {
    if (this.memberId == null) throw new IllegalStateException("memberId must be set");
    return new Order(this);
}

8. 가변 인수(varargs): 편한데, 설계가 흐려지는 지점

가변 인수는 호출부를 짧게 만들지만, 객체 생성 API에서는 함정이 많다.

8.1 의미가 사라진다

1
new Something("a", "b", "c");

이게 무엇인지 타입으로는 보이지만 의미로는 안 보인다. 특히 요소마다 의미가 다른데 모두 같은 타입이면, 점층적 생성자와 같은 문제가 반복된다.

8.2 배열 생성과 방어적 복사 문제가 붙는다

varargs는 내부적으로 배열로 들어온다. 이 배열을 그대로 필드로 저장하면 외부에서 배열을 수정할 수 있는 여지가 생긴다.

1
2
3
4
5
6
7
public class Tags {
    private final String[] tags;

    public Tags(String... tags) {
        this.tags = tags; // 위험
    }
}

안전하게 하려면 복사해야 한다.

1
2
3
public Tags(String... tags) {
    this.tags = tags.clone();
}

그런데 여기서 결론이 나온다. 이런 방어 코드를 붙일수록 API가 복잡해지고, 결국 빌더가 더 낫게 된다.

8.3 그래서 실무에서는 이렇게 정리된다

  • 의미가 단순한 나열이면 varargs도 괜찮다
  • 객체 구성의 핵심 파라미터라면 varargs는 피하고, 빌더로 의미를 살린다
  • 컬렉션 필드는 결국 빌더 + copyOf로 프리징하는 게 가장 안정적이다

9. 빌더를 쓰지 말아야 하는 경우도 있다

빌더는 만능이 아니다. 남발하면 오히려 코드를 무겁게 만든다.

빌더를 쓰지 않는 게 더 좋은 경우는 보통 이런 케이스다.

  • 매개변수가 2~3개로 고정이고 의미가 명확할 때
  • 값 객체(Value Object)처럼 불변식이 단순하고 생성 비용이 작을 때
  • 성능이 매우 민감해 객체 생성을 극단적으로 줄여야 할 때(물론 이런 케이스는 흔치 않다)

예를 들어 Point(x, y)는 생성자만으로도 충분히 명확하다. 무조건 빌더로 가는 건 오히려 과하다.

10. 빌더의 단점은 무엇이고, 그걸 어떻게 관리해야 하는가

아이템 2가 자주 오해되는 지점은 이거다. 빌더를 소개하면서 단점을 가볍게 언급하니, 빌더가 거의 정답처럼 보인다. 하지만 실무에서 빌더는 분명한 비용과 위험을 동반하는 선택이다.

10.1 빌더는 객체를 두 개 만든다

빌더를 쓰면 항상 두 개의 객체가 생성된다.

  • Builder 객체
  • 실제 결과 객체

이건 문법의 문제가 아니라 메모리 모델의 문제다.

1
2
3
4
Order order = Order.builder()
                   .price(1000)
                   .quantity(2)
                   .build();

이 코드 한 줄의 의미는 다음과 같다.

  • 힙에 Builder 인스턴스 생성
  • 힙에 Order 인스턴스 생성

Builder는 곧바로 버려지고 GC 대상이 된다

일반적인 서비스 코드에서는 이 비용이 문제 되지 않는다. 하지만 루프 안에서 대량 객체를 만들거나, 스트림/배치/매핑 계층에서 빌더를 무차별로 쓰면 누적 비용이 된다.

그래서 실무에서는 이런 기준이 자연스럽게 생긴다.

  • 요청 단위 객체 생성 → 빌더 OK
  • 수만 건 데이터 매핑 → 생성자 / 팩터리 / 전용 변환 로직 고려

빌더는 편의성 도구이지, 고성능 생성 도구는 아니다.

10.2 컴파일 타임 안정성이 생성자보다 약해질 수 있다

빌더는 가독성을 얻는 대신, 일부 컴파일 타임 보장을 런타임으로 미룬다.

1
2
3
Order order = Order.builder()
                   .price(1000)
                   .build(); // quantity 누락

이 코드는 컴파일된다. 문제는 build 시점까지 가야 드러난다.

그래서 빌더를 쓸 때는 반드시 필수 값 전략을 명확히 해야 한다.

  • 필수 값은 Builder 생성자에서 받는다
  • build에서 누락 검증을 강제한다

순서가 중요한 단계형 빌더라면 상태 기반 검증을 둔다

이걸 하지 않으면, 빌더는 안전한 생성 방식이 아니라 실수를 늦게 발견하는 방식이 된다.

10.3 API 표면적이 커진다는 건 유지보수 비용이다

빌더를 도입하면 클래스 하나에 다음이 추가된다.

  • Builder 타입
  • 필드별 설정 메서드
  • build 메서드
  • 검증 로직

이건 단순 코드량 증가가 아니다. 공개 API가 늘어나는 것이다.

특히 라이브러리나 공용 모듈에서는 이 차이가 크다.

  • 문서화 대상 증가
  • 사용법 학습 비용 증가
  • 변경 시 호환성 고려 포인트 증가

그래서 내부 도메인 객체와 외부 노출 객체는 기준이 다르다.

  • 내부 전용 객체 → 빌더 적극 사용 가능
  • 외부 API 모델 → 생성자 / 팩터리도 충분히 고려

11. JPA 엔티티에서 빌더를 조심해야 하는 이유

아이템 2를 실무에 적용할 때 가장 많이 나오는 질문이 이거다.

엔티티도 빌더 쓰면 좋은 거 아닌가요?

결론부터 말하면, 대부분의 경우 조심해야 한다.

11.1 엔티티는 생성보다 생명주기가 더 중요하다

엔티티는 단순 데이터 덩어리가 아니다.

영속성 컨텍스트에 관리되고 변경 감지가 동작하며 프록시로 감싸질 수 있고 트랜잭션 경계에 민감하다

빌더는 기본적으로 외부에서 모든 필드를 열어두는 구조다. 이건 엔티티의 불변식(invariant)을 깨기 쉽다.

1
2
3
4
5
6
7
8
9
@Entity
@Builder
public class Order {
    @Id @GeneratedValue
    private Long id;

    private int price;
    private int quantity;
}

이 구조의 문제는 명확하다.

  1. 어떤 필드가 필수인지 보이지 않는다
  2. 생성 시점의 의미가 흐려진다
  3. 도메인 규칙이 생성자에 모이지 않는다

그래서 실무에서는 보통 이렇게 분리한다.

  • 엔티티 생성 → 명시적 생성자 / 정적 팩터리
  • 조회 DTO / 커맨드 객체 → 빌더 적극 사용

11.2 엔티티 빌더는 테스트와 혼동을 만든다

테스트 코드에서 빌더를 쓰면 이런 코드가 나온다.

1
2
3
4
5
Order order = Order.builder()
                   .price(1000)
                   .quantity(2)
                   .status(ORDERED)
                   .build();

겉으로는 편해 보이지만, 이 객체가 도메인 규칙을 만족하는지 한눈에 보이지 않는다.

그래서 테스트에서는 종종 이 패턴이 더 낫다.

1
Order order = OrderFixture.normalOrder();

빌더는 조립 도구지, 도메인 시나리오를 표현하는 도구는 아니다.

12 .Lombok @Builder를 쓸 때 절대 하면 안 되는 패턴들

Lombok @Builder는 아이템 2를 빠르게 적용하게 해주는 도구다. 하지만 설계를 대신해주지는 않는다. 오히려 아무 생각 없이 쓰면 아이템 2의 취지를 정면으로 배반한다.

12.1 모든 필드에 @Builder를 열어두는 패턴

가장 흔하고, 가장 위험한 형태다.

1
2
3
4
5
6
7
@Builder
public class Order {
    private Long id;
    private int price;
    private int quantity;
    private OrderStatus status;
}

이 구조의 문제는 명확하다.

  • 어떤 값이 필수인지 알 수 없다
  • 어떤 조합이 유효한지 드러나지 않는다
  • 도메인 규칙이 생성 시점에 강제되지 않는다

결과적으로 이런 코드가 가능해진다.

1
Order order = Order.builder().build();

컴파일도 되고, 런타임에서도 바로 터지지 않을 수 있다. 하지만 이 객체는 의미 없는 객체다.

빌더는 안전한 생성을 돕는 패턴이지, 아무 상태나 허용하는 패턴이 아니다.

12.2 엔티티에 무분별하게 @Builder를 붙이는 패턴

JPA 엔티티에 @Builder를 바로 붙이는 건 실무에서 가장 많이 보는 실수다.

1
2
3
4
5
6
7
8
9
@Entity
@Builder
public class Member {
    @Id @GeneratedValue
    private Long id;

    private String name;
    private int age;
}

이 구조는 다음 문제를 동시에 만든다.

  • JPA 기본 생성자 요구사항과 충돌
  • 엔티티 생성 책임이 외부로 완전히 열림
  • 도메인 생성 규칙이 사라짐

특히 위험한 건 id까지 빌더로 열리는 경우다.

1
Member m = Member.builder().id(1L).name("kim").build();

이 객체는 JPA 입장에서 이미 존재하는 엔티티처럼 보일 수도 있고, 영속성 컨텍스트에서는 예측 불가능한 동작을 만든다.

따라서 기준은 다음과 같이 두는 것이 좋다.

  • 엔티티 생성은 명시적 생성자 또는 정적 팩터리
  • 빌더는 DTO / 커맨드 객체 / 테스트 전용에 한정

12.3 @Builder(toBuilder = true) 남용

이 옵션은 겉보기에 굉장히 매력적이다.

1
2
3
Order newOrder = oldOrder.toBuilder()
                         .price(2000)
                         .build();

하지만 이건 불변 객체에서만 제한적으로 써야 한다. 엔티티나 생명주기가 긴 객체에 쓰면 다음 문제가 생긴다.

  • 기존 객체의 상태를 복사해 새 객체를 만들지만
  • 두 객체의 정체성 구분이 모호해진다
  • 캐시, equals/hashCode, 식별자와 충돌

특히 JPA 엔티티에서 이건 거의 재앙이다. 엔티티는 복사해서 쓰는 개념이 아니다.

12.4 @Builder + 컬렉션 필드 + 방어적 복사 누락

1
2
3
4
@Builder
public class Order {
    private List<String> items;
}

이 코드는 빌더를 써도 안전하지 않다.

1
2
3
List<String> list = new ArrayList<>();
Order order = Order.builder().items(list).build();
list.add("NEW_ITEM"); // order 내부 상태가 바뀜

빌더는 프리징을 자동으로 해주지 않는다. 프리징은 설계자의 책임이다.

반드시 생성자에서 방어적 복사가 필요하다.

1
2
3
private Order(Builder b) {
    this.items = List.copyOf(b.items);
}

12.5 Lombok 빌더의 본질적 한계

Lombok @Builder는 다음을 대신해주지 않는다.

  1. 필수/선택 값 구분
  2. 도메인 불변식 설계
  3. 검증 위치 결정
  4. 객체 생명주기 설계

그래서 Lombok 빌더는 이렇게 써야 한다.

  • DTO / 조회 모델 / 요청 객체 → 적극 사용
  • 엔티티 / 도메인 핵심 객체 → 매우 제한적으로 사용
  • 검증은 반드시 build 또는 생성자에 모을 것

마무리 : 실무에서 정리되는 아이템 2의 기준

  • 생성자 매개변수가 많아질수록 문제는 문법이 아니라 의미 손실이다
  • 객체는 생성되는 순간부터 완전하고 유효해야 한다
  • 빌더는 그 완성 시점을 build 하나로 모으는 도구다
  • 하지만 비용, 제약 약화, API 증가를 감수해야 한다
  • 그래서 모든 객체에 쓰는 게 아니라 필요한 지점에만 쓴다

정리하면 이 한 문장이다.

빌더는 객체 생성을 편하게 만드는 패턴이 아니라, 객체 완성의 책임을 한 지점으로 모으는 패턴이다.

  • 생성자: 빠르고 강하지만, 의미가 약해질 수 있다
  • 자바빈: 편하지만, 불완전한 객체를 만들 수 있다
  • 빌더: 안전하지만, 비용과 복잡도를 가진다

따라서 객체를 어떻게 만들지가 아니라, 객체가 언제 완성되었는지를 코드로 드러내야 한다.


[번외] 빌더 vs 정적 팩터리 선택 기준 도식화

아이템 1과 아이템 2는 경쟁 관계가 아니다. 둘은 서로 다른 문제를 푼다.

핵심은 언제 무엇을 선택하느냐다.

해결하려는 문제 기준

상황 빌더 정적 팩터리
매개변수 수가 많다 ✅ 적합 ❌ 가독성 저하
필수/선택 값이 섞여 있다 ✅ 매우 적합 ❌ 시그니처 폭증
생성 의미가 하나로 고정 ❌ 과함 ✅ 적합
생성 비용을 숨기고 싶다
캐싱 / 인스턴스 재사용
생성 결과 타입을 숨김
테스트 데이터 조립

객체 성격 기준

객체 성격 추천 방식
값 객체(Value Object) 생성자 / 정적 팩터리
요청 DTO 빌더
조회 DTO 빌더
엔티티 생성자 / 정적 팩터리
설정 객체 빌더
캐시 키 / 식별 객체 정적 팩터리

설계 관점 기준

  • 생성의 의미가 중요하면 → 정적 팩터리
  • 조립 과정이 중요하면 → 빌더
  • 객체 생명주기가 길면 → 빌더 신중
  • 객체 정체성이 중요하면 → 정적 팩터리

자바빈을 써야할 때도 있다

자바빈을 쓸 수밖에 없는 상황도 있다. 대표적으로 역직렬화나 프레임워크 바인딩이다. 이때는 다음으로 피해를 줄인다.

  • 외부 입력 모델은 자바빈을 허용하되, 도메인 모델로 들어올 때는 변환 단계에서 불변 객체로 만든다.
  • setter가 있는 타입을 도메인 규칙 중심으로 직접 사용하지 않는다.
  • 검증은 setter마다 흩뿌리지 말고, 변환 시점 또는 빌드 시점에서 한 번에 한다.

예시로, 요청 DTO는 자바빈이어도 되지만, 엔티티나 값 객체는 자바빈 형태로 만들지 않는 쪽이 안전하다.

결론

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
시작
  |
  |-- 파라미터가 많거나(대략 4개 이상) 필수/선택이 섞였나?
  |        |-- 예 --> 빌더 우선 검토
  |        |
  |        |-- 아니오
  |
  |-- 생성 의미를 이름으로 드러내야 하나? (from, of, valueOf, parse 등)
  |        |-- 예 --> 정적 팩터리 우선
  |        |
  |        |-- 아니오
  |
  |-- 캐싱/인스턴스 재사용/구현 타입 은닉이 필요한가?
  |        |-- 예 --> 정적 팩터리
  |        |
  |        |-- 아니오 --> 생성자 또는 정적 팩터리(취향/가독성)

아이템 1은 객체를 어떻게 생성할지 숨기라고 말한다면, 아이템 2는 객체가 언제 완성되는지 드러내라고 이야기 한다.

그래서 실무에서는 이 조합이 가장 강력하다.

  • 정적 팩터리 + 내부에서 빌더 사용
  • 외부에는 단순한 생성 API
  • 내부에서는 안전한 조립 과정