본문 바로가기

Programming/Design Pattern

빌더 패턴 vs Lombok @Builder, 언제 직접 구현할까

추천캐릭터 2026. 8. 7. 08:00
728x90

빌더 패턴 vs Lombok @Builder, 언제 직접 구현할까

생성자 파라미터가 6개, 7개로 늘어나면 어떤 값이 몇 번째 자리인지 헷갈리기 시작합니다. 선택적 필드가 몇 개만 더 늘어도 오버로딩된 생성자가 줄줄이 생기는 텔레스코핑 생성자(telescoping constructor) 문제입니다. 빌더 패턴은 이 문제를 해결하지만, 요즘은 대부분 Lombok @Builder로 대체해 씁니다. 둘의 차이를 알아야 언제 직접 구현이 필요한지 판단할 수 있습니다.

텔레스코핑 생성자 문제

public class Order {
    public Order(Long userId, String productName, int quantity) { ... }
    public Order(Long userId, String productName, int quantity, String couponCode) { ... }
    public Order(Long userId, String productName, int quantity, String couponCode, boolean giftWrap) { ... }
    // 파라미터 조합이 늘어날 때마다 생성자가 늘어난다
}

호출부에서 new Order(1L, "keyboard", 2, null, true)처럼 의미 없는 null이나 순서를 외워야 하는 boolean 값이 반복되면 가독성이 급격히 떨어집니다.

빌더 패턴 직접 구현

public class Order {
    private final Long userId;
    private final String productName;
    private final int quantity;
    private final String couponCode;
    private final boolean giftWrap;

    private Order(Builder builder) {
        this.userId = builder.userId;
        this.productName = builder.productName;
        this.quantity = builder.quantity;
        this.couponCode = builder.couponCode;
        this.giftWrap = builder.giftWrap;
    }

    public static class Builder {
        private final Long userId;
        private final String productName;
        private int quantity = 1;
        private String couponCode;
        private boolean giftWrap = false;

        public Builder(Long userId, String productName) {
            this.userId = userId;
            this.productName = productName;
        }

        public Builder quantity(int quantity) {
            if (quantity < 1) {
                throw new IllegalArgumentException("수량은 1 이상이어야 합니다");
            }
            this.quantity = quantity;
            return this;
        }

        public Builder couponCode(String couponCode) {
            this.couponCode = couponCode;
            return this;
        }

        public Builder giftWrap(boolean giftWrap) {
            this.giftWrap = giftWrap;
            return this;
        }

        public Order build() {
            return new Order(this);
        }
    }
}
Order order = new Order.Builder(1L, "keyboard")
        .quantity(2)
        .giftWrap(true)
        .build();

핵심은 두 가지입니다. 필수값(userId, productName)은 Builder 생성자에서 강제하고, 선택값은 메서드 체이닝으로 채웁니다. 그리고 quantity()처럼 값을 채우는 시점에 유효성 검증을 끼워 넣을 수 있습니다.

Lombok @Builder

@Builder
@Getter
public class Order {
    private final Long userId;
    private final String productName;
    private final int quantity;
    private final String couponCode;
    private final boolean giftWrap;
}
Order order = Order.builder()
        .userId(1L)
        .productName("keyboard")
        .quantity(2)
        .giftWrap(true)
        .build();

Lombok은 컴파일 시점에 Order.Builder 클래스와 각 필드에 대응하는 세터 스타일 메서드, build()를 자동으로 만들어 줍니다. 코드량이 눈에 띄게 줄고, 필드가 추가돼도 빌더 코드를 손으로 늘릴 필요가 없습니다.

직접 구현이 필요한 순간

Lombok @Builder가 편한 만큼 놓치는 것도 있습니다.

  • 필수값 강제: @Builder는 모든 필드를 선택값처럼 다룹니다. userId를 빼먹고 build()를 호출해도 컴파일 타임에는 걸러지지 않고, 런타임에 null인 채로 객체가 만들어질 수 있습니다. 직접 구현하면 생성자 파라미터로 필수값을 강제할 수 있습니다.
  • 필드 간 유효성 검증: quantity가 1 이상이어야 한다거나, giftWrap이 true면 couponCode가 필수라는 식의 필드 간 제약은 @Builder만으로는 표현하기 어렵습니다. build() 시점에 검증 로직을 넣거나, 값을 채우는 메서드 안에서 직접 검증해야 하는데 이건 결국 손으로 구현해야 합니다.
  • 여러 표현의 조립 로직: GoF 원전의 빌더 패턴은 같은 조립 절차로 서로 다른 Product를 만드는 Director 개념까지 포함합니다. 문서 변환기가 같은 입력으로 PDF, HTML을 각각 조립하는 경우처럼 조립 절차 자체를 재사용해야 한다면 Lombok으로는 표현하기 어렵습니다.

실무 절충안

가장 흔한 절충은 @Builder는 그대로 쓰되, 필수값 검증은 정적 팩토리 메서드로 감싸는 방식입니다.

@Builder(access = AccessLevel.PRIVATE)
@Getter
public class Order {
    private final Long userId;
    private final String productName;
    private final int quantity;

    public static Order of(Long userId, String productName, int quantity) {
        if (userId == null || productName == null) {
            throw new IllegalArgumentException("userId, productName은 필수입니다");
        }
        return Order.builder()
                .userId(userId)
                .productName(productName)
                .quantity(quantity)
                .build();
    }
}

builder()의 접근 범위를 PRIVATE로 막아 외부에서는 반드시 of()를 거치도록 하면, Lombok의 boilerplate 제거 이점을 유지하면서 필수값 검증도 지킬 수 있습니다. 값을 일부만 바꿔 새 객체를 만들어야 한다면 @Builder(toBuilder = true)를 추가해 existingOrder.toBuilder().quantity(3).build()처럼 쓰는 것도 실무에서 자주 쓰는 패턴입니다.

마무리

단순 DTO나 엔티티처럼 필드 조합에 강한 제약이 없다면 Lombok @Builder로 충분합니다. 반대로 필수값 강제나 필드 간 유효성 검증이 객체 생성 시점에 반드시 지켜져야 한다면, boilerplate가 늘더라도 직접 구현하거나 정적 팩토리 메서드로 감싸는 절충안을 쓰는 편이 안전합니다.

728x90