CS

[디자인 패턴] 빌더패턴 (Builder Pattern)

nkdev 2025. 1. 10. 01:25

빌더 패턴은 클래스의 객체를 만들 때, 선택적 매개변수가 많은 경우 유용하다.

class Student {
    private String name;
    private int age;
    private int number;
}

이 Student 클래스의 객체를 만들 때, 생성자/Setter/빌더 중 뭘 사용해야 좋을까?

 

1. 점층적 생성자 패턴

다양한 매개변수를 입력받아서 인스턴스를 생성하고싶을 때마다 사용하던 생성자를 오버로딩하는 방법도 있다. 이것을 '점층적 생성자 패턴(Telescoping Constructor Pattern)'이라고 한다. 

class Student {
    private String name;
    private int age;
    private int number;

    public Student(String name){
         this.name = name;
    }

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

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

1) 생성자의 매개변수가 많을 때 순서를 알아야 한다

하지만 이 방식은 매개변수가 많으면 생성자에 매개변수를 줄 때 어떤 순서로 줘야 하는지 헷갈리게 된다.

지금은 매개변수가 3개지만 20개 넘는다면 어떻게 될까?

20개의 입력 순서를 알아야 하므로 생성자가 어떻게 생겼는지 찾으러 클래스 내부를 뒤져봐야 한다.

(요즘엔 ide에서 입력 순서를 알려주기도 함)

//매개변수가 많다면 입력 순서를 헷갈릴 수 있음
Student student = new Student("David", 24, 1);

 

2) 생성자 이름이 모두 동일해서 객체 생성 시 목적과 특징을 알 수 없다

필드가 많을 수록 다양한 객체를 생성하기 위해 생성자 수가 늘어난다.

하지만 생성자 이름이 모두 같기 때문에 해당 객체의 목적과 특징을 쉽게 파악할 수 없다.

 

아래 예제에서 student1을 생성할 때는 studentWithName(), student2를 생성할 때는 studentWithNameAndAge()라는 이름으로 생성자를 호출하면 쓰는 입장에서 파라미터로 뭘 줘야 할지도 한 눈에 보이고, 객체 생성 목적도 바로 파악할 수 있을 것이다. 

 

그러나 원칙상 생성자의 이름은 바꿀 수 없으므로 모두 Student()로 쓸 수밖에 없다.

//세 객체는 모두 Student()라는 이름의 생성자를 사용하므로 객체 생성 목적을 파악하기 어려움
Student student1 = new Student("Min");
Student student2 = new Student("David", 24);
Student student3 = new Student("Ray", 26, 7);

 

따라서 점층적 생성자 패턴은 가독성, 유지보수 측면에서 좋지 않다.

 

2. 자바빈 패턴

점층적 생성자 패턴의 단점을 보완하는 방법이다. 기본 생성자로 객체 생성 후 Setter로 객체 필드에 값을 설정하는 방식이다. 생성자 오버로딩에 비해 가독성도 좋아졌고, Setter을 호출해 선택적 파라미터 설정이 가능해졌다.

class Student {
    private String name;
    private int age;
    private int number;

    public Student() {};
    public void setName(String name){
        this.name = name;
    }

    public void setAge(int age){
        this.age = age;
    }

    public void setNumber(int number){
        this.number = number;
    }
}

 

하지만 이 방식은 객체 생성 시점에 모든 값들을 주입하지 않아 일관성(consistency) 문제, 불변성(immutable) 문제가 나타난다.

 

1) 일관성 문제

👉🏻 필수 매개변수가 누락될 수 있다 

객체가 초기화될 때 반드시 설정되어있어야 하는 값 (필수 매개변수)이 개발자의 실수로 안 채워진 상태로 쓰이게 되면, 런타임 에러가 발생한다. 객체를 생성하는 부분과 값을 설정하는 부분이 물리적으로 떨어져있기 때문에 발생하는 문제이다. 

//기본 생성자로 객체 생성, setter로 필드 주입. name값만 할당함
Student student = new Student();
student.setName("David");

이 경우 만약에 age가 필수 매개변수였다면 나중에 이렇게 만들어진 객체를 사용했을 때 age값이 없는 객체를 사용하게 되기 때문에 에러가 발생할 수 있다.

 

👉🏻 값을 할당하면 안 되는 필드에 값을 줄 수 있다

DB에 의해 generatedValue로 지정되는 id 필드나 timestamp에 의해 부여되는 createdDate 필드 등은 개발자가 직접 값을 세팅해주면 안 된다. 

obj.setId("abc123");
obj.setCratedDate("2024-05-22");

이러한 필드의 setter을 잘못 열어두면 값을 직접 할당하는 실수를 범할 수 있다.

2) 불변성 문제 

👉🏻 아무데서나 객체를 함부로 조작할 수 있다

setter는 보통 public으로 선언되기 때문에 해당 객체와 전혀 상관 없는 다른 곳에서도 사용할 수 있게 되어있다.

그래서 객체를 생성한 뒤에도 여전히 Setter라는 함수가 열려있기 때문에 다른 곳에서 객체를 함부로 조작할 위험이 있다.

 

 

3. 빌더 패턴

빌더 클래스를 만들어서 메서드를 통해 값을 입력받는 방법이다. 빌더 클래스의 메서드를 체이닝 형태로 호출하여 인스턴스를 만들고, 마지막에 build()메서드를 호출하여 최종적으로 객체를 생성한다. 

 

생성자 오버로딩을 안 해도 되고, 데이터 순서에 상관 없이 파라미터를 설정할 수 있고, 필수 매개변수를 놓치는 일도 없게 되므로 1번, 2번 방법의 장점만을 취한 방법이다.

 

👉🏻 빌더 클래스 만드는 방법 

  1. 원래 클래스와 똑같이 멤버를 선언해준다.
  2. 멤버의 Setter을 만들어준다.
    • 메서드 이름은 setXXX 형태가 아니라 멤버 이름과 똑같이 만듦
    • 마지막에 return this; 로 StudentBuilder 객체 자신을 리턴하여 메서드 호출 후 연속적으로 메서드를 체이닝하여 호출할 수 있게 만든다.
public class Student {
    private String name;
    private int age;
    private int number;
    
    //생성자: private
    private Student(Builder builder){
        this.name = builder.name;
        this.age = builder.age;
        this.number = builder.number;
    }

    //inner class로 빌더 클래스 생성
    public static class Builder {
        private String name;
        private int age;
        private int number;

        public Builder(String name) { //필수 멤버
            this.name = name;
        }

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

        Builder number(int number){
            this.number = number;
            return this;
        }
    }
 

필수 매개변수를 빌더의 생성자로 받게 해서 필수 멤버를 설정해주어야 객체가 생성되도록 유도하고, 선택적 멤버는 빌더의 메서드로 받도록 유도하면, 사용자로 하여금 필수 멤버와 선택 멤버를 구분해서 객체 생성할 수 있게 할 수 있다.

생성자를 Private으로 설정하여 외부에서 객체를 생성할 수 없게 했다.

 

📍@Builder 어노테이션

이 어노테이션을 클래스 레벨에 붙이면 모든 멤버 변수를 파라미터로 받는 생성자가 자동으로 생성되고, 모든 멤버변수에 대해 빌더 메서드가 만들어진다. 

 

그러나 DB에서 자동으로 생성해주는 값에 의존하는 필드가 있을 것이다. 예를 들면 id, timestamp 등.. 그러나 이 값들을 지정할 수 있는 빌더 메서드가 열려있는 것이 문제다. 모르고 직접 값을 넣어버리는 실수가 발생할 수 있다.

 

따라서 @Builder을 생성자 레벨에 붙여서 사용하자.

class Student {
    private String name;
    private int age;
    private LocalDateTime createdDate;
    private boolean isVerified;

   @Buidler
    public Student(String name, int age) {
        this.name = name;
        this.age = age;
        this.createdDate = LocalDateTime.now();
        this.isVerifiefd = false;
    };
}
 

name, age 값만 받고 나머지는 default값을 설정하는 생성자에 @Builder을 붙였다. 그러면 Student 객체를 생성할 때 빌더 패턴으로 name, age만 설정할 수 있고 나머지 멤버들은 값을 설정할 수 없도록 닫혀있어 실수를 방지할 수 있다. 

 

📍@NoArgsConstructor 접근 권한을 최소화 하자

그리고 생성자는 클래스 하나 당 하나씩만 열어두고 빌더패턴으로 객체 생성하게 하는 게 좋다고 한다.

 

예를 들어 JPA를 쓰려면 기본 생성자가 무조건 있어야 하는데, @NoArgsConstructor(access = AccessLevel.protected) 로 막아둬야 실수로 기본 생성자를 호출하는 상황이 일어나지 않는다.

이렇게 기본 생성자가 열려있으면

@Test
void test() {
    Product product = new Product();
    assertThat(product.getId(), is(notNullValue()));
}
//통과X
 

Id가 설정되지 않은 채 생성된 객체가 생길 수 있다. 위험하다..

무조건 @NoArgsConstructor(access = AccessLevel.PROTECTED)로 막아두자.

애초에 생성자는 한 개만 두는 게 좋다.


https://cheese10yun.github.io/lombok/#google_vignette << 이 글 너무 좋다! 실무에서 롬복 사용법.