[디자인 패턴] 빌더(Builder) 패턴

doodoom·2022년 9월 19일
0

Design Pattern

목록 보기
4/4

0. 이 글을 쓰게 된 이유

남의 코드들을 보면서 빌더 패턴을 처음 접했다. 많은 개발자들이 이 패턴을 쓰는 이유와 제대로 알고 쓰고 싶어서 책을 읽고 공부한 내용을 정리해본다.

1. 빌더 패턴

빌더 패턴에 대해서 공부를 해보니 평소에 사용하는 빌더 패턴(Effective Java에서 설명하고 있는)과 GoF의 빌더 패턴의 목적이 조금 다르다는 것을 알게되었다.

  • Effective Java에서 설명하는 빌더 패턴
    • 객체 생성을 가독성 있고 유연하게 하기 위한 목적
  • Gof 디자인 패턴의 빌더 패턴
    • 객체의 생성 알고리즘과 조립 방법을 분리하는 것이 목적

이 글에서는 Effective Java에서 설명하는 빌더 패턴의 목적 위주로 설명한다.

1.1 Effective Java 빌더 패턴

Effective Java 아이템 2.생성자에 매개변수가 많다면 빌더를 고려하라

이펙티브 자바에서 말하는 빌더 패턴은 객체 생성을 가독성 있고 유연하기 하기 위한 목적이다.

이 책에서 설명하는 방식처럼 점층적 생성자 패턴(telescoping constructor pattern)자바빈 패턴(JavaBeans pattern)을 알아보고 왜 빌더 패턴을 사용하는지 알아보자.

1.2 점층적 생성자 패턴(telescoping constructor pattern)

점층적 생성자 패턴을 만드는 방법은 다음과 같다.

  1. 필수 매개변수마 받는 생성자를 만든다.
  2. 1개의 선택 매개변수를 받는 생성자를 받는다.
  3. 2개의 선택 매개변수를 받는 생성자를 만든다.
    ... 반복
  4. 모든 선택적 인자를 다 받는 생성자를 추가한다.
// 예시 코드 : 영양성분 클래스
public class NutritionFacts {

    private final int servingSize;
    private final int servings;
    private final int calories;
    private final int fat;
    private final int sodium;
    private final int carbohydrate;

    public NutritionFacts(int servingSize, int servings) {
        this(servingSize, servings, 0);
    }

    public NutritionFacts(int servingSize, int servings, int calories) {
        this(servingSize, servings, calories, 0);
    }

    public NutritionFacts(int servingSize, int servings, int calories,
        int fat) {
        this(servingSize, servings, calories, fat, 0);
    }

    public NutritionFacts(int servingSize, int servings, int calories, int fat,
        int sodium) {
        this(servingSize, servings, calories, fat, sodium, 0);
    }

    public NutritionFacts(int servingSize, int servings, int calories, int fat,
        int sodium, int carbohydrate) {
        this.servingSize = servingSize;
        this.servings = servings;
        this.calories = calories;
        this.fat = fat;
        this.sodium = sodium;
        this.carbohydrate = carbohydrate;
    }
}

장점

  • 이 클래스의 인스턴스를 만들려면 원하는 매개변수를 모두 포함한 생성자 중 가장 짧은 것을 골라 호출하면 된다.
new NutritionFacts(240, 8, 100, 0, 35, 27);
  • 그럼 굳이 모든 인자를 다 받는 생성자를 호출해서 빈값에 null이나 0을 넣을 필요가 없어져 가독성을 조금이나마 높인다.

단점

  • 이런 생성자는 사용자가 설정하길 원치 않는 매개변수까지 값을 지정해줘야한다. (위 호출 예시에서 0을 넣은 것처럼)
  • 매개변수가 많아지면 코드를 수정하기 어렵다.
  • 생성자를 호출할 시에 타입이 같은 매개변수가 늘어져있으면 실수를 해도 컴파일 에러가 나지 않아 찾아내기가 어렵다.

1.3 자바빈 패턴(JavaBeans pattern)

이번에는 매개변수가 많을 때 활용 가능한 자바 빈즈 패턴이다.
자바 빈즈 패턴을 만드는 방법은 다음과 같다.

  1. 매개변수가 없는 생성자를 만든다.
  2. setter를 통해 매개변수에 값을 할당한다.
public class NutritionFacts {

    private int servingSize; // 필수 매개변수
    private int servings;    // 필수 매개변수
    private int calories;
    private int fat;
    private int sodium;
    private int carbohydrate;

    public NutritionFacts() {
    }

    public void setServingSize(int servingSize) {
        this.servingSize = servingSize;
    }

    public void setServings(int servings) {
        this.servings = servings;
    }

    public void setCalories(int calories) {
        this.calories = calories;
    }

    public void setFat(int fat) {
        this.fat = fat;
    }

    public void setSodium(int sodium) {
        this.sodium = sodium;
    }

    public void setCarbohydrate(int carbohydrate) {
        this.carbohydrate = carbohydrate;
    }
}
NutritionFacts nutritionFacts = new NutritionFacts();
        nutritionFacts.setServingSize(240);
        nutritionFacts.setServings(8);
        nutritionFacts.setCalories(100);
        nutritionFacts.setSodium(35);
        nutritionFacts.setCarbohydrate(27);

장점

  • 점층적 생성자 패턴의 단점들이 자바 빈즈 패턴에서는 더 이상 보이지 않음.
    • 매개변수가 많아져도 선택적으로 setter를 호출해서 설정할 수 있어서 가독성이 올라감.

단점

  • 객체 하나를 만들려면 메서드를 여러 개 호출해야 함.
  • 객체가 완전히 생성되기 전까지는 일관성(consistency)이 무너진 상태에 놓임.
    • 1회 호출로 객체 생성이 끝나지 않음.
    • 즉, 한 번에 생성하지 않고 생성한 객체에 값을 계속 넣어줘야함.
  • setter 때문에 클래스를 불변(immutable)클래스로 만들 수 없음.
    • 스레드 안정성을 얻으려면 프로그래머가 추가 작업을 해줘야 함.

1.3 빌더(builer) 패턴

위의 모든 문제들을 모두 해결하고 가독성을 겸비한 패턴이 빌더 패턴이다.

public class NutritionFacts {

    private int servingSize; // 필수 매개변수
    private int servings;    // 필수 매개변수
    private int calories;
    private int fat;
    private int sodium;
    private int carbohydrate;

    private NutritionFacts(Builder builder) {
        servingSize = builder.servingSize;
        servings = builder.servings;
        calories = builder.calories;
        fat = builder.fat;
        sodium = builder.sodium;
        carbohydrate = builder.carbohydrate;
    }

    public static class Builder {

        // 필수 매개변수
        private final int servingSize;
        private final int servings;

        // 선택 매개변수 - 기본 값으로 초기화
        private int calories = 0;
        private int fat = 0;
        private int sodium = 0;
        private int carbohydrate = 0;

        public Builder(int servingSize, int servings) {
            this.servingSize = servingSize;
            this.servings = servings;
        }

        public Builder calories(int calories) {
            this.calories = calories;
            return this;
        }

        public Builder fat(int fat) {
            this.fat = fat;
            return this;
        }

        public Builder sodium(int sodium) {
            this.sodium = sodium;
            return this;
        }

        public NutritionFacts build() {
            return new NutritionFacts(this);
        }
    }
    
}

다음과 같이 호출하면 된다.

NutritionFacts cocaCola = new NutritionFacts
            .Builder(240, 8)    // 필수값 입력
            .calories(100)
            .sodium(35)
            .carbohydrate(27)
            .build();

장점

  • 클래스를 불변으로 만들 수 있다.
  • 빌더의 세터 메서드들이 빌더 자신을 반환하므로 연쇄적으로 호출할 수 있다.
  • 각 인자가 어떤 의미인지 알 수가 있어 가독성이 높다.
  • 한번에 객체를 생성하므로 객체의 일관성이 깨지지 않는다.

빌더 패턴의 활용 (계층 구조)

Effective java에서 빌더 패턴은 계층적으로 설계된 클래스와 함께 쓰기에 좋다라고 한다. Effective java에 나온 예시를 바탕으로 무슨 뜻인지 이해해보자.

다음은 피자의 다양한 종류를 표현하는 계층구조의 루트에 놓인 추상 클래스이다.

public abstract class Pizza {
    public enum Topping {
        HAM, MUSHROOM, ONION, PEPPER, SAUSAGE
    }
    final Set<Topping> toppings;

    Pizza(Builder<?> builder) {
        toppings = builder.toppings.clone();
    }

    abstract static class Builder<T extends Builder<T>> {

        EnumSet<Topping> toppings = EnumSet.noneOf(Topping.class);

        public T addTopping(Topping topping) {
            toppings.add(Objects.requireNonNull(topping));
            return self();
        }

        abstract Pizza build();
        // 하위 클래스는 이 메서드를 overriding 하여 "this"를 반환하도록 해야한다.
        protected abstract T self();

    }
}

Pizza.Builder 클래스는 재귀적 타입 한정을 이용하는 제너릭 타입이다. 여기에 추상 메서드인 self를 더해 하위 클래스에서 형변환하지 않고도 메서드 연쇄를 지원할 수 있다. self 타입이 없는 자바를 위한 이 우회 방법을 시뮬레이트한 셀프 타입(simulated self-type) 관용구라 한다.

다음은 일반적인 뉴욕 피자이다. 뉴욕 피자는 size 매개변수를 필수로 받는다.

public class NyPizza extends Pizza {


    public enum Size {SMALL, MEDIUM, LARGE;}

    private final Size size;

    public NyPizza(Builder builder) {
        super(builder);
        size = builder.size;
    }

    public static class Builder extends Pizza.Builder<Builder> {

        private final Size size;

        public Builder(Size size) {
            this.size = Objects.requireNonNull(size);
        }


        @Override
        NyPizza build() {
            return new NyPizza(this);
        }

        @Override
        protected Builder self() {
            return this;
        }
    }
}

이러한 경우에는 다음과 같은 방식으로 생성할 수 있다.

NyPizza nypizza = new NyPizza
            .Builder(MEDIUM)
            .addTopping(HAM)
            .addTopping(ONION)
            .build();

다음은 칼초네 피자이다. 소스를 안에 넣을지 선택(sauceInside)하는 매개변수를 필수인자로 받는다.

public class Calzone extends Pizza {
    private final boolean sauceInside;

    private Calzone(Builder builder) {
        super(builder);
        sauceInside = builder.sauceInside;
    }
    
    public static class Builder extends Pizza.Builder<Builder> {

        private boolean sauceInside = false; // default

        public Builder sauceInside() {
            sauceInside = true;
            return this;
        }


        @Override
        Calzone build() {
            return new Calzone(this);
        }

        @Override
        protected Builder self() {
            return this;
        }
    }
}

이러한 경우 다음과 같이 생성할 수 있다.

Calzone calzone = new Builder()
            .sauceInside()
            .addTopping(ONION)
            .build();

NyPizza.Builder는 NyPizza를 반환하고, Calzone.Builder는 Calzone를 반환한다. 하위 클래스의 메서드가 상위 클래스의 메서드가 정의한 반환 타입이 아닌, 그 하위 타입을 반환하는 기능을 공변 반환 타입(concariant return typing)이라 한다. 이 기능을 이용하면 클라이언트가 형변환에 신경 쓰지 않고도 빌더를 사용할 수 있다.

생성자로는 누릴 수 없는 사소한 이점으로, 빌더를 이용하면 가변인수(varargs) 매개변수를 여러 개 사용할 수 있다. 각각을 적절한 메서드로 나눠 선언하면 된다. 아니면 메서드를 여러 번 호출하도록 하고 각 호출 때 넘겨진 매개변수들을 하나의 필드로 모을 수도 있다.(위 Pizza.Builder에 addTopping이 예이다.)

정리

빌더 패턴은 상당히 유연하다. 빌더 하나로 여러 객체를 순회하면서 만들 수 있고, 빌더에 넘기는 매개변수에 따라 다른 객체를 만들 수 있다. 객체마다 부여되는 일련번호와 같은 특정 필드느 빌더가 알아서 채우도록 할 수도 있다.

정리하자면, 생성자나 정적 팩터리가 처리해야 할 매개변수가 많다면 빌더 패턴을 선택하는 게 더 낫다.
매개변수 중 다수가 필수가 아니거나 같은 타입이면 특히 더 그렇다. 빌더는 점층적 생성자보다 클라이언트 코드를 읽고 쓰기가 훨씬 간결하고, 자바빈즈보다 훨씬 안전하다.

profile
백엔드 개발자 최영훈입니다

0개의 댓글