독서

[이펙티브 자바] 아이템 02. 생성자에 매개변수가 많다면 빌더를 고려하라

오렌지색 귤 2022. 1. 18. 19:09
반응형

Start Quiz

 

질문 1. 생성자와 아이템 1에서 다룬 정적 팩터리 중에서 선택적 매개변수가 많을 때 적절히 대응할 수 있는 방식은?

 

 

 

 

 

정답

 

둘 다 적절히 대응하기 어렵습니다. 선택적 매개변수가 많을 때는 빌더를 고려해야 합니다.

 

 

본 내용

 

문제 상황

 

매개변수가 많은 클래스를 생성하고 싶다!

 

해결책 1. 점층적 생성자 패턴 (not the best)

 

선택적 매개변수가 많은 경우, 프로그래머들은 점층적 생성자 패턴(telescoping constructor pattern)을 즐겨 사용했다.

 

아래의 코드는 점층적 생성자 패턴의 책 내 예시를 변형한 코드이다.

 

public class NutritionFacts {
        private final int servingSize;  // 필수
        private final int servings;  // 필수
        private final int calories;  // 선택
        private final int fat;  // 선택

        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 = servingSize;
                this.servings = servings;
                this.calories = calories;
                this.fat = fat;
        }
}

 

해당 예시에서는 매개변수가 6개 뿐이지만, 매개변수 개수가 많으지면 코드를 작성하거나 읽기 어려워 실수를 유발하게 된다.

 

해결책 2. 자바빈즈 패턴 (not the best)

 

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

 

아래의 코드는 자바빈즈 패턴의 책 내 예시를 변형한 코드이다.

 

public class NutritionFacts {
        private int servingSize = -1;  // 필수
        private int servings = -1;  // 필수
        private int calories = 0;
        private int fat = 0;

        public NutritionFacts() { }

        public void setServingSize(int val) { servingSize = val; }
        public void setServings(int val) { servings = val; }
        public void setCalories(int val) { calories = val; }
        public void setFat(int val) { fat = val; }
}

 

코드가 길어지긴 했지만 인스턴스를 만들기 쉽고, 더 읽기 쉬운 코드가 되긴 했다.

 

NutritionFacts cocaCola = new NutritionFacts();
cocaCola.setServingSize(240);
cocaCola.setServings(8);
cocaCola.setCalories(100);

 

다만, 자바빈즈 패턴에서는 객체 하나를 만들려면 위의 코드와 같이 Setter 메서드를 여러번 호출해야 하고, 객체가 완전히 생성되기 전가지는 일관성(consistency)이 무너진 상태에 놓이게 된다.

 

점층적 생성자 패턴에서는 매개변수들이 유효한지를 생성자에서만 확인하면 일관성을 유지할 수 있었는데, 이 패턴에서는 불가능하다.

 

이런 문제로 인해 자바빈즈 패턴에서는 클래스를 불변으로 만들 수 없고, 스레드 안전성을 얻으려면 추가 작업이 필요하다.

 

해결책 3. freezing (not the best)

 

자바빈즈 패턴에서 객체가 생성되는 과정에 일관성이 무너지는 것을 완화하고자 생성이 끝난 객체를 수동으로 '얼리고(freezing)', 얼리기 전에는 사용할 수 없도록 한다.

 

다만 이 방법은 다루기 어려워 실전에서 거의 쓰이지 않으며, 컴파일러가 프로그래머의 freeze 메서드 사용을 검사해주지 못해 런타임 오류에 취약하다.

 

freezing 이란 무엇인가?

"얼린다"는 표현은 많은 예외를 던진다는 뜻입니다.

  1. 객체가 생성되는 과정에서 getter나 다른 메서드로 부터 객체를 읽으려는 시도가 있을 때, "객체 생성 중입니다"와 같은 예외를 던집니다.
  2. 객체가 완성되고 '얼려진' 후에는 setter나 다른 메서드로 부터 객체의 상태를 변경하려는 시도가 있을 때, "we are frozen"과 같은 예외를 던집니다.


    참고 StackOverFlow

 

해결책 4. 빌더 패턴 (best)

 

빌더 패턴은 점층적 생성자 패턴의 안전성과 자바빈즈 패턴의 가독성을 겸비했다.


클라이언트는 필수 매개변수만으로 빌더 객체를 얻고, setter 메서드로 선택 매개변수를 설정하고, 매개변수가 없는 build 메서드를 호출해 (보통은 불변인) 객체를 얻는다.

 

아래 코드는 책 내 예시를 변형한 코드이다.

 

// 불변 클래스
public class NutritionFacts {
        private final int servingSize;
        private final int servings;
        private final int calories;
        private final int fat;

        public static class Builder {
                // 필수 매개변수
                private final int servingSize;
                private final int servings;

                // 선택 매개변수 - 기본값으로 초기화
                private int calories = 0;
                private int fat = 0;

                public Builder(int servingSize, 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 NutritionFacts build() {
                        return new NutritionFacts(this);
                }
        }

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

 

아래의 클라이언트 코드를 보자. 빌더의 setter 메서드들은 빌더 자신을 반환하기 때문에 연쇄적으로 호출할 수 있으며, 이러한 방식을 fluent API 혹은 메서드 연쇄(method chaining)이라 한다.

 

NutritionFacts cocaCola = new NutritionFacts.Builder(240, 8)
                .calories(100)
                .build();

 

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

 

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


각 계층의 클래스에 관련 빌더를 멤버로 정의하는데, 추상 클래스는 추상 빌더를, 구체 클래스는 구체 빌더를 갖도록 하자.

 

아래 코드는 추상 클래스와 구체 클래스의 책 내 예시이다.

 

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

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

 

다음의 코드를 클라이언트가 작성한다고 생각해보자

 

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

 

우선 구체 클래스인 NyPizza 클래스 내의 내부 클래스인 Builder 객체가 만들어진다.


이 빌더의 생성자는 Size 매개변수를 필요로 하는데, 상속되면서 새로이 생긴 필드 값이기 때문이다.


NyPizza 클래스의 Builder 객체가 만들어진 상태에서 addTopping() 메서드를 호출하면 구체 클래스에서 해당 메서드가 있는지 찾게 된다.


찾을 수 없는 관계로 상위 메서드인 Pizza 클래스에서 이 메서드를 찾게 된다.


이것이 가능한 것은 NyPizza가 Pizza를 상속했을 뿐만 아니라 NyPizza의 Builder 또한 Pizza 의 Builder를 상속받고 있기 때문이다.


Pizza의 Builder는 토핑을 넣은 다음 return self();로 자기 자신을 반환하려 하는데, 이때 실제로 반환되는 것은 Pizza의 Builder가 아니라 NyPizza의 Builder이다.


왜 그런 것일까?


그것은 하위 클래스인 NyPizza의 Builder에서 self 메서드를 오버라이딩해서 자신을 반환하도록 했기 때문이다!


결국 한번의 addTopping 이후 반환되는 것은 NyPizza의 Builder이고, 이 상황이 계속 반복되다가 build가 호출된다.


NyPizza의 Builder에서 build 메서드가 호출되면 자기 자신(Builder)를 NyPizza의 생성자에 제공한다.


NyPizza의 생성자에서는 super(builder)를 통해 Pizza의 빌더가 가지고 있던 토핑들을 Pizza 필드 내의 Set에 저장하게 되며, 다음 코드인 size = builder.size; 에서는 NyPizza의 빌더에 저장되어 있던 size가 생성자에게 전달되면서 불변 객체가 만들어진다.

 

생성자로는 누릴 수 없는 사소한 이점

 

상단에 있는 addTopping 메서드는 메서드를 여러 번 호출하도록 하고 각 호출 때 넘겨진 매개변수들을 하나의 필드로 모은 방식이다.


반면에, 아래와 같이 빌더를 이용하면 가변인수(varargs) 매개변수를 여러개 사용할 수 있다.

 

아래의 코드는 StackOverFlow를 참고 했습니다.

 

builder
    .withOptions("a", "b", "c")   // varargs
    .withColors("red", "blue")    // more varargs
    .build();

 

빌더 패턴은 상당히 유연합니다.


빌더 하나로 여러 객체를 순회하면서 만들 수 있고, 빌더에 넘기는 매개변수에 따라 다른 객체를 만들 수도 있습니다.


객체마다 부여되는 일련번호와 같은 특정 필드는 빌더가 알아서 채우도록 할 수도 있습니다.

 

이러한 장점에도 불구하고 빌더 패턴에도 몇가지 단점은 존재합니다.

 

단점 1. 객체를 만들려면 빌더부터 만들어야 한다

 

빌더 생성 비용이 크지는 않지만 성능에 민감한 상황에서는 문제가 된다

 

단점 2. 점층적 생성자 패턴보다는 코드가 장황해서 매개변수가 4개 이상은 되어야 값어치를 한다

 

하지만 API는 시간이 지날수록 매개변수가 많아지는 경향이 있으므로 애초에 빌더로 시작하는 편이 나을 때가 많다.

 

 

 

End Quiz

질문 1. NyPizza pizza = new NyPizza.Builder(SMALL)...build(); 에서 new 연산자를 작성한 이유

 

 

 

 

 

 

 

정답

아래 코드에서 내부 클래스는 new 연산자를 2번 사용해서 생성할 수 있으며, 내부 스태택 클래스는 new 연산자를 1번 사용해야 클래스가 생성됨을 확인할 수 있습니다.

주의해야 할 점은 내부 클래스 생성 시에는 ClassTest 객체와 InnerClass 객체 모두 생성되는 한편, 내부 스태틱 클래스 생성시에는 InnerStaticClass 객체만 생성됩니다.

public class ClassTest {

    class InnerClass {}

    static class InnerStaticClass {}

    public static void main(String[] args) {

        ClassTest.InnerClass innerClass1 = new ClassTest().new InnerClass();
        ClassTest.InnerClass innerClass2 = new ClassTest().new InnerClass();

        ClassTest.InnerStaticClass innerStaticClass1 = new ClassTest.InnerStaticClass();
        ClassTest.InnerStaticClass innerStaticClass2 = new ClassTest.InnerStaticClass();

        if (innerClass1 == innerClass2) {
            System.out.println("내부 클래스 같다");
        } else {
            System.out.println("내부 클래스 다르다");
        }

        if (innerStaticClass1 == innerStaticClass2) {
            System.out.println("내부 스태틱 클래스 같다");
        } else {
            System.out.println("내부 스태틱 클래스 다르다");
        }
    }
}

 

 

 

 

질문 2. innerClass1 == innerClass2 의 결과와 innerStaticClass1 == innerStaticClass2 의 결과를 예상해봅시다.

 

 

 

 

정답

둘 다 다르다

반응형