생성자가 3개까지는 점층적 생성자 패턴을 사용해도 크게 문제가 없다. 점층적 생성자 패턴이란, 평소 생성자를 생성하는 방식으로 생성자를 정의하는 패턴이다.
public class Member {
private String name;
private int age;
private String job;
private String email;
private LocalDateTime createdAt;
private LocalDateTime modifiedAt;
public Member(String name) {
this.name = name;
this.createdAt = LocalDateTime.now();
this.modifiedAt = LocalDateTime.now();
}
public Member(String name, int age, String job, String email) {
this.name = name;
this.age = age;
this.job = job;
this.email = email;
this.createdAt = LocalDateTime.now();
this.modifiedAt = LocalDateTime.now();
}
}
이렇게 생성자가 3개 이상에 점층적 생성자 패턴을 적용해보았다. 특히 필수 생성자가 3개를 초과하면 굉장히 복잡해진다. 해당 Member를 인스턴스하기 위한 생성자 호출을 해보자.
Member member = new Member("hello world", 14, "학생", "marrin1101@naver.com");
생성자 인자가 많다면 우선 IDE 도움을 받기 쉽지 않고, 물론 인텔리제이의 경우 어느정도 지원해주기는 하나, 인자 입력에 따라 IDE 지원을 받지 못할 수 도 있다. 게다가 인자가 많으면 이게 어떤 인자인지 헷갈리고 같은 타입인 경우, 해당 인자에 들어갈 입력값이 맞는지 헷갈리는 경우가 많다.
이를 개선하기 위해 자바 빈즈 패턴을 적용해보자
public class Member {
private String name;
private int age;
private String job;
private String email;
private LocalDateTime createdAt;
private LocalDateTime modifiedAt;
public void setName(String name) {
this.name = name;
}
public void setAge(int age) {
this.age = age;
}
public void setJob(String job) {
this.job = job;
}
public void setEmail(String email) {
this.email = email;
}
public void setCreatedAt(LocalDateTime createdAt) {
this.createdAt = createdAt;
}
public void setModifiedAt(LocalDateTime modifiedAt) {
this.modifiedAt = modifiedAt;
}
}
setter를 활용해서 인스턴스를 초기화하는 방법이다. 문제점은 불변을 보장하지 않고 생성자를 초기화할때 매번 인스턴스 변수를 위해 초기화를 진행해 주어야 하기 때문에 인스턴스 변수가 많다면 이것 또한 인스턴스 생성이 힘들어진다. 이럴 때 가장 좋은 방식은 빌더 패턴이다.
public class Member {
private final String name;
private final int age;
private final String job;
private final String email;
private final LocalDateTime createdAt;
private final LocalDateTime modifiedAt;
public static class Builder {
// 필수 변수
private final String name;
private final int age;
private final LocalDateTime createAt;
private final LocalDateTime modifiedAt;
private String job;
private String email;
public Builder(String name, int age) {
this.name = name;
this.age = age;
this.createAt = LocalDateTime.now();
this.modifiedAt = LocalDateTime.now();
}
public Builder job(String job) {
this.job = job;
return this;
}
public Builder email(String email) {
this.email = email;
return this;
}
public Member build() {
return new Member(this);
}
}
private Member(Builder builder) {
this.name = builder.name;
this.age = builder.age;
this.job = builder.job;
this.email = builder.email;
this.createdAt = builder.createAt;
this.modifiedAt = builder.modifiedAt;
}
}
빌더 패턴은 외부에서 생성자 호출을 막고 builder 패턴의 build로 최종적으로 생성자를 호출하는 패턴이다. 해당 패턴은 중첩 클래스의 인스턴스를 생성해서 초기화하기 때문에 성능 문제가 있지 않느냐하면 할 말이 없지만, 인스턴스 비용보다 코드 관리에서 이점이 굉장히 크기 때문에 사용하는 패턴이다. 이를 호출하는 방법은 다음과 같다.
Member member = new Member.Builder("james", 14)
.job("덕수")
.email("hello@naver.com")
.build();
Member 생성 패턴을 살펴보면 필수 생성인자와 선택 생성 인자를 쉽게 파악할 수 있고 member 인스턴스가 어떤 인자로 생성되는지 조금 더 쉽게 파악가능한 장점이 있다.
Pizza라는 최상위 타입이 있고 NeworkPizza와 Calzone 이 서브타입이라 가정하자. 이때도 builder를 이용하면 유연하게 생성자 생성 전략을 가져갈 수 있다.
import java.util.EnumSet;
import java.util.Set;
public abstract class Pizza {
public enum Topping {HAM, MUSHROOM, ONION, PEPPER, SAUSAGE}
private final Set<Topping> toppings;
abstract static class Builder<T extends Builder<T>> {
private EnumSet<Topping> toppings = EnumSet.noneOf(Topping.class);
public T addTopping(Topping topping) {
toppings.add(topping);
return self();
}
public abstract Pizza build();
protected abstract T self();
}
protected Pizza(Builder<?> builder) {
toppings = builder.toppings.clone();
}
}
Builder를 abstract로 정의한다. sub type에서는 해당 builder를 재정의 할 것이다. 이떄 제네릭은 <T extends Builder>로 정의 되어있는데 이 뜻은 Builder의 subtype T 타입을 Builder 제네릭으로 쓸 수 있다는 의미이다.
특이한 점은 이전과 다르게 self()를 이용해 자기 자신을 호출하는데 그 이유는 서브 타입 builder는 상위 builder를 상속받기 때문에 만약 상위 builder가 return this를 반환하면 builder 패턴은 서브타입 builder가 아닌 상위 타입 builder가 리턴된다. 그러면 하위 타입의 builder를 활용할 수 없기 때문에 self를 protected로 하위 타입에서 재구현하고 이를 리턴하도록 하는 것이다.
이를 이용한 하위 타입의 builder 구현은 다음과 같다.
public class NewYorkPizza extends Pizza {
public enum Size {SMALL, MIDIUM, LARGE}
private final Size size;
public static class Builder extends Pizza.Builder<Builder> {
private final Size size;
public Builder (Size size) {
this.size = size;
}
@Override
public NewYorkPizza build() {
return new NewYorkPizza(this);
}
@Override
protected Builder self() {
return this;
}
}
private NewYorkPizza(Builder builder) {
super(builder);
this.size = builder.size;
}
}
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 Pizza build() {
return new Calzone(this);
}
@Override
protected Builder self() {
return this;
}
}
private Calzone(Builder builder) {
super(builder);
sauceInside = builder.sauceInside;
}
}
NewYorkPizza newYorkPizza = new NewYorkPizza.Builder(Size.LARGE)
.addToppings(Topping.HAM)
.addToppings(Topping.ONION)
.build();
상속을 활용해 중복되는 인자를 상속 builder를 활용해 처리하고 있다. 이처럼 builder는 상속에도 유연한 패턴을 가진다. 다만 self 자체가 자바에서는 지원하지 않기 때문에 이 부분 구현만 유의하도록 한다.
해당 코드를 실전에서 사용하기엔 길고 복잡하다. builder라는 패턴을 사용하기 위해 클래스에 필요한 코드보다 훨씬 많은 양의 코드가 들어간다. 이를 Lombok을 활용해 해결할 수 있다.
@SuperBuilder
public abstract class LombokPizza {
public enum Topping {HAM, ONION}
@Singular("addTopping")
private final Set<Topping> toppings;
}
@SuperBuilder를 활용해 코드를 구현한다. @SuperBuilder는 상속 가능한 Builder를 Annotation Processor를 활용해 구현한다. @Singular 어노테이션은 Collection 타입에 단순 객체 생성이 아닌 Collection에 add를 수행하는 형식으로 builder 패턴을 생성한다.
@SuperBuilder
public class LombokNewYorkPizza extends LombokPizza {
public enum Size {SMALL, LARGE}
private final Size size;
}
끝났다. 상당히 간단하지 않은가? 만약 우리가 구현해야 할 Builder가 있다면 Lombok 활용을 추천한다.
public abstract class LombokPizza {
private final Set<Topping> toppings;
protected LombokPizza(LombokPizzaBuilder<?, ?> b) {
Set toppings;
switch (b.toppings == null ? 0 : b.toppings.size()) {
case 0:
toppings = Collections.emptySet();
break;
case 1:
toppings = Collections.singleton((Topping)b.toppings.get(0));
break;
default:
Set<Topping> toppings = new LinkedHashSet(b.toppings.size() < 1073741824 ? 1 + b.toppings.size() + (b.toppings.size() - 3) / 3 : Integer.MAX_VALUE);
toppings.addAll(b.toppings);
toppings = Collections.unmodifiableSet(toppings);
}
this.toppings = toppings;
}
public abstract static class LombokPizzaBuilder<C extends LombokPizza, B extends LombokPizzaBuilder<C, B>> {
private ArrayList<Topping> toppings;
public LombokPizzaBuilder() {
}
protected abstract B self();
public abstract C build();
public B addTopping(Topping addTopping) {
if (this.toppings == null) {
this.toppings = new ArrayList();
}
this.toppings.add(addTopping);
return this.self();
}
public B toppings(Collection<? extends Topping> toppings) {
if (toppings == null) {
throw new NullPointerException("toppings cannot be null");
} else {
if (this.toppings == null) {
this.toppings = new ArrayList();
}
this.toppings.addAll(toppings);
return this.self();
}
}
public B clearToppings() {
if (this.toppings != null) {
this.toppings.clear();
}
return this.self();
}
public String toString() {
return "LombokPizza.LombokPizzaBuilder(toppings=" + this.toppings + ")";
}
}
public static enum Topping {
HAM,
ONION;
private Topping() {
}
}
}
코드가 길어졌는데 보면 우리가 공부한 상속 builder 패턴과 상당한 유사점이 있음을 볼 수 있다.
public abstract static class LombokPizzaBuilder<C extends LombokPizza, B extends LombokPizzaBuilder<C, B>> {
private ArrayList<Topping> toppings;
public LombokPizzaBuilder() {
}
protected abstract B self();
public abstract C build();
public B addTopping(Topping addTopping) {
if (this.toppings == null) {
this.toppings = new ArrayList();
}
this.toppings.add(addTopping);
return this.self();
}
}
중요한 부분만 떼어서 builder 패턴을 보자.
조금 다른점이 있지만 핵심은 같다. abstract로 self와 build가 주어져있다. 조금 차이점이 있다면 위에 제네릭 부분인데, 이것도 사실은 같은 부분이다. 우리가 Pizza로 타입이 설정되어 있어서 상속받을 때 subClass로 type을 설정해도 Override하는데 큰 문제는 없었다. 그러나 위와 같이 제네릭으로 명시해두면, 좀 더 실수할 가능성이 줄고 더 정확하게 코드를 짤수 있을 것 같다.
public class LombokNewYorkPizza extends LombokPizza {
private final Size size;
protected LombokNewYorkPizza(LombokNewYorkPizzaBuilder<?, ?> b) {
super(b);
this.size = b.size;
}
public static LombokNewYorkPizzaBuilder<?, ?> builder() {
return new LombokNewYorkPizzaBuilderImpl();
}
private static final class LombokNewYorkPizzaBuilderImpl extends LombokNewYorkPizzaBuilder<LombokNewYorkPizza, LombokNewYorkPizzaBuilderImpl> {
private LombokNewYorkPizzaBuilderImpl() {
}
protected LombokNewYorkPizzaBuilderImpl self() {
return this;
}
public LombokNewYorkPizza build() {
return new LombokNewYorkPizza(this);
}
}
public abstract static class LombokNewYorkPizzaBuilder<C extends LombokNewYorkPizza, B extends LombokNewYorkPizzaBuilder<C, B>> extends LombokPizza.LombokPizzaBuilder<C, B> {
private Size size;
public LombokNewYorkPizzaBuilder() {
}
protected abstract B self();
public abstract C build();
public B size(Size size) {
this.size = size;
return this.self();
}
public String toString() {
String var10000 = super.toString();
return "LombokNewYorkPizza.LombokNewYorkPizzaBuilder(super=" + var10000 + ", size=" + this.size + ")";
}
}
public static enum Size {
SMALL,
LARGE;
private Size() {
}
}
}
책에서는 바로 final class로 LombokPizza.Builder를 상속받아서 구현했다. Lombok은 자동 생성이기 때문에 아마도 abstract class를 생성하고 이를 impl구현을 통해 완성한 것 같다. 또 다른 이유가 있다면 newYorkPizza의 상속 가능성을 열어두기 위해 구현했을 수도 있다.
책에서 나온 부분 중 제네릭 부분이 아쉬웠다. 그 이유는 Builder 제네릭이 T 타입으로 나여서 Pizza Build()를 자식이 상속할 때 자동 완성이 Pizza로 나와 아무생각없이 IDE를 활용해 구현하면 Cast type 에러가 뜬다. 그렇기에 좀 더 개선해보자
public abstract class Pizza {
public enum Topping {HAM, CHEESE, ONION}
private final Set<Topping> toppings;
abstract static class Builder<C extends Pizza, B extends Builder<C, B>> {
private EnumSet<Topping> toppings = EnumSet.noneOf(Topping.class);
public B addToppings(Topping topping) {
toppings.add(topping);
return self();
}
public abstract C build();
protected abstract B self();
}
protected Pizza(Builder<?, ?> builder) {
this.toppings = builder.toppings.clone();
}
}
public final class NewYorkPizza extends Pizza {
public enum Size {SMALL, MIDIUM, LARGE}
private final Size size;
public static final class Builder extends Pizza.Builder<NewYorkPizza, Builder> {
private final Size size;
public Builder (Size size) {
this.size = size;
}
@Override
public NewYorkPizza build() {
return new NewYorkPizza(this);
}
@Override
protected Builder self() {
return this;
}
}
private NewYorkPizza(Builder builder) {
super(builder);
this.size = builder.size;
}
}