디자인패턴

Builder Pattern(빌더 패턴)

빈코더 2024. 12. 16. 18:38
728x90

Builder Pattern(빌더 패턴)

Builder Pattern은 소프트웨어 디자인 패턴 중 하나로 객체 생성의 복잡성을 줄이고, 단계적으로 객체를 구성할 수 있도록 도와주는 생성 디자인 패턴입니다. 주로 생성자가 복잡하거나 다양한 설정이 필요한 객체를 생성할 때 사용합니다.

예를 들어 자동차를 만든다고 가정할때, 자동차를 주문할때 여러가지 옵션을 넣을수가 있습니다. 썬루프, 시트, 타이어 크기, 자동차 색상 등등을 구매자가 원하는대로 결졍됩니다. 어느 사람은 기본 옵션을 그대로 사용할 수 도 있고, 어떤 사람은 썬루푸, 시트만 변경할 수 도 있고, 어떤 사람은 모두 변경할 수 도 있습니다. 이처럼 선택적 옵션을 보다 유연하게 받아 다양한 타입의 인스턴스를 생성할 수 있어, 클래스의 선택적 매개변수가 많은 상황에서 유용하게 사용됩니다.

Builder Pattern의 탄생 배경

1. Telescoping Constructor Pattern(점층적 생성자 패턴)

점층적 생성자 패턴은 생성자의 매개변수를 점진적으로 늘려가며 필수 매개변수와 선택적 매개변수를 모두 처리할 수 있는 방식으로 객체를 생성하는 패턴입니다.

이 패턴은 간단한 구조의 객체를 생성할 때 유용하지만, 매개변수의 개수가 많아질수록 관리가 어렵고 가독성이 떨어지는 단점이 있습니다.

  class User(
      val name: String,          // 필수 매개변수
      val email: String,         // 필수 매개변수
      val age: Int = 0,          // 선택적 매개변수 (기본값 제공)
      val address: String = ""   // 선택적 매개변수 (기본값 제공)
  ) {
      override fun toString(): String {
          return "User(name='$name', email='$email', age=$age, address='$address')"
      }
  }

  fun main() {
    // 필수 매개변수만 제공
    val user1 = User("John", "john@example.com")

    // 선택적 매개변수 제공
    val user2 = User("John", "john@example.com", 30)

    // 모든 매개변수 제공
    val user3 = User("John", "john@example.com", 30, "123 Street, City")
}

위는 간단한 예제지만 제가 제가 처음 예제로 들었던 자동차 옵션으로 코드를 작성하면 매개변수만 수십개에서 수백개까지 제공될 수 있습니다.

그러면 내가 입력한 매개변수가 어떠한 매개변수인지 확인이 어려워 가독성이 떨어지고, 매개변수의 순서를 알아야 합니다. 그리고 새로운 매개변수가 추가될 때마다 새로운 생성자를 작성해야 합니다.

이러한 문제점을 해결하기 위해서 JavaBeans pattern이 등장합니다.

2. JavaBeans Pattern(자바 빈 패턴)

자바빈 패턴은 객체를 기본 생성자로 생성한 후, Setter 메서드를 사용해 속성을 설정하는 방식으로 구성하는 패턴입니다. 이 패턴은 Java 및 스프링 프레임워크 에서 사용되었으며, 제가 공부하고 있는 코틀린에서는 setter, getter 메서드를 따로 생성하지 않아도 class로 생성하면 자동으로 생성자를 정의하기 때문에 사용되지 않는것으로 알고 있습니다.

특징으로는 객체를 생성할 때 매개변수가 없는 기본 생성자를 사용하고, setter 메서드를 사용하여 단계적으로 객체를 구성합니다. 그리고 필요한 속성만으로 선택적으로 설정할 수 있다는 특징이 있습니다.

먼저 이해가 쉽도록 자바로 예를 들어보겠습니다.

  public class User {
      private String name;
      private String email;
      private int age;

      // 기본 생성자
      public User() {}

      // Setter 메서드
      public void setName(String name) {
          this.name = name;
      }

      public void setEmail(String email) {
          this.email = email;
      }

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

      @Override
      public String toString() {
          return "User{name='" + name + "', email='" + email + "', age=" + age + "}";
      }
  }

  public class Main {
      public static void main(String[] args) {
          User user = new User();
          user.setName("John");
          user.setEmail("john@example.com");
          user.setAge(30);

          System.out.println(user);
      }
  }

  User{name='John', email='john@example.com', age=30}

위와 같이 Setter 메서드를 생성하여 생성자를 생성하면 setter 메서드를 통해 생성자의 속성을 설정하는 방식입니다.

이걸 Kotiln으로 변경하면 ..

  class User {
      var name: String? = null
      var email: String? = null
      var age: Int? = null

      override fun toString(): String {
          return "User(name=$name, email=$email, age=$age)"
      }
  }

  // 1. 객체 생성 및 사용 방법 
  fun main() {
    val user = User()
    user.name = "John"              // Setter를 사용해 값 설정
    user.email = "john@example.com"
    user.age = 30

    println(user)

  // 2. Property를 활용한 방법 (개선된 방법)
  fun main() {
      val user = User().apply {
          name = "John"                // Property에 직접 값 설정
          email = "john@example.com"
          age = 30
      }

      println(user)
  }
}


Java보다 훨씩 직관적이고 쉽게 구현이 가능합니다.

이 패턴을 설명하려고 한건 아니니.. 다시 본론으로 넘어가서 Java의 자바빈 패턴은 간단한 객체 설정에 적합하고, 유연성을 제공하지만 불완전 상태, 불변성 부족, 필수 속성 강제 불가등의 단점이 있습니다.

이러한 문제를 해결하기 위해 새로운 패턴인 빌더 패턴이 등장합니다.

 

3. Builder Pattern(빌더 패턴)

빌더 패턴은 자바빈 패턴의 불완전 상태, 불변성 부족, 필수 속성 강제 불가등의 단점을 보완하여 개발된 패턴으로 

복잡한 객체 생성 과정을 단순화하고, 단계적으로 객체를 구성할 수 있도록 도와주는 생성 디자인 패턴입니다.

주로 매개변수가 많거나 객체 생성 과정이 복잡한 경우에 사용됩니다. 

 

3-1. 불변성 보장

  • JavaBeans 패턴
    • 객체를 생성한 후 Setter를 통해 속성을 설정하므로, 객체가 변경 가능한 상태가 됩니다.
    • 이로 인해 객체의 일관성을 유지하기 어렵고, 멀티스레드 환경에서는 동기화 문제가 발생할 수 있습니다. 
  • 빌더 패턴
    • Builder에서 객체의 모든 속성을 설정한 뒤, 최종적으로 불변 객체를 생성합니다.
    • build() 메서드가 호출되기 전까지 객체는 생성되지 않으므로 불완전한 상태가 없습니다.
// JavaBeans 패턴: 객체가 변경 가능한 상태
val user = User()
user.setName("John") // 설정 단계
user.setEmail("john@example.com") // 여전히 변경 가능

// 빌더 패턴: 불변 객체
val user = User.Builder()
    .setName("John")
    .setEmail("john@example.com")
    .build() // build() 호출 후 객체는 불변

 

3-2. 완전한 객체 상태 보장

  • JavaBeans 패턴
    • 객체가 완전히 설정되기 전에, 불완전한 상태로 사용될 가능성이 있습니다.
  • 빌더 패턴
    • 객체는 build() 메서드가 호출될 때만 생성되므로, 완전한 상태의 객체만 생성됩니다.
    • 필수 속성을 강제할 수 있어, 객체 생성에 필요한 모든 값이 설정되었음을 보장합니다.
// JavaBeans 패턴: 불완전한 객체 상태
val user = User()
user.setName("John") // Email은 설정되지 않은 상태

// 빌더 패턴: 완전한 상태 보장
val user = User.Builder()
    .setName("John")
    .setEmail("john@example.com") // 필수 속성 설정 강제
    .build()

 

3-3. 필수 속성과 선택 속성의 명확한 구분

  • JavaBeans 패턴
    • 필수 속성과 선택적 속성이 명확히 구분하기 어렵습니다. 클라이언트가 필수 속성을 누락해도 컴파일 에러가 발생하지 않습니다.
  • 빌더 패턴
    • 필수 속성을 빌더의 생성자 또는 build() 호출 전 로직에서 강제할 수 있어, 잘못된 객체가 생성 가능성을 방지합니다.
// 빌더 패턴: 필수 속성 강제
class User private constructor(
    val name: String,
    val email: String
) {
    class Builder {
        var name: String? = null
        var email: String? = null

        fun setName(name: String) = apply { this.name = name }
        fun setEmail(email: String) = apply { this.email = email }

        fun build(): User {
            if (name.isNullOrEmpty() || email.isNullOrEmpty()) {
                throw IllegalStateException("Name and Email are required")
            }
            return User(name!!, email!!)
        }
    }
}

 

3-4. 가독성과 유지보수성 향상

  • JavaBeans 패턴
    • 여러 Setter를 호출해야 하므로, 객체 생성 코드가 길어지고 가독성이 떨어질 수 있습니다.
  • 빌더 패턴
    • 메서드 체이닝을 사용하여 객체 생성 과정을 직관적이고 간결하게 표현할 수 있습니다.
// JavaBeans 패턴: Setter 호출이 많아 가독성이 떨어짐
val user = User()
user.setName("John")
user.setEmail("john@example.com")
user.setAge(30)

// 빌더 패턴: 메서드 체이닝으로 간결
val user = User.Builder()
    .setName("John")
    .setEmail("john@example.com")
    .setAge(30)
    .build()

 

3-5. 객체 생성의 유연성 증가

  • JavaBeans 패턴
    • 속성을 단계적으로 설정할 수 있지만, 특정 속성 간 조합을 관리하거나 새로운 속성을 추가할 때 코드 수정이 필요합니다.
  • 빌더 패턴
    • 객체 생성 로직이 Builder 내부에 캡슐화되므로, 새로운 속성이 추가되거나 생성 로직이 변경되더라도 기존 코드에 영향을 주지 않습니다.
// 빌더 패턴: 새로운 속성 추가
class User private constructor(
    val name: String,
    val email: String,
    val age: Int?,
    val address: String?
) {
    class Builder {
        private var name: String = ""
        private var email: String = ""
        private var age: Int? = null
        private var address: String? = null

        fun setName(name: String) = apply { this.name = name }
        fun setEmail(email: String) = apply { this.email = email }
        fun setAge(age: Int?) = apply { this.age = age }
        fun setAddress(address: String?) = apply { this.address = address }

        fun build() = User(name, email, age, address)
    }
}

 

Builder Pattern의 특징

  1. 객체 생성의 분리
    • 객체 생성 로직과 객체의 표현(속성 설정)을 분리합니다.
    • 객체 생성 과정을 캡슐화하여 클라이언트 코드가 간단해집니다.
  2. 가독성 향상
    • 메서드 체이닝 방식으로 객체를 구성하므로, 가독성이 좋아지고 유지보수가 쉬워집니다.
      • 메서드 체이닝: 객체의 메서드가 자신의 객체를 반환하여, 여러 메서드를 연속적으로 호출할 수 있는 방식
  3. 유연한 객체 생성
    • 필수 속성과 선택적 속성을 명확히 구분할 수 있어, 다양한 조합으로 객체를 생성할 수 있습니다. 
  4. 불편 객체 생성 가능
    • 최종적으로 완성된 객체는 불변으로 만들수 있어 안전성을 제공합니다.

Builder Pattern 예제 

1. 전통적인 빌더 패턴 구현

Kotlin도 Java와 동일하게 Builder 클래스를 작성해 빌더 패턴을 구현할 수 있습니다. 

class User private constructor(
    val name: String,
    val email: String,
    val age: Int?,
    val address: String?
) {
    // Builder 클래스
    class Builder {
        private var name: String = ""
        private var email: String = ""
        private var age: Int? = null
        private var address: String? = null

        fun setName(name: String) = apply { this.name = name }
        fun setEmail(email: String) = apply { this.email = email }
        fun setAge(age: Int?) = apply { this.age = age }
        fun setAddress(address: String?) = apply { this.address = address }

        fun build(): User {
            if (name.isBlank() || email.isBlank()) {
                throw IllegalStateException("Name and Email are required")
            }
            return User(name, email, age, address)
        }
    }
}

// 사용 예제 
fun main() {
    val user = User.Builder()
        .setName("John Doe")
        .setEmail("john.doe@example.com")
        .setAge(30)
        .setAddress("123 Street, City")
        .build()

    println("User: ${user.name}, Email: ${user.email}, Age: ${user.age}, Address: ${user.address}")
}

 

하지만 이건 Java와 비슷하게 구현을 한 것이고 Kotlin을 다른 방식으로 구현이 가능합니다. 

2. Named Arguments

Kotlin은 함수 호출 시 이름 있는 인자(named arguments)를 지원하므로, 빌더 패턴 없이도 매개변수가 많은 객체를 쉽게 생성할 수 있습니다. 

data class User(
    val name: String,
    val email: String,
    val age: Int? = null,
    val address: String? = null
)

fun main() {
    val user = User(
        name = "John Doe",
        email = "john.doe@example.com",
        age = 30,
        address = "123 Street, City"
    )
    println(user)
}
  • 장점: 명확한 매개변수 전달, 불필요한 빌더 클래스 제거.
  • 제한: 필수 속성을 강제하거나 추가 로직을 캡슐화하기 어려움

3. DSL 스타일 빌더

Kotlin의 함수형 DSL(도메인 특화 언어) 스타일을 사용하여 객체 생성을 더욱 유연하고 간결하게 구현할 수 있습니다.

class UserBuilder {
    var name: String = ""
    var email: String = ""
    var age: Int? = null
    var address: String? = null

    fun build() = User(name, email, age, address)
}

fun user(init: UserBuilder.() -> Unit): User {
    val builder = UserBuilder()
    builder.init()
    return builder.build()
}

data class User(
    val name: String,
    val email: String,
    val age: Int?,
    val address: String?
)

fun main() {
    val user = user {
        name = "John Doe"
        email = "john.doe@example.com"
        age = 30
        address = "123 Street, City"
    }
    println(user)
}
  • 장점: 읽기 쉬운 코드로 객체를 선언적으로 생성 가능
  • 제한: Kotlin 고유 기능에 익숙하지 않은 경우 초기 학습이 필요

결론

Kotlin에서도 Builder 패턴을 사용하지만, Java와 달리 Kotlin 고유 기능 덕분에 Builder 패턴을 사용하지 않아도 객체 생성 과정을 간결하고 유연하게 처리할 수 있다고 생각이 듭니다. 그러나, 복잡한 객체 생성이나 필수 속성을 강제가 필요한 상황에서는 빌더 패턴이 여전히 좋은 패턴이라고 생각이 듭니다. 

참고

  1. 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

 

728x90