목차
생성자 인자가 폭발한다
필드가 많은 객체를 만든다고 하자. User에 id·email은 필수고, name·age·phone·address는 선택이다. 생성자로 만들면 이렇게 된다.
public User(Long id, String email) { ... }
public User(Long id, String email, String name) { ... }
public User(Long id, String email, String name, int age) { ... }
public User(Long id, String email, String name, int age, String phone) { ... }
// 선택 필드 조합마다 생성자가 하나씩 는다선택 필드가 늘수록 생성자 조합이 폭발한다. “name은 없고 phone만 있는” 경우까지 만들려면 끝이 없다. 이걸 텔레스코핑 생성자라고 부른다.
순서로 기억해야 하는 게 위험하다
조합을 줄이려고 인자를 다 받는 생성자 하나로 몰아도, 부르는 쪽이 이렇게 된다.
User user = new User(1L, "kim@example.com", "김", 30, "010-1234-5678", "서울");30이 age인지 뭔지, 뒤의 문자열들이 phone인지 address인지 순서로만 알 수 있다. 인자 두 개의 순서를 바꿔 넣어도 컴파일은 되고, 런타임에야 틀린다. 타입이 같은 인자가 나열되면 이 위험이 특히 크다.
세터로 만들면 불완전하고 가변이다
그럼 세터는 어떨까. 빈 객체를 만들고 하나씩 채운다.
User user = new User();
user.setId(1L);
user.setEmail("kim@example.com");
user.setName("김");읽기는 낫다. 그런데 두 가지가 걸린다.
- 미완성 객체가 돌아다닌다.
setEmail전의user도 이미 존재한다. id만 세팅된 반쪽짜리가 다른 코드로 넘어갈 수 있다. - 불변이 안 된다. 세터가 열려 있으니 만든 뒤에도 아무나 바꾼다. 값이 고정돼야 하는 객체에는 못 쓴다.
빌더 = 단계별로 조립해 마지막에 완성한다
빌더 패턴이 이 셋을 한 번에 푼다.
객체를 한 번에 만들지 말고, 별도의 빌더에 값을 단계별로 쌓은 뒤, 마지막에 완성된 객체 하나를 찍어낸다.
만드는 도중의 반쪽짜리는 빌더 안에만 있고, 밖으로 나오는 건 build()가 돌려주는 완성된 객체뿐이다.
빌더를 만든다
User 안에 정적 중첩 클래스로 빌더를 둔다. 값을 넣는 메서드가 자기 자신을 돌려줘서 체이닝이 된다.
public class User {
private final Long id;
private final String email;
private final String name;
private final int age;
private User(Builder b) { // 빌더로만 만들 수 있다
this.id = b.id; this.email = b.email;
this.name = b.name; this.age = b.age;
}
public static class Builder {
private final Long id; // 필수
private final String email; // 필수
private String name; // 선택
private int age; // 선택
public Builder(Long id, String email) { // 필수는 빌더 생성자로 받는다
this.id = id;
this.email = email;
}
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);
}
}
}쓰는 쪽은 이렇게 읽힌다.
User user = new User.Builder(1L, "kim@example.com")
.name("김")
.age(30)
.build();무엇이 name이고 age인지 메서드 이름으로 드러나고, 필요 없는 선택 필드는 그냥 안 부르면 된다.
필수는 생성자로, 선택은 메서드로
빌더의 짜임에 뜻이 있다. 필수 값은 빌더 생성자로 받고, 선택 값은 체이닝 메서드로 받았다.
그래서 필수를 빠뜨릴 수가 없다. new User.Builder(...)를 부르는 순간 id·email을 반드시 넘겨야 하니까. 텔레스코핑 생성자의 “무엇을 필수로 강제할지”가 여기서 깔끔하게 정리된다. 필수는 문법이 강제하고, 선택은 있으면 부르고 없으면 만다.
build()에서 검증하고 불변으로 굳힌다
build()는 단순히 객체를 넘기기만 하는 게 아니다. 완성 직전에 검증하는 자리다.
public User build() {
if (age < 0) {
throw new IllegalStateException("age는 음수일 수 없다");
}
return new User(this);
}여러 필드에 걸친 규칙(“A가 있으면 B도 있어야 한다” 같은 것)을 여기서 한 번에 확인한다. 통과해야 객체가 나온다.
그리고 나온 User는 불변이다. 필드가 전부 final이고 세터가 없다. 만들 때만 값을 정하고, 그 뒤엔 아무도 못 바꾼다. 세터 방식이 못 주던 두 가지 - 미완성 객체 없음, 불변 보장 - 을 빌더가 준다.
세 방법을 비교하면
| 텔레스코핑 생성자 | 세터 | 빌더 | |
|---|---|---|---|
| 읽힘 | 나쁨 (순서로 구분) | 좋음 | 좋음 |
| 필수 강제 | 됨 | 안 됨 | 됨 (빌더 생성자) |
| 미완성 노출 | 없음 | 있음 | 없음 |
| 불변 | 됨 | 안 됨 | 됨 |
빌더는 텔레스코핑의 “불변·필수 강제”와 세터의 “읽힘”을 둘 다 가진다. 대신 빌더 클래스를 하나 더 쓰는 값을 치른다.
디렉터는 조립 순서를 감춘다
GoF 원형에는 빌더 말고 **디렉터(Director)**가 하나 더 있다. 자주 만드는 조립 과정을 디렉터가 알고 있어서, 클라이언트는 “표준 사용자 하나 만들어줘”라고만 하면 디렉터가 정해진 순서로 빌더를 부른다. 같은 조립 절차를 여러 곳에서 재사용할 때 값을 한다.
다만 자바에서 흔히 쓰는 빌더(이펙티브 자바 스타일)는 디렉터를 대개 생략하고 클라이언트가 직접 체이닝한다. 조립 과정이 매번 다르고 단순하면 디렉터가 오히려 군더더기라서다. 그래서 실무에서 “빌더”라고 하면 보통 이 디렉터 없는 형태를 가리킨다.
실무: Lombok @Builder
이 빌더 코드를 매번 손으로 짜는 건 지겹다. 그래서 실무 자바에선 **Lombok의 @Builder**를 자주 쓴다.
@Builder
public class User {
private final Long id;
private final String email;
private final String name;
private final int age;
}애너테이션 하나로 위의 빌더 클래스가 자동 생성된다. User.builder().id(1L).email("...").build() 꼴로 바로 쓴다. 다만 필수/선택 구분이나 여러 필드 검증은 기본 @Builder론 약해서, 필요하면 커스터마이즈한다. 빌더는 선택 필드가 많은 불변 객체(설정, DTO, 요청 객체)에서 특히 값을 한다.
정리
| 빌더란 | 객체를 단계별로 쌓아 마지막에 완성본 하나를 찍어낸다 |
| 무엇을 푸나 | 텔레스코핑 생성자(인자 폭발)·순서 의존·세터의 미완성/가변 |
| 필수/선택 | 필수는 빌더 생성자로 강제, 선택은 체이닝 메서드로 |
| build() | 검증하는 자리이자, 불변 객체를 굳혀 내보내는 자리 |
| 디렉터 | 조립 순서를 감추는 원형 요소. 자바 빌더는 대개 생략 |
| 실무 | Lombok @Builder / 선택 필드 많은 불변 객체 |
인자를 한 줄에 몰아넣지 않고, 이름 붙은 단계로 쌓아 완성하는 것. 읽히면서 필수를 강제하고 불변으로 끝나는 것 - 그게 빌더다.