빌더 패턴 특징과 장단점
빌더 패턴을 살펴보기 전, 객체를 생성 시 사용하는 두 가지 방식을 간단하게 살펴본다.
빌더 패턴은 점층적 생성자 패턴과 자바빈즈 패턴의 장점을 채택하여 만들어졌다고 볼 수 있다.
점층적 생성자 패턴
유물이다. 요즘엔 거의 자바빈즈 패턴(setter) 또는 빌더 패턴을 사용하므로 실무 코드에서는 보기 어렵다. 말 그대로 점층적으로 생성자를 늘린다는 의미이다. 보통 필드 값이 추가될 때마다 생성자를 하나씩 추가한다.
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, calories, fat, sodium, 0);
}
public NutritionFacts(int servingSize, int servings, int calories, int calories1, 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 n = new NutritionFacts(10, 3, 3);
}
}
점층적 생성자 패턴이 사용된 NutritionFacts 클래스의 인스턴스를 생성하기 위해서는 클라이언트가 필요로 하는 매개변수를 모두 포함한 생성자 중, 가장 짧은 생성자를 호출하면 된다.
단점으로는 사용자가 원하는 값을 설정하여 객체를 생성하려 할 때, 불필요한 값들도 같이 설정된다는 점이 있다. 위 코드를 예시로 들었을 때 클라이언트는 servingSize와 servings, fat 값만 초기화하여 사용하고 싶은데, 그러기 위해서 calories 값도 초기화를 해야 한다. 매개변수 servingSize, servings, fat을 가지는 생성자가 없기 때문이다.
매개변수 개수가 많아지면 코드를 작성하거나 읽기 어려워진다. 현재 필드 값이 6개뿐임에도 불구하고, 필요한 생성자가 5개나 존재한다. 또한 타입이 같은 매개변수가 연달아 있으면 내가 어떤 필드 값을 초기화하려 했는지 헷갈릴 수도 있다. main 메서드에서 new NutritionFacts(10, 3, 3);으로 생성자를 호출했지만 10, 3, 3이 어떤 파라미터 값에 들어가는 건지 한눈에 알아보기 어렵다.
자바빈즈 패턴
자바빈즈 패턴은 매개변수가 없는 생성자로 객체를 만든 후, 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() { }
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 int calc() {
return servingSize * servings;
}
public static void main(String[] args) {
NutritionFacts n = new NutritionFacts();
int quantity = n.calc();
n.setServingSize(10);
n.setServings(5);
}
}
자바빈즈 패턴의 단점은 다음과 같다. 객체 하나를 제대로 만들기 위해 set 메서드를 여러 번 호출해주어야 한다. 또한 위 예제의 main 메서드에서는 NutritionFacts 객체의 필수 값인 servingSize와 servings가 설정이 되지 않은 상태로 calc() 메서드를 호출하였다. 이처럼 필수 값이 설정되지 않은 상태로 객체가 쓰일 수 있다. 그리고 현재는 servingSize와 sevings 값이 필수 값임을 주석으로 명시해 주었지만, 만약 그렇지 않다면 어떠한 값이 필수 값인지 알 수 없다.
물론 이러한 단점을 해소할 수 있는 방법이 있다. 기본 생성자를 사용하지 않고, 필수 값을 매개 변수로 하는 생성자를 만들어 사용하면 된다.
public class NutritionFacts {
// 중략
public NutritionFacts(int servingSize, int servings) {
this.servingSize = servingSize;
this.servings = servings;
}
// 중략
public static void main(String[] args) {
NutritionFacts n = new NutritionFacts(10, 5);
int quantity = n.calc();
n.setServings(1); // 불변성을 보장할 수 없다.
}
}
하지만 이렇게 만들어도 불변 객체로 만들기 어렵다는 단점은 여전히 존재한다. 한 번 설정한 값은 변경하지 않게 만들고 싶은 것이 불변성인데, set 메서드를 사용해 버리면 값을 변경할 수 있으니 불변성이 지켜지지 못하는 것이다.
빌더 패턴
빌더 패턴을 사용해 객체를 얻는 방법은 다음과 같다.
1. 필수 매개변수가 담긴 생성자(혹은 정적 팩토리 메서드)를 호출해 빌더 객체를 얻는다.
2. 빌더 객체가 제공하는 메서드-일종의 setter 메서드-들로 원하는 선택 매개변수를 설정한다.
3. 매개변수가 없는 build 메서드를 호출해 필요한 객체를 얻는다.
대게 빌더는 생성할 클래스 안에 정적 멤버 클래스로 만들어둔다.
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) { // 1.
this.servingSize = servingSize;
this.servings = servings;
}
public Builder calories(int val) { // 2.
calories = val;
return this;
}
public Builder fat(int val) { // 2.
fat = val;
return this;
}
public Builder sodium(int val) { // 2.
sodium = val;
return this;
}
public Builder carbohydrate(int val) { // 2.
carbohydrate = val;
return this;
}
public NutritionFacts build() { // 3.
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;
}
@Override
public String toString() { // 편의상 작성한 것으로 빌더 패턴과는 무관하다.
return "servingSize : " + servingSize
+ "\nservings : " + servings
+ "\ncalories : " + calories
+ "\nfat : " + fat
+ "\nsodium : " + sodium
+ "\ncarbohydrate : " + carbohydrate;
}
public static void main(String[] args) {
NutritionFacts n = new Builder(240, 8)
.calories(10)
.fat(10)
.build();
System.out.println(n.toString());
}
}
필수 필드 값을 Builder 클래스의 생성자의 매개변수로 지정함으로써, 생성자에 필수 값을 담을 수 있다.
필수 값이 아닌 것은 Builder 타입을 반환하는 메서드를 사용해 선택적으로 설정 가능하게 만든다.
빌더를 통해 생성한 객체는 다시 변경할 수 없으므로 불변성을 가진다. main() 메서드에서 인스턴스를 생성하는 모습을 보면 NutritionFacts 인스턴스 n에 어떠한 값이 설정되는지 한 눈에 볼 수 있다. 결과적으로 빌더 패턴을 사용하면 점층적 생성자의 장점(불변성)과 자바빈즈의 장점(가독성)을 가질 수 있다.
setter와 차이점
앞서 말했듯이 빌더 패턴에서도 setter와 유사한 동작을 하는 메서드를 사용한다고 하였다. 이는 반환 타입이 Builder인 메서드들을 의미한다(Builder calories(), Builder fat(), ...). setter() 메서드와의 차이점은 setter() 메서드의 반환 타입은 void이지만, 빌더 패턴에서 매개변수를 set 해주는 메서드는 반환 타입이 Builder라는 것이다. 대수롭지 않은 차이라고 생각될 수 있지만 (this-위 예제에서는 Builder-를 return하여) Builder 타입을 반환함으로써 메서드 체이닝(Method Chaining)이 가능하게 만들어준다.
또한 빌더 패턴을 사용하면, setter() 메서드를 사용했을 때는 보장할 수 없는 불변성을 보장해 준다는 장점이 있다.
NutritionFacts 객체를 생성하고 난 뒤, setCalories() 메서드를 사용해 객체 내 calories 값을 언제든지 변경할 수 있다. 그러나 빌더 패턴을 사용해 build() 메서드로 NutritionFacts 객체를 생성하고 나면, 객체 내 값들을 변경할 수 있는 방법이 존재하지 않는다. (값을 변경하는 메서드를 따로 만들지 않는다면..) 그냥 새로운 NutritionFacts 객체를 하나 더 만드는 방법밖에 없다.
빌더 패턴 단점
빌더는 오히려 코드를 이해하기 어렵게 만들고, 필드가 중복이 되므로 반드시 빌더 패턴이 좋다고 말할 수는 없다. 위 예제에서도 NutritionFacts 클래스 안에 Builder 내부 클래스를 선언하였고(이것만 해도 벌써 복잡하다), NutritionFacts 클래스 내 모든 필드가 Builder 클래스 내부에도 동일하게 선언되어 있다. 코드의 양도 길어질뿐더러, 메모리도 (빌더 패턴을 사용하지 않은 것보다) 많이 잡아먹는다.
Lombok
물론 롬복 라이브러리를 사용해 손쉽게 빌더를 생성할 수 있다. 이를 통해 빌더 패턴을 사용할 때 가독성을 좋게 만들 수 있다.
lombok 라이브러리를 추가하고 클래스 윗부분 또는 클래스의 생성자 윗부분에 @Builder 어노테이션만 붙여주면 된다.
하지만 이도 단점이 존재하는데, 우리가 손수 빌더 패턴을 만들었을 때처럼 필수적인 필드 값을 생성자에 넣어 설정할 수 없다. 그러한 기능을 제공하지 않기 때문이다.
또한 모든 필드 값을 매개변수로 받는(모든 파라미터를 받는) 생성자가 기본으로 생긴다.
import lombok.Builder;
@Builder
public class NutritionFacts {
private int servingSize;
private int servings;
private int calories;
private int fat;
public static void main(String[] args) {
NutritionFacts n = NutritionFacts.builder()
.calories(2)
.build();
NutritionFacts n2 = new NutritionFacts(20, 5, 1, 1);
}
}
빌더를 사용하지 않더라도 생성자로 객체를 생성할 수 있게 된다. n2 인스턴스는 정상적으로 생성된다. 오류는 발생하지 않는다. 기본으로 생성되는 모든 파라미터를 받는 생성자의 사용을 막기 위해서는 @AllArgsConstructor(access = AccessLebel.PRIVATE)로 설정하는 방법이 있다.
Effective Java (이펙티브 자바) - 조슈아 블로크
[인프런] 이펙티브 자바 완벽 공략 1부 - 백기선