메모리 구조 · 스택 · PWT 심화 Q&A
다이아몬드 문제, Existential, PWT, 스택 프레임, 레지스터 완전 정복
Q1. 다이아몬드 문제 (Diamond Problem)
다중 상속에서 발생하는 모호성 문제.
Animal
/ \
Dog Cat
\ /
Pet
Pet이 Dog와 Cat을 둘 다 상속하면, Animal의 메서드가 두 경로로 들어온다.
→ pet.speak() 호출 시 어느 쪽 Animal을 쓸지 모호함
Swift는 다중 상속 금지로 회피
// ❌ Swift에서 불가능 class Pet: Dog, Cat { } // 클래스 다중 상속 불가 // ✅ Protocol로 해결 protocol Runnable { func run() } protocol Swimmable { func swim() } class Pet: Animal, Runnable, Swimmable { func run() { } // 명시적 구현 func swim() { } // 명시적 구현 }
- 프로토콜은 구현이 아닌 요구사항만 정의
- 충돌 시 타입이 직접 구현해서 해결
- default implementation 충돌도 타입에서 명시적 구현으로 해결
Q2. Existential (any) 사용 시 항상 Dynamic Dispatch?
맞다. any (Existential Container) 사용 시 메서드 호출은 항상 Dynamic Dispatch.
protocol Animal {
func speak()
}
struct Dog: Animal {
func speak() { print("멍") }
}
let animal: any Animal = Dog()
animal.speak() // Dynamic Dispatch (PWT 조회)
Existential Container 구조
┌─────────────────────────────┐ │ Value Buffer (24 bytes) │ ← 값 또는 힙 포인터 ├─────────────────────────────┤ │ Type Metadata Pointer │ ← 실제 타입 정보 ├─────────────────────────────┤ │ PWT (Protocol Witness Table)│ ← 메서드 주소 테이블 └─────────────────────────────┘
| 방식 | Dispatch | 성능 |
|---|---|---|
any Animal | Dynamic (PWT) | 느림 |
some Animal | Static (컴파일타임 확정) | 빠름 |
<T: Animal> | Static (Specialization) | 빠름 |
any는 컴파일 타임에 구체 타입을 모르기 때문에 항상 PWT 경유.
Q3. "레지스터/스택에 위치"가 뭔 말?
레지스터와 스택 = CPU가 데이터를 저장하는 위치
속도: 레지스터 > 스택 > 힙
| 위치 | 설명 | 속도 |
|---|---|---|
| 레지스터 | CPU 내부 초고속 저장소 (수십 개) | 가장 빠름 |
| 스택 | 함수 호출 시 자동 할당/해제 | 빠름 |
| 힙 | 동적 할당 (malloc, ARC) | 느림 |
// ✅ 스택/레지스터 (빠름) let x: Int = 42 // 작은 값 → 레지스터 가능 let point = CGPoint(x: 0, y: 0) // 작은 struct → 스택 // ❌ 힙 (느림) let animal: any Animal = Dog() // Existential → 힙 가능성 class MyClass { } // 클래스 → 항상 힙
Q4. Struct가 크면 힙에 저장?
일반적으로 NO. Struct는 크기와 상관없이 스택에 저장된다.
struct Huge {
var a, b, c, d, e, f, g, h: Int // 64 bytes
}
let huge = Huge(...) // ✅ 여전히 스택에 저장
단, Existential Container 안에서는 다르다
protocol P { }
struct Small: P { var a: Int } // 8 bytes
struct Large: P { var a, b, c, d: Int } // 32 bytes
let s: any P = Small() // Value Buffer(24B)에 inline ✅
let l: any P = Large() // 24B 초과 → 힙 할당
| 상황 | 저장 위치 |
|---|---|
| 일반 struct 변수 | 항상 스택 (크기 무관) |
any Protocol 안 (≤24B) | 스택 (Value Buffer inline) |
any Protocol 안 (>24B) | 힙 |
| 클로저에 캡쳐된 struct | 힙 (Box) |
| 클래스 프로퍼티인 struct | 힙 (클래스가 힙이니까) |
Q5. PWT (Protocol Witness Table)
PWT = (타입, 프로토콜) 쌍마다 생성되는 메서드 주소 테이블
어떤 타입이 해당 프로토콜을 conform할 때, 그 타입이 구현한 메서드의 코드 주소를 가리키는 포인터 배열이다.
protocol Animal {
func speak()
func eat()
}
struct Dog: Animal {
func speak() { print("멍") } // 0x1000
func eat() { print("먹음") } // 0x2000
}
Dog의 Animal PWT
┌─────────────────────────────┐ │ speak() → 0x1000 │ ← Dog.speak() 코드 주소 ├─────────────────────────────┤ │ eat() → 0x2000 │ ← Dog.eat() 코드 주소 └─────────────────────────────┘
실제 코드는 Code Segment에
PWT (Data 영역) Code Segment (Text)
┌──────────────┐ ┌──────────────────────┐
│ speak → 0x1000 ──────────→ │ 0x1000: Dog.speak() │
├──────────────┤ │ push rbp │
│ eat → 0x2000 ────────────→ │ mov rsp, rbp │
└──────────────┘ │ print("멍") │
└──────────────────────┘
- 타입마다 conform하는 프로토콜별로 PWT 생성
Dog: Animal, Runnable→ PWT 2개- 컴파일 타임에 PWT 생성, 런타임에 조회
Q6. 프로토콜 변수도 PWT에 포함?
맞다. 변수는 getter/setter 함수 포인터로 PWT에 들어간다.
protocol Animal {
var name: String { get set }
func speak()
}
struct Dog: Animal {
var name: String
func speak() { }
}
Dog-Animal PWT: ┌─────────────────────────┐ │ name.getter → 0x1000 │ ├─────────────────────────┤ │ name.setter → 0x1100 │ ├─────────────────────────┤ │ speak() → 0x2000 │ └─────────────────────────┘
Q7. 메모리 구조 (Code/Data/Heap/Stack)
| 영역 | 주소 | 성장 방향 | 내용 |
|---|---|---|---|
| Code | 낮음 | - | 기계어 코드 |
| Data | 낮음 | - | 전역변수, Metadata, PWT |
| Heap | 중간 | ↓ (높은 주소로) | 동적 할당 객체 |
| Stack | 높음 | ↑ (낮은 주소로) | 지역변수, 함수 호출 |
Q8. 스택 프레임 동작 과정
func a0() {
var a = 1
a1()
print(a)
}
func a1() {
var x = 1
var y = 2
var z = 3
}
a0 → a1 호출 시 스택 상태
Return Address vs Saved Frame Pointer
| 항목 | 저장 내용 | 가리키는 곳 | 용도 |
|---|---|---|---|
| Return Address | 돌아갈 코드 주소 | Code Segment | 코드 실행 위치 복원 |
| Saved FP | 이전 프레임 주소 | Stack | 스택 데이터 위치 복원 |
FP 위치 — 프레임 "맨 위"가 아님
a1 프레임 구조: ┌───────────────┐ │ Return Addr │ ← FP + 16 (위쪽) ├───────────────┤ │ Saved FP │ ← FP + 8 ├───────────────┤ │ (기준점) │ ← FP ★ 여기를 가리킴 ├───────────────┤ │ 변수 x │ ← FP - 8 ├───────────────┤ │ 변수 y │ ← FP - 16 └───────────────┘
- 위로(+): 복귀 정보 접근 (Return Addr, Saved FP)
- 아래로(-): 지역변수 접근
Q9. PC, FP, SP 레지스터
| 레지스터 | 이름 | 저장하는 값 | 가리키는 영역 |
|---|---|---|---|
| PC | Program Counter | 현재 실행할 명령어 주소 | Code Segment |
| FP | Frame Pointer | 현재 스택 프레임 기준점 | Stack |
| SP | Stack Pointer | 스택의 꼭대기 주소 | Stack |
FP vs SP
| 레지스터 | 역할 | 변화 |
|---|---|---|
| FP | 프레임 기준점 (변수 접근용) | 함수 내에서 고정 |
| SP | 스택 꼭대기 (다음 할당 위치) | 변수 추가/제거 시 계속 이동 |
변수 1개일 때: 변수 3개일 때:
├─────────────┤ ├─────────────┤
│ Saved FP │ │ Saved FP │
├─────────────┤ ├─────────────┤
↑ FP, SP 같음 │ (기준점) │ ← FP (고정)
├─────────────┤
│ x = 1 │
├─────────────┤
│ y = 2 │
├─────────────┤ ← SP (내려감)
Q10. isa 포인터
isa = "is a" (이 인스턴스는 ~타입이다)
let dog = Dog()
// dog의 isa → "이건 Dog 타입이다" (Dog Metadata 가리킴)
- isa → Data Segment의 Type Metadata
- vtable → Code Segment의 실제 코드
Q11. Stack Overflow
힙을 침범하는 게 아니라, 스택 영역 한계를 초과하는 것.
무한 재귀 = Stack Overflow 대표 원인
func infinite() {
infinite() // 계속 호출
}
호출 1: [Return Addr][SFP][locals]
호출 2: [Return Addr][SFP][locals]
호출 3: [Return Addr][SFP][locals]
...
호출 10000: Stack Limit 초과 → 💥 Stack Overflow
| 상황 | 설명 |
|---|---|
| Stack Overflow | 스택이 자신의 한계를 초과 (OS가 감지) |
| Heap 침범? | 현대 OS는 Guard Page로 보호, 직접 침범 거의 불가 |
- OS가 스택 영역에 고정 크기 할당 (보통 1MB~8MB)
- 초과 시 SIGSEGV (Segmentation Fault) 발생
- 힙과 스택 사이에 Guard Page (접근 불가 영역)가 있어서 서로 침범 방지
Q12. Existential 메서드 호출 시 Value Buffer를 조회하는 이유?
메서드 호출 시 self를 전달해야 하기 때문.
protocol Animal {
func speak()
}
struct Dog: Animal {
var name: String
func speak() { print("\(name): 멍!") } // ← self.name 접근
}
let animal: any Animal = Dog(name: "바둑이")
animal.speak()
Existential Container 구조
┌─────────────────────────────┐ │ Value Buffer (24 bytes) │ ← 실제 Dog 데이터 (또는 힙 포인터) │ name: "바둑이" │ ├─────────────────────────────┤ │ Type Metadata Pointer │ ├─────────────────────────────┤ │ PWT Pointer │ ← speak() 주소 찾기용 └─────────────────────────────┘
호출 과정
1. Value Buffer 조회 → self (Dog 인스턴스) 위치 확보
2. PWT 포인터 로드
3. PWT[speak 슬롯] → 함수 주소 (0x1000)
4. CALL 0x1000(self) ← self를 인자로 전달!
왜 self가 필요한가
// Dog.speak()의 실제 구현 func speak(_ self: Dog) { print("\(self.name): 멍!") // self.name 접근해야 함 }
PWT에서 speak() 주소를 찾아도, 실제 Dog 데이터 (name = "바둑이")가 어디 있는지 모름 → self.name 접근 불가
| 단계 | 목적 | 답하는 질문 |
|---|---|---|
| Value Buffer 조회 | self 데이터 위치 | 무엇을 (인스턴스) |
| PWT 조회 | 메서드 코드 위치 | 어떻게 (함수 주소) |
PC · FP · SP 레지스터와 스택 프레임 (2026-07-06)
세 레지스터 역할
| 레지스터 | 가리키는 곳 | 역할 | 언제 바뀜 |
|---|---|---|---|
| PC (Program Counter) | Code Segment | 현재 실행할 명령어 주소 | 매 명령어마다 |
| FP (Frame Pointer) | Stack | 현재 함수 프레임의 기준점 | 함수 진입/복귀 시만 |
| SP (Stack Pointer) | Stack | 스택 최상단 (가장 낮은 주소) | push/pop마다 |
스택 프레임 구조
함수가 호출되면 새 프레임이 생기고, 두 가지 복귀 정보가 저장된다:
┌─────────────────────────────────────┐ │ 새 프레임 (add 함수) │ ├─────────────────────────────────────┤ │ return addr → 호출자의 다음 PC │ ← 어느 코드로 돌아갈지 │ saved FP → 호출자의 FP │ ← 어느 프레임으로 돌아갈지 ├─────────────────────────────────────┤ │ 지역변수들 │ ← SP가 가리킴 └─────────────────────────────────────┘
| 저장 값 | 용도 | 누가 저장 |
|---|---|---|
| return addr | 함수 끝나면 어느 코드로 돌아갈지 (PC 복원) | call 명령어가 자동 push |
| saved FP | 함수 끝나면 어느 프레임으로 돌아갈지 (FP 복원) | 함수 진입 시 직접 push |
함수 호출 단계별 상세
// 예시 코드
func add(a: Int, b: Int) -> Int {
let result = a + b
return result
}
func main() {
let x = 10
let y = 20
let sum = add(a: x, b: y)
}
① main() 진입 - 프레임 설정
Stack
┌────────────────────────────┐
│ return addr (런타임으로) │ ← main을 호출한 곳으로 돌아갈 주소
│ saved FP (런타임의 FP) │ ← 런타임 프레임 기억
├────────────────────────────┤ ← FP (main 프레임 기준점)
│ │ ← SP
└────────────────────────────┘
PC: 0x1000 (let x = 10)
② let x = 10, let y = 20 실행 후
Stack
┌────────────────────────────┐
│ return addr (런타임으로) │
│ saved FP (런타임의 FP) │
├────────────────────────────┤ ← FP (고정!)
│ x = 10 │
│ y = 20 │ ← SP (push로 내려감)
└────────────────────────────┘
PC: 0x1008 (call add)
③ add() 호출 - call 명령어 실행
call 명령어가 자동으로:
- 인자 push (a, b)
- return addr push (main으로 돌아올 주소)
- PC를 add 함수 주소로 점프
Stack
┌────────────────────────────┐
│ return addr (런타임으로) │
│ saved FP (런타임의 FP) │
├───── main 프레임 ──────────┤ ← FP (아직 main의 FP)
│ x = 10 │
│ y = 20 │
│ a = 10 (인자) │
│ b = 20 (인자) │
│ return addr (0x100C) │ ← call이 push
└────────────────────────────┘ ← SP
PC: 0x2000 (add 함수 시작)
④ add() 진입 - 새 프레임 설정
함수 진입 시:
- 이전 FP(main의 FP) 스택에 저장
- FP = 현재 SP (새 기준점)
Stack ┌────────────────────────────┐ │ return addr (런타임으로) │ │ saved FP (런타임의 FP) │ ├───── main 프레임 ──────────┤ │ x = 10 │ │ y = 20 │ │ a = 10 │ │ b = 20 │ │ return addr (0x100C) │ │ saved FP (main의 FP) │ ← main FP 백업 ├───── add 프레임 ───────────┤ ← FP (새 기준점) │ result = 30 │ ← SP └────────────────────────────┘ PC: 0x2004 (return result)
⑤ return result - add 종료, main으로 복귀
함수 종료 시:
- SP = FP (지역변수 버림)
- FP = saved FP 복원 (main의 FP)
- PC = return addr (main의 다음 명령어)
Stack
┌────────────────────────────┐
│ return addr (런타임으로) │
│ saved FP (런타임의 FP) │
├───── main 프레임 ──────────┤ ← FP (복원됨!)
│ x = 10 │
│ y = 20 │
│ (반환값 30) │ ← SP
└────────────────────────────┘
PC: 0x100C (main의 다음 명령어)
FP 체인 - 함수 호출 스택 추적
┌─────────────────────────────────────────┐ │ FP가 linked list처럼 연결됨 │ │ │ │ add의 FP → main의 FP → 런타임의 FP │ │ (saved) (saved) │ └─────────────────────────────────────────┘
이 체인 덕분에 함수가 아무리 깊이 중첩되어도 순서대로 복귀 가능. 디버거의 Call Stack도 이 체인을 따라간다.
요약
| 프레임 | return addr | saved FP |
|---|---|---|
| main | 런타임으로 돌아갈 PC | 런타임의 FP |
| add | main으로 돌아갈 PC | main의 FP |
saved FP는 "어느 프레임", return addr는 "어느 코드"로 돌아갈지 기억하는 것.
2026-07-18 학습노트 — 스택/힙 · CPU 레지스터/캐시 · main()
Q. 스택 할당은 왜 사실상 공짜인가?
스택 할당은 스택 포인터(SP)를 이동시키는 명령 하나로 끝난다.
함수 진입: sub sp, sp, #32 ; 32바이트 프레임 확보 — 명령 하나 함수 종료: add sp, sp, #32 ; 되돌림 — 명령 하나
- 빈 공간 탐색 없음, 락 없음, 해제 관리 없음.
- 방금 쓴 스택 영역은 캐시에 뜨겁다(hot).
Q. 힙의 오버헤드는?
- 할당/해제: 빈 공간 탐색 + 멀티스레드 시 락 경합.
- ARC:
class인스턴스는 참조될 때마다 atomic retain/release(카운트 증감)가 런타임에 호출됨. - 캐시 미스: 힙 객체는 메모리에 흩어져 있어 locality가 나쁨 → 캐시 미스 → 느린 메인메모리 접근.
Q. 스택 메모리 배치 (SP/FP, 자라는 방향)?
Apple 플랫폼(ARM64/x86-64)에서 스택은 아래로(낮은 주소로) 자란다.
높은 주소 ↑ [ 현재 프레임 시작 ] ← FP(프레임 포인터) = 높은 주소 쪽 [ 지역변수 ... ] [ 현재 프레임 끝 ] ← SP(스택 포인터) = 낮은 주소 쪽(논리적 top) 낮은 주소 ↓
- SP는 "가장 최근에 쌓인 = 가장 낮은 주소"를 가리킨다. 높은 주소는 FP가 가리킴.
- 프레임은 함수 진입 시 전체 크기를 한 번에 확보 → 본문에선 offset 접근, SP 고정(예외:
alloca, 가변인자, push/pop).
Q. 왜 작은 값은 struct가 좋고, 크면 문제인가?
- struct는 대입/전달마다 값 전체를 복사(memcpy).
- 작은 struct(예:
CGPoint): 복사 저렴 + 종종 레지스터에 통째로 실림 → 힙·ARC 없이 빠름. - 큰 struct: 매 복사마다 전체 바이트 memcpy → 비쌈, 스택도 많이 차지 → class(참조 8바이트만 복사)가 유리할 수 있음.
- COW로 완화 가능(표준 컬렉션은 자동, 직접 만든 struct는 수동 구현 필요).
Q. "스택이 캐시에 뜨겁다"의 의미?
스택은 같은 좁은 주소 영역을 밀고 되돌리며 반복 재사용한다 → 그 영역이 항상 캐시에 올라와 있어 캐시 미스가 거의 없다.
Q. PC/SP는 레지스터인가? 코어마다 있나?
- PC(Program Counter, x86=IP/RIP): 다음 실행할 명령어 주소. 분기/호출이 PC를 바꾼다.
- SP(Stack Pointer): 현재 스택 top.
- 레지스터는 코어마다 독립(PC, SP, 범용 레지스터). 여러 코어가 진짜 병렬 실행.
- 스택 포인터 값도 스레드마다 유지(컨텍스트 스위칭 시 저장/복원).
Q. 캐시도 코어마다 존재하나?
계층마다 다르다.
- L1: 코어 전용(private per-core), 가장 빠르고 작음(보통 명령어 L1i / 데이터 L1d 분리).
- L2: 설계마다 다름(코어 전용이거나 클러스터 공유).
- L3 / SLC: 여러 코어가 공유.
코어 전용 캐시가 있어서 캐시 일관성(cache coherence) 문제가 생기고, 이를 하드웨어가 조율 + 순서 보장을 위한 메모리 배리어가 필요 → atomic/락/actor가 결국 이 문제를 다룬다.
Q. 앱은 main()부터 시작하나?
- 그렇다.
@main(구@UIApplicationMain)이 컴파일러가main()을 자동 생성하게 하고, 그 안에서UIApplicationMain(...)을 호출한다. main()도 함수라 자기 스택 프레임을 가지며, 그 위로 프레임이 쌓인다.- 단, iOS 앱의
main()은UIApplicationMain호출 후 리턴하지 않고 RunLoop를 돌린다 → main 프레임은 앱이 사는 내내 스택 맨 밑에 남고, 그 위에서 이벤트마다 프레임이 쌓였다 걷힌다. - 정확히는 pre-main(dyld의 라이브러리 로드·링크·초기화)이 먼저 있고 그 다음
main()이 불린다.