본문 바로가기
Back-end & 알고리즘

Lombok Builder를 모든 클래스에 붙이면 안 되는 객체지향적 반전

by CodeByJin 2026. 6. 6.
반응형

무지성 Builder 패턴이 실무에서 부메랑으로 돌아오는 이유

Lombok의 @Builder 애노테이션은 현대 자바 백엔드 개발자들에게 공기와 같은 존재입니다. 생성자의 파라미터 순서를 뒤바꿔서 발생하는 끔찍한 런타임 버그를 방지해주고, 가독성 높은 코드를 만들어준다는 점에서 대안이 없어 보이기도 합니다. "가독성이 좋고 안전하니까 모든 엔티티와 DTO에 빌더를 붙이자"는 규칙이 팀 내에 관습처럼 자리 잡은 경우도 흔히 봅니다.

 

하지만 실무에서 빌더 패턴을 남용하면 객체 지향의 핵심인 '유효성 검증'과 '캡슐화'가 깨지는 역설적인 상황을 마주하게 됩니다. 빌더는 단순히 객체 생성 편의를 위한 도구일 뿐, 무조건적인 정답이 아닙니다. 이 글에서는 기술의 정의를 넘어, 실무 운영 환경에서 빌더 남용이 초래하는 유지보수 비용과 성능 트레이드오프를 짚어보고, 팀 생산성을 높이기 위한 명확한 설계 판단 기준을 공유하고자 합니다.

흔한 오해와 이 블로그만의 관점

많은 개발자가 "빌더 패턴은 Setter가 없으니 객체의 불변성을 보장하고 안전하다"고 오해합니다. 그러나 이는 반만 맞고 반은 틀린 이야기입니다.

 

이 블로그만의 관점은 명확합니다. 빌더 패턴이 불변성을 제공할지는 몰라도, '일관성(Consistency)'과 '제약 조건'을 보장하지는 못합니다.

빌더 패턴은 필드 하나하나를 빌더 메서드로 쪼개어 입력받기 때문에, 객체가 완전히 생성되기 전까지 불완전한 상태의 객체를 코드 곳곳에 노출시킬 위험이 있습니다. 필수적인 비즈니스 제약 조건 검증 로직이 빌더 뒤로 숨거나 누락되면서, 컴파일 시점에는 완벽해 보이지만 런타임에 데이터가 오염되는 현상이 대표적인 부작용입니다.

운영과 유지보수 관점에서의 트레이드오프

1. 메모리 사용량과 GC(Garbage Collection) 영향

빌더 패턴을 사용하면 객체를 생성하기 위해 필수적으로 'Builder'라는 중간 임시 객체를 하나 더 생성해야 합니다. 고성능 처리가 필요한 트래픽 밀집 구간(예: 대용량 배치 처리, 실시간 로그 수집 파이프라인)에서 매 객체 생성마다 빌더 인스턴스가 힙(Heap) 메모리에 할당되면, 그만큼 Minor GC의 빈도가 잦아지고 CPU 사용량이 스파이크를 치는 병목 지점이 될 수 있습니다. 단일 요청에서는 미미한 수치일지라도, 루프 내부나 대량의 스트림 처리 시에는 무시할 수 없는 비용입니다.

2. 디버깅 난이도와 에러 추적

생성자 체이닝 형식을 취하는 빌더 패턴 특성상, 빌더 호출 도중 예외가 발생하거나 특정 필드가 누락되어 NPE(NullPointerException)가 발생하면 Stack Trace를 추적하기 까다로워집니다. 줄 단위로 디버깅 포인트를 잡기도 모호하며, 수십 개의 필드를 빌더로 조립하는 과정에서 어떤 필드가 원인인지 직관적으로 파악하기 어렵기 때문에 운영 복잡도가 상승합니다.

3. 팀 생산성과 비즈니스 응집도

필수 필드와 선택 필드의 구분이 명확하지 않은 상태로 빌더를 개방해두면, 신입 팀원이나 다른 도메인 담당자가 객체를 생성할 때 어떤 값을 필수로 채워야 하는지 비즈니스 규칙을 문서나 코드를 일일이 까보지 않고는 알 수 없습니다. 이는 코드 리뷰 비용을 증가시키고 잘못된 객체 생성으로 인한 장애 전파 확률을 높입니다.

자바 빌더 패턴 코드 화면
자바 빌더 패턴 코드 화면

실무 검증: 잘못된 구현과 개선된 설계

실무에서 흔히 발견되는 빌더 패턴의 오용 패턴과 이를 객체 지향적으로 수정한 예시 코드를 살펴보겠습니다.

[예시 시나리오] 주문(Order) 객체 생성 비즈니스

주문 객체는 반드시 주문자 ID(userId)와 최소 결제 금액(totalPrice)이 존재해야 하며, 최소 결제 금액은 0원 이상이어야 한다는 제약 조건이 있습니다.

잘못된 구현 사례: 맹목적인 @Builder 개방

// Lombok의 @Builder를 클래스 상단에 기재하여 무조건적인 빌더 생성
@Getter
@Builder
public class Order {
    private Long id;
    private String userId; // 필수 필드
    private long totalPrice; // 필수 필드, 0원 이상이어야 함
    private String couponCode; // 선택 필드
}

// 실무 코드 리뷰에서 걸러내기 힘든 위험한 호출 사례
public void createOrderExample() {
    // 필수 값인 userId와 totalPrice가 빠졌음에도 컴파일 시점에 감지되지 않음
    Order invalidOrder = Order.builder()
    .couponCode("DISCOUNT10")
    .build();
    // 결제 로직으로 넘어가서야 런타임 에러 또는 데이터 오염 발생
}

 

위 예시처럼 클래스 레벨에 무턱대고 @Builder를 붙여버리면, 필수 필드가 비어있거나 도메인 제약 조건을 위반한 객체임에도 불구하고 아무런 제약 없이 생성이 가능해집니다. 이를 막기 위해 서비스 레이어마다 검증 로직을 중복으로 작성하는 악순환이 시작됩니다.

개선된 구현 사례: 제약 조건을 강제하는 생성자 기반 빌더

@Getter
public class Order {
    private final Long id;
    private final String userId;
    private final long totalPrice;
    private final String couponCode;

    // 점진적 생성자 패턴이나 가독성 문제를 해결하기 위해 빌더를 커스텀 정의하거나
    // 필수 인자를 받는 생성자 위에 빌더를 제한적으로 배치합니다.
    @Builder
    private Order(String userId, long totalPrice, String couponCode) {
        // 객체 생성 시점에 반드시 유효성 검증을 수행하여 불완전한 상태의 생성을 차단
        if (userId == null || userId.isBlank()) {
             throw new IllegalArgumentException("주문자 ID는 필수입니다.");
        }
        
        if (totalPrice < 0) {
             throw new IllegalArgumentException("결제 금액은 0원 이상이어야 합니다.");
        }
        
        this.id = null; // DB 저장 시 할당된다고 가정
        this.userId = userId;
        this.totalPrice = totalPrice;
        this.couponCode = couponCode;
    }
}

// 실무 적용 방식
public void secureOrderExample() {
    // 필수 제약 조건을 강제하며, 선택적 필드만 유연하게 입력받음
    Order validOrder = Order.builder()
    .userId("user_123")
    .totalPrice(15000)
    .couponCode("WELCOME_BONUS")
    .build();
}

 

수정된 설계에서는 클래스 레벨의 빌더를 제거하고, 필수 필드의 정합성을 검증하는 비공개 생성자(private constructor) 위에 빌더를 위치시켰습니다. 이제 이 객체는 인스턴스화되는 순간 조건 검증을 거치므로 시스템 내부에서 항상 신뢰할 수 있는 일관된 상태를 유지합니다. 테스트 난이도 역시 서비스 레이어의 모킹 없이 단위 테스트로 깔끔하게 떨어집니다.

반응형

코드 리뷰 관점 및 운영 시 주의점

  • 장애 가능성: 클래스 레벨 빌더를 방치하면 비즈니스 로직 확장 시 필드가 추가되어도 기존 빌더 호출부에서 컴파일 에러가 나지 않아 누락 사고(NPE 등)가 잦아집니다.
  • 유지보수 관점: 핵심 비즈니스 밸리데이션은 무조건 객체 내부 생성 시점으로 응집시켜야 장애 전파를 막을 수 있습니다.

그래서 언제 쓰고, 언제 쓰지 말아야 하는가?

무조건 쓰지 말라는 극단론을 펴려는 것이 아닙니다. 아키텍처적 판단을 돕기 위한 명확한 선택 기준 체크리스트를 제시합니다.

❌ 이럴 때는 빌더를 쓰지 말고 정적 팩토리 메서드나 생성자를 쓰세요

  • 필드가 3개 이하이며, 모든 필드가 필수인 경우: 굳이 중간 임시 가비지 객체를 만들며 빌더를 쓸 이유가 전혀 없습니다. 일반 생성자나 정적 팩토리 메서드(of(), from())가 가독성과 메모리 측면 모두에서 압도적으로 유리합니다.
  • 성능이 극도로 중요한 루프 내부: 루프 안에서 대량의 DTO 변환 처리가 일어날 때 빌더를 남용하면 메모리 할당 압박(Memory Allocation Pressure)으로 인해 가비지 컬렉터가 자주 깨어납니다.
  • 도메인의 비즈니스 규칙이 빈번하게 바뀌는 핵심 엔티티: 입력 파라미터 간의 결합 조건(예: A 필드가 있으면 B 필드는 없어야 한다 등)이 복잡할 때는 생성자나 도메인 메서드 내부에서 엄격하게 통제하는 편이 유지보수 비용을 낮춥니다.

⭕ 이럴 때는 빌더 패턴이 훌륭한 대안입니다

  • 인자의 수가 많고(대략 5개 이상), 그중 선택적(Optional) 필드가 다수 포함된 경우: 점진적 생성자(Telescoping Constructor) 패턴으로 인해 생성자가 무한정 늘어나는 지옥을 방지해 줍니다.
  • 외부 시스템과의 연동을 위한 대형 DTO를 설계할 때: 클라이언트 요청 명세나 오픈 API 연동을 위한 데이터 바인딩 객체는 선택 필드가 많기 때문에 빌더 패턴을 적용했을 때 팀 생산성이 눈에 띄게 증가합니다.

코드 리뷰어들을 위한 요약 가이드

팀원들의 Pull Request를 검토할 때, 혹은 스스로 아키텍처적 결정을 내릴 때 아래 두 가지만 기억하면 빌더 남용으로 인한 리스크를 대부분 방어할 수 있습니다.

 

첫째, 클래스 레벨의 @Builder 애노테이션은 원칙적으로 금지하고, 필수 검증 로직이 포함된 특정 생성자에만 한정적으로 적용하는 프로세스를 제안해 보세요. 둘째, 빌더를 적용하기 전에 "이 객체가 가질 수 있는 불완전한 상태는 무엇인가?"를 자문해 보십시오. 무지성 편의성 뒤에 숨은 실무적 비용을 계산하기 시작할 때, 진정으로 지속 가능한 백엔드 시스템 아키텍처가 완성됩니다.

 

 

Java Stream이 가독성 좋은 코드라는 환상: 당신의 API가 대용량 트래픽에서 무너지는 이유

Java 8에 Stream API가 도입된 이후, 많은 개발팀의 코드 컨벤션이 변했습니다. "for 루프와 if 조건문 선언을 지양하고 가독성과 선언형 프로그래밍의 이점을 살리기 위해 Stream을 적극 활용하자"는 방

byteandbit.tistory.com

* 본 포스팅에 사용된 이미지는 생성형 AI를 통해 생성된 이미지입니다. *

반응형