빌더 패턴(Builder Pattern)

byeol·2023년 6월 15일
post-thumbnail

디자인 패턴 중에 하나인 빌더 패턴에 대해서 이야기해보려고 합니다.

🏭빌더 패턴

제가 처음에 빌더 패턴에 대해서 들었을 때
어떤 것과 비유해야 잘 이해할 수 있을까 생각하다 서브웨이가 떠올랐습니다.

서브웨이는 5단계에 따라 메뉴, 빵, 치즈, 야채, 소스, 세트 선택 여부를 선택합니다. 손님은 이 순서에 따라 본인이 원하는 것들을 말합니다.

어떤 사람은 야채를 먹지 못할 수도 있고 어떤 사람은 세트를 선택하지 않을 수도 있습니다.

이러한 상황에서 빌드 패턴은 여러 개의 선택적 속재료로 다양한 인스턴스를 생성할 수 있도록 도와줍니다.

여기서 모두 Effectice Java에 등장하는 빌드 패턴을 쉽게 떠올릴 수 있습니다. 이 빌드 패턴과 GoF의 빌드 패턴에 차이점이 있다는 것을 알고 계시나요?

이 글을 통해서 어떤 차이가 있는지 알아보도록 해요

🕵️‍♀️등장 배경

점층적 생성자 패턴

점층적 생성자 패턴은
서브웨이 샌드위치를 예로 들었을 때
샌드위치를 구성하는 필수 선택지 메뉴와 빵을 기준으로
각각의 선택에 따라 속성들이 추가하면서 생성자를 오버로딩하는 방식입니다.

class Sandwitch {
    Sandwitch(String menu, String bread) { ... }
    Sandwitch(String menu, String bread, String cheese) { ... }
    Sandwitch(String menu, String bread, String cheese, String topping) { ... }
    ...

하지만 이러한 방식은

  • 어떤 위치에 어떤 매개변수가 들어갔는지 기억해야 하며

  • 위와 같이 선택하지 않은 상황에서 불필요하게 "" 혹은 "false"를 언급해줘야 합니다.

    Sandwitch sandwitch = new Sandwitch("BLT", "flat", "", "")   

자바 빈 패턴

점층적 생성자 패턴의 단점을 보완하여 Setter 메소드를 이용하는 자바 빈 패턴이 등장하였습니다.

class Sandwitch {
    private String menu;
    private String bread;
    
    private String cheese;
    private String topping;
    private String vegetable;
    private String sauce;
    private boolean set;
    private String cookie;
    
    public Sandwitch() {}
    public void setMenu(String menu) {
        this.menu = menu;
    }

    public void setBread(String bread) {
        this.bread = bread;
    }

    public void setCheese(String cheese) {
        this.cheese = cheese;
    }

    public void setTopping(String topping) {
        this.lettuce = lettuce;
    }

    public void setVegetable(String vegetable) {
        this.vegetable = vegetable;
    }
    
    public void setSauce(String sauce) {
        this.sauce = sauce;
    }

    public void setSet(boolean set) {
        this.set = set;
    }
    
     public void setCookie(String cookie) {
        this.cookie = cookie;
    }
public static void main(String[] args) {
    // 모든 재료가 있는 샌드위치
    Sandwitch sandwitch1 = new Sandwitch();
    sandwitch1.setMenu("Egg");
    sandwitch1.setBread("Flat");
    sandwitch1.setCheese("American");
    sandwitch1.setTopping("아보카도");
    sandwitch1.setVegetable("양파");
    sandwitch1.setSauce("렌치");
    sandwitch1.setSet(true);
    sandwitch1.setCookie("스토로베리치즈쿠키")

    // 메뉴와 빵과 소스만 있는 샌드위치
    Sandwitch sandwitch2 = new Sandwitch();
    sandwitch2.setMenu("Egg");
    sandwitch2.setBread("Flat");
    sandwitch2.setSauce("머스타드");

    // 메뉴와 빵만 있는 샌드위치
    Sandwitch sandwitch3 = new Sandwitch();
    sandwitch3.setMenu("BLT");
    sandwitch3.setBread("Flat");
    
}

하지만 위 방식에는 두 가지 문제점이 있습니다.

  • 개발자의 실수로 필수 매개변수인 메뉴와 빵이 들어가지 않은 샌드위치가 만들어질 수 있습니다. 샌드위치에 빵이 없다면 샌드위치가 아니겠죠?
  • 또한 Setter 메소드는 public한 메서드이기 때문에 다른 곳에 함부로 호출하여 설정된 객체의 속성을 바꿀 수도 있습니다. 나는 분명 에그마요를 주문했는데 이탈리안 비엘티가 나올 수도 있는 것입니다.

빌더 패턴

위 같은 문제를 해결하기 위해서 별도의 Builder 클래스를 만들어
메소드를 통해서 필드의 값들을 단계적으로 입력받아 최종적으로 build() 메소드로 하나의 인스턴스를 생성하여 리턴합니다.

Sandwitch 클래스

public class Sandwich {
    private String menu;
    private String bread;
    private String cheese;
    private String topping;
    private String vegetable;
    private String sauce;
    private boolean set;
    private String cookie;

    public Sandwich(String menu, String bread, String cheese, String topping, String vegetable, String sauce, boolean set, String cookie) {
        this.menu = menu;
        this.bread = bread;
        this.cheese = cheese;
        this.topping = topping;
        this.vegetable = vegetable;
        this.sauce = sauce;
        this.set = set;
        this.cookie = cookie;
    }

}

SandwitchBuilder 클래스

class SandwichBuilder {
    private String menu;
    private String bread;
    private String cheese;
    private String topping;
    private String vegetable;
    private String sauce;
    private boolean set;
    private String cookie;

    public SandwichBuilder menu(String menu) {
        this.menu = menu;
        return this;
    }

    public SandwichBuilder bread(String bread) {
        this.bread = bread;
        return this;
    }

    public SandwichBuilder cheese(String cheese) {
        this.cheese = cheese;
        return this;
    }

    public SandwichBuilder topping(String topping) {
        this.topping = topping;
        return this;
    }

    public SandwichBuilder vegetable(String vegetable) {
        this.vegetable = vegetable;
        return this;
    }
    ...

    public Sandwich build() {
        return new Sandwich(menu, bread, cheese, topping, vegetable, sauce, set, cookie); // Student 생성자 호출
    }
}

빌더 클래스는 앞서 자바 빈 패턴에 있던 Setter 메소드와의 차이점이 있습니다. 바로 객채 자신을 리턴하기 때문에 return this; 메서드 호출 후 연속적으로 빌더 메서드들을 체이닝 하여 호출할 수 있습니다.

SandwitchBuilder 빌더 클래스 실행

   SandWtich bltSandWtich = new SandWitchBuilder()
                             .menu("BLT")
                             .bread("Flat")
                             .cheese("아메리칸치즈")
                             .sauce("렌치")
                             .build();

빌더 패턴의 종류

심플 빌더 패턴

심플 빌더 패턴은 저희가 생각하고 있던 그 빌더 패턴입니다.

생성자가 많거나 변경 불가능한 불변 객체가 필요한 경우
코드의 가독성을 높이고 필수 파라미터를 개발자가 실수로 빼먹을 경우와 의도치 않은 곳에서 객체의 속성을 변경시키는 경우를 방지하는 것에 중점을 둡니다.

앞서 배웠던 빌더 패턴과 큰 차이가 없지만
앞서 빌더 클래스가 외부 클래스였다면 심플 빌더 패턴을 정적 내부 클래스로 구현된다는 점에 차이가 있습니다.

package org.optional;

class Sandwich {
    String menu;
    String bread;
    String cheese;
    String topping;
    String vegetable;
    String sauce;
    boolean set;
    String cookie;

    //정적 내부 빌더 클래스
    public static class Builder {

        //필수 파라미터
        String menu;
        String bread;
        
        //선택 파라미터
        String cheese;
        String topping;
        String vegetable;
        String sauce;
        boolean set;
        String cookie;
        
        // 필수 파라미터는 빌더 생성자로 받는다.
        public Builder(String menu, String bread) {
            this.menu = menu;
            this.bread = bread;
        }

        // 선택 파라미터는 각 메서드로 받는다.
        public Builder cheese(String cheese) {
            this.cheese = cheese;
            return this;
        }

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

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

        //...
        // 대상 객체의 private 생성자를 호출해서 최종 인스턴스화
        public Sandwich build() {
            return new Sandwich(this);
        }
    }
    
    // private 하기 때문에 외부에서 생성자로 인스턴스화는 불가능
    private Sandwich(Builder builder) {
        this.menu = builder.menu;
        this.bread = builder.bread;
        this.cheese = builder.cheese;
        this.topping = builder.topping;
        this.vegetable = builder.vegetable;
        this.sauce = builder.sauce;
        this.set = builder.set;
        this.cookie = builder.cookie;
    }

}

빌더 클래스가 정적 내부 클래스로 구현된 이유

  • 하나의 빌더 클래스 = 하나의 대상 객체 생성만을 위해 사용
  • 대상 객체가 오로지 빌더 객체만으로만 초기화하도록 하기 위해 -> 생성자를 private로 하였기 때문에 오로지 빌더 객체를 통해서만 초기화가 가능하도록
  • static으로 선언한 이유는 일반 내부 클래스의 경우 내부 클래스가 생성되기 전에 외부의 클래스가 먼저 인스턴스화 됩니다.
  • 메모리 누수의 문제

GoF 빌더 패턴

심플 빌더 패턴은 GoF의 빌더 패턴보다 좀 더 코딩 위주의 활용법을 설명합니다. 코드의 가독성과 유지보수가 편해지므로 빌더 패턴을 쓰라고 말하기 때문입니다. GoF가 책을 썼을 때에는 상대적으로 덜 중요했던 객체의 일관성(필수 파라미터), 변경 불 가능성 등의 특징을 설명합니다.

하지만 GoF의 빌더 패턴은 객체의 생성 알고리즘과 조립 방법을 분리하는 것이 목적입니다.

심플 빌더 패턴은 하나의 대상 객체 대한 생성만을 목적을 두지만
GoF의 빌더 패턴은 여러가지의 빌드 형식을 유연하게 처리하는 것에 목적을 둡니다.

구조

🟦 Builder : 빌더 추상 클래스
🟦 ConcreteBuilder : Builder의 구현체로 Product 생성을 담당합니다.
🟦 Director : Builder에서 제공하는 메서드를 사용해서 정해진 순서대로 Product 생성하는 프로세스를 정의합니다.
🟦 Product : Director가 Builder로 만들어낸 결과물입니다.

예시를 통해 자세히 살펴보기

🕵️‍♀️ 첫 번째 예시

✅ 사용되는 Data

public class Sandwich4 {
    private String bread;
    private String patties;

    public Sandwich4(String bread, String patties){
        this.bread =bread;
        this.patties=patties;
    }

    public String getBread (){
        return  bread;
    }

    public String getPatties () {
        return patties;
    }
}

✅ Builder : 빌더 추상 클래스

abstract class Builder {

    protected Sandwich4 sandwich4;

    public Builder(Sandwich4 sandwich4){
        this.sandwich4 = sandwich4;
    }

    public abstract String bread();
    public abstract String patties();
    public abstract String body();

}

✅ ConcreteBuilder : Builder의 구현체로 Product 생성을 담당합니다.

public class HamburgerBuilder extends Builder{

    public HamburgerBuilder(Sandwich4 sandwich4){
        super(sandwich4);
    }

    @Override
    public String bread() {
        return "두껍고 폭신한 " + sandwich4.getBread();
    }

    @Override
    public String patties() {
        return "소고기 수제" + sandwich4.getPatties();
    }

    @Override
    public String body() {
        StringBuilder sb = new StringBuilder();

        sb.append(sandwich4.getBread());
        sb.append(sandwich4.getPatties());

        return sb.toString();
    }
}
public class ToastBuilder extends Builder{

    public ToastBuilder(Sandwich4 sandwich4){
        super(sandwich4);
    }
    @Override
    public String bread() {
        return "네모난 "+sandwich4.getBread();
    }

    @Override
    public String patties() {
        return "얇고 돼지고기의 "+sandwich4.getPatties();
    }

    @Override
    public String body() {
        StringBuilder sb = new StringBuilder();

        sb.append(sandwich4.getBread());
        sb.append(sandwich4.getPatties());

        return sb.toString();
    }
}

✅ Director : Builder에서 제공하는 메서드를 사용해서 정해진 순서대로 Product 생성하는 프로세스를 정의합니다.

public class Director {
    private Builder builder;

    public Director(Builder builder){
        this.builder = builder;
    }

    public String build() {
        StringBuilder sb = new StringBuilder();

        sb.append(builder.bread());
        sb.append(builder.patties());

        return sb.toString();
    }
}

✅ Product 결과물

public class Main {
    public static void main(String[] args) {
         Sandwich4 sandwich4 = new Sandwich4("빵","햄");

         Builder builder1 = new HamburgerBuilder(sandwich4);
         Director director1 = new Director(builder1);
         String result1 = director1.build();
         System.out.println(result1);


        Builder builder2 = new ToastBuilder(sandwich4);
        Director director2 = new Director(builder2);
        String result2 = director2.build();
        System.out.println(result2);
    }
}

🕵️‍두 번째 예시

✅ Builder

public interface TourPlanBuilder {

   TourPlanBuilder nightsAndDays(int nights, int days);

   TourPlanBuilder title(String title);

   TourPlanBuilder startDate(LocalDate localDate);

   TourPlanBuilder whereToStay(String whereToStay);

   TourPlanBuilder addPlan(int day, String plan);

   TourPlan getPlan();

}

✅ ConcreteBuilder

public class DefaultTourBuilder implements TourPlanBuilder {

   private String title;

   private int nights;

   private int days;

   private LocalDate startDate;

   private String whereToStay;

   private List<DetailPlan> plans;

   @Override
   public TourPlanBuilder nightsAndDays(int nights, int days) {
       this.nights = nights;
       this.days = days;
       return this;
   }

   @Override
   public TourPlanBuilder title(String title) {
       this.title = title;
       return this;
   }

   @Override
   public TourPlanBuilder startDate(LocalDate startDate) {
       this.startDate = startDate;
       return this;
   }

   @Override
   public TourPlanBuilder whereToStay(String whereToStay) {
       this.whereToStay = whereToStay;
       return this;
   }

   @Override
   public TourPlanBuilder addPlan(int day, String plan) {
       if (this.plans == null) {
           this.plans = new ArrayList<>();
       }

       this.plans.add(new DetailPlan(day, plan));
       return this;
   }

   @Override
   public TourPlan getPlan() {
       return new TourPlan(title, startDate, days, nights, whereToStay, plans);
   }
}

✅ Director

public class TourDirector {

  private TourPlanBuilder tourPlanBuilder;

  public TourDirector(TourPlanBuilder tourPlanBuilder) {
      this.tourPlanBuilder = tourPlanBuilder;
  }

  public TourPlan cancunTrip() {
      return tourPlanBuilder.title("칸쿤 여행")
              .nightsAndDays(2, 3)
              .startDate(LocalDate.of(2020, 12, 9))
              .whereToStay("리조트")
              .addPlan(0, "체크인하고 짐 풀기")
              .addPlan(0, "저녁 식사")
              .getPlan();
  }

  public TourPlan longBeachTrip() {
      return tourPlanBuilder.title("롱비치")
              .startDate(LocalDate.of(2021, 7, 15))
              .getPlan();
  }
}

✅ Product

public static void main(String[] args) {
   TourDirector director = new TourDirector(new DefaultTourBuilder());
   TourPlan tourPlan = director.cancunTrip();
}

첫 번째 예시와 다르게 추상클래스가 아닌 인터페이스를 통해서 Builder를 만들었습니다.

두 가지 예시를 통해서 Builder는 부품을 생산하고
Director는 이 부품들을 조립해서 어떻게 표현할 것인지를 결정합니다.

따라서 부품을 생산하는 쪽은 추상화하여 변경이 용이하도록 하였고 복잡한 객체를 생성하는 단계를 분리해서 다루고 있습니다.

장점과 단점

장점

  • 객체들을 단계별로 생성하거나 생성 단계들을 연기하거나 재귀적으로 단계들을 실행할 수 있습니다.
  • 제품들의 다양한 표현을 만들 때 같은 생성 코드를 재사용할 수 있습니다.
  • 단일책임원칙을 지킵니다. 제품의 비즈니스 로직에서 복잡한 생성 코드를 고립시킬 수 있습니다.

단점

  • 패턴이 여러 개의 새 클래스를 생성해야 하므로 코드의 전반적인 복잡성이 증가합니다.

정리

빌더 패턴에 대해서 알아보았습니다.
처음 제가 든 예시는 서브웨이의 샌드위치를 만드는 과정이었고
결국 빌더 패턴은 점층적인 생성자를 제거하기 위해서 등장하게 되었음을 알 수 있었습니다.
즉 객체 생성을 단순화하고 유연성을 제공하는 것이라는 것을 알 수 있었습니다.

하지만 아마도 우리들은 GoF의 빌드 패턴을 알기 보다는 이펙티브 자바의 빌더 패턴에 대해서 알고 있었을 것입니다.

GoF의 빌더 패턴은 복잡한 객체의 생성과정을 단계별로 분리했다는 것
그래서 별도의 빌더 클래스를 사용해서 빌더 객체를 통해서 객체를 구성하고 조립하는 방식으로 분리된다는 것입니다.

심플 빌더 패턴(이펙티브 자바)은 더 간단한 구조로 빌더 클래스가 정적 내부 클래스로 존재하고 직접 객체를 생성하고 조립하는 방식을 사용합니다. 그래서 코드가 더 직관적인하는 점이 있습니다.

사용목적에도 차이가 있습니다.
GoF 패턴의 경우 복잡한 객체의 생성과정을 추상화한다는 것, 그리고 동일한 생성 절차를 가진 다양한 종류의 객체를 생성하기 위해 사용됩니다. (샌드위치에는 버거, 토스트 혹은 파일에는 XML파일,JSON 파일)

심플 빌더 패턴의 경우 객체 생성과 조립을 단순화하고 가독성을 높인다는 것입니다. (버거, 토스트가 아닌 버거의 종류를 다양하게 만드는 것-> 새우 버거, 불고기 버거)

profile
꾸준하게 Ready, Set, Go!

1개의 댓글

comment-user-thumbnail
2023년 6월 16일

우왕 바로 이해 했습니다!

답글 달기