Programming/Java

[Effective Java] item2 - 생성자에 매개변수가 많다면 빌더를 고려하라.

VSFe 2021. 1. 21. 19:40
생성자에 매개변수가 많다면 빌더를 고려하라!

 

지난 item을 통해 정적 팩토리 메소드에 대해서 배웠다. 물론 약간의 단점만 감수하면 매우 훌륭한 도구이다.

다만 매개변수가 많으면 어떨까?

치킨이 먹고 싶으니

치킨 객체를 만든다고 가정해보자.

치킨 객체엔 다양한 인자들이 있다고 하자. 이름, 브랜드, 가격, 열량, 순살여부, (매우 중요하다!) 평점을 저장한다고 하자. 그렇다면 클래스의 기본 형태는

public class Chicken {
    private final String chickenName;   // 필수
    private final String brand;         // 필수
    private final int price;            // 선택
    private final int calrories;        // 선택
    private final int boneless;         // 선택
    private final double review;        // 선택
}

이런 형태가 될 것이다. 일반적으로 위 두개는 기억을 못 할리가 없지만, 처음 입력하는 사람이 나머지는 못 할수도 있을 것이다. 즉 2개의 데이터 이외에도 4개의 데이터는 생각이 나면 입력하고, 생각이 안 나면 입력을 안 하면 될 것이다.

다만 모든 경우를 고려하게 된다면... 2^4 = 16개의 생성자를 만들어야 하며, 정적 팩토리 메소드를 쓰면 16개의 서로 다른 메소드를 만들어야 할 것이다.

아~~ 점심 나가서 먹을 것 같아~~

실제 상황에서 이런 답이 없는 경우를 피하기 위해, 이전 프로그래머들은 점층적 생성자 패턴 (Telescoping Constructor Pattern)을 사용했다. 필수 매개변수만 받는 생성자부터 시작하여 2개, 3개, 4개, 5개, 6개 ... 순으로 선택 매개변수를 점점 늘려가며 생성자를 늘려가는 방식이다. 예시 코드를 만들어보면,

public class Chicken {
    private final String chickenName;   // 필수
    private final String brand;         // 필수
    private final int price;            // 선택
    private final int calrories;        // 선택
    private final int boneless;         // 선택
    private final double review;        // 선택


    public Chicken(String chickenName, String brand) {
        this(chickenName, brand, 0);
    }

    public Chicken(String chickenName, String brand, int price) {
        this(chickenName, brand, price, 0);
    }

    public Chicken(String chickenName, String brand, int price, int calrories) {
        this(chickenName, brand, price, calrories, 0);
    }

    public Chicken(String chickenName, String brand, int price, int calrories, int boneless) {
        this(chickenName, brand, price, calrories, boneless, 0);
    }

    public Chicken(String chickenName, String brand, int price, int calrories, int boneless, double review) {
        this.chickenName = chickenName;
        this.brand = brand;
        this.price = price;
        this.calrories = calrories;
        this.boneless = boneless;
        this.review = review;
    }
}

인스턴스를 만들려면 원하는 매개변수를 모두 포함한 생성자 중 가장 짧은 것을 호출하면 될 것이다. 즉,

Chicken goldenOlive = new Chicken("Golden Olive Fried", "BBQ", 18000, 0, 1); 같은 구조가 될 것이다.

보다시피 칼로리를 알지 못해서 0을 넘겨줬는데, 만약에 매개변수가 한 20개 정도 된다면 어떨까? 맨 끝에 있는 데이터 1개 때문에 0을 여러번 넣어야 하는 일이 생길 것이고, 코드를 읽는 것도 매우 어려워진다.

두번째 방법으로 자바빈즈 (JavaBeans pattern)에 대해서 보자. 이것은 매개변수가 없는 생성자로 객체를 만든 후, setter 메서드를 호출해 원하는 매개변수의 값을 설정하는 방식이다.

자바빈즈 패턴을 활용해 코드를 다시 짜보자!

public class Chicken {
    private String chickenName = "";    // 필수
    private String brand = "";          // 필수
    private int price = 0;              // 선택
    private int calrories = 0;          // 선택
    private int boneless = 0;           // 선택
    private double review = 0;          // 선택

    // Constuctor
    public Chicken() {}

    // Setter Methods
    public void setChickenName(String val) { chickenName = val; }
    public void setBrand(String val) { brand = val; }
    public void setPrice(int val) { price = val; }
    public void setCalrories(int val) { calrories = val; }
    public void setboneless(int val) { boneless = val; }
    public void setReview(double val) { review = val; }
}

코드가 길지만, 일단 내가 뭘 하려는지는 확실히 구분할 수 있다. 실제로 인스턴스를 만들려고 하면,

public static void main(String[] args) {
    Chicken goldenOlive = new Chicken();
    goldenOlive.setChickenName("Golden Olive Fried");
    goldenOlive.setBrand("BBQ");
    goldenOlive.setPrice(19000);
    goldenOlive.setboneless(1);
}

확실히 읽기 쉬워졌다! 다만 이것도 완벽한게 아니다. 위에서도 말했지만, 매개변수가 한 20개 정도 되면 객체만 생성하다 하루가 다 갈 수 있다. 메소드로 빼서 따로 실행하면 그건 생성자지...

그리고 그것과 별개로, 객체가 완전히 생성될 때 까지는 일관성이 무너진 상황에 놓이게 된다. 처음 소개한 점층적 생성자 패턴의 경우 잘못된 데이터가 들어오는지 확인하는 코드를 삽입하면 되지만, 자바빈즈의 경우 완전하지 않은 상태의 객체에 접근할 수 있게되어 디버깅의 난이도가 올라가게 된다.

즉, 자바빈즈 패턴에서는 클래스를 불변으로 만들 수 없고, Thread-Safety를 확보하기 위해 추가 작업을 해줘야 한다...

라고 책에 써 있는데, 전자야 당연히 생성 시점에 인자가 final로 설정되어 있으면 추가적인 Setter를 통해 값을 수정할 수 없으니 그렇다 쳐도, 왜 Thread-Safe하지 않다는걸까?

사실 상식적으로 mutable한 데이터는 모두 thread-safe하지 않기 때문에 synchronized를 사용해야 하는건 다들 알 것이다. 그럼에도 Thread-Safe하지 않다고 콕 찝어서 말한 것은, 객체 생성과정에서의 thread-safefy를 강조하고 싶은 것이라고 본다.

일일히 synchronized를 붙이는 고통스러운 작업은 잊어버리고, 새로운 대안을 알아보자.

잠깐 맨 위로 올라가서 제목을 다시 읽어보자. "생성자에 매개변수가 많다면 빌더를 고려하라!" 자. 빌더 패턴(Builder Pattern)을 배워보자. 빌더 패턴은 다음과 같은 절차로 온전하 객체를 생성한다.

  • 필수 매개변수 (치킨 클래스의 예시를 들면 이름과 브랜드)로 빌더 객체를 얻는다.
  • 빌더 객체가 제공하는 유사 Setter 메소드로 원하는 선택 매개변수를 설정한다.
  • 매개변수가 없는 build 메서드를 호출해 실제 객체를 얻는다. (이때, 일반적으로는 불변으로 만든다.)

코드를 보면서 절차를 밟아보면 쉽게 이해가 갈 것이다.

public class Chicken {
    private final String chickenName;
    private final String brand;
    private final int price;
    private final int calrories;
    private final int boneless;
    private final double review;

    public static class ChickenBuilder {
        private final String chickenName;   // 필수 매개변수!
        private final String brand;         // 너도!

        // 선택 매개변수 - 기본값으로 초기화!
        private int price       = 0;
        private int calrories   = 0;
        private int boneless    = 0;
        private double review   = 0;

        public ChickenBuilder(String chickenName, String brand) {
            this.chickenName = chickenName;
            this.brand       = brand;
        }

        public ChickenBuilder price(int val) { price = val; return this; }
        public ChickenBuilder calrories(int val) { calrories = val; return this; }
        public ChickenBuilder boneless(int val) { boneless = val; return this; }
        public ChickenBuilder review(double val) { review = val; return this; }

        public Chicken build() {
            return new Chicken(this);
        }
    }

    public Chicken(ChickenBuilder cb) {
        chickenName     = cb.chickenName;
        brand           = cb.brand;
        price           = cb.price;
        calrories       = cb.calrories;
        boneless        = cb.boneless;
        review          = cb.review;
    }
}

보면 ChickenBuild의 내부 메소드들이 자기 자신을 리턴하는 것을 볼 수 있는데, 왜 그럴까? 이렇게 하면 연쇄적인 호출이 가능하기 때문이다. 이것을 메소드 체이닝 (Method Chaining)이라고 하는데, JS를 사용한 사람들이라면 익숙할 것이다.

여전히 다른 예시처럼 황금 올리브 객체를 만들려고 한다면

Chicken goldenOlive = new Chicken.ChickenBuilder("Golden Olive Fried", "BBQ").price(19000).boneless(1).build(); 같이 한 줄로 작성할 수 있고, 가독성도 나쁘지 않다.

물론 위에서 이야기 했듯이 필요에 따라 유효성 검사 코드를 팍팍 칠 수 있을 것이다.

혹자는 이 빌드 패턴을 점층적 생성자 패턴의 장점과 자바빈즈 패턴의 장점을 혼합한 것이라고 하는데, 가독성 측면에서의 장점인 자바빈즈 패턴과, 생성 과정에서의 일관성과 Thread-Safe함을 보장할 수 있다는 장점을 가진 점층적 생성자 패턴의 장점을 모두 갖고 있기 때문이다.

빌더 패턴은 계층적으로 설계된 클래스와 함께 쓰기에 좋다고 하는데, 이걸 활용하여 한번 실제 객체를 설계해보자!

위에서 계속 사용한 치킨 클래스를 그대로 사용하기엔 상속을 고려하지 않고 설계했기 때문에 조금 애매한 감이 있어, 새로운 구조를 설계했다. 걱정마시라, 또 치킨이다!

 

이런식의 구조로 만들어보자. 잠깐 코드를 짜기전에 생각해보자. 상위 추상 클래스에서 빌더를 만들어놓으면, 상속을 받은 구현체 클래스는 어떻게 사용해야 할까? 당연히 하위 클래스에서 빌더를 자신의 타입에 맞도록 구현해야 할 것이다. 이 점을 신경 쓰면서 코드를 짜보자!

import java.util.EnumSet;
import java.util.Objects;
import java.util.Set;

public abstract class Chicken {
    public enum SideMenu { FRENCH_FRIED, COLA, CHEESE_BALL, CHEESE_STICK, HOTDOG }
    final Set<SideMenu> sides;
    
    final String name;
    final String brand;
    final int price;
    
    abstract static class Builder<T extends Builder<T>> {
        EnumSet<SideMenu> sides = EnumSet.noneOf(SideMenu.class);
        String name = "";
        String brand = "";
        int price = 0;

        public T addSideMenu(SideMenu sm) {
            sides.add(Objects.requireNonNull(sm));
            return self();
        }
        public T setName(String val) {
            name = val;
            return self();
        }
        public T setBrand(String val) {
            brand = val;
            return self();
        }
        public T setPrice(int val) {
            price = val;
            return self();
        }

        abstract Chicken build();

        protected abstract T self();
    }

    Chicken(Builder<?> builder) {
        sides = builder.sides.clone();
        name = builder.name;
        brand = builder.brand;
        price = builder.price;
    }
}

자... 코드가 쉽지 않다. 그래도 이해하지 못하고 넘어가면 찝찝하니, 하나씩 뜯어보자.

Chicken.Builder는 재귀적 타입 한정 (이 문장을 해석하자면, 모든 타입 T는 자기 자신을 구현/상속하고 있다.)을 활용하고 있다. 또한, this로 리턴하게 되면 타입이 정해지는 꼴이 되니, self()로 우회하여 각각의 하위 메소드에서 처리를 위임시킨다. 이렇게 하면 하위 메소드에서도 문제 없이 메소드 체이닝을 할 수 있을 것이다! (우리는 이러한 우회 방법을 시뮬레이트한 셀프 타입 (Simulated self-type) 관용구라고 부른다.)

별도로 내가 잘 몰라서 찾아본 부분까지 정리를 해보자...

  • EnumSet은 abstract class이다! 따라서 객체처럼 사용이 불가능하기 때문에 new를 쓰지 않고 noneOf를 쓰는데, noneOf는 가능한 범위 (여기선 SideMenu.class)의 원소에 맞춰 item1에서 살짝 봤던 RegularEnumSet/JumboEnumSet 중 조건에 맞는 구현체를 리턴하는 정적 팩토리 메소드이다.
  • requiredNotNull은 Null이 들어오지 못하게 하는 것으로, 들어오면 IllegalArgumentException을 반환한다. (그냥 require도 있는데, 이는 내부 조건문이 참인지 확인한다.)

위의 복잡한 코드를 해석했으면, 나머지 구현 클래스는 만들기는 별로 어렵지 않다.

// FriedChicken.java
public class FriedChicken extends Chicken {
    public enum Spicy { MILD, HOT, FIRE }
    private final Spicy spicy;

    public static class Builder extends Chicken.Builder<Builder> {
        private final Spicy spicy;

        public Builder(Spicy spicy) {
            this.spicy = Objects.requireNonNull(spicy);
        }

        @Override public FriedChicken build() {
            return new FriedChicken(this);
        }

        @Override protected Builder self() { return this; }
    }

    private FriedChicken(Builder builder) {
        super(builder);
        spicy = builder.spicy;
    }
}

// SaucedChicken.java
public class SaucedChicken extends Chicken {
    public enum SauceKind { NORMAL, SOY, GALBI, HOT }
    private final SauceKind sauce;

    public static class Builder extends Chicken.Builder<Builder> {
        private final SauceKind sauce;

        public Builder(SauceKind sauce) {
            this.sauce = Objects.requireNonNull(sauce);
        }

        @Override public SaucedChicken build() {
            return new SaucedChicken(this);
        }

        @Override protected Builder self() { return this; }
    }

    private SaucedChicken(Builder builder) {
        super(builder);
        sauce = builder.sauce;
    }
}

각 하위 클래스의 빌더가 정의한 build 메소드는 각각의 구현체 하위 클래스를 반환한다. 이런식으로 하위 클래스의 메소드가 상위 클래스가 정의한 타입이 아닌, 하위 타입을 반환하는 기능을 공변환 타이핑(covariant return typing)이라고 한다. 이런 기법을 활용하면 매번 형변환을 고민할 필요 없이 그냥 빌더 내부를 오버라이드 해서 사용할 수 있다.

소소한 이점으로, 필요한 인자가 여러개 인 경우 메소드를 여러 번 호출하게 한 뒤 하나의 필드로 모을 수 있다. 위의 코드를 활용하면,

FriedChicken goldenOlive = new FriedChicken.Builder(MILD).addSideMenu(COLA)
							 .addSideMenu(CHEESE_BALL).setName("Golden Olive")
							 .setBrand("BBQ").setPrice(19000).build();

addSideMenu를 여러번 호출하여 Set<SideMenu>라는 하나의 필드에 데이터를 모을 수 있다.

물론 장점만 있는 것은 아니다. 객체를 만들기 위해 빌더를 생산해야 하는데, 자원사용에 민감한 환경에선 사용하기 조금 어렵다는 단점이 있고, 코드가 장황해지기 때문에 매개변수가 4개 정도는 되어야 사용할 만하다는 점이 있다.

즉, 처리해야 할 매개변수가 많거나, 매개변수의 수가 확장될 가능성이 있다면 빌더로 설계하는 것이 유리하다는 이야기이다!

마침 어제 황금 올리브를 먹어서 이거 갖고 이야기를 풀어봤는데,

(뒷광고 아닙니다...)

재미는 있지만 코드를 하나하나 짜야 하니 죽겠다...