Item2: Builder Pattern(빌더 패턴)

박상원·2023년 11월 14일

Effective Java

목록 보기
2/6
post-thumbnail

생성자와 정적 팩토리는 선택적 매개변수가 많을 때 적절히 대응하기 어렵다. 선택적 매개 변수가 많은 경우에 사용하는 생성자 패턴에 대해서 살펴 볼 것이다.

생성자에 매개변수가 많다면 빌더를 고려하라


Item1을 하고 나서인지 그래도 Item2 제목은 아예 낯설지는 않은것 같다.

들어가기에 앞서 이해에 도움이 되는(알아야 하는) 배경지식을 하나 알고가자.

  • 필수 매개변수와 선택 매개변수

다음은 클래스 Laptop에 담겨져 있는 정보들이다.

이걸 보면 문득 이런 생각이 들 수 있다.

Q. 객체 하나 생성하는데 굳이 저 정보들이 필수적으로 있어야 하나?
다시 말해, 당장 객체를 만들어서 써야하는데
현재 저 정보를 다 가지고 있지 않으면 어쩌냐는 말이다.

저기서는 당장 필요한 정보도 있고(다른 로직에서 필요해서 영향도가 높은),
굳이 당장 필요없는 정보도 있을것이다.

그런데 급하지 않은 정보 때문에 객체 생성을 못하고 있는건 딱히 달갑지 않다.

그래서 보통 클래스를 정의할 때,
인스턴스 생성에 꼭 필요한 변수와 굳이 필요하지 않는 변수로 구분하곤 한다.

  • 필수 매개변수 - 인스턴스 생성에 꼭 필요한 변수
  • 선택 매개변수 - 인스턴스 생성에 있어도 되고 없어도 되는 변수

이와 같은 배경지식을 가지고 나서, Itme2를 제대로 시작해보자.

  • 문제 인식

앞서 Item1에서, 클래스에서 인스턴스를 생성하는 역할을 하는 두 친구, 생성자(Constructor)와 정적 팩토리 메서드(Static Factory Method)를 배웠다.

앞에서 두 친구가 서로 장단점을 말하며 치열하게 싸웠지만,
둘 다 가지고 있는 공통적인 문제가 하나 있다.

'선택적 매개변수가 많을 때 적절히 대응하기 어렵다'라는 것이다.

생성자와 정적 팩토리 메서드는 인스턴스를 만드는 방법에서의 차이가 있을 뿐,
이런 본질적인 문제에서는 차이가 없다.

결국 이를 해결하려면 새로운 방법이 필요하다.

이제부터 선택 매개변수가 많은 상황에 대해서 대처법을 알아보자.

생성자 패턴 1. 점층적 생성자 패턴


점층적 생성자 패턴(telescoping constructor pattern)은 다음과 같이 필수 인자를 받는 생성자를 정의한 후, 선택적 인자를 하나씩 추가해 가며 정의하는 것이다.

점층적 생성자 패턴(telescoping constructor pattern)

  • 필수 매개변수만 가진 생성자
  • 필수 매개변수 + 선택 매개변수 1개를 가진 생성자
  • 필수 매개변수 + 선택 매개변수 2개를 가진 생성자
    ...
    ....
  • 위와 같이 매개변수를 점점 늘려가며 모조합에 대한 생성자를 생성하는 것
public class Item {
	private final String itemCd;
    private final String itemNm;
    private final String ctgId;
    private final BigDecimal price;
    private final String sellTypeCd;
    
    public Item(String itemCd, String itemNm, String ctgId) {
    	this(itemCd, itemNm, ctgId, 0);
    }
    
    public Item(String itemCd, String itemNm, String ctgId, BigDecimal price) {
    	this(itemCd, itemNm, ctgId, price, "10");
    }
    
    public Item(String itemCd, String itemNm, String ctgId, BigDecimal price, String sellTypeCd) {
    	this.itemCd = itemCd;
        this.itemNm = itemNm;
        this.ctgId = ctgId;
        this.price = price;
        this.sellTypeCd = sellTypeCd;
    }
}

예시에서는 인자가 5개라 간단해 보일 수 있지만, 매개변수가 더 늘어날 수록 코드를 작성하기 어려워지고, 가독성이 떨어지게 된다.

Commnet.
사실 엄밀히 말해서 변수가 6개라고 생성자가 6개만 필요한 것도 아니다.

대충 모든 조합을 생각해봐도
(6C1 + 6C2 + 6C3 + 6C4 + 6C5 + 6C6 = 58)개 정도는 필요하다.

그런데 실제로 상용화 되고 있는 서비스들의 코드를 보면,
변수가 100개도 넘는 클래스는 차고 넘친다.

결국 개발자가 원하는 조합을 정확하게 가지는 생성자는 기대하기 어렵고,
그와 비슷한 생성자를 직접 찾아서 써야한다.

클라이언트 측면에서 코드를 작성하기도 어렵고 읽기도 어렵다는 것이다.

점층적 생성자 패턴의 단점

  • 사용자가 원하는 매개변수 조합을 가진 생성자는 기대할 수 없다.
  • 수많은 생성자 중 유사한 것을 찾아보는 것은 너무 힘든 일이다.
  • 개발자가 생성자 사용에 있어 실수할 가능성이 높다.
  • 이는 결국 컴파일러에 걸리지 않는 런타임 에러를 발생시킬 수 있다.

생성자 패턴 2. JavaBeans Pattern


어쨋든 점층적 생성자 패턴이 별로이므로 조금 다른 방법을 생각해본다.

점층적 생성자 패턴은 매개변수의 조합을 맞추는 것이 포인트였다면,
이번엔 객체를 만든 후에 매개변수를 주입하는 것이 포인트다.

자바빈즈 패턴은 매개변수가 없는 생성자로 객체를 만든 후 setter 메서드를 호출해 원하는 매개변수의 값을 설정하는 방식이다.

자바빈즈 패턴(JavaBeans pattern)

  • 매개변수가 없는 생성자를 사용한다.
  • 생성자를 통해 객체를 만든다.
  • Setter 메서드를 통해 매개변수들을 하나씩 주입한다.
public class Item {
	private String itemCd;
    private String itemNm;
    private String ctgId;
    private BigDecimal price;
    private String sellTypeCd;
    
    public Item() {}
    
    public void setItemCd(String itemCd) {this.itemCd = itemCd;}
    public void setItemNm(String itemNm) {this.itemNm = itemNm;}
    public void setCtgId(String ctgId) {this.ctgId = ctgId;}
    public void setPrice(BigDecimal price) {this.price = price}
    public void setSellTypeCd(String sellTypeCd) {this.sellTypeCd = sellTypeCd;}
}
Item item = new Item();
item.setItemCd("12345678");
item.setItemNm("Effective Java 3/E");
item.setCtgId("9999");
item.setPrice("36000");
item.setSellTypeCd("20");

자바빈즈 패턴은 점층적 생성자 패턴의 단점을 보완해 인스턴스 생성이 더 쉽고, 더 가독성이 좋아졌다.

하지만, 자바빈즈 패턴에서는 객체 하나를 만드려면 메서드를 여러 개 호출해야 하고, 객체가 완전히 생성되기 전까지는 일관성(Consistency)이 무너진 상태에 있게 된다. 일관성이 깨지므로 자바 빈즈 패턴에서는 클래스를 불변으로 만들 수 없으며, 스레드 안정성을 얻으려면 개발자가 추가 작업을 해줘야 한다. 이러한 단점을 보완하기 위해 freeze 메서드를 사용할 수 있으나, freeze 메서드를 확실히 호출해줬는지 컴파일러가 보증할 방법이 없어 런타임 오류에 취약하다.

점층적 생성자 패턴의 경우 적절한 생성자 검증이 귀찮긴 하지만,
매개변수들을 이용해 한번에 생성하므로 그 객체의 일관성이 유지된다.

그런데 자바빈즈 패턴의 경우 객체를 일단 생성해놓고 변수를 하나하나 주입한다.
즉, 객체 생성은 되었어도 매개변수 주입 과정이 끝나지 않으면,
객체는 일관성(Consistency)이 무너진 상태가 된다.
쉽게 말해 매개변수 하나 추가 될 때마다 인스턴스가 변한다는 말이다.
다만, 일관성이 무너지므로 불변 클래스로 만드는 것도 불가능하다.

자바빈즈 패턴의 단점

  • 객체 생성 후, Setter 메서드를 하나하나 호출하는 것은 불편하다.
  • 매개변수 주입 과정이 끝나기 전까지 객체의 일관성이 무너진다.
  • 그러므로 클래스를 불변 타입으로 만드는 것이 불가능해진다.

생성자 패턴 3. Builder Pattern


잠시 정리를 해보자면, 점층적 생성자 패턴은 분명 시스템적으로 안전한 것이 장점이다.
그렇지만 너무 단순하기 때문에 불편함이 증대된다는 단점은 개선의 여지가 없다.

자바빈즈 패턴은 가독성도 좋고 유연하다는 것이 장점이다.
그렇지만 시스템적으로 불안정하다는 것과 사용하기가 불편하다는 것이 치명적이다.

이러한 맥락에서 등장한 것이 바로 빌더 패턴(Builder Pattern) 이다.

빌더 패턴(Builder pattern)

  • 사용자는 필수 매개변수만으로 생성자(혹은 정적 팩토리 메서드)를 호출해 빌더 객체를 얻는다.
  • 빌더 객체가 제공하는(Setter 메서드와 유사한) 것ㅇ으로 선택 매개변수를 주입한다.
  • 매개변수가 없는 build 메서드를 호출하여 필요한 객체를 얻는다.
    (빌더는 생성할 클래스 안에 정적 멤버 클래스로 만들어두는게 보통이다.)

위 방법만 읽어보면 정말 좋은 것 같다.

매개변수를 직접 주입하기 때문에 분명 유연해질 수 있다.

또 객체를 미리 생성하지 않고 나중에 한번에 생성하기 때문에,
일관성(Consistency)이 무너지지도 않는다.

결국 점층적 생성자 패턴의 안정성과 자바빈즈 패턴의 가독성,
둘의 장점을 모두 취한 것이다.

public class Item {
	private final String itemCd;
    private final String itemNm;
    private String ctgId;
    private final BigDecimal price;
    private final String sellTypeCd;
    
    public static class Builder {
    	private final String itemCd;
        private final String itemNm;
        private final String ctgId;
        
        // 선택적 매개변수는 default 값으로 초기화
        private BigDecimal price = BigDecimal.ZERO;
        private String sellTypeCd = "00";
        
        public Builder(String itemCd, String itemNm, String ctgId) {
        	this.itemCd = itemCd;
            this.itemNm = itemNm;
            this.ctgId = ctgId;
        }
        
        public Builder price(BigDecimal price) {
        	this.price = price;
            return this;
        }
        
        public Builder sellTypeCd(String sellTypeCd) {
        	this.sellTypeCd = sellTypeCd;
            return this;
        }
        
        public Item build() {
        	return new Item(this);
        }
    }
    
    private Item(Builder builder) {
    	itemCd = builder.itemCd;
        itemNm = builder.itemNm;
        ctgId = builder.ctgId;
        price = builder.price;
        sellTypeCd = builder.sellTypeCd;
    }
}
Item item = new Item.Builder("12345678", "Effective Java 3/E", "9999")
					.price(36000)
                    .sellTypeCd("90")
                    .build();

클라이언트는 필수 매개변수만으로 생성자를 호출해 빌더 객체를 얻고, 빌더 객체가 제공하는 setter 메서드들로 원하는 선택 매개변수들을 설정할 수 있다. 마지막으로 매개변수가 없는 build() 메서드를 호출해 필요한 객체를 얻을 수 있다. 이렇게 연쇄적으로 메서드를 호출하는 방법을 fluent API or method chaining이라 한다.

이런 method chaining이 가능한 이유는,
빌더 Setter 역할을 하는 메서드들이 자기 스스로를 반환하기 때문이다.

빌더 패턴의 유효성 체크

  • 빌더의 생성자와 메서드에서 매개변수 검사.
  • build 메서드가 호출하는 생성자에서 여러 매개변수에 대한 불변식 검사.
  • 공격에 대비해 불변식을 보장.
  • 검사를 통해 구체적으로 어떤 매개변수가 잘못되었는지를 알려주는 IllegalArgumentException을 이용

불변

  • Immutable 혹은 Immutability
  • 어떠한 변경도 허용하지 않는다는 뜻
  • 주로 가변(Mutable)객체와 구분하는 용도로 사용
  • ex) String 객체는 한번 만들어지면 절대 값을 바꿀 수 없는 불변 객체

불변식

  • 반드시 만족해야 하는 조건
  • 변경을 허용할 순 있으나, 주어진 조건 내에서만 허용.
  • ex) 리스트의 크기는 변할 수 있어도 어떤 때에도 반드시 0 이상이어야 함.
  • 가변 객체에도 불변식은 존재할 수 있음
  • 불변은 불변식의 극단적인 예

불변식을 보장하기 위해서는 빌더로 부터 매개변수를 복사한 후 해당 객체 필드도 검사해야 한다. (item50) 검사시 잘못된 점을 발견하면 어떤 매개변수가 잘못되었는지에 대한 메세지를 담아 IllegalArgumentException (item75) 오류발생을 해주면 된다.

계층적으로 설계된 클래스

빌더 패턴은 계층적으로 설계된 클래스와 사용하기에 좋다.

여기서 계층적이라는 뜻은,
마치 음식 레시피와 같이 A를 넣고 B를 넣고 C를 넣고... 의 느낌이다.

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) {
        	apiType.add(Objects.requireNonNull(apiType));
            return self();
        }
        
        abstract Allnco build();
        
        // 하위 클래스는 이 메서드를 overriding해 "this"를 반환하도록 구현해야 함.
        protected abstract T self();
    }
    
    Allnco(Builder<?> builder) {
    	apiTypes = builder.apiTypes.clone();
    }
}

여기서 Allnco.Builder 클래스는 재귀적 타입 한정을 이용하는 제네릭 타입이다. 여기에 추가적으로 추상 메서드인 self()를 추가해 하위 클래스에서 형 변환 하지 않고도 method chaining을 할 수 있다.

public class Gmarket extends Allnco {
	public enum Chnl { ONLINE, OUTLET, MART, DEPARTMENT, BUYING }
    private final Chnl chnl;
    
    public static 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;
    }
}
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)
					   .build();

각각의 하위 클래스의 빌더가 정의한 build 메서드는 해당 하위 클래스 (Naver, Gmarket)을 반환하도록 되어있다. 이렇게 하위 클래스의 메서드가 상위 클래스가 정의한 리턴타입이 아닌, 그 하위 타입을 리턴하는 것을 Convariant return typing(공변 반환 타이핑)이라 한다. 이 기능으로 클라이언트가 형변환에 신경 쓰지 않고 빌더를 사용할 수 있다.

지금까지 빌더 패턴의 장점에 대해서 살펴 봤다.
하지만 꼭 장점만 있는 것은 아니다.

눈치 챘겠지만, 객체를 만들려고 하면 일단 빌더 코드를 따로 짜야한다.
물론 빌더 코드 자체가 그렇게 기회 비용이 높지는 않지만,
아주 미세한 성능 차이도 민감한 상황이라면 조금 문제가 될 수 있다.

또 굳이 매개변수 갯수가 많지 않은데 빌더 패턴을 쓸 이유는 없다.
적어도 매개변수 갯수는 4개는 되야 사용하는 의미가 있다.

하지만, API는 처음 만들때가 중요한게 아니라 시간이 갈수록 비대해지곤 한다.
처음엔 생성자나 정적 팩토리 메서드로 만들었다가 나중에 빌더 패턴으로 바꿀수도 있긴 하지만,
그럴거면 그냥 시작부터 빌더로 시작하는게 나을수도 있다.

빌더 패턴의 장점

  • 점층적 생성자 패턴처럼 객체 안정성이 있다.
  • 자바빈즈 패턴처럼 코드 가독성이 좋고 유연하다.

빌더 패턴의 단점

  • 빌더에 대한 코드를 따로 작성해야 한다.
  • 매개변수가 4개 이상일 때 그 효과가 좋다.

결론

빌더 패턴은 빌더 하나로 여러 객체를 만들 수 있고, 빌더에 넘기는 매개변수에 따라 다른 객체를 만들 수 있으므로 매우 유연하다.

하지만 객체를 만들려면, 그에 앞서 빌더부터 만들어야 한다. 또한, 성능에 민감한 상황에서는 빌더 생성 비용이 문제가 될 수 있다. 또한 매개변수가 4개 이상이 되어야 값어치를 한다.

즉, 인자가 많은 생성자나 정적 팩터리가 필요한 클래스를 설계할 때, 대부분의 인자가 선택적 인자인 상황에 유용하다. 빌더는 점층적 생성자보다 간결하고, 자바빈즈보다 훨씬 안전하다.

Item2 정리

  • 처리해야 할 매개변수가 많다면 빌더 패턴을 선택하는 것이 낫다.
  • 점층적 생성자보다 클라이언트 코드를 읽고 쓰기가 간결하다.
  • 자바빈즈보다 훨씬 안전하다.

참조

  1. https://velog.io/@holidenty
  2. https://dahye-jeong.gitbook.io

0개의 댓글