[Effective Java] item 2. 빌더 패턴을 고려하라

2021. 11. 19. 11:25Programming Languages/Effective Java

문제

   일반적인 자바빈 규약을 준수한 객체를 생성하려고 하면 코드가 장황해지고 메서드를 여러 개 호출해야 한다. 다음과 같은 객체가 그러하다.

public class Room {
    private int roomNo;
    private Sofa sofa;
    private TV tv;
    private Bed bed;
    
    public Room() {}
    
    // 게터 및 세터
}

   위와 같은 경우 빈 객체를 생성하고 세터 메서드로 모두 값을 지정해주어야 한다. 이렇게 객체를 구성하면 세터로 모든 멤버 변수를 지정해주기 전까지 일관성이 무너진 상태가 된다. 또한 매개변수들이 전부 유효한지 생성자를 통해 확인했는데 세터로 값을 주입받기 시작하므로 안정성 또한 무너졌다. 이러한 문제점 때문에 버그가 발생할 확률이 증가하고 디버깅 및 유지보수가 힘들어진다. 일관성이 무너지는 문제 때문에 자바빈은 클래스를 불변하게 만들 수 없으며 쓰레드 안정성을 얻으려면 프로그래머가 추가 작업을 해줘야 한다.

   그렇다면 생성자로 모든 필드를 받으면 된다고 생각할지도 모른다. 그러나 생성자로 모든 필드를 받게 되면 필드가 추가될수록 생성자도 늘어난다. 그리고 생성자들에서 this()를 계속 호출해주는 불상사가 발생한다. 결과적으로 멤버 변수가 늘어날수록 코드가 매우 난잡해진다. 참고로 아래와 같은 방식을 점층적 생성자 패턴이라고 부른다.

public class Room {
    private int roomNo;
    private Sofa sofa;
    private TV tv;
    private Bed bed;
    
    public Room() {}
    
    public Room(int roomNo) {
    	this(roomNo, new Sofa("기본 소파"), new TV("브라운관 TV"), new Bed("싱글베드"));
    }
    
    public Room(int roomNo, Sofa sofa) {
    	this(roomNo, sofa, new TV("브라운관 TV"), new Bed("싱글베드"));
    }
    
    public Room(int roomNo, Sofa sofa, TV tv) {
    	this(roomNo, sofa, tv, new Bed("싱글베드"));
    }
    
    public Room(int roomNo, Sofa sofa, TV tv, Bed bed) {
    	this(roomNo, sofa, tv, bed);
    }
    
    //이 외에도 수많은 생성자가 존재할 수 있다.
    //Sofa, TV를 받는 생성자라던가... 매개변수가 많아질수록 답이 없어진다.
    //냉장고, 에어컨 등이 새로 들어오면 기존 코드를 죄다 수정해야 한다.
    
    // 게터 및 세터
}

이런 문제를 어떻게 해결하는 것이 좋을까?

해결 방안

   점층적 생성자 패턴의 안정성과 자바빈의 가독성을 갖춘 빌더 패턴으로 이를 아름답게 해결할 수 있다. 클라이언트는 필요한 객체를 직접 만드는 대신, 필수 매개변수만으로 생성자를 호출해 빌더 객체를 얻는다. 그리고 빌더 객체가 제공하는 일종의 세터 메서드들로 원하는 선택 매개변수들을 설정한다. 마지막으로 매개변수가 없는 build() 메서들을 호출해 우리가 원하는 객체를 얻는다. 이를 코드로 살펴보자.

public class Room {

    private final int roomNo;
    private final Sofa sofa;
    private final TV tv;
    private final Bed bed;
    
    public static class Builder {
        //필수 파라미터
        private final int roomNo;
        private final Sofa sofa;
        
        //선택 파라미터
        private TV tv = new TV("브라운관 TV"); 
        private Bed bed = new Bed("싱글베드");
        
    	public Builder(int roomNo, Sofa sofa) {
            this.roomNo = roomNo;
            this.sofa = sofa;
        }
        
        public Builder tv(TV tv) {
            this.tv = tv;
            return this;
        }
        
        public Builder bed(Bed bed) {
            this.bed = bed;
            return this;
        }
        
        private Room build() {
            return new Room(this);
        }
    }
    
    private Room(Builder builder) {
    	this.roomNo = builder.roomNo;
        this.sofa = builder.sofa;
        this.tv = tv;
        this.bed = bed;
    }
}

빌더를 통해 Room 객체를 생성하는 코드는 다음과 같다.

Room room = new Room.Builder(104, new Sofa("회장님 소파"))
                .tv(new TV("LG 65인치 TV"))
                .bed(new Bed("트윈베드"))
                .build();

   지금은 유효성 검사 코드가 없는 상태이다. 잘못된 매개변수를 최대한 일찍 발견하려면 빌더의 생성자와 메서드에서 입력 매개변수를 검사하고, build 메서드가 호출하는 생성자에서 여러 매개변수에 걸친 불변성을 검사하면 된다. 공격에 대비해 이런 불변식을 보장하려면 빌더로부터 매개변수를 복사한 후 해당 객체 필드들도 검사해야 한다. 검사에서 잘못된 점을 발견하면 어떤 매개변수가 잘못되었는지를 자세히 알려주는 메시지를 담아 IllgegalArgumentException을 던지면 된다.

   또한 빌더 패턴은 계층적으로 설계된 클래스와 함께 쓰기에 좋다. 각 계층의 클래스에 관련 빌더를 멤버로 정의하자. 추상 클래스는 추상 빌더를, 구상 클래스는 구상 빌더를 갖게 된다. 다음은 나름대로 책 예제의 주제를 피자에서 가구로 변경한 것이다.

public abstract class Furniture {

    public enum Material { MDF, SOFTWOOD, HARDWOOD }
    final Set<Material> materials;
    
    abstract static class Builder<T extends Builder<T>> {
        EnumSet<T> materials = Enumset.noneOf(Material.class);
        public T addMaterial(Material material) {
            materials.add(Objects.requireNonNull(material));
        }
        
        abstract Furniture build();
    
        // 하위 클래스는 이 메서드를 오버라이딩하여 this를 반환해야 한다.
        protected abstract T self();
    }
    
    Furniture(Builder<?> builder) {
        materials = builder.materials.clone();
    }
}
public class BookShelf extends Furniture {

    public enum Floor { ONE, TWO, THREE }
    private final Floor floor;
    
    public static class Builder extends Furniture.Builder<Builder> {
        private final Floor floor;
        
        public Builder(Floor floor) {
            this.floor = Objects.requireNonNull(floor);
        }
        
        @Override
        public Furniture build() {
            return new BookShelf(this);
        }
        
        @Override
        public Builder self() { return this; }
    }
    
    private BookShelf(Builder builder) {
        super(builder);
        floor = builder.floor;
    }
}
public class Dresser extends Furniture {
    private final int level;
    
    public static class Builder extends Furniture.Builder<Builder> {
        private int level = 3; // 기본값
        
        public Builder level(int level) {
            this.level = level;
            return this;
        }
        
        @Override
        public Dresser build() {
        	return new Dresser(this);
        }
        
        @Override
        protected Builder self() { return this; }
    }
    
    private Dresser(Builder builder) {
        super(builder);
        level = builder.level;
    }
}

   각 하위 클래스의 빌더가 정의한 build 메서드는 해당하는 구체 하위 클래스를 반환하도록 선언헌다. BookShelf는 BookShelf를 반환하고, Dresser는 Dresser를 반환하도록 선언한다. 하위 클래스의 메서드가 상위 클레스의 메서드가 정의한 반환 타입이 아닌 그 하위 타입을 반환하는 기능을 공변 반환 타이핑이라고 한다. 이 기능을 이용하면 클라이언트가 형변환에 신경쓰지 않고 빌더를 사용할 수 있어 편리하다.

   위 코드로 실제 가구를 만들어본 코드는 다음과 같다. enum 타입은 static import한 것이라 가정하고 작성했다.

BookShelf bookShelf = new BookShelf.Builder(TWO)
          .addMaterial(MDF)
          .addMaterial(HARDWOOD)
          .build();
Dresser dresser = new Dresser.Builder()
          .addMaterial(SOFTWOOD)
          .level(5)
          .build();

   Dresser에서는 상태를 디폴트값으로 지정했다. 즉 빌더 패턴을 사용하면 필수로 입력받아야 하는 매개변수와 선택적인 매개변수를 나누어 받을 수 있고 간단하게 메서드를 하나 추가함으로써 선택적 매개변수를 지정할 수도 있다. 빌더는 이러한 유연성을 제공해준다. 자바빈 규약보다 훨씬 안전하고 클라이언트에서 코드를 쓰기 편하다.

   그렇다고 빌더 패턴에 장점만 있는 것은 아니다. 객체를 만들려면, 빌더 클래스부터 생성하여 만들어야 한다. 그렇기 때문에 성능에 민감한 경우에는 문제가 될 수 있다. 또한 점층적 생성자 패턴보다 코드가 장황해서 매개변수가 4개가 넘어가는 경우에야 값어치를 한다. 하지만 시간이 지날수록 매개변수가 많아지는 경우가 훨씬 많으므로 처음부터 빌더 패턴을 사용하는 편이 나을 때가 많다.

요약

  • 빌더 패턴은 자바빈이나 점층적 팩토리 패턴으로 객체를 생성하는 것보다 훨씬 안전하고 편리하게 객체를 생성해주는 패턴이다.
  • 계층적으로 설계된 클래스에서 빌더 패턴 사용 시 효과적이다.
  • 점층적 팩토리 패턴보다 성능상 단점은 있지만 이익이 훨씬 많으므로 일반적인 상황에서는 빌더 패턴을 고려하자.

[Item 2. 빌더 패턴을 고려하라]를 하면서 배운 점

  • 빌더 패턴을 실질적으로 적용하는 방법
  • Objects.requireNonNull(require) 메서드
  • nested static class의 활용
  • self 타입 관용구
  • 점층적 팩토리 패턴
  • 공변 반환 타이핑