(Java/Kotlin) 빌더 패턴(Builder Pattern) 완전 정리
개요
- 생성 패턴(Creational Pattern) 중 빌더 패턴(Builder Pattern) 을 다룹니다.
- 빌더 패턴은 복잡한 객체의 생성 과정을 단계별로 분리 해 가독성과 유지보수성을 높이는 패턴입니다.
- 이 글에서는 다음을 설명합니다.
- 빌더 패턴이 필요한 이유
- Java 전통 방식 Builder
- Kotlin의 named argument, apply, DSL Builder
- Android 실전 예제 (AlertDialog, Notification, Retrofit)
- 빌더 패턴의 장단점
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는 기본값 사용
)
- 단순한 객체라면
data class+ named argument로 충분합니다. - 복잡한 유효성 검증, 단계별 생성 로직이 필요할 때 Builder 패턴을 적용합니다.
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() 시점에 방어적 복사 |
- 빌더 패턴은 “언제 어떤 값을 넣는지 명확하게” 하는 것이 핵심입니다.
- Kotlin에서는 named argument로 해결되는 경우가 많으므로, 복잡한 생성 로직이나 DSL 스타일이 필요할 때 빌더 패턴을 적용하세요.
참고
- Effective Java 3rd Edition — Joshua Bloch (Item 2)
- Kotlin DSL 공식 문서
- Android AlertDialog 공식 문서