로컬에서는 잘 되는데 ☘️

빌더 패턴(Builder Pattern)

by youngjun._.

1. 빌더 패턴이란?

생성과 관련된 디자인 패턴으로, 동일한 프로세스를 거쳐 다양한 구성의 인스턴스를 만드는 방법

 

1-1. 정의(Definition)

  • GoF 디자인 패턴 중 생성 패턴에 해당한다.
  • 빌더 패턴은 복잡한 객체를 생성하는 클래스표현하는 클래스분리하여, 동일한 절차에서도 서로 다른 표현을 생성하는 방법을 제공한다.
  • 생성해야하는 객체가 Optional한 속성을 많이 가질 때 더 좋다.

 

2. 해결하고자 하는 문제

빌더 패턴은 생성과 관련된 어떤 문제를 해결하려고 했을까?

 

결론부터 이야기하면, 객체를 생성할 때 생성자(Constructor)만 사용할 때 발생할 수 있는 문제를 개선하기 위해 고안됐다.

 

이전에 알아본 생성 패턴과 비교하자면, 팩토리 메소드 패턴이나 추상 팩토리 패턴에서는 생성해야하는 클래스에 대한 속성 값이 많을 때 아래와 같은 이슈들이 있다.

 

1. 클라이언트 프로그램에서 팩토리 클래스를 호출할 때 Optional한 인자가 많아지면, 타입과 순서에 대한 관리가 어려워져 에러 발생 확률이 높아진다.
2. 경우에 따라 필요 없는 파라미터들에 대해서 팩토리 클래스에 일일이 NULL을 넘겨줘야한다.
3. 생성해야 하는 sub class가 무거워지고 복잡해짐에 따라 팩토리 클래스 또한 복잡해진다. 

 

빌더 패턴은 이러한 문제들을 해결하기 위해 별도의 Builder 클래스를 만들어 필수 값에 대해서는 생성자를 통해, 선택적인 값들에 대해서는 메소드를 통해 step-by-step으로 값을 입력받은 후에 build() 메소드를 통해 최종적으로 하나의 인스턴스를 return하는 방식이다.

 

크게 3가지 이슈를 해결하려면 다음과 같은 요구사항을 만족하면 된다.

1. 불필요한 생성자를 만들지 않고 객체를 만든다.
2. 데이터의 순서에 상관 없이 객체를 만들어 낸다.
3. 사용자가 봤을때 명시적이고 이해할 수 있어야 한다.

 

3. 코드로 알아보자

요구사항을 기반으로 개발하는 상황을 가정하자.

 

여행 계획을 세우는 앱을 개발한다고 할때, 다음과 같은 요구사항이 있다.

 

요구사항 1 : 여행 계획 항목은 이렇게 해주세요
여행 제목, 여행 출발 일, 몇박 며칠 동안 어디서 머물지, n일차에 할일을 기록

 

요구 사항 1을 만족하려면, 아래와 같은 도메인으로 구성된다.

/**
 * 여행 계획
 */
public class TourPlan {
    private String title; // 여행 제목
    private LocalDate startDate; // 출발 일
    private int nights; // 몇 박
    private int days; // 며칠
    private String whereToStay; // 어디서 머물지
    private List<DetailPlan> plans; // n일차 할 일
}

/**
 * n일차 할 일
 */
public class DetailPlan {
    private int day; // n일차
    private String plan; // 할 일
}

 

요구사항 2 : 당일 치기 계획도 필요해요
당일 치기는 n박m일이 필요 없고, 어디서 머물지도 필요없다.

 

필수적인 정보와 선택적인 정보로 Optional한 속성들이 생겼을 때 어떻게 구현하면 될까?

 

3-1. 점층적 생성자 패턴

점층적 생성자 패턴을 적용하면 생성자 오버로딩을 통해 구현할 수 있다.

 

/**
 * 기본 생성자 (필수)
 */
public TourPlan() {
}

/**
 * 일반적인 여행 계획 생성자
 *
 * @param title 여행 제목
 * @param startDate 출발 일
 * @param nights n박
 * @param days m일
 * @param whereToStay 머물 장소
 * @param plans n일차 할 일
 */
public TourPlan(String title, LocalDate startDate, int nights, int days,
    String whereToStay, List<DetailPlan> plans) {
    this.title = title;
    this.nights = nights;
    this.days = days;
    this.startDate = startDate;
    this.whereToStay = whereToStay;
    this.plans = plans;
}

/**
 * 당일치기 여행 계획 생성자
 *
 * @param title 여행 제목
 * @param startDate 출발 일
 * @param plans 1일차 할 일
 */
public TourPlan(String title, LocalDate startDate, List<DetailPlan> plans) {
    this.title = title;
    this.startDate = startDate;
    this.plans = plans;
}

위와 같이 점층적 생성자 패턴으로 구현하면 Optional한 인자에 따라 새로운 생성자를 만들거나, Null 값으로 채워야하는 이슈가 발생한다.

 

물론 Lombok의 @AllArgsConstructor 에너테이션을 활용하면 코드가 길어지는 문제는 해결할 수 있지만, 여전히 인자가 많은 경우 타입과 순서로 발생할 수 있는 에러 가능성이 존재한다.

// 순서를 파악이 어렵고, 가독성이 떨어진다.
new TourPlan("여행 계획", LocalDate.of(2021,12, 24), 3, 4, "호텔",
    Collections.singletonList(new DetailPlan(1, "체크인")));
    
// 생성자를 만들지 않고 당일치기 객체를 생성하면 불필요한 Null을 채워야한다.
new TourPlan("여행 계획", LocalDate.of(2021,12, 24), null, null, null,
    Collections.singletonList(new DetailPlan(1, "놀고 돌아오기")));
더보기
사실 IntelliJ는 똑똑하고 친절해서 hint가 뜨긴한다.
친철한 IDE..

3-2. 자바 빈(Bean) 패턴

이러한 단점을 보완하기 위해 setter 메소드를 사용한 자바 빈(Bean) 패턴이 고안됐다.

TourPlan tourPlan = new TourPlan();
tourPlan.setTitle("칸쿤 여행");
tourPlan.setNights(2);
tourPlan.setDays(3);
tourPlan.setStartDate(LocalDate.of(2021, 12, 24));
tourPlan.setWhereToStay("리조트");
tourPlan.addPlan(1, "체크인 이후 짐풀기");
tourPlan.addPlan(1, "저녁 식사");
tourPlan.addPlan(2, "조식 부페에서 식사");
tourPlan.addPlan(2, "해변가 산책");
tourPlan.addPlan(2, "점심은 수영장 근처 음식점에서 먹기");
tourPlan.addPlan(2, "리조트 수영장에서 놀기");
tourPlan.addPlan(2, "저녁은 BBQ 식당에서 스테이크");
tourPlan.addPlan(3, "조식 부페에서 식사");
tourPlan.addPlan(3, "체크아웃");

가독성도 좋아지고, 순서에도 자유롭기 때문에 에러 발생 가능성도 줄어들었다.

 

하지만, 해결하지 못한 문제점들이 있다.

 

1. 함수 호출이 인자만큼 이루어지고, 객체 호출 한번에 생성할 수 없다.

2. immutable 객체를 생성할 수 없다. (setter로 값 변경 가능)
   - 쓰레드간 공유 가능한 객체 일관성(consistency)이 일시적으로 깨질 수 있다.

 

3-3. 드디어 빌더 패턴

생성자 패턴과 자바 빈 패턴의 장점을 결합하여 객체 생성과 관련된 문제를 해결했다.

 

  • 필요한 객체를 직접 생성하는 대신, 먼저 필수 인자들을 생성자에 전부 전달하여 빌더 객체를 만든다.
  • 그리고 선택 인자는 가독성이 좋은 코드로 인자를 넘길 수 있다.
  • setter가 없으므로 객체 일관성을 유지하여 불변 객체로 생성할 수 있다.

기본적인 Class Diagram은 다음과 같다.

출처 : 백기선님 - 코딩으로 학습하는 GoF의 디자인 패턴 강의 중

 

먼저, Interface인 TourPlanBuilder를 만들어준다.

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);
    }
}

 

 

이제 TourPlan 객체를 생성하는 코드를 보자.

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

위에서 언급했던 문제점이 모두 해결되었다!

 

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();
    }
}
public static void main(String[] args) {
    TourDirector director = new TourDirector(new DefaultTourBuilder());
    TourPlan tourPlan = director.cancunTrip();
}

 

빌더 패턴의 장점을 정리하면 다음과 같다.

필요한 데이터만 설정할 수 있음
유연성을 확보할 수 있음
가독성을 높일 수 있음
불변성을 확보할 수 있음

 

빌더 패턴은 굉장히 자주 사용되는 생성 패턴 중 하나로, Retrofit이나 Okhttp 등 유명 오픈소스에서도 이 빌더 패턴을 사용하고 있다.
실무에서는 Stream.Builder API, StringBuilder, UriComponentsBuilder 등에 활용된다.

위에 언급한 장점을 잘 이해하고 필요하다면, 디렉터까지 잘 활용해보자.

 

 

Reference

 

블로그의 정보

개발하는만두

youngjun._.

활동하기