반응형

일단 읽기에 앞서, 본 내용은 지극히 주관적으로 재미삼아 변형해본 과정이기 때문에 널리 알려진 빌더 패턴을 학습하는 내용이 아닙니다.

개선이 아니라 변형이라고 칭한 이유는, 그만큼의 단점도 있다고 생각되었기 때문입니다.

먼저 기존의 빌더 패턴 방식에서의 코드를 확인하고 제가 생각하는 문제점을 살펴보겠습니다.

public class Person {
    private String firstName;
    private String lastName;
    private int age;
    private String phoneNumber;

    private Person(Builder builder) {
        this.firstName = builder.firstName;
        this.lastName = builder.lastName;
        this.age = builder.age;
        this.phoneNumber = builder.phoneNumber;
    }

    public static class Builder {
        private String firstName;
        private String lastName;
        private int age;
        private String phoneNumber;

        public static Builder builder(String firstName, String phoneNumber) {
            Builder builder = new Builder();
            builder.firstName = firstName;
            builder.phoneNumber = phoneNumber;
            return builder;
        }

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

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

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

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

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

흔히 사용되는 빌드 패턴 방식의 코드입니다.

Builder의 스태틱 메소드로 빌더 인스턴스를 생성하고, 필수로 초기화해야하는 값이 있으면, 이 스태틱 메소드의 파라미터로 넘겨줍니다.

위 코드를 이용하여 인스턴스를 생성하면 아래와 같습니다.

public static void main(String[] args) {
    Person person = Person.Builder.builder("Song", "HeeJae")
            .age(26)
            .phoneNumber("01012345678")
            .build();
}

하지만 위 코드에는 문제점이 있습니다.

빌더 인스턴스를 생성하는 builder 메소드는 firstName과 phoneNumber를 받게 되어있습니다.

하지만 클라이언트 입장에서 사용한 코드는 firstName과 lastName을 초기화하고 있습니다.

동일한 타입의 필드가 여러개 있다면, 메소드 시그니처 뿐만 아니라 어떤 필드를 초기화하고 있는지 파라미터 명까지 확인해야하는 상황입니다.

생성자에서 많은 파라미터를 요구할 때, 이를 해결하고자 적용하기도 하는 빌더 패턴에 대한 장점을 전혀 살리지 못하고 있습니다.

반드시 요구되어야하는 필드 값이라면, 생성자 방식과 동일하게 여전히 가독성 면에서 좋지 않은 모습을 보여주고 있습니다.

이번에는 아래와 같이 클라이언트 코드가 작성되었습니다.

public static void main(String[] args) {
    Person person = Person.Builder.builder("Song", "01012345678")
            .age(26)
            .lastName("HeeJae")
            .age(26)
            .build();
}

요구하는 필드들의 위치를 잘 고려해서 builder 메소드를 호출하였고, 잘 작성된 듯 보입니다.

하지만 age를 초기화하는 코드가 2번 호출되고 있습니다.

저만 이런 실수를 하는지는 모르겠지만, 필드가 많은 경우에 실수로 동일한 타입의 초기화 코드를 여러번 작성하는 경우가 종종 있었습니다.

불필요한 메소드 호출이 중복으로 수행되는 상황입니다.

이번엔 IDE로 개발하면서 자동으로 완성해주는 메소드 목록을 살펴보겠습니다.

 

IDE 자동완성
인텔리제이 IDE 자동완성

Object에 달린 기본 메소드를 차치하고라도, 뭐가 상당히 많습니다.

각 필드명마다 메소드가 생성되어있어야하고, 아까의 경우처럼 중복해서 초기화할 수도 있고, 실수로 필드 초기화를 하지 않아서 불완전한 인스턴스가 생성될 수도 있습니다.

위 문제들을 해결하는 빌더 패턴을 작성해보고자 이 글을 작성하게 되었습니다.

제가 생각한 추가적인 요구 사항은 다음과 같습니다.

1. 반드시 요구되는 필수 타입들의 가독성 개선

2. 중복 초기화 방지

3. 필드 초기화 누락 방지

4. 수 많은 필드 메소드를 모두 노출하지 않고, 하나의 단계에는 하나의 필드 초기화 메소드만 노출

이를 구현하기 위해 몇 번의 고민 과정을 거치게 되었습니다.(이 내용은 스킵하고 바로 아래의 코드를 먼저 확인해도 됩니다.)

- 필수 타입들도 다른 타입들의 초기화 메소드처럼 필드명을 이름으로 가지는 초기화 메소드를 가지면 어떨까?

- 또, 각 단계마다 하나의 초기화 메소드만 노출해야한다.

- 그렇다면 각각의 단계는 반드시 하나의 메소드만 노출해야하므로, 이를 구현할 방법이 필요하다.

- 각 초기화 단계를 독립적인 인스턴스와 그 단계의 값을 초기화하는 메소드로 구성하고, 각 단계의 반환 값을 다음 단계 인스턴스로 해주면 어떨까?

- 하지만 이렇게 될 경우, 필드의 개수만큼 인스턴스를 계속해서 새로 생성하는 비용이 생긴다.

- 또한, 각 단계마다 새로운 인스턴스에 상태를 유지해줘야하므로 번거로운 작업이 지속된다.

- 이를 해결하려면, 결국 하나의 인스턴스만 유지하는게 더욱 저렴할 것이다.

- 분명 하나의 인스턴스인데, 어쩔 때는 A메소드만 가지고 있고, 어쩔 때는 B메소드만 가지고 있고, ... 이런 식으로 구현하면 어떤 방식을 취해야 할까?

- 다중 상속을 이용해보면 어떨까?

- 각 단계들을 모두 인터페이스로 정의하고, 이 인터페이스들을 모두 구현하는 하위 클래스를 생성한다.

- 첫 초기화 단계에서는, 이 하위 클래스로 인스턴스로 생성하고, 실질적으로 반환할 때는 첫 단계의 인터페이스로 업캐스팅하여 반환한다.

- 각 단계를 지날 때마다 다음 단계로 업캐스팅하여, 각 단계에서는 인스턴스가 해당하는 단계에서 하나의 메소드를 가지게 만든다.

이러한 과정을 거쳐서 만들어진 빌더의 대략적인 구조는 아래와 같습니다.

빌더패턴 구조

FieldStep1, 2, 3은 인터페이스고, Step은 이들을 구현한 클래스라고 보시면 됩니다.

Step과 Builder는 서로 의존합니다.

이제 이를 작성한 코드를 살펴보겠습니다.

 

public class Person {
    private String firstName;
    private String lastName;
    private int age;
    private String phoneNumber;

    private Person(Builder builder) {
        this.firstName = builder.step.firstName;
        this.lastName = builder.step.lastName;
        this.age = builder.step.age;
        this.phoneNumber = builder.step.phoneNumber;
    }

    public interface FirstNameStep {
        LastNameStep firstName(String firstName);
    }

    public interface LastNameStep {
        AgeStep lastName(String lastName);
    }

    public interface AgeStep {
        PhoneNumberStep age(int age);
    }

    public interface PhoneNumberStep {
        Builder phoneNumber(String phoneNumber);
    }

    public static class Step implements FirstNameStep, LastNameStep, AgeStep, PhoneNumberStep {
        private String firstName;
        private String lastName;
        private int age;
        private String phoneNumber;
        private Builder builder;

        public Step(Builder builder) {
            this.builder = builder;
        }

        @Override
        public LastNameStep firstName(String firstName) {
            this.firstName = firstName;
            return this;
        }

        @Override
        public AgeStep lastName(String lastName) {
            this.lastName = lastName;
            return this;
        }

        @Override
        public PhoneNumberStep age(int age) {
            this.age = age;
            return this;
        }

        @Override
        public Builder phoneNumber(String phoneNumber) {
            this.phoneNumber = phoneNumber;
            return builder;
        }
    }

    public static class Builder {
        private Step step = new Step(this);
        public static FirstNameStep builder() {
            return new Builder().step;
        }

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

코드가 확실히 장황해졌습니다.

각 필드마다 Step인터페이스가 정의되었고, 이들을 구현한 Step 클래스가 있습니다.

Builder는 Builder와 Step의 인스턴스를 생성하고, Step 인스턴스를 첫 번째 필드의 타입으로 업캐스팅하여 반환해줍니다.

그리고 각 필드의 Step 인터페이스는 하나의 필드를 초기화하는 하나의 메소드를 가지고 있고, 다음 단계의 인스턴스로 캐스팅하여 반환합니다.

결국 마지막 단계에 다다르면, 다시 Builder 인스턴스를 반환하고, 이 단계에 다다라서야 이제 build()를 호출할 수 있게 됩니다.

IDE에서 위 빌더 패턴을 사용하여 코드를 작성해보겠습니다.

IDE 빌드 과정
자동완성으로 빌드 과정

 

이를 사용하는 코드는 위와 같은 단계로 작성됩니다.

각 단계에서는 하나의 필드 초기화 메소드만 강제되고, 마지막 필드까지 초기화를 마친 뒤에야 Builder 인스턴스를 얻고, Person 인스턴스를 생성해낼 수 있습니다.

얻고자 했던 요구 사항을 모두 만족하게 된 것입니다.

업캐스팅된 상위 타입 덕분에, 각 단계에서는 하나의 필드를 초기화하는 메소드만 호출할 수 있습니다.

필수로 요구하는 필드들은 기존의 방식처럼 한 번에 전달되지 않고, 반환 타입으로 체인처럼 연결됩니다. 메소드 호출로 필드 초기화를 수행하기 때문에 가독성이 좋아졌습니다.

또, 각 단계는 중복해서 수행할 수도 없고, 누락될 수도 없기 때문에 불완전한 인스턴스 생성을 방지할 수 있습니다.

위처럼 변형된 빌더 패턴을 사용하는 방식을 안다면,

단순히 Person.Builder.builder() 메소드 호출만 수행함으로써 인스턴스 초기화 과정을 손쉽게 거칠 수 있습니다.

각 단계에서는 제한된 메소드만 호출할 수 있기 때문에 실수할 염려도 크게 줄어듭니다.

하지만 위 코드에는 또 다른 문제점이 있습니다.

1. 인터페이스가 필드의 개수만큼 만들어집니다. 이러한 까닭에 클래스 파일이 너무 많이 생성됩니다.

2. 캡슐화가 제대로 되어있지 않습니다. 각 단계의 인터페이스가 외부에 노출되고 있는 상황입니다.

3. 기존의 빌더 패턴에서는 빌더 인스턴스를 한 번 생성하면 되었는데, 여기에서는 Step 인스턴스도 생성해야하는 상황입니다.

또, 아직 구현되지 않은 개선해야할 사항이 남아있습니다.

지금은 필수 타입마다 인터페이스를 작성하였고, 그 단계를 모두 수행해야하기 때문에, 선택적으로 값을 초기화하는 부분은 누락된 상황입니다.

하지만 이 부분은 유동적으로 작성할 수 있을 것이라 생각됩니다.

필수 타입만 별도의 인터페이스를 정의하여 체인처럼 연결하고, 선택적인 타입들은 기존의 빌더 패턴 방식을 이용한다면, 인터페이스의 개수를 최소화하면서 가독성을 늘릴 수 있을 것입니다. (이 경우에는 중복 초기화를 해결할 방법 고민해봐야 함)

물론, 저도 아직 코드로 작성해보진 않았기 때문에 일단 추측만 해볼 뿐입니다.

이러한 문제점들과 개선점들이 있지만,

인스턴스를 생성할 때 가독성과 안전성을 더욱 추구한다면, 이런 식으로 작성해도 되지 않을까 싶어서 고민해본 결과입니다.

처음에 언급했듯이, 지극히 주관적으로 생각하고 작성한 코드라 다른 문제가 있을지도 모르겠습니다.

아직 실무를 접해보지 않은 부족한 학생이라, 그냥 호기심에 작성해본 코드라고 여겨주시면 감사하겠습니다.

일단 여기까지만 작성해두었지만,

시간적 여유가 생기고 새로운 아이디어가 떠오른다면, 코드를 더욱 개선해나가는 과정을 거치면서 문제점들을 해결해보도록 하겠습니다.

오류나 문제점, 피드백 감사하게 받겠습니다!

코드는 아래 링크의 custom 패키지에서 만나볼 수 있습니다.

https://github.com/SongHeeJae/custom-builder-pattern

반응형

+ Recent posts