(Java/Kotlin) 빌더 패턴(Builder Pattern) 완전 정리

개요


1. 왜 빌더 패턴이 필요한가

❌ 문제 ① — 생성자 과부하 (Telescoping Constructor)

// 매개변수가 늘어날수록 생성자가 폭발적으로 증가
class User {
    User(String name) { ... }
    User(String name, int age) { ... }
    User(String name, int age, String email) { ... }
    User(String name, int age, String email, String phone) { ... }
    User(String name, int age, String email, String phone, String address) { ... }
}

// 사용 — 각 값이 무엇을 의미하는지 알 수 없음
User user = new User("홍길동", 30, "hong@test.com", "010-1234-5678", "서울시");

❌ 문제 ② — 자바빈 방식 (Setter 남용)

User user = new User();
user.setName("홍길동");
user.setAge(30);
user.setEmail("hong@test.com");
// ⚠️ 객체 생성과 초기화 사이에 불완전한 상태가 존재
// ⚠️ 불변 객체(Immutable Object)를 만들 수 없음

✅ 빌더 패턴으로 해결

val user = User.Builder("홍길동")
    .age(30)
    .email("hong@test.com")
    .phone("010-1234-5678")
    .address("서울시")
    .build()
// 각 값이 무엇인지 명확, 필수/선택 매개변수 구분 가능

2. Java 전통 방식 Builder

public class User {
    // 불변 필드
    private final String name;   // 필수
    private final int age;       // 선택
    private final String email;  // 선택
    private final String phone;  // 선택
    private final String address; // 선택

    // 외부에서 직접 생성 불가 — Builder를 통해서만 생성
    private User(Builder builder) {
        this.name    = builder.name;
        this.age     = builder.age;
        this.email   = builder.email;
        this.phone   = builder.phone;
        this.address = builder.address;
    }

    public String getName()   { return name; }
    public int    getAge()    { return age; }
    public String getEmail()  { return email; }
    public String getPhone()  { return phone; }
    public String getAddress(){ return address; }

    // 정적 내부 클래스 — Builder
    public static class Builder {
        private final String name;   // 필수
        private int age      = 0;    // 선택 — 기본값
        private String email = "";
        private String phone = "";
        private String address = "";

        public Builder(String name) {
            if (name == null || name.isBlank())
                throw new IllegalArgumentException("이름은 필수입니다");
            this.name = name;
        }

        public Builder age(int age) {
            if (age < 0) throw new IllegalArgumentException("나이는 0 이상이어야 합니다");
            this.age = age;
            return this;  // 메서드 체이닝을 위해 this 반환
        }

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

        public User build() {
            return new User(this);
        }
    }
}
// 사용
User user = new User.Builder("홍길동")
    .age(30)
    .email("hong@test.com")
    .phone("010-1234-5678")
    .build();

3. Kotlin — named argument로 Builder 대체

Kotlin은 기본 인수(default argument)이름 있는 인수(named argument) 로 전통 Builder의 필요성을 크게 줄여줍니다.

data class User(
    val name: String,              // 필수
    val age: Int = 0,              // 선택 — 기본값
    val email: String = "",
    val phone: String = "",
    val address: String = ""
)

// 사용 — Builder 없이도 가독성 확보
val user = User(
    name    = "홍길동",
    age     = 30,
    email   = "hong@test.com"
    // phone, address는 기본값 사용
)

4. Kotlin — apply로 Builder 구현

Kotlin의 apply 확장 함수를 활용하면 Java Builder를 더 간결하게 표현할 수 있습니다.

class NetworkConfig {
    var baseUrl: String = ""
    var connectTimeoutMs: Long = 5_000L
    var readTimeoutMs: Long = 10_000L
    var writeTimeoutMs: Long = 10_000L
    var retryCount: Int = 3
    var headers: MutableMap<String, String> = mutableMapOf()
    var isLoggingEnabled: Boolean = false

    fun addHeader(key: String, value: String) = apply {
        headers[key] = value
    }

    // 유효성 검증 포함
    fun build(): OkHttpClient {
        require(baseUrl.isNotBlank()) { "baseUrl은 필수입니다" }
        require(connectTimeoutMs > 0) { "connectTimeout은 0보다 커야 합니다" }

        val builder = OkHttpClient.Builder()
            .connectTimeout(connectTimeoutMs, TimeUnit.MILLISECONDS)
            .readTimeout(readTimeoutMs, TimeUnit.MILLISECONDS)
            .writeTimeout(writeTimeoutMs, TimeUnit.MILLISECONDS)

        if (isLoggingEnabled) {
            builder.addInterceptor(HttpLoggingInterceptor().apply {
                level = HttpLoggingInterceptor.Level.BODY
            })
        }

        headers.forEach { (key, value) ->
            builder.addInterceptor { chain ->
                chain.proceed(chain.request().newBuilder().addHeader(key, value).build())
            }
        }

        return builder.build()
    }
}

// 사용
val client = NetworkConfig().apply {
    baseUrl           = "https://api.example.com"
    connectTimeoutMs  = 3_000L
    readTimeoutMs     = 15_000L
    isLoggingEnabled  = BuildConfig.DEBUG
}.addHeader("Authorization", "Bearer $token")
 .addHeader("Accept-Language", "ko")
 .build()

5. Kotlin DSL Builder

Kotlin의 람다 수신자(Lambda with Receiver) 를 활용하면 선언적인 DSL 스타일 Builder를 만들 수 있습니다.

// 데이터 클래스
data class MenuItem(
    val id: Int,
    val title: String,
    val price: Int,
    val description: String = "",
    val isAvailable: Boolean = true,
    val options: List<MenuOption> = emptyList()
)

data class MenuOption(
    val name: String,
    val extraPrice: Int = 0
)

data class Menu(
    val restaurantName: String,
    val items: List<MenuItem>
)
// DSL Builder
class MenuBuilder {
    var restaurantName: String = ""
    private val items = mutableListOf<MenuItem>()

    fun item(id: Int, title: String, price: Int, block: MenuItemBuilder.() -> Unit = {}) {
        val builder = MenuItemBuilder(id, title, price).apply(block)
        items.add(builder.build())
    }

    fun build() = Menu(restaurantName, items.toList())
}

class MenuItemBuilder(
    private val id: Int,
    private val title: String,
    private val price: Int
) {
    var description: String = ""
    var isAvailable: Boolean = true
    private val options = mutableListOf<MenuOption>()

    fun option(name: String, extraPrice: Int = 0) {
        options.add(MenuOption(name, extraPrice))
    }

    fun build() = MenuItem(id, title, price, description, isAvailable, options.toList())
}

// 최상위 DSL 진입 함수
fun menu(block: MenuBuilder.() -> Unit): Menu {
    return MenuBuilder().apply(block).build()
}
// 사용 — XML처럼 선언적으로 구성
val menu = menu {
    restaurantName = "맛있는 식당"

    item(id = 1, title = "불고기 버거", price = 8500) {
        description = "부드러운 불고기 패티와 특제 소스"
        isAvailable = true
        option("치즈 추가", extraPrice = 500)
        option("베이컨 추가", extraPrice = 700)
    }

    item(id = 2, title = "감자튀김", price = 2500) {
        description = "바삭한 황금빛 감자튀김"
        option("라지 사이즈", extraPrice = 500)
    }

    item(id = 3, title = "콜라", price = 1500)
}

println(menu.restaurantName)        // 맛있는 식당
println(menu.items[0].options[0])   // MenuOption(name=치즈 추가, extraPrice=500)

6. Android 실전 예제 ① — AlertDialog Builder

Android SDK 자체가 빌더 패턴을 광범위하게 사용합니다.

// AlertDialog — Android 내장 Builder 패턴
AlertDialog.Builder(context)
    .setTitle("주문 확인")
    .setMessage("총 12,500원을 결제하시겠습니까?")
    .setPositiveButton("결제") { _, _ ->
        viewModel.handleIntent(OrderIntent.ConfirmOrder)
    }
    .setNegativeButton("취소") { dialog, _ ->
        dialog.dismiss()
    }
    .setCancelable(false)
    .show()
// 커스텀 Dialog Builder — 앱 전용 DSL
class ConfirmDialogBuilder(private val context: Context) {
    var title: String = ""
    var message: String = ""
    var confirmText: String = "확인"
    var cancelText: String = "취소"
    var onConfirm: (() -> Unit)? = null
    var onCancel: (() -> Unit)? = null
    var isCancelable: Boolean = true

    fun show() {
        AlertDialog.Builder(context)
            .setTitle(title)
            .setMessage(message)
            .setPositiveButton(confirmText) { _, _ -> onConfirm?.invoke() }
            .setNegativeButton(cancelText)  { _, _ -> onCancel?.invoke() }
            .setCancelable(isCancelable)
            .show()
    }
}

fun confirmDialog(context: Context, block: ConfirmDialogBuilder.() -> Unit) {
    ConfirmDialogBuilder(context).apply(block).show()
}

// 사용
confirmDialog(context) {
    title       = "주문 확인"
    message     = "총 12,500원을 결제하시겠습니까?"
    confirmText = "결제"
    cancelText  = "취소"
    onConfirm   = { viewModel.handleIntent(OrderIntent.ConfirmOrder) }
    onCancel    = { /* 취소 처리 */ }
    isCancelable = false
}

7. Android 실전 예제 ② — Notification Builder

fun buildOrderNotification(context: Context, orderId: String, status: String): Notification {
    val pendingIntent = PendingIntent.getActivity(
        context, 0,
        Intent(context, OrderDetailActivity::class.java).apply {
            putExtra("orderId", orderId)
        },
        PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
    )

    return NotificationCompat.Builder(context, CHANNEL_ORDER)
        .setSmallIcon(R.drawable.ic_order)
        .setContentTitle("주문 상태 업데이트")
        .setContentText("주문 #$orderId — $status")
        .setPriority(NotificationCompat.PRIORITY_HIGH)
        .setAutoCancel(true)
        .setContentIntent(pendingIntent)
        .addAction(
            R.drawable.ic_track,
            "배송 추적",
            pendingIntent
        )
        .build()
}

8. Android 실전 예제 ③ — Retrofit Builder

// Retrofit — 내부적으로 Builder 패턴 사용
val retrofit = Retrofit.Builder()
    .baseUrl("https://api.example.com/")
    .client(
        OkHttpClient.Builder()
            .connectTimeout(10, TimeUnit.SECONDS)
            .readTimeout(30, TimeUnit.SECONDS)
            .addInterceptor(AuthInterceptor(tokenManager))
            .addInterceptor(HttpLoggingInterceptor().apply {
                level = if (BuildConfig.DEBUG)
                    HttpLoggingInterceptor.Level.BODY
                else
                    HttpLoggingInterceptor.Level.NONE
            })
            .build()
    )
    .addConverterFactory(GsonConverterFactory.create())
    .addCallAdapterFactory(CoroutineCallAdapterFactory())
    .build()

9. 빌더 패턴 vs 팩토리 패턴

항목 빌더 패턴 팩토리 패턴
목적 복잡한 객체를 단계적으로 생성 객체 생성 로직을 캡슐화
매개변수 많고 선택적 적고 고정적
생성 과정 여러 단계, 순서 있음 단일 호출
결과물 다양한 조합의 동일 타입 다양한 타입 중 하나
사용 예 AlertDialog, Notification, OkHttpClient ViewModel, Fragment, Bitmap

10. 주의사항

❌ Builder 내부에서 외부 상태를 참조하지 않는다

// ❌ 빌더 생성 시점과 build() 시점의 값이 달라질 수 있음
class ReportBuilder {
    var data = externalList  // 가변 외부 참조 위험
}

// ✅ build() 시점에 방어적 복사
fun build(): Report {
    return Report(data = data.toList())  // 불변 복사본 전달
}

❌ 필수 값 누락을 런타임이 아닌 컴파일 타임에 잡는다

// ❌ build()에서야 누락을 알게 됨
fun build(): Config {
    checkNotNull(baseUrl) { "baseUrl은 필수입니다" }  // 런타임 오류
    return Config(baseUrl!!, timeout)
}

// ✅ 필수 값은 생성자 파라미터로 강제
class ConfigBuilder(private val baseUrl: String) {  // 컴파일 타임 강제
    var timeout: Long = 5_000L
    fun build() = Config(baseUrl, timeout)
}

11. 정리

항목 내용
목적 복잡한 객체 생성 과정을 단계별로 분리
핵심 필수 매개변수는 생성자, 선택 매개변수는 메서드 체이닝
Kotlin 대안 단순 객체는 data class + named argument로 충분
Kotlin DSL 람다 수신자로 선언적 빌더 구현 가능
Android 사례 AlertDialog, Notification, Retrofit, OkHttpClient
주의점 필수 값은 컴파일 타임 강제, build() 시점에 방어적 복사

참고



Related Posts