← 학습일지로 돌아가기

메모리 구조 · 스택 · PWT 심화 Q&A

다이아몬드 문제, Existential, PWT, 스택 프레임, 레지스터 완전 정복

Q1. 다이아몬드 문제 (Diamond Problem)

다중 상속에서 발생하는 모호성 문제.

       Animal
       /    \
    Dog      Cat
       \    /
        Pet

PetDogCat을 둘 다 상속하면, 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 AnimalDynamic (PWT)느림
some AnimalStatic (컴파일타임 확정)빠름
<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 (Text) │ ← 실행 코드 (Read-Only) │ ├─────────────────┤ │ │ Data │ ← 전역변수, Metadata, vtable, PWT │ ├─────────────────┤ │ │ Heap ↓ │ ← 동적 할당 (위로 성장) │ │ ↓ │ │ │ (빈 공간) │ │ │ ↑ │ │ │ Stack ↑ │ ← 함수 호출 (아래로 성장) ▼ └─────────────────┘ 높은 주소
영역주소성장 방향내용
Code낮음-기계어 코드
Data낮음-전역변수, Metadata, PWT
Heap중간↓ (높은 주소로)동적 할당 객체
Stack높음↑ (낮은 주소로)지역변수, 함수 호출
주의: Code가 가장 낮은 주소, Stack이 가장 높은 주소에서 시작.

Q8. 스택 프레임 동작 과정

func a0() {
    var a = 1
    a1()
    print(a)
}

func a1() {
    var x = 1
    var y = 2
    var z = 3
}

a0 → a1 호출 시 스택 상태

높은 주소 ┌═══════════════════════════════════════════════════════════┐ │ a0 프레임 │ ├───────────────┬───────────────────────────────────────────┤ │ 0x8010 │ (a0 호출한 함수의 Return Addr) │ ├───────────────┼───────────────────────────────────────────┤ │ 0x8008 │ (a0 호출한 함수의 Saved FP) │ ├───────────────┼───────────────────────────────────────────┤ │ 0x8000 │ a0 변수 a = 1 ← a0의 FP │ ╞═══════════════╪═══════════════════════════════════════════╡ │ a1 프레임 │ ├───────────────┼───────────────────────────────────────────┤ │ 0x7FE8 │ Return Addr ← a0 코드로 복귀 │ ├───────────────┼───────────────────────────────────────────┤ │ 0x7FE0 │ Saved FP = 0x8000 ← a0의 FP 저장 │ ├───────────────┼───────────────────────────────────────────┤ │ 0x7FD8 │ (기준점) ← a1의 FP ★ │ ├───────────────┼───────────────────────────────────────────┤ │ 0x7FD0 │ x = 1 [FP - 8] │ ├───────────────┼───────────────────────────────────────────┤ │ 0x7FC8 │ y = 2 [FP - 16] │ ├───────────────┼───────────────────────────────────────────┤ │ 0x7FC0 │ z = 3 [FP - 24] │ ├───────────────┼───────────────────────────────────────────┤ │ │ ← SP (스택 꼭대기) │ └───────────────┴───────────────────────────────────────────┘ 낮은 주소

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
└───────────────┘
FP를 중간에 두는 이유:
  • 위로(+): 복귀 정보 접근 (Return Addr, Saved FP)
  • 아래로(-): 지역변수 접근

Q9. PC, FP, SP 레지스터

레지스터이름저장하는 값가리키는 영역
PCProgram Counter현재 실행할 명령어 주소Code Segment
FPFrame Pointer현재 스택 프레임 기준점Stack
SPStack 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 가리킴)
Heap Data Segment Code Segment ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ Dog 인스턴스 │ │ Dog Metadata │ │ 0x3000: │ ├──────────────┤ ├──────────────┤ │ Dog.speak() │ │ isa ─────────────────→ │ vtable[0] ───────────→ │ 코드... │ ├──────────────┤ │ vtable[1] ───────────→ ├──────────────┤ │ name: "멍멍이" │ │ ... │ │ 0x4000: │ ├──────────────┤ └──────────────┘ │ Dog.eat() │ │ refCount: 1 │ └──────────────┘ └──────────────┘

Q11. Stack Overflow

힙을 침범하는 게 아니라, 스택 영역 한계를 초과하는 것.

┌─────────────────┐ │ Code │ ├─────────────────┤ │ Data │ ├─────────────────┤ │ Heap ↓ │ │ │ │ (빈 공간) │ │ │ │ ────────────── │ ← Stack Limit (보통 1~8MB) │ Stack ↑ │ └─────────────────┘ Stack이 Limit을 넘으면 → 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 접근해야 함
}
Value Buffer 없이는:

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 명령어가 자동으로:

  1. 인자 push (a, b)
  2. return addr push (main으로 돌아올 주소)
  3. 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() 진입 - 새 프레임 설정

함수 진입 시:

  1. 이전 FP(main의 FP) 스택에 저장
  2. 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으로 복귀

함수 종료 시:

  1. SP = FP (지역변수 버림)
  2. FP = saved FP 복원 (main의 FP)
  3. 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 addrsaved FP
main런타임으로 돌아갈 PC런타임의 FP
addmain으로 돌아갈 PCmain의 FP
한 문장 요약:

saved FP는 "어느 프레임", return addr는 "어느 코드"로 돌아갈지 기억하는 것.

2026-07-18 학습노트 — 스택/힙 · CPU 레지스터/캐시 · main()

Q. 스택 할당은 왜 사실상 공짜인가?

스택 할당은 스택 포인터(SP)를 이동시키는 명령 하나로 끝난다.

함수 진입:  sub sp, sp, #32   ; 32바이트 프레임 확보 — 명령 하나
함수 종료:  add sp, sp, #32   ; 되돌림 — 명령 하나

Q. 힙의 오버헤드는?

  1. 할당/해제: 빈 공간 탐색 + 멀티스레드 시 락 경합.
  2. ARC: class 인스턴스는 참조될 때마다 atomic retain/release(카운트 증감)가 런타임에 호출됨.
  3. 캐시 미스: 힙 객체는 메모리에 흩어져 있어 locality가 나쁨 → 캐시 미스 → 느린 메인메모리 접근.

Q. 스택 메모리 배치 (SP/FP, 자라는 방향)?

Apple 플랫폼(ARM64/x86-64)에서 스택은 아래로(낮은 주소로) 자란다.

높은 주소 ↑
  [ 현재 프레임 시작 ] ← FP(프레임 포인터)  = 높은 주소 쪽
  [ 지역변수 ...     ]
  [ 현재 프레임 끝   ] ← SP(스택 포인터)    = 낮은 주소 쪽(논리적 top)
낮은 주소 ↓

Q. 왜 작은 값은 struct가 좋고, 크면 문제인가?

Q. "스택이 캐시에 뜨겁다"의 의미?

스택은 같은 좁은 주소 영역을 밀고 되돌리며 반복 재사용한다 → 그 영역이 항상 캐시에 올라와 있어 캐시 미스가 거의 없다.

이전 함수 데이터를 다시 본다는 뜻이 아니라, 같은 물리 메모리를 재활용한다는 의미다.

Q. PC/SP는 레지스터인가? 코어마다 있나?

Q. 캐시도 코어마다 존재하나?

계층마다 다르다.

코어 전용 캐시가 있어서 캐시 일관성(cache coherence) 문제가 생기고, 이를 하드웨어가 조율 + 순서 보장을 위한 메모리 배리어가 필요 → atomic/락/actor가 결국 이 문제를 다룬다.

Q. 앱은 main()부터 시작하나?