정적 팩터리와 생성자는 공통적인 제약이 존재하는데, 선택적 매개변수가 많을 때는 적절하게 대응하기가 힘들다.
📌 인스턴스화(Instantiate)
class Car {
private String brand;
private String wheel;
private String name;
private String engine;
private int capacity;
private long price;
public Car(String brand, String wheel, String name, String engine, int capacity, long price) {
this.brand = brand;
this.wheel = wheel;
this.name = name;
this.engine = engine;
this.capacity = capacity;
this.price = price;
}
}
Car 클래스는 생성자로 많은 인자를 요구한다.
하지만 클라이언트 입장에선 각 파라미터가 어떤 값을 요구하는지 확인하기가 어려우며, wheel의 경우엔 대부분 고정적으로 4라는 값을 입력받을 것이다.
위의 생성자로 인스턴스화를 하면 다음과 같이 호출해야 한다.
Car myCar = new Car("현대", 4, "제네시스", "독일엔진", 4, 700000000);
만약, 몇 가지 정보를 아직 알지 못 한다면 null값을 던져주어야 한다.
Car myCar = new Car("현대", 4, null, null, 4, 0);
이런 방식은 가독성이 매우 좋지 않기 때문에, 개발자들은 점층적 생성자 패턴을 즐겨 사용했었다.
📌 점층적 생성자 패턴(telescoping constructor pattern)
class Car {
private String brand;
private int wheel;
private String name;
private String engine;
private int capacity;
private long price;
public Car(String brand) {
this(brand, 0, "", "", 0, 0);
}
public Car(String brand, int wheel) {
this(brand, wheel, "", "", 0, 0);
}
public Car(String brand, int wheel, String name) {
this(brand, wheel, name, "", 0, 0);
}
(...)
public Car(String brand, int wheel, String name, String engine, int capacity, long price) {
this.brand = brand;
this.wheel = wheel;
this.name = name;
this.engine = engine;
this.capacity = capacity;
this.price = price;
}
}
이 방법은 인스턴스를 만들기 위해 원하는 매개변수를 모두 포함한 생성자 중에서 가장 짧은 것을 골라서 호출하면 된다.
하지만 brand와 wheel만 입력하는 생성자를 만듦과 동시에 brand와 name만을 입력하는 생성자를 만들 수는 없으므로 사용자가 원치 않는 매개변수까지 포함하게 될 수 있다.
그리고 객체의 속성이 늘어날 수록 코드는 고작 생성자 정의만으로 걷잡을 수없이 길어지게 되며, 여전히 가독성이 좋다고는 차마 말할 수가 없다.
일반적으로 매개변수가 많으면 클라이언트는 매개변수 순서를 잘못 입력하여 런타임 과정에서 오작동을 일으킬 수도 있다.
📌 자바 빈 패턴(JavaBeens Pattern)
class Car {
private String brand;
private int wheel;
private String name;
private String engine;
private int capacity;
private long price;
public Car(String brand, int wheel, String name, String engine, int capacity, long price) {
this.brand = brand;
this.wheel = wheel;
this.name = name;
this.engine = engine;
this.capacity = capacity;
this.price = price;
}
public void setBrand(String brand) {
this.brand = brand;
}
public void setWheel(int wheel) {
this.wheel = wheel;
}
public void setName(String name) {
this.name = name;
}
public void setEngine(String engine) {
this.engine = engine;
}
public void setCapacity(int capacity) {
this.capacity = capacity;
}
public void setPrice(long price) {
this.price = price;
}
}
이제 한창 개발을 배우는 사람이라면 가장 익숙한 형태일 것이라 생각한다.
일단 객체를 생성하고 나서 setter 함수로 멤버 변수를 초기화하는 방법이다.
코드가 다소 길긴 하지만 확실히 점층적 생성자 패턴보다 가독성이 훨씬 좋아졌으며, 인스턴스를 만들기 쉬워졌다.
하지만 이 방법의 문제점 또한 존재한다.
1. 객체 하나를 생성하기 위해 다수의 메서드를 호출해야 한다.
2. 객체가 완성하기 까지 일관성이 무너진 상태에 놓이게 된다.
특히, 2번으로 인해 발생하는 문제들은 또 다른 여러 문제점들을 파생한다.
버그를 심은 코드와 그 버그로 인해 런타임 과정에서 이슈가 발생하는 코드가 물리적으로 멀리 떨어져 있을 것이므로 디버깅이 힘들다.
그리고 일관성이 무너지기 때문에 자바빈즈 패턴에서는 클래스를 불변으로 만들 수 없기 때문에 Thread-safe를 얻기 위해 추가 작업을 해주어야만 한다.
예시로 생성이 끝난 객체를 freeze하기 전까지는 사용할 수 없도록 만드는 방법이 있는데, 이 방법을 전에 한 번 써보다가 다루기가 너무 어려워서 포기한 적이 있었다.
그리고 이 방법을 써도 개발자가 freeze 메서드를 호출을 제대로 해줬는지 아닌지를 컴파일러가 보증해줄 방법이 없어서, 휴먼 에러가 발생할 가능성을 여전히 배제할 수 없다.
📌 빌더 패턴(Builder pattern)
점층적 생성자 패턴과 자바 진 패턴의 장점을 결합한 패턴이라고 생각하면 된다.
(스프링 부트로 개발을 할 때, Builder로 객체를 생성하는 것이 좋다는 글을 많이 봤었는데 드디어 알게 되었다.)
class Car {
private String brand;
private String name;
private String engine;
private int wheel;
private int capacity;
private long price;
private Car(Builder builder) {
this.brand = builder.brand;
this.name = builder.name;
this.engine = builder.engine;
this.wheel = builder.wheel;
this.capacity = builder.capacity;
this.price = builder.price;
}
public static class Builder {
// 필수 매개변수
private String brand;
private String name;
// 선택 매개변수
private String engine = "";
private int wheel = 4;
private int capacity = 0;
private long price = 0;
public Builder(String brand, String name) {
this.brand = brand;
this.name = name;
}
public Builder engine(String engine) {
this.engine = engine;
return this;
}
public Builder wheel(int wheel) {
this.wheel = wheel;
return this;
}
public Builder capacity(int capacity) {
this.capacity = capacity;
return this;
}
public Builder price(int price) {
this.price = price;
return this;
}
public Car build() {
return new Car(this);
}
}
}
빌더의 세터 메서드들은 빌더 자신을 반환하기 때문에 연쇄적으로 호출할 수 있다.
이를 플루언트 API(fluent API) 혹은 메서드 연쇄(method chaining)라 한다.
클라이언트는 다음과 같이 인스턴스를 생성할 수 있다.
Car myCar = new Car.Builder("현대", "제네시스")
.engine("독일").wheel(4).capacity(4).price(700000000)
.build();
자바 빈즈 패턴의 문제점은 결국 객체를 생성한 "후"에 setter 메서드로 인자를 넣는 것에 있었다.
사용자의 실수나 악의적인 목적으로 setter에 유효하지 않은 인자를 던졌을 때, 공격에 대비하기가 매우 힘들어진다.
하지만 빌더 패턴은 객체 생성 "전"에 값을 setter 메서드를 통해 넣고, build 메서드로 객체를 생성하면 된다.
즉, 도중에 값이 변경될 우려가 없기 때문에 불변성과 안정성 모두 향상시킬 수 있게 된다.
불변식을 보장하기 위해서는 빌더로부터 매개변수를 복사하고 해당 객체 필드를 검사해야 한다(Item 50).
검사 시 잘못된 점이 있다면 어떠한 매개변수가 잘못되었는지에 대한 메세지를 담아서 IllegalArgumentException으로 오류를 발생시키면 된다.
✒️ 불변(immutable)과 불변식(invariant)
• 불변: String 객체처럼 한 번 생성되고 난 이후 어떠한 변경도 허용하지 않는다.
• 불변식: 프로그램이 실행되는 동안(혹은 정해진 시간) 반드시 만족해야 하는 조건. 변경을 허용할 수는 있으나, 주어진 조건 내에서만 허용한다. (ex. 리스트의 크기가 한 순간이라도 음수 값이 된다면 불변식이 깨진 것이다.)
📌 계층적으로 설계된 클래스와 Builder 패턴
빌더 패턴은 계층적으로 설계된 클래스와 함께 쓰기 좋다.
추상 클래스는 추상 빌더를, 구체 클래스는 구체 빌더를 갖게 한다.
예시 코드는 여기서 참조했다.
public abstract class Allnco {
public enum ApiType { ADD_ITEM, UPDATE_ITEM, UPDATE_IMAGE, UPDATE_PRC }
final Set<ApiType> apiTypes;
abstract static class Builder<T extends Builder<T>> {
EnumSet<ApiType> apiTypes = EnumSet.noneOf(ApiType.class);
public T addApiType(ApiType apiType){
apiTypes.add(Objects.requireNonNull(apiType));
return self();
}
abstract Allnco build();
// 하위 클래스는 이 메서드를 overriding해 "this"를 반환하도록 구현해야함.
protected abstract T self();
}
Allnco(Builder<?> builder){
apiTypes = builder.apiTypes.clone();
}
}
Allnco.Builder 클래스는 재귀적 타입 한정(Item 30)을 이용하는 제네릭 타입으로, 추상 메서드인 self를 더해 하위 클래스에서는 형변환을 하지 않아도 메서드 연쇄를 지원할 수가 있게 된다.
* self 타입이 존재하지 않는 자바를 위한 이 우회 방법을 시뮬레이트한 셀프 타입(simulated self-type) 관용구라 한다.
Allnco의 하위 클래스 2개를 생성해보자.
public class Gmarket extends Allnco{
public enum Chnl { ONLINE, OUTLET, MART, DEPARTMENT, BUYING }
private final Chnl chnl; // final -> immutable
public static class Builder extends Allnco.Builder<Builder> {
private final Chnl chnl;
public Builder(Chnl chnl){
this.chnl = Objects.requireNonNull(chnl);
}
@Override
public Gmarket build(){
return new Gmarket(this);
}
@Override
protected Builder self(){
return this;
}
}
private Gmarket(Builder builder){
super(builder);
chnl = builder.chnl;
}
}
public class Naver extends Allnco{
private final boolean isHapi;
public static class Builder extends Allnco.Builder<Builder> {
public boolean isHapi = false;
public Builder connectToHapi(){
isHapi = true;
return this;
}
@Override
public Naver build(){
return new Naver(this);
}
@Override
protected Builder self(){
return this;
}
}
private Naver(Builder builder){
super(builder);
isHapi = builder.isHapi;
}
}
각 하위 클래스의 빌더가 정의한 build 메서드는 해당 하위 클래스인 Gmarket과 Naver을 반환하도록 되어 있다.
(하위 클래스의 메서드가 상위 클래스의 메서드가 정의한 반환 타입이 아닌, 그 하위 타입을 반환하는 기능을 공변 반환 타이핑(covariant return typing)이라 한다. 이 기능을 사용하면 클라이언트는 형변환에 신경 쓰지 않고 빌더를 사용할 수 있다.)
이런 계층적 빌더 패턴을 사용하는 클라이언트의 코드는 앞에서 빌더 패턴을 사용하는 코드와 다르지 않다.
Gmarket gmarket = new Gmarket.Builder(Gmarket.Chnl.MART)
.addApiType(Gmarket.ApiType.UPDATE_ITEM)
.addApiType(Gmarket.ApiType.UPDATE_PRC)
.build();
Naver naver = new Naver.Builder()
.addApiType(Naver.ApiType.ADD_ITEM)
.connectToHapi()
.build();
빌더 패턴을 사용하면 가변인수(varargs) 매개변수를 여러 개 사용할 수 있어서 적절한 메서드로 나눠 선언하거나,
addApiType 메서드처럼 여러 번 호출하도록 하고 각 호출 때 넘겨진 매개변수들을 하나의 필드로 묶을 수도 있다.
빌더 하나로 여러 객체를 순회하면서 만들 수도 있고, 빌더에 넘기는 매개변수에 따라 다른 객체를 생성할 수도 있다.
그리고 객체마다 부여되는 고유값들은 빌더가 알아서 채우도록 지정할 수도 있다.
📌 빌더 패턴 단점
객체를 만들려면 빌더부터 만들어야 하기 때문에 성능 측면에서 문제가 될 수 있다.
그리고 이 패턴이 익숙하지 않은 개발자나, 계층적 빌더 패턴이 다소 복잡한 코드의 경우 매개변수가 4개 이상 정도는 해야 값어치를 한다.
(하지만 API 개발은 다음 릴리즈를 고려해야 하므로 매개변수가 추가될 수 있다는 점들 또한 고려해야 한다.)
✒️ Lombok @Builder
스프링 부트를 공부할 때 사용했었던 꿀기능(그 땐 몰랐는데, 지금 생각하니 진짜 꿀기능..^^)이 있었다.
lombok 라이브러리의 @Builder 어노테이션을 붙이면 Builder 패턴을 자동으로 생성해준다.
@Builder
class Car {
private String brand;
private String name;
private String engine;
private int wheel;
private int capacity;
private long price;
}
가독성 미쳤다..