JAVA/Effective Java

[아이템 2] 생성자에 매개변수가 많다면 빌더를 고려하라

꾸준함. 2024. 1. 18. 09:40

정적 팩토리 메서드와 생성자의 한계 및 해결책

정적 팩토리 메서드와 생성자 모두 optional 한 매개변수가 많을 때 적절히 대응하기 어렵다는 공통점이 있지만 점층적 생성자 패턴, Java Beans 패턴 혹은 Builder 패턴을 통해 어느 정도 대응이 가능합니다.

아래의 식품 영양정보를 예시로 각 패턴을 간략히 정리하겠습니다.

  • 1회 내용량, 총 n회 내용량, 1회 제공량 당 칼로리와 같은 필수 항목(required field) 몇 개와
  • 총 지방, 트랜스 지방, 포화 지방, 콜레스테롤, 나트륨 등 수많은 선택 항목(optional field)으로 이루어지고 대부분의 선택 항목들은 값이 0입니다.

 

점층적 생성자 패턴

 

점층적 생성자 패턴 적용 시 모든 필드를 전부 받는 생성자 혹은 정적 팩토리 메서드를 선언하는 대신 아래와 같은 생성자를 사용합니다.

  • 필수 매개변수들만 받는 생성자
  • 필수 매개변수들과 optional 한 필드 1개를 매개변수로 받는 생성자
  • 필수 매개변수들과 optional 한 필드 2개를 매개변수로 받는 생성자
  • ...
  • 필수 매개변수들과 optional한 필드 N개를 매개변수로 받는 생성자

 

 

 

위와 같이 점층적 생성자 패턴을 적용할 경우 장단점은 아래와 같습니다.

 

장점

  • 클래스 인스턴스를 생성할 때 모든 매개변수를 포함한 생성자를 호출하는 대신 원하는 매개변수를 모두 포함한 생성자 중 가장 짧은 것을 골라 호출 가능 (앞선 예시에서는 전달되지 않은 필드들은 모두 0으로 초기화)

 

단점

  • 매개변수가 늘어날수록 사용자가 설정하고 싶지 않은 매개변수까지 포함하기 쉬움
    • 생성자 조합을 모든 경우의 수인 N!로 가져가기 힘듦

 

  • 똑같은 자료형을 가지는 필드가 많을 경우 어떤 파라미터를 전달해야 할지 헷갈릴 확률이 높음
    • 타입이 같기 때문에 실수로 순서를 잘 못 전달하더라도 컴파일 시점에서 에러를 파악하기 힘듦 
    • Intellij를 사용할 경우 cmd + P 혹은 ctrl + P 단축키를 사용할 경우 어떤 매개변수를 전달해야 할지 알 수 있기는 함

 

 

 

Java Beans 패턴

 

Java Beans 패턴은 아래와 같이 매개변수가 없는 기본 생성자로 객체를 생성한 뒤 setter 메서드들을 호출해 원하는 매개변수의 값을 설정하는 방식입니다.

 

 

 

위 방식을 적용할 경우 장단점은 아래와 같습니다.

 

장점

  • 인스턴스를 생성하기 쉬움
  • 점층적 생성자 패턴보다 간단해져 가독성이 좋아짐

 

단점

  • 필수값들이 세팅되지 않은 상태로 사용될 수 있기 때문에 일관성이 무너진 상태로 존재할 가능성이 높아짐
    • 객체를 기본 생성자로 생성한 뒤 setter를 통해 필수값을 모두 세팅하기 전에 사용될 가능성이 있고 이는 컴파일 시점이 아닌 런타임 시점에 파악되므로 버그를 잡기도 힘듦
    • 반면 점층적 생성자 패턴에서는 매개변수들이 유효한지를 생성자에서만 확인하면 일관성을 유지할 수 있었음

 

  • 기본 생성자로 일단 인스턴스를 생성하기 때문에 불변(immutable) 객체로 만들 수 없어 thread-safe 하게 만들기 위해서는 javascript의 freeze 메서드와 같은 추가 작업 필요
    • 사실상 변경이 가능한 가변 객체인데 중간에 불변으로 만드는 과정이기 때문에 기준과 시점을 선택하기 어려움
    • javascript에서는 기본으로 제공하는 메서드이지만 Java에서는 직접 구현도 필요하며 앞서 언급했다시피 기준과 시점을 정하기 어렵고 javascript에서조차 strict mode일 때만 사용 가능하다는 제약 사항 존재
    • 현업에서 객체 freezing이 사용될 일은 거의 없다고 생각
    • 보다 자세한 내용은 링크 참고

 

이처럼 점층적 생성자 패턴과 Java Beans 패턴 모두 장점보다는 단점이 더 많기 때문에 아래와 같이 두 방법을 혼용하는 방식으로 단점들을 상쇄하는 방식도 있습니다.

  • required 필드들은 생성자로 넘기고
  • optional 필드들은 setter 메서드로 설정

 

하지만 이 방식 또한 setter 메서드를 사용하기 때문에 불변 객체(immutable instance)로 선언하기 어렵다는 단점이 존재합니다.

 

Builder 패턴

 

Builder 패턴은 점층적 생성자 패턴의 안전성과 Java Beans 패턴의 가독성을 겸비한 디자인 패턴입니다.

해당 패턴을 적용할 경우 클라이언트는 필요한 객체를 직접 만드는 대신 아래 과정을 거쳐 불변 객체를 얻을 수 있습니다.

  • 필수 매개변수만으로 생성자 혹은 정적 팩토리 메서드를 호출해 빌더 객체를 얻은 후
  • 빌더 객체가 제공하는 일종의 세터 메서드들로 원하는 optional 필드들을 설정
  • 마지막으로 매개변수가 없는 build() 메서드를 호출해 불변(immutable) 객체 취득

 

 

 

Builder 패턴은 계층적으로 설계된 클래스와 함께 사용하기에도 좋습니다.

추상 클래스인 Pizza가 추상 빌더를, 구현체인 NyPizza와 Calzone가 구체 빌더를 갖는다고 가정하면 아래와 같이 코드를 작성할 수 있습니다.

 

 

 

 

 

 

각각의 Pizza 클래스에 빌더를 생성하고 특히 추상 클래스인 Pizza에는 추상 Builder를 만들어 추상 빌더가 받을 수 있는 제너릭 타입에 빌더 자신의 하위 클래스 타입을 받도록 하여 재귀적인 타입 제한을 사용했습니다.

  • NyPizza 클래스의 빌더가 Pizza.Builder<Builder>를 상속하는 것을 볼 수 있듯이 NyPizza.Builder를 제너릭 타입으로 가져가는 것을 확인 가능
  • 앞서 NutritionFacts의 Builder 체이닝 메서드는 this를 반환
  • 만약 추상 클래스 Builder의 addTopping이 this를 반환할 경우 반환 타입을 T가 아닌 Builder <T>를 반환하도록 변경해야 함
    • Builder 하위 클래스들은 자기 자신을 반환해야 하기 때문에 위와 같이 변경할 경우 사용하기 불편해짐
    • ex) Calzone 클래스의 Builder 내 sauceInside() 메서드는 자기 자신을 반환해야 하고 해당 메서드는 Calzone에서만 쓰임 (부모 타입의 빌더에는 sauceInside() 메서드가 없음
    • 이를 위해 추상 클래스 Builder에 제너릭 T 타입을 반환하는 self() 메서드를 만들어 자기 자신을 반환할 수 있도록 처리

 

self() 메서드의 중요성을 코드를 통해 확인하면 아래와 같습니다.

self() 메서드가 있을 경우 NyPizza 혹은 Calzone 인스턴스를 생성할 때 타입 캐스팅을 안 해도 됩니다.

 

 

 

반면, self() 메서드가 없을 경우 build() 메서드가 더 이상 하위 Builder의 bulid()가 아닌 추상 클래스 Pizza Builder의 build() 메서드가 됩니다.

이에 따라 타입 캐스팅을 해줘야 하는 불편함이 생기고 Calzone의 경우 sauceInside() 메서드가 추상 클래스 빌더에는 없기 때문에 컴파일조차 안됩니다.

따라서 빌더를 상속 계층에서 사용할 경우 self라는 메커니즘을 만들어서 빌더 팩토리 계층 구조를 재활용할 수 있어야 합니다.

 

 

 

 

정리하자면 Builder 패턴을 적용할 경우 아래와 같은 장단점을 갖습니다.

 

장점

  • Java Beans보다 훨씬 일관성(consistent) 있게 객체 생성이 가능
    • 필수로 필요한 필드들을 생성자를 통해 반드시 가져올 수 있게 강제
    • 선택적으로 필요한 필드들은 메서드 체이닝을 통해 optional 하게 가져올 수 있음
    • 보통은 build() 메서드를 통해 불변 객체를 얻을 수 있음

 

  • 메서드 체이닝을 사용하기 때문에 가독성이 높아짐
  • 계층적으로 설계된 클래스와 함께 사용하기 좋음 (앞서 언급한 추상 클래스 예제 참고)
  • Builder 체이닝 메서드마다 가변인수 매개변수를 전달받을 수 있도록 설계한다면 생성자를 통해 인스턴스를 생성하는 것과 달리 가변인수 매개변수를 여러 개 사용 가능
  • 상당히 유연하다는 장점
    • Builder 하나로 여러 객체를 순회하면서 생성 가능 (빌더에 넘기는 매개변수에 따라 다른 객체 생성)
    • 객체마다 부여되는 일렬번호와 같은 특정 필드는 빌더가 알아서 채우도록 설계 가능

 

단점

  • 기본적으로 빌더 생성 비용이 크지 않지만 성능에 민감한 상황에서 영향을 끼칠 수 있음
  • 객체를 생성하기 위해 클래스와 별도로 빌더부터 만들어야 하는 번거로움
    • 점층적 생성자 패턴보다는 코드가 자오항해서 매개변수가 최소 4개 이상일 때부터 값어치를 함
    • 이는 Lombok의 @Builder 애노테이션을 통해 해결 가능
    • Lombok의 @Builder 애노테이션을 사용할 경우 디폴트로 모든 필드를 매개변수로 받는 생성자가 생성되는데 이를 막고 싶다면 AccessLevel을 아래 이미지처럼 PRIVATE으로 선언하면 됨

 

 

정리

Optional 한 필드들이 적을 때는 점층적 생성자 패턴이 빌더 패턴보다 사용하기 편할 수 있습니다.

하지만 API는 시간에 비례해서 매개변수가 많아지는 케이스가 많으므로 점층적 생성자 패턴으로 시작해서 나중에 빌더 패턴으로 전환하기보다는 애초에 빌더 패턴으로 시작하는 것이 나을 수 있습니다.

앞서 언급했다시피 빌더는 점층적 생성자보다는 가독성이 좋고 Java Beans보다는 훨씬 일관성 있게 인스턴스를 생성 가능하기 때문입니다.

 

참고

이펙티브 자바

이펙티브 자바 완벽 공략 1부 - 백기선 강사님

반응형