티스토리 뷰

야곰 아카데미에서 프로젝트를 진행하다가 "OO는 왜 구조체로 구현했나요?"라는 질문을 받았는데, 그 질문에 대한 답변이 바로 떠오르지 않았다. 왜냐하면... 특별한 이유를 생각해보지 않고 구현했기 때문이었다^^;;; 굳이굳이 이유를 찾아보자면, 아직 프로젝트 요구사항이 전부 밝혀지지 않아서 굳이 class로 작성할 이유를 찾지 못했기 때문이라고 할 수 있겠다. (애플 공식문서에서 클래스에서만 지원하는 기능이 필요한 경우가 아니면 기본적으로 구조체를 쓰라고 했기 때문에)

그런데 연결 리스트를 구현해보는 과정에서 Node는 아무 생각 없이 클래스로 구현했었던 것이 생각나서, (나를 포함한 다른 사람들도) 왜 노드는 클래스로 구현했지?라는 의문을 갖게 되었다.

Doubly Linked List의 노드는 일반적으로 아래와 같이, 노드의 값 그리고 앞뒤로 연결된 노드에 대한 reference를 프로퍼티로 갖는다.

class Node {
    var value: Int
    var prev: Node?
    var next: Node?
}

이 노드를 아래와 같이 구조체로 구현해보면, 오류가 발생한다.

struct Node {
    var value: Int
    var prev: Node? // 컴파일 오류 발생
    var next: Node? // 컴파일 오류 발생
}

발생하는 컴파일 오류: Node는 값 타입이기 때문에, 재귀적으로 자신을 포함하는 저장 프로퍼티를 가질 수 없다고 한다.

왜죠..? WHY....?
결론을 요약하자면 구조체는 값 타입이므로 컴파일 타임에 스택에 공간을 할당해줘야 하는데, 컴파일 타임에 이 Node의 크기를 알 수 없기 때문이다. Node의 크기를 알 수 없는 이유는, 이 Node가 재귀적으로 계속 새로운 Node에 대한 정보를 가지고 있는 구조이기 때문에 크기를 특정할 수 없어 컴파일 타임에 알 수가 없다는 것이다.

제대로 이해하기 위해서는 Swift의 메모리 할당 방식에 대해서 알아야 한다.
분명히 몇주 전 활동학습 주제로 공부했었는데, 명확하게 이해가 안 되는 점이 있어 다시 공부하기로 함^^!


Swift의 메모리 할당/해제 방식 (힙, 스택)

프로그램을 위해 할당된 메모리 공간은 총 4가지 영역으로 나뉘어져 있다 (코드 영역, 데이터 영역, 힙 영역, 스택 영역). 그 중에서 힙이랑 스택만 정리해보자.

힙 영역

런타임 전까지는 크기를 알 수 없는 데이터를 가지고 있기 위한 메모리 덩어리
힙으로 할당된 메모리는 ARC에 의해서 해제된다

  • Swift는 어떤 메모리에 대한 새로운 참조가 생겼을 때, 그 메모리의 reference count를 +1 한다
  • 참조가 해제되면 그 메모리의 reference count를 -1 한다.
  • reference count가 0이 되면, 메모리가 해제된다.
  • 전통적인 garbage collection은 힙 전체를 돌아다니면서 더 이상 참조되지 않는 메모리를 찾아 해제하는 방식이기 때문에, Swift의 ARC가 더 효율적이다.

스택 영역

함수를 호출할 때 지역 변수, 매개 변수, 반환값(return value) 등이 스택에 저장된다.

  • 하나의 함수를 호출하는 데 필요한 메모리 덩어리를 묶어서 스택 프레임(Stack Frame)이라고 부른다.
    • 아래 디버그 화면에서 함수 단위 하나하나가 스택 프레임이고, 이 전체는 Call Stack이라고 부른다.
  • 어떤 함수가 호출되면, CPU가 스택 포인터(스택의 가장 위를 가리키고 있는 것)을 위로 옮겨서 스택에 메모리를 할당한다. (몇 바이트의 공간이 필요한지와 관계 없이)
  • 함수가 종료되면 CPU가 스택 포인터를 아래로 옮겨서, 위의 것들(스택 프레임)을 메모리에서 해제시킨다.
    → LIFO(Last In First Out) 구조
  • 스택에 할당된 변수들은 실제로는 스택에서 보관하는 경우보다 CPU 레지스터에서 보관하는 경우가 더 많다
    → 이 변수들을 조작하기 위해 메모리 액세스를 요구하지 않기 때문에 더 빠르게 조작할 수 있다.
    → 이런 경우, 스택에 할당된 메모리를 해제할 때 레지스테이 보관된 변수를 별도로 clean up 하지 않아도 된다 (언젠가 이 변수들이 overwritten 될 것이기 때문에)

(구조상) 메모리의 가장 상단에 위치한다 (메모리 주소값이 높은 값 → 낮은 값 으로 써나감).

컴파일 타임에 크기를 알 수 있는 데이터에 주로 사용된다.

  • 많은 언어에서 Integer는 스택에 할당되고, String은 그렇지 않다
    • 어떤 숫자를 표현하기 위해 필요한 메모리는 항상 알 수 있지만(정해져있음), String은 길이가 수정될 수 있기 때문에 메모리가 얼마나 필요한지 알 수 없기 때문이다.
    • Swift에서 String을 어떻게 할당하는지는 밑에서 알아볼 예정

Swift에서 int, float, struct같이 값 타입으로 구현되어 있는 것들은 스택에 할당되고, class, closure 참조 타입은 힙에 할당된다.

  • 정확히는 참조 타입의 경우 참조 타입의 주소값은 스택 영역에, 참조 타입의 실체는 힙에 할당된다.
  • 값 타입도 무조건 스택 영역에 할당되는 것은 아니다(힙 영역에 할당되는 경우도 존재한다).

힙 vs 스택

데이터를 스택에 할당하는 것이 힙에 할당하는 것보다 (일반적으로) 훨씬 효율적이다.

  • 힙에서 빈 공간을 찾는 데 시간이 걸리고
  • 그 빈 공간에 데이터를 할당할 때 얼마만큼의 공간을 쓸 것인지이 메모리가 사용되고 있다는 표시를 남기기 위해 저장하려는 데이터 외에 추가로 자리를 차지하기 때문이다.
  • 스택은 CPU가 스택 포인터를 위로 움직이라고 지시한 후 그 메모리에 할당하기만 하면 되니까 상대적으로 작업 속도가 빠르다.

Swift는 클래스, 클로저 뿐 아니라 프로그램이 실행될 수 있는 동안 크기가 커질 수 있는 데이터를 힙에 저장한다

  • 예: 크기가 정해지지 않은 배열, 문자열 등
    → 분명히!! 값 타입(구조체, integer, 배열, 문자열 등)은 스택에 저장한다고 했는데 이게 무슨말일까?

힙 영역과 스택 영역에 메모리가 할당되는 예시

struct Cat {
    let name: String
    var age: Int
}

class Today {
    let weather: String = "맑음"
    let condition: String = "졸림"

    init() {
        petCondition()
    }

    func petCondition() {
        let petCondition: String = "귀여움"
    }
}

class Summer {
    let name: String = "써머"
    let pet: Cat = Cat(name: "또킨", age: 2)
    let today: Today = Today()

    init() {
        copy()
    }

    func copy() {
        let copyCat: Cat = Cat(name: "치킨", age: 0)
        let copyTtokin = pet
        let copyToday = today
    }
}

let summer: Summer = Summer()

위의 코드를 실행하면,

  1. 생성된 summer 인스턴스는 상수이기 때문에 스택 영역에 할당된다.
  2. Summer는 참조 타입이기 때문에, 힙 영역에 Summer를 할당하기 위한 공간을 계산한다.
    • 위 예시에서 총 필요한 영역은 우선 4개이다.
      name 1개, pet 2개(pet.name, pet.age), today 1개
    • 할당하기 위한 공간(영역)을 먼저 잡은 후, 한 줄씩 실행되면서 값이 할당된다.
      name = "써머", pet.name = "또킨", pet.age = 2, today = Today()

  3. todayToday() 클래스를 할당해주기 위해, 힙 영역에 Today 클래스를 위한 영역을 할당해준다.
    • today.weather = "맑음", today.condition = "졸림"

  4. 힙 영역에 Today가 할당되고 나면, SummertodayToday()가 생성되었기 때문에 Today()init() 메서드를 호출하고, init() 메서드 안의 petCondition() 메서드가 호출된다.
    • Today.init()Today.petCondition()은 스택 영역에 할당된다(함수의 호출이기 때문에).
    • petCondition()안의 petCondition = "귀여움"도 스택 영역에 할당된다(메서드의 지역변수니까).
    • 여기까지 실행되고 나면, Today.petCondition()Today.init()의 호출이 종료되면서
      petCondition = "귀여움" -> Today.petCondition() -> Test.init()의 순서로 스택 영역에서 pop 되고, Today 클래스의 호출이 종료된다.
  1. Today 클래스의 호출이 종료되면, 힙 영역에 있는 summertoday 변수에 할당된 Today 클래스의 주소가 저장되고, today는 힙 영역의 Today 영역을 바라보게 된다 (retain).
  2. 이제 summer의 함수(init(), copy())를 호출한다.
    • init()이 처음으로 호출되고, init() 안에서 copy()가 호출되면서 copy()의 지역 변수 copyCatcopyTtokin, copyToday가 차지할 영역이 스택에 할당된 후, 한 줄씩 실행되면서 초기값이 세팅된다.
    • 이 때 copyCat, copyTtokin, copyToday는 서로 메모리에 할당되는 과정이 다르다.
      • copyCat의 경우 name = "치킨", age = 0을 하나씩 새로 할당해주고
      • copyTtokin의 경우 pet을 복사해와서 할당해주고
      • copyToday는 클래스이기 때문에, 힙 영역에 있는 Today를 바라보게 된다.
  1. 여기까지 모든 함수의 실행이 끝났으므로.. copy() 메서드와 copy() 메서드의 지역변수들을 pop한다.
    • 이 때, copyToday가 pop하면서 Today의 retain count 가 -1된다.
  2. 그리고 summer.init()이 종료되어 pop된다.
  3. 그리고 Summer 클래스에 대한 호출이 종료되면서, summer 변수에 할당되어 있는 Summer의 주소가 저장된다.
  4. 메인 함수의 호출이 종료되면 summermain()이 pop되면서, summerSummer를 바라보던 게 끊어지고(release), Summer의 retain count는 0이 되어 ARC에 의해 메모리에서 해제된다.
![](https://i.imgur.com/G5NcxTi.png)
  1. 그리고 Summer가 힙 영역에서 해제되면서,todayToday를 더이상 바라보지 않기 때문에 Today의 retain count도 0이 되어 ARC에 의해 해제된다.
  2. 모든 게 종료되면, 메모리에 아무것도 존재하지 않는 상태가 된다.

Swift는 Array, Dictionary, String 등을 어떻게 저장하는가?

Array, Dictionary, String 등은 그 길이를 컴파일 타임에 미리 알 수 없는데(길이가 변경될 수 있으니까) Swift에는 구조체로 구현되어 있다. 그런데 스택에 할당하기 위해서는 컴파일 타임에 길이를 알 수 있어야 한다고 했다.
하지만 아래의 예시는 오류가 발생하지 않는다. 왜일까?

struct Node {
    var value = Int
    var arr: [Node] // 오류 발생하지 않음
}

한줄요약: Swift는 Copy On Write를 사용하기 때문이다.

Swift에서 Array, Dictionary, String 등은 인스턴스가 변경되었을(mutated) 때, 변경된 실제 카피는 힙 메모리에만 만들어진다.

  • 값 타입으로서 스택 영역 안에 존재하는 배열이 담고 있는 것은, 사실 저 힙 메모리의 주소값이다.
  • 실제로 String이나 Array가 차지하고 있는 메모리의 크기를 확인해보면, String의 길이를 늘리거나 Array의 원소를 넣고 빼도 스택 영역에서 차지하고 있는 메모리 크기는 동일하다.
    (https://sujinnaljin.medium.com/ios-swift의-type과-메모리-저장-공간-25555c69ccff)

그리고 값 타입의 인스턴스를 복사해서 사용할 때, 복사본(copy)의 값이 변경되기 전까지는 실제 데이터의 복사본을 안 만든다. 복사본에서는 원본 값을 가리키는 주소값을 가지고 있다가 복사본의 데이터를 변경해주면 (복사한 배열에 원소를 추가한다던가), 그 때 데이터의 복사본을 만들어서 걔를 변경하고, 그 새로운 복사본을 가리키는 주소값을 새로 가리키게 된다.

 

그리고 Swift의 배열이 재미있는점.. 현재 할당되어 있는 크기가 꽉 찼는데 새로운 원소를 append 하려고 하면, 배열의 크기를 현재의 2배로 늘린다(multiple).
(https://github.com/apple/swift/blob/main/stdlib/public/core/Array.swift)

출처

https://medium.com/@leandromperez/bidirectional-associations-using-value-types-in-swift-548840734047
https://medium.com/@itchyankles/memory-management-in-rust-and-swift-8ecda3cdf5b7#.vxuxwzr2l
https://www.mikeash.com/pyblog/friday-qa-2015-04-17-lets-build-swiftarray.html
https://sujinnaljin.medium.com/ios-swift의-type과-메모리-저장-공간-25555c69ccff
https://www.freecodecamp.org/news/the-story-of-one-mother-two-sons-value-type-vs-reference-type-in-swift-6e125af2d5d0/
https://github.com/apple/swift/blob/main/stdlib/public/core/Array.swift

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

URLComponents, URLQueryItem  (0) 2022.11.17
댓글
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2024/05   »
1 2 3 4
5 6 7 8 9 10 11
12 13 14 15 16 17 18
19 20 21 22 23 24 25
26 27 28 29 30 31
글 보관함