빌더 패턴
정적 팩터리와 생성자에 선택적 매개변수가 많을 때 고려할 수 있는 방안
대안1: 점층적 생성자 패턴 또는 생성자 체이닝
• 매개변수가 늘어나면 클라이언트 코드를 작성하거나 읽기 어렵다.
public class A{
int a;
int b;
int c;
A(int a,int b) {}; //필요한 필드만 주입하여 생성하는 경우가 있다.
A(int b){};//3개 필드 ->최대 2^3==8개의 생성자가 생성된다.
............
}
대안2: 자바빈즈 패턴 (기본 생성자로 생성 후, setter를 통해 필요한 의존관계를 주입)
• 완전한 객체를 만들려면 메서드를 여러번 호출해야 한다. (일관성이 무너진 상 태가 될 수도 있다.)
ex) A객체는 a,b,c 객체를 주입받아야 사용될 수 있는데, a,b 객체만 setter로 주입되어도 A 객체를 사용할 수 있다
• 클래스를 불변으로 만들 수 없다(setter가 존재하면 값을 이후에 변경할 수 있기에 불변성이 깨진다.)
-> 객체 프리징을 통해서 해소가 가능하다. 다만 자바스크립트로 구성되 있고, 실제로 사용하는 경우가 많지는 않다.
권장하는 방법: 빌더 패턴
• 플루언트 API 또는 메서드 체이닝을 한다.
• 계층적으로 설계된 클래스와 함께 사용하기 좋다.
• 점층적 생성자보다 클라이언트 코드를 읽고 쓰기가 훨씬 간결하고, 자바빈즈 보다 훨씬 안전하다
요약: 필수가 아닌 필드는 유동적으로 생성시에 넘겨주지 않으며 생성하고, 불변 객체로 만들고 싶다면 빌더를 사용하자!
빌더 패턴 예시
public static ClubMemberWithRankDTO of(ClubMember clubMember, UrlUtil urlUtil) {
return ClubMemberWithRankDTO.builder()
.email(clubMember.getEmail().toLowerCase())
.name(clubMember.getName())
.build();
}
.........
@Builder
public class ClubMember {............}
위는 정적 팩토리 메소드로 엔티티를 DTO로 변환하는 메소드를 만든 것이다. 빌더 패턴을 사용하면 무수히 많은 생성자 파라미터 조합을 생각하지 않고, 간편하게 내가 추가하고 싶은 의존성만 빌더 패턴을 통해 전달하여 객체를 생성할 수 있다.
@Builder 롬북 어노테이션을 빌더를 사용할 객체에 추가 시, 롬북이 컴파일 시 해당 객체의 코드를 조작해서 빌더를 만들어준다.
코드 조작을 통해 변경 된 코드 예시
//
// Source code recreated from a .class file by IntelliJ IDEA
// (powered by FernFlower decompiler)
//
package me.whiteship.chapter01.item02.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 void main(String[] args) {
NutritionFacts cocaCola = (new Builder(240, 8)).calories(100).sodium(35).carbohydrate(27).build();
}
private NutritionFacts(Builder builder) {
this.servingSize = builder.servingSize;
this.servings = builder.servings;
this.calories = builder.calories;
this.fat = builder.fat;
this.sodium = builder.sodium;
this.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.servingSize = servingSize;
this.servings = servings;
}
public Builder calories(int val) {
this.calories = val;
return this;
}
public Builder fat(int val) {
this.fat = val;
return this;
}
public Builder sodium(int val) {
this.sodium = val;
return this;
}
public Builder carbohydrate(int val) {
this.carbohydrate = val;
return this;
}
public NutritionFacts build() {
return new NutritionFacts(this);
}
}
}
@Builder를 통한 빌더 생성시의 단점
- 모든 argument를 파라미터로 받는 생성자(AllArgConstructor)가 자동으로 생긴다.
외부에서 해당 객체를 생성하려고하면, AllArgCOnstructor가 노출되어 개발에 불편해진다.
해결책: @AllArgConstructor(access = AccessLevel.PRIVATE) 와 같이 설정을 줘서 private으로 막아 놓으면 된다!
-필수값을 지정할 수 없다.
빌더 패턴을 직접 구현해서 사용하면, 아래와 같이 필수 파라미터는 필수로 받아오고, optional한 값은 선택적으로 넣을 수 있다.
new NutritionFacts.Builder(10, 10) //필수 값들은 넣어서 생성해주고
.calories(10) //optional한 값만 옵션으로 넣어서 생성가능
.build();
하지만 @Builder 사용 시, 필수 값을 설정하지 못하고, 항상 optional하게 값을 가져와야한다.
즉, 아래의 예시와 같이 실수로 필수값을 제외해도, 그걸 캐치해낼 수 없다는 뜻이다.
NutritionFacts.builder() //필수 값들은 넣어서 생성해주고
//.id(10) //필수 값
//.name(10) //필수 값
.calories(10) //optional한 값만 옵션으로 넣어서 생성가능
.build();
빌더 패턴 자체의 단점
- 빌더를 만들기 위해 들어가는 코드가 너무 많다. 그러므로 매개변수의 개수가 4-5개 정도가 넘어가면 도입을 고려해보자.
빌더 패턴은 계층적으로 설계된 클래스와 함께 쓰기에 좋다.
추상 클래스는 추상빌더를 구체 클래스는 구체 빌더를 갖게 구현하는 것으로 빌더 팩토리 계층 구조를 재활용할 수 있다.
빌더를 상속 계층에서 만들어서 쓸 때 유용하게 쓸 수 있는 장치 중 하나인 self()를 설명하겠다.
이렇게하면, 빌더 팩토리 계층 구조를 재활용할 수 있게된다.
Pizza라는 부모 객체에 addTopping이라는 메소드가 있는데, 해당 메소드는 토핑을 저장하는 리스트에 파라미터로 들어온 토핑을 추가하고, builder를 통해 객체를 리턴하도록 구현해야한다고 생각하자.
결과적으로 아래와 같이, Pizza의 addTopping 메소드를 통해 자식 객체인 NyPizza와 Calzone 에 토핑을 추가해야한다.
public static void main(String[] args) {
NyPizza pizza = new NyPizza.Builder(SMALL)
.addTopping(SAUSAGE)
.addTopping(ONION).build();
Calzone calzone = new Calzone.Builder()
.addTopping(HAM).sauceInside().build();
System.out.println(pizza);
System.out.println(calzone);
}
우선, 부모 객체인 Pizza를 간단히 구현해보겠다.
public abstract class Pizza {
public enum Topping { HAM, MUSHROOM, ONION, PEPPER, SAUSAGE }
final Set<Topping> toppings;
//타입 제한
abstract static class Builder<T extends Builder<T>> {//Pizza 빌더는 자신의 하위타입의 빌더만 (NYPizza, Calzone) 만 호환된다.
EnumSet<Topping> toppings = EnumSet.noneOf(Topping.class);
abstract Pizza build();
public T addTopping(Topping topping) {
toppings.add(Objects.requireNonNull(topping));
return self(); // 하위 클래스는 이 메서드를 재정의(overriding)하여
// 하위 클래스 내에서 "this"를 반환하도록 해야 한다.
}
protected abstract T self();
}
Pizza(Builder<?> builder) {
toppings = builder.toppings.clone(); // 아이템 50 참조
}
}
addTopping() 메소드를 주목해서 살펴보자.
Pizza.Builder 클래스는 재귀적 타입 한정을 이용하는 제네릭 타입이다. 여기에 추상 메서드인 self를 더해 하위 클래스에서는 형 변환하지 않고도 메서드 연쇄를 지원할 수 있다. 이를 셀프 타입 관용구라 한다.
문제
아래와 같이 return this;를 수행하면 어떤 문제점이 생길까?
public abstract class Pizza {
....................
public Builder<T> addTopping (Topping topping){
toppings.add(Objects.requireNonNull(topping));
return this;
}
}
하위 객체가 addTopping을 사용하는데 return this 해버리면, 자기자신이 아니라 Pizza를 리턴한다. addTopping은 Pizza의 메소드이다. this == Pizza이지, 호출하는 하위 객체가 아니다. 따라서, Pizza를 반환해버리면, Pizza의 하위 객체인 NyPizza, Calzone 등이 자신이 가진 고유의 메소드를 사용할 수 없다( 상위의 Pizza 타입을 반환하기에 )
따라서, 아래와 같이 NyPizza.Builder()를 사용하더라도, 리턴은 Pizza로 들어오게 된다.
Pizza pizza= new NyPizza.Builder(SMALL)//NyPizza.Builder 사용시 pizza에서 this 리턴하여 Pizza 타입된다.
.addTopping(SAUSAGE)
.addTopping(ONION).build();
이번엔 하위 타입 피자를 알아보자.
package me.whiteship.chapter01.item02.hierarchicalbuilder;
import java.util.Objects;
// 코드 2-5 뉴욕 피자 - 계층적 빌더를 활용한 하위 클래스 (20쪽)
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; } //self 메소드 재정의
}
private NyPizza(Builder builder) {
super(builder);
size = builder.size;
}
@Override public String toString() {
return toppings + "로 토핑한 뉴욕 피자";
}
}
각 하위 클래스의 빌더가 정의한 build 메서드는 해당하는 구체 하위 클래스를 반환하도록 선언한다. NyPizza.Builder는 NyPizza를 반환한다. (하위 클래스의 메서드가 상위 클래스의 메서드가 정의한 반환 타입이 아닌, 그 하위 타입을 반환하도록)
이렇게 함으로써, 계층형 Builder를 사용하더라도, 자기자신에 대한 Builder를 생성할 수 있다.
NyPizza pizza = new NyPizza.Builder(SMALL).addTopping(ONION).build();
여기까지 빌더를 통해 생성자, 정적팩토리 등 객체 생성을 대체하는 법을 알아보았다.
이번엔 빌더를 디자인 패턴의 관점으로 확인해보자.
빌더 패턴 -디자인 패턴
'객체 생성' 프로세스를 분리된 빌더에게 위임하는 것으로 SRP를 지킬 수 있다.
추가적으로, 빌더를 사용하면 각 빌더마다 빌더가 제공하는 각각의 메소드에 가변인수를 적용하면 마치 여러개의 가변인수를 사용하는 것 처럼 사용 가능하다.
가변인수란?
동적인 개수의 인자를 받을 수 있게 만들어준다.
public class test {
public static void main(String[] args) {
test t = new test();
t.variable();
t.variable("A");
t.variable("A","B");
t.variable("A","B","C");
t.variable("A","B","C","D");
}
public void variable(String... s) {//가변된 수의 string인자를 받을 수 있다.
System.out.println(s);
}
}
위와 같이 "타입..." 을 통해 정의하면, 이제 해당 영역에는 호출자가 추가한 매개 변수 개수만큼 가변적으로 인자를 받을 수 있다.
하지만 가변인수는 다음과 같은 제약조건이 있다
- 각 메소드에는 1개의 가변인수만 사용 가능하고, 인자의 맨 오른쪽에 놔야한다.
ex) A(int a,string c, int... var);
요약
생성자나 정적 팩터리가 처리해야 할 매개변수가 많다면 빌더 패턴을 사용하자
'java' 카테고리의 다른 글
java의 동작 원리 및 구조 [JVM의 구조] (0) | 2023.08.02 |
---|---|
[java] 체크, 언체크 예외에 대한 분석과 잘못된 오해 (0) | 2023.07.18 |
[이펙티브 자바] 정적 팩토리 메소드 (0) | 2023.07.16 |
[이펙티브 자바] ENUM (0) | 2023.07.15 |