본문 바로가기
개발 공부/Java

이펙티브 자바 아이템 2 - 생성자에 매개변수가 많다면 빌더를 고려하라 - 핵심 정리

by 개발인생 2022. 9. 5.
반응형

아이템 2 - 생성자에 매개변수가 많다면 빌더를 고려하라 - 핵심 정리

이 글은 백기선 님의 이펙티브 자바 강의와 이펙티브 자바 3 / E 편을 참고하여 작성하였습니다.

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

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

   public NutritionFacts(int servingSize, int servings,
                         int calories) {
      this.servingSize  = servingSize;
      this.servings     = servings;
      this.calories     = calories;
      this.fat          = 0;
      this.sodium       = 0;
      this.carbohydrate = 0;
   }
   public NutritionFacts(int servingSize, int servings,
                         int calories, int fat) {
      this.servingSize  = servingSize;
      this.servings     = servings;
      this.calories     = calories;
      this.fat          = fat;
      this.sodium       = 0;
      this.carbohydrate = 0;
   }

   public NutritionFacts(int servingSize, int servings,
                         int calories, int fat,
                         int sodium) {
      this.servingSize  = servingSize;
      this.servings     = servings;
      this.calories     = calories;
      this.fat          = fat;
      this.sodium       = sodium;
      this.carbohydrate = 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 nutritionFacts01 = new NutritionFacts(1, 0);
      NutritionFacts nutritionFacts02 = new NutritionFacts(1, 0, 10);
   }

위와같이 코드를 짜야한다.

하지만 클래스에서 중복되는 코드가 많고 코드가 길어지게 된다.

이럴때 사용하는 대안방안이 점층적 생성자 패턴 또는 생성자 체이닝 을 사용하는 것이다.

정적 팩터리와 생성자에 선택적 매개변수가 많을 때 고려할 수 있는 방안

대안1 : 점층적 생성자 패턴 또는 생성자 체이닝

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 void main(String[] args) {
      NutritionFacts nutritionFacts01 = new NutritionFacts(1, 0);
      NutritionFacts nutritionFacts02 = new NutritionFacts(1, 0, 10);
   }

   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 nutritionFacts01 = new NutritionFacts(1, 0);
      NutritionFacts nutritionFacts02 = new NutritionFacts(1, 0, 10);
   }

이렇게 인스턴스를 만들 때 어떤 파라미터를 주어야할지 헷갈리게 된다.

대안2 : 자바빈즈 패턴

자바 빈즈는 자바 표준 스펙 중의 하나이다.

클래스의 필드에 대한 getter, setter 네이밍 패턴을 정의한 표준 스펙 중 하나이다.

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

   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 static void main(String[] args) {
      NutritionFacts cocaCola = new NutritionFacts();

      cocaCola.setServings(240);
      cocaCola.setServings(8);
      cocaCola.setCalories(100);
   }

자바빈즈의 장점은 객체 생성이 쉬워진다는 점입니다.

단점

  • 완전한 객체를 만들려면 메서드를 여러번 호출해야 한다 (일관성이 무너진 상태가 될 수도 있다)
   public static void main(String[] args) {
      NutritionFacts cocaCola = new NutritionFacts();

      cocaCola.setCalories(100);
   }

위의 코드를 보면 필수로 값이 세팅되어지지 않고 사용되어질 여지가 있다.

기본 생성자를 사용한 후 Setter 를 사용해 값을 세팅하는 경우

어느 값까지 세팅되어야하는지 알기가 어려워진다.

주석을 남기고 문서화하는 방법밖에 없다.

필드는 생성자로 넘겨받도록 강제하고 옵셔널한 값들을 세터를 통해 넘겨받도록 할 수도 있다.

public class NutritionFacts {
   private int servingSize;   // 필수
   private int servings;  // 필수
   private int calories       = 0;  // 선택
   private int fat            = 0;  // 선택
   private int sodium         = 0;  // 선택
   private int carbohydrate   = 0;  // 선택

   public NutritionFacts(int servingSize, int servings) {
      this.servingSize = servingSize;
      this.servings = servings;
   }

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

하지만 위의 방법도 여전히 단점이 있다.

  • 클래스를 불변으로 만들 수 없다.

Setter 를 통해서 값을 설정하다보니 불변객체로 만들기가 어려워진다.

객체 프리징이라는 기술이 있지만 현업에서는 잘 사용하지 않는다.

Builder

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

위의 클래스는 빌더패턴을 적용한 클래스이다.

클래스 안에 빌더를 만들고, 빌더안에 클래스와 동일한 필드를 가지도록 만든다.

빌더의 생성자에는 필수로 받아야하는 값을 넣어주고 나머지 세팅을 해야하는 필드들은

일종의 Setter를 만들어 값을 설정하도록한다.

최종적으로 build 메서드를 제공한다.

빌더 안에서의 Setter 의 특징은 return 값으로 Builder 타입을 리턴하게 된다.

이 때문에 플루언트 API 또는 메서드 체이닝 이 가능해진다.

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

위와같이 메서드 체이닝이 가능해진다.

장점

  • 객체를 생성할 때 필수 설정을 세팅할 수 있게 된다.

생성자의 매개변수도 줄어들고 객체도 안전하게 사용할 수 있게된다.

그렇다고 모든 경우에 빌더가 적절한 건 아니다.

빌더는 코드를 이해하기 어렵게 만든다.

빌더를 만들면서 중복되는 코드가 생기기 때문에

필수적인 필드와 필수적인 필드가 있을 때 이것들 때문에 너무 생성자의 매개변수가 많이 늘어날 때 사용하는 걸 권장한다.

Lombok 을 사용하면 빌더의 코드를 줄일 수 있다.

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

위처럼 @Builder 를 사용하면 Lombok 에서 애노테이션 프로세서를 통해 알아서 빌더를 만들어 준다.

@Builder(builderClassName = "builder")
@AllArgsConstructor(access = AccessLevel.PRIVATE)
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;
}

builderClassName 설정을 통해 생성될 빌더의 이름을 설정할 수도 있다.

@Builder 의 단점은

  1. 모든 파라미터를 받는 생성자가 생기게 된다.

이런 경우를 허용하지 않으려면

Lombok 에서 제공하는 @AllArgsConstructor(access = AccessLevel.PRIVATE) 설정을 통해 해결할 수 있게 된다.

  1. 필수값을 지정해 줄 수 없게된다.

빌더 패턴을 직접 작성할 때는 필수로 들어가야하는 값을 파라미터로 설정할 수 있게 만들 수 있었다.

하지만 Lombok 에서 @Builder 는 해당 기능을 제공하지 않는다.

빌더를 계층구조에서 사용하는 방법


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

Pizza 라는 추상 클래스 입니다.

pizza는 빌더 자신의 하위 클래스를 타입을 받도록 했다.

재귀적인 타입제한을 사용했다고 할 수 있다.

public class NyPizza extends Pizza{
   public enum Size {
      SMALL,
      MEDIUM,
      LARGE
   }
   private final Size size;

   public static class Builder extends Pizza.Builder<NyPizza.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;
   }
}

Pizza를 상속받은 NyPizza 클래스이다.

여기서 주의할 점은 Pizza 클래스에서

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

      protected abstract T self();
   }

self 메서드를 통해 상속받은 클래스의 빌더를 리턴하도록 해야한다.

즉 하위 클래스의 self는 자기자신을 리턴해야한다.

그래야 하위 클래스에 있는 빌더만의 특수한 기능을 사용할 수 있다.

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

위와 같이 사용할 수 있다.

Self() 라는 매커니즘을 이용해 빌더를 만들었을 때는 별도의 캐스팅이 필요없게 된다.

반응형

댓글