Home > Backend Development > 📚[Backend Development] 🚀 @Builder

📚[Backend Development] 🚀 @Builder
Backend Development Server Java Lombok

🏗️ @Builder

“생성자가 너무 길어요… 더 나은 방법 없나요?” 🤔
private/final → 생성자 → @Builder 순서로 오셨다면, 이미 객체 설계 감각이 생기고 있다는 신호입니다! 👍


🎯 @Builder가 뭔가요?

한 줄 요약: Lombok이 제공하는 빌더 패턴(Builder Pattern) 자동 생성 어노테이션

👉 객체 생성 과정을 단계적으로, 명확하게 표현하게 해줍니다.


😫 빌더 패턴이 없을 때의 문제

Case 1: 파라미터 지옥

public class Member {
    private String name;
    private int age;
    private String email;
    private String address;
    private String phone;

    public Member(String name, int age, String email, String address, String phone) {
        this.name = name;
        this.age = age;
        this.email = email;
        this.address = address;
        this.phone = phone;
    }
}

사용할 때:

Member member = new Member("Kobe", 30, "kobe@test.com", "Seoul", "010-1234-5678");

❌ 문제점:

  1. 파라미터 순서 헷갈림 (age랑 name 순서가 뭐였더라?)
  2. 어떤 값이 뭔지 한눈에 안 보임
  3. "Seoul"이 주소인지 회사인지 모름
  4. 실수로 순서 바꿔도 컴파일 에러 안 남 (같은 타입이면)

Case 2: 선택 값(Optional) 처리의 악몽

// 전화번호는 선택사항이라면?

// 방법 1: 생성자 오버로딩
public Member(String name, int age, String email, String address) { }
public Member(String name, int age, String email, String address, String phone) { }

// 방법 2: null 전달
new Member("Kobe", 30, "kobe@test.com", "Seoul", null);  // 😱

❌ 문제점:

  • 생성자가 기하급수적으로 늘어남
  • null 값 전달이 명시적이지 않음
  • 조합이 복잡해지면 관리 불가능

✨ @Builder 사용하면?

@Builder
public class Member {
    private String name;
    private int age;
    private String email;
    private String address;
    private String phone;
}

사용할 때:

Member member = Member.builder()
    .name("Kobe")
    .age(30)
    .email("kobe@test.com")
    .address("Seoul")
    .phone("010-1234-5678")
    .build();

✅ 장점:

  • 🎨 가독성 최고 - 필드명이 명확히 보임
  • 🔀 순서 상관 없음 - 원하는 순서로 작성 가능
  • 🎯 선택 값 생략 가능 - phone 빼도 됨
  • 💡 의미 전달 명확 - 코드만 봐도 무슨 객체인지 알 수 있음

🔍 @Builder가 내부적으로 만들어주는 것

@Builder
public class Member {
    private String name;
    private int age;
}

⬇️ Lombok이 생성하는 코드 (개념)

public class Member {
    private String name;
    private int age;

    // 1. Builder 클래스 생성
    public static class MemberBuilder {
        private String name;
        private int age;

        public MemberBuilder name(String name) {
            this.name = name;
            return this;  // 체이닝을 위해 자기 자신 반환
        }

        public MemberBuilder age(int age) {
            this.age = age;
            return this;
        }

        public Member build() {
            return new Member(name, age);
        }
    }

    // 2. 정적 팩토리 메서드
    public static MemberBuilder builder() {
        return new MemberBuilder();
    }

    // 3. 생성자
    private Member(String name, int age) {
        this.name = name;
        this.age = age;
    }
}

핵심 원리: 메서드 체이닝

builder() → name() → age() → build()
각 메서드가 자기 자신을 리턴하므로 계속 이어서 호출 가능!

💼 @Builder는 언제 사용하나요?

1️⃣ 생성자 파라미터가 많을 때 (가장 대표적) ⭐

// ❌ 생성자 방식 - 가독성 최악
Order order = new Order(
    memberId,
    productId,
    price,
    quantity,
    LocalDateTime.now(),
    "Seoul",
    "010-1234-5678"
);  // 뭐가 뭔지 모르겠어요...

// ✅ Builder 방식 - 가독성 최고
Order order = Order.builder()
    .memberId(memberId)
    .productId(productId)
    .price(price)
    .quantity(quantity)
    .orderedAt(LocalDateTime.now())
    .deliveryAddress("Seoul")
    .contactPhone("010-1234-5678")
    .build();  // 명확해요!

📌 기준: 파라미터 3개 이상이면 Builder 고려!


2️⃣ 선택 값(Optional field)이 많을 때

@Builder
public class Profile {
    private String nickname;       // 필수
    private String bio;           // 선택
    private String imageUrl;      // 선택
    private String website;       // 선택
    private String location;      // 선택
}

사용 예시:

// 필수값만 설정
Profile profile1 = Profile.builder()
    .nickname("kobe")
    .build();

// 원하는 것만 추가
Profile profile2 = Profile.builder()
    .nickname("kobe")
    .bio("Backend Developer")
    .imageUrl("https://...")
    .build();

✨ 생성자 오버로딩 10개 만드는 것보다 훨씬 깔끔!


3️⃣ 불변 객체(final)를 만들 때

@Getter
@Builder
public class MemberDto {
    private final Long id;
    private final String name;
    private final String email;
    private final LocalDateTime joinDate;
}

왜 좋은가?

// ✅ setter 필요 없음
MemberDto dto = MemberDto.builder()
    .id(1L)
    .name("Kobe")
    .email("kobe@test.com")
    .joinDate(LocalDateTime.now())
    .build();

// dto.setName("..."); // setter 없음! 불변 보장

💎 불변성 + 가독성 = 완벽한 조합


4️⃣ 테스트 코드 작성할 때

@Test
void memberTest() {
    // ✅ 테스트 의도가 명확
    Member member = Member.builder()
        .name("test")
        .age(20)
        .email("test@test.com")
        .build();

    // 테스트 진행...
}

@Test
void orderTest() {
    // ✅ 필요한 값만 설정
    Order order = Order.builder()
        .memberId(1L)
        .status(OrderStatus.COMPLETED)  // 이 테스트의 핵심!
        .build();
}

👍 테스트 유지보수가 압도적으로 쉬워집니다!


5️⃣ 도메인 의미가 중요한 경우

// ❌ 의미가 불명확
Order order = new Order(1L, 2L, 10000, 2, LocalDateTime.now());

// ✅ 도메인 의미가 코드로 보임
Order order = Order.builder()
    .memberId(1L)           // 누가 주문했는지
    .productId(2L)          // 무엇을 주문했는지
    .totalPrice(10000)      // 얼마를 지불했는지
    .quantity(2)            // 몇 개를 주문했는지
    .orderedAt(LocalDateTime.now())  // 언제 주문했는지
    .build();

💡 “이 주문은 이런 의미를 가진다”가 코드로 표현됨!


⚠️ 언제 사용하면 안 좋을까?

❌ 1. 필드가 1~2개뿐일 때

// 이럴 땐 생성자가 더 직관적
public class Point {
    private int x;
    private int y;
}

// ✅ 생성자가 낫다
Point point = new Point(10, 20);

// ❌ 오버엔지니어링
Point point = Point.builder()
    .x(10)
    .y(20)
    .build();

📌 기준: 파라미터 2개 이하면 생성자 추천


❌ 2. JPA Entity에 무분별하게 사용

// ⚠️ 위험한 패턴
@Entity
@Builder  // 조심!
@NoArgsConstructor
public class Member {
    @Id @GeneratedValue
    private Long id;
    private String name;
}

🤔 왜 위험한가?

// 아무나 Entity를 마음대로 생성 가능
Member member = Member.builder()
    .id(999L)  // ⚠️ ID를 임의로 설정?
    .name("해커")
    .build();

✅ 올바른 패턴:

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)  // JPA용
@AllArgsConstructor(access = AccessLevel.PRIVATE)   // Builder용
@Builder
public class Member {
    @Id @GeneratedValue
    private Long id;
    private String name;

    // 또는 Builder를 특정 생성자에만 적용
    @Builder
    public Member(String name) {  // id는 빠짐!
        this.name = name;
    }
}

🎯 핵심:

  • 외부에서는 Builder로만 생성 가능
  • 민감한 필드(id 등)는 Builder에서 제외

❌ 3. 상태 변경이 많은 객체

// 상태 변화가 핵심인 객체
public class GameCharacter {
    private int hp;
    private int level;
    private int exp;

    // ❌ Builder보다는 의미 있는 메서드가 낫다
    public void levelUp() {
        this.level++;
        this.hp = level * 100;
    }

    public void takeDamage(int damage) {
        this.hp -= damage;
    }
}

💡 변경이 핵심이면 Builder보다 도메인 메서드 사용!


📊 생성자 vs Builder 완전 비교

항목 생성자 Builder
📝 파라미터 많음 (4개 이상) ❌ 가독성 떨어짐 ✅ 명확하고 깔끔
👀 가독성 낮음 높음
🎯 선택 값 처리 불편 (오버로딩 필요) 매우 편함
💎 불변 객체 가능 더 적합
🧪 테스트 코드 불편 매우 편함
⚡ 성능 약간 빠름 약간 느림 (무시 가능)
🎨 필드 2개 이하 더 적합 오버엔지니어링
📖 코드 양 적음 많음 (Lombok이 생성)

🏆 실무 Best Practice

패턴 1: DTO (API 요청/응답)

@Getter
@Builder
public class MemberResponse {
    private final Long id;
    private final String name;
    private final String email;
    private final LocalDateTime joinDate;
}

// Controller에서
@GetMapping("/members/{id}")
public MemberResponse getMember(@PathVariable Long id) {
    Member member = memberService.findById(id);

    return MemberResponse.builder()
        .id(member.getId())
        .name(member.getName())
        .email(member.getEmail())
        .joinDate(member.getJoinDate())
        .build();
}

패턴 2: Entity (통제된 생성)

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)  // JPA가 사용
@AllArgsConstructor(access = AccessLevel.PRIVATE)   // Builder가 사용
@Builder
public class Member {
    @Id @GeneratedValue
    private Long id;

    private String name;
    private String email;
    private LocalDateTime joinDate;

    // 정적 팩토리 메서드로 한 번 더 감싸기
    public static Member createMember(String name, String email) {
        return Member.builder()
            .name(name)
            .email(email)
            .joinDate(LocalDateTime.now())
            .build();
    }
}

패턴 3: 일부 필드만 Builder 적용

@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Member {
    @Id 
    @GeneratedValue
    private Long id;

    private String name;
    private String email;
    private LocalDateTime createdAt;

    // 특정 생성자에만 @Builder 적용
    @Builder
    public Member(String name, String email) {
        this.name = name;
        this.email = email;
        this.createdAt = LocalDateTime.now();
    }
}

✅ 장점:

  • id는 Builder에서 제외 (안전)
  • createdAt은 자동 설정
  • name, email만 Builder로 설정

패턴 4: @Builder.Default로 기본값 설정

@Builder
public class Order {
    private Long memberId;
    private Long productId;

    @Builder.Default
    private OrderStatus status = OrderStatus.PENDING;

    @Builder.Default
    private LocalDateTime orderedAt = LocalDateTime.now();
}

// 사용
Order order = Order.builder()
    .memberId(1L)
    .productId(2L)
    // status와 orderedAt은 기본값 자동 설정!
    .build();

🎨 실전 종합 예제

Case 1: 회원가입 요청 DTO

@Getter
@Builder
public class SignUpRequest {
    private final String email;
    private final String password;
    private final String name;

    // 선택 필드
    @Builder.Default
    private final String nickname = "익명";

    private final String phone;
    private final String address;
}

// Controller
@PostMapping("/signup")
public void signUp(@RequestBody SignUpRequest request) {
    // 어떤 정보가 들어왔는지 명확!
}

Case 2: 복잡한 주문 도메인

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Order {
    @Id 
    @GeneratedValue
    private Long id;

    private Long memberId;
    private Long productId;
    private int quantity;
    private int totalPrice;
    private String deliveryAddress;
    private String receiverName;
    private String receiverPhone;

    @Enumerated(EnumType.STRING)
    private OrderStatus status;

    private LocalDateTime orderedAt;
    private LocalDateTime completedAt;

    @Builder
    public Order(Long memberId, Long productId, int quantity, int totalPrice,
                 String deliveryAddress, String receiverName, String receiverPhone) {
        this.memberId = memberId;
        this.productId = productId;
        this.quantity = quantity;
        this.totalPrice = totalPrice;
        this.deliveryAddress = deliveryAddress;
        this.receiverName = receiverName;
        this.receiverPhone = receiverPhone;
        this.status = OrderStatus.PENDING;
        this.orderedAt = LocalDateTime.now();
    }

    // 비즈니스 로직
    public void complete() {
        this.status = OrderStatus.COMPLETED;
        this.completedAt = LocalDateTime.now();
    }
}

// Service에서 사용
Order order = Order.builder()
    .memberId(member.getId())
    .productId(product.getId())
    .quantity(orderRequest.getQuantity())
    .totalPrice(product.getPrice() * orderRequest.getQuantity())
    .deliveryAddress(orderRequest.getAddress())
    .receiverName(orderRequest.getName())
    .receiverPhone(orderRequest.getPhone())
    .build();

Case 3: 테스트 픽스처(Fixture)

// 테스트용 객체 생성 헬퍼
public class MemberFixture {

    public static Member createDefaultMember() {
        return Member.builder()
            .name("테스트유저")
            .email("test@test.com")
            .build();
    }

    public static Member createMemberWithName(String name) {
        return Member.builder()
            .name(name)
            .email("test@test.com")
            .build();
    }

    public static Member createAdmin() {
        return Member.builder()
            .name("관리자")
            .email("admin@test.com")
            .role(MemberRole.ADMIN)
            .build();
    }
}

// 테스트에서 사용
@Test
void memberTest() {
    Member member = MemberFixture.createDefaultMember();
    // 테스트...
}

💡 실무 팁 모음

✅ DO - 이렇게 사용하세요

// 1. DTO는 거의 항상 Builder
@Builder
public class MemberResponse { }

// 2. 불변 객체는 Builder + final
@Builder
public class Money {
    private final BigDecimal amount;
    private final String currency;
}

// 3. 복잡한 객체 생성은 Builder
@Builder
public class ReportRequest {
    private LocalDate startDate;
    private LocalDate endDate;
    private List<String> categories;
    private ReportType type;
}

// 4. 테스트 픽스처는 Builder
Member testMember = Member.builder()
    .name("test")
    .build();

❌ DON’T - 이건 피하세요

// 1. 간단한 객체에 Builder 남발
@Builder  // ❌
public class Point {
    private int x;
    private int y;
}

// 2. Entity에 무분별한 Builder
@Entity
@Builder  // ❌ 통제 안 됨
public class Member { }

// 3. 모든 필드를 Builder로 노출
@Builder  // ❌ id까지 Builder로?
public class Member {
    private Long id;  // 이건 DB가 생성해야 함
}

🎯 결정 플로우차트


🔥 핵심 정리

💬 한 줄 요약

@Builder는
“이 객체는 이렇게 만들어져야 한다”를 코드로 표현하는 도구


📌 기억 공식

상황 권장 방법
🎯 필드 많다 (4개 이상) @Builder
💎 불변 객체 만들기 @Builder
🧪 테스트 코드 @Builder
📦 DTO / VO @Builder
🗄️ JPA Entity 통제해서 사용
📍 필드 2개 이하 생성자

🎨 최종 체크리스트

객체 생성 시 이것만 확인하세요:

  • 파라미터가 4개 이상인가? → Builder
  • 선택 값이 많은가? → Builder
  • 불변 객체를 만드는가? → Builder
  • 테스트 코드에서 쓰는가? → Builder
  • JPA Entity인가? → 신중하게 (통제)
  • 간단한 객체인가? → 생성자

🚀 실전 적용 순서

  1. DTO부터 시작 - 제일 안전하고 효과 확실
  2. 테스트 코드에 적용 - 가독성 향상 체감
  3. 복잡한 도메인 객체 - 점진적 확대
  4. Entity는 신중하게 - 통제된 방식으로