Post

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

🎬 Intro

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

🎯 패턴 종류

✅ 점층적 생성자 패턴(telescoping constructor pattern)

매개변수를 전부 다 받는 생성자까지 늘려가는 방식

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
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, int carbohydrate) {
    this.servingSize = servingSize;
    this.servings = servings;
    this.calories = calories;
    this.fat = fat;
    this.sodium = sodium;
    this.carbohydrate = carbohydrate;
  }
}

다음과 같은 단점이 존재한다

  • 매개변수가 많아 질수록 가독성이 떨어짐
  • 원치 않는 매개변수까지 값을 지정해줘야 함
  • 코드를 읽을 때 각 값의 의미가 무엇인지 헷갈림
  • 타입이 같은 매개변수가 연달아 늘어서 있다면, 클라이언트가 실수로 매개변수의 순서를 잘못 건네줘도 컴파일 단계에서 알아채지 못한다

✅ 자바빈즈 패턴(JavaBeans pattern)

매개변수가 없는 생성자로 객체를 만든 후, 세터 메서드를 호출해 원하는 매개변수의 값을 설정하는 방식

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
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 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;
  }
}

점층적 생성자 패턴의 단점들이 자바빈즈 패턴에서는 더 이상 보이지 않는다. 하지만 다음과 같은 단점이 존재한다.

  • 객체 하나를 만들려면 메서드를 여러개 호출해야함
  • 객체가 완전히 생성되기 전까지는 일관성이 무너진 상태에 놓이게 됨(필수 데이터가 누락된 상태)
  • 세터로 인해 클래스를 불변으로 만들 수 없으므로 스레드 안전성이 떨어짐
  • freeze 메서드를 통해 불변이 안되는 단점을 보완할 수 있으나, 해당 메서드가 확실히 호출 되었는지 컴파일러가 보증할 방법이 없어서 런타임 오류에 취약하다
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
public class User {
  private String name;
  private int age;
  private boolean frozen = false; // 객체가 freeze 되었는지 상태를 추적

  // 기본 생성자
  public User() {
  }

  public void setName(String name) {
    checkIfFrozen(); // setter 호출 시마다 freeze 상태 체크
    this.name = name;
  }

  public void setAge(int age) {
    /**
     * checkIfFrozen() 메서드가 누락되어 불변이 아니게 됨
     * 컴파일러는 이를 알아챌 수가 없음
     */
    this.age = age;
  }

  // freeze 메서드 - 객체를 불변 상태로 만듦
  public void freeze() {
    this.frozen = true;
  }

  // freeze 상태 체크 헬퍼 메서드
  private void checkIfFrozen() {
    if (frozen) {
      throw new IllegalStateException("객체가 freeze 되어 수정할 수 없습니다.");
    }
  }
}

✅ 빌더 패턴(Builder pattern)

필수 매개변수만으로 생성자를 호출해 빌더 객체를 얻고, 빌더 객체가 제공하는 세터 메서드들로 선택 매개변수들을 설정한 뒤, build 메서드를 호출해 최종 객체를 얻는 생성 패턴

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
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;
  private final List<String> ingredients = new ArrayList<>();

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

    // 세터, this를 리턴하여 메소드 체이닝 형태로 호출 가능
    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 Builder ingredients(List<String> val) {
      // 유효성 검증
      // 외부와 참조를 끊어주기 위해 방어적 복사
      this.ingredients = new ArrayList<>(val);
      return this;
    }

    public NutritionFacts build() {
      // 참조 타입이 있다면 방어적 복사를 한 뒤에 유효성 검증
      List<String> ingredientsCopy = new ArrayList<>(ingredients);
      // 유효성 검증

      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;
  }
}
1
2
3
4
5
NutritionFacts cocaCola = new NutritionFacts.Builder(240, 8)
  .calories(100)
  .sodium(35)
  .carbohydrate(27)
  .build();  

다음과 같은 장점이 있다.

  • 가독성이 좋음
  • 불변이므로 스레드 안정성 보장
  • 객체 생성시 일관성 보장

🎯 빌더 패턴 장점

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

추상 클래스는 추상 빌더를, 구체 클래스는 구체 빌더를 갖게 한다

추상 빌더
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
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 (T) this; 명시적 타입 캐스팅
      return self(); // self() 메서드를 이용하면 명시적인 타입 캐스팅이 필요없다
    }

    abstract Pizza build();

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

  Pizza(Builder<?> builder) {
    toppings = builder.toppings.clone(); // 아이템 50 참조  
  }
}
  • Pizza.Builder 클래스는 재귀적 타입 한정을 이용하는 제네릭 타입
    • T는 Builder의 하위 타입
    • Builder에서도 다시 T가 쓰임
    • 이렇게 T의 정의 안에 T자신이 포함되는 구조라서 재귀적이라고 표현
  • self() 메서드를 이용하여 하위 클래스에서 형변환하지 않고도 메서드 체이닝을 지원할 수 있음
구체 빌더
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
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;
  }
}
  • NyPizza.Builder가 상속할 때 그 T자리에 NyPizza.Builder 자신을 타입으로 넣음
  • 결과적으로 Pizza.Builder를 상속받는 형태가 됨
    1
    
    public static class Builder extends Pizza.Builder<NyPizza.Builder>
    
  • 따라서 Pizza.Builder의 메서드들이 NyPizza.Builder 타입을 정확하게 다룰 수 있게 됨
    1
    2
    3
    4
    5
    6
    
    NyPizza.Builder builder = new NyPizza.Builder(Size.SMALL);
    // addTopping()은 NyPizza.Builder를 반환하므로
    // 계속 NyPizza의 기능들을 체이닝으로 사용 가능
    builder.addTopping(Topping.SAUSAGE)
          .addTopping(Topping.ONION)
          .build();
    

✅ 가변인수 매개변수를 여러 개 사용할 수 있다

생성자나 메서드에서는 가변인수 매개변수를 하나만 사용할 수 있다. 하지만 빌더를 사용하면 각각의 세터 메서드가 서로 독립적이므로 여러 개의 가변인수를 받아 객체를 생성할 수 있다

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
public class Pizza {
  private final Set<String> toppings;
  private final Set<String> sauces;

  public static class Builder {
    private Set<String> toppings = new HashSet<>();
    private Set<String> sauces = new HashSet<>();

    // 가변인수를 사용하는 첫 번째 메서드
    public Builder addToppings(String... toppings) {
      Arrays.stream(toppings).forEach(this.toppings::add);
      return this;
    }

    // 가변인수를 사용하는 두 번째 메서드
    public Builder addSauces(String... sauces) {
      Arrays.stream(sauces).forEach(this.sauces::add);
      return this;
    }

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

// 사용 예시
Pizza pizza = new Pizza.Builder()
  .addToppings("페퍼로니", "올리브", "양파")  // 가변인수 사용
  .addSauces("토마토", "갈릭")               // 다른 가변인수 사용
  .build();

  • 생성자였다면 이런 방식은 불가능

✅ 빌더 하나로 여러 객체를 순회하면서 만들 수 있고, 특정 필드는 빌더가 알아서 채우도록 할 수 있다

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
public class Order {
  private final int orderId;      // 자동 증가하는 일련번호
  private final String customerName;
  private final List<String> items;

  public static class Builder {
    private static int counter = 1;  // 일련번호를 위한 정적 카운터
    private String customerName;
    private List<String> items = new ArrayList<>();

    public Builder customerName(String name) {
      this.customerName = name;
      return this;
    }

    public Builder addItems(List<String> items) {
      this.items = new ArrayList<>(items);
      return this;
    }

    public Builder reset() {
      customerName = null;
      items.clear();
      return this;
    }

    public Order build() {
      // build 시점에 자동으로 일련번호 할당
      return new Order(counter++, this);
    }
  }

  private Order(int orderId, Builder builder) {
    this.orderId = orderId;
    this.customerName = builder.customerName;
    this.items = builder.items;
  }
}
1
2
3
4
5
public record OrderData(
  String customerName,
  List<String> items) {
  
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 주문 데이터
List<OrderData> orderData = Arrays.asList(
    new OrderData("김철수", Arrays.asList("피자", "콜라")),
    new OrderData("이영희", Arrays.asList("치킨", "사이다")),
    new OrderData("박지민", Arrays.asList("햄버거", "감자튀김"))
);

final List<Order> orders = new ArrayList<>();

for (OrderData data : orderData) {
    final Order newOrder = builder.reset() // 빌더 초기화
           .customerName(data.customerName())
           .addItems(data.items())
           .build(); // orderId는 자동으로 할당
    orders.add(newOrder);
}

  • 하나의 빌더로 데이터를 순회하면서 Order 객체를 생성
  • reset()을 통해 새로운 Order 객체 생성가능
  • build()를 통해 id를 자동으로 증가시키면서 할당

🎯 빌더 패턴 단점

✅ 객체를 만들려면, 그에 앞서 빌더 부터 만들어야 한다

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

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

하지만 API는 시간이 지날수록 매개변수가 많아지는 경향이 있으므로 단점으로 보기에는 애매하긴 하다

✨ Summary

점층적 생성자 패턴

  • 매개변수를 하나씩 늘려가며 생성자를 만드는 방식
  • 단점: 가독성이 떨어지고, 매개변수가 많아지면 관리가 어려움

자바빈즈 패턴

  • 기본 생성자로 객체를 만들고 setter로 값을 설정
  • 단점: 객체 일관성이 깨질 수 있고, 불변 객체를 만들 수 없음

빌더 패턴 (권장)

  • 필수 매개변수로 빌더 객체를 만들고, 선택 매개변수는 메서드 체이닝으로 설정
  • 장점:
    • 가독성이 좋음
    • 불변 객체를 만들 수 있음
    • 계층적 구조에 적합
    • 여러 개의 가변인수 사용 가능
    • 빌더 재사용 가능
  • 단점:
    • 객체 생성 전에 빌더부터 만들어야 함 (성능에 민감한 상황에서 문제될 수 있음)
    • 코드가 장황해서 매개변수가 4개 이상은 되어야 값어치를 함

생성자나 정적 팩터리 방식으로 시작했다가 나중에 매개변수가 많아지면 빌더 패턴으로 전환할 수도 있지만, 이전에 만들어둔 생성자와 정적 팩터리가 아주 도드라져 보일 것이다. 그러니 애초에 빌더로 시작하는 편이 나을 때가 많다.

  • 생성자나 정적 팩터리가 처리해야 할 매개변수가 많다면 빌더 패턴을 선택하는 게 더 낫다. 매개변수 중 닥수가 필수가 아니거나 같은 타입이면 특히 더 그렇다.
  • 빌더는 점층적 생성자보다 가독성이 좋고, 자바빈즈보다 스레드 안전하고 일관성 있는 객체를 생성할 수 있다.
This post is licensed under CC BY 4.0 by the author.