동료의 코드를 보다가 같은 객체를 생성자와 빌더로 섞어 만드는 부분을 발견했다. 왜 두 방식을 함께 썼는지 물었지만 납득할 만한 답을 듣지 못했고, 나 역시 “협업하려면 무조건 빌더를 쓰는 편이 낫지 않을까?”라는 생각을 설명할 수 없었다.
결론부터 말하면 생성자와 빌더 중 하나가 항상 우월한 것은 아니다. 필수값과 불변식을 가장 분명하게 드러내는 생성 API를 선택하면 된다. 참고로 Java 생성자는 언어 기능이고 Builder는 객체 생성을 단계적으로 조립하는 디자인 패턴이므로, 기존 제목의 “생성자 패턴”이라는 표현도 정확하지 않았다.
필수값이 적으면 생성자가 더 솔직하다
매개변수가 적고 각 값의 의미가 분명하다면 생성자는 가장 짧고 강한 API다.
RetryPolicy policy = new RetryPolicy(3, Duration.ofSeconds(2));
생성 시점에 필수값을 모두 받고 검증하면 불완전한 객체가 밖으로 나가지 않는다. 다만 같은 타입의 인자가 여러 개이거나 인자 순서만 보고 의미를 알기 어렵다면 호출 코드가 위험해진다.
// 둘 다 int라서 호출부만 보면 의미를 바꾸기 쉽다.
new ConnectionPool(20, 5);
이 경우 이름 있는 정적 팩터리 메서드가 의도를 더 잘 드러낼 수도 있다.
ConnectionPool pool = ConnectionPool.withLimits(
20, // maxSize
5 // minIdle
);
생성자와 초기화 자체의 기본 동작은 Java 생성자와 초기화에서 별도로 정리했다.
빌더가 값을 발휘하는 조건
빌더는 다음 조건에서 특히 유용하다.
- 선택 매개변수가 많다.
- 같은 타입의 매개변수가 여럿이라 이름을 드러내야 한다.
- 일부 값만 조합하는 경우가 많다.
- 생성 과정을 읽기 쉬운 형태로 표현하고 싶다.
- 객체를 불변으로 유지하면서 긴 생성자 호출은 피하고 싶다.
그러나 빌더가 있다고 필수값과 유효성이 자동으로 보장되는 것은 아니다. 호출자가 필수 setter를 빼먹을 수 있으므로 build() 또는 최종 생성자에서 불변식을 확인해야 한다.
import java.time.Duration;
import java.util.Objects;
public final class DeploymentRequest {
private final String service;
private final String image;
private final int replicas;
private final Duration timeout;
private DeploymentRequest(Builder builder) {
this.service = requireText(builder.service, "service");
this.image = requireText(builder.image, "image");
if (builder.replicas < 1) {
throw new IllegalArgumentException("replicas must be at least 1");
}
if (builder.timeout.isNegative() || builder.timeout.isZero()) {
throw new IllegalArgumentException("timeout must be positive");
}
this.replicas = builder.replicas;
this.timeout = builder.timeout;
}
public static Builder builder(String service, String image) {
return new Builder(service, image);
}
public static final class Builder {
private final String service;
private final String image;
private int replicas = 1;
private Duration timeout = Duration.ofSeconds(30);
private Builder(String service, String image) {
this.service = Objects.requireNonNull(service);
this.image = Objects.requireNonNull(image);
}
public Builder replicas(int replicas) {
this.replicas = replicas;
return this;
}
public Builder timeout(Duration timeout) {
this.timeout = Objects.requireNonNull(timeout);
return this;
}
public DeploymentRequest build() {
return new DeploymentRequest(this);
}
}
private static String requireText(String value, String name) {
if (value == null || value.isBlank()) {
throw new IllegalArgumentException(name + " must not be blank");
}
return value;
}
}
이 예시는 service와 image를 builder() 진입점의 필수 인자로 두고, replicas와 timeout만 선택적으로 바꾸게 한다. 빌더의 유창한 호출 문법보다 유효하지 않은 상태를 만들기 어렵게 한 것이 더 중요하다.
Lombok @Builder도 설계 결정을 대신하지 않는다
Lombok 공식 문서에 따르면 클래스에 @Builder를 붙이면 모든 필드를 받는 package-private 생성자를 바탕으로 빌더를 생성한다. 값을 설정하지 않으면 기본적으로 null, 0, false가 들어가며, 필드 기본값을 빌더에도 적용하려면 @Builder.Default 같은 별도 규칙을 알아야 한다.
따라서 클래스 전체에 습관적으로 @Builder를 붙이면 다음을 놓치기 쉽다.
- 어떤 값이 필수인지 호출 API만으로 알기 어렵다.
- 도메인 불변식 검증이 빠질 수 있다.
- 내부에서만 설정해야 할 필드까지 공개될 수 있다.
- 생성자,
@NoArgsConstructor,@Value와 조합할 때 실제 생성 코드가 예상과 달라질 수 있다.
Lombok을 쓰더라도 검증이 들어간 생성자에 @Builder를 적용하거나, 정적 생성 메서드를 빌더의 대상으로 삼는 편이 의도를 보존하기 쉽다. 도구가 보일러플레이트는 줄여 주지만 객체의 올바른 생성 규칙까지 결정해 주지는 않는다.
선택 기준을 짧게 정리하면
| 상황 | 먼저 검토할 방식 |
|---|---|
| 필수값 1~3개, 의미가 명확함 | 생성자 |
| 같은 타입 인자가 많거나 생성 의미에 이름이 필요함 | 정적 팩터리 메서드 |
| 필수값과 선택값이 많고 조합이 다양함 | 빌더 |
| 단순한 데이터 전달용 값 묶음 | record 또는 명시적 DTO 생성자 |
| 외부 라이브러리가 객체 생성을 담당함 | 그 라이브러리의 매핑·생성 계약 |
숫자는 절대 기준이 아니다. 인자 개수보다 호출부에서 의미를 오해할 가능성, 객체의 불변식, 공개 API의 변경 비용이 더 중요하다. Builder 패턴 자체와 다른 생성 패턴의 위치는 디자인 패턴 활용 가이드에서도 비교할 수 있다.
지금의 생각
처음에는 협업을 위해 빌더를 통일하면 읽기 쉬워질 것이라 생각했다. 하지만 통일된 문법보다 중요한 것은 팀이 어떤 값이 필수이고, 어느 시점에 검증하며, 누가 객체 생성을 책임지는지 같은 기준을 공유하는 일이다.
생성자는 짧다고 낡은 방식이 아니고, 빌더는 길다고 좋은 설계가 아니다. 객체가 유효한 상태로만 만들어지고 호출부에서 의도가 드러난다면 둘을 함께 쓰는 것도 자연스럽다. 이제는 “무조건 Builder” 대신 생성 규칙을 먼저 설명할 수 있는지를 보려고 한다.
참고 자료
'배움과 성장 > 소프트웨어 개발' 카테고리의 다른 글
| 자바답게 프로그래밍한다는 질문이 남긴 것 (0) | 2022.10.31 |
|---|---|
| W3Schools Java 튜토리얼 복습: 추상 클래스·인터페이스·컬렉션 (0) | 2022.10.20 |
| Java String·StringBuilder·StringBuffer 차이: 문자열 연결 기준 정리 (4) | 2022.09.10 |
| Java 람다식: 함수형 인터페이스·target type·effectively final (0) | 2022.06.28 |
| Java Comparable과 Comparator 차이: 객체 정렬 기준을 안전하게 만드는 법 (4) | 2022.02.20 |
댓글