정적 팩토리 메서드가 생성자 방식의 단점을 상당 부분 보완해주긴 하지만
둘의 공통적인 제약은
결국 둘 다 매개변수가 많을 때 대응하기 어렵다는 점이다.
이에 따라 새로운 대안들이 발굴되기 시작.
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 = serving;
this.servings = servings;
this.calories = calories;
this.fat = fat;
this.sodium = sodium;
this.carbohydrate = carbohydrate;
}
}
// 사용 예시
NutritionFacts cocaCola = new NutritionFacts(240, 8, 100, 0, 35, 27);
필수 매개변수만 받는 생성자 ,
필수 선택 각각 하나의 매개변수만 받는 생성자,
선택 매개변수 2개를 받는 생성자..
이런식으로 매개변수를 전부 받는 생성자까지 늘리는 방식
매우 번거롭고 확장하기도 힘들뿐더러
개수가 많아지면 각 값의 의미가 무엇인지 헷갈릴 것이며,
특히 타입이 같은 매개변수에 대해 실수를 유발해
치명적인 버그로 이어질 수 있다는 치명적 단점이 존재한다.
public class NutritionFacts {
private final int servingSize = -1; // 필수, 기본값 없음
private final int servings = -1; // 필수, 기본값 없음
private final int calories = 0;
private final int fat = 0;
private final int sodium = 0;
private final int carbohydrate = 0;
public NutritionFacts() {
}
public void setServingSize(int servingSize) {
this.serving = serving;
}
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;
}
}
// 사용 예시
NutritionFacts cocaCola = new NutritionFacts();
cocaCola.setServingSize(240);
cocaCola.setServings(8);
cocaCola.setCalories(100);
cocaCola.setSodium(35);
cocaCola.setCarbohydrate(27);
매개변수가 없는 생성자로 객체를 만든 후
Setter 메서드를 호출하여 원하는 매개변수 값을 설정.
점층적 생성자 패턴에서 보였던 비슷한 타입의 매개변수에 대한 실수를 줄일 수 있고
set 메서드로 인해 가독성도 더 좋아진듯한 인상을 받는다.
하지만 객체 하나를 만들기 위해 set 메서드를 엄청나게 호출해야 하고
객체가 완성되기 전까지는 일관성이 무너진 상태에 놓이게 된다.
일관성이 깨진 객체가 만들어지면 버그를 심은 코드와
그 버그 때문에 런타임에 문제를 겪는 코드가 물리적으로 멀어져 있어
디버깅에도 힘이 든다.
→ 즉 추적이 힘들다 라는 뜻
set 메서드로 값을 변경할 수 있어 불변 클래스로 만들기도 힘들고,
변경이 가능하기 때문에 멀티 쓰레드에서도 안전하지 못하다.
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 NutritionFacts(Builder builder) {
serving = builder.servingSize;
servings = builder.servings;
calories = builder.calories;
fat = builder.fat;
sodium = builder.sodium;
carbohydrate = builder.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.serving = servingSize;
this.servings = servings;
}
public Builder calories(int calories) {
this.calories = calories;
return this;
}
public Builder fat(int fat) {
this.fat = fat;
return this;
}
public Builder sodium(int sodium) {
this.sodium = sodium;
return this;
}
public Builder carbohydrate(int carbohydrate) {
this.carbohydrate = carbohydrate;
return this;
}
public NutritionFacts build() {
return new NutritionFacts(this);
}
}
}
// 사용 예시
NutritionFacts cocaCola = new NutritionFacts.Builder(240, 8)
.calories(100)
.sodium(35)
.carbohydrate(27)
.build();
클라이언트 NutritionFacts 는 필요한 객체를 직접 만들지 않고,
필수 매개변수만으로 생성자 (혹은 정적 팩토리) 를 호출해 빌더 객체를 얻는다.
그 다음 빌더 객체가 제공하는 일종의 setter 메서드로 매개변수들을 받아온다
마지막으로 Build 를 호출해 NutritionFacts 객체를 반환한다. (보통은 불변 상태)
빌더는 생성할 클래스안에 정적 멤버 클래스로 만들어두는 게 보통.
NutritionFacts 클래스는 불변이며 모든 매개변수 기본값들을 한 곳에 모아뒀디.
빌더의 메서드들은 빌더를 반환하게 만들어
연속적으로 줄줄이 호출할 수 있게끔 되어있는데
이를 메서드 체이닝(Method Chaining) 이라 한다.
사용 예시에서 알 수 있듯이 타입이 같은 매개변수끼리
헷갈려서 위치가 서로 바뀔일이 없으며
명시적이고 가독성도 매우 훌륭하다.
(파이썬과 스칼라에 있는 명명된 선택적 매개변수를 흉내낸 것이라고 한다.)
내부적으로 유효성 검사 코드도 구현할 수 있는데, 여기서 불변식이라는 개념이 등장한다.
불변식이란? (Invariant)
프로그램이 실행되는 동안 또는 정해진 기간 동안 반드시 만족해야 하는 조건을 말한다.
다시 말해 변경을 허용할 수 있으나, 주어진 조건 내에서만 허용한다는 뜻이다.
예를 들면, “리스트(List)의 경우, size의 크기는 반드시 0 이상이어야 하고
한 순간이라도 음수 값이 될 수 없다.” 라는 조건식이 List.size()의 불변식이다.
매개변수를 복사하여 해당 객체 필드들을 검사하는 예시
public final class Person {
private final String name;
private final int age;
private Person(Builder builder) {
// 빌더에서 값 가져와서 필드에 할당
this.name = builder.name;
this.age = builder.age;
// 필드 값의 유효성 검사
if (name == null || name.trim().isEmpty()) {
throw new IllegalArgumentException("Name cannot be empty");
}
if (age < 0) {
throw new IllegalArgumentException("Age cannot be negative");
}
}
// getter만 제공하여 객체 불변 보장
public String getName() {
return name;
}
public int getAge() {
return age;
}
public static class Builder {
private String name;
private int age;
public Builder setName(String name) {
this.name = name;
return this;
}
public Builder setAge(int age) {
this.age = age;
return this;
}
public Person build() {
// 빌더에서 값을 가져와 Person 객체를 생성
return new Person(this);
}
}
}
public abstract class Pizza {
public enum Topping { HAM, ONION, GALIC, SAUSAGE, BULGOGI }
Set<Topping> toppingList;
abstract static class Builder<T extends Builder<T>>{
EnumSet<Topping> toppingList = EnumSet.noneOf(Topping.class);
public T addTopping(Topping topping){
toppingList.add(Objects.requireNonNull(topping));
return self();
}
abstract Pizza build();
protected abstract T self();
}
Pizza(Builder<?> builder){
toppingList = builder.toppingList.clone(); // 아이템 50 참조
}
}
<? extends U> : 상한 경계 와일드 카드, 명시된 U 의 하위 타입들만 받을 수 있음
ex)
ArrayList<? extends Object> parent = new ArrayList<>();
ArrayList<? extends Integer> child = new ArrayList<>();
parent = child; // (제네릭 타입 업캐스팅)
ArrayList<? extneds Object> parent 는
Object 의 하위의 타입들을 받을 수 있다는 뜻이니
ArrayList<? extneds Integer> child 는 parent 에 들어갈 수 있다.
업캐스팅이 가능
self() 메서드의 역할은 자기 참조를 반환하여
하위 클래스의 빌더에서 상위 클래스의 체이닝이 가능하게 만든다.
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;
}
}
<? extends U> 제네릭을 사용하여 상위 빌더(Pizza.Builder)에서 하위 빌더(NyPizza.Builder)를 상속받을 수 있도록 구현.
이 방식을 통해 하위클래스에서 상위 클래스의 빌더를 재사용하고
각 하위 클래스에 특화된 빌더를 생성할 수 있게 만듦.
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;
}
}
이런식으로 빌더를 상속받아 또 다른 하위 타입을 구현하여
다른 속성을 빌더 패턴으로 지원할 수 있게끔 만들 수 있다.
이렇게 설명하면 매우 어려워 보이는데 쉽게 말하면 Lombok 의
@SuperBuilder 어노테이션의 동작 원리를 설명한 것이라 생각하면 간단하다.
@SuperBuilder? : 자식 클래스의 빌더는 부모클래스에 있는 변수까지 접근을 못하기 때문에
자식클래스에서 builder를 통해 부모클래스의 필드에 접근 가능하게 만드는 기능.
Lombok 어노테이션을 사용해 좀 더 실무적으로 사용되는 코드로 변환하면 다음과 같이 적용 가능하다.
@MappedSuperclass
@Getter
@EntityListeners(AuditingEntityListener.class)
@SuperBuilder
@NoArgsConstructor
public class BaseEntity {
@CreatedDate
@Column(updatable = false)
private LocalDateTime createdDate;
@LastModifiedDate
private LocalDateTime updatedDate;
}
@Entity
@Getter
@SuperBuilder
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Answer extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
private Integer id;
@Column(length = 200)
private String content;
private Integer questionId;
}
// 사용 예시
Answer answer = Answer.builder()
.content(answerReq.getContent())
.questionId(answerReq.getQuestionId())
.createdDate(LocalDateTime.now())
.updatedDate(LocalDateTime.now())
.build();
빌더 패턴을 직접 구현하는 방식의 장점은
조건부 로직 추가나 특정 검증을 추가하여
커스텀해 세밀한 제어가 가능하다 라는 장점도 있지만
@Valid를 사용하거나 @Builder를 사용하면서도 동시에 충분히 구현할 수 있는 부분이라
Trade Off 를 고려해야겠지만은 일반적인 경우에는
어노테이션을 사용하는 것이 매우 효율적일 것이라 생각한다.
특히 @SuperBuilder 같은 경우에는 직접 구현할 경우 더욱 복잡하기 때문에
Trade Off 가 훨씬 유리한 선택.
Effective Java - (Joshua Bloch)