ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [이펙티브 자바] item 2 - 생성자에 매개변수가 많다면 빌더를 고려하라
    개발서적읽기/Effective Java 3E 2020. 5. 19. 21:23

    ■상황


    어떤 클래스의 인스턴스를 반환해야 하는 상황이 생겼다.


    생성자나 정적 팩토리 메서드를 만들면 될 것 같다.


    그런데 생성자나 정적 팩터리 메서드에 필요한 선택적 매개변수가 


    많은 경우는 어떻게 할까?




    ■첫 번째 방법 : 점층적 생성자 패턴


    가장 심플한 방법이다. 생성자나 팩터리 메서드를 오버로딩하면 된다.


    하지만 이 방법은 코드가 더러워지는(?) 특징을 가지고 있다.

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

    public static void main(String[] args) {
    NutritionFacts cocaCola =
    new NutritionFacts(240, 8, 100, 0, 35, 27);
    }

    }

    main 메소드 내의 new NutritionFacts를 보면 혼란스러워진다. 


    각 인자값들이 어떤 의미를 가지고 있는지 명시적으로 알 수가 없다. 


    각각이 무엇을 의미하는지 알고 싶으면 NutritionFacts 클래스 내부를 직접 봐야한다.


    또한 산술적으로 매개변수 5개로 만들 수 있는 생성자는 최소한 60개가 넘는다. 


    어마어마하다. 모든 생성자를 정의할 것이 아니라면, 원치 않는 변수까지도 초기화해야 하는


    상황이 발생할 수도 있다. 엉뚱한 생성자를 호출해 컴파일이 아닌 런타임 오류가 발생할 


    가능성도 있다.(item51) 생성자 매개변수가 적어도 된다면 적합한 방법일 수 있겠으나 


    그렇지 않은 경우라면 보기만 해도 답답해지는 방법이다.


    명시적인 자바빈즈 패턴으로 이 단점을 해결할 수 있다.




    ■두 번째 방법 : 자바빈즈 패턴


    매개변수가 없는 생성자로 객체를 만든 후


    setter 메서드들을 사용해 필드값을 초기화하는 방법이다.

    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() { }
    // Setters
    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; }
    public void setSodium(int val) { sodium = val; }
    public void setCarbohydrate(int val) { carbohydrate = val; }

    public static void main(String[] args) {
    NutritionFacts cocaCola = new NutritionFacts();
    cocaCola.setServingSize(240);
    cocaCola.setServings(8);
    cocaCola.setCalories(100);
    cocaCola.setSodium(35);
    cocaCola.setCarbohydrate(27);
    }
    }

    이 방법도 단점이 있다. 여러번 set 메서드를 호출해야 하기 때문에 


    객체가 완전히 생성되기 전까지는 일관성(consistency)이 무너진 상태에 놓인다.


    점층적 생성자 패턴은 일관성은 있지만 자바빈즈 패턴은 그렇지 못한 경우가 


    생길 수 있다. 일관성이 무너지면 그 객체 생성으로 인한 버그가 언제 어디서 


    런타임 오류를 일으킬 지 모른다. 


    자바빈즈 패턴은 클래스를 불변(item17)으로 만들 수 없고 Thread-safe 하지 않다.


    그래서 생성이 끝난 객체를 수동으로 얼리고(freezing) 얼리기 전에는 


    사용할 수 없도록 하기도 한다. 하지만 이 방법은 어려워 가성비가 떨어진다. 


    또 객체 사용 전에, 개발자가 freeze 메서드를 확실히 호출했는지 컴파일러가 


    체크할 수 없기 때문에 런타임 에러가 발생할 가능성이 있다.


    아래에 일관성과 가독성을 겸비한 해결법이 있다.




    ■세 번째 방법 : 빌더 패턴


    빌더 패턴은 점층적 생성자 패턴의 일관성(안전성)과 


    자바빈즈 패턴의 가독성을 모두 갖췄다.

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

    먼저 private static의 Builder에 NutritionFacts의 필드를 본떠놓는다.


    그리고 필수 필드와 선택 필드를 구분하고 필수 필드로만 Builder 생성자를 만든다.


    그리고 선택 필드를 set 할 수 있는 메소드를 만든다. 이 메소드의 반환 타입은 Builder다


    클라이언트는 Builder 생성자를 호출하고 set 메소드들을 chain 형식으로 선택해 호출한다.


    (chain 형식을 플루언트 API(fluent API) 혹은 메서드 연쇄(method chaining)라 한다)


    set 메소드가 끝나면 최종적으로 build 메소드를 호출해 우리가 필요한 NutritionFacts 


    인스턴스를 얻는다. 이 인스턴스는 set메소드가 없기 때문에 불변(Immutable)이다.


    잘못된 매개변수를 최대한 일찍 발견하려면, Builder 생성자와 set 메서드에서 


    매개변수 검사를 하면 된다. 그리고 build 메서드에서 호출하는 NutritionFacts 생성자 


    내부에서도 매개변수 빌더로부터 받을 필드도 검사가 필요하다.


    검사를 할 땐 IllegalArgumentException을 사용하자(item75)



    빌더 패턴 개선점


    NutritionFacts와 Builder는 필드들이 중복된다. 이를 상속으로 해결할 수 있지 않을까?


    Builder가 NutritionFacts을 상속하게 하고 NutritionFacts의 필드들의 접근 제한자를 


    protected로 바꾸면 중복을 없앨 수도 있을 것 같다.



    => 해봤는데 아래의 이유때문에 상속을 이용하는 방법은 가성비가 떨어진다



    1. NutritionFacts의 필드들을 final로 쓸 수 없다


    2. NutritionFacts의 protected 생성자를 만들어야 한다


    3. build 메서드가 애매해진다. 


    Builder 자체를 NutritionFacts로 형변환해서 사용할 수도 있기 때문


    개선이 실패했다 ㅠ




    ■빌더 패턴 활용 :: 계층적으로 설계된 클래스


    추상클래스(Pizza)와 그 구현클래스가 2개 이상 있을 때(NyPizza, Calzone) 


    빌더 패턴을 사용하면 좋다.


    추상 클래스(Pizza)에는 추상 빌더를, 구체 클래스(NyPizza, Calzone)에는 


    구체 빌더를 갖게 한다.


    아래는 추상 클래스인 Pizza 클래스이다.

    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(); // 아이템 50 참조
    }
    }

    - Pizza.Builder 클래스는 재귀적 타입 한정(item30)을 이용하는 제네릭 타입이다.


    - Pizza.Builder 클래스의 추상 메서드인 self는 형변환하지 않고도 메서드 연쇄를 


    지원할 수 있게 한다. self 타입이 없는 자바를 위한 이 우회 방법을 


    시뮬레이트한 셀프 타입(simulated self-type) 관용구라 한다.




    아래는 추상 클래스인 Pizza 클래스를 상속한 하위 클래스 2개이다.


    NyPizza는 크키(size) 매개변수를 필수로 받고 Calzone는 소스를 넣을지 선택하는


    (sauceInside) 매개변수를 필수로 받는다.

    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 toppings + "로 토핑한 뉴욕 피자";
    }
    }
    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;
    }

    @Override public String toString() {
    return String.format("%s로 토핑한 칼초네 피자 (소스는 %s에)",
    toppings, sauceInside ? "안" : "바깥");
    }
    }

    각 하위 클래스의 빌더가 정의한 build 메서드는 해당하는 구체 하위 클래스를 


    반환하도록 선언한다. 


    - NyPizza.Builder -> NyPizza 반환


    - Calzone.Builder -> Calzone 반환


    이렇게 하위 클래스의 메서드가 상위 클래스의 메서드가 정의한 반환 타입이 아닌


    자신의(하위의) 타입을 반환하는 기능을 공변 반환 타이핑 이라 한다.


    공변 변환 타이핑을 이용하면, 사용자가 형변환에 신경쓰지 않고도 빌더를 사용할 수 있다.


    사용자는 '계층적 빌더' 를 구현한 클래스를 아래와 같이 사용할 수 있다.

    NyPizza pizza = new NyPizza.Builder(SMALL)
    .addTopping(SAUSAGE).addTopping(ONION).build();
    Calzone calzone = new Calzone.Builder()
    .addTopping(HAM).sauceInside().build();

    빌더를 사용하면 생성자로는 누릴 수 없는 사소한 이점을 얻을 수 있다.


    바로 가변인수(varargs) 매개변수를 여러 개 사용할 수 있다는 점이다.


    각각을 적절한 메서드로 나눠 선언하면 된다. 아니면 메서드를 여러 번 호출하도록 하고


    각 호출 때 넘겨진 매개변수들을 하나의 필드로 모을 수도 있다. (Pizza.addTopping)




    ■빌더 패턴의 또 다른 장점


    빌더 패턴은 상당히 유연하다. 빌더 하나로 여러 객체를 순회하면서 만들 수 있고,


    빌더에 넘기는 매개변수에 따라 다른 객체를 만들 수도 있다. 


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




    ■빌더 패턴의 단점


    [빌더를 만드는 비용]

    성능이 민감한 상황에서는 문제가 될 수 있다.


    [코드 장황]

    매개변수가 4개 이하면 차라리 점층적 생성자 패턴이 코드가 깔끔할 수 있다.

    댓글

Designed by Tistory.