Java에서 여러 매개변수 객체를 위한 Builder Pattern
1. 개요
한 클래스가 6개, 7개의 매개변수를 가지고 있고, 그 중 몇 개는 선택 사항일 때, 일반적인 생성자는 빠르게 읽기 어려워지고 매개변수를 잘못 전달하기 쉬워집니다. Builder Pattern이 이 문제를 해결합니다. 각 매개변수에 명확한 이름을 지정하고, 기본값을 가지며, 한 곳에서만 검증할 수 있습니다.
이 글은 4가지 부분으로 나뉩니다. 먼저 여러 매개변수의 생성자 문제를 설명합니다. 그 다음 완전한 Builder를 직접 작성하고 JUnit 5로 테스트합니다. 마지막으로 Lombok @Builder를 사용하는 방법과 Java 21에서 Builder를 record와 결합하는 방법을 다룹니다.
2. 여러 매개변수의 생성자 문제
2.1. Telescoping 생성자
Order 클래스가 있다고 가정해봅시다. 이 클래스는 customerId, items 목록, shippingAddress, couponCode, giftWrap, priority 필드가 있습니다. 일반적인 방식은 여러 개의 오버로드된 생성자를 작성하는 것이고, 각 생성자마다 하나의 매개변수를 추가합니다. 이런 방식을 telescoping 생성자라고 합니다:
Order order = new Order("C001", List.of("SKU-1"), null, null, true, 0);
이 줄을 읽을 때, true가 무엇이고 0이 무엇인지 알기 어렵습니다. 더 나쁜 것은, shippingAddress와 couponCode가 모두 String 타입입니다. 이 두 매개변수의 위치를 바꾸면, 코드는 여전히 컴파일되지만 오류는 실행 시에만 나타납니다.
2.2. JavaBeans와 setter
두 번째 방식은 빈 생성자를 사용하고 setter를 호출하는 것입니다:
Order order = new Order();
order.setCustomerId("C001");
order.setGiftWrap(true);
// setItems(...) 호출을 잊음 — 컴파일러는 아무것도 말하지 않음
코드는 더 읽기 좋지만 세 가지 단점이 있습니다:
- 객체가 각 setter 호출 사이에 “반쯤” 만들어진 상태로 존재합니다.
- 필드를
final로 선언할 수 없어서 객체가 불변이 아닙니다. - 필드 간의 제약을 검증할 자연스러운 장소가 없습니다.
Builder는 setter의 가독성 장점을 유지하면서 위의 세 가지 단점을 모두 극복합니다.
3. Builder 직접 구현
3.1. 기본 구조
Builder는 보통 static nested 클래스입니다. 이것은 외부 클래스의 필드 복사본을 포함하고, 체인 호출을 위해 자신을 반환하는 메서드(return this)를 가지며, 최종 객체를 생성하는 build() 메서드를 가집니다:
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
public final class Order {
private final String customerId;
private final List<String> items;
private final String shippingAddress;
private final String couponCode;
private final boolean giftWrap;
private final int priority;
private Order(Builder builder) {
this.customerId = builder.customerId;
this.items = List.copyOf(builder.items);
this.shippingAddress = builder.shippingAddress;
this.couponCode = builder.couponCode;
this.giftWrap = builder.giftWrap;
this.priority = builder.priority;
}
public static Builder builder(String customerId) {
return new Builder(customerId);
}
public String getCustomerId() { return customerId; }
public List<String> getItems() { return items; }
public String getShippingAddress() { return shippingAddress; }
public String getCouponCode() { return couponCode; }
public boolean isGiftWrap() { return giftWrap; }
public int getPriority() { return priority; }
@Override
public String toString() {
return "Order[customerId=%s, items=%s, address=%s, coupon=%s, giftWrap=%s, priority=%d]"
.formatted(customerId, items, shippingAddress, couponCode, giftWrap, priority);
}
public static final class Builder {
private final String customerId;
private final List<String> items = new ArrayList<>();
private String shippingAddress;
private String couponCode;
private boolean giftWrap = false;
private int priority = 0;
private Builder(String customerId) {
this.customerId = Objects.requireNonNull(customerId, "customerId는 null일 수 없습니다");
}
public Builder item(String sku) {
this.items.add(Objects.requireNonNull(sku));
return this;
}
public Builder shippingAddress(String shippingAddress) {
this.shippingAddress = shippingAddress;
return this;
}
public Builder couponCode(String couponCode) {
this.couponCode = couponCode;
return this;
}
public Builder giftWrap(boolean giftWrap) {
this.giftWrap = giftWrap;
return this;
}
public Builder priority(int priority) {
this.priority = priority;
return this;
}
public Order build() {
if (items.isEmpty()) {
throw new IllegalStateException("Order는 최소 하나의 항목을 가져야 합니다");
}
if (priority < 0 || priority > 5) {
throw new IllegalStateException("priority는 0..5 범위 내에 있어야 합니다");
}
return new Order(this);
}
}
}
위 코드에서 주목할 점들:
Order의 생성자는private입니다.Order를 생성하는 유일한 방법은 Builder를 거치는 것입니다. 따라서 모든 객체는build()에서 이미 검증되었습니다.- 필수 매개변수는
builder(customerId)에 있습니다.customerId를 잊을 수 없습니다. 없으면 코드가 컴파일되지 않습니다. 여기에는 정말 필수적인 매개변수만, 최대 한두 개만 추가하세요. 더 많으면 다시 telescoping 문제로 돌아갑니다. - 기본값은 Builder에 있습니다 (
giftWrap = false,priority = 0). 호출처에 흩어져 있지 않습니다. - **
List.copyOf(builder.items)**는 수정할 수 없는 복사본을 생성합니다.build()후에 builder를 다시 사용하더라도, 이미 생성된Order는 영향을 받지 않습니다. - 모든 제약은
build()에 있습니다. 여러 필드와 관련된 조건을 검증하기에 자연스러운 장소입니다.
3.2. Builder 사용하기
public class OrderDemo {
public static void main(String[] args) {
Order order = Order.builder("C001")
.item("SKU-1")
.item("SKU-2")
.shippingAddress("12 Lý Thường Kiệt, Hà Nội")
.giftWrap(true)
.build();
System.out.println(order);
}
}
각 값 앞에 그 이름이 있으므로 매개변수의 순서를 기억할 필요가 없습니다. couponCode와 priority는 호출되지 않았으므로 기본값을 받습니다:
Order[customerId=C001, items=[SKU-1, SKU-2], address=12 Lý Thường Kiệt, Hà Nội, coupon=null, giftWrap=true, priority=0]
item(...)을 호출하지 않으면 build()가 즉시 오류를 던집니다:
Exception in thread "main" java.lang.IllegalStateException: Order는 최소 하나의 항목을 가져야 합니다
오류는 객체를 생성할 때 바로 나타나고, 3단계 후에 어떤 서비스가 빈 목록을 만날 때가 아닙니다.
4. Builder 테스트하기
세 가지 동작을 테스트해야 합니다: 기본값, 검증, 그리고 build된 객체의 불변성. 프로젝트에 아직 없으면 테스트 의존성을 추가합니다. Spring Boot에서는, spring-boot-starter-test가 이미 JUnit 5와 AssertJ를 포함하고 있습니다:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class OrderBuilderTest {
@Test
void givenOnlyRequiredFields_whenBuild_thenDefaultsApplied() {
Order order = Order.builder("C001").item("SKU-1").build();
assertThat(order.isGiftWrap()).isFalse();
assertThat(order.getPriority()).isZero();
assertThat(order.getCouponCode()).isNull();
}
@Test
void givenNoItems_whenBuild_thenThrowsException() {
assertThatThrownBy(() -> Order.builder("C001").build())
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("최소 하나의 항목");
}
@Test
void givenInvalidPriority_whenBuild_thenThrowsException() {
assertThatThrownBy(() -> Order.builder("C001").item("SKU-1").priority(9).build())
.isInstanceOf(IllegalStateException.class);
}
@Test
void givenBuilderReused_whenAddMoreItems_thenBuiltOrderUnchanged() {
Order.Builder builder = Order.builder("C001").item("SKU-1");
Order first = builder.build();
builder.item("SKU-2");
assertThat(first.getItems()).containsExactly("SKU-1");
assertThatThrownBy(() -> first.getItems().add("SKU-3"))
.isInstanceOf(UnsupportedOperationException.class);
}
}
마지막 테스트가 가장 중요합니다. List.copyOf(...)를 this.items = builder.items로 바꾸면, first.getItems()는 SKU-2도 포함하고 테스트는 실패합니다. 이것은 Builder를 직접 작성할 때 매우 흔한 오류입니다.
5. Lombok @Builder 사용하기
Builder를 손으로 작성하는 것은 상당히 길습니다. 실제로 많은 팀이 Lombok을 사용하여 이 코드를 생성합니다. 다음과 같이 의존성을 추가합니다(Spring Boot parent가 이미 버전을 관리합니다):
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
import lombok.Builder;
import lombok.Getter;
import lombok.NonNull;
import lombok.Singular;
import lombok.ToString;
import java.util.List;
@Getter
@ToString
@Builder(toBuilder = true)
public class Customer {
@NonNull
private final String email;
private final String fullName;
@Builder.Default
private final String tier = "STANDARD";
@Singular
private final List<String> tags;
}
Customer customer = Customer.builder()
.email("lan@example.com")
.fullName("Nguyễn Thị Lan")
.tag("newsletter")
.tag("early-adopter")
.build();
Customer vip = customer.toBuilder().tier("VIP").build();
System.out.println(customer);
System.out.println(vip);
Customer(email=lan@example.com, fullName=Nguyễn Thị Lan, tier=STANDARD, tags=[newsletter, early-adopter])
Customer(email=lan@example.com, fullName=Nguyễn Thị Lan, tier=VIP, tags=[newsletter, early-adopter])
각 annotation은 한 가지 일을 담당합니다:
@Builder는Customer.builder()메서드, 값 설정 메서드,build()를 생성합니다.@NonNull은 생성자에 null 검사를 생성합니다.email이 없으면build()는NullPointerException을 던집니다.@Builder.Default는 초기값"STANDARD"를 유지합니다. 이 annotation이 없으면 Lombok은 초기값을 무시합니다, 그리고tier는null이 됩니다.@Singular는tag(...)메서드를 생성하여 각 요소를 추가합니다. Lombok은 build할 때 list를 불변으로 만듭니다.toBuilder = true는 기존 객체에서 수정된 복사본을 만들 수 있게 합니다. 이 방법은 불변 객체로 작업할 때 매우 편리합니다.
참고: Lombok은 직접 작성한
build()처럼 여러 필드 검증을 위한 자연스러운 장소가 없습니다. 복잡한 제약이 필요하면, 검사가 있는 생성자를 직접 선언하고 그 생성자에@Builder를 배치하거나, 수동 작성으로 돌아갈 수 있습니다.
Jackson을 사용하여 JSON을 @Builder가 있는 클래스로 역직렬화하는 경우, @Jacksonized를 추가해야 합니다. 없으면 Jackson은 클래스가 빈 생성자를 가지지 않기 때문에 객체를 생성하는 방법을 모릅니다.
6. record와 Builder 결합하기
Java 16부터 record는 데이터 클래스 불변성을 위한 자연스러운 선택입니다. record가 선택 사항인 많은 필드를 가질 때도 Builder를 추가할 수 있습니다. 검증을 compact 생성자에 배치하면, 누군가 생성자를 직접 호출하더라도 실행됩니다:
import java.math.BigDecimal;
import java.util.Objects;
public record PaymentRequest(String orderId, BigDecimal amount, String currency, String note) {
public PaymentRequest {
Objects.requireNonNull(orderId, "orderId");
if (amount == null || amount.signum() <= 0) {
throw new IllegalArgumentException("amount는 0보다 커야 합니다");
}
currency = currency == null ? "VND" : currency;
}
public static Builder builder() {
return new Builder();
}
public static final class Builder {
private String orderId;
private BigDecimal amount;
private String currency;
private String note;
public Builder orderId(String orderId) { this.orderId = orderId; return this; }
public Builder amount(BigDecimal amount) { this.amount = amount; return this; }
public Builder currency(String currency) { this.currency = currency; return this; }
public Builder note(String note) { this.note = note; return this; }
public PaymentRequest build() {
return new PaymentRequest(orderId, amount, currency, note);
}
}
}
PaymentRequest request = PaymentRequest.builder()
.orderId("ORD-42")
.amount(new BigDecimal("150000"))
.build();
System.out.println(request);
PaymentRequest[orderId=ORD-42, amount=150000, currency=VND, note=null]
이 방식으로, Builder는 가독성 부분만 담당하고, record는 불변성과 검증 부분을 담당합니다. 두 책임이 분리됩니다. Lombok @Builder도 record 위에 직접 배치할 수 있습니다. 더 간단하게 하려면 말입니다.
7. 흔한 오류와 언제 사용하지 않을지
흔한 오류들:
- 가변 collection 누수. Builder의 list를 객체에 직접 할당하면 “불변” 객체도 수정될 수 있습니다. 항상
List.copyOf,Set.copyOf또는Map.copyOf를 사용하세요. build()호출 잊기.Order대신Builder를 받습니다. 컴파일러는 보통 이 오류를 잡습니다. 타입이 맞지 않기 때문입니다. 하지만 generic 코드나var를 사용할 때는 오류가 빠져나갈 수 있습니다.- 흩어진 검증. Builder의 각 setter에서 검사하면 필드 간의 제약을 잡지 못합니다.
build()나 compact 생성자에 모으세요. - 여러 thread에서 builder 공유하기. Builder는 가변 객체이고 thread-safe하지 않습니다. 객체를 생성할 때마다 새 builder를 만드세요.
- JPA entity에
@Builder사용하기. Hibernate는 매개변수 없는 생성자를 필요합니다.@Builder만 사용하면 그 생성자가 없어집니다. 따라서@NoArgsConstructor(access = AccessLevel.PROTECTED)와@AllArgsConstructor를 추가해야 합니다. 일반적으로 entity에는 Builder 사용을 제한하는 것이 좋습니다.
참고: 2개, 3개의 필수 매개변수만 있는 클래스에는 Builder를 사용하지 마세요. 예를 들어,
Money(amount, currency)는 생성자나Money.of(amount, currency)같은 static factory 메서드가 더 간단합니다. 모든 매개변수가 필수일 때도 Builder는 적합하지 않습니다. 이 경우 “누락된 매개변수” 오류가 컴파일 타임에서 런타임으로 옮겨지기 때문입니다.
간단한 규칙: 클래스가 4개 이상의 매개변수를 가질 때, 대부분이 선택 사항이거나 같은 타입의 매개변수가 많아서 실수하기 쉬울 때 Builder를 고려하세요.
8. 결론
Builder Pattern은 여러 매개변수를 가진 객체를 읽기 쉬운 방식으로 생성하고, 명확한 기본값과 단일 검증 지점을 가질 수 있게 해줍니다. Builder를 직접 작성할 때는, 생성자를 private으로 유지하고, 필수 매개변수를 builder(...) 메서드로 옮기고, collection을 복사하고, build()에서 제약을 검증하세요.
Lombok @Builder는 반복 코드를 줄여주지만, @Builder.Default와 @Singular를 기억해야 합니다. Java 21에서는 record와 Builder를 결합하면, compact 생성자에서 즉시 검증이 있는 불변 객체를 얻을 수 있습니다. 마지막으로, Builder는 매개변수 수가 정말 문제가 될 때만 사용하세요. 작은 클래스의 경우 생성자나 static factory 메서드가 여전히 더 나은 선택입니다.