(Kotlin/코틀린) 프로토타입 패턴(Prototype Pattern) 완전 정리

개요


1. 왜 프로토타입 패턴이 필요한가

❌ 문제 — 비용이 큰 객체를 매번 새로 생성

// DB에서 수만 건을 조회하고 복잡한 계산을 거쳐 Report 생성
fun createMonthlyReport(month: Int): Report {
    val rawData       = database.fetchAllTransactions(month)  // 느린 조회
    val processedData = heavyProcessing(rawData)              // 복잡한 계산
    return Report(month, processedData)
}

// 같은 데이터인데 팀별로 반복 생성 — 낭비
val hrReport      = createMonthlyReport(4)
val financeReport = createMonthlyReport(4)
val ceoReport     = createMonthlyReport(4)

✅ 프로토타입으로 해결 — 한 번 만들고 복사

// 한 번만 생성
val original = createMonthlyReport(4)

// 복사해서 팀별 커스터마이징
val hrReport      = original.copy(recipient = "인사팀")
val financeReport = original.copy(recipient = "재무팀")
val ceoReport     = original.copy(recipient = "대표이사", isConfidential = true)

프로토타입이 적합한 경우:

✔ 객체 생성 비용이 큰 경우 (DB 조회, 네트워크, 복잡한 계산)
✔ 비슷한 객체를 여럿 만들어야 하는 경우
✔ 객체의 초기 설정이 복잡해 재사용하고 싶은 경우
✔ 불변 상태를 유지하면서 일부 값만 변경하고 싶은 경우 (MVI State)

2. 얕은 복사 vs 깊은 복사

프로토타입 패턴에서 가장 중요한 개념입니다.

얕은 복사 (Shallow Copy)

data class Address(var city: String, var street: String)
data class Person(val name: String, val address: Address)

val original = Person("홍길동", Address("서울", "강남대로"))
val shallow  = original.copy()  // data class 기본 copy() — 얕은 복사

// 기본 타입 필드 — 독립적으로 복사됨
println(original.name)  // 홍길동
println(shallow.name)   // 홍길동 (독립)

// 참조 타입 필드 — 같은 객체를 공유
shallow.address.city = "부산"
println(original.address.city)  // 부산 ❌ — original도 변경됨
println(shallow.address.city)   // 부산
얕은 복사:
original ─→ [name: "홍길동"] [address ─→ Address("서울", "강남대로")]
shallow  ─→ [name: "홍길동"] [address ─┘]  ← 같은 Address 객체 참조

깊은 복사 (Deep Copy)

val original = Person("홍길동", Address("서울", "강남대로"))

// 중첩 객체까지 새로 생성
val deep = original.copy(address = original.address.copy())

deep.address.city = "부산"
println(original.address.city)  // 서울 ✅ — original 영향 없음
println(deep.address.city)      // 부산
깊은 복사:
original ─→ [name: "홍길동"] [address ─→ Address("서울", "강남대로")]
deep     ─→ [name: "홍길동"] [address ─→ Address("부산", "강남대로")]  ← 별개 객체

3. data class copy() — 기본 사용

data classcopy()를 자동 생성합니다. 모든 필드가 불변(val)이라면 얕은 복사로도 안전합니다.

data class UserConfig(
    val name: String,
    val theme: String = "light",
    val fontSize: Int = 14,
    val language: String = "ko",
    val notificationsEnabled: Boolean = true
)

val defaultConfig = UserConfig(name = "기본 설정")

// 일부만 변경해 새 객체 생성 — 원본 불변 유지
val darkConfig  = defaultConfig.copy(theme = "dark")
val largeConfig = defaultConfig.copy(fontSize = 18, language = "en")
val muteConfig  = defaultConfig.copy(notificationsEnabled = false)

println(defaultConfig.theme)   // light ✅ 원본 불변
println(darkConfig.theme)      // dark
println(largeConfig.fontSize)  // 18

4. 중첩 참조 타입 — copy()의 한계

참조 타입 필드가 가변(var) 이거나 가변 컬렉션이면 얕은 복사가 위험합니다.

data class Tag(val id: Int, var name: String)  // name이 가변

data class Article(
    val title: String,
    val tags: MutableList<Tag>
)

val original = Article("Kotlin 정리", mutableListOf(Tag(1, "Kotlin"), Tag(2, "Android")))

// ❌ 얕은 복사 — tags 리스트를 공유
val shallow = original.copy()
shallow.tags.add(Tag(3, "Design Pattern"))

println(original.tags.size)  // 3 ❌ — original도 변경됨

5. 깊은 복사 구현 방법

방법 1 — copy() 중첩 호출

data class Address(val city: String, val street: String)

data class User(
    val name: String,
    val address: Address,
    val hobbies: List<String>
)

val original = User("홍길동", Address("서울", "강남대로"), listOf("독서", "등산"))

// 참조 타입마다 copy() 중첩 호출
val deep = original.copy(
    address = original.address.copy(),
    hobbies = original.hobbies.toList()  // 새 리스트 생성
)

방법 2 — deepCopy 확장 함수

복잡한 중첩 구조라면 확장 함수로 의도를 명확히 표현합니다.

data class FilterOption(
    val category: String,
    val priceRange: IntRange,
    val selectedBrands: MutableList<String> = mutableListOf(),
    val sortOrder: String = "newest"
)

fun FilterOption.deepCopy() = FilterOption(
    category       = category,
    priceRange     = priceRange,
    selectedBrands = selectedBrands.toMutableList(),  // 새 리스트
    sortOrder      = sortOrder
)

val defaultFilter = FilterOption(
    category       = "전체",
    priceRange     = 0..100_000,
    selectedBrands = mutableListOf("Nike", "Adidas")
)

val myFilter = defaultFilter.deepCopy()
myFilter.selectedBrands.add("Puma")

println(defaultFilter.selectedBrands)  // [Nike, Adidas] ✅
println(myFilter.selectedBrands)       // [Nike, Adidas, Puma]

방법 3 — 불변 컬렉션으로 설계 (권장)

가장 근본적인 해결책은 가변 타입을 쓰지 않는 것입니다.

// ✅ 모든 필드를 불변으로 — copy()만으로 안전
data class FilterOption(
    val category: String,
    val priceRange: IntRange,
    val selectedBrands: List<String> = emptyList(),  // 불변 List
    val sortOrder: String = "newest"
)

val defaultFilter = FilterOption("전체", 0..100_000, listOf("Nike", "Adidas"))

// 새 브랜드 추가 — 기존 리스트 + 새 항목으로 새 리스트 생성
val myFilter = defaultFilter.copy(
    selectedBrands = defaultFilter.selectedBrands + "Puma"
)

println(defaultFilter.selectedBrands)  // [Nike, Adidas] ✅
println(myFilter.selectedBrands)       // [Nike, Adidas, Puma]

6. 프로토타입 레지스트리

복사할 원본 객체들을 미리 등록해두고 이름으로 꺼내 쓰는 방식입니다.

data class NotificationTemplate(
    val title: String,
    val body: String,
    val channelId: String,
    val priority: Int = 0
)

object NotificationRegistry {
    private val templates = mapOf(
        "order_complete" to NotificationTemplate(
            title     = "주문 완료",
            body      = "주문이 정상 처리되었습니다",
            channelId = "ORDER",
            priority  = 1
        ),
        "delivery_start" to NotificationTemplate(
            title     = "배송 시작",
            body      = "상품이 출발했습니다",
            channelId = "DELIVERY",
            priority  = 1
        ),
        "promotion" to NotificationTemplate(
            title     = "혜택 안내",
            body      = "새로운 프로모션이 시작되었습니다",
            channelId = "MARKETING",
            priority  = 0
        )
    )

    // 복사본 반환 — 원본 보호
    fun get(key: String): NotificationTemplate =
        templates[key]?.copy() ?: error("템플릿 없음: $key")
}

// 사용 — 템플릿을 복사해 커스터마이징
fun sendOrderNotification(orderId: String) {
    val notification = NotificationRegistry.get("order_complete")
        .copy(body = "주문 #$orderId 이 완료되었습니다")
    // 발송 처리
}

fun sendDeliveryNotification(trackingNumber: String) {
    val notification = NotificationRegistry.get("delivery_start")
        .copy(body = "운송장 번호: $trackingNumber")
    // 발송 처리
}

7. Android 실전 예제 ① — MVI State 복사

MVI 패턴의 상태 변경이 프로토타입 패턴의 가장 자연스러운 Android 활용 사례입니다.

data class CartState(
    val isLoading: Boolean = false,
    val items: List<CartItem> = emptyList(),
    val totalPrice: Int = 0,
    val couponCode: String? = null,
    val discountAmount: Int = 0,
    val error: String? = null
)

data class CartItem(
    val productId: Long,
    val name: String,
    val price: Int,
    val quantity: Int  // 불변
)

class CartViewModel : ViewModel() {
    private val _state = MutableStateFlow(CartState())
    val state: StateFlow<CartState> = _state.asStateFlow()

    fun addItem(newItem: CartItem) {
        _state.update { current ->
            val updatedItems = current.items + newItem           // 새 리스트
            current.copy(                                        // 프로토타입 ✅
                items      = updatedItems,
                totalPrice = updatedItems.sumOf { it.price * it.quantity }
            )
        }
    }

    fun updateQuantity(productId: Long, quantity: Int) {
        _state.update { current ->
            val updatedItems = current.items.map { item ->
                if (item.productId == productId) item.copy(quantity = quantity)  // ✅
                else item
            }
            current.copy(
                items      = updatedItems,
                totalPrice = updatedItems.sumOf { it.price * it.quantity }
            )
        }
    }

    fun removeItem(productId: Long) {
        _state.update { current ->
            val updatedItems = current.items.filter { it.productId != productId }
            current.copy(
                items      = updatedItems,
                totalPrice = updatedItems.sumOf { it.price * it.quantity }
            )
        }
    }

    fun applyCoupon(code: String, discount: Int) {
        _state.update { it.copy(couponCode = code, discountAmount = discount) }
    }
}

8. Android 실전 예제 ② — 알림 설정 템플릿

data class PushConfig(
    val channelId: String,
    val smallIcon: Int,
    val importance: Int      = NotificationManager.IMPORTANCE_DEFAULT,
    val autoCancel: Boolean  = true,
    val vibrate: Boolean     = true,
    val sound: Boolean       = true,
    val badgeCount: Int      = 0
)

object PushConfigFactory {
    private val orderBase = PushConfig(
        channelId  = "ORDER",
        smallIcon  = R.drawable.ic_order,
        importance = NotificationManager.IMPORTANCE_HIGH
    )

    private val chatBase = PushConfig(
        channelId  = "CHAT",
        smallIcon  = R.drawable.ic_chat,
        importance = NotificationManager.IMPORTANCE_HIGH
    )

    private val promoBase = PushConfig(
        channelId  = "PROMO",
        smallIcon  = R.drawable.ic_promo,
        importance = NotificationManager.IMPORTANCE_LOW,
        vibrate    = false
    )

    fun order(badgeCount: Int = 0) = orderBase.copy(badgeCount = badgeCount)
    fun chat(sound: Boolean = true) = chatBase.copy(sound = sound)
    fun promo() = promoBase.copy()
}

// 사용
val config = PushConfigFactory.order(badgeCount = 3)
buildNotification(context, config)

9. 얕은 복사 vs 깊은 복사 정리

항목 얕은 복사 깊은 복사
기본 타입 (Int, String 등) 독립 복사 독립 복사
참조 타입 (객체, 컬렉션) 같은 객체 공유 새 객체 생성
성능 빠름 느림 (중첩 깊이에 비례)
안전성 가변 필드 포함 시 부작용 위험 완전 독립
Kotlin 기본 data class copy() 직접 구현 / 확장 함수
안전한 경우 모든 필드가 불변(val) 일 때 가변 필드 포함 시

10. copy() 사용 시 주의사항

❌ 가변 컬렉션을 val로 선언해도 내부는 가변

data class Bag(val items: MutableList<String>)  // val이지만 내부는 가변

val original = Bag(mutableListOf("A", "B"))
val copy     = original.copy()

copy.items.add("C")
println(original.items)  // [A, B, C] ❌ — 공유됨
// ✅ 불변 List 사용
data class Bag(val items: List<String>)

val original = Bag(listOf("A", "B"))
val copy     = original.copy(items = original.items + "C")
println(original.items)  // [A, B] ✅

❌ 중첩 depth가 깊을 때 copy() 연쇄

// 3단계 중첩 — copy() 연쇄가 장황해짐
val updated = order.copy(
    customer = order.customer.copy(
        address = order.customer.address.copy(city = "부산")
    )
)

// ✅ 중간 변수로 가독성 개선
val updatedAddress  = order.customer.address.copy(city = "부산")
val updatedCustomer = order.customer.copy(address = updatedAddress)
val updatedOrder    = order.copy(customer = updatedCustomer)

11. 정리

항목 내용
목적 기존 객체를 복사해 새 객체를 효율적으로 생성
Kotlin 기본 도구 data class copy() — 얕은 복사
깊은 복사 copy() 중첩 / deepCopy() 확장 함수 / 불변 컬렉션
가장 안전한 설계 모든 필드를 불변(val) 으로, 불변 컬렉션 사용
Android 활용 MVI State 변경, 알림 템플릿, 설정 복제
주의사항 val MutableList는 내부가 가변 — 불변 List 사용 권장

참고



Related Posts