프로그래밍/알고리즘&자료구조&패턴

디자인패턴 - 빌더패턴

lazy_web_devloper 2025. 8. 18. 10:50
728x90
반응형

JAVA로 구현하는 알고리즘과 디자인 패턴 공부: 빌더 패턴 (Builder)

1. 빌더 패턴이란? 🔨

빌더 패턴복잡한 객체를 단계별로 차근차근 생성하는 디자인 패턴입니다. 객체의 복잡한 생성 과정과 최종 표현을 분리하여, 동일한 생성 절차를 통해 다양한 형태의 객체를 만들 수 있게 해줍니다.

샌드위치 가게 '서브웨이'에서 주문하는 과정을 생각하면 쉽습니다. 여러분은 직원에게 한 번에 모든 재료를 말하지 않습니다. 대신, 다음과 같은 단계를 거칩니다.

  1. 빵을 선택합니다.
  2. 메인 재료를 선택합니다.
  3. 치즈 추가 여부를 결정합니다.
  4. 야채를 추가합니다.
  5. 소스를 선택합니다.

여기서 샌드위치를 만드는 직원이 바로 **'빌더(Builder)'**입니다. 여러분은 원하는 부분을 순서대로 지정하고, 마지막에 빌더는 완성된 샌드위치를 전달해 줍니다. 핵심은 치즈를 빼거나 소스를 추가하는 등 각 단계를 자유롭게 선택할 수 있다는 점입니다.

2. 왜 사용할까요?

빌더 패턴은 다음과 같은 경우에 특히 유용합니다.

  • 객체에 선택적으로 설정할 값이 많을 때: 생성자에 매개변수가 너무 많으면 (new Character("간달프", 100, 50, null, "지팡이", null, true)) 코드가 헷갈리고 실수하기 쉽습니다. 이를 '점층적 생성자 패턴'이라는 안티 패턴이라고도 합니다. 빌더 패턴은 이 문제를 해결합니다.
  • 불변 객체(Immutable Object)를 만들고 싶을 때: 불변 객체는 한번 생성되면 그 상태를 바꿀 수 없는 객체를 말합니다. 빌더 패턴을 사용하면 객체 생성 과정에서 모든 값을 설정한 뒤, 마지막에 불변 객체를 만들 수 있어 안정성이 높아집니다.
  • 하나의 생성 과정으로 다양한 형태의 객체를 만들고 싶을 때: 동일한 빌더를 사용하되, 설정값을 다르게 하여 여러 버전의 객체를 생성할 수 있습니다.

3. Java로 구현하는 방법

게임 캐릭터(Character)를 만드는 예시를 통해 알아보겠습니다. 캐릭터는 이름, 직업 외에 닉네임, 길드, 아이템 등 수많은 선택적 속성을 가질 수 있습니다.

1단계: private 생성자를 가진 메인 클래스 만들기

우리가 최종적으로 만들 객체입니다. 생성자가 private이므로 외부에서 new로 직접 생성할 수 없고, 오직 내부의 빌더를 통해서만 생성할 수 있습니다.

Java
 
// 최종적으로 생성할 복잡한 객체 (Product)
public class Character {

    // 필수 속성
    private final String name;
    private final String job;

    // 선택적 속성
    private final String nickname;
    private final String guild;
    private final int level;

    // 생성자는 private이며, Builder 객체를 매개변수로 받는다.
    private Character(CharacterBuilder builder) {
        this.name = builder.name;
        this.job = builder.job;
        this.nickname = builder.nickname;
        this.guild = builder.guild;
        this.level = builder.level;
    }

    // 모든 필드에 대한 Getter...
    @Override
    public String toString() {
        return "Character{" +
                "name='" + name + '\'' +
                ", job='" + job + '\'' +
                ", nickname='" + nickname + '\'' +
                ", guild='" + guild + '\'' +
                ", level=" + level +
                '}';
    }

    // 2단계 코드는 이 클래스 내부에 작성됩니다.
    // ...
}

2단계: static 중첩 빌더 클래스 만들기

이 패턴의 핵심입니다. 메인 클래스(Character) 내부에 static 중첩 클래스로 만듭니다.

  • 메인 클래스와 동일한 필드를 가집니다.
  • 빌더의 생성자에서는 필수적인 속성만 받습니다.
  • 각각의 선택적 속성을 설정하는 메서드는 빌더 자기 자신(return this;)을 반환합니다. 이를 **플루언트 인터페이스(fluent interface)**라고 하며, **메서드 체이닝(method chaining)**이 가능하게 합니다.
  • build() 메서드는 메인 클래스의 private 생성자를 호출하여 최종 객체를 생성하고 반환합니다.
Java
 
// Character 클래스 내부에 작성
public static class CharacterBuilder {

    // 메인 클래스와 동일한 필드들
    // 필수 속성
    private final String name;
    private final String job;

    // 선택적 속성
    private String nickname;
    private String guild;
    private int level = 1; // 기본값 설정 가능

    // 빌더의 생성자는 필수 속성만 받는다.
    public CharacterBuilder(String name, String job) {
        this.name = name;
        this.job = job;
    }

    // 선택적 속성을 설정하는 메서드. 체이닝을 위해 빌더 자신을 반환한다.
    public CharacterBuilder withNickname(String nickname) {
        this.nickname = nickname;
        return this;
    }

    // 다른 선택적 속성을 설정하는 메서드
    public CharacterBuilder withGuild(String guild) {
        this.guild = guild;
        return this;
    }
    
    // 또 다른 선택적 속성을 설정하는 메서드
    public CharacterBuilder withLevel(int level) {
        this.level = level;
        return this;
    }

    // 마지막 단계: 최종 Character 객체를 생성하여 반환한다.
    public Character build() {
        return new Character(this);
    }
}

3단계: 클라이언트 코드에서 빌더 사용하기

이제 캐릭터를 생성하는 코드가 매우 유연하고 읽기 쉬워집니다.

Java
 
public class GameClient {
    public static void main(String[] args) {
        // 필수 필드만으로 기본 캐릭터 생성
        Character warrior = new Character.CharacterBuilder("아라곤", "전사")
                                        .withLevel(87)
                                        .build();
        System.out.println(warrior);

        // 메서드 체이닝을 이용해 모든 필드를 설정한 캐릭터 생성
        Character mage = new Character.CharacterBuilder("간달프", "마법사")
                                    .withNickname("미스란디르")
                                    .withGuild("이스타리")
                                    .withLevel(99)
                                    .build();
        System.out.println(mage);
    }
}

4. 빌더 패턴과 스프링

빌더 패턴은 스프링과 그 생태계에서 복잡한 설정이나 응답 객체를 생성할 때 널리 사용됩니다.

  • ResponseEntity: 스프링 MVC 컨트롤러에서 HTTP 응답을 만들 때 ResponseEntity.BodyBuilder를 사용하여 .status(), .header() 같은 메서드를 체인 방식으로 호출하고, 마지막에 .body()로 응답 객체를 완성합니다.
  • Java
     
    @GetMapping("/hello")
    public ResponseEntity<String> sayHello() {
        return ResponseEntity.ok() // 빌더 시작
                .header("Custom-Header", "hello-world")
                .body("Hello, World!");
    }
    
  • UriComponentsBuilder: 쿼리 파라미터나 경로 변수가 포함된 복잡한 URI를 안전하고 깔끔하게 생성할 수 있도록 도와줍니다.

5. 문제 제시 및 답변 💡

문제: PizzaOrder 시스템을 만들고 있습니다. 피자는 반드시 size와 crust 타입을 가져야 합니다. 하지만 cheese, pepperoni, olives, onions 토핑은 모두 선택 사항입니다. 빌더 패턴을 사용하여 피자 주문을 깔끔하고 읽기 쉽게 만들려면 Pizza 클래스와 그 생성 과정을 어떻게 설계해야 할까요?

답변:

Character 예제와 정확히 동일한 방식으로 빌더 패턴을 적용하면 됩니다.

  1. Pizza 클래스:
    • size, crust, cheese 등 모든 속성을 private final 필드로 가집니다.
    • 생성자는 private으로 선언하고 PizzaBuilder 객체를 매개변수로 받습니다.
  2. PizzaBuilder static 중첩 클래스:
    • 생성자는 필수 값인 size와 crust만 받습니다: public PizzaBuilder(String size, String crust).
    • 각각의 선택적 토핑을 위한 메서드, 예를 들어 withCheese(boolean hasCheese), withPepperoni(boolean hasPepperoni) 등을 만듭니다. 각 메서드는 this를 반환해야 합니다.
    • 마지막으로 build() 메서드가 Pizza 인스턴스를 생성하여 반환합니다.
  3. 사용 코드:이 접근 방식은 코드를 읽기 쉽게 만들고, 나중에 새로운 토핑을 추가하더라도 기존 코드를 변경할 필요가 없어 매우 유연합니다.
  4. Java
     
    // 클라이언트 코드가 매우 명확하고 설명적으로 변한다.
    Pizza hawaiianPizza = new Pizza.PizzaBuilder("라지", "씬")
                                .withCheese(true)
                                .withPepperoni(true)
                                .build();
    
    Pizza veggiePizza = new Pizza.PizzaBuilder("미디움", "오리지널")
                                .withCheese(true)
                                .withOlives(true)
    
                                .withOnions(true)
                                .build();
    
728x90
반응형