Consider Builder if parameter so much in constructor

jiho·2021년 5월 17일
0

EffectiveJava

목록 보기
3/12

정적 팩토리 메서드와 생성자 둘 다 제약이 하나 있습니다. 선택적 매개변수가 많을 때 적절히 대응하기 어렵다는 것입니다.

빌더 패턴 이외의 방법들을 우선 살펴보겠습니다.

점층적 생성자 패턴

매개변수가 많을 경우, 프로그래머들은 점층적 생성자 패턴(telescoping constructor pattern)을 즐겨 사용했다. 필수 매개변수만 받는 생성자, 필수 매개변수와 선택 매개변수 1개를 받는 생성자, 선택 매개변수를 2개까지 받는 생성자, ... 이런 식으로 선택 매개변수를 모두 받는 생성자까지 늘려가는 방식입니다.

public class NutritionFacts {
	private final int servingSize;
    private final int servings;
    private final int calorise;
    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;
    }
}

이 클래스의 인스턴스를 만들려면 원하는 매개변수를 모두 포함한 생성자 중 가장 짧은 것을 골라 호출하면 된다. 하지만 코드 자체만 봐도 너무 많은 코드가 작성되고 성능적으로도 call stack이 불필요하게 많이 쌓임을 느낄 수 있습니다.

요약하면, 점층적 생성자 패턴은 쓸 수는 있지만, 매개변수 개수가 많이지면 클라이언트 코드를 작성하거나 읽기 어렵습니다.

자바 빈즈 패턴(JavaBeans Pattern)

이런 선택 매개변수가 많을 때 활용할 수 있는 두 번째 대안인 자바빈즈 패턴을 보겠습니다. 매개변수가 없는 생성자로 객체를 만든 후, Setter메서드들을 호출해 원하는 매개변수의 값을 설정하는 방식입니다. 이 방식은 단순한 DTO를 생성해서 사용할 때는 유용할 수도 있습니다.

public class NutritionFacts {
	private int servingSize = -1;
    private int servings = -1;
    private int calorise = 0;
    private int fat = 0;
    private int sodium = 0;
    private int carbohydrate = 0;
    
    public NutritionFacts() { }
    //Setter Methods
    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) { carbohydate = val; }
}

점층적 생성자 패턴에서 보이던 단점인 많은 코드들이 더이상 보이지않습니다. 더 읽기 쉬운 코드가 되었습니다.

하지만 심각한 단점이 있는데 자바빈즈패턴에서는 객체 하나를 만들려면 메서드를 여러 개 호출해야하고 객체가 완성되기 전까지는 일광성(consistency)를 보장할 수 없다.

생성자를 통해 객체를 생성할 경우, 검증 코드를 삽입함으로써 일관성을 보장할 수 있었지만 자바 빈즈 패턴을 사용하면 그러한 장점이 사라진 것입니다.

이러한 단점을 완화하고자 객체를 수동으로 Freezing해서 사용할 수 없도록 할 수 있습니다. 하지만 이 방법은 다루기 어려워서 실전에서는 거의 사용되지 않습니다. 이 방법을 쓴다고 하더라도 개발자가 freeze 메서드를 확실히 호출해줬는지 컴파일러를 통해 보장할 방법이 없습니다.

다행히 마지막 대안 어느정도 이러한 두 가지 방법의 단점 모두 극복해줍니다.

Builder Pattern

점층적 생성자 패턴의 안정성과 자바빈즈 패턴의 가독성을 겸비한 빌더 패턴이 있습니다. GoF 디자인 패턴 중 생성관련 패턴 중 하나입니다. 클라이언트는 필요한 객체를 직접 만드는 대신, 필수 매개변수만으로 생성자를 호출해 빌더 객체를 얻습니다. 그런 다음 빌더 객체가 제공하는 setter 메서드들로 원하는 선택 매개변수들을 설정합니다. 마지막으로 build 메서드를 호출해 우리가 필요한 객체를 얻는 것이 빌더 패턴입니다.

  1. create builder
  2. set member variable using setter of builder object
  3. build using build method of builder object

빌더는 생성할 클래스 안에 정적 내부 클래스로 만들어두는게 보통입니다.


public class NutritionFacts {
	private final int servingSize;
    private final int servings;
    private final int calorise;
    private final int fat; 
    private final int sodium; 
    private final int carbohydrate;
    
    public static class Builder {
      // 필수 매개변수
      private final int servingSize;
      private final int servings;

      // 선택 매개변수 - 기본값으로 초기화한다.
      private int calorise = 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 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) { carbohydate = val; }
      public NutritionFacts build() {
      	return new NutritionFacts(this);
      }
    }
    
    private NutritionFacts(Builder builder) {
        servingSize = builder.servingSize;
        servings = builder.servings;
        calorise = builder.calorise;
        fat = builder.fat; 
        sodium = builder.sodium; 
        carbohydrate = builder.carbohydrate;
    }
}

NutritionFacts 클래스는 불변이며, 모든 매개변수의 기본값들을 한곳에 모아뒀습니다. 빌더의 세터 메소드들은 빌더 자신을 반환하기때문에 연쇄적으로 호출할 수 있습니다. 이런 방식을 메서드 호출이 흐르듯 연결된다는 뜻으로 플루언트 API(Fluent API)혹은 메서드 연쇄(method chaining)이라 합니다. stream 관련 메소드들이 이런 형태의 API을 잘 사용하고 있는 예입니다.

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

이 클라이언트 코드는 쓰기 쉽고, 무엇보다도 읽기 쉽습니다. 빌더 패턴은 명명된 선택적 매개변수(named optional parameters)를 흉내낸 것입니다.

파이썬이나 Swift와 같은 현대적인 언어들은 언어 자체에서 이런 선택적 매개변수를 지원하고 있습니다.

build 메서드가 호출하는 생성자에서 여러 매개변수에 걸친 불변식(프로그램이 실행되는 동안, 혹은 정해진 기간동안 반드시 만족해야하는 조건)을 보장하려면 빌더로부터 매개변수를 복사한 후 해당 객체 필드들도 검사를 해야합니다. 만약 잘못된 필드가 존재한다면 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) {
        	topping.add(Objects.requireNonNull(topping)); // null check and add.
            return self();
        }
        
        abstract Pizza build();
        
        // 하위 클래스는 이 메서드를 재정의(overriding)하여
        // this를 반환하도록 해야한다.
        protected abstract T self();
    }
    
    Pizza(Builder<?> builder) {
    	toppings = builder.toppings.clone(); // builder에서 사용중인 toppings Set 내부에서 사용 중인 instance를 공유하기 때문에 clone으로 복사본을 생성해야한다.
        
    }
}

다소 코드가 어려워 보일 수 있습니다. 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;
        }
    }
    
    NYPizza(Builder builder) {
        super(builder);
        size = builder.size;
    }
}

NYPizza 클래스의 빌더가 정의한 build 메서드는 NYPizza를 반환하도록 선언했습니다.

하위 클래스의 메서드가 상위 클래스의 메서드가 정의한 타입이 아닌, 그 하위 타입을 반환하는 기능을 공변환 타이핑(covariant return typing)이라합니다. (Pizza가 아닌 NYPizza)

이 기능을 이용하면 클라이언트가 형변환에 신경쓰지않고도 빌더를 사용할 수 있습니다.


NYPizza pizza = new NYPizza.Builder(SMALL).addTopping(ONION).build();

봐왔듯이 빌더패턴은 상당히 유연합니다. 이러한 방식외에도 다양하게 활용가능합니다.

빌더 패턴의 단점

빌더 생성비용이 크지는 않지만 성능에 민감한 상황에서는 문제가 될 수 있습니다. 또한 점층적 생성자 패턴보다는 코드가 장황해서 매개변수가 4개 이상은 되야 값어치를 할 수 있습니다. 하지만 시간이 지날수록 매개변수가 많아지는 경향이 있음을 명심해야합니다.

빌더 패턴을 활용한 Java SE 의 Locale

Java 공식 API 중 Builder 패턴을 사용한 Class는 무엇일지 찾는 와중에 java.util.Locale 클래스와 java.util.LocaleBuilder를 발견했으니 유사하게 적용했는지 인터페이스 위주로 살펴보겠습니다.

Locale 클래스는 지리적, 정치적, 문화적 지역을 나타내는 정보입니다. 역할을 수행하기 위해 Locale을 필요로하는 작업을 Locale-Sensitive하다라고 불르며 사용자를 위한 정보를 재구성하기 위해 Locale을 필요로합니다. 예를들어, 숫자를 나타내는 것은 locale-sensitive한 작업입니다. 숫자는 사용자의 나라, 지역 혹은 문화의 관습에 따라 정해집니다.

Locale 객체는 다음과 같은 필드로 구성되어집니다.

language, script, country(region), variant, extension

각 요소에 대해 자세히는 설명하지 않겠습니다. 궁금하다면 해당 내용을 참조해보시길 바랍니다.

Locale같은 경우, 간편한 생성을 위해 paramter 3개까지는 public 생성자를 제공하는 것을 알 수 있습니다.

하지만 java.util.Locale.Builder를 정적 내부 클래스로 제공하며 좀더 복잡한 생성방식을 위해 Builder클래스를 지원하는 것 또한 살펴볼 수 있습니다.

profile
Scratch, Under the hood, Initial version analysis

0개의 댓글