Java/Effective Java

[이펙티브 자바] 아이템 02.생성자에 매개변수가 많다면 빌더를 고려하라.(Re)

메성 2020. 7. 19. 21:47
반응형

정적 팩터리와 생성자는 선택적 매개변수가 많을 때 적절히 대응하기가 어렵다는 점이 있다. 그럼 이런 선택적 매개변수가 많을 때 클래스는 어떤 식으로 대응을 할까?


점층적 생성자 패턴 사용

점층적 생성자 패턴은 필수 매개변수만 받는 생성자와 선택 매개변수를 받는 생성자 여러개를 모두 두어 클라이언트가 요구하는 사항에 따라 객체를 생성하는 패턴 방식이다.

public class NutritionFacts {
  private final int servingSize;  // (mL, 1회 제공량)     필수
  private final int servings;     // (회, 총 n회 제공량)  필수
  private final int calories;     // (1회 제공량당)       선택
  private final int fat;          // (g/1회 제공량)       선택
  private final int sodium;       // (mg/1회 제공량)      선택
  private final int carbohydrate; // (g/1회 제공량)       선택

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

  public static void main(String[] args) {
    NutritionFacts cocaCola =
      new NutritionFacts(240, 8, 100, 0, 35, 27);
  }

}

위에서 보는 바와 같이 필수 매개 변수인 servingSize와 servings는 객체 생성 시 무조건 매개변수 값으로 들어가고, 선택적 매개변수는 필수 매개 변수와 같이 사용하여 생성할 수 있는 경우의 수 만큼 생성자를 두는 것이다.

이 클래스의 인스턴스를 만들려면 원하는 매개변수를 모두 포함한 생성자 중 가장 짧은 것을 골라 호출하면 된다.

 

하지만 딱 보기에도 매개변수가 많아지면 많아질수록 클라이언트는 코드를 작성하거나 읽기 어렵게 될 것이다. 클라이언트가 코드를 보았을 때 내가 필요로 하는 생성자는 무엇이고, 매개변수의 수와 순서가 맞는지를 일일히 확인해야 한다는 것이다.


자바빈즈(JavaBeans) 패턴 사용

자바빈즈 패턴은 선택적 매개변수의 수가 많을 때 사용할 수 있는 패턴이다. 즉, 매개변수가 없는 기본 생성자를 만들고 Setter를 호출하여 원하는 매개변수들을 설정해주는 방식이다.

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() { }
  // Setters
  public void setServingSize(int val)  { servingSize = val; }
  public void setServings(int val)     { servings = val; }
  public void setCalories(int val)     { calories = val; }
  public void setFat(int val)          { fat = val; }
  public void setSodium(int val)       { sodium = val; }
  public void setCarbohydrate(int val) { carbohydrate = val; }

  public static void main(String[] args) {
    NutritionFacts cocaCola = new NutritionFacts();
    cocaCola.setServingSize(240);
    cocaCola.setServings(8);
    cocaCola.setCalories(100);
    cocaCola.setSodium(35);
    cocaCola.setCarbohydrate(27);
  }
}

점층적 생성자 패턴의 단점인 매개변수가 많아질수록 클라이언트가 어떤 생성자를 호출해야하는지에 대해서 해결했다는 것을 확인할 수 있다. 해당 패턴을 사용하는 클라이언트는 일단 기본 생성자를 만들고 추 후에 Setter를 통해서 매개 변수들을 선택하여 필드값에 값을 채우면 되는 것이다.

 

하지만 자바빈즈 패턴의 경우 심각한 단점을 안고 있는데, 바로 객체를 하나 만들기 위해서는 메서드를 여러 개 호출해야 하고, 객체가 완전히 생성되기 전에는 객체의 일관성이 무너진다는 것이다.

 

즉, 매개 변수가 없는 기본 생성자를 호출한 다음 1회 함수 호출로 객체의 생성을 끝낼 수가 없어 일시적으로 객체의 일관성이 깨지게 되는 것이다.

일관성이 깨진다?

  • 한 번 객체를 생성할 때, 그 객체가 변할 가능성이 있다는 것이다.
  • 즉, Setter 메소드에 의해서 각 필드값들이 변경될 가능성을 열어뒀다는 것이다.

점층적 생성자 패턴과 비교해보면, 점층적 생성자 패턴의 경우 필수 매개 변수가 유효한지를 생성자에서만 확인하면 되었으나, 자바빈 패턴은 그렇지 않다는 것이다.

또한, 일관성이 무너지는 문제로 인해 자바빈즈 패턴에서는 클래스를 불변으로 만들 수 없어 스레드의 안정성을 얻으려면 추가 작업이 필요하다.

불변 클래스

  • 불변 클래스는 인스턴스의 내부 값을 수정할 수 없는 클래스를 말하며, 불변 인스턴스에 저장된 정보는 고정되어 객체가 파괴되는 순간까지 절대 달라지지 않는 것이다.

불변식(invariant)

  • 프로그램이 실행되는 동안 혹은 정해진 기간 동안에는 반드시 만족해야하는 조건을 말한다. 다시 말해 변경을 허용할 수 있느나 주어진 조건 내에서만 허용한다는 것으로 말할 수 있다. 예를 들어 리스트의 크기는 0 이상이어야 하는데 만약 한순간에 음수 값이 된다면 불변식이 깨진 것이다.

빌더 패턴

점층적 생성자 패턴의 안전성과 자바빈즈 패턴의 가독성을 겸비한 패턴이 바로 빌더 패턴이다.

클라이언트는 필요한 객체를 직접 만드는 대신, 필수 매개변수 만으로 생성자를 호출하여 빌더 객체를 만든다. 그 후 빌더 객체가 제공하는 메서드들을 호출하여 선택 매개변수에 값을 준다. 그리고 마지막으로 매개변수가 없는 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;

  //Builder가 static class인 이유는 부모(NutritionFacts) 클래스의 생성여부와 상관없이 독립적으로 사용하기 위함.
  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 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;
  }

  public static void main(String[] args) {
    NutritionFacts cocaCola = new Builder(240, 8)
      .calories(100)
      .sodium(35)
      .carbohydrate(27)
            .build();
  }
}

NutritionFacts 클래스는 불변 클래스이고, 모든 매개변수의 기본 값들을 한 곳에 모아 두었고, Builder의 메서드들은 빌더 자신을 반환하기 때문에 연쇄적으로 호출이 가능하다.

main 함수에서 보이는 것처럼 클라이언트는 NutritionFacts를 생성하기에 코드를 사용하기 쉽고 읽기도 쉽다는 것을 알 수 있다.

빌더 패턴을 사용하면서 중요한 사항은 잘못된 매개변수를 입력할 수도 있으므로 각 메서드에서 유효성 검사를 진행하도록 해야하며, 외부 공격을 대비하기 위해서 빌더로 부터 매개 변수를 복사한 후 객체를 생성하기 전에 해당 객체 필드들도 검사해봐야 한다.

그리고 검사 후 잘못된 점을 발견할 시 어떤 매개변수가 잘못 되었는지 자세히 알려주는 메시지를 담아 IllegalArgumentException(부적절한 매개변수를 가진 메서드)을 던진다.


빌더 패턴은 계층적으로 설계된 클래스와 함께 쓰기에도 좋다.

각 계층의 클래스에 관련 빌더를 멤버로 정의하자. 추상 클래스는 추상 빌더를 멤버로 두고 구현 클래스는 구현 빌더를 멤버로 둘 수 있도록 하는 것이다.

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

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

  Pizza(Builder<?> builder) {
    toppings = builder.toppings.clone(); // 아이템 50 참조
  }
}

Pizza.Builder 클래스는 재귀적 타입 한정(Pizza.Builder의 타입은 Pizza를 상속받은 모든 Builder가 될 수 있다.)을 이용하는 제네릭 타입이다. 해당 소스에서는 제네릭 타입(T)을 가진 self 메서드를 이용해 하위 클래스에서 형변환 하지 않고도 메서드 연쇄를 지원할 수 있다.

 

재귀적 타입 한정 참고

 

[ Java] Java의 Generics

Java 언어에서 언어적으로 가장 이해하기 어렵고 제대로 사용하기가 어려운 개념이 Generics가 아닐까 싶다. 평소에 클래스나 인터페이스 설계 시 Generics를 자주 사용하긴 했지만 어떠한 계기로 인�

medium.com

 

이제 Pizza의 하위 클래스들을 살펴보자.

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

  @Override public String toString() {
    return toppings + "로 토핑한 뉴욕 피자";
  }
}

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

  @Override public String toString() {
    return String.format("%s로 토핑한 칼초네 피자 (소스는 %s에)",
                         toppings, sauceInside ? "안" : "바깥");
  }
}

public class PizzaTest {
  public static void main(String[] args) {
    NyPizza pizza = new NyPizza.Builder(SMALL)
      .addTopping(SAUSAGE).addTopping(ONION).build();
    Calzone calzone = new Calzone.Builder()
      .addTopping(HAM).sauceInside().build();

    System.out.println(pizza);
    System.out.println(calzone);
  }
}

각 하위 클래스의 빌더가 정의한 build 메서드는 하위 클래스의 구현 클래스를 반환하도록 선언하고 있다. 또한, 하위 클래스의 메서드가 상위 클래스의 메서드가 정의한 반환 타입이 아닌 하위 타입을 반환하는 기능을 covariant return typing이라 하는데, 결과적으로 이 기능을 이용하면 클라이언트가 형변환에 신경 쓰지 않고 빌더를 사용할 수 있다.


빌더 패턴은 빌더 객체에 넘기는 매개변수에 따라 다른 객체로 만들 수 있어 매우 유연하다는 장점을 가지고 있고, 객체가 생성되는 시점에 객체가 완성되므로 불변 클래스도 만들 수 있다.

하지만 빌더 패턴을 이용하여 객체를 만들려면 빌더 객체 부터 만들어야 한다는 단점이 있는데, 빌더 객체의 생성 비용이 크지는 않지만 성능상 이슈가 나타날 수 있기 때문이다.

또한, 점층적 생성 패턴 보다는 코드가 장황해서 매개변수가 4개 이상은 되어야 값어치가 있다. 하지만 실무에서 매개변수는 4개가 충분히 넘을 수 있다고 판단한다.

결과적으로, 빌더 패턴은 점층적 생성 패턴에 비해 클라이언트가 코드를 읽고 쓰기가 매우 간결하며 자바빈즈 패턴 보다 일관성 및 불변식 면에서 매우 안전하다는 것이다.


이펙티브 자바 Effective Java 3/E
국내도서
저자 : 조슈아 블로크(Joshua Bloch) / 이복연(개앞맵시)역
출판 : 인사이트 2018.11.01
상세보기
반응형