Open longlivedrgn opened 3 months ago
저는 iOS 앱의 메모리 구조가 일반적인 운영체제와 크게 다르지 않은 것으로 알고 있습니다. 따라서 크게 코드, 데이터, 스택, 힙이라고 하는 영역으로 분리가 되는데요. 코드는 소스코드가 올라가고, 데이터는 전역변수가 올라가고, 스택은 지역변수 및 함수, 값타입 등이 올라갑니다. 특히 여기까지는 컴파일 시점에 사이즈를 어느 정도 가늠을 할 수 있어서, 실제로 계산된 크기를 가지고 실행이 됩니다. 힙은 런타임에 사이즈가 동적으로 늘어날 수 있는 영역이며, 그 특성상 특히 콜렉션 타입의 요소들이 할당되는 영역입니다. 외에도 클로저를 포함한 참조타입의 인스턴스나 실존 타입들이 할당되게 됩니다.
두 가지 관점에서 이야기를 풀어나가야할 것 같습니다. 앞서 참조타입, 실존타입 등이 힙 영역에 할당이 될 수 있다고 말씀드렸는데요. 이를 조금 어렵게 이야기를 해보면 다형성을 갖게 되는 인스턴스들은 모두 힙영역에 할당이 된다고 말씀드릴 수 있을 것 같습니다. 이는 하나의 인스턴스가 서로 다른 타입으로 해석이 될 수 있다는 말인데, 컴퓨터가 런타임에 실제로 어떤 인스턴스를 다른 타입으로 해석하기 위해서는, 컴퓨터는 멍청하기 때문에 관련된 데이터가 반드시 존재해야 합니다. 실제 메커니즘은 상이하나 둘다 테이블을 통해 이를 관리하게 되는데요. Swift에서 참조타입의 인스턴스들은 v-table을 통해서 해당 인스턴스의 실제타입을 확인할 수 있고, 실존타입의 인스턴스들은 실존타입 내 protocol witness table을 통해 인스턴스의 실제타입을 확인할 수 있습니다.
다른 하나의 관점은 생명주기와 관련된 것입니다. 비교를 위해 먼저 스택을 짚고 넘어가면, 스택에 올라가는 값타입의 인스턴스는 어떤 맥락에서 사용이 끝나면, 바로 버릴 수 있습니다. 다른 맥락에서 이를 사용하려고 하면 인스턴스가 복사가 되며, 새로운 인스턴스를 생성하기 때문입니다. 하지만 힙영역에 올라가는 참조타입의 인스턴스들은 그럴 수 없는데요. 어떤 맥락에서 사용이 끝나더라도 다른 곳에서 이를 사용하면, 똑같은 인스턴스를 가리키고 사용을 하기 때문에, 메모리에서 해제가 되면 크리티컬한 에러가 될 수 있기 때문입니다. 스위프트에서는 이를 ARC라고 하는 기법으로 풀어내었는데 컴파일 타임에 참조타입에 레퍼런스 카운트라는 프로퍼티를 삽입하고, 사용 전후에 해당 카운트를 올리고 내리면서 0이 될 때 해제가 될 수 있도록 만들어 놓은 것입니다. 실존타입의 인스턴스의 경우에는 또 value witness table이라고 하는 테이블을 내부에 두어 이를 통해 메모리에서 해제가 될 수 있도록 만들어져 있습니다.
간단하게 후입선출 형식으로 맥락이 끝나면 바로바로 메모리에서 해제가 되는 것으로 알고 있는데요. 이 때 재귀함수처럼 맥락이 굉장히 길어질 수 있는 경우는 stack overflow 에러를 조심해야할 필요가 있습니다. 실제로 이를 회피하기 위해서, 상용앱에서 재귀함수를 작성할 때에는 꼬리재귀의 형태로 작성하는 게 좋습니다
참조 타입은 힙 영역에 할당된다. Swift에서 참조 타입으로는 클래스, 클로저, 프로토콜 등이 있다. 하지만 struct 같은 경우에도 내부적으로 힙 영역에 할당될 수 있다. UIFont와 같은 class로 되어 있는 것은 인스턴스에 생성하는 시점에 참조된다. 값 타입인 경우에도 3 words가 넘어가면 실존 타입으로 값 전체가 힙 영역에 할당 후 복사된다. ARC를 통해 레퍼런스 카운트를 통해 사용에 따라 개수가 올라가고 내려간다. 카운트가 0이 될 때 해제 되도록 만들어져 있다.
함수 호출 시 지역 변수, 매개변수, 리턴 값이 저장되며, 함수가 종료되면 메모리가 해제된다. 함수 내 다른 함수를 호출했다면, 마지막 함수가 값을 반환 할 때까지 함수는 다른 함수가 종료될 때까지 메모리 해제를 기다리게 된다. 하지만 스택 영역에서는 한정된 크기로 재귀 형식으로 메모리가 쌓이게 되면 스택 오버 플로우가 발생한다. 그리고 함수가 종료되면 변수의 값들은 메모리가 해제된 상태로 접근할 수 없게 된다.
힙과 스택은 같은 메모리 공간을 공유한다.
힙은 낮은 메모리 주소부터 할당 받는다.
스택은 높은 메모리 주소부터 할당 받는다.
스택 오버 플로우: 스택에 너무 많은 메모리를 할당하게 되어, 스택 영역을 초과한 경우 발생
힙 오버 플로우
함수 호출시 스택 프레임 생성
❓스택 프레임
: 함수 실행에 필요한 정보를 저장하는 메모리 블록
대개 낮은 주소에서 높은 주소로 할당(적재)된다.
iOS에서는 ARC를 통해서 힙 영역에 객체가 할당이된다.
[자세한 ARC는 다음 레벨에서 정리할 예정]
강한 참조
iOS 앱의 힙/스탭/코드/데이터 영역은 일반적인 힙/스택/코드/데이터 영역과 크게 다르지 않다. 관련된 걸 굳이 꼽자면, iOS를 처음 공부하던 무렵 '기기의'가 아닌 '앱의' 힙/스택/코드/데이터 영역을 지칭한다는 걸 알고 의외라고 생각했던 기억이 있다. 물리적인 파일시스템 등을 차치하고 보면 앱 '활동'의 모든 명령과 처리는 네 가지 영역 위를 오가며 이루어지는 과정인 셈이다. 둘째, 셋째 질문은 곧 '앱의 컴파일과 런타임에서의 명령 처리 과정'을 나눠 설명하라는 말과 같다.
네 영역의 크기는 언제 결정되며 각자의 정보는 어떤 기준으로 나눠 가질까? 공부하다 보면 코드·데이터에 비해 힙·스택 영역을 중점적으로 공부하게 된다. 전자는 컴파일 타임에, 후자는 런타임에 구성되는 편이라 그런 듯하다.
코드 영역은 개발자가 작성한 '코드'를 흔히 기계어라고 부르는 형태의 '코드'로 컴파일러에 의해 컴파일타임에 write되며 런타임에 read되기만 하는 곳이다. 어쨌든 크기도 내용도 빌드와 함께 끝나는 읽기 전용 공간인데 직접 읽을 일도 없으므로 사실상 개발자가 어떻게 할 구석이 없다. 데이터 영역은 그래도 타입에 대한 정보나 전역변수가 저장되기에 조금 관심을 가질 만하다.
힙과 스택은 자료구조로서의 포괄적인 의미가 있어 개념적으로는 쉽지만 실전에서 고려할 일이 많은 영역이다. 스택 영역은 자료구조로서의 스택과 마찬가지로 '가벼운' 이미지다. 자료구조 스택처럼, 코드 영역의 명령이 순차적으로 실행되면서 필요한 정보들, 즉 메서드의 지역변수와 같은 데이터가 쌓였다가 쓰임이 다하면 휘발된다. 쌓이고 휘발되는 기준은 쉽게 말해 함수 단위, 어렵게 말해 스택 포인터 단위라고 할 수 있다. 힙 영역은 이전 질문의 ARC와 엮어서 할 말이 많은 파트다. 컴파일 타임에 크기를 알 수 없는 값들, 참조 타입들 등 쌓이면 무거워질 수밖에 없는 정보들인지라 영역의 크기도 런타임에 결정되며 이 '참조'를 적절히 쌓고 해제해주지 않으면 앱의 성능에 부정적인 영향을 미친다. 예를 들어, 어딘가에서 한 클래스를 런타임에 초기화하여 가지고 있다면 이 클래스의 데이터는 힙 영역의 빈 곳 어딘가에 할당된다. 이제 이 데이터의 주소가 여러 메시지에 의해 주고받아 지는 동안 '주소를 들고 있는 곳의 수'가 카운팅(개발자가 설계, ARC에 의해 관리) 되다가 0이 되는 순간 메모리에서 해제될 수 있게 되는데, 이를 잘못 예측·계산하여 상호참조 하는 등 불필요해졌는데도 어딘가에서 계속 참조하고 있거나 결코 참조를 멈추지 못하게 된다면 메모리 누수가 발생하는 것이다.