ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • Item 2: Consider a builder when faced with many constructor parameters
    독서/Effective Java 2021. 12. 4. 18:36

    시작

    영양 성분 표시를 자동으로 저장하는 서비스를 제공한다고 가정해보자.

    먼저, 영양 성분 표시를 위한 데이터 클래스를 만들 것이다.

    기획자가 샘플을 보여줬는데 샘플에는 다음과 같은 필드들이 존재했다.

    1회 제공량, 용기당 1회 제공량, 1회 제공량당 칼로리 - 필수 필드
    총 지방, 포화 지방, 트랜스 지방, 콜레스테롤, 나트륨 + 20개 이상의 필드 - 선택적(Optional) 필드, 기본값은 0

    이제 기본적인 클래스를 만들었다.

    public class NutritionFacts {
      private final int servingSize; // (mL) required
      private final int servings; // (per container) required
      private final int calories; // (per serving) optional
      private final int fat; // (g/serving) optional
      private final int sodium; // (mg/serving) optional
      private final int carbohydrate; // (g/serving) optional
    }

    그럼 다음과 같은 상황에서 제품이 들어왔을 때 해당 값들을 초기화 하는 방법들에는 무엇이 있을까?

    가장 먼저 생각나는 건 Constructor와 Static Factory Method(Item 1)이 있을 것이다.

    근데... 선택적 필드가 너무 많아서 해당 메소드들을 사용하기 애매해진다.

    ※ 제약 사항

    정적 팩토리 메서드와 생성자는 많은 수의 optional parameters에 잘 확장되지 않는다는 한계를 공유한다.

    먼저 점층적 생성자 패턴을 활용하기로 한다.

     

    점층적 생성자 패턴

    // Telescoping constructor pattern - does not scale well!
    public class NutritionFacts {
      private final int servingSize; // (mL) required
      private final int servings; // (per container) required
      private final int calories; // (per serving) optional
      private final int fat; // (g/serving) optional
      private final int sodium; // (mg/serving) optional
      private final int carbohydrate; // (g/serving) optional
    
      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;
      }
    }

    (1) 초기화하고 싶은 필드만 포함한 생성자가 없다면, 설정하길 원치 않는 필드까지 매개변수에 값을 지정해줘야 한다.

    null을 넣는다거나, 초기 값을 넣는다거나...

    (2) 복잡하고 읽기 어렵다.
    위의 예시만 봐도 생성자들을 볼 때마다 각 값의 의미가 무엇인지 헷갈릴 것이고 매개변수가 몇 개인지, 뭘 써야 하는지 주의해야 한다. 타입이 같은 매개변수가 연달아 늘어서 있으면 찾기 어려운 버그로 이어질 수 있다(String 뒤에 또 String이 있어서 값의 순서가 변경된다거나...). 클라이언트가 실수로 매개변수의 순서를 바꿔 건네줘도 컴파일러는 알아채지 못하고, 결국 런타임에 엉뚱한 동작을 하게 된다.

    (3) 매개변수의 수가 많아질 경우 걷잡을 수 없게 된다(현실에서의 클래스들은 엄청난 양의 변수를 가지는 경우가 상당수이다).

     

    Java Beans

    NoArgsConstructor로 만든 후 setter로 해당 value를 설정하는 방법.

    public class NutritionFacts {
        private int servingSize = -1;
        private int servings = -1;
        private int calories = 0;
        private int fat = 0;
        private int sodium = 0;
        private int carbohydrate = 0;
    
        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 cocaCola = new NutritionFacts();
    cocaCola.setServingSize(240);
    cocaCola.setServings(8);
    cocaCola.setCalories(100);
    cocaCola.setSodium(35);
    cocaCola.setCarbohydrate(27);

    해당 방식의 문제점

    cocaCola의 영양 성분을 표기하는 과정을 보면 표기를 위해 set 메서드를 계속해서 호출하는 것을 볼 수 있다.

    완성이 되기 전까지 어느 곳에서든 사용할 수 있기 때문에 하나의 성분 표시가 여러 상태를 내포할 수 있다.

    ※ 불변성을 내포하지 않기 때문에 누군가가 쉽게 해당 성분 표기를 바꿔치기할 수 있다!

     

    또 다른 방법은? Builder Pattern 

    코드로 보자

    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 static class Builder {
        	// Required Parameters
            private final int servingSize;
            private final int servings;
    
            // Optional Parameters
            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 val) {
                calories = val;
                return this;
            }
    
            public Builder fat(int val) {
                fat = val;
                return this;
            }
    
            public Builder sodium(int val) {
                sodium = val;
                return this;
            }
    
            public Builder carbohydrate(int val) {
                carbohydrate = val;
                return this;
            }
    
            public NutritionFacts build() {
                return new NutritionFacts(this);
            }
        }
        
        private NutritionFacts(Builder builder) {
            servingSize = builder.servingSize;
            servings = builder.servings;
            calories = builder.calories;
            fat = builder.fat;
            sodium = builder.sodium;
            carbohydrate = builder.carbohydrate;
        }
    }
    
    // 사용 예
        NutritionFacts cocaCola = 
          new NutritionFacts.Builder(240, 8)
            .calories(100)
            .sodium(35)
            .carbohydrate(27)
            .build();

    (1) 빌더가 완성되고 .build()를 하기 전까지는 해당 성분 표기는 존재하지 않는다.

    (2) 빌더와 Private Constructor를 매핑하기 때문에 외부에서 해당 성분 표기에 대한 변경이 일어날 수 없다.

    (3) 불변으로 생성해 사용할 수 있다.

    ※ 보통 빌더를 만들 경우에는 만들고자 하는 클래스 내부에 static class로 생성한다.

    ※ Lombok에서도 builder를 지원한다.

    https://projectlombok.org/features/Builder

     

    계층 구조 클래스와 빌더 패턴

    • 각 계층의 클래스에 관련 빌더를 멤버로 정의한다.
    • 추상 클래스는 추상 빌더 생성.
    • 구현 클래스(concrete class)는 구현 빌더 생성.
    public abstract class Pizza{
       public enum Topping { HAM, MUSHROOM, ONION, PEPPER, SAUSAGE }
       final Set<Topping> toppings;
       
       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();
    
          protected abstract T self();
       }
    
       Pizza(Builder<?> builder) {
          toppings = builder.toppings.clone();
       }
    }

    취상위 클래스인 Pizza 클래스이다.

    추상화된 빌더 클래스가 존재하고, addTopping, build, self 메소드를 선언했다.

    Pizza.Builder 클래스는 재귀적 타입 한정을 이용하는 제네릭 타입이다. 

    self: 하위 클래스에서는 형변환 하지 않고도 메서드 연쇄를 지원하기 위한 메소드. self 타입이 없는 자바를 위한 이 우회 방법을 simulated self-type 관용구라 한다.

    public class NyPizza extends Pizza {
       public enum Size { SMALL, MEDIUM, LARGE }
       private final Size size;
    
       public static class Builder extends Pizza.Builder<Builder> {
          private final Size size;
    
          public Builder(Size size) {
             this.size = Objects.requireNonNull(size);
          }
    
          @Override public NyPizza build() {
             return new NyPizza(this);
          }
    
          @Override protected Builder self() { return this; }
       }
    
       private NyPizza(Builder builder) {
          super(builder);
          size = builder.size;
       }
    }
    
    public class Calzone extends Pizza {
       private final boolean sauceInside;
    
       public static class Builder extends Pizza.Builder<Builder> {
          private boolean sauceInside = false;
    
          public Builder sauceInside() {
             sauceInside = true;
             return this;
          }
    
          @Override public Calzone build() {
             return new Calzone(this);
          }
    
          @Override protected Builder self() { return this; }
       }
    
       private Calzone(Builder builder) {
          super(builder);
          sauceInside = builder.sauceInside;
       }
    }

     

    NyPizza.Builer는 NyPizza를 반환하고, Calzone.Builder는 Calzone를 반환하는 실제 구현 빌더들이 적용되었다.

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

    계층적 빌더를 사용하는 클라이언트 코드
    NYPizza pizza = new NYPizza.Builder(SMALL)
            .addTopping(SAUSAGE)
            .addTopping(ONION)
            .build();
    
    Calzone calzone = new Calzone.Builder()
            .addTopping(HAM)
            .sauceInside()
            .build();

    빌더를 이용하면 가변인수 매개변수를 여러 개 사용할 수 있다. addTopping 메서드가 이렇게 구현된 예다.
    빌더 하나로 여러 객체를 순회하면서 만들 수 있고, 빌더에 넘기는 매개변수에 따라 다른 객체를 만들 수도 있다.

    빌더 패턴의 단점

    객체를 만들려면 그에 앞서 빌더부터 만들어야 한다. 물론 빌더 생성 비용이 크진 않지만, 성능에 민감한 상황에서는 문제가 될 수 있다.

    점층적 생성자 패턴보다는 코드가 장황해 매개변수가 4개 이상은 되어야 값어치를 한다.

     

     

    정리

    Constructor와 Static Factory Method가 처리해야 할 매개변수가 많다변 빌더 패턴을 선택하자.

    특히, 매개변수 중 다수가 필수가 아니거나 같은 타입이면 더 쓰자.

    빌더는 점층적 생성자보다 클라이언트 코드를 이해하기 쉽고, 자바빈즈보다 훨씬 안전하다.

    (개인적으로는 일단 데이터 클래스 류는 최소화된 범위로 만들었다가, 커지는 순간 Lombok의 builder를 적용해서 사용하는 편이기는 하다.)

     

    댓글

Designed by Tistory.