들어가며

Swift에서 값 타입과 참조 타입의 주요한 차이 중 하나는, 값 타입은 새로운 변수를 할당하거나 파라미터로 전달될 때 값 복사가 일어난다는 점이다. 다만, 이러한 복사 작업은 상당한 시간이 걸리므로 이런 작업을 최적화 하기 위한 기술이 바로 COW(Copy-on-Write)이다. "쓸 때 복사한다."라는 의미인데 COW가 어떤 방식으로 동작하는지, 또 Swift에서 어떤 타입에 구현되어있는지를 살펴본다.


실험

실험준비

아래와 같이 메모리 주소를 출력하는 함수를 정의하고 실험을 진행한다. UnsafeRawPointer는 Swift가 지원하는 포인터 타입 중 하나인데, 나중에 포스트를 통해 자세히 설명할 예정이다. 일단 객체의 메모리 주소를 나타내는 타입이라고 이해하자.

func printAddress(of object: UnsafeRawPointer) {
    print(object)
}

Array

먼저 Array에 대한 실험을 진행해보면?

func testArray() {
    var array1 = [1, 2, 3]
    var array2 = array1

    printAddress(of: array1)
    printAddress(of: array2)

    array2[0] = 7

    printAddress(of: array2)
}


위와같은 결과를 확인할 수 있다. 처음 array2에 array1을 할당했을 때, 바로 복사가 되는것이 아님을 확인할 수 있다. array1와 array2가 같은 메모리 주소를 가르키고 있다가 array2 요소를 수정했을 때 비로소 복사가 일어난다.
이러한 동작에 대해서 Array 문서에 기술되어 있는 내용을 확인해 보자.



Array의 복사본은 그 복사본을 수정하기 전까지 같은 storage를 공유하고, 수정이 발생하게 되면 그 때 복사하여 비로소 유일한 복사본 storage를 갖게 되고 이러한 최적화는 복사본의 양을 줄일 수 있다고 명시되어 있다.
"Array는 스탠다드 라이브러리의 다른 모든 가변 컬렉션 타입과 마찬가지로 COW 최적화를 사용한다." 라고 되어있는 것을 보아 Array 말고도 다른 컬렉션 타입도 COW가 구현되어 있다는 힌트를 얻을 수 있다.

Array외에 다른 타입들도 확인해보자.

Set, Dictionary

func testSet() {
    var set1: Set<Int> = [1, 2, 3]
    var set2 = set1

    printAddress(of: &set1)
    printAddress(of: &set2)
}

func testDictionary() {
    var dict1: [Int: Int] = [1: 1, 2: 2, 3: 3]
    var dict2 = dict1

    printAddress(of: &dict1)
    printAddress(of: &dict2)
}


응? set1과 set2, dict1과 dict2의 주소가 변경이 일어나기도 전에 다르다. 공식문서에는 구현되어 있다고 하는데.. 왜 이럴까?
xcode에서 지원하는 메모리 View를 통해 상세히 살펴보자

dictionary1의 주소값에 저장되어 있는 데이터

dictionary2의 주소값에 저장되어 있는 데이터


변경 전 시점에서는, 메모리 주소는 다르지만, 같은 값을 저장하고 있음을 확인할 수 있다.
dict2[1 ] = 2 를통해 변경을 진행하면, dict2 메모리 주소에 저장된 데이터가 아래와 같이 변경된다.


공식문서에서 COW가 구현되어있다는 것을 명시해놓은 점과, 3쌍의 Int: Int dictionary를 표현하기 위해 8바이트는 부족하다는 점을 고려해 볼 때 배열과는 다르게 한번 Wrapping이 되어있고 다른 주소값을 저장하는게 아닐까 조심스럽게 추측해본다. (답을 아시는 분.. 댓글로 남겨주세요)

String

스트링은 어떨까? 먼저 15글자 이하인 경우를 살펴보자. (String은 15글자 이하와 초과일 때, 저장 동작이 다르다. 이에 대해서는 후속 포스팅에서 자세히 알아볼 것!)

func testString() {
    var str1 = "StringStringStr"
    var str2 = str1

    printAddress(of: &str1)
    printAddress(of: &str2)

    printAddress(of: &str2)
}


처음부터 주소값이 다른것을 확인할 수 있음


서로 다른 주소공간에 String이 그대로 저장되어 있는 것을 볼때, COW가 동작하지 않는 것을 확인할 수 있다.

이번엔 15자를 초과하는 String (var str1 = "StringStringString")에 대해서 실험해보자
String은 15글자를 초과하게 되면 Heap영역에 데이터를 저장한다.

아래와 같이, 처음에는 저장하고 있는 데이터가 동일한 것을 확인할 수 있다. 아마 heap영역의 주소를 가리키는 것이라고 생각된다.


str2 += "String" 연산을 통해 값을 변경시키면, str2의 주소에 저장된 데이터가 아래와 같이 변경된다.


15글자를 초과하는 String이 해당 주소에 Heap의 주소값을 저장한다는 가정이 맞다면, 위의 결과로 COW가 동작한다는 것이 확인된다.

구조체

그렇다면 일반적인 사용자 정의 구조체는 어떨까?
아래와 같은 코드로 실험을 진행해보자.

struct SomeValue {
    var a: Int
    var b: Int
}

func testStruct() {
    var struct1 = SomeValue(a: 1, b: 2)
    var struct2 = struct1

    printAddress(of: &struct1)
    printAddress(of: &struct2)

    struct1.a = 7

    printAddress(of: &struct2)
}


아래와 같이, 새로운 값을 할당하기도 전에 다른 메모리 주소공간을 가지는 것을 확인할 수 있다.


상세히 살펴보면,

1과 2가 저장되어 있는것을 직접 확인할 수 있다.

7과 2


위와 같이, 서로 다른 메모리 공간에 처음부터 값들을 가지고 있는것이 확인되므로 사용자 정의 Struct 타입에는 COW가 구현되어 있지 않다고 확인할 수 있다.

그렇다면 사용자 정의 구조체에 COW를 구현하고 싶으면 어떻게 해야 할까?

final class Ref<T> {
    var val : T
    init(_ v : T) {val = v}
}

struct Box<T> {
    var ref : Ref<T>
    init(_ x : T) { ref = Ref(x) }

    var value: T {
        get { return ref.val }
        set {
        // 유일하게 참조되는지를 확인
          if (!isUniquelyReferencedNonObjC(&ref)) {
            ref = Ref(newValue)
            return
          }
          ref.val = newValue
        }
    }
}


struct 내부에 class 인스턴스를 저장프로퍼티로 소유하고, 그 인스턴스 내부에 프로퍼티로 원본 데이터를 저장한다. 그리고 getter에서는 참조의 데이터를 그대로 반환하지만, setter에서 유일하게 참조되고 있는지를 확인하고 아니라면 새로운 참조 인스턴스를 생성해서 반환하도록 구현한다.


결론

- Swift에서 기본적으로 COW가 구현되어 있는 타입은 Standard Library의 가변 길이를 가진 컬렉션 프로토콜을 준수하는 타입인 Array, Dictionary, Set, String 등이다. (공식문서에 명시되어 있지 않은 컬렉션 타입들도, COW의 목적에 비춰볼때 구현되어 있다고 생각함이 타당한듯)
- Struct는 기본적으로 COW 동작이 구현되어 있지 않아서, 필요하다면 커스텀하게 구현해 주어야 한다.

'iOS > Swift' 카테고리의 다른 글

[Swift Language Guide] String & Character  (0) 2022.09.19
[Swift] High-Order Function (고차함수)  (0) 2021.11.14