Effective JAVA Item 02

Dong yeong Kim·2022년 6월 1일
0

EffectiveJava

목록 보기
2/14

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

이전 Item 01에서 배운 정적 팩터리와, 생성자는 똑같은 제약이 하나 있습니다.
바로, 선택적인 매개변수가 많을 경우입니다. 책에는 식품의 영양정보를 표현하는 클래스를 설명하고 있습니다. 영앙정보는 1회 내용량, 총 n회 제공량, 1회 제공량당 칼로리 같은 필수 항목 몇 개와, 총 지방, 트랜스지방, 포화지방 등 수십가지의 선택 항목으로 이뤄집니다. 그러나 대부분 제품은 이 선택 항목 중 대다수의 값이 0입니다.

아래 영양정보 클래스의 예를 들어보겠습니다.

package item2;

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

이런 경우, 예시와 같이 점층적 생성자 패턴(telescoping costructor pattern)을 사용한다고 하는데, 이는 필수 매개변수만 받는 생성자, 필수 매개변수와 선택 매개변수 1개... 2개... , 형태로 전부 다 받는 생성자까지 늘려가는 방식입니다. 딱 봐도 복잡합니다.

해당 예시는 매개변수가 '겨우' 6개라 괜찮아 보일 수 있습니다. 하지만 수가 더 늘어난다면..?


JavaBeans Pattern

점층적 생성자 패턴의 문제를 해결하기 위해, 자바빈즈 패턴 대안이 있습니다.
이는, 매개변수가 없는 생성자로 객체를 만든 후, setter 메서드를 호출하여 값을 설정하는 방식입니다.

package item2;

public class JavaBeansNutritionFacts {

    private int servingSize = -1; // 필수; 기본값 없음
    private int servings = -1; // 필수; 기본값 없음
    private int calories = 0;
    private int fat = 0;
    private int sodium = 0;
    private int carbohydtate = 0;

    public JavaBeansNutritionFacts() {};

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

점층적 생성자 패턴의 단점을이 보이지 않습니다. 가독성이 좋고, 인스턴스를 만들기 쉽습니다.

하지만, 자바빈즈는 심각한 단점을 지니고 있다고 합니다.

  • 객체 하나를 만들 경우, 메서드를 여러개 호출해야 함
        // 점층적 생성자 패턴
        NutritionFacts cocaCola = new NutritionFacts(240, 8, 100, 0, 35, 27);

        //자바빈즈 패턴
        JavaBeansNutritionFacts coke = new JavaBeansNutritionFacts();
        coke.setServingSize(240);
        coke.setServings(8);
        coke.setCalories(100);
        coke.setSodium(35);
        coke.setCarbohydtate(27);
  • 객체가 완전히 생성되기 전까지는 일관성(consistency)을 보장할 수 없음

점층적 생성자 패턴에서는 매개변수들이 유효한지를 생성자에서만 확인하면 됩니다.
하지만, 자바빈즈 패턴은 빈 객체에 setter를 주입하는 방식이기 때문에, 그러한 검증 과정이 완전히 사라져 버립니다.

만약, 이러한 이유로 일관성이 깨진 객체가 있다면, 연쇄적으로 일어날 수 있는 버그, 그리고 그 버그를 해결할 비용문제도 만만치 않을 것입니다.

이러한 단점을 완화하고자, 생성이 끝난 객체를 수동으로 freezing 하여 얼리기 전에는 사용할 수 없도록 하기도 한다는데, 다루기 어렵고 복잡하여 실전에서는 거의 쓰이지 않는다고 합니다.


Builder Pattern

빌더 패턴은, 필수 매개변수만으로 생성자(정적 팩터리 메서드)를 호출해 객체를 얻고, 빌더 객체가 제공하는 일종의 세터 메서드들로 원하는 매개변수를 설정합니다.
(빌더는 생성할 클래스 안에 정적 멤버 클래스로 만들어 두는것이 보통입니다.)

package item2;

public class BuilderNutritionFacts {
    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 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 BuilderNutritionFacts build() {
            return new BuilderNutritionFacts(this);
        }
    }

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

예시 처럼, 빌더 메서드들은 모두 자신을 반환하기 때문에 연쇄적으로 호출할 수 있습니다. 이는, 메서드 체이닝(Method Chaining)형식이라고 하는데, 사용 예시는 아래와 같습니다.

BuilderNutritionFacts pepsi = new BuilderNutritionFacts.Builder(240, 0)
      .calories(100)
      .sodium(35)
      .carbohydrate(27)
      .build();

설명과 같이, 필수 매개변수를 지정하여 객체를 만들면서, 선택적으로 변수를 세팅하고 마지막에 build();로 인스턴스를 반환받을 수 있습니다.

생성 중 유효성을 검사하는 코드가 존재해야 하지만, 이는 다른 Item 에서 포스팅 하도록 하겠습니다. (Item 50, Item 70)


빌더 패턴은, 계층적 클래스에 사용하기 좋습니다. 각 계층의 클래스에 관련 빌더를 멤버로 정의합니다. (추상 클래스 <-> 추상 빌더, 구체 클래스 <-> 구체 빌더)

책의 예시는 피자의 다양한 종류를 표현하는 계층구조의 루트(Pizza) 추상 클래스 입니다.

package item2;

import java.util.EnumSet;
import java.util.Objects;
import java.util.Set;

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

        // 하위 클래스는 이 메서드를 재정의(Overriding)하여
        // "this"를 반환하도록 해야 한다.
        protected abstract T self();
    }

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

}
  1. 추상 클래스 Pizza니까, 여러가지의 구체 피자 클래스가 있을 것이다.

  2. 토핑도 추가적으로 들어갈 수 있다.

  3. Builder 추상 클래스로 (추상 클래스 <-> 추상 빌더) 어떠한 메서드를 사용해 선택 매개변수를 추가할 수 있다.

  4. build(); 메서드로 인스턴스를 반환 받는다.

  5. self(); 메서드로 객체 값을 반환받는다.

  6. builder 클래스의 토핑을 해당 클래스의 toppings에 clone한다.

protected abstract T self(); // 하위 클래스에서 형변환 없이 메서드 체이닝 방식을 지원
// simulated self-type

다음은, 이 추상 클래스를 구현한 예시입니다.

package item2;

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;
    } // 생성 시 빌더가 필요한데, 이 빌더는 사이즈를 받아야 한다.
}
  1. NyPizza 에는 필수적으로 Size가 들어간다.
  2. 추상 클래스 Pizza 빌더를 상속 받아, 빌더를 만든다.
  3. build() 와 self()를 오버라이딩 한다.
package item2;

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;
    }
}
  1. NyPizza와 달리, sauceInside 라는 값이 있다. (계층적 클래스의 장점)
  2. sauceInside() 라는 메서드만 넣어도 true로 반환한다. (값이 필요 없음)
  3. 이하 동일

처음 경험해본 어색한 코드라 이해하기 힘들었지만.. 혹여라도 이상한 점이 있다면 알려주시면 감사하겠습니다.

각 하위 클래스의 빌더가 정의한 build 메서드는, 해당하는 구체 하위 클래스를 반환하도록 선언합니다. 하위 클래스가의 메서드가 상위 클래스가 정의한 반환 타입이 아닌, 하위 타입을 반환하는 기능을 공변 변환 타이핑 (covariant return typing)이라고 합니다. 이 기능으로 형변환에 신경쓰지 않고 빌더를 사용할 수 있습니다.

 NyPizza pizza = new NyPizza.Builder(SMALL)
           .addTopping(SAUSAGE) // 가변인수 매개변수 여러개 사용
           .addTopping(ONION) // 가변인수 매개변수 여러개 사용
           .build();

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

빌더는, 가변인수 매개변수를 여러개 사용할 수 있습니다. 각각을 적절한 메서드로 나눠 선언하거나, 메서드를 여러 번 호출하도록 하고 각 호출 때 넘겨진 매개변수들을 하나의 필드로 모을 수 있습니다. Pizza 클래스의 addTopping 메서드가 그 예시입니다.


회고

결론적으로, 생성자나 정적 팩터리 메서드에서 처리해야 할 매개변수가 많다면, 빌더 패턴을 선택하는 게 좋습니다.

뭐랄까.. 이번 포스팅은 되게 쉬운 듯 하면서도 여러 아이템이 겹쳐 어려운 듯 했습니다. 그저 Lombok의 @Buiilder 만 알면 될 것 같았는데.. 어떤 형식으로 이루어지고, 어떤 장점이 있는지 정확히 알아 좋았습니다.

profile
날 것의 기술 '불'로그

0개의 댓글