Swift/개념 & 응용

[Swift] 깊은 복사와 얕은 복사(feat. NSCopying)

유정주 2022. 8. 25. 13:28
반응형

Value 타입과 Reference 타입

값(value) 타입과 참조(reference) 타입은 복사 방식이 다릅니다.

일반적으로 값 타입은 깊은 복사, 참조 타입은 얕은 복사가 발생한다고 알고 계실텐데요.

정말 그런지 알아보고 참조 타입이어도 깊은 복사를 할 수 있는 방법에 대해 알아봅시다.

 

깊은 복사(Deep copy)

깊은 복사는 데이터 자체를 복사하는 방법입니다.

각자 독립적인 메모리를 차지하기 때문에 복사한 인스턴스의 데이터를 바꾸더라도

원본에 영향을 주지 않습니다.

 

값 타입의 인스턴스들은 깊은 복사를 하게 되는데요.

아래는 Collection 타입인 Array를 복사하는 예제입니다.

var arr: [Int] = [1, 2, 3]
var copyArr = arr

print("arr: \(arr)") //[1, 2, 3]
print("copyArr: \(copyArr)") //[1, 2, 3]

copyArr = [4, 5, 6]
print("arr: \(arr)") //[1, 2, 3]
print("copyArr: \(copyArr)") //[4, 5, 6]

복사된 copyArr의 값을 바꿔도 원본 arr에는 아무런 영향을 주지 않습니다.

 

얕은 복사(Shallow copy)

얕은 복사는 이름처럼 최소한의 복사만 진행하는 것으로,

복사를 해도 새로 인스턴스 메모리가 생기지 않고 주소값을 공유합니다.

원본과 복사본이 같은 주소값을 참조하므로 한 쪽의 데이터를 바꾸면 다른 쪽에도 영향을 줍니다.

 

참조 타입인 경우 얕은 복사가 발생합니다.

Human 클래스를 정의해서 확인해 보겠습니다.

class Human {
    var name: String = ""
    init(name: String) {
        self.name = name
    }
}

let human = Human(name: "시리")
let copyHuman = human

print("human: \(human.name)") //시리
print("copyHuman: \(copyHuman.name)") //시리

copyHuman.name = "빅스비"

print("human: \(human.name)") //빅스비
print("copyHuman: \(copyHuman.name)") //빅스비

시리라는 이름의 Human 클래스 인스턴스를 생성하고 copyHuman에 복사합니다.

두 인스턴스의 메모리 주소가 같은 것을 볼 수 있습니다.

 

human과 copyHuman은 복사한 직후에는 같은 이름을 갖고 있습니다.

copyHuman의 name을 빅스비로 바꿨을 때

human과 copyHuman은 같은 클래스 인스턴스의 메모리를 참조하고 있기 때문에

human의 name도 빅스비로 변경됩니다.

 

깊은 복사 vs 얕은 복사

깊은 복사는 인스턴스의 변화가 다른 인스턴스에 영향을 주지 않는다는 장점이 있지만

메모리를 더 많이 소비한다는 단점이 있습니다.

얕은 복사는 같은 메모리 주소를 참조하므로 메모리는 절약되지만

인스턴스의 변화가 같은 메모리 주소를 참조하는 인스턴스에 영향을 줍니다.

 

깊은 복사와 얕은 복사는 어떤 것이 좋다라고 하기에는 애매합니다.

서로 다른 역할을 하고 있기 때문에 상황에 맞게 사용해야 되는 개념입니다.

 

위에서는 값 타입은 깊은 복사, 참조 타입은 얕은 복사가 발생한다고 말했습니다.

값 타입은 항상 깊은 복사가 일어나고 참조 타입은 항상 얕은 복사가 일어날까요?

 

값 타입의 Copy-On-Write

깊은 복사는 메모리 효율이 좋지 않습니다.

Int, Double 등의 단일 변수라면 영향이 미미하겠지만, Array, Set 등 콜렉션 타입은 크기가 상당히 커질 수 있죠.

따라서 복사를 할 때마다 새롭게 메모리 복사를 진행하는 것은 매우 비효율적입니다.

 

그래서 나온 기술이 COW(Copy-On-Write)입니다.

복사가 발생한 직후에는 얕은 복사를 하고,

한 쪽의 데이터가 변하면 그때 깊은 복사를 진행합니다.

 

그러면 얕은 복사의 장점과 깊은 복사의 장점을 모두 가질 수 있습니다.

 

함수에 배열에 전달될 때도 깊은 복사가 발생합니다.

COW는 이 상황에서도 큰 이점을 가지는데요.

함수의 매개변수는 기본적으로 let 이기 때문에 꼭 필요한 상황에 var로 매개변수를 담는 것이 아닌 이상

깊은 복사로 인한 메모리 비효율이 발생할 위험이 없습니다.

 


COW는 모든 면에서 완벽한가?

COW가 모든 면에서 좋지는 않습니다.

COW는 런타임에 데이터가 변경되었는지 아닌지 확인하는 작업이 필요합니다.

따라서 메모리는 효율적이지만 시간은 비효율적인 편이죠.

개인적인 의견이지만 시간 손실보다 메모리 이득이 압도적이라 COW를 채택한 것 같네요 ㅎ

이에 대한 더 자세한 내용은 아래 링크에서 확인해 주세요.

https://medium.com/@nitingeorge_39047/copy-on-write-in-swift-b44949436e4f

 

참조 타입의 깊은 복사

참조 타입은 NSCopying 프로토콜을 채택하여 깊은 복사를 할 수 있습니다.

NSCopying을 준수하려면 copy 메서드를 구현해야 합니다.

 

예시를 먼저 보겠습니다.

class Human: NSCopying {
    var name: String
    
    init(name: String) {
        self.name = name
    }
    
    func copy(with zone: NSZone? = nil) -> Any {
        return Human(name: self.name)
    }
}

Human 클래스를 정의하고 NSCopying을 채택했습니다.

NSCopying 프로토콜이 요구하는 copy 메서드에서는 새로운 Human 인스턴스를 생성하여 반환합니다.

 

let human = Human(name: "시리")
let copyHuman = human.copy() as! Human

print("human: \(human.name)") //시리
print("copyHuman: \(copyHuman.name)") //시리

copyHuman.name = "빅스비"

print("human: \(human.name)") //시리
print("copyHuman: \(copyHuman.name)") //빅스비

copy()를 호출해서 인스턴스 복사를 해줍니다.

새로운 인스턴스를 생성해서 반환하므로 (어쩌면 당연히) 깊은 복사가 발생합니다.

두 인스턴스의 메모리 주소도 다른 것을 볼 수 있습니다.

 

참조 타입 안에 참조 타입이 있을 때

참조 타입 안에 참조 타입이 있을 때는 어떤 복사가 일어날까요?

class Human: NSCopying {
    var name: String
    var address: Address
    
    init(name: String, address: Address) {
        self.name = name
        self.address = address
    }
    
    func copy(with zone: NSZone? = nil) -> Any {
        return Human(name: self.name, address: self.address)
    }
}

class Address {
    var address: String
    init(_ address: String) {
        self.address = address
    }
}

Address라는 새로운 클래스를 정의해서 Human 클래스 프로퍼티로 추가했습니다.

 

let human = Human(name: "시리", address: Address("미국"))
let copyHuman = human.copy() as! Human

복사만 진행하고 메모리 주소를 확인해보았습니다.

Human 클래스 인스턴스는 다른 메모리를 갖지만 address는 같은 주소값을 공유합니다.

즉, address는 얕은 복사가 발생했습니다.

 

name과 Address 인스턴스의 address 값을 바꿔보았습니다.

copyHuman.name = "빅스비"
copyHuman.address.address = "한국"

print("human: \(human.name) / \(human.address.address)") // 시리 / 한국
print("copyHuman: \(copyHuman.name) / \(copyHuman.address.address)") //빅스비 / 한국

copyHuman의 Address의 address만 "한국"으로 바꿨는데 human의 address도 변경된 것을 볼 수 있습니다.

 

 

한 가지 흥미로웠던 점은,

copyHuman의 address에 새로운 인스턴스를 할당했을 때입니다.

copyHuman.name = "빅스비"
copyHuman.address = Address("한국")

기존 클래스의 얕은 복사와 완전히 같다면 human과 copyHuman 모두 새로운 Address로 바뀌어야 합니다.

하지만 결과는 copyHuman의 address 인스턴스만 변경되었습니다.

 

NSCopying을 채택하지 않은 클래스는

이것처럼 데이터가 변경된 후에도 address의 메모리 주소가 같습니다. 

 

이런 차이가 생기는 이유는 Address가 클래스 프로퍼티이기 때문입니다.

human과 copyHuman은 서로 다른 클래스 인스턴스입니다.

copyHuman.address에 새로운 Address를 넣은 것은

copyHuman 인스턴스의 address 프로퍼티에 새로운 주소값을 넣은 것이기 때문에

human 인스턴스의 address에는 영향을 주지 않은 것이에요.

 

값 타입 안에 참조 타입이 있을 때

값 타입 안에 참조 타입이 있을 때 참조 타입은 어떻게 복사가 될까요?

값 타입을 따라서 깊은 복사가 될까요 참조 타입만 얕은 복사가 될까요?

 

확인해 봅시다!

struct Human {
    var name: String
    var address: Address
}

class Address {
    var address: String
    init(_ address: String) {
        self.address = address
    }
}

Human 구조체 안에 Address 클래스를 프로퍼티로 추가했습니다.

var human = Human(name: "시리", address: Address("미국"))
var copyHuman = human

copyHuman.name = "빅스비"
copyHuman.address.address = "한국"

print("\(human.name) / \(human.address.address)") //시리 / 한국
print("\(copyHuman.name) / \(copyHuman.address.address)") //빅스비 / 한국

참조 타입인 프로퍼티는 얕은 복사가 발생하네요.

이때는 참조 타입 프로퍼티가 NSCopying을 준수해야 깊은 복사로 복사된다고 합니다.

 

감사합니다!


아직은 초보 개발자입니다.

더 효율적인 코드 훈수 환영합니다!

공감 댓글 부탁드립니다.

 

 

반응형