Dev/Java

[Effective Java : Item 2] 생성자에 매개변수가 많다면 빌더를 고려하라

김세진 2024. 1. 19. 16:34
반응형

 

 

 

 

개요

 

생성자와 정적 팩터리 메서드에는 공통적인 제약이 하나 있는데, 바로 매개변수가 많을 경우 적절히 대응하기 힘들다는 점이다. 식품 포장의 영양정보를 표현하는 클래스가 있다고 가정하자. 이 클래스에는 다양한 영양 정보를 표시하기 위해 수많은 필드들이 있을 것이다. 만약 특정 식품 포장에 영양정보를 표현하기 위해 이 클래스를 사용한다면 대부분의 제품은 필수 값을 제외하고 선택 항목들의 값이 0이 될 것이다.

 

위와 같은 문제를 해결하기 위해 다음과 같은 대안이 있다.

 

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

 

빌더 패턴이 가장 권장되는 것처럼 제목에서부터 언급하지만, 그 이유를 알기 위해 순차적으로 살펴보겠다.

 


점층적 생성자 패턴(telescoping constructor 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 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;
	}
}

 

위는 필수 매개변수가 2개, 선택 매개변수가 4개인 영양정보를 점층적 생성자 패턴으로 나타낸 코드이다. 개발자는 원하는 매개변수를 모두 포함한 생성자 중 가장 짧은 것을 골라 호출하면 된다. 이처럼 생성자를 하나만 가지고 있을 때보다 유연해진 것을 알 수 있다. 하지만 매개변수가 조금만 많아져도 득보단 실이 많은 패턴이 될 수 있는데, 그 장단점은 아래와 같다.

 

장점

1. 매개변수가 적을 경우, 다른 패턴보다 코드가 간결하고 가독성이 좋다.

2. 매개변수가 유효한지 생성자에서만 확인된다면 일관성을 유지할 수 있다.

 

단점

1. 조건에 맞는 생성자 중 가장 짧은 것을 골라 사용해도 사용자가 설정하길 원치 않는 매개변수까지 포함할 경우, 모두 값을 지정해야 한다.

- 위의 예시에서 선택 항목으로 carbohydrate만 포함하여 만들고자 한다면 NutritionFacts(200, 8, 0, 0, 0, 24) 과 같이 나머지 선택 항목도 값을 0으로 지정해줘야 한다.

 

2. 매개변수 개수가 조금만 많아져도 코드를 작성하고 읽기가 금세 어려워진다.

3. 개발자가 실수로 매개변수의 순서를 바꿔 건네도 컴파일 단계에서 오류를 잡을 수 없다.

 

 

자바빈즈 패턴(JavaBeans pattern)

 

public class NutritionFacts {
	// 기본값이 있을 경우, 기본값으로 초기화
	private int servingSize 	= -1;	// 필수, 기본값 x
	private int servings 		= -1; 	// 필수, 기본값 x
	private int calories		= 0;
	private int fat				= 0;
	private int sodium			= 0;
	private int carbohydrate	= 0;
	
	public NutritionFacts() {}
    // setter 메서드들
	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; }
}

 

 

각각의 필드를 설정하기 위한 setter 메서드들을 구성한다. 그리고 객체를 생성하기 위해서 아래와 같이 사용하면 된다.

NutritionFacts cocaCola = new NutritionFacts();
cocaCola.setServingSize(240);
cocaCola.setServings(8);
cocaCola.setCalories(100);
cocaCola.setSodium(35);
cocaCola.setCarbohydrate(27);

 

원하는 매개 변수만 선택하여 지정할 수 있게 되었으므로 점층적 생성자 패턴보다 인스턴스 생성이 쉽고, 가독성이 좋아졌다. 하지만 치명적인 단점들 또한 존재한다. 해당 패턴의 장단점은 아래와 같다.

 

장점

1. 개체 속성을 간편하게 접근 및 변경이 가능하다.

2. 가독성이 좋다.

 

단점

1. 객체 하나를 만들기 위해 메서드를 여러 개 호출해야 한다.

2. 객체가 완전히 생성되기 전까진 일관성(consistency)이 무너진 상태에 놓인다.

3. 2번과 같은 이유 때문에 클래스를 불변으로 만들 수 없다.

 

필요한 매개변수들이 모두 설정되기 전에 객체가 사용될 우려가 있고, 이는 즉 일관성이 무너질 수 있음을 나타낸다. 이 문제는 생성자에 필수값들을 포함하여 생성하게끔 강제해서 어느 정도 예방할 수 있지만, 여전히 해당 객체에 필요한 선택 매개변수는 값이 설정되지 않을 수 있고 setter 메서드를 사용해야 하므로 불변으로 만들 수 없다는 문제가 남아있다.

 

위의 문제 또한 객체가 완전히 생성되었음을 알리는 freeze 메서드를 사용하여 해결할 수 있지만, 다루기도 어렵고 개발자가 freeze를 호출하였는지 컴파일러가 보증해주지 않아서 런타임 오류에 취약하므로 완전한 해결법이 아니다.

 

 

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

// 빌더의 setter 메서드들은 빌더 자신을 반환하기 때문에 연쇄 호출하여 값을 지정한다.
// 이를 플루언트 API(fluent API), 혹은 메서드 연쇄(method chaining)라 한다.
NutritionFacts cocaCola = new Builder(240, 8)
    .calories(100).sodium(35).carbohydrate(27).build();

 

필수 매개변수는 생성자(or 정적 팩터리 메서드)의 매개변수로 넣어 생성하게끔 강제한 뒤, 나머지 매개변수들은 setter 메서드들로 설정한다. 이후 build()를 호출해 원하는 값이 모두 설정된 객체를 얻는다. 이렇게 빌더 패턴을 사용하면 일관성이 유지되어 불변 객체를 생성할 수도 있고, 가독성 또한 좋은 것을 확인할 수 있다.

 

또한 빌더 패턴은 계층적으로 설계된 클래스와 함께 쓰기 좋은 패턴이다. 아래는 추상 클래스인 Pizza와 그 구현체인 NyPizza(뉴욕 피자), Calzone(칼초네 피자)의 코드이다. 뉴욕 피자는 크기(size), 칼초네 피자는 소스를 넣는지 여부(sauceInside)를 매개변수를 필수로 받게 되어 있다.

 

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 를 살펴보면, Builder의 "T"는 Builder<T>를 확장한 제네릭 타입을 나타낸다. 따라서 T는 자기 자신이나 자기 자신을 확장한 타입과만 비교할 수 있다는 제약이 생기는데, 이를 재귀적 타입 한정이라 한다.

 

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

 

코드를 살펴보면 추상 클래스의 build()에서 자신을 반환하지 않고, 각 하위 클래스의 build() 메서드에서 해당하는 구체 하위 클래스를 반환한다. 이처럼 하위 클래스의 메서드가 상위 클래스 메서드가 정의한 반환 타입이 아닌 그 하위 타입을 반환하는 기능을 공변 반환 타이핑(covariant return typing)이라 한다. 또한 부모 클래스가 아닌 자식 클래스에서 self() 메서드를 통해 자신을 반환하게 하여 자식의 Builder 를 반환하게 한다. 위 두 가지 기법을 통해 형변환에 신경 쓰지 않고도 빌더를 사용할 수 있게 된다.

 

지금까지 살펴본 빌더 패턴의 장단점을 정리하자면 아래와 같다.

 

장점

1. 일관성 및 불변성을 유지할 수 있다.

2. 매개변수가 많아도 가독성이 좋다.

3. 계층적 클래스에서 사용하기 편하다.

 

단점

1. 객체 생성을 위해 빌더부터 만들어야 한다.(빌더를 구현하기 위한 노력이 필요하다)

2. 빌더 생성 비용이 크지는 않지만 성능에 민감한 상황에서 문제가 될 수 있다.

3. 코드가 길어질 수 있어 매개변수가 4개 이상은 되어야 그 값어치를 한다.

 

 

 


정리

 

생성자(혹은 정적 팩터리 메서드)가 처리해야 할 매개변수가 많아질 경우 빌더 패턴을 고려하도록 하자. 점층적 생성자 패턴이나 자바빈즈 패턴을 활용할 수도 있지만, API는 시간이 지날수록 매개변수가 많아지는 경향이 있다. 개발중인 API가 그렇게 될 가능성이 농후해보인다면, 추후에 빌더 패턴으로 변환하는 과정이 더 까다로울 수 있으므로 시작부터 빌더 패턴을 선택하는 것이 현명할 수 있겠다.

 

 

 

 

반응형