들어가며
지난 Item01에서는 public 생성자의 대안으로 정적 팩토리 메서드를 사용하는 것에 대해 정리했다. 많은 경우에 정적 팩토리 메서드는 public 생성자에 비해 장점을 가지고 있지만, 두 방법 모두 똑같은 제약을 가지고 있다. 바로 객체에 전달할 매개변수 중 선택적 매개변수가 많을 경우에 그에 대한 유연성이 떨어진다는 점이다. 이번 포스트에서는 선택적 매개변수가 많을 경우 public 생성자, 정적 팩토리 메서드의 대안들에 대해 알아보고 그 중에서 빌더 패턴에 대해 정리해볼 예정이다.
확장의 어려움
앞에서 언급했듯이 public 생성자와 정적 팩토리 메서드 기법은 확장이 어렵다. 예를 들어 건강 식품(단백질 보충제, 기타 아미노산 보충제 등)의 경우, 1회 제공량, 총 제공량, 중량 등의 공통적인 항목이 있을 수 있지만 단백질 함량, 탄수화물 함량, 비타민 A, B, C의 함량 등 어떤 제품은 아예 없지만 어떤 제품은 중요한 항목이 있을 수 있다. 예로부터 개발자들은 이러한 상황을 해결하기 위해 점층적 생성자 패턴(Telescoping constructor pattern)을 즐겨 사용했다.
점층적 생성자 패턴
점층적 생성자 패턴이란 간단히 말하면 생성자를 매개변수의 수를 점층적으로 늘려가며 생성하는 기법이다. 매개인자가 없는 기본 생성자, 매개인자가 1개인 생성자, 2개인 생성자... 와 같이 생성자를 여러 개 만드는 기법이며 아래는 점층적 생성자 패턴의 예이다.
public class HealthProduct{
private final int onceAmount; // 1회 제공량
private final int totalAmount; // 총 제공량
private final int weight; // 제품 중량
private final double protein; // 단백질 함량
private final double fat; // 지방 함량
private final double vitaminA; // 비타민 A 함량
private final double vitaminB; // 비타민 B 함량
public HealthProduct(int onceAmount, int totalAmount, int weight){
this.onceAmount = onceAmount;
this.totalAmount = totalAmount;
this.weight = weight;
}
public HealthProduct(int onceAmount, int totalAmount, int weight, double protein){
this.onceAmount = onceAmount;
this.totalAmount = totalAmount;
this.weight = weight;
this.protein = protein;
}
public HealthProduct(int onceAmount, int totalAmount, int weight, double protein, double fat){
this.onceAmount = onceAmount;
this.totalAmount = totalAmount;
this.weight = weight;
this.protein = protein;
this.fat = fat;
}
public HealthProduct(int onceAmount, int totalAmount, int weight, double protein, double fat, double vitaminA){
this.onceAmount = onceAmount;
this.totalAmount = totalAmount;
this.weight = weight;
this.protein = protein;
this.fat = fat;
this.vitaminA = vitaminA;
}
...
}
이 경우 만약 1회 제공량이 10mg, 총 제공량이 3000mg, 총 중량이 700g인 단백질, 지방 함량이 없고 비타민 A 함량이 3000mg인 비타민 보충제의 인스턴스를 생성하려면 HealthProduct vitaminAProduct = new HealthProduct(10, 3000, 700, 0, 0, 3000); 와 같이 실제 설정하기를 원치 않는 단백질, 지방 함량도 0으로 넣어주어야 한다. 이 경우에는 매개변수가 그나마 적은 축에 속하지만 실제 비즈니스 로직을 구현하다보면 매개변수가 20개를 넘어가는 일도 허다하다.
정리하자면 점층적 생성자 패턴을 이용하여 가변개수의 매개변수에 대응을 할 수 는 있지만, 매개변수의 수가 많다면 코드를 읽거나 작성하기 어렵다는 것이다. 또, 같은 타입의 변수가 연달아 열거되어 있다면 실수로 매개변수의 숫자를 바꿔서 넣더라도 오류를 파악하기 어렵다는 단점이 있다. 이러한 점층적 생성자 패턴의 단점을 보완하기 위한 방법으로 자바 빈즈 패턴이 있다.
자바 빈즈 패턴
자바 빈즈 패턴이란, 간단하게 기본 생성자로 객체를 만든 후 setter를 통해 각 매개변수를 설정하는 방법이다. 그 예는 아래와 같다.
public class HealthProduct{
private final int onceAmount; // 1회 제공량
private final int totalAmount; // 총 제공량
private final int weight; // 제품 중량
private final double protein; // 단백질 함량
private final double fat; // 지방 함량
private final double vitaminA; // 비타민 A 함량
private final double vitaminB; // 비타민 B 함량
public HealthProduct() {}
public void setOnceAmount(int onceAmount) {this.onceAmount = onceAmount;}
public void settotalAmount(int totalAmount) {this.totalAmount = totalAmount;}
public void setWeight(int weight) {this.weight = weight;}
public void setProtein(double protein) {this.protein = protein;}
...
}
이 경우 점층적 생성자 패턴에서와 같은 비타민 A 보충제의 인스턴스를 생성하고자 할 때에는 아래와 같이 생성할 수 있다.
HealthProduct vitaminAProduct = new HealthProduct();
vitaminAProduct.setOnceAmount(10);
vitaminAProduct.setTotalAmount(3000);
vitaminAProduct.setWeight(700);
vitaminAProduct.setVitaminA(3000);
이와 같이 객체를 생성하게 된다면 점층적 생성자 패턴에서 나타났던 불필요한 매개변수 설정이나 각 매개변수가 무엇을 뜻하는지 알기 어렵다는 단점이 사라지게 된다. 하지만 자바 빈즈 패턴 또한 단점을 가지고 있는데, 객체 하나를 만들기 위해 너무 많은 메소드를 호출해야 한다는 점과 객체가 완전히 생성되기 전까지는 일관성이 무너진다는 점이다. 따라서 자바 빈즈 패턴에서는 클래스를 불변으로 만들 수 없으며 스레드 안정성을 얻으려면 개발자가 추가 작업을 해주어야 한다.
빌더 패턴
이러한 점층적 생성자 기법, 자바 빈즈 패턴의 단점을 상쇄해줄 수 있는 것이 바로 빌더 패턴이다. 빌더 패턴에서는 개발자가 필요한 객체를 직접 만드는 대신, 필수 매개변수만으로 생성자나 정적 팩토리 메소드를 호출하여 빌더 객체를 얻은 뒤, 빌더 객체가 제공하는 일종의 세터 메소드를 통해 선택적 매개변수들을 설정하고 마지막으로 build 메소드를 호출하여 객체를 얻게 된다. 이러한 빌더는 보통 클래스 내부에 정적 멤버 클래스로 정의하는게 일반적이며 그 예를 살펴보겠다.
public class HealthProduct {
private final int onceAmount;
private final int totalAmount;
private final int weight;
private final double fat;
private final double protein;
private final double vitaminA;
private final double vitaminB;
public static class Builder{
private final int onceAmount;
private final int totalAmount;
private final int weight;
private double fat = 0.0;
private double protein = 0.0;
private double vitaminA = 0.0;
private double vitaminB = 0.0;
public Builder(int onceAmount, int totalAmount, int weight) {
// 여기서 입력값의 유효성 검사 가능
this.onceAmount = onceAmount;
this.totalAmount = totalAmount;
this.weight = weight;
}
public Builder fat(double val) {
// 여기서도 입력값의 유효성 검사 가능
this.fat = val;
return this;
}
public Builder protein(double val) {
this.protein = val;
return this;
}
public Builder vitaminA(double val) {
this.vitaminA = val;
return this;
}
public Builder vitaminB(double val) {
this.vitaminB = val;
return this;
}
public HealthProduct build() {
return new HealthProduct(this);
}
}
private HealthProduct(Builder builder) {
// 최종적으로 builder 내부의 값들을 복사하여 불변식을 검사할 수 있다.
onceAmount = builder.onceAmount;
totalAmount = builder.totalAmount;
weight = builder.weight;
fat = builder.fat;
protein = builder.protein;
vitaminA = builder.vitaminA;
vitaminB = builder.vitaminB;
}
}
HealthProduct 내부에 Builder 클래스를 선언했고 필수 요소들은 생성자를 통해 받도록 했고 선택 요소들은 각 요소의 이름으로 메소드를 만들고 Builter를 반환하도록 했다. 빌더 내부의 선택요소 setter는 전부 Builder 자신을 반환하기 때문에 연쇄적으로 호출이 가능하다. 이 방식은 호출이 흐르듯 연결된다는 뜻에서 fluent API 혹은 method chaining 이라고 한다. 이 경우 인스턴스 생성은 아래와 같이 할 수 있다.
HealthProduct healthProduct = new HealthProduct.Builder(10, 3000, 700).vitaminA(3000).build();
이와 같이 객체를 생성한다면 점층적 생성자 기법, 자바 빈즈 기법에서 나타났던 문제들을 해결할 수 있다. 여기서는 생략했지만 각 매개변수에 대한 유효성을 검사하려면 빌더의 생성자, 메서드에서 입력된 값을 검사하고 build 메서드가 호출하는 생성자에서 여러 매개 변수에 대한 불변식을 검사(값 validation)하는 방식을 사용할 수 있다.
💡 불변(immutable)은 어떠한 변경도 허용하지 않는다는 뜻이며 대표적으로 한 번 만들어지면 절대 값을 바꿀 수 없는 String 객체가 있다.
불변식(invariant)는 프로그램 런타임 혹은 정해진 기간 동안 반드시 만족해야 하는 조건을 뜻한다. 즉, 변경이 허용은 되지만 주어진 조건 내에서만 허용되어야 한다는 것이다. 리스트의 크기는 변경될 수 있으나 항상 0 이상이어야 한다는 것들이 이에 속한다.
정리하면 가변 객체에도 불변식을 존재할 수 있으며, 불변은 불변식의 극단적 예라고 볼 수 있다.
정리
빌더 패턴을 상당히 유연하며 빌더에 넘기는 매개변수에 따라 다른 객체를 만들 수도 있을 뿐만 아니라 특정 필드의 경우 빌더가 알아서 채우도록 할 수도 있다. 하지만 빌더 패턴을 사용하려면 사용에 앞서 빌더를 만들어야 하며 매개변수가 4개 이상 정도는 되어야 값어치를 한다고 볼 수 있다.
이러한 장단점을 따져 정적 팩토리 메서드 기법과 빌더 패턴의 사용을 고려해야 하며, 책에서는 처리해야 할 매개변수가 적다면 정적 팩토리 메서드, 많다면 빌더 패턴을 권고하고 있다. 나 같은 경우에는 평소에 객체 생성에 대한 이름을 부여하여 개발자가 이해하기 쉽다는 장점에 의해 정적 팩토리 메서드 패턴을 즐겨 사용했으나, 앞으로는 무조건적으로 해당 패턴을 사용하기 보다는 상황에 따라 유연하게 사용하는 쪽으로 변화해야겠다.
추가 - @Builder
정리에서 언급한대로 빌더 패턴은 장점도 있지만 단점도 존재하는데, 그 중 큰 단점은 빌더를 미리 만들어야 한다는 점이다. 이 단점은 Lombok을 이용하면 쉽게 극복이 가능한데, @Builder 어노테이션을 사용하면 된다. 위에서 살펴보았던 예제와 동일한 클래스에 빌더를 생성하려면 아래와 같이 클래스 위에 @Builder 어노테이션을 추가해주기만 하면 된다.
@Builder
public class HealthProduct2 {
private final int onceAmount;
private final int totalAmount;
private final int weight;
private final double fat;
private final double protein;
private final double vitaminA;
private final double vitaminB;
}
@Builder 어노테이션은 클래스 위에 붙여주기만 하면 자동으로 해당 클래스 내부에 Builder 클래스를 만들고 각 매개변수의 setter를 만들어주며 모든 매개변수를 받아 객체를 생성해주는 생성자 또한 자동으로 생성해준다 - @AllArgsConstructor를 내부적으로 약하게 포함하고 있다고 봐도 된다 -. 실제로 빌드를 진행한 후에 만들어진 자바 클래스를 보면 아래와 같다.
public class HealthProduct {
private final int onceAmount;
private final int totalAmount;
private final int weight;
private final double fat;
private final double protein;
private final double vitaminA;
private final double vitaminB;
// 모든 매개변수를 인자로 받는 생성자 자동 생성
HealthProduct(final int onceAmount, final int totalAmount, final int weight,
final double fat, final double protein, final double vitaminA, final double vitaminB) {
this.onceAmount = onceAmount;
this.totalAmount = totalAmount;
this.weight = weight;
this.fat = fat;
this.protein = protein;
this.vitaminA = vitaminA;
this.vitaminB = vitaminB;
}
public static HealthProduct.HealthProductBuilder builder() {
return new HealthProduct.HealthProductBuilder();
}
// 빌더 클래스 자동 생성
public static class HealthProductBuilder {
private int onceAmount;
private int totalAmount;
private int weight;
private double fat;
private double protein;
private double vitaminA;
private double vitaminB;
HealthProductBuilder() {
}
// 각 매개변수의 setter 자동 생성
public HealthProduct.HealthProductBuilder onceAmount(final int onceAmount) {
this.onceAmount = onceAmount;
return this;
}
public HealthProduct.HealthProductBuilder totalAmount(final int totalAmount) {
this.totalAmount = totalAmount;
return this;
}
public HealthProduct.HealthProductBuilder weight(final int weight) {
this.weight = weight;
return this;
}
public HealthProduct.HealthProductBuilder fat(final double fat) {
this.fat = fat;
return this;
}
public HealthProduct.HealthProductBuilder protein(final double protein) {
this.protein = protein;
return this;
}
public HealthProduct.HealthProductBuilder vitaminA(final double vitaminA) {
this.vitaminA = vitaminA;
return this;
}
public HealthProduct.HealthProductBuilder vitaminB(final double vitaminB) {
this.vitaminB = vitaminB;
return this;
}
public HealthProduct build() {
return new HealthProduct(this.onceAmount, this.totalAmount, this.weight, this.fat, this.protein, this.vitaminA, this.vitaminB);
}
public String toString() {
return "HealthProduct.HealthProductBuilder(onceAmount=" + this.onceAmount + ", totalAmount=" + this.totalAmount + ", weight=" + this.weight + ", fat=" + this.fat + ", protein=" + this.protein + ", vitaminA=" + this.vitaminA + ", vitaminB=" + this.vitaminB + ")";
}
}
}
주의 사항 - @Builder와 생성자
@Builder를 사용하는 경우에 @NoArgsConstructor 혹은 일부 매개변수만을 갖는 생성자를 만들어 둔다면 컴파일 에러가 나는 것을 확인할 수 있다.
위에서 @Builder 어노테이션에는 @AllArgsConstructor 가 포함되어 있다고 볼 수 있다고 했는데 더 정확히 보면 해당 어노테이션이 명시적으로 포함된 것이 아닌 weak @AllArgsConstructor 가 포함되어 있다고 보는 것이 더 정확하다. 왜 약한 포함이라고 표현했냐면 @Builder 어노테이션에 포함된 @AllArgsConstructor는 다른 명시적인 생성자 (기본 생성자 - @NoArgsConstructor 혹은 일부 매개변수만을 포함한 생성자 - @RequiredArgsConstructor) 가 선언되어 있다면 적용되지 않기 때문이다.
본문에서 살펴보았듯이 @Builder는 모든 매개변수를 포함하는 생성자가 해당 클래스 내부에 존재해야만 적용가능하기 때문에 모든 매개변수가 포함된 생성자가 명시적으로 선언되지 않은 상태에서 @Builder 어노테이션을 사용할 경우 컴파일 에러가 나는 것이다.
'Java' 카테고리의 다른 글
Jackson 라이브러리의 직렬화/역직렬화 (0) | 2022.09.21 |
---|---|
제네릭을 이용한 마이바티스 쿼리 유틸 만들어보기 (0) | 2022.09.13 |
GC 개념 및 동작 원리 (0) | 2022.09.13 |
JVM 이란? (0) | 2022.08.23 |
[Effective Java] Item01. 생성자 대신 정적 팩토리 메서드를 고려하라 (0) | 2022.01.10 |
댓글