[Swift] Value Type과 Reference Type

2024. 9. 1. 15:56·Swift

이 글이 조금.. 이것저것 많이 들어있어서 제목을 정하는데 조금 애를 먹었다.
솔직히 저 제목도 맞는 건지는 모르겠지만 일단 ! Value Type과 Reference Type에 대해 알아볼 것이다.
(정확히는 sruct와 class가 메모리 상에 어떻게 저장되는지를 알아보다가 좀 범위가 넓어진..)

메모리 구조

이 뒤에 메모리에 대한 이야기를 좀 많이 할 예정이기 때문에 간단하게 Swift의 메모리 구조에 대해 알아보자.

  1. Text 영역
    • 기계어로 변경된 코드(실행 가능한 코드)가 저장되는 곳
    • 프로그램이 실행될 때 메모리에 로드되며 종료 시 메모리에서 제거
  2. Data 영역
    • 전역 변수와 정적 변수(static variables), 상수가 저장되는 곳
    • Code 영역과 같이 프로그램이 실행될 때 메모리에 로드되며 종료 시 메모리에서 제거
  3. Stack 영역
    • 지역 변수와 함수의 매개변수가 저장되는 곳
    • 메소드가 종료되면 메모리 해제
    • 몇몇 예외상황을 빼고는 Swift의 Value Type이 저장됨
  4. Heap 영역
    • 동적으로 할당되는 데이터(e.g. 클래스 인스턴스, 배열과 같은 데이터 구조)가 저장되는 곳
    • 이 곳에 저장되는 객체는 lifetime을 가짐
    • ARC로 메모리 관리가 됨

Value Type과 Reference Type

서론이 조금 길었다.. 이제 이 글의 본론인 Value Type과 Reference Type에 대해 알아보도록 하자.

Swift에는 타입 선언을 크게 3가지로 나눠 할 수 있다. class, struct, enum이다.

여기서 우리는 저 타입들을 Value Type과 Reference Type으로 나눌 수 있는데, class는 Reference Type, struct와 enum이 Value Type이다. 이렇게 나뉜 Value Type이냐 Reference Type이냐에 따라 메모리에 어떻게 저장되는지 나눌 수 있다.

Value Type(값 타입)은 일단 자신만의 데이터를 가지고 있고 변수를 선언할 때마다 값을 복사해서 가지고있기 때문에 하나의 변수를 바꿔도 다른 변수에 영향을 끼치지 않는다.

Reference Type(참조 타입)은 변수를 생성하더라도 새로 생성해주지 않는 이상 같은 주소를 참조하게된다. 따라서 하나의 변수 내부 값이 바뀌면 다른 변수의 내부 값도 바뀔 수 있는 것이다. (애플은 참.. 이름 하나는 명시적으로 잘 짓는 것 같다.)

Value Type과 Reference Type을 구분짓는 하나의 기준이 하나 더 있다. 바로 성능 차이이다. Swift에서 Value Type과 Reference Type의 성능 차이를 야기하는 것에는 3가지 이유가 있다.

  1. 복사(copy) 비용
  2. 할당(allocation)과 해제(deallocation) 비용
  3. 참조 카운팅(reference counting) 비용

흠.. 복사는 위에서 말했듯이 Value Type의 경우 아예 값 복사가 일어나고, Reference Type의 경우 참조를 한다고 했으니 1,3번은 어느정도 예상이 가는데.. 2번은 왜 cost의 차이가 나타나게 되는 것일까?

Heap vs Stack 할당 비용

먼저 Value Type과 Reference Type 각각 stack 영역 heap 영역에 할당된다고 가정하고 들어가자. (몇몇 예외인 경우는 이 밑에서 알아볼 예정 !)

stack 영역에서 메모리 할당은 말 그대로 stack 데이터 구조와 같이 일어난다. LIFO(Last Input First Output) 구조로 push, pop으로 메모리 할당과 해제가 일어난다. 이러한 구조는 포인터 하나만 두고도 손쉽게 메모리를 할당하고 해제하는 것을 구현할 수 있게 만든다. 예를 들어 메소드 하나가 끝났다고 하면 우리는 그냥 stack pointer를 해당 메소드를 호출한 곳으로 바꾸기만 하면 되기 때문이다.

반면 heap 영역의 경우 메모리 할당을 하기 위해서는 적절한 사이즈의 빈 memory block을 찾아야한다. 뿐만 아니라, 여러 스레드가 힙에 메모리 할당을 할 수도 있기 때문에 힙을 동기화해주는 과정까지 필요하다. 메모리를 해제할 때에도 해당 메모리를 적절한 위치로 다시 위치시키는 과정이 필요하다.

allocation과 deallocation에 관련한 내용은 아래 wwdc로 더 자세히 확인해볼 수 있다.

iOS Memory Deep Dive - WDC18

우리가 알고 있듯, Value Type은 대부분 stack 영역에 할당되고 Reference Type은 대부분 heap 영역에 할당되며 위에서 알아 본 바와 같이 각각의 영역마다 할당과 해제를 할 때 드는 cost의 차이 때문에 두 타입의 성능차이가 나게 되는 것이다.

그럼 Value Type이 heap 영역에 할당되고 Reference Type이 stack 영역에 할당되는 예외적인 경우는 어떤 것이 있을까?

Reference Type이 Stack 영역에 할당되는 경우

우선 이런 예외의 경우는 Swift의 Compiler의 판단에 의해 나타나게 된다.

컴파일 타임에 해당 Reference Type이 그 사이즈가 확실히 정해져 있다거나 lifetime이 예상 가능하다고 판단되면 stack 영역에 할당된다. (아까 위에서 heap 영역에 할당되는 객체들은 각자마다 자신의 lifetime을 가지고 있다는 언급 기억!)

이러한 최적화 과정은 컴파일 과정 중 SIL 구문이 생성되는 과정에서 이루어지게 된다.

Xcode의 Build System은 이 전에 학습정리를 해둔게 있는데 .. 이거 올리고 올릴테니 한 번.. 읽어봐도 좋고..
(가독성 구릴확률 max)

이 글을 많이 참고했으니 이걸 읽어보는 것도 추천한다.

Value Type이 Heap 영역에 할당되는 경우

Reference Type을 stack 영역에 할당하게 될 경우와 마찬가지로 Value Type이 heap 영역에 할당되는 시점도 컴파일 시점에 결정된다.

이때 Swift Compiler가 Value Type을 heap 영역에 할당하는 방법은 boxing이라는 기법을 통해 Compiler가 Value Type을 컴파일 시점에 직접 Reference Type으로 바꿔서 heap 영역에 할당한다는 것이다 ..!

타입을.. 아예 바꿔버린다니.. 충격.. 💥

반대의 경우 (Reference Type을 heap 영역에 저장하는 경우)는 unboxing이 일어나지는 않고 그냥 Stack Promotion이라는 최적화 기법에 의해 일어난다고한다.

그래서 Value Type이 Heap 영역에 할당되는 경우는 무엇이 있는지 살펴보자. (그새 또 샛길로 빠질뻔;;)
Value Type이 Heap 영역에 할당된는 경우는 좀 많으니 인덱싱을 해서 꼼꼼하게 살펴보겠다.

  1. struct가 프로토콜을 준수하는 경우

    아래와 같이 Bar 라는 프로토콜을 준수하는 Baz 구조체가 있다고 해보자.

     protocol Bar {}
     struct Baz: Bar {}

    터미널에서 다음 명령어를 사용하면 Swift 파일의 SIL 결과값을 print해볼 수 있다.

    swiftc -emit-silgem -0 \[파일명\]

    실행시키고 확인해보면 아래 사진과 같은 결과가 나온다.

    %1 = alloc\_box... 부분을 보면 init() 내부에서 self가 boxing된 것을 확인할 수 있다.

    이걸 실험해보면서 뭔가 이상한 점을 발견했다.

    아래와 같이 내부에 constant 값 하나를 가지고 있는 Baz2가 있고, 아무런 protocol을 준수하지 않고 아무런 값이 존재하지 않는 struct 타입의 Baz3가 있다.

     struct Baz2 {
         let constant: Int
     }
    
     struct Baz3 {}

    위 코드에 대한 SIL output을 찍어보면 아래와 같은 결과가 나온다.

    여기서 이상하다고 느꼈던 점은 struct 구조체 내부에 프로퍼티와 같은 값이 존재하는 Baz2의 경우 boxing 없이 stack 영역에 implicit value로 잘 들어가는데, struct 구조체 내부에 아무 값이 없는 Baz3의 경우 프로토콜을 채택하지 않았음에도 불구하고 boxing을 했다는 점이다..

    왜이러는 걸까.. 이유를.. 찾을 수 없었다....

  2. Value Typer과 Reference Type이 섞여있는 경우

    아래와 같은 코드가 있다고 해보자.

     class A {}
     struct B {
         let a = A()
     }
    
     struct C {}
     class D {
         let c = C()
     }

    위 코드의 SIL output을 print 해보면 아래와 같이 나온다.

    신기하게도 class 내부에 struct가 호출되는 경우(위 코드에서 타입 C에 해당)에도 해당 struct가 boxing 되는 것을 확인할 수 있었다.

  3. Generic Value Type의 경우

    아래와 같은 제네릭 형태의 struct Bas가 있다.

     struct Bas<T> {
         let x: T
    
         init(x: T) {
             self.x = x
         }
     }

    해당 코드의 SIL output을 확인해보면 다음과 같이 boxing이 일어나는 것을 확인할 수 있었다.

    근데 여기서 또 이상한점..

     struct Bas<T> {
         let x: T
     }

    위 코드처럼 이니셜라이저를 따로 호출해주지 않으면 아래 결과처럼 implicit value로 boxing이 되지 않는다..

    제네릭을 쓸 때 이니셜라이저를 호출하고 안하고에 어떤 차이가 있는지..차이 없지 않나….🫠

    Phind에 물어봐도 둘 다 boxing을 하지 않는다느니 이상한 소리나 하고있다… 왜 둘이 차이가 나는지 아시는 분… 도와주세요….ㅠㅠ

  4. Collection 타입

    Array, Dictionary, Set, String(collection of characters)와 같은 가별 길이 collection들은 일반적으로 내부 데이터를 Heap에 저장해서 사용한다. 컴파일 타임에 해당 값의 정확한 사이즈를 알기 어렵기 때문에 Heap에 할당한 후 적절하게 사이즈를 증감시킨다고한다.

    여기서 우리가 흔히 알고 있는 Copy on Write(COW)에 대한 개념이 나오게된다 !

    Array, String 등은 기본적으로 struct로 구현된 Value Type이다. 때문에 인스턴스를 생성할 때마다 저마다의 unique data를 가져야한다. 하지만 굉장히 mutable한 collection 타입에서 값이 바뀔 때마다 매번 공간을 복사하고 할당하는데에는 부담이있기 때문에 해당 문제를 해결하기 위해 collection은 Copy on Write(COW)라는 최적화 기법을 사용한다.

    Copy on Write 기법이란, 값을 새로운 변수에 할당할 때 바로 복사본을 만드는 것이 아니라, 수정(write)이 발생할 때 복사본(copy)를 만드는 것이다. 수정 전까지는 이 전 기존의 값이 저장된 메모리 주소를 참조하는 방식으로, 변수간 같은 instance를 공유하고 값의 변화가 일어나면 그때 새로운 메모리를 할당하는 방식을 가지고있다.

이 외에도 Escaping closure의 capture, Inout 인수 등.. 예외 상황이 꽤나 많다..

그래서.. struct랑 class 언제 어디서 어떻게 쓰는데..?

결국 돌고돌아 struct와 class 선택의 시간…이다..

Apple에서 지양하는 바를 밝혀주긴했지만 굉장히 애매모호하고 상황에 따라 적재적소에 잘.. 알아서 사용할 필요가 있어보인다.

그때그때 상황은 다르지만 아래와 같은 성능 측면을 고려하여 struct와 class를 알맞게 사용해야한다.

  • Reference Value를 내부 값으로 가지고 있는 Value Type의 경우를 최대한 피해야한다. Reference Value를 가지고 있는 Value Type의 경우 값 복사가 일어나면서 내부 Referecnce Type의 Reference counting 오버헤드가 발생할 수 있기 때문.

  • protocol을 준수하거나 제네릭 타입의 Value Type의 경우 더 높은 할당 비용(allocation cost)를 야기한다.

결론은 위 성능들을 잘 고려해서 알잘딱깔센하게 사용하자 ^_^

참고자료

🔗 [Swift] Memory Structure

🔗 Value types and reference types are the core concepts in Swift

🔗 스위프트 타입별 메모리 분석 실험

🔗 [iOS] Swift의 Type과 메모리 저장 공간

🔗 Understanding Swift Performance - WWDC16

🔗 Phind

저작자표시 (새창열림)

'Swift' 카테고리의 다른 글

[Swift] Task  (8) 2024.10.25
[Swift] Actor(1)  (4) 2024.10.21
[Swift] Property wrapper  (0) 2024.10.06
[Swift] Swift 빌드 과정  (2) 2024.09.08
'Swift' 카테고리의 다른 글
  • [Swift] Task
  • [Swift] Actor(1)
  • [Swift] Property wrapper
  • [Swift] Swift 빌드 과정
00me
00me
얼렁뚱땅 방장이 운영하는 기술 블로그
  • 00me
    영미의 iOS 다이어리
    00me
  • 전체
    오늘
    어제
    • 프로그래밍 (29)
      • 📖 (0)
      • CS (4)
      • Python (5)
      • Swift (5)
      • iOS (3)
      • 코테 (3)
        • 자료구조 (0)
        • 알고리즘 (3)
      • 회고 (9)
  • 링크

    • 🍧 GitHub
  • 인기 글

  • hELLO· Designed By정상우.v4.10.3
00me
[Swift] Value Type과 Reference Type
상단으로

티스토리툴바