02. 생성자에 매개변수가 많다면 빌더를 고려하라.
정적 팩토리와 생성자는 선택적 매개변수가 많을 때 대응하기 어렵다는 공통적인 제약이 있다.
꼭 내용이 들어가지 않아도 되는 선택적 매개변수가 많을 때 우리는 다음과 같은 선택지가 있다.
1. 점층적 생성자 패턴, telescoping constructor pattern
→ 필수 매개변수만 받는 생성자에서 선택 매개변수를 하나씩 추가해가면서 다수의 생성자를 만들어 사용하는 방식
public class NutritionsFacts_tcp {
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 NutritionsFacts_tcp(int servingSize, int servings) {
this(servingSize, servings, 0);
}
public NutritionsFacts_tcp(int servingSize, int servings, int calories) {
this(servingSize, servings, calories, 0);
}
public NutritionsFacts_tcp(int servingSize, int servings, int calories, int fat) {
this(servingSize, servings, calories, fat, 0);
}
public NutritionsFacts_tcp(int servingSize, int servings, int calories, int fat, int sodium) {
this(servingSize, servings, calories, fat, sodium, 0);
}
public NutritionsFacts_tcp(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. 자바빈즈 패턴, JavaBeans pattern
매개변수가 없는 생성자로 객체를 만들고 setter로 원하는 매개변수의 값을 설정한다.
public class Nutritions_javabeans {
private int servingSize; // (mL, 1회 제공량) 필수
private int servings; // (회, 총 n회 제공량) 필수
private int calories; // (1회 제공량당) 선택
private int fat; // (g/1회 제공량) 선택
private int sodium; // (mg/1회 제공량) 선택
private int carbohydrate; // (g/1회 제공량) 선택
public Nutritions_javabeans() {}
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; }
}
점층적 생성자 패턴에서 보이던 비슷비슷해보이는 생성자 더미들은 없지만 자바빈즈 패턴에는 심각한 단점이 있다.
단점
- 객체가 완전히 생성되기 전까지 일관성이 무너진 상태이다.
- 클래스를 불변으로 만들 수 없다.
- 객체 하나를 만들기 위해서 여러 메서드를 호출해야한다.
물론 이런 단점을 극복하고자 freezing이란 기법이 존재하지만 다루기 어렵다. 또한 freeze()가 쓰이고 있는지 컴파일러가 보증해줄 수 없기 때문에 런타임 오류에 취약하다.
3. 빌더 패턴, Builder pattern
(클라이언트는) 필수 매개변수만으로 생성자나 정적 팩토리를 호출하여 빌더 객체를 얻고, 이 빌더 객체가 제공하는 setter()로 원하는 선택 매개변수의 값을 빌더 객체에 지정한다. 마지막으로 Builder 객체로 타겟 객체를 생성하는 로직을 가진 build()로 원하던 객체를 얻는다.
public class Nutritions_builder {
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; // (mL, 1회 제공량) 필수
private final int servings; // (회, 총 n회 제공량) 필수
private int calories = 0; // (1회 제공량당) 선택
private int fat = 0; // (g/1회 제공량) 선택
private int sodium = 0; // (mg/1회 제공량) 선택
private int carbohydrate = 0; // (g/1회 제공량) 선택
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 Nutritions_builder build() {
return new Nutritions_builder(this);
}
}
private Nutritions_builder(Builder builder) {
servingSize = builder.servingSize;
servings = builder.servings;
calories = builder.calories;
fat = builder.fat;
sodium = builder.sodium;
carbohydrate = builder.carbohydrate;
}
}
// 클라이언트는 이렇게 가져다 쓸 수 있어요
Nutritions_builder newInstance = new Nutritions_builder.Builder(240, 8)
.calories(100)
.sodium(35)
.carbohydrate(27)
.build();
단점
- 객체를 만드려면 우선 빌더객체부터 만들어져야 한다.
→ 빌더 생성 비용이 크지는 않지만 성능에 민감한 상황에서는 문제가 될 수도 있다. - 코드가 장황해진다.
장점
- fluent
→ 메서드 호출이 흐르듯 연결된다~, method chaining을 의미 - 가독성이 좋다.
- 자바빈즈보다 안전하다.
- 빌더의 메서드를 여러번 사용함으로써 가변인수 매개변수를 여러 개 사용할 수 있다.
- 계층적으로 설계된 클래스와 함께 사용하기 좋다.
불변, immutable → 어떠한 변경도 허용하지 않는다.
불변식, invariant → 프로그램이 실행되는 동안, 혹은 정해진 기간 동안 반드시 만족해야하는 조건
→ 근데.. 계층적으로 설계된 클래스와 함께 사용하기 좋다. ......? 에? 뭔 말?
계층적으로 설계된 클래스란?
아하 계층적으로 설계되었다고 해서 뭔가 낯설게 느껴졌는데 사실 우리가 잘 아는 것이다. 상속을 이용하여 설계된 클래스 구조!
// 상위 클래스
public class Animal {
private String name;
public Animal(String name) {
this.name = name;
}
public void eat() {
System.out.println(name + " is eating.");
}
public String getName() {
return name;
}
}
// 하위 클래스
public class Dog extends Animal {
public Dog(String name) {
super(name);
}
public void bark() {
System.out.println(getName() + " is barking.");
}
}
// 또 다른 하위 클래스
public class Cat extends Animal {
public Cat(String name) {
super(name);
}
public void meow() {
System.out.println(getName() + " is meowing.");
}
}
// 추상 클래스
public abstract class Bird extends Animal {
public Bird(String name) {
super(name);
}
public abstract void fly();
}
// 인터페이스
public interface Pet {
void play();
}
// 인터페이스 구현
public class Parrot extends Bird implements Pet {
public Parrot(String name) {
super(name);
}
@Override
public void fly() {
System.out.println(getName() + " is flying.");
}
@Override
public void play() {
System.out.println(getName() + " is playing.");
}
}
이런 구조들이 되겠다.
그렇다면 계층적으로 설계된, 그러니까 상속을 이용해서 설계된 클래스들은 왜 빌더 패턴을 사용하기 좋다고 하는 걸까.
이펙티브 자바에 나와있는 예시는 다음과 같다.
public abstract class Pizza {
public enum Topping {
CHEESE, PEPPERONI, CLAMS, VEGGIE
}
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();
}
}
그리고 구현 클래스의 예제
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 = Objects.requireNonNull(size);
}
@Override
public NyPizza build() {
return new NyPizza(this);
}
@Override
protected Builder self() {
return this;
}
}
}
public class Calzone extends Pizza {
private final boolean sauceInside;
private Calzone(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
public Calzone build() {
return new Calzone(this);
}
@Override
protected Builder self() {
return this;
}
}
}
이렇게 사용했을 때 장점은 다음과 같다.
- 추상 클래스로 선언한 Builder의 선언에 따라 하위 클래스의 빌더가 정의한 build 메서드는 구체 하위 클래스를 반환한다. : Pizza가 아니라 NyPizza, Calzone를 반환한다는 뜻. → 공변환 타이핑(covariant return typing) 이렇게 되면 클라이언트는 형변환에 신경쓰지 않고 빌더를 사용할 수 있다.
- 추상 메서드 self를 더해서 하위 클래스에서 형변환하지 않고 메서드 연쇄를 지원할 수 있다.
- (내 생각) 상위 클래스, Pizza에서 피자에 모두 공통되게 들어가는 topping이라는 필드에 대해서 값을 셋팅해줄 수 있는 빌더 메서드를 지원함으로서 Pizza 구현 클래스들에게 공통적인 로직을 제공할 수 있다는 장점이 있을 것 같다.
개인적인 생각정리
나도 빌더를 많이 쓰지만, 무지성으로 롬복이 이끄는대로 사용했었는데..ㅎㅎㅋ 항상 빌더를 쓸까 정팩메로 쓸까 고민되는 지점이 휴먼 에러로 빌더에 특정 필드를 누락시켰을때, 값이 null로 지정되어도 개발할 당시에 내가 알 방법이 없어서 위험하다는 지점이었다. 그런데 이번에 빌더 클래스를 똑바로 읽고 나니까, 필수적으로 값이 지정되어야 하는 요소에 대해서 빌더 생성자의 파라미터에 값을 넣도록 할 수 있다는 것을 알게 되어서 이런식으로 쓰면 좀 더 편리하고 안전하게 빌더를 쓸 수 있을 것 같다. 그리고 값 셋팅해주는 메서드에 유효성 검증 로직을 넣을 수 있다는 점도…..
흠 그런데 마지막에 계층적 설계에서 빌더가 주는 장점을 아직은 공감을 못하겠다. 내 생각인 3번째는 내 생각이니까 그러려니하는데……아직 1,2번 장점은… 직접 겪게 되면 더 와닿을듯
만일 이런 예제가 생긴다면 여기에 다시 기록해두도록 하겠음….
[ 참고 ]
https://m.yes24.com/Goods/Detail/65551284
그리고... thanks to 약 500장의 책을 함께 끝낸 스터디원느님들......