본 글은 조슈아 블로크의 이펙티브 자바 개정 3판을 읽고 정리한 글 입니다.
정적 팩터리와 생성자의 단점은 매개변수가 많을 때 대응하기 어려움.
식품 포장의 영양 정보를 표현하는 클래스를 생각해보자.
영양정보는 1회 내용량, 총 n회 제공량, 1회 제공량 당 칼로리등 20개가 넘는 선택 항목으로 이루어져 있는데, 대부분의 선택 항목이 0이다.
이를 표현하는 정적 팩터리 혹은 생성자는 과거에는 점층적 생성자 패턴(telescoping costructor pattern)을 사용했다.
점층적 생성자 패턴이란?
필수 매개변수만 받는 생성자부터 선택 매개변수를 1개 ~ 전부 까지 늘려가는 방식
public class NutritionFacts {
private final int servingSize; // 필수
private final int servings; // 필수
private final int calories; // 선택
...(선택 상수 생략)
public NutritionFacts(int servingSize, int servings) {
this(servingSize, servings, 0);
}
public NutritionFacts(int servingSize, int servings, int calories) {
this(servingSize, servings, calories, 0);
}
}
점층적 생성자 패턴의 단점: 매개변수 개수가 많아지면, 클라이언트 코드를 작성하거나 읽기 어려움. -> 클라이언트가 실수로 매개변수의 순서를 바꿔 건네줘도 컴파일러는 알아채지 못하고, 런타임에 엉뚱한 동작을 할 수 있음.
자바 빈즈 패턴(JavaBeans pattern)
매개변수가 없는 생성자로 객체를 만든 후, 세터(setter) 메서드를 호출해 원하는 매개변수 값을 설정하는 방식
public class NutritionFacts {
private int servingSize = -1;
private int servings = -1;
private int calories = 0;
public NutritionFacts() { }
// 세터 메서드들
public void setServingSize(int val) { servingSize = val; }
public void setServings(int val) { servings = val; }
...(세터 메서드 생략)
}
자바빈즈의 단점: 객체 하나를 만들기 위해 메서드를 여러 개 호출해야 하며, 객체가 완전히 생성되기 전까지는 일관성이 무너진 상태이 된다.
- 왜 일관성이 무너질까? -> 점층적 생성자 패턴에서는 매개변수가 유효한지 생성자에서 확인하여 일관성을 유지했지만, 자바빈즈에는 존재하지 않는다.
일관성이 깨진 코드를 만들면 버그를 심은 코드와 버그 때문에 런타임에 문제를 겪는 코드가 멀리 떨어져 있어 디버깅이 쉽지 않음.
클래스를 불변으로 만들 수 없고, 스레드 안전성을 위해 추가 작업을 해야 함
불변 클래스: 그 인스턴스의 내부 값을 수정할 수 없는 클래스이다.
빌더 패턴(Builder Pattern)
클라이언트가 필요한 객체를 직접 만드는 대신, 필수 매개변수만으로 생성자를 호출해 빌더 객체를 얻고, 세터 메서드로 선택 매개변수를 설정하는 방식이다. 마지막으로 build 메서드를 호출해 필요한 객체를 얻는 방식
빌더는 클래스에서 정적 멤버 클래스로 만드는게 보통이다. NutritionFacts 클래스는 불변이며 매개변수의 기본값을 한 곳에 모아둔다.
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) {
this(servingSize, servings, 0, 0, 0, 0);
}
public NutritionFacts(int servingSize, int servings, int calories) {
this(servingSize, servings, calories, 0, 0, 0);
}
public NutritionFacts(int servingSize, int servings, int calories, int fat) {
this(servingSize, servings, calories, fat, 0, 0);
}
public NutritionFacts(int servingSize, int servings, int calories, int fat, int sodium) {
this(servingSize, servings, calories, fat, sodium, 0);
}
}
NutritionFacts cocaCola = new NutritionFacts.builder(240, 8)
.calories(100).sodium(35).carbohydrate(27).build();
}
위의 클라이언트 코드는 클래스를 사용하는데, 빌더 패턴은 명명된 선택적 매개변수(named optional parameters)를 흉내낸 것.
빌더 패턴은 계층적으로 설계된 클래스와 쓰기에 좋다. 추상 클래스는 추상 빌더, 구체 클래스(concrete class)는 구체 빌더를 갖게 하였음.
public abstract class Pizza {
public enum Topping {
HAM, MUSHROOM, ONION, PEPPER, SAUSAGE
}
final Set<Topping> toppings;
Pizza(Builder<?> builder) {
toppings = builder.toppings.clone();
}
abstract static class Builder<T extends Builder<T>> {
private EnumSet<Topping> toppings = EnumSet.noneOf(Topping.class);
public T addTopping(Topping topping) {
toppings.add(topping);
return self();
}
abstract Pizza build();
protected abstract T self();
}
}
Pizza.builder 클래스는 재귀적 타입 한정을 사용하는 제네릭 타입이고, 추상 메서드인 self를 더해 하위 클래스에서 형 변환없이 메서드 연쇄를 지원 가능하다 -> 이러한 우회 방법을 simulated self-type 관용구라고 한다.
Pizza의 하위 클래스가 2개 있는데, 뉴욕 피자, 칼초네 피자 클래스가 존재한다. 뉴욕 피자 클래스는 크기(size) 매개변수를 받고, 칼초네 피자는 소스 선택(sauceInside)하는 매개변수를 필수로 받는다.
public class NyPizza extends Pizza {
public enum Size {
SMALL, MEDIUM, LARGE
}
private final Size size; // 필수 매개변수
private NyPizza(Builder builder) {
super(builder);
size = builder.size;
}
public static class Builder extends Pizza.Builder<Builder> {
private final Size size;
public Builder(Size size) {
this.size = size;
}
@Override
NyPizza build() {
return new NyPizza(this);
}
@Override
protected Builder self() {
return this;
}
}
}
public class CalzonePizza extends Pizza {
private final boolean sauceInside; // 선택 매개변수
private CalzonePizza(Builder builder) {
super(builder);
sauceInside = builder.sauceInside;
}
public static class Builder extends Pizza.Builder<Builder> {
private boolean sauceInside = false;
public Builder sauceInside() {
sauceInside = true;
return this;
}
@Override
CalzonePizza build() {
return new CalzonePizza(this);
}
@Override
protected Builder self() {
return this;
}
}
}
각 하위 클래스의 빌더가 정의한 build 메서드는 구체 하위 클래스를 반환한다. 하위 클래스의 메서드가 상위 클래스의 메서드가 정의한 반환 타입이 아닌 그 하위 타입을 반환하는 기능을 공변 반환 타이핑(covariant return typing)이라 한다.
NyPizza nyPizza = new NyPizza.Builder(Size.SMALL) .addTopping(Topping.SAUSAGE) .addTopping(Topping.ONION) .build();
CalzonePizza calzonePizza = new CalzonePizza.Builder() .addTopping(Topping.HAM) .sauceInside() .build();
빌더 패턴을 호출하는 클라이언트 코드
빌더를 이용하면 가변인수(varargs) 매개변수를 여러 개 사용할 수 있다. 메서드를 여러 번 호출하고, 각각을 적절한 메서드로 나눠 선언 가능하며 호출때 넘겨진 매개변수를 하나의 필드로 모을 수 있다.
빌더 패턴의 단점: 객체를 만드려면 그에 앞서 빌더부터 만들어야 함. 성능에 민감한 상황에서는 문제가 될 수 있다. 매개변수가 4개 이상은 해야 점층적 생성자 패턴 이상의 값어치를 한다. 애초에 빌더로 시작하자.