vmmap을 활용한 메모리 분석

값 타입, 참조 타입에 대해 공부를 하다 보니 메모리가 실제로 Heap 영역에 쓰여지는지, 또는 Stack 영역에 쓰여지는지 궁금증이 생겼습니다.
따라서 vmmap을 활용해서 인스턴스가 생성될 때 어떤 영역에 저장되는지, 어떤 일이 일어나는지 분석해보기로 하였습니다.

[참고] 김정님과 김종권님이 작성하신 글을 참고하여 진행했습니다. 🙂

[Jung Kim 님 블로그: 스위프트 타입별 메모리 분석 실험]

[김종권 님 블로그: Swift 메모리 할당]


1. vmmap 이란?

vmmap은 macOS에서 특정 프로세스의 메모리 맵을 확인할 수 있게 해주는 명령어입니다.
이 명령어를 통해 프로세스가 사용하는 메모리 영역을 상세하게 확인할 수 있습니다.

vmmap <PID>

image

vmmap을 통해 확인한 프로세스의 메모리 영역입니다.
자세히 보면 TEXT, DATA, STACK, MALLOC 등 다양한 영역들의 메모리 사용량을 확인할 수 있습니다.


2. 값 타입 주소 읽기

값 타입의 메모리 주소를 참조하기 위해서 withUnsafePointer(to:)를 사용했습니다.
이 메서드를 활용해서 값 타입의 메모리 포인터를 사용할 수 있습니다.
UnsafeMutablePointer와의 차이점은 값을 변경할 수 있느냐 없느냐의 차이입니다.

이렇게 알아낸 포인터를 출력해보면 16진수로 표현된 메모리 주소를 얻을 수 있습니다.


3. 값 타입의 힙 영역 주소 읽기

UnsafeRawPointer는 타입 정보가 없는 포인터로, 직접 값을 해석할 수 없습니다.
이를 사용하면 문자열처럼 힙 영역에 저장되는 주소를 확인할 수 있습니다.


4. Int 타입 확인하기

var A = 10
A = 123456789012345

우선 Int가 어떻게 표현되는지 확인해보겠습니다.
값을 변경하더라도 스택 주소값은 동일하고, 이를 확인해보면 다음과 같습니다.

스크린샷 2025-02-12 오후 12 02 53

첫 번째 값은 10으로, 이는 0a로 표현되었습니다.
두 번째 값은 123456789012345인데, 스택에 저장된 8바이트를 확인하면 0x00007048860ddf79, 즉 123456789012345라는 값을 표현하고 있습니다.

var A = 1
var B = A
...
B = 3
...
A = 3

다음과 같이 Int 타입 두 개를 정의한 뒤, 값을 할당, 복사, 변경해줄 때 메모리 주소의 변화를 관찰하였습니다. (김정님께서 하신 실험을 재현했습니다.)

image

스택의 메모리 범위는 0x000000016f604000 ~ 0x000000016fe00000 입니다.

출력 결과는 위와 같은데, 이를 통해 두 값이 모두 스택 영역에 할당되었음을 확인할 수 있습니다.

image

또한 lldb에 memory read를 입력하면 32바이트가 출력되는 것을 확인할 수 있습니다.
이는 특정 메모리 주소의 내용을 출력하는 것으로, 메모리 주소에 대해 저장된 데이터가 출력됩니다.
이때 한 줄에 1바이트 16개가 출력되는데, 숫자 쌍 당 1바이트를 의미합니다. (16진수이므로 각 자리가 4bit를 표현할 수 있습니다.)
이를 확인해보면 Int 타입은 8바이트이고, 메모리 상에서 연속적인 주소에 저장된 것을 확인할 수 있습니다.


4. String 타입 확인하기

var str1 = "AAAA"
str1 = "AAAAAAAAAA"
str1 = "ADJISJDLDNFLSKFMNDLS"
var str2 = str1
str2 = "AAAA"

String의 경우는 위와 같이 시나리오를 작성하여 테스트 해보았습니다. (김정님께서 하신 실험을 재현했습니다2)

image

image

출력된 주소들을 메모리 주소에 맞춰보면 String의 경우 스택 영역에 공간을 할당받으면서도 힙 영역을 사용하고 있는 것을 알 수 있습니다.
여기서 사용되는 공간인 MALLOC_NANO는 성능 최적화를 위한 공간으로 전용 메모리 공간을 미리 할당하여 아주 작은 객체를 빠르게 할당하고 해제할 수 있는 영역이라고 합니다.
이때 가상 주소 공간을 미리 확보하기 때문에 기존의 Heap 영역과 겹치지 않는 높은 주소를 OS가 예약해서 사용한다고 하네요. 🙂

image

맨 처음 str1에 AAAA를 대입할 때는 스택 공간에서 값이 보여집니다.

image

이를 좀 더 긴 문자열로 바꿔 대입했을 때도 마찬가지입니다.

image

이보다 더 긴 문자열로 할당하자 스택 영역이 아닌 Heap 영역에 값을 저장한 것으로 보입니다.
값이 Heap에 있다면 문자열을 복사할 때도 참조를 복사하게 될까요?

새로운 변수 str2는 str1의 값을 그대로 복사하였습니다.
그런데 이때 Stack 영역은 새롭게 할당받지만, Heap 영역은 str1의 주소와 동일한 주소를 사용하는 것을 확인할 수 있었습니다. (이 동작은 Copy-on-write 때문에 발생하는 현상입니다.)

[참고] 실행할 때마다 값이 공유될 때도 있고 아닐 때도 있어, 값이 늘 공유되는 것은 아닌 것 같습니다.


image

다음으로 str2에 짧은 문자열을 할당해주자 힙 영역의 주소가 바뀐 것을 확인할 수 있습니다.
그리고 짧은 문자열을 넣어주었기 때문에 스택 영역에 값이 저장된 것을 확인할 수 있습니다.

어떻게 하면 이렇게 유동적으로 힙 영역을 활용할 수 있는 걸까요? 이 문제는 SIL를 활용해서 좀 더 자세히 확인할 수 있었습니다.

SIL란?

Swift 컴파일러가 사용하는 중간 표현으로, Swift 코드가 실행 가능한 바이너리 코드로 변환되기 전에 거치는 중간 단계 언어입니다.

터미널에서 swiftc -emit-sil 명령어를 통해 SIL 코드를 확인할 수 있습니다.


%0 = alloc_stack [var_decl] $String, var, name "str1", type $String // users: %7, %47, %46, %14, %25
%1 = string_literal utf8 "AAAA"                 // user: %6
%2 = integer_literal $Builtin.Word, 4           // user: %6
%3 = integer_literal $Builtin.Int1, -1          // user: %6
%4 = metatype $@thin String.Type                // user: %6
  // function_ref String.init(_builtinStringLiteral:utf8CodeUnitCount:isASCII:)
%5 = function_ref @$sSS21_builtinStringLiteral17utf8CodeUnitCount7isASCIISSBp_BwBi1_tcfC : $@convention(method) (Builtin.RawPointer, Builtin.Word, Builtin.Int1, @thin String.Type) -> @owned String // user: %6
 %6 = apply %5(%1, %2, %3, %4) : $@convention(method) (Builtin.RawPointer, Builtin.Word, Builtin.Int1, @thin String.Type) -> @owned String // user: %7
 store %6 to %0 : $*String                       // id: %7

위 내용을 살짝 살펴보면 %0에서 스택 영역에 var라는 이름의 공간이 할당되고, 그 뒤로는 입력해준 String의 메타 데이터와 관련된 작업들이 이루어지는 것 같습니다.
결과적으로 apply를 통해 %6에 지정해주는데, 여기서 apply는 함수를 호출하는 것으로 %5(%1, %2, %3, %4)를 호출한 것으로 보입니다.
sSS21_builtinStringLiteral17utf8CodeUnitCount7isASCIISSBp_BwBi1_tcfC이 String을 생성하는 빌트인 함수이므로 String을 생성하는 작업을 한 뒤 결과 String을 %6에 넣어준 것입니다.
이 과정에서 마지막에 스택 영역에 생성한 문자열을 넣어주고 있으므로 스택에서 문자열 값을 확인할 수 있었습니다.

%8 = string_literal utf8 "AAAAAAAAAA"           // user: %13
%9 = integer_literal $Builtin.Word, 10          // user: %13
%10 = integer_literal $Builtin.Int1, -1         // user: %13
%11 = metatype $@thin String.Type               // user: %13
  // function_ref String.init(_builtinStringLiteral:utf8CodeUnitCount:isASCII:)
%12 = function_ref @$sSS21_builtinStringLiteral17utf8CodeUnitCount7isASCIISSBp_BwBi1_tcfC : $@convention(method) (Builtin.RawPointer, Builtin.Word, Builtin.Int1, @thin String.Type) -> @owned String // user: %13
%13 = apply %12(%8, %9, %10, %11) : $@convention(method) (Builtin.RawPointer, Builtin.Word, Builtin.Int1, @thin String.Type) -> @owned String // user: %16
%14 = begin_access [modify] [static] %0 : $*String // users: %16, %15, %18
%15 = load %14 : $*String                       // user: %17
store %13 to %14 : $*String                     // id: %16
release_value %15 : $String                     // id: %17
end_access %14 : $*String                       // id: %18

변수에 새로운 문자열을 대입하면, 앞의 과정과 동일하게 힙 영역에서 문자열을 생성하고 이를 스택 영역으로 복사합니다.

%19 = string_literal utf8 "ADJISJDLDNFLSKFMNDLS" // user: %24
%20 = integer_literal $Builtin.Word, 20         // user: %24
%21 = integer_literal $Builtin.Int1, -1         // user: %24
%22 = metatype $@thin String.Type               // user: %24
  // function_ref String.init(_builtinStringLiteral:utf8CodeUnitCount:isASCII:)
%23 = function_ref @$sSS21_builtinStringLiteral17utf8CodeUnitCount7isASCIISSBp_BwBi1_tcfC : $@convention(method) (Builtin.RawPointer, Builtin.Word, Builtin.Int1, @thin String.Type) -> @owned String // user: %24
%24 = apply %23(%19, %20, %21, %22) : $@convention(method) (Builtin.RawPointer, Builtin.Word, Builtin.Int1, @thin String.Type) -> @owned String // users: %32, %28, %26
%25 = begin_access [modify] [static] %0 : $*String // users: %28, %27, %30
retain_value %24 : $String                      // id: %26
%27 = load %25 : $*String                       // user: %29
store %24 to %25 : $*String                     // id: %28
release_value %27 : $String                     // id: %29
end_access %25 : $*String                       // id: %30

다음은 더 긴 문자열을 대입했을 때 입니다.
이 경우에도 동일하게 문자열을 생성하지만, 다른 점은 retain_value를 통해 참조 카운트가 증가되었습니다.
따라서 힙 영역에서 생성한 String에 대한 참조를 확보하여 String이 힙 영역에서 사라지지 않습니다.

이에 따라 String은 값 타입이지만 참조 타입처럼 동작하는 경우도 있다는 사실을 알게 되었습니다.


5. Array 타입 확인하기

var array1: [Int] = [1, 2, 3, 4, 5]
var array2 = array1
array2.append(10)
array1.append(7)

다음은 Array 타입을 확인해 보았습니다.

image

Array를 생성하고 메모리 주소를 확인해보면, 스택에 저장된 값은 힙 영역의 주소를 가리키고 있음을 알 수 있습니다.

image

그리고 힙 메모리를 확인해보면 정수 데이터가 저장되어 있습니다.

이에 따라 Array는 힙에 저장되고, 스택에서는 이를 참조하고 있다는 것을 확인할 수 있습니다.

image

array2에도 기존의 배열을 할당해주면, 스택 영역에서 동일한 참조를 가리키도록 되어 있습니다.
값 타입이라고 무조건 복사되는 것이 아니라, 이번에도 참조를 전달합니다.

image

이때 값을 바꿔주면 위와 같이 새로운 힙 주소가 할당됩니다.

그렇다면, Array는 어떻게 값을 계속 추가할 수 있는 걸까요?

func intTest() {
    var array: [Int] = [1, 2, 3]
    
    for i in 0..<500 {
        array.append(i)
    }
}

위와 같이 배열에 지속적으로 값을 추가하면서 메모리 영역의 변화를 확인해보았습니다.

image

정리해보면 다음과 같습니다.

0x0000600000edc170: 0~4
0x00006000010d8020: 5~14
0x00000001227059a0: 15~34
0x0000000122705b00: 35~74
0x0000000124008220: 75~182
0x0000000124808220: 183~374
0x000000012280a620: 375~...

이 결과를 통해 힙 영역에 새로운 할당이 이루어지는 것은 선형적이지 않고 지수 형태로 증가한다는 것을 확인할 수 있습니다.
이는 append의 동작과 관련이 있습니다.

배열은 할당된 용량을 지수적으로 증가시키는 전략을 사용하기 때문에, append(_:) 메서드를 여러 번 호출하는 경우 단일 요소를 추가하는 연산의 평균 시간 복잡도는 O(1)이다. 배열에 추가적인 용량이 있으며, 다른 인스턴스와 저장 공간을 공유하지 않는 경우 요소를 추가하는 것은 O(1)이다. 하지만, 배열이 요소를 추가하기 전에 저장 공간을 재할당해야 하거나, 저장 공간이 다른 복사본과 공유되는 경우에는 O(n)의 시간 복잡도를 갖게 되며, 여기서 n은 배열의 길이이다. 출처

위 내용에 따라서 배열의 용량이 가득 차게 되면 힙 저장 공간을 재할당하여 동적으로 할당이 가능한 것으로 보입니다.
그렇다면 재할당 이후 공간과 기존 공간은 어떤 모습일까요?

image

위처럼 재할당이 이루어지면 기존의 공간을 비워주는 동작을 해주는 것 같습니다.
이를 통해 Swift가 어떤 식으로 배열의 동적 할당을 최적화하고 있는지 확인할 수 있습니다.


6. 간단한 Class 타입 확인하기

class Point {
    var x: Int = 0
    var y: Int = 0
}

...

var point1 = Point()
point1.x = 15
var point2 = point1

다음은 간단한 클래스를 작성하여 테스트 하였습니다.

image

클래스의 경우, 생성하면 Stack 영역과 Heap 영역을 모두 사용합니다.
이때 Stack 영역에는 Heap 영역을 가리키는 주소값이 들어있습니다.

image

값을 변경해도 마찬가지로, 스택 영역과 힙 영역의 주소값은 변하지 않았습니다.
다만 힙 영역에 변경한 값 15가 0f로 변경되어 있습니다.

image

새로운 값에 기존의 클래스 인스턴스를 대입해주면 스택 영역의 주소만 바뀌고 힙 영역의 주소는 공유하는 것을 확인할 수 있습니다.
두 스택 영역의 주소 차이는 딱 8바이트 만큼으로, memory read에서도 차례로 저장되어 있습니다.


7. 간단한 Struct 타입 확인하기

struct Point {
    var x: Int = 0
    var y: Int = 0
}

...

var point1 = Point()
var point2 = point1
point2.x = 15

image

간단한 Struct의 경우 스택에 값이 저장되는 것을 확인할 수 있습니다.
이를 다른 변수에 할당하면 값이 복사되고, 스택 메모리 영역을 새롭게 할당받게 됩니다.
확인해보면, 값이 복사되었기 때문에 기존 주소는 값의 변동이 없다는 것을 알 수 있습니다.


8. Struct를 가지고 있는 Class 타입

이번에는 중첩 구조를 가진 타입입니다!
클래스 안에 구조체가 있다면, 이 구조체는 어디에 저장될까요?

var a = A(X(17))
var b = a
b.x = X(20)

동일한 클래스 인스턴스를 a, b에 넣어주고, b에서 구조체의 값을 다른 것으로 변경했습니다.

위에서 나왔던 결과와 동일하게, a, b는 스택 주소는 다르지만 동일한 클래스 인스턴스에 대한 참조를 가지고 있습니다.

image

이 참조의 구조체 값을 변경해보면 위과 같습니다.
힙 영역의 클래스 인스턴스의 값이 11에서 14로 변경되었습니다.
즉 기존에 a를 생성할 때 넣어준 17의 값이 20으로 변경된 것이죠.
이에 따라서 클래스 안에 저장된 구조체 프로퍼티는 힙 영역에서 관리된다고 생각할 수 있습니다. (일반적인 경우)


9. Class를 가지고 있는 Struct 타입

이번에는 구조체 안에 참조 타입을 넣어보겠습니다.

var a = A(X(17))
var b = a
b.x = X(20)

image

생성한 클래스 인스턴스를 다른 변수에 넣어주면, 클래스의 참조를 공유합니다. (스택 영역은 별도입니다.)
동일한 참조를 공유하고 있으니, 한 곳에서의 변경 사항이 다른 곳에 반영되겠죠?

image

참조 자체를 새로운 값으로 대입하게 되면, a, b는 구조체이므로 결과적으로는 b의 참조 값만 바뀌고 a는 기존의 참조를 유지하고 있습니다.

이렇게 참조 값을 공유하게 되면, 결국 Struct를 복사할 때마다 힙 영역의 reference count를 증가시키게 됩니다.
이것이 애플에서 말했던 참조 카운팅 오버헤드의 문제라고 생각됩니다.


번외. Dictionary의 동작

항상 당연하다는 듯이 딕셔너리를 사용하고 있지만 내부적으로 어떻게 동작하는지 막연했기 때문에 Dictionary의 동작도 확인해보고자 합니다.

var cache = [String: Int]()
cache["iOS"] = 1
cache["Android"] = 2
cache["Linux"] = 3
...
var a = cache["iOS"]

image

딕셔너리를 생성하고 값을 변경해도 스택 영역에 할당된 주소는 바뀌지 않습니다.

image

딕셔너리의 스택 메모리 주소보다 앞 영역을 확인해보면 위와 같이 키 값이 저장되어 있습니다.

딕셔너리에 저장된 메모리 주소를 확인해보면 heap 영역인데, 이를 확인해보면 다음과 같습니다.

image

아까 지정해 준 키가 들어가 있는 것을 확인할 수 있습니다.
키에는 순서가 없는 만큼, 랜덤하게 들어가 있는 것 같습니다.
왜 힙 영역과 스택 영역 모두 키 값이 보이는지, 그리고 정확하게 값은 어디에 저장되는지 확실히 알아내지는 못했습니다. ㅠㅠ (아시는 분이 있다면 알려주세요!)

%0 = alloc_stack [var_decl] $Dictionary<String, Int>, var, name "cache", type $Dictionary<String, Int> // users: %8, %143, %142, %22, %41, %60, %79, %135
%1 = integer_literal $Builtin.Word, 0           // user: %3
  // function_ref _allocateUninitializedArray<A>(_:)
%2 = function_ref @$ss27_allocateUninitializedArrayySayxG_BptBwlF : $@convention(thin) <τ_0_0> (Builtin.Word) -> (@owned Array<τ_0_0>, Builtin.RawPointer) // user: %3
%3 = apply %2<(String, Int)>(%1) : $@convention(thin) <τ_0_0> (Builtin.Word) -> (@owned Array<τ_0_0>, Builtin.RawPointer) // user: %4
%4 = tuple_extract %3 : $(Array<(String, Int)>, Builtin.RawPointer), 0 // user: %7
%5 = metatype $@thin Dictionary<String, Int>.Type // user: %7
  // function_ref Dictionary.init(dictionaryLiteral:)
%6 = function_ref @$sSD17dictionaryLiteralSDyxq_Gx_q_td_tcfC : $@convention(method) <τ_0_0, τ_0_1 where τ_0_0 : Hashable> (@owned Array<(τ_0_0, τ_0_1)>, @thin Dictionary<τ_0_0, τ_0_1>.Type) -> @owned Dictionary<τ_0_0, τ_0_1> // user: %7
%7 = apply %6<String, Int>(%4, %5) : $@convention(method) <τ_0_0, τ_0_1 where τ_0_0 : Hashable> (@owned Array<(τ_0_0, τ_0_1)>, @thin Dictionary<τ_0_0, τ_0_1>.Type) -> @owned Dictionary<τ_0_0, τ_0_1> // user: %8
store %7 to %0 : $*Dictionary<String, Int>      // id: %8

딕셔너리를 생성할 때 일어나는 일들입니다.
딕셔너리를 생성하고 스택 영역을 확보해서 이를 저장하는 과정을 확인할 수 있습니다.

%9 = string_literal utf8 "iOS"                  // user: %14
%10 = integer_literal $Builtin.Word, 3          // user: %14
%11 = integer_literal $Builtin.Int1, -1         // user: %14
%12 = metatype $@thin String.Type               // user: %14
  // function_ref String.init(_builtinStringLiteral:utf8CodeUnitCount:isASCII:)
%13 = function_ref @$sSS21_builtinStringLiteral17utf8CodeUnitCount7isASCIISSBp_BwBi1_tcfC : $@convention(method) (Builtin.RawPointer, Builtin.Word, Builtin.Int1, @thin String.Type) -> @owned String // user: %14
%14 = apply %13(%9, %10, %11, %12) : $@convention(method) (Builtin.RawPointer, Builtin.Word, Builtin.Int1, @thin String.Type) -> @owned String // user: %21
%15 = integer_literal $Builtin.Int64, 1         // user: %16
%16 = struct $Int (%15 : $Builtin.Int64)        // user: %17
%17 = enum $Optional<Int>, #Optional.some!enumelt, %16 : $Int // user: %19
%18 = alloc_stack $Optional<Int>                // users: %19, %27, %24
store %17 to %18 : $*Optional<Int>              // id: %19
%20 = alloc_stack $String                       // users: %21, %26, %24
store %14 to %20 : $*String                     // id: %21
%22 = begin_access [modify] [static] %0 : $*Dictionary<String, Int> // users: %25, %24
  // function_ref Dictionary.subscript.setter
%23 = function_ref @$sSDyq_Sgxcis : $@convention(method) <τ_0_0, τ_0_1 where τ_0_0 : Hashable> (@in Optional<τ_0_1>, @in τ_0_0, @inout Dictionary<τ_0_0, τ_0_1>) -> () // user: %24
%24 = apply %23<String, Int>(%18, %20, %22) : $@convention(method) <τ_0_0, τ_0_1 where τ_0_0 : Hashable> (@in Optional<τ_0_1>, @in τ_0_0, @inout Dictionary<τ_0_0, τ_0_1>) -> ()
end_access %22 : $*Dictionary<String, Int>      // id: %25
dealloc_stack %20 : $*String                    // id: %26
dealloc_stack %18 : $*Optional<Int>             // id: %27

이 부분은 “iOS” 키 값에 1을 저장할 때 일어나는 일들입니다.
우선 String 키와 Int를 생성해서 스택 영역에 저장하고, Dictionary의 setter를 사용해서 값을 저장한 뒤 할당했던 스택 영역을 해제합니다.
특이한 것은 이때 Int가 그냥 저장되는 것이 아니라 Optional가 저장됩니다. (딕셔너리에서 값을 얻는 동작이 옵셔널이어서 이렇게 해주는 것 같습니다.)

%66 = alloc_stack [var_decl] $Optional<Int>, var, name "a", type $Optional<Int> // users: %82, %78
  %67 = string_literal utf8 "iOS"                 // user: %72
  %68 = integer_literal $Builtin.Word, 3          // user: %72
  %69 = integer_literal $Builtin.Int1, -1         // user: %72
  %70 = metatype $@thin String.Type               // user: %72
  // function_ref String.init(_builtinStringLiteral:utf8CodeUnitCount:isASCII:)
  %71 = function_ref @$sSS21_builtinStringLiteral17utf8CodeUnitCount7isASCIISSBp_BwBi1_tcfC : $@convention(method) (Builtin.RawPointer, Builtin.Word, Builtin.Int1, @thin String.Type) -> @owned String // user: %72
  %72 = apply %71(%67, %68, %69, %70) : $@convention(method) (Builtin.RawPointer, Builtin.Word, Builtin.Int1, @thin String.Type) -> @owned String // user: %76
  %73 = begin_access [read] [static] %0 : $*Dictionary<String, Int> // users: %74, %81
  %74 = load %73 : $*Dictionary<String, Int>      // user: %78
  %75 = alloc_stack $String                       // users: %76, %80, %79, %78
  store %72 to %75 : $*String                     // id: %76
  // function_ref Dictionary.subscript.getter
  %77 = function_ref @$sSDyq_Sgxcig : $@convention(method) <τ_0_0, τ_0_1 where τ_0_0 : Hashable> (@in_guaranteed τ_0_0, @guaranteed Dictionary<τ_0_0, τ_0_1>) -> @out Optional<τ_0_1> // user: %78
  %78 = apply %77<String, Int>(%66, %75, %74) : $@convention(method) <τ_0_0, τ_0_1 where τ_0_0 : Hashable> (@in_guaranteed τ_0_0, @guaranteed Dictionary<τ_0_0, τ_0_1>) -> @out Optional<τ_0_1>
  destroy_addr %75 : $*String                     // id: %79
  dealloc_stack %75 : $*String                    // id: %80
  end_access %73 : $*Dictionary<String, Int>      // id: %81
  dealloc_stack %66 : $*Optional<Int>             // id: %82
  destroy_addr %0 : $*Dictionary<String, Int>     // id: %83
  dealloc_stack %0 : $*Dictionary<String, Int>    // id: %84
  %85 = tuple ()                                  // user: %86
  return %85 : $()                                // id: %86

값을 꺼내서 a라는 변수에 넣는 과정도 getter를 통해 이루어집니다.

이러한 딕셔너리에 값을 넣을 때 중요한 점이 있습니다.
바로 값 또는 키가 강한 참조를 가질 수 있다는 점입니다.

var cache: [String: Person] = [:]
    
var person1: Person? = Person(name: "Jake", age: 10)
    
cache["person1"] = person1
    
print(">>> nil로 설정합니다.")
person1 = nil
    
print(">>> 딕셔너리에서 값을 제거했습니다.")
cache.removeValue(forKey: "person1")

이 코드의 실행 결과는 다음과 같습니다.

>>> nil로 설정합니다.
>>> 딕셔너리에서 값을 제거했습니다.
>>> deinit이 불렸습니다.

왜 이런 일이 일어날까요?

image

저장하려는 값이 참조 타입이므로 위 그림과 같이 외부에 있는 변수 person1과 딕셔너리 내의 값은 동일한 참조를 공유하게 됩니다.
따라서 참조 카운트가 올라가게 된 것이고, 해당 인스턴스를 완전히 해제하려면 딕셔너리에 참조된 값 또한 제거해주어야 합니다.


마무리

이렇게 다양한 타입들의 메모리 구조와 작동 방식에 대해 살펴보았습니다. (대부분 재현한 것이긴 하지만요 🫠)
그래도 직접 해보니 메모리가 어떻게 동작하는지, 어떤 방식으로 확인해볼 수 있는지 알게 된 것 같아서 좋은 경험이었습니다.