iOS & Swift

얕은 복사(Shallow Copy) vs 깊은 복사(Deep Copy), 복사했는데 원본이 같이 바뀌는 이유 (Swift COW)

배열을 복사해서 새 변수에 담았습니다. 복사본만 고쳤는데 원본까지 바뀌어 있네요. 프로그래밍하다 한 번은 꼭 만나는 미스터리입니다.

이석우iOS Developer4분 읽기
얕은 복사(Shallow Copy) vs 깊은 복사(Deep Copy), 복사했는데 원본이 같이 바뀌는 이유 (Swift COW) 대표 이미지

배열을 복사해서 새 변수에 담았습니다. 복사본만 고쳤는데 원본까지 바뀌어 있네요. 프로그래밍하다 한 번은 꼭 만나는 미스터리입니다.

범인은 “복사”라는 말의 두 얼굴입니다. 값을 통째로 베끼는 복사가 있고, 같은 데이터를 가리키는 이름표만 하나 더 만드는 복사가 있어요.

얕은 복사와 깊은 복사를 가르는 기준을 짚고, Swift에서는 이 문제가 어떻게 달라지는지 정리합니다. 값 타입과 Copy-on-Write 덕분에 사정이 달라지지만, 그래도 함정은 남습니다.

복사했다고 믿었는데 안쪽은 같은 걸 가리키고 있었습니다
복사했다고 믿었는데 안쪽은 같은 걸 가리키고 있었습니다

핵심 요약입니다.

  1. 참조 복사: 데이터는 그대로 두고 가리키는 이름표만 복제. 한쪽을 고치면 같이 바뀐다
  2. 얕은 복사: 겉 껍데기는 새로 만들지만, 안에 든 참조들은 같은 대상을 가리킨다
  3. 깊은 복사: 안에 든 것까지 재귀적으로 전부 새로 만든다. 완전히 독립
  4. Swift 값 타입은 대입만으로 독립 복사처럼 동작하지만, 그 안에 클래스가 들어 있으면 얕은 복사 문제가 그대로 돌아온다

세 단계로 나눠야 정확합니다

흔히 얕은/깊은 두 종류로 말하지만 참조 복사까지 세 단계로 나눠야 그림이 맞습니다.

참조 복사는 복사가 아니라 별명 붙이기입니다. 클래스 인스턴스를 다른 변수에 대입하면 객체는 하나이고 가리키는 손가락만 둘이 돼요.

class Profile { var name = "김개발" }
let a = Profile()
let b = a          // 참조 복사: 객체는 하나
b.name = "이코딩"
print(a.name)      // "이코딩" — 원본도 바뀜

얕은 복사는 껍데기 한 겹만 새로 만드는 겁니다. 새 상자를 만들긴 했는데, 상자 안에 든 게 참조라면 그 참조가 가리키는 대상은 공유돼요.

깊은 복사는 상자 안의 상자, 그 안의 상자까지 전부 새로 만드는 겁니다. 두 구조는 완전히 독립이라 어느 쪽을 고쳐도 서로 영향이 없습니다.

얕은 복사는 껍데기만, 깊은 복사는 안까지 전부 새로 만듭니다
얕은 복사는 껍데기만, 깊은 복사는 안까지 전부 새로 만듭니다

Swift 값 타입: 대입이 곧 복사

Swift의 구조체·열거형·기본 타입은 값 타입이라, 대입하는 순간 값이 복제됩니다.

var original = [1, 2, 3]
var copy = original
copy.append(4)
print(original) // [1, 2, 3] — 안전

다른 언어에서 배열 대입이 참조 복사인 것과 비교하면 큰 차이입니다. 파이썬에서 리스트를 대입하면 같은 리스트를 가리키죠.

“매번 통째로 복사하면 느리지 않나?” 싶은데, 여기서 Copy-on-Write(COW)가 등장합니다. Array·Dictionary·String은 대입 시점엔 내부 버퍼를 공유하다가, 어느 한쪽이 수정하는 순간에만 실제 복사를 수행해요. 읽기만 하면 복사 비용이 없고, 의미상으로는 항상 독립 값처럼 동작합니다. 값 타입의 안전함과 참조 공유의 효율을 같이 챙긴 설계예요.


그런데 함정이 남아 있습니다

값 타입 안에 참조 타입이 들어가는 순간, 얕은 복사 문제가 부활합니다.

class Attachment { var filename = "a.png" }

struct Mail {
    var title: String
    var attachment: Attachment  // 구조체 안의 클래스
}

var mail1 = Mail(title: "원본", attachment: Attachment())
var mail2 = mail1               // 구조체는 복사되지만...
mail2.attachment.filename = "b.png"
print(mail1.attachment.filename) // "b.png" — 원본도 바뀜!

구조체 Mail은 분명 복사됐습니다. title은 독립이에요. 하지만 attachment 프로퍼티에 들어 있던 건 “참조”라서 복사된 것도 참조입니다. 두 Mail이 같은 Attachment를 공유하게 된 거죠. 정확히 얕은 복사의 정의입니다.

해결하려면 깊은 복사를 직접 만들어야 합니다. Attachment를 새로 만들어 넣는 복사 로직을 작성하거나, 애초에 Attachment를 구조체로 바꾸는 방법이 있어요. Objective-C 계열에서는 NSCopying 프로토콜의 copy(with:)를 구현하는 게 전통적인 방식이고 클래스 안에 또 클래스가 있다면 그 안까지 재귀적으로 복사해야 진짜 깊은 복사가 됩니다.

실무 관점의 결론은 Swift가 값 타입을 권장하는 이유와 만납니다. 모델을 구조체와 값 타입 프로퍼티로만 구성하면 이 문제 자체가 사라져요.

모델을 값 타입으로만 구성하면 이 문제 자체가 사라져요
모델을 값 타입으로만 구성하면 이 문제 자체가 사라져요

면접에서 한 문장으로

“얕은 복사는 최상위 껍데기만 새로 만들어 내부 참조가 공유되는 복사이고, 깊은 복사는 내부까지 재귀적으로 복제해 완전히 독립시키는 복사입니다. Swift 값 타입은 대입만으로 독립 복사처럼 동작하지만, 값 타입 안의 참조 타입 프로퍼티는 여전히 공유되므로 주의해야 합니다.”

꼬리 질문 대비: “COW가 뭔가요?”(수정 시점까지 복사를 미루는 최적화), “깊은 복사는 어떻게 구현하나요?”(NSCopying 또는 수동 복제, 재귀 적용)까지 답하면 충분합니다.


정리

  • 복사는 참조 복사 → 얕은 복사 → 깊은 복사 세 단계로 나눠 이해한다
  • 참조 복사는 이름표만 복제(객체 공유), 얕은 복사는 껍데기만 복제(내부 참조 공유), 깊은 복사는 전부 복제(완전 독립)
  • Swift 값 타입은 대입이 곧 복사이고, Array·String은 COW로 수정 시점까지 실제 복사를 미룬다
  • 값 타입 안에 클래스 프로퍼티가 있으면 얕은 복사 문제가 그대로 재현된다
  • 깊은 복사가 필요하면 NSCopying이나 수동 복제 로직을 재귀적으로 구현한다
  • 근본 해법은 모델을 값 타입으로 구성하는 것 — Swift가 구조체를 권하는 또 하나의 이유