이펙티브 자바 3/E - 2장 아이템 2

aaron.park·2020년 3월 2일
0
post-thumbnail

저번 포스팅에서 정적 팩터리 메서드 패턴에 대해서 정리했었다. 그러나 생성자와 정적 팩터리 메서드의 공통적인 제약이 하나 있는데, 그것은 선택적 매개변수가 많을 때 적절히 대응하기 어렵다는 것이다.

점층적 생성자 패턴

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

   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) {
      this(servingSize, servings, calories, fat, 0);
   }

   // ... 생략 .../
}

이 코드는 영양소 성분 중 필수인 매개변수와 선택적인 매개변수를 구분해서 받고 싶을 때, 점층적 생성자 패턴을 사용하는 코드이다. 지금에야 매개변수가 6개 밖에 없어서 그럭저럭 만들 수는 있지만, 실제 업무를 하다보면, 혹은 개발을 진행하다 보면 매개변수가 더욱 더 많아질 것이며, 그렇게되면 코드가 더욱 복잡해질 것이다.

자바 빈즈 패턴

이런 상황을 보완하기 위해서 자바 빈즈(JavaBeans) 패턴 사용을 고려해 볼 수도 있겠다. 자바빈즈 패턴은 매개변수가 없는 생성자로 객체를 만든 후, 세터 메서드를 호출해 원하는 매개변수의 값을 설정하는 방식이다.

public class NutritionFacts {
   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 NutritionFacts(int servingSize, int servings) {
      this.servingSize = servingSize;
      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(240, 8);
cocaCola.setCalories(100);
cocaCola.setCalories(35);
cocaCola.setCalories(27);

하지만 이러면 심각한 단점이 하나 있다. 바로 객체의 불변성을 보장하지 못한다는 것이다. 객체 생성이 한 번에 끝나지 않고, 원하는 값을 넣어주기 위해서는 set메서드를 연속해서 호출해야 한다. 원하는 값을 다 넣어주었다 하더라도, 이후에 악의적인 목적으로 set메서드를 호출해 값을 변조할 수도 있다. 결국 자바빈즈 패턴을 객체의 일관성을 크게 훼손하게 될 것이다. freeze메서드 등으로 통해 객체를 얼리는 방법도 있다고 하지만, 다루기 어려워 잘 쓰이지 않는다.

불변이란 어떤 객체에 대해서 어떠한 변경도 허용하지 않겠다는 뜻이다. 자바의 String 객체가 대표적인 불변 객체로, 한번 만들어지면 값이 절대 변경되지 않는 불변 객체이다. 흔히 생각하는 문자열 연산은 값을 변경하는 것이 아니라, 아예 새로 만들어 내는 것이다.
불변식은 반드시 만족해야 하는 조건으로, 가변이다(즉, 불변은 아니다). 리스트 객체를 예로 들면, size값은 항상 0보다 커야한다는 것이 리스트 객체의 불변식이다.

빌더 패턴

점층적 생성자 패턴의 안전성 장점과, 자바 빈즈 패턴의 가독성 장점을 모두 가져온 것이 바로 빌더 패턴(Builder pattern)이다. 빌더 패턴은 처음부터 만들고자 하는 객체가 아닌 그 객체의 빌더 객체를 만들어, 필요한 값을 넣고나서 마지막에 build 메서드를 통해 객체를 생성하는 패턴이다.

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 NutriFactsBuilder {
      // 필수 매개변수
      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 NutriFactsBuilder(int servingSize, int servings) {
         this.servingSize = servingSize;
         this.servings = servings;
      }

      public NutriFactsBuilder calories(int val) { calories = val; return this; }
      public NutriFactsBuilder fat(int val) { fat = val; return this; }
      public NutriFactsBuilder sodium(int val) { sodium = val; return this; }
      public NutriFactsBuilder carbohydrate(int val) { carbohydrate = val; return this; }
      public NutritionFacts build() { return new NutritionFacts(this);  }
   }

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

NutritionFacts 클래스는 불변이며, 모든 매개변수의 기본값을 한 곳에 모아 뒀다. 이 빌더의 Setter 메서드는 빌더 자신을 반환하기 때문에 연쇄적으로 호출할 수 있다(메서드 체이닝).

NutritionFacts cocaCola = new NutriFactsBuilder(240, 8)
      .calories(100).sodium(35).carbohydrate(30).build();

핵심을 짚기 위해 유효성 검사는 생략되어 있는데, 만약 유효성 검사를 넣을 경우(예를 들어, 0 이상, 5000이하의 값만 들어오게끔), Setter메서드에서 해당 로직을 검사하고, 조건에 맞지 않는다면 메시지를 담아 예외를 던지면 된다(IllegalArgumentException).

빌더 패턴에 대해서 의문을 던지는 사람이 있을지도 모르겠다.
"하나의 Builder를 가지고 필요할 때마다 값을 넣어주는 것이라면, 앞서 말한 JavaBeans 패턴과 다른게 뭐지? 필요할때마다 Set 메서드 호출하는 것이라면 차라리 귀찮게 Builder를 만들지 않고도 setter 메서드로 하는게 낫지 않나?"
빌더 패턴과 자바 빈즈 패턴의 가장 큰 차이점은 앞에서도 말한, 불변성에 있다. 자바 빈즈 패턴은 객체를 생성한 '', 값을 setter 메서드를 통해 넣는다. 그렇기에 객체 사용 도중 실수로, 혹은 악의적인 목적으로 setter 메서드를 통해 유효하지 않은 값이나 null값, 혹은 정확하지 않은 값이 들어갈 수 있다.
반면, 빌더 패턴은 객체 생성 '', 값을 setter 메서드를 통해 넣는다. 그리고 다 넣었다면 마지막에 build 메서드를 호출하여 객체를 생성한다. 그렇기 때문에 객체 사용 중에 값이 변경될 우려가 없으며, 불변성과 안정성이 올라간다. 당연하지만, 빌더 패턴 사용시에는 public setter 메서드를 선언해서는 안된다.

빌더 패턴 - 계층 클래스

빌더 패턴은 계층적으로 설계된 클래스와 함께 쓰기에 좋다. 각 계층 클래스에 관련 빌더를 멤버로 정의하고, 추상 클래스에는 추상 빌더를, 구현 클래스에는 구현 빌더를 갖게 한다.
다음은 Pizza라는 추상 클래스를 상속받는 NyPizza(뉴욕 피자)와 Calzone(칼조네 피자)의 클래스 구조도이다.

NyPizza는 size라는 값을 필수로 받아야 하며, Calzone는 sauseInside라는 부울 값을 통해 소스를 넣을지 말지 선택할 수 있다.

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.Builder 클래스는 제네릭 클래스이다(아직 제네릭에 익숙하지 않기 때문에 이 부분 이해가 좀 어려웠던 것 같다). 여기에 self라는 추상메서드를 더해 하위 클래스에서 형변환하지 않고도 메서드 체이닝이 가능하도록 하고 있다. self 타입이 없는 자바를 위해서 이런식으로 우회하고 있는데, 이를 시뮬레이트한 셀프 타입(simulated self-type) 관용구라고 한다.
생성자에서 clone() 메서드를 사용하고 있는데, 얕은 복사를 통해서 발생할 수 있는 문제를 방지하기 위해서이다. 이 내용은 책 아이템 50에서 다루고 있다.

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, Calzone)는 부모 클래스(Pizza)의 self메서드를 의무적으로 오버라이딩 해야 한다. 또 각 하위 클래스의 Builderbuild 메서드에서는 그 하위 클래스를 반환하도록 해야 한다(공변환 타이핑, 상위 클래스가 선언한 정의한 반환타입이 아닌, 하위 타입을 반환함). 이렇게 함으로서 클라이언트는 형변환에 신경쓰지 않고 빌더를 사용할 수 있다.

NyPizza pizza =  new NyPizza.Builder(SMALL)
      .addTopping(SAUSAGE).addTopping(ONION).build();
Calzone calzone = new Calzone.Builder()
      .addTopping(HAM).sauceInside().build();

이렇게 빌더 패턴은 여러모로 유용하게 사용할 수 있다. 빌더 패턴을 사용하려면 빌더부터 선언해야 한다는 단점이 있지만, 확장 가능성이 높은 객체에 대해서는 처음부터 빌더를 사용하는 것이 가장 좋을 듯 하다(Lombok라이브러리를 보통 많이 활용한다).

profile
애런 퐉의 블로그

0개의 댓글