Skip to content

6.1 런타임 시스템 — 함수 호출은 어떻게 실행되는가

스택 트레이스는 마법으로 나타나는 것이 아니라, 호출 규약이라는 ABI 계약에 따라 스택에 쌓인 프레임 체인을 걸어간 기록이다. 이 문서는 프로세스 주소 공간과 스택 프레임이라는 좌표계를 세워 크래시 덤프·스택 오버플로·끊긴 async 트레이스를 표면 증상이 아니라 구조로 설명한다. 여기서 세운 모델은 6.2(스택과 힙의 경계)와 6.3(root set 스캔)이 그대로 재사용한다.

학습 목표

  • 프로세스 주소 공간의 배치(코드·데이터·힙·스택)를 좌표계로 삼아 크래시 주소와 스택 트레이스를 해석한다.
  • 호출 규약을 "인자·반환값·레지스터 복원 책임을 나누는 ABI 계약"으로 설명하고, 계약이 어긋나는 지점(FFI)의 오류를 진단한다.
  • 스택 프레임 구조를 근거로 스택 트레이스의 생성 원리와 한계(인라인화, async 경계, 프레임 포인터 생략)를 구분한다.
  • 정적·동적 링킹에서 심볼이 언제 누구에 의해 해석되는지 설명하고, 라이브러리 버전 충돌의 정체를 링킹 모델로 설명한다.
  • zero-cost 예외 처리의 비용이 어디에 있는지 근거로 "예외는 느리다"는 주장을 조건부 명제로 교정한다.

배경: 왜 이것이 존재하는가

컴파일러(챕터 5)가 함수 하나를 기계어로 번역하는 것과, 수천 개의 함수가 서로를 호출하며 하나의 프로세스로 실행되는 것 사이에는 채워야 할 간극이 있다. 서로 다른 컴파일 단위가(때로는 서로 다른 언어와 컴파일러가) 만든 함수들이 인자를 주고받으려면 합의된 규약이 필요하고, 호출이 반환할 위치와 지역 변수의 수명을 관리하려면 스택이라는 자료구조가 필요하며, 별도로 컴파일된 라이브러리의 함수 이름이 실제 주소로 바뀌려면 링커와 로더가 필요하다.

이 합의의 총체가 ABI(application binary interface)다. API가 소스 코드 수준의 계약이라면 ABI는 기계어 수준의 계약이고, 우리가 매일 읽는 스택 트레이스·크래시 덤프·프로파일은 전부 이 계약이 실행된 흔적이다. 흔적을 읽는 도구는 누구나 쓰지만, 계약 자체를 모르면 도구가 침묵하거나 거짓말하는 순간 — 프레임이 사라진 트레이스, async 경계에서 끊긴 스택, FFI에서의 이유 없는 크래시 — 에 추측밖에 할 수 없다.

핵심 개념

이 문서의 관찰 예제는 다음 환경에서 실행해 결과를 기재했다: Apple M5 Pro(arm64), macOS 26.5.2, Node.js 24.14.0(V8 13.6), Apple clang 21. 절대 수치는 환경에 따라 다르지만 구조는 이식된다.

프로세스 주소 공간 — 크래시 리포트의 좌표계

프로세스는 자신만의 가상 주소 공간을 갖고, 그 안에 성격이 다른 영역들이 배치된다.

text
높은 주소  ┌────────────────┐
          │ 스택            │ 함수 호출마다 자라고 반환마다 줄어든다 (아래로 성장)
          │   ↓            │
          │  (미사용)       │
          │   ↑            │
          │ 힙             │ malloc/new가 관리한다 (위로 성장) — 6.2
          ├────────────────┤
          │ 데이터          │ 전역 변수, 정적 변수
          ├────────────────┤
          │ 코드(텍스트)     │ 기계어 — 읽기 전용·실행 가능
낮은 주소  └────────────────┘

배치는 개념도다 — 실제 주소는 ASLR(address space layout randomization)로 실행마다 달라지고, 공유 라이브러리·스레드 스택·mmap 영역이 사이사이에 들어온다. 이 지도의 용도는 크래시 주소의 분류다. 세그멘테이션 폴트가 코드 영역 근처의 주소에서 났다면 함수 포인터 오염을, 스택 꼭대기 근처라면 스택 오버플로를, 낮은 주소(null 근처)라면 null 포인터 역참조를 먼저 의심한다. 이 지도를 만들고 유지하는 가상 메모리 메커니즘 — 페이지 테이블, demand paging, 페이지 폴트 — 은 8.2가 다루고, 이 챕터는 지도 자체를 좌표계로만 쓴다.

호출 규약 — 호출은 ABI 계약이다

f(a, b)라는 호출이 기계어가 되려면 컴파일러는 다음 질문에 답해야 한다. 인자를 어디에 넣는가? 반환값은 어디서 받는가? 호출 전후로 값이 보존되어야 하는 레지스터는 누가 지키는가? 이 답의 집합이 호출 규약(calling convention)이고, 같은 플랫폼의 모든 컴파일러가 같은 답을 쓰기로 한 약속이 ABI의 일부다.

x86-64 System V ABI(Linux·macOS의 x86-64 표준)를 대표 사례로 보면, 계약의 구조가 드러난다.

  • 인자 전달: 정수·포인터 인자 처음 6개는 레지스터로, 나머지는 스택으로 전달한다. 레지스터 전달이 기본인 이유는 메모리 왕복을 줄이기 위해서다 — "인자는 스택에 쌓인다"는 32비트 시절의 모델은 이제 예외 경로다.
  • 반환값: 지정된 레지스터로 돌려준다. 레지스터에 안 들어가는 큰 구조체는 호출자가 마련한 메모리에 쓴다(호출자가 숨은 포인터 인자를 전달한다).
  • 복원 책임의 분담: 일부 레지스터는 caller-saved — 호출자가 "호출 후에도 필요하면 내가 미리 저장한다". 나머지는 callee-saved — 피호출자가 "쓰려면 내가 저장했다가 복원한다". 이 분담은 저장 비용을 실제로 그 레지스터를 쓰는 쪽에만 지우는 최적화다.

이 문서의 관찰 머신(arm64)은 AAPCS64라는 다른 규약을 쓴다 — 인자 레지스터 8개, 반환 주소를 스택이 아니라 링크 레지스터(lr)로 전달받는 점 등이 다르다. 그러나 "인자·반환값·복원 책임을 계약으로 정한다"는 구조는 같고, 우리가 쓸 관찰도 그 구조를 본다. 레지스터·명령어 수준의 ISA 논의는 챕터 7이 맡는다.

계약이라는 관점이 실무에서 중요한 이유는 양쪽이 다른 계약을 가정하면 조용히 어긋나기 때문이다. FFI(foreign function interface)에서 함수 시그니처를 실제와 다르게 선언하면 — 인자 개수가 다르거나, 32비트/64비트 폭이 다르거나, 구조체를 값으로 넘기는 방식이 다르면 — 피호출자는 엉뚱한 레지스터·스택 위치를 읽는다. 컴파일은 성공하고, 크래시는 한참 뒤 무관해 보이는 곳에서 난다.

스택 프레임 — 트레이스는 프레임 체인을 걷는 기록이다

함수가 호출되면 스택에 그 호출의 작업 공간인 스택 프레임(stack frame)이 만들어진다. 전형적인 프레임에는 세 가지가 있다.

  1. 반환 주소(return address) — 이 함수가 끝나면 돌아갈 호출자 코드의 주소. x86-64에서는 call 명령이 스택에 넣고, arm64에서는 lr 레지스터로 받아 피호출자가 (다른 호출을 하려면) 프레임에 저장한다.
  2. 저장된 프레임 포인터(frame pointer) — 호출자 프레임의 기준 주소. 각 프레임이 직전 프레임의 위치를 담으므로, 프레임 포인터를 따라가면 스택을 거슬러 올라가는 연결 리스트가 된다.
  3. 지역 변수와 저장된 callee-saved 레지스터 — 이 호출의 작업 공간.

스택 트레이스는 이 연결 리스트를 걷는 것이다: 현재 프레임 포인터에서 저장된 반환 주소를 읽고 → 그 주소를 심볼 테이블로 함수 이름·행 번호로 번역하고 → 저장된 프레임 포인터로 이동해 반복한다. 디버거의 bt, 프로파일러의 콜 스택 샘플, 크래시 리포트, 예외의 stack 속성 — 전부 같은 걸음이다.

관찰 1 — 디버거로 프레임 체인을 걷는다

최소 C 예제로 이 구조를 직접 본다. arm64 macOS + Apple clang 21 기준이며, 주소·레지스터 이름은 플랫폼에 따라 다르다.

c
// frames.c — 스택 프레임 구조를 디버거로 관찰하기 위한 최소 예제
#include <stdio.h>

static long leaf(long a, long b) {
    long local = a * b;
    return local + 1;
}

static long middle(long x) {
    long doubled = x * 2;
    return leaf(doubled, x + 1);
}

int main(void) {
    printf("%ld\n", middle(21));
    return 0;
}
sh
clang -g -O0 frames.c -o frames
lldb -b -o 'breakpoint set -n leaf' -o run -o bt -o 'register read fp sp lr' frames

이 환경의 실제 출력에서 발췌한 것이다.

text
(lldb) bt
  * frame #0: 0x00000001000004e8 frames`leaf(a=42, b=22) at frames.c:5:18
    frame #1: 0x00000001000004d0 frames`middle(x=21) at frames.c:11:12
    frame #2: 0x0000000100000480 frames`main at frames.c:15:21
    frame #3: 0x00000001840b7e00 dyld`start + 6992
(lldb) register read fp sp lr
      fp = 0x000000016fdfe2c0
      sp = 0x000000016fdfe290
      lr = 0x00000001000004d0  frames`middle + 44 at frames.c:11:5

읽는 법: lr(반환 주소)이 정확히 frame #1의 주소 — leaf가 끝나면 돌아갈 middle 안의 지점이다. 이어서 fp가 가리키는 메모리를 읽으면:

text
(lldb) memory read -f x -s 8 -c 2 $fp
0x16fdfe2c0: 0x000000016fdfe2e0 0x0000000100000480

두 워드가 나란히 있다 — 저장된 프레임 포인터(0x…e2e0, middle 프레임의 기준)와 저장된 반환 주소(0x…0480, frame #2 main 안의 지점). 디버거의 bt는 이 두 워드 쌍을 따라 걷는 루프에 심볼 번역을 붙인 것에 불과하다. 트레이스가 "만들어지는" 곳은 어디에도 없다 — 스택에 이미 있는 구조를 읽어낼 뿐이다.

재귀와 스택 오버플로 — 한계는 크기의 함수다

프레임은 호출마다 쌓이므로, 재귀 깊이의 한계는 스택 크기 ÷ 프레임 크기로 결정된다. 이 나눗셈의 양변 모두 변수라는 점이 실무 진단의 핵심이다.

  • 스택 크기는 런타임·스레드마다 다르다. macOS·Linux의 메인 스레드는 통상 8MB, 새로 만드는 스레드는 훨씬 작은 기본값(macOS pthread 512KB)을 갖는다. V8은 OS 스택보다 작은 자체 한계(이 환경 기준 약 1MB, --stack-size로 조정)를 두고 넘치기 전에 RangeError를 던진다. Go는 8KB에서 시작해 부족하면 스택을 더 큰 곳으로 복사해 옮기는 가변 스택이라 사실상 힙이 허용하는 만큼 자란다 — "재귀 한계"가 언어 상수가 아니라 런타임 설계의 결과라는 좋은 대조다.
  • 프레임 크기는 지역 변수·인자·저장 레지스터의 양이다. 같은 깊이의 재귀도 프레임이 큰 함수는 먼저 넘친다.

tail call이 있으면 이 산수 자체가 사라질 수 있다. 재귀 호출이 함수의 마지막 동작이면 현재 프레임을 재사용할 수 있고(호출을 점프로 바꾼다), C 컴파일러는 최적화 수준에서 이를 수행하며 Scheme은 언어 명세로 보장한다. JavaScript는 ES2015 명세에 proper tail call이 있지만 주요 엔진 중 JavaScriptCore만 구현했다 — V8·SpiderMonkey는 "스택 트레이스가 사라진다"는 디버깅 비용을 이유로 거부했다. 즉 Node.js에서 tail call에 의존한 재귀는 여전히 넘친다.

관찰 2 — 재귀 깊이 한계를 실측한다

js
// recursion-depth.mjs — 프레임 크기가 다른 두 재귀의 최대 깊이를 실측한다
let depthSmall = 0;
function smallFrame() {
  depthSmall++;
  return 1 + smallFrame();
}

let depthBig = 0;
function bigFrame(a = 1, b = 2, c = 3, d = 4, e = 5, f = 6, g = 7, h = 8) {
  depthBig++;
  const x1 = a + b, x2 = c + d, x3 = e + f, x4 = g + h;
  const x5 = x1 * x2, x6 = x3 * x4, x7 = x5 - x6, x8 = x7 + x1;
  return x8 + bigFrame(b, c, d, e, f, g, h, a);
}

try { smallFrame(); } catch (e) { console.log('smallFrame depth:', depthSmall, `(${e.constructor.name})`); }
try { bigFrame(); } catch (e) { console.log('bigFrame depth:  ', depthBig, `(${e.constructor.name})`); }

이 환경의 결과다(3회 반복 모두 동일 — V8의 한계 검사는 결정적이다).

실행smallFramebigFrame
기본 (node recursion-depth.mjs)103642826
node --stack-size=2000212015781
node --stack-size=50052011418

두 방향 모두 모델과 일치한다. 깊이는 스택 크기(--stack-size, KB 단위)에 거의 비례하고, 프레임이 큰 함수(인자 8개 + 중간값 8개)는 같은 스택에서 약 3.7배 얕게 넘친다. 역산하면 이 환경에서 smallFrame의 프레임은 약 100바이트, bigFrame은 약 360바이트다. "재귀 한계 1만"은 언어의 상수가 아니라 이 나눗셈의 결과이며, V8 버전이나 함수 모양이 바뀌면 달라진다. 프로덕션에서 스택 오버플로를 만나면 "더 깊게"가 아니라 "깊이를 입력 크기와 무관하게" — 명시적 스택을 쓰는 반복문 변환(1.4의 DFS 논의와 같은 기법)이 정답인 이유다.

링킹과 로딩 — 심볼은 언제 누가 해석하는가

printf를 호출하는 코드를 컴파일하면, 그 시점의 산출물에는 printf의 주소가 없다 — 이름표만 있다. 이름표(심볼)를 주소로 바꾸는 작업이 링킹이고, 언제 하느냐가 두 방식을 가른다.

  • 정적 링킹: 빌드 시점에 라이브러리 코드를 실행 파일에 복사해 넣고 심볼을 확정한다. 실행 파일은 자기 완결적이지만 커지고, 라이브러리 수정은 재빌드를 요구한다.
  • 동적 링킹: 실행 파일에는 "어떤 라이브러리의 어떤 심볼이 필요하다"는 기록만 남긴다. 프로세스 시작 시 동적 로더(macOS dyld, Linux ld.so — 관찰 1의 backtrace 맨 아래에 있던 dyld 프레임이 그것이다)가 라이브러리를 주소 공간에 매핑하고 심볼을 해석한다. 함수 심볼은 첫 호출까지 해석을 미루는 lazy binding이 흔하다.

동적 링킹의 대가는 해석이 실행 환경에서 일어난다는 것이다. 빌드 머신에서 링크가 성공했어도, 실행 머신의 라이브러리가 다른 버전이면 로더가 다른 코드를 — 또는 없는 심볼을 — 만난다. "내 머신에서는 되는데"류 오류 중 symbol not found, GLIBC_2.x not found의 정체가 이것이다: 심볼 해석이라는 계약 확인이 배포 시점까지 미뤄진 것. 컨테이너 이미지가 이 문제를 푸는 방식은 실행 환경(라이브러리 포함)을 통째로 고정하는 것이고, Go·Rust가 기본 정적 링킹을 선호하는 것도 같은 문제의 다른 답이다. 재배치·PLT/GOT 같은 바이너리 포맷 세부는 이 커리큘럼 범위 밖이며, 참고 자료의 Drepper 문서가 표준 자료다.

예외와 스택 언와인딩 — zero-cost의 비용 위치

예외가 던져지면 런타임은 catch 지점까지 스택을 되감아야 한다 — 프레임을 하나씩 버리면서, 각 프레임의 정리 코드(C++ 소멸자, finally, Rust의 Drop)를 실행하는 스택 언와인딩(stack unwinding)이다. 구현 전략은 비용을 어디에 둘지의 선택이다.

  • 초기 방식(setjmp/longjmp 계열): try에 진입할 때마다 복원 지점을 등록한다. 예외가 없어도 try마다 상시 비용을 낸다.
  • 테이블 방식(이른바 zero-cost, Itanium C++ ABI): 컴파일러가 "코드 주소 → 언와인딩 방법·핸들러 위치"를 담은 테이블을 바이너리에 함께 넣는다. 정상 경로는 아무 것도 하지 않는다 — 그래서 zero-cost다. 대신 throw가 발생하면 현재 주소로 테이블을 탐색하고 프레임마다 언와인딩 정보를 해석하며 걷는, 정상 호출보다 자릿수가 다르게 비싼 경로를 밟는다.

즉 "예외는 느리다"는 문장은 비용의 위치를 말하지 않아서 틀린다. 테이블 방식에서 비싼 것은 던지는 행위이고, try 블록의 존재나 예외가 안 나는 정상 경로는 (분기 최적화 제약을 제외하면) 공짜에 가깝다. 따라서 판단 기준은 빈도다 — 진짜 예외 상황(파일 없음, 연결 끊김)이라면 zero-cost 설계가 이상적이고, 예외를 제어 흐름으로(파싱 실패가 입력의 절반인 핫 루프에서 throw) 쓰면 테이블 탐색 비용을 반복 지불한다. Rust·Go가 예상 가능한 실패를 반환값(Result, error)으로 처리하고 예외적 상황(panic)만 언와인딩에 맡기는 것은 이 비용 구조를 타입으로 강제한 설계다.

언와인딩은 스택 트레이스와 같은 인프라를 쓴다는 점도 기억할 가치가 있다 — 프레임을 거슬러 걷는 능력. 최적화로 프레임 포인터를 생략한 코드에서도 예외가 동작하는 이유는 언와인드 테이블이 프레임 포인터 없이 걷는 방법을 담고 있기 때문이다(더 깊이 참조).

실무 관점

인라인화 — 트레이스의 프레임은 실재를 보장하지 않는다

컴파일러가 함수를 호출 지점에 인라인하면 프레임 자체가 생기지 않는다. 관찰 1의 예제를 -O2로 다시 컴파일하고 (상수 접기를 막기 위해 입력을 argc에 의존시켜) 같은 지점에서 backtrace를 뜨면:

text
  * frame #0: 0x000000010000047c frames-o2`leaf(a=<unavailable>, b=<unavailable>) at frames-live.c:5:20 [opt] [inlined]
    frame #1: 0x000000010000047c frames-o2`middle(x=21) at frames-live.c:11:12 [opt] [inlined]
    frame #2: 0x000000010000047c frames-o2`main(argc=1, argv=0x000000016fdfe960) at frames-live.c:15:21 [opt]

세 프레임이 같은 주소(0x…047c)를 가리킨다 — 실제 스택 프레임은 main 하나뿐이고, leafmiddle은 디버그 정보로 재구성된 가상 프레임이다([inlined] 표시, 인자는 <unavailable>). 디버그 정보가 없는 릴리스 바이너리나 JIT 코드에서는 재구성이 불가능해 프레임이 그냥 사라진다. 프로덕션 트레이스에서 "호출했을 리 없는 경로"가 보이거나 중간 함수가 빠져 있다면, 코드가 아니라 인라인화를 먼저 의심하라. 같은 이유로 V8의 최적화 계층(5.3)이 만든 코드의 트레이스도 역최적화 정보로 재구성된 것이다. 극단으로, 이 예제에서 입력을 상수로 두면 -O2는 호출 트리 전체를 컴파일 타임에 접어 printf("925")만 남긴다 — 관찰할 프레임 자체가 소멸한다.

async/await — 콜 스택은 동기 실행 구간의 기록이다

await 이후의 코드는 원래 호출자의 프레임 위에서 실행되지 않는다. 프라미스가 해소되면 이벤트 루프가 계속 실행을 새 스택에서 시작하므로, 그 시점의 물리적 콜 스택에는 이벤트 루프 내부와 재개된 함수만 있다 — await까지 이어졌던 호출 경로는 이미 반환해 사라진 프레임들이다. async 스택 트레이스가 끊기는 것은 버그가 아니라 콜 스택이 동기 실행 구간의 기록이라는 정의의 귀결이다.

그래서 "이어진" async 트레이스는 전부 런타임이 별도로 유지한 정보다. V8은 await 지점의 프라미스 체인을 따라가 논리적 호출 경로를 재구성하고(Node.js에서 기본 활성), 그 범위를 넘는 연결 — 콜백 큐, 타이머, 메시지 큐 — 은 잃어버린다. 프로덕션 에러 트레이스가 processTicksAndRejections에서 끊겨 있다면, 물리 스택의 한계에 걸린 것이며, 원인 경로가 필요하면 컨텍스트 전파(AsyncLocalStorage 등)를 직접 심어야 한다.

심볼과 source map — 주소를 이름으로 되돌리는 별도 자산

트레이스 걷기가 내놓는 원재료는 코드 주소의 나열이다. 이름·파일·행 번호는 별도 자산이 번역한다 — 네이티브 세계에서는 심볼 테이블과 디버그 정보(분리 배포되는 dSYM·debuginfo, 크래시 리포터의 symbolication), JavaScript 세계에서는 번들·압축 전 소스와 매핑을 담은 source map. 공통 함정도 같다: 번역 자산이 실행된 바이너리·번들과 정확히 같은 빌드여야 한다. 버전이 하나라도 어긋나면 그럴듯하지만 틀린 함수 이름이 나온다 — 없는 것보다 나쁜 트레이스다. 빌드 파이프라인이 심볼/소스맵을 빌드 산출물과 같은 식별자로 보관하는 것은 이 때문이다.

프레임 포인터 생략 — 프로파일링과의 트레이드오프

프레임 포인터는 레지스터 하나를 상시 점유하므로, 컴파일러는 -fomit-frame-pointer로 이를 지역 변수용으로 돌릴 수 있다. 대가는 프레임 체인의 소실이다 — 언와인드 테이블로 걷는 디버거·예외는 동작하지만, 시그널 컨텍스트에서 초당 수천 번 스택을 걷어야 하는 샘플링 프로파일러는 테이블 해석이 너무 비싸 프레임 포인터에 의존하는 경우가 많다. 그 결과 생략된 바이너리의 flame graph는 깊이 1~2의 무의미한 스택으로 뭉개진다. 성능 몇 %와 관측 가능성의 교환이며, 최근 흐름은 관측 쪽으로 기울었다 — Fedora·Ubuntu가 배포판 전체를 프레임 포인터 유지로 다시 빌드한 것이 대표 사례다. 자사 서비스 바이너리라면 프로파일링 가능성을 기본값으로 삼는 것을 권한다.

FFI 경계 — 계약 불일치는 조용히 어긋난다

호출 규약 절의 경고를 진단 관점으로 뒤집으면: FFI 크래시의 전형적 증상은 크래시 지점과 원인 지점의 분리다. 시그니처 불일치로 피호출자가 스택을 잘못 읽거나 잘못 정리하면, 오염된 것은 반환 주소나 callee-saved 레지스터이고, 크래시는 반환 후 호출자 쪽 임의 지점에서 난다. 트레이스가 "여기서 죽을 리 없는" 곳을 가리키고 프레임 체인이 중간에 쓰레기 주소로 끊긴다면, 마지막으로 건넌 FFI 경계의 선언(인자 개수·폭·구조체 전달 방식·가변 인자 여부)을 실제 C 헤더와 대조하는 것이 첫 수순이다.

더 깊이

언와인드 테이블 — 프레임 포인터 없이 걷는 법

프레임 포인터 체인은 걷기의 한 방법일 뿐이다. 표준적인 대안은 컴파일러가 방출하는 언와인드 테이블(DWARF CFI, .eh_frame)로, "코드 주소가 X일 때 반환 주소와 각 레지스터의 복원 위치"를 주소 구간마다 기술한다. 예외 처리 절의 zero-cost 테이블이 바로 이것이고, 디버거의 bt도 프레임 포인터가 없으면 이를 해석한다. 정확하지만 해석 비용이 커서, 커널 프로파일러(Linux perf)는 프레임 포인터 걷기, 테이블 사전 변환(ORC), 스택 메모리 통째 복사 후 사후 언와인딩 같은 절충들을 각각 다른 비용으로 제공한다. "어떤 unwinder를 쓰는가"는 프로파일 도구를 고를 때 실제로 갈리는 사양이다.

V8의 스택 한계 — OS 한계와 별도인 이유

관찰 2에서 --stack-size=2000(약 2MB)은 macOS 메인 스레드 스택(8MB)보다 작다. V8이 OS 한계를 그대로 쓰지 않고 자체 한계를 두는 것은 넘치기 전에 멈추기 위해서다 — OS 스택 오버플로는 가드 페이지 접근 → SIGSEGV로, 복구 불가능한 프로세스 종료다. V8은 호출 진입마다 스택 포인터를 한계값과 비교해, 넘기 전에 JavaScript 세계의 복구 가능한 오류(RangeError)로 바꾼다. 한계 검사가 결정적이므로 관찰 2의 깊이가 실행마다 정확히 같았던 것이다. --stack-size를 OS 스택보다 크게 잡으면 이 안전망이 뒤집혀 진짜 SIGSEGV가 난다는 점도 같은 모델에서 예측된다.

정리

  • 프로세스 주소 공간(코드·데이터·힙·스택)은 크래시 주소를 분류하는 좌표계다. 상세 메커니즘은 8.2의 몫이다.
  • 호출 규약은 인자·반환값·레지스터 복원 책임을 나누는 ABI 계약이고, FFI 오류는 이 계약의 불일치가 조용히 어긋난 것이다.
  • 스택 트레이스는 프레임 포인터(또는 언와인드 테이블)를 따라 저장된 반환 주소를 읽어가는 걸음이다. 인라인화는 프레임을 없애고, async 경계는 물리 스택의 정의상 기록 밖이며, 심볼·source map은 별도 자산이다 — 트레이스의 한계는 전부 이 생성 원리에서 나온다.
  • 재귀 한계는 스택 크기 ÷ 프레임 크기이고 양변 모두 변수다. 실측으로 확인했다: 깊이는 --stack-size에 비례하고 프레임 크기에 반비례한다.
  • 동적 링킹은 심볼 해석을 실행 시점으로 미루는 선택이고, 버전 충돌 오류는 그 지연의 대가다.
  • zero-cost 예외는 정상 경로에서 무비용, throw 경로에서 고비용이다. 판단할 것은 "예외가 느린가"가 아니라 "throw가 얼마나 잦은가"다.

확인 문제

1. 프로덕션 서비스의 flame graph가 대부분 깊이 1~2의 스택으로 뭉개져 나온다. 코드는 명백히 깊은 호출 구조를 갖고 있다. 이 문서의 모델로 가능한 원인을 두 가지 제시하고, 각각을 확인할 방법을 말하라.

정답과 해설

(1) 프레임 포인터 생략 — 샘플링 프로파일러가 프레임 포인터 걷기에 의존하는데 바이너리가 -fomit-frame-pointer로 빌드된 경우. 빌드 플래그를 확인하거나, 언와인드 테이블 기반(또는 DWARF 모드) 프로파일링으로 바꿔 스택이 복원되는지 본다. (2) 인라인화·JIT — 뜨거운 경로가 소수의 큰 함수로 인라인되어 물리 프레임이 실제로 1~2개뿐인 경우. 디버그 정보·인라인 정보를 해석하는 프로파일러로 재구성되는지, 또는 최적화 수준을 낮춘 빌드에서 스택이 나타나는지 대조한다. 두 원인 모두 "트레이스는 스택의 실재 구조를 걷는다"는 생성 원리의 귀결이다.

2. Node.js 서비스에서 요청 처리 중 발생한 에러의 스택 트레이스가 processTicksAndRejections 근처에서 끊겨, 어느 요청 핸들러에서 시작됐는지 보이지 않는다. 이것이 버그가 아닌 이유를 구조로 설명하고, 원인 경로를 확보하는 방법을 제시하라.

정답과 해설

콜 스택은 동기 실행 구간의 기록이다. await나 콜백 이후의 코드는 이벤트 루프가 새 스택에서 시작하므로, 그 시점의 물리 스택에는 이벤트 루프 내부(processTicksAndRejections)와 재개된 함수만 존재한다 — 요청 핸들러의 프레임은 이미 반환해 사라졌다. 확보 방법: V8의 async 스택 트레이스가 커버하는 프라미스 체인 범위인지 확인하고, 그 밖(콜백 큐, 타이머, 이벤트 이미터 경유)이라면 AsyncLocalStorage 같은 컨텍스트 전파로 요청 식별자를 직접 운반하거나, 경계를 건널 때 원인 스택을 캡처해 에러에 연결하는(cause 체인) 계측을 심는다.

3. 팀이 "예외는 느리니 모든 함수가 에러 코드를 반환하도록 리팩터링하자"고 제안한다. zero-cost 예외 모델을 근거로 이 제안이 성능을 개선하는 경우와 오히려 악화시키는(또는 무의미한) 경우를 구분하라.

정답과 해설

테이블 방식 예외의 비용 위치: 정상 경로 ≈ 0, throw 경로 = 테이블 탐색 + 프레임별 언와인딩으로 매우 비쌈. 따라서 개선되는 경우는 throw가 잦을 때 — 실패가 입력의 상당 비율인 핫 루프(파싱, 검증)에서 예외를 제어 흐름으로 쓰고 있었다면 반환값 전환이 실질 이득이다. 무의미하거나 악화되는 경우: 예외가 진짜 예외적(드묾)이라면 현재 비용은 이미 0에 가깝고, 모든 호출 지점에 에러 코드 검사 분기가 추가되어 정상 경로가 오히려 비싸지고 코드도 복잡해진다. 판단에 필요한 데이터는 "예외 발생 빈도 × 언와인딩 깊이"이지 "예외 사용 여부"가 아니다.

참고 자료