아이템 2 생성자에 매개변수가 많다면 빌더를 고려하라
정적 팩터리와 생성자에는 선택적 매개변수가 많을 때 적절히 대응하기 어렵다는 공통제약이 있다.
보통 클래스용 생성자 혹은 정적 팩터리는 점층적 생성자 패턴, 자바빈즈 패턴 등을 사용하였다.
점층적 생성자 패턴 (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;
}
}
문제점
- 매개변수 갯수가 많을 때 가독성 저하: 각 생성자는 여러 개의 매개변수를 받으며, 매개변수 갯수가 늘어날수록 사용하기가 복잡해진.
- 의미가 불분명한 인자: 생성자를 호출할 때, 각 매개변수의 의미를 쉽게 파악할 수 없다. 예를 들어 new NutritionFacts(240, 8, 100, 2, 30, 20)은 무엇이 무엇인지 알기 어렵다.
자바빈즈 패턴 (JavaBeans Pattern)
자바빈즈 패턴은 기본 생성자를 사용하여 객체를 생성한 후, setter 메서드를 이용해 필드를 설정하는 방식이다.
public class NutritionFacts {
private int servingSize; // 선택
private int servings; // 선택
private int calories; // 선택
private int fat; // 선택
private int sodium; // 선택
private int carbohydrate; // 선택
// 기본 생성자
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; }
}
문제점
- 객체의 불완전 상태: 객체가 완전히 설정되기 전에 불완전한 상태로 존재할 수 있다. 이는 잠재적인 오류를 유발할 수 있다.
- 불변성 결여: setter 메서드를 사용하므로 객체는 변경 가능하다. 즉, 생성 후에도 필드 값이 변경될 수 있어 불변성을 보장하지 못한다.
빌더 패턴 (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;
}
// 빌더 객체에서 최종 NutritionFacts 객체를 생성하는 메서드
public NutritionFacts build() {
return new NutritionFacts(this);
}
}
// NutritionFacts 생성자: 빌더 객체를 받아서 필드 초기화
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();
장점
- 불변성 유지: 빌더 패턴은 객체 생성 후 필드 값을 변경할 수 없기 때문에 불변성을 보장한다.
- 필수 및 선택 필드 구분: 필수 매개변수는 빌더의 생성자로 받고, 선택 매개변수는 체이닝 메서드로 처리하여 각 필드의 설정을 유연하게 할 수 있.
- 가독성 향상: 매개변수의 의미를 명확하게 하고, 필요한 필드만 선택적으로 설정할 수 있기 때문에 가독성이 좋다.
- 유연성: 여러 단계의 필드 설정이 가능하며, 필요할 때만 특정 메서드를 호출해 선택 필드를 설정할 수 있다.
세 패턴의 비교
패턴 장점 단점
점층적 생성자 패턴 | - 불변성 보장 |
- 컴파일 타임에 잘못된 객체 생성 방지 | - 매개변수 개수가 많아지면 가독성 저하
- 인자의 의미 파악 어려움 | | 자바빈즈 패턴 | - 선택적인 필드 설정 가능
- 가독성 우수 | - 불완전 상태의 객체가 존재 가능
- 불변성 없음 | | 빌더 패턴 | - 불변성 보장
- 가독성 우수
- 필수 및 선택 필드 구분 가능 | - 다소 복잡한 구현 필요
- 성능 오버헤드가 약간 있음 |
계층적으로 설계된 클래스와 잘 어울리는 빌더 패턴
다음은 피자의 다양한 종류를 표현하는 계층구조의 루트에 놓인 추상 클래스다.
추상 클래스 Pizza
// 추상 클래스 Pizza: 피자를 나타내는 추상 클래스
public abstract class Pizza {
// 열거형 Topping: 피자에 추가할 수 있는 토핑 종류를 정의
public enum Topping { HAM, MUSHROOM, ONION, PEPPER, SAUSAGE }
// toppings 필드: 피자에 추가된 토핑들을 저장하는 불변 집합 (Set)
final Set<Topping> toppings;
// Pizza 클래스의 추상 빌더 클래스 정의
// 빌더 패턴을 사용하여 피자 객체를 생성할 수 있도록 함
abstract static class Builder<T extends Builder<T>> {
// EnumSet을 사용해 빈 토핑 목록을 생성 (열거형 Topping 타입의 집합)
EnumSet<Topping> toppings = EnumSet.noneOf(Topping.class);
// addTopping 메서드: 피자에 새로운 토핑을 추가
// 입력된 토핑이 null이면 예외 발생 (Objects.requireNonNull)
// 메서드 체이닝을 위해 self() 메서드를 호출하여 자신을 반환
public T addTopping(Topping topping) {
toppings.add(Objects.requireNonNull(topping));
return self();
}
// 추상 메서드 build(): 실제 피자 객체를 생성하는 메서드로 하위 클래스에서 구현 필요
abstract Pizza build();
// 추상 메서드 self(): 하위 클래스에서 자신(빌더)을 반환하도록 구현
// 메서드 체이닝을 위한 재귀적 제네릭 타입 지원
protected abstract T self();
}
// Pizza 클래스의 생성자: 빌더에서 전달된 토핑을 불변 집합으로 복사하여 초기화
// clone()을 사용하여 외부에서 토핑 집합을 수정할 수 없도록 함 (불변성 보장)
Pizza(Builder<?> builder) {
toppings = builder.toppings.clone(); // 불변 집합을 유지하기 위해 clone() 사용
}
}
추가 설명
A. Builder만 있었다면
제네릭이 없으면 모든 피자 종류를 지원할 수 없고, Builder 클래스의 메서드를 재사용하기 어렵다. 여러 피자 클래스에서 빌더를 공유할 수 없게 된다.
B. Builder<T> 만 있었다면
T가 제한이 없기 때문에, Builder<String>처럼 피자와 전혀 관계없는 타입을 사용할 수 있다. 이는 피자 빌더의 의도와 맞지 않으며, 타입 안전성을 보장하지 못한다.
C. Builder<T extends Builder>만 있었다면
빌더의 타입을 다른 빌더의 타입으로 제한할 수 있다. 그러나 NyPizza.Builder와 Calzone.Builder 같은 서로 다른 빌더 간의 메서드 체이닝이 여전히 혼란스러울 수 있다.
D.Builder<T extends Builder<T>>
이 구조는 빌더가 자신을 반환하도록 강제한다. 즉, NyPizza.Builder는 항상 NyPizza 객체를 반환하고, 다른 피자 빌더를 사용할 수 없게 된다. 이렇게 하면 메서드 체이닝이 자연스럽고 안전해지며, 타입 안전성을 극대화할 수 있다.
NyPizza
NyPizza는 뉴욕 스타일 피자를 나타내며, 추가적인 속성으로 사이즈를 정의하고 있다.
public class NyPizza extends Pizza {
public enum Size { SMALL, MEDIUM, LARGE } // 피자 사이즈 열거형
private final Size size;
// NyPizza용 빌더 클래스
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 + "로 토핑한 뉴욕 피자"; // 피자의 정보 출력
}
}
특징
- NyPizza.Builder는 Pizza.Builder를 상속받아 피자 토핑 기능을 확장하며, 뉴욕 피자의 추가 속성인 사이즈를 필수로 받는다.
- build() 메서드는 NyPizza 객체를 생성하고 반환한다.
- self() 메서드는 빌더 객체가 자기 자신을 반환하도록 하여, 메서드 체이닝이 가능하게 함.
Calzone
Calzone은 소스가 안에 들어가는 칼초네 피자를 나타내며, 소스를 피자 내부에 넣을지 여부를 설정할 수 있다.
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); // Calzone 객체 생성
}
@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 ? "안" : "바깥"); // 피자의 정보 출력
}
}
특징:
- Calzone.Builder는 Pizza.Builder를 상속받으며, 소스가 피자 내부에 들어가는지 여부를 설정할 수 있는 추가적인 기능을 제공한다.
- build() 메서드는 Calzone 객체를 반환한다.
- sauceInside() 메서드는 체이닝 방식으로 사용 가능하며, 소스를 피자 내부에 넣을지 여부를 설정함.
테스트 클래스 PizzaTest
public class PizzaTest {
public static void main(String[] args) {
// 빌더 패턴을 이용해 NyPizza 생성
NyPizza pizza = new NyPizza.Builder(NyPizza.Size.SMALL)
.addTopping(Pizza.Topping.SAUSAGE)
.addTopping(Pizza.Topping.ONION)
.build();
// 빌더 패턴을 이용해 Calzone 생성
Calzone calzone = new Calzone.Builder()
.addTopping(Pizza.Topping.HAM)
.sauceInside()
.build();
System.out.println(pizza);
System.out.println(calzone);
}
}
핵심 정리
생성자나 정적 팩터리가 처리해야 할 매개변수가 많다면 빌더 패턴을 선택하는 게 더 낫다. 매개변수 중 다수가 필수가 아니거나 같은 타입이면 특히 더 그렇다. 빌더는 점층적 생성자보다 클라이언트 코드를 읽고 쓰기가 훨씬 간결하고, 자바빈즈보다 훨씬 안전하다.
'Java' 카테고리의 다른 글
이펙티브 자바(Effective Java) 2장 - 아이템 6 불필요한 객체 생성을 피하라 (0) | 2024.12.03 |
---|---|
이펙티브 자바(Effective Java) 2장 - 아이템 5 자원을 직접 명시하지 말고 의존 객체 주입을 사용하라 (0) | 2024.12.03 |
이펙티브 자바(Effective Java) 2장 - 아이템 4 인스턴스화를 막으려거든 private 생성자를 사용하라 (0) | 2024.12.03 |
이펙티브 자바(Effective Java) 2장 - 아이템 3 private 생성자나 열거 타입으로 싱글턴임을 보증하라 (0) | 2024.12.03 |
이펙티브 자바(Effective Java) 2장 - 아이템 1 생성자 대신 정적 팩터리 메서드를 고려하라 (1) | 2024.12.03 |