Effective Java를 읽고 정리한 정리본입니다.

📌 Item 02 : 생성자에 매개변수가 많다면 빌더를 고려하라

이번 챕터에서는 선택적 매개변수가 많을 때 고려 해야 하는 사항으로 “빌더”를 제시하고 있다.

🫧 점층적 생성자 패턴

디자인 패턴의 일종으로, 필수 매개변수만 받는 생성자, 필수 매개변수와 선택 매개변수 1개를 받는 생성자, 선택 매개변수를 2개까지 받는 생성자… 형태로 선택 매개변수를 전부 다 받는 생성자까지 늘려가는 방식이다.

이러한 생성자의 단점은 다음과 같다.

  1. 사용자가 설정하기를 원치 않는 매개변수까지 포함해야 한다.
  2. 매개변수가 많아질수록 작성 & 읽기가 어렵다.

따라, 이를 보완하는 두 번째 대안인 자바 빈즈 패턴이 등장했다.

🫧 자바빈즈 패턴

매개변수가 없는 생성자로 객체를 만든 후, setter 메서드들을 호출해 원하는 매개변수의 값을 설정하는 방식이다.

자바빈즈 패턴을 사용한다면, 점층적 생성자 패턴의 단점이 확실하게 해결될 것이다. 인스턴스 생성이 쉬워지며, 그 결과 더 읽기 쉬운 코드가 탄생한다.

그러나, 자바빈즈 패턴에도 단점은 있다.

  1. 객체 하나를 만들기 위해 메서드를 여러 개 호출해야 한다.
  2. 일관성이 지켜지지 않는다.
  3. 클래스를 불변으로 만들 수 없다.
  4. 스레드 안전성을 위해 프로그래머의 추가 작업이 필요하다.

일관성이 지켜지지 않음으로써, 중간에 의도치 않게 중지된 경우 원하지 않는 객체가 생성될 수 있다.

이러한 단점을 완화하고자 생성이 끝난 객체를 수동으로 얼리고(freezing), 얼리기 전에는 사용할 수 없도록 하기도 한다.

그러나 컴파일러가 확인하기 어렵고, 다루기도 어렵기에 많이 사용하지는 않는다.

🫧 빌더 패턴

필수 매개변수는 생성자를 통해 빌더 객체를 얻은 후 선택 매개변수는 세터 메서드들로 설정하고 build 메서드를 호출하는 점층적 생성자 패턴의 안정성 + 자바빈즈 패턴의 가독성을 겸비한 패턴

✨ 실행 순서

  1. 필수 매개변수만으로 생성자 or 정적 패터리 호출
  2. 빌더 객체가 제공하는 일종의 세터 메서드들로 원하는 선택 매개변수 설정
  3. 매개변수가 없는 build 메서드를 호출해 대게 불변인 객체 얻기

예시

public class NutritionFacts {
  private final int servingSize;
  private final int servings;
  private final int calories;
  private final int fat;
  private final int sodium;

  private 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 = NutritionFacts.Builder(240, 8)
                          .calories(100)
                          .sodium(35)
                          .carbohydrate(27)
                          .build();

NutiritionFacts 내부의 구현 클래스인 Builder을 도트 연산자로 접근한다. 필수 매개변수의 값을 넣어주고, 도트 연산자를 통해 다시 메서드를 호출한다. return this를 통해 다시 Builder 자신을 반환함으로써 다른 메서드를 연쇄적으로 호출할 수 있는 것이다.

정리하자면 다음과 같다.

NutritionFacts 클래스는 불변이며, 빌더의 세터 메서드들은 빌더 자신을 반환하기 때문에 연쇄적으로 호출이 가능한 형태이다.

💡 이런 방식을 fluent API 또는 method chainig 이라 한다.

build()는 객체 생성이 완료됨과 동시에 NutritionFacts 객체를 만들어 주는 역할을 하는데, 이는 Builder 내에 구현 되어 있는 것이 아닌, NutritionFacts 내에 구현 되어 있다.

추가로, 빌더 패턴은 (파이썬 & 스칼라에 있는) 명명된 선택적 매개변수 (named optional parameters) 를 흉내낸 것이다. 이는 뒤에 자세하게 설명하겠다.

✨ 빌더 이점

  1. 불변 객체이다.
    • 일관성 유지
    • 자바빈즈 패턴의 단점 보완
  2. 계층적으로 설계된 클래스와 함께 쓰기 좋다.
  3. 가변인수 매개변수를 여러 개 선언할 수 있다.

아래 예시를 보면 2번과 3번 이점에 대해 더 잘 이해할 수 있을 것이다.

이 예시는 피자의 다양한 종류를 표현하는 계층 구조의 루트에 놓인 추상 클래스이다.

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();

    // 하위 클래스는 이 메서드를 재정의하여 this를 반환하도록 해야 한다.
    protected abstract T serlf();
  }
  Pizza(Builder<?> builder) {
    toppings = builder.toppings.clone();
  }
}

아래 코드는 Pizza의 하위 클래스로 구현해 둔 “뉴욕 피자”이다.

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

이를 사용하게 되면, 다음과 같은 코드가 나온다.

NyPizza pizza = new NyPizza.Builder(SMALL)
                  .addTopping(SAUSAGE)
                  .addTopping(ONION)
                  .build();

예시에서 볼 수 있듯, addTopping 메서드를 여러 개 선언해 주지 않았는데도 가변인수 매개변수를 여러 개 사용할 수 있다는 이점이 있다.

✨ 빌더 단점

  1. 생성 비용이 존재한다.
    • 생성 비용이 크지는 않지만, 성능에 민감한 상황에서는 문제가 발생할 수 있다.
  2. 코드가 장황하다.
    • 매개변수가 4개 이상 되어야 빛을 발한다.
    • 그러나 확장성을 생각하자.

생성자나 정적 팩터리가 처리해야 할 매개변수가 많다면 빌더 패턴을 선택하는 게 더 낫다. 그러므로 매개변수가 많다면 빌더를 고려하라.

📌 추가 공부 및 질문

🫧 명명된 선택적 매개변수 (named optional parameters)

🫧 객체 얼리기 (freezing)

🫧 도트 연산자

🫧 추상 클래스

🫧 자바의 접근 제어자

📌 참고 자료