6 분 소요

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

   생성자와 정적 팩토리 메서드는 똑같은 제약이 하나 있다. 바로 선택적 매개변수가 많을 때 대응하게 어렵다는 것이다.

   아래와 같은 클래스가 있다고 생각해보자.

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회 제공량)       선택

    ...
}

   servingSize, servings 필드를 제외하고는 모두 선택 사항이고, 기본값은 0이다.

점층적 생성자(Telescoping constructor) 패턴

   위 클래스를 위한 생성자는 일반적으로 아래와 같이 구성할 수 있다.

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

   위와 같은 패턴을 점층적 생성자 패턴이라고 부른다. 이 패턴은 여러 생성자에서 발생하는 코드의 중복을 줄여주는 장점이 있다.

   그러나, 점측정 생성자 패턴은 몇 가지 문제가 있다.

매개변수가 많아질수록 코드를 작성하거나 읽기 어렵다

   첫째로, 매개변수 즉, 필드가 많아질수록 코드를 작성하거나 읽거나 사용하기 어렵다. 당장 위의 생성자를 사용한다고 생각해보자. 전부 int 타입이라 어디에 어떤 값을 넣어줘야 하는지 생성자 코드를 직접 살펴보지 않고는 통 알 수가 없다. 또한, 실수로 매개변수의 위치가 바뀌어 버린다면 런타임 시에 큰 버그가 될 여지도 너무 많다.

   물론 IDE를 사용하면 각 생성자의 매개변수 이름 등을 확인하는 도움을 주기 때문에 불편함이 일부 해소될 수 있으나, 어디까지나 IDE를 사용한다는(매개변수 이름을 제공하는 기능을 가진) 전제 하에 해소되는 것이며 이런 전제가 깔려야 한다는 것부터 이미 불편하다는 뜻이다.

의미 없는 매개변수를 굳이 넣어줄 때가 있다

   위 코드에서 servingSize, servings, fat 값만 설정하고 싶다고 생각해보자. 하지만 이 세 값만을 받는 생성자는 없다. 어쩔 수 없이 calories 매개변수를 포함하는 생성자를 사용해야 하는데, 당연히 해당 값의 설정은 원치 않으므로 0을 넣어줄 것이다. 이처럼, 사용하는 입장에서 굳이 신경써도 되지 않을 값을 설정해 주어야 하므로 불편하다.

자바 빈즈(JavaBeans) 패턴

   그렇다면 자바 표준인 자바 빈즈 패턴은 어떨까? 아래 코드는 자바 빈즈 패턴을 적용한 모습이다.

public class NutritionFacts {
    // 필드 (기본값이 있다면) 기본값으로 초기화된다.
    private int servingSize  = -1; // 필수; 기본값 없음
    private int servings     = -1; // 필수; 기본값 없음
    private int calories     = 0;
    private int fat          = 0;
    private int sodium       = 0;
    private int carbohydrate = 0;
    private boolean healthy;

    public NutritionFacts() { }

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

    public void setHealthy(boolean healthy) {
        this.healthy = healthy;
    }
}

   이 방법 역시 여러 불편함을 가질 수 있다.

객체의 일관성이 깨진다

   위 코드는 기본 생성자를 통해 객체를 생성한 후 setter 메서드로 값들을 지정해준다. 즉, 객체를 만들기 위해서는 여러 메서드의 호출이 따르며 객체가 완성되기 전에는 생성된 객체가 불완전함을 의미한다. 왜냐하면 필수 필드들이 초기화 되지 않은 상태로 객체가 생성되었기 때문이다. 이 값들을 setter 메서드로 설정해 주어야 하는데, 그 전에 실수로 해당 객체를 사용하는 코드가 들어가게 되면 버그로 이어질 수 있다.

   그렇다면 필수 값들만 생성자로 받은 후에 setter 메서드로 선택 값들을 설정하게 하면 어떨까? 필수 값들은 객체 생성 시에 초기화가 되고 나머지 필드는 어차피 옵션이니 선택적으로 setter 메서드를 호출해서 사용하면 문제가 없을 것으로 보여진다. 그러나 이 방법 또한 일부 상황에서는 부적절할 수 있다.

불변 객체로 설계할 수 없다

   불변 객체란, 객체가 한 번 생성되면 다시는 객체 내부 값을 변경할 수 없는 객체를 의미한다. 불변 객체는 값을 변경할 수 없으므로 여기저기 어디에서 호출되어도 매우 안전하며 특히 멀티 쓰레드 환경에서 큰 장점을 가진다. 하지만 setter 메서드가 있는 순간 불변은 깨져버린다. setter 메서드를 통해 객체 내부를 변경할 수 있기 때문이다.

   이런 여러 문제점들을 해소하기 위한 좋은 대안이 있다. 그것은 바로 빌더(Builder) 패턴이다.

빌더(Builder) 패턴

   빌더 패턴은 점층적 생성자 패턴의 단순함(그리고 일관성 있는 객체 생성)과 자바 빈즈 패턴의 유연성 및 가독성의 장점을 모두 취한 패턴이다. 빌더 패턴은 Builder 클래스를 추가로 만들어서 객체를 조립할 수 있게 하고 마지막에 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 final int carbohydrate;

    private NutritionFacts(Builder builder) {
        servingSize  = builder.servingSize;
        servings     = builder.servings;
        calories     = builder.calories;
        fat          = builder.fat;
        sodium       = builder.sodium;
        carbohydrate = builder.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; }

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

   첫번째로 눈여겨 볼 것은 Builder 클래스의 생성자이다. 이 생성자는 필수 값들을 매개변수로 받기 때문에 Builder 객체를 생성할 때 반드시 설정해 주어야 하므로 객체의 일관성을 보장할 수 있다.

   선택적 값들은 setter 메서드로 설정해준다. 빌더 패턴에서는 보통 메서드 이름에 ‘set’ 키워드를 사용하지 않고 필드의 이름을 그대로 사용하는 경우가 많지만, setter 메서드와 똑같은 기능을 수행한다. 특징은 반환 타입이 void가 아니라 Builder인 것인데 이를 통해 fluent API(또는 method chaining) 방식으로 빌더 패턴을 사용할 수 있게 된다. 즉, 다음과 같이 함수를 연쇄적으로 호출하여 가독성도 높여주며 사용하기도 쉬워진다.

NutritionFacts nutritionFacts = new NutritionFacts.Builder(10, 10)
        .calories(10)
        .fat(30)
        .sodium(5)
        .build();

   또한 빌더 패턴은 불변 객체로 사용 가능케 한다. 외부에서 NutritionFacts 클래스의 내부 값을 변경할 수 있는 방법이 전혀 없다. 모든 필드는 private 지시자로 지정되어 있어 외부에서 접근할 수 없고 setter 메서드가 있는 것도 아니기에 값도 변경할 수 없다. 심지어 final로 지정되어 있어 내부에서도 값을 변경할 수 없다. Builder 클래스를 통해서만 NutritionFacts 객체를 만들 수 있기 때문에 객체의 생성을 분리한 점에서 유지보수도 수월하다.

   하지만 이런 빌더 패턴도 장점만 가지고 있는 것은 아니다.

빌더 패턴의 단점

  1. 빌더 객체부터 만들어야 한다

       객체를 생성하기 위해 먼저 빌더 객체부터 만들어야 한다. 즉, 객체 생성을 위해 추가적으로 또 다른 객체를 생성해야 하므로 성능에 아주 민감한 상황에서는 사용하기 어려울 수 있다.

  2. 코드가 복잡하다

       빌더 코드를 보면 알겠지만 코드가 매우 장황하고 복잡하다. 필드가 많으면 많을수록 복잡하다. 애초에 빌더 패턴을 사용하기 위해서는 저런 복잡한 코드를 먼저 작성해야 하니 꽤나 번거롭다고 여길 수 있다.

       이 단점을 해소하기 위해 lombok@Builder 어노테이션을 사용하는 것도 방법이 될 수 있으나, 이 어노테이션이 만능은 아니다. @Builder 어노테이션이 만들어주는 빌더의 생성자는 매개변수가 없으므로 위의 상황처럼 필수 필드와 선택 필드를 구분할 수 없다. 또한, NutritionFacts 클래스에도 모든 필드를 포함하는 생성자를 자동으로 만들어버린다. (물론 이 문제는 @AllArgsConstructor 어노테이션을 통해 해결할 수 있긴 하다)

       하지만 딱히 필수, 선택 필드를 나눌 필요가 없는 상황에서는 @Builder 어노테이션은 정말 좋은 선택이 될 수 있다. 어노테이션 하나만으로 빌더 패턴을 구현할 수 있으니 너무 간결하다.


[참고]
백기선, inflearn 강의 이펙티브 자바 완벽 공략 1부
Joshua Bloch, 이펙티브 자바

<== Prev                                                Next ==>