아이템 2. 생성자에 매개변수가 많다면 빌더를 고려하라.

객체를 생성하는 방법 (인스턴스화)

  1. public 생성자
  2. public static 팩토리 메소드

⇒ 아이템 1에서 다룬 내용.

근데 위 2가지 방법은 객체의 선택 필드가 많아질수록 적절히 대응하기 어렵다.

참고 : 필수, 선택 필드를 임의로 정하여 생성자, 정적 팩토리 메소드를 사용할 수 있다.

하나씩 예를 들어보자.

점층적 생성자 패턴 (telescoping constructor pattern)

public class NutritionFacts {
    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 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;
    }
}

위 객체에서 필수 변수 2개를 기점으로 생성자가 점층적으로 늘어나는 형태이다.

필수 필드를 제외하고 내가 셋팅하고 싶은 필드를 포함하는 생성자를 이용하면, 나머지 선택 필드는 점층적으로 호출되며 값들이 0으로 셋팅되는 방식이다.

원한다면 순서 상관없이 원하는 필드만을 셋팅하도록 생성자를 추가적으로 만들 수 있지만, 매개변수가 많아진다면 경우의 수가 너무 많아지므로 굉장히 비효율 적인 방식이다.

NutritionFacts cocaCola = new NutritionFacts(240, 8, 100, 0, 35, 27);

위에서 만든 생성자를 이용하여 객체를 만들었는데, 생성자 파라미터 각각의 숫자가 뭘 의미하는지 알 수가 없다;

⇒ 매개변수 개수가 많아질 수록 클라이언트 코드를 작성하거나 읽기 어렵다.

자바빈즈 패턴(JavaBeans pattern)

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

NutritionFacts cocaCola = new NutritionFacts();
cocaCola.setServingSize(240);
cocaCola.setServings(8);
cocaCola.setCalories(100);
cocaCola.setSodium(35);
cocaCola.setCarbohydrate(27);

점층적 생성자 패턴과 달리 어떤 필드값이 셋팅되는지 한눈에 알아볼 수 있다.

하지만 치명적인 단점이 있다.

  • 객체 하나를 만들기 위해 메서드를 여러개 호출해야 한다.
  • 객체가 완전히 생성되기 전까지는 일관성(consistency)이 무너진 상태에 놓인다.
  • ⇒ 위 예제라면 setCalories까지만 셋팅된 상태에서 NutritionFacts 객체를 사용하는 경우 sodium과 carbohydrate가 셋팅되지 않으므로 안정적이지 않음. ⇒ 버그 발생..
  • 불변 클래스로 만들지 못함. (setter가 있기 때문)
  • 스레드 안전성을 얻기 위해 추가적인 수고가 필요함. ⇒ 멀티스레드 환경에서는 locking 같은것이 필요함.

[참고]

객체를 수동으로 freezing하는 방식도 있지만 이 방법은 다루기 어려워서 실전에서는 거의 쓰이지 않는다고 함.

freeze concept은 다음과 같다.

public class Foo
{

    private int a;
    private int b;
    private boolean frozen;

    public int getA()
    {
        return this.a;
    }

    public int getB()
    {
        return this.b;
    }

    public synchronized void setA(final int a)
    {
        checkNotFrozen();
        this.a = a;
    }

    public synchronized void setB(final int b)
    {
        checkNotFrozen();
        this.b = b;
    }

    public boolean isFrozen()
    {
        return this.frozen;
    }

    public synchronized void freeze()
    {
        this.frozen = true;
    }

    private void checkNotFrozen()
    {
        if (this.frozen)
            throw new RuntimeException();
    }

}

Foo 클래스의 필드 a와 b를 세터로 호출할때, freeze가 됬는지 항상 확인한다. 객체를 수동으로 freeze하면 객체의 frozen필드가 true가 되고, 이후 필드 a, b 세터가 호출되면 런타임에러가 발생하게 된다.


빌더 패턴

위 점층적 생성자 패턴의 안전성과 자바빈즈 패턴의 가독성을 모두 가진 패턴이다.

우리는 보통 빌더 패턴을 Lombok을 통해 많이 사용해 왔는데, 아래는 직접 Builder를 구현한 코드이다.

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(int calories) {
            this.calories = calories;
        }

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

Builder 정적 내부 클래스로 사용하여, NutritionFacts 필드와 똑같이 셋팅한 뒤, 빌더 세터 메서드를 구현하는데, 세터 메서드가 빌더 자신을 반환하기 때문에 연쇄적으로 호출할 수 있다.

NutritionFacts cocaCola = new NutritionFacts.Builder(240, 8)
                                                            .calories(100)
                                                            .sodium(35)
                                                            .carbohydrate(27)
                                                            .build();

이 클라이언 코드는 쓰기도 쉽고 읽기도 쉬움.


[참고]

위 빌더 패턴은 파이썬 or 스칼라의 named optional parameters(명명된 선택적 매개변수)를 흉내낸 것이라고 볼 수 있다.

named optional parameters의 예

PrintOrderDetails(orderNum: 31, productName: "Red Mug", sellerName: "Gift Shop");

빌더 패턴은 계층적으로 설계된 클래스와 함께쓰기 좋다.

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

        protected abstract T self();
    }

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

Builder<T extends Builder<T>> : 재귀적 한정 타입 → 타입 매개변수 T는 Builder를 상속 받는 객체만 지정할 수 있다. → 결국 Builder 혹은 Builder를 상속받는 객체만 타입 매개변수로 지정 가능 → 재귀적이다

자바에는 self() 타입이 없다. 그래서 위와 같이 추상 메서드 self를 만들어서 하위 클래스에서 형변환 없이 메서드 체이닝을 지원하게 만든다. ⇒ simulated selft-type : 시뮬레이트한 셀프타입 관용구 라고 한다.

이제 이 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;
    }
}
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);
        }

        @Override
        protected Builder self() {
            return this;
        }
    }

    private Calzone(Builder builder) {
        super(builder);
        sauceInside = builder.sauceInside;
    }
}

다음과 같이 Pizza 추상클래스를 적절히 오버라이딩하여 미완성 설계도를 완성한다.

여기서 build와 self를 오버라이딩하였다. self()는 Builder를 반환하고, build()는 하위 클래스 생성자를 호출한다. 하위클래스의 생성자는 상위 클래스의 생성자를 호출하여 toppings를 셋팅하고, 하위 클래스 필드를 셋팅한다.

실제 사용 예는 아래와 같다.

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

Calzone calzone = new Calzone.Builder()
          .addTopping(HAM)
          .sauceInside()
          .build();

빌더 패턴의 장단점

장점

  • 유연하다.
  • 빌더 하나로 여러 객체를 순회하면서 만들 수 있다.
  • 빌더에 넘기는 매개변수에 따라 다른 객체를 만들 수 있다.
  • 객체마다 부여되는 일련번호와 같은 특정 필드는 빌더가 알아서 채우도록 할 수 있다.

단점

  • 객체를 만들기 위해 빌더 객체를 만들어야 한다.
  • 빌더 생성 비용이 크지는 않지만 성능에 민감한 상황에서 문제가 발생할 수 있따.
  • 점층적 생성자 패턴보다는 코드가 장황하기 때문에 매개변수가 4개 이상은 되야 값어치를 한다. (근데 API는 시간이 지날 수록 매개변수가 많아지는 상황이 많이 발생한다.)

Lombok을 통하면 훨씬 더 Simple해 질 수 있다.

@Data
@Builder(builderMethodName = "hiddenBuilder")
public class NutritionFacts {
    private final int servingSize;
    private final int servings;
    @Builder.Default private final int calories = 0;
    @Builder.Default private final int fat = 0;
    @Builder.Default private final int solium = 0;
    @Builder.Default private final int carbohydrate = 0;

    public static NutritionFactsBuilder builder(int servingsSize, int servings){
        return hiddenBuilder()
            .servingSize(servingsSzie)
            .servings(servings);
    }
}

위 방식은 일반적인 @Builder 방식과 다른데, 필드인 servingSize와 servings를 필수로 가지기 위한 커스텀 정적 팩토리 메서드를 만들고, 나머지 필드들은 롬복이 제공하는 builder 방식을 사용한다. ( 위 코드에서 Builder에서 설정한 Default값이 없으면 미리 지정한 값이 셋팅(0)이 된다.)

추가로, builderMethodName 을 지정하면 필수 값을 받지 않으면 생성이 되지 않도록 제약할 수 있다.

댓글

Designed by JB FACTORY