개발 공부 기록하기/01. JAVA & Kotlin

짧은 코멘트와 함께하는 이펙티브 자바) #2 생성자에 매개변수가 많다면 빌더를 고려하라

lannstark 2020. 9. 17. 08:31

짧은 코멘트

  1. 빌더 역시 굉장히 많이 사용하는 패턴이다. 점층적 생성자 패턴과 자바빈즈 패턴은 요즘 코드에서 찾아볼 수 없다.
  2. lombok의 @Builder를 사용하여 쉽게 구현할 수 있으며, accessLevel 역시 조정할 수 있다.
  3. lombok의 @Builder를 이용해 빌더를 여러개 만들때 빌더 클래스와 메소드 이름을 적절히 조정해줄 필요가 있다. 그렇지 않으면, 오류가 날 수 있는데 빌더 클래스가 동일한 이름으로 2개 이상 존재하기 때문이다.

빌더

정적 팩토리 메소드(이하 정적 팩토리)와 생성자에는 똑같은 제약이 하나 있다. 선택적 매개변수가 많을 때 적절히 대응하기 어렵다는 점이다. 이를 해결하기 위해서 점층적 생성자 패턴(telescoping constructor pattern)을 즐겨 사용했다.

점층적 생성자 패턴 예시

public class A {
  private int value1;
  private String value2;

  public A(int value1) {
    this.value1 = value1;
  }

  public A(int value1, String value2) {
    this.value1 = value1;
    this.value2 = value2;
  }
}

하지만 이런 생성자는 사용자가 설정하길 원치 않는 매개변수까지 포함하기 쉬운데 어쩔 수 없이 그런 매개변수에도 값을 지정해 주어야 한다. 때문에 매개변수 개수가 많아지면 클라이언트 코드를 작성하거나 읽기 어렵다.

두 번째 대안은 자바빈즈 패턴(JavaBeans pattern)이다 매개 변수가 없는 생성자로 객체를 만든 후, setter 메소드들을 호출해 원하는 매개변수의 값을 설정하는 방식이다

이 패턴은 심각한 단점을 가지고 있는데, 이 패턴을 사용하게 되었을 때 객체 하나를 만들려면 메소드를 여러 개 호출해야 하고, 객체가 완전히 생성되기 전까지는 일관성이 무너진 상태에 놓이게 된다. 또한 이때문에 클래스를 불변으로 만들 수 없다

세 번째 대안이 바로 안정성과 가독성을 겸비한 빌더 패턴(Builder pattern, GOF)이다 클라이언트는 필요한 객체를 직접 만드는 대신, 필수 매개변수만으로 생성자(혹은 정적 팩토리)를 호출해 빌더 객체를 얻는다 그런 다음 빌더 객체가 제공하는 일종의 setter 메소드들로 원하는 선택 매개변수들을 설정한다

빌더의 setter 메소드들은 빌더 자신을 반환하기 때문에 연쇄적으로 호출할 수 있다. 이런 방식을 메소드 호출이 흐르듯 연결된다라는 뜻으로 fluent API 혹은 method chaining이라고 한다

  • 빌더 패턴은 파이썬과 스칼라에 있는 명명된 선택적 매개변수를 흉내낸 것이다

TODO : 생성자와 메소드에서 입력 매개변수를 검사하고 build 메소드가 호출하는 생성자에서 여러 매개변수에 걸친 불변식을 검사하자. 어떤 매개변수가 잘못되었는지를 자세히 알려주는 메시지를 담아 IllegalArgumentException을 던지면 된다

불변식(invariant)은 프로그램이 실행되는 동안, 혹은 정해진 기간 동안 반드시 만족해야 하는 조건을 말한다

가변 객체에도 불변식은 존재할 수 있으며, 넓게 보면 불변은 불변식의 극단적인 예라고 할 수 있다

빌더 패턴은 계층적으로 설계된 클래스와 함께 사용하기에 좋다 재귀적 타입 한정을 이용하는 제네릭 타입과 추상 메소드 self를 활용할 수 있다.

빌더 패턴의 간단한 예시와, 계층적으로 설계된 클래스에서의 예시

public class ClassA {
  private final int A;
  private final int B;

  /* 빌더 클래스 */
  public static class Builder {
    // 필수 매개변수
    private final int A;

    // 선택 매개변수와 기본값
    private int B = 0;

    public Builder(int A) {
      this.A = A;
    }

    public Builder B(int B) {
      this.B = B;
      return this;
    }

    public ClassA build() {
      return new ClassA(this);
    }
  }

  /* 빌더 클래스를 이용한 private 생성자 */
  private ClassA(Builder builder) {
    this.A = builder.A;
    this.B = builder.B;
  }

}
public abstract class Pizza {
  public enum Topping { HAM, ONION, PEPPER }
  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 self();
  }

  Pizza (<Builder<?> builder) {
    toppings = builder.toppings.clone();
  }
}

/* Pizza를 상속받아 구현한 NyPizza */
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;
  }
}

공변 반환 타이핑(covariant return typing) : 하위 클래스의 메소드가 상위 클래스의 메소드가 정의한 반환 타입이 아닌, 그 하위 타입을 반환하는 기능

빌더 패턴에도 단점이 있다. 객체를 만들려면, 그에 앞서 빌더부터 만들어야 한다. 빌더 생성 비용이 크지는 않지만 성능에 민감한 상황에서는 문제가 될 수 있다 또한 코드가 장황해서 매개변수가 4개 이상은 되어야 값어치를 한다 (lombok의 @Builder를 사용하면 극복할 수 있다)

핵심

생성자나 정적 팩토리가 처리해야할 매개변수가 많다면 빌더 패턴을 선택하는 게 더 낫다