[Spring Boot] 빌더(Builder) 패턴

민지의 회고록·2023년 8월 9일

1. 빌더 패턴이란?

  • 복잡한 객체 생성 과정과 표현 방법을 분리하여 다양한 구성의 인스턴스를 만드는 생성 패턴.

  • Builder 클래스를 만들어 메소드를 통해 각각 값을 입력받고 build() 메소드로 하나의 인스턴스를 생성하여 리턴하는 패턴

2. 빌터 패턴 구조

1) Student 클래스 - 이 클래스의 객체 생성만을 하는 별도의 빌더 클래스를 만들 것을 가정한다.

class Student {
    private int id;
    private String name = "아무개";
    private String grade = "freshman";
    private String phoneNumber = "010-0000-0000";

    public Student(int id, String name, String grade, String phoneNumber) {
        this.id = id;
        this.name = name;
        this.grade = grade;
        this.phoneNumber = phoneNumber;
    }
    
    @Override
    public String toString() {
        return "Student { " +
                "id='" + id + '\'' +
                ", name=" + name +
                ", grade=" + grade +
                ", phoneNumber=" + phoneNumber +
                " }";
    }
}

2) 빌더 클래스 구현

  • Builder 클래스를 생성하고 Student 클래스 멤버와 똑같이 필드 멤버를 구성
  • 각 멤버의 Setter 메소드를 구현한다.
    - 기존 Setter와 다르게 멤버 이름으로 메소드명을 짓는다.
    • 반환 문구인 return this는 StudentBuilder 객체 자신을 리턴함으로써 메소드 호출 후 연속적으로 빌더 메소드들을 체이닝(Chanining) 하여 호출하게 된다.
      ex) new StudentBuilder().id(값).name(값)
  • 마지막으로 Student 객체를 만들어준다
    - 빌더 클래스의 필드들을 Student 생성자 인자로 보내면서 멤버 구성이 완료된 Student 인스턴스를 얻게 된다.
class StudentBuilder {
    private int id;
    private String name;
    private String grade;
    private String phoneNumber;

    public StudentBuilder id(int id) {
        this.id = id;
        return this;
    }

    public StudentBuilder name(String name) {
        this.name = name;
        return this;
    }

    public StudentBuilder grade(String grade) {
        this.grade = grade;
        return this;
    }

    public StudentBuilder phoneNumber(String phoneNumber) {
        this.phoneNumber = phoneNumber;
        return this;
    }
    
    public Student build() {
        return new Student(id, name, grade, phoneNumber); // Student 생성자 호출
    }
}

3) 빌더 클래스 실행

  • 앞에서 설계한 빌더 객체를 실행한다.
public static void main(String[] args) {

    Student student = new StudentBuilder()
                .id(2016120091)
                .name("임꺽정")
                .grade("Senior")
                .phoneNumber("010-5555-5555")
                .build();

    System.out.println(student);
}

3. 장단점

장점

1) 객체 생성 과정을 일관된 프로세스로 표현
- 다른 방식에 비해 한눈에 파악할 수 있다.  
- 연속된 동일 타입 매개변수를 많이 설정할 경우 발생할 수 있는 설졍 오류를 방지o
2) 객체 목적에 따른 초기화가 필수인 멤버와 선택적인 멤버를 분리 가능
  • 초기화가 필수인 멤버는 생성자로 받아 필수 멤버 설정을 해주어야 빌더 객체가 생성되도록 유도
Student student1 = 
        new StudentBuilder(2016120091) // 필수 멤버
        .name("홍길동") // 선택 멤버
        .build();

Student student2 = 
        new StudentBuilder(2016120091) // 필수 멤버
        .name("임꺽정") // 선택 멤버
        .grade("freshman") // 선택 멤버
        .build();

Student student3 = 
        new StudentBuilder(2016120091) // 필수 멤버
        .name("주몽") // 선택 멤버
        .grade("Senior") // 선택 멤버
        .phoneNumber("010-5555-5555") // 선택 멤버
        .build();
3) 초기화 필수 멤버와 선택적인 멤버를 지정가능
  • 초기화 필수 멤버를 생성자로 받아 꼭 이 멤버 만은 값을 받게 한다.
class StudentBuilder {
    // 초기화 필수 멤버
    private int id;

    // 초기화 선택적 멤버
    private String name;
    
    // 필수 멤버는 빌더의 생성자를 통해 설정
    public StudentBuilder(int id) {
        this.id = id;
    }

    // 나머지 선택 멤버는 메서드로 설정
    public StudentBuilder name(String name) {
        this.name = name;
        return this;
    }
 }
Student student1 = 
        new StudentBuilder(2016120091) // 필수 멤버
        .name("홍길동") // 선택 멤버
        .build();
4) 초기화 검증 분리
  • 각 멤버 설정 메소드에서 검증 분리 가능
  • 유지 보수에 용이함.
class StudnetBuilder{
        
        ...
        
    public StudentBuilder name(String name) {
        this.name = name;
        return this;
    }

    public StudentBuilder grade(String grade) {
        if (!grade.equals("freshman") && !grade.equals("sophomore") && !grade.equals("junior") && !grade.equals("senior")) {
            throw new IllegalArgumentException(grade);
        }
        this.grade = grade;
        return this;
    }
    
    ...
    
    
 }
5) 멤버에 대한 불변성 증가
  • 생성자 없이 객체에 대한 변경 가능성을 최소화 시켜 불변성을 갖게한다.
  • 불변 객체 필요성
    - 가변 객체 작업 중 예외 발생 시 에러 유발 위험성 o
    • 다른 사람이 개발한 함수를 위험없이 이용하라 수 있어 유지보수에 유용함

단점

1) 코드 복잡성 증가
  • n개의 클래스와 n개의 새로운 빌더 클래스를 생성해야함.
  • 클래스 수 증가 및 관리해야할 클래스 수도 증가하여 구조가 복잡해질 수 있다.
2) 생성자 보다 성능이 떨어짐
  • 매번 메소드를 호출하여 빌더를 거쳐 인스턴스화하기 때문.

4. Lombok의 @Builder

  • Lombok이 제공하는 builder 어노테이션
    - 클래스에 붙여주면 클래스를 컴파일 할 때 자동으로 클래스 내부에 빌더 API가 만들어짐.

1) 빌더 어노테이션 구현

  • @Builder : PersonBuilder 빌더 클래스와 이를 반환하는 builder() 메서드 생성
  • @AllArgsConstructor(access = AccessLevel.PRIVATE) : @Builder 어노테이션을 선언하면 전체 인자를 갖는 생성자를 자동으로 만드는데, 이를 private 생성자로 설정
  • @ToString : toString() 메서드 자동 생성
@Builder
@AllArgsConstructor(access = AccessLevel.PRIVATE)
@ToString
class Person {
    private final String name;
    private final String age;
    private final String gender;
    private final String job;
    private final String birthday;
    private final String address;
}
public static void main(String[] args) {
    Person person = Person.builder()
            .name("홍길동")
            .age("26")
            .gender("man") // 선택 파라미터
            .job("Warrior")
            .birthday("1800.10.10")
            .address("조선")
            .build();
}

2) 필수 파라미터 빌더 구현

  • @Builder 어노테이션으로 빌더 패턴을 구현하면, 필수 파라미터를 지정할 수 없게 된다.
    ➡ 객체 안에 builder() 정적 메소드를 구현
    - 빌더 객체 생성 전 필수 파라미터 설정
    - 파라미터 검증 로직 추가 가능
@Builder
@AllArgsConstructor(access = AccessLevel.PRIVATE)
@ToString
class Person {
    private final String name;
    private final String age;
    private final String gender;
    private final String job;
    private final String birthday;
    private final String address;

    // 필수 파라미터 빌더 메서드 구현
    public static PersonBuilder builder(String name, String age) {
        // 빌더의 파라미터 검증
        if(name == null || age == null)
            throw new IllegalArgumentException("필수 파라미터 누락");

        // 필수 파라미터를 미리 빌드한 빌더 객체를 반환 (지연 빌더 원리)
        return new PersonBuilder().name(name).age(age);
    }
}
public static void main(String[] args) {

    Person person = Person.builder("홍길동", "26") // 필수 파라미터
            .gender("man") // 선택 파라미터
            .job("Warrior")
            .birthday("1800.10.10")
            .address("조선")
            .build();

    System.out.println(person);
}

5. 빌더 패턴의 종류

저자에 따라 2가지로 나뉜다.

5-1 심플 빌더 패턴(Effective java)

  • 생성자 많거나 불변 객체 필요할 때 사용된다.
    - 가독성, 일관성, 불변성 유지
  • 전역 내부 클래스(static inner class)로 구현
    - 두 클래스를 그룹핑하여 각 클래스 간의 관계 파악 하기 좋음.
    • 생성자의 외부 노출 방지
    • 메모리 낭비 방지를 위해 static inner 클래스로 정의
1) 구현
  • 전역 중첩 클래스(static nested class)로 빌더 클래스 생성
  • 빌더로 인스턴스화 하기 때문에, 대상 객체 생성자는 private로 정의
  • public 정의한 빌더 클래스 생성자로 필수 파라미터를 받는다.
  • 선택적 파라미터는 메소드로 받고 반환값은 this!
  • build()로 객체 생성!
class Person {
    // final 키워드로 필드들을 불변 객체로 만든다.
    private final String name;
    private final String age;
    private final String gender;
    private final String job;
    private final String birthday;
    private final String address;

    // 정적 내부 빌더 클래스
    public static class Builder {

        // 필수 파라미터
        private final String name;
        private final String age;

        // 선택 파라미터
        private String gender;
        private String job;
        private String birthday;
        private String address;

        // 필수 파라미터는 빌더 생성자로 받게 한다
        public Builder(String name, String age) {
            this.name = name;
            this.age = age;
        }

        // 선택 파라미터는 각 메서드를 통해 정의한다
        public Builder gender(String gender) {
            this.gender = gender;
            return this;
        }

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

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

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

        // 대상 객체의 private 생성자를 호출하여 최종 인스턴스화 한다
        public Person build() {
            return new Person(this); // 빌더 객체 자신을 넘긴다.
        }
    }

    // private 생성자 - 생성자는 외부에서 호출되는것이 아닌 빌더 클래스에서만 호출되기 때문에
    private Person(Builder builder) {
        this.name = builder.name;
        this.age = builder.age;
        this.gender = builder.gender;
        this.job = builder.gender;
        this.birthday = builder.birthday;
        this.address = builder.address;
    }

    @Override
    public String toString() {
        return "Person{" +
                "name='" + name + '\'' +
                ", age='" + age + '\'' +
                ", gender='" + gender + '\'' +
                ", job='" + job + '\'' +
                ", birthday='" + birthday + '\'' +
                ", address='" + address + '\'' +
                '}';
    }
}
public static void main(String[] args) {

    Person person = new Person
            .Builder("홍길동", "26") // static inner class 초기화 (필수 파라미터)
            .gender("man") // 선택 파라미터
            .job("Warrior")
            .birthday("1800.10.10")
            .address("조선")
            .build();

    System.out.println(person);
}

5-2 디렉터 빌더 패턴(GOF)

  • 복잡한 객체 생성 알고리즘 과 조립 방법을 분리
  • Director : 빌더를 받아 조립 방법을 정의한 클래스
  • 여러개의 대상 객체를 유연하게 생성, 처리
1) 구조 - Director을 통해 간단하게 인스턴스를 얻고 코드를 재사용

  • Builder : 빌더 추상 클래스
  • ConcreteBuilder : 빌더 구현체, product 생성 담당
  • Director : 빌더를 받아 빌더를 조립하는 방법 정의
  • Product : Director가 빌더로 만든 결과물
2) 구현

다음 제시된 예제는 data 객체를 빌더를 통해 적절한 문자열로 변환하는 것이다.

- 각 문자열을 알맞은 포맷으로 바꾸는건 director가 맞게 된다.
- 클라이언트는 복잡한 빌드 과정을 알 필요 없이 메소드 호출만으로 결과 도출 o

  • Director
// 각 문자열 포맷 빌드 과정을 템플릿화 시킨 디렉터
class Director {
    private Builder builder;

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

    // 일종의 빌드 템플릿 메서드라 보면 된다
    public String build() {
        StringBuilder sb = new StringBuilder();

		// 빌더 구현체에서 정의한 생성 알고리즘이 실행됨
        sb.append(builder.head());
        sb.append(builder.body());
        sb.append(builder.foot());

        return sb.toString();
    }
}
  • data
class Data {
    private String name;
    private int age;

    public Data(String name, int age) {
        this.name = name;
        this.age = age;
    }

    public String getName() {
        return name;
    }

    public int getAge() {
        return age;
    }
}
  • builder
abstract class Builder {
    // 상속한 자식 클래스에서 사용하도록 protected 접근제어자 지정
    protected Data data;

    public Builder(Data data) {
        this.data = data;
    }

    // Data 객체의 데이터들을 원하는 형태의 문자열 포맷을 해주는 메서드들 (머리 - 중간 - 끝 형식)
    public abstract String head();
    public abstract String body();
    public abstract String foot();

}
  • Plain text builder
// Data 데이터들을 평범한 문자열로 변환해주는 빌더
class PlainTextBuilder extends Builder {

    public PlainTextBuilder(Data data) {
        super(data);
    }

    @Override
    public String head() {
        return "";
    }

    @Override
    public String body() {
        StringBuilder sb = new StringBuilder();

        sb.append("Name: ");
        sb.append(data.getName());
        sb.append(", Age: ");
        sb.append(data.getAge());

        return sb.toString();
    }

    @Override
    public String foot() {
        return "";
    }
}
  • 빌더 실행
public static void main(String[] args) {
    // 1. 포맷할 자바 데이터 생성
    Data data = new Data("홍길동", 44);

    // 2. 일반 텍스트로 포맷하여 출력하기
    Builder builder1 = new PlainTextBuilder(data);
    Director director1 = new Director(builder1);
    String result1 = director1.build();
    System.out.println(result1);
    }
  • 결과 : 일반 문자열로 잘 표현된 것을 볼 수 있다.

    -> 디렉터를 통해 일관된 프로세스로 인스턴스를 쉽게 만들 수 있다.
    -> 클라이언트 측에선 디렉터가 제공하는 메소드를 호출하기 때문에, 코드를 재사용할 수 있게 된다.


참고자료 : https://inpa.tistory.com/entry/GOF-%F0%9F%92%A0-%EB%B9%8C%EB%8D%94Builder-%ED%8C%A8%ED%84%B4-%EB%81%9D%ED%8C%90%EC%99%95-%EC%A0%95%EB%A6%AC
profile
민지가 공부한 내용을 회고합니다~~

0개의 댓글