C++ 스택 vs 힙 | 재귀에서 프로그램이 죽는 이유와 스택 오버플로우 사례
이 글의 핵심
C++ 스택 vs 힙: 재귀에서 프로그램이 죽는 이유와 스택 오버플로우 사례. 스택 오버플로우로 프로그램을 크래시시킨 이야기·C++ 메모리 구조 전체 그림.
💡 초보자를 위한 비유: 스택은 “지금 이 함수 안에서만 쓰고 나가면 치우는 책상 위 메모지”, 힙은 “필요할 때 창고에서 꺼내 쓰고, 안 쓰면 직접 반납해야 하는 큰 상자”에 가깝습니다. 메모지(스택)는 빠르지만 양이 한정되어 있고, 창고(힙)는 크지만 관리 비용이 있습니다.
들어가며: 스택 오버플로우로 프로그램을 크래시시킨 이야기
“왜 갑자기 죽지?” - 재귀 함수의 함정
알고리즘 문제를 풀다가, 재귀 함수로 피보나치를 구현했습니다. 작은 입력값(n=10)에서는 잘 작동했는데, n=100000을 입력하자마자 프로그램이 아무 에러 메시지 없이 죽었습니다. 이 예제에서 fibonacci는 호출될 때마다 스택에 4KB짜리 배열(cache[1000])을 하나씩 쌓습니다. n-1, n-2로 두 갈래로 재귀하므로 호출 횟수가 기하급수적으로 늘고, 그만큼 스택에 쌓이는 메모리도 폭발합니다. main에서 fibonacci(100000)을 부르면 수십만 번 이상 호출되면서 스택 한도를 넘어서서 스택 오버플로우로 프로그램이 종료됩니다. cache 배열은 실제로 사용되지 않지만, “함수마다 스택을 얼마나 쓰는지”를 보여주기 위해 넣은 것입니다.
// 복사해 붙여넣은 뒤: g++ -std=c++17 -o fib fib.cpp && ./fib
#include <iostream>
int fibonacci(int n) {
int cache[1000]; // 4KB 배열
if (n <= 1) return n;
return fibonacci(n - 1) + fibonacci(n - 2);
}
int main() {
std::cout << fibonacci(100000); // ❌ 크래시!
return 0;
}
위 코드 설명: fibonacci가 호출될 때마다 int cache[1000](약 4KB)이 스택에 쌓이고, n-1과 n-2로 두 갈래 재귀라 호출 수가 기하급수적으로 늘어납니다. fibonacci(100000)은 수십만 번 이상 호출되며 스택 한도를 넘어 스택 오버플로우로 종료됩니다.
실행 결과: ./fib 실행 시 스택 오버플로우로 프로그램이 종료됩니다(환경에 따라 메시지 없이 죽거나 “Segmentation fault” 등).
디버깅 과정:
- ❌ 로직 오류? → 코드는 정상
- ❌ 메모리 누수? → Valgrind로 확인했지만 아님
- ✅ 스택 오버플로우(stack overflow—스택 메모리 한도를 넘어서서 데이터가 덮어쓰이거나 프로그램이 종료되는 현상)! 원인:
- 각 재귀 호출마다 4KB 할당
- 100000번 호출 = 400MB 필요
- 스택 크기 (기본 1-8MB) 초과 → 크래시
이 경험으로 스택과 힙의 차이를 뼈저리게 배웠습니다.
C++에서는 “어디에 메모리가 잡히는지”를 모르면 재귀 깊이, 대용량 버퍼, 전역/지역 변수 선택에서 실수가 나오기 쉽습니다. 이 글에서 스택·힙·전역 메모리의 특성을 정리해 두면, 이후 스마트 포인터(#6-3)와 RAII(#6-4)를 이해할 때도 도움이 됩니다.
한 줄 요약: “크기가 작고 수명이 스코프 안이면 스택, 크거나 수명이 길면 힙”을 기본으로 두고, 재귀·큰 배열은 스택 한도를 넘지 않도록 주의하면 됩니다. 이 글을 읽으면: - 스택과 힙의 동작 원리를 명확히 이해할 수 있습니다.
- 스택 오버플로우를 예방하는 방법을 배웁니다.
- 언제 스택을 쓰고 언제 힙을 써야 하는지 판단할 수 있습니다.
- 메모리 구조를 이해하여 성능 최적화를 할 수 있습니다. 이전 글: C++ 실전 가이드 #5: 컴파일 과정 분석에서 링커와 오브젝트 파일을 다뤘습니다.
실무 적용 경험: 이 글은 대규모 C++ 프로젝트에서 실제로 겪은 문제와 해결 과정을 바탕으로 작성되었습니다. 책이나 문서에서 다루지 않는 실전 함정과 디버깅 팁을 포함합니다.
1. C++ 메모리 구조 전체 그림
C++ 프로그램이 실행될 때, 운영체제는 다음과 같은 메모리 영역을 할당합니다. 비유하면 건물의 층별 용도처럼, 코드(Text)·전역 데이터(Data/BSS)·힙·스택이 각각 다른 “층”에 자리 잡고, 주소가 낮은 쪽에서 높은 쪽으로 올라갑니다. 아래 다이어그램은 프로세스 메모리 레이아웃을 단순화한 것입니다. 스택은 위로 자라나고, 힙은 아래로 자라나며, 중간에서 만나면 스택 오버플로우가 발생합니다.
// 실행 예제
flowchart TB
subgraph addr["주소 방향 (낮음 → 높음)"]
direction TB
T[코드 Text]
D[전역/정적 Data, BSS]
H[힙 Heap ↑]
free[빈 공간]
S[스택 Stack ↓]
end
T --> D --> H --> free --> S
style H fill:#e1f5fe
style S fill:#fff3e0
| 영역 | 용도 | 수명·특징 |
|---|---|---|
| 스택 | 지역 변수, 함수 인자, 반환 주소 | 함수 반환 시 자동 해제, 크기 제한(보통 1~8MB) |
| 힙 | new/malloc으로 할당 | 수동 해제 또는 스마트 포인터, 크기 제한이 상대적으로 큼 |
graph TB
HIGH["높은 주소 0xFFFFFFFF..."]
STACK["Stack 스택br/━━━━━━━━━━━━━━━br/지역 변수, 매개변수, 호출 정보br/자동 할당/해제 · 1-8MBbr/↓ 아래로 성장"]
UNUSED["⋮br/미사용 공간br/⋮"]
HEAP["Heap 힙br/━━━━━━━━━━━━━━━br/new/malloc 동적 할당br/수동 관리 delete/freebr/↑ 위로 성장"]
BSS["BSS 영역br/━━━━━━━━━━━━━━━br/초기화 안 된 전역/정적br/int global_var; → 0으로 초기화"]
DATA["Data 영역br/━━━━━━━━━━━━━━━br/초기화된 전역/정적br/int global_var = 42;"]
TEXT["Text 영역br/━━━━━━━━━━━━━━━br/실행 코드 (기계어)br/읽기 전용"]
LOW["낮은 주소 0x00000000..."]
HIGH --> STACK
STACK --> UNUSED
UNUSED --> HEAP
HEAP --> BSS
BSS --> DATA
DATA --> TEXT
TEXT --> LOW
style HIGH fill:#f5f5f5,stroke:#666,stroke-width:2px,color:#333
style STACK fill:#e3f2fd,stroke:#1976d2,stroke-width:3px,color:#000
style UNUSED fill:#fafafa,stroke:#999,stroke-width:1px,stroke-dasharray: 5 5,color:#666
style HEAP fill:#fff3e0,stroke:#f57c00,stroke-width:3px,color:#000
style BSS fill:#f3e5f5,stroke:#7b1fa2,stroke-width:2px,color:#000
style DATA fill:#e8f5e9,stroke:#388e3c,stroke-width:2px,color:#000
style TEXT fill:#fce4ec,stroke:#c2185b,stroke-width:2px,color:#000
style LOW fill:#f5f5f5,stroke:#666,stroke-width:2px,color:#333
각 영역의 역할
| 영역 | 용도 | 크기 | 수명 |
|---|---|---|---|
| Text | 실행 코드 | 컴파일 시 결정 | 프로그램 종료까지 |
| Data | 초기화된 전역 변수 | 컴파일 시 결정 | 프로그램 종료까지 |
| BSS | 초기화 안 된 전역 변수 | 컴파일 시 결정 | 프로그램 종료까지 |
| Heap | 동적 할당 메모리 | 런타임에 가변 | 명시적 해제까지 |
| Stack | 지역 변수, 함수 호출 | 런타임에 가변 | 스코프 종료까지 |
| 스택 크기가 제한된 이유: 스택은 한 번에 하나의 스레드가 사용하고, 함수 호출이 깊어질수록 아래로 쌓입니다. 그래서 OS는 스택에 “보통 1~8MB” 정도만 할당해 두고, 그 한도를 넘기면 스택 오버플로우(크래시)가 납니다. 재귀를 많이 쓰거나, 함수 안에서 큰 배열을 지역 변수로 선언하면 이 한도에 쉽게 닿을 수 있으므로, 큰 버퍼나 재귀 깊이는 힙이나 반복문으로 처리하는 편이 안전합니다. |
2. 스택 메모리: 빠르고 자동이지만 제한적
스택 메모리의 동작 원리
void function() {
int a = 10; // 스택에 4바이트 할당
double b = 3.14; // 스택에 8바이트 할당
char c[100]; // 스택에 100바이트 할당
// 함수 종료 시 자동으로 모두 해제
}
위 코드에서 int a, double b, char c[100]은 모두 함수 안의 지역 변수이므로 스택에 할당됩니다. a는 4바이트, b는 8바이트, c는 100바이트를 차지하고, 함수가 끝나면 이 블록 전체가 스택에서 제거됩니다. 즉 스택은 “함수 진입 시 자동으로 할당, 함수 반환 시 자동으로 해제”되는 구조라서, 개발자가 직접 해제할 필요가 없습니다. 스택 프레임 (Stack Frame) 시각화:
// 실행 예제
graph TB
subgraph BEFORE[1️⃣ 함수 호출 전]
M1["main 프레임br/━━━━━━━━━━━━br/main 변수들"]
end
subgraph DURING[2️⃣ function 호출 후]
SP["⬅ Stack Pointer SP"]
C[""c(100"]\n100 bytes"]
B["b doublebr/8 bytes"]
A["a intbr/4 bytes"]
RET["return addressbr/반환 주소 8 bytes"]
M2["main 프레임br/━━━━━━━━━━━━br/main 변수들"]
SP -.-> C
C --> B
B --> A
A --> RET
RET --> M2
end
subgraph AFTER[3️⃣ function 종료 후]
SP2["⬅ Stack Pointer 위로 이동"]
M3["main 프레임br/━━━━━━━━━━━━br/main 변수들br/✅ 스택 프레임 자동 정리"]
SP2 -.-> M3
end
BEFORE --> DURING
DURING --> AFTER
style BEFORE fill:#e8f5e9,stroke:#388e3c,stroke-width:2px
style DURING fill:#fff3e0,stroke:#f57c00,stroke-width:2px
style AFTER fill:#e8f5e9,stroke:#388e3c,stroke-width:2px
style M1 fill:#c8e6c9,stroke:#388e3c,stroke-width:2px
style C fill:#ffe0b2,stroke:#f57c00,stroke-width:2px
style B fill:#ffe0b2,stroke:#f57c00,stroke-width:2px
style A fill:#ffe0b2,stroke:#f57c00,stroke-width:2px
style RET fill:#ffccbc,stroke:#d84315,stroke-width:2px
style M2 fill:#c8e6c9,stroke:#388e3c,stroke-width:2px
style M3 fill:#c8e6c9,stroke:#388e3c,stroke-width:2px
스택의 장점
1. 속도가 매우 빠름 스택에 변수 하나 할당하는 것은 스택 포인터를 조금 옮기는 것에 가까워서 수 CPU 사이클이면 끝납니다. 반면 new는 힙에서 빈 공간을 찾고, 할당하고, 포인터를 돌려주는 과정이 들어가 수십~수백 사이클이 걸릴 수 있습니다. 그래서 자주 호출되는 경로에서는 스택에 작은 객체를 두는 편이 유리합니다.
// 스택 할당 (1 CPU 사이클)
int x = 42;
// 힙 할당 (수십~수백 CPU 사이클)
int* y = new int(42);
위 코드 설명: int x = 42는 스택 포인터만 조금 옮기는 수준이라 매우 빠르고, new int(42)는 힙에서 블록을 찾고 메타데이터를 쓰는 과정이 들어가 수십~수백 배 더 오래 걸립니다.
2. 자동 메모리 관리
스택에 선언한 변수는 함수가 끝날 때 스택 프레임이 정리되면서 함께 사라집니다. int, std::string, std::vector 모두 function 안에서 선언되었으므로, 함수를 벗어나는 순간 메모리가 자동으로 해제됩니다. std::string과 std::vector는 내부적으로 힙을 쓸 수 있지만, 그 객체 자체는 스택에 있기 때문에 스코프를 벗어나면 소멸자가 호출되어 내부 자원까지 정리됩니다. 따라서 new/delete를 직접 쓰지 않아도 됩니다.
void function() {
int x = 10;
std::string name = "Alice";
std::vector<int> numbers = {1, 2, 3};
// 함수 종료 시 자동으로 모두 해제
// delete 불필요!
}
3. 캐시 친화적 스택은 메모리가 연속적이고 지역성이 높아 CPU 캐시 효율이 좋습니다.
스택의 단점
1. 크기 제한
void badFunction() {
int hugeArray[1000000]; // 4MB
// ❌ 스택 오버플로우 가능!
}
위 코드 설명: 지역 변수 hugeArray는 스택에 할당되는데, 4MB는 대부분 환경의 기본 스택 크기(1~8MB)에 가깝거나 넘어서 스택 오버플로우를 일으킬 수 있습니다. 이렇게 큰 버퍼는 std::vector 등으로 힙에 두는 것이 안전합니다.
기본 스택 크기:
- Linux: 8MB (ulimit -s로 확인)
- Windows: 1MB (링커 옵션으로 변경 가능)
- macOS: 8MB 2. 수명이 스코프에 제한됨
int* badFunction() {
int x = 42;
return &x; // ❌ 댕글링 포인터!
}
// x는 함수 종료 시 소멸됨
위 코드 설명: x는 스택에 있는 지역 변수라 함수가 끝나면 스택 프레임과 함께 사라집니다. 그 주소를 반환하면 호출 측에서 받은 포인터는 이미 해제된 메모리를 가리키는 댕글링 포인터가 되어, 역참조 시 정의되지 않은 동작이 됩니다.
스택 오버플로우 실전 사례
사례 1: 큰 배열
겪은 크래시:
void processImage() {
unsigned char image[4096 * 4096 * 4]; // 64MB!
// ❌ 스택 크기 초과 → 즉시 크래시
}
위 코드 설명: 64MB 배열을 스택에 두면 일반적인 스택 크기(1~8MB)를 크게 넘어서, 함수 진입 직후 스택 오버플로우로 크래시할 가능성이 높습니다. 이미지처럼 큰 데이터는 반드시 힙에 할당해야 합니다. 해결법:
void processImage() {
std::vector<unsigned char> image(4096 * 4096 * 4);
// ✅ 힙에 할당
}
위 코드 설명: std::vector는 내부 버퍼를 힙에 할당하므로 64MB처럼 큰 크기도 스택 한도와 무관하게 쓸 수 있습니다. 벡터 객체 자체는 스택에 있어도, 실제 데이터는 힙에 있고 스코프를 벗어나면 소멸자가 자동으로 해제합니다.
사례 2: 깊은 재귀
겪은 문제:
void recursiveFunction(int n) {
int localVar[1000]; // 4KB
if (n > 0) {
recursiveFunction(n - 1);
}
}
int main() {
recursiveFunction(10000); // ❌ 40MB 필요 → 크래시!
return 0;
}
위 코드 설명: 호출마다 4KB 스택이 쌓이므로 10000번이면 약 40MB가 필요합니다. 스택은 보통 1~8MB라 한도를 넘어 스택 오버플로우가 납니다. 재귀 깊이를 줄이거나, 큰 지역 데이터는 힙으로 옮겨야 합니다. 해결법 1: 반복문 사용:
void iterativeFunction(int n) {
int localVar[1000];
for (int i = 0; i < n; i++) {
// 반복문은 스택 프레임 재사용
}
}
위 코드 설명: 반복문은 한 번의 스택 프레임 안에서 루프만 도므로, localVar가 n번 쌓이지 않고 한 번만 할당됩니다. 재귀를 반복문으로 바꾸면 스택 사용량이 크게 줄어듭니다.
해결법 2: 힙 사용:
void recursiveFunction(int n) {
auto localVar = std::make_unique<int[]>(1000);
if (n > 0) {
recursiveFunction(n - 1);
}
}
위 코드 설명: 4KB 배열을 std::make_unique<int[]>(1000)으로 힙에 두면, 스택에는 포인터(8바이트)만 쌓입니다. 재귀 깊이가 깊어도 스택 사용량이 작게 유지되어 스택 오버플로우를 피할 수 있습니다.
해결법 3: 스택 크기 증가 (임시 방편):
# Linux
ulimit -s 16384 # 16MB
# Windows (링커 옵션)
g++ -Wl,--stack,16777216 program.cpp # 16MB
위 코드 설명: ulimit -s는 프로세스의 스택 크기 한도를 바이트 단위로 설정합니다. Linux에서는 16384(KB)로 16MB를 주는 예시입니다. 근본 해결은 큰 데이터를 힙으로 옮기는 것이고, 스택 확대는 임시 조치로만 쓰는 것이 좋습니다.
3. 힙 메모리: 유연하지만 수동 관리 필요
힙 메모리의 특징
int main() {
// 스택에 포인터 변수 (8바이트)
int* ptr = new int(42); // 힙에 int (4바이트) 할당
std::cout << *ptr << std::endl; // 42 출력
delete ptr; // 힙 메모리 해제 (필수!)
return 0;
}
위 코드 설명: new int(42)로 힙에 4바이트가 할당되고, ptr은 스택에 있는 8바이트 포인터입니다. delete ptr로 그 힙 블록을 해제해야 하고, 해제하지 않으면 메모리 누수가 됩니다. 사용이 끝난 뒤 반드시 한 번만 delete를 호출해야 합니다.
메모리 레이아웃:
graph LR
subgraph Stack[Stack 영역]
PTR["ptrbr/━━━━━━━━br/주소값br/8 bytes"]
end
subgraph Heap[Heap 영역]
VAL["intbr/━━━━━━━━br/value: 42br/4 bytes"]
end
PTR -->|가리킴| VAL
style Stack fill:#e3f2fd,stroke:#1976d2,stroke-width:2px
style Heap fill:#fff3e0,stroke:#f57c00,stroke-width:2px
style PTR fill:#bbdefb,stroke:#1976d2,stroke-width:2px
style VAL fill:#ffe0b2,stroke:#f57c00,stroke-width:2px
힙의 장점
1. 크기 제한 없음 (시스템 메모리까지)
// 1GB 배열도 가능
std::vector<int> huge(250000000); // 1GB
위 코드 설명: std::vector는 요소를 힙에 할당하므로, 시스템 메모리 범위 안에서 큰 크기도 할당할 수 있습니다. 2.5억 개 int는 약 1GB이며, 스택이 아니라 힙을 쓰므로 스택 한도와 무관합니다.
2. 수명 제어 가능
int* createData() {
int* data = new int(42);
return data; // ✅ 함수 밖에서도 사용 가능
}
int main() {
int* ptr = createData();
std::cout << *ptr << std::endl;
delete ptr;
return 0;
}
위 코드 설명: new로 할당한 메모리는 함수가 끝나도 힙에 남아 있으므로, 포인터를 반환해 호출자가 사용할 수 있습니다. 단, 호출 측에서 delete ptr로 해제해야 하며, 스마트 포인터(std::unique_ptr)를 쓰면 해제를 자동으로 할 수 있습니다.
3. 다형성 구현
class Animal { virtual void speak() = 0; };
class Dog : public Animal { void speak() override { std::cout << "Woof\n"; } };
class Cat : public Animal { void speak() override { std::cout << "Meow\n"; } };
std::vector<std::unique_ptr<Animal>> animals;
animals.push_back(std::make_unique<Dog>());
animals.push_back(std::make_unique<Cat>());
for (auto& animal : animals) {
animal->speak(); // 다형성!
}
위 코드 설명: 파생 클래스 객체(Dog, Cat)는 크기가 다르고 실행 시점에 타입이 정해지므로 힙에 할당하는 것이 자연스럽습니다. std::unique_ptr<Animal>로 소유권을 관리하면 animals가 소멸될 때 각 동물 객체가 자동으로 해제됩니다.
힙의 단점
1. 느림
// 벤치마크 결과 (측정)
// 스택 할당: 1ns
// 힙 할당: 50-100ns (50-100배 느림!)
위 코드 설명: 스택은 포인터 이동 수준이라 나노초 단위이고, 힙 할당은 OS/할당자에서 블록 탐색·메타데이터 갱신 등이 들어가 수십~수백 배 느립니다. 루프 안에서 반복 할당할 때 이 차이가 그대로 누적됩니다. 2. 수동 관리 필요
int* ptr = new int(42);
// ....복잡한 로직 ...
delete ptr; // 잊으면 메모리 누수!
위 코드 설명: new로 할당한 메모리는 delete를 호출하기 전까지 프로세스가 끝날 때까지 남습니다. 예외나 early return으로 delete에 도달하지 않으면 누수가 되므로, 실무에서는 std::unique_ptr 등 RAII로 감싸는 것이 안전합니다.
3. 메모리 단편화
graph LR
B1["사용 중"]
F1["빈 공간br/⚠️"]
B2["사용 중"]
F2["빈 공간br/⚠️"]
B3["사용 중"]
B1 --- F1
F1 --- B2
B2 --- F2
F2 --- B3
NOTE["작은 빈 공간들이 흩어져br/큰 메모리 할당 불가"]
style B1 fill:#4caf50,stroke:#2e7d32,stroke-width:2px,color:#fff
style B2 fill:#4caf50,stroke:#2e7d32,stroke-width:2px,color:#fff
style B3 fill:#4caf50,stroke:#2e7d32,stroke-width:2px,color:#fff
style F1 fill:#ffcdd2,stroke:#c62828,stroke-width:2px,stroke-dasharray: 5 5
style F2 fill:#ffcdd2,stroke:#c62828,stroke-width:2px,stroke-dasharray: 5 5
style NOTE fill:#fff9c4,stroke:#f57f17,stroke-width:2px
동적 배열 할당
// C 스타일 (사용 지양)
int* arr1 = (int*)malloc(100 * sizeof(int));
free(arr1);
// C++ 스타일 (기본)
int* arr2 = new int[100];
delete[] arr2; // ⚠️ delete[] 사용 필수!
// 현대 C++ (권장)
std::vector<int> arr3(100); // ✅ 자동 메모리 관리
위 코드 설명: 배열은 new[]로 할당했으면 반드시 delete[]로 해제해야 합니다. delete만 쓰면 첫 원소만 해제되고 나머지는 누수됩니다. std::vector는 내부에서 힙을 쓰고 소멸 시 자동으로 해제하므로 직접 new/delete를 쓰지 않아도 됩니다.
겪은 delete vs delete[] 실수
문제의 코드:
int* arr = new int[100];
delete arr; // ❌ delete[] 대신 delete 사용
위 코드 설명: new int[100]으로 배열을 할당했는데 delete만 쓰면, 할당자는 “단일 객체 하나”만 해제한다고 간주합니다. 나머지 99개 원소는 해제되지 않아 누수되고, 일부 환경에서는 힙 손상으로 크래시가 날 수 있습니다.
결과:
- 첫 번째 원소만 해제
- 나머지 99개 원소는 메모리 누수
- Visual Studio 디버그 모드에서는 즉시 크래시
- Release 모드에서는 조용히 누수 (더 위험!) 올바른 사용:
int* single = new int(42);
delete single; // ✅ 단일 객체
int* array = new int[100];
delete[] array; // ✅ 배열
// 가장 좋은 방법
auto array = std::make_unique<int[]>(100); // ✅ 자동 관리
위 코드 설명: 단일 객체는 delete, 배열은 delete[]로 짝을 맞춰야 합니다. std::make_unique<int[]>(100)을 쓰면 배열도 RAII로 관리되어 스코프를 벗어날 때 자동으로 올바르게 해제됩니다.
일상 비유로 이해하기: 메모리를 아파트 건물로 생각해보세요. 스택은 엘리베이터 같아서 빠르지만 공간이 제한적입니다. 힙은 창고처럼 넓지만 물건을 찾는 데 시간이 걸립니다. 포인터는 “3층 302호”처럼 주소를 가리키는 메모지라고 보면 됩니다.
4. 스택 vs 힙 성능 비교
직접 측정한 벤치마크
같은 횟수만큼 “스택에 int 하나 할당”과 “힙에 int 할당 후 해제”를 반복해 걸린 시간을 재면, 힙이 스택보다 수십 배 이상 느린 것을 확인할 수 있습니다. 루프 안에서 new/delete를 반복하는 것은 실전에서 피하는 것이 좋습니다.
#include <chrono>
#include <iostream>
int main() {
const int iterations = 1000000;
// 스택 할당 측정
auto start = std::chrono::high_resolution_clock::now();
for (int i = 0; i < iterations; i++) {
int x = 42; // 스택
}
auto end = std::chrono::high_resolution_clock::now();
auto stack_time = std::chrono::duration_cast<std::chrono::microseconds>(end - start).count();
// 힙 할당 측정
start = std::chrono::high_resolution_clock::now();
for (int i = 0; i < iterations; i++) {
int* x = new int(42); // 힙
delete x;
}
end = std::chrono::high_resolution_clock::now();
auto heap_time = std::chrono::duration_cast<std::chrono::microseconds>(end - start).count();
std::cout << "Stack: " << stack_time << "μs\n";
std::cout << "Heap: " << heap_time << "μs\n";
std::cout << "Heap is " << (heap_time / stack_time) << "x slower\n";
return 0;
}
위 코드 설명: 같은 횟수로 스택 할당(int x = 42)과 힙 할당(new/delete)을 반복해 시간을 재는 코드입니다. 스택은 수 마이크로초, 힙은 수만 마이크로초가 나오는 것이 일반적이라, 루프 안에서 new/delete를 반복하는 것은 피하는 것이 좋습니다.
제 결과 (Intel i7, Linux):
Stack: 1,234μs
Heap: 89,567μs
Heap is 72x slower
왜 힙이 느린가?
- 시스템 콜 필요: 운영체제에 메모리 요청
- 메모리 검색: 사용 가능한 블록 찾기
- 메타데이터 관리: 할당 크기, 상태 정보 저장
- 동기화 오버헤드: 멀티스레드 환경에서 락 필요
5. 실전 선택 가이드: 언제 스택? 언제 힙?
스택 사용 (기본 선택)
✅ 스택을 사용해야 하는 경우:
- 작은 크기 (수백 바이트 이하)
int x = 42; double y = 3.14; std::string name = “Alice”; // 작은 문자열 - 수명이 명확한 지역 변수
// 실행 예제
void function() {
int counter = 0;
// counter는 함수 내에서만 사용
}
- 성능이 중요한 경우
// 루프 안에서 반복 할당
// 실행 예제
for (int i = 0; i < 1000000; i++) {
int temp = i * 2; // ✅ 스택 (빠름)
}
힙 사용
✅ 힙을 사용해야 하는 경우:
- 큰 크기 (수 KB 이상)
// ❌ 스택
// int largeArray[1000000]; // 4MB - 위험!
// ✅ 힙
std::vector<int> largeArray(1000000); // 4MB - 안전
- 수명이 스코프를 넘어서는 경우
// 실행 예제
std::unique_ptr<Data> loadData() {
auto data = std::make_unique<Data>();
// ....데이터 로드 ...
return data; // ✅ 힙 메모리는 함수 밖에서도 유효
}
- 크기를 런타임에 결정
int size;
std::cin >> size;
// ❌ 스택 (C++ 표준 아님, 일부 컴파일러만 지원)
// int arr[size];
// ✅ 힙
std::vector<int> arr(size);
- 다형성 필요
std::vector<std::unique_ptr<Shape>> shapes; shapes.push_back(std::make_unique<Circle>()); shapes.push_back(std::make_unique<Rectangle>());
실전 판단 플로우차트
크기가 1KB 미만인가?
├─ Yes → 스택 사용
└─ No → 다음 질문
수명이 스코프 내인가?
├─ Yes → 스택 고려 (크기 주의)
└─ No → 힙 사용
성능이 매우 중요한가?
├─ Yes → 스택 사용 (가능하면)
└─ No → 힙 사용 (안전)
6. 완전한 메모리 관리 예제
예제 1: RAII 기반 리소스 관리 (권장 패턴)
문제 시나리오: 파일 핸들, 소켓, 동적 메모리 등 여러 리소스를 관리할 때 예외 발생 시 해제를 누락하는 경우가 많습니다. 해결: RAII(Resource Acquisition Is Initialization)로 생성자에서 획득, 소멸자에서 해제합니다.
#include <memory>
class BufferManager {
std::unique_ptr<uint8_t[]> data_;
size_t size_;
public:
explicit BufferManager(size_t n) : data_(std::make_unique<uint8_t[]>(n)), size_(n) {}
uint8_t* data() { return data_.get(); }
size_t size() const { return size_; }
};
void processData() {
BufferManager buffer(1024 * 1024); // 1MB, 함수 종료 시 자동 delete[]
}
위 코드 설명: std::make_unique로 힙에 할당하고, 객체 소멸 시 unique_ptr이 자동으로 delete[]를 호출합니다. 예외 발생 시에도 스택 언와인딩으로 소멸자가 호출되어 누수가 없습니다.
예제 2: shared_ptr로 공유 소유권
문제 시나리오: 여러 객체가 같은 리소스를 공유해야 합니다. 누가 마지막에 해제할지 알 수 없습니다.
#include <memory>
#include <vector>
struct Resource { /* ....*/ };
std::vector<std::shared_ptr<Resource>> cache_;
void shareResource() {
auto res = std::make_shared<Resource>();
cache_.push_back(res); // 참조 카운트 증가
// 여러 곳에서 shared_ptr 보유해도 마지막 참조가 사라질 때만 해제됨
}
위 코드 설명: shared_ptr은 참조 카운팅으로 “마지막 참조가 사라질 때” 해제합니다. 순환 참조가 생기면 weak_ptr로 끊어야 합니다.
예제 3: 예외 안전한 메모리 할당
문제 시나리오: new 후 예외가 발생하면 delete에 도달하지 않아 누수됩니다.
// ❌ 나쁜 예: 예외 시 누수
void badAlloc() {
int* data = new int[1000];
processData(data); // 예외 발생 가능!
delete[] data; // 도달 못 함
}
// ✅ 좋은 예: 스마트 포인터
void goodAlloc() {
auto data = std::make_unique<int[]>(1000);
processData(data.get()); // 예외 발생해도 해제됨
}
// ✅ 좋은 예: std::vector (가장 권장)
void bestAlloc() {
std::vector<int> data(1000);
processData(data.data()); // 예외 안전
}
위 코드 설명: std::vector와 unique_ptr은 예외가 발생해도 스택 언와인딩 시 소멸자가 호출되어 메모리가 해제됩니다.
예제 4: 커스텀 삭제자
문제 시나리오: malloc/free, fopen/fclose 등 C API와 연동할 때 사용합니다.
#include <memory>
#include <cstdlib>
struct FreeDeleter { void operator()(void* p) const { std::free(p); } };
void useMallocMemory() {
std::unique_ptr<void, FreeDeleter> ptr(std::malloc(1024));
// 소멸 시 자동으로 free(ptr.get()) 호출
}
위 코드 설명: unique_ptr의 두 번째 템플릿 인자로 삭제자를 지정하면 C API와 연동 시 RAII를 적용할 수 있습니다.
7. 자주 발생하는 메모리 관련 에러
에러 1: 스택 오버플로우
증상:
Segmentation fault (core dumped)
원인: 스택 크기 초과 해결법:
- 큰 배열은 힙 사용
- 재귀 깊이 제한
- 스택 크기 증가 (임시)
에러 2: 댕글링 포인터
2시간 동안 찾은 버그:
int* getPointer() {
int x = 42;
return &x; // ❌ 스택 변수의 주소 반환
}
int main() {
int* ptr = getPointer();
std::cout << *ptr; // ❌ 정의되지 않은 동작!
return 0;
}
위 코드 설명: getPointer가 반환되면 x가 있던 스택 프레임은 무효가 되는데, 그 주소를 ptr이 받아 역참조하면 이미 해제된 메모리를 읽는 정의되지 않은 동작(UB)이 됩니다. 값이 우연히 42처럼 보일 수 있지만, 보장되지 않으며 최적화에 따라 크래시할 수도 있습니다.
문제: x는 함수 종료 시 소멸, ptr은 유효하지 않은 메모리를 가리킴
해결법:
std::unique_ptr<int> getPointer() {
return std::make_unique<int>(42); // ✅ 힙 사용
}
위 코드 설명: std::make_unique<int>(42)는 힙에 int를 할당하고 그 소유권을 unique_ptr에 넘깁니다. 반환된 포인터는 힙 메모리를 가리키므로 함수가 끝난 뒤에도 유효하고, unique_ptr이 소멸될 때 자동으로 delete가 호출됩니다.
에러 3: 스택과 힙 혼동
void badFunction() {
int x = 42;
delete &x; // ❌ 스택 메모리를 delete → 크래시!
}
위 코드 설명: x는 스택에 있는 지역 변수이므로 new로 할당된 것이 아닙니다. 스택 주소에 delete를 쓰면 할당자가 관리하지 않는 메모리를 해제하려다 힙 메타데이터가 깨지거나 즉시 크래시할 수 있습니다. delete는 반드시 new로 얻은 주소에만 사용해야 합니다.
규칙:
new로 할당한 것만delete- 스택 변수는 자동 해제
에러 4: 이중 해제 (Double Free)
문제 시나리오: 같은 포인터를 두 번 delete하면 힙 메타데이터가 손상되어 크래시합니다.
int* ptr = new int(42);
delete ptr;
delete ptr; // ❌ 이중 해제 → 크래시!
해결법:
// ✅ delete 후 nullptr 대입 (C++11 이전 관례)
delete ptr;
ptr = nullptr;
// 두 번째 delete는 nullptr에 대해 안전 (아무것도 안 함)
// ✅ 더 좋은 방법: 스마트 포인터 사용
auto ptr = std::make_unique<int>(42);
// delete 호출 불필요, 자동 해제
에러 5: use-after-free
문제 시나리오: delete 후에도 포인터를 사용하는 경우. 디버그 빌드에서는 “조용히” 동작하다가 릴리즈에서 크래시할 수 있습니다.
int* ptr = new int(42);
delete ptr;
*ptr = 100; // ❌ use-after-free! 정의되지 않은 동작
해결법:
auto ptr = std::make_unique<int>(42);
// ptr이 소멸되면 더 이상 접근 불가
// 또는 delete 후 ptr = nullptr; 하고 사용 전 검사
에러 6: 예외로 인한 누수
문제 시나리오: new와 delete 사이에 예외가 발생하면 delete에 도달하지 않습니다.
void process() {
int* data = new int[1000];
mayThrow(); // 예외 발생!
delete[] data; // 실행되지 않음
}
해결법: 스마트 포인터 또는 std::vector 사용 (위 예제 3 참고).
에러 7: 초기화되지 않은 포인터
문제 시나리오: 선언만 하고 할당하지 않은 포인터를 역참조하면 랜덤 메모리 접근으로 크래시합니다.
int* ptr; // ❌ 초기화 안 됨
*ptr = 42; // 크래시!
해결법:
int* ptr = nullptr; // ✅ 명시적 초기화
if (ptr) *ptr = 42; // 사용 전 검사
// 또는 스마트 포인터
auto ptr = std::make_unique<int>(42);
8. 메모리 디버깅 팁
Valgrind: 메모리 누수·오류 탐지
설치 (Linux/macOS):
# Ubuntu/Debian
sudo apt install valgrind
# macOS (Homebrew)
brew install valgrind
사용법:
# 메모리 누수 검사
valgrind --leak-check=full ./my_program
# 오류 메모리 접근 검사
valgrind --tool=memcheck ./my_program
해석: definitely lost는 메모리 누수, Invalid read는 use-after-free나 댕글링 포인터를 의미합니다.
AddressSanitizer (ASan): 빠른 메모리 오류 검사
장점: Valgrind보다 2~5배 빠르고, 오버헤드가 낮습니다. CI에서 자주 사용합니다. 사용법:
# 컴파일 시 -fsanitize=address 옵션
g++ -g -fsanitize=address -fno-omit-frame-pointer -o program program.cpp
# 실행
./program
해석: heap-use-after-free는 이미 해제된 메모리를 읽는 오류입니다. 스택 트레이스로 정확한 위치를 알 수 있습니다.
UndefinedBehaviorSanitizer (UBSan)
용도: 정수 오버플로우, 부동소수점 오류, null 포인터 역참조 등 정의되지 않은 동작 탐지.
g++ -g -fsanitize=undefined -o program program.cpp
GDB로 스택 오버플로우 디버깅
재현 시나리오: 재귀 함수가 크래시할 때 어디서 문제인지 확인합니다.
# 코어 덤프 생성
ulimit -c unlimited
./program # 크래시 후 core 파일 생성
# GDB로 분석
gdb ./program core
(gdb) bt # 백트레이스
(gdb) frame 5 # 특정 프레임으로 이동
(gdb) info locals # 지역 변수 확인
스택 크기 확인:
# Linux: 현재 스택 한도
ulimit -s
# macOS
ulimit -s
디버깅 체크리스트
| 도구 | 용도 | 언제 사용 |
|---|---|---|
| Valgrind | 누수, use-after-free | 로컬 개발, CI |
| AddressSanitizer | 힙 오버플로우, use-after-free | 빠른 검사, CI |
| UBSan | 정의되지 않은 동작 | 엣지 케이스 검증 |
| GDB | 크래시 분석 | 코어 덤프 분석 |
9. 프로덕션 메모리 패턴
패턴 1: 객체 풀 (Object Pool)
문제: 루프 안에서 반복 new/delete는 성능 저하와 단편화를 유발합니다.
해결: 미리 할당한 객체를 acquire/release로 재사용합니다.
#include <vector>
#include <memory>
struct Object { int id; std::vector<int> data; };
class ObjectPool {
std::vector<std::unique_ptr<Object>> pool_;
std::vector<Object*> available_;
public:
Object* acquire() {
if (available_.empty()) {
pool_.push_back(std::make_unique<Object>());
available_.push_back(pool_.back().get());
}
Object* obj = available_.back();
available_.pop_back();
return obj;
}
void release(Object* obj) { available_.push_back(obj); }
};
위 코드 설명: 게임 엔진, 네트워크 서버에서 자주 사용합니다.
패턴 2: 스택 기반 할당자 (Stack Allocator)
문제: 짧은 수명의 객체가 많을 때 힙 단편화가 심해집니다.
해결: 선형 버퍼에 순차 할당하고 reset()으로 일괄 해제합니다.
class StackAllocator {
std::unique_ptr<uint8_t[]> buffer_;
size_t size_, offset_ = 0;
public:
explicit StackAllocator(size_t n) : buffer_(std::make_unique<uint8_t[]>(n)), size_(n) {}
void* allocate(size_t n);
void reset() { offset_ = 0; } // 전체 해제
};
위 코드 설명: 프레임 렌더링, 파싱 등 “한 번에 처리 후 버림” 패턴에 적합합니다.
패턴 3: 스마트 포인터 선택 가이드
unique_ptr → 단일 소유권, "이 객체는 나만 가짐"
shared_ptr → 공유 소유권, "여러 곳에서 참조"
weak_ptr → shared_ptr 순환 참조 방지
실무 규칙:
- 기본은
unique_ptr(가장 가볍고 빠름) - 공유가 필요할 때만
shared_ptr shared_ptr순환 참조 시weak_ptr로 끊기
프로덕션 체크리스트
-
new/delete직접 사용 최소화 - 스마트 포인터(
unique_ptr,shared_ptr) 사용 - CI에서 AddressSanitizer 또는 Valgrind 실행
- 큰 버퍼(1KB 이상)는 힙 사용
- 재귀 깊이 제한 또는 반복문으로 변경
- 예외 발생 시에도 리소스 해제 보장 (RAII)
자주 묻는 질문 (FAQ)
스택과 힙의 가장 큰 차이는 무엇인가요?
스택은 자동으로 할당·해제되고 빠르지만 크기가 제한적(1-8MB)입니다. 힙은 수동으로 관리해야 하고 느리지만 시스템 메모리 한도까지 사용할 수 있습니다. 기본적으로 작은 지역 변수는 스택, 큰 데이터나 동적 할당은 힙을 사용합니다.
스택 오버플로우는 어떻게 예방하나요?
- 큰 배열(1KB 이상)은 힙에 할당하거나 std::vector 사용, 2) 재귀 함수는 깊이를 제한하거나 반복문으로 변환, 3) 필요시 스택 크기를 늘리는 방법이 있습니다. 가장 안전한 방법은 큰 데이터를 힙에 할당하는 것입니다.
언제 스택을 쓰고 언제 힙을 써야 하나요?
스택: 크기가 작고(수백 바이트 이하), 수명이 스코프 내로 명확하고, 성능이 중요할 때 사용합니다. 힙: 크기가 크거나(1KB 이상), 수명이 스코프를 넘어서거나, 런타임에 크기가 결정되거나, 다형성이 필요할 때 사용합니다.
스택이 힙보다 얼마나 빠른가요?
실제 벤치마크 결과 스택 할당은 약 1ns, 힙 할당은 50-100ns로 50-100배 차이가 납니다. 루프 안에서 반복 할당하는 경우 이 차이가 누적되어 큰 성능 차이를 만듭니다.
스택 변수의 주소를 반환하면 왜 안 되나요?
함수가 종료되면 스택 프레임이 정리되어 그 변수가 차지하던 메모리는 무효가 됩니다. 그 주소를 반환해서 사용하면 댕글링 포인터(dangling pointer)가 되어 정의되지 않은 동작(undefined behavior)이 발생합니다. 함수 밖에서 사용할 데이터는 힙에 할당하거나 값으로 반환해야 합니다.
같이 보면 좋은 글 (내부 링크)
이 주제와 연결되는 다른 글입니다.
- C++ 메모리 누수 | 서버 다운시킨 실제 사례와 Valgrind로 찾는 5가지 패턴
- C++ 스마트 포인터 | 3일 동안 찾지 못한 순환 참조 버그 해결법
- C++ RAII | “파일을 열 수 없습니다” 장애의 원인과 자동 리소스 관리
이 글에서 다루는 키워드 (관련 검색어)
C++ 스택 힙 차이, 스택 오버플로우, 메모리 구조, 스택 vs 힙, 포인터 기초, 메모리 할당, C++ 메모리 관리, 재귀 스택 오버플로우, 댕글링 포인터 등으로 검색하시면 이 글이 도움이 됩니다.
마무리
핵심 요약
✅ 스택: 빠르고 자동이지만 크기 제한 (1-8MB) ✅ 힙: 유연하고 크지만 수동 관리 필요 ✅ 기본은 스택: 작고 수명이 명확하면 스택 ✅ 큰 데이터는 힙: 1KB 이상이면 힙 고려 ✅ 성능 차이: 스택이 힙보다 50-100배 빠름 ✅ 스택 오버플로우 주의: 큰 배열, 깊은 재귀 피하기
실무 팁
- 기본은 스택 사용 (빠르고 안전)
- 1KB 이상은 힙 고려
- 재귀 함수는 깊이 제한
- 스택 변수 주소 반환 금지
- 힙 사용 시 스마트 포인터 필수
초보자를 위한 체크리스트
- 큰 배열·큰 구조체를 지역 변수로 두지 않았는가? (스택 한도 초과 위험)
- 재귀 깊이가 입력에 비례해 무한정 커지지 않는가? (필요하면 반복문·명시적 스택)
- 함수 안에서 만든 지역 배열의 주소를 반환하지 않았는가?
- 힙을 쓸 때는
new/delete짝을 맞추거나, 다음 글에서 다루는 스마트 포인터로 넘어갈 준비가 되었는가?
한 줄 요약: 크기가 작고 수명이 스코프 안이면 스택, 크거나 수명이 길면 힙을 쓰고, 재귀·큰 배열은 스택 한도를 넘지 않도록 하세요. 다음으로 메모리 누수(#6-2)를 읽어보면 좋습니다.
다음 글
스택과 힙의 차이를 이해했다면, 이제 힙 메모리를 안전하게 관리하는 방법을 배울 차례입니다. 다음 글: C++ 실전 가이드 #6-2: new/delete와 메모리 누수 완벽 분석 - 서버를 다운시킨 메모리 누수 사례와 해결법을 설명합니다.
참고 자료
관련 글
- C++ 스택 vs 힙 완벽 가이드 | 재귀 크래시, 메모리 레이아웃, RAII·스마트 포인터 실전 패턴
- C++ 메모리 누수 | 서버 다운시킨 실제 사례와 Valgrind로 찾는 5가지 패턴
- C++ Valgrind 완벽 가이드 | Memcheck·누수 탐지
- C++ 스마트 포인터 | 3일 동안 찾지 못한 순환 참조 버그 해결법
- C++ RAII |
심화 부록: 구현·운영 관점
이 부록은 앞선 본문에서 다룬 주제(「C++ 스택 vs 힙 | 재귀에서 프로그램이 죽는 이유와 스택 오버플로우 사례」)를 구현·런타임·운영 관점에서 다시 압축합니다. 도메인별 세부 구현은 글마다 다르지만, 입력 검증 → 핵심 연산 → 부작용(I/O·네트워크·동시성) → 관측의 흐름으로 장애를 나누면 원인 추적이 빨라집니다.
내부 동작과 핵심 메커니즘
flowchart TD A[입력·요청·이벤트] --> B[파싱·검증·디코딩] B --> C[핵심 연산·상태 전이] C --> D[부작용: I/O·네트워크·동시성] D --> E[결과·관측·저장]
sequenceDiagram participant C as 클라이언트/호출자 participant B as 경계(런타임·게이트웨이·프로세스) participant D as 의존성(API·DB·큐·파일) C->>B: 요청/이벤트 B->>D: 조회·쓰기·RPC D-->>B: 지연·부분 실패·재시도 가능 B-->>C: 응답 또는 오류(코드·상관 ID)
- 불변 조건(Invariant): 버퍼 경계, 프로토콜 상태, 트랜잭션 격리, FD 상한 등 단계별로 문장으로 적어 두면 디버깅 비용이 줄어듭니다.
- 결정성: 순수 층과 시간·네트워크·스케줄에 의존하는 층을 분리해야 테스트와 장애 분석이 쉬워집니다.
- 경계 비용: 직렬화, 인코딩, syscall 횟수, 락 경합, 할당·GC, 캐시 미스를 의심 목록에 둡니다.
- 백프레셔: 생산자가 소비자보다 빠를 때 버퍼·큐·스트림에서 속도를 줄이는 신호를 어디에 둘지 정의합니다.
프로덕션 운영 패턴
| 영역 | 운영 관점 질문 |
|---|---|
| 관측성 | 요청 단위 상관 ID, 에러율·지연 p95/p99, 의존성 타임아웃·재시도가 대시보드에 보이는가 |
| 안전성 | 입력 검증·권한·비밀·감사 로그가 코드 경로마다 일관적인가 |
| 신뢰성 | 재시도는 멱등 연산에만 적용되는가, 서킷 브레이커·백오프·DLQ가 있는가 |
| 성능 | 캐시·배치 크기·커넥션 풀·인덱스·백프레셔가 데이터 규모에 맞는가 |
| 배포 | 롤백 룬북, 카나리/블루그린, 마이그레이션·피처 플래그가 문서화되어 있는가 |
| 용량 | 피크 트래픽·디스크·FD·스레드 풀 상한을 주기적으로 검증하는가 |
스테이징은 데이터 양·네트워크 RTT·동시성을 프로덕션에 가깝게 맞출수록 재현율이 올라갑니다.
확장 예시: 엔드투엔드 미니 시나리오
앞선 본문 주제(「C++ 스택 vs 힙 | 재귀에서 프로그램이 죽는 이유와 스택 오버플로우 사례」)를 배포·운영 흐름에 맞춰 옮긴 체크리스트입니다. 도메인에 맞게 단계 이름만 바꿔 적용할 수 있습니다.
- 입력 계약 고정: 스키마·버전·최대 페이로드·타임아웃·에러 코드를 경계에 둔다.
- 핵심 경로 계측: 요청 ID, 단계별 지연, 외부 호출 결과 코드를 로그·메트릭·트레이스에서 한 흐름으로 본다.
- 실패 주입: 의존성 타임아웃·5xx·부분 데이터·락 대기를 스테이징에서 재현한다.
- 호환·롤백: 설정/마이그레이션/클라이언트 버전을 되돌릴 수 있는지 확인한다.
- 부하 후 검증: 피크 대비 p95/p99, 에러율, 리소스 상한, 알림 임계값을 점검한다.
handle(request):
ctx = newCorrelationId()
validated = validateSchema(request)
authorize(validated, ctx)
result = domainCore(validated)
persistOrEmit(result, idempotentKey)
recordMetrics(ctx, latency, outcome)
return result
문제 해결(Troubleshooting)
| 증상 | 가능 원인 | 조치 |
|---|---|---|
| 간헐적 실패 | 레이스, 타임아웃, 외부 의존성, DNS | 최소 재현 스크립트, 분산 트레이스·로그 상관관계, 재시도·서킷 설정 점검 |
| 성능 저하 | N+1, 동기 I/O, 락 경합, 과도한 직렬화, 캐시 미스 | 프로파일러·APM으로 핫스팟 확인 후 한 가지씩 제거 |
| 메모리 증가 | 캐시 무제한, 구독/리스너 누수, 대용량 버퍼, 커넥션 미반납 | 상한·TTL·힙/FD 스냅샷 비교 |
| 빌드·배포만 실패 | 환경 변수, 권한, 플랫폼 차이, lockfile | CI 로그와 로컬 diff, 런타임·이미지 버전 핀 |
| 설정 불일치 | 프로필·시크릿·기본값, 리전 | 스키마 검증된 설정 단일 소스와 배포 매트릭스 표준화 |
| 데이터 불일치 | 비멱등 재시도, 부분 쓰기, 캐시 무효화 누락 | 멱등 키·아웃박스·트랜잭션 경계 재검토 |
권장 순서: (1) 최소 재현 (2) 최근 변경 범위 축소 (3) 환경·의존성 차이 (4) 관측으로 가설 검증 (5) 수정 후 회귀·부하 테스트.
배포 전에는 git add → git commit → git push 후 npm run deploy 순서를 권장합니다.