🏠 생성자에 매개변수가 많다면 빌더를 고려하라.
클래스를 설계하다보면 어떤 인스턴스임에 따라 필드 값이 필수일수도, 필요하지 않을 수도 있다.
이에 대해 모든 걸 고려해서 설계하다보면 생성자가 감당하기 힘들 정도로 많아진다.
Builder 패턴이 나오기 전에는 점층적 생성자 패턴을 즐겨 사용했다고 한다.
💊 점층적 생성자 패턴이란 ?
점층적 생성자 패턴(Telescoping Constructor Pattern)은 생성자 오버로딩을 이용하여 객체를 생성하는 패턴이다. 이 패턴은 매개변수가 많은 객체를 생성할 때 유용하지만 매개변수가 많아질수록 코드의 가독성이 떨어지고, 실수할 가능성이 높아진다.
public class Pizza {
private int size;
private boolean cheese;
private boolean pepperoni;
private boolean bacon;
public Pizza(int size) {
this.size = size;
}
public Pizza(int size, boolean cheese) {
this(size);
this.cheese = cheese;
}
public Pizza(int size, boolean cheese, boolean pepperoni) {
this(size, cheese);
this.pepperoni = pepperoni;
}
public Pizza(int size, boolean cheese, boolean pepperoni, boolean bacon) {
this(size, cheese, pepperoni);
this.bacon = bacon;
}
}
이와 같은 형식으로 각 생성자는 이전 생성자를 호출하는 형식으로 진행된다. 사용자는 자신에게 필요한 선택자 매개변수만 전달해 생성할 수 있다. 다만 사용자가 설정하길 원치 않는 매개변수까지 포함하기 쉬워 어쩔 수 없이 필요하지 않은 매개변수에도 값을 지정해줘야 하는 경우가 생길 수 있다는 단점이 존재한다.
요약하면, 점층적 생성자 패턴도 사용할 수 있지만 매개변수가 많아지면 클라이언트 코드를 작성하거나 읽기 어렵다 !
💊 자바빈즈 패턴이란 ?
자바 빈즈 패턴(JavaBeans Pattern)은 객체를 생성할 때 선택적 매개변수가 많을 경우 사용하는 패턴이다. 이 패턴은 객체의 생성과 초기화를 분리하여 구현된다. 객체는 매개변수가 없는 생성자로 생성되고, 이후 setter 메소드를 호출하여 선택적 매개변수를 설정한다.
public class Pizza {
private int size;
private boolean cheese;
private boolean pepperoni;
private boolean bacon;
public Pizza() {}
public void setSize(int size) {
this.size = size;
}
public void setCheese(boolean cheese) {
this.cheese = cheese;
}
public void setPepperoni(boolean pepperoni) {
this.pepperoni = pepperoni;
}
public void setBacon(boolean bacon) {
this.bacon = bacon;
}
}
Pizza pizza = new Pizza();
pizza.setSize(12);
pizza.setCheese(true);
pizza.setPepperoni(true);
코드가 길어지긴 했지만 인스턴스를 만들기 쉽고, 읽기 쉬운코드가 작성되었다.
하지만 자바빈즈 패턴의 심각한 단점이 존재한다. 객체 하나를 만들기 위해 메소드를 여러개 호출해야 하며, 객체가 완전히 생성되기 전까지는 일관성이 무너진 상태에 놓인다.
점층적 생성자 패턴은 생성자 코드 안에서만 유효성을 확인하면 됐기에 불변으로 만들 수 있지만, 자바빈즈 패턴에서는 디버깅도 만만치 않을 뿐더러 유효성을 확인하기 어렵다. => 어디에서든 setter 메소드를 통해 객체값을 변경할 수 있기 때문이다.
💊 그래서 나온게 빌더 (Builder) 패턴이란 ?
빌더 패턴(Builder Pattern)은 객체를 생성할 때 선택적 매개변수가 많을 경우 사용하는 패턴이다. 이 패턴은 객체의 생성과 초기화를 분리하여 구현된다. 객체는 빌더 클래스의 메소드를 호출하여 생성된다. 빌더 클래스의 메소드는 선택적 매개변수를 설정하고, 마지막으로 build() 메소드를 호출하여 객체를 생성한다.
public class Pizza {
private int size;
private boolean cheese;
private boolean pepperoni;
private boolean bacon;
public static class Builder {
private int size;
private boolean cheese = false;
private boolean pepperoni = false;
private boolean bacon = false;
public Builder(int size) {
this.size = size;
}
public Builder cheese(boolean value) {
cheese = value;
return this;
}
public Builder pepperoni(boolean value) {
pepperoni = value;
return this;
}
public Builder bacon(boolean value) {
bacon = value;
return this;
}
public Pizza build() {
return new Pizza(this);
}
}
private Pizza(Builder builder) {
size = builder.size;
cheese = builder.cheese;
pepperoni = builder.pepperoni;
bacon = builder.bacon;
}
}
Pizza 클래스는 불변이며, 모든 매개변수의 기본 값들을 한 곳에 모아 관리한다. 또한 setter 메서드는 빌더 자신을 반환하기 때문에 연쇄적으로 호출할 수 있다.
Pizza pizza = new Pizza.Builder(12)
.cheese(true)
.pepperoni(true)
.build();
이와 같이 클라이언트 코드는 쓰기 쉽고, 읽기도 쉽다. 빌더 패턴은 명명된 선택적 매개변수를 흉내낸 것이라고 한다.
잘못 전달된 매개변수를 최대한 일찍 발견하려면 빌더의 생성자와 메서드에서 입력된 매개변수를 검사하고, build 메서드가 호출하는 생성자에서 여러 매개변수에 걸친 불변식을 검사해야한다. 우리는 불변식과 불변에 대해서 구분할 필요가 있다.
불변(immutable)은 객체의 상태가 생성된 후 변경되지 않음을 의미한다. 불변 객체는 생성자에서 초기화된 후 내부 상태가 변경되지 않는다. 예를 들어, String 클래스는 불변 객체이다.
반면, 불변식(invariant)은 객체의 상태가 유효한지 확인하는 논리적 조건이다. 불변식은 객체의 생성자, 메소드 및 내부 연산에서 유지되어야 한다. 예를 들어, Stack 클래스의 불변식은 "스택의 크기는 항상 0 이상이어야 한다"일 수 있다. 보통 조건문을 통해 예외처리를 진행한다.
💊 계층적으로 설계된 클래스에 빌더 패턴 적용하기.
계층적으로 설계된 클래스에서 빌더 패턴을 적용하는 방법은 빌더 클래스를 상속하여 구현하는 것이다. 각 하위 클래스는 상위 클래스의 빌더를 상속받아 선택적 매개변수를 추가하고, build() 메소드를 오버라이드하여 해당 클래스의 객체를 생성한다. 추상 클래스는 추상 빌더를, 구체 클래스는 구체 빌더를 갖게 한다.
public abstract class Pizza {
private final boolean cheese;
private final boolean pepperoni;
private final boolean bacon;
public static abstract class Builder<T extends Builder<T>> {
private boolean cheese = false;
private boolean pepperoni = false;
private boolean bacon = false;
public T cheese(boolean value) {
cheese = value;
return self();
}
public T pepperoni(boolean value) {
pepperoni = value;
return self();
}
public T bacon(boolean value) {
bacon = value;
return self();
}
abstract Pizza build();
protected abstract T self(); //하위 클래스는 해당 메소드를 재정의 해야하며, this 를 반환해야한다.
}
Pizza(Builder<?> builder) {
cheese = builder.cheese;
pepperoni = builder.pepperoni;
bacon = builder.bacon;
}
}
Pizza.Builder 클래스는 재귀적 타입 한정을 이용하는 제네릭 타입이다.
재귀적 타입 한정(Recursive Type Bound)은 제네릭 클래스나 메소드에서 자기 자신의 타입을 참조하는 타입 한정이다. 이를 통해 제네릭 클래스나 메소드에서 자기 자신의 타입을 반환하거나 비교할 수 있다.
또한 추상메서드인 self 를 더해, 하위 클래스에서 형변환을 굳이 하지 않고도 메서드 연쇄를 지원할 수 있다. 다음은 Pizza 클래스를 상속받은 하위클래스 칼존 피자이다.
public class CalzonePizza extends Pizza {
private final boolean extraSauce;
public static class Builder extends Pizza.Builder<Builder> {
private boolean extraSauce = false;
public Builder extraSauce(boolean value) {
extraSauce = value;
return this;
}
@Override
public CalzonePizza build() {
return new NewYorkPizza(this);
}
@Override
protected Builder self() {
return this;
}
}
private CalzonePizza(Builder builder) {
super(builder);
extraSauce = builder.extraSauce;
}
}
칼존 피자는 Pizza 멤버변수에 소스의 추가여부인 extraSauce 를 필수로 받는 하위 클래스이다. 하위클래스가 정의한 build 메서드는 구체 하위 클래스를 반환하도록 설계한다. 이처럼 하위 클래스의 메서드가 상위클래스의 메서드가 정의한 반환 타입이 아닌, 하위 타입을 반환하는 기능을 공변 반환 타이핑이라고 한다. 해당 기능을 사용하면 형변환에 신경쓰지 않고 빌더 패턴을 사용해 개발할 수 있다.
빌더 패턴은 상당히 유연하다. 빌더 하나로 여러 객체를 순회하면서 만들 수 있고 빌더에 넘기는 매개변수에 따라 다른 객체를 만들 수도 있다. 다만 객체를 만드려면 빌더 부터 작성해야 한다는 단점 또한 존재한다. 이는 lombok 라이브러리의 @Builder 어노테이션을 통해 해결할 수는 있지만 계층적인 구조에서는 커스텀하게 사용하기는 어렵다. 빌더 생성 비용이 크지는 않지만 성능에 민감한 상황에서는 문제가 될 수도 있다.
또한 점층적 생성자 패턴보다는 코드가 장황해서 최소한 매개변수가 4개 이상은 되어야지 값어치를 한다고 한다. 다만 API 는 시간이 지남에따라 매개변수가 증가하는 경향이 있음을 명시하자. 후에 빌더패턴으로 변경할 수도 있지만 애초에 빌더로 시작하는 편이 나을 때가 많다고 한다.
'개인 공부 > 스터디' 카테고리의 다른 글
[이펙티브 자바] equals 재정의 (0) | 2023.06.13 |
---|---|
[이펙티브 자바] 상속보다는 컴포지션을 사용하라 (1) | 2023.06.13 |
[데이터 중심 어플리케이션 설계] 설계시 고려사항 (0) | 2023.06.05 |
[이펙티브 자바] Comparable (0) | 2023.05.30 |
[이펙티브 자바] 불필요한 객체 생성 (2) | 2023.05.16 |