도서 요약 / / 2023. 2. 9. 08:43

디자인 패턴 - 빌더(builder) 패턴

udemy 강좌(Java Design Patterns & SOLID Design Principles)를 정리한 내용이다.

https://www.udemy.com/course/design-patterns-in-java-concepts-hands-on-projects/learn/lecture/9604610?start=0#overview

빌더 패턴에 대해 알아보자.
빌더 패턴은 생성 디자인 패턴이고 객체 생성할 때 빌더를 사용한다는 것을 의미한다.
디자인 패턴 세부사항으로 들어가기 전에 우선 이 패턴이 해결하려고 하는 몇 가지 문제를 먼저 보자.

빌더 디자인 패턴이 해결하려고 하는 문제

  • 생성자에 많은 정보가 필요하다.

이 객체는 불변(immutable)하다고 하자. 객체가 한번 생성되고 나면 변경할 수 없다는 의미다. 그래서 String 객체를 만들고 나면 그 값은 변경할 수 없다.
일반적으로 불변 클래스를 만들 때 생성자 내에 객체의 모든 정보를 넘겨야 하므로 여러 개의 파라미터가 포함됨을 알 수 있다.

많은 인수를 가지는 메소드나 생성자가 있는 코드에서 그 코드를 사용하는 사람이 정확한 값이 무엇이며 어떤 순서를 넘겨야 하는 알아내야 하기 때문에 나쁜 코드로 생각된다. 그리고 인수들이 비슷하거나 동일한 데이터라면 문제는 더 복잡해진다.

예제에서, Product 생성자는 4개의 인수가 필요하다. 어떤 사람들은 파라미터 이름이 문서화 될 수 있다고 생각하지만, 우리의 코드는 컴파일된 코드나 jar 파일로 배포가 된다. 그래서 파라미터 이름 그 자체는 소용이 없다. 이 파라미터를 알아내는 유일한 방법은 문서를 참고하는 것 뿐이다.

빌더 디자인 패턴이 이러한 상황에서 실제로 도움이 된다. 빌더 패턴이 주는 두 가지 장점은 아래와 같다.

첫 번째는 객체 생성을 할 때 생성자 인수를 사용하기 쉽게 된다. 그리고 두 번째는 처음 코드 작성할 때 많은 인수들을 받는 생성자를 만들 필요가 없는 것이다.

빌더 디자인 패턴이 해결하려고 하는 문제

이제 User 클래스의 생성자를 한번 보자, Address 객체와 Rule 컬렉션이 필요하다는 것을 알 수 있다. User 객체를 만드는 데 이러한 객체들이 사용된다는 것을 알 수 있다.

그래서 객체를 만드는데 여러가지 구성요소가 필요한 경우가 있을 때 빌더 디자인 패턴을 사용할 수 있다.

두 번째는 생성자를 보면 우선 Address 객체와 Role 컬렉션을 만들고 나서 User 컬렉션을 호출할 수 있다는 것을 알 수 있다.
즉, User 객체를 만들기 전에 따라야 할 몇 가지 단계가 있다는 것이다.

그래서 빌더 디자인 패턴은 많은 도움을 준다.

빌더는 무엇인가?

  • 객체를 생성할 때 여러 단계의 복잡한 과정이 있는 경우 빌더 패턴이 도움이 될 수 있다.
  • 빌더에서 클라이언트 코드와 개별 클래스로 추상화함으로써 객체 생성과 관련된 로직을 제거한다.

UML

빌더 구현하기

빌더를 구현하는 방법을 알아보자.

  • builder를 만들면서 시작한다.
    • product의 구성요소를 확인하고 그 요소를 만드는 메소드를 제공한다.
    • 그 다음, assemble 메소드를 제공하거나 product/object를 만들기 위한 메소드를 제공해야 한다.
    • build 객체를 가져오기 위한 방법/메소드를 제공한다. 나중에 재사용할 수 있도록 builder는 product에 참조값을 가지게 할 수도 있다.
  • director가 별개 클래스가 될 수 있거나 클라이언트가 디렉터(director) 역할을 할 수 있다.
    • 클라이언트가 빌더를 사용하는 방법을 알아야 한다는 의미이다.

예제: UML

Java 구현

구현하기 위해 필요한 소스는 아래와 같다.

// DTO를 구성하기 위해 사용되는 엔티티 클래스
@Getter
@Setter
public class User {

    private String firstName;

    private String lastName;

    private LocalDate birthday;

    private Address address;
}

User 클래스가 있다. 엔티티 클래스라고 생각할 수 있다. 퍼시스턴시 레이어에서 데이터를 가져오고 DB에 저장되어 있다.

그리고 DTO를 구성하기 위해 이 객체를 사용해 볼 예정이다.

@Getter
@Setter
public class Address {

    private String houseNumber;

    private String street;

    private String city;

    private String zipcode;

    private String state;
}

다음은 Address 클래스이다. 일반적인 값 객체이며 주소 정보를 저장하기 위해서 User 엔티티 내부에 위치한다.

public interface UserDTO {

    String getName();

    String getAddress();

    String getAge();
}
@Getter
@Setter
@AllArgsConstructor
@ToString
public class UserWebDTO implements UserDTO {

    private String name;

    private String address;

    private String age;
}

UserDTO는 빌더 디자인 패턴에서 만들어지는 product 클래스이다. 그 말은 이 클래스의 객체를 빌더에서 만든다는 것이다.

// Abstract Builder
public interface UserDTOBuilder {

    UserDTOBuilder withFirstName(String fname);

    UserDTOBuilder withLastName(String lname);

    UserDTOBuilder withBirthday(LocalDate date);

    UserDTOBuilder withAddress(Address address);

    // 최종 Product를 구성할 때 사용하는 메소드
    UserDTO build();

    // 이미 만들어진 객체를 가져올 때 사용하는 메소드
    UserDTO getUserDTO();
}

UserDTOBuilder의 인터페이스가 있다. 여기 메소드들은 빌더 그 자체를 리턴한다는 것을 알 수 있다. 이 구현유형을 사용하며 메소드 체이닝을 가능하게 한다.

build() 메소드는 최종 객체를 모으는 메소드이며 생성된 객체를 리턴한다.

getUserDTO() 메소드가 있고 User 객체를 리턴하는 역할을 한다.

// UserWebDTO를 위한 구체적인 빌더
public class UserWebDTOBuilder {
}

UserWebDTOBuilder는 구체적인 빌더이고 아래와 같이 구현이 된다.

구현을 하면 아래와 같다.

// UserWebDTO를 위한 구체적인 빌더
public class UserWebDTOBuilder implements UserDTOBuilder {

    private String firstName;

    private String lastName;

    private String age;

    private String address;

    private UserWebDTO dto;

    @Override
    public UserDTOBuilder withFirstName(String fname) {
        this.firstName = fname;
        return this;
    }

    @Override
    public UserDTOBuilder withLastName(String lname) {
        this.lastName = lname;
        return this;
    }

    @Override
    public UserDTOBuilder withBirthday(LocalDate date) {
        Period ageInYears = Period.between(date, LocalDate.now());
        this.age = Integer.toString(ageInYears.getYears());
        return this;
    }

    @Override
    public UserDTOBuilder withAddress(Address address) {
        this.address = address.getHouseNumber() + ", " + address.getState() + "\n"
            + address.getCity() + "\n"
            + address.getState() + " " + address.getZipcode();
        return this;
    }

    @Override
    public UserDTO build() {
        this.dto = new UserWebDTO(firstName + " " + lastName, address, age);
        return this.dto;
    }

    @Override
    public UserDTO getUserDTO() {
        return this.dto;
    }
}

다음은 이 빌더를 사용할 클라이언트를 구현할 차례이다.

public class Client {

    public static void main(String[] args) {
        User user = createUser();
        UserDTOBuilder builder = new UserWebDTOBuilder();

        UserDTO dto = directBuild(builder, user);
        System.out.println(dto);
    }

    // Director
    private static UserDTO directBuild(UserDTOBuilder builder, User user) {
        return builder
            .withFirstName(user.getFirstName())
            .withLastName(user.getLastName())
            .withAddress(user.getAddress())
            .withBirthday(user.getBirthday())
            .build();
    }

    // sample User를 리턴
    public static User createUser() {
        User user = new User();
        user.setBirthday(LocalDate.of(1960, 5, 6));
        user.setFirstName("Ron");
        user.setLastName("Swanson");
        Address address = new Address();
        address.setHouseNumber("100");
        address.setStreet("State Street");
        address.setCity("Pawnee");
        address.setState("Indiana");
        address.setZipcode("12345");
        user.setAddress(address);

        return user;
    }
}

다음은 빌더를 구현하는 또 다른 방법을 알아볼 것이다.

// Product 클래스
@Getter
@Setter
@ToString
public class UserDTO {

    private String name;

    private String address;

    private String age;

    public static UserDTOBuilder getBuilder() {
        return new UserDTOBuilder();
    }

    public static class UserDTOBuilder {
        private String firstName;

        private String lastName;

        private String age;

        private String address;

        private UserDTO userDTO;

        public UserDTOBuilder withFirstName(String fname) {
            this.firstName = fname;
            return this;
        }

        public UserDTOBuilder withLastName(String lname) {
            this.lastName = lname;
            return this;
        }

        public UserDTOBuilder withBirthday(LocalDate date) {
            Period ageInYears = Period.between(date, LocalDate.now());
            this.age = Integer.toString(ageInYears.getYears());
            return this;
        }

        public UserDTOBuilder withAddress(com.example.design.builder.type1.Address address) {
            this.address = address.getHouseNumber() + ", " + address.getStreet() + "\n"
                + address.getCity() + "\n"
                + address.getState() + " " + address.getZipcode();
            return this;
        }

        public UserDTO build() {
            this.userDTO = new UserDTO();
            userDTO.setName(firstName + " " + lastName);
            userDTO.setAddress(address);
            userDTO.setAge(age);

            return this.userDTO;
        }

        public UserDTO getUserDTO() {
            return this.userDTO;
        }
    }
}

여기서 Builder가 inner 클래스로 구현되어 있다. 내부 클래스로 되어 있어 빌더에 대한 명확한 이름을 제공한다.

또한 객체 생성을 외부에서 사용할 수 없으므로 캡슐화에 장점이 있다.

클라이언트 코드는 아래와 같다.

public class Client {

    public static void main(String[] args) {
        User user = createUser();
        UserDTO dto = directBuild(UserDTO.getBuilder(), user);
        System.out.println(dto);
    }

    // Director
    private static UserDTO directBuild(UserDTO.UserDTOBuilder builder, User user) {
        return builder
            .withFirstName(user.getFirstName())
            .withLastName(user.getLastName())
            .withAddress(user.getAddress())
            .withBirthday(user.getBirthday())
            .build();
    }

    // sample User를 리턴
    public static User createUser() {
        User user = new User();
        user.setBirthday(LocalDate.of(1960, 5, 6));
        user.setFirstName("Ron");
        user.setLastName("Swanson");
        Address address = new Address();
        address.setHouseNumber("100");
        address.setStreet("State Street");
        address.setCity("Pawnee");
        address.setState("Indiana");
        address.setZipcode("12345");
        user.setAddress(address);

        return user;
    }
}

구현 고려사항

  • 내부 정적 클래스로 빌더를 구현함으로써 불변 클래스를 손쉽게 만들 수 있다. 불변성이 주요 관심사가 아니더라도 이런 유형의 구현이 사용되는 경우를 많이 보게 될 것이다.
    • 왜냐하면 product 클래스 내부에 빌드를 가지는 것이 namespace를 부여하는데 좀 더 좋기 때문에 여러분은 빌더를 찾기 위해 코드를 뒤질 필요가 없다. 빌더는 객체가 만들어지는 클래스 내부에 존재한다.

디자인 고려사항

  • 디렉터(director) 역할은 개별 클래스에서 거의 구현되지 않고 일반적으로 객체 인스턴스를 사용하는 곳이나 클라이언트에서 그 역할을 한다.
  • 추상 빌더는 product 자체가 상속구조가 아니면 필요하지 않다. 하나의 product 클래스에 하나의 빌더만 필요해서 추상 빌더를 만드는 것은 의미가 없다. 구체적인 빌더를 직접 생성할 수 있다. 추상 빌더를 사용할 지 말지는 여러분이 선택하면 된다.
  • 여러분이 "너무 많은 생성자 인수" 문제를 겪고 있다면 빌더 패턴이 도움이 된다는 것을 나타낸다.

위험요소

특정 디자인 패턴의 단점을 이야기할 때 추가되는 클래스, 기존 코드를 리팩토링 하는 노력, 특정 패턴을 이해하기 위한 복잡성을 고려한다.

그런 점에서 볼 때, 빌더 디자인 패턴은 특별한 단점이 없다. 하지만 이 패턴이 완전 무결하다는 것을 의미하지는 않는다. 이 패턴을 작업할 때 직면하는 몇 가지 이슈가 있다.

  • 빌더 메소드가 리턴하는 객체의 메소드 체이닝 때문에 신참자들에게는 복잡해 보일 수가 있다.
  • 부분적으로 객체가 초기화될 가능성이 있다. 코드는 withXXX 메소드로 몇 개의 속성만을 세팅하고 build()를 호출할 수 있다. 필요한 속성이 빠졌을 때 빌더 메소드는 적절한 기본값 부여하거나 예외를 던저야만 한다.



반응형
  • 네이버 블로그 공유
  • 네이버 밴드 공유
  • 페이스북 공유
  • 카카오스토리 공유