Swift

Colletcion은 Struct 타입인데 Heap에 저장되는 이유?

GREEN.1229 2023. 9. 25. 14:33

안녕하세요. 그린입니다 🍏

이번 포스팅에서는 Swift 세상으로 넘어와서 Collection이라는 값 타입이 왜 Heap에 저장되는지 그 이유를 파헤쳐보겠습니다!

 

이전 포스팅에서 메모리 덤프를 분석하다가 string이 dump에 남아있는걸 보고 string을 조금 더 파헤쳐보고있었는데요.

 

잘 몰랐던 사실이나 잘못 알고 있었던 지식을 깨달았어요!

그래서 이번에 String으로 시작했지만 Collecion이라는 상위 개념으로 같이 설명을 해보려합니다 🙋🏻

 

메모리 덤프 분석 (feat. fridump)

안녕하세요. 그린입니다 🍏 이번 포스팅에서는 fridump를 이용해서 메모리 덤프 분석을 해볼까해요! 사실 보안팀에서는 각 앱의 보안을 진단하기 위해서 memory dump를 보고 분석하는 업무도 하고

green1229.tistory.com

 


Struct vs Class

우선 간단히 Struct 즉, 구조체인 값 타입과 Class 참조 타입의 차이에 대해서는 면밀히 간략히라도 안다는 가정하에 말해보겠습니다.

우리가 알아볼 내용에서 두 타입의 차이를 따져보자면, Struct는 값 타입 그리고 Class는 참조 타입입니다.

 

이것을 통해서 파생되는 학습일거에요!

 

여기서 우리가 흔히 (저도 막연히 그랬지만) 잘못 알고 있는것이 있습니다.

값 타입은 메모리에서 Stack 영역에 저장되고 참조 타입은 Heap 영역에 저장된다는것입니다 🤔

 

근데 사실 가장 잘못알고 있었던 사실입니다.

 

오늘 같이 살펴볼 String, Array와 같은 Collection이라 부르는 타입들도 Struct 타입이지만 사실상으로는 Heap 영역에 저장되요.

그러니까 참조 타입은 무조건 Heap에 저장된다! 라는것은 잘못된 지식이죠 🥲

 

그럼 왜 Struct, 값 타입이 Heap에 저장되는 이유와 어떠한 경우가 그럴까 한번 바로 살펴보겠습니다.


Struct인데 Heap 메모리 영역에 저장되는것은 어떤 경우가 있으며 왜 그럴까?

우선 예시로 상위 개념으로 가져올것은 Collection 타입입니다.

우리는 이미 Array, Set, Dictionary, String과 같은 타입들이 Collection 타입임을 알고 있습니다.

이 타입들의 구현을 까보면 모두 Struct, 즉 값 타입임을 알 수 있죠!

 

@frozen public struct String {
  ...
}

 

모든 값 타입이 Heap에 저장되는것은 아니기에 왜 이런 Collection 타입은 Heap 메모리에 저장될까요?

 

일단 String을 예시로 한번 설명해보겠습니다.

 

String은 문자열을 나타내는 타입이죠.

Int, Char와 같이 이미 데이터가 저장되기전에 선언하여 어느정도 메모리 공간을 차지하는 녀석이 아닌 이 차지할 수 있는 공간은 참 가변적입니다.

 

그말은 즉, 우리가 컴파일을 돌릴때 이 데이터의 크기를 딱 고정하여 알 수 없기에 Stack처럼 고정적 크기를 가진 값 타입을 저장할 수 없습니다.

 

그렇기에 런타임 중 이 값들은 변할 수 있고 그에 따라 크기 또한 변하게 됩니다.

그렇기에 Heap 메모리 영역에 동적으로 할당되고 해제될 수 있도록 저장합니다.

 

사실 String은 Heap 메모리 영역에만 저장되는것은 아니고 엄밀하게는 실제 데이터는 Heap에 그리고 이 데이터를 참조하는 정보는 Stack에 저장되게 됩니다.

포인터와 같은 개념이죠.

 

즉, String 타입뿐 아니라 앞서 언급한 Array, Set, Dictionary 등 Collection 타입이라 부를것들은 모두 이 룰을 따르게 됩니다.

 

그럼 실제로 그런지 한번 코드로 메모리 영역을 찍어 볼까요?

 

우선 Collection 타입이 아닌 Int 타입으로 한번 시도해보겠습니다.

var firstNum: Int = 10
var secondNum = firstNum

withUnsafePointer(to: &firstNum) { pointer in
    print("The memory address of firstNum is \(pointer)")
}
withUnsafePointer(to: &secondNum) { pointer in
    print("The memory address of secondNum is \(pointer)")
}

🙋🏻 출력 
// The memory address of firstNum is 0x0000000100008008
// The memory address of secondNum is 0x0000000100008010

 

보시면 구조체이고 Stack에 기본적으로 저장이 되니까 당연히 secondNum이 firstNum의 값을 대입했다하더라도 메모리 주소값을 보면 다른 주소값을 가집니다.

 

즉, 두 데이터는 각자 별도로 할당된 Stack 공간을 가집니다.

 

참조 타입이 아니니 firstNum의 값을 바꿔도 secondNum의 값은 불변하며 역시 각자 가진 메모리 공간을 가지겠죠?

 

그럼 이걸 String 타입으로 한번 볼까요?

 

var firstStr: String = "10"
var secondStr: String = firstStr

// MARK: - Stack 메모리 영역 주소 출력
func printStackAddress(input: inout String) {
  withUnsafePointer(to: &input) { pointer in
      print("The stack memory address is \(pointer)")
  }
}

// MARK: - Heap 메모리 영역 주소 출력
func printHeapAddress(address input: UnsafeRawPointer) {
    print(String(format: "The heap memory address is %p", Int(bitPattern: input)))
}

printStackAddress(input: &firstStr)
printStackAddress(input: &secondStr)
printHeapAddress(address: firstStr)
printHeapAddress(address: secondStr)

🙋🏻 출력
// The stack memory address is 0x0000000100008018
// The stack memory address is 0x0000000100008028
// The heap memory address is 0x600000c00bf0
// The heap memory address is 0x600000c00bf0

 

짜잔! 보시면 두 부분으로 메서드화 시켰어요.

하나는 stack 메모리 주소를 출력하는 메서드와 하나는 heap 메모리 주소를 출력하는 메서드입니다.

 

즉, secondStr이 firstStr을 대입받아도 각 stack 즉 해당 영역에는 각 변수별 heap 실 데이터를 참조하는 데이터가 적재되죠.

그렇기에 stack 영역은 주소는 같을 수 없죠.

 

다만, heap 영역 주소 출력을 실행하면 같은 주소값을 가집니다.

결론적으로 두 변수가 같은 heap 영역의 주소값을 바라보고 있다는 소리입니다.

 

그럼 여기서 한번 firstStr의 값을 변경해볼까요?

 

var firstStr: String = "10"
var secondStr: String = firstStr

// MARK: - Stack 메모리 영역 주소 출력
func printStackAddress(input: inout String) {
  withUnsafePointer(to: &input) { pointer in
      print("The stack memory address is \(pointer)")
  }
}

// MARK: - Heap 메모리 영역 주소 출력
func printHeapAddress(address input: UnsafeRawPointer) {
    print(String(format: "The heap memory address is %p", Int(bitPattern: input)))
}

🔥🔥🔥🔥🔥🔥🔥
firstStr = "30"
🔥🔥🔥🔥🔥🔥🔥

printStackAddress(input: &firstStr)
printStackAddress(input: &secondStr)
printHeapAddress(address: firstStr)
printHeapAddress(address: secondStr)

🙋🏻 출력
// The stack memory address is 0x0000000100008018
// The stack memory address is 0x0000000100008028
// The heap memory address is 0x600000c00bf0
// The heap memory address is 0x600000c04020 ⚠️⚠️⚠️⚠️⚠️

 

불꽃 이모지 부분에서 보이시는것처럼 firstStr에 30이라는 문자열로 값을 변경했어요.

그러고 힙 영역 메모리 주소를 찍어보면 이제 달라졌습니다!!

 

즉, 같은 힙 메모리를 참조하고 있지 않다는 소리죠.

 

그말은 곧 힙 메모리가 하나 더 생겼고 별도로 구분되어졌다는 소리입니다.

 

그럼 왜 이런 결과가 생겼을지 한번 알아보겠습니다!


CoW (Copy on Write)의 매직 🧙🏻‍♀️

왜 힙 메모리가 하나 더 생겼고 같은 메모리를 참조하지 않는지 알려면 우리는 Swift의 CoW라는것에 대해 알아야합니다.

 

Cow는 참조하고 있는 값의 변경이 생기기전까지는 실제로 firstStr, secondStr이 같은 값을 참조하고 있다가 값이 변경되면 복사를 일으킵니다.

즉, 변경이 일어나기전까지는 같은 데이터를 공유하고 있고 변경이 발생해야지만 복사하여 별도 메모리 데이터를 가지게 되죠.

 

방금도 위에서 firstStr의 값을 변경해주니까 복사가되고 별도로 메모리 데이터를 가지게 된것입니다.

 

CoW는 위와 같이 String이나 Array, Set, Dictionary와 같은 Collection 타입을 복사하여 사용할때 발생되는 개념입니다.

 

그럼 왜 Collection 타입은 CoW라는 개념을 도입했을까요?

우선 당연하게도 메모리 성능적으로 효율을 점하는것에 있겠죠?

만약 CoW가 없이 모두 힙에 가변적인 데이터를 복사할때마다 잡아둔다면 불필요한 메모리 공간을 미리 차지해버리는것 아닐까요?

복사는 일어나지 않을 수도 있잖아요!?

 

그렇기에 초기에는 메모리를 고려하여 불필요한 복사를 막고 같은 값을 참조하게 가져간 후 실제로 수정이 발생하면 복사를 해서 메모리 공간을 차지하게 된다면 더 효율적일거에요.

 

즉 좀 더 자세히 들어가보면 secondStr이 할당되는 순간 얕은 복사가 일어나서 heap의 실제 데이터의 주소값을 공유하게 되고 값이 수정되면 깊은 복사를 통해 별도 메모리 공간을 차지하게 되는것이죠.

 

결국 정리하면 CoW는 메모리 공간을 최대한 효율적으로 사용하기 위함이다!! 라고 설명할 수 있을것 같습니다.

 

 

그래서 결국 우리의 Collection 타입은 Struct의 이점을 가져가면서 메모리 공간 효율을 위해 CoW를 채택한것으로 볼 수 있습니다.

 

결론적으로 위 정리가 Collection 타입들은 Struct인데 Heap에 데이터가 저장되는 이유입니다 🙋🏻