IT

58. 코틀린으로 배우는 디자인 패턴 (1/4) — 생성 패턴: Abstract Factory, Builder, Prototype, Singleton

Hans L 2026. 8. 9. 13:40

시리즈 안내
1편 — 생성 패턴 (현재 글)
2편 — 구조 패턴 ①: Adapter, Bridge, Composite, Decorator
3편 — 구조 패턴 ②: Facade, Flyweight, Proxy
4편 — 행동 패턴: Chain of Responsibility, Command, Iterator, Mediator, Memento

들어가며

디자인 패턴은 "이런 상황에서는 보통 이렇게 풉니다"라는 선배 개발자들의 관습을 이름 붙여 정리해 둔 카탈로그입니다. 새로운 기술이 아니라 공통 어휘에 가깝습니다. 코드 리뷰에서 "여기 데코레이터로 빼는 게 낫지 않을까요?" 한마디로 설명이 끝나는 것, 그게 패턴을 아는 실익입니다.

다만 자바 기준으로 쓰인 패턴 설명을 코틀린에 그대로 옮기면 어색해집니다. 코틀린은 GoF 패턴 중 상당수를 언어 기능으로 흡수했기 때문입니다. object 키워드, 기본 인자와 이름 붙은 인자, data classcopy(), by 위임 같은 것들이죠. 그래서 이 시리즈는 패턴마다 세 가지를 같이 다룹니다.

  1. 원래 이 패턴이 풀려던 문제
  2. 교과서적인 구현
  3. 코틀린에서는 어떻게 줄어드는가 (또는 왜 여전히 필요한가)

패턴 분류 한눈에 보기

분류 관심사 이 시리즈에서 다루는 패턴
생성 (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 classcopy()가 곧 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 classcopy() — 단, 얕은 복사 주의
Singleton 인스턴스가 하나여야 할 때 object 한 줄. 다만 DI를 먼저 고려할 것

 

관통하는 교훈은 하나입니다. 패턴을 외워서 적용하는 게 아니라, 패턴이 풀려던 문제가 내 코드에 있는지를 먼저 보세요. 코틀린에서는 그 문제 자체가 이미 없는 경우가 꽤 많습니다.

 

다음 편에서는 객체를 조합하는 방식을 다루는 구조 패턴 — Adapter, Bridge, Composite, Decorator를 살펴보겠습니다.

 

Thanks

Hans