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

NAKTA·2023년 11월 15일
1

effective java 3e

목록 보기
2/5
post-thumbnail

🌱 들어가면서

프로그램을 만들 때, 규모가 점점 커지면 커질수록 클래스 내부 필드와 메서드는 복잡해지기 마련이다. 그래서 코드를 체계적으로 관리하지 않으면 코드의 가독성과 유지보수성이 저하되고 이는 생산성에 영향을 미칠 수 밖에 없다. 그렇다면 많은 매개변수를 가지는 객체는 어떻게 생성해야 할까? 이번 글에서는 이러한 고민을 다뤄볼려고 한다.



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

Car 클래스의 매개변수 정보는 다음과 같다고 가정해보자.

public class Car {
    private String brand;           // 필수
    private String model;           // 필수
    private String color;           // 필수
    private String country;         // 선택
    private int year;               // 선택
    private int weight;             // 선택
}

Car 객체를 만들 때, brand, model, color 는 반드시 값이 있어야 되며 이 정보들이 제공되지 않으면 Car 객체를 적절하게 생성하거나 사용하는 것이 어려울 수 있다.

그리고 country, year, weight 는 사용자가 원하는 경우에만 제공하면 되므로, Car 객체를 생성할 때는 반드시 설정할 필요가 없다.

일반적으로 클래스를 정의할 때,
객체 생성에 꼭 필요한 매개변수와 굳이 필요하지 않은 매개변수를 다음과 같이 구분한다.


📑 정리

  • 필수 매개변수 : 클래스나 메서드 등을 생성하거나 호출할 때 반드시 제공되어야 하는 매개변수
  • 선택 매개변수 : 클래스나 메서드 호출 시 제공되어도 되고, 생략되어도 괜찮은 매개변수



🙃 매개변수가 많을 때 생성 패턴

1. 점층적 생성자 패턴

쉽게 말해서 필수 매개변수를 포함한 생성자 수를 점층적으로 늘려가는 방식이다.

아래 예시 코드를 보자.

public class Car {
    private String brand;           // 필수
    private String model;           // 필수
    private String color;           // 필수
    private String country;         // 선택
    private int year;               // 선택
    private int weight;             // 선택

    public Car(String brand, String model, String color) {
        this(brand, model, color, "");
    }

    public Car(String brand, String model, String color, String country) {
        this(brand, model, color, "", 0);
    }

    public Car(String brand, String model, String color, String country, int year) {
        this(brand, model, color, "", 0, 0);
    }

    public Car(String brand, String model, String color, String country, int year, int weight) {
        this.brand = brand;
        this.model = model;
        this.color = color;
        this.country = country;
        this.year = year;
        this.weight = weight;
    }
}

다음 코드는 한 눈에 보기에도 비효율적인 발상이라는 것을 눈치챌 수 있다.

첫 번째로, year, weight 만 값을 할당하고 싶을 때 강제적으로 country 값을 임의로 설정할 수 밖에 없다.

두 번째로, year, country 만 값을 할당하거나 weight, country 만 값을 할당하는 경우의 생성자를 생성할 수 없다.

특히, 현재 선택 매개변수의 수가 6개에서 50개로 확 늘어난다면 어떻게 될까? 클래스 내부에서 생성자의 비중이 거의 다 차지하게 될 것이다.


📑 정리

  • 원하는 매개변수 조합으로 생성자를 생성할 수 없다.
  • 코드가 매우 복잡해진다
  • 프로그래머가 실수할 가능성이 높고 디버깅이 힘들어진다



2. 자바빈즈 패턴

자바빈즈 패턴은 간단하게 말해서 Setter 메서드를 이용해 필요한 값만 넣는 방식이다.

아래 예시 코드를 보자.

public class Car {
    private String brand;           // 필수
    private String model;           // 필수
    private String color;           // 필수
    private String country;         // 선택
    private int year;               // 선택
    private int weight;             // 선택
    
    public void setBrand(String brand) {
    	this.brand = brand;
    }
    
    public void setModel(String model) {
    	this.model = model;
    }
    
    public void setColor(String color) {
    	this.color = color;
    }

    public void setCountry(String country) {
        this.country = country;
    }

    public void setYear(int year) {
        this.year = year;
    }

    public void setWeight(int weight) {
        this.weight = weight;
    }
}

확실히 점층적 생성자 패턴과 비교 했을 때는 여러가지 조합을 생각하면서 생성자를 만들지 않아도 되고 무엇보다 코드 가독성이 좋아보인다.

하지만, 자바 빈즈 패턴은 큰 단점이 있다.

첫 번째로, 불변 객체 를 만들기가 어려워진다. 불변 객체 는 한번 객체가 생성되면 상태가 변경되지 않아야 하는데, Setter를 이용하면 객체의 상태를 변경할 수 있기 때문이다.

두 번째로, 객체의 일관성이 무너진다. Setter 를 이용하면 객체의 상태를 중간에 변경할 수 있기 때문에 객체가 일관된 상태를 유지하기 어려워진다.


📑 정리

  • 불변 객체 를 만들기가 어렵다.
  • 객체의 일관성 이 무너진다.


3. 빌더 패턴

빌더 패턴은 객체 생성과정을 캡슐화 하고 유연성 을 높이기 위한 디자인 패턴이다.

거두절미하고 빌더 패턴의 예시 코드를 보자.

public class CarWithBuilder {
    private final String brand;           // 필수
    private final String model;           // 필수
    private final String color;           // 필수
    private final String country;         // 선택
    private final int year;               // 선택
    private final int weight;             // 선택

    private CarWithBuilder(Builder builder) {
        brand   =   builder.brand;
        model   =   builder.model;
        color   =   builder.color;
        country =   builder.country;
        year    =   builder.year;
        weight  =   builder.weight;
    }

    public static class Builder {
        private final String brand;       // 필수
        private final String model;       // 필수
        private final String color;       // 필수

        // 선택 매개변수 기본값 초기화
        private String country = "";      // 선택
        private int year = 0;             // 선택
        private int weight = 0;           // 선택

        public Builder (String brand, String model, String color) {
            this.brand = brand;
            this.model = model;
            this.color = color;
        }

        public Builder country(String country) {
            this.country = country;
            return this;
        }

        public Builder year(int year) {
            this.year = year;
            return this;
        }

        public Builder weight(int weight) {
            this.weight = weight;
            return this;
        }

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

빌더 패턴은 다음과 같이 구현된다.

클래스 내부에 static classBuilder 를 정의해놓고
그 내부에는 필수 매개변수를 받는 생성자와
Setter 역할을 하는 선택 매개변수 설정 메서드들로 구성되어 있고
마지막 build() 메서드를 호출하여 객체를 생성하는 방식이다.


객체를 생성하는 코드를 한번 보자.

public static void main(String[] args) {
CarWithBuilder carWithBuilder = new CarWithBuilder.Builder("Benz", "S-Class", "Black")
				                .country("Germany")
                				.build();
}

필수 매개변수를 통해 Builder 를 생성하고 필요에 따라 메서드를 호출하여 연속적으로 선택 매개변수를 설정할 수 있다.

이러한 방식이 가능한 이유는 빌더 패턴은 Method Chaining 방식을 이용하기 때문에 생성자와 하위 메서드들이 Builder 자신인 this 를 반환하기 때문에 연속적으로 값을 설정할 수 있다.

빌더 패턴의 특징을 보면 알 수 있듯이 위의 점층적 생성자 패턴자바빈즈 패턴 의 장점을 모두 합쳤다는 것을 알 수 있다.

💡 빌더 패턴의 유효성 검사

  • 빌더 클래스 내부의 메서드에서 이루어진다.
  • 주로 객체 생성 build() 메서드를 호출할 때 매개변수에 대한 유효성을 검사한다.

위의 빌더 패턴 방식은 그 자체만으로도 충분히 좋은 패턴임이 분명하지만,
빌더 패턴은 계층적으로 설계된 클래스 일 때, 빛을 발한다.

아래의 예시 코드를 보자.

public abstract class Pizza 
{
    public enum Topping 
  	{ 
  		HAM, MUSHROOM, ONION, PEPPER, SAUSAGE 
  	}
    final Set<Topping> toppings;

    abstract static class Builder<T extends Builder<T>> 
  	{
        EnumSet<Topping> toppings = EnumSet.noneOf(Topping.class);
        public T addTopping(Topping topping) 
  		{
            toppings.add(Objects.requireNonNull(topping));
            return self();
        }

        abstract Pizza build();

        protected abstract T self();
    }

    Pizza(Builder<> builder)
	{
        toppings = builder.toppings.clone();
    }
}

다음 추상 클래스 코드에서 Builder<T extends Builder<T>> 부분을 주목해보자.

해당 방식은 재귀적 타입 한정 (Recursive type bound) 으로 불리는데 Item 30 에서 내용 정리가 나오므로 그때 자세히 정리하겠다.


해당 Pizza 클래스를 상속 받은 NyPizza 클래스와 Calzone 클래스 코드는 다음과 같다.

public class NyPizza extends Pizza 
{
    public enum Size 
  	{ 
  		SMALL, MEDIUM, LARGE 
  	}

    private final Size size;

    public static class Builder extends Pizza.Builder<Builder> 
  	{
        private final Size size;

        public Builder(Size size) 
  		{
            this.size = Objects.requireNonNull(size);
        }

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

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

    private NyPizza(Builder builder) {
        super(builder);
        size = builder.size;
    }

    @Override public String toString() 
  	{
        return toppings + "로 토핑한 뉴욕 피자";
    }
}
public class Calzone extends Pizza 
{
    private final boolean sauceInside;

    public static class Builder extends Pizza.Builder<Builder> 
  	{
        private boolean sauceInside = false;

        public Builder sauceInside() 
  		{
            sauceInside = true;
            return this;
        }

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

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

    private Calzone(Builder builder) {
        super(builder);
        sauceInside = builder.sauceInside;
    }

    @Override 
  	public String toString() 
  	{
        return String.format("%s로 토핑한 칼초네 피자 (소스는 %s에)", 
  						toppings, sauceInside ? "안" : "바깥");
    }
}

각 피자는 Pizza 를 상속받고, 각 피자의 빌더는 Pizza.Builder 를 상속 받는 것을 볼 수 있다.

해당 코드에서 재밌는 점을 발견할 수 있는데,
NyPizza, Calzone 내부 Builder 는 각각 Pizza build() 를 구현해야 했지만 실제로는 각각 NyPizza build(), Calzone build() 로 상위 클래스가 아닌 자신의 클래스로 반환하는 것을 볼 수 있다.

해당 기능을 공변 반환 타이핑 (covariant return typing) 이라고 부른다.

이렇게 되면 따로 type casting을 하지 않아도 돼서 편해진다.

💡 공변 반환 타이핑 (covariant return typing) 이란?

  • JDK 1.5부터 추가된 개념이다.
  • 부모 클래스의 메소드를 오버라이딩하는 경우, 부모 클래스의 반환 타입은 자식 클래스의 타입으로 변경이 가능하다.

이제 각 피자의 객체를 생성해보자.

public static void main(String[] args) {
        NyPizza nyPizza = new NyPizza.Builder(NyPizza.Size.LARGE)
                .addTopping(Pizza.Topping.SAUSAGE)
                .addTopping(Pizza.Topping.ONION)
                .build();

        Calzone calzone = new Calzone.Builder()
                .sauceInside()
                .addTopping(Pizza.Topping.ONION)
                .addTopping(Pizza.Topping.HAM)
                .build();

        System.out.println(nyPizza);
        System.out.println(calzone);
    }

재귀적 한정 타입 (Recursive type bound) 로 인해서 각 피자는 빌더로 객체를 생성할 때 addTopping() 메서드를 호출해도 자신의 Builder 타입 this 를 반환하기 때문에 문제없이 생성할 수 있는 것을 볼 수 있다.

개인적으로 해당 예제 코드를 보면서 타입이 안전하고 코드를 보기좋게 잘 짰다는 느낌을 받았다.


모든 방식이 그렇듯, 빌더 패턴도 단점이 존재한다.

빌더 패턴을 쓰는 이상, 객체를 만들 때 마다 내부 빌더 코드를 매번 작성해야 한다.
빌더 코드 자체가 성능에 크게 영향을 끼치지는 않겠지만, 아주 미세한 성능 차이에도 민감한 상황이라면 해당 패턴을 쓰는데 고민을 해봐야 될 것이다.


📑 정리

  • 복잡한 객체 생성을 추상화하고, 객체 생성에 필요한 세부 단계를 분리하여 캡슐화한다.
  • 객체 생성에 필요한 단계를 나누어 놓기 때문에 원하는 설정 단계만을 선택하여 객체를 생성을 유연하게 할 수 있다.
  • 빌더 패턴을 사용하여 객체를 생성하면 상태를 더이상 변경할 수 없어 객체의 일관성이 유지된다.
  • 객체 생성 코드를 단순화하고 가독성을 향상시킨다.
  • 내부 빌더 코드를 매번 작성해줘야 한다.
  • 매개변수가 4개 이상일 때 그 효과가 좋다고 말한다.



🔎 참조

profile
느려도 확실히

0개의 댓글