시리즈 안내
1편 — 생성 패턴 (현재 글)
2편 — 구조 패턴 ①: Adapter, Bridge, Composite, Decorator
3편 — 구조 패턴 ②: Facade, Flyweight, Proxy
4편 — 행동 패턴: Chain of Responsibility, Command, Iterator, Mediator, Memento
들어가며
디자인 패턴은 "이런 상황에서는 보통 이렇게 풉니다"라는 선배 개발자들의 관습을 이름 붙여 정리해 둔 카탈로그입니다. 새로운 기술이 아니라 공통 어휘에 가깝습니다. 코드 리뷰에서 "여기 데코레이터로 빼는 게 낫지 않을까요?" 한마디로 설명이 끝나는 것, 그게 패턴을 아는 실익입니다.
다만 자바 기준으로 쓰인 패턴 설명을 코틀린에 그대로 옮기면 어색해집니다. 코틀린은 GoF 패턴 중 상당수를 언어 기능으로 흡수했기 때문입니다. object 키워드, 기본 인자와 이름 붙은 인자, data class의 copy(), by 위임 같은 것들이죠. 그래서 이 시리즈는 패턴마다 세 가지를 같이 다룹니다.
- 원래 이 패턴이 풀려던 문제
- 교과서적인 구현
- 코틀린에서는 어떻게 줄어드는가 (또는 왜 여전히 필요한가)
패턴 분류 한눈에 보기
| 분류 | 관심사 | 이 시리즈에서 다루는 패턴 |
|---|---|---|
| 생성 (Creational) | 객체를 어떻게 만드는가 | Abstract Factory, Builder, Prototype, Singleton |
| 구조 (Structural) | 객체를 어떻게 조합하는가 | Adapter, Bridge, Composite, Decorator, Facade, Flyweight, Proxy |
| 행동 (Behavioral) | 객체가 어떻게 협력하는가 | Chain of Responsibility, Command, Iterator, Mediator, Memento |
이번 편은 첫 번째 줄, 생성 패턴 네 가지입니다.
1. Abstract Factory — 제품군 제조기
한 줄 요약: 서로 짝이 맞아야 하는 객체들을 묶어서 만들어 준다.
어떤 문제를 푸나
결제 연동을 예로 들어 보겠습니다. PG사를 붙이면 보통 결제 요청, 결제 취소, 웹훅 검증이 한 세트로 따라옵니다. 이때 토스페이먼츠 결제 요청 객체 + 카카오페이 취소 객체 조합은 절대 나오면 안 됩니다. 인증 방식도 API 스펙도 다르니까요.
if (pg == "toss") ... else if (pg == "kakao") ... 를 세 군데에 흩뿌려 놓으면, 새 PG사가 추가될 때 한 군데를 빠뜨리는 순간 이 조합이 실제로 생깁니다. Abstract Factory는 "짝이 맞는 객체를 만드는 책임"을 한 곳으로 모아서 이 실수를 구조적으로 막습니다.

PaymentService는 점선 위쪽(인터페이스)만 알고, 아래쪽 구현체는 전혀 모릅니다. 한 팩토리가 만든 제품끼리만 짝이 맺어지므로 "토스 요청 + 카카오 취소" 조합은 애초에 만들어질 수 없습니다.
구현
// --- 제품(Product) 인터페이스들 ---
interface PaymentRequester {
fun request(orderId: String, amount: Long): String // 반환값: paymentKey
}
interface PaymentCanceller {
fun cancel(paymentKey: String, reason: String)
}
// --- 추상 팩토리 ---
interface PaymentGatewayFactory {
fun createRequester(): PaymentRequester
fun createCanceller(): PaymentCanceller
}
// --- 토스페이먼츠 제품군 ---
class TossRequester(private val secretKey: String) : PaymentRequester {
override fun request(orderId: String, amount: Long): String {
// POST https://api.tosspayments.com/v1/payments (Basic 인증)
return "toss_$orderId"
}
}
class TossCanceller(private val secretKey: String) : PaymentCanceller {
override fun cancel(paymentKey: String, reason: String) {
// POST /v1/payments/{paymentKey}/cancel
}
}
class TossGatewayFactory(private val secretKey: String) : PaymentGatewayFactory {
override fun createRequester() = TossRequester(secretKey)
override fun createCanceller() = TossCanceller(secretKey)
}
사용하는 쪽은 어느 PG사인지 전혀 모릅니다.
class PaymentService(factory: PaymentGatewayFactory) {
private val requester = factory.createRequester()
private val canceller = factory.createCanceller()
fun pay(orderId: String, amount: Long): String = requester.request(orderId, amount)
fun refund(paymentKey: String) = canceller.cancel(paymentKey, "고객 요청")
}
val service = PaymentService(TossGatewayFactory(secretKey = System.getenv("TOSS_SECRET")))
카카오페이를 추가할 때 손대는 곳은 KakaoGatewayFactory 하나입니다. PaymentService는 컴파일조차 다시 할 필요가 없습니다.
코틀린스럽게
팩토리가 메서드 하나뿐이라면(= Factory Method에 가깝다면) 인터페이스를 만들 필요 없이 함수 타입으로 충분합니다.
class OrderService(private val createRequester: () -> PaymentRequester) { /* ... */ }
OrderService { TossRequester(secretKey) }
선택지가 고정되어 있다면 enum에 팩토리 역할을 겸하게 하는 것도 코틀린에서 흔한 형태입니다.
enum class Pg(val factory: (String) -> PaymentGatewayFactory) {
TOSS(::TossGatewayFactory),
KAKAO(::KakaoGatewayFactory);
}
주의점
- 제품 종류(요청/취소/검증)를 늘리는 건 비쌉니다. 모든 팩토리 구현체를 다 고쳐야 하니까요. 제품군(PG사)은 자주 늘고 제품 종류는 안정적일 때 잘 맞습니다.
- Spring을 쓴다면
Map<String, PaymentGatewayFactory>주입으로 팩토리 선택 자체를 프레임워크에 맡길 수 있습니다.
2. Builder — 레고 조립공
한 줄 요약: 복잡한 객체를 단계별로 조립한다. 만드는 과정과 결과물을 분리한다.
어떤 문제를 푸나
자바에서 Builder가 사랑받은 진짜 이유는 점층적 생성자(telescoping constructor) 때문이었습니다.
new HttpRequest(url, "POST", headers, 3000, null, true, false) // 마지막 두 개가 뭐였더라?
코틀린에서는 대부분 필요 없다
코틀린은 이 문제를 언어 차원에서 해결했습니다. 기본 인자 + 이름 붙은 인자면 끝입니다.
data class HttpRequest(
val url: String,
val method: String = "GET",
val headers: Map<String, String> = emptyMap(),
val timeoutMillis: Long = 3_000,
val body: String? = null,
)
val request = HttpRequest(
url = "https://api.example.com/orders",
method = "POST",
timeoutMillis = 5_000,
)
코틀린 코드에서 자바식 Builder 클래스를 손으로 짜고 있다면, 십중팔구 불필요합니다. 자바에서 호출될 API를 만드는 경우가 거의 유일한 예외입니다.
여전히 Builder가 필요한 순간: DSL
빌더가 살아남는 지점은 중첩 구조를 선언적으로 쓰고 싶을 때입니다. 코틀린에서는 수신 객체 지정 람다(T.() -> Unit)로 구현합니다.
@DslMarker
annotation class HttpDsl
@HttpDsl
class HttpRequestBuilder(private val url: String) {
var method: String = "GET"
var timeoutMillis: Long = 3_000
var body: String? = null
private val headers = mutableMapOf<String, String>()
fun header(name: String, value: String) {
headers[name] = value
}
fun build() = HttpRequest(url, method, headers.toMap(), timeoutMillis, body)
}
fun httpRequest(url: String, block: HttpRequestBuilder.() -> Unit): HttpRequest =
HttpRequestBuilder(url).apply(block).build()
호출부는 이렇게 됩니다.
val request = httpRequest("https://api.example.com/orders") {
method = "POST"
header("Authorization", "Bearer $token")
header("Content-Type", "application/json")
body = """{"orderId":"A-1001","amount":25000}"""
}
@DslMarker는 중첩 DSL에서 바깥쪽 빌더의 메서드가 안쪽에서 실수로 호출되는 것을 막아 줍니다. Ktor, Gradle Kotlin DSL, kotlinx.html이 전부 이 방식입니다.
주의점
build()에서headers.toMap()처럼 방어적 복사를 하지 않으면, 빌더를 재사용했을 때 이미 만들어진 객체가 함께 변합니다.- 필수 값은 빌더 프로퍼티가 아니라 생성자 파라미터로 받으세요(위 예제의
url). 그래야 컴파일 타임에 강제됩니다.
3. Prototype — 복제 장인
한 줄 요약: 잘 만들어 둔 객체를 복사해서 새 객체를 만든다.
어떤 문제를 푸나
객체를 처음부터 세팅하는 비용이 클 때가 있습니다. 설정값이 20개인 리포트 템플릿, DB에서 읽어 온 기본 알림 템플릿 같은 것들이죠. 매번 새로 만드는 대신 원본을 복사하고 달라지는 부분만 바꾸는 방식이 Prototype입니다.
코틀린에서는 copy()
data class의 copy()가 곧 Prototype입니다.
data class NotificationTemplate(
val channel: Channel,
val title: String,
val body: String,
val retryCount: Int = 3,
)
val default = NotificationTemplate(
channel = Channel.PUSH,
title = "주문이 접수되었습니다",
body = "{userName}님, 주문 {orderId}가 접수되었습니다.",
)
val vip = default.copy(title = "[VIP] 주문이 접수되었습니다", retryCount = 5)
val email = default.copy(channel = Channel.EMAIL)
함정: copy()는 얕은 복사다
이 시리즈에서 꼭 기억하셨으면 하는 부분입니다.
data class Cart(val items: MutableList<String>)
val origin = Cart(mutableListOf("사과"))
val copied = origin.copy()
copied.items.add("바나나")
println(origin.items) // [사과, 바나나] ← 원본이 함께 바뀐다

copy()는 프로퍼티 참조만 복사합니다. 해결책은 두 가지입니다.
// 1) 복사 시점에 명시적으로 깊은 복사
val copied = origin.copy(items = origin.items.toMutableList())
// 2) 애초에 불변 컬렉션을 쓴다 (권장)
data class Cart(val items: List<String>)
val copied = origin.copy(items = origin.items + "바나나")
상속 계층이 있다면
data class는 상속에 쓰기 어렵기 때문에, 다형적인 복제가 필요하면 전통적인 방식이 여전히 유효합니다.
interface Prototype<T> {
fun clone(): T
}
class Circle(val radius: Double, val style: Style) : Shape, Prototype<Circle> {
override fun clone() = Circle(radius, style.copy())
}
자바의 Cloneable / Object.clone()은 쓰지 마세요. 계약이 모호하고 얕은 복사 문제를 그대로 안고 있습니다.
4. Singleton — 유일무이
한 줄 요약: 인스턴스가 딱 하나만 존재하도록 보장한다.
코틀린에서는 object 한 줄
자바에서 double-checked locking이니 enum 싱글톤이니 하며 씨름하던 것이 코틀린에서는 키워드 하나입니다.
object AppConfig {
val apiBaseUrl: String = System.getenv("API_BASE_URL") ?: "http://localhost:8080"
val defaultTimeoutMillis: Long = 3_000
}
// 사용
val url = AppConfig.apiBaseUrl
object는 JVM의 클래스 초기화 규칙 덕분에 최초 접근 시점에 딱 한 번, 스레드 안전하게 초기화됩니다. 별도의 동기화 코드가 필요 없습니다.
생성 시점에 인자가 필요하다면
object는 생성자를 가질 수 없습니다. 런타임 값이 필요하면 지연 초기화를 직접 구현해야 합니다.
class ConnectionPool private constructor(private val size: Int) {
companion object {
@Volatile
private var instance: ConnectionPool? = null
fun getInstance(size: Int): ConnectionPool =
instance ?: synchronized(this) {
instance ?: ConnectionPool(size).also { instance = it }
}
}
}
@Volatile + 이중 검사는 정석 형태이므로 그대로 외워 두셔도 좋습니다.
진짜 하고 싶은 말: 웬만하면 쓰지 마세요
Singleton은 GoF 패턴 중 가장 자주 안티패턴으로 지목되는 패턴입니다.
- 테스트가 어렵다. 전역 상태이므로 테스트 간에 상태가 새고, 가짜 구현으로 바꿔치기하기 어렵습니다.
- 의존성이 숨는다. 생성자 시그니처만 봐서는 이 클래스가 무엇에 의존하는지 알 수 없습니다.
- 동시성 문제의 온상이 된다. 가변 상태를 가진 싱글톤은 특히 위험합니다.
Spring을 쓰신다면 @Component가 이미 싱글톤 스코프입니다. 컨테이너가 인스턴스를 하나만 관리하면서도, 주입 지점에서는 평범한 의존성처럼 보이고 테스트에서는 얼마든지 교체할 수 있습니다. "인스턴스가 하나여야 한다"는 요구는 대부분 DI로 푸는 편이 낫습니다. object는 상태 없는 유틸리티나 상수 묶음 정도로 제한해서 쓰세요.
정리
| 패턴 | 언제 | 코틀린에서는 |
|---|---|---|
| Abstract Factory | 짝이 맞아야 하는 객체군을 갈아끼울 때 | 인터페이스 그대로. 단일 메서드라면 함수 타입으로 축약 |
| Builder | 중첩·선언적 구성이 필요할 때 | 기본 인자로 대부분 해결. DSL이 필요할 때만 수신 객체 람다 |
| Prototype | 세팅 비용이 큰 객체를 변형해 쓸 때 | data class의 copy() — 단, 얕은 복사 주의 |
| Singleton | 인스턴스가 하나여야 할 때 | object 한 줄. 다만 DI를 먼저 고려할 것 |
관통하는 교훈은 하나입니다. 패턴을 외워서 적용하는 게 아니라, 패턴이 풀려던 문제가 내 코드에 있는지를 먼저 보세요. 코틀린에서는 그 문제 자체가 이미 없는 경우가 꽤 많습니다.
다음 편에서는 객체를 조합하는 방식을 다루는 구조 패턴 — Adapter, Bridge, Composite, Decorator를 살펴보겠습니다.
Thanks
Hans
'IT' 카테고리의 다른 글
| 60. 코틀린으로 배우는 디자인 패턴 (3/4) — 구조 패턴 ②: Facade, Flyweight, Proxy (0) | 2026.08.09 |
|---|---|
| 59. 코틀린으로 배우는 디자인 패턴 (2/4) — 구조 패턴 ①: Adapter, Bridge, Composite, Decorator (0) | 2026.08.09 |
| 57. Netflix는 어떻게 초당 7만 5천 건의 이벤트를 카운팅할까? — Distributed Counter Abstraction 완전 분석 (0) | 2026.07.31 |
| 56. [CS 지식] 기술 면접 단골 질문! OOP(객체지향) 4대 특징 완벽 정리 (0) | 2026.07.31 |
| 55. DORA 매트릭(Metric)이란? DevOps 성과를 측정하는 4가지 핵심 지표 (0) | 2026.07.31 |