점층적 생성자 패턴?

개발을 하다 보면 특정 클래스의 상태, 선택적인 매개변수가 많아질수록 점층적인 모양의 생성자들이 만들어지게 되는 경우가 있습니다. 그렇기에 코드에서 필수적인 매개변수 생성자부터 선택적인 변수 1.... N개 생성자까지 5개 이상의 생성자를 보게 될 수도 있습니다.

public Sample(Long id, String name) {
//...
}

public Sample(Long id, String name, Int age) {
//...
}

public Sample(Long id, String name, Int age, String profile) {
//...
}

// ~~~

public Sample(Long id, String name, Int age, String profile, String email, LocalDateTime createBy, LocalDateTime updateBy) {
//...
}

이러한 클래스를 비즈니스 로직, 전달되는 값에 따라 상황에 맞게 생성하기 위해 생성자 중 하나를 선택하게 되거나 의미를 위해 Item1에서 설명된 정적 팩토리 메서드를 사용할 수도 있습니다.

 

개인적으로 다양한 생성자와 (개발자를 위해) 의미를 부여하기 위한 정적 팩토리 메서드가 클래스에 많이 존재하는 것도 코드를 보는 입장에서는 보기 좋지는 않다고 생각합니다.

 

 

자바 빈즈 패턴?

이러한 문제의 대안으로 자바 빈즈 패턴이 존재합니다. 해당 방식은 No-arg 생성자를 통해 인스턴스를 생성하고 각각의 필드를 Setter를 이용해 값을 저장하는 것인데, 이 패턴도 코드를 작업하다 보면 필드를 누락할 수 있고, 그로 인해 발생하는 예외 지점을 찾는 것은 생각보다 쉽지 않습니다. 세터 자체가 어떠한 의미를 지니지도 않기 때문에 별로 좋아하지 않습니다.

 

추가적으로 해당 객체를 불변으로 만들 수 없고. 상황에 따라 스레드 안정성도 고려하여야 합니다.

Sample sample = new Sample();
sample.setId(1L);
sample.setName("Lob");
sample.setProfile("Serrl");
sample.setEmail("Example@hello.world");
sample.setCreateBy(LocaldateTime.now());
sample.setUpdateBy(LocaldateTime.now());

 

빌더 패턴?

이러한 상황에서 점층적인 생성자 패턴의 일관성, 안정성과 가독성을 챙기는 대안이 나오게 되는데 그것이 바로 빌더 패턴입니다.

public static class Builder {

    private Long requestId;
    private String requestCode;
    private Long userId;
    private String hrOrgan;
    private String username;
    private String password;
        private LocalDateTime createDate;

    public Builder requestId(Long val){
        requestId = val;
        return this;
    }

    public Builder requestCode(String val){
        requestCode = val;
        return this;
    }

    public Builder userId(Long val){
        userId = val;
        return this;
    }

    public Builder createDate(LocalDateTime val){
        LocalDateTime = val;
        return this;
    }

    public Builder hrOrgan(String val){
        hrOrgan = val;
        return this;
    }

    public Builder username(String val){
        username = val;
        return this;
    }

    public Builder password(String val){
        password = val;
        return this;
    }

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

protected HostRequest() {
}

public HostRequest(Builder builder){
    requestId   = builder.requestId;
    requestCode = builder.requestCode;
    userId      = builder.userId;
    createDate  = builder.createDate;
    hrOrgan     = builder.hrOrgan;
    username    = builder.username;
    password    = builder.password;
}

//....................

HostRequest request = new HostRequest.Builder()
        .requestId(1L)
        .requestCode("code")
        .userId(1L)
        .hrOrgan("platform service")
        .username("Lob")
        .password("examplePassword")
        .createDate(LocalDateTime.now())
        .build();

해당 예시의 경우 클래스 선언부에 빌더 코드가 많이 생성되지만, 사용하는 지점에서는 메서드의 이름으로 가독성이 향상되고 메서드 체이닝을 통해 인스턴스 자체가 원자적으로 생성되는 것처럼 보이는 효과를 지닙니다.

 

빌더 패턴을 사용하면서 클래스 래밸에서는 이러한 코드를 감추기 위해 Lombok의 Builder를 사용합니다.

해당 기능의 경우, Type래밸에서 Lombok의 No-Arg, Required 생성자와 같이 사용할 때 이슈가 합니다. 그렇기에 아래와 같이 생성자를 정의하고 해당 어노테이션을 사용하는 것을 선호합니다.

@Builder
public HostRequest(Builder builder){
    requestId   = builder.requestId;
    requestCode = builder.requestCode;
    userId      = builder.userId;
    createDate  = builder.createDate;
    hrOrgan     = builder.hrOrgan;
    username    = builder.username;
    password    = builder.password;
}

추가적으로, 계층적인 상속 구조에서 제네릭 빌더를 통해 하위 타입 객체에도 빌더를 상속하는 등 유연성을 지닐 수 있다고 합니다. 개인적으로 계층 상속 구조를 자주 사용하지는 않기 때문에.. 해당 부분은 넘어가도록 하겠습니다.

 

 

참고 자료

  • Effective Java 3판

일반적으로 Class의 Instance를 생성하는 방법은 public Constructor를 사용하지만, 그와 별도로 Static factory method를 사용할 수 있습니다.

 

이러한 메서드는 instance를 반환하는 단순한 형태나 별도 로직을 포함하는 형태를 지니게 됩니다.

 

 

Effective Java에서는 이를 통해 여러 장점과 단점이 존재한다고 하는데. 하나하나 정리해보았습니다.

 

 

 

1. Class를 생성하는 행위, 특성에 대해 이름을 부여할 수 있다.


객체의 Constructor 그 자체와 넘기는 Parameter 만으로는 해당 객체의 특성이나 의미를 자세히 알아내기가 어렵고 이를 이해하기 위해 API 문서를 더 확인해야 할 수 있습니다. 이때 Static factory method의 Naming을 통해 뜻을 나타내는 건 개발자에게 좋은 정보가 될 수 있습니다.

List<Value> empties = new ArrayList<>();

List<Value> empties = ArrayListUtils.emptyValues();

위의 예제는 어떠한 비즈니스를 설정하지 않고 단순하게 작성한 것이기에 공감이 되지 않을 수 있습니다. 그런 분들은 다른 상황에서 좋은 케이스를 만나볼 수 있을 것이라고 생각합니다.

 

예제의 의미는 알겠지만 위의 장점을 동의하지 못하신다면 프로젝트, 비즈니스 이해도가 존재하지 않는 프로젝트로 투입되었을 때를 생각해보면 좋을 것 같습니다. 그럴 때에 값 객체나, 도메인 모델 등이 method 방식으로 생성이 된다면 후자가 더 좋은 가독성을 지닌다고 생각합니다.

 

그리고 동일한 시그니처를 가지는 Constructor는 하나만 존재할 수 있습니다. 점층적인 생성자 패턴을 통해 여러 생성자를 생성하고 다른 로직이나 값을 끼워 넣을 수 있지만, 이는 개발자에게 혼란을 줄 수 있습니다.

개인적으로 생성자에 로직이 들어가는 것은 좋다고 생각하지 않습니다.

 

하지만 Static factory method를 사용한다면 method의 이름을 통해 이러한 문제를 해결할 수 있습니다.

 

 

 

2. 객체를 사용할 때마다 새로운 인스턴스를 생성하지 않아도 된다.


Static factory method를 사용하면 내부에 특정 상태를 가지는 인스턴스를 생성하여 가지고 있다가 전달하거나, 사용한, 사용할 객체를 캐싱하여 계속해서 반환할 수 있습니다.

private static class IntegerCache {
        //...
    static final Integer cache[];

    // static block 초기화 로직

        public static Integer valueOf(int i) {
            if (i >= IntegerCache.low && i <= IntegerCache.high)
            return IntegerCache.cache[i + (-IntegerCache.low)];
            return new Integer(i);
        }

캐싱과 관련해서는 Integer class의 cache 배열을 통해 확인할 수 있는데, 인자 값에 따라서 캐싱한 인스턴스를 반환하는 로직을 확인할 수 있습니다.

 

 

 

3. 반환 타입의 하위 타입 객체를 반환할 수 있다.


// Collections의 정적 메서드들

unmodifiableCollection()
unmodifiableSet()
unmodifiableSortedSet()
unmodifiableNavigableSet()
unmodifiableList()
UnmodifiableRandomAccessList()
...

이러한 정적 메서드들은 각각의 하위 구현체를 반환하고 있습니다. 개발자는 직접 그 구현체에 대한 API를 확인하지 않더라도 Static factory method를 통해 이를 반환받고 사용할 수 있습니다.

 

이는 API의 크기와 개발자가 알고 있는 범주를 최소화하고 명시된 메서드의 이름과 간략한 설명을 통해 어떠한 객체를 얻을지 알 수 있어 개발에 대한 편의성을 가지게 됩니다.

 

 

 

4. 입력 매개변수에 따라 매번 다른 클래스의 객체를 반환할 수 있다.


public static List<SimpleGrantedAuthority> ofRole(Domain model) {
    return domain.hasRole() 
         ? Collections.unmodifiableList(new SimpleGrantedAuthority("ADMIN"))
         : ListUtils.emptyRoleList(); // 가상의 Static factory method를 사용하였다.
}

----

List<GrantedAuthority> role = ofRole(model);

권한 상태 정보를 가지는 특정 Domain model이 있다고 가정한 예제입니다. Static factory method 내부에서는 전달받은 model 권한 상태에 따라 두 가지 유형의 리스트를 전달하게 됩니다. 클라이언트에서는 이를 알 수 없지만 로직 자체는 정상적으로 동작하기에, 개인적으로는 이를 통해 상황에 따라 유연한 로직 처리도 가능할 것이라고 생각합니다.

 

그리고 Java API의 EnumSet의 경우에는 전달받은 인자의 길이에 따라서 RegularEnumSet이나 JumboEnumSet 등을 반환하게 되는데 이는 값에 따라 메모리 공간을 개선하는 효과를 가지기도 합니다. 즉 성능이나 메모리 관점에서의 최적화도 Static factory method를 통해 가능합니다.

 

 

 

5. 메서드가 작성되는 시점에는 객체 클래스가 존재하지 않아도 된다.


Static factory method를 구현하는 시점에 Interface를 반환 타입을 지정한다면, 이후 시점에서 Interface의 구현체들이 추가되고 별도 모듈을 통해 주입되더라도 문제없이 주입받아 동작되게 만들 수 있습니다.

 

JDBC와 같이 다양한 벤더들이 하나의 서비스 API를 구성하는 구조(서비스 제공자 프레임워크)에서 서비스 인스턴스를 생성하고 주입해주는 접근 API 모듈은 이러한 특성을 활용한 것입니다.

접근 API 모듈 즉, Driver Manager는 getConnection() 이란 정적 팩터리 메서드를 가지고 있는데, 프로젝트에서 정의한 데이터 소스, 의존성을 통해 MySQL에서 제공하는 JDBC Driver가 추가되고 해당 구현체가 구현한 커넥션을 반환받을 수 있습니다.

 

 

여기까지가 이펙티브 자바에서 다루는 장점입니다. 이번엔 단점을 보도록 하겠습니다.

 

 

 

1. 상속을 하려면 public이나 protected 생성자가 필요하므로 정적 팩터리 메서드만 제공하게 하면 하위 클래스를 만들 수 없다.


사실 이 것은 상속 관점에서의 제한적인 시선이라고 생각이 듭니다. 최근에는 객체에서 기능을 제공하기 위해서 결합성을 올리고, 하위 구현체가 잘못된 동작을 할 수 있는 상속 방식을 사용하는 것보다 상태 변수로 가지는 조합 방식을 더 선호하기 때문입니다.

 

그렇기에 조합 방식을 사용하여 개발하는 사람들에게는 오히려 장점이 된다고 생각이 듭니다.

 

 

 

2. Static factory method에 대한 설명은 프로그래머가 찾기 어렵다.


정적 팩터리 메서드는 생성자처럼 명확한 API 설명을 가지지 않으므로 의미상 이해하기 어려운 부분에 대해서는 찾는 것이 더 어렵게 된다고 합니다. 이건 Java doc의 문제점..?

 

이를 완화하기 위해서 Static factory method를 작성할 때 흔히 사용되는 명명 규약을 준수하는 것이 좋다고 합니다.

from, of, valueOf, getInstance 등이 존재합니다.

 

 

 

참고 자료

  • Effective Java 3판

+ Recent posts