|

코틀린 디자인 패턴 (5) – Prototype 패턴과 data class copy 정리

코틀린 Prototype 패턴과 data class copy 차이를 설명하는 대표 이미지
copy()가 편해도 shallow copy와 deep copy의 경계는 직접 판단해야 한다

코틀린 Prototype 패턴을 이해할 때 가장 중요한 질문은 이것입니다. 비슷한 객체를 빨리 하나 더 만드는 것이 목적이냐, 아니면 내부 상태까지 완전히 독립적인 복제본이 필요한가입니다. 특히 data class copy()를 자주 쓰는 Kotlin 코드에서는 이 경계가 더 중요합니다.

코틀린에서는 data classcopy() 덕분에 Prototype 이야기가 예전보다 훨씬 가볍게 들립니다. 실제로 많은 경우 copy()만으로도 충분합니다.

하지만 Kotlin 공식 문서가 분명히 말하듯 copy()는 shallow copy입니다. 안쪽에 mutable 객체가 들어 있으면 원본과 복사본이 같은 대상을 함께 볼 수 있습니다. 이번 글은 이 지점을 실무 기준으로 차분하게 정리해보겠습니다.


Prototype 패턴을 왜 다시 봐야 할까

Prototype 패턴의 핵심은 새 객체를 매번 처음부터 조립하는 대신, 이미 준비된 객체를 복제해서 조금만 바꿔 쓰는 것입니다.

실무에서는 기본 설정이 거의 같은 요청 객체를 여러 개 만들거나, 초안 상태를 복제해서 실험 버전을 만들거나, 알림 템플릿을 복제해 수신자별로 조금씩 바꾸는 장면이 자주 나옵니다. 이때는 생성자를 처음부터 다시 적는 것보다 기준 객체를 복제하는 쪽이 더 읽기 좋을 때가 많습니다.

Prototype의 진짜 가치는 복제 자체보다, 이미 검증된 기본 상태를 안전하게 재사용하는 데 있습니다.


data class copy가 가장 먼저 떠오르는 이유

코틀린에서는 이 패턴을 무겁게 구현하지 않아도 되는 경우가 많습니다. data classcopy()를 자동으로 만들어주기 때문입니다.

data class NotificationTemplate(
    val channel: String,
    val title: String,
    val body: String,
    val urgent: Boolean,
)

val defaultTemplate = NotificationTemplate(
    channel = "email",
    title = "주간 리포트",
    body = "이번 주 핵심 지표를 확인해주세요.",
    urgent = false,
)

val vipTemplate = defaultTemplate.copy(
    title = "[VIP] 주간 리포트",
    urgent = true,
)

이 코드는 Prototype을 아주 Kotlin답게 보여줍니다. 새 객체를 처음부터 다시 만들지 않고, 이미 준비된 객체를 복제한 뒤 필요한 값만 바꿉니다. 이런 경우에는 별도 clone() 메서드나 프로토타입 레지스트리까지 갈 필요가 없습니다.


copy()만으로 충분한 경우

값 몇 개만 바꾸면 끝날 때

주문 요청, 검색 조건, 화면 상태처럼 기존 객체에서 1~2개 필드만 바꾸면 되는 경우가 대표적입니다.

data class SearchCondition(
    val keyword: String,
    val page: Int,
    val pageSize: Int,
    val includeClosed: Boolean,
)

val firstPage = SearchCondition(
    keyword = "kotlin",
    page = 1,
    pageSize = 20,
    includeClosed = false,
)

val secondPage = firstPage.copy(page = 2)

이 경우 복제의 목적은 복잡한 객체 그래프를 새로 만드는 일이 아니라, 기준값을 유지한 채 일부만 수정하는 것입니다.

사실상 값 객체처럼 다룰 때

프로퍼티가 val이고, 그 안쪽도 사실상 변경되지 않는 구조라면 shallow copy가 큰 문제가 되지 않는 경우가 많습니다. 문자열, 숫자, enum, 읽기 전용으로 운용되는 구조가 여기에 가깝습니다.

파생 버전을 만드는 정도일 때

복제의 의미가 완전한 스냅샷이 아니라 원본에서 파생된 새 버전 정도라면 deep copy까지 갈 이유가 약합니다.

복제 대상이 사실상 값 객체에 가깝다면 data class copy는 Prototype을 가장 간단하게 구현하는 방법입니다.


shallow copy가 위험해지는 순간

문제는 copy()가 나쁘다는 게 아니라, 무엇을 복제하지 않는지 놓치기 쉽다는 점입니다. Kotlin 공식 문서는 copy()가 shallow copy라고 분명히 설명합니다.

data class UserProfile(
    val name: String,
    val tags: MutableList<String>,
)

val original = UserProfile(
    name = "BS",
    tags = mutableListOf("android", "kotlin"),
)

val copied = original.copy()
copied.tags += "mentor"

println(original.tags) // [android, kotlin, mentor]
println(copied.tags)   // [android, kotlin, mentor]

겉만 보면 copied는 새 객체입니다. 하지만 tags는 같은 MutableList를 같이 보고 있습니다.

  • 복사본만 수정했다고 생각했는데 원본도 바뀐다
  • 디버깅할 때 어디서 상태가 새었는지 찾기 어려워진다
  • 테스트와 운영 환경에서 버그가 다르게 드러날 수 있다

즉, shallow copy의 진짜 위험은 문법이 아니라 정신 모델이 어긋나는 데 있습니다. 개발자는 복제했다고 생각했는데, 실제로는 일부 참조만 공유된 상태이기 때문입니다.


nested object가 있으면 더 조심해야 한다

data class RetryPolicy(
    var maxRetry: Int,
    var backoffMillis: Long,
)

data class ApiRequest(
    val path: String,
    val retryPolicy: RetryPolicy,
)

val defaultRequest = ApiRequest(
    path = "/v1/posts",
    retryPolicy = RetryPolicy(maxRetry = 3, backoffMillis = 1000),
)

val urgentRequest = defaultRequest.copy()
urgentRequest.retryPolicy.maxRetry = 5

println(defaultRequest.retryPolicy.maxRetry) // 5
println(urgentRequest.retryPolicy.maxRetry)  // 5

겉보기에는 immutable data class처럼 보여도, 내부에 mutable 객체가 들어 있으면 실제 동작은 전혀 immutable하지 않을 수 있습니다. 이 점이 shallow copy를 더 헷갈리게 만듭니다.


상태 스냅샷이라면 판단을 더 엄격하게

Prototype이 특히 위험해지는 장면은 복제본이 원본과 독립적으로 살아야 하는 경우입니다. draft 상태를 복제해 A안, B안 실험 버전을 만드는 상황이 대표적입니다.

data class SeoOptions(
    val keywords: MutableList<String>,
    var title: String,
)

data class PostDraft(
    val content: String,
    val seo: SeoOptions,
)

val baseDraft = PostDraft(
    content = "초안 본문",
    seo = SeoOptions(
        keywords = mutableListOf("코틀린", "디자인 패턴"),
        title = "초안 제목",
    ),
)

val experimentDraft = baseDraft.copy()
experimentDraft.seo.keywords += "Prototype"
experimentDraft.seo.title = "실험 제목"

이런 코드에서는 실험용 draft만 바꾸고 싶었는데 원본의 SEO 정보까지 같이 오염될 수 있습니다. 이때 shallow copy는 편리한 기본값이 아니라 위험한 지름길이 됩니다.


deep copy가 필요한 순간

여기서 중요한 건 항상 deep copy하자가 아닙니다. 질문은 하나면 충분합니다. 복제본이 안쪽 상태까지 원본과 완전히 독립적이어야 하는가입니다.

  • 복제본을 수정해도 원본은 절대 바뀌면 안 된다
  • 스냅샷, 히스토리, undo/redo처럼 과거 상태 보존이 중요하다
  • 실험 버전이나 분기 버전이 서로 영향을 주면 안 된다
  • 내부에 mutable list, map, nested object가 있다

반대로 내부 상태가 사실상 읽기 전용이거나, 안쪽 참조를 공유해도 설계상 문제없다면 shallow copy로 충분할 수 있습니다. deep copy는 기본 예절이 아니라 독립성 요구사항에 대한 설계 결정입니다.


Kotlin에서 deep copy는 보통 명시적으로 만든다

Kotlin은 data class에 copy()를 자동으로 주지만, deep copy는 자동으로 해주지 않습니다. 그래서 필요하면 어디까지 깊게 복제할지 직접 적어줘야 합니다.

data class SeoOptions(
    val keywords: MutableList<String>,
    val title: String,
)

data class PostDraft(
    val content: String,
    val seo: SeoOptions,
)

fun PostDraft.deepCopy(): PostDraft = copy(
    seo = seo.copy(
        keywords = seo.keywords.toMutableList(),
    ),
)

이 방식의 장점은 복제 범위를 코드로 드러낼 수 있다는 점입니다. 동시에 객체 구조가 깊어질수록 비용과 실수 지점도 늘어나므로, 정말 필요한 곳에만 쓰는 편이 좋습니다.


body property는 copy 기대를 더 헷갈리게 만든다

Kotlin 공식 문서에 따르면 copy()를 포함한 data class 생성 멤버는 primary constructor에 선언된 프로퍼티만 기준으로 동작합니다. 그래서 class body에 둔 상태는 기대와 다르게 복제될 수 있습니다.

data class SessionTemplate(
    val name: String,
) {
    var timeoutSeconds: Int = 30
}

val base = SessionTemplate("default")
base.timeoutSeconds = 60

val copied = base.copy(name = "admin")
println(copied.timeoutSeconds) // 30

이 장면이 주는 메시지는 단순합니다. data class를 쓰더라도 어떤 상태가 복제 의미에 포함되는지 분명히 설계해야 합니다.


그럼 Prototype을 별도 패턴으로 의식할 순간

기준 객체를 여러 군데서 재사용할 때

기본 설정 묶음, 템플릿 객체, 테스트 seed처럼 복제해서 변형하는 흐름 자체가 반복된다면 Prototype 관점이 유효합니다. 이때는 단순 생성보다 기준 객체 관리가 더 중요한 문제이기 때문입니다.

복제 전략이 도메인 규칙이 될 때

어떤 필드는 공유해도 되고, 어떤 필드는 반드시 분리해야 하는지가 비즈니스 규칙이 될 수 있습니다. 이때는 copy()를 아무 데서나 직접 부르게 두기보다 명시적 복제 메서드로 감싸는 편이 더 안전합니다.

fun PostDraft.createExperimentVersion(): PostDraft = copy(
    seo = seo.copy(
        keywords = seo.keywords.toMutableList(),
    ),
)

초기 상태 조립 비용이 클 때

객체 자체가 무거워서라기보다 유효한 초기 상태를 맞추는 과정이 복잡할 수 있습니다. 이때는 이미 검증된 prototype seed를 복제해서 쓰는 편이 실수를 줄입니다.


실무 판단 기준

  1. 이 객체는 정말 복제해서 변형하는 흐름이 반복되는가
  2. 내부에 mutable 참조가 있는가
  3. 복제본이 원본과 상태를 공유해도 되는가
  4. 복제 규칙을 호출자에게 맡겨도 되는가

정리하면 이렇게 볼 수 있습니다. 값 객체에 가깝다면 copy()를 우선 떠올리고, nested mutable state가 있다면 shallow copy 위험을 먼저 의식해야 합니다. 독립 스냅샷이 필요하면 deep copy를 검토하고, 복제 규칙이 중요하면 명시적 복제 API를 두는 편이 낫습니다.


마무리

코틀린에서 Prototype 패턴은 옛날 패턴을 억지로 되살리는 이야기가 아닙니다. 오히려 data class copy() 덕분에 더 현실적인 주제가 됐습니다.

문제는 copy()를 쓸 수 있느냐가 아닙니다. 진짜 질문은 복제의 의미가 어디까지인가입니다. 겉 객체만 새로 만들면 충분한지, 안쪽 상태까지 독립적이어야 하는지, 그 판단이 설계를 갈라놓습니다.

이전 글인 코틀린 디자인 패턴(4) – Builder 패턴과 named argument는 어떻게 다를까를 먼저 읽었다면, 이번 글은 생성 패턴 흐름에서 자연스럽게 이어집니다. Builder가 생성 과정을 정리하는 도구였다면, Prototype은 이미 있는 상태를 어떻게 안전하게 재사용할지 묻는 패턴이기 때문입니다.

공식 참고 자료로는 Kotlin data classes 문서, object declarations 문서를 함께 보면 좋습니다. Prototype 일반 개념은 Refactoring.Guru의 Prototype 설명도 빠르게 감을 잡는 데 도움이 됩니다.

함께보면 좋은 글