[이펙티브 자바] item 2 - 생성자에 매개변수가 많다면 빌더를 고려하라

2020. 8. 10. 13:06기술 서적/Effective Java 3∕E

Effective Java 3/E


정적 팩터리 메서드와 생성자는 선택적 매개변수가 많을 때 적절히 대응하기 어렵다는 공통점을 갖는다.

선택적 매개변수가 많을 때 활용할 수 있는 방법은 아래와 같다>

  • 점층적 생성자 패턴(telescoping constructor pattern)
  • 자바빈즈 패턴(JavaBeans pattern)
  • 빌더 패턴(Builder pattern)

 

 

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

필수 매개변수만 받는 생성자,

필수 매개변수와 선택적 매개변수 1개를 받는 생성자,

필수 매개변수와 선택적 매개변수를 2개까지 받는 생성자,

… 이러한 형태로 선택 매개변수를 전부 다 받는 생성자까지 늘려가는 방식

 

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

}

식품 포장의 영양정보를 표현하는 클래스를 점층적 생성자 패턴으로 만든 예시. 

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

NutritionFacts cocaCola = new NutritionFacts(240,  8, 100, 0, 35, 27);

인스턴스 생성 시 탄수화물(carbohydrate)값을 지정해서 만들려면, 탄수화물 값을 매개변수로 받는 생성자 중 가장 짧은 것인 마지막 생성자를 호출해야한다.

이 때, 지방(fat)은 기본값인 0이지만, 생성자에서 매개변수로 받고있기 때문에 0을 넘겨줘야만 한다.

만약 선택 매개변수 개수가 아주 많아진다면 클라이언트 코드를 작성할 때 각 값의 의미가 무엇인지 헷갈릴 수 있고, 매개변수가 몇 개 인지도 주의해서 세어야 할 것이다.

타입이 같은 매개변수를 연속적으로 받을 경우 버그를 잡기 어려워 질 수도 있다.

대신, 생성자에서 매개변수들이 유효한지 확인하면 이후에는 객체가 일관성을 유지할 수 있다.

 

 

자바빈즈 패턴(JavaBeans Pattern)

매개변수가 없는 생성자로 객체를 만든 후, 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() {
	}

	// setter
	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);

점층적 생성자 패턴과 달리, 기본값으로 설정할 매개변수인 지방(fat)을 지정하지 않아줘도 된다.

하지만, 객체 하나를 만들려면 setter 메서드를 여러 개 호출해야 하고

객체가 완전히 생성되기 전까지는 (필요한 매개변수들이 setter 메서드를 통해 모두 세팅되기 전까지는) 일관성(consistency)이 무너진상태에 놓이게 된다. 일관성이 무너지는 문제 때문에 자바빈즈 패턴에서는 클래스를 불변으로 만들 수 없으며, 스레드 안정성을 얻으려면 프로그래머가 추가 작업을 해야한다.

 

이를 보완하기 위해 생성이 끝난 객체를 수동으로 얼리고(freezing) 얼리기 전에는 사용할 수 없도록 하는 방법도 있지만 실전에서는 거의 쓰이지 않는다. 다루기 어렵고, 객체 사용 전 프로그래머가 freeze 메서드를 확실히 호출했는지를 컴파일러가 보증 할 방법이 없어서 런타임 오류에 취약하기 때문이다.

 

 

빌더 패턴(Builder pattern)

생성자 (혹은 정적 팩터리) 메서드를 호출해 필수 매개변수만으로 빌더 객체를 얻고

빌더 객체가 제공하는 일종의 setter 메서드들로 원하는 선택 매개변수들을 설정한 뒤

마지막으로 매개변수가 없는 build 메서드를 호출해 필요한 객체(보통은 불변)를 얻는 방식

public class NutritionFacts {

	// 빌더 패턴(Builder pattern)
	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 {
		// 필수 매개변수
		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;
	}

}

빌더 클래스는 보통 생성할 클래스 안에 정적 멤버 클래스로 만든다.

 

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

빌더의 세터 메서드들(calories, fat, sodium, carbohydrate)은 빌더 자신을 반환(return this)하기 때문에 연쇄적으로 호출이 가능하다.
이런 방식을 플루언트 API(fluent API) 혹은 메서드 연쇄(method chaining)라 한다.

매개변수를 모두 설정 한 후 build 메서드를 호출해야 객체가 생성되기 때문에 NutritionFacts 클래스는 불변이다.

 

빌더 패턴은 파이썬과 스칼라에 있는 명명된 선택적 매개변수(named optional parameters)를 흉내낸 것이다. (C#에도 있다.)

 

위의 코드에는 매개변수의 유효성 검사가 생략되었다. 빌더의 생성자와 메서드에서 입력 매개변수를 검사하고 build() 메서드가 호출하는 생성자에서 여러 매개변수에 따른 불변식(invariant)을 검사하면 된다. 공격에 대비해 불변식을 보장하려면, 빌더로부터 매개변수를 복사한 후 해당 객체 필드들도 검사해야한다. 잘못된 점을 발견하면 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();
        
		protected abstract T self();
	}

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

Builder에 시뮬레이트한 셀프 타입(simulated self-type) 관용구인 self() 추상 메서드를 넣어 자식 클래스에서 형변환 하지 않고도 메서드 체이닝을 할 수 있도록 지원했다.

 

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() {
			this.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;
	}
	
}

공변 반환 타이핑 (convariant return typing)

공변(convariant)은 함께 변한다는 의미

자식 클래스의 메서드가 부모 클래스 메서드 정의에서 지정한 리턴 타입(Pizza)이 아닌, 해당 타입의 하위 타입 (NyPizza, Calzone)을 반환하는 기능

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

Pizza.Builder.build()Pizza를 반환하지만,

NyPizza.Builder.build()NyPizza를 반환하고, Calzone.Builder.build()Calzone를 반환한다.

 

 

클래스 다이어그램으로 표현하면 위와 같다.

NyPizzaCalzone가 추상 클래스인 Pizza를 상속받는다.

그리고 NyPizza.BuilderCalzone.Builder는 부모 클래스로 Pizza.Builder를 상속받는다.

 

 

package item02;

import static item02.Pizza.Topping.*;
import static item02.NyPizza.Size.*;

public class MakingPizza {

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

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

}

다이어그램에서 ~toppings를 보면 여러개 있을 수도, 아예 없을 수도 있는(0..*) 가변 인수이다. (variable arguments)

addTopping()은 여러 번 호출될 수 있고, 호출 될 때 마다 매개변수를 하나의 필드인 EnumSet<Topping> toppings에 모아놓는다.

다른 방법으로는 각각을 적절한 메서드로 나눠 선언하면 된다. (addToppingHam, addToppingMushroom, ... 이런식으로 메서드를 개별로 추가하면 된다는 말인가?)

 

 


참고 자료