[ITEM2] 생성자에 매개변수가 많다면 빌더를 고려하라

뚝딱이·2024년 1월 7일
0

이펙티브 자바

목록 보기
3/55
post-thumbnail

정적 팩터리 메서드와 생성자는 매개변수가 많을 때 적절히 대응하기 어렵다.

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

위와 같이 필드가 6개인 클래스가 존재한다고 하자. 이때 servingSize, servings는 필수항목이고, 나머지 4가지 필드는 선택항목이라고 할 때 정적 팰터리 메서드는 어떻게 사용할까?

점층적 생성자 패턴

바로 점층적 생성자 패턴을 사용할 수 있다. 필수 매개변수만을 받는 생성자를 만들고, 필수 생성매개변수와 선택 매개변수 하나를 받는 생성자를 만들고,..와 같은 식으로 점층적으로 생성자의 매개변수를 늘리는 패턴이다. 아래의 예제를 보면 더 이해가 쉽다.

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

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

위의 예제를 보면 필수 매개 변수인 servingSize, servings를 받는 생성자가 하나 있고 그 뒤어 선택 매개변수를 하나씩 늘려 받는 생성자들이 있는 것을 볼 수 있다. 이게 바로 점층적 생성자 패턴이다. 이를 통해 인스턴스를 만들려면 원하는 매개변수를 모두 포함한 생성자 중에 가장 짧은 것을 골라 호출하면 된다.

단점

하지만 위의 패턴도 단점이 존재한다. caloriesfat을 채우지 않고 sodium을 채운 인스턴스를 생성하고 싶을 때 채우기 싫어도 매개변수의 값을 지정해줘야한다. 또한 사용자 입장에서는 매개변수의 순서, 개수등을 모두 생각해야하므로 작성에 실수가 있을 수 있다. 예제에선 필드가 6개임에도 사용할 때 헷갈릴 수 있는 여지가 많은데, 필드가 더 많아진다면 어떻게 되겠는가? 같은 타입의 매개변수가 연달아 있다면, 찾기 어려운 버그로 이어질 수도 있다. 컴파일 타임에는 알아채지 못하고 런타임에 이상하게 동작할 것이다.

두번째 대안이 있다.

자바빈즈 패턴

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

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;

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

필수값에는 -1을 채워넣고 선택 값에는 0으로 미리 초기화를 해놓는 것이다. setter를 쓰면서 코드가 길어졌지만 인스턴스를 만들기 쉽고 읽기도 쉬워졌다.

단점

하지만 인스턴스를 생성해보면 치명적인 단점이 보인다.

        NutritionFacts nutritionFacts = new NutritionFacts();
        nutritionFacts.setCalories(12);
        nutritionFacts.setFat(1);
        nutritionFacts.setServings(10);
        nutritionFacts.setSodium(20);

위에서 보듯이 nutritionFacts 객체 하나를 위해 메서드가 4개나 호출되었다. 따라서 객체가 완전히 생성되기 전까지는 일관성이 무너진 상태에 놓인다.

또한, 현재 필수값 중 하나인 servingSize이 할당되지 않았는데도, 객체 생성이 잘 되고 있다. 원래는 이를 생성자에서 강제했어야했는데 그렇게 하지 못한 것이다. 하지만 이는 기본 생성자를 사용하지 않고 필수 매개변수들로만 채운 생성자를 사용함으로써 변경할 수 있다. 그래도 위의 단점들은 여전하다.

그리고 각 매개변수들이 유효한지 확인하는 것을 기존에는 생성자에서 검사해 일관성을 유지할 수 있었는데, setter를 사용하면서 불가능해졌다. 이로 인해 런타임시 나타나는 버그에 대한 디버깅도 쉽지 않아진다. 원래는 생성자만을 확인하면 되었는데, 이제는 모든 setter들을 모두 살펴봐야 하기 때문이다.

setter가 다 열려 있으면서 값의 변경이 쉬워진다. setter가 열려있을 때의 단점은 java 개발자라면 흔히 알고 있을 것이다. 내가 의도치 않은 변경에 취약해질 위험성이 매우 크다.

마지막으로 클래스를 불변으로 만들 수 없고 스레드 안전성을 얻기 위한 추가 작업이 필요하다. freeze 메서드를 통해 객체의 생성이 완료되기 전까지 사용할 수 없게 하는 방법이 있지만 까다로워 실제로 잘 쓰이지 않는다.

우리에겐 마지막 세번재 대안이 있다.

빌더 패턴

점층적 생성자 패턴의 안전성과 자바 빈즈 패턴의 가독성을 겸비한 패턴이다. 이제 클라이언트는 필요한 매개변수만으로 객체를 생성할 수 있다.

빌더 패턴에서는 필수 매개 변수로 이루어진 생성자로 빌더 객체를 얻고 빌더 객체가 제공하는 메서드를 통해 원하는 선택 매개변수들을 설정한다. 마지막으로 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;

    public static class Builder {
        // Required parameters
        private final int servingSize;
        private final int servings;

        // Optional parameters - initialized to default values
        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;
    }

    public static void main(String[] args) {
        NutritionFacts cocaCola = new NutritionFacts.Builder(240, 8)
                .calories(100).sodium(35).carbohydrate(27).build();
    }
}

눈여겨 봐야할 것은 Builder 클래스이다. 우선 필수 매개 변수들을 final로 선언해 생성자를 만든다.

        private final int servingSize;
        private final int servings;
        
        public Builder(int servingSize, int servings) {
            this.servingSize = servingSize;
            this.servings    = servings;
        }

그리고 선택 매개 변수에 대해서는 기본값을 설정해놓는다.

        private int calories      = 0;
        private int fat           = 0;
        private int sodium        = 0;
        private int carbohydrate  = 0;

이 선택 매개 변수들을 설정할 수 있는 메서드들을 정의한다.

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

일반적인 setter와는 메서드명도 다르고 return 값도 존재한다. 메서드명은 설정할 매개변수값으로 하고, 해당 매개변수를 설정 (calories = val)한 뒤, return this를 통해 Builder를 반환해 연쇄적으로 이 메서드들이 사용될 수 있도록 한다.

마무리로 값들을 설정한 Builder를 인스턴스로 만들어 반환해줘야 하므로 build() 메서드를 다음과 같이 정의하면 된다.

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

이때 주의할 점은 생성자가 Builder를 매개 변수로 받아 Builder의 각각의 필드 값을 자신의 필드값으로 받는다는 것이다.

    private NutritionFacts(Builder builder) {
        servingSize  = builder.servingSize;
        servings     = builder.servings;
        calories     = builder.calories;
        fat          = builder.fat;
        sodium       = builder.sodium;
        carbohydrate = builder.carbohydrate;
    }

이를 통해 원하는 값들을 모두 설정한 인스턴스를 만들 수 있다.

따라서 최종적으로 NutritionFacts cocaCola = new NutritionFacts.Builder(240, 8).calories(100).sodium(35).carbohydrate(27).build();의 형태로 사용할 수 있는 것이다.

만약 유효성 검사를 하고 싶다면 build()에서 호출한 생성자에서 유효성 검사를 하면된다.

불변(immutable 혹은 immutability)은 어떠한 변경도 허용하지 않는다는 뜻으로, 주로 변경을 허용하는 가변객체와 구분하는 용도로 쓰인다.
불변식(invariant)은 프로그램이 실행되는 동안, 혹은 정해진 기간 동안 반드시 만족해야하는 조건을 말한다. 다시 말해 변경을 허용할 수는 있으나, 주어진 조건 내에서만 허용한다는 뜻이다.

빌더 패턴은 계층적으로 설계된 클래스와 함께 쓰기 좋다. 추상 클래스는 추상 빌더를, 구체 클래스는 구체 빌더를 갖게 한다. 다음의 예제를 살펴보자.

import java.util.*;

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

        // Subclasses must override this method to return "this"
        protected abstract T self();
    }

    Pizza(Builder<?> builder) {
        toppings = builder.toppings.clone(); // See Item 50
    }
}

추상 클래스인 Pizza에 추상 빌더를 갖도록 했다. 이때 addTopping()을 통해 Topping을 연쇄적으로 추가할 수 있는데, 이를 위해선 구현하는 클래스에서 self() 메서드를 오버라이딩해 this를 반환하도록 해야한다.

import java.util.Objects;

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

    @Override public String toString() {
        return "New York Pizza with " + toppings;
    }
}

Pizza 클래스를 상속받은 NyPizza 클래스이다. addTopping을 연쇄적으로 사용하기 위해 self 메서드에서 this를 return한 것을 볼 수 있다.

public class Main {
    public static void main(String[] args) {
        NyPizza pizza = new NyPizza.Builder(NyPizza.Size.SMALL)
                .addTopping(Pizza.Topping.SAUSAGE).addTopping(Pizza.Topping.ONION).build();
    }
}

따라서 최종적으로 위와 같이 사용할 수 있다. 추상 클래스에서 보였던 addTopping이 구현클래스인 NyPizza에서도 연쇄적으로 잘 쓰이는 것을 볼 수 있다.

장점

빌더를 이용하면 매개변수를 여러개 사용할 수 있고, 각각의 매개변수가 타입이 같아도 이름을 통해 헷갈리지 않고 사용할 수 있다. 빌더는 빌더 하나로 여러 객체를 순회하며 만들 수 있고, 넘기는 매개변수에 따라 다른 객체를 만들 수도 있다. 객체마다 부여되는 특정 필드는 빌더가 알아서 채우도록 할 수도 있다.

단점

객체를 만들기 위해선 빌더부터 만들어야한다. 생성 비용이 크지 않으나, 성능에 민감하다면 문제가 될 수 있다.
점층적 생성자 패턴보다는 코드가 장황해 매개변수가 4개 이상일 때 값어치를 한다.

생성자나 정적 팩터리가 처리해야할 매개변수가 많다면 빌더 패턴을 선택하는게 더 낫다. 매개변수 중 다수가 필수가 아니거나, 같은 타입인 경우 특히 더 그렇다. 빌더는 점층적 생성자보다 클라이언트 코드를 읽고 쓰기가 훨씬 간결하고, 자바빈즈 보다 훨씬 안전하다.

출처

이펙티브 자바 3/E

profile
백엔드 개발자 지망생

0개의 댓글

관련 채용 정보