Spring

[디자인 패턴]빌더(Builder) 패턴이란?

Woonys 2022. 6. 4. 16:10
반응형

빌더 패턴이란?

디자인 패턴 중 생성 패턴에 해당. 생성 패턴은 인스턴스를 만드는 절차를 추상화하는 패턴이다. 생성 패턴에 속하는 패턴들은 객체를 생성, 합성하는 방법이나 객체의 표현 방법을 시스템과 분리해준다.

생성 패턴에는 중요한 이슈가 두 가지가 있는데

  1. 생성 패턴은 시스템이 어떤 Concrete Class를 사용하는지에 대한 정보를 캡슐화한다.
  2. 생성 패턴은 이들 클래스의 인스턴스들이 어떻게 만들고 어떻게 결합하는지에 대한 부분을 완전히 가려준다.

즉, 생성 패턴을 이용하면 무엇이 생성되고, 누가 이것을 생성하며, 이것이 어떻게 생성되는지, 언제 생성할 것인지 결정하는데 유연성을 확보할 수 있게 된다.

빌더 패턴은 복잡한 객체를 생성하는 클래스와 표현하는 클래스를 분리하여 동일한 절차에서도 서로 다른 표현을 생성하는 방법을 제공한다. 많은 optional한 멤버 변수(or 파라미터)나 지속성 없는 상태값들에 대해 처리해야 하는 문제를 해결한다.

ex) 팩토리 패턴, 추상 팩토리 패턴에서 생성해야 하는 클래스에 대한 속성 값이 많을 때 아래와 같은 이슈가 발생한다.

  1. 클라이언트 프로그램으로부터 팩토리 클래스로 많은 파라미터를 넘겨줄 때 타입, 순서 등에 대한 관리가 어려워져 에러가 발생할 확률이 높아짐
  2. 경우에 따라 필요 없는 파라미터들에 대해 팩토리 클래스에 일일이 null 값을 넘겨줘야 한다.
  3. 생성해야 하는 sub class가 무거워지고 복잡해짐에 따라 팩토리 클래스 또한 복잡해짐.

빌더 패턴은 이런 문제를 해결하기 위해 별도의 builder 클래스를 만들어 필수 값에 대해서는 생성자로, 선택적인 값들에 대해서는 메소드로 step-by-step으로 값을 입력받은 다음 build() 메소드를 통해 최종적으로 하나의 인스턴스를 리턴하는 방식이다.

사용법

  • 객체의 생성 알고리즘이 조립 방법에 독립적일 때
  • 합성할 객체들의 표현이 서로 다르더라도 생성 절차에서 표현 과정을 지원해야 할 때

구조

  • Builder(추상 클래스)를 정의하고 이를 상속받은 ConcreteBuilder(서브클래스)로 구현한다.
  • Product 일부가 build될 때마다 Director는 Builder에게 통보한다.
  • Builder는 Director의 요청을 처리해 Product에 부품을 추가한다.

장점

  • 필요한 데이터만 설정 가능
  • 표현을 다양하게 변경할 수 있다. → 유연성 확보
  • 생성과 표현 코드를 분리한다. → 가독성 높임
  • 복합 객체를 생성하는 절차를 세밀하게 나눌 수 있다. → 변경 가능성 최소화

예제

아래 User 클래스를 바탕으로 왜 생성자나 수정자보다 빌더를 써야하는지 이해해보자!

@NoArgsConstructor
@AllArgsConstructor
public class User {

        private String name;
        private int age;
        private int height;
        private int iq;
}

1. 필요한 데이터만 생성할 수 있음

예를 들어 User 객체를 생성해야 하는데 age라는 파라미터가 필요없는 상황이라고 가정하자. 생성자나 정적 메소드를 사용하는 경우라면 우리는 age에 더미 값을 넣어주거나 age가 없는 생성자를 새로 만들어줘야 한다.

// 1. 더미값을 넣어주는 방법
User user = new User("망나니개발자", 0, 180, 150)

// 2. 생성자 또는 정적 메소드를 추가하는 방법
@NoArgsConstructor
@AllArgsConstructor
public class User {
        private String name;
        private int age;
        private int height;
        private int iq;

        public User (String name, int height, int iq) {
                this.name = name;
                this.height = height;
                this.iq = iq;
                // this.age는 필요없는 상황!
        }

        public static User of(String name, int height, int iq) {
                return new User(name, 0, 180, 150); // 근데 여기서는 더미값 (age=0)을 넣어준다
        }
}

이런 작업이 한두 번이면 번거롭더라도 걍 하면 됨. 근데 요구사항은 늘 변하게 되어 있고, 반복적인 변경을 필요로 하면서 시간 낭비로 이어진다. 이걸 빌더 패턴으로 작성하면 동적으로 처리가 가능해진다.

User user = User.builder()
                            .name("망나니 개발자")
                            .height(180)
                            .iq(150).build();
                            // age는 넣을 필요 없음.

이렇게 필요한 데이터만 설정할 수 있는 빌더의 장점은 생성자 또는 정적 메소드와 비교할 때, 테스트용 객체를 생성하기 용이하게 해주며 불필요한 코드 양을 줄이는 등의 이점을 가져온다.

2. 유연성을 확보할 수 있음

예를 들어 User 클래스에 몸무게를 나타내는 새로운 변수 weight를 추가해야 한다고 하자. 하지만 아래와 같이 생성자를 객체로 만드는 코드가 이미 있다고 하자.

// As-is
User user = new User("망나니개발자", 28, 180, 150);

//To-be: weight: 75(kg) 추가
User user = new User("망나니개발자", 28, 180, 150, 75);

그러면 우리는 새롭게 추가되는 변수 대문에 기존 코드를 수정해야 하는 상황에 직면한다. 위의 경우는 짧지만(75만 추가하면 끝), 기존 작성 코드 양이 방대하다면 감당하기 어려울 수 있다. 여기서 빌더 패턴 이용하면 새로운 변수가 추가되는 등의 상황이 생겨도 기존 코드에 영향을 주지 않을 수 있다.

@Test
public void 1번테스트() {
        // 수정 필요함(As-is)
        User user = new User("망나니개발자", 28, 180, 150);

        // 수정 필요함(To-Be)
        User user = new User("망나니개발자", 28, 180, 150, 75);

        ...
}

...

@Test
public void 100번테스트() {

        // 수정 필요함 (As-is)
        User user = new User("망나니개발자", 28, 180, 150);

        // 수정 필요함 (To-Be)
        User user = new User("망나니개발자", 28, 180, 150, 75);
        ...
}

위와 같이 User 객체를 생성하는 코드가 100개 있다면? 모든 로직을 수정해주거나 생성자를 따로 추가하는 등의 불필요한 조치를 해줘야 한다. 하지만 빌더 패턴 기반으로 코드가 작성되어 있다면? 기존 코드를 수정할 필요가 없다. 왜냐면 빌더 패턴은 유연하게 객체 값을 설정하도록 돕기 때문.

3. 가독성을 높일 수 있음

빌더 패턴 사용하면 매개변수가 많아져도 가독성 높일 수 있음. 생성자를 객체로 생성하는 경우에는 매개변수가 많아질수록 코드 리딩이 급격히 떨어진다.

User user = new User("망나니개발자", 28, 180, 150);

위 코드를 보면 28, 180, 150이 각각 뭘 의미하는지 바로 파악도 힘들고 클래스 변수가 4개 이상만 되어도 코드를 읽기 힘들어진다. 그런데 아래와 같이 빌더 패턴을 적용하면 직관적으로 어떤 데이터에 어떤 값이 설정되는지 쉽게 파악해 가독성을 높일 수 있다.

User user = User.builder()
                            .name("망나니개발자")
                            .age(28)
                            .height(180)
                            .iq(150).build();

4. 변경 가능성을 최소화할 수 있음

많은 개발자들이 수정자(Setter) 패턴을 흔히 사용한다. 하지만 Setter를 구현한다는 건 불필요하게 변경 가능성을 열어두는 것과 같음. 이는 유지보수 시에 값이 할당된 지점을 찾기 힘들게 만들며 불필요한 코드 리딩 등을 유발한다. 만약 값을 할당하는 시점이 객체의 생성 뿐이라면 객체에 잘못된 값이 들어왔을 때 그 지점을 찾기 쉬우므로 유지보수성이 훨씬 높아질 것이다. 따라서 클래스 변수는 변경 가능성을 최소화하는 게 좋다.

변경 가능성을 최소화? 변수를 final로 선언함으로써 불변성 확보! 따라서 위의 코드를 아래와 같이 수정 가능. User 클래스는 아래와 같이 수정할 수 있다.

@Builder
@RequiredArgsConstructor
public class User {

        private final String name;
        private final int age;
        private final int height;
        private final int iq;

}

하지만 경우에 따라서 클래스 변수에 final을 붙일 수 없는 경우가 있음. 이때는 final이 없어도 Setter를 구현하지 않음으로써 동일한 효과를 얻는다. 중요한 건 변경 가능성을 열어두지 않는 것인데, final로 강제할 수 있다면 가장 바람직하나 final을 붙일 수 없는 경우라면 Setter를 넣어주지 않으면 된다.

@Builder
@AllArgsConstructor
public class User {

        private String name;
        private int age;
        private int height;
        private int iq;

}

결론

객체를 생성하는 대부분 경우에는 빌더 패턴 적용하는 게 좋다. 예외 케이스는 크게 2가지인데, 아래의 경우에는 빌더를 구현할 필요가 없다.

  1. 객체 생성을 라이브러리로 위임하는 경우
  2. 변수 개수가 2개 이하이며 변경 가능성이 없는 경우

예를 들어 엔티티(Entity) 객체나 도메인 객체로부터 DTO(?)를 생성하는 경우라면 직접 빌더를 만들고 하는 작업이 번거로우니 MapStructsk ModelMapper같은 라이브러리를 통해 생성을 위임할 수 있다. 또한, 변수가 늘어날 가능성이 거의 없으며 변수 개수가 2개 이하인 경우에는 정적 팩토리 메소드를 사용하는 게 더 좋을 수 있다. 이 경우에 빌더를 남용하는 건 오히려 코드를 비대하게 만들 수 있기 때문! 따라서 변수 개수와 변경 가능성 등을 중점적으로 보고 빌더 패턴을 적용할지 판단하자.

Reference

https://readystory.tistory.com/121

https://4z7l.github.io/2021/01/19/design_pattern_builder.html

https://mangkyu.tistory.com/163

반응형