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

sobaman·2025년 1월 1일
0

Java

목록 보기
7/10

정적 팩토리 메서드가 생성자 방식의 단점을 상당 부분 보완해주긴 하지만

둘의 공통적인 제약은

결국 둘 다 매개변수가 많을 때 대응하기 어렵다는 점이다.

이에 따라 새로운 대안들이 발굴되기 시작.

빌더 패턴이 도입되기 전 개발자들이 사용했었던 대안

  1. 점층적 생성자 패턴
public class NutritionFacts {

    private final int servingSize;         
    private final int servings;        
    private final int calories;        
    private final int fat;             
    private final int sodium;          
    private final int carbohydrate;    

    public NutritionFacts(int servingSize, int servings) {
        this(servingSize, servings, 0);
    }

    public NutritionFacts(int servingSize, int servings, int calories) {
        this(servingSize, servings, calories, 0);
    }

    public NutritionFacts(int servingSize, int servings, int calories, int fat) {
        this(servingSize, servings, calories, fat, 0);
    }

    public NutritionFacts(int servingSize, int servings, int calories, int fat, int sodium) {
        this(servingSize, servings, calories, fat, sodium, 0);
    }

    public NutritionFacts(int servingSize, int servings, int calories, int fat, int sodium, int carbohydrate) {
        this.servingSize = serving;
        this.servings = servings;
        this.calories = calories;
        this.fat = fat;
        this.sodium = sodium;
        this.carbohydrate = carbohydrate;
    }
}

// 사용 예시
NutritionFacts cocaCola = new NutritionFacts(240, 8, 100, 0, 35, 27);

필수 매개변수만 받는 생성자 ,
필수 선택 각각 하나의 매개변수만 받는 생성자,
선택 매개변수 2개를 받는 생성자..
이런식으로 매개변수를 전부 받는 생성자까지 늘리는 방식

매우 번거롭고 확장하기도 힘들뿐더러
개수가 많아지면 각 값의 의미가 무엇인지 헷갈릴 것이며,
특히 타입이 같은 매개변수에 대해 실수를 유발해
치명적인 버그로 이어질 수 있다는 치명적 단점이 존재한다.

  1. 자바빈즈 패턴
public class NutritionFacts {

    private final int servingSize = -1;         // 필수, 기본값 없음
    private final int servings = -1;        // 필수, 기본값 없음
    private final int calories = 0;         
    private final int fat = 0;              
    private final int sodium = 0;           
    private final int carbohydrate = 0;     

    public NutritionFacts() {
    }

    public void setServingSize(int servingSize) {
        this.serving = serving;
    }

    public void setServings(int servings) {
        this.servings = servings;
    }

    public void setCalories(int calories) {
        this.calories = calories;
    }

    public void setFat(int fat) {
        this.fat = fat;
    }

    public void setSodium(int sodium) {
        this.sodium = sodium;
    }

    public void setCarbohydrate(int carbohydrate) {
        this.carbohydrate = carbohydrate;
    }
}

// 사용 예시
NutritionFacts cocaCola = new NutritionFacts();
cocaCola.setServingSize(240);
cocaCola.setServings(8);
cocaCola.setCalories(100);
cocaCola.setSodium(35);
cocaCola.setCarbohydrate(27);

매개변수가 없는 생성자로 객체를 만든 후
Setter 메서드를 호출하여 원하는 매개변수 값을 설정.

점층적 생성자 패턴에서 보였던 비슷한 타입의 매개변수에 대한 실수를 줄일 수 있고
set 메서드로 인해 가독성도 더 좋아진듯한 인상을 받는다.

하지만 객체 하나를 만들기 위해 set 메서드를 엄청나게 호출해야 하고
객체가 완성되기 전까지는 일관성이 무너진 상태에 놓이게 된다.

일관성이 깨진 객체가 만들어지면 버그를 심은 코드와
그 버그 때문에 런타임에 문제를 겪는 코드가 물리적으로 멀어져 있어
디버깅에도 힘이 든다.

즉 추적이 힘들다 라는 뜻

set 메서드로 값을 변경할 수 있어 불변 클래스로 만들기도 힘들고,
변경이 가능하기 때문에 멀티 쓰레드에서도 안전하지 못하다.

빌더 패턴

public class NutritionFacts {

    private final int servingSize;
    private final int servings;
    private final int calories;
    private final int fat;
    private final int sodium;
    private final int carbohydrate;

    private NutritionFacts(Builder builder) {
        serving = builder.servingSize;
        servings = builder.servings;
        calories = builder.calories;
        fat = builder.fat;
        sodium = builder.sodium;
        carbohydrate = builder.carbohydrate;
    }

		
    public static class Builder {
        
        // 필수
        private final int servingSize;
        private final int servings;
    
        // 선택
        private int calories = 0;
        private int fat = 0;
        private int sodium = 0;
        private int carbohydrate = 0;

        public Builder(int servingSize, int servings) {
            this.serving = servingSize;
            this.servings = servings;
        }

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

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

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

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

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

// 사용 예시
NutritionFacts cocaCola = new NutritionFacts.Builder(240, 8)
        .calories(100)
        .sodium(35)
        .carbohydrate(27)
        .build();

클라이언트 NutritionFacts 는 필요한 객체를 직접 만들지 않고,

필수 매개변수만으로 생성자 (혹은 정적 팩토리) 를 호출해 빌더 객체를 얻는다.

그 다음 빌더 객체가 제공하는 일종의 setter 메서드로 매개변수들을 받아온다

마지막으로 Build 를 호출해 NutritionFacts 객체를 반환한다. (보통은 불변 상태)

빌더는 생성할 클래스안에 정적 멤버 클래스로 만들어두는 게 보통.

NutritionFacts 클래스는 불변이며 모든 매개변수 기본값들을 한 곳에 모아뒀디.

빌더의 메서드들은 빌더를 반환하게 만들어
연속적으로 줄줄이 호출할 수 있게끔 되어있는데
이를 메서드 체이닝(Method Chaining) 이라 한다.

사용 예시에서 알 수 있듯이 타입이 같은 매개변수끼리
헷갈려서 위치가 서로 바뀔일이 없으며
명시적이고 가독성도 매우 훌륭하다.

(파이썬과 스칼라에 있는 명명된 선택적 매개변수를 흉내낸 것이라고 한다.)

내부적으로 유효성 검사 코드도 구현할 수 있는데, 여기서 불변식이라는 개념이 등장한다.

불변식이란? (Invariant)

프로그램이 실행되는 동안 또는 정해진 기간 동안 반드시 만족해야 하는 조건을 말한다.

다시 말해 변경을 허용할 수 있으나, 주어진 조건 내에서만 허용한다는 뜻이다.

예를 들면, “리스트(List)의 경우, size의 크기는 반드시 0 이상이어야 하고

한 순간이라도 음수 값이 될 수 없다.” 라는 조건식이 List.size()의 불변식이다.

매개변수를 복사하여 해당 객체 필드들을 검사하는 예시

public final class Person {
    private final String name;
    private final int age;

    private Person(Builder builder) {
        // 빌더에서 값 가져와서 필드에 할당
        this.name = builder.name;
        this.age = builder.age;

        // 필드 값의 유효성 검사
        if (name == null || name.trim().isEmpty()) {
            throw new IllegalArgumentException("Name cannot be empty");
        }
        if (age < 0) {
            throw new IllegalArgumentException("Age cannot be negative");
        }
    }

    // getter만 제공하여 객체 불변 보장
    public String getName() {
        return name;
    }

    public int getAge() {
        return age;
    }

    public static class Builder {
        private String name;
        private int age;

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

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

        public Person build() {
            // 빌더에서 값을 가져와 Person 객체를 생성
            return new Person(this);
        }
    }
}

빌더 패턴은 계층적으로 설계된 클래스와 함께 쓰기 좋다

public abstract class Pizza {
    public enum Topping { HAM, ONION, GALIC, SAUSAGE, BULGOGI }
    Set<Topping> toppingList;

    abstract static class Builder<T extends Builder<T>>{

        EnumSet<Topping> toppingList = EnumSet.noneOf(Topping.class);

        public T addTopping(Topping topping){
            toppingList.add(Objects.requireNonNull(topping));
            return self();

        }

        abstract Pizza build();

        protected abstract T self();

    }

    Pizza(Builder<?> builder){
        toppingList = builder.toppingList.clone(); // 아이템 50 참조
    }
}

<? extends U> : 상한 경계 와일드 카드, 명시된 U 의 하위 타입들만 받을 수 있음

ex)

ArrayList<? extends Object> parent = new ArrayList<>();
ArrayList<? extends Integer> child = new ArrayList<>();

parent = child; // (제네릭 타입 업캐스팅)

ArrayList<? extneds Object> parent 는
Object 의 하위의 타입들을 받을 수 있다는 뜻이니
ArrayList<? extneds Integer> child 는 parent 에 들어갈 수 있다.

업캐스팅이 가능

self() 메서드의 역할은 자기 참조를 반환하여
하위 클래스의 빌더에서 상위 클래스의 체이닝이 가능하게 만든다.

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;
	}
}

<? extends U> 제네릭을 사용하여 상위 빌더(Pizza.Builder)에서 하위 빌더(NyPizza.Builder)를 상속받을 수 있도록 구현.

이 방식을 통해 하위클래스에서 상위 클래스의 빌더를 재사용하고

각 하위 클래스에 특화된 빌더를 생성할 수 있게 만듦.

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;
	}
}

이런식으로 빌더를 상속받아 또 다른 하위 타입을 구현하여

다른 속성을 빌더 패턴으로 지원할 수 있게끔 만들 수 있다.

이렇게 설명하면 매우 어려워 보이는데 쉽게 말하면 Lombok 의

@SuperBuilder 어노테이션의 동작 원리를 설명한 것이라 생각하면 간단하다.

@SuperBuilder? : 자식 클래스의 빌더는 부모클래스에 있는 변수까지 접근을 못하기 때문에

자식클래스에서 builder를 통해 부모클래스의 필드에 접근 가능하게 만드는 기능.

  • 주로 생성일, 수정일 같은 공통적으로 관리해야 하는 부모 클래스를 따로 분리시킬 때 사용
  • 부모클래스와 자식클래스 모두 적용시켜야 올바르게 작동한다

Lombok 어노테이션을 사용해 좀 더 실무적으로 사용되는 코드로 변환하면 다음과 같이 적용 가능하다.

@MappedSuperclass
@Getter
@EntityListeners(AuditingEntityListener.class)
@SuperBuilder
@NoArgsConstructor
public class BaseEntity {

    @CreatedDate
    @Column(updatable = false)
    private LocalDateTime createdDate;
    @LastModifiedDate
    private LocalDateTime updatedDate;

}

@Entity
@Getter
@SuperBuilder
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Answer extends BaseEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    private Integer id;
    @Column(length = 200)
    private String content;
    private Integer questionId;
}

// 사용 예시
Answer answer = Answer.builder()
											.content(answerReq.getContent())
                      .questionId(answerReq.getQuestionId())
                      .createdDate(LocalDateTime.now())
                      .updatedDate(LocalDateTime.now())
                      .build();

빌더 패턴 직접 구현 vs Lombok 어노테이션 사용

빌더 패턴을 직접 구현하는 방식의 장점은

조건부 로직 추가나 특정 검증을 추가하여

커스텀해 세밀한 제어가 가능하다 라는 장점도 있지만

@Valid를 사용하거나 @Builder를 사용하면서도 동시에 충분히 구현할 수 있는 부분이라

Trade Off 를 고려해야겠지만은 일반적인 경우에는

어노테이션을 사용하는 것이 매우 효율적일 것이라 생각한다.

특히 @SuperBuilder 같은 경우에는 직접 구현할 경우 더욱 복잡하기 때문에

Trade Off 가 훨씬 유리한 선택.

참고문헌

Effective Java - (Joshua Bloch)

profile
백엔드 공부 정리 블로그

0개의 댓글