🟠 코틀린↗ 🔌 C · 임베디드↗ 🦀 Rust↗ 🐍 파이썬↗ ☕ 자바↗ 🟨 JS·TS↗ 🟣 C#↗ 🗄️ DB↗ 🌐 서버기술↗ 🎓 CS 기술↗
👋 처음이신가요? 이 가이드는 C 와 C++ 를 한자리에서 다룹니다 — 기초 문법 · 백엔드 서버 · 게임 서버 · 고성능 시스템, 그리고 파이썬 · Node 에서 불러 쓸 모듈까지 · 🔍 검색(Ctrl K)에 “게임 서버 틱”처럼 하려는 일을 한글로 입력 · 코드 상자에 마우스를 올리면 복사 버튼이 나타납니다
C17 · C23 · 포인터 · 메모리 · 미정의 동작 · POSIX · 소켓 · 스레드 · 임베디드

C 를 알아야
C++ 가
이해됩니다

C++ 를 배우다 막히는 지점은 대부분 C 에서 물려받은 부분입니다 — 포인터, 배열이 포인터로 변하는 규칙, 널로 끝나는 문자열, 헤더와 링커, 그리고 미정의 동작. 이 탭은 그 바닥을 따로 떼어 정면으로 다룹니다. C ABI 로 라이브러리를 내보내거나, 커널·임베디드·기존 C 코드베이스를 만질 때도 그대로 쓰입니다. 앞의 열두 절로 언어를 익히고 나면, 뒤의 열네 절에서 표준 라이브러리 · 자료구조 · 문자열 · POSIX · 소켓 · 스레드 · 프로세스 · C23 · 임베디드 · 라이브러리 배포 · 테스트 · 빌드 · 생태계까지 C 로 실제 물건을 만드는 데 필요한 전부를 이어서 봅니다.

26SECTIONS
C17기준 표준 (C23 표기)
0사전 지식
100%복붙해서 도는 예제
C 01

C 는 왜 아직도 여기 있나

1972년에 나온 언어가 2020년대에도 리눅스 커널 · 파이썬 인터프리터 · SQLite · 모든 운영체제의 바닥을 차지하고 있습니다. 낡아서 남아 있는 게 아니라 대체할 자리가 없어서 남아 있습니다.

C 가 지금도 쓰이는 네 자리

자리이유
운영체제 · 드라이버하드웨어 주소를 있는 그대로 다뤄야 합니다
언어 런타임파이썬 · 루비 · PHP 가 전부 C 로 구현돼 있습니다
임베디드 · 펌웨어램이 수십 KB 인 칩에서는 C 밖에 안 돕니다
언어 사이의 다리C ABI 가 모든 언어의 공용어입니다 — FFI 탭의 주제
이 탭을 읽어야 하는 실질적인 이유 — 다른 언어로 부를 라이브러리를 만들려면 결국 C 인터페이스를 설계하게 됩니다. 안은 C++ 로 쓰더라도 경계는 C 입니다.

C 와 C++ 는 얼마나 다른가

C 코드는 대부분 C++ 컴파일러로도 컴파일됩니다. 하지만 "C++ 는 C 의 상위 집합"이라는 말은 정확하지 않습니다 — void* 암묵 변환, 구조체 초기화, bool, new 같은 이름 등에서 실제로 어긋납니다.

더 중요한 차이는 문법이 아니라 사고방식입니다. C 에는 소멸자도, RAII 도, 템플릿도 없습니다. 모든 자원 관리를 사람이 직접 합니다. 그래서 규율이 필요하고, 그 규율이 무너진 자리에서 세상의 보안 취약점 절반이 나왔습니다.

환경 만들기 — 셋 중 하나면 됩니다

리눅스 · WSL

sudo apt install build-essential gdb
gcc --version

# 컴파일 (경고를 최대로)
gcc -std=c17 -Wall -Wextra -g hello.c -o hello
./hello

macOS

xcode-select --install
clang --version

clang -std=c17 -Wall -Wextra -g hello.c -o hello
./hello

윈도우

# 방법 A — MSYS2 (gcc 를 그대로)
pacman -S mingw-w64-ucrt-x86_64-gcc

# 방법 B — Visual Studio (MSVC)
cl /std:c17 /W4 hello.c

첫 프로그램 — 한 줄씩 뜯어보기

#include <stdio.h>      // printf 의 '선언'만 가져옵니다 (구현은 링크 단계)

int main(void) {        // (void) = 인자를 받지 않는다는 명시
    printf("안녕, C\n");
    return 0;           // 0 = 정상 종료. 셸이 이 값을 읽습니다
}

int main() 와 int main(void) 는 C 에서 의미가 다릅니다. 앞의 것은 "인자 정보를 모른다"이고, 뒤의 것이 "인자가 없다"입니다. C++ 에서는 둘이 같아서 헷갈리기 쉽습니다.

경고 플래그를 반드시 켜세요

# 처음부터 이 조합으로 시작하세요
gcc -std=c17 -Wall -Wextra -Wpedantic \
    -Wshadow -Wconversion -g -O1 main.c -o app

# 개발 중에는 새니타이저까지
gcc -std=c17 -Wall -Wextra -g \
    -fsanitize=address,undefined main.c -o app
C 는 기본 설정이 아무것도 안 알려 줍니다. 경고를 안 켜면 if (x = 1) 같은 오타가 조용히 컴파일됩니다. 경고 플래그는 선택이 아니라 필수입니다.
표준 버전 — 무엇을 고를까. 실무 기본값은 C11 또는 C17입니다(둘은 거의 같습니다). C99 는 오래된 임베디드 툴체인 때문에 아직 쓰이고, C23 은 true/false 내장, nullptr, typeof 같은 편한 것들이 들어왔지만 컴파일러 지원이 아직 고르지 않습니다.
C 02

정수 — 크기가 정해져 있지 않습니다

파이썬 정수는 무한대까지 커지고, 자바 int 는 항상 32비트입니다. C 의 int 는 "최소 16비트, 나머지는 구현에 따라"입니다. 이 한 문장에서 나오는 버그가 아주 많습니다.

표준이 보장하는 것은 최소 크기뿐

타입표준 보장보통(64비트 리눅스)
char1바이트1 · 부호는 구현 정의
short≥ 16비트2
int≥ 16비트4
long≥ 32비트8 (윈도우는 4)
long long≥ 64비트8
size_t크기 표현용8 · 부호 없음
long 은 리눅스에서 8, 윈도우에서 4바이트입니다. 플랫폼 사이를 오가는 구조체에 long 을 쓰면 그 자체가 버그입니다.

그래서 실무에서는 stdint.h

#include <stdint.h>
#include <inttypes.h>   // 출력 형식 매크로

int32_t  a = -5;         // 정확히 32비트, 부호 있음
uint64_t b = 1ULL << 40; // 정확히 64비트, 부호 없음
size_t   n = 100;        // 길이 · 인덱스는 항상 size_t

// 출력할 때도 타입에 맞는 형식을 써야 합니다
printf("%" PRId32 " %" PRIu64 " %zu\n", a, b, n);

// ❌ printf("%d", n);   // size_t 를 %d 로 → UB

파일 형식 · 네트워크 프로토콜 · 다른 언어와 주고받는 구조체에는 반드시 고정 크기 타입을 쓰세요.

부호 있는 오버플로는 UB, 부호 없는 것은 순환

❌ 무엇이 일어날지 알 수 없다
int x = INT_MAX;
x = x + 1;          // 부호 있는 오버플로 = 미정의 동작

// 컴파일러는 "오버플로는 일어나지 않는다"고 가정하고
// 최적화하므로, 아래 검사가 통째로 삭제될 수 있습니다
if (x + 1 < x) { /* 여기는 영영 실행되지 않을 수 있음 */ }
✅ 넘치기 전에 검사한다
#include <limits.h>
if (a > 0 && b > INT_MAX - a) { /* 넘칠 것이다 */ }
else                            { int c = a + b; }

// GCC · Clang 내장 함수를 쓰면 더 간단합니다
int c;
if (__builtin_add_overflow(a, b, &c)) { /* 넘침 처리 */ }

// 부호 없는 타입은 넘침이 UB 가 아니라 '2^n 으로 나눈 나머지'
unsigned u = UINT_MAX; u++;   // 정의된 동작: 0 이 됩니다
부호 있는 값과 없는 값을 비교하면 조용히 뒤집힙니다. int i = -1; size_t n = 3; if (i < n) 는 거짓입니다 — i 가 거대한 부호 없는 수로 변환되기 때문입니다. -Wsign-compare(-Wextra 에 포함)를 켜 두세요.

char 의 부호는 정해져 있지 않다

x86 에서는 부호 있음, ARM 에서는 부호 없음인 경우가 많습니다. 바이트를 다룰 때는 unsigned char 나 uint8_t 를 명시하세요.

정수 승격이 먼저 일어난다

uint8_t a=200, b=100; a+b 는 uint8_t 가 아니라 int 로 계산돼 300 이 됩니다. 다시 담을 때 잘리므로 중간 타입을 항상 의식해야 합니다.

나눗셈과 시프트

음수를 오른쪽으로 시프트하는 것은 구현 정의이고, 시프트 폭이 타입 비트 수 이상이면 UB 입니다. x >> 32 (32비트 int) 는 쓰면 안 됩니다.

C 03

배열과 포인터 — 같아 보이지만 다릅니다

"C 에서 배열은 포인터다"라는 말은 절반만 맞습니다. 배열은 배열이고, 대부분의 자리에서 포인터로 변할 뿐입니다. 이 '변한다'가 어디서 일어나는지 아는 것이 C 를 다루는 핵심입니다.

배열을 함수에 넘기는 순간 — 크기 정보가 사라집니다 ① 선언한 함수 안에서는 배열 int a[10]; 10칸이 붙어 있는 실체 sizeof(a) == 40 — 전체 크기를 압니다 ② 함수 인자로 넘기면 포인터 void f(int a[10]) — 사실은 int* 주소 하나 sizeof(a) == 8 — 10 이라 써도 무시됩니다 그래서 C 함수의 규칙은 늘 이 모양입니다 void sum(const int* a, size_t n); 길이를 반드시 함께 받습니다 — 표준 라이브러리 함수가 전부 이렇게 생긴 이유입니다 배열을 매개변수로 받은 함수 안에서 sizeof 로 길이를 구하려는 시도가 초보자의 1번 실수입니다.
이 규칙 하나가 버퍼 오버플로가 C 에서 흔한 이유이기도 합니다 — 언어가 길이를 안 들고 다닙니다.

배열이 포인터로 변하지 않는 자리

int a[10];

sizeof(a);      // 40  — 여기서는 배열 그대로
&a;             // int(*)[10] — 배열 전체의 주소 (타입이 다릅니다!)
_Alignof(a);    // 정렬도 배열 기준 (식에 쓰는 건 GCC 확장 — 표준은 _Alignof(int[10]))

// 나머지는 전부 첫 원소의 주소로 변합니다
int* p = a;     // == &a[0]
f(a);           // f 는 int* 를 받습니다

// &a 와 a 는 값은 같지만 타입이 다릅니다
a + 1;          // 4바이트 앞
&a + 1;         // 40바이트 앞 (!)

포인터 산술의 단위는 '타입 크기'

int  a[5] = {10,20,30,40,50};
int* p = a;

*(p + 2);       // 30 — 2 는 '2바이트'가 아니라 '원소 2개'(= 8바이트)
p[2];           // 정확히 같은 뜻 (a[i] == *(a+i))
2[a];           // 놀랍게도 이것도 합법입니다 (쓰지 마세요)

// 두 포인터의 차이는 '원소 개수'
ptrdiff_t d = &a[4] - &a[1];    // 3

// ⚠ 배열 범위를 넘은 포인터 연산 자체가 UB 입니다
int* q = a + 5;   // 끝 바로 뒤까지는 합법 (역참조는 불가)
int* r = a + 6;   // ❌ UB

2차원 배열 — 진짜 2차원과 포인터 배열은 다릅니다

연속된 진짜 2차원

int m[3][4];            // 12개가 한 덩어리로 연속

m[1][2];                // == *(*(m+1)+2)
sizeof(m);              // 48
sizeof(m[0]);           // 16 (한 행)

// 함수로 넘길 때 열 수는 반드시 알려야 합니다
void f(int m[][4], int rows);
void g(int (*m)[4], int rows);   // 같은 뜻
메모리가 연속이라 캐시에 유리합니다. 크기를 컴파일 때 아는 경우 이쪽이 정답입니다.

포인터 배열 (흩어진 행)

int** m = malloc(3 * sizeof(int*));
for (int i = 0; i < 3; i++)
    m[i] = malloc(4 * sizeof(int));   // 행마다 따로 할당

m[1][2];                // 두 번 역참조 — 캐시 미스가 늘어납니다

// 해제도 역순으로 전부
for (int i = 0; i < 3; i++) free(m[i]);
free(m);
int** 와 int[3][4] 는 호환되지 않습니다. 서로 넘기면 컴파일은 경고만 내고 실행에서 죽습니다.
실무에서 가장 편한 절충 — 1차원 배열 하나를 잡고 idx = row * cols + col 로 직접 계산하는 방식입니다. 연속성도 지키고, 함수 인자로 넘기기도 쉽고, 해제도 한 번이면 끝납니다. 수치 계산 코드가 대부분 이 방식을 씁니다.
C 04

문자열 — 널 하나로 끝을 표시합니다

C 문자열은 타입이 아니라 약속입니다 — "'\0' 을 만날 때까지가 문자열이다". 길이를 들고 다니지 않는 이 설계에서 지난 50년 보안 취약점의 상당수가 나왔습니다.

"hello" 는 5바이트가 아니라 6바이트입니다 'h' 'e' 'l' 'l' 'o' '\0' ← 끝 표시 strlen 은 이 널을 찾을 때까지 한 바이트씩 셉니다 — 길이를 구하는 것이 O(n) 입니다 버퍼보다 긴 문자열을 복사하면 buf[8] — 우리가 잡은 공간 옆 지역 변수 저장된 복귀 주소 strcpy 는 널을 만날 때까지 계속 씁니다 — 여기까지 전부 덮어씁니다 복귀 주소가 바뀌면 함수가 끝날 때 공격자가 원하는 코드로 점프합니다 — 스택 버퍼 오버플로 공격의 원리입니다.
그래서 C 에서 문자열을 다룰 때는 길이를 언제나 손에 쥐고 있어야 합니다.

쓰면 안 되는 함수와 대안

쓰지 말 것대신
getsC11 에서 표준에서 삭제됨 — fgets
strcpysnprintf(dst, cap, "%s", src)
strcat길이를 세어 snprintf 로 한 번에
sprintfsnprintf — 상한을 반드시 지정
strncpy널을 안 붙일 수 있습니다 — 이름과 달리 안전하지 않음
strncpy 는 안전한 strcpy 가 아닙니다. 원본이 상한보다 길면 널 종료를 붙이지 않고 끝냅니다. 그 뒤 strlen 을 부르면 버퍼 밖으로 달려 나갑니다.

실무에서 쓰는 패턴

#include <stdio.h>
#include <string.h>

char buf[64];

// ① 만들기 — snprintf 하나면 충분합니다
int n = snprintf(buf, sizeof buf, "%s-%d", name, id);
if (n < 0 || (size_t)n >= sizeof buf) {
    /* 잘렸습니다. n 은 '필요했던 길이'를 알려 줍니다 */
}

// ② 읽기 — fgets 는 개행까지 함께 담습니다
if (fgets(buf, sizeof buf, stdin)) {
    buf[strcspn(buf, "\n")] = '\0';   // 개행 제거 관용구
}

// ③ 비교 — == 는 주소 비교입니다!
if (strcmp(a, b) == 0) { /* 같다 */ }

문자열 리터럴은 읽기 전용입니다

❌ 리터럴을 고치려 하면 죽습니다
char* s = "hello";   // 읽기 전용 영역을 가리킴
s[0] = 'H';          // 💥 세그멘테이션 폴트 (UB)

// C++ 에서는 이 대입 자체가 컴파일 에러입니다
// C 에서는 경고만 나거나 아예 안 납니다
✅ 고칠 것은 배열로 복사해 둡니다
char s[] = "hello";  // 6바이트를 스택에 복사
s[0] = 'H';          // ✅ 괜찮습니다

// 고치지 않을 것은 const 를 붙여 의도를 밝힙니다
const char* msg = "hello";   // 컴파일러가 막아 줍니다

한글은 한 글자가 아닙니다

UTF-8 에서 한글 한 글자는 3바이트입니다. strlen("한글") 은 6 을 돌려줍니다. 바이트 수와 글자 수를 구분하세요 — 자르다가 깨지는 사고의 원인입니다.

POSIX · BSD 확장

strdup(복사본 할당) 은 POSIX, strlcpy/strlcat(항상 널 종료) 은 BSD·macOS 계열입니다. 이식성이 필요하면 snprintf 로 통일하세요.

길이를 들고 다니는 습관

큰 프로젝트는 대부분 { char* p; size_t len; } 같은 자체 문자열 구조체를 만들어 씁니다. 길이를 잃지 않는 것만으로 버그가 크게 줄어듭니다.

C 05

메모리 — malloc 과 그 다섯 가지 함정

C 에는 가비지 컬렉터도, 소멸자도, unique_ptr 도 없습니다. 내가 잡은 것은 내가 놓아야 하고, 놓은 뒤에는 다시 쳐다보지 말아야 합니다. 규칙은 두 줄이지만, 지키는 일이 어렵습니다.

기본 네 함수

#include <stdlib.h>

int* p = malloc(n * sizeof *p);   // 쓰레기 값으로 채워져 있음
if (!p) { /* 실패 처리 — 반드시 검사 */ }

int* q = calloc(n, sizeof *q);    // 0 으로 초기화 + 곱셈 넘침 검사

int* r = realloc(p, m * sizeof *p);
if (!r) { free(p); return -1; }   // ⚠ 실패해도 p 는 살아 있습니다
p = r;                            //   바로 대입하면 p 를 잃습니다

free(p);
p = NULL;                         // 해제 후 NULL — 습관으로 만드세요
sizeof *p 를 쓰세요. sizeof(int) 라고 쓰면 나중에 타입을 바꿀 때 여기를 같이 고쳐야 합니다. *p 로 쓰면 타입이 바뀌어도 자동으로 따라옵니다.

다섯 가지 함정

이름증상
메모리 누수free 를 안 함 — 오래 도는 프로세스가 서서히 죽습니다
해제 후 사용free 뒤에 접근 — 보안 취약점 1위
이중 해제같은 것을 두 번 free — 힙 구조가 깨집니다
버퍼 넘침잡은 크기 밖에 쓰기 — 옆 데이터가 조용히 바뀝니다
미초기화 읽기malloc 직후의 값을 그냥 씀 — 실행마다 결과가 달라집니다
다섯 개 전부 ASan 이 잡아 줍니다. 개발과 CI 에서 -fsanitize=address 를 켜는 것이 이 절의 결론입니다.
힙에서 벌어지는 네 가지 시나리오 정상 — 잡고 · 쓰고 · 놓고 · NULL malloc → 사용 → free → p = NULL. 이 네 단계가 한 함수 안에서 보이면 실수하기 어렵습니다 누수 — free 를 빠뜨림 블록이 힙에 영영 남습니다. 요청마다 조금씩 새면 며칠 뒤 OOM 으로 죽습니다 — 재현이 가장 어려운 버그 해제 후 사용 — 반납한 자리를 계속 가리킴 그 자리는 곧 다른 데이터가 차지합니다. 값이 멀쩡해 보이다가 어느 순간부터 이상해집니다 이중 해제 — 같은 블록을 두 번 반납 힙 관리 자료구조 자체가 깨져, 전혀 관계없는 나중의 malloc 에서 크래시가 납니다 세 가지 모두 free 직후 포인터를 NULL 로 만드는 습관 하나로 크게 줄어듭니다 — free(NULL) 은 안전합니다.
"크래시가 난 자리"와 "버그가 있는 자리"가 완전히 다른 것이 힙 버그의 특징입니다.

소유권을 코드로 표현하는 방법

함수 이름과 주석으로 규약을 밝힌다

/* 반환값의 소유권은 호출자에게 넘어갑니다.
   다 쓴 뒤 buffer_free() 로 해제하세요. */
Buffer* buffer_create(size_t cap);
void    buffer_free(Buffer* b);

/* 반환값은 내부 버퍼를 가리킵니다.
   해제하지 마세요. b 가 살아 있는 동안만 유효합니다. */
const char* buffer_data(const Buffer* b);

C 에는 소유권을 표현할 문법이 없습니다. 이름 규칙(create/free 짝)과 const 가 유일한 도구입니다.

에러 처리 — goto 가 정답인 드문 자리

int load(const char* path, Result* out) {
    FILE*  f   = NULL;
    char*  buf = NULL;
    int    rc  = -1;

    f = fopen(path, "rb");
    if (!f)   goto done;

    buf = malloc(SIZE);
    if (!buf) goto done;

    if (fread(buf, 1, SIZE, f) != SIZE) goto done;

    out->data = buf;  buf = NULL;   // 소유권을 넘겼으니 여기서 비웁니다
    rc = 0;

done:
    free(buf);                     // NULL 이면 아무 일도 안 합니다
    if (f) fclose(f);
    return rc;
}
C 에는 finally 도 소멸자도 없습니다. 정리 지점을 한 곳으로 모으는 goto 가 커널을 포함한 대형 C 코드베이스의 표준 관용구입니다.

malloc 반환값 캐스팅

C 에서는 (int*)malloc(...) 처럼 캐스팅하지 않는 것이 권장됩니다. 캐스팅이 stdlib.h 를 빠뜨린 실수를 가려 버리기 때문입니다. (C++ 에서는 반대로 캐스팅이 필수입니다.)

스택에 잡는 것이 더 낫다

크기가 작고 함수 안에서만 쓴다면 지역 배열이 훨씬 빠르고 안전합니다. 스택은 보통 1~8MB 이므로, 수 KB 까지는 고민하지 말고 스택에 두세요.

아레나 · 풀 할당

수명이 같은 객체가 많다면 큰 덩어리를 한 번 잡고 잘라 쓰다가 한 번에 버리는 방식이 빠르고 누수도 없습니다. 게임·컴파일러에서 널리 쓰는 기법입니다.

C 06

구조체 · 정렬과 패딩

구조체 크기는 필드 크기의 합이 아닙니다. CPU 가 좋아하는 위치에 맞추려고 컴파일러가 빈칸을 끼워 넣기 때문입니다. 파일 형식이나 네트워크 프로토콜을 구조체로 다루려면 이 규칙을 알아야 합니다.

필드 순서만 바꿔도 구조체 크기가 달라집니다 ① char · int · char 순서 — 12바이트 a 빈칸 3바이트 n (int · 4바이트) b 빈칸 3바이트 빈칸이 6바이트 — 실제 데이터는 6바이트인데 크기는 12바이트입니다 ② 큰 것부터 — 8바이트 n (int · 4바이트) a b 빈칸 2 빈칸이 2바이트로 줄었습니다 — 구조체를 백만 개 만들면 4MB 차이입니다 규칙은 하나입니다 — 각 필드는 자기 크기의 배수 위치에서 시작해야 합니다 그리고 구조체 전체 크기는 가장 큰 필드 크기의 배수로 맞춰집니다(배열로 놓을 수 있어야 하므로)
멤버를 크기가 큰 것부터 선언하는 것만으로 메모리가 줄어듭니다 — 공짜 최적화입니다.

확인하는 법

#include <stddef.h>
#include <stdio.h>

struct A { char a; int n; char b; };

printf("%zu\n", sizeof(struct A));          // 12
printf("%zu\n", offsetof(struct A, n));     // 4  ← 빈칸 3 뒤
printf("%zu\n", _Alignof(struct A));        // 4

// 컴파일 때 못 박아 두는 방법 (C11)
_Static_assert(sizeof(struct A) == 12, "레이아웃이 바뀌었습니다");

_Static_assert 를 걸어 두면, 누군가 필드를 추가해 레이아웃을 바꿨을 때 빌드가 막아 줍니다. 파일 형식·프로토콜 구조체에는 꼭 넣으세요.

빈칸을 없애는 packed — 그리고 대가

// GCC · Clang
struct __attribute__((packed)) Header {
    uint8_t  type;
    uint32_t length;      // 1바이트 위치에서 시작 — 정렬이 깨집니다
};

// MSVC
#pragma pack(push, 1)
struct Header { uint8_t type; uint32_t length; };
#pragma pack(pop)
packed 는 마지막 수단입니다. 정렬되지 않은 접근은 x86 에서는 느려지기만 하지만 일부 ARM 에서는 크래시합니다. 파일에서 읽을 때는 바이트를 하나씩 읽어 조립하는 편이 이식성과 안전성 모두에서 낫습니다.

공용체와 비트필드

union — 같은 자리를 여러 타입으로

// 크기는 '가장 큰 멤버' 하나 — 동시에 하나만 유효합니다
typedef struct {
    enum { INT, STR, DBL } tag;    // 무엇이 들었는지 표시
    union { int i; char* s; double d; } v;
} Value;

Value x = { .tag = INT, .v.i = 42 };

switch (x.tag) {
    case INT: printf("%d\n", x.v.i); break;
    /* ... */
}
넣은 것과 다른 멤버를 읽는 것은 C 에서 허용되지만(타입 펀), C++ 에서는 UB 입니다. 태그를 같이 두는 습관이 안전합니다.

비트필드 — 플래그를 촘촘히

struct Flags {
    unsigned enabled  : 1;
    unsigned mode     : 3;   // 0~7
    unsigned reserved : 4;
};   // 쓰는 비트는 8개 — 단 sizeof 는 보통 4 (unsigned 단위로 잡힘)

읽기 쉽지만 비트 배치 순서가 구현 정의입니다. 하드웨어 레지스터나 통신 프로토콜에는 직접 시프트와 마스크를 쓰는 편이 이식성이 높습니다.

구조체를 그대로 파일에 쓰거나 소켓으로 보내지 마세요. 패딩 · 엔디안 · 타입 크기가 기계마다 다릅니다. 필드마다 명시적으로 직렬화하는 코드를 쓰는 것이 결국 가장 빠른 길입니다.
C 07

함수 포인터 — C 가 다형성을 흉내 내는 법

C 에는 인터페이스도 가상 함수도 없습니다. 그 자리를 함수 포인터가 채웁니다. 리눅스 커널의 파일 시스템, qsort, 이벤트 콜백이 전부 이 하나로 돌아갑니다.

문법 — 읽는 순서만 익히면 됩니다

int add(int a, int b) { return a + b; }

// 이름부터 안쪽으로 읽습니다: f 는 → 포인터 → 함수 → int 반환
int (*f)(int, int) = add;      // &add 라고 써도 같습니다
int r = f(2, 3);               // (*f)(2,3) 과 동일

// typedef 로 감싸면 훨씬 읽힙니다
typedef int (*BinOp)(int, int);
BinOp ops[] = { add, sub, mul };
int r2 = ops[0](2, 3);

// 괄호를 빼면 뜻이 완전히 달라집니다
int* g(int, int);              // int* 를 반환하는 함수 (포인터 아님)

qsort — 표준 라이브러리의 예

#include <stdlib.h>

int cmp_int(const void* a, const void* b) {
    int x = *(const int*)a, y = *(const int*)b;
    return (x > y) - (x < y);      // 뺄셈은 오버플로 위험이 있습니다
}

int a[5] = {3,1,4,1,5};
qsort(a, 5, sizeof a[0], cmp_int);
return x - y; 로 쓰면 안 됩니다. 큰 값들에서 정수 오버플로가 나서 정렬이 조용히 틀어집니다. 위처럼 비교 결과의 차로 쓰세요.
함수 포인터 표 — C++ vtable 을 손으로 만든 것과 같습니다 공통 코드 s->ops->read(s, buf, n) 어떤 구현인지 모릅니다 ops 표 read · write · close 함수 주소 세 개 파일 구현 소켓 구현 메모리 버퍼 구현 콜백에는 사용자 데이터 포인터를 반드시 함께 받으세요 void on_event(Event* e, void* user); — 이 void* 가 없으면 전역 변수로 상태를 넘기게 됩니다 함수 포인터와 데이터 포인터는 원칙적으로 다른 종류입니다 void* 에 함수 주소를 담는 것은 표준상 보장되지 않습니다 — dlsym 을 쓸 때 흔히 마주치는 경고입니다
C++ 의 virtual 은 컴파일러가 이 표를 대신 만들어 주는 것일 뿐입니다.

인터페이스 흉내 — 전체 모습

typedef struct Stream Stream;

typedef struct {
    ssize_t (*read )(Stream*, void*, size_t);
    ssize_t (*write)(Stream*, const void*, size_t);
    void    (*close)(Stream*);
} StreamOps;

struct Stream {
    const StreamOps* ops;   // 첫 필드에 두는 것이 관례
    /* 구현마다 다른 상태가 뒤에 옵니다 */
};

// 부르는 쪽은 구현을 전혀 모릅니다
ssize_t n = s->ops->read(s, buf, sizeof buf);

쓸 때 주의할 것

  • NULL 검사 — 표의 함수가 비어 있을 수 있습니다
  • 시그니처 불일치 — 잘못된 타입으로 부르면 UB. typedef 로 고정하세요
  • 수명 — 콜백에 넘긴 void* 가 콜백이 불릴 때까지 살아 있어야 합니다
  • C ABI 경계 — 다른 언어에서 넘어온 콜백은 호출 규약이 맞아야 합니다
  • 등록 해제 — 콜백을 등록했으면 해제하는 함수도 함께 제공하세요
C 08

파일 입출력과 에러 처리

C 의 에러 처리는 반환값과 errno 뿐입니다. 예외가 없으니 모든 호출의 반환값을 확인해야 하고, 확인하지 않은 한 줄이 나중에 사고가 됩니다.

텍스트 읽기 — 한 줄씩

#include <stdio.h>
#include <errno.h>
#include <string.h>

FILE* f = fopen("data.txt", "r");
if (!f) {
    fprintf(stderr, "열기 실패: %s\n", strerror(errno));
    return -1;
}

char line[256];
while (fgets(line, sizeof line, f)) {
    line[strcspn(line, "\n")] = '\0';
    /* 처리 */
}
if (ferror(f)) { /* 읽기 도중 오류 */ }

fclose(f);
feof(f) 를 루프 조건으로 쓰면 마지막 줄이 두 번 처리됩니다. feof 는 "읽기를 시도해서 실패한 뒤"에 참이 되기 때문입니다. 위처럼 읽기 함수의 반환값을 조건으로 쓰세요.

바이너리 읽기 — 통째로

FILE* f = fopen("blob.bin", "rb");   // ⚠ 윈도우에서 "b" 는 필수
if (!f) return -1;

fseek(f, 0, SEEK_END);
long size = ftell(f);                // 2GB 넘으면 부족합니다
if (size < 0) { fclose(f); return -1; }   // ftell 실패는 -1
fseek(f, 0, SEEK_SET);

unsigned char* buf = malloc((size_t)size);
if (!buf) { fclose(f); return -1; }
size_t got = fread(buf, 1, (size_t)size, f);
if (got != (size_t)size) { /* 짧게 읽힘 */ }

fclose(f);
free(buf);

큰 파일에는 fseeko/ftello(POSIX) 또는 메모리 매핑(mmap·CreateFileMapping)을 씁니다.

규칙이유
errno 는 즉시 읽는다다음 라이브러리 호출이 덮어씁니다. 실패 직후에 저장하세요
성공 시 errno 는 의미 없다성공해도 값이 바뀔 수 있습니다 — 반환값으로 먼저 판단하세요
fclose 도 실패할 수 있다버퍼를 비우다 디스크가 꽉 차면 여기서 실패합니다. 쓰기 파일은 반환값을 보세요
바이너리는 "b" 모드윈도우에서 텍스트 모드는 \n 을 \r\n 으로 바꿉니다
stdout 은 줄 단위 버퍼파이프로 넘기면 블록 버퍼가 됩니다 — 크래시하면 로그가 통째로 사라집니다
에러는 stderr 로버퍼링이 없고, 리다이렉트로 분리할 수 있습니다

반환값 규약을 하나로

프로젝트 안에서 "0 성공 · 음수 에러" 처럼 한 가지로 통일하세요. 어떤 함수는 0 이 성공이고 어떤 함수는 0 이 실패면 호출부에서 반드시 헷갈립니다.

부분 읽기 · 부분 쓰기

read/write(POSIX)는 요청한 만큼 다 처리하지 않을 수 있습니다. 소켓과 파이프에서 특히 그렇습니다 — 루프로 감싸는 것이 기본입니다.

경로와 인코딩

윈도우에서 한글 경로는 fopen 으로 안 열릴 수 있습니다. _wfopen 을 쓰거나 UTF-8 매니페스트를 켜야 합니다 — 크로스 플랫폼 코드에서 자주 만나는 함정입니다.

C 09

전처리기 — 강력하고 위험합니다

#define 은 문법을 아는 도구가 아니라 텍스트 치환기입니다. 컴파일러가 보기 전에 소스를 갈아엎기 때문에, 잘 쓰면 유용하고 잘못 쓰면 디버거로도 안 보이는 버그를 만듭니다.

매크로가 무는 세 가지

// ① 괄호 누락
#define SQ(x) x * x
SQ(1 + 2)        // → 1 + 2 * 1 + 2  = 5  (9 가 아닙니다)
#define SQ(x) ((x) * (x))            // ✅ 전부 괄호로

// ② 인자를 두 번 평가
#define MAX(a,b) ((a) > (b) ? (a) : (b))
MAX(i++, j)      // i 가 두 번 증가할 수 있습니다

// ③ 여러 문장을 한 덩어리로
#define LOG(m) printf("%s", m); fflush(stdout)
if (x) LOG("hi");    // fflush 는 if 밖에서 항상 실행됩니다!

#define LOG(m) do { printf("%s", m); fflush(stdout); } while (0)
상수는 매크로 대신 enum 이나 static const 를 쓰세요. 타입이 있고, 디버거에 이름이 보이고, 스코프도 지켜집니다.

그래도 매크로가 필요한 자리

// 파일 · 줄 번호가 필요한 로깅 — 함수로는 불가능합니다
#define LOG_ERR(fmt, ...) \
    fprintf(stderr, "[%s:%d] " fmt "\n", __FILE__, __LINE__, __VA_ARGS__)

// 배열 길이 (배열에만 써야 합니다 — 포인터에 쓰면 틀립니다)
#define COUNT_OF(a) (sizeof(a) / sizeof((a)[0]))

// 조건부 컴파일
#ifdef NDEBUG
#  define DBG(...) ((void)0)
#else
#  define DBG(...) LOG_ERR(__VA_ARGS__)
#endif

// 인클루드 가드 — 모든 헤더의 첫 줄
#ifndef MYLIB_BUFFER_H
#define MYLIB_BUFFER_H
/* ... */
#endif

플랫폼 · 컴파일러 분기

자주 쓰는 미리 정의된 매크로

매크로참이 되는 곳
_WIN32윈도우 (64비트에서도 정의됩니다)
__linux__리눅스
__APPLE__macOS · iOS
__GNUC__GCC 와 Clang 둘 다
_MSC_VERMSVC — 값이 버전
__cplusplusC++ 컴파일러로 컴파일 중
__STDC_VERSION__C 표준 버전 (C17 은 201710L)

C++ 에서도 부를 수 있는 헤더 만들기

#ifndef MYLIB_H
#define MYLIB_H

#ifdef __cplusplus
extern "C" {
#endif

int mylib_add(int a, int b);
void mylib_free(char* p);

#ifdef __cplusplus
}
#endif

#endif  /* MYLIB_H */
C 라이브러리 헤더의 표준 형태입니다. 이 여섯 줄이 없으면 C++ 에서 포함할 때 이름 맹글링 때문에 링크가 실패합니다. (FFI 탭에서 자세히 다룹니다)
#pragma once 는 표준이 아닙니다 — 다만 GCC · Clang · MSVC 가 전부 지원해서 실무에서는 널리 쓰입니다. 심볼릭 링크나 같은 파일이 두 경로로 보이는 환경에서 드물게 오작동하므로, 배포용 공개 헤더에는 전통적인 인클루드 가드를 권합니다.
C 10

여러 파일로 나누기 · 빌드

파일이 두 개가 되는 순간 선언과 정의, 내부와 외부를 구분해야 합니다. C 에는 모듈이 없어서 헤더와 링커의 규칙이 그 역할을 대신합니다.

헤더에는 '무엇이 있다'만, 소스에는 '실제 내용'을 buffer.h 타입 선언 · 함수 선언 extern 전역 선언 인클루드 가드 main.c — 포함해서 사용 buffer.c — 실제 정의 링커 선언과 정의를 잇는다 정의는 정확히 하나여야 static — 그 파일 안에서만 보이게 하는 스위치 static 을 붙이면 다른 파일에서 볼 수 없습니다 이름이 겹쳐도 충돌하지 않습니다 컴파일러가 더 과감하게 최적화합니다 안 붙이면 전역 심볼이 되어 링커에 노출됩니다 같은 이름이 두 파일에 있으면 충돌 공개할 것이 아니라면 붙이는 편이 낫습니다 헤더에 전역 변수를 그냥 쓰면 포함한 파일마다 정의가 생깁니다 — 헤더는 extern, 소스에 한 번만 정의하세요.
C 의 "모듈"은 결국 헤더 + static + 이름 접두사 세 가지로 흉내 내는 것입니다.

전역 변수를 여러 파일에서 쓰기

/* config.h — 선언만 */
extern int g_verbose;          // "어딘가에 있다"

/* config.c — 정의는 딱 한 번 */
int g_verbose = 0;

/* main.c */
#include "config.h"
int main(void) { g_verbose = 1; }
헤더에 int g_verbose; 라고 쓰면 포함하는 파일마다 정의가 생겨 multiple definition 이 납니다. (일부 컴파일러는 관대하게 넘어가지만 표준은 아닙니다)

가장 단순한 Makefile

CC      = gcc
CFLAGS  = -std=c17 -Wall -Wextra -g -O2
OBJS    = main.o buffer.o config.o

app: $(OBJS)
	$(CC) $(CFLAGS) -o $@ $(OBJS)

%.o: %.c
	$(CC) $(CFLAGS) -c $< -o $@

clean:
	rm -f $(OBJS) app

들여쓰기는 반드시 탭이어야 합니다 — 스페이스를 쓰면 "missing separator" 에러가 납니다. Make 의 대표적인 첫 관문입니다.

헤더 의존성 — 안 잡으면 반드시 물립니다

# 헤더를 고쳤는데 다시 빌드가 안 되는 문제를 막습니다
CFLAGS += -MMD -MP
-include $(OBJS:.o=.d)

이 두 줄이 없으면 헤더를 고쳐도 .o 가 그대로 남아 "고쳤는데 왜 안 바뀌지"가 반복됩니다. 실무에서는 이런 것을 알아서 처리하는 CMake 를 쓰는 편이 낫습니다(빌드 탭 참고).

공개 API 를 설계하는 규칙

  • 공개 함수는 전부 같은 접두사로 (mylib_) — C 에는 네임스페이스가 없습니다
  • 내부용 함수·변수는 전부 static
  • 구조체 내용은 가능하면 헤더에 노출하지 않기(불투명 포인터)
  • 헤더는 스스로 포함 가능해야 합니다 — 필요한 헤더를 자기가 포함
  • 헤더에서 <stdio.h> 같은 큰 헤더를 불필요하게 포함하지 않기
C 11

미정의 동작 — C 를 위험하게 만드는 것

UB 는 "이상한 값이 나온다"가 아니라 "표준이 아무 약속도 하지 않는다"입니다. 컴파일러는 UB 가 절대 일어나지 않는다고 가정하고 최적화합니다 — 그래서 내가 쓴 검사 코드가 통째로 사라지는 일이 벌어집니다.

UB 가 무서운 진짜 이유 — 검사 코드가 사라집니다 int v = *p; if (p == NULL) return; 내가 쓴 코드 컴파일러의 추론 "역참조가 성공했으니 p 는 NULL 이 아니다" 검사 삭제 if 문이 통째로 없어집니다 실무에서 가장 자주 밟는 여섯 가지 ① 부호 있는 정수 오버플로 a + b 가 INT_MAX 를 넘는 순간 넘침 검사가 최적화로 사라집니다 ② 초기화하지 않은 값 읽기 int x; if (x) — 실행마다 다릅니다 디버그에서만 되는 코드의 원인 ③ 배열 범위 밖 접근 a[n] — 끝 바로 뒤도 읽으면 UB 한 칸 차이 실수가 가장 흔합니다 ④ 수명이 끝난 것을 가리키기 지역 배열의 주소를 return 해제한 포인터를 계속 사용
"내 컴퓨터에서는 잘 돌던데"가 UB 에서는 아무 근거가 되지 않습니다.

나머지 두 가지 — 덜 알려졌지만 자주 밟습니다

// ⑤ 엄격한 앨리어싱 — 다른 타입 포인터로 같은 메모리 읽기
float f = 1.0f;
int   i = *(int*)&f;          // ❌ UB (-O2 에서 이상해집니다)

uint32_t bits;
memcpy(&bits, &f, sizeof bits);   // ✅ memcpy 는 항상 안전합니다

// ⑥ 한 식 안에서 같은 변수를 두 번 바꾸기
i = i++ + 1;                  // ❌ UB
arr[i] = i++;                 // ❌ UB
printf("%d %d", i++, i++);    // ❌ 인자 평가 순서도 정해져 있지 않습니다

memcpy 는 최적화 단계에서 대개 사라집니다. "느릴 것 같아서" 포인터 캐스팅을 쓸 이유가 없습니다.

방어하는 방법 — 도구가 전부입니다

도구잡아 주는 것
-Wall -Wextra미초기화 · 부호 비교 · 빠뜨린 return
-fsanitize=undefined오버플로 · 널 역참조 · 잘못된 시프트
-fsanitize=address범위 밖 접근 · 해제 후 사용 · 누수
Valgrind재컴파일 없이 검사 (느리지만 강력)
clang-tidy · cppcheck실행 없이 코드에서 패턴 탐지
퍼징(libFuzzer)사람이 생각 못 한 입력을 자동으로
UB 는 사람의 주의력으로 막을 수 없습니다. 도구를 CI 에 붙이는 것이 유일하게 확실한 대책입니다.
"UB 를 이용한 트릭"은 코드베이스의 시한폭탄입니다. 컴파일러 버전이 올라가거나 최적화 수준이 바뀌는 순간 동작이 달라집니다. 실제로 리눅스 커널은 -fno-strict-aliasing 을 켜서 표준이 아니라 컴파일러와 따로 약속하는 방식으로 이 문제를 우회합니다.
C 12

C 와 C++ — 실제로 다른 지점들

"C++ 는 C 의 상위 집합"이라는 설명은 실무에서는 틀립니다. C 코드를 C++ 로 컴파일하면 안 되는 것들이 있고, 반대로 C++ 습관을 C 에 가져오면 어색해집니다. 두 언어를 오갈 때 걸리는 지점을 모았습니다.

항목CC++
void* 대입암묵 변환됨 — 캐스팅 불필요캐스팅 필수 — malloc 에 캐스팅이 필요한 이유
구조체 이름struct P p; — struct 를 붙여야P p; 로 충분
지정 초기화C99 부터 {.x = 1}C++20 부터, 순서까지 지켜야 함
함수 선언 f()"인자 정보 없음" — 아무거나 받음"인자 없음" — f(void) 와 같음
bool<stdbool.h> 필요 (C23 부터 내장)키워드로 내장
union 타입 펀허용 — 다른 멤버로 읽어도 됨UB — memcpy/bit_cast 를 쓸 것
이름 맹글링없음 — 심볼 이름이 그대로있음 — extern "C" 가 필요한 이유
new·class·template그냥 식별자로 쓸 수 있음예약어 — C 헤더에 쓰면 C++ 에서 깨짐
문자 리터럴 'a'int 타입char 타입 — sizeof('a') 가 다릅니다

C 헤더를 C++ 에서도 쓰이게 만들기

  • extern "C" 가드를 반드시 넣습니다 (C 09 참고)
  • C++ 예약어를 식별자로 쓰지 않습니다 — new, class, template, this, operator
  • 구조체 태그와 typedef 이름을 같게 두면 양쪽에서 자연스럽습니다
  • 가변 길이 배열(VLA)은 C++ 에 없습니다 — 공개 헤더에 쓰지 마세요
  • restrict 는 C 전용입니다 — 매크로로 감싸 두세요

C 스타일이 C++ 에서 문제가 되는 것들

// ① malloc/free 로 C++ 객체를 다루면 생성자·소멸자가 안 불립니다
Widget* w = (Widget*)malloc(sizeof(Widget));   // ❌ 생성자 미호출
Widget* w = new Widget();                      // ✅

// ② memset 으로 클래스를 0 으로 미는 것
memset(&obj, 0, sizeof obj);   // ❌ vptr 까지 밀어 버립니다

// ③ C 캐스트는 무엇을 하는지 알 수 없습니다
(Derived*)base;                // ❌ 어떤 캐스트인지 불분명
static_cast<Derived*>(base);   // ✅ 의도가 드러납니다

// ④ 배열 대신 std::vector, 문자열 대신 std::string
//    C++ 에서 원시 배열을 쓸 이유는 거의 없습니다

어느 쪽으로 쓸 것인가 — 실무 판단

C 로 쓰는 게 나은 경우

  • 임베디드 · 부트로더 · 커널 모듈
  • 여러 언어에서 부를 라이브러리의 경계
  • C 로 된 기존 코드베이스에 얹는 코드
  • 빌드 도구가 C++ 를 지원하지 않는 환경

C++ 로 쓰는 게 나은 경우

  • 자원 관리가 복잡한 코드 — RAII 가 압도적
  • 자료구조가 많이 필요한 코드 — STL
  • 제네릭이 필요한 코드 — 템플릿
  • 규모가 커질 코드 — 타입 시스템의 도움이 큽니다

실무에서 가장 흔한 절충

안은 C++ 로 쓰고, 밖으로 내보내는 경계만 C 로 만드는 방식입니다. RAII 와 STL 의 이득을 그대로 누리면서, 파이썬·Node·C#·Go 어디서든 부를 수 있는 .so·.dll 이 나옵니다.

다음으로 볼 곳 — 여기서 배운 포인터 · 메모리 · 헤더 개념은 C ABI · FFI 탭에서 그대로 쓰입니다. C++ 의 RAII 와 스마트 포인터가 이 절의 문제들을 어떻게 없애는지는 현대 C++ 탭에서 볼 수 있습니다.
C 13

표준 라이브러리 지도 — 무엇이 어느 헤더에 있나

파이썬은 import 할 모듈 이름을 몰라도 검색하면 나옵니다. C 는 헤더가 30개도 안 되는 대신 이름이 줄임말이라 짐작이 안 됩니다 — atoi 가 왜 stdlib.h 에 있는지, memcpy 가 왜 string.h 에 있는지는 외우는 수밖에 없습니다. 이 절은 그 지도를 한 장에 담고, 실제로 손이 자주 가는 함수만 골라 표로 정리합니다.

C 표준 라이브러리 — 헤더 아홉 묶음이면 거의 전부입니다 stdio.h printf scanf fopen fgets 파일과 표준 입출력 stdlib.h malloc free qsort exit 메모리 · 변환 · 난수 string.h memcpy strlen strcmp 문자열이 아니라 메모리 math.h sqrt pow fabs round 링크에 -lm 이 필요 time.h time clock strftime 시각 · 경과 시간 측정 ctype.h isdigit isspace tolower 파서를 쓸 때 항상 stdint.h · stdbool.h int32_t uint64_t bool 크기가 확실한 타입 errno.h · assert.h errno perror assert 실패 원인과 개발용 검사 threads.h · stdatomic.h C11 이후 · 선택 기능 실무는 pthreads 가 다수 보조 헤더 — 필요할 때만 찾아 쓰면 됩니다 limits.h 정수 최댓값 · float.h 실수 정밀도 · stdarg.h 가변 인자 · stddef.h size_t NULL offsetof · locale.h 지역화
헤더 이름은 기능이 아니라 역사로 나뉘어 있습니다 — string.h 가 문자열 전용이 아니라는 점이 대표적입니다.

가장 많이 쓰는 함수 — stdio · stdlib

함수하는 일 · 주의
printf/fprintf서식 출력. 형식 문자와 타입이 다르면 UB
snprintf버퍼에 서식 출력 — sprintf 대신 항상 이것
fgets한 줄 읽기. 개행이 같이 들어옵니다
fopen/fclose실패하면 NULL. 반드시 검사
fread/fwrite바이너리 덩어리 입출력. 반환값 = 항목 수
malloc/calloccalloc 은 0으로 채우고 곱셈 넘침도 검사
realloc실패하면 원본 유지 + NULL 반환 ★
strtol/strtod문자열 → 수. atoi 대신 이것(에러 확인 가능)
qsort/bsearch비교 함수 포인터를 넘기는 정렬 · 이진 탐색
exit/atexit종료와 종료 훅. abort 는 정리 없이 죽음
man 페이지가 진짜 문서입니다 — 리눅스·맥에서 man 3 strtol 을 치면 반환값·에러·예제가 전부 나옵니다. 3번 절이 C 라이브러리, 2번 절이 시스템 콜입니다. 윈도우라면 cppreference.com/w/c 가 같은 역할을 합니다.

string.h — 이름과 달리 절반이 메모리 함수

함수하는 일 · 주의
memcpy겹치면 안 됨. 겹치면 memmove
memset바이트 단위 채움. 0 말고는 거의 안 씁니다
memcmp구조체 비교에 쓰면 패딩 때문에 틀립니다
strlen널을 만날 때까지 셉니다 — O(n). 루프 조건에 넣지 말 것
strcmp같으면 0. if (strcmp(a,b)) 는 “다르면”입니다
strcpy/strcat길이 검사 없음 — 버퍼 오버플로 1순위
strncpy안전해 보이지만 널을 안 붙일 수 있습니다 ★
strchr/strstr못 찾으면 NULL
strtok원본을 파괴 + 전역 상태 — strtok_r 을 쓰세요
strdupPOSIX(C23 부터 표준). free 는 내 책임
strncpy 는 안전 함수가 아닙니다 — 원본이 n 바이트 이상이면 널 종료를 붙이지 않습니다. “문자열 복사”가 목적이라면 snprintf(dst, n, "%s", src) 가 훨씬 안전하고 의도도 분명합니다. BSD 에서 온 strlcpy 는 표준이 아니라 플랫폼마다 있고 없고가 갈립니다 — 이식성이 필요하면 snprintf 가 정답입니다.

직접 확인 — 헤더 하나가 무엇을 주는지 보기 🟢 기초

한 파일에 몰아 담은 표준 라이브러리 맛보기

#include <stdio.h>      // printf, fopen
#include <stdlib.h>     // malloc, strtol, qsort
#include <string.h>     // strlen, memcpy
#include <ctype.h>      // isspace
#include <math.h>       // sqrt   (링크에 -lm)
#include <time.h>       // clock
#include <errno.h>      // errno
#include <limits.h>     // INT_MAX
#include <stdint.h>     // int64_t
#include <stdbool.h>    // bool, true, false

static int cmp_int(const void *a, const void *b) {
    int x = *(const int *)a, y = *(const int *)b;
    return (x > y) - (x < y);      // 뺄셈은 넘침 위험 — 이 관용구가 안전
}

int main(void) {
    // ① 문자열 → 수 : 에러를 확인할 수 있는 방법
    errno = 0;
    char *end;
    long v = strtol("  42abc", &end, 10);
    printf("v=%ld, 남은 문자='%s', errno=%d\n", v, end, errno);

    // ② 정렬
    int a[] = {5, 2, 9, 1};
    qsort(a, 4, sizeof a[0], cmp_int);
    for (int i = 0; i < 4; i++) printf("%d ", a[i]);
    putchar('\n');

    // ③ 시간 재기 (CPU 시간)
    clock_t t0 = clock();
    double s = 0;
    for (int i = 1; i < 5000000; i++) s += sqrt((double)i);
    printf("합=%.2f, %.3f초\n", s, (double)(clock() - t0) / CLOCKS_PER_SEC);

    printf("INT_MAX=%d, int64 최대=%lld\n", INT_MAX, (long long)INT64_MAX);
    return 0;
}
gcc -std=c17 -O2 -Wall -Wextra tour.c -o tour -lm
./tour

printf 서식 — 여기서 제일 많이 틀립니다

타입서식비고
int%d—
unsigned%u음수를 넣으면 거대한 수로 보입니다
long%ld윈도우에서 long 은 32비트
long long%lld어디서나 64비트
size_t%zu★ %d 로 찍으면 64비트에서 깨집니다
ptrdiff_t%td포인터 차이
int64_tPRId64<inttypes.h> 매크로
double%f %g %efloat 도 %f (자동 승격)
char*%sNULL 을 넘기면 UB
포인터%p(void*) 로 캐스트해서 넘길 것
#include <inttypes.h>
int64_t big = 9000000000;
size_t  n   = sizeof(int);

printf("%" PRId64 " / %zu\n", big, n);   // 이식성 있는 방법

// 컴파일러가 서식 오류를 잡아 주게 하기 ★
//   gcc/clang: -Wformat 은 -Wall 에 포함됩니다
//   내가 만든 함수에도 붙일 수 있습니다:
void logf_(const char *fmt, ...)
    __attribute__((format(printf, 1, 2)));
서식 문자열에 사용자 입력을 넣지 마세요 — printf(user_input) 은 포맷 스트링 취약점입니다. 출력만 하려면 printf("%s", user_input), 또는 fputs(user_input, stdout) 을 쓰세요.

한 단계 더 — 있는 줄 몰랐던 유용한 것들 🟡 중급

assert · errno · static_assert

#include <assert.h>
#include <errno.h>
#include <string.h>   // strerror

void put(int *arr, size_t n, size_t i, int v) {
    assert(arr != NULL);
    assert(i < n);          // 개발 중에만 검사됩니다
    arr[i] = v;
}
// NDEBUG 가 정의되면 assert 는 통째로 사라집니다:
//   gcc -DNDEBUG -O2 ...   ← 릴리스 빌드
// ★ 그래서 assert 안에 '부작용이 있는 식'을 넣으면 안 됩니다
//   assert(pop(&stack) == 3);   // 릴리스에서 pop 이 실행되지 않음!

// 컴파일 시점 검사 — C11 부터
#include <assert.h>
static_assert(sizeof(int) == 4, "이 코드는 32비트 int 를 가정합니다");

FILE *f = fopen("없는파일", "r");
if (!f) {
    // errno 는 '마지막 실패'를 담습니다. 성공한 호출도 값을 바꿀 수 있으니 실패 직후에만 봅니다
    int e = errno;          // 바로 저장 — 다음 호출(fprintf 포함)이 덮어쓸 수 있음
    fprintf(stderr, "열기 실패: %s (errno=%d)\n", strerror(e), e);
    // 또는 perror("fopen"); 한 줄로 — 단 다른 호출보다 먼저 불러야 합니다
}
errno 를 읽는 규칙 — ① 호출 직후에 읽습니다(다음 호출이 덮어씁니다). ② 성공했는지는 반환값으로 판단하고, errno 는 왜 실패했는지를 알 때만 봅니다. ③ strtol 처럼 “errno 로만 넘침을 알 수 있는” 함수는 호출 전에 errno=0 을 넣습니다.

난수 · 시간 · 환경변수

#include <stdlib.h>
#include <time.h>

// ① 난수 — rand() 는 품질이 낮습니다. 게임/시뮬레이션까지가 한계
srand((unsigned)time(NULL));      // 시드는 한 번만
int d = rand() % 6 + 1;           // ★ 모듈로 편향이 있지만 주사위 정도면 무방
// 암호용이라면 절대 rand() 를 쓰지 마세요:
//   리눅스 getrandom(), BSD/맥 arc4random_buf(), 윈도우 BCryptGenRandom()

// ② 실제 경과 시간 (clock 은 CPU 시간이라 sleep 을 못 셉니다)
struct timespec t0, t1;
timespec_get(&t0, TIME_UTC);      // C11 표준
/* ... 작업 ... */
timespec_get(&t1, TIME_UTC);
double ms = (t1.tv_sec - t0.tv_sec) * 1e3
          + (t1.tv_nsec - t0.tv_nsec) / 1e6;

// ③ 사람이 읽는 시각
time_t now = time(NULL);
struct tm tmv;
#if defined(_WIN32)
    localtime_s(&tmv, &now);
#else
    localtime_r(&now, &tmv);      // localtime() 은 스레드 안전하지 않습니다
#endif
char buf[64];
strftime(buf, sizeof buf, "%Y-%m-%d %H:%M:%S", &tmv);

// ④ 환경변수
const char *home = getenv("HOME");    // 없으면 NULL
왜 _r · _s 짝이 있나 — localtime·strtok·asctime 처럼 내부에 전역 버퍼를 쓰는 옛 함수들은 스레드에서 서로를 덮어씁니다. POSIX 는 _r(reentrant), 윈도우는 _s(safe) 이름으로 대체본을 줍니다. 멀티스레드 코드에서는 무조건 이쪽을 쓰세요.
표준에 없는 것들 — C 표준 라이브러리에는 네트워크·스레드 풀·정규식·JSON·디렉터리 순회·동적 배열이 없습니다. C11 의 <threads.h> 조차 선택 기능이라 MSVC 는 오랫동안 지원하지 않았습니다. 그래서 실무 C 는 POSIX(리눅스·맥) + Win32(윈도우) + 외부 라이브러리를 섞어 씁니다 — c17 · c18 · c26 에서 그 지형을 차례로 봅니다.
정리 — C 표준 라이브러리는 아홉 묶음이면 거의 다 커버됩니다. stdio(입출력) · stdlib(메모리·변환) · string(메모리 복사) 셋이 8할이고, atoi 대신 strtol, sprintf 대신 snprintf, strcpy 대신 snprintf 이 세 가지 교체만 몸에 붙여도 C 에서 나는 사고의 절반이 사라집니다.
C 14

동적 배열 · 연결 리스트를 직접 만듭니다

파이썬의 list, C++ 의 std::vector 에 해당하는 것이 C 에는 없습니다. 그래서 C 프로젝트는 거의 예외 없이 자기 동적 배열을 하나 가지고 있습니다. 직접 만들어 보면 “왜 vector 는 push 가 평균 O(1) 인가”, “왜 포인터를 보관하면 위험한가”가 한 번에 이해됩니다. 여기서 만든 코드는 그대로 복사해 쓸 수 있는 수준으로 씁니다.

같은 값 다섯 개 — 메모리에서는 완전히 다르게 놓입니다 ① 동적 배열 — 힙에 한 덩어리, 인덱스 접근 O(1) 10 20 30 40 50 빈칸 빈칸 len=5 · cap=7 — 주소가 12바이트씩 나란히 꽉 차면 cap 을 2배로 늘려 realloc 합니다 ② 연결 리스트 — 노드마다 따로 malloc, 흩어져 있음 10 next → 0x7f2a 20 next → 0x91c0 30 next → 0x4d18 40 next → NULL 끝 중간에 끼워 넣기·빼기는 포인터 두 개만 고치면 끝납니다 — O(1) 대신 i 번째를 찾으려면 앞에서부터 걸어야 하고, 매 노드가 캐시 미스입니다 — O(n) 배열이 유리한 경우 순회가 잦다 · 인덱스로 바로 꺼낸다 · 메모리를 아껴야 한다 실무 C 코드의 90%는 그냥 동적 배열이면 됩니다 리스트가 유리한 경우 중간 삽입·삭제가 매우 잦다 · 원소 주소가 변하면 안 된다 커널의 대기 큐 · 이벤트 루프의 타이머 목록이 대표적
배열은 커질 때 통째로 옮겨 다니고, 리스트는 영영 제자리입니다 — 이 한 줄이 둘의 모든 차이를 만듭니다.

동적 배열 — 복붙해서 바로 쓰는 전체 코드

// vec.h  — int 전용 동적 배열 (제네릭은 아래에서)
#ifndef VEC_H
#define VEC_H
#include <stddef.h>

typedef struct {
    int   *data;     // 힙에 있는 연속 버퍼
    size_t len;      // 지금 들어 있는 개수
    size_t cap;      // 버퍼가 담을 수 있는 개수
} Vec;

void vec_init(Vec *v);
int  vec_push(Vec *v, int value);          // 0 성공 / -1 메모리 부족
int  vec_get(const Vec *v, size_t i);
void vec_free(Vec *v);
#endif
// vec.c
#include "vec.h"
#include <stdlib.h>
#include <string.h>

void vec_init(Vec *v) { v->data = NULL; v->len = 0; v->cap = 0; }

static int vec_grow(Vec *v, size_t need) {
    if (need <= v->cap) return 0;

    size_t cap = v->cap ? v->cap * 2 : 8;   // ★ 2배 성장 — 평균 O(1) 의 이유
    while (cap < need) cap *= 2;

    // 곱셈이 넘치면 malloc 이 '작은' 크기를 성공시켜 버립니다 — 반드시 검사
    if (cap > (size_t)-1 / sizeof(int)) return -1;

    int *p = realloc(v->data, cap * sizeof(int));
    if (!p) return -1;        // ★ 실패해도 v->data 는 그대로 살아 있습니다
    v->data = p;
    v->cap  = cap;
    return 0;
}

int vec_push(Vec *v, int value) {
    if (vec_grow(v, v->len + 1) != 0) return -1;
    v->data[v->len++] = value;
    return 0;
}

int vec_get(const Vec *v, size_t i) {
    return v->data[i];        // 범위 검사는 호출자 책임 — C 의 기본 태도
}

void vec_free(Vec *v) {
    free(v->data);
    v->data = NULL; v->len = v->cap = 0;   // ★ dangling 방지
}
절대 하면 안 되는 realloc — v->data = realloc(v->data, n) 처럼 같은 변수에 바로 대입하면, 실패해서 NULL 이 돌아왔을 때 원래 버퍼의 주소를 잃어버려 통째로 샙니다. 위 코드처럼 임시 포인터에 받고 검사한 뒤에 대입하세요. 이건 C 코드 리뷰에서 가장 자주 지적되는 항목입니다.

왜 2배인가 — 증가 전략이 성능을 정합니다 🟡 중급

1씩 늘리기 vs 2배로 늘리기

전략n번 push 총 복사량평가
+1 씩O(n²) — 1+2+…+n10만 개면 체감으로 멈춤
+1024 씩O(n²) (상수만 작음)큰 데이터에서 결국 무너짐
×2 배O(n) — 총 복사 < 2n★ 표준 선택
×1.5 배O(n)메모리 재사용에 유리(MSVC STL)
// 성장 횟수를 직접 세어 보면 바로 보입니다
Vec v; vec_init(&v);
size_t prev = 0, grows = 0;
for (int i = 0; i < 1000000; i++) {
    vec_push(&v, i);
    if (v.cap != prev) { prev = v.cap; grows++; }
}
printf("100만 개를 담는 동안 성장 %zu회, cap=%zu\n", grows, v.cap);
// → 성장 18회. 즉 push 100만 번 중 재할당은 18번뿐입니다
vec_free(&v);
이걸 상환 분석(amortized)이라 부릅니다 — 어쩌다 한 번 비싼 연산이 있어도 전체를 나눠 보면 상수 시간이라는 뜻입니다. 파이썬 list.append, C++ vector::push_back, 자바 ArrayList.add 가 전부 같은 원리입니다.

가장 위험한 함정 — 재할당하면 주소가 바뀝니다 ★

위험 — 원소 주소를 보관
Vec v; vec_init(&v);
vec_push(&v, 10);
int *p = &v.data[0];   // 주소를 들고 있음

for (int i = 0; i < 100; i++)
    vec_push(&v, i);   // 중간에 realloc 발생!

*p = 99;               // ★ 이미 해제된 메모리
// use-after-free — 조용히 잘 돌다가
// 어느 날 다른 데이터를 망가뜨립니다
안전 — 인덱스를 보관
Vec v; vec_init(&v);
vec_push(&v, 10);
size_t idx = 0;        // 인덱스는 안 변합니다

for (int i = 0; i < 100; i++)
    vec_push(&v, i);

v.data[idx] = 99;      // 매번 새로 계산
// 리스트가 필요한 진짜 이유가 바로
// '주소가 절대 안 변한다'는 성질입니다
C++ 의 반복자 무효화와 똑같은 문제입니다 — vector 에 push_back 하면 기존 반복자·참조·포인터가 전부 무효가 되죠. C 에서는 그 규칙을 컴파일러가 알려 주지 않을 뿐입니다. ASan(-fsanitize=address)을 켜고 테스트를 돌리면 이 종류는 거의 다 잡힙니다.

제네릭 흉내 — void* 방식과 매크로 방식 🔴 고급

① void* + 원소 크기 — 코드가 하나면 됩니다

typedef struct {
    void  *data;
    size_t len, cap, esz;   // esz = 원소 하나의 바이트 수
} GVec;

static void gvec_init(GVec *v, size_t esz) {
    v->data = NULL; v->len = v->cap = 0; v->esz = esz;
}

static int gvec_push(GVec *v, const void *item) {
    if (v->len == v->cap) {
        size_t cap = v->cap ? v->cap * 2 : 8;
        void *p = realloc(v->data, cap * v->esz);
        if (!p) return -1;
        v->data = p; v->cap = cap;
    }
    // 바이트 단위로 계산해야 하므로 char* 로 캐스트합니다
    memcpy((char *)v->data + v->len * v->esz, item, v->esz);
    v->len++;
    return 0;
}

#define GVEC_AT(v, T, i)  (((T *)(v)->data)[i])

// 사용
GVec v; gvec_init(&v, sizeof(double));
double x = 3.14;
gvec_push(&v, &x);                 // ★ 값이 아니라 주소를 넘깁니다
printf("%f\n", GVEC_AT(&v, double, 0));
장점 — 코드가 한 벌이라 바이너리가 작습니다. 단점 — 타입 검사가 전혀 없어서 sizeof 를 틀리면 컴파일은 되고 실행이 망가집니다. SQLite·libcurl 같은 실전 C 라이브러리가 이 방식을 씁니다.

② 매크로로 타입별 코드 찍어내기

// C++ 템플릿을 전처리기로 흉내 내는 방법입니다
#define VEC_DECLARE(T, name)                                   \
    typedef struct { T *data; size_t len, cap; } name;         \
    static inline void name##_init(name *v){                   \
        v->data=NULL; v->len=v->cap=0; }                       \
    static inline int name##_push(name *v, T x){               \
        if (v->len == v->cap) {                                \
            size_t c = v->cap ? v->cap*2 : 8;                  \
            T *p = realloc(v->data, c*sizeof(T));              \
            if (!p) return -1;                                 \
            v->data = p; v->cap = c; }                         \
        v->data[v->len++] = x; return 0; }                     \
    static inline void name##_free(name *v){                   \
        free(v->data); v->data=NULL; v->len=v->cap=0; }

VEC_DECLARE(int,    IntVec)
VEC_DECLARE(double, DblVec)

IntVec a; IntVec_init(&a); IntVec_push(&a, 7);
// a.data[0] 은 진짜 int* — 타입 검사가 살아 있습니다
매크로의 대가 — 에러 메시지가 한 줄로 뭉쳐 나와 읽기 어렵습니다. gcc -E 로 펼쳐 보면 원래 코드가 나오니, 매크로 자료구조를 디버깅할 땐 이 습관이 필수입니다. 실전에서는 stb_ds.h(공개 도메인, 헤더 한 장)나 klib 의 kvec.h 가 이 방식의 완성형입니다.

단일 연결 리스트 — 전체 코드와 흔한 실수

typedef struct Node {
    int          value;
    struct Node *next;      // ★ 자기 타입을 가리키려면 struct 태그가 필요
} Node;

typedef struct { Node *head; size_t len; } List;

void list_init(List *l) { l->head = NULL; l->len = 0; }

// 맨 앞에 넣기 — O(1)
int list_push_front(List *l, int v) {
    Node *n = malloc(sizeof *n);       // ★ sizeof(Node) 대신 sizeof *n
    if (!n) return -1;
    n->value = v;
    n->next  = l->head;
    l->head  = n;
    l->len++;
    return 0;
}

// 전부 해제 — next 를 먼저 저장해 두는 것이 핵심
void list_free(List *l) {
    Node *cur = l->head;
    while (cur) {
        Node *nx = cur->next;   // ★ free 뒤에는 cur->next 를 못 읽습니다
        free(cur);
        cur = nx;
    }
    l->head = NULL; l->len = 0;
}
// 값으로 찾아 지우기 — 이중 포인터 관용구 ★
// head 를 지우는 경우와 중간을 지우는 경우가
// 하나의 코드로 합쳐집니다
int list_remove(List *l, int v) {
    Node **pp = &l->head;          // '나를 가리키는 포인터'의 주소
    while (*pp) {
        Node *cur = *pp;
        if (cur->value == v) {
            *pp = cur->next;       // 앞사람의 next 를 바로 고침
            free(cur);
            l->len--;
            return 1;
        }
        pp = &cur->next;
    }
    return 0;
}

// 순회
for (Node *p = l.head; p; p = p->next)
    printf("%d ", p->value);

// 뒤집기 — 면접 단골
Node *prev = NULL, *cur = l.head;
while (cur) {
    Node *nx = cur->next;
    cur->next = prev;
    prev = cur;
    cur  = nx;
}
l.head = prev;
이중 포인터(Node **)가 어렵게 느껴진다면 — “다음 노드”가 아니라 “그 노드를 가리키고 있는 칸 자체”를 들고 다닌다고 생각하세요. 그 칸이 list->head 일 수도 있고 어떤 노드의 next 일 수도 있는데, 고쳐야 할 대상은 언제나 그 칸이기 때문에 특수 케이스가 사라집니다. 리눅스 커널 코드에 이 관용구가 도처에 나옵니다.
정리 — C 에는 vector 가 없으니 동적 배열 하나는 직접 갖고 시작합니다. 핵심은 2배 성장(평균 O(1))과 realloc 을 임시 포인터로 받기, 그리고 재할당하면 원소 주소가 바뀐다는 사실입니다. 연결 리스트는 “중간 삽입이 잦거나 주소가 고정돼야 할 때”만 꺼내면 되고, 그때는 이중 포인터 관용구가 코드를 절반으로 줄여 줍니다.
C 15

해시 테이블과 정렬 · 탐색 — 딕셔너리도 직접 만듭니다

파이썬의 dict 도 C 에는 없습니다. 다행히 해시 테이블은 100줄이면 만들 수 있고, 정렬과 이진 탐색은 표준 qsort·bsearch 가 이미 줍니다. 이 절에서는 오픈 어드레싱 해시 테이블 전체 코드와, 비교 함수를 넘기는 C 특유의 정렬 방식을 봅니다.

오픈 어드레싱 — 자리가 차 있으면 옆 칸으로 밀어 넣습니다 ① 해시 함수가 슬롯 번호를 정합니다 키 "apple" 임의 길이의 바이트 열 FNV-1a % 8 = 3 3번 슬롯에 저장 평균 O(1) 로 찾을 수 있게 됩니다 ② 같은 번호가 나오면 — 옆 칸을 차례로 봅니다(선형 탐사) 비어 있음 0 비어 있음 1 비어 있음 2 apple 3 ← 해시 그대로 grape 4 ← 밀려남 비어 있음 5 melon 6 비어 있음 7 "grape" 의 해시도 3이었지만 이미 차 있어 3 → 4 로 한 칸 밀려 저장됐습니다 찾을 때도 3, 4, 5 … 순으로 키를 비교하고, 빈 칸을 만나면 "없음"으로 확정합니다 두 가지를 놓치면 해시 테이블이 망가집니다 ① 적재율이 0.7 을 넘으면 탐사가 길어집니다 — 슬롯을 2배로 늘리고 전부 다시 넣습니다(rehash) ② 삭제할 때 빈 칸으로 만들면 뒤에 밀려 있던 키를 못 찾습니다 — 묘비(tombstone) 표시가 필요합니다
충돌은 피하는 게 아니라 다루는 것입니다 — 옆 칸으로 미는 방식이 가장 단순하고 캐시에 가장 좋습니다.

문자열 → 값 해시 테이블 — 전체 코드

#include <stdint.h>
#include <stdlib.h>
#include <string.h>

// FNV-1a — 짧고 빠르고 분포가 괜찮습니다
static uint64_t fnv1a(const char *s) {
    uint64_t h = 14695981039346656037ULL;  // offset basis
    while (*s) {
        h ^= (unsigned char)*s++;
        h *= 1099511628211ULL;             // FNV prime
    }
    return h;
}

typedef struct {
    char *key;      // NULL = 빈 칸, TOMB = 삭제됨
    int   val;
} Slot;

typedef struct {
    Slot  *slots;
    size_t cap;     // 항상 2의 거듭제곱
    size_t used;    // 살아 있는 항목 수
} Map;

static char TOMB_MARK;                    // 주소만 쓰는 묘비 표식
#define TOMB (&TOMB_MARK)

int map_init(Map *m, size_t cap) {
    m->slots = calloc(cap, sizeof(Slot)); // 전부 NULL 로
    if (!m->slots) return -1;
    m->cap = cap; m->used = 0;
    return 0;
}
static int map_grow(Map *m);   // 아래에 정의

int map_put(Map *m, const char *key, int val) {
    if ((m->used + 1) * 10 >= m->cap * 7)  // 적재율 0.7
        if (map_grow(m) != 0) return -1;

    size_t i = fnv1a(key) & (m->cap - 1);  // ★ % 대신 비트 AND (2의 거듭제곱)
    size_t first_tomb = (size_t)-1;

    for (;;) {
        Slot *s = &m->slots[i];
        if (s->key == NULL) {              // 빈 칸 = 없다는 뜻
            size_t at = (first_tomb != (size_t)-1) ? first_tomb : i;
            char *dup = strdup(key);
            if (!dup) return -1;
            m->slots[at].key = dup;
            m->slots[at].val = val;
            m->used++;
            return 0;
        }
        if (s->key == TOMB) {
            if (first_tomb == (size_t)-1) first_tomb = i;
        } else if (strcmp(s->key, key) == 0) {
            s->val = val;                  // 덮어쓰기
            return 0;
        }
        i = (i + 1) & (m->cap - 1);        // 옆 칸으로
    }
}

int *map_get(Map *m, const char *key) {
    size_t i = fnv1a(key) & (m->cap - 1);
    for (;;) {
        Slot *s = &m->slots[i];
        if (s->key == NULL) return NULL;    // 빈 칸 = 없음
        if (s->key != TOMB && strcmp(s->key, key) == 0)
            return &s->val;
        i = (i + 1) & (m->cap - 1);
    }
}
int map_del(Map *m, const char *key) {
    size_t i = fnv1a(key) & (m->cap - 1);
    for (;;) {
        Slot *s = &m->slots[i];
        if (s->key == NULL) return 0;                       // 없음
        if (s->key != TOMB && strcmp(s->key, key) == 0) {
            free(s->key);
            s->key = TOMB;                                  // ★ NULL 이 아니라 묘비
            m->used--;
            return 1;
        }
        i = (i + 1) & (m->cap - 1);
    }
}

static int map_grow(Map *m) {
    Map bigger;
    if (map_init(&bigger, m->cap * 2) != 0) return -1;
    for (size_t i = 0; i < m->cap; i++) {
        Slot *s = &m->slots[i];
        if (s->key && s->key != TOMB) {
            map_put(&bigger, s->key, s->val);   // 해시가 같아도 슬롯 번호는 달라집니다
            free(s->key);
        }
    }
    free(m->slots);
    *m = bigger;
    return 0;
}

// 사용
Map m; map_init(&m, 16);
map_put(&m, "apple", 3);
map_put(&m, "grape", 7);
int *p = map_get(&m, "grape");
if (p) printf("grape=%d\n", *p);
묘비를 빠뜨리면 생기는 버그 — apple 을 지우고 NULL 로 만들면, 4번에 밀려 있던 grape 를 찾을 때 3번이 빈 칸이라 즉시 “없음”으로 끝나 버립니다. 데이터가 있는데도 못 찾는, 재현이 어려운 종류의 버그입니다. 묘비가 너무 쌓이면 used 가 그대로여도 rehash 로 청소해야 합니다.

표준이 주는 것 — qsort 와 bsearch 🟢 기초

비교 함수를 넘기는 방식

#include <stdlib.h>

typedef struct { char name[32]; int age; } Person;

// 인자는 항상 const void* 두 개입니다
static int by_age(const void *a, const void *b) {
    const Person *x = a, *y = b;
    // ★ return x->age - y->age;  ← 넘침 가능. 쓰지 마세요
    return (x->age > y->age) - (x->age < y->age);
}

static int by_name(const void *a, const void *b) {
    return strcmp(((const Person *)a)->name,
                  ((const Person *)b)->name);
}

Person ps[] = {{"kim",31},{"lee",24},{"park",45}};
size_t n = sizeof ps / sizeof ps[0];

qsort(ps, n, sizeof ps[0], by_age);       // 나이 오름차순
qsort(ps, n, sizeof ps[0], by_name);      // 이름 사전순

// 반드시 '같은 기준으로 정렬된' 배열에서만 동작합니다
// 지금 ps 는 이름순이므로 나이로 찾기 전에 다시 나이순으로 정렬
qsort(ps, n, sizeof ps[0], by_age);
Person key = { .age = 31 };
Person *hit = bsearch(&key, ps, n, sizeof ps[0], by_age);
printf("%s\n", hit ? hit->name : "없음");
qsort 는 안정 정렬이 아닙니다 — 같은 값끼리의 원래 순서가 보장되지 않습니다. “나이순, 나이가 같으면 이름순”이 필요하면 비교 함수 안에서 2차 기준까지 직접 처리하세요. if (r != 0) return r; return strcmp(...); 형태가 됩니다.

직접 쓰는 이진 탐색 — 경계 조건이 전부

// v 이상인 첫 인덱스 (C++ 의 lower_bound)
size_t lower_bound(const int *a, size_t n, int v) {
    size_t lo = 0, hi = n;            // ★ 닫힌 구간이 아니라 [lo, hi)
    while (lo < hi) {
        size_t mid = lo + (hi - lo) / 2;   // ★ (lo+hi)/2 는 넘칠 수 있음
        if (a[mid] < v) lo = mid + 1;
        else            hi = mid;
    }
    return lo;                        // 없으면 '들어갈 자리'를 돌려줍니다
}

int a[] = {1,3,3,5,9};
size_t i = lower_bound(a, 5, 3);      // → 1
size_t j = lower_bound(a, 5, 4);      // → 3 (없지만 자리는 3)

// bsearch 와의 차이: bsearch 는 '있으면 아무거나 하나',
// lower_bound 는 '경계'를 줍니다. 중복이 있으면 후자가 유용합니다
상황선택
원소 100개 미만그냥 선형 탐색 — 캐시 때문에 더 빠를 때도
정렬 후 여러 번 조회qsort + bsearch
넣고 빼기가 섞여 있다해시 테이블
순서대로 순회도 필요정렬된 배열 유지 또는 트리
키가 정수이고 조밀하다그냥 배열 — 해시가 필요 없습니다

실무 관점 — 해시를 직접 만들지 말아야 할 때 🔴 고급

해시 DoS — 외부 입력이 키가 될 때

// 공격자가 '해시가 전부 같은 키'를 골라 보내면
// 모든 항목이 한 슬롯에 몰려 O(n) 이 됩니다.
// HTTP 헤더·JSON 키·쿼리 파라미터가 대표적인 통로입니다.

// 대응 ① 프로세스마다 무작위 시드를 섞습니다
static uint64_t g_seed;   // 시작할 때 난수로 채움

static uint64_t fnv1a_seeded(const char *s) {
    uint64_t h = 14695981039346656037ULL ^ g_seed;
    while (*s) { h ^= (unsigned char)*s++; h *= 1099511628211ULL; }
    return h;
}

// 대응 ② 키가 신뢰할 수 없는 입력이면 SipHash 를 씁니다
//   — 파이썬 3.4+, 러스트 HashMap, 리눅스 커널이 쓰는 방식
//   구현이 짧아(200줄) 그대로 넣어 쓸 수 있습니다
파이썬이 PYTHONHASHSEED 를 두는 이유가 정확히 이것입니다. 2011년에 여러 웹 프레임워크가 이 공격으로 한꺼번에 털렸고, 그 뒤로 “외부 입력을 키로 쓰는 해시는 시드를 섞는다”가 상식이 됐습니다.

가져다 쓰는 편이 나은 경우

라이브러리특징
stb_ds.h헤더 한 장, 공개 도메인. 동적 배열·해시맵. 가장 손쉬움
klib (khash.h)매크로 기반, 매우 빠름. samtools 등에서 실전 검증
uthash구조체에 필드를 심는 방식. 기존 구조체에 얹기 좋음
tommyds여러 자료구조 모음. 벤치마크 공개
glib (GHashTable)무겁지만 완성도 높음. GNOME 생태계
// stb_ds.h — 한 파일만 받아서 넣으면 끝
#define STB_DS_IMPLEMENTATION
#include "stb_ds.h"

int *nums = NULL;
arrput(nums, 10);              // 동적 배열
arrput(nums, 20);
printf("%zu개, 첫 값 %d\n", arrlen(nums), nums[0]);
arrfree(nums);

struct { char *key; int value; } *dict = NULL;
shput(dict, "apple", 3);       // 문자열 키 해시맵
printf("%d\n", shget(dict, "apple"));
shfree(dict);
직접 만드는 값어치는 “한 번 만들어 보고 원리를 아는 것”에 있습니다. 제품 코드에서는 검증된 헤더 한 장을 가져다 쓰는 편이 거의 항상 낫습니다 — 묘비·rehash·시드 같은 세부를 이미 다 처리해 두었기 때문입니다.
정리 — 해시 테이블의 핵심은 셋입니다. 2의 거듭제곱 크기 + 비트 마스크(% 보다 빠름), 적재율 0.7 에서 rehash, 그리고 삭제는 묘비로. 정렬·탐색은 qsort/bsearch 로 충분하되 비교 함수에서 뺄셈 대신 (x>y)-(x<y) 를 쓰세요. 실전에서는 stb_ds.h 한 장을 넣는 것이 대개 가장 좋은 선택입니다.
C 16

문자열 처리 실전 — 파싱 · 동적 버퍼 · UTF-8

C 에서 실제로 시간을 가장 많이 잡아먹는 일이 문자열 다루기입니다. 길이를 모르는 입력을 받아서, 자르고, 숫자로 바꾸고, 다시 이어 붙이는 과정에 C 의 버퍼 오버플로 사고 대부분이 숨어 있습니다. 여기서는 안전하게 자르는 법 · 자동으로 늘어나는 문자열 버퍼 · 한글이 깨지지 않게 다루는 법을 전부 도는 코드로 정리합니다.

UTF-8 — 한 글자가 1~4바이트, strlen 이 세는 것은 바이트입니다 ① 문자열 "가A" 는 메모리에 이렇게 놓입니다 0xEA 선두 바이트 0xB0 뒤따르는 바이트 0x80 뒤따르는 바이트 0x41 'A' — ASCII 그대로 0x00 널 종료 배열 크기는 5바이트 글자는 두 개뿐입니다 이 세 바이트가 합쳐서 한 글자 '가' 입니다 ② 그래서 이런 결과가 나옵니다 strlen("가A") 는 4 입니다 글자 수가 아니라 바이트 수를 셉니다 s[0] 하나만 찍으면 깨진 조각이 나옵니다 글자 수는 선두 바이트만 세면 됩니다 상위 두 비트가 10 이 아닌 바이트만 셉니다 코드로는 단 세 줄이면 끝납니다 UTF-8 이 사실상 표준이 된 이유 ① ASCII 는 그대로 1바이트 — 기존 C 코드와 프로토콜이 대부분 손대지 않고 돕니다 ② 선두와 뒤따름이 구분되므로, 아무 위치에서 잘려도 글자 경계를 다시 찾을 수 있습니다
C 의 char* 는 글자의 배열이 아니라 바이트의 배열입니다 — 한글이 깨지는 사고는 전부 여기서 시작합니다.

한 줄 읽어 CSV 로 자르기 — 안전한 방법

#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <errno.h>      // errno · ERANGE

// "kim,31,seoul\n" 같은 줄을 필드로 자릅니다
void parse_line(char *line) {
    // ① 끝의 개행 제거 — fgets 는 '\n' 을 남깁니다
    line[strcspn(line, "\r\n")] = '\0';   // ★ 관용구. 개행이 없어도 안전

    // ② strtok_r — 상태를 saveptr 에 두므로 스레드 안전
    char *save = NULL;
    char *name = strtok_r(line, ",", &save);
    char *ages = strtok_r(NULL, ",", &save);   // ★ 두 번째부터는 NULL
    char *city = strtok_r(NULL, ",", &save);
    if (!name || !ages || !city) {            // 필드가 모자라면
        fprintf(stderr, "형식 오류\n");
        return;
    }

    // ③ 숫자 변환 — 실패를 알 수 있는 방법
    char *end;
    errno = 0;
    long age = strtol(ages, &end, 10);
    if (end == ages || *end != '\0' || errno == ERANGE) {
        fprintf(stderr, "나이가 숫자가 아닙니다: %s\n", ages);
        return;
    }
    printf("%s / %ld / %s\n", name, age, city);
}

int main(void) {
    char buf[512];
    while (fgets(buf, sizeof buf, stdin))   // ★ sizeof buf 로 크기를 넘김
        parse_line(buf);
}
윈도우에서 strtok_r 이 없다면 — MSVC 는 같은 기능을 strtok_s 로 줍니다. #ifdef _WIN32 #define strtok_r strtok_s #endif 한 줄이면 양쪽에서 같은 코드가 돕니다. 그냥 strtok 은 내부 전역 상태를 써서 다른 스레드나 중첩 호출과 충돌합니다.

해서는 안 되는 것들 — 실제 사고 목록

위험
char buf[64];
gets(buf);              // C11 에서 삭제됨. 무조건 금지
scanf("%s", buf);       // 길이 제한 없음 → 오버플로
strcpy(buf, argv[1]);   // 인자가 길면 그대로 넘침
sprintf(buf, "%s/%s", dir, file);   // 같은 문제

int n = atoi(argv[1]);  // 실패해도 0. 구분 불가
char *p = strtok(s, ","); // 전역 상태 · 원본 파괴
안전
char buf[64];
fgets(buf, sizeof buf, stdin);
scanf("%63s", buf);     // ★ 크기-1 을 반드시 명시

snprintf(buf, sizeof buf, "%s", argv[1]);
int need = snprintf(buf, sizeof buf, "%s/%s", dir, file);
if (need >= (int)sizeof buf)
    fprintf(stderr, "경로가 잘렸습니다\n");   // ★ 반환값 확인

long n = strtol(argv[1], &end, 10);
char *p = strtok_r(s, ",", &save);
snprintf 의 반환값이 핵심입니다 — 실제로 쓴 바이트 수가 아니라 “잘리지 않았다면 필요했을 길이”를 돌려줍니다. 그래서 ret >= 버퍼크기 이면 잘린 것입니다. 이 값을 먼저 NULL, 0 으로 호출해 재면, 필요한 만큼만 malloc 하는 관용구가 됩니다.

자동으로 늘어나는 문자열 — C 의 StringBuilder 🟡 중급

Str 버퍼 — 이어 붙이기가 잦은 코드에 필수

#include <stdarg.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

typedef struct { char *p; size_t len, cap; } Str;

static void str_init(Str *s) { s->p = NULL; s->len = s->cap = 0; }

static int str_reserve(Str *s, size_t need) {
    if (need + 1 <= s->cap) return 0;        // +1 은 널 자리
    size_t cap = s->cap ? s->cap : 32;
    while (cap < need + 1) cap *= 2;
    char *q = realloc(s->p, cap);
    if (!q) return -1;
    s->p = q; s->cap = cap;
    return 0;
}

int str_append(Str *s, const char *t) {
    size_t n = strlen(t);
    if (str_reserve(s, s->len + n) != 0) return -1;
    memcpy(s->p + s->len, t, n + 1);          // 널까지 복사
    s->len += n;
    return 0;
}

void str_free(Str *s) { free(s->p); str_init(s); }
// 서식 붙이기 — vsnprintf 로 필요 길이를 먼저 잽니다 ★
int str_appendf(Str *s, const char *fmt, ...) {
    va_list ap, ap2;
    va_start(ap, fmt);
    va_copy(ap2, ap);                       // ★ 두 번 쓰려면 복사 필수
    int need = vsnprintf(NULL, 0, fmt, ap); // 길이만 계산
    va_end(ap);
    if (need < 0) { va_end(ap2); return -1; }

    if (str_reserve(s, s->len + (size_t)need) != 0) {
        va_end(ap2); return -1;
    }
    vsnprintf(s->p + s->len, (size_t)need + 1, fmt, ap2);
    va_end(ap2);
    s->len += (size_t)need;
    return 0;
}

// 사용
Str out; str_init(&out);
str_append(&out, "결과: ");
for (int i = 0; i < 3; i++)
    str_appendf(&out, "[%d:%s] ", i, i % 2 ? "홀" : "짝");
puts(out.p);          // 결과: [0:짝] [1:홀] [2:짝]
printf("길이 %zu, 용량 %zu\n", out.len, out.cap);
str_free(&out);
va_list 는 한 번 쓰면 끝입니다 — vsnprintf 를 두 번 호출하려면 반드시 va_copy 로 복사본을 만들어야 합니다. 이걸 빠뜨리면 x86-64 리눅스에서는 우연히 돌고 ARM 이나 윈도우에서 깨지는 최악의 이식성 버그가 됩니다. 그리고 va_copy 한 것도 반드시 va_end 해야 합니다.

한글·이모지를 다루기 — UTF-8 실전 🔴 고급

글자 수 세기 · 안전하게 자르기

#include <stddef.h>
#include <stdbool.h>
#include <string.h>     // strlen

// 뒤따르는 바이트는 상위 두 비트가 10 입니다 (0x80~0xBF)
static bool is_cont(unsigned char c) { return (c & 0xC0) == 0x80; }

// 글자 수 = 뒤따르지 않는 바이트의 개수
size_t utf8_len(const char *s) {
    size_t n = 0;
    for (const unsigned char *p = (const unsigned char *)s; *p; p++)
        if (!is_cont(*p)) n++;
    return n;
}

// 글자 단위로 안전하게 자르기 — 최대 max 바이트 안에서
// 글자 중간이 잘리지 않는 위치를 돌려줍니다
size_t utf8_trunc(const char *s, size_t max) {
    size_t n = strlen(s);
    if (n <= max) return n;
    size_t cut = max;
    while (cut > 0 && is_cont((unsigned char)s[cut])) cut--;
    return cut;
}

// 코드포인트 하나를 읽고 다음 위치로 이동
const char *utf8_next(const char *s, unsigned *out) {
    unsigned char c = (unsigned char)*s;
    int extra = c < 0x80 ? 0 : c < 0xE0 ? 1 : c < 0xF0 ? 2 : 3;
    unsigned cp = c & (0xFF >> (extra + 2));
    if (extra == 0) cp = c;
    for (int i = 0; i < extra; i++)
        cp = (cp << 6) | (*++s & 0x3F);
    *out = cp;
    return s + 1;
}
대부분의 경우 디코딩까지 갈 필요가 없습니다 — 검색·비교·저장은 바이트 그대로 하면 정확히 동작합니다(UTF-8 의 설계가 그렇습니다). 디코딩이 필요한 순간은 글자 수 제한 · 커서 이동 · 폭 계산 정도입니다.

윈도우 콘솔과 파일 경로 — 최대 함정

// 리눅스·맥: 파일 경로도 소스도 전부 UTF-8. 신경 쓸 것 없음
// 윈도우: 기본 코드 페이지가 UTF-8 이 아닐 수 있습니다

#ifdef _WIN32
#include <windows.h>
static void setup_console(void) {
    SetConsoleOutputCP(CP_UTF8);   // 출력 코드 페이지를 UTF-8 로
    SetConsoleCP(CP_UTF8);         // 입력도
}
#else
static void setup_console(void) { }
#endif

int main(void) {
    setup_console();
    printf("한글이 깨지지 않습니다\n");
}

// 파일 경로에 한글이 있으면 윈도우에서는 fopen 이 실패할 수 있습니다.
// 방법 ① 실행 파일에 UTF-8 매니페스트를 넣습니다 (Win10 1903+)
// 방법 ② _wfopen 과 MultiByteToWideChar 로 UTF-16 을 씁니다
// 방법 ③ MSVC 라면 /utf-8 플래그로 소스 인코딩을 못박습니다
함수왜 쓰나
strcspn(s,"\r\n")개행 위치 찾기 — 개행이 없어도 안전
strspn앞쪽 공백 건너뛰기
memchr널이 섞인 바이너리에서 찾기
strnlen널이 없을 수도 있는 버퍼의 길이
snprintf(NULL,0,…)필요한 길이 미리 재기
strcasecmp대소문자 무시 비교(POSIX · MSVC 는 _stricmp)
파이썬·자바와의 결정적 차이 — 파이썬 str 은 코드포인트의 열이고 자바 String 은 UTF-16 의 열이라, len(s) 가 곧 글자 수입니다. C 는 바이트 열뿐이라 그 계층이 없습니다. 대신 인코딩을 내가 완전히 통제할 수 있어, 네트워크·파일 포맷 같은 경계에서는 오히려 유리합니다.
정리 — 문자열에서 지킬 규칙은 네 개입니다. 입력은 fgets, 조립은 snprintf(반환값 확인), 자르기는 strtok_r, 숫자 변환은 strtol(end 와 errno 확인). 길이가 변하는 문자열이 필요하면 Str 버퍼 40줄을 하나 만들어 두고 계속 씁니다. 한글은 바이트 열로 그대로 다루되, 자를 때만 글자 경계를 확인하면 됩니다.
C 17

POSIX — 파일 · 디렉터리 · 시간을 시스템 수준에서

C 표준에는 디렉터리를 읽는 함수조차 없습니다. 파일 크기, 수정 시각, 폴더 순회, 메모리 매핑은 전부 POSIX(리눅스·맥·BSD)나 Win32(윈도우)의 영역입니다. 이 절은 실무에서 실제로 쓰는 것만 골라, 양쪽 모두에서 도는 형태로 정리합니다. #ifdef _WIN32 분기가 어떻게 생기는지도 여기서 감을 잡게 됩니다.

파일을 읽는 세 가지 경로 — 어디까지 내려가는가 stdio — FILE* fopen · fgets · fprintf 이식성 최고 · 버퍼링 자동 mmap — 메모리 매핑 포인터로 그냥 접근 큰 파일 랜덤 접근에 유리 POSIX 파일 디스크립터 — open · read · write 시스템 콜을 직접 부릅니다. 버퍼링도 내가 관리합니다 stdio 는 사실 이 위에 얹혀 구현되어 있습니다 mmap 은 read 를 거치지 않고 페이지 폴트로 읽습니다 커널 — 페이지 캐시 · 파일 시스템 · 디스크 같은 파일을 두 번 읽으면 두 번째는 메모리에서 옵니다. 그래서 벤치마크가 왜곡됩니다 작은 텍스트 파일 · 이식성이 중요 → stdio 로 충분합니다 비블로킹 · 소켓 · 타임아웃 같은 정밀 제어 → 파일 디스크립터 · 수 GB 랜덤 접근 → mmap
셋은 경쟁 관계가 아니라 층입니다 — 아래로 내려갈수록 제어는 늘고 이식성은 줄어듭니다.

open · read · write — 시스템 콜 직접 쓰기

#include <fcntl.h>      // open, O_RDONLY
#include <unistd.h>     // read, write, close
#include <errno.h>
#include <string.h>

int copy_file(const char *src, const char *dst) {
    int in = open(src, O_RDONLY);
    if (in < 0) return -1;                      // ★ 실패는 -1, errno 에 이유

    int out = open(dst, O_WRONLY | O_CREAT | O_TRUNC, 0644);
    if (out < 0) { close(in); return -1; }

    char buf[65536];
    ssize_t n;
    while ((n = read(in, buf, sizeof buf)) > 0) {
        ssize_t off = 0;
        while (off < n) {
            // ★ write 는 요청한 만큼 다 안 쓸 수 있습니다 (부분 쓰기)
            ssize_t w = write(out, buf + off, (size_t)(n - off));
            if (w < 0) {
                if (errno == EINTR) continue;   // ★ 시그널에 끊긴 것뿐
                close(in); close(out); return -1;
            }
            off += w;
        }
    }
    int rc = (n < 0) ? -1 : 0;
    close(in);
    if (close(out) < 0) rc = -1;   // ★ close 실패도 데이터 유실입니다
    return rc;
}
초보가 반드시 밟는 두 함정 — ① write 가 일부만 쓰고 돌아올 수 있습니다(특히 소켓·파이프). 위처럼 루프로 감싸야 합니다. ② read/write 가 EINTR 로 실패하면 에러가 아니라 시그널에 끊긴 것이니 다시 부르면 됩니다.

stat · 디렉터리 순회

#include <sys/stat.h>
#include <dirent.h>
#include <stdio.h>
#include <string.h>     // strcmp
#include <time.h>

void list_dir(const char *path) {
    DIR *d = opendir(path);
    if (!d) { perror("opendir"); return; }

    struct dirent *e;
    while ((e = readdir(d)) != NULL) {
        if (!strcmp(e->d_name, ".") || !strcmp(e->d_name, ".."))
            continue;

        char full[4096];
        snprintf(full, sizeof full, "%s/%s", path, e->d_name);

        struct stat st;
        if (stat(full, &st) != 0) continue;     // 심볼릭 링크가 깨졌을 수도

        char when[32];
        struct tm tmv;
        localtime_r(&st.st_mtime, &tmv);
        strftime(when, sizeof when, "%Y-%m-%d %H:%M", &tmv);

        printf("%c %10lld  %s  %s\n",
               S_ISDIR(st.st_mode) ? 'd' : '-',   // 종류
               (long long)st.st_size,             // 크기
               when, e->d_name);
    }
    closedir(d);
}
st_mode 검사뜻
S_ISREG일반 파일
S_ISDIR디렉터리
S_ISLNK심볼릭 링크(lstat 으로 봐야 보임)
st_size바이트 크기
st_mtime내용이 마지막으로 바뀐 시각

mmap — 파일을 포인터처럼 🔴 고급

수 GB 로그 파일에서 줄 수 세기

#include <sys/mman.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>
#include <stdio.h>

long count_lines(const char *path) {
    int fd = open(path, O_RDONLY);
    if (fd < 0) return -1;

    struct stat st;
    if (fstat(fd, &st) != 0) { close(fd); return -1; }
    if (st.st_size == 0) { close(fd); return 0; }

    // 파일 전체를 주소 공간에 붙입니다 — 실제로 읽지는 않습니다
    char *p = mmap(NULL, (size_t)st.st_size,
                   PROT_READ, MAP_PRIVATE, fd, 0);
    close(fd);                       // ★ 매핑 후에는 fd 를 닫아도 됩니다
    if (p == MAP_FAILED) return -1;

    // 커널에 접근 패턴을 알려 주면 미리 읽어 줍니다
    madvise(p, (size_t)st.st_size, MADV_SEQUENTIAL);

    long lines = 0;
    const char *cur = p, *end = p + st.st_size;
    while ((cur = memchr(cur, '\n', (size_t)(end - cur))) != NULL) {
        lines++; cur++;              // memchr 은 SIMD 로 최적화돼 매우 빠릅니다
    }
    munmap(p, (size_t)st.st_size);
    return lines;
}
왜 빠른가 — read 는 커널 버퍼에서 내 버퍼로 복사하지만, mmap 은 복사 없이 페이지 캐시를 그대로 내 주소 공간에 보여 줍니다. 큰 파일을 여러 번 훑거나 랜덤 접근할 때 차이가 큽니다. SQLite·Redis·Elasticsearch 가 모두 이 방식을 씁니다.

mmap 을 쓰면 안 되는 경우

상황이유
순차로 한 번만 읽음read 가 더 단순하고 비슷하게 빠릅니다
32비트 프로세스주소 공간이 부족해 큰 파일을 못 붙입니다
매핑 중 파일이 줄어듦접근하면 SIGBUS 로 즉사합니다 ★
네트워크 파일 시스템NFS 에서 동작이 예측하기 어렵습니다
작은 파일 다수매핑 비용(TLB·페이지 테이블)이 더 큽니다
// 쓰기 매핑 — 파일 내용을 메모리처럼 고칩니다
char *p = mmap(NULL, len, PROT_READ | PROT_WRITE,
               MAP_SHARED, fd, 0);   // ★ MAP_SHARED 여야 파일에 반영
p[0] = 'X';
msync(p, len, MS_SYNC);              // 디스크에 확실히 반영
munmap(p, len);

// 익명 매핑 — 파일 없이 큰 메모리 덩어리 얻기 (malloc 의 속살)
char *big = mmap(NULL, 1u << 30, PROT_READ | PROT_WRITE,
                 MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
// 1GB 를 '예약'만 합니다 — 실제로 건드린 페이지만 물리 메모리를 씁니다
MAP_ANONYMOUS 로 1GB 를 잡아도 메모리가 안 줄어드는 이유 — 리눅스는 건드리기 전까지 물리 메모리를 배정하지 않습니다(overcommit). top 의 VIRT 는 늘고 RSS 는 그대로인 현상이 이것입니다.

시간 재기와 윈도우 분기 🟡 중급

어떤 시계를 써야 하나

#include <time.h>

// ① 경과 시간 측정 — 반드시 MONOTONIC
//    시스템 시계가 NTP 로 뒤로 점프해도 영향받지 않습니다
struct timespec t0, t1;
clock_gettime(CLOCK_MONOTONIC, &t0);
/* ... 작업 ... */
clock_gettime(CLOCK_MONOTONIC, &t1);
double sec = (t1.tv_sec - t0.tv_sec)
           + (t1.tv_nsec - t0.tv_nsec) / 1e9;

// ② '지금 몇 시인가' — REALTIME (벽시계)
clock_gettime(CLOCK_REALTIME, &t0);

// ③ 이 스레드가 실제로 CPU 를 쓴 시간
clock_gettime(CLOCK_THREAD_CPUTIME_ID, &t0);

// ④ 잠깐 자기 (EINTR 을 견디는 형태)
struct timespec req = { .tv_sec = 0, .tv_nsec = 200000000 }; // 0.2초
while (nanosleep(&req, &req) == -1 && errno == EINTR) { }
벤치마크에 time(NULL) 이나 CLOCK_REALTIME 을 쓰면 안 됩니다 — 시간 동기화가 시계를 뒤로 돌리면 음수 경과 시간이 나옵니다. “왜 처리 시간이 -3초로 찍히지?”라는 장애 보고는 거의 전부 이것입니다.

POSIX ↔ Win32 대응표와 분기 관용구

POSIXWin32
open/read/writeCreateFile/ReadFile/WriteFile
closeCloseHandle
statGetFileAttributesEx · _stat64
opendir/readdirFindFirstFile/FindNextFile
mmapCreateFileMapping+MapViewOfFile
clock_gettimeQueryPerformanceCounter
nanosleepSleep(ms)
mkdir(p,mode)CreateDirectory · _mkdir(p)
// 얇은 이식 계층을 하나 만들어 두는 것이 정석입니다
#ifdef _WIN32
  #include <windows.h>
  static double now_sec(void) {
      LARGE_INTEGER f, c;
      QueryPerformanceFrequency(&f);
      QueryPerformanceCounter(&c);
      return (double)c.QuadPart / (double)f.QuadPart;
  }
  static void sleep_ms(int ms) { Sleep((DWORD)ms); }
#else
  #include <time.h>
  static double now_sec(void) {
      struct timespec t;
      clock_gettime(CLOCK_MONOTONIC, &t);
      return t.tv_sec + t.tv_nsec / 1e9;
  }
  static void sleep_ms(int ms) {
      struct timespec r = { ms / 1000, (long)(ms % 1000) * 1000000L };
      nanosleep(&r, NULL);
  }
#endif
// ★ 나머지 코드는 now_sec / sleep_ms 만 부릅니다 — #ifdef 가 번지지 않게
#ifdef 를 코드 곳곳에 흩뿌리지 마세요 — 플랫폼 함수 하나당 래퍼 하나를 만들고 본문에는 래퍼만 쓰는 것이 규칙입니다. 그러지 않으면 3년 뒤 아무도 못 고치는 파일이 됩니다. libuv 는 이 이식 계층 자체를 라이브러리로 만든 것입니다(c26 참고).
정리 — C 표준만으로는 폴더도 못 읽습니다. 파일 크기·시각은 stat, 폴더는 opendir, 큰 파일은 mmap, 시간 측정은 CLOCK_MONOTONIC 이 정답입니다. read·write 는 부분 전송과 EINTR 을 루프로 감싸는 것이 기본형이고, 윈도우 대응은 #ifdef 를 래퍼 함수 안에만 가두면 됩니다.
C 18

소켓 프로그래밍 — TCP 서버와 클라이언트를 처음부터

네트워크 라이브러리를 한 겹만 걷어 내면 전부 이 여섯 개의 C 함수가 나옵니다 — socket · bind · listen · accept · connect · close. 파이썬 socket 모듈도 이름까지 똑같습니다. 여기서는 복사해서 바로 도는 에코 서버와 클라이언트를 만들고, 접속이 늘어날 때 select · poll · epoll 로 어떻게 넘어가는지까지 봅니다.

TCP — 서버와 클라이언트가 부르는 순서, 그리고 만나는 지점 서버 쪽 클라이언트 쪽 socket(AF_INET, SOCK_STREAM, 0) 통신용 파일 디스크립터를 하나 만듭니다 bind(fd, 주소, 크기) 어느 IP 와 포트에서 들을지 못박습니다 listen(fd, backlog) 대기 큐를 열고 접속을 받을 준비를 합니다 accept(fd) → 새 fd 를 돌려줍니다 접속이 올 때까지 여기서 멈춰 기다립니다 read / write → close(새 fd) 듣는 fd 는 닫지 않고 다음 손님을 받습니다 socket(AF_INET, SOCK_STREAM, 0) 서버와 똑같이 시작합니다 connect(fd, 서버 주소) 3-way 핸드셰이크가 여기서 일어납니다 write / read → close(fd) 보낸 순서대로 도착하지만 경계는 없습니다 backlog 는 "핸드셰이크는 끝났지만 아직 accept 하지 않은 연결"이 쌓이는 큐의 길이입니다 accept 가 돌려준 새 fd 가 진짜 통신 통로이고, 듣는 fd 는 계속 살아 다음 접속을 받습니다
서버가 fd 를 두 개 갖는다는 점이 핵심입니다 — 듣는 소켓과 손님별 소켓은 완전히 다릅니다.

TCP 에코 서버 — 윈도우·리눅스·맥에서 그대로 도는 전체 코드

// echo_server.c
//   리눅스/맥 : gcc -std=c17 -O2 -Wall echo_server.c -o echo_server
//   윈도우    : gcc echo_server.c -o echo_server.exe -lws2_32
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

#ifdef _WIN32
  #include <winsock2.h>
  #include <ws2tcpip.h>
  #pragma comment(lib, "ws2_32.lib")      // MSVC 용. gcc 는 -lws2_32
  typedef SOCKET sock_t;
  #define CLOSESOCK closesocket
  #define SOCK_ERR  WSAGetLastError()
  static int net_init(void) {
      WSADATA w; return WSAStartup(MAKEWORD(2,2), &w);   // ★ 윈도우만 필요
  }
  static void net_quit(void) { WSACleanup(); }
#else
  #include <sys/socket.h>
  #include <netinet/in.h>
  #include <arpa/inet.h>
  #include <unistd.h>
  #include <errno.h>
  typedef int sock_t;
  #define INVALID_SOCKET (-1)
  #define CLOSESOCK close
  #define SOCK_ERR  errno
  static int  net_init(void) { return 0; }
  static void net_quit(void) { }
#endif

#define PORT 9000

int main(void) {
    if (net_init() != 0) { fprintf(stderr, "네트워크 초기화 실패\n"); return 1; }

    sock_t srv = socket(AF_INET, SOCK_STREAM, 0);
    if (srv == INVALID_SOCKET) { perror("socket"); return 1; }

    // ★ 서버를 껐다 켤 때 "Address already in use" 를 막습니다
    int yes = 1;
    setsockopt(srv, SOL_SOCKET, SO_REUSEADDR, (const char *)&yes, sizeof yes);

    struct sockaddr_in addr;
    memset(&addr, 0, sizeof addr);
    addr.sin_family      = AF_INET;
    addr.sin_addr.s_addr = htonl(INADDR_ANY);   // 모든 인터페이스
    addr.sin_port        = htons(PORT);         // ★ 네트워크 바이트 순서

    if (bind(srv, (struct sockaddr *)&addr, sizeof addr) != 0) {
        fprintf(stderr, "bind 실패 (%d)\n", SOCK_ERR); return 1;
    }
    if (listen(srv, 128) != 0) {
        fprintf(stderr, "listen 실패 (%d)\n", SOCK_ERR); return 1;
    }
    printf("포트 %d 에서 대기 중\n", PORT);

    for (;;) {
        struct sockaddr_in peer;
        socklen_t plen = sizeof peer;
        sock_t cli = accept(srv, (struct sockaddr *)&peer, &plen);
        if (cli == INVALID_SOCKET) continue;

        char ip[INET_ADDRSTRLEN];
        inet_ntop(AF_INET, &peer.sin_addr, ip, sizeof ip);
        printf("접속: %s:%d\n", ip, ntohs(peer.sin_port));

        char buf[4096];
        for (;;) {
            int n = recv(cli, buf, sizeof buf, 0);
            if (n == 0)  { printf("상대가 닫음\n"); break; }   // ★ 정상 종료
            if (n < 0)   { printf("오류 %d\n", SOCK_ERR); break; }

            // ★ send 도 부분 전송이 있습니다 — 다 보낼 때까지 루프
            int off = 0;
            while (off < n) {
                int w = send(cli, buf + off, n - off, 0);
                if (w <= 0) goto done;
                off += w;
            }
        }
    done:
        CLOSESOCK(cli);
    }
    CLOSESOCK(srv);
    net_quit();
    return 0;
}
가장 많이 틀리는 세 가지 — ① htons/htonl 을 빠뜨려 포트가 엉뚱한 값이 됩니다. ② recv 가 0 을 돌려주면 에러가 아니라 상대가 정상적으로 닫은 것입니다. ③ SO_REUSEADDR 없이 서버를 재시작하면 TIME_WAIT 때문에 1~2분 동안 bind 가 실패합니다.

클라이언트 — 이름 해석까지 제대로

// getaddrinfo 는 IPv4/IPv6 를 자동으로 처리합니다
// (inet_addr 은 IPv4 전용 + 낡았습니다 — 쓰지 마세요)
struct addrinfo hints, *res, *it;
memset(&hints, 0, sizeof hints);
hints.ai_family   = AF_UNSPEC;      // v4 든 v6 든
hints.ai_socktype = SOCK_STREAM;

if (getaddrinfo("127.0.0.1", "9000", &hints, &res) != 0) return 1;

sock_t fd = INVALID_SOCKET;
for (it = res; it; it = it->ai_next) {       // ★ 후보를 순서대로 시도
    fd = socket(it->ai_family, it->ai_socktype, it->ai_protocol);
    if (fd == INVALID_SOCKET) continue;
    if (connect(fd, it->ai_addr, (int)it->ai_addrlen) == 0) break;
    CLOSESOCK(fd);
    fd = INVALID_SOCKET;
}
freeaddrinfo(res);
if (fd == INVALID_SOCKET) { fprintf(stderr, "접속 실패\n"); return 1; }

const char *msg = "안녕 서버\n";
send(fd, msg, (int)strlen(msg), 0);

char buf[4096];
int n = recv(fd, buf, sizeof buf - 1, 0);
if (n > 0) { buf[n] = '\0'; printf("에코: %s", buf); }
CLOSESOCK(fd);
먼저 이렇게 시험해 보세요 — 서버를 띄운 뒤 다른 터미널에서 nc 127.0.0.1 9000(또는 telnet) 을 치면 클라이언트를 안 만들고도 바로 확인됩니다. 패킷이 궁금하면 tcpdump -i lo port 9000 또는 Wireshark 를 켜 두면 됩니다.

TCP 는 스트림입니다 — 메시지 경계가 없습니다 ★

// send 를 세 번 했다고 recv 가 세 번 나뉘어 오지 않습니다.
// 한 번에 뭉쳐 올 수도, 한 번이 여러 번에 나뉘어 올 수도 있습니다.
//   → '어디까지가 한 메시지인지'는 내가 정해야 합니다

// 방법 ① 길이 접두사 (가장 흔하고 안전)
int send_msg(sock_t fd, const void *p, uint32_t len) {
    uint32_t be = htonl(len);                 // 4바이트 길이 먼저
    if (send_all(fd, &be, 4) != 0) return -1;
    return send_all(fd, p, len);
}

int recv_msg(sock_t fd, char *out, uint32_t max, uint32_t *got) {
    uint32_t be;
    if (recv_all(fd, &be, 4) != 0) return -1;
    uint32_t len = ntohl(be);
    if (len > max) return -1;                 // ★ 길이 검증 필수(메모리 폭탄 방지)
    if (recv_all(fd, out, len) != 0) return -1;
    *got = len;
    return 0;
}

// 방법 ② 구분자 — 줄바꿈까지 읽기 (HTTP · Redis 프로토콜 방식)
// 방법 ③ 고정 길이 레코드 — 가장 단순하지만 확장이 어렵습니다
“내 컴퓨터에선 되던데요”의 정체 — 로컬에서는 데이터가 작아서 한 번에 다 옵니다. 실제 네트워크에서는 MTU·혼잡 제어 때문에 조각나 갑자기 깨집니다. recv 한 번 = 메시지 하나라고 가정한 코드는 배포한 날 터집니다.

접속이 늘어나면 — select · poll · epoll 🔴 고급

한 스레드로 여러 접속 다루기

// poll — select 보다 낫고 어디서나 됩니다(윈도우는 WSAPoll)
#include <poll.h>

struct pollfd fds[1024];
int nfds = 0;
fds[nfds].fd = srv; fds[nfds].events = POLLIN; nfds++;   // 듣는 소켓

for (;;) {
    int r = poll(fds, nfds, 1000);        // 1초 타임아웃
    if (r < 0) { if (errno == EINTR) continue; break; }

    for (int i = 0; i < nfds; i++) {
        if (!(fds[i].revents & POLLIN)) continue;

        if (fds[i].fd == srv) {           // 새 접속
            int cli = accept(srv, NULL, NULL);
            if (cli >= 0 && nfds < 1024) {
                set_nonblocking(cli);     // ★ 반드시 논블로킹으로
                fds[nfds].fd = cli;
                fds[nfds].events = POLLIN;
                nfds++;
            }
        } else {                          // 기존 접속에 데이터
            char buf[4096];
            int n = recv(fds[i].fd, buf, sizeof buf, 0);
            if (n <= 0) { close(fds[i].fd); fds[i] = fds[--nfds]; }
            else        { send_all(fds[i].fd, buf, n); }
        }
    }
}
// 논블로킹으로 바꾸기
#ifdef _WIN32
    u_long on = 1; ioctlsocket(fd, FIONBIO, &on);
#else
    int fl = fcntl(fd, F_GETFL, 0);
    fcntl(fd, F_SETFL, fl | O_NONBLOCK);
#endif
// 논블로킹에서는 데이터가 없으면 recv 가 -1 을 돌려주고
// errno 가 EAGAIN / EWOULDBLOCK 입니다 — 에러가 아닙니다

셋 중 무엇을 쓰나

방식복잡도언제
selectO(n) 매번 전체 스캔fd 1024 제한. 새 코드에는 쓰지 않습니다
pollO(n)수백 접속까지. 이식성이 가장 좋음
epollO(준비된 것만)리눅스. 수만 접속 — 서버의 표준
kqueue같음맥·BSD
IOCP완료 통지형윈도우 고성능 서버
io_uring배치 제출리눅스 5.1+ — 최신 최고 성능
// epoll — 리눅스에서 접속 1만 개를 한 스레드로
#include <sys/epoll.h>

int ep = epoll_create1(0);
struct epoll_event ev = { .events = EPOLLIN, .data.fd = srv };
epoll_ctl(ep, EPOLL_CTL_ADD, srv, &ev);

struct epoll_event out[512];
for (;;) {
    int n = epoll_wait(ep, out, 512, -1);
    for (int i = 0; i < n; i++) {      // ★ '준비된 것만' 돌아옵니다
        int fd = out[i].data.fd;
        /* ... 위와 같은 처리 ... */
    }
}
// EPOLLET(엣지 트리거)를 쓰면 더 빠르지만,
// "EAGAIN 이 날 때까지 전부 읽어야" 하는 규칙이 생깁니다
실무에서는 직접 안 씁니다 — libuv(Node.js 의 심장) 나 libevent 가 epoll·kqueue·IOCP 를 하나의 API 로 덮어 줍니다. 그래도 이 절을 아는 것이 중요한 이유는, 장애가 났을 때 로그에 나오는 것이 결국 이 층의 용어이기 때문입니다.
실서비스로 가기 전 최소 체크리스트 — ① SIGPIPE 무시(signal(SIGPIPE, SIG_IGN))하지 않으면 끊긴 연결에 send 하는 순간 프로세스가 죽습니다. ② 읽기·쓰기 타임아웃(SO_RCVTIMEO) 설정. ③ 길이 필드는 반드시 상한 검증. ④ TLS 가 필요하면 직접 만들지 말고 mbedTLS·OpenSSL 를 얹습니다. ⑤ 작은 메시지를 자주 보낸다면 TCP_NODELAY(Nagle 끄기)를 검토합니다.
정리 — TCP 서버는 socket → bind → listen → accept 네 줄이 전부이고, 클라이언트는 socket → connect 두 줄입니다. 가장 중요한 사실은 TCP 에 메시지 경계가 없다는 것 — 길이 접두사를 붙이는 습관 하나가 실전 버그의 절반을 막습니다. 접속이 많아지면 poll → epoll 로 올라가되, 실무에서는 대개 libuv 같은 래퍼를 씁니다.
C 19

스레드와 동기화 — pthreads · C11 threads · atomics

파이썬에는 GIL 이 있어서 스레드를 써도 CPU 가 하나뿐이지만, C 는 진짜로 코어를 다 씁니다. 그 대가로 데이터 레이스라는, 재현도 안 되고 디버거로도 안 잡히는 종류의 버그가 생깁니다. 이 절은 왜 counter++ 하나가 깨지는지부터 시작해서 뮤텍스·조건 변수·원자 연산과 TSan 으로 실제로 잡아내는 법까지 갑니다.

counter++ 는 한 동작이 아닙니다 — 읽고 · 더하고 · 쓰는 세 단계입니다 두 스레드가 각각 한 번씩 증가시킵니다. 결과는 2 여야 합니다 스레드 A load → 0 +1 = 1 store 1 스레드 B load → 0 +1 = 1 store 1 시간 결과는 2 가 아니라 1 입니다 B 가 읽은 값이 A 의 갱신 전 값이라 하나가 통째로 사라졌습니다 이것이 데이터 레이스입니다 — 대부분은 잘 돌다가 가끔 틀립니다 고치는 방법은 두 가지뿐입니다 ① 뮤텍스로 세 단계를 통째로 묶어 한 번에 한 스레드만 ② atomic_fetch_add 로 CPU 차원의 한 동작으로 만듭니다
데이터 레이스는 “가끔 값이 이상하다”로 나타납니다 — 디버거를 붙이면 느려져서 재현이 안 되는 것이 특징입니다.

pthreads — 리눅스·맥의 사실상 표준

#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>

typedef struct { int from, to; long sum; } Job;

// 스레드 함수의 모양은 고정입니다: void* (*)(void*)
static void *worker(void *arg) {
    Job *j = arg;
    long s = 0;
    for (int i = j->from; i < j->to; i++) s += i;
    j->sum = s;                 // ★ 각자 다른 Job 을 쓰므로 레이스 없음
    return NULL;
}

int main(void) {
    enum { N = 4 };
    pthread_t th[N];
    Job jobs[N];

    for (int i = 0; i < N; i++) {
        jobs[i] = (Job){ i * 250000, (i + 1) * 250000, 0 };
        if (pthread_create(&th[i], NULL, worker, &jobs[i]) != 0) {
            perror("pthread_create"); return 1;
        }
    }
    long total = 0;
    for (int i = 0; i < N; i++) {
        pthread_join(th[i], NULL);   // ★ 끝날 때까지 기다립니다
        total += jobs[i].sum;
    }
    printf("합계 %ld\n", total);
}
// 컴파일: gcc -std=c17 -O2 -pthread sum.c -o sum
-pthread 를 빼먹으면 링크는 되는데 런타임에 이상하게 동작할 수 있습니다 (일부 환경에서 스레드 지원 코드가 비활성화됩니다). -lpthread 가 아니라 -pthread 를 컴파일·링크 양쪽에 주는 것이 정확한 방법입니다.

C11 <threads.h> — 표준이지만 현실은

#include <threads.h>
#include <stdio.h>

static int worker(void *arg) {     // ★ 반환형이 int (pthreads 는 void*)
    printf("스레드 %d\n", *(int *)arg);
    return 0;
}

int main(void) {
    thrd_t t;
    int id = 7;
    if (thrd_create(&t, worker, &id) != thrd_success) return 1;
    thrd_join(t, NULL);

    mtx_t m;  mtx_init(&m, mtx_plain);
    mtx_lock(&m);  /* 임계 구역 */  mtx_unlock(&m);
    mtx_destroy(&m);
}
환경지원 상황(2026 기준)
glibc(리눅스)2.28+ 부터 지원
macOSlibc 에 없음 — 별도 구현 필요
MSVC최근 버전에서 지원 시작(오래 없었음)
임베디드대부분 없음
그래서 실무는 아직 pthreads 입니다 — 리눅스·맥·안드로이드가 pthreads 이고, 윈도우는 CreateThread/SRWLOCK 또는 pthreads-win32 를 씁니다. 새 프로젝트라면 스레드 래퍼를 얇게 하나 두고 그 안에서만 분기하는 것이 가장 안전합니다.

뮤텍스와 조건 변수 — 생산자·소비자 큐 🟡 중급

스레드 안전 큐 — 실무에서 가장 자주 쓰는 형태

#include <pthread.h>
#include <stdbool.h>

#define CAP 256

typedef struct {
    int   buf[CAP];
    size_t head, tail, count;
    bool  closed;
    pthread_mutex_t m;
    pthread_cond_t  not_empty;   // "넣었다" 알림
    pthread_cond_t  not_full;    // "뺐다" 알림
} Queue;

void q_init(Queue *q) {
    q->head = q->tail = q->count = 0;
    q->closed = false;
    pthread_mutex_init(&q->m, NULL);
    pthread_cond_init(&q->not_empty, NULL);
    pthread_cond_init(&q->not_full,  NULL);
}

void q_push(Queue *q, int v) {
    pthread_mutex_lock(&q->m);
    while (q->count == CAP)                    // ★ if 가 아니라 while
        pthread_cond_wait(&q->not_full, &q->m);
    q->buf[q->tail] = v;
    q->tail = (q->tail + 1) % CAP;
    q->count++;
    pthread_cond_signal(&q->not_empty);
    pthread_mutex_unlock(&q->m);
}
bool q_pop(Queue *q, int *out) {
    pthread_mutex_lock(&q->m);
    while (q->count == 0 && !q->closed)
        pthread_cond_wait(&q->not_empty, &q->m);

    if (q->count == 0) {                       // 닫혔고 비었다
        pthread_mutex_unlock(&q->m);
        return false;
    }
    *out = q->buf[q->head];
    q->head = (q->head + 1) % CAP;
    q->count--;
    pthread_cond_signal(&q->not_full);
    pthread_mutex_unlock(&q->m);
    return true;
}

// 더 이상 안 넣는다고 알리기 — 소비자들이 빠져나옵니다
void q_close(Queue *q) {
    pthread_mutex_lock(&q->m);
    q->closed = true;
    pthread_cond_broadcast(&q->not_empty);     // ★ 전원 깨우기
    pthread_mutex_unlock(&q->m);
}
while 이지 if 가 아닙니다 — cond_wait 는 아무도 신호를 안 보냈는데 깨어날 수 있습니다(가짜 깨어남). 조건을 다시 확인하지 않으면 빈 큐에서 꺼내는 버그가 납니다. 이건 자바 wait/notify, 파이썬 Condition 도 완전히 동일합니다.
왜 조건 변수가 필요한가 — 큐가 비었을 때 while(count==0){} 로 도는 것은 CPU 코어 하나를 통째로 태우는 짓입니다. 조건 변수는 커널에 재워 두었다가 신호가 왔을 때만 깨워 줍니다. cond_wait 는 뮤텍스를 자동으로 풀고 잠들었다가, 깨어날 때 다시 잠급니다 — 이 원자성이 조건 변수의 존재 이유입니다.

원자 연산과 데이터 레이스 잡기 🔴 고급

stdatomic.h — 뮤텍스 없이 안전하게

#include <stdatomic.h>

atomic_long counter = 0;

void *bump(void *_) {
    for (int i = 0; i < 1000000; i++)
        atomic_fetch_add(&counter, 1);   // ★ 읽기+더하기+쓰기가 한 동작
    return NULL;
}
// 뮤텍스보다 훨씬 빠릅니다 — 커널에 들어가지 않으니까요

// 플래그 하나로 스레드 멈추기 (가장 흔한 용도)
atomic_bool stop = false;

void *loop(void *_) {
    while (!atomic_load_explicit(&stop, memory_order_relaxed)) {
        /* 일 */
    }
    return NULL;
}
// 다른 스레드에서:
atomic_store(&stop, true);

// 락 없는 스택 push — CAS(compare-and-swap) 관용구
void push(_Atomic(Node *) *head, Node *n) {
    Node *old = atomic_load(head);
    do {
        n->next = old;
    } while (!atomic_compare_exchange_weak(head, &old, n));
    // ★ 실패하면 old 가 '현재 값'으로 갱신되어 다시 시도합니다
}
volatile 은 스레드 동기화 도구가 아닙니다 — 컴파일러 최적화만 막을 뿐 CPU 의 재배치와 캐시 일관성은 전혀 다루지 않습니다. 자바·C#의 volatile 과 의미가 다릅니다. C 에서 스레드 간 공유에는 반드시 _Atomic 이나 뮤텍스를 쓰세요. volatile 의 진짜 자리는 c22 의 하드웨어 레지스터입니다.

ThreadSanitizer — 실제로 잡아 봅니다

# 레이스가 있는 코드를 일부러 만들어 보세요
gcc -std=c17 -g -O1 -fsanitize=thread -pthread race.c -o race
./race
WARNING: ThreadSanitizer: data race (pid=12345)
  Write of size 8 at 0x55d1e8 by thread T2:
    #0 bump race.c:12 (race+0x1220)

  Previous write of size 8 at 0x55d1e8 by thread T1:
    #0 bump race.c:12 (race+0x1220)

  Location is global 'counter' of size 8 at 0x55d1e8

SUMMARY: ThreadSanitizer: data race race.c:12 in bump
도구잡는 것
-fsanitize=thread데이터 레이스 · 잘못된 락 순서. 5~15배 느림
-fsanitize=address버퍼 넘침 · use-after-free (TSan 과 동시 사용 불가)
helgrindvalgrind 계열 레이스 검출. 더 느리지만 재빌드 불필요
gdb thread apply all bt데드락 났을 때 모든 스레드 스택
데드락을 예방하는 규칙 하나 — 여러 뮤텍스를 잡을 때는 항상 같은 순서로 잡습니다. A→B 로 잡는 곳과 B→A 로 잡는 곳이 하나라도 있으면 언젠가 멈춥니다. 락에 번호를 매기고 번호가 커지는 방향으로만 잡는 것이 실무의 정석입니다.
정리 — C 의 스레드는 진짜 병렬이고, 그래서 공유 데이터를 만지는 순간 규칙이 필요합니다. 여럿이 읽고 쓰면 → 뮤텍스, 카운터·플래그 하나면 → _Atomic, 기다려야 하면 → 조건 변수(while 로 재확인). 그리고 스레드를 쓰는 프로젝트라면 CI 에 -fsanitize=thread 빌드를 하나 넣어 두세요 — 사람 눈으로 찾을 수 없는 버그를 기계가 찾아 줍니다.
C 20

프로세스 · 시그널 · 파이프 — 셸이 하는 일을 직접

ls | grep c 를 치면 셸이 무슨 일을 하는지 아십니까? fork 로 자기를 복제하고, pipe 로 둘을 잇고, exec 로 다른 프로그램이 되고, wait 으로 기다립니다 — 전부 이 절의 함수입니다. 파이썬 subprocess, Node child_process 도 안에서 정확히 이 일을 합니다. Ctrl+C 를 눌렀을 때 프로그램이 깨끗하게 끝나게 만드는 법도 여기서 배웁니다.

fork() — 한 번 부르고 두 번 돌아옵니다 부모 프로세스 (PID 1000) 여기까지는 평범한 프로그램 하나입니다 pid = fork(); 부모 — 반환값은 1001 (자식 PID) wait() 으로 자식이 끝나길 기다립니다 기다리지 않으면 좀비가 남습니다 자식 — 반환값은 0 execvp() 로 다른 프로그램이 됩니다 성공하면 이 아래 코드는 실행되지 않습니다 메모리는 그 자리에서 복사되지 않습니다 — copy-on-write 두 프로세스는 같은 물리 페이지를 공유하다가, 어느 한쪽이 쓰는 순간에만 그 페이지를 복사합니다 그래서 메모리를 1GB 쓰는 프로세스에서도 fork 자체는 밀리초 단위로 끝납니다
반환값이 0 이면 내가 자식, 0보다 크면 내가 부모 — 이 한 줄이 프로세스 프로그래밍의 출발점입니다.

fork + exec + wait — 외부 명령 실행하기

#include <unistd.h>
#include <sys/wait.h>
#include <stdio.h>
#include <errno.h>

int run(char *const argv[]) {
    pid_t pid = fork();
    if (pid < 0) { perror("fork"); return -1; }

    if (pid == 0) {                       // ── 자식 ──
        execvp(argv[0], argv);            // 성공하면 여기로 안 돌아옵니다
        perror("execvp");                 // 여기 왔다는 건 실패했다는 뜻
        _exit(127);                       // ★ exit 이 아니라 _exit
    }

    // ── 부모 ──
    int status;
    while (waitpid(pid, &status, 0) < 0)
        if (errno != EINTR) return -1;

    if (WIFEXITED(status))   return WEXITSTATUS(status);   // 정상 종료 코드
    if (WIFSIGNALED(status)) return 128 + WTERMSIG(status); // 시그널로 죽음
    return -1;
}

int main(void) {
    char *cmd[] = { "ls", "-l", NULL };   // ★ 마지막은 반드시 NULL
    printf("종료 코드 %d\n", run(cmd));
}
자식에서는 exit 대신 _exit 를 쓰세요. exit 은 atexit 훅을 돌리고 stdio 버퍼를 flush 하는데, 그 버퍼는 부모에게서 복사된 것이라 같은 출력이 두 번 찍힙니다. 실제로 로그가 중복되는 사고의 단골 원인입니다.

파이프로 자식의 출력을 받아 오기

// 간단하게: popen — 셸을 거칩니다
FILE *p = popen("git rev-parse HEAD", "r");
if (p) {
    char line[128];
    if (fgets(line, sizeof line, p)) printf("커밋 %s", line);
    int rc = pclose(p);     // ★ 반드시 pclose (종료 코드도 여기서)
}
// ⚠ popen 은 문자열을 셸에 넘깁니다 —
//   사용자 입력을 그대로 넣으면 명령 주입 취약점이 됩니다

// 정확하게: pipe + fork (셸을 안 거칩니다)
int fd[2];
if (pipe(fd) != 0) return -1;       // fd[0]=읽기, fd[1]=쓰기

pid_t pid = fork();
if (pid == 0) {
    close(fd[0]);                   // ★ 안 쓰는 쪽은 반드시 닫습니다
    dup2(fd[1], STDOUT_FILENO);     // 표준 출력을 파이프로 바꿔치기
    close(fd[1]);
    execlp("ls", "ls", "-l", (char *)NULL);
    _exit(127);
}
close(fd[1]);                       // 부모도 쓰기 쪽을 닫아야
char buf[4096];                     // 자식이 끝날 때 EOF 가 옵니다
ssize_t n;
while ((n = read(fd[0], buf, sizeof buf)) > 0)
    fwrite(buf, 1, (size_t)n, stdout);
close(fd[0]);
waitpid(pid, NULL, 0);
파이프에서 가장 흔한 버그 — 안 쓰는 쪽 fd 를 안 닫는 것입니다. 쓰기 쪽이 어딘가에 하나라도 열려 있으면 읽기 쪽이 EOF 를 영영 못 받아 프로그램이 멈춥니다. “왜 자식이 끝났는데 read 가 안 끝나지?”의 답은 거의 항상 이것입니다.

시그널 — Ctrl+C 를 우아하게 처리하기 🟡 중급

sigaction — signal() 보다 이쪽을 쓰세요

#include <signal.h>
#include <stdio.h>
#include <string.h>     // memset
#include <unistd.h>

// ★ 핸들러에서 건드릴 수 있는 유일한 전역 타입
static volatile sig_atomic_t g_stop = 0;

static void on_sigint(int sig) {
    (void)sig;
    g_stop = 1;          // 플래그만 세웁니다. 그 이상은 하지 않습니다
}

int main(void) {
    struct sigaction sa;
    memset(&sa, 0, sizeof sa);
    sa.sa_handler = on_sigint;
    sigemptyset(&sa.sa_mask);
    sa.sa_flags = SA_RESTART;      // 끊긴 시스템 콜을 자동 재시작

    sigaction(SIGINT,  &sa, NULL);  // Ctrl+C
    sigaction(SIGTERM, &sa, NULL);  // kill 기본 시그널
    signal(SIGPIPE, SIG_IGN);       // ★ 소켓 서버에는 거의 필수

    while (!g_stop) {
        /* 일을 합니다 */
        sleep(1);
    }
    printf("정리하고 종료합니다\n");   // 여기서 파일 닫기·플러시
    return 0;
}
핸들러 안에서 하면 안 되는 것 — printf·malloc·free는 모두 비동기 시그널 안전이 아닙니다. 락을 잡고 있는 중에 시그널이 오면 데드락이 납니다. 핸들러에서 허용되는 것은 volatile sig_atomic_t 플래그 설정과 write(2) 정도입니다. 나머지는 전부 메인 루프에서 하세요.

알아 둘 시그널과 종료 코드

시그널언제 · 무엇
SIGINT (2)Ctrl+C. 잡을 수 있음
SIGTERM (15)kill 기본 · 도커 stop. 정리하고 나가라
SIGKILL (9)잡을 수 없음. 즉시 사망
SIGSEGV (11)잘못된 메모리 접근 — 대개 포인터 버그
SIGPIPE (13)끊긴 파이프·소켓에 쓰기. 무시 설정 필수
SIGCHLD (17)자식이 끝남. 좀비 회수 신호
SIGABRT (6)abort()·assert 실패
SIGBUS (7)정렬 오류 · mmap 한 파일이 줄어듦
# 셸에서 종료 코드로 원인을 봅니다
./app; echo $?
#   0     정상
#   1~125 프로그램이 정한 값
#   130   = 128 + 2  → SIGINT (Ctrl+C)
#   139   = 128 + 11 → SIGSEGV (세그폴트)
#   137   = 128 + 9  → SIGKILL (OOM 킬러가 죽였을 가능성 큼)

# 도커에서 컨테이너가 137 로 죽으면 메모리 초과가 1순위 의심
dmesg | grep -i "killed process"
graceful shutdown 이 왜 중요한가 — 쿠버네티스·도커는 종료할 때 SIGTERM 을 보내고 몇 초 기다린 뒤 SIGKILL 합니다. 그 사이에 연결 정리·버퍼 플러시를 못 하면 데이터가 유실됩니다. 위의 플래그 패턴 하나면 그 요구를 만족합니다.

윈도우에서는 — fork 가 없습니다 🔴 고급

CreateProcess — fork+exec 이 한 함수

#include <windows.h>
#include <stdio.h>

int run_win(const char *cmdline) {
    STARTUPINFOA si;  PROCESS_INFORMATION pi;
    ZeroMemory(&si, sizeof si);  si.cb = sizeof si;
    ZeroMemory(&pi, sizeof pi);

    // ★ cmdline 은 쓰기 가능한 버퍼여야 합니다 (API 가 수정합니다)
    char buf[1024];
    snprintf(buf, sizeof buf, "%s", cmdline);

    if (!CreateProcessA(NULL, buf, NULL, NULL, FALSE,
                        0, NULL, NULL, &si, &pi)) {
        fprintf(stderr, "CreateProcess 실패 %lu\n", GetLastError());
        return -1;
    }
    WaitForSingleObject(pi.hProcess, INFINITE);   // wait() 에 해당

    DWORD code = 0;
    GetExitCodeProcess(pi.hProcess, &code);
    CloseHandle(pi.hProcess);                     // ★ 핸들 누수 주의
    CloseHandle(pi.hThread);
    return (int)code;
}
왜 윈도우에 fork 가 없나 — 윈도우의 프로세스는 생성 시점에 실행할 이미지를 정하는 구조라 “현재 프로세스를 복제한다”는 개념 자체가 없습니다. 그래서 이식성 있는 코드는 “외부 프로그램 실행”이라는 한 가지 기능으로 추상화해 두고 그 안에서 분기합니다.

대응표와 실무 선택

POSIXWindows
fork + execvpCreateProcess
waitpidWaitForSingleObject
pipeCreatePipe
popen_popen
kill(pid, SIGTERM)TerminateProcess(강제)
시그널 핸들러SetConsoleCtrlHandler
getpidGetCurrentProcessId
양쪽을 다 지원해야 한다면 — 프로세스 실행은 차이가 커서 #ifdef 로 덮기 힘듭니다. libuv 의 uv_spawn 이나 subprocess.h(단일 헤더 라이브러리)를 쓰면 파이프 연결·환경 변수·종료 코드까지 한 API 로 해결됩니다. 직접 만들 값어치가 별로 없는 영역입니다.
좀비 프로세스 — 자식이 끝났는데 부모가 wait 을 안 하면 커널이 종료 코드를 담은 껍데기를 계속 붙들고 있습니다(ps 에 Z 로 보임). 오래 도는 서버라면 SIGCHLD 핸들러에서 while (waitpid(-1, NULL, WNOHANG) > 0) {} 를 돌려 회수하세요.
정리 — fork 는 반환값으로 부모·자식을 구분하고, 자식은 exec 으로 다른 프로그램이 됩니다. 부모는 반드시 wait 으로 회수해야 좀비가 안 남습니다. 파이프는 안 쓰는 쪽을 닫는 것이 전부이고, 시그널 핸들러에서는 플래그만 세우고 나옵니다. 윈도우에는 fork 가 없으니 “프로세스 실행” 한 기능으로 추상화하는 것이 정답입니다.
C 21

C23 — 30년 만에 C 가 편해졌습니다

C 는 오랫동안 “변하지 않는 언어”였습니다. C11 이후 C17 은 버그 수정판이었고, 실무 기준은 늘 C99 였죠. 그런데 C23 은 다릅니다 — nullptr, constexpr, typeof, #embed, auto, 2진 리터럴까지 C++ 에서 부러워하던 것들이 대거 들어왔습니다. 다만 컴파일러 지원이 고르지 않아, 무엇을 지금 써도 되는지를 아는 것이 이 절의 목표입니다.

C 표준 연표 — 그리고 무엇이 지금 쓸 수 있는가 C89 / C90 1990 함수 프로토타입 C99 1999 stdint · inline C11 2011 _Atomic · threads C17 2018 버그 수정판 C23 2024 nullptr · #embed 시간 2026년 8월 기준 지원 현황 GCC 15 · Clang 20 은 -std=c23 으로 대부분 쓸 수 있습니다 (일부 기능은 여전히 실험적) MSVC 는 일부만 지원합니다 — 여러 플랫폼을 지원해야 한다면 아직 C17 이 안전한 기준선입니다
지금 혼자 쓰는 도구라면 C23 을 켜도 좋고, 남이 빌드할 코드라면 C17 이 기본값입니다.

바로 편해지는 것들

// ① nullptr — NULL 의 모호함이 사라집니다
void f(int);
void f_ptr(char *);
char *p = nullptr;          // 타입이 nullptr_t 로 확실합니다
// 예전에는 NULL 이 0 일 수도 (void*)0 일 수도 있어
// 가변 인자로 넘길 때 크기가 어긋나는 사고가 있었습니다
execl("/bin/ls", "ls", nullptr);   // ★ (char*)NULL 안 써도 됩니다

// ② bool 이 키워드가 됐습니다 — stdbool.h 불필요
bool ok = true;

// ③ 2진 리터럴 + 자릿수 구분자
unsigned mask  = 0b1010'1010;      // 비트 마스크가 눈에 보입니다
unsigned flags = 0b0000'0001;

// ④ auto — 타입 추론 (C++ 의 그것과 같습니다)
auto n = 42;                       // int
auto s = "hi";                     // char* (C 의 문자열 리터럴은 const 가 아님)
// 매크로 안에서 임시 변수를 만들 때 특히 유용합니다

// ⑤ typeof / typeof_unqual — GCC 확장이 표준이 됐습니다
#define SWAP(a, b) do { typeof(a) _t = (a); (a) = (b); (b) = _t; } while (0)

// ⑥ 열거형의 밑바탕 타입 지정
enum Color : unsigned char { RED, GREEN, BLUE };   // 1바이트 확정
typeof 가 표준이 된 것이 생각보다 큽니다 — 지금까지 매크로 안에서 임시 변수를 만들려면 타입을 인자로 또 받아야 했습니다. 이제 타입 안전한 매크로를 제대로 쓸 수 있어, c14 의 제네릭 자료구조가 훨씬 깔끔해집니다.

구조를 바꾸는 것들

// ① constexpr — 진짜 컴파일 시점 상수
constexpr int MAX = 1024;
int arr[MAX];               // enum 이나 #define 을 안 써도 됩니다
// (C++ 과 달리 C23 의 constexpr 은 '객체'에만 붙습니다. 함수는 아직 없습니다)

// ② #embed — 파일을 배열로 바로 넣기 ★
const unsigned char icon[] = {
    #embed "logo.png"
};
// 예전에는 xxd -i 로 헤더를 생성해 빌드에 끼워 넣었습니다.
// 폰트·셰이더·인증서를 실행 파일에 박을 때 압도적으로 편합니다

// ③ _BitInt(N) — 원하는 비트 수의 정수
_BitInt(24)  pixel;         // 정확히 24비트
unsigned _BitInt(3) tiny;   // 0~7
// 하드웨어 레지스터·비트 프로토콜을 다룰 때 유용합니다

// ④ [[attributes]] — C++ 문법이 그대로
[[nodiscard]] int must_check(void);
[[deprecated("v2 에서 제거됩니다")]] void old_api(void);
[[maybe_unused]] static int debug_only;
[[fallthrough]];            // switch 에서 의도적 관통 표시

// ⑤ 표준에 들어온 함수들
memset_explicit(key, 0, len);   // 최적화가 지우지 못하게 (비밀 삭제)
// strdup / strndup 도 드디어 표준이 됐습니다
memset_explicit 가 왜 필요한가 — 비밀번호 버퍼를 memset(buf, 0, n) 으로 지워도, 그 뒤에 buf 를 안 쓰면 컴파일러가 그 memset 을 통째로 삭제해 버립니다(“죽은 저장 제거”). 비밀이 메모리에 그대로 남아 덤프에 찍히는 실제 취약점이었습니다.

지금 켜도 되는가 — 기능별 판단표 🟡 중급

2026년 8월 기준, 기능별 현실

기능GCC · ClangMSVC판단
nullptr지원부분혼자 쓰는 코드면 ○
bool 키워드지원부분stdbool.h 를 계속 include 하면 양쪽 다 안전
2진 리터럴지원(오래전부터 확장으로도)지원◎ 사실상 안전
typeof지원(GNU 확장으로 30년)부분○
constexpr지원부분△ enum 이 여전히 무난
#embedGCC 15 · Clang 19+미지원△ 빌드 스크립트 대안을 남겨 두세요
auto지원부분△ 남용하면 가독성이 떨어집니다
_BitInt(N)지원미지원✕ 임베디드 전용으로 생각
[[nodiscard]]지원부분○ 매크로로 감싸면 안전
// 이식성을 지키면서 새 기능을 쓰는 관용구
#if __STDC_VERSION__ >= 202311L
  #define NODISCARD [[nodiscard]]
  #define NULLPTR   nullptr
#elif defined(__GNUC__)
  #define NODISCARD __attribute__((warn_unused_result))
  #define NULLPTR   NULL
#else
  #define NODISCARD
  #define NULLPTR   NULL
#endif

NODISCARD int parse(const char *s, int *out);
# 내 컴파일러가 어느 표준까지 되는지 확인
gcc -std=c23 -dM -E - < /dev/null | grep __STDC_VERSION__
#   #define __STDC_VERSION__ 202311L   ← C23 이면 이 값

gcc --version    # GCC 15 이상이면 C23 대부분 사용 가능
clang --version  # Clang 20 이상 권장
cl              # MSVC — /std:c17 또는 /std:clatest
실무 권장 — 라이브러리를 배포한다면 -std=c17 을 기준으로 두고, C23 기능은 위처럼 매크로로 감싸서 선택적으로 씁니다. 개인 도구·리눅스 전용 서비스라면 -std=c23 을 켜도 문제없습니다.

C23 이 바꾸지 않은 것 🔴 고급

여전히 그대로인 것들

항목C23 이후에도
메모리 안전그대로 없습니다 — 범위 검사도 소유권도 없음
모듈없음. 여전히 #include 와 헤더 가드
네임스페이스없음. 접두사로 이름 충돌을 피합니다
제네릭_Generic 과 매크로가 전부
표준 컨테이너없음. 직접 만들거나 라이브러리
예외없음. 반환값과 errno
RAII없음(GCC/Clang 의 cleanup 확장은 있음)
// GCC/Clang 확장이지만 실무에서 아주 유용한 것 — 자동 정리
static inline void free_ptr(void *p) { free(*(void **)p); }
#define AUTOFREE __attribute__((cleanup(free_ptr)))

void f(void) {
    AUTOFREE char *buf = malloc(1024);
    if (!buf) return;
    /* ... 중간에 어디서 return 해도 ... */
}   // ★ 스코프를 벗어날 때 자동으로 free 됩니다 (C++ 의 RAII 흉내)
// 표준은 아니지만 GCC·Clang 양쪽에서 되고, systemd 가 전면적으로 씁니다

_Generic — C 의 함수 오버로딩

// C11 부터 있던 기능인데 잘 안 알려져 있습니다
#include <math.h>

#define abs_any(x) _Generic((x),          \
        int:    abs,                      \
        long:   labs,                     \
        float:  fabsf,                    \
        double: fabs                      \
    )(x)

printf("%d %f\n", abs_any(-3), abs_any(-3.5));

// 타입에 따라 다른 출력 서식을 고르기
#define fmt_of(x) _Generic((x),           \
        int:         "%d",                \
        unsigned:    "%u",                \
        long long:   "%lld",              \
        double:      "%f",                \
        char *:      "%s",                \
        default:     "%p")

#define print_any(x) printf(fmt_of(x) "\n", (x))
print_any(42);        // %d
print_any("hello");   // %s
표준 헤더가 실제로 이렇게 만들어져 있습니다 — <tgmath.h> 의 sqrt(x) 가 인자 타입에 따라 sqrtf/sqrt/sqrtl 로 갈리는 것이 _Generic 의 대표 용례입니다. 다만 컴파일 시점 선택일 뿐이라 런타임 다형성은 여전히 함수 포인터(c07)의 몫입니다.
정리 — C23 은 C 가 처음으로 “편의성”을 챙긴 표준입니다. nullptr·bool·2진 리터럴·typeof 는 지금 써도 거의 안전하고, #embed·_BitInt 는 GCC/Clang 전용으로 생각하는 편이 마음 편합니다. 다만 메모리 안전·모듈·컨테이너는 여전히 없습니다 — C 를 쓰는 이유와 감수할 것은 그대로입니다.
C 22

임베디드 · 시스템 C — 운영체제 없이 도는 코드

전 세계에서 매년 만들어지는 마이크로컨트롤러는 수백억 개이고, 그 위에서 도는 코드는 대부분 C 입니다. malloc 도 printf 도 운영체제도 없는 환경에서 C 가 어떻게 쓰이는지를 보면, 지금까지 배운 포인터·비트 연산·volatile 이 왜 언어에 있는지가 비로소 이해됩니다. Arduino·ESP32·STM32 중 어디서 시작하면 되는지도 정리합니다.

고정 크기 링 버퍼 — 인터럽트와 메인 루프를 잇는 다리 head (쓰기) tail (읽기) D 0 E 1 다음에 쓸 칸 2 비어 있음 3 비어 있음 4 A 5 B 6 C 7 7번을 넘으면 0으로 돌아갑니다 — 나눗셈 없이 비트 마스크 하나로 왜 링 버퍼인가 malloc 이 없어도 되고 크기가 절대 변하지 않습니다 인터럽트에서 넣고 메인 루프에서 꺼내는 표준 패턴입니다 지켜야 할 두 가지 ① 크기는 2의 거듭제곱 — 인덱스는 비트 마스크로 감쌉니다 ② 쓰는 쪽 하나, 읽는 쪽 하나일 때만 락 없이 안전합니다
임베디드에서 malloc 을 안 쓰는 이유가 여기 있습니다 — 크기가 정해진 구조는 언제 터질지 알 수 있습니다.

volatile — 여기서가 진짜 쓰임새입니다

// 하드웨어 레지스터는 '내가 안 바꿔도 값이 변합니다'
// volatile 이 없으면 컴파일러가 두 번째 읽기를 생략해 버립니다

#define UART0_BASE  0x40011000u
#define UART_SR     (*(volatile uint32_t *)(UART0_BASE + 0x00))
#define UART_DR     (*(volatile uint32_t *)(UART0_BASE + 0x04))
#define SR_TXE      (1u << 7)   // 송신 버퍼 빔

void uart_putc(char c) {
    while (!(UART_SR & SR_TXE)) { }   // ★ volatile 이 없으면 무한 루프
    UART_DR = (uint32_t)(unsigned char)c;
}

// volatile 이 하는 일 / 하지 않는 일
//   ○ 매번 실제로 메모리를 읽고 씁니다 (최적화로 생략 안 됨)
//   ○ volatile 접근끼리의 순서를 유지합니다
//   ✕ 원자성을 보장하지 않습니다      → 스레드에는 _Atomic (c19)
//   ✕ CPU 재배치·캐시 일관성과 무관   → 배리어가 따로 필요

// 인터럽트 핸들러와 공유하는 변수에는 붙입니다
static volatile uint32_t g_ticks;      // 타이머 ISR 이 증가시킴

void SysTick_Handler(void) { g_ticks++; }   // 인터럽트에서
void delay_ms(uint32_t ms) {
    uint32_t start = g_ticks;
    while (g_ticks - start < ms) { }         // ★ 뺄셈이라 오버플로에도 안전
}
32비트 MCU 에서 64비트 변수를 ISR 과 공유하면 읽는 도중에 인터럽트가 끼어들어 위 32비트와 아래 32비트가 다른 시점의 값이 될 수 있습니다(찢어진 읽기). volatile 은 이걸 막아 주지 않습니다 — 인터럽트를 잠깐 끄거나 32비트 이하 타입만 공유하세요.

비트 연산 — 레지스터를 다루는 문법

#include <stdint.h>

#define BIT(n)            (1u << (n))
#define SET_BIT(r, m)     ((r) |=  (m))
#define CLR_BIT(r, m)     ((r) &= ~(m))
#define TGL_BIT(r, m)     ((r) ^=  (m))
#define GET_BIT(r, m)     (((r) & (m)) != 0)

// 여러 비트로 된 필드 쓰기 — 읽고, 지우고, 넣기 (read-modify-write)
#define FIELD(r, mask, shift, v) \
    ((r) = ((r) & ~(mask)) | (((uint32_t)(v) << (shift)) & (mask)))

// GPIO 예: 5번 핀을 출력으로 설정하고 켜기
#define GPIOA_MODER (*(volatile uint32_t *)0x40020000u)
#define GPIOA_ODR   (*(volatile uint32_t *)0x40020014u)

FIELD(GPIOA_MODER, 0x3u << 10, 10, 0b01);  // 5번 핀 모드 = 출력
SET_BIT(GPIOA_ODR, BIT(5));                // 5번 핀 HIGH
CLR_BIT(GPIOA_ODR, BIT(5));                // 5번 핀 LOW

// 자주 쓰는 비트 요령
uint32_t x = 0b1011'0000;
int lowest = x & -x;              // 가장 낮은 1 비트만 남기기
int count  = __builtin_popcount(x);   // 1 비트 개수 (GCC/Clang)
int lead   = __builtin_clz(x);        // 앞쪽 0 개수 → 로그2 계산에
x &= x - 1;                       // 가장 낮은 1 비트 지우기
비트필드(struct { unsigned a:3; })를 레지스터 맵에 쓰지 마세요 — 비트 배치 순서가 컴파일러·플랫폼마다 다릅니다. 데이터시트대로 동작한다는 보장이 없습니다. 레지스터는 마스크와 시프트를 명시하는 위 방식이 안전하고, 실제 벤더 헤더도 그렇게 되어 있습니다.

인터럽트 안전 링 버퍼 — 복붙해서 쓰는 코드 🟡 중급

단일 생산자 · 단일 소비자 큐 (락 없음)

#include <stdint.h>
#include <stdbool.h>

#define RB_SIZE 256                      // ★ 반드시 2의 거듭제곱
#define RB_MASK (RB_SIZE - 1)

typedef struct {
    uint8_t  buf[RB_SIZE];
    volatile uint16_t head;              // 쓰는 쪽(ISR)만 고칩니다
    volatile uint16_t tail;              // 읽는 쪽(메인)만 고칩니다
} Ring;

// ── 인터럽트 안에서 호출 (생산자) ──
bool rb_push(Ring *r, uint8_t v) {
    uint16_t next = (uint16_t)((r->head + 1) & RB_MASK);
    if (next == r->tail) return false;    // 가득 참 — 버립니다
    r->buf[r->head] = v;
    __atomic_thread_fence(__ATOMIC_RELEASE);   // 데이터가 먼저 보이도록
    r->head = next;                       // ★ 마지막에 head 갱신
    return true;
}

// ── 메인 루프에서 호출 (소비자) ──
bool rb_pop(Ring *r, uint8_t *out) {
    if (r->tail == r->head) return false; // 비었음
    __atomic_thread_fence(__ATOMIC_ACQUIRE);
    *out = r->buf[r->tail];
    r->tail = (uint16_t)((r->tail + 1) & RB_MASK);
    return true;
}

// UART 수신 인터럽트에서
void USART1_IRQHandler(void) {
    if (UART_SR & SR_RXNE)
        rb_push(&g_rx, (uint8_t)UART_DR);   // ★ 짧게 끝냅니다
}

// 메인 루프에서
int main(void) {
    hw_init();
    for (;;) {
        uint8_t c;
        while (rb_pop(&g_rx, &c))
            handle_byte(c);                 // 무거운 처리는 여기서
        idle_sleep();
    }
}
인터럽트 핸들러의 철칙 — ① 짧게. 수 마이크로초 안에 끝내고, 처리는 메인 루프로 넘깁니다. ② malloc·printf·블로킹 대기 금지. ③ 공유 변수는 volatile + 가능하면 한쪽만 쓰는 구조로 설계합니다. ④ 부득이하게 여러 변수를 함께 고쳐야 하면 인터럽트를 잠깐 끄고(critical section) 최소한만 감쌉니다.

운영체제가 없는 빌드 — freestanding 과 링커 스크립트 🔴 고급

-ffreestanding 이 무엇을 바꾸나

# 호스티드(hosted) — 우리가 지금까지 쓴 환경
#   main 이 시작점 · 표준 라이브러리 전부 사용 가능
gcc main.c -o app

# 프리스탠딩(freestanding) — OS 도 libc 도 없음
arm-none-eabi-gcc -mcpu=cortex-m4 -mthumb \
  -ffreestanding -nostdlib -nostartfiles \
  -T linker.ld -Wl,-Map=out.map \
  startup.c main.c -o firmware.elf

arm-none-eabi-objcopy -O binary firmware.elf firmware.bin
arm-none-eabi-size firmware.elf     # ROM/RAM 사용량 확인
프리스탠딩에서 쓸 수 있는 표준 헤더는 이것뿐입니다:
  <stddef.h> <stdint.h> <stdbool.h> <limits.h>
  <float.h> <stdarg.h> <iso646.h> <stdalign.h> <stdnoreturn.h>

없는 것: printf, malloc, 파일, 문자열 함수 일부
  → printf 는 UART 로 내보내는 미니 버전을 직접 만들거나
    nanoprintf 같은 단일 헤더 구현을 넣습니다
실제로는 벤더 SDK 를 씁니다 — STM32 는 STM32Cube(HAL/LL), ESP32 는 ESP-IDF, 노르딕은 nRF Connect SDK 가 시작 코드·링커 스크립트·드라이버를 전부 제공합니다. 위 명령을 손으로 칠 일은 드물지만, 빌드가 깨졌을 때 읽을 수 있어야 합니다.

링커 스크립트 맛보기

/* linker.ld — 메모리 어디에 무엇을 놓을지 정합니다 */
MEMORY {
  FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 512K
  RAM  (rwx) : ORIGIN = 0x20000000, LENGTH = 128K
}

SECTIONS {
  .text : {           /* 코드와 상수 — 플래시에 */
    KEEP(*(.isr_vector))   /* ★ 인터럽트 벡터는 맨 앞에 고정 */
    *(.text*)
    *(.rodata*)
  } > FLASH

  .data : {           /* 초기값 있는 전역 — RAM 에서 실행, 플래시에 원본 */
    _sdata = .;
    *(.data*)
    _edata = .;
  } > RAM AT > FLASH
  _sidata = LOADADDR(.data);   /* 플래시에 있는 .data 원본의 주소 — startup 이 씀 */

  .bss : {            /* 0으로 시작하는 전역 — RAM 만 차지 */
    _sbss = .;
    *(.bss*) *(COMMON)
    _ebss = .;
  } > RAM
}
/* startup.c — main 보다 먼저 도는 코드 */
extern uint32_t _sdata, _edata, _sbss, _ebss, _sidata;

void Reset_Handler(void) {
    // ① 초기값 있는 전역을 플래시에서 RAM 으로 복사
    uint32_t *src = &_sidata, *dst = &_sdata;
    while (dst < &_edata) *dst++ = *src++;

    // ② .bss 를 0 으로 (C 표준이 약속한 것)
    for (dst = &_sbss; dst < &_ebss; dst++) *dst = 0;

    main();                 // ★ 이제야 C 코드가 정상 동작합니다
    for (;;) { }
}
“전역 변수는 자동으로 0”이 공짜가 아니었습니다 — 위 .bss 초기화 루프가 그 약속을 지키는 코드입니다. PC 에서는 운영체제와 C 런타임이 대신 해 주던 일이라 보이지 않았을 뿐입니다.

어디서 시작하나 — 보드 고르기

보드가격대 · 특징이럴 때
Arduino Uno R4가장 쉬움. IDE 한 번 설치로 끝처음이라면 여기. C++ 처럼 보이지만 사실상 C
Raspberry Pi Pico 2싸고 강력(RP2350). C SDK 공식 지원C 로 제대로 배우기 가장 좋은 균형
ESP32-S3Wi-Fi·BLE 내장. ESP-IDF(C 기반)네트워크가 필요한 장치
STM32 Nucleo산업 표준. HAL·CubeMX취업·산업용을 노린다면
Raspberry Pi 5리눅스가 도는 컴퓨터임베디드가 아니라 작은 서버에 가깝습니다
첫 프로젝트 추천 순서 — ① LED 깜빡이기(GPIO) → ② UART 로 PC 와 대화(위 링 버퍼가 그대로 쓰입니다) → ③ 버튼 인터럽트 + 디바운싱 → ④ 타이머로 PWM → ⑤ I2C 센서 읽기. 여기까지 오면 데이터시트를 읽고 레지스터를 직접 설정할 수 있게 됩니다 — 그게 임베디드 C 의 전부라고 해도 과언이 아닙니다.
정리 — 임베디드 C 는 지금까지 배운 것에서 빼는 쪽입니다 — malloc 대신 고정 크기 링 버퍼, 예외 대신 반환값, 스레드 대신 인터럽트 + 메인 루프. volatile 은 여기서 진짜 역할을 하고, 비트 마스크와 시프트가 문법처럼 쓰입니다. 시작은 Raspberry Pi Pico 2 나 Arduino 로 LED 하나 깜빡이는 것부터면 충분합니다.
C 23

라이브러리 만들기 — 남이 쓸 수 있는 형태로 내보내기

C 가 지금도 중심에 있는 진짜 이유는 C ABI 가 모든 언어의 공통 언어이기 때문입니다. 파이썬·자바·C#·Go·러스트·자바스크립트가 전부 C 로 만든 .so·.dll 을 부를 수 있습니다. 이 절에서는 정적·동적 라이브러리를 빌드하고, 심볼을 골라 내보내고, 헤더를 설계하고, 다른 언어에서 한 줄로 불러 보는 것까지 한 번에 해 봅니다.

정적 링크와 동적 링크 — 라이브러리 코드가 언제 합쳐지는가 정적 — libmath.a / math.lib 동적 — libmath.so / math.dll 빌드: main.o + libmath.a 링커가 필요한 오브젝트를 골라 꺼내 옵니다 빌드: main.o + libmath.so 코드는 안 넣고 이름과 심볼만 적어 둡니다 실행 파일 안에 코드가 들어 있음 파일이 커지지만 자기 완결적입니다 실행 파일에는 참조만 남음 파일이 작지만 혼자서는 못 돕니다 실행: 찾을 것이 없습니다 복사해서 어디에 두든 그대로 돕니다 실행: 로더가 파일을 찾아 붙입니다 못 찾으면 시작조차 못 하고 죽습니다 고르는 기준은 결국 배포 방식입니다 배포가 단순해야 한다 · 버전 충돌이 싫다 · 컨테이너 이미지를 작게 → 정적 여러 프로그램이 공유한다 · 보안 패치를 따로 내려야 한다 · 플러그인 구조 → 동적
파이썬·자바가 부르는 것은 거의 항상 동적 라이브러리입니다 — 런타임에 붙일 수 있어야 하니까요.

만들어 봅니다 — 같은 소스로 .a 와 .so 를 둘 다

// mathlib.h  — 공개 헤더. 이 파일이 곧 API 문서입니다
#ifndef MATHLIB_H
#define MATHLIB_H

#include <stddef.h>

// ── 내보내기 매크로 (윈도우 때문에 필요합니다) ──
#if defined(_WIN32)
  #if defined(MATHLIB_BUILD)
    #define ML_API __declspec(dllexport)
  #else
    #define ML_API __declspec(dllimport)
  #endif
#else
  #define ML_API __attribute__((visibility("default")))
#endif

#ifdef __cplusplus
extern "C" {          // ★ C++ 에서 include 해도 되게
#endif

ML_API int    ml_add(int a, int b);
ML_API double ml_mean(const double *xs, size_t n);
ML_API const char *ml_version(void);

#ifdef __cplusplus
}
#endif
#endif  // MATHLIB_H
// mathlib.c
#define MATHLIB_BUILD      // ★ 라이브러리를 '만드는' 쪽임을 표시
#include "mathlib.h"

// 내부 전용 함수는 static — 밖에서 안 보입니다
static double sum(const double *xs, size_t n) {
    double s = 0;
    for (size_t i = 0; i < n; i++) s += xs[i];
    return s;
}

int ml_add(int a, int b) { return a + b; }

double ml_mean(const double *xs, size_t n) {
    return n ? sum(xs, n) / (double)n : 0.0;
}

const char *ml_version(void) { return "1.2.0"; }
# ── 리눅스/맥 ──
# 정적 라이브러리
gcc -std=c17 -O2 -c mathlib.c -o mathlib.o
ar rcs libmathlib.a mathlib.o

# 동적 라이브러리 (-fPIC 필수!)
gcc -std=c17 -O2 -fPIC -fvisibility=hidden \
    -shared mathlib.c -o libmathlib.so.1.2.0
ln -sf libmathlib.so.1.2.0 libmathlib.so.1
ln -sf libmathlib.so.1     libmathlib.so

# 쓰는 쪽
gcc main.c -L. -lmathlib -o app
LD_LIBRARY_PATH=. ./app          # 실행할 때 찾을 경로
# ── 윈도우 (MinGW/gcc) ──
gcc -O2 -DMATHLIB_BUILD -shared mathlib.c -o mathlib.dll -Wl,--out-implib,mathlib.lib
gcc main.c -L. -lmathlib -o app.exe        # app.exe 옆에 mathlib.dll 이 있어야 실행됩니다

# ── 윈도우 (MSVC) ──
cl /O2 /DMATHLIB_BUILD /LD mathlib.c        # → mathlib.dll + mathlib.lib
cl main.c mathlib.lib

# ── 무엇이 실제로 내보내졌는지 확인 ──
nm -D --defined-only libmathlib.so   # 리눅스: 공개 심볼 목록
objdump -p app | grep NEEDED         # 이 실행 파일이 요구하는 .so 들
ldd app                              # 실제로 어떤 파일에 연결됐는지 (맥: otool -L)
dumpbin /EXPORTS mathlib.dll         # 윈도우
-fPIC 을 빼면 x86-64 리눅스에서 relocation R_X86_64_32S against ... can not be used when making a shared object 라는 링크 에러가 납니다. 동적 라이브러리는 메모리 어디에 올라갈지 모르므로 위치 독립 코드여야 하기 때문입니다. 정적 라이브러리에 넣을 오브젝트도 나중에 .so 에 들어갈 수 있으니 처음부터 -fPIC 을 켜 두는 편이 좋습니다.

심볼 가시성 — 내보낼 것만 내보내기 🔴 고급

왜 숨겨야 하나

숨기면얻는 것
로딩 속도심볼 테이블이 작아져 동적 링크가 빨라집니다
이름 충돌다른 라이브러리의 같은 이름과 안 부딪힙니다 ★
최적화내부 함수를 인라인·제거할 수 있게 됩니다
API 안정성사용자가 내부 함수에 의존하는 것을 원천 차단
바이너리 크기안 쓰는 코드를 링커가 떼어낼 수 있습니다
# 기본값을 '숨김'으로 바꾸고 필요한 것만 default 로 표시
gcc -fvisibility=hidden -fPIC -shared ...

# 리눅스에서 버전 스크립트로 더 정밀하게
cat > export.map <<'EOF'
MATHLIB_1.0 {
  global:
    ml_add; ml_mean; ml_version;
  local:
    *;                # 나머지는 전부 숨김
};
EOF
gcc -shared -fPIC mathlib.c -Wl,--version-script=export.map -o libmathlib.so
이름 충돌이 실제로 얼마나 흔한가 — 파이썬 확장 모듈 두 개가 각각 정적으로 링크한 zlib 를 갖고 있을 때, 심볼이 공개되어 있으면 먼저 로드된 쪽이 이깁니다. 버전이 다르면 그대로 크래시입니다. 접두사(ml_)와 hidden 가시성 두 가지가 이걸 막습니다.

헤더 설계 · 버전 관리 규칙

// 좋은 공개 헤더의 조건
// ① 자기 완결적 — 이것만 include 해도 컴파일돼야 합니다
// ② 다른 헤더를 최소한으로 include (전방 선언 활용)
// ③ 매크로 이름에 접두사 (MATHLIB_ 처럼)
// ④ 구조체는 감춥니다 — 불투명 포인터 ★

typedef struct ml_ctx ml_ctx;      // 정의는 .c 안에만 있습니다

ML_API ml_ctx *ml_ctx_new(size_t cap);
ML_API void    ml_ctx_free(ml_ctx *c);
ML_API int     ml_ctx_push(ml_ctx *c, double v);

// 이렇게 하면 구조체 필드를 바꿔도 사용자를 다시 빌드시키지 않습니다.
// 반대로 헤더에 struct 정의를 노출하면 필드 하나만 추가해도
// 크기가 바뀌어 ABI 가 깨집니다 (사용자 프로그램이 그대로 죽습니다)

// 버전 정보를 컴파일 시점에도 노출
#define MATHLIB_VERSION_MAJOR 1
#define MATHLIB_VERSION_MINOR 2
#define MATHLIB_VERSION_PATCH 0
바꾸는 것버전을 어떻게
함수 추가minor 증가 — 기존 사용자 영향 없음
버그 수정patch 증가
구조체 크기 변경major 증가 — ABI 깨짐
함수 시그니처 변경major 증가
함수 제거major 증가
SONAME 을 붙이세요 — -Wl,-soname,libmathlib.so.1 을 주면 실행 파일에 major 버전까지만 기록됩니다. 그래야 1.2.0 → 1.3.0 업데이트가 재빌드 없이 반영됩니다. 이게 리눅스 배포판이 보안 패치를 뿌리는 방식입니다.

다른 언어에서 부르기 — 각 한 줄씩 🟢 기초

파이썬 · C#

# 파이썬 — 표준 라이브러리 ctypes 만으로 됩니다
import ctypes, platform

name = "mathlib.dll" if platform.system() == "Windows" else "./libmathlib.so"
lib = ctypes.CDLL(name)

lib.ml_add.argtypes = [ctypes.c_int, ctypes.c_int]   # ★ 반드시 명시
lib.ml_add.restype  = ctypes.c_int
print(lib.ml_add(2, 3))            # 5

lib.ml_mean.argtypes = [ctypes.POINTER(ctypes.c_double), ctypes.c_size_t]
lib.ml_mean.restype  = ctypes.c_double
arr = (ctypes.c_double * 4)(1.0, 2.0, 3.0, 4.0)
print(lib.ml_mean(arr, 4))         # 2.5

lib.ml_version.restype = ctypes.c_char_p
print(lib.ml_version().decode())   # 1.2.0
// C# — P/Invoke
using System.Runtime.InteropServices;

internal static partial class Native {
    [LibraryImport("mathlib")]            // .NET 7+ 소스 생성 방식
    internal static partial int ml_add(int a, int b);
}
Console.WriteLine(Native.ml_add(2, 3));

자바 (FFM) · Node · 러스트

// 자바 21+ — Foreign Function & Memory API (JNI 없이)
import java.lang.foreign.*;
import java.lang.invoke.MethodHandle;

try (Arena arena = Arena.ofConfined()) {
    SymbolLookup lib = SymbolLookup.libraryLookup("mathlib", arena);
    MethodHandle add = Linker.nativeLinker().downcallHandle(
        lib.find("ml_add").orElseThrow(),
        FunctionDescriptor.of(ValueLayout.JAVA_INT,
                              ValueLayout.JAVA_INT, ValueLayout.JAVA_INT));
    System.out.println((int) add.invokeExact(2, 3));   // 5
}
// Node 24 — koffi (네이티브 빌드 없이 로드)
const koffi = require('koffi');
const lib = koffi.load('./libmathlib.so');
const ml_add = lib.func('int ml_add(int, int)');
console.log(ml_add(2, 3));         // 5
// 러스트 — extern "C" 선언만 하면 됩니다
#[link(name = "mathlib")]
extern "C" { fn ml_add(a: i32, b: i32) -> i32; }

fn main() { unsafe { println!("{}", ml_add(2, 3)); } }
여기서 규칙 하나 — 언어 경계로 나가는 함수는 구조체를 값으로 주고받지 말고, int·포인터·길이로만 이야기하세요. 구조체 레이아웃·패딩·호출 규약은 언어마다 미묘하게 다릅니다. 단순한 시그니처가 곧 이식성입니다.
정리 — 라이브러리 만들기의 핵심은 네 가지입니다. -fPIC + -shared 로 빌드하고, -fvisibility=hidden 과 내보내기 매크로로 공개 심볼을 고르고, 불투명 포인터로 구조체를 감추고, SONAME 과 semver 로 버전을 관리합니다. 이렇게 만든 .so·.dll 하나면 파이썬·자바·C#·Node·러스트가 전부 부를 수 있습니다.
C 24

테스트와 디버깅 — 기계가 대신 찾게 만듭니다

C 의 버그는 증상이 원인에서 멀리 떨어져 나타납니다 — 100줄 앞에서 배열을 하나 넘어 썼는데 전혀 다른 함수에서 죽죠. 그래서 C 에서는 “눈으로 코드를 본다”가 가장 비효율적인 방법입니다. 새니타이저·valgrind·gdb 를 증상별로 어떤 걸 꺼내야 하는지 정리하고, 테스트 프레임워크를 붙여 CI 에서 자동으로 돌게 만드는 데까지 갑니다.

증상별로 어떤 도구를 꺼내는가 세그폴트로 즉시 죽는다 어디서 죽었는지부터 알아야 합니다 gdb ./app core → bt 스택을 거슬러 올라가면 대개 한 번에 나옵니다 가끔 값이 이상하다 잘 돌다가 어쩌다 틀립니다 -fsanitize=address 로 재빌드 넘침·해제 후 사용을 그 자리에서 잡아냅니다 메모리가 계속 늘어난다 오래 돌리면 결국 죽습니다 valgrind --leak-check=full 누가 할당하고 안 풀었는지 스택으로 보여 줍니다 멀티스레드에서만 깨진다 디버거를 붙이면 재현이 안 됩니다 -fsanitize=thread 실제로 충돌하지 않아도 레이스를 찾아냅니다 무엇을 하든 첫 단계는 -g -O0 -Wall -Wextra 로 다시 빌드하는 것입니다 — 최적화가 켜져 있으면 스택도 변수도 제대로 안 보입니다 재현이 안 되는 버그일수록 새니타이저가 답입니다. 디버거는 "재현되는 버그"의 도구입니다
C 디버깅의 8할은 도구를 고르는 일입니다 — 증상에 맞는 도구를 켜면 대부분 몇 분 안에 끝납니다.

새니타이저 — 가장 값싸고 강력한 도구

# ① 주소 새니타이저 — 메모리 오류 종합 검출 (2배 느림)
gcc -std=c17 -g -O1 -fsanitize=address,undefined \
    -fno-omit-frame-pointer app.c -o app
./app

# 잡아 주는 것:
#   heap-buffer-overflow    배열 밖 읽기/쓰기
#   stack-buffer-overflow   지역 배열 넘침
#   heap-use-after-free     free 뒤 접근
#   double-free             두 번 해제
#   memory leak             종료 시 안 푼 메모리 (LeakSanitizer 포함)

# ② UB 새니타이저 — 미정의 동작 검출
gcc -fsanitize=undefined -fno-sanitize-recover=all app.c -o app
#   부호 있는 정수 오버플로 · 널 역참조 · 정렬 위반 · 시프트 범위 초과

# ③ 옵션으로 더 엄격하게
ASAN_OPTIONS=detect_leaks=1:abort_on_error=1 ./app
UBSAN_OPTIONS=print_stacktrace=1 ./app
=================================================================
==31337==ERROR: AddressSanitizer: heap-buffer-overflow on address
0x602000000018 at pc 0x0001 bp 0x7ffd sp 0x7ffd
WRITE of size 4 at 0x602000000018 thread T0
    #0 0x1149 in main app.c:7          ← 여기서 넘어 썼습니다

0x602000000018 is located 0 bytes to the right of 8-byte region
allocated by thread T0 here:
    #0 0x7f4a in malloc
    #1 0x1122 in main app.c:5          ← 여기서 할당했습니다
이 두 줄이 전부입니다 — “어디서 넘어 썼는지”와 “그 메모리를 어디서 할당했는지”. printf 를 100개 박는 것보다 비교할 수 없이 빠릅니다. 개발·테스트 빌드에는 기본으로 켜 두는 것을 권합니다.

gdb — 외워야 할 명령은 열 개뿐

명령하는 일
run / r실행 (인자는 run a b)
bt백트레이스 — 가장 많이 씁니다
frame N / f NN번 스택 프레임으로 이동
break f.c:42중단점. break foo if x==3 도 됨
n / s / c다음 줄 / 함수 안으로 / 계속
p expr값 출력. p *arr@10 은 배열 10개
watch xx 가 바뀌는 순간 멈춤 — 값 오염 추적에 최고
info locals지역 변수 전부
thread apply all bt모든 스레드 스택 (데드락 분석)
x/16xb p메모리를 바이트 16개로 덤프
# 코어 덤프 켜기 (평소엔 꺼져 있습니다)
ulimit -c unlimited
cat /proc/sys/kernel/core_pattern      # 어디에 떨어지는지 확인
./app                                  # 죽으면 core 파일 생성

gdb ./app core
(gdb) bt full                          # 스택 + 각 프레임의 지역 변수
(gdb) f 2                              # 내 코드가 있는 프레임으로
(gdb) p *node                          # 구조체 통째로 출력
(gdb) p node->next->value

# 죽은 이유가 바로 보이는 경우가 많습니다:
#   $1 = (Node *) 0x0        → 널 포인터였습니다
#   value = <optimized out>  → -O0 로 다시 빌드하세요
맥이라면 lldb 를 씁니다 — 명령이 거의 같습니다(bt, frame select, p, br s -f a.c -l 42). VS Code·CLion 의 디버거도 결국 이 둘 위에 얹힌 UI 입니다.

테스트 붙이기 — C 에도 프레임워크가 있습니다 🟡 중급

Unity — 가장 가볍고 임베디드에서도 돕니다

// test_vec.c  (unity.c / unity.h 두 파일만 넣으면 끝)
#include "unity.h"
#include "vec.h"

void setUp(void)    { }      // 각 테스트 전
void tearDown(void) { }      // 각 테스트 후

void test_push_increases_len(void) {
    Vec v; vec_init(&v);
    TEST_ASSERT_EQUAL_INT(0, vec_push(&v, 42));
    TEST_ASSERT_EQUAL_size_t(1, v.len);
    TEST_ASSERT_EQUAL_INT(42, vec_get(&v, 0));
    vec_free(&v);
}

void test_grows_past_initial_cap(void) {
    Vec v; vec_init(&v);
    for (int i = 0; i < 1000; i++)
        TEST_ASSERT_EQUAL_INT(0, vec_push(&v, i));
    TEST_ASSERT_EQUAL_INT(999, vec_get(&v, 999));
    TEST_ASSERT_TRUE(v.cap >= 1000);
    vec_free(&v);
}

int main(void) {
    UNITY_BEGIN();
    RUN_TEST(test_push_increases_len);
    RUN_TEST(test_grows_past_initial_cap);
    return UNITY_END();      // ★ 실패 개수가 종료 코드 → CI 가 이걸 봅니다
}
gcc -std=c17 -g -fsanitize=address \
    test_vec.c vec.c unity.c -o test_vec
./test_vec
# test_vec.c:27:test_push_increases_len:PASS
# test_vec.c:28:test_grows_past_initial_cap:PASS
# 2 Tests 0 Failures 0 Ignored  OK

고르기 · CTest 로 묶기

프레임워크특징
Unity파일 3개. 임베디드 표준. 의존성 0
cmocka모킹 지원(함수 가로채기). 시스템 콜 테스트에 강함
Criterion테스트마다 별도 프로세스 — 죽어도 나머지가 돕니다
greatest / utest.h헤더 한 장. 가장 간편
CTest프레임워크가 아니라 러너. CMake 에 내장
# CMakeLists.txt — 테스트를 CTest 에 등록
enable_testing()

add_executable(test_vec test_vec.c vec.c unity.c)
target_link_libraries(test_vec PRIVATE mathlib)
add_test(NAME vec COMMAND test_vec)

# 새니타이저를 켠 테스트 빌드를 따로 두는 것이 정석입니다
add_executable(test_vec_asan test_vec.c vec.c unity.c)
target_compile_options(test_vec_asan PRIVATE -fsanitize=address,undefined -g)
target_link_options(test_vec_asan    PRIVATE -fsanitize=address,undefined)
add_test(NAME vec_asan COMMAND test_vec_asan)
cmake -B build -DCMAKE_BUILD_TYPE=Debug
cmake --build build -j
ctest --test-dir build --output-on-failure

# valgrind 로 전체 테스트를 한 번에 돌리기
ctest --test-dir build -T memcheck
C 에서 테스트가 특히 값어치 있는 이유 — 리팩터링할 때 컴파일러가 잡아 주는 범위가 다른 언어보다 훨씬 좁기 때문입니다. 타입이 약하고 포인터가 자유로우니 테스트가 유일한 안전망입니다. 새니타이저를 켠 테스트 하나가 코드 리뷰 열 번보다 많이 잡아냅니다.

최적화 빌드와 디버그 빌드의 차이 🔴 고급

-O0 과 -O2 는 다른 프로그램입니다

플래그쓰는 곳주의
-O0 -g디버깅느림. 하지만 변수가 다 보입니다
-O1 -g새니타이저속도와 가시성의 균형 — 권장
-O2배포 기본인라인·루프 변형으로 스택이 뭉갬
-O3수치 계산항상 빠르진 않습니다. 재 보고 결정
-Og디버깅용 최적화GCC 전용. -O0 보다 빠르고 볼 만함
-O2 -g배포 + 심볼★ 성능은 그대로, 크래시 분석 가능
# 배포 바이너리에서 심볼만 따로 떼어 보관하는 정석
gcc -O2 -g app.c -o app
objcopy --only-keep-debug app app.debug     # 심볼 파일 분리
objcopy --strip-debug app                   # 배포용은 가볍게
objcopy --add-gnu-debuglink=app.debug app   # 연결 정보만 남김

# 나중에 코어 덤프가 오면
gdb -s app.debug -e app core
“-O0 에선 되는데 -O2 에선 깨져요” — 컴파일러 버그가 아니라 거의 항상 미정의 동작(UB)입니다. 부호 있는 오버플로, 초기화 안 한 변수, 엄격한 앨리어싱 위반 등이 최적화가 켜지면 다르게 해석되기 때문입니다. 이때야말로 -fsanitize=undefined 를 켤 자리입니다(c11 참고).

CI 에 넣을 최소 구성

# .github/workflows/ci.yml — 이 정도면 충분합니다
name: ci
on: [push, pull_request]

jobs:
  test:
    strategy:
      matrix:
        os: [ubuntu-latest, macos-latest, windows-latest]
    runs-on: ${{ matrix.os }}
    steps:
      - uses: actions/checkout@v4
      - name: 빌드
        run: |
          cmake -B build -DCMAKE_BUILD_TYPE=Debug
          cmake --build build -j
      - name: 테스트
        run: ctest --test-dir build --output-on-failure

  sanitize:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: ASan + UBSan
        run: |
          cmake -B build -DCMAKE_C_FLAGS="-fsanitize=address,undefined -g -O1"
          cmake --build build -j
          ctest --test-dir build --output-on-failure
세 OS 를 돌리는 것이 핵심입니다 — C 코드의 이식성 버그는 다른 컴파일러로 빌드해 보는 순간 절반이 드러납니다. long 크기, 구조체 패딩, 경로 구분자, 대소문자 구분 파일 시스템 — 전부 CI 가 대신 잡아 줍니다.
정리 — C 디버깅은 도구를 고르는 일입니다. 죽으면 gdb + bt, 이상하면 ASan, 메모리가 늘면 valgrind, 스레드면 TSan. 그리고 무엇을 하든 먼저 -g -O1 로 다시 빌드합니다. 테스트는 Unity 파일 세 개면 시작할 수 있고, 새니타이저를 켠 CI 잡 하나가 코드 리뷰 열 번의 값어치를 합니다.
C 25

실전 프로젝트 구조 · 빌드 — Makefile 과 CMake 완전판

파일이 세 개를 넘어가는 순간 gcc *.c 로는 감당이 안 됩니다. 무엇이 무엇에 의존하는지를 적어 두면 도구가 바뀐 것만 다시 빌드해 줍니다. 이 절은 복사해서 바로 쓰는 Makefile 완전판과 CMake 표준 구성, 그리고 디렉터리 배치 · .clang-format · GitHub Actions까지 “새 C 프로젝트를 시작하는 한 세트”를 통째로 만듭니다.

make 가 "무엇을 다시 빌드할지" 정하는 방법 — 의존 그래프와 시각 main.c vec.c util.c vec.h main.o vec.o util.o app — 링크 결과 실행 파일 오브젝트 중 하나라도 새로우면 다시 링크합니다 vec.h 를 고치면 main.o 와 vec.o 가 둘 다 낡은 것이 되어 다시 컴파일됩니다 util.c 만 고치면 util.o 하나만 다시 만들고 링크만 다시 합니다 — 그래서 큰 프로젝트도 빌드가 몇 초입니다
빌드 도구가 하는 일은 하나입니다 — 이 그래프를 따라가며 낡은 것만 다시 만드는 것.

Makefile 완전판 — 새 프로젝트에 그대로 복사

# ── 설정 ─────────────────────────────────────────────
TARGET   := app
SRCDIR   := src
INCDIR   := include
BUILDDIR := build

CC       := gcc
CSTD     := -std=c17
WARN     := -Wall -Wextra -Wpedantic -Wshadow -Wconversion \
            -Wstrict-prototypes -Wmissing-prototypes
CPPFLAGS := -I$(INCDIR) -MMD -MP        # ★ -MMD -MP 가 의존성 자동 생성
LDFLAGS  :=
LDLIBS   := -lm

# make DEBUG=1 로 디버그 빌드
ifdef DEBUG
  OPT := -O0 -g -fsanitize=address,undefined
  LDFLAGS += -fsanitize=address,undefined
else
  OPT := -O2 -DNDEBUG
endif
CFLAGS := $(CSTD) $(WARN) $(OPT)

# ── 파일 목록 (자동 수집) ────────────────────────────
SRCS := $(wildcard $(SRCDIR)/*.c)
OBJS := $(patsubst $(SRCDIR)/%.c,$(BUILDDIR)/%.o,$(SRCS))
DEPS := $(OBJS:.o=.d)

# ── 규칙 ─────────────────────────────────────────────
.PHONY: all clean run test format
all: $(TARGET)

$(TARGET): $(OBJS)
	$(CC) $(LDFLAGS) $^ $(LDLIBS) -o $@

$(BUILDDIR)/%.o: $(SRCDIR)/%.c | $(BUILDDIR)
	$(CC) $(CPPFLAGS) $(CFLAGS) -c $< -o $@

$(BUILDDIR):
	mkdir -p $@

run: $(TARGET)
	./$(TARGET)

# ※ 'test: DEBUG=1' 은 소용없음 — ifdef 는 파일을 읽을 때 한 번만 평가됩니다
#   새니타이저로 돌리려면: make clean && make DEBUG=1 test
test: $(TARGET)
	./$(TARGET) --self-test

format:
	clang-format -i $(SRCDIR)/*.c $(INCDIR)/*.h

clean:
	rm -rf $(BUILDDIR) $(TARGET)

-include $(DEPS)      # ★ 헤더 의존성을 여기서 읽어 들입니다
Makefile 의 규칙 줄은 반드시 탭(Tab) 으로 들여씁니다 — 스페이스면 missing separator 에러가 납니다. 그리고 -MMD -MP 가 이 파일의 핵심입니다. 이게 없으면 헤더를 고쳐도 다시 빌드되지 않아, “왜 수정이 반영이 안 되지?”로 몇 시간을 날립니다. -MMD 는 .d 파일에 의존 목록을 적고, 마지막 줄의 -include 가 그걸 읽어 옵니다.

디렉터리 배치 — 사실상의 표준

myproj/
├── CMakeLists.txt
├── Makefile              # 간단한 경우 둘 중 하나만
├── README.md
├── .clang-format
├── .gitignore
├── include/
│   └── myproj/           # ★ 폴더를 한 겹 더 — #include "myproj/vec.h"
│       ├── vec.h         #   이름 충돌을 원천 차단합니다
│       └── str.h
├── src/
│   ├── main.c
│   ├── vec.c
│   ├── str.c
│   └── internal.h        # ★ 내부 전용 헤더는 src 안에
├── tests/
│   ├── test_vec.c
│   └── unity/            # 테스트 프레임워크
├── third_party/          # 외부 라이브러리 원본 (stb 등)
├── examples/
│   └── demo.c
└── build/                # ★ .gitignore 대상. 절대 커밋하지 않습니다
규칙이유
include/<프로젝트>/설치했을 때 다른 라이브러리와 안 섞입니다
공개 헤더만 include 에내부 헤더는 src/ 에 두어 API 를 좁게
함수 이름에 접두사vec_push 처럼 — C 에 네임스페이스가 없으니
빌드 산출물은 별도 폴더소스와 섞이면 clean 이 위험해집니다
third_party/ 분리남의 코드에는 우리 경고 설정을 적용하지 않습니다

.clang-format · .gitignore

# .clang-format — 팀 규칙을 파일 하나로 고정
BasedOnStyle: LLVM
Language: Cpp
IndentWidth: 4
TabWidth: 4
UseTab: Never
ColumnLimit: 100
PointerAlignment: Right          # char *p  (C 관습)
AlignConsecutiveAssignments: Consecutive
AllowShortFunctionsOnASingleLine: Inline
BreakBeforeBraces: Attach
SortIncludes: CaseSensitive
IncludeBlocks: Regroup
SpaceAfterCStyleCast: true
# .gitignore
build/
*.o
*.d
*.a
*.so
*.dll
*.exe
compile_commands.json
core
core.*
.cache/
.vscode/
.idea/
# 편집기 자동완성이 되게 — 이 파일 하나면 clangd 가 다 알아냅니다
cmake -B build -DCMAKE_EXPORT_COMPILE_COMMANDS=ON
ln -sf build/compile_commands.json .

# Makefile 프로젝트라면 bear 로 생성합니다
bear -- make

# 저장할 때마다 포맷 (git 훅)
echo 'clang-format -i $(git diff --cached --name-only | grep -E "\.[ch]$")' \
  > .git/hooks/pre-commit && chmod +x .git/hooks/pre-commit
compile_commands.json 이 없으면 편집기가 #include 경로도 매크로도 몰라서 빨간 줄이 잔뜩 뜨는데 빌드는 잘 되는 상태가 됩니다. C 프로젝트를 열자마자 이 파일부터 만드는 것이 습관이 되면 좋습니다.

CMake — 규모가 커지면 이쪽 🟡 중급

C 프로젝트용 CMakeLists.txt 표준형

cmake_minimum_required(VERSION 3.20)
project(myproj VERSION 1.2.0 LANGUAGES C)

set(CMAKE_C_STANDARD 17)
set(CMAKE_C_STANDARD_REQUIRED ON)
set(CMAKE_C_EXTENSIONS OFF)          # ★ -std=c17 (gnu17 아님)
set(CMAKE_EXPORT_COMPILE_COMMANDS ON)

# 라이브러리 — 실행 파일과 테스트가 함께 씁니다
add_library(myproj_core
    src/vec.c
    src/str.c)

target_include_directories(myproj_core
    PUBLIC  ${CMAKE_CURRENT_SOURCE_DIR}/include   # 쓰는 쪽에도 전파
    PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src)      # 내부 헤더는 우리만

target_compile_options(myproj_core PRIVATE
    $<$<C_COMPILER_ID:MSVC>:/W4>
    $<$<NOT:$<C_COMPILER_ID:MSVC>>:-Wall;-Wextra;-Wshadow>)

# 실행 파일
add_executable(app src/main.c)
target_link_libraries(app PRIVATE myproj_core)

# 테스트
enable_testing()
add_subdirectory(tests)

# 설치 규칙 — make install 로 시스템에 배포
install(TARGETS app myproj_core
        RUNTIME DESTINATION bin
        LIBRARY DESTINATION lib
        ARCHIVE DESTINATION lib)
install(DIRECTORY include/ DESTINATION include)

외부 라이브러리 가져오기 · 빌드 명령

# ① 시스템에 설치된 것 찾기
find_package(CURL REQUIRED)
target_link_libraries(app PRIVATE CURL::libcurl)

find_package(Threads REQUIRED)
target_link_libraries(app PRIVATE Threads::Threads)   # -pthread

# ② 소스에서 받아 함께 빌드 (의존성 관리가 가장 간편)
include(FetchContent)
FetchContent_Declare(cjson
    GIT_REPOSITORY https://github.com/DaveGamble/cJSON.git
    GIT_TAG        v1.7.18)
set(ENABLE_CJSON_TEST OFF CACHE BOOL "" FORCE)
FetchContent_MakeAvailable(cjson)
target_link_libraries(app PRIVATE cjson)

# ③ pkg-config 로 찾기 (리눅스에서 흔합니다)
find_package(PkgConfig REQUIRED)
pkg_check_modules(SQLITE REQUIRED IMPORTED_TARGET sqlite3)
target_link_libraries(app PRIVATE PkgConfig::SQLITE)
# 빌드 — 어느 OS 에서나 같은 명령
cmake -B build -G Ninja -DCMAKE_BUILD_TYPE=Release
cmake --build build -j
ctest --test-dir build --output-on-failure
cmake --install build --prefix /usr/local

# 디버그 + 새니타이저 빌드를 따로 두기
cmake -B build-asan -DCMAKE_BUILD_TYPE=Debug \
      -DCMAKE_C_FLAGS="-fsanitize=address,undefined"
cmake --build build-asan -j

# 캐시가 꼬였을 때 (자주 있습니다)
rm -rf build && cmake -B build ...
Make 와 CMake 중 무엇을 — 파일 20개 미만에 리눅스만 지원한다면 Makefile 로 충분합니다. 윈도우도 지원 · 외부 라이브러리 사용 · IDE 연동 · 테스트 러너 중 하나라도 필요하면 CMake 로 갑니다. 2026년 기준 CMake 4.x 가 사실상 C/C++ 의 표준 빌드 시스템입니다.

배포까지 — GitHub Actions 로 바이너리 굽기 🔴 고급

태그를 밀면 세 OS 바이너리가 릴리스에 붙는 워크플로

# .github/workflows/release.yml
name: release
on:
  push:
    tags: ['v*']

jobs:
  build:
    strategy:
      fail-fast: false
      matrix:
        include:
          - os: ubuntu-latest
            name: linux-x86_64
            flags: "-static"          # 정적 링크 → 어디서나 도는 파일 하나
          - os: macos-latest
            name: macos-arm64
            flags: ""
          - os: windows-latest
            name: windows-x86_64
            flags: ""
    runs-on: ${{ matrix.os }}
    steps:
      - uses: actions/checkout@v4

      - name: 설정
        run: cmake -B build -DCMAKE_BUILD_TYPE=Release
                   -DCMAKE_EXE_LINKER_FLAGS="${{ matrix.flags }}"

      - name: 빌드
        run: cmake --build build --config Release -j

      - name: 테스트
        run: ctest --test-dir build -C Release --output-on-failure

      - name: 산출물 업로드
        uses: actions/upload-artifact@v4
        with:
          name: app-${{ matrix.name }}
          path: |
            build/app
            build/Release/app.exe

  release:
    needs: build
    runs-on: ubuntu-latest
    permissions:
      contents: write
    steps:
      - uses: actions/download-artifact@v4
      - uses: softprops/action-gh-release@v2
        with:
          files: app-*/**
리눅스에서 -static 이 강력한 이유 — glibc 버전이 다른 서버에 복사해도 그대로 돕니다. 다만 glibc 정적 링크는 getaddrinfo 같은 일부 기능에서 경고가 나므로, 네트워크를 쓰는 프로그램이라면 musl(musl-gcc) 이나 Alpine 컨테이너 빌드가 더 깔끔합니다. C 프로그램이 수십 KB 파일 하나로 배포되는 것은 여전히 다른 언어가 못 따라오는 장점입니다.
정리 — C 프로젝트의 시작 세트는 다섯 개입니다 — src/·include/<프로젝트>/ 배치, -MMD -MP 가 들어간 Makefile(또는 CMake), .clang-format, compile_commands.json, 세 OS 를 도는 CI. 여기까지 갖추면 파일이 100개로 늘어도 빌드는 몇 초에 끝나고, 협업자가 cmake -B build 한 줄로 시작할 수 있습니다.
C 26

C 생태계 — 지금 무엇을 가져다 쓰나

C 에는 pip 도 npm 도 없지만, 세상에서 가장 많이 실행되는 라이브러리들이 C 로 되어 있습니다. 직접 만들 것과 가져다 쓸 것을 구분하는 감각이 실력의 절반입니다. 이 절은 2026년 기준으로 실제로 많이 쓰이는 라이브러리 아홉 개를 용도별로 정리하고, 각각 최소 예제 한 조각씩과 함께 C 로 만들어진 유명 프로젝트들이 왜 C 를 골랐는지를 봅니다.

2026년 기준 — C 프로젝트에서 실제로 손이 가는 라이브러리 SQLite 파일 하나짜리 관계형 DB 소스 한 장으로 넣습니다 libcurl HTTP · FTP · TLS 클라이언트 사실상 모든 기기에 있습니다 cJSON · yyjson JSON 파싱과 생성 쉬운 쪽 · 빠른 쪽 zlib · zstd 압축과 해제 호환성 · 속도로 나뉩니다 libuv 비동기 이벤트 루프 Node.js 의 심장입니다 libevent 이벤트 기반 네트워크 memcached 가 씁니다 raylib 게임 · 그래픽 · 소리 배우기 가장 쉽습니다 SDL3 창 · 입력 · 오디오 · GPU 상용 게임의 이식 표준 mbedTLS · OpenSSL TLS 와 암호 직접 만들지 마세요 C 로 만들어진 것들 — 지금 이 순간에도 돌고 있습니다 리눅스 커널 · Redis · CPython · Git · Nginx · SQLite · FFmpeg · PostgreSQL · curl · OpenSSL
공통점이 하나 있습니다 — 수십 년을 살아남아야 하고, 모든 플랫폼에서 돌아야 하는 것들입니다.

SQLite — 소스 한 장으로 DB 를 넣습니다

// sqlite3.c(약 25만 줄 한 파일) + sqlite3.h 를 받아 함께 컴파일하면 끝입니다
#include "sqlite3.h"
#include <stdio.h>

int main(void) {
    sqlite3 *db;
    if (sqlite3_open("app.db", &db) != SQLITE_OK) return 1;

    sqlite3_exec(db,
        "CREATE TABLE IF NOT EXISTS log(id INTEGER PRIMARY KEY, msg TEXT)",
        NULL, NULL, NULL);

    // ★ 항상 준비된 문장(prepared statement) — SQL 주입을 막습니다
    sqlite3_stmt *st;
    sqlite3_prepare_v2(db, "INSERT INTO log(msg) VALUES(?)", -1, &st, NULL);
    sqlite3_bind_text(st, 1, "첫 기록", -1, SQLITE_STATIC);
    sqlite3_step(st);
    sqlite3_finalize(st);

    sqlite3_prepare_v2(db, "SELECT id, msg FROM log", -1, &st, NULL);
    while (sqlite3_step(st) == SQLITE_ROW)
        printf("%d: %s\n", sqlite3_column_int(st, 0),
                           sqlite3_column_text(st, 1));
    sqlite3_finalize(st);
    sqlite3_close(db);
}
// gcc main.c sqlite3.c -o app -lpthread -ldl
세상에서 가장 많이 배포된 데이터베이스입니다 — 안드로이드·iOS·크롬·파이어폭스·항공기 시스템까지 전부 들어 있습니다. 테스트 코드가 본체보다 600배 많은 것으로도 유명합니다. “설정도 서버도 없는 DB”가 필요하면 고민할 것 없이 이것입니다.

libcurl + cJSON — API 를 부르고 파싱하기

#include <curl/curl.h>
#include <cjson/cJSON.h>
#include <stdlib.h>
#include <string.h>

typedef struct { char *p; size_t n; } Buf;

static size_t on_data(void *src, size_t sz, size_t nm, void *ud) {
    size_t total = sz * nm;
    Buf *b = ud;
    char *q = realloc(b->p, b->n + total + 1);
    if (!q) return 0;                       // 0 을 반환하면 전송이 중단됩니다
    b->p = q;
    memcpy(b->p + b->n, src, total);
    b->n += total;
    b->p[b->n] = '\0';
    return total;
}

int main(void) {
    curl_global_init(CURL_GLOBAL_DEFAULT);
    CURL *c = curl_easy_init();
    Buf buf = {0};

    curl_easy_setopt(c, CURLOPT_URL, "https://api.example.com/items");
    curl_easy_setopt(c, CURLOPT_WRITEFUNCTION, on_data);
    curl_easy_setopt(c, CURLOPT_WRITEDATA, &buf);
    curl_easy_setopt(c, CURLOPT_TIMEOUT, 10L);          // ★ 타임아웃 필수
    curl_easy_setopt(c, CURLOPT_FOLLOWLOCATION, 1L);

    if (curl_easy_perform(c) == CURLE_OK) {
        cJSON *root = cJSON_Parse(buf.p);
        cJSON *name = cJSON_GetObjectItemCaseSensitive(root, "name");
        if (cJSON_IsString(name)) printf("이름: %s\n", name->valuestring);
        cJSON_Delete(root);                             // ★ 해제는 내 책임
    }
    free(buf.p);
    curl_easy_cleanup(c);
    curl_global_cleanup();
}
// gcc main.c -o app -lcurl -lcjson

용도별 선택표 — 무엇을 언제 🟢 기초

2026년 기준 실무 선택

하고 싶은 일라이브러리메모
데이터 저장SQLite파일 하나. 서버형이 필요하면 libpq(PostgreSQL)
HTTP 요청libcurlTLS·프록시·재시도까지 전부. 대안 없음
JSONcJSON / yyjson쉬운 쪽 / 빠른 쪽(수 GB/s). jansson 도 좋음
압축zlib / zstd호환성 / 속도·압축률. miniz 는 헤더 한 장
비동기 서버libuv / libeventepoll·kqueue·IOCP 를 하나로 덮어 줍니다
게임 · 그래픽raylib / SDL3배우기 쉬움 / 상용 수준 이식성
TLS · 암호mbedTLS / OpenSSL임베디드 · 소형 / 기능 완비. 직접 구현 금지
GUIGTK4 / Nuklear본격적 / 헤더 한 장짜리 즉시 모드
자료구조stb_ds / klib동적 배열·해시맵(c14·c15 참고)
테스트Unity / cmocka가벼움 / 모킹 지원(c24 참고)
로깅log.c / zlog한 파일 / 설정 파일 기반
명령행 인자argtable3 / 직접getopt_long 이면 대개 충분합니다
# 의존성을 관리하는 방법 — C 에는 표준이 없어서 셋 중 하나를 고릅니다
# ① 시스템 패키지 (가장 단순, 리눅스 전용에 적합)
sudo apt install libcurl4-openssl-dev libsqlite3-dev zlib1g-dev

# ② vcpkg — 윈도우 포함 크로스 플랫폼 (2026 기준 가장 무난)
vcpkg install curl sqlite3 zstd
cmake -B build -DCMAKE_TOOLCHAIN_FILE=$VCPKG_ROOT/scripts/buildsystems/vcpkg.cmake

# ③ CMake FetchContent — 소스째 받아 함께 빌드 (재현성 최고)
#   c25 의 예시 참고

# ④ 그냥 소스를 복사 (헤더 한 장짜리 라이브러리)
#   stb_ds.h · miniz.h · nuklear.h · log.c — third_party/ 에 넣고 커밋
라이브러리를 고를 때 확인할 세 가지 — ① 라이선스(MIT·BSD·zlib 는 자유롭고, GPL 은 내 코드까지 공개해야 할 수 있습니다). ② 최근 커밋(보안 패치가 도는지). ③ C 표준만 쓰는지(POSIX 전용이면 윈도우에서 못 씁니다). C 라이브러리는 한 번 넣으면 10년을 같이 갑니다.

C 로 만든 유명 프로젝트 — 왜 C 였나 🟡 중급

읽어 볼 만한 코드베이스

프로젝트규모C 를 고른 이유
Linux 커널3천만 줄+런타임 없이 하드웨어 직접 제어. 유일한 선택지였습니다
Redis약 15만 줄가장 읽기 좋은 C 코드로 유명. 자료구조 교재급
SQLite약 15만 줄어디서나 컴파일돼야 함. 의존성 0
CPython약 60만 줄모든 OS 에서 빌드 + C 확장 API 를 제공해야 함
Git약 30만 줄속도와 이식성. 초기 리눅스 개발자 문화
Nginx약 20만 줄이벤트 기반 · 메모리 풀 설계로 극한 성능
FFmpeg약 100만 줄SIMD 어셈블리와 섞어 쓰기 좋음
curl약 15만 줄세상 모든 장치에 이식돼야 함
공부용으로 하나만 고르라면 Redis 입니다 — sds.c(동적 문자열), dict.c(해시 테이블), adlist.c(연결 리스트), ae.c(이벤트 루프)가 이 탭에서 만든 것들의 실전판입니다. 파일 하나가 500~1500줄이라 읽기에도 부담이 없습니다.

2026년의 C — 어디로 가고 있나

// raylib — "10줄로 창 띄우기". 배우기 가장 즐거운 C 라이브러리
#include "raylib.h"

int main(void) {
    InitWindow(800, 450, "안녕 raylib");
    SetTargetFPS(60);
    while (!WindowShouldClose()) {
        BeginDrawing();
            ClearBackground(RAYWHITE);
            DrawText("C 로 만든 창입니다", 190, 200, 20, DARKGRAY);
            DrawCircle(GetMouseX(), GetMouseY(), 20, MAROON);
        EndDrawing();
    }
    CloseWindow();
}
// gcc main.c -o game -lraylib -lm -lpthread -ldl
흐름의미
러스트로의 이동커널·안드로이드가 신규 드라이버에 러스트를 허용. C 를 대체가 아니라 병행
메모리 안전 압박미국 정부 기관 권고 이후 새니타이저·정적 분석이 사실상 의무가 됐습니다
C23 채택GCC/Clang 이 앞서고 MSVC 가 따라가는 중
WebAssemblyEmscripten 으로 C 코드가 브라우저에서 돕니다
여전한 수요임베디드 · 커널 · 런타임 · 라이브러리 경계는 바뀌지 않습니다
C 를 배운 값어치 — 앞으로 러스트를 쓰든 고를 쓰든, 포인터·메모리 레이아웃·링크·시스템 콜을 아는 사람과 모르는 사람의 차이는 계속 벌어집니다. 그리고 모든 언어의 FFI 경계는 여전히 C ABI 입니다 — 이건 당분간 바뀌지 않습니다.
다음에 뭘 하면 좋은가 — ① 이 탭의 동적 배열·해시 테이블·문자열 버퍼를 한 파일로 묶어 자기 mylib.h 를 만들어 보세요. ② 그걸로 작은 도구(로그 파서·JSON 뷰어·TCP 채팅)를 하나 만듭니다. ③ 만든 것을 c23 처럼 .so 로 빌드해 파이썬에서 불러 봅니다. 이 세 단계를 마치면 C 를 “읽을 수 있는 언어”가 아니라 “쓸 수 있는 도구”로 갖게 됩니다.
정리 — C 에 패키지 매니저는 없지만 검증된 라이브러리는 넘칩니다 — DB 는 SQLite, HTTP 는 libcurl, JSON 은 cJSON/yyjson, 비동기는 libuv, 그래픽은 raylib/SDL3, TLS 는 mbedTLS. 의존성은 vcpkg 나 FetchContent 로 관리하고, 헤더 한 장짜리는 그냥 third_party/ 에 넣습니다. 그리고 Redis 소스 읽기는 이 탭 전체의 가장 좋은 복습입니다.
C++23 · 서버 · 게임 · 고성능 시스템 · 다른 언어에서 불러 쓰는 모듈

가장 빠른 코드가
필요한 자리에는
여전히 C++ 가 있습니다

C++ 의 강점은 하드웨어에 가장 가까우면서도 추상화를 공짜로 쓴다는 것입니다 — 실행 중 통역도, 가비지 컬렉터의 멈춤도 없습니다. 그래서 게임 엔진 · 브라우저 · DB 엔진 · 거래 시스템 · 영상 처리 · 임베디드가 전부 C++ 로 서 있습니다. 이 가이드는 그 C++ 를 기초 문법부터 백엔드 서버 · 게임 서버 · 고성능 시스템, 그리고 파이썬 · Node · 자바 · C# 에서 불러 쓰는 모듈까지 한 페이지로 잇습니다. 이 탭을 마치면 C++ 코드를 읽고 고칠 수 있게 됩니다.

12SECTIONS
C++23기준 (C++20 호환)
0사전 지식
4깊이 층
CPP 01

C++ 를 실행하는 법 — 컴파일이 전부의 시작

파이썬은 파일을 그냥 실행합니다. C++ 는 기계어로 번역한 뒤 그 결과물을 실행합니다. 이 한 단계가 속도의 이유이자, 앞으로 만날 모든 번거로움의 이유입니다.

소스 한 줄이 실행 파일이 되기까지 — 네 단계 1 전처리 — g++ -E #include 를 파일째로 붙여 넣고 #define 을 치환합니다. 20줄짜리 소스가 수만 줄이 됩니다 2 컴파일 — g++ -S 문법 검사와 최적화를 거쳐 어셈블리로. 우리가 보는 에러의 90%가 여기서 납니다 3 어셈블 — g++ -c 오브젝트 파일(.o)이 나옵니다. 다른 파일에 있는 함수의 주소는 아직 빈칸입니다 4 링크 — g++ (플래그 없음) .o 들과 라이브러리를 이어 붙여 실행 파일로. undefined reference 는 전부 이 단계입니다 에러 문구만 봐도 어느 단계인지 알 수 있습니다 "No such file" → 1단계 · "expected ';'" → 2단계 · "undefined reference to" → 4단계
파이썬에는 이 네 단계가 없습니다 — C++ 에러 메시지를 읽는 첫걸음이 이 구분입니다.

첫 프로그램 — 파이썬과 나란히

// hello.cpp
#include <iostream>          // 파이썬의 import 에 해당

int main() {                  // 시작 지점이 반드시 있어야 합니다
    std::cout << "안녕\n";     // print("안녕")
    return 0;                 // 0 = 정상 종료 (셸이 이 값을 봅니다)
}
# ① 번역(컴파일) → ② 실행  — 두 단계입니다
g++ -std=c++20 -O2 hello.cpp -o hello
./hello                       # 윈도우면 hello.exe

# 파이썬은 이 한 줄이 전부였습니다
python hello.py
컴파일러는 뭘 쓰나 — 리눅스·맥은 g++ 또는 clang++, 윈도우는 Visual Studio Build Tools(cl.exe)를 깔면 됩니다. 어느 쪽이든 이 가이드의 코드는 그대로 돕니다. 지금 아무것도 깔기 싫다면 godbolt.org 에서 브라우저로 바로 돌려 볼 수 있습니다.

왜 이 귀찮음을 감수하나

파이썬C++
실행 방식한 줄씩 해석미리 기계어로
타입 검사실행 중에컴파일할 때
에러를 보는 시점그 줄에 도달할 때실행 전
속도기준수십~수백 배
메모리 관리자동(GC)내가 설계
고치고 확인즉시빌드 기다림
오해 하나 — “C++ 가 빠르다”가 내가 쓴 C++ 코드가 빠르다는 뜻은 아닙니다. 생각 없이 쓴 C++ 는 numpy 보다 느립니다. 빠른 이유는 메모리 배치와 복사를 내가 직접 정할 수 있기 때문이고, 그건 ⚡ 성능 탭에서 다룹니다.

이렇게도 쓸 수 있다 — 컴파일 옵션이 결과를 바꿉니다 🟡 중급

외우면 좋은 플래그 다섯 개

g++ -std=c++20 -O2 -Wall -Wextra -g main.cpp -o app

#  -std=c++20   어느 표준으로 볼지 (안 주면 컴파일러 기본값 — 낡을 수 있음)
#  -O2          최적화 ★ 이거 하나로 10배 차이가 납니다
#  -Wall -Wextra 경고를 켜기 ★ C++ 경고는 대부분 진짜 버그입니다
#  -g           디버그 정보 (디버거로 붙을 때 필요)

# 경고를 에러로 승격 — 팀 프로젝트의 기본 설정
g++ -std=c++20 -O2 -Wall -Wextra -Werror main.cpp

# 여러 파일이면 전부 나열하거나, 나눠서 컴파일합니다
g++ -std=c++20 -c a.cpp -o a.o     # 번역만 (오브젝트 파일)
g++ -std=c++20 -c b.cpp -o b.o
g++ a.o b.o -o app                 # 링크 (합치기)
왜 나눠서 컴파일하나 — 파일 하나를 고쳤을 때 그 파일만 다시 번역하면 되기 때문입니다. 파일이 수백 개인 프로젝트에서 이 차이가 빌드 30초 vs 20분이 됩니다. CMake 가 이걸 자동으로 관리해 줍니다(🛠️ 빌드 탭).

두 종류의 에러를 구분하기 ★

① 컴파일 에러 — 문법·타입이 틀렸다
error: 'cout' was not declared in this scope
   → #include <iostream> 을 빠뜨렸거나 std:: 를 안 붙였습니다

error: invalid conversion from 'const char*' to 'int'
   → 타입이 안 맞습니다. 그 줄만 보면 됩니다

② 링크 에러 — 문법은 맞는데 '실체'가 없다
undefined reference to `foo()'
   → 선언만 있고 정의가 없거나, 그 .cpp 를 컴파일에 안 넣었습니다
   → 라이브러리를 안 붙였을 때도 납니다 (-lpthread 등)

multiple definition of `foo()'
   → 같은 함수가 두 번 정의됐습니다 (헤더에 정의를 넣은 경우)
이 구분이 왜 중요한가 — 에러 메시지에 error: 와 파일·줄 번호가 있으면 컴파일, undefined reference·LNK2019 처럼 줄 번호가 없으면 링크입니다. 고쳐야 할 곳이 완전히 다릅니다 — 링크 에러는 코드가 아니라 빌드 구성의 문제입니다.

실무에선 이렇게 — 손으로 g++ 를 치지 않습니다 🔴 실무

CMake — 지금은 이 20줄이면 충분합니다

# CMakeLists.txt
cmake_minimum_required(VERSION 3.20)
project(myproj LANGUAGES CXX)

set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)

add_executable(app main.cpp util.cpp)     # 실행 파일
# add_library(mylib STATIC util.cpp)      # 라이브러리라면

target_compile_options(app PRIVATE
  $<$<CXX_COMPILER_ID:MSVC>:/W4>
  $<$<NOT:$<CXX_COMPILER_ID:MSVC>>:-Wall -Wextra>)
# 빌드는 어느 OS 에서나 같은 두 줄
cmake -B build -DCMAKE_BUILD_TYPE=Release
cmake --build build -j

./build/app        # 윈도우: build\Release\app.exe
왜 CMake 인가 — 윈도우·리눅스·맥에서 같은 파일 하나로 빌드되고, pybind11·vcpkg·CI 가 전부 CMake 를 전제로 돌아갑니다. “C++ 프로젝트를 받았다” = “CMakeLists.txt 부터 연다”가 됩니다.

개발 중에 실제로 쓰는 것들

# ① 빌드 캐시 — 두 번째부터 몇 배 빨라집니다
sudo apt install ccache          # 또는 brew install ccache
cmake -B build -DCMAKE_CXX_COMPILER_LAUNCHER=ccache

# ② 편집기 자동완성이 되게 — compile_commands.json ★
cmake -B build -DCMAKE_EXPORT_COMPILE_COMMANDS=ON
ln -s build/compile_commands.json .        # clangd·VSCode 가 이걸 읽습니다

# ③ 빌드 시스템은 Ninja 가 make 보다 빠릅니다
cmake -B build -G Ninja

# ④ 포맷·정적 분석 (파이썬의 ruff 자리)
clang-format -i src/*.cpp        # .clang-format 파일로 팀 규칙 고정
clang-tidy src/*.cpp -- -std=c++20
처음 세팅에서 가장 많이 막히는 것 — 편집기에 빨간 줄이 잔뜩 뜨는데 빌드는 되는 경우. 거의 전부 compile_commands.json 이 없거나 편집기가 못 찾아서입니다. 이 파일 하나로 자동완성·정의로 이동이 전부 살아납니다.

한 단계 더 — 번역 과정을 눈으로 보기 🧬 전문가

소스가 실행 파일이 되기까지 네 단계

# ① 전처리 — #include 를 통째로 붙여넣고 매크로를 펼칩니다
g++ -E hello.cpp | wc -l
#   40줄짜리 파일이 <iostream> 하나 때문에 수만 줄이 됩니다
#   → 헤더를 함부로 include 하면 '빌드가 느려지는' 이유가 이것입니다

# ② 컴파일 — 어셈블리로
g++ -S -O2 hello.cpp -o -   | head -40

# ③ 어셈블 — 오브젝트 파일(.o)로. 아직 실행 못 합니다
g++ -c hello.cpp -o hello.o
nm -C hello.o                 # 이 파일이 가진/필요한 심볼 목록
#   T main            ← 내가 제공 (Text = 코드)
#   U std::cout       ← 내가 필요로 함 (Undefined) ★

# ④ 링크 — 여러 .o 와 라이브러리를 이어 붙여 실행 파일로
g++ hello.o -o hello
ldd hello                     # 실행할 때 필요한 공유 라이브러리 (맥은 otool -L)

빌드가 느릴 때 어디를 보나

# 파일별 컴파일 시간 재기 (clang)
clang++ -ftime-trace -c big.cpp
#   big.json 을 chrome://tracing 이나 ui.perfetto.dev 에 넣으면
#   어느 헤더·어느 템플릿이 시간을 먹는지 그림으로 보입니다

# 전처리 결과 크기로 대략 감 잡기
for f in src/*.cpp; do echo "$(g++ -E $f 2>/dev/null | wc -l) $f"; done | sort -rn | head

# 자주 쓰는 처방
#   · 헤더에서 다른 헤더 include 를 줄이고 '전방 선언'으로 대체
#       class Foo;              // 정의 없이 이름만 알려 주기
#   · 무거운 표준 헤더는 .cpp 안으로 내리기
#   · 미리 컴파일된 헤더 (CMake 3.16+)
#       target_precompile_headers(app PRIVATE <vector> <string>)
#   · Unity build(파일 합쳐 컴파일) — CMAKE_UNITY_BUILD ON
이걸 알면 달라지는 것 — “빌드가 느리다”를 체감이 아니라 수치로 말할 수 있게 됩니다. C++ 프로젝트에서 빌드 시간은 곧 팀 전체의 생산성이라, 이걸 줄이는 사람이 실제로 대접받습니다.
정리 — C++ 는 컴파일 → 실행 두 단계입니다. g++ -std=c++20 -O2 -Wall a.cpp -o a 이 한 줄만 외우면 시작할 수 있고, 실무에서는 이걸 CMake 가 대신 해 줍니다. 에러가 나면 먼저 컴파일 에러인지 링크 에러인지부터 구분하세요 — 고칠 곳이 다릅니다.
CPP 02

변수 · 타입 · auto

파이썬은 값이 타입을 정했습니다. C++ 는 내가 미리 정합니다. 귀찮은 대신, 실행 중에 타입을 확인하는 비용이 사라집니다.

기본 타입

int    n = 10;            // 정수 (보통 32비트)
double d = 3.14;          // 실수 (파이썬 float 와 같음)
bool   b = true;          // 참/거짓 (소문자!)
char   c = 'A';           // 글자 하나 (작은따옴표)

std::string s = "안녕";   // 문자열 — #include <string>

// auto — 오른쪽을 보고 타입을 알아서 정합니다
auto x = 10;              // int
auto y = 3.14;            // double
auto z = "글자";           // ⚠ std::string 이 아니라 const char*
auto w = std::string("글자");   // 이렇게 해야 string

// 변수는 반드시 초기화하세요 ★
int a;                    // ❌ 쓰레기 값이 들어 있습니다 (UB)
int a2{};                 // ✅ 0 으로 초기화
std::string s2;           // string 은 안 적어도 빈 문자열
파이썬에서 온 사람이 가장 놀라는 것 — 초기화하지 않은 변수는 0이 아닙니다. 그 자리에 남아 있던 아무 값이 들어 있고, 그걸 읽는 건 정식으로 미정의 동작(UB)입니다. 선언과 동시에 값을 주는 습관이 C++ 안전의 절반입니다.

파이썬 타입과의 대응

파이썬C++메모
intint · long long범위 제한 있음 — 넘치면 UB
floatdouble같은 IEEE 754
boolbooltrue/false 소문자
strstd::string바이트 배열입니다(뒤에서)
liststd::vector<T>한 가지 타입만 담습니다
dictstd::unordered_map정렬이 필요하면 map
tuplestd::tuple · struct보통 struct 가 낫습니다
Nonestd::optional<T>널 포인터와는 다릅니다
bytesstd::vector<uint8_t>
파이썬 int 와 결정적으로 다른 점 — 파이썬 정수는 자릿수 제한이 없었습니다. C++ int 는 대개 ±21억이고, 넘치면 조용히 이상한 값이 되거나 컴파일러가 “넘칠 리 없다”고 가정해 코드를 지워 버립니다(🔬 전문가 탭 UB).

이렇게도 쓸 수 있다 — 크기를 정확히 정하는 타입 🟡 중급

고정폭 정수 — 다른 언어와 데이터를 주고받을 때 필수

#include <cstdint>

int32_t  a;     // 정확히 32비트 부호 있음
uint32_t b;     // 정확히 32비트 부호 없음
int64_t  c;     // 64비트
uint8_t  byte;  // 1바이트 (바이너리 데이터에)

//  int · long 의 크기는 플랫폼마다 다릅니다
//    윈도우 64비트: long = 4바이트
//    리눅스 64비트: long = 8바이트   ← 같은 코드가 다르게 동작
//  → 파일 포맷 · 네트워크 패킷 · 다른 언어와의 인터페이스에는
//    반드시 int32_t 처럼 크기가 박힌 타입을 쓰세요 ★

// 크기 · 정렬 확인
sizeof(int)        // 4
sizeof(void*)      // 8 (64비트)
alignof(double)    // 8

// 컨테이너 크기·인덱스는 size_t (부호 없음)
std::size_t n = v.size();

부호 있는 것과 없는 것을 섞으면 ★

std::vector<int> v;                  // 비어 있음

for (int i = 0; i < v.size() - 1; ++i)   // ❌ 무한 루프에 가까운 폭주
    ...
//  v.size() 는 부호 없는 0.  0 - 1 은 -1 이 아니라
//  18446744073709551615 (엄청나게 큰 수)가 됩니다.

// ✅ 방법 1 — 크기를 먼저 검사
if (!v.empty())
    for (std::size_t i = 0; i + 1 < v.size(); ++i) ...

// ✅ 방법 2 — 범위 for (인덱스를 아예 쓰지 않기) ★ 가장 좋습니다
for (int x : v) ...

// ✅ 방법 3 — C++20 부터는 안전한 비교 함수가 있습니다
#include <utility>
if (std::cmp_less(i, v.size())) ...    // 부호가 달라도 수학적으로 비교

// 변환은 반드시 명시적으로
double d = 3.9;
int    n = static_cast<int>(d);       // 3 (버림) — 의도를 코드에 남깁니다
-Wall -Wextra 를 켜야 하는 이유 — 위 비교는 comparison of integer expressions of different signedness 경고로 잡힙니다. C++ 에서 경고는 거의 다 진짜 버그입니다. 경고를 켜지 않으면 이런 게 조용히 배포됩니다.

실무에선 이렇게 — 타입으로 실수를 막습니다 🔴 실무

auto 를 언제 쓰고 언제 안 쓰나

// ✅ 타입 이름이 길고, 오른쪽만 봐도 뻔할 때
auto it = m.find(key);                     // 반복자 타입을 적을 이유가 없습니다
auto p  = std::make_unique<Node>();
for (const auto& [key, val] : dict) { ... } // 구조적 바인딩 (C++17)

// ❌ 읽는 사람이 타입을 알아야 판단할 수 있을 때
auto   total = compute();    // int 인지 double 인지 모르면 리뷰가 안 됩니다
double total = compute();

// ⚠ auto 는 참조·const 를 '떼어 버립니다' — 복사가 몰래 생깁니다 ★
auto        s = obj.name();  // 복사!
auto&       s = obj.name();  // 참조 (원본을 봄)
const auto& s = obj.name();  // 읽기 전용 참조 ← 함수 인자·범위 for 의 기본형

for (auto x : big_vec)         // ❌ 원소마다 복사
for (const auto& x : big_vec)  // ✅ 복사 없음

숫자 하나를 그냥 넘기지 않기

// ❌ 인자를 바꿔 넣어도 컴파일이 됩니다
void reserve_room(int user_id, int room_no);
reserve_room(room_no, user_id);        // 아무도 못 잡습니다

// ✅ 타입을 따로 만들면 컴파일러가 잡아 줍니다 (강한 타입)
struct UserId { int v; };
struct RoomNo { int v; };
void reserve_room(UserId, RoomNo);
reserve_room(RoomNo{3}, UserId{7});    // ❌ 컴파일 에러 — 여기서 막힙니다

// 상수는 이름을 붙입니다
constexpr int    kMaxRetry = 3;        // 매크로(#define) 대신 ★
constexpr double kTaxRate  = 0.1;

// 타입 별칭 — 의미를 이름으로
using Millis = std::chrono::milliseconds;
using Matrix = std::vector<std::vector<double>>;   // typedef 보다 이쪽
파이썬 타입 힌트와의 차이 — 파이썬 힌트는 메모였고 실행에 영향이 없었습니다. C++ 타입은 강제이고, 어기면 실행조차 못 합니다. 귀찮은 만큼 런타임에 터질 일이 컴파일 시점으로 당겨집니다 — 이게 C++ 의 값어치입니다.

한 단계 더 — 메모리에 실제로 어떻게 놓이나 🧬 전문가

크기 · 정렬 · 패딩

struct 나쁜배치 {      // sizeof == 24
    char  a;           // 1바이트 + 패딩 7
    double b;          // 8바이트  (8의 배수 자리에 놓여야 함)
    char  c;           // 1바이트 + 패딩 7
};

struct 좋은배치 {      // sizeof == 16  ★ 큰 것부터 정렬
    double b;
    char   a;
    char   c;          // 패딩 6
};

static_assert(sizeof(좋은배치) == 16);   // 컴파일 시점에 검증

//  구조체 100만 개면 24MB vs 16MB — 캐시 적중률까지 갈립니다.
//  다른 언어와 구조체를 주고받을 때는 이 배치가 정확히 맞아야 합니다(🔗 C ABI 탭).

#include <bit>                       // C++20
std::bit_cast<uint64_t>(3.14);        // 비트를 그대로 재해석 (안전한 방법)
//  옛 코드의 union 이나 포인터 캐스팅은 대부분 UB 입니다

정수 승격과 부동소수의 함정

// ① 작은 타입은 연산 전에 int 로 승격됩니다
uint8_t a = 200, b = 100;
auto c = a + b;              // c 는 uint8_t 가 아니라 int (300)
uint8_t d = a + b;           // 300 을 1바이트에 → 44 (조용히 잘림)

// ② 부호 있는 정수의 오버플로는 UB (부호 없는 것은 랩어라운드로 정의됨)
int n = INT_MAX;
n + 1;                       // ❌ UB — 컴파일러가 "일어날 리 없다"고 가정합니다
//   그래서 if (n + 1 < n) 같은 오버플로 검사가 통째로 삭제됩니다 ★
#include <numeric>           // C++26 이전에는 직접 검사
if (n > INT_MAX - 1) { /* 넘칠 상황 */ }

// ③ 부동소수 — 파이썬과 똑같은 문제, 똑같은 해법
0.1 + 0.2 == 0.3;            // false
std::abs((0.1+0.2) - 0.3) < 1e-9;    // 이렇게 비교
//   금액은 int64_t(최소 단위) 로 다루는 것이 정석입니다

// ④ 정수 나눗셈은 버림 — 파이썬의 // 와 같습니다
5 / 2;                       // 2
5.0 / 2;                     // 2.5  (한쪽만 실수여도 실수 연산)
왜 이걸 알아야 하나 — 파이썬 모듈로 만들 때 이 함정들이 “파이썬에서는 맞는데 C++ 로 옮기니 값이 다르다” 로 나타납니다. 특히 정수 오버플로와 부호 없는 뺄셈 두 가지가 압도적으로 많습니다.
정리 — 타입을 미리 적는 것이 파이썬과의 가장 큰 차이입니다. int·double·bool·std::string·std::vector 다섯 개면 시작할 수 있고, 변수는 항상 초기화하고 범위 for(for (const auto& x : v))를 쓰면 처음 흔히 밟는 함정 대부분을 피합니다.
CPP 03

함수 — 오버로딩과 인자 전달

문법은 파이썬과 비슷합니다. 다른 건 두 가지 — 같은 이름의 함수를 여러 개 만들 수 있고, 인자를 어떻게 넘기느냐가 성능을 정합니다.

기본 형태

// 반환타입 이름(인자) { ... }
int add(int a, int b) {
    return a + b;
}

void greet(const std::string& name) {   // void = 돌려줄 것 없음
    std::cout << name << "님 안녕\n";
}

// 기본 인자 — 뒤쪽부터만 가능합니다
double round_to(double v, int digits = 2);

// 오버로딩 — 이름이 같고 '인자 모양'이 다르면 별개 함수
int  area(int w, int h)          { return w * h; }
double area(double r)            { return 3.14159 * r * r; }
area(3, 4);      // 첫 번째
area(2.0);       // 두 번째    ← 컴파일러가 알아서 고릅니다

// ⚠ 반환 타입만 다른 오버로딩은 안 됩니다
int  f(int);
double f(int);   // ❌ 컴파일 에러
파이썬과 다른 점 — 파이썬은 같은 이름을 두 번 쓰면 나중 것이 앞의 것을 덮어썼습니다. C++ 는 인자 모양이 다르면 둘 다 살아 있고, 호출할 때 컴파일러가 고릅니다. 파이썬에서 if isinstance(x, int) 로 분기하던 자리가 C++ 에서는 오버로딩입니다.

여러 값을 돌려주기

// 파이썬:  return 몫, 나머지

// ① struct — 이름이 붙어서 가장 읽기 좋습니다 ★
struct DivResult { int quot; int rem; };
DivResult divide(int a, int b) {
    return {a / b, a % b};            // 중괄호로 바로 만듭니다
}
auto r = divide(7, 2);
r.quot;  r.rem;

// ② 구조적 바인딩으로 풀기 (C++17) — 파이썬 언패킹과 같은 느낌
auto [q, m] = divide(7, 2);

// ③ std::pair · std::tuple — 이름이 없어 first/second 가 됩니다
std::pair<int,int> p = {7, 2};
auto [a, b] = p;

// ④ 참조 인자로 '채워 주기' — 옛 스타일이지만 여전히 보입니다
void divide(int a, int b, int& quot, int& rem) {
    quot = a / b;  rem = a % b;       // 호출한 쪽 변수가 바뀝니다
}
int q2, m2;
divide(7, 2, q2, m2);
고르는 기준 — 값이 2개이고 의미가 뻔하면 pair, 그 외에는 거의 항상 struct 가 낫습니다. tuple 은 std::get<2>(t) 처럼 번호로 꺼내야 해서 나중에 읽기 어렵습니다.

이렇게도 쓸 수 있다 — 인자를 넘기는 네 가지 방법 🟡 중급

기본값은 const& 입니다 ★

void f1(std::vector<int> v);          // ① 값 — 통째로 복사됩니다
void f2(const std::vector<int>& v);   // ② const 참조 — 복사 없음, 못 바꿈 ★
void f3(std::vector<int>& v);         // ③ 참조 — 복사 없음, 바꿀 수 있음
void f4(std::vector<int>* v);         // ④ 포인터 — 없을 수도 있을 때

std::vector<int> big(1'000'000);
f1(big);     // 400만 바이트를 복사 — 그냥 읽기만 할 건데도!
f2(big);     // 주소만 넘김 (8바이트)

//  ②가 기본입니다. 함수 안에서 바꿔야 하면 ③,
//  "값이 없을 수도 있다"를 표현해야 하면 ④ 또는 std::optional 을 씁니다.

//  ⚠ 단, 아주 작은 타입은 값이 더 빠릅니다
void g(int x);            // ✅ int·double·포인터는 그냥 값으로
void g(const int& x);     // ❌ 오히려 손해 (간접 접근이 생김)

헷갈리는 const 위치

void show(const std::string& s);   // s 의 내용을 못 바꿈 ★ 가장 많이 씀

// 포인터는 두 자리에 붙일 수 있습니다 — 오른쪽에서 왼쪽으로 읽으세요
const int* p;        // p 는  const int 를 가리키는 포인터 (값 못 바꿈)
int* const p2 = &n;  // p2 는 const 포인터 (가리키는 대상을 못 바꿈)
const int* const p3 = &n;   // 둘 다 못 바꿈

// 멤버 함수 뒤의 const — "이 함수는 객체를 안 바꿉니다"
class Box {
    int n_ = 0;
public:
    int  size() const { return n_; }   // ★ 읽기 전용 함수에는 꼭 붙이세요
    void add()        { ++n_; }
};

const Box b;
b.size();     // ✅ const 객체는 const 함수만 부를 수 있습니다
b.add();      // ❌ 컴파일 에러 — 여기서 실수를 막아 줍니다
const 를 붙이는 습관의 값어치 — “이 함수는 내 데이터를 건드리지 않는다”가 주석이 아니라 컴파일러가 보증하는 사실이 됩니다. 나중에 멀티스레드로 갈 때 const 객체는 여러 스레드가 동시에 읽어도 안전하다는 판단 근거가 됩니다.

실무에선 이렇게 — 함수 시그니처가 곧 계약입니다 🔴 실무

인자 전달 결정표

이럴 때이렇게
읽기만 · 큰 객체const T& ★ 기본값
읽기만 · int·double·포인터T (값)
안에서 바꿔야 함T& — 함수 이름에 드러내기
없을 수도 있음const T* 또는 std::optional<T>
받아서 보관할 것T 값으로 받고 std::move ★
문자열을 읽기만std::string_view (탭 2)
배열 구간을 읽기만std::span<const T> (C++20)
// '보관할 것'의 관용구 — 복사 한 번만 일어납니다
class Logger {
    std::string name_;
public:
    explicit Logger(std::string name)      // 값으로 받아서
        : name_(std::move(name)) {}        // 옮겨 담기 (탭 2에서 자세히)
};

실수를 컴파일 시점에 잡는 표시들

// ① [[nodiscard]] — 반환값을 무시하면 경고 ★
[[nodiscard]] bool try_connect();
try_connect();          // warning: 반환값이 무시됨 → 실패를 못 보고 지나감

// ② explicit — 의도치 않은 자동 변환 막기
class Timeout {
public:
    explicit Timeout(int sec);   // explicit 없으면 f(5) 가 그냥 컴파일됩니다
};
void f(Timeout t);
f(5);                   // ❌ 에러 (explicit 덕분에)
f(Timeout{5});          // ✅ 의도가 보입니다

// ③ 매개변수 이름으로 뜻을 드러내기 — bool 인자는 특히 위험
save(data, true, false);              // ❌ 뭐가 뭔지 모릅니다
save(data, Overwrite::Yes, Backup::No);  // ✅ enum class 로

enum class Overwrite { No, Yes };     // enum class 는 암묵 변환이 없습니다

// ④ 함수는 '하나의 일'만 — 이름에 and 가 들어가면 쪼갤 신호
void load_and_validate_and_save();    // ❌

한 단계 더 — 컴파일러는 어떤 함수를 고르나 🧬 전문가

오버로드 해석 · ADL

// 후보를 모으고 → 변환 비용이 가장 싼 것을 고릅니다
void f(int);
void f(double);
void f(long);
f('a');      // char → int 가 가장 싼 변환 → f(int)
f(3.14f);    // float → double → f(double)
f(1u);       // 애매하면 ambiguous 에러가 납니다

//  변환 순위: 정확히 일치 > 승격(char→int) > 표준 변환 > 사용자 변환 > 가변인자

// ADL(인자 의존 이름 조회) — namespace 를 안 적어도 찾아지는 이유
namespace geo { struct Point{}; void print(const Point&); }
geo::Point p;
print(p);    // ✅ geo:: 를 안 붙였는데 찾아집니다 (인자 타입의 namespace 를 뒤짐)

//  그래서 이런 관용구가 성립합니다
using std::swap;      // 표준 swap 을 후보에 넣고
swap(a, b);           // ADL 로 사용자 정의 swap 이 있으면 그쪽 우선

// 템플릿보다 일반 함수가 우선입니다 (같은 비용이면)
template<class T> void g(T);
void g(int);
g(1);        // 일반 함수 g(int)

inline · noexcept 의 진짜 의미

// ① inline 은 "인라인 전개하라"는 뜻이 아닙니다 (컴파일러가 알아서 판단)
//    "이 정의가 여러 번역 단위에 있어도 봐 달라"는 링커용 표시입니다 ★
// util.h
inline int square(int x) { return x * x; }   // 헤더에 정의를 두려면 inline 필수
//    빼면 multiple definition 링크 에러

// ② noexcept — "예외를 안 던진다"는 약속
void f() noexcept;
//    지키지 못하면 std::terminate 로 프로그램이 즉시 죽습니다.
//    대신 컴파일러가 예외 처리 코드를 생략해 더 나은 코드를 냅니다.
//    ★ 이동 생성자에 noexcept 가 있어야 vector 재할당이 '복사' 대신 '이동'을 씁니다
//      (없으면 강한 예외 보장을 위해 안전한 복사로 돌아갑니다 — 성능이 크게 갈립니다)

// ③ 반환값 최적화(RVO) — C++17 부터 일부는 의무 사항입니다
std::vector<int> make() {
    std::vector<int> v(1000);
    return v;                 // NRVO — 의무는 아니지만 주요 컴파일러가 대개 생략 (안 되면 이동)
}                             //   의무(C++17)는 return std::vector<int>(1000); 처럼 임시를 반환할 때
auto v = make();              // 호출한 쪽 자리에 바로 만들어집니다

// ⚠ return std::move(v); 라고 쓰면 오히려 RVO 를 막습니다 — 그냥 return v;
실무에서 실제로 물리는 것 — 이동 생성자에 noexcept 를 안 붙여서 vector 가 커질 때마다 전부 복사되는 경우가 흔합니다. static_assert(std::is_nothrow_move_constructible_v<T>); 한 줄로 못 박아 두세요.
정리 — 함수는 파이썬과 거의 같지만 두 가지가 다릅니다. 같은 이름을 인자 모양별로 여러 개 만들 수 있고, 인자를 어떻게 넘기느냐가 복사 비용을 정합니다. 큰 객체를 읽기만 할 땐 const T&, 작은 값은 그냥 값 — 이 두 줄이 90%입니다.
CPP 04

조건 · 반복 · 범위 for

여기는 파이썬과 가장 비슷한 부분입니다. 괄호와 중괄호가 붙었을 뿐, 하는 일은 똑같습니다.

조건과 반복

if (score >= 90) {
    grade = 'A';
} else if (score >= 80) {
    grade = 'B';
} else {
    grade = 'F';
}

// 삼항 연산자 (파이썬의 x if c else y)
int m = (a > b) ? a : b;

// 반복 세 가지
for (int i = 0; i < 10; ++i) { ... }        // 파이썬 range(10)
while (running) { ... }
do { ... } while (cond);                     // 최소 한 번은 실행

// ★ 범위 기반 for — 파이썬 for 와 같은 것. 되도록 이걸 쓰세요
for (const auto& x : items) { ... }          // 읽기만
for (auto& x : items) { x *= 2; }            // 바꾸기
for (auto [key, val] : dict) { ... }         // dict.items()

// 인덱스가 꼭 필요하면 (C++20 이전)
for (std::size_t i = 0; i < items.size(); ++i) ...

switch — 값 하나로 갈라질 때

switch (cmd) {
    case 1:
        start();
        break;              // ★ break 를 빼면 아래로 흘러내립니다
    case 2:
    case 3:                 // 2와 3을 같이 처리
        stop();
        break;
    default:
        unknown();
}

//  의도적으로 흘려보낼 때는 표시를 남깁니다 (C++17)
case 4:
    log();
    [[fallthrough]];
case 5:
    run();
    break;

//  ⚠ switch 는 정수·enum·char 만 됩니다 (문자열 불가)
//    문자열이면 if-else 사슬이나 unordered_map<string, 함수> 를 씁니다
중괄호를 생략하지 마세요 — if (x) doA(); doB(); 에서 doB() 는 조건과 무관하게 항상 실행됩니다. 한 줄짜리라도 { } 를 붙이는 게 팀 규칙인 곳이 많습니다.

이렇게도 쓸 수 있다 — 범위를 좁히는 문법 🟡 중급

if 안에서 변수 만들기 (C++17)

// ❌ it 이 if 밖에도 살아 있어 나중에 실수로 쓰게 됩니다
auto it = m.find(key);
if (it != m.end()) { use(it->second); }

// ✅ if 안에서만 사는 변수 — 파이썬의 왈러스(:=) 와 같은 자리
if (auto it = m.find(key); it != m.end()) {
    use(it->second);
}                         // 여기서 it 은 사라집니다

// switch 도 됩니다
switch (auto st = check(); st) {
    case Status::Ok: ...
}

// 범위 for + 초기화 (C++20)
for (auto rows = load(); const auto& r : rows) { ... }

// 구조적 바인딩으로 map 순회 — 파이썬 items() 와 같은 모양
for (const auto& [name, score] : scores) {
    std::cout << name << ": " << score << "\n";
}

for 문 대신 알고리즘 ★

#include <algorithm>
#include <numeric>

// 파이썬                        C++
// sum(v)                        std::accumulate / std::reduce
// max(v)                        std::max_element
// sorted(v)                     std::sort (제자리) / ranges::sort
// any(...)                      std::any_of
// [f(x) for x in v]             std::transform
// [x for x in v if p(x)]        std::copy_if / erase-remove

int total = std::accumulate(v.begin(), v.end(), 0);
auto mx   = *std::max_element(v.begin(), v.end());
std::sort(v.begin(), v.end());
bool has  = std::any_of(v.begin(), v.end(), [](int x){ return x < 0; });

// C++20 ranges — 훨씬 짧아집니다
#include <ranges>
std::ranges::sort(v);
auto evens = v | std::views::filter([](int x){ return x % 2 == 0; })
               | std::views::transform([](int x){ return x * x; });
for (int x : evens) ...        // 여기서야 실제로 계산됩니다 (지연 평가)
왜 알고리즘을 쓰나 — 짧아서가 아니라 이름이 의도를 말해 주기 때문입니다. std::any_of 는 “하나라도 있으면”이라고 읽히고, 손으로 쓴 for 문은 읽는 사람이 루프를 따라가며 해석해야 합니다. 버그도 대개 손으로 쓴 쪽에서 납니다.

실무에선 이렇게 — 순회 중 컨테이너를 건드리면 🔴 실무

반복자 무효화 — C++ 에서 가장 자주 나는 크래시 ★

std::vector<int> v{1,2,3,4,5};

// ❌ 순회 중에 추가·삭제 — 파이썬은 에러라도 났지만 C++ 는 그냥 죽습니다
for (auto& x : v) {
    if (x == 3) v.push_back(99);   // 재할당되면 x 가 가리키던 메모리가 사라짐
}                                   // → 세그폴트 또는 조용히 이상한 값 (UB)

// ✅ ① 지울 때 — erase-remove 관용구 (C++20 은 std::erase)
std::erase_if(v, [](int x){ return x % 2 == 0; });   // 짝수 전부 삭제

// ✅ ② 반복자 루프에서 지우기 — erase 가 '다음 반복자'를 돌려줍니다
for (auto it = v.begin(); it != v.end(); ) {
    if (*it < 0) it = v.erase(it);   // ★ ++it 을 하면 안 됩니다
    else         ++it;
}

// ✅ ③ 추가할 것은 따로 모았다가 루프가 끝난 뒤에
std::vector<int> to_add;
for (const auto& x : v) if (need(x)) to_add.push_back(make(x));
v.insert(v.end(), to_add.begin(), to_add.end());

어떤 연산이 무효화하나

컨테이너무효화되는 때
vector재할당되면 전부(push_back·insert·resize) · 지운 자리 뒤쪽
deque가운데 삽입·삭제 시 전부. 양끝은 반복자만
list지운 그것만 — 나머지는 안전
map·set지운 그것만
unordered_map리해시되면 전부(원소 추가 시) · 참조는 유지
실무 대처 — 순회 중 수정이 필요하면 인덱스로 돌거나(vector), 수정 목록을 따로 모으는 방식이 안전합니다. 그리고 개발 중에는 디버그 모드의 반복자 검사를 켜 두세요 — MSVC 는 기본으로, libstdc++ 는 -D_GLIBCXX_DEBUG 로 켜면 UB 대신 명확한 에러 메시지가 나옵니다.

한 단계 더 — 루프는 어떻게 기계어가 되나 🧬 전문가

컴파일러가 실제로 하는 변형

int sum(const std::vector<int>& v) {
    int s = 0;
    for (int x : v) s += x;
    return s;
}
// -O2 로 컴파일하면 이렇게 바뀝니다
//   · 루프 언롤링   — 한 바퀴에 4~8개씩 처리
//   · 벡터화(SIMD)  — paddd 명령 하나로 정수 4~8개를 동시에 더함
//   · 경계 검사 제거 — size() 를 매 바퀴 부르지 않고 미리 계산
//   결과적으로 파이썬 루프 대비 100배 이상 벌어집니다 (⚡ 성능 탭)

// 확인하는 법
//   godbolt.org 에 붙여넣고 -O2 → 우측 어셈블리에서 xmm/ymm 레지스터를 찾기
//   또는 벡터화 리포트
//     g++ -O3 -fopt-info-vec-missed sum.cpp
//     clang++ -O3 -Rpass-missed=loop-vectorize sum.cpp

벡터화를 막는 것들

// ① 루프 안의 함수 호출 (인라인이 안 되면)
for (int x : v) s += slow_call(x);      // 벡터화 불가

// ② 반복마다 달라지는 분기
for (int x : v) if (cond(x)) s += x;    // 조건부 이동으로 바뀌기도 하지만 대개 손해

// ③ 포인터가 겹칠 수 있다는 가능성 (aliasing) ★
void add(int* out, const int* a, const int* b, int n) {
    for (int i = 0; i < n; ++i) out[i] = a[i] + b[i];
}
//   컴파일러는 out 과 a 가 같은 메모리일 수 있다고 가정해 보수적으로 컴파일합니다
//   → 겹치지 않는다고 알려 주면 벡터화됩니다
void add(int* __restrict out, const int* a, const int* b, int n);
//   (표준은 아니지만 gcc·clang·msvc 모두 지원)

// ④ 데이터 의존성
for (int i = 1; i < n; ++i) v[i] += v[i-1];   // 앞 결과가 필요 → 순차

// ⑤ float 연산 순서 — 결합법칙이 성립하지 않아 기본적으로 재배치 금지
//    -ffast-math 로 허용할 수 있지만 정확도를 포기하는 것이라 신중하게
순서를 지키세요 — 이런 것들은 프로파일러가 그 루프를 가리킨 뒤에 볼 내용입니다. 대부분의 실제 병목은 알고리즘과 메모리 접근 패턴이지 벡터화 여부가 아닙니다.
정리 — 조건·반복은 파이썬과 거의 같습니다. for (const auto& x : v) 하나만 손에 익히면 인덱스 실수가 사라지고, 순회 중에 컨테이너를 건드리지 않는 것이 C++ 에서 가장 중요한 규칙입니다 — 파이썬은 에러를 냈지만 C++ 는 그냥 죽거나 이상한 값이 됩니다.
CPP 05

포인터와 참조 — 여기가 진짜 관문

파이썬 변수는 전부 포인터였습니다. 다만 그 사실이 감춰져 있었을 뿐입니다. C++ 는 값을 직접 다룰지, 주소를 다룰지 내가 고릅니다. 그게 속도의 대가로 치르는 값입니다.

값 · 포인터 · 참조가 메모리에서 실제로 놓이는 자리 스택 — 함수가 끝나면 자동으로 사라진다 int n 30 — 실제 값이 여기 있다 int copy 10 — n 과 완전히 별개의 상자 int* p 0x7ffd... — n 의 주소 vector v {시작 주소, 크기, 용량} 힙 — 직접 또는 소멸자가 정리해야 사라진다 new int(5) delete 를 빠뜨리면 누수 원소 1000개 vector 가 소유합니다 v 가 사라질 때 함께 해제 참조 T& 는 이 그림에 칸이 없습니다 — 대개 컴파일러가 원본을 직접 쓰도록 바꿔 버리기 때문입니다.
"왜 안 바뀌지?"의 답은 늘 이 그림에 있습니다 — 값을 넘겼으면 별개의 상자가 만들어집니다.

값 · 포인터 · 참조 세 가지

int n = 10;

int  copy = n;    // ① 값   — 별개의 상자. copy 를 바꿔도 n 은 그대로
int* p    = &n;   // ② 포인터 — n 의 '주소'를 담음  (& = 주소 구하기)
int& r    = n;    // ③ 참조  — n 의 '다른 이름'

*p = 20;          // 포인터가 가리키는 곳에 쓰기 (* = 역참조)
r  = 30;          // 참조는 그냥 쓰면 원본이 바뀝니다
std::cout << n;   // 30

// 포인터는 '가리키는 대상을 바꿀 수 있고, 아무것도 안 가리킬 수 있습니다'
p = nullptr;      // ✅ 비어 있음을 표현 가능
int& r2;          // ❌ 참조는 반드시 만들 때 대상이 있어야 합니다
r = other;        //   (이건 r 이 other 를 가리키게 하는 게 아니라
                  //    n 에 other 의 값을 복사하는 것입니다)
파이썬으로 번역하면 — 파이썬의 a = b 는 항상 ② 포인터 복사였습니다. 그래서 리스트를 넘기면 원본이 바뀌었죠. C++ 는 기본이 ① 값 복사라 반대로 생각해야 합니다. “왜 안 바뀌지?”가 나오면 십중팔구 값으로 넘긴 것입니다.

언제 무엇을 쓰나 — 한 장 요약

참조 T&포인터 T*
빈 상태불가능nullptr
대상 바꾸기불가능가능
문법값처럼 r.f()p->f()
산술 연산없음p+1 가능
주 용도함수 인자★선택적 값 · 배열 · C API
// 기본 규칙 — 참조로 되면 참조를 쓰세요 (널 검사가 필요 없어집니다)
void print(const Config& c);        // ✅ 항상 있어야 하는 것
void print(const Config* c) {       // 없을 수도 있는 것
    if (!c) return;                 //   → 호출부마다 널 검사가 생깁니다
}

// 화살표는 (*p).x 의 줄임말입니다
struct P { int x; };
P  v;   P* p = &v;
v.x;    p->x;    (*p).x;    // 뒤 둘은 같은 것

이렇게도 쓸 수 있다 — 스택과 힙 🟡 중급

변수는 어디에 놓이나

void f() {
    int a = 1;                    // 스택 — 함수가 끝나면 자동으로 사라짐
    std::vector<int> v(1000);     // v 자체는 스택, 내용 1000개는 힙

    int* p = new int(5);          // 힙 — 내가 delete 할 때까지 살아 있음
    delete p;                     // ⚠ 빠뜨리면 누수
}                                 // a, v 는 여기서 자동 정리 (v 의 힙 메모리도)

//  스택: 빠름 · 자동 정리 · 크기 제한(보통 1~8MB) · 함수 끝나면 소멸
//  힙  : 느림 · 수동 관리 · 크기 제한 사실상 없음 · 내가 놓을 때까지 유지

// ★ 현대 C++ 의 답 — new/delete 를 직접 쓰지 않습니다
auto p2 = std::make_unique<int>(5);   // 알아서 해제됩니다 (탭 2 스마트 포인터)

// 큰 배열을 스택에 잡으면 터집니다
void g() {
    int big[10'000'000];          // ❌ 스택 오버플로 (약 40MB)
    std::vector<int> ok(10'000'000);  // ✅ 내용은 힙으로 갑니다
}

배열과 포인터는 사실상 같은 것

int arr[5] = {1,2,3,4,5};
int* p = arr;            // 배열 이름은 첫 원소의 주소로 '붕괴'합니다

p[2];        // 3    — 배열처럼 씁니다
*(p + 2);    // 3    — 같은 뜻 (포인터 산술)
//  p + 2 는 '2바이트 뒤'가 아니라 '원소 2개 뒤' 입니다 (타입 크기만큼)

// ⚠ 붕괴하면 크기 정보가 사라집니다 — C 시절 버그의 근원
void f(int* a) { sizeof(a); }    // 8 (포인터 크기) — 배열 길이가 아닙니다!
void g(int arr[5]) { }           // 이것도 사실 int* 입니다 (5는 무시됨)

// ✅ C++20 — 포인터와 길이를 한 묶음으로
#include <span>
void h(std::span<const int> a) {
    a.size();                    // 길이가 따라옵니다
    for (int x : a) ...          // 범위 for 도 됩니다
}
h(arr);                          // 배열도
h(vec);                          // vector 도 그냥 넘어갑니다 ★
std::span 은 실무에서 매우 유용합니다 — 함수가 vector·배열·string 어느 쪽이든 복사 없이 받을 수 있게 됩니다. 파이썬 모듈을 만들 때 numpy 버퍼를 받는 자리에도 그대로 쓰입니다(🐍 탭).

실무에선 이렇게 — 원시 포인터는 '빌려 보는 것'만 🔴 실무

소유권을 코드로 말하기 ★

// 현대 C++ 의 규칙 — '누가 해제 책임을 지는가'를 타입으로 표현합니다
std::unique_ptr<T>   // 나 혼자 소유 (해제 책임 있음)   ★ 기본값
std::shared_ptr<T>   // 여럿이 나눠 소유 (마지막이 해제)
std::weak_ptr<T>     // 보기만 (순환 참조 끊기)
T&                   // 반드시 있는 것을 빌려 봄        ★ 함수 인자
T*                   // 없을 수도 있는 것을 빌려 봄
T                    // 값 (내 복사본)

// ❌ 옛 코드에서 보이는 형태 — 이걸 받은 사람은 delete 해야 하는지 알 수 없습니다
Widget* create();

// ✅ 의도가 드러납니다
std::unique_ptr<Widget> create();     // "받는 쪽이 소유합니다"
const Widget& get() const;            // "내 것을 잠깐 보여 줍니다"

// 원시 포인터를 넘길 때는 반드시 이 질문에 답하세요
//   ① 널이 될 수 있나?          → 안 되면 참조로
//   ② 해제 책임이 넘어가나?      → 넘어가면 unique_ptr 로
//   ③ 대상이 나보다 오래 사나?   → 아니면 그 포인터는 언젠가 죽습니다

댕글링 — 사라진 것을 가리키기

// ❌ ① 지역 변수의 주소를 돌려주기
const std::string& bad() {
    std::string s = "임시";
    return s;                 // s 는 함수가 끝나며 사라집니다 → UB
}

// ❌ ② vector 가 재할당되면 이전 포인터·참조가 전부 죽습니다
std::vector<int> v{1,2,3};
int& first = v[0];
v.push_back(4);               // 재할당될 수 있음
first = 99;                   // → 이미 해제된 메모리에 쓰기

// ❌ ③ 임시 객체에 참조 붙이기
std::string_view sv = std::string("임시") + "붙이기";
//   식이 끝나면 임시 문자열이 사라져 sv 는 쓰레기를 가리킵니다

// ✅ 진단 — 컴파일러와 도구가 잡아 줍니다
//   g++ -Wall           → warning: reference to local variable returned
//   -fsanitize=address  → 실행 시 정확한 위치를 알려 줍니다 ★
//   clang-tidy          → 정적 분석으로 미리

// ✅ 습관 — "이 참조가 가리키는 것이 나보다 오래 사는가?"를
//    포인터·참조를 저장할 때마다 자문하세요. 지역 변수면 값으로 복사하세요.
파이썬에서 넘어온 사람이 가장 크게 다치는 지점 — 파이썬은 참조가 살아 있으면 객체가 안 죽었습니다(참조 카운팅). C++ 는 참조가 있든 말든 수명이 끝나면 사라집니다. 수명을 관리하는 것이 나라는 게 두 언어의 가장 큰 차이입니다.

한 단계 더 — 널 역참조가 왜 '그냥 크래시'가 아닌가 🧬 전문가

UB 는 최적화와 만나 코드를 지웁니다 ★

void f(int* p) {
    int x = *p;          // ① 역참조 — 컴파일러: "p 는 널이 아니구나"
    if (p == nullptr)    // ② 그러므로 이 조건은 '항상 거짓'
        return;          // ③ → 이 검사 자체가 통째로 삭제됩니다
    use(x);
}
//  결과: 널을 넘기면 크래시가 아니라 '검사가 없는 채로 그대로 진행'
//  → 디버그 빌드에서는 멀쩡하고 릴리즈에서만 터지는 대표적 원인 ★

// ✅ 순서를 지키면 됩니다 — 검사를 먼저
void g(int* p) {
    if (!p) return;
    use(*p);
}

// 같은 원리의 유명한 사례들
int n = INT_MAX; if (n + 1 < n) { /* 오버플로 검사 */ }   // 통째로 삭제됨
//   "부호 있는 오버플로는 UB → 일어날 리 없다 → 조건은 거짓"

// 확인하는 법
//   -fsanitize=undefined  로 빌드하고 실행하면 그 자리에서 알려 줍니다
//   godbolt 에서 -O2 어셈블리를 보면 검사가 사라진 것이 눈에 보입니다

포인터를 다룰 때의 규칙들

// ① 배열 범위를 벗어난 포인터는 '계산'만 해도 UB
int a[5];
int* p = a + 5;      // ✅ 마지막 다음 자리까지는 합법 (end 반복자)
int* q = a + 6;      // ❌ 계산만 해도 UB
*p;                  // ❌ 당연히 역참조도 불가

// ② 서로 다른 객체의 포인터끼리 비교·뺄셈은 의미가 없습니다
int x, y;
&x < &y;             // 결과가 정해져 있지 않습니다

// ③ 타입을 우회한 재해석(strict aliasing 위반)
float f = 1.0f;
int i = *(int*)&f;              // ❌ UB — 컴파일러가 둘이 무관하다고 가정
int j = std::bit_cast<int>(f);  // ✅ C++20 · 안전하고 비용도 0
std::memcpy(&j, &f, 4);         // ✅ 예전부터 쓰던 합법적 방법

// ④ 정렬되지 않은 주소 접근
//    구조체를 바이트 버퍼에서 그냥 캐스팅해 읽는 코드는 위험합니다
//    → memcpy 로 복사해 오세요 (컴파일러가 최적화로 없애 줍니다)
Header h;
std::memcpy(&h, buf, sizeof h);   // ✅ 이식성 있고 UB 아님

// ⑤ 널 포인터에 0을 더하는 것은 C++ 에서는 합법(결과도 널)이지만 C 에서는 UB 입니다
실무 결론 — 이 규칙들을 다 외울 필요는 없습니다. -fsanitize=address,undefined 를 개발·CI 빌드에 켜 두면 대부분 실행 즉시 정확한 위치와 함께 잡힙니다(🛠️ 빌드 탭). 안 켜면 몇 달 뒤 운영에서 재현 안 되는 크래시로 돌아옵니다.
정리 — &n 은 주소 구하기, *p 는 그 주소의 값 꺼내기입니다. 함수 인자는 참조(const T&)를 기본으로 쓰고, new·delete 는 직접 쓰지 마세요 — 스마트 포인터가 대신합니다(탭 2). 가장 중요한 질문 하나: “이게 가리키는 대상이 아직 살아 있나?”
CPP 06

vector · string — 파이썬 list·str 의 자리

C++ 컨테이너는 수십 가지지만, 실무의 90%는 vector 입니다. 나머지는 “언제 예외인가”만 알면 됩니다.

vector 는 어떻게 늘어나나 — reserve 가 왜 중요한가 push_back 을 20번 하면 재할당이 5번 — 막대 길이가 곧 용량입니다 1개째 용량 1 · 이동 0 2개째 용량 2 · 1개 이동 3개째 용량 4 · 2개 이동 5개째 용량 8 · 4개 이동 9개째 용량 16 · 8개 이동 17개째 용량 32 · 16개 이동 0 8 16 24 32 용량(개) 20번 넣는 동안 실제로 옮긴 원소는 1+2+4+8+16 = 31개 — 넣은 개수보다 많습니다 재할당 한 번에 벌어지는 세 가지 ① 새 메모리 확보 보통 기존의 두 배 크기 그동안 둘 다 살아 있다 ② 전부 옮긴다 이동 가능하면 이동 아니면 전부 복사 ③ 옛 메모리 해제 기존 포인터 · 이터레이터 참조가 전부 무효가 된다 크기를 대충이라도 알면 reserve(n) 한 줄로 재할당을 0번으로 만들 수 있습니다 — 가장 값싼 최적화입니다.
③번이 진짜 함정입니다 — push_back 하는 동안 붙잡아 둔 포인터는 언제든 무효가 됩니다.

vector — 파이썬 list

#include <vector>

std::vector<int> v;              // 빈 것
std::vector<int> v2{1, 2, 3};    // 초기값
std::vector<int> v3(100, 0);     // 0 이 100개
std::vector<std::string> names;  // 문자열 목록

v.push_back(10);          // append
v.emplace_back(10);       // 같은 일 + 객체를 '그 자리에서' 만듦 (복사 없음)
v.pop_back();             // 마지막 제거
v.size();  v.empty();
v[0];                     // 인덱스 (범위 검사 없음 ★)
v.at(0);                  // 범위를 벗어나면 예외를 던짐
v.front();  v.back();
v.clear();

for (const auto& x : v) ...        // 순회

// 정렬·검색
#include <algorithm>
std::sort(v.begin(), v.end());
bool found = std::binary_search(v.begin(), v.end(), 42);   // 정렬돼 있을 때
v[10] 은 범위를 검사하지 않습니다 — 파이썬처럼 IndexError 가 나지 않고 그냥 엉뚱한 메모리를 읽습니다(UB). 개발 중에는 at() 을 쓰거나 AddressSanitizer 를 켜 두세요. 성능이 중요한 자리에서만 [] 를 씁니다.

string — 파이썬 str

#include <string>
using namespace std::string_literals;

std::string s = "안녕";
s += " 하세요";              // 이어붙이기
s.size();                    // ⚠ 글자 수가 아니라 '바이트 수'입니다
                             //   "안녕" → 6 (UTF-8 에서 한글은 3바이트)
s.substr(0, 3);              // 부분 문자열 (바이트 기준!)
s.find("하세");               // 못 찾으면 std::string::npos
s.replace(0, 3, "반갑");
s.empty();  s.clear();

// 숫자 변환
int  n = std::stoi("42");
double d = std::stod("3.14");
std::string t = std::to_string(42);

// 포맷 (C++20) — 파이썬 f-string 의 자리 ★
#include <format>
std::string msg = std::format("{}님 점수 {:.1f}", name, 92.567);
//   → "김민준님 점수 92.6"
//   C++23 이면 std::print("{}\n", msg); 로 출력까지
한글을 다룰 때 — std::string 은 바이트 배열이라 s[0] 이 “안”이 아니라 그 첫 바이트입니다. 바이트 단위로 자르면 글자가 깨집니다. 대개는 자르지 말고 통째로 넘기는 설계가 답이고, 정말 글자 단위가 필요하면 ICU 같은 라이브러리를 씁니다.

이렇게도 쓸 수 있다 — 컨테이너 고르기 🟡 중급

파이썬 자료구조와의 대응

#include <map>
#include <unordered_map>
#include <set>

// dict → unordered_map (해시) · map (정렬됨)
std::unordered_map<std::string, int> scores;
scores["민준"] = 90;
scores.at("민준");                  // 없으면 예외
if (auto it = scores.find("서연"); it != scores.end())
    use(it->second);

// ⚠ scores["없는키"] 는 조회가 아니라 '0으로 만들어 넣기' 입니다 ★
//    읽기만 할 때는 find 나 at 을 쓰세요 (const 객체에는 [] 자체가 안 됩니다)

// set → unordered_set · set
std::set<int> uniq{3, 1, 2};       // 자동 정렬 + 중복 제거

// C++20 — 있는지 확인이 한결 읽기 쉬워졌습니다
if (scores.contains("민준")) ...

// 순회
for (const auto& [name, score] : scores) ...

무엇을 언제 쓰나

쓸 것이럴 때
vector기본값. 순서가 있고 끝에서 넣고 뺄 때
array<T,N>크기가 컴파일 시점에 정해진 고정 배열
unordered_map키로 빠르게 찾기 O(1)
map키 순서대로 순회해야 할 때
deque양쪽 끝에서 넣고 빼기
list중간 삽입·삭제가 아주 잦고 반복자를 유지해야 할 때 (드묾)
의외의 사실 — “중간 삽입이 많으니 list” 는 대개 틀린 선택입니다. vector 는 메모리가 연속이라 캐시에 잘 맞아, 원소 수천 개까지는 중간 삽입조차 vector 가 더 빠른 경우가 많습니다. 재보지 않고 자료구조를 바꾸지 마세요(⚡ 성능 탭).

실무에선 이렇게 — 복사와 재할당을 통제하기 🔴 실무

reserve 하나로 몇 배가 갈립니다 ★

// vector 는 꽉 차면 '더 큰 메모리를 새로 잡고 전부 옮깁니다'
std::vector<int> v;
for (int i = 0; i < 1'000'000; ++i)
    v.push_back(i);          // 재할당이 약 20번, 그때마다 전체 복사

// ✅ 크기를 알면 미리 잡아 두세요
std::vector<int> v;
v.reserve(1'000'000);        // 한 번만 할당
for (int i = 0; i < 1'000'000; ++i)
    v.push_back(i);

// reserve 는 '용량'만, resize 는 '실제 원소'까지 만듭니다
v.reserve(100);   // size()==0, capacity()==100
v.resize(100);    // size()==100 (0으로 채워짐)

// 객체를 담을 때는 emplace_back — 임시 객체를 만들었다 복사하지 않습니다
struct User { std::string name; int age; };
users.push_back(User{"민준", 30});   // 만들고 → 옮김
users.emplace_back("민준", 30);      // ★ 그 자리에서 바로 생성

// 다 쓴 메모리를 실제로 돌려주기
v.clear();                    // size 만 0, 메모리는 유지됩니다
std::vector<int>().swap(v);   // 통째로 반납 (또는 shrink_to_fit)

복사가 몰래 일어나는 자리

// ① 범위 for 에서 & 를 빠뜨림
for (auto s : names)          // ❌ 문자열마다 복사
for (const auto& s : names)   // ✅

// ② map 순회에서 타입을 잘못 적기
std::map<std::string, Big> m;
for (const std::pair<std::string, Big>& p : m)   // ❌ 실제 타입은
                                                  //    pair<const string, Big>
                                                  //    → 매번 변환 복사가 생깁니다
for (const auto& p : m)                           // ✅ auto 를 쓰세요

// ③ 함수가 값으로 받기 (앞 섹션)
void f(std::vector<int> v);        // ❌
void f(const std::vector<int>& v); // ✅

// ④ 반환값을 참조에 안 받기
const auto& ref = get_vector();    // 임시의 수명이 연장됩니다 (합법)
auto copy = get_vector();          // 대개 RVO 로 복사 없음 — 이건 괜찮습니다

// 찾는 법 — 복사 생성자에 로그를 심어 보면 바로 보입니다
struct Trace {
    Trace(const Trace&) { std::puts("복사됨!"); }
};
파이썬 모듈을 만들 때 특히 중요합니다 — 파이썬 list 를 받아 std::vector 로 바꾸는 순간 전체 복사가 일어납니다. 큰 데이터는 numpy 배열로 받아 복사 없이 처리하는 게 정석입니다(🐍 탭 y06).

한 단계 더 — vector 와 string 의 실제 구현 🧬 전문가

성장 배수와 상각 O(1)

std::vector<int> v;
for (int i = 0; i < 20; ++i) {
    if (v.capacity() != v.size()) { }
    v.push_back(i);
    std::cout << v.size() << "/" << v.capacity() << "\n";
}
//  libstdc++ · libc++ : 2배씩   (1,2,4,8,16,...)
//  MSVC              : 1.5배씩  (1,2,3,4,6,9,...)
//  → 표준은 배수를 정하지 않습니다. '상각 O(1)'만 요구합니다.
//    1.5배는 이전에 반납한 메모리를 재사용할 수 있다는 장점이 있습니다.

// 재할당 시 이동이냐 복사냐 — noexcept 가 가릅니다 ★
struct A { A(A&&) noexcept; };      // 이동 (빠름)
struct B { B(const B&); B(B&&); };  // 이동이 던질 수 있음 → 복사로 후퇴
//   (복사 생성자가 없으면 noexcept 가 아니어도 이동을 씁니다)
static_assert(std::is_nothrow_move_constructible_v<A>);

// 원소가 100만 개인 vector<string> 을 재할당하면
//   noexcept 있음: 포인터만 옮김 (수 ms)
//   없음        : 문자열 전부 복사 (수백 ms) ← 이 차이가 실제로 납니다

작은 문자열 최적화(SSO)

std::string s = "짧음";
//  짧은 문자열은 '힙에 할당하지 않고 string 객체 안에 그대로' 담습니다
//    libstdc++ : 15바이트까지
//    libc++    : 22바이트까지
//    MSVC      : 15바이트까지
//  → 짧은 문자열은 할당이 0번. 그래서 로그·키 문자열이 생각보다 빠릅니다

sizeof(std::string);      // 32 (libstdc++) — 포인터 하나가 아닙니다

// 확인하는 법 — 전역 operator new 를 가로채 할당 횟수를 세어 봅니다
static int alloc_count = 0;
void* operator new(std::size_t n) { ++alloc_count; return std::malloc(n); }
void  operator delete(void* p) noexcept { std::free(p); }

// 실무 함의
//   · 짧은 키를 map 에 넣는 것은 생각보다 싸다
//   · 임계 구간에서 문자열을 만들면 길이에 따라 성능이 '계단식'으로 변한다
//   · 읽기만 할 거면 string_view 로 아예 만들지 않는 게 최선 (탭 2 m11)

// 메모리를 실제로 재 보기
v.capacity() * sizeof(int) + sizeof(v);   // vector 가 쓰는 대략의 바이트
여기까지 알면 — “왜 이 코드가 특정 입력에서만 갑자기 느려지나”를 재할당·SSO 경계로 설명할 수 있게 됩니다. C++ 성능 논의에서 근거를 대는 사람과 감으로 말하는 사람이 여기서 갈립니다.
정리 — vector 가 파이썬 list, string 이 str, unordered_map 이 dict 입니다. 거의 항상 vector 가 정답이고, 크기를 알면 reserve 를 부르고, 순회할 땐 const auto& 를 붙이세요 — 이 셋이면 대부분의 성능 문제가 예방됩니다.
CPP 07

클래스 — 소멸자가 결정적 차이

문법은 파이썬 클래스와 비슷합니다. 다른 건 하나 — 객체가 사라질 때 자동으로 불리는 함수(소멸자)가 있습니다. C++ 의 자원 관리 전체가 여기서 나옵니다.

여는 중괄호와 닫는 중괄호 사이 — C++ 가 갈라지는 지점 ① C++ — 닫는 중괄호에서 즉시, 생성의 역순으로 ① File f 생성 ② Lock l 생성 } 를 만남 → l 소멸 → f 소멸 시점이 정확히 정해져 있습니다 — 예외가 나도 똑같이 불립니다 ② 자바 · 파이썬 — GC 가 "언젠가" ① 객체 생성 ② 참조가 사라짐 ③ GC 가 돌 때 회수 — 언제인지 모름 파일 · 락 · 소켓은 코드에서 직접 닫아야 합니다 그래서 C++ 에는 finally 가 없습니다 정리해야 할 것을 객체의 소멸자에 넣으면 언어가 알아서 부릅니다 — 이 방식의 이름이 RAII 입니다
"소멸 시점이 정해져 있다"는 것이 C++ 의 가장 큰 장점이자 스마트 포인터가 성립하는 근거입니다.

기본 클래스

class Counter {
public:                              // 밖에서 쓸 수 있는 것
    Counter() = default;             // 기본 생성자
    explicit Counter(int start)      // 생성자 (파이썬 __init__)
        : n_(start) {}               // ★ 초기화 리스트 — 본문보다 이쪽

    ~Counter() {                     // 소멸자 — 사라질 때 자동 호출
        std::cout << "정리됨\n";
    }

    void add(int d)       { n_ += d; }
    int  value() const    { return n_; }   // 안 바꾸는 함수엔 const

private:                             // 밖에서 못 건드림 (기본값)
    int n_ = 0;                      // 멤버는 뒤에 _ 를 붙이는 관례가 흔합니다
};

Counter c{10};        // 스택에 생성
c.add(5);
c.value();            // 15
// } 를 만나면 여기서 ~Counter() 가 자동으로 불립니다 ★
파이썬과의 대응 — __init__ = 생성자, self = this(생략 가능), __del__ = 소멸자입니다. 다만 파이썬 __del__ 은 언제 불릴지 보장이 없었고, C++ 소멸자는 수명이 끝나는 그 지점에서 반드시 불립니다. 이 “반드시”가 다음 섹션들의 토대입니다.

struct 와 class

struct Point { int x; int y; };   // 기본 public
class  Impl  { int x; };          // 기본 private
//  문법상 이 차이 하나뿐입니다.

// 관례 — 데이터만 담으면 struct, 동작과 불변식이 있으면 class
struct Config {                   // 그냥 값 묶음
    std::string host;
    int port = 5432;
};
Config c{.host = "db.local", .port = 5433};   // 지정 초기화 (C++20)

// 값 묶음에 공짜로 붙는 것들
struct Vec2 {
    double x{}, y{};
    auto operator<=>(const Vec2&) const = default;  // 비교 전부 자동 (C++20)
    bool operator==(const Vec2&) const = default;
};
Vec2 a{1,2}, b{1,2};
a == b;              // true — 직접 안 써도 됩니다

// 파이썬 __repr__ 자리는 operator<< 입니다
std::ostream& operator<<(std::ostream& os, const Vec2& v) {
    return os << "Vec2(" << v.x << ", " << v.y << ")";
}

이렇게도 쓸 수 있다 — 복사될 때 무슨 일이 일어나나 🟡 중급

컴파일러가 몰래 만들어 주는 함수들

class Box { std::vector<int> data_; };

// 아무것도 안 써도 컴파일러가 이 여섯 개를 만들어 줍니다
Box();                          // 기본 생성자
Box(const Box&);                // 복사 생성자
Box& operator=(const Box&);     // 복사 대입
Box(Box&&);                     // 이동 생성자   (탭 2)
Box& operator=(Box&&);          // 이동 대입
~Box();                         // 소멸자

Box a;
Box b = a;      // 복사 생성 — data_ 가 통째로 복사됩니다
b = a;          // 복사 대입

// 복사를 막고 싶으면 명시적으로 지웁니다
class Handle {
public:
    Handle(const Handle&) = delete;             // 복사 금지 ★
    Handle& operator=(const Handle&) = delete;
    Handle(Handle&&) = default;                 // 이동은 허용
    Handle& operator=(Handle&&) = default;
};
//  파일 핸들·소켓·락처럼 '둘이 같이 가지면 안 되는 것'에 씁니다

생성 순서와 초기화 리스트

class Service {
    std::string name_;
    Logger      log_;
    int         id_;
public:
    Service(std::string name, int id)
        : name_(std::move(name)),   // ★ 선언 순서대로 초기화됩니다
          log_(name_),              //   (여기 적은 순서가 아니라!)
          id_(id) {}
};
//  선언 순서와 다르게 적으면 -Wreorder 경고가 납니다. 순서를 맞추세요.

// ❌ 본문에서 대입하면 '기본 생성 후 대입' — 두 번 일하는 것입니다
Service(std::string name) {
    name_ = name;         // 빈 문자열을 만든 뒤 다시 대입
}
// ✅ 초기화 리스트는 '처음부터 그 값으로' 만듭니다
Service(std::string name) : name_(std::move(name)) {}

// const 멤버와 참조 멤버는 초기화 리스트로만 가능합니다
class Holder {
    const int id_;
    Logger&   log_;
public:
    Holder(int id, Logger& log) : id_(id), log_(log) {}   // 이 방법뿐
};
기본 멤버 초기화를 먼저 쓰세요 — int n_ = 0; 처럼 선언 자리에 값을 두면 생성자가 여러 개여도 빠뜨릴 수 없습니다. 생성자마다 다른 값이 필요할 때만 초기화 리스트로 덮어씁니다.

실무에선 이렇게 — 소멸자로 자원을 묶습니다 (RAII 예고) 🔴 실무

파이썬 with 가 항상 켜져 있는 셈

// ❌ 손으로 정리하면 return·예외 경로마다 빠뜨립니다
void f() {
    FILE* fp = std::fopen("a.txt", "r");
    if (!ok()) return;              // ← fclose 를 안 했습니다 (누수)
    process(fp);                    // ← 여기서 예외가 나도 누수
    std::fclose(fp);
}

// ✅ 소멸자에 정리를 넣으면 어떤 경로로 빠져나가도 불립니다 ★
class File {
    FILE* fp_ = nullptr;
public:
    explicit File(const char* path) : fp_(std::fopen(path, "r")) {
        if (!fp_) throw std::runtime_error("열기 실패");
    }
    ~File() { if (fp_) std::fclose(fp_); }      // 여기서 정리

    File(const File&) = delete;                 // 복사 금지 (두 번 닫힘 방지)
    File& operator=(const File&) = delete;

    FILE* get() const { return fp_; }
};

void g() {
    File f("a.txt");
    if (!ok()) return;      // ✅ 여기서도 닫힘
    process(f.get());       // ✅ 예외가 나도 닫힘
}                           // ✅ 정상 종료도 닫힘

0 의 법칙 · 5 의 법칙 ★

// ★ 0 의 법칙 — 소멸자·복사·이동을 아예 안 쓰는 것이 최선입니다
class Good {
    std::vector<int>        data_;    // 알아서 정리됨
    std::unique_ptr<Impl>   impl_;    // 알아서 정리됨
    std::string             name_;
    // 아무것도 안 써도 완벽하게 동작합니다 ★
};

// 5 의 법칙 — 하나라도 직접 쓰면 나머지도 정의해야 합니다
//   소멸자 / 복사 생성 / 복사 대입 / 이동 생성 / 이동 대입
class Raw {
    int* p_ = new int(0);
public:
    ~Raw() { delete p_; }        // 소멸자만 썼더니
};
Raw a, b = a;                    // 복사되면 같은 포인터를 둘이 delete → 크래시 ★

// 그래서 실무 규칙은 이것입니다
//   ① 자원은 unique_ptr·vector·string 같은 '이미 관리되는 타입'에 맡긴다
//   ② 정말 직접 관리해야 하면 그 클래스는 '자원 하나만' 감싸고
//      다섯 개를 모두 명시한다 (또는 복사를 delete 한다)
이 규칙 하나로 C++ 메모리 버그의 대부분이 사라집니다. “delete 를 어디서 부르지?”라는 고민이 나온다면 이미 잘못된 길에 들어선 것입니다 — 그 자원을 감쌀 타입을 먼저 만드세요(탭 2).

한 단계 더 — 객체 하나의 실제 비용 🧬 전문가

언제 무엇이 불리는지 눈으로 보기

struct Trace {
    Trace()                 { std::puts("생성"); }
    Trace(const Trace&)     { std::puts("복사"); }
    Trace(Trace&&) noexcept { std::puts("이동"); }
    Trace& operator=(const Trace&) { std::puts("복사대입"); return *this; }
    Trace& operator=(Trace&&) noexcept { std::puts("이동대입"); return *this; }
    ~Trace()                { std::puts("소멸"); }
};

std::vector<Trace> v;
v.reserve(4);
v.push_back(Trace{});     // 생성 → 이동 → 소멸
v.emplace_back();         // 생성           ★ 임시 객체가 없습니다

Trace f() { Trace t; return t; }
auto a = f();             // 생성 하나뿐 (RVO — 복사도 이동도 없음)

//  이 여섯 줄짜리 구조체를 프로젝트에 하나 넣어 두면
//  "왜 여기서 복사가 나지?" 를 추측이 아니라 출력으로 확인할 수 있습니다 ★

객체 레이아웃과 특수 멤버 자동 생성 규칙

// ① 이동 생성자는 '복사 생성자나 소멸자를 직접 쓰면' 자동 생성이 막힙니다
struct A { ~A() {} };           // 소멸자를 썼더니
A a; A b = std::move(a);        // → 이동이 아니라 '복사'가 불립니다 (조용히)
//   대형 컨테이너에서 성능이 몇 배 나빠지는 흔한 원인 ★
//   해결: 필요한 것을 = default 로 명시
struct B { ~B() = default; B(B&&) = default; B& operator=(B&&) = default; };

// ② 클래스 크기 — 빈 클래스도 1바이트입니다 (주소가 있어야 하므로)
struct Empty {};                 sizeof(Empty);        // 1
struct Derived : Empty { int x; }; sizeof(Derived);    // 4 (빈 기반 최적화)
struct HasMember { Empty e; int x; }; sizeof(HasMember); // 8 (패딩 발생)
//   → C++20 [[no_unique_address]] 로 멤버에도 같은 최적화를 적용할 수 있습니다
struct Better { [[no_unique_address]] Empty e; int x; };  // 4 (GCC·Clang — MSVC 는 무시, [[msvc::no_unique_address]])

// ③ 컴파일 시점에 성질 확인 — 라이브러리 인터페이스에 못 박아 두기
static_assert(std::is_trivially_copyable_v<Point>);   // memcpy 해도 되는 타입인가
static_assert(std::is_standard_layout_v<Point>);      // C 와 호환되는 배치인가 ★
//   이 두 개가 참이어야 C ABI 로 구조체를 그대로 넘길 수 있습니다(🔗 탭)
모듈을 만들 때 직결됩니다 — 파이썬·Node 에 넘길 구조체는 is_standard_layout 이 참이어야 바이트 단위로 그대로 주고받을 수 있습니다. 가상 함수가 하나라도 있으면 이 성질이 깨집니다(vtable 포인터가 앞에 붙습니다).
정리 — C++ 클래스의 핵심은 소멸자입니다. 객체가 사라지는 그 자리에서 반드시 불리기 때문에, “열었으면 닫기”를 사람이 아니라 타입이 책임지게 만들 수 있습니다. 그리고 가장 좋은 클래스는 소멸자를 안 쓰는 클래스입니다 — vector·string·unique_ptr 이 대신해 주니까요.
CPP 08

상속과 가상 함수

파이썬은 항상 자식 메서드가 불렸습니다. C++ 는 virtual 을 붙여야 그렇게 됩니다. 안 붙이면 조용히 부모 것이 불립니다.

인터페이스와 구현

class Storage {                       // 파이썬 ABC 자리
public:
    virtual ~Storage() = default;     // ★ 반드시 virtual 소멸자
    virtual void save(std::string_view key,
                      std::span<const std::byte> data) = 0;   // = 0 → 순수 가상
    virtual bool exists(std::string_view key) const = 0;
};

class FileStorage : public Storage {
public:
    void save(std::string_view key,
              std::span<const std::byte> data) override {     // ★ override 필수
        ...
    }
    bool exists(std::string_view key) const override { ... }
};

// 쓰는 쪽은 인터페이스만 압니다
void backup(Storage& s) { s.save("k", data); }

FileStorage fs;
backup(fs);                            // 실행 시점에 FileStorage 것이 불립니다

// 다형적으로 보관하려면 포인터로
std::vector<std::unique_ptr<Storage>> all;
all.push_back(std::make_unique<FileStorage>());
virtual ~Storage() 를 빠뜨리면 — Storage* 로 delete 할 때 자식 소멸자가 안 불립니다(UB). 자식이 들고 있던 메모리·파일이 그대로 샙니다. “상속할 클래스에는 virtual 소멸자” 는 예외 없는 규칙입니다.

virtual 을 안 붙이면

struct Base    { void f() { std::puts("Base"); } };        // virtual 없음
struct Derived : Base { void f() { std::puts("Derived"); } };

Derived d;
Base& b = d;
b.f();          // "Base" ★ 파이썬이라면 "Derived" 였을 자리

// virtual 을 붙이면 기대대로 동작합니다
struct Base2    { virtual void f() { std::puts("Base"); } };
struct Derived2 : Base2 { void f() override { std::puts("Derived"); } };
Derived2 d2;
Base2& b2 = d2;
b2.f();         // "Derived"

// ⚠ 객체 잘림(slicing) — 값으로 복사하면 자식 부분이 잘려 나갑니다
void take(Base2 b);      // ❌ 값으로 받으면
take(d2);                //    Base2 부분만 복사됩니다 (자식이 사라짐)
void take(Base2& b);     // ✅ 참조나 포인터로 받으세요

std::vector<Base2> v;    // ❌ 다형적 컨테이너로 쓸 수 없습니다
std::vector<std::unique_ptr<Base2>> v2;   // ✅

이렇게도 쓸 수 있다 — 상속 말고 다른 방법 🟡 중급

가상 함수가 필요 없는 경우가 많습니다

// ① 함수 객체로 충분한 경우 — 콜백 하나면 상속이 과합니다
void process(const Data& d, std::function<void(Result)> on_done);
process(d, [](Result r){ save(r); });

// ② 종류가 '고정되어 있으면' variant — 가상 호출도 힙 할당도 없습니다
using Shape = std::variant<Circle, Rect, Triangle>;
double area(const Shape& s) {
    return std::visit([](const auto& x){ return x.area(); }, s);
}
//   컴파일 시점에 전부 정해져 인라인까지 됩니다 (탭 2 m10)

// ③ 컴파일 시점 다형성 — 템플릿
template<class Storage>
void backup(Storage& s) { s.save("k", data); }
//   가상 호출 비용 0. 대신 타입마다 코드가 따로 생깁니다

// ④ 정말 런타임에 갈아끼워야 할 때만 가상 함수
//    (플러그인 · 설정으로 구현 선택 · 테스트용 가짜 객체)

상속을 쓸 때의 규칙

// ① override 를 반드시 적으세요 — 오타를 컴파일러가 잡아 줍니다
struct D : B {
    void run(int) override;    // 부모에 run(int) 이 없으면 컴파일 에러 ★
    void run(long);            // override 없으면 '새 함수'가 조용히 생깁니다
};

// ② 더 못 물려받게 막기
struct Final final : B { ... };            // 이 클래스는 상속 불가
void f() final;                            // 이 함수는 더 재정의 불가
//   final 은 컴파일러가 가상 호출을 직접 호출로 바꿀 수 있게 해 성능에도 도움

// ③ protected 는 최소한으로 — 자식이 부모 내부에 의존하기 시작합니다

// ④ 다중 상속은 '인터페이스(순수 가상만 있는 클래스)' 에만
struct Readable  { virtual ~Readable() = default;  virtual Data read() = 0; };
struct Writable  { virtual ~Writable() = default;  virtual void write(Data) = 0; };
struct File : Readable, Writable { ... };   // ✅ 이 정도는 안전합니다
//   상태를 가진 클래스를 여럿 상속하면 다이아몬드 문제와 virtual 상속으로 이어집니다
파이썬 습관과의 차이 — 파이썬은 믹스인 상속이 흔했지만, C++ 에서는 상속이 가장 강한 결합입니다. “기능을 재사용하고 싶다”면 멤버로 가지는 것(컴포지션)이 거의 항상 낫습니다.

실무에선 이렇게 — 테스트할 수 있는 구조로 🔴 실무

경계에만 인터페이스를 둡니다

// 바깥 세계(파일·네트워크·시각)와 닿는 곳만 인터페이스로 뽑습니다
class Clock {
public:
    using time_point = std::chrono::system_clock::time_point;
    virtual ~Clock() = default;
    virtual time_point now() const = 0;
};
class SystemClock : public Clock { ... };
class FakeClock   : public Clock {           // 테스트용
    std::chrono::system_clock::time_point t_;
public:
    time_point now() const override { return t_; }
};

class Session {
    Clock& clock_;                    // 참조로 주입 (소유하지 않음)
public:
    explicit Session(Clock& c) : clock_(c) {}
    bool expired() const { return clock_.now() > deadline_; }
};

// 테스트에서는 시간을 마음대로 조작할 수 있습니다
FakeClock fake; fake.set(내일);
Session s{fake};
assert(s.expired());

이 가이드의 목적과 연결하면

// 파이썬·Node 에 노출할 모듈에서는 상속을 최소로 씁니다. 이유가 분명합니다.
//
//  ① 가상 함수가 있으면 구조체를 C ABI 로 그대로 넘길 수 없습니다
//     (앞에 vtable 포인터가 붙어 표준 배치가 깨짐 — 🔗 탭)
//
//  ② 클래스 계층을 그대로 노출하면 바인딩 코드가 급격히 복잡해집니다
//     pybind11 은 상속도 지원하지만, 대개는 '함수 몇 개'로 노출하는 편이
//     쓰는 쪽에서도 훨씬 파이썬답습니다
//
//  ③ 파이썬 쪽에서 C++ 클래스를 상속하려면 트램폴린 클래스가 필요합니다
//     (가능하지만 비용과 복잡도가 큽니다 — 🐍 탭 y05)

// ✅ 모듈 인터페이스는 이런 모양이 가장 다루기 쉽습니다
std::vector<double> smooth(std::span<const double> input, int window);
//   상태 없음 · 상속 없음 · 입력과 출력만 — 어느 언어에서든 감싸기 쉽습니다
설계 원칙 하나 — 내부는 자유롭게, 경계는 단순하게. C++ 안에서는 어떤 구조를 쓰든 상관없지만, 다른 언어에 노출하는 표면은 함수와 값만으로 유지하는 것이 유지보수를 좌우합니다.

한 단계 더 — 가상 호출은 실제로 얼마나 비싼가 🧬 전문가

vtable 의 구조

struct B { virtual void f(); virtual void g(); int x; };
sizeof(B);      // 16 — vptr(8) + int(4) + 패딩(4)
//   가상 함수가 하나라도 있으면 객체 앞에 '표 주소' 8바이트가 붙습니다

// b.f() 가 실제로 하는 일
//   ① 객체에서 vptr 을 읽는다        (메모리 접근 1)
//   ② 표에서 f 의 주소를 읽는다      (메모리 접근 2)
//   ③ 그 주소로 간접 점프            (분기 예측 실패 가능)
//
//   직접 호출 대비 대략 몇 사이클 — 그 자체는 크지 않습니다.
//   진짜 비용은 ★ 인라인이 불가능해진다는 것입니다.
//   한 줄짜리 접근자가 인라인되면 0사이클인데, 가상이면 호출이 남습니다.

// 컴파일러가 대상을 확신하면 직접 호출로 바꿉니다 (devirtualization)
struct D final : B { void f() override; };
D d; d.f();          // 정적 타입이 확실 → 직접 호출 · 인라인 가능
//   final 을 붙이는 것만으로 최적화 여지가 생깁니다

언제 문제가 되나 — 그리고 대안

// 문제가 되는 조건은 셋이 겹칠 때입니다
//   ① 아주 자주 불린다(수백만 회)  ② 함수 본문이 아주 짧다
//   ③ 루프 안에서 불린다 → 벡터화가 막힌다

// ❌ 원소마다 가상 호출 — 캐시도, 벡터화도 놓칩니다
for (auto& s : shapes) total += s->area();

// ✅ 대안 ① 타입별로 묶어서 처리 (배치 처리)
for (auto& c : circles) total += c.area();   // 인라인 · 벡터화 가능
for (auto& r : rects)   total += r.area();

// ✅ 대안 ② variant + visit — 런타임 분기지만 인라인은 됩니다
for (const auto& s : shapes) total += std::visit(AreaOf{}, s);

// ✅ 대안 ③ 함수 단위를 키우기 — 원소 하나가 아니라 '배열 전체'를 가상 함수로
struct Filter { virtual void apply(std::span<double>) = 0; };
//   가상 호출 1번 + 내부는 순수 루프 → 비용이 사실상 사라집니다 ★

// 측정 없이 판단하지 마세요
//   perf stat -e branch-misses,cache-misses ./app
//   대부분의 프로그램에서 가상 호출은 병목 근처에도 못 갑니다
흔한 과잉 반응 — “가상 함수는 느리다”며 전부 템플릿으로 바꾸면 빌드 시간과 바이너리 크기가 폭발하고 코드가 읽기 어려워집니다. 위 대안 ③(호출 단위를 키우기) 이 거의 항상 가장 좋은 답입니다.
정리 — 자식 것이 불리게 하려면 virtual, 재정의할 때는 override, 상속할 클래스에는 virtual 소멸자. 이 세 개가 규칙의 전부입니다. 그리고 상속은 마지막에 고려하세요 — C++ 에서는 variant·템플릿·함수 객체로 해결되는 경우가 훨씬 많습니다.
상속을 값으로 다루면 잘려 나갑니다 — 슬라이싱 원본 — Dog 객체 Animal 부분 Dog 부분 (name, bark) Animal a = dog; 값으로 대입 — 잘립니다 Animal 부분만 사라졌습니다 a.speak() 는 Animal 의 것이 불립니다 참조·포인터로 — 온전합니다 Animal& a = dog; unique_ptr<Animal> p = make_unique<Dog>() 가상 함수가 Dog 의 것으로 불립니다 기반 포인터로 delete 하는데 소멸자가 virtual 이 아니면 Dog 의 소멸자가 불리지 않습니다 — 멤버가 쥐고 있던 메모리·파일이 그대로 샙니다. 컴파일러는 아무 말도 하지 않습니다 규칙 두 줄 — 다형적으로 쓸 기반 클래스에는 virtual ~Animal() = default; 를 반드시 둡니다 상속 계층은 값이 아니라 참조·스마트 포인터로 다룹니다. 그럴 생각이 없다면 클래스를 final 로 막아 두는 편이 낫습니다
파이썬·자바에는 없던 함정입니다 — 그쪽은 객체가 늘 참조라 잘릴 일이 없었습니다.
상속을 값으로 다루면 잘려 나갑니다 — 슬라이싱 원본 — Dog 객체 Animal 부분 Dog 부분 (name, bark) Animal a = dog; 값으로 대입 — 잘립니다 Animal 부분만 사라졌습니다 a.speak() 는 Animal 의 것이 불립니다 참조·포인터로 — 온전합니다 Animal& a = dog; unique_ptr<Animal> p = make_unique<Dog>() 가상 함수가 Dog 의 것으로 불립니다 기반 포인터로 delete 하는데 소멸자가 virtual 이 아니면 Dog 의 소멸자가 불리지 않습니다 — 멤버가 쥐고 있던 메모리·파일이 그대로 샙니다. 컴파일러는 아무 말도 하지 않습니다 규칙 두 줄 — 다형적으로 쓸 기반 클래스에는 virtual ~Animal() = default; 를 반드시 둡니다 상속 계층은 값이 아니라 참조·스마트 포인터로 다룹니다. 그럴 생각이 없다면 클래스를 final 로 막아 두는 편이 낫습니다
파이썬·자바에는 없던 함정입니다 — 그쪽은 객체가 늘 참조라 잘릴 일이 없었습니다.
CPP 09

헤더와 소스 분리 — 파일이 여러 개가 될 때

파이썬은 import 한 줄이면 끝이었습니다. C++ 는 “이런 게 있다”(헤더)와 “이렇게 동작한다”(소스)를 나눕니다. 링크 에러의 90%가 여기서 나옵니다.

선언은 .h, 정의는 .cpp

// ── math_util.h ─────────────────────────
#pragma once            // ★ 이 파일이 두 번 포함되는 것을 막습니다

#include <vector>

namespace mu {
double mean(const std::vector<double>& v);   // 선언만 (구현 없음)

inline double square(double x) { return x * x; }  // 짧으면 헤더에 (inline 필수)

struct Stats {          // 클래스 '정의'는 헤더에 있어야 합니다
    double mean;        //   (크기를 알아야 쓸 수 있으므로)
    double stddev;
};
}  // namespace mu


// ── math_util.cpp ───────────────────────
#include "math_util.h"     // 내 헤더를 먼저 (자기 완결성 검사)
#include <numeric>

namespace mu {
double mean(const std::vector<double>& v) {      // 여기가 진짜 구현
    return std::accumulate(v.begin(), v.end(), 0.0) / v.size();
}
}

헤더에 둬야 하는 것과 두면 안 되는 것

헤더에 둬야 함소스에 둬야 함
클래스·struct 정의일반 함수의 본문
템플릿 (전체 구현)전역 변수의 실체
inline 함수내부에서만 쓰는 헬퍼
constexpr 상수무거운 #include
enum · using 별칭using namespace ★
// ❌ 헤더에서 절대 하면 안 되는 것
using namespace std;        // 이 헤더를 포함한 모든 파일이 오염됩니다

// ❌ 헤더에 함수 정의 (inline 없이)
int add(int a, int b) { return a+b; }   // multiple definition 링크 에러

// ❌ 헤더에 전역 변수 정의
int counter = 0;            // 같은 에러
inline int counter = 0;     // ✅ C++17 인라인 변수
extern int counter;         // ✅ 또는 선언만 하고 .cpp 에 정의
multiple definition 링크 에러가 나면 거의 항상 헤더에 정의를 넣은 것입니다. inline 을 붙이거나 .cpp 로 내리세요. #pragma once 는 같은 파일 내 중복 포함만 막아 줄 뿐, 여러 .cpp 에 퍼지는 중복 정의는 막지 못합니다 — 다른 문제입니다.

이렇게도 쓸 수 있다 — 빌드를 가볍게 유지하기 🟡 중급

전방 선언으로 #include 줄이기

// ❌ 헤더에서 무거운 헤더를 포함하면 이 헤더를 쓰는 모두가 비용을 냅니다
// widget.h
#include "heavy_engine.h"      // 5000줄
class Widget { Engine* e_; };  // 포인터인데 전체 정의가 필요할 이유가 없습니다

// ✅ 이름만 알려 주면 됩니다 (전방 선언)
class Engine;                  // "Engine 이라는 클래스가 어딘가에 있다"
class Widget {
    Engine* e_;                // 포인터·참조는 크기를 알 필요가 없습니다
public:
    void run();
};
// widget.cpp 에서만 진짜로 포함
#include "heavy_engine.h"

// 전방 선언으로 되는 것 / 안 되는 것
//   ✅ 포인터 · 참조 멤버, 함수의 인자·반환 타입 선언
//   ❌ 값 멤버(크기를 알아야 함), 상속, 멤버 함수 호출, sizeof

// 표준 라이브러리는 전방 선언하면 안 됩니다 (UB)
namespace std { class string; }   // ❌ 절대 금지 — <string> 을 포함하세요

Pimpl — 구현을 헤더에서 완전히 감추기

// ── session.h — 사용자에게 보이는 전부
#pragma once
#include <memory>
#include <string_view>

class Session {
public:
    Session();
    ~Session();                       // ★ 헤더에 선언만, 정의는 .cpp 에
    Session(Session&&) noexcept;
    Session& operator=(Session&&) noexcept;

    void send(std::string_view msg);
private:
    struct Impl;                      // 이름만 (내용은 아무도 모름)
    std::unique_ptr<Impl> p_;
};

// ── session.cpp
#include "session.h"
#include <boost/asio.hpp>            // 무거운 의존성이 여기 갇힙니다 ★

struct Session::Impl {
    boost::asio::ip::tcp::socket sock;
};
Session::Session() : p_(std::make_unique<Impl>()) {}
Session::~Session() = default;        // ★ Impl 이 완전한 이 자리에서 정의
Pimpl 을 쓰는 이유 셋 — ① 내부를 바꿔도 쓰는 쪽을 다시 컴파일하지 않아도 됩니다, ② 무거운 헤더가 밖으로 새지 않습니다, ③ ABI 안정성이 생겨 라이브러리 배포에 유리합니다(🔬 d07). 대가는 힙 할당 한 번과 간접 접근이라, 자주 만드는 작은 객체에는 쓰지 않습니다.

실무에선 이렇게 — 폴더 구조와 포함 규칙 🔴 실무

공개 헤더와 내부 헤더를 나눕니다

mylib/
  CMakeLists.txt
  include/mylib/          ← 공개 API (설치되는 것)
      core.h
      types.h
  src/                    ← 구현 + 내부 전용 헤더
      core.cpp
      detail/parser.h     ← 밖에서 못 씀
      detail/parser.cpp
  tests/
  python/                 ← 파이썬 바인딩 (🐍 탭)
add_library(mylib src/core.cpp src/detail/parser.cpp)

target_include_directories(mylib
    PUBLIC  $<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/include>
    PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src)    # src 는 나만

# 쓰는 쪽은 이렇게만 하면 됩니다
target_link_libraries(app PRIVATE mylib)        # include 경로가 따라옵니다
포함 방식의 관례 — 내 프로젝트 헤더는 #include "...", 외부·표준 헤더는 #include <...>. 그리고 공개 헤더는 경로를 포함해(#include <mylib/core.h>) 쓰는 것이 이름 충돌을 막는 표준적인 방식입니다.

헤더가 자기 완결적인지 검사하기

# 헤더는 "그것만 포함해도 컴파일되어야" 합니다
echo '#include "mylib/core.h"
int main(){}' | g++ -std=c++20 -x c++ - -Iinclude -fsyntax-only
#   실패하면 core.h 가 다른 헤더에 얹혀 가고 있다는 뜻입니다

# .cpp 에서 자기 헤더를 '맨 처음' 포함하면 이 문제가 자동으로 드러납니다
# core.cpp
#include "mylib/core.h"      ← 첫 줄 ★
#include <vector>

# 안 쓰는 include 찾기
include-what-you-use -Xiwyu --no_fwd_decls src/core.cpp

# 포함 관계 그래프 (누가 무엇을 끌어오는지)
g++ -H -fsyntax-only src/core.cpp 2>&1 | head -40
// C++20 모듈 — include 를 대체할 미래의 방식
export module mylib.core;
export double mean(std::span<const double>);
//   빌드가 크게 빨라지지만 2026년 현재도 도구 지원이 고르지 않습니다.
//   ★ 파이썬·Node 바인딩과 섞어 쓰기에는 아직 이르니
//     이 가이드는 헤더 방식으로 진행합니다.

한 단계 더 — ODR — 하나의 정의 규칙 🧬 전문가

같은 이름이 두 개면 무슨 일이 일어나나

// 규칙: 프로그램 전체에서 함수·변수의 '정의'는 정확히 하나여야 합니다.
//       클래스·inline 함수·템플릿은 여러 번역 단위에 나와도 되지만
//       ★ 모든 정의가 '완전히 동일'해야 합니다. 다르면 진단 없이 UB.

// 실제로 나는 사고 — 빌드 플래그가 달라 구조체 크기가 달라지는 경우
// a.cpp: -DUSE_EXTRA 로 컴파일
struct Cfg { int a; int b; };
// b.cpp: 플래그 없이 컴파일
struct Cfg { int a; };
//  → 링크는 성공하고, 실행 중에 메모리가 깨집니다 (원인 추적 극악)

// 진단 방법
//   ① -flto 를 켜면 링커가 타입 불일치를 잡아 주는 경우가 있습니다
//   ② gold 링커의 --detect-odr-violations (lld 에는 없음)
//   ③ AddressSanitizer 는 전역 변수 ODR 위반을 잡아 줍니다
//   ④ 근본 대책: 플래그로 구조체 내용을 바꾸지 않기 ★

// 내부 전용 심볼은 밖으로 내보내지 않기
namespace { void helper(); }     // 익명 namespace = 이 파일 전용 ★
static void helper2();           // C 시절 방식 (같은 효과)
//   이러면 다른 파일의 같은 이름과 충돌하지 않습니다

심볼을 직접 들여다보기

# 이 오브젝트가 무엇을 제공하고 무엇을 필요로 하나
nm -C --defined-only build/core.o    | head     # 제공 (T/W/D)
nm -C --undefined-only build/core.o  | head     # 필요 (U)

#   T  텍스트(코드) — 이 파일이 정의
#   W  약한 심볼    — inline·템플릿 (중복 허용, 링커가 하나만 남김)
#   U  미정의       — 다른 곳에서 와야 함
#   t  소문자 = 내부 링키지 (익명 namespace·static)

# undefined reference 를 만났을 때의 실전 순서
#   ① nm 으로 그 심볼을 '제공하는' 오브젝트가 있는지 확인
#   ② 있으면 → 링크 순서·라이브러리 누락 문제
#   ③ 없으면 → 그 .cpp 를 빌드에 안 넣었거나, extern "C" 불일치(🔗 f02)
nm -C --defined-only build/*.o | grep 심볼이름

# 라이브러리에서 찾기
nm -C -D /usr/lib/libfoo.so | grep 심볼이름     # 윈도우: dumpbin /exports

# 바이너리 크기가 어디서 왔는지 (템플릿 폭발 추적)
nm -C --size-sort -S build/app | tail -20
이 도구들이 실력을 가릅니다 — 링크 에러 앞에서 추측으로 CMake 를 만지는 사람과 nm 한 번으로 “이 심볼은 어디에도 없다”를 확인하고 원인으로 직행하는 사람의 차이가 여기서 납니다.
정리 — 헤더는 “이런 게 있다”, 소스는 “이렇게 동작한다” 입니다. 헤더 맨 위에 #pragma once, 함수 정의는 .cpp 에, 헤더에 정의를 두려면 inline. multiple definition 이 나오면 헤더를 의심하고, undefined reference 가 나오면 빌드에 그 파일을 넣었는지부터 보세요.
헤더는 명함, 소스는 실제 일 — 나누는 이유 math.h — 선언 int add(int, int); "이런 함수가 있다" math.cpp — 구현 int add(int a, int b) { ... } "실제로 이렇게 한다" 파일마다 따로 컴파일 math.o · main.o 아직 서로를 모릅니다 링커가 이어 붙임 선언과 구현을 연결 못 찾으면 undefined reference 같은 헤더가 여러 번 포함되면 선언이 중복됩니다 — #pragma once 로 한 번만 읽히게 그리고 헤더에 구현을 넣으면 그 헤더를 포함한 모든 파일이 매번 다시 컴파일됩니다 헤더에는 필요한 것만 포함하세요 — 헤더가 헤더를 물고 오면 빌드 시간이 급격히 늘어납니다 C++20 의 모듈이 이 구조를 대체하려 하지만, 아직은 헤더가 현실입니다
빌드가 느린 프로젝트의 원인은 대개 코드가 아니라 헤더 의존 관계입니다.
CPP 10

네임스페이스와 전처리기

네임스페이스는 파이썬의 모듈과 같은 역할입니다. 전처리기(#)는 컴파일 전에 텍스트를 갈아 끼우는 단계로, 강력하지만 위험합니다.

네임스페이스

namespace mylib {
namespace detail {                 // 내부 구현용 (관례)
    int helper();
}
double mean(const std::vector<double>&);
}

// C++17 부터 중첩을 한 줄로
namespace mylib::detail { int helper(); }

mylib::mean(v);                    // 전체 이름으로
namespace ml = mylib;              // 별칭
ml::mean(v);

// ⚠ using namespace 는 .cpp 안에서만, 그것도 좁게
void f() {
    using namespace std::chrono_literals;
    auto d = 100ms;                // 이 함수 안에서만 유효
}

// ❌ 헤더나 파일 최상단의 using namespace std;
//    - std 에는 이름이 수천 개 있어 내 이름과 충돌합니다
//    - 특히 count · distance · data · size 같은 흔한 이름이 실제로 부딪힙니다

전처리기 — 텍스트 치환일 뿐

#include <vector>      // 그 파일 내용을 여기에 통째로 붙여넣기
#define MAX 100         // 이후 MAX 를 전부 100 으로 바꿔치기

#ifdef _WIN32           // 조건부 컴파일 — 플랫폼 분기
    #include <windows.h>
#else
    #include <unistd.h>
#endif

#if __cplusplus >= 202002L      // 표준 버전으로 분기
    #include <format>
#endif

// ⚠ 매크로는 타입도 스코프도 없습니다 — 실제로 사고가 납니다
#define SQUARE(x) x * x
SQUARE(1 + 2);          // 1 + 2 * 1 + 2  =  5  ★ 9가 아닙니다
#define SQUARE2(x) ((x) * (x))    // 괄호로 감싸도
SQUARE2(i++);           // i 가 두 번 증가합니다

// ✅ 현대 C++ 의 대안
constexpr int kMax = 100;                       // #define 상수 대신
constexpr int square(int x) { return x * x; }   // 함수 매크로 대신
template<class T> constexpr T sq(T x){return x*x;}
윈도우에서 자주 겪는 일 — <windows.h> 가 min·max 를 매크로로 정의해 std::min 이 깨집니다. #define NOMINMAX 를 windows.h 보다 먼저 두거나, CMake 에서 target_compile_definitions(app PRIVATE NOMINMAX) 로 못 박으세요.

이렇게도 쓸 수 있다 — 매크로가 아직 필요한 자리 🟡 중급

대체 불가능한 세 가지 용도

// ① 파일·줄 번호 — 함수로는 불가능합니다
#define LOG(msg) \
    std::fprintf(stderr, "[%s:%d] %s\n", __FILE__, __LINE__, msg)
//   C++20 에는 함수로 되는 방법이 생겼습니다 ★
#include <source_location>
void log(std::string_view msg,
         std::source_location loc = std::source_location::current()) {
    std::printf("[%s:%u] %.*s\n", loc.file_name(), loc.line(),
                (int)msg.size(), msg.data());
}

// ② 조건부 컴파일 (플랫폼·빌드 종류)
#ifndef NDEBUG
    assert(index < size);      // 릴리즈 빌드에서는 통째로 사라집니다
#endif

// ③ DLL 내보내기 표시 (윈도우) — 🔗 탭에서 다시 나옵니다
#ifdef _WIN32
    #define MYLIB_API __declspec(dllexport)
#else
    #define MYLIB_API __attribute__((visibility("default")))
#endif
MYLIB_API int compute(int);

// 유용한 미리 정의된 매크로
__FILE__  __LINE__  __func__   __DATE__
_WIN32  __linux__  __APPLE__   __GNUC__  _MSC_VER

매크로를 쓸 수밖에 없다면

// ① 이름을 크게, 접두어를 붙여 충돌을 피하세요
#define MYLIB_CHECK(x) ...        // ✅
#define CHECK(x) ...              // ❌ 다른 라이브러리와 부딪힙니다

// ② 인자는 전부 괄호로, 여러 줄은 do-while(0) 으로
#define MYLIB_SET(a, b)      \
    do {                     \
        (a) = (b);           \
        log(#a " 설정됨");    \
    } while (0)
//   if (c) MYLIB_SET(x,1); else ...  가 정상 동작하게 하는 관용구입니다

// ③ 문자열화(#)와 이어붙이기(##)
#define STR(x) #x                 // STR(abc) → "abc"
#define CAT(a,b) a##b             // CAT(my,var) → myvar

// ④ 다 쓰면 해제
#undef MYLIB_SET

// ⑤ 매크로가 펼쳐진 결과를 확인하는 법 ★
//    g++ -E file.cpp | grep -A5 "찾을내용"
//    "왜 이 매크로가 이상하게 도나?" 는 이 한 줄로 대부분 풀립니다
기준선 — 상수 → constexpr, 함수 → constexpr 함수나 템플릿, 타입 → using. 매크로는 전처리 단계가 아니면 못 하는 일에만 남기세요. 그러면 디버거·자동완성·에러 메시지가 전부 정상 동작합니다.

실무에선 이렇게 — 플랫폼 분기를 한곳에 모읍니다 🔴 실무

#ifdef 를 코드 곳곳에 뿌리지 않기

// ❌ 함수마다 분기가 흩어지면 어느 조합이 실제로 빌드되는지 아무도 모릅니다
void f() {
#ifdef _WIN32
    Sleep(100);
#else
    usleep(100000);
#endif
}

// ✅ 얇은 계층 하나로 몰아넣습니다
// platform.h
namespace plat { void sleep_ms(int ms); }

// platform_win.cpp  /  platform_posix.cpp  로 파일을 나누고
// CMake 가 OS 에 맞는 파일만 빌드에 넣습니다
if (WIN32)
    target_sources(mylib PRIVATE src/platform_win.cpp)
else()
    target_sources(mylib PRIVATE src/platform_posix.cpp)
endif()

// ✅ 더 좋은 것 — 표준에 있으면 표준을 쓰세요
#include <thread>
std::this_thread::sleep_for(std::chrono::milliseconds(100));   // 어디서나 동작
#include <filesystem>      // 경로·파일 조작도 표준으로 통일됐습니다

기능 탐지와 assert 전략

// ① 표준 기능이 있는지 확인 (컴파일러 버전으로 분기하지 말고)
#if __has_include(<format>)
    #include <format>
#endif
#ifdef __cpp_lib_ranges          // 기능 테스트 매크로
    ...
#endif

// ② assert 는 '있을 수 없는 일'에만 — 릴리즈에서 사라진다는 것을 기억하세요
assert(index < size_);           // 프로그래머 실수 검출용
if (!file) throw std::runtime_error("파일 없음");   // 사용자 입력은 예외로 ★

// ⚠ assert 안에 부작용을 넣으면 릴리즈에서 동작이 달라집니다
assert(pop() == 3);              // ❌ NDEBUG 면 pop() 자체가 사라집니다

// ③ 컴파일 시점 검증 — 비용 0
static_assert(sizeof(void*) == 8, "64비트 빌드만 지원합니다");
static_assert(std::is_standard_layout_v<Packet>);

// ④ 릴리즈에서도 살아 있어야 하는 검사는 직접 만듭니다
#define MYLIB_ENSURE(cond, msg) \
    do { if (!(cond)) throw std::logic_error(msg); } while (0)
파이썬 모듈을 만들 때 특히 중요합니다 — assert 로만 막아 둔 인덱스 검사는 릴리즈 빌드(-DNDEBUG)에서 사라집니다. 파이썬 쪽에서 잘못된 값을 넘기면 예외가 아니라 세그폴트로 인터프리터가 통째로 죽습니다(🐍 y12). 외부에서 들어오는 값은 항상 진짜 검사로 막으세요.

한 단계 더 — 전처리 결과를 다루는 기술 🧬 전문가

무엇이 정의돼 있는지 전부 보기

# 컴파일러가 기본으로 정의하는 매크로 전체 목록
g++ -std=c++20 -dM -E -x c++ /dev/null | sort | head -30
#   __cplusplus 202002L · __GNUC__ · __x86_64__ · __SIZEOF_INT__ ...
#   → "이 플랫폼에서 어떤 매크로로 분기해야 하나" 를 추측하지 않고 확인합니다

# 내 파일에서 특정 매크로가 최종적으로 무엇으로 펼쳐지는가
g++ -E -P main.cpp | grep -n "찾을내용"
#   -P 는 줄 번호 표시(#line)를 빼서 읽기 쉽게 합니다

# 어떤 헤더가 어디서 포함됐는지 (중첩 깊이가 점으로 표시됩니다)
g++ -H -fsyntax-only main.cpp 2>&1 | head -30

# NDEBUG 가 실제로 켜졌는지 확인 (CMake Release 는 자동으로 켭니다)
g++ -O2 -DNDEBUG -dM -E -x c++ /dev/null | grep NDEBUG

심볼 가시성 — 라이브러리를 만들 때

// 리눅스는 기본적으로 '모든 심볼을 내보냅니다'. 문제가 생깁니다.
//   · .so 가 불필요하게 커지고 로딩이 느려집니다
//   · 내부 심볼이 다른 라이브러리와 충돌합니다
//   · 최적화 여지가 줄어듭니다 (밖에서 부를 수 있다고 가정해야 하므로)

// ✅ 기본을 숨기고 필요한 것만 내보내기
// CMake
set_target_properties(mylib PROPERTIES
    CXX_VISIBILITY_PRESET hidden
    VISIBILITY_INLINES_HIDDEN ON)

// export.h — 세 OS 를 하나로
#pragma once
#if defined(_WIN32)
  #ifdef MYLIB_BUILDING
    #define MYLIB_API __declspec(dllexport)
  #else
    #define MYLIB_API __declspec(dllimport)
  #endif
#else
  #define MYLIB_API __attribute__((visibility("default")))
#endif

MYLIB_API int compute(int);      // 이것만 밖에서 보입니다

// CMake 가 이 헤더를 만들어 주기도 합니다
include(GenerateExportHeader)
generate_export_header(mylib)
왜 이게 이 가이드의 핵심과 닿나 — 파이썬 확장 모듈(.so/.pyd)도 공유 라이브러리입니다. 가시성을 통제하지 않으면 같은 프로세스에 로드된 다른 확장과 심볼이 충돌해 엉뚱한 함수가 불릴 수 있습니다. pybind11 이 기본으로 hidden 가시성을 권하는 이유입니다.
정리 — 네임스페이스는 파이썬 모듈과 같은 역할이고, #include 는 파일 내용을 그 자리에 붙여넣기입니다. #define 상수 대신 constexpr, 함수 매크로 대신 함수를 쓰면 디버거와 자동완성이 정상 동작합니다. 매크로는 플랫폼 분기와 파일·줄 번호 정도에만 남기세요.
CPP 11

예외와 에러 처리

문법은 파이썬과 거의 같습니다. 다른 건 문화 — C++ 에는 예외를 아예 쓰지 않는 코드베이스가 많습니다. 두 방식을 다 알아야 남의 코드를 읽을 수 있습니다.

예외 기본

#include <stdexcept>

double divide(double a, double b) {
    if (b == 0)
        throw std::invalid_argument("0으로 나눌 수 없습니다");
    return a / b;
}

try {
    auto r = divide(1, 0);
} catch (const std::invalid_argument& e) {   // ★ const 참조로 받으세요
    std::cerr << e.what() << "\n";
} catch (const std::exception& e) {          // 좁은 것 → 넓은 것 순서
    std::cerr << "그 외: " << e.what() << "\n";
}
// catch (...) 는 모든 것을 잡습니다 (마지막 수단)

// 표준 예외 계층
//   std::exception
//     ├─ logic_error   → invalid_argument · out_of_range · domain_error
//     ├─ runtime_error → range_error · system_error(errno·OS 에러)
//     └─ bad_alloc(메모리 부족) · bad_cast

// 내 예외 만들기
class ParseError : public std::runtime_error {
public:
    ParseError(int line, const std::string& msg)
        : std::runtime_error("행 " + std::to_string(line) + ": " + msg),
          line_(line) {}
    int line() const noexcept { return line_; }
private:
    int line_;
};

예외를 쓰지 않는 방식

// ① 반환값으로 실패를 표현 — C++23 std::expected ★
#include <expected>
std::expected<Config, std::string> load(const std::string& path) {
    if (!exists(path))
        return std::unexpected("파일이 없습니다");
    return Config{...};
}

auto r = load("a.json");
if (!r) { log(r.error()); return; }
use(*r);                         // 성공했을 때만 값에 접근

// C++23 이전이라면 tl::expected 나 직접 만든 Result 타입을 씁니다

// ② 값이 없을 수 있음 — optional (실패 '이유'가 필요 없을 때)
std::optional<int> to_int(std::string_view s);
if (auto n = to_int(txt)) use(*n);

// ③ 에러 코드 — OS·C 라이브러리와 맞물릴 때
std::error_code ec;
auto sz = std::filesystem::file_size(p, ec);   // 예외를 안 던지는 오버로드
if (ec) { log(ec.message()); }
왜 예외를 안 쓰는 곳이 있나 — 게임 엔진·임베디드·금융 거래처럼 지연 시간이 예측 가능해야 하는 분야에서는 예외를 끕니다(-fno-exceptions). 예외를 던지는 순간의 비용이 크고 일정하지 않기 때문입니다. 구글 스타일 가이드가 예외를 금지하는 것도 같은 맥락(과 역사적 이유)입니다.

이렇게도 쓸 수 있다 — 예외 안전성 🟡 중급

예외가 지나가도 망가지지 않게

// 예외가 던져지면 그 지점부터 함수 밖으로 '되감기'가 일어나면서
// 스택에 있던 객체들의 소멸자가 전부 불립니다 (스택 되감기)
// → RAII 를 쓰면 자원 정리가 자동으로 됩니다 ★

void f() {
    std::lock_guard lk(mtx_);         // 잠금
    auto p = std::make_unique<T>();   // 할당
    risky();                          // 여기서 예외가 나도
}                                     // 잠금 해제 · 메모리 해제가 보장됩니다

// 예외 안전성 세 단계
//   ① 기본 보장   — 자원은 안 새고 객체는 유효한 상태 (최소 기준)
//   ② 강한 보장   — 실패하면 아무 일도 없던 것처럼 (트랜잭션)
//   ③ 무예외 보장 — 절대 던지지 않음 (noexcept)

// 강한 보장의 관용구 — copy and swap
void Config::update(const Config& other) {
    Config tmp(other);            // ① 복사본에서 작업 (여기서 실패해도 원본 무사)
    swap(tmp);                    // ② 성공했을 때만 교체 (swap 은 noexcept)
}

// 소멸자에서는 절대 예외를 던지지 마세요 ★
~File() {
    try { flush(); } catch (...) { /* 로그만 */ }   // 던지면 terminate
}

원인을 잃지 않고 다시 던지기

// 그대로 다시 던지기 (사본이 아니라 원본이 올라갑니다)
try { work(); }
catch (const std::exception&) {
    log("실패");
    throw;                      // ★ throw e; 가 아니라 그냥 throw;
}
//   throw e; 로 쓰면 객체 잘림이 일어나 파생 예외 정보가 사라집니다

// 원인을 품고 새 예외 던지기 (파이썬의 raise ... from e)
#include <exception>
try { parse(); }
catch (...) {
    std::throw_with_nested(ParseError(line, "설정 파싱 실패"));
}

// 중첩된 원인 전부 출력
void print_all(const std::exception& e, int depth = 0) {
    std::cerr << std::string(depth*2, ' ') << e.what() << "\n";
    try { std::rethrow_if_nested(e); }
    catch (const std::exception& inner) { print_all(inner, depth+1); }
    catch (...) {}
}

// 스레드 경계를 넘기기 — future 가 예외를 옮겨 줍니다
auto fut = std::async([]{ throw std::runtime_error("워커 실패"); });
try { fut.get(); } catch (const std::exception& e) { ... }   // 여기서 다시 던져집니다

실무에선 이렇게 — 경계에서 전부 잡습니다 🔴 실무

예외가 밖으로 새면 프로그램이 죽습니다

// ① main 은 반드시 감싸세요 — 안 그러면 terminate 로 즉사(스택 정보도 없이)
int main() try {
    run();
    return 0;
} catch (const std::exception& e) {
    std::cerr << "치명적: " << e.what() << "\n";
    return 1;
} catch (...) {
    std::cerr << "알 수 없는 오류\n";
    return 2;
}

// ② 스레드 함수도 반드시 감싸야 합니다 (예외가 새면 terminate)
std::thread t([]{
    try { work(); }
    catch (const std::exception& e) { log(e.what()); }
});

// ③ ★ C ABI · 파이썬 · Node 경계는 절대 예외를 통과시키면 안 됩니다
extern "C" int mylib_compute(const double* p, int n, double* out) {
    try {
        compute(std::span(p, n), out);
        return 0;                       // 성공
    } catch (const std::bad_alloc&) {
        return -2;
    } catch (...) {
        return -1;                      // 에러 코드로 변환 ★
    }
}
//   C++ 예외가 C 경계를 넘어가면 미정의 동작입니다(🔗 f09 · 🐍 y08)

무엇을 예외로, 무엇을 반환값으로

상황이렇게
흔히 일어나는 실패
파일 없음 · 파싱 실패 · 조회 없음
expected·optional
드물고 회복 불가
메모리 부족 · 설정 손상
예외
프로그래머 실수
인덱스 범위 초과
assert · 계약 위반
생성자에서의 실패예외 (반환값이 없으므로)
소멸자 · 이동 연산절대 던지지 않기
언어 경계 밖에러 코드로 변환
팀에서 먼저 정할 것 — “예외를 쓰는가”는 프로젝트 단위의 결정입니다. 중간에 섞이면 최악입니다(예외를 안 잡는 코드 사이로 예외가 지나감). 라이브러리를 만든다면 예외를 끈 프로젝트도 쓸 수 있게 expected 기반 API 를 함께 제공하는 것이 안전합니다.

한 단계 더 — 예외의 실제 비용 🧬 전문가

던지지 않으면 정말로 0 입니다

현대 구현은 '테이블 기반(zero-cost)' 방식입니다.

  · try 블록에 들어가는 데 드는 명령 = 없음
  · 대신 컴파일러가 '어디서 예외가 나면 무엇을 정리할지' 표를 만들어
    바이너리의 별도 섹션(.eh_frame · .gcc_except_table)에 넣어 둡니다
  · 실제로 던져지면 그 표를 '읽어 가며' 스택을 되감습니다

그래서 비용 구조가 이렇습니다
  · 던지지 않을 때 : 실행 비용 0 · 바이너리 크기 +5~15%
  · 던질 때        : 매우 비쌈 (수 마이크로초 — 표 조회 + 되감기)
                     ★ 게다가 시간이 일정하지 않습니다

→ 결론: '정상 흐름'에 예외를 쓰면 안 됩니다.
   루프 안에서 예외로 흐름을 제어하면 수백 배 느려집니다.
   반대로 '진짜 예외적인 상황'이라면 비용을 걱정할 이유가 없습니다.

측정하고 확인하기

# 예외 관련 섹션이 바이너리에서 차지하는 크기
size -A build/app | grep -E "eh_frame|except"

# 예외를 끄고 빌드하면 얼마나 줄어드는지 (임베디드에서 실제로 씁니다)
g++ -O2 -fno-exceptions -fno-rtti app.cpp -o app_small
#   ⚠ 표준 라이브러리 일부가 동작하지 않습니다
#     (std::vector 의 at() · std::stoi 등은 예외에 의존)
#     그래서 예외를 끈 프로젝트는 자체 컨테이너를 쓰는 경우가 많습니다

# 어디서 예외가 던져지는지 잡기 (gdb)
gdb ./app
(gdb) catch throw            # 던지는 순간 멈춥니다 ★
(gdb) run
(gdb) bt                     # 그 시점의 스택

# terminate 로 죽었을 때 원인 보기
(gdb) catch catch
(gdb) break std::terminate
// 잡히지 않은 예외의 정보를 남기기
std::set_terminate([]{
    if (auto e = std::current_exception()) {
        try { std::rethrow_exception(e); }
        catch (const std::exception& ex) { std::fprintf(stderr, "%s\n", ex.what()); }
    }
    std::abort();          // 코어 덤프를 남깁니다 (🔬 d11)
});
정리 — try/catch/throw 는 파이썬과 같습니다. 다른 점은 받을 때 const&, 다시 던질 땐 throw;, 소멸자에서는 던지지 않기 세 가지입니다. 그리고 C·파이썬·Node 경계를 예외가 넘어가면 안 됩니다 — 반드시 에러 코드로 바꿔 넘기세요.
예외가 지나갈 때 소멸자가 역순으로 불립니다 — 되감기 main — try { … } catch (const std::exception&) load_config() ifstream f; lock_guard lk; parse() throw parse_error{...} ② main 의 catch 가 잡습니다 여기까지 오는 동안 아무것도 새지 않았습니다 ① 되감기 — 지역 객체 소멸자가 선언 역순으로 lk 가 락을 풀고, f 가 파일을 닫습니다. finally 가 필요 없는 이유입니다 잡을 곳이 하나도 없으면 std::terminate 소멸자 안에서 예외가 새어 나가도 즉시 종료됩니다 그래서 C++ 의 에러 처리는 RAII 와 한 몸입니다 자원을 객체에 쥐여 두기만 하면 정상 반환·조기 반환·예외 세 경로가 전부 같은 정리 코드를 탑니다 — 실패 경로를 따로 쓰지 않습니다
예외를 쓰든 안 쓰든 자원은 객체가 쥐게 — 이 원칙 하나로 대부분의 누수가 사라집니다.
예외가 지나갈 때 소멸자가 역순으로 불립니다 — 되감기 main — try { … } catch (const std::exception&) load_config() ifstream f; lock_guard lk; parse() throw parse_error{...} ② main 의 catch 가 잡습니다 여기까지 오는 동안 아무것도 새지 않았습니다 ① 되감기 — 지역 객체 소멸자가 선언 역순으로 lk 가 락을 풀고, f 가 파일을 닫습니다. finally 가 필요 없는 이유입니다 잡을 곳이 하나도 없으면 std::terminate 소멸자 안에서 예외가 새어 나가도 즉시 종료됩니다 그래서 C++ 의 에러 처리는 RAII 와 한 몸입니다 자원을 객체에 쥐여 두기만 하면 정상 반환·조기 반환·예외 세 경로가 전부 같은 정리 코드를 탑니다 — 실패 경로를 따로 쓰지 않습니다
예외를 쓰든 안 쓰든 자원은 객체가 쥐게 — 이 원칙 하나로 대부분의 누수가 사라집니다.
CPP 12

치트시트 — 파이썬 · 자바에서 옮겨 오기

이미 아는 개념에 이름표만 새로 붙이면 됩니다. 이 표를 옆에 두고 남의 C++ 코드를 읽어 보세요.

이렇게도 쓸 수 있다 — 파이썬 ↔ C++ 대조표 🟡 중급

파이썬C++메모
■ 값과 자료구조
x = 10int x = 10;타입을 적습니다
lst = [1,2,3]std::vector<int> v{1,2,3};한 타입만
d = {"a": 1}std::unordered_map<std::string,int>정렬 필요하면 map
s = {1,2}std::set · unordered_set
t = (1,2)std::pair · structstruct 를 권장
Nonestd::optional<T>nullptr 과 다릅니다
lst.append(x)v.push_back(x) · emplace_back
len(lst)v.size()부호 없는 타입 ★
x in lststd::ranges::find(v,x) != v.end()set 이면 contains
■ 흐름
for x in lst:for (const auto& x : v)★ 기본형
for i,x in enumerate(v)for (size_t i=0;i<v.size();++i)C++23 은 views::enumerate
zip(a,b)views::zip (C++23)이전엔 인덱스로
[f(x) for x in v]views::transform · std::transform
sum(v)std::accumulate(v.begin(),v.end(),0)초기값 타입 주의
sorted(v)std::ranges::sort(v)제자리 정렬
lambda x: x*2[](int x){ return x*2; }대괄호가 캡처
■ 구조
def f(a, b=1)int f(int a, int b = 1)반환 타입 필요
class A:class A { ... };세미콜론 잊지 마세요
__init__생성자 A(...)
__del__소멸자 ~A()호출 시점이 확정적
__repr__operator<<
selfthis (대개 생략)포인터입니다
with open(...)RAII (소멸자)탭 2 m01
import m#include "m.h"텍스트 삽입
try/except/raisetry/catch/throwconst& 로 받기
f"{x:.2f}"std::format("{:.2f}", x)C++20

실무에선 이렇게 — 파이썬 개발자가 밟는 지뢰 10개 🔴 실무

①~⑤

// ① 변수를 초기화하지 않음 → 쓰레기 값 (0 이 아닙니다)
int n;              // ❌
int n = 0;          // ✅

// ② 대입이 복사임을 잊음
std::vector<int> b = a;   // 전체 복사 (파이썬은 참조였습니다)
auto& b = a;              // 참조가 필요하면 명시

// ③ 범위 for 에서 & 를 빠뜨려 원소마다 복사
for (auto s : names)          // ❌
for (const auto& s : names)   // ✅

// ④ 지역 변수의 참조·포인터를 반환
const std::string& f() { std::string s; return s; }   // ❌ 댕글링

// ⑤ map 의 [] 로 조회 — 없는 키를 '만들어' 버립니다
if (m["없는키"] == 0) ...     // ❌ 항목이 생깁니다
if (auto it = m.find(k); it != m.end()) ...   // ✅

⑥~⑩

// ⑥ 부호 없는 뺄셈
for (size_t i = 0; i < v.size() - 1; ++i)   // ❌ v 가 비면 폭주

// ⑦ 순회 중 컨테이너 수정 → 반복자 무효화 (조용히 죽습니다)

// ⑧ 인덱스 범위를 넘어도 에러가 안 남
v[100];             // ❌ UB — IndexError 가 아닙니다
v.at(100);          // ✅ 예외

// ⑨ 클래스 정의 뒤 세미콜론 누락
class A { };        // ← 이 세미콜론. 없으면 엉뚱한 에러가 쏟아집니다

// ⑩ 정수 나눗셈
1 / 2;              // 0   (파이썬 3 는 0.5 였습니다)
1.0 / 2;            // 0.5

// ★ 이 열 개 중 ④⑥⑦⑧ 은 AddressSanitizer 로 대부분 잡힙니다
//   (① 초기화 누락은 ASan 이 못 잡음 — -Wall 경고 · MemorySanitizer · Valgrind 로)
g++ -fsanitize=address,undefined -g main.cpp && ./a.out

한 단계 더 — 여기서 어디로 가나 🧬 전문가

이 가이드의 나머지 경로

  • 🧱 현대 C++ — RAII · 스마트 포인터 · 이동 시맨틱. 여기까지 해야 “C++ 를 쓴다”고 할 수 있습니다
  • 🐍 파이썬 모듈 — 이 가이드의 목적지. pybind11 로 함수 하나를 파이썬에 노출하는 것부터 numpy 제로카피·GIL 해제까지
  • 🟨 Node 애드온 — 같은 일을 N-API 로
  • 🔗 C ABI · FFI — 언어를 가리지 않는 가장 단순한 방법. 바인딩 없이 ctypes 로 바로 부르는 경로입니다
  • 🛠️ 빌드 · ⚡ 성능 · 🔬 전문가 — 배포와 최적화, 그리고 내부

최소한으로 시작하는 법

C++ 를 '다 배우고' 모듈을 만들려고 하면 시작을 못 합니다.
실제로 필요한 최소 집합은 이 정도입니다.

  ✅ 지금 당장 필요 (이 탭)
     타입 · 함수 · 참조 · vector · string · 클래스 · 헤더 분리

  ✅ 바로 다음 (탭 2에서 앞부분만)
     RAII · unique_ptr · const& 인자 · 이동의 개념

  ⏳ 나중에 필요할 때
     템플릿 · 컨셉 · 메모리 모델 · 커스텀 할당자

권장 순서
  ① 이 탭을 훑고  →  ② 🐍 y02 로 '첫 모듈'을 실제로 만들어 보고
  →  ③ 막히는 개념이 나오면 그때 탭 2로 돌아오기

만들어 본 뒤에 배우는 편이 훨씬 빠릅니다.
파이썬에서 import 되는 순간의 감각이 다음 학습을 끌고 갑니다.
기억할 한 가지 — 목표는 C++ 개발자가 되는 것이 아니라 파이썬·Node 가 못 하는 일을 대신할 모듈을 만드는 것입니다. 그 목적에는 이 탭 + 탭 2 앞부분 + 🐍 탭이면 충분합니다.
정리 — 파이썬에서 넘어올 때 진짜 새로 배우는 건 세 가지입니다 — 타입을 적는 것, 값이 복사된다는 것, 수명을 내가 관리한다는 것. 나머지는 이름만 다를 뿐 이미 아는 것입니다. 막히면 -fsanitize=address,undefined 를 켜세요 — 절반은 그 자리에서 답이 나옵니다.
RAII · 스마트 포인터 · 이동 · ranges · 컨셉

여기까지 해야
“C++ 를 쓴다”고
말할 수 있습니다

new·delete 를 손으로 쓰던 시절의 C++ 는 잊으세요. 현대 C++ 는 자원을 객체 수명에 묶고, 복사 대신 옮기고, 루프 대신 조합합니다. 이 탭의 앞 세 섹션만 익혀도 메모리 버그가 거의 사라집니다.

12SECTIONS
C++20기준
3먼저 볼 것
4깊이 층
MOD 01

RAII — C++ 의 심장

“가진 것은 사라질 때 알아서 반납한다”는 한 문장이 전부입니다. 파이썬의 with 가 모든 객체에 항상 걸려 있는 셈입니다.

RAII — 자원의 수명을 객체의 수명에 묶는다 ① 직접 여닫기 — 나가는 길마다 닫아야 한다 f = fopen(...) 중간 return → fclose 를 또 써야 함 예외 발생 → 아예 못 부름 나가는 길이 다섯 개면 닫는 코드도 다섯 개 — 하나만 빠뜨려도 누수 ② RAII — 나가는 길이 몇 개든 상관없다 std::ifstream f(...) — 생성자가 연다 중간 return → 소멸자가 닫는다 예외 발생 → 그래도 닫는다 닫는 코드를 아예 쓰지 않습니다 — 빠뜨릴 수가 없습니다 표준 라이브러리가 이미 주는 RAII 래퍼 unique_ptr 힙 메모리 lock_guard 뮤텍스 fstream 파일 핸들 jthread 스레드 합류
"닫는 것을 잊었다"는 버그가 C++ 에서 드문 이유입니다 — 잊을 코드 자체가 없습니다.

문제 — 손으로 정리하면 반드시 빠뜨립니다

void process(const char* path) {
    FILE* f = std::fopen(path, "r");
    std::mutex& m = get_mutex();
    m.lock();
    int* buf = new int[1024];

    if (!validate()) return;          // ❌ 셋 다 안 풀림
    if (parse(f) < 0) { m.unlock(); return; }   // ❌ 두 개 빠짐
    risky();                          // ❌ 예외가 나면 전부 샘

    delete[] buf;
    m.unlock();
    std::fclose(f);
}
//  경로가 늘어날수록 정리 코드가 곱하기로 늘어납니다.
//  예외까지 고려하면 사람이 지킬 수 없습니다.
다른 언어는 어떻게 했나 — 자바는 try-finally, 파이썬은 with, C# 은 using. 전부 “블록을 벗어날 때 정리” 를 문법으로 만든 것입니다. C++ 는 그걸 객체의 소멸자로 해결했고, 그래서 블록을 따로 쓸 필요가 없습니다.

해법 — 자원을 객체에 묶습니다

void process(const std::filesystem::path& path) {
    std::ifstream f(path);                    // 소멸자가 닫음
    std::lock_guard lk(get_mutex());          // 소멸자가 풂
    std::vector<int> buf(1024);               // 소멸자가 해제

    if (!validate()) return;      // ✅ 셋 다 자동 정리
    if (parse(f) < 0) return;     // ✅
    risky();                      // ✅ 예외가 나도 정리됨
}                                 // ✅ 정상 종료도 정리됨
//  ★ 정리 코드가 한 줄도 없습니다.

// 표준 라이브러리는 이미 전부 RAII 입니다
std::vector · std::string · std::unique_ptr · std::shared_ptr
std::lock_guard · std::unique_lock · std::scoped_lock
std::ifstream · std::ofstream · std::jthread(C++20)
std::filesystem 의 임시 경로 관리
기억할 한 줄 — “정리 코드를 쓰고 있다면, 그건 타입을 안 만든 것이다.” delete·close·unlock 을 직접 부르는 코드가 보이면 그 자원을 감쌀 클래스를 먼저 찾으세요. 대개 표준에 이미 있습니다.

이렇게도 쓸 수 있다 — 직접 RAII 타입 만들기 🟡 중급

C 라이브러리 자원 감싸기

// 방법 ① unique_ptr 에 커스텀 삭제자 — 클래스를 안 만들어도 됩니다 ★
struct FileCloser {
    void operator()(FILE* f) const noexcept { if (f) std::fclose(f); }
};
using FilePtr = std::unique_ptr<FILE, FileCloser>;

FilePtr open_file(const char* p) {
    FilePtr f(std::fopen(p, "rb"));
    if (!f) throw std::runtime_error("열기 실패");
    return f;                          // 이동으로 나갑니다
}
// 사용
auto f = open_file("a.bin");
std::fread(buf, 1, n, f.get());        // 원본 포인터가 필요하면 .get()

// 방법 ② 직접 클래스 (더 많은 제어가 필요할 때)
class Socket {
    int fd_ = -1;
public:
    explicit Socket(int fd) noexcept : fd_(fd) {}
    ~Socket() { if (fd_ >= 0) ::close(fd_); }

    Socket(const Socket&)            = delete;     // 복사 금지 ★
    Socket& operator=(const Socket&) = delete;
    Socket(Socket&& o) noexcept : fd_(std::exchange(o.fd_, -1)) {}
    Socket& operator=(Socket&& o) noexcept {
        if (this != &o) { if (fd_ >= 0) ::close(fd_); fd_ = std::exchange(o.fd_, -1); }
        return *this;
    }
    int get() const noexcept { return fd_; }
};

임시 상태 되돌리기 — scope guard

// "블록을 벗어날 때 이걸 실행해" 를 즉석에서 만드는 관용구
template<class F>
class ScopeExit {
    F f_;
    bool active_ = true;
public:
    explicit ScopeExit(F f) : f_(std::move(f)) {}
    ~ScopeExit() { if (active_) f_(); }
    void dismiss() noexcept { active_ = false; }   // 취소
    ScopeExit(const ScopeExit&) = delete;
};
template<class F> ScopeExit(F) -> ScopeExit<F>;   // 추론 가이드 (C++17)

// 사용 — 원래 값으로 되돌리기
void render() {
    auto old = get_state();
    set_state(temp);
    ScopeExit restore([&]{ set_state(old); });     // ★ 어떤 경로로 나가도 복원

    draw();                    // 예외가 나도 복원됩니다
}

// 트랜잭션 패턴 — 성공하면 취소
void insert(Db& db, const Row& r) {
    db.begin();
    ScopeExit rollback([&]{ db.rollback(); });
    db.exec(r);
    db.commit();
    rollback.dismiss();        // ★ 여기까지 왔으면 롤백하지 않음
}
표준에는 아직 없습니다 — Library Fundamentals TS v3 의 std::experimental::scope_exit, 그리고 absl::Cleanup·gsl::finally 같은 구현이 널리 쓰입니다. 20줄짜리 이 클래스 하나가 정리 누락을 통째로 없앱니다.

실무에선 이렇게 — 0의 법칙과 예외 안전 🔴 실무

가장 좋은 클래스는 아무것도 안 쓴 클래스

// ★ 0의 법칙 — 소멸자·복사·이동을 하나도 정의하지 않는 것이 최선입니다
class Report {
    std::string              title_;
    std::vector<double>      data_;
    std::unique_ptr<Cache>   cache_;
    // 여섯 개 특수 멤버가 전부 올바르게 자동 생성됩니다 ★
};

// 5의 법칙 — 하나라도 직접 쓰면 나머지도 검토해야 합니다
//   소멸자 / 복사 생성 / 복사 대입 / 이동 생성 / 이동 대입
// 특히 ⚠ 소멸자를 정의하면 '이동'이 자동 생성되지 않아
//        조용히 복사로 후퇴합니다 (성능이 몇 배 나빠짐)

class Buffer {
public:
    ~Buffer() { log("해제"); }              // 소멸자를 썼더니
    Buffer(Buffer&&) = default;             // ★ 이것들을 명시해야 합니다
    Buffer& operator=(Buffer&&) = default;
    Buffer(const Buffer&) = default;
    Buffer& operator=(const Buffer&) = default;
private:
    std::vector<std::byte> d_;
};

// 검증 — 컴파일 시점에 못 박아 두세요
static_assert(std::is_nothrow_move_constructible_v<Buffer>);

예외 안전성 세 등급

등급보장
기본자원이 안 새고 객체는 유효한 상태 (최소 기준)
강한실패하면 아무 일도 없던 것처럼 (트랜잭션)
무예외noexcept — 절대 던지지 않음
// 강한 보장의 관용구 — copy and swap
class Config {
    std::map<std::string, std::string> kv_;
public:
    void merge(const Config& other) {
        Config tmp(*this);              // ① 사본에서 작업
        for (const auto& [k, v] : other.kv_)
            tmp.kv_[k] = v;             //    여기서 던져도 원본은 무사
        swap(tmp);                      // ② 성공했을 때만 교체 (noexcept)
    }
    void swap(Config& o) noexcept { kv_.swap(o.kv_); }
};

// ★ 반드시 noexcept 여야 하는 것들
//   소멸자 (기본으로 noexcept — 던지면 terminate)
//   이동 생성자·이동 대입   ← vector 재할당 성능에 직결
//   swap
//   해제 함수
소멸자에서 던지면 프로그램이 죽습니다 — 예외 되감기 중에 또 던지면 std::terminate 입니다. 소멸자에서 실패할 수 있는 일(flush·close)을 한다면 try/catch 로 감싸 로그만 남기고, 명시적 close() 메서드를 따로 제공하세요.

한 단계 더 — 소멸 순서와 수명의 규칙 🧬 전문가

언제 어떤 순서로 불리나

// ① 지역 객체 — 선언의 역순 ★
void f() {
    A a;    // 세 번째로 소멸
    B b;    // 두 번째
    C c;    // 첫 번째
}           // ~C → ~B → ~A

// ② 멤버 — 선언 순서의 역순 (초기화는 선언 순서)
class X {
    A a_;   // 두 번째 소멸
    B b_;   // 첫 번째 소멸
};

// ③ 임시 객체 — 그 식(full-expression)이 끝날 때
f(std::string("임시").c_str());     // f 가 끝난 뒤 소멸 — 안전
const char* p = std::string("임시").c_str();
use(p);                             // ❌ 이미 소멸 — 댕글링

// ④ 임시의 수명 연장 — const 참조에 바인딩하면 늘어납니다
const std::string& s = make_string();   // ✅ s 의 수명까지 연장
std::string_view sv = make_string();    // ❌ view 는 연장되지 않습니다 ★

// ⑤ 정적 객체 — main 이 끝난 뒤, 생성의 역순
//    ⚠ 번역 단위 간의 초기화 순서는 정해져 있지 않습니다 (정적 초기화 순서 문제)
//    → 함수 안 static 으로 피하세요 (첫 호출 때 생성 · 스레드 안전)
Logger& logger() { static Logger l; return l; }

RAII 가 깨지는 자리

// ① 소멸자가 불리지 않는 경우들
std::exit(1);          // 지역 객체의 소멸자가 불리지 않습니다
std::abort();          // 마찬가지
std::terminate();
longjmp();             // C 의 점프 — 소멸자를 건너뜁니다 ★
//   → 종료 처리가 필요하면 std::atexit 이나 명시적 정리를 쓰세요

// ② detach 된 스레드
std::thread t(work);
t.detach();            // main 이 끝나면 스레드 객체가 정리 안 된 채 종료
//   → std::jthread (C++20) 는 소멸자에서 join 합니다 ★

// ③ 순환 참조 (shared_ptr)
struct Node { std::shared_ptr<Node> next; };
//   서로를 가리키면 카운트가 0이 되지 않아 소멸자가 안 불립니다 (m02)

// ④ 예외가 스레드 밖으로 나가면
std::thread t([]{ throw std::runtime_error("x"); });   // terminate
//   → 스레드 함수 전체를 try 로 감싸세요

// ⑤ 컨테이너에 원시 포인터를 담은 경우
std::vector<Widget*> v;    // 벡터가 비워져도 Widget 은 안 지워집니다
std::vector<std::unique_ptr<Widget>> v2;   // ✅
진단 도구 — RAII 위반은 AddressSanitizer 와 LeakSanitizer 로 대부분 잡힙니다. -fsanitize=address 로 빌드하고 테스트를 돌리면 누수된 할당의 스택 트레이스까지 나옵니다(🛠️ b10).
정리 — RAII 는 “정리 코드를 쓰지 않는 기술”입니다. 자원을 객체 멤버로 두면 return 이든 예외든 어떤 경로로 나가도 자동으로 정리됩니다. 가장 좋은 클래스는 소멸자를 아예 안 쓴 클래스이고, delete·close·unlock 이 보이면 감쌀 타입을 찾으세요.
MOD 02

스마트 포인터 — new·delete 를 지웁니다

이것만 써도 메모리 누수의 90%가 사라집니다. 핵심은 “누가 소유하는가”를 타입으로 표현하는 것입니다.

누가 이 메모리를 지울 책임을 지는가 — 세 가지 답 unique_ptr 소유자는 언제나 하나 복사 불가 · 이동만 가능 런타임 비용이 0 이다 기본값 — 여기서 시작하세요 shared_ptr 참조 개수를 함께 센다 0 이 되는 순간 해제 원자적 증감 비용이 있다 정말 공유가 필요할 때만 weak_ptr 소유하지 않는다 살아 있는지만 확인 lock() 으로 잠깐 빌린다 캐시 · 관찰자 · 부모 참조 shared_ptr 만 있을 때 생기는 문제 — 순환 참조 A 개수 1 B 개수 1 한쪽을 weak_ptr 로 바꾸면 풀린다 보통 자식 → 부모 방향을 weak 로 둡니다 서로를 가리키면 개수가 0 이 되지 않아 영영 해제되지 않습니다 — GC 가 없는 언어의 대표적 누수입니다.
고르는 순서는 값 → unique_ptr → shared_ptr 입니다. 마지막까지 오는 경우는 생각보다 드뭅니다.

unique_ptr — 기본값

#include <memory>

auto p = std::make_unique<Widget>(arg1, arg2);   // ★ new 대신 이것
p->draw();
(*p).draw();
p.get();          // 원본 포인터 (C API 에 넘길 때)
p.reset();        // 지금 해제
p = nullptr;      // 같은 뜻

// 복사 불가 · 이동만 가능 — "소유자는 하나"를 타입이 강제합니다
auto q = p;              // ❌ 컴파일 에러
auto q = std::move(p);   // ✅ 소유권 이전 (p 는 이제 nullptr)

// 함수 인터페이스로 의도를 표현
std::unique_ptr<Widget> create();          // "받는 쪽이 소유합니다"
void consume(std::unique_ptr<Widget> w);   // "넘기면 내 것이 됩니다"
void use(const Widget& w);                 // "잠깐 빌려 봅니다" ★
void use(Widget* w);                       // "빌려 보되 없을 수도"

// 배열도 됩니다 (대개 vector 가 낫습니다)
auto arr = std::make_unique<double[]>(1000);
arr[0] = 1.0;

// 비용 — 원시 포인터와 완전히 같습니다 (크기 8바이트, 오버헤드 0) ★

shared_ptr — 정말 필요할 때만

auto s = std::make_shared<Widget>(args);   // 참조 카운트 방식
auto s2 = s;                                // 카운트 +1
s.use_count();                              // 현재 소유자 수
//  마지막 shared_ptr 이 사라질 때 해제됩니다

// weak_ptr — 보기만 하고 수명에 관여하지 않습니다
std::weak_ptr<Widget> w = s;
if (auto locked = w.lock()) {               // 살아 있으면 shared_ptr 획득
    locked->draw();
}

// ⚠ 순환 참조 — 서로 shared_ptr 이면 영원히 해제되지 않습니다 ★
struct Node {
    std::shared_ptr<Node> child;
    std::weak_ptr<Node>   parent;    // ★ 부모는 weak 로 끊습니다
};

// 비용 — 원시 포인터의 두 배 크기 + 원자적 카운트 연산
sizeof(std::unique_ptr<int>);   // 8
sizeof(std::shared_ptr<int>);   // 16 (포인터 + 제어 블록 포인터)
//   복사할 때마다 원자 연산이 일어나 멀티스레드에서 경합이 생깁니다
shared_ptr 을 기본값으로 쓰지 마세요 — “누가 소유하는지 생각하기 귀찮아서” 쓰는 경우가 많은데, 그러면 수명이 언제 끝나는지 아무도 모르는 코드가 됩니다. 정말로 소유자가 여럿일 때만(캐시·그래프·비동기 완료 대기) 쓰세요.

이렇게도 쓸 수 있다 — 커스텀 삭제자와 C API 연동 🟡 중급

C 라이브러리 자원 관리

// ① 함수 객체 삭제자 — 크기가 늘지 않습니다 ★ (권장)
struct SqliteCloser {
    void operator()(sqlite3* db) const noexcept { sqlite3_close(db); }
};
using DbPtr = std::unique_ptr<sqlite3, SqliteCloser>;
sizeof(DbPtr);        // 8 — 원시 포인터와 같음

// ② 함수 포인터 삭제자 — 포인터 하나만큼 커집니다
using FilePtr = std::unique_ptr<FILE, decltype(&std::fclose)>;
FilePtr f(std::fopen("a.txt","r"), &std::fclose);
sizeof(FilePtr);      // 16

// ③ 람다 삭제자 (C++20 부터 편해졌습니다)
auto del = [](void* p){ std::free(p); };
std::unique_ptr<void, decltype(del)> buf(std::malloc(1024), del);

// ④ shared_ptr 은 삭제자가 타입에 안 들어갑니다 (제어 블록에 저장)
std::shared_ptr<FILE> sf(std::fopen("a.txt","r"),
                         [](FILE* f){ if (f) std::fclose(f); });
//   ⚠ unique_ptr 와 달리 포인터가 nullptr 이어도 삭제자를 부릅니다 → 널 검사 필수
//   타입이 단순해 컨테이너에 담기 편하지만 그만큼 비용이 있습니다

// C API 에 넘기고 되받기
void give_to_c() {
    auto p = std::make_unique<Widget>();
    c_api_register(p.release());          // ★ 소유권을 C 로 넘김
}
void take_from_c(Widget* raw) {
    std::unique_ptr<Widget> p(raw);       // ★ 소유권을 되찾음
}

다형성과 Pimpl

// 인터페이스를 unique_ptr 로 다루기
std::vector<std::unique_ptr<Shape>> shapes;
shapes.push_back(std::make_unique<Circle>(1.0));
shapes.push_back(std::make_unique<Rect>(2.0, 3.0));
for (const auto& s : shapes) total += s->area();
//  ★ 기반 클래스에 virtual 소멸자가 반드시 있어야 합니다

// 팩토리
std::unique_ptr<Shape> make_shape(std::string_view kind) {
    if (kind == "circle") return std::make_unique<Circle>(1.0);
    if (kind == "rect")   return std::make_unique<Rect>(1.0, 2.0);
    return nullptr;
}

// Pimpl — 헤더에서는 불완전 타입이라 주의가 필요합니다
// widget.h
class Widget {
    struct Impl;
    std::unique_ptr<Impl> p_;
public:
    Widget();
    ~Widget();                       // ★ 선언만! 정의는 .cpp 에
    Widget(Widget&&) noexcept;
    Widget& operator=(Widget&&) noexcept;
};
// widget.cpp
struct Widget::Impl { /* ... */ };
Widget::Widget() : p_(std::make_unique<Impl>()) {}
Widget::~Widget() = default;         // ★ Impl 이 완전한 이 자리에서
//   헤더에 = default 를 두면 "incomplete type" 에러가 납니다

실무에선 이렇게 — 소유권을 설계로 표현하기 🔴 실무

함수 시그니처가 곧 계약입니다

쓸 것의미
T (값)내 복사본을 갖는다
const T&반드시 있는 것을 읽기만 ★
T&있는 것을 수정한다
const T*없을 수도 있는 것을 읽기만
unique_ptr<T> (값)소유권을 넘긴다
unique_ptr<T>&소유자 자체를 바꾼다 (드묾)
shared_ptr<T> (값)소유를 공유한다
const shared_ptr<T>&공유할지 내가 정한다
// ❌ 흔한 실수 — 읽기만 하는데 shared_ptr 을 값으로 받기
void draw(std::shared_ptr<Widget> w);    // 호출마다 원자 카운트 증감
// ✅ 소유에 관심이 없으면 참조나 원시 포인터로
void draw(const Widget& w);              // ★ 이것이 기본
//   호출 쪽: draw(*ptr);

팀 규칙으로 굳히기

// ① new / delete 를 코드에서 금지 (린터로 강제)
//    .clang-tidy
//      Checks: 'modernize-make-unique,
//               cppcoreguidelines-owning-memory,
//               cppcoreguidelines-no-malloc,
//               bugprone-use-after-move'

// ② 원시 포인터는 '빌려 보는 것'만 — 소유를 표현하지 않습니다
//    소유가 필요하면 unique_ptr, 관찰만이면 T* 또는 T&

// ③ make_unique / make_shared 를 쓰세요
auto p = std::unique_ptr<Widget>(new Widget(a, b));   // ❌ 장황
auto p = std::make_unique<Widget>(a, b);              // ✅
//   ★ f(std::unique_ptr<A>(new A), g()) 는 예외 시 누수 가능성이 있었습니다
//     (C++17 에서 완화됐지만 make_ 를 쓰는 편이 안전하고 짧습니다)

// ④ make_shared 의 장단
//    ✅ 객체와 제어 블록을 한 번에 할당 → 할당 1회 · 캐시 유리
//    ⚠ weak_ptr 이 남아 있으면 객체 메모리도 함께 유지됩니다
//      (큰 객체 + 오래 사는 weak_ptr 조합에서는 shared_ptr(new T) 가 나을 수 있음)
파이썬 모듈을 만들 때 — pybind11 에 노출할 클래스는 py::class_<T, std::shared_ptr<T>> 로 등록하면 파이썬 참조 카운트와 C++ 소유권이 함께 관리됩니다(🐍 y05). C++ 쪽에서도 shared_ptr 로 들고 있다면 이 형태가 필수입니다.

한 단계 더 — 제어 블록과 실제 비용 🧬 전문가

shared_ptr 내부

shared_ptr 은 두 개의 포인터입니다
  ① 객체 포인터
  ② 제어 블록 포인터 → { 강한 카운트, 약한 카운트, 삭제자, 할당자 }

make_shared<T>(args)
  객체와 제어 블록을 한 번에 할당 (할당 1회) ★
  → 캐시 지역성이 좋고 빠릅니다
  ⚠ 대신 weak_ptr 이 하나라도 살아 있으면
    객체 메모리 전체가 해제되지 않습니다 (제어 블록과 붙어 있으므로)

shared_ptr<T>(new T(args))
  할당 2회 (객체 + 제어 블록)
  → weak_ptr 이 남아도 객체 메모리는 즉시 해제됩니다

카운트 연산 비용
  · 복사·소멸마다 원자적 증감 (lock 접두 명령)
  · 여러 스레드가 같은 shared_ptr 을 복사하면 캐시 라인 경합 ★
  · 그래서 핫 루프에서 shared_ptr 을 값으로 넘기면 눈에 띄게 느려집니다
    → const shared_ptr& 또는 원시 참조로 넘기세요

측정하고 확인하기

// 할당 횟수를 세어 보기
static std::atomic<int> g_allocs{0};
void* operator new(std::size_t n) { ++g_allocs; return std::malloc(n); }
void  operator delete(void* p) noexcept { std::free(p); }

int main() {
    { auto p = std::make_shared<Widget>(); }          // 1회
    { auto p = std::shared_ptr<Widget>(new Widget); } // 2회
    std::printf("%d\n", g_allocs.load());
}
// 값 전달 vs 참조 전달의 차이를 재 보기
void by_value(std::shared_ptr<Widget> p)        { use(*p); }
void by_ref(const std::shared_ptr<Widget>& p)   { use(*p); }
void by_raw(const Widget& w)                    { use(w); }

// 1000만 번 호출 시 (환경마다 다릅니다 — 직접 재세요)
//   by_value : 원자 연산 2000만 번 → 눈에 띄게 느림
//   by_ref   : 거의 0
//   by_raw   : 0  ★ 인터페이스로 가장 좋습니다

// 원자 연산 비용 확인
// perf stat -e cache-misses,LLC-store-misses ./bench
멀티스레드에서 특히 조심 — 여러 스레드가 같은 객체를 가리키는 shared_ptr 을 복사하면, 제어 블록의 카운트가 같은 캐시 라인에 있어 심한 경합이 생깁니다(⚡ e08). 스레드마다 필요한 것을 미리 복사해 두고 루프 안에서는 참조로 쓰세요.
정리 — new·delete 를 직접 쓰지 마세요. make_unique 가 기본이고, 소유자가 정말 여럿일 때만 shared_ptr, 순환은 weak_ptr 로 끊습니다. 함수 인자로는 소유에 관심이 없으면 const T& — 이 한 줄이 성능과 가독성을 둘 다 지켜 줍니다.
MOD 03

이동 시맨틱 — 복사하지 않고 옮기기

100MB 짜리 벡터를 함수에서 돌려줄 때 복사가 일어나지 않는 이유입니다. 개념은 하나 — “어차피 사라질 것이라면 내용을 훔쳐 오면 된다.”

복사와 이동 — 힙에 있는 1000개를 옮기는 두 가지 방법 ① 복사 — auto b = a; a b 힙 · 1000개 힙 · 새 1000개 원소 수에 비례한 시간 + 메모리가 두 배로 듭니다 ② 이동 — auto b = std::move(a); a (비었다) b 힙 · 1000개 (그대로 둔다) 주소 · 크기 · 용량 세 값만 옮깁니다 — 원소 수와 무관하게 일정합니다 이동한 뒤의 a 는 어떤 상태인가 유효하지만, 값은 정해져 있지 않습니다 a.size() 를 부르는 것은 합법이고 a[0] 을 읽는 것은 UB 입니다 — 새 값을 넣기 전에는 쓰지 마세요
함수에서 큰 객체를 그냥 return 하면 컴파일러가 알아서 이동하거나 아예 생략합니다 — std::move 를 붙이지 마세요.

복사와 이동의 차이

std::vector<int> a(1'000'000);

// 복사 — 100만 개를 새 메모리에 전부 옮겨 적습니다 (느림)
std::vector<int> b = a;              // a 는 그대로 살아 있습니다

// 이동 — 내부 포인터만 넘겨받습니다 (거의 공짜) ★
std::vector<int> c = std::move(a);   // a 는 '비어 있지만 유효한' 상태가 됩니다
a.size();                            // 0 (읽어도 UB 는 아닙니다)
a.push_back(1);                      // 다시 써도 됩니다

// vector 내부를 그림으로 보면
//   복사: [ptr|size|cap] → 새 버퍼를 잡고 100만 개 memcpy
//   이동: [ptr|size|cap] 세 값만 옮기고 원본은 nullptr 로  ★ 8바이트 3개

// std::move 는 '옮기는 함수'가 아닙니다 ★
//   그냥 "이건 훔쳐 가도 된다"고 표시하는 캐스트일 뿐입니다.
//   실제 이동은 이동 생성자·이동 대입이 합니다.
이동 후의 객체는 “유효하지만 값이 정해지지 않은 상태”입니다. size()·clear()·재대입은 안전하지만, 내용이 무엇인지 가정하면 안 됩니다. 표준 컨테이너는 대개 비어 있게 되지만 표준이 보장하는 것은 아닙니다.

언제 자동으로 이동되나

// ① 임시 객체(rvalue)를 대입할 때 — 자동으로 이동
std::vector<int> v = make_vector();     // 이동 (또는 RVO 로 아예 생략)
v = make_vector();                       // 이동 대입

// ② 함수에서 지역 변수를 반환할 때 — 자동
std::vector<int> f() {
    std::vector<int> local(1000);
    return local;                        // ✅ 이동 (사실 대개 RVO 로 복사·이동 둘 다 생략)
    // return std::move(local);          // ❌ 오히려 RVO 를 막습니다
}

// ③ 컨테이너에 임시를 넣을 때
v.push_back(make_widget());              // 이동
v.push_back(std::move(w));               // 명시적 이동
v.emplace_back(args...);                 // 아예 그 자리에서 생성 ★

// ④ 명시적으로 넘길 때
process(std::move(big_data));            // "더 안 쓸 거니까 가져가"

// ⚠ const 객체는 이동되지 않습니다 (복사로 후퇴)
const std::vector<int> cv(1000);
auto x = std::move(cv);                  // 조용히 '복사'됩니다 ★
//   → 이동시킬 값에 const 를 붙이지 마세요

이렇게도 쓸 수 있다 — 직접 구현하기 🟡 중급

이동 생성자·이동 대입

class Buffer {
    std::size_t n_ = 0;
    double*     p_ = nullptr;
public:
    explicit Buffer(std::size_t n) : n_(n), p_(new double[n]{}) {}
    ~Buffer() { delete[] p_; }

    // 복사 — 새로 할당하고 내용을 옮겨 적습니다
    Buffer(const Buffer& o) : n_(o.n_), p_(new double[o.n_]) {
        std::copy_n(o.p_, n_, p_);
    }

    // 이동 — 포인터를 훔치고 원본을 비웁니다 ★ noexcept 필수
    Buffer(Buffer&& o) noexcept
        : n_(std::exchange(o.n_, 0)),
          p_(std::exchange(o.p_, nullptr)) {}

    // 이동 대입 — 내 것을 버리고 훔쳐 옵니다
    Buffer& operator=(Buffer&& o) noexcept {
        if (this != &o) {                    // ★ 자기 대입 방어
            delete[] p_;
            n_ = std::exchange(o.n_, 0);
            p_ = std::exchange(o.p_, nullptr);
        }
        return *this;
    }
    Buffer& operator=(const Buffer& o) {      // copy and swap 관용구
        Buffer tmp(o);
        std::swap(n_, tmp.n_);
        std::swap(p_, tmp.p_);
        return *this;
    }
};
사실 이 클래스는 만들 필요가 없습니다 — std::vector<double> 를 멤버로 두면 여섯 개 특수 멤버가 전부 공짜로 올바르게 만들어집니다(0의 법칙). 직접 구현은 진짜로 원시 자원을 감쌀 때만 하세요.

noexcept 가 성능을 가릅니다 ★

// vector 가 커질 때 기존 원소를 새 버퍼로 옮겨야 합니다.
// 이때 '이동'을 쓸지 '복사'를 쓸지는 noexcept 여부로 정해집니다.

struct A { A(A&&) noexcept; };     // ✅ 이동으로 옮김 (빠름)
struct B { B(const B&); B(B&&); }; // ❌ 이동이 던질 수 있음 → 복사로 후퇴
//   (복사 생성자가 없으면 noexcept 가 아니어도 이동을 씁니다)
//   왜? 이동 중에 예외가 나면 '원본도 새 버퍼도 망가진' 상태가 됩니다.
//   복사는 실패해도 원본이 무사하므로 강한 예외 보장을 지킬 수 있습니다.

// 확인
static_assert(std::is_nothrow_move_constructible_v<MyType>,
              "이동 생성자에 noexcept 를 붙이세요");

// 실제 차이 — 문자열 100만 개를 담은 vector 를 늘릴 때
//   noexcept 있음 : 포인터만 옮김        → 수 ms
//   없음          : 문자열 전부 복사     → 수백 ms  ★ 100배 차이

// 이동 후 사용 금지 — 린터가 잡아 줍니다
std::string s = "안녕";
auto t = std::move(s);
use(s);                    // ⚠ clang-tidy: bugprone-use-after-move

실무에선 이렇게 — 복사를 없애는 인터페이스 🔴 실무

'보관할 것'은 값으로 받고 move

class Logger {
    std::string name_;
public:
    // ✅ 관용구 — 복사가 딱 한 번만 일어납니다
    explicit Logger(std::string name) : name_(std::move(name)) {}
};

Logger a("파일");                     // 임시 → 이동 (복사 0)
std::string n = "파일";
Logger b(n);                          // 복사 1회 (불가피)
Logger c(std::move(n));               // 이동 (복사 0)

// ❌ 예전 방식 — const& 로만 받으면 임시여도 복사가 일어납니다
explicit Logger(const std::string& name) : name_(name) {}   // 항상 복사 1회

// ⚠ 값으로 받고 move 하지 않으면 복사가 두 번 됩니다
explicit Logger(std::string name) : name_(name) {}          // ❌ move 누락

// 컨테이너에 넣을 때
v.push_back(w);              // 복사
v.push_back(std::move(w));   // 이동
v.emplace_back(a, b);        // ★ 임시 객체 없이 그 자리에서 생성

// map 에 넣기
m.emplace(key, std::move(value));
m.try_emplace(key, args...);          // ★ 이미 있으면 value 를 만들지도 않음

복사가 남아 있는 곳 찾기

// ① 진단용 구조체를 프로젝트에 하나 두세요 (기초 탭 x07 과 같은 것)
struct Trace {
    Trace() { std::puts("생성"); }
    Trace(const Trace&) { std::puts("★복사★"); }
    Trace(Trace&&) noexcept { std::puts("이동"); }
    ~Trace() { std::puts("소멸"); }
};

// ② 컴파일러 경고로 잡기
//    -Wreturn-std-move  -Wpessimizing-move  -Wredundant-move
g++ -Wall -Wextra -Wpessimizing-move -Wredundant-move
//   return std::move(local);  → warning: moving a local object prevents copy elision

// ③ clang-tidy 규칙
//    performance-unnecessary-value-param
//    performance-move-const-arg
//    performance-for-range-copy        ← for (auto x : v) 잡아 줍니다 ★
//    modernize-pass-by-value

// ④ 프로파일러로 확인
//    perf record -g ./app && perf report
//    memcpy · operator new 가 상위에 보이면 복사가 남아 있습니다
가장 자주 발견되는 세 가지 — ① for (auto x : v) 에서 & 누락, ② 큰 객체를 값으로 받는 함수 인자, ③ map 순회에서 pair<const K, V> 를 잘못 적어 생기는 변환 복사. clang-tidy 한 번이면 전부 나옵니다.

한 단계 더 — 값 카테고리와 완벽 전달 🧬 전문가

lvalue · rvalue · xvalue

C++ 의 모든 식은 값 카테고리를 가집니다

            expression
            /        \
        glvalue      rvalue
        /     \      /     \
    lvalue    xvalue      prvalue

  lvalue  : 이름이 있고 주소를 얻을 수 있음      int x;  x
  prvalue : 순수한 임시값                        42,  f()
  xvalue  : 곧 사라질 것 (eXpiring)              std::move(x)
  rvalue  : prvalue + xvalue → '훔쳐 가도 되는 것'

  T&   (lvalue 참조)  : lvalue 에만 바인딩
  const T&            : 둘 다 바인딩 (그래서 예전엔 이것만 썼습니다)
  T&&  (rvalue 참조)  : rvalue 에만 바인딩 → 이동의 근거 ★

  ★ 핵심 혼동: 'T&& 인 변수 자체는 lvalue 입니다'
    void f(Widget&& w) { g(w); }              // 복사됩니다!
    void f(Widget&& w) { g(std::move(w)); }   // ✅ 이동
    (이름이 있으면 lvalue — 예외 없는 규칙입니다)

완벽 전달 — 받은 그대로 넘기기

// 템플릿의 T&& 는 rvalue 참조가 아니라 '전달 참조' 입니다 ★
template<class T>
void wrapper(T&& arg) {                 // T 가 추론될 때만 전달 참조
    inner(std::forward<T>(arg));        // lvalue 면 lvalue 로, rvalue 면 rvalue 로
}
//   forward 는 '조건부 move' 입니다 — 원래 카테고리를 복원합니다

// 참조 접힘 규칙 (왜 이게 되는지)
//   T = Widget&   → Widget& &&  → Widget&    (lvalue 를 넘긴 경우)
//   T = Widget    → Widget&&               (rvalue 를 넘긴 경우)

// 실제 쓰임 — make_unique 의 구현이 이것입니다
template<class T, class... Args>
std::unique_ptr<T> make_unique(Args&&... args) {
    return std::unique_ptr<T>(new T(std::forward<Args>(args)...));
}

// emplace_back 도 같은 원리 — 인자를 그대로 생성자에 전달합니다
v.emplace_back(std::string("x"), 42);   // 임시 없이 그 자리에서 생성

// ⚠ 전달 참조는 오버로드를 다 잡아먹습니다
template<class T> void f(T&&);          // 거의 모든 타입에 매칭
void f(int);                            // 정확히 int 일 때만 이깁니다
short s = 1;  f(s);                     //   short·long·char 는 템플릿으로 갑니다
//   → 컨셉으로 제약을 걸어야 합니다 (m09)
template<class T> requires (!std::integral<std::remove_cvref_t<T>>)
void f(T&&);                            // 정수는 전부 f(int) 로
//   (T&& 에 lvalue 가 오면 T = int& 라 remove_cvref_t 로 벗겨서 검사)
실무에서 실제로 쓰는 규칙 — std::forward 는 템플릿 안에서 전달 참조를 그대로 넘길 때만 씁니다. 그 외에는 전부 std::move 입니다. 헷갈리면 “타입이 T&& 이고 T 가 추론되면 forward, 아니면 move”로 외우세요.
정리 — std::move 는 “이건 훔쳐 가도 된다”는 표시일 뿐입니다. 큰 객체를 넘길 때 복사 대신 포인터만 옮겨 거의 공짜가 됩니다. 직접 만든다면 이동 생성자에 noexcept 를 반드시 붙이고, 보관할 인자는 값으로 받아 std::move 하세요.
MOD 04

const 와 constexpr

const 는 “안 바꾸겠다는 약속”, constexpr 은 “실행 전에 미리 계산해 두기”입니다. 둘 다 비용 없이 안전과 속도를 얻는 도구입니다.

const 정확성

class Report {
    std::vector<double> data_;
    mutable std::optional<double> cache_;   // ★ const 함수에서도 바꿀 수 있음
public:
    // 읽기만 하는 함수에는 반드시 const
    std::size_t size() const noexcept { return data_.size(); }

    double mean() const {                   // const 함수인데 캐시는 갱신
        if (!cache_) cache_ = compute();
        return *cache_;
    }

    // const 버전과 비const 버전을 함께 제공하는 관용구
    double&       at(std::size_t i)       { return data_[i]; }
    const double& at(std::size_t i) const { return data_[i]; }
};

const Report r = load();
r.size();      // ✅ const 함수만 부를 수 있습니다
r.at(0) = 1;   // ❌ 컴파일 에러 — 여기서 실수를 막습니다

// 왜 중요한가
//   ① 의도가 코드에 드러납니다 (주석이 아니라 컴파일러가 보증)
//   ② const 객체는 여러 스레드가 동시에 읽어도 안전합니다 ★
//   ③ 컴파일러가 더 나은 코드를 만듭니다

constexpr — 컴파일 시점 계산

constexpr int square(int x) { return x * x; }

constexpr int a = square(5);      // 컴파일 시점에 25 로 확정 ★
int n = read_input();
int b = square(n);                // 런타임에 계산 (같은 함수로 둘 다 됩니다)

// 배열 크기 · 템플릿 인자로 쓸 수 있습니다
constexpr std::size_t kBufSize = 1024 * 4;
std::array<std::byte, kBufSize> buf;

// C++20 — 반드시 컴파일 시점에만 실행되게
consteval int must_be_compile_time(int x) { return x * 2; }

// 컴파일 시점에 실행 중인지 분기
constexpr int f(int x) {
    if (std::is_constant_evaluated()) return slow_but_exact(x);
    return fast_approx(x);
}

// 표준 라이브러리도 상당수 constexpr 입니다 (C++20+)
constexpr std::array<int, 5> sorted = []{
    std::array<int, 5> a{5,3,1,4,2};
    std::sort(a.begin(), a.end());    // ★ 컴파일 시점에 정렬됩니다
    return a;
}();

// 매크로를 대체합니다
#define MAX 100                       // ❌ 타입도 스코프도 없음
constexpr int kMax = 100;             // ✅

이렇게도 쓸 수 있다 — 불변으로 설계하기 🟡 중급

값 객체를 불변으로

// 설정·좌표·금액처럼 '값'인 것은 만들고 나서 안 바뀌는 편이 안전합니다
struct Money {
    const std::int64_t cents;          // 원 단위 정수 (기초 탭 x02)
    const char*        currency;

    constexpr Money add(Money o) const {
        return Money{cents + o.cents, currency};   // 새 값을 만듭니다
    }
};

// 더 실용적인 형태 — const 멤버는 이동을 막으므로 대개 이렇게 씁니다
class Config {
    std::string host_;
    int         port_;
public:
    Config(std::string h, int p) : host_(std::move(h)), port_(p) {}
    const std::string& host() const noexcept { return host_; }
    int port() const noexcept { return port_; }

    // 수정 대신 '바꾼 사본'을 돌려줍니다
    [[nodiscard]] Config with_port(int p) const {
        Config c = *this; c.port_ = p; return c;
    }
};
auto dev = prod.with_port(5433);       // 원본은 그대로
const 멤버 변수는 신중하게 — 대입과 이동이 막혀 컨테이너에 담을 수 없게 됩니다. “불변”을 표현하려면 멤버를 private 으로 두고 setter 를 안 만드는 편이 실용적입니다.

컴파일 시점 테이블 만들기

// 룩업 테이블을 실행 시점에 만들지 않기
constexpr std::array<std::uint8_t, 256> make_lut() {
    std::array<std::uint8_t, 256> t{};
    for (int i = 0; i < 256; ++i)
        t[i] = static_cast<std::uint8_t>(i * i / 255);
    return t;
}
constexpr auto kLut = make_lut();     // ★ 바이너리에 데이터로 박힙니다
//   → 시작 시간 0, 스레드 안전, 캐시에 미리 올라감

// 컴파일 시점 문자열 처리 (C++20)
constexpr std::size_t hash(std::string_view s) {   // consteval 이면 런타임 값 cmd 로 못 부름
    std::size_t h = 14695981039346656037ULL;          // FNV-1a (64비트)
    for (char c : s) { h ^= static_cast<std::uint8_t>(c); h *= 1099511628211ULL; }
    return h;
}
switch (hash(cmd)) {                  // 문자열 switch 를 흉내낼 수 있습니다
    case hash("start"): ...
}

// 컴파일 시점 검증 — 실수를 빌드 때 잡습니다
static_assert(kLut[255] == 255);
static_assert(sizeof(Packet) == 16, "패킷 크기가 바뀌었습니다");

실무에선 이렇게 — const 를 팀 규칙으로 🔴 실무

어디에 붙이나

자리규칙
읽기만 하는 멤버 함수항상 const ★
큰 객체 인자const T&
바뀌지 않는 지역 변수const auto (권장)
포인터가 가리키는 대상const T*
컴파일 시점에 아는 값constexpr
반환값붙이지 마세요 — 이동을 막습니다
값 인자선언에는 불필요(정의에만)
// ❌ 반환값에 const 를 붙이면 이동이 막힙니다
const std::string get();      // s = get(); 처럼 대입하면 이동 대신 복사됩니다
std::string get();            // ✅

// ⚠ const 는 스레드 안전을 '의미'합니다 (C++11 이후 표준 관례)
//   const 함수는 여러 스레드가 동시에 불러도 안전해야 합니다.
//   mutable 캐시를 두면 그 약속이 깨집니다 → mutex 로 보호하세요
mutable std::mutex m_;
mutable std::optional<double> cache_;

const 를 벗기는 것에 대하여

// const_cast — 거의 항상 설계가 잘못됐다는 신호입니다
void f(const Widget& w) {
    const_cast<Widget&>(w).modify();   // ❌ 원본이 진짜 const 면 UB ★
}

// 유일하게 정당한 경우 — const 가 아닌 객체를 const 참조로 받았을 때
//   (그리고 그 사실을 확실히 알 때). 그런 설계 자체를 피하세요.

// 옛 C API 가 const 를 안 쓸 때는 어쩔 수 없습니다
void c_api(char* s);                   // 실제로는 수정하지 않는데 const 가 없음
std::string t = s;
c_api(t.data());                       // ✅ 복사본을 넘기는 편이 안전

// 중복 제거의 정석 (const 버전에서 비const 버전 부르기)
class C {
    const T& get() const { /* 복잡한 로직 */ }
    T& get() {
        return const_cast<T&>(std::as_const(*this).get());   // 관용구
    }
};
//   ★ 반대 방향(비const 에서 const 로 위임)은 안전하지 않습니다
실무 감각 — const_cast 가 코드에 보이면 리뷰에서 반드시 이유를 물어야 합니다. 대부분은 인터페이스를 잘못 설계한 것이고, 고칠 수 있습니다.

한 단계 더 — const 가 최적화에 주는 것 🧬 전문가

생각보다 도움이 안 됩니다

// 흔한 오해 — "const 를 붙이면 빨라진다"
void f(const int& x, int& y) {
    int a = x;
    y = 1;
    int b = x;        // ★ 컴파일러는 x 를 다시 읽어야 합니다
}
//   왜? x 와 y 가 같은 객체일 수 있고(aliasing),
//   const 참조는 '내가 안 바꾼다'는 뜻이지 '아무도 안 바꾼다'가 아닙니다.

// 진짜로 최적화에 도움이 되는 것들
//   ① constexpr — 아예 계산이 사라집니다 ★
//   ② 지역 const 변수 — 컴파일러가 값을 추적할 수 있습니다
//   ③ const 전역·정적 — 읽기 전용 섹션(.rodata)에 배치돼 공유됩니다
//   ④ __restrict — "겹치지 않는다"를 알려 주면 벡터화가 됩니다(⚡ e06)

// 확인 — godbolt 에서 const 있는 버전과 없는 버전의 어셈블리를 비교해 보세요
//   대부분의 경우 완전히 동일한 코드가 나옵니다.

// ★ 그래도 const 를 쓰는 이유는 '성능'이 아니라
//   ① 실수 방지 ② 의도 표현 ③ 스레드 안전 판단 근거 입니다.

constexpr 의 한계와 대안

// 컴파일 시점 계산의 비용 — 빌드 시간입니다
//   큰 테이블이나 복잡한 계산을 constexpr 로 하면 컴파일이 느려집니다
//   측정: clang++ -ftime-trace 로 어느 constexpr 이 오래 걸리는지 확인

// 컴파일러 제한에 걸릴 수 있습니다
//   -fconstexpr-ops-limit=  (GCC)
//   -fconstexpr-steps=      (Clang)
//   error: constexpr evaluation hit maximum step limit

// 대안 — 빌드 단계에서 코드를 생성
//   ① 스크립트로 .h 파일 생성 (CMake add_custom_command)
//   ② 실행 시 한 번만 초기화 (함수 안 static — 스레드 안전 보장)
const auto& lut() {
    static const auto t = make_lut();     // 첫 호출 때 한 번
    return t;
}

// C++20 constinit — '컴파일 시점 초기화'를 강제 (정적 초기화 순서 문제 회피)
constinit static Logger g_logger{};       // 동적 초기화면 컴파일 에러 ★

// 정리
//   작은 상수·간단한 계산 → constexpr
//   큰 테이블            → 생성 스크립트나 함수 안 static
//   전역 객체            → constinit 또는 함수 안 static
실무 우선순위 — const 는 안전과 가독성을 위해 습관적으로 붙이고, constexpr 은 실제로 컴파일 시점에 알 수 있는 값에만 쓰세요. 성능을 위해 constexpr 을 남발하면 빌드 시간만 늘어납니다.
정리 — 읽기만 하는 멤버 함수에는 무조건 const, 큰 인자는 const T&, 컴파일 시점에 아는 값은 constexpr. const 는 속도를 위한 것이 아니라 실수를 막고 의도를 드러내기 위한 것이고, 멀티스레드에서 “동시에 읽어도 되는가”의 판단 근거가 됩니다.
MOD 05

STL 컨테이너 — 거의 항상 vector

컨테이너는 수십 가지지만 실무의 90%는 vector 입니다. 나머지는 “언제 예외인가”만 알면 됩니다.

컨테이너 고르기 — 대부분의 답은 vector 입니다 무엇을 담나 vector 순서가 있고 앞에서 뒤로 훑는다 고민되면 이것 unordered_map 키로 찾는 게 주 용도 평균 O(1) 순서는 보장 안 됨 map · set 정렬된 순회가 필요 범위 조회가 편하다 O(log n) · 트리 구조 deque · list 양끝에서 넣고 뺀다 중간 삽입이 아주 잦다 캐시에는 불리하다 "이론상 list 가 빠른데 실제로는 vector 가 이기는" 이유 vector 는 원소가 메모리에 붙어 있어 CPU 가 한 번에 여러 개를 캐시로 끌어옵니다. list 는 원소마다 따로 흩어져 있어 건널 때마다 캐시 미스가 납니다 — 삽입 횟수보다 순회 횟수가 많다면 거의 항상 vector 가 빠릅니다.
복잡도 표만 보고 고르면 틀립니다 — 실제 기준은 캐시이고, 그래서 기본은 vector 입니다.

선택 순서도

키로 찾아야 하나?
  ├─ 예 → 순서대로 순회해야 하나?
  │        ├─ 예 → std::map · std::set          (트리, O(log n))
  │        └─ 아니오 → std::unordered_map · set  (해시, O(1)) ★
  └─ 아니오 → 크기가 컴파일 시점에 정해지나?
             ├─ 예 → std::array<T, N>            (스택, 할당 없음)
             └─ 아니오 → 양쪽 끝에서 넣고 빼나?
                        ├─ 예 → std::deque
                        └─ 아니오 → std::vector  ★ 기본값

  그 외
    · 항상 정렬 상태 유지 + 중복 허용 → std::multiset · sorted vector
    · 우선순위 큐                     → std::priority_queue
    · 고정 크기 비트 집합              → std::bitset
    · 문자열                          → std::string · std::string_view
std::list 는 거의 답이 아닙니다 — “중간 삽입이 많으니 리스트”는 교과서적 답이지만, 실제로는 메모리가 흩어져 캐시 미스가 잦아 수천 개 규모까지는 vector 가 더 빠릅니다. 재보지 않고 바꾸지 마세요(⚡ e02).

복잡도 비교

연산vectordequemapu_map
인덱스 접근O(1)O(1)——
키 검색O(n)O(n)O(log n)O(1)
끝에 추가O(1)*O(1)O(log n)O(1)*
앞에 추가O(n)O(1)——
중간 삽입O(n)O(n)O(log n)O(1)*
순서 순회가장 빠름빠름정렬됨임의
메모리연속블록노드버킷

* 상각(amortized) — 가끔 재할당이 일어납니다

복잡도만 보고 고르면 틀립니다 — map 의 O(log n) 이 vector 의 O(n) 보다 느린 경우가 흔합니다(원소 수백 개 이하). 연속 메모리의 캐시 이점이 복잡도 차이를 덮기 때문입니다.

이렇게도 쓸 수 있다 — 덜 알려진 유용한 것들 🟡 중급

알아 두면 계속 쓰게 되는 API

// map — C++17 이후 편의 기능들
if (auto it = m.find(k); it != m.end()) use(it->second);
if (m.contains(k)) ...                          // C++20 ★
auto [it, inserted] = m.insert_or_assign(k, v);
m.try_emplace(k, args...);                      // 이미 있으면 값을 만들지도 않음 ★
auto node = m.extract(k);                       // 노드를 꺼내 다른 map 으로 이동
m2.insert(std::move(node));                     // 재할당 없이 옮기기

// vector
v.reserve(n);                     // 재할당 방지 ★
v.shrink_to_fit();
std::erase(v, value);             // C++20 — erase-remove 관용구 대체
std::erase_if(v, pred);           // ★
v.assign(first, last);

// array — 스택에 고정 크기
std::array<int, 5> a{1,2,3,4,5};
a.size();  a.at(0);  std::get<0>(a);

// span (C++20) — 소유하지 않는 '창'
void f(std::span<const double> s);      // vector·array·C배열 전부 받습니다 ★
f(v); f(arr); f({ptr, n});

// bitset · flat 컨테이너 (C++23)
std::bitset<64> flags;
std::flat_map<int, std::string> fm;     // vector 기반 → 캐시 친화적 ★

해시 컨테이너 다루기

// 내 타입을 키로 쓰려면 해시가 필요합니다
struct Point { int x, y;
    bool operator==(const Point&) const = default; };

template<> struct std::hash<Point> {
    std::size_t operator()(const Point& p) const noexcept {
        std::size_t h = std::hash<int>{}(p.x);
        h ^= std::hash<int>{}(p.y) + 0x9e3779b9 + (h << 6) + (h >> 2);
        return h;                        // boost::hash_combine 방식
    }
};
std::unordered_map<Point, double> grid;

// ★ 계약: a == b 이면 hash(a) == hash(b) 여야 합니다.
//   그리고 키가 map 안에 있는 동안 해시 값이 변하면 안 됩니다.

// 성능 조절
m.reserve(expected_count);        // ★ 리해시 방지 — 큰 차이가 납니다
m.max_load_factor(0.7f);
m.bucket_count();

// ⚠ 표준 unordered_map 은 노드 기반이라 느립니다.
//   성능이 중요하면 오픈 어드레싱 구현을 고려하세요
//     absl::flat_hash_map · ankerl::unordered_dense · boost::unordered_flat_map
//   → 대개 2~5배 빠릅니다 (다만 참조 안정성이 없어집니다)

실무에선 이렇게 — 할당을 통제하기 🔴 실무

재할당과 반복자 무효화

// ① 크기를 알면 반드시 reserve
std::vector<Row> rows;
rows.reserve(expected);            // 재할당 0회 ★

// ② 참조·포인터를 보관해야 하면 vector 를 쓰지 마세요
std::vector<Widget> v;
Widget* p = &v[0];
v.push_back(w);                    // 재할당되면 p 는 죽습니다 ★
//   → deque · list · unordered_map(참조 안정) 또는 인덱스를 보관

// ③ 무효화 규칙 요약
//   vector      : 재할당 시 전부 · erase 이후 뒤쪽
//   deque       : 중간 수정 시 전부 · 양끝 삽입은 반복자만(참조는 유지)
//   list · map  : 지운 그것만
//   u_map       : 리해시 시 반복자만 (참조·포인터는 유지) ★

// ④ 순회 중 수정 (기초 탭 x04 와 같은 규칙)
for (auto it = v.begin(); it != v.end(); ) {
    if (drop(*it)) it = v.erase(it);
    else           ++it;
}
std::erase_if(v, drop);            // ✅ C++20 — 이게 더 빠르고 안전합니다

메모리를 재사용하는 패턴

// ❌ 루프마다 새 벡터 — 할당이 반복됩니다
for (const auto& chunk : chunks) {
    std::vector<double> tmp;
    process(chunk, tmp);
}

// ✅ 밖에 두고 clear 로 재사용 — 용량이 유지됩니다 ★
std::vector<double> tmp;
tmp.reserve(max_size);
for (const auto& chunk : chunks) {
    tmp.clear();                   // size 만 0, 메모리는 유지
    process(chunk, tmp);
}

// 다형적 메모리 자원 (C++17) — 아레나에서 할당
#include <memory_resource>
std::array<std::byte, 64 * 1024> buf;
std::pmr::monotonic_buffer_resource pool{buf.data(), buf.size()};
std::pmr::vector<int> v{&pool};    // ★ 힙 할당 없이 스택 버퍼 사용
v.reserve(1000);                    // pool 에서 가져옵니다
//   요청 처리처럼 '한 번에 만들고 한 번에 버리는' 작업에 적합합니다
//   해제는 pool 이 통째로 — 개별 free 가 없어 매우 빠릅니다
파이썬 모듈에서 특히 유용합니다 — 호출마다 벡터를 새로 만들면 할당이 지연 시간의 변동을 만듭니다. 엔진 객체에 버퍼를 멤버로 두고 재사용하면 호출 시간이 훨씬 일정해집니다(🐍 y05).

한 단계 더 — 컨테이너의 실제 메모리 🧬 전문가

노드 기반의 진짜 비용

std::map<int, int> 원소 하나당 실제 메모리 (libstdc++ 기준)

  키 4 + 값 4 = 8바이트가 실제 데이터
  + 좌·우·부모 포인터 24바이트
  + 색상(레드블랙) + 패딩 8바이트
  + 할당자 오버헤드 ~16바이트
  = 원소당 약 56바이트 ★ 데이터의 7배

std::unordered_map<int,int>
  노드 (키+값+next 포인터) + 버킷 배열
  = 원소당 약 40~50바이트

std::vector<std::pair<int,int>>
  = 원소당 8바이트 (+ 여유 용량) ★

→ 100만 개면 56MB vs 8MB.
  게다가 map 은 원소가 메모리에 흩어져 있어
  순회할 때마다 캐시 미스가 납니다.

★ 그래서 실무 결론:
  '정렬된 vector + binary_search' 가
  map 보다 빠른 경우가 매우 흔합니다 (읽기 위주라면 거의 항상).

직접 재 보고 판단하기

// 같은 작업을 세 가지로 구현해 비교
#include <chrono>
template<class F> auto bench(F f) {
    auto t = std::chrono::steady_clock::now();
    f();
    return std::chrono::duration<double, std::milli>(
        std::chrono::steady_clock::now() - t).count();
}

// ① map
std::map<int,int> m;
// ② unordered_map
std::unordered_map<int,int> um;  um.reserve(N);
// ③ 정렬된 vector
std::vector<std::pair<int,int>> v;  v.reserve(N);
std::ranges::sort(v, {}, &std::pair<int,int>::first);
auto it = std::ranges::lower_bound(v, key, {}, &std::pair<int,int>::first);

// 전형적인 결과의 경향 (직접 재세요)
//   삽입이 잦고 순서가 필요 없다        → unordered_map
//   만들어 두고 읽기만 · 순회가 잦다     → 정렬된 vector ★
//   범위 조회가 필요하다               → 정렬된 vector 또는 map
//   원소가 수십 개                     → 무엇을 써도 차이 없음 (vector 로)
실측 없이 자료구조를 바꾸지 마세요 — “O(1)이니까 빠르다”는 상수 인자와 캐시 앞에서 자주 뒤집힙니다. perf stat -e cache-misses 로 확인하면 왜 그런지까지 설명할 수 있게 됩니다.
정리 — vector 가 기본값이고, 키로 찾아야 하면 unordered_map, 순서가 필요하면 map 입니다. 크기를 알면 reserve, 참조를 보관하면 재할당 주의, 그리고 노드 기반 컨테이너는 메모리를 7배 쓴다는 것만 기억하세요.
MOD 06

알고리즘과 ranges

직접 for 문을 쓰는 대신 이미 만들어진 동작을 조합합니다. 짧아서가 아니라 이름이 의도를 말해 주기 때문입니다.

자주 쓰는 것들

#include <algorithm>
#include <numeric>
#include <ranges>
namespace rg = std::ranges;

// C++20 ranges — begin/end 를 안 적어도 됩니다 ★
rg::sort(v);
rg::sort(people, {}, &Person::age);        // 프로젝션으로 정렬 기준 지정
rg::stable_sort(v);                        // 같은 값의 순서 유지
rg::find(v, 42);
rg::find_if(v, [](int x){ return x > 0; });
rg::count(v, 42);
rg::any_of(v, pred);  rg::all_of(v, pred);  rg::none_of(v, pred);
rg::min_element(v);   rg::max_element(v);
rg::reverse(v);       rg::rotate(v, v.begin()+3);
rg::unique(v);                             // 인접 중복 제거 (정렬 후)
rg::binary_search(v, 42);                  // 정렬돼 있을 때
rg::lower_bound(v, 42);

// 수치
std::accumulate(v.begin(), v.end(), 0.0);  // 합 (초기값 타입 주의!) ★
std::reduce(v.begin(), v.end());           // 병렬 가능 버전
std::transform_reduce(a.begin(), a.end(), b.begin(), 0.0,
                      std::plus{}, std::multiplies{});   // 내적
std::partial_sum(...);  std::adjacent_difference(...);

// ⚠ accumulate(v.begin(), v.end(), 0) 에 double 벡터를 넣으면
//   0 이 int 라 결과가 int 로 잘립니다. 반드시 0.0 을 쓰세요 ★

views — 지연 평가 파이프라인

namespace vw = std::views;

// 파이썬의 컴프리헨션·제너레이터에 해당합니다
auto result = v
    | vw::filter([](int x){ return x % 2 == 0; })
    | vw::transform([](int x){ return x * x; })
    | vw::take(5);

for (int x : result) ...        // ★ 여기서야 실제로 계산됩니다 (지연 평가)

// 자주 쓰는 view
vw::filter · vw::transform · vw::take · vw::drop
vw::take_while · vw::drop_while
vw::reverse · vw::join · vw::split
vw::iota(0, 10)                  // 0..9 (파이썬 range)
vw::zip(a, b)                    // C++23 ★
vw::enumerate(v)                 // C++23 — 파이썬 enumerate
vw::chunk(v, 3) · vw::slide(v, 3)  // C++23

// 결과를 컨테이너로 (C++23)
auto vec = result | rg::to<std::vector>();
// C++20 이라면 — filter 뒤 take 는 begin/end 타입이 달라 views::common 으로 맞춥니다
auto c = result | vw::common;
std::vector<int> vec(c.begin(), c.end());
지연 평가의 이점 — 중간 결과를 담을 벡터를 만들지 않습니다. take(5) 가 있으면 5개만 계산하고 멈춥니다. 파이썬의 제너레이터 파이프라인과 정확히 같은 개념입니다.

이렇게도 쓸 수 있다 — 파이썬 코드를 옮기기 🟡 중급

대조표

파이썬C++20/23
[f(x) for x in v]v | vw::transform(f)
[x for x in v if p(x)]v | vw::filter(p)
sum(v)std::accumulate(..., 0.0)
sorted(v, key=f)rg::sort(v, {}, f)
enumerate(v)vw::enumerate(v) (C++23)
zip(a, b)vw::zip(a, b) (C++23)
range(n)vw::iota(0, n)
itertools.islicevw::drop(k) | vw::take(n)
any() / all()rg::any_of / all_of
reversed(v)v | vw::reverse
"".join(parts)parts | vw::join
collections.Counter직접(unordered_map + 루프)
// 실전 예 — CSV 한 줄을 파싱해 합계
auto vals = line
    | vw::split(',')
    | vw::transform([](auto&& s){ return std::stod(std::string(s.begin(), s.end())); })
    | vw::filter([](double d){ return d > 0; })
    | rg::to<std::vector>();
double total = std::accumulate(vals.begin(), vals.end(), 0.0);
//   ⚠ 람다에 | 로 이어 붙일 수는 없습니다 (| 는 range 어댑터 전용)

알고리즘으로 대체하면 좋은 패턴

// ① "하나라도 있나"
bool found = false;
for (const auto& x : v) if (p(x)) { found = true; break; }   // ❌
bool found = rg::any_of(v, p);                                // ✅

// ② 조건에 맞는 것만 남기기
std::erase_if(v, [](const auto& x){ return !valid(x); });     // ✅ C++20

// ③ 두 컨테이너 비교
bool same = rg::equal(a, b);
auto [i1, i2] = rg::mismatch(a, b);       // 처음 다른 지점

// ④ 조건에 따라 나누기
auto mid = std::partition(v.begin(), v.end(), pred);   // 앞쪽이 참
// mid 앞은 pred 참, 뒤는 거짓

// ⑤ 상위 N개만 (전체 정렬 없이) ★
std::partial_sort(v.begin(), v.begin()+10, v.end());
std::nth_element(v.begin(), v.begin()+k, v.end());     // k번째만 정확히

// ⑥ 병합·집합 연산 (정렬돼 있을 때)
rg::set_intersection(a, b, std::back_inserter(out));
rg::set_difference(a, b, std::back_inserter(out));
rg::merge(a, b, std::back_inserter(out));

실무에선 이렇게 — 언제 쓰고 언제 안 쓰나 🔴 실무

ranges 의 현실적인 한계

// ⚠ ① 컴파일 에러가 끔찍합니다
//    제약을 어기면 수백 줄짜리 템플릿 에러가 나옵니다.
//    → 컨셉(m09)이 도입되며 나아졌지만 여전히 어렵습니다.

// ⚠ ② 컴파일 시간이 늘어납니다
//    파이프라인이 길수록 템플릿 인스턴스화가 늘어납니다.

// ⚠ ③ 디버거로 들여다보기 어렵습니다
//    view 안에서 중단점을 걸어도 타입 이름이 수십 줄입니다.

// ⚠ ④ 성능이 항상 좋지는 않습니다
//    대부분 최적화로 사라지지만, 복잡한 파이프라인은
//    손으로 쓴 루프보다 느릴 수 있습니다 — 재 보세요.

// ⚠ ⑤ 수명 함정 — view 는 원본을 참조합니다 ★
auto bad() {
    std::vector<int> v{1,2,3};
    return v | vw::filter(pred);      // ❌ v 가 사라지면 댕글링
}
//   → 결과를 vector 로 실체화해서 반환하세요

// ✅ 그래도 쓰는 이유 — '무엇을 하는지'가 코드에 드러납니다
//    이름 있는 알고리즘 > 손으로 쓴 루프 (버그도 여기서 덜 납니다)

팀에서의 도입 기준

  • 알고리즘(rg::sort·rg::find_if)은 적극 쓰세요 — 학습 비용이 거의 없고 이득이 명확합니다
  • views 파이프라인은 2~3단계까지 — 그 이상은 함수로 나누는 편이 읽힙니다
  • 핫 루프에는 손으로 — 측정해서 차이가 나면 명시적 루프로
  • public 인터페이스에 view 를 노출하지 마세요 — 타입이 복잡하고 수명 문제가 생깁니다. std::span 이나 std::generator(C++23)를 쓰세요
// 인터페이스는 단순하게
std::vector<Row> filter_rows(std::span<const Row> in, const Query& q);
//   내부 구현에서는 ranges 를 마음껏 쓰되, 밖으로는 구체 타입으로

한 단계 더 — 반복자와 병렬 실행 🧬 전문가

반복자 카테고리

알고리즘이 어떤 컨테이너에서 되는지를 결정합니다

  input      : 한 번만 앞으로 (istream)
  forward    : 여러 번 앞으로 (forward_list)
  bidirectional : 앞뒤로 (list, map)
  random_access : 점프 가능 (vector, deque, array) ★
  contiguous : 메모리가 연속 (vector, array, span) — C++20

  · std::sort 는 random_access 가 필요합니다
    → list 에는 못 씁니다. list.sort() 멤버 함수를 쓰세요
  · std::span 은 contiguous 를 요구합니다
    → deque 는 못 넘깁니다 (블록으로 나뉘어 있으므로)

C++20 부터는 컨셉으로 표현됩니다
  std::random_access_iterator<It>
  std::ranges::contiguous_range<R>
  → 제약을 어기면 '무엇이 부족한지' 한 줄로 알려 줍니다 (m09)

병렬 알고리즘

#include <execution>

std::sort(std::execution::par, v.begin(), v.end());
std::transform(std::execution::par_unseq,
               a.begin(), a.end(), out.begin(), f);
double s = std::reduce(std::execution::par, v.begin(), v.end());

// 실행 정책
//   seq       순차 (기본)
//   par       여러 스레드
//   par_unseq 여러 스레드 + 벡터화 (SIMD)
//   unseq     벡터화만 (C++20)

// ⚠ 주의점
//   · GCC 는 TBB 링크가 필요합니다: target_link_libraries(app PRIVATE tbb)
//   · f 는 스레드 안전해야 하고 예외를 던지면 terminate 입니다
//   · 원소가 적으면 스레드 생성 비용이 더 큽니다 (수만 개 이상부터)
//   · reduce 는 순서를 보장하지 않아 float 결과가 미세하게 달라질 수 있습니다 ★
//     (결정적 결과가 필요하면 accumulate 를 쓰세요)

// 파이썬 확장에서 쓸 때는 GIL 을 놓고 부르세요 (🐍 y07)
{
    py::gil_scoped_release rel;
    std::for_each(std::execution::par, ...);
}
병렬화 전에 확인할 것 — 대부분의 배열 연산은 메모리 대역폭이 한계라 코어를 늘려도 4~5배에서 평평해집니다(⚡ e07). 원소당 계산량이 많을 때만 코어 수에 비례합니다.
정리 — 손으로 for 문을 쓰기 전에 이름 있는 알고리즘이 있는지 먼저 보세요. rg::sort·rg::find_if·std::erase_if·rg::any_of 이 넷만 써도 코드가 눈에 띄게 읽기 좋아집니다. views 파이프라인은 2~3단계까지, 그리고 view 를 반환하지 마세요.
MOD 07

람다와 std::function

파이썬 lambda 와 같지만 무엇을 가져다 쓸지 대괄호에 적습니다. 이 캡처 규칙 하나가 C++ 람다의 전부이자, 가장 자주 나는 버그의 원인입니다.

캡처 규칙

int n = 10;
std::string s = "안녕";

[]        (int x){ return x * 2; }        // 아무것도 안 가져옴
[n]       (int x){ return x * n; }        // n 을 '복사'
[&n]      (int x){ n += x; }              // n 을 '참조' (원본이 바뀜)
[=]       (int x){ return x * n; }        // 쓰는 것 전부 복사 ⚠
[&]       (int x){ n += x; }              // 쓰는 것 전부 참조 ⚠
[n, &s]   (int x){ s += std::to_string(n); }   // 섞어서 ★ 권장
[s = std::move(s)] (){ use(s); }          // 초기화 캡처 (C++14) — 이동
[this]    (){ return member_; }           // 멤버에 접근
[*this]   (){ return member_; }           // 객체를 통째로 복사 (C++17) ★

// 반환 타입은 대개 추론되지만, 명시할 수도 있습니다
auto f = [](int x) -> double { return x / 2.0; };

// mutable — 복사한 것을 수정하려면
auto counter = [n]() mutable { return ++n; };   // 람다 안의 사본이 증가

// 제네릭 람다 (C++14/20)
auto g = [](const auto& a, const auto& b){ return a < b; };
auto h = []<class T>(const std::vector<T>& v){ return v.size(); };  // C++20

참조 캡처의 함정 ★

// ❌ 지역 변수를 참조로 캡처하고 밖으로 내보내기
std::function<int()> make_bad() {
    int local = 42;
    return [&local]{ return local; };     // local 이 사라진 뒤 호출됨 → UB
}

// ❌ 비동기·스레드에 참조 캡처
void schedule() {
    std::string msg = "안녕";
    pool.submit([&msg]{ log(msg); });     // schedule 이 먼저 끝나면 댕글링
}

// ✅ 값으로 캡처하거나 이동
pool.submit([msg]{ log(msg); });           // 복사
pool.submit([msg = std::move(msg)]{ log(msg); });   // 이동 ★

// ⚠ [=] 는 this 를 '포인터로' 캡처합니다 (C++20 에서 deprecated — 동작은 그대로)
class Widget {
    int n_ = 0;
public:
    auto get() {
        return [=]{ return n_; };          // ❌ 사실은 this 포인터 캡처
        return [*this]{ return n_; };      // ✅ 객체 복사 (C++17)
        return [n = n_]{ return n; };      // ✅ 값만 복사
    }
};

// 규칙 — 람다가 그 자리에서 바로 쓰이면 [&] 도 괜찮지만,
//        저장·전달·비동기라면 반드시 값 캡처 ★

이렇게도 쓸 수 있다 — 함수를 값으로 다루기 🟡 중급

std::function 과 대안

#include <functional>

// 어떤 호출 가능 객체든 담습니다 (타입 소거)
std::function<void(int)> cb;
cb = [](int x){ std::printf("%d\n", x); };
cb = &some_function;
cb = Functor{};
if (cb) cb(42);                     // 비어 있는지 확인 후 호출

// ⚠ 비용이 있습니다
//   · 힙 할당 가능성 (캡처가 크면)
//   · 간접 호출 → 인라인 불가 ★
//   · 크기 32바이트 (libstdc++)

// 대안 ① 템플릿 — 비용 0, 인라인 가능
template<class F>
void each(std::span<const int> v, F f) { for (int x : v) f(x); }
//   ★ 헤더에 있어야 하고 코드가 타입마다 생성됩니다

// 대안 ② function_ref (C++26 예정 · 지금은 라이브러리로)
//   소유하지 않는 '함수 참조' — 할당 없음, 인라인 가능
void each(std::span<const int> v, tl::function_ref<void(int)> f);

// 대안 ③ move_only_function (C++23) — 이동만 가능한 것도 담습니다
std::move_only_function<void()> task = [p = std::make_unique<T>()]{ ... };
//   std::function 은 복사 가능해야 해서 unique_ptr 캡처를 못 담았습니다 ★

실전 활용

// ① 정렬 기준
rg::sort(people, [](const auto& a, const auto& b){
    return std::tie(a.team, a.age) < std::tie(b.team, b.age);   // 다중 기준 ★
});

// ② 즉시 실행 람다 — 복잡한 초기화를 const 로
const auto config = []{
    Config c;
    c.load("app.toml");
    c.validate();
    return c;
}();                                 // ★ 만들고 바로 호출

// ③ 오버로드 조합 (variant 방문에 씁니다 — m10)
template<class... Ts> struct overloaded : Ts... { using Ts::operator()...; };
template<class... Ts> overloaded(Ts...) -> overloaded<Ts...>;

std::visit(overloaded{
    [](int i)                { std::printf("정수 %d\n", i); },
    [](const std::string& s) { std::printf("문자열 %s\n", s.c_str()); },
}, value);

// ④ 재귀 람다 (C++23 deducing this)
auto fib = [](this auto&& self, int n) -> int {
    return n < 2 ? n : self(n-1) + self(n-2);
};
// C++20 이하에서는 std::function 이나 Y 조합자가 필요했습니다

실무에선 이렇게 — 콜백 설계 🔴 실무

인터페이스에 무엇을 쓸까

상황선택
헤더 전용 · 성능 중요템플릿 F&&
구현을 .cpp 에 숨기고 싶다std::function
호출만 하고 저장 안 함function_ref류
이동만 가능한 캡처move_only_function
C API 경계함수 포인터 + void*(🔗 f08)
여러 구독자vector<function> · 시그널 라이브러리
// 콜백을 멤버로 보관할 때의 수명 문제
class Button {
    std::function<void()> on_click_;
public:
    void set_handler(std::function<void()> f) { on_click_ = std::move(f); }
};
// ⚠ 핸들러가 다른 객체를 참조로 캡처했는데 그 객체가 먼저 죽으면?
//   → weak_ptr 로 캡처해 살아 있는지 확인하는 패턴 ★
btn.set_handler([w = std::weak_ptr(self)]{
    if (auto s = w.lock()) s->do_something();
});

성능 비교

// 1억 번 호출 시의 경향 (직접 재세요)
//   직접 호출          1.0×   기준
//   템플릿 + 람다       1.0×   완전히 인라인됩니다 ★
//   함수 포인터         1.5×   간접 호출
//   std::function      3~10×  간접 + 할당 가능성 ★
//   가상 함수          1.5~2×

// 확인 — 캡처 크기에 따라 힙 할당 여부가 갈립니다
std::function<void()> f1 = []{};                    // 할당 없음 (SBO)
std::function<void()> f2 = [a,b,c,d,e]{};           // 힙 할당 가능 ★
//   libstdc++ 는 16바이트까지 내부 저장, 그 이상은 힙

// 핫 루프에서 std::function 을 쓰고 있다면
//   ① 템플릿으로 바꾸거나
//   ② 루프 밖으로 빼서 호출 자체를 줄이거나
//   ③ 배열 단위로 처리하는 인터페이스로 바꾸세요
void apply(std::span<double> v, std::function<double(double)> f);  // ❌ 원소마다
void apply_scale(std::span<double> v, double k);                    // ✅ 통째로
이 가이드의 일관된 원칙 — 원소마다 함수 객체를 호출하는 인터페이스는 C++ 안에서도, 파이썬 경계에서도 같은 이유로 느립니다. 동작을 배열 단위로 올리는 것이 거의 항상 답입니다.

한 단계 더 — 람다는 무엇으로 컴파일되나 🧬 전문가

이름 없는 클래스입니다

int n = 5;
auto f = [n](int x){ return x + n; };

// 컴파일러가 만드는 것 — 대략 이런 클래스입니다
class __lambda_1 {
    int n_;                                    // 캡처한 것이 멤버
public:
    explicit __lambda_1(int n) : n_(n) {}
    int operator()(int x) const { return x + n_; }   // ★ 기본이 const
};
//   mutable 을 붙이면 operator() 의 const 가 빠집니다

sizeof(f);          // 4 (캡처한 int 하나)
sizeof([]{});       // 1 (빈 클래스도 1바이트)

// 캡처 없는 람다는 함수 포인터로 변환됩니다 ★ (C API 콜백에 유용)
int (*fp)(int) = [](int x){ return x * 2; };
c_api_register(fp);
//   캡처가 있으면 불가능합니다 → void* user 로 상태를 넘기세요(🔗 f08)

// 상태를 전역에 두는 꼼수는 쓰지 마세요 (스레드 안전하지 않음)

인라인화와 최적화

// 템플릿에 람다를 넘기면 '타입이 유일'해 완전히 인라인됩니다
template<class F> void each(std::span<int> v, F f) { for (int& x : v) f(x); }
each(v, [](int& x){ x *= 2; });
//   → 컴파일 결과는 손으로 쓴 루프와 동일합니다 (godbolt 로 확인 가능) ★

// std::function 으로 받으면 타입이 소거되어 인라인이 불가능합니다
void each(std::span<int> v, std::function<void(int&)> f);
//   → 매 원소마다 간접 호출 + 벡터화 실패

// 확인
// g++ -O2 -S 로 어셈블리를 보거나
// godbolt.org 에서 두 버전을 나란히 비교

// 람다 캡처의 숨은 비용
auto f = [big = std::vector<double>(1000)]{ ... };   // 람다 객체가 큽니다
sizeof(f);                                            // 24 (vector 크기)
std::function<void()> g = f;                          // ★ 힙 할당 발생

// 캡처를 shared_ptr 로 줄이는 기법
auto data = std::make_shared<std::vector<double>>(1000);
auto f2 = [data]{ use(*data); };                      // 캡처는 포인터 하나
실무 감각 — 람다 자체는 비용이 0 입니다. 비용은 std::function 으로 타입을 지울 때 생깁니다. “람다가 느리다”가 아니라 “타입 소거가 느리다”가 정확한 표현입니다.
정리 — 람다는 대괄호에 무엇을 가져올지 적는 이름 없는 함수입니다. 바로 쓰면 [&], 저장·비동기면 값 캡처 — 이 한 줄이 규칙의 전부입니다. 인터페이스에는 성능이 중요하면 템플릿, 구현을 숨기려면 std::function.
MOD 08

템플릿 입문

템플릿은 타입을 나중에 채우는 틀입니다. 같은 코드를 int 용·double 용으로 두 번 쓰지 않게 해 줍니다.

함수·클래스 템플릿

// 함수 템플릿 — 타입은 대개 추론됩니다
template<class T>
T max_of(const T& a, const T& b) { return a < b ? b : a; }

max_of(1, 2);            // T = int
max_of(1.5, 2.5);        // T = double
max_of<double>(1, 2.5);  // 명시

// 클래스 템플릿
template<class T, std::size_t N>
class FixedBuffer {
    std::array<T, N> data_{};
public:
    T& operator[](std::size_t i) { return data_[i]; }
    constexpr std::size_t size() const noexcept { return N; }
};
FixedBuffer<double, 128> buf;

// C++17 — 클래스 템플릿도 추론됩니다
std::vector v{1, 2, 3};              // vector<int>
std::pair p{1, "a"};                 // pair<int, const char*>

// 가변 인자 템플릿
template<class... Args>
void log(const char* fmt, Args&&... args) {
    std::printf(fmt, std::forward<Args>(args)...);
}

// 폴드 표현식 (C++17) — 인자를 접습니다
template<class... Ts>
auto sum(Ts... xs) { return (xs + ...); }        // (1 + (2 + (3)))
template<class... Ts>
bool all_true(Ts... xs) { return (xs && ...); }

헤더에 둬야 하는 이유

// ❌ 템플릿 정의를 .cpp 에 두면 링크 에러가 납니다
// util.h
template<class T> T twice(T x);        // 선언만
// util.cpp
template<class T> T twice(T x) { return x*2; }
// main.cpp
twice(3);        // undefined reference to twice<int>(int) ★

// 왜? 컴파일러는 'T = int' 인 버전을 실제로 만들어야 하는데,
//     정의가 다른 번역 단위에 있으면 볼 수 없습니다.

// ✅ 방법 ① 헤더에 전부 (가장 흔함)
// util.h
template<class T> T twice(T x) { return x * 2; }

// ✅ 방법 ② 쓸 타입을 미리 지정 (명시적 인스턴스화)
// util.cpp
template int    twice<int>(int);
template double twice<double>(double);
//   → 헤더는 선언만 두고, 빌드 시간을 줄일 수 있습니다

// ✅ 방법 ③ 구현을 별도 헤더로 분리하는 관습
// util.h  →  #include "util.ipp" (또는 .tpp) 를 맨 아래에
템플릿이 빌드 시간을 늘리는 이유 — 쓰는 곳마다 코드가 새로 생성되고, 헤더에 있으니 포함하는 모든 파일이 그 비용을 냅니다. 큰 프로젝트에서 빌드가 느려지는 1순위 원인입니다(🔬 d08).

이렇게도 쓸 수 있다 — 타입에 따라 다르게 🟡 중급

컴파일 시점 분기

#include <type_traits>

// if constexpr (C++17) — 안 맞는 가지는 컴파일조차 안 됩니다 ★
template<class T>
void print(const T& v) {
    if constexpr (std::is_integral_v<T>)
        std::printf("정수 %lld\n", static_cast<long long>(v));
    else if constexpr (std::is_floating_point_v<T>)
        std::printf("실수 %f\n", static_cast<double>(v));
    else if constexpr (requires { v.size(); })     // C++20 requires
        std::printf("컨테이너 크기 %zu\n", v.size());
    else
        std::puts("기타");
}

// 자주 쓰는 type traits
std::is_same_v<T, U>            std::is_integral_v<T>
std::is_floating_point_v<T>     std::is_pointer_v<T>
std::is_const_v<T>              std::is_trivially_copyable_v<T>
std::is_nothrow_move_constructible_v<T>
std::remove_cvref_t<T>          std::decay_t<T>
std::conditional_t<조건, A, B>

// 특수화 — 특정 타입만 다르게
template<class T> struct Serializer {
    static std::string to_text(const T& v) { return std::to_string(v); }
};
template<> struct Serializer<std::string> {          // 완전 특수화
    static std::string to_text(const std::string& v) { return "\"" + v + "\""; }
};

에러 메시지를 읽을 만하게

// ❌ 제약이 없으면 실패가 '함수 안쪽 깊은 곳'에서 납니다
template<class T> auto sum(const T& c) {
    typename T::value_type s{};      // T 에 value_type 이 없으면 여기서 폭발
    for (const auto& x : c) s += x;
    return s;
}
sum(42);        // 200줄짜리 에러 ★

// ✅ static_assert 로 앞에서 막기
template<class T> auto sum(const T& c) {
    static_assert(requires { c.begin(); c.end(); },
                  "sum() 은 범위(range)만 받습니다");
    ...
}

// ✅✅ 컨셉으로 (C++20 — 다음 섹션 m09) ★
template<std::ranges::range R> auto sum(const R& c);
sum(42);   // error: constraints not satisfied — 한 줄로 끝납니다

// 디버깅 — 타입이 무엇으로 추론됐는지 보기
template<class T> struct ShowType;          // 정의를 일부러 안 함
ShowType<decltype(x)> _;                    // 에러 메시지에 타입이 나옵니다 ★

실무에선 이렇게 — 템플릿을 쓸 때와 안 쓸 때 🔴 실무

판단 기준

✅ 템플릿이 맞을 때❌ 다른 방법이 나을 때
타입만 다르고 로직이 같다런타임에 타입이 정해진다 → 가상 함수·variant
성능이 중요하다(인라인)구현을 .cpp 에 숨겨야 한다
컨테이너·알고리즘류빌드 시간이 이미 문제다
컴파일 시점 검증이 필요팀이 템플릿 에러를 못 읽는다
// 타입 소거로 빌드 시간을 줄이는 절충안
// 헤더에는 얇은 템플릿만, 실제 구현은 .cpp 의 비템플릿 함수로
void process_impl(const void* data, std::size_t n, int type_tag);  // .cpp

template<class T>
void process(std::span<const T> v) {                    // 헤더
    process_impl(v.data(), v.size(), type_tag_of<T>());
}
//   → 인스턴스화되는 코드가 아주 작아집니다 ★

빌드 시간·바이너리 크기 관리

// ① 명시적 인스턴스화로 중복 컴파일 막기
// matrix.h
extern template class Matrix<double>;     // ★ "여기서 만들지 마"
// matrix.cpp
template class Matrix<double>;            // 여기서 한 번만

// ② 템플릿 매개변수 수를 줄이기
template<class T, class Alloc, class Policy, int N>   // ❌ 조합 폭발
template<class T>                                     // ✅

// ③ 타입에 무관한 부분은 기반 클래스로 빼기 (코드 팽창 방지)
class MatrixBase { protected: void common_logic(); };  // 비템플릿
template<class T> class Matrix : MatrixBase { ... };

// ④ 측정
clang++ -ftime-trace -c matrix.cpp        // 어느 템플릿이 오래 걸리는지
nm -C --size-sort -S app | tail -20       // 어느 인스턴스가 큰지
bloaty app                                 // 섹션별 크기 분석
현실적인 조언 — 템플릿을 쓰는 것과 템플릿 라이브러리를 만드는 것은 전혀 다른 난이도입니다. 대부분의 실무 코드는 std::vector<T> 를 쓰는 정도면 충분하고, 직접 만드는 템플릿은 한두 개면 됩니다.

한 단계 더 — 인스턴스화와 이름 조회 🧬 전문가

2단계 이름 조회

// 템플릿은 두 시점에 이름을 찾습니다
//   ① 정의 시점 — T 에 의존하지 않는 이름
//   ② 인스턴스화 시점 — T 에 의존하는 이름 (+ ADL)

template<class T>
void f(T t) {
    helper(t);          // ★ ② 시점에 ADL 로 찾습니다 → 나중에 정의해도 됨
    g();                // ① 시점에 찾습니다 → 앞에 선언이 있어야 함
}

// 의존 타입에는 typename 이 필요합니다
template<class T>
void g(T c) {
    typename T::value_type x;         // ★ typename 없으면 파싱 에러
    typename T::template rebind<int> r;   // 중첩 템플릿에는 template 도
}
//   C++20 부터 많은 자리에서 typename 이 선택적이 됐습니다

// 기반 클래스가 템플릿이면 멤버를 못 찾습니다
template<class T> struct Base { int n; };
template<class T> struct Derived : Base<T> {
    void f() {
        n = 1;              // ❌ 못 찾습니다 (의존 기반이라)
        this->n = 1;        // ✅
        Base<T>::n = 1;     // ✅
    }
};

SFINAE 에서 컨셉으로

// 옛 방식 — SFINAE (Substitution Failure Is Not An Error)
template<class T,
         class = std::enable_if_t<std::is_integral_v<T>>>
void f(T x);                       // 정수일 때만 후보에 들어감
//   읽기 어렵고 에러 메시지가 최악입니다

template<class T>
auto size_of(const T& c) -> decltype(c.size()) { return c.size(); }
//   표현식 SFINAE — c.size() 가 유효할 때만

// C++20 컨셉 — 같은 일을 읽을 수 있게 (m09) ★
template<std::integral T> void f(T x);

template<class T>
concept Sized = requires(const T& c) { { c.size() } -> std::convertible_to<std::size_t>; };
template<Sized T> auto size_of(const T& c) { return c.size(); }

// CRTP — 정적 다형성 (가상 함수 없이)
template<class Derived>
struct Shape {
    double area() const { return static_cast<const Derived*>(this)->area_impl(); }
};
struct Circle : Shape<Circle> {
    double area_impl() const { return 3.14159 * r * r; }
    double r;
};
//   가상 호출 비용 0 · 완전히 인라인됩니다
//   ⚠ 대신 런타임 다형성은 못 합니다 (컨테이너에 섞어 담을 수 없음)
//   C++23 deducing this 로 더 간단해졌습니다
정리 — 템플릿은 타입을 나중에 채우는 틀입니다. 정의는 반드시 헤더에 두어야 하고, 그래서 빌드 시간이 늘어납니다. 타입에 따라 분기할 땐 if constexpr, 제약을 걸 땐 컨셉(다음 섹션)을 쓰면 에러 메시지가 사람이 읽을 수 있게 됩니다.
MOD 09

컨셉 — 템플릿 에러를 한 줄로

“이런 타입만 받는다”를 코드로 적습니다. 수백 줄짜리 템플릿 에러가 한 줄로 줄어드는 것이 가장 큰 이득입니다.

쓰는 법

#include <concepts>

// 표준 컨셉을 그대로 쓰기
template<std::integral T>
T twice(T x) { return x * 2; }

twice(3);      // ✅
twice(3.5);    // ❌ error: constraints not satisfied
               //    note: 'double' does not satisfy 'integral'  ★ 한 줄

// 여러 표기법 (전부 같은 뜻)
template<std::integral T> void f(T);
template<class T> requires std::integral<T> void f(T);
template<class T> void f(T) requires std::integral<T>;
void f(std::integral auto x);                    // ★ 가장 짧은 형태

// 자주 쓰는 표준 컨셉
std::integral · std::floating_point · std::same_as<T>
std::convertible_to<T> · std::derived_from<T>
std::equality_comparable · std::totally_ordered
std::copyable · std::movable · std::default_initializable
std::invocable<Args...> · std::predicate<Args...>
std::ranges::range · random_access_range · contiguous_range

직접 만들기

// requires 식으로 "무엇이 가능해야 하는가" 를 적습니다
template<class T>
concept Drawable = requires(const T& t, Canvas& c) {
    t.draw(c);                                   // 이 호출이 유효해야 함
    { t.bounds() } -> std::convertible_to<Rect>; // 반환 타입까지 검사 ★
    typename T::style_type;                      // 이 중첩 타입이 있어야 함
    requires std::copyable<T>;                   // 다른 컨셉을 요구
};

template<Drawable T>
void render(const T& obj) { obj.draw(canvas); }

// 조합
template<class T>
concept Numeric = std::integral<T> || std::floating_point<T>;

template<class T>
concept SmallNumeric = Numeric<T> && (sizeof(T) <= 4);

// 오버로드 선택에도 쓰입니다 — 더 제약이 강한 쪽이 선택됩니다 ★
void g(std::integral auto x)          { std::puts("정수"); }
void g(std::floating_point auto x)    { std::puts("실수"); }
void g(auto x)                        { std::puts("기타"); }
g(1);      // "정수"
g(1.0);    // "실수"
g("a");    // "기타"

이렇게도 쓸 수 있다 — 실전 컨셉 🟡 중급

프로젝트에 두면 유용한 것들

// ① 숫자 컨테이너
template<class R>
concept NumericRange =
    std::ranges::input_range<R> &&
    Numeric<std::ranges::range_value_t<R>>;

template<NumericRange R>
auto mean(const R& r) {
    using V = std::ranges::range_value_t<R>;
    return std::accumulate(std::ranges::begin(r), std::ranges::end(r), V{})
           / static_cast<V>(std::ranges::size(r));
}
mean(std::vector{1.0, 2.0});    // ✅
mean(std::vector{"a"s});        // ❌ 명확한 에러

// ② 연속 메모리 (FFI·SIMD 에 필요) ★
template<class R, class T>
concept ContiguousOf =
    std::ranges::contiguous_range<R> &&
    std::same_as<std::ranges::range_value_t<R>, T>;

void send(ContiguousOf<std::byte> auto&& buf);   // vector·array·span 만

// ③ 직렬화 가능
template<class T>
concept Serializable = requires(const T& v, Writer& w) {
    { v.serialize(w) } -> std::same_as<void>;
    { T::type_name() } -> std::convertible_to<std::string_view>;
};

에러 메시지가 얼마나 달라지나

제약 없는 템플릿에 잘못된 타입을 넣으면

  In instantiation of 'auto sum(const T&) [with T = int]':
  required from here
  error: request for member 'begin' in 'c', which is of non-class type 'const int'
  ... (템플릿 스택 40줄) ...
  ... (표준 라이브러리 내부 120줄) ...

컨셉을 쓰면

  error: no matching function for call to 'sum(int)'
  note: candidate template ignored: constraints not satisfied
  note: because 'std::ranges::range<int>' evaluated to false
  note:   because 'begin(t)' would be invalid

★ "왜 안 되는지"가 세 줄로 나옵니다.
  이것만으로도 컨셉을 도입할 이유가 충분합니다.
도입 비용이 거의 없습니다 — 기존 템플릿에 template<std::ranges::range R> 처럼 제약만 붙이면 됩니다. 동작은 그대로이고 에러 메시지만 좋아집니다.

실무에선 이렇게 — 인터페이스를 컨셉으로 🔴 실무

가상 함수 대신 컨셉

// 런타임에 갈아끼울 필요가 없다면 컨셉이 더 낫습니다
template<class T>
concept Storage = requires(T& s, std::string_view k, std::span<const std::byte> v) {
    { s.save(k, v) }  -> std::same_as<void>;
    { s.load(k) }     -> std::convertible_to<std::vector<std::byte>>;
    { s.exists(k) }   -> std::convertible_to<bool>;
};

template<Storage S>
void backup(S& storage, const Data& d) {
    storage.save("backup", serialize(d));
}
//   ✅ 가상 호출 0 · 완전히 인라인 · 상속 불필요
//   ❌ 런타임에 구현을 바꿀 수 없고, 컨테이너에 섞어 담을 수 없습니다

// 둘 다 필요하면 — 컨셉으로 제약하고 타입 소거 래퍼를 따로
class AnyStorage {                     // 런타임 다형성이 필요한 곳에서만
    struct Base { virtual ~Base() = default; virtual void save(...) = 0; };
    template<Storage S> struct Impl : Base { S s; void save(...) override {...} };
    std::unique_ptr<Base> p_;
public:
    template<Storage S> AnyStorage(S s) : p_(std::make_unique<Impl<S>>(std::move(s))) {}
    void save(...) { p_->save(...); }
};

도입 순서

  • ① 표준 컨셉부터 — std::integral·std::ranges::range 를 기존 템플릿에 붙이는 것만으로 효과가 큽니다
  • ② 자주 반복되는 제약을 이름으로 — requires 식이 두 곳 이상에 나오면 컨셉으로 뽑으세요
  • ③ public 인터페이스에 — 라이브러리 사용자가 받는 에러 메시지가 곧 문서입니다
  • ④ 과하게 만들지 마세요 — 컨셉이 너무 촘촘하면 정당한 타입까지 막습니다. 실제로 쓰는 연산만 요구하세요
// ⚠ 흔한 과잉 — 실제로 안 쓰는 것까지 요구
template<class T>
concept Bad = std::copyable<T> && std::default_initializable<T>
           && std::equality_comparable<T> && ...;   // 다 필요한가?

// ✅ 함수가 실제로 하는 것만
template<class T>
concept Good = requires(const T& a, const T& b) { { a < b } -> std::convertible_to<bool>; };

한 단계 더 — 컨셉의 세부 규칙 🧬 전문가

포함 관계와 오버로드 선택

// 컨셉은 '더 제약이 강한 쪽'이 선택됩니다 (subsumption)
template<class T> concept A = std::integral<T>;
template<class T> concept B = A<T> && sizeof(T) == 4;

void f(A auto);      // ①
void f(B auto);      // ②
f(int{});            // ② — B 가 A 를 포함하므로 더 구체적

// ⚠ 포함 관계는 '컨셉의 원자적 구성'으로 판단합니다.
//   같은 뜻이어도 다른 식으로 쓰면 포함 관계가 성립하지 않습니다
template<class T> concept C1 = sizeof(T) > 2;
template<class T> concept C2 = C1<T> && sizeof(T) < 8;
//   → C2 가 C1 을 포함합니다 (C1 을 이름으로 재사용하므로) ✅
//     sizeof(T) > 2 를 글자 그대로 다시 적으면 '다른 원자식'이라 포함 안 됩니다
template<class T> concept D1 = (sizeof(T) > 2);
template<class T> concept D2 = (sizeof(T) >= 3);
//   → 수학적으로 같지만 포함 관계가 아닙니다 → ambiguous 에러 ★

// requires 절 안의 requires 식 (헷갈리는 이중 requires)
template<class T>
    requires requires(T t) { t.foo(); }      // 첫 requires=절, 둘째=식
void g(T t);

검사되지 않는 것들

// ① 컨셉은 '문법적 요구'만 검사합니다. 의미는 검사하지 않습니다.
template<class T>
concept Comparable = requires(T a, T b) { { a < b } -> std::convertible_to<bool>; };
//   a < b 가 컴파일된다는 것만 보장합니다.
//   그것이 '순서 관계'로서 올바른지(추이성 등)는 검사할 수 없습니다 ★
//   → 표준은 이런 요구를 '의미 요구사항'으로 문서에 적습니다

// ② requires 식은 '컴파일 되는가'만 봅니다. 실행하지 않습니다.
requires(T t) { t.dangerous(); };   // 실제로 부르지 않습니다

// ③ 컨셉 자체는 런타임 비용이 0 입니다 (전부 컴파일 시점)

// 디버깅 — 컨셉이 왜 만족되지 않는지 확인
static_assert(std::ranges::range<MyType>);       // 어디서 실패하는지 좁히기
static_assert(requires(MyType m){ m.begin(); }); // 단계별로 나눠서

// 컴파일러가 알려 주는 정도가 다릅니다
//   GCC   : note 로 실패한 절을 짚어 줍니다 (-fconcepts-diagnostics-depth=3 로 더 깊게)
//   Clang : 더 자세한 편
//   MSVC  : /diagnostics:caret
실무 결론 — 컨셉의 진짜 가치는 “제약을 강제하는 것”보다 “왜 안 되는지 알려 주는 것”에 있습니다. 표준 컨셉을 붙이는 것만으로도 팀 전체의 디버깅 시간이 줄어듭니다.
정리 — 컨셉은 템플릿에 “이런 타입만” 이라는 조건을 붙이는 문법입니다. template<std::integral T> 처럼 표준 컨셉을 붙이기만 해도 수백 줄짜리 에러가 세 줄로 줄어듭니다. 직접 만들 때는 함수가 실제로 쓰는 연산만 요구하세요.
MOD 10

optional · variant · expected

“값이 없을 수도 있다”, “둘 중 하나다”, “실패할 수 있다”를 주석이 아니라 타입으로 표현합니다. 그러면 컴파일러가 처리를 강제합니다.

optional — 있거나 없거나

#include <optional>
#include <charconv>     // from_chars

std::optional<int> to_int(std::string_view s) {
    int v{};
    auto [p, ec] = std::from_chars(s.data(), s.data()+s.size(), v);
    if (ec != std::errc{}) return std::nullopt;      // 실패
    return v;                                        // 성공
}

if (auto n = to_int(text)) {          // ★ if 안에서 바로
    use(*n);                          // 역참조로 값 꺼내기
}
int v = to_int(text).value_or(0);     // 기본값
to_int(text).value();                 // 없으면 std::bad_optional_access 예외

// 파이썬 None 과 같은 자리지만 '타입에 드러난다'는 점이 다릅니다
//   파이썬: def find(k) -> int | None   ← 힌트일 뿐
//   C++  : std::optional<int> find(K k) ← 안 꺼내면 컴파일이 안 됩니다 ★

// 모나딕 연산 (C++23) — 체이닝
auto r = to_int(text)
    .transform([](int n){ return n * 2; })     // 값이 있으면 변환
    .and_then(validate)                        // optional 을 반환하는 함수
    .or_else([]{ return std::optional{0}; });  // 없으면 대체

// ⚠ optional<T> 는 T 만큼 + 1바이트(+패딩) 커집니다
sizeof(std::optional<double>);   // 16 (8 + 1 + 패딩 7)
//   참조는 담을 수 없습니다 → std::optional<std::reference_wrapper<T>> 또는 T*

variant — 여럿 중 하나

#include <variant>

using Value = std::variant<int, double, std::string>;

Value v = 42;
v = 3.14;
v = "안녕"s;

// ① index / holds_alternative 로 확인
if (std::holds_alternative<int>(v)) use(std::get<int>(v));
if (auto* p = std::get_if<double>(&v)) use(*p);    // ★ 예외 없이 안전

// ② visit — 모든 경우를 처리해야 컴파일됩니다 ★
std::visit(overloaded{
    [](int i)                { std::printf("정수 %d\n", i); },
    [](double d)             { std::printf("실수 %f\n", d); },
    [](const std::string& s) { std::printf("문자열 %s\n", s.c_str()); },
}, v);
//   경우를 빠뜨리면 컴파일 에러 — switch 의 default 누락 같은 실수가 막힙니다

// 상속 대신 쓰기 — 종류가 '고정'되어 있을 때 (기초 탭 x08)
using Shape = std::variant<Circle, Rect, Triangle>;
double area(const Shape& s) {
    return std::visit([](const auto& x){ return x.area(); }, s);
}
//   ✅ 힙 할당 없음 · 가상 호출 없음 · 인라인 가능
//   ❌ 종류를 나중에 추가하려면 이 파일을 고쳐야 합니다

sizeof(Value);   // 가장 큰 대안 + 태그 (여기서는 string 크기 + 8)

이렇게도 쓸 수 있다 — expected — 실패 이유까지 🟡 중급

C++23 std::expected

#include <expected>

enum class ParseError { Empty, BadChar, Overflow };

std::expected<int, ParseError> parse(std::string_view s) {
    if (s.empty()) return std::unexpected(ParseError::Empty);
    int v{};
    auto [p, ec] = std::from_chars(s.data(), s.data()+s.size(), v);
    if (ec == std::errc::result_out_of_range)
        return std::unexpected(ParseError::Overflow);
    if (ec != std::errc{} || p != s.data() + s.size())   // "12abc" 처럼 뒤에 남아도 실패
        return std::unexpected(ParseError::BadChar);
    return v;
}

auto r = parse(text);
if (r) use(*r);
else   handle(r.error());          // ★ 왜 실패했는지 알 수 있습니다

int v = parse(text).value_or(0);

// 체이닝 — 실패하면 그대로 흘러갑니다
auto result = parse(text)
    .and_then(validate)            // expected 를 반환하는 함수
    .transform([](int n){ return n * 2; })
    .transform_error([](ParseError e){ return to_string(e); });

// C++23 이전이라면 tl::expected 를 쓰거나 직접 만듭니다
template<class T, class E>
using Result = std::variant<T, E>;   // 간단한 대체

셋을 언제 쓰나

쓸 것이럴 때
optional<T>없을 수 있고 이유는 알 필요 없음
조회 결과 없음 · 선택적 설정
expected<T,E>실패할 수 있고 이유가 중요
파싱 · 파일 · 네트워크
variant<A,B,C>종류가 여럿이고 각각 다르게 처리
토큰 · 메시지 · 도형
예외드물고 회복 불가
메모리 부족 · 불변식 위반
T* (널 가능)소유하지 않고 없을 수도
옵션 인자
파이썬과 비교하면 — 파이썬은 None 반환과 예외를 섞어 썼고, 타입 힌트가 있어도 강제되지 않았습니다. C++ 의 optional·expected 는 값을 꺼내지 않으면 쓸 수 없어 처리 누락이 구조적으로 막힙니다.

실무에선 이렇게 — 에러 처리 전략 통일 🔴 실무

계층별로 나눕니다

// ① 안쪽(도메인 로직) — expected 로 실패를 값으로
std::expected<Order, OrderError> make_order(const Request&);

// ② 중간(조합) — 그대로 전파, 체이닝
std::expected<Receipt, OrderError> checkout(const Request& r) {
    return make_order(r)
        .and_then(reserve_stock)
        .and_then(charge)
        .transform(make_receipt);
}

// ③ 경계(HTTP·FFI·파이썬) — 최종 변환 ★
extern "C" int32_t api_checkout(const char* json) {
    auto r = checkout(parse(json));
    if (!r) return to_error_code(r.error());       // 🔗 f09
    return 0;
}
// 파이썬 바인딩이라면
m.def("checkout", [](const Request& r) {
    auto res = checkout(r);
    if (!res) throw py::value_error(to_string(res.error()));  // 🐍 y08
    return *res;
});

// ★ 원칙: 예외는 '진짜 예외적인 것'에만, 흔한 실패는 expected 로.
//   그리고 언어 경계에서는 반드시 그 언어의 관용구로 변환합니다.

에러 타입 설계

// ❌ 문자열만 — 프로그램이 판단할 수 없습니다
std::expected<T, std::string> f();

// ✅ 코드 + 맥락
struct Error {
    enum class Code { NotFound, Invalid, Timeout, Internal } code;
    std::string detail;                      // 사람용 메시지
    std::source_location where =
        std::source_location::current();     // ★ C++20 — 어디서 났는지
};
std::expected<T, Error> f();

// 여러 계층의 에러를 합치기
using AnyError = std::variant<ParseError, IoError, NetError>;

// 로깅
void log(const Error& e) {
    std::printf("[%s:%u] %s\n",
        e.where.file_name(), e.where.line(), e.detail.c_str());
}

// ⚠ [[nodiscard]] 를 꼭 붙이세요 — 결과 무시를 막습니다 ★
[[nodiscard]] std::expected<T, Error> f();
f();      // warning: 반환값이 무시됨
[[nodiscard]] 없이 expected 를 쓰면 에러 코드 시절과 똑같은 문제가 생깁니다 — 아무도 안 봅니다. expected 를 반환하는 모든 함수에 붙이세요.

한 단계 더 — 메모리 배치와 비용 🧬 전문가

얼마나 커지나

sizeof(int);                                  // 4
sizeof(std::optional<int>);                   // 8  (4 + bool + 패딩)
sizeof(std::optional<double>);                // 16
sizeof(std::optional<std::string>);           // 40 (32 + 8)

sizeof(std::variant<int, double>);            // 16 (8 + 태그 + 패딩)
sizeof(std::variant<int, std::string>);       // 40

sizeof(std::expected<int, int>);              // 8
sizeof(std::expected<std::string, Error>);    // 둘 중 큰 것 + 태그

// ★ optional 은 '빈 상태'를 별도 플래그로 표현합니다.
//   포인터처럼 이미 '없음'을 표현할 수 있는 타입은 낭비입니다
std::optional<T*>         // ❌ 16바이트 — 그냥 T* 를 쓰세요 (nullptr)
std::optional<std::string_view>  // ❌ 대개 빈 view 로 충분

// variant 는 가장 큰 대안의 크기를 차지합니다
std::variant<char, BigStruct>   // BigStruct 크기 + 태그 ★
//   → 큰 대안은 unique_ptr 로 감싸세요
std::variant<char, std::unique_ptr<BigStruct>>

visit 의 구현과 성능

// std::visit 은 대개 '점프 테이블'로 컴파일됩니다
//   대안이 적으면(2~4개) 컴파일러가 if-else 체인으로 펼치기도 합니다
//   → 가상 호출과 비슷하거나 더 빠릅니다 (인라인 가능성이 있으므로)

// ⚠ 여러 variant 를 동시에 visit 하면 조합 폭발
std::visit(f, v1, v2);    // 대안이 각각 5개면 25개 분기가 생성됩니다 ★

// valueless_by_exception — 드물지만 알아 둘 것
//   대입 중 예외가 나면 variant 가 '아무 값도 없는' 상태가 될 수 있습니다
if (v.valueless_by_exception()) { /* 복구 */ }
//   → 이동 생성자가 noexcept 면 거의 발생하지 않습니다

// 성능 비교 (경향 — 직접 재세요)
//   variant + visit  : 인라인 가능 · 힙 할당 없음 · 캐시 유리 ★
//   virtual          : 간접 호출 · 힙 할당 · 포인터 추적
//   → 종류가 고정이면 variant 가 대개 빠릅니다

// 재귀 자료구조는 간접 참조가 필요합니다
struct Json;
using JsonValue = std::variant<std::nullptr_t, bool, double, std::string,
                               std::vector<Json>,
                               std::map<std::string, Json>>;
struct Json { JsonValue v; };      // ★ variant 안에 자기 자신을 직접 못 넣습니다
실무 판단 — 종류가 코드에 고정되어 있으면 variant, 나중에 플러그인으로 추가될 수 있으면 가상 함수입니다. 파서·상태 기계·메시지 타입은 대개 전자, 확장 포인트는 후자입니다.
정리 — 없을 수 있으면 optional, 실패 이유가 필요하면 expected, 종류가 여럿이면 variant. 셋 다 값을 꺼내지 않으면 쓸 수 없어 처리 누락이 구조적으로 막힙니다. 반환 함수에는 [[nodiscard]] 를 꼭 붙이세요.
"없을 수도 있다 · 여러 개 중 하나다 · 실패했다"를 타입으로 optional<T> has_value T 값 자리 값이 있거나 · 없거나 find() 결과 · 선택 설정값 variant<A, B, C> index A · B · C 중 하나 지금 무엇이 들었는지 자신이 압니다 토큰 · 파싱 결과 · 상태 기계 expected<T, E> (C++23) T 성공값 E 실패 이유 둘 중 하나 — 왜 실패했는지가 남습니다 예외 없이 실패를 돌려줄 때 공통점 — 힙을 쓰지 않고 값 안에 상태를 같이 담습니다 널 포인터로 "없음"을 흉내 내던 자리가 사라집니다. 크기는 대략 가장 큰 멤버 + 태그 만큼입니다 비어 있는 optional 에 * 나 value() 를 쓰면? *opt 는 검사 없이 바로 접근해 UB, value() 는 예외를 던집니다 — 값을 꺼내기 전에 if (opt) 로 확인하거나 value_or() 를 쓰세요
"없을 수 있다"가 타입에 적혀 있으면 확인을 잊은 자리를 컴파일러가 짚어 줍니다.
"없을 수도 있다 · 여러 개 중 하나다 · 실패했다"를 타입으로 optional<T> has_value T 값 자리 값이 있거나 · 없거나 find() 결과 · 선택 설정값 variant<A, B, C> index A · B · C 중 하나 지금 무엇이 들었는지 자신이 압니다 토큰 · 파싱 결과 · 상태 기계 expected<T, E> (C++23) T 성공값 E 실패 이유 둘 중 하나 — 왜 실패했는지가 남습니다 예외 없이 실패를 돌려줄 때 공통점 — 힙을 쓰지 않고 값 안에 상태를 같이 담습니다 널 포인터로 "없음"을 흉내 내던 자리가 사라집니다. 크기는 대략 가장 큰 멤버 + 태그 만큼입니다 비어 있는 optional 에 * 나 value() 를 쓰면? *opt 는 검사 없이 바로 접근해 UB, value() 는 예외를 던집니다 — 값을 꺼내기 전에 if (opt) 로 확인하거나 value_or() 를 쓰세요
"없을 수 있다"가 타입에 적혀 있으면 확인을 잊은 자리를 컴파일러가 짚어 줍니다.
MOD 11

string_view — 복사 없는 문자열

문자열을 복사하지 않고 들여다보기만 하는 창입니다. 함수 인자에 쓰면 그냥 빨라지는데, 수명 함정이 있습니다.

무엇이 좋아지나

#include <string_view>

// ❌ 예전 — 호출할 때마다 문자열이 만들어집니다
void log(const std::string& msg);
log("리터럴");            // const char* → std::string 임시 생성 (할당!) ★

// ✅ string_view — 포인터와 길이만 넘깁니다 (할당 0)
void log(std::string_view msg);
log("리터럴");            // 할당 없음
log(s);                   // std::string 도 그대로
log({buf, n});            // 버퍼 일부도

sizeof(std::string_view);  // 16 (포인터 8 + 길이 8)

// 부분 문자열도 복사 없이
std::string_view sv = text;
sv.substr(0, 10);         // ★ 새 문자열을 만들지 않습니다 (창만 좁힘)
text.substr(0, 10);       // 반면 이건 새 string 을 할당합니다

// 쓸 수 있는 것
sv.size()  sv.empty()  sv[i]  sv.front()  sv.back()
sv.find("x")  sv.starts_with("a")  sv.ends_with("z")   // C++20
sv.remove_prefix(3);  sv.remove_suffix(2);
for (char c : sv) ...

// 못 하는 것 — 내용 수정 (읽기 전용입니다)
sv[0] = 'x';              // ❌ 컴파일 에러

수명 함정 ★

// ❌ ① 임시 문자열을 가리키기
std::string_view sv = get_name() + "님";     // 임시가 식 끝에 사라짐 → 댕글링
std::string_view sv = std::to_string(42);    // 같은 문제

// ❌ ② 멤버로 보관
class Bad {
    std::string_view name_;                  // 원본이 먼저 죽으면?
public:
    explicit Bad(std::string_view n) : name_(n) {}
};
Bad b{std::string("임시")};                   // ❌ 생성 직후 댕글링

// ❌ ③ 컨테이너가 재할당되면
std::vector<std::string> v{"a"};
std::string_view sv = v[0];
v.push_back("b");                            // 재할당 → sv 무효

// ❌ ④ 널 종료가 보장되지 않습니다
void c_api(const char*);
c_api(sv.data());                            // ❌ 뒤에 다른 내용이 이어질 수 있음
c_api(std::string(sv).c_str());              // ✅ 복사해서 넘기기

// ✅ 규칙
//   · 함수 '인자'로만 쓰세요 (그 호출 동안만 유효하면 됨) ★
//   · 보관해야 하면 std::string 으로 복사
//   · 반환하지 마세요 (인자로 받은 것을 그대로 돌려주는 경우만 예외)
한 문장 규칙 — string_view 는 인자 전용입니다. 멤버·반환·컨테이너에 담고 싶어지면 그건 std::string 이 맞는 자리입니다.

이렇게도 쓸 수 있다 — 파싱에서의 위력 🟡 중급

할당 없는 파서

// CSV 한 줄을 자르기 — 할당 0회 ★
std::vector<std::string_view> split(std::string_view s, char sep) {
    std::vector<std::string_view> out;
    std::size_t start = 0;
    while (start <= s.size()) {
        auto pos = s.find(sep, start);
        if (pos == std::string_view::npos) {
            out.push_back(s.substr(start));
            break;
        }
        out.push_back(s.substr(start, pos - start));
        start = pos + 1;
    }
    return out;
}
//   ⚠ 반환된 view 들은 원본 s 가 살아 있는 동안만 유효합니다

// 숫자 변환도 할당 없이 (C++17)
#include <charconv>
int parse_int(std::string_view sv) {
    int v{};
    std::from_chars(sv.data(), sv.data() + sv.size(), v);
    return v;
}
//   ★ std::stoi 는 std::string 을 만들지만 from_chars 는 그렇지 않습니다
//     게다가 로케일 영향도 없고 예외도 던지지 않습니다 (훨씬 빠름)

// C++20 ranges 로
for (auto part : text | std::views::split(',')) {
    std::string_view sv{part.begin(), part.end()};
    ...
}

한글과 바이트

// ⚠ string_view 도 '바이트' 단위입니다 (기초 탭 x06)
std::string_view sv = "안녕하세요";
sv.size();          // 15 (한글 한 글자 3바이트)
sv.substr(0, 4);    // ❌ 두 번째 글자 중간에서 잘립니다 → 깨진 문자

// UTF-8 경계를 지키며 자르기
std::size_t utf8_prev_boundary(std::string_view s, std::size_t i) {
    while (i > 0 && (static_cast<unsigned char>(s[i]) & 0xC0) == 0x80) --i;
    return i;                        // 이어지는 바이트(10xxxxxx)를 건너뜀
}

// 글자 수 세기 (코드포인트 기준)
std::size_t utf8_length(std::string_view s) {
    std::size_t n = 0;
    for (unsigned char c : s)
        if ((c & 0xC0) != 0x80) ++n;
    return n;
}
//   ⚠ 이것도 '사람이 보는 글자'와는 다릅니다 (결합 문자·이모지)
//     정확히 하려면 ICU 같은 라이브러리가 필요합니다

// 실무 조언 — 문자열을 '자르지 않는' 설계가 가장 안전합니다.
//   자르기가 필요하면 파이썬 쪽에서 처리하고 C++ 로는 통째로 넘기세요(🐍 y04)

실무에선 이렇게 — 문자열 인터페이스 설계 🔴 실무

무엇을 언제 쓰나

자리타입
읽기만 하는 인자std::string_view ★
보관할 인자std::string (값) + move
수정할 인자std::string&
반환std::string (값)
멤버std::string
C API 로 넘길 것const char* (널 종료 보장)
바이너리 데이터std::span<const std::byte>
class Config {
    std::string host_;                                   // 멤버는 string
public:
    explicit Config(std::string host)                    // 보관 → 값 + move
        : host_(std::move(host)) {}

    [[nodiscard]] std::string_view host() const noexcept  // 읽기 → view
        { return host_; }

    bool matches(std::string_view pattern) const;        // 인자 → view
};

문자열 조립 최적화

// ❌ + 연산자 체인 — 중간 임시가 여러 개 생깁니다
std::string s = a + "-" + b + "-" + c;

// ✅ 미리 잡고 이어 붙이기
std::string s;
s.reserve(a.size() + b.size() + c.size() + 2);
s += a; s += '-'; s += b; s += '-'; s += c;

// ✅✅ std::format (C++20) — 한 번에, 읽기도 좋습니다 ★
std::string s = std::format("{}-{}-{}", a, b, c);

// 반복 조립은 back_inserter 로
std::string out;
std::format_to(std::back_inserter(out), "{}: {}\n", key, value);

// 미리 크기 계산 (C++20)
auto n = std::formatted_size("{}-{}", a, b);

// 여러 조각 합치기
std::vector<std::string_view> parts{...};
std::string joined;
joined.reserve(std::accumulate(parts.begin(), parts.end(), std::size_t{0},
    [](std::size_t n, auto sv){ return n + sv.size() + 1; }));
for (auto sv : parts) { joined += sv; joined += ','; }
std::format 을 기본으로 쓰세요 — printf 처럼 타입 실수가 나지 않고(컴파일 시점 검사), stringstream 보다 빠르며, 파이썬 f-string 과 문법이 비슷합니다. 컴파일러가 지원하지 않으면 fmt 라이브러리가 같은 API 를 제공합니다.

한 단계 더 — SSO 와 문자열 비용 🧬 전문가

작은 문자열 최적화

// std::string 은 짧으면 힙을 안 씁니다 (객체 안에 직접 저장)
//   libstdc++ : 15바이트까지    libc++ : 22바이트까지    MSVC : 15바이트
sizeof(std::string);        // 32 (libstdc++)

// 확인 — 할당 횟수를 세어 보기
static int allocs = 0;
void* operator new(std::size_t n) { ++allocs; return std::malloc(n); }

std::string a = "짧음";              // 할당 0
std::string b(100, 'x');             // 할당 1

// ★ 성능이 '계단식'으로 변하는 이유
//   키 길이가 15바이트를 넘는 순간 map 삽입 비용이 갑자기 뜁니다
//   → 로그 태그·enum 이름처럼 짧은 문자열은 생각보다 쌉니다

// 한글은 3바이트라 6글자부터 SSO 를 넘습니다 (libstdc++·MSVC) ★
std::string k = "안녕하세요";        // 15바이트 — 경계선 (아직 SSO, 한 글자 더하면 할당)

// 대안 — 고정 크기 문자열
template<std::size_t N> struct FixedString {
    std::array<char, N> data{};
    std::uint8_t len{};
};
//   할당이 절대 없고 캐시에 잘 맞습니다 (게임·거래 시스템에서 씁니다)

비교와 해시 비용

// map<string, T> 에 string_view 로 조회하기 — 임시 string 없이 ★
std::map<std::string, int, std::less<>> m;     // ★ std::less<> (투명 비교자)
m.find(std::string_view{"key"});                // 할당 없이 조회

struct StringHash {
    using is_transparent = void;                // ★ 이 표시가 필요합니다
    std::size_t operator()(std::string_view sv) const {
        return std::hash<std::string_view>{}(sv);
    }
    std::size_t operator()(const std::string& s) const {
        return std::hash<std::string>{}(s);
    }
};
std::unordered_map<std::string, int, StringHash, std::equal_to<>> um;
um.find(std::string_view{"key"});               // C++20 — 할당 없음

// 문자열 인터닝 — 같은 문자열을 한 번만 저장하고 포인터로 비교
class Interner {
    std::unordered_set<std::string> pool_;
public:
    std::string_view intern(std::string_view sv) {
        return *pool_.emplace(sv).first;        // 참조가 안정적입니다
    }
};
//   비교가 포인터 비교로 줄어 파서·심볼 테이블에서 큰 이득
투명 비교자를 모르면 조용히 느려집니다 — map<string,T> 에 const char* 로 조회하면 호출마다 임시 std::string 이 만들어집니다. std::less<> 하나만 추가하면 사라집니다.
정리 — string_view 는 복사 없이 문자열을 들여다보는 창입니다. 함수 인자에만 쓰고, 멤버·반환값에는 std::string 을 쓰세요. 문자열 조립은 std::format, 숫자 변환은 from_chars 가 가장 빠르고 안전합니다.
string_view 는 '포인터 + 길이' 두 값입니다 — 글자를 복사하지 않습니다 원본 버퍼 (std::string 하나) GET /api/users?page=2 HTTP/1.1 sv1 = method ptr → 위치 0, len 3 sv2 = path ptr → 위치 4, len 17 새 메모리 0바이트 substr 도 그냥 포인터 이동입니다 그래서 원본보다 오래 살면 안 됩니다 string_view sv = get_name() + "!"; — 임시 string 이 그 줄 끝에서 사라져 sv 는 해제된 메모리를 가리킵니다. 멤버로 보관하지 마세요 쓰는 자리는 분명합니다 — 읽기만 하고 호출 동안만 사는 함수 파라미터. 여기에 쓰면 const string& 로 받을 때 생기던 임시 복사가 사라집니다
파이썬 슬라이싱과 달리 잘라도 복사가 없습니다 — 대신 수명은 우리가 챙깁니다.
string_view 는 '포인터 + 길이' 두 값입니다 — 글자를 복사하지 않습니다 원본 버퍼 (std::string 하나) GET /api/users?page=2 HTTP/1.1 sv1 = method ptr → 위치 0, len 3 sv2 = path ptr → 위치 4, len 17 새 메모리 0바이트 substr 도 그냥 포인터 이동입니다 그래서 원본보다 오래 살면 안 됩니다 string_view sv = get_name() + "!"; — 임시 string 이 그 줄 끝에서 사라져 sv 는 해제된 메모리를 가리킵니다. 멤버로 보관하지 마세요 쓰는 자리는 분명합니다 — 읽기만 하고 호출 동안만 사는 함수 파라미터. 여기에 쓰면 const string& 로 받을 때 생기던 임시 복사가 사라집니다
파이썬 슬라이싱과 달리 잘라도 복사가 없습니다 — 대신 수명은 우리가 챙깁니다.
MOD 12

동시성 — GIL 이 없는 세계

파이썬은 GIL 때문에 스레드가 사실상 직렬이었습니다. C++ 는 진짜로 동시에 돕니다 — 그래서 조심할 것도 진짜로 생깁니다.

스레드 만들고 기다리기

#include <thread>
#include <mutex>

// C++20 jthread — 소멸자가 join 합니다 ★ (thread 는 terminate)
{
    std::jthread t([]{ work(); });
}   // 여기서 자동으로 join

// 취소까지 지원
std::jthread t([](std::stop_token st){
    while (!st.stop_requested()) { step(); }
});
t.request_stop();

// 공유 데이터는 반드시 보호
std::mutex m;
int counter = 0;
void inc() {
    std::lock_guard lk(m);       // RAII — 예외가 나도 풀립니다 ★
    ++counter;
}

// 여러 뮤텍스를 한 번에 (교착 방지)
std::scoped_lock lk(m1, m2);     // C++17 — 순서를 알아서 정합니다

// 조건 변수 — "무언가 준비될 때까지 기다리기"
std::condition_variable cv;
std::unique_lock lk(m);
cv.wait(lk, []{ return ready; });    // ★ 술어를 반드시 주세요 (가짜 깨움 방지)
cv.notify_one();  cv.notify_all();

더 쉬운 방법부터

// ① 병렬 알고리즘 — 스레드를 직접 안 만들어도 됩니다 ★
#include <execution>
std::sort(std::execution::par, v.begin(), v.end());
std::transform(std::execution::par_unseq, a.begin(), a.end(), out.begin(), f);

// ② async / future — 결과를 나중에 받기
#include <future>
auto fut = std::async(std::launch::async, []{ return heavy(); });
auto r = fut.get();              // 완료될 때까지 대기 (예외도 여기서 전달)

// ③ atomic — 락 없이 간단한 값 공유
std::atomic<int> done{0};
done.fetch_add(1, std::memory_order_relaxed);
std::atomic<bool> stop{false};

// ④ 스레드 로컬
thread_local std::vector<double> scratch;    // 스레드마다 별도 버퍼 ★

// ⑤ 동기화 도구 (C++20)
std::latch  latch{4};   latch.count_down();  latch.wait();
std::barrier bar{4};    bar.arrive_and_wait();
std::counting_semaphore<10> sem{3};
순서를 지키세요 — ① 공유하지 않기(각자 자기 데이터), ② 병렬 알고리즘, ③ async·스레드 풀, ④ 정말 필요할 때만 직접 스레드 + 뮤텍스. 대부분의 문제는 ①②에서 끝납니다.

이렇게도 쓸 수 있다 — 데이터 경쟁 피하기 🟡 중급

공유를 없애는 것이 최선입니다

// ❌ 공유 상태 + 락 — 경합으로 스레드를 늘려도 안 빨라집니다
std::mutex m;
std::vector<double> results;
parallel_for(n, [&](int i){
    auto r = compute(i);
    std::lock_guard lk(m);
    results.push_back(r);        // ★ 모든 스레드가 여기서 줄을 섭니다
});

// ✅ 미리 자리를 잡아 두고 각자 자기 칸에만 쓰기
std::vector<double> results(n);
parallel_for(n, [&](int i){
    results[i] = compute(i);     // 락 없음 · 각자 다른 인덱스 ★
});
//   ⚠ 인접한 인덱스에 다른 스레드가 쓰면 false sharing 이 생깁니다(⚡ e08)
//     → 청크 단위로 나누세요

// ✅ 스레드별 결과를 모았다가 마지막에 합치기
std::vector<std::vector<double>> per_thread(nthreads);
// ... 각자 자기 것에 push_back ...
for (auto& v : per_thread) results.insert(results.end(), v.begin(), v.end());

// ✅ 불변 데이터는 락이 필요 없습니다
const auto& table = build_table();   // 만들고 나서 안 바뀜
parallel_for(n, [&](int i){ use(table[i]); });   // 안전 ★

흔한 동시성 버그

// ① 검사 후 사용 (TOCTOU)
if (!m.contains(k)) m[k] = make();     // ❌ 둘 사이에 다른 스레드가 넣을 수 있음
{ std::lock_guard lk(mx); if (!m.contains(k)) m[k] = make(); }   // ✅

// ② 락을 잡은 채 오래 걸리는 일
{
    std::lock_guard lk(m);
    auto r = network_call();           // ❌ 다른 스레드가 전부 대기
}
auto r = network_call();               // ✅ 밖에서 계산하고
{ std::lock_guard lk(m); store(r); }   //    락은 짧게

// ③ 교착 — 락 순서가 다름
// 스레드A: lock(m1); lock(m2);
// 스레드B: lock(m2); lock(m1);        // ❌ 서로 기다림
std::scoped_lock lk(m1, m2);           // ✅ 항상 같은 순서로 잡아 줍니다

// ④ 이중 검사 잠금 — 직접 만들지 마세요
static std::once_flag flag;
std::call_once(flag, []{ init(); });   // ✅ 표준이 보장합니다
Logger& get() { static Logger l; return l; }   // ✅ 함수 안 static 도 안전

// ⑤ atomic 이면 안전하다는 오해
std::atomic<int> n;
n = n + 1;                             // ❌ 읽기와 쓰기가 분리됨
n.fetch_add(1);                        // ✅ 원자적

실무에선 이렇게 — 파이썬 모듈에서의 동시성 🔴 실무

GIL 을 놓으면 진짜로 병렬입니다

// 🐍 y07 과 이어지는 내용 — GIL 을 놓는 순간
// 파이썬이 제공하던 '어쩌다 직렬화'가 사라집니다.

// ❌ 전역 상태 — GIL 이 있을 때는 우연히 안전했습니다
static std::vector<double> g_cache;
m.def("run", [](py::array_t<double> a) {
    py::gil_scoped_release rel;
    g_cache.push_back(...);            // ★ 이제 진짜 데이터 경쟁
});

// ✅ 상태를 객체 안으로
py::class_<Engine>(m, "Engine")
    .def("run", &Engine::run, py::call_guard<py::gil_scoped_release>());
//   서로 다른 Engine 인스턴스는 독립적입니다
//   문서에 "같은 인스턴스를 여러 스레드에서 쓰지 마세요" 라고 명시

// ✅ 꼭 공유해야 하면 직접 보호
class Engine {
    mutable std::shared_mutex mx_;     // 읽기는 동시에, 쓰기는 배타적
    std::vector<double> data_;
public:
    double get(int i) const {
        std::shared_lock lk(mx_);      // 여러 스레드가 동시에 읽기 ★
        return data_[i];
    }
    void set(int i, double v) {
        std::unique_lock lk(mx_);      // 쓸 때만 배타적
        data_[i] = v;
    }
};

스레드 풀을 직접 만들지 마세요

// 표준에는 아직 스레드 풀이 없습니다 (C++26 예정).
// 직접 만들면 종료 처리·예외 전파·작업 훔치기에서 반드시 버그가 납니다.

// ✅ 검증된 것을 쓰세요
//   · std::execution::par (내부적으로 풀을 씁니다) ★ 가장 간단
//   · Intel TBB   — 작업 훔치기 · 성숙함
//   · OpenMP      — 지시문 한 줄
//   · BS::thread_pool — 헤더 하나짜리 경량

#include <BS_thread_pool.hpp>
BS::thread_pool pool(std::thread::hardware_concurrency());
auto fut = pool.submit_task([]{ return heavy(); });
pool.wait();

// ⚠ 스레드 수는 사용자가 정하게 하세요
//   이미 멀티프로세싱으로 도는 환경에서 모든 코어를 잡으면 오히려 느려집니다
m.def("set_threads", [](int n){ get_pool().reset(n); });
//   기본값은 1 이나 환경변수로 (OMP_NUM_THREADS 관례를 따르는 것도 좋습니다)
파이썬 3.13+ free-threading 시대 — GIL 없는 인터프리터에서는 여러 스레드가 동시에 내 확장에 들어옵니다. 전역 가변 상태가 있다면 지금 없애 두세요(🐍 y07). -fsanitize=thread 로 검증하는 것이 유일하게 확실한 방법입니다.

한 단계 더 — 메모리 모델 맛보기 🧬 전문가

왜 순서가 뒤바뀌나

// 컴파일러와 CPU 는 성능을 위해 명령 순서를 바꿉니다.
// 단일 스레드에서는 결과가 같지만, 다른 스레드가 보는 순서는 다를 수 있습니다.

// ❌ 고전적인 실패
bool ready = false;
int  data  = 0;
// 스레드 A
data = 42;
ready = true;          // ★ 컴파일러가 이 둘의 순서를 바꿀 수 있습니다
// 스레드 B
if (ready) use(data);  // data 가 아직 0일 수 있습니다

// ✅ atomic 으로 순서를 강제
std::atomic<bool> ready{false};
int data = 0;
// A
data = 42;
ready.store(true, std::memory_order_release);    // 앞의 쓰기를 모두 공개
// B
if (ready.load(std::memory_order_acquire))       // 그 이후를 모두 관찰
    use(data);                                    // ✅ 42 가 보장됩니다

// memory_order 요약
//   seq_cst  기본값 · 가장 안전 · 가장 느림 (전역 순서)
//   acquire/release  생산자-소비자 쌍 ★ 실무에서 쓰는 대부분
//   relaxed  순서 보장 없음 · 카운터처럼 값만 정확하면 될 때
//   → 확신이 없으면 기본값(seq_cst)을 쓰세요. 대개 충분히 빠릅니다.

검증 도구

# ThreadSanitizer — 데이터 경쟁을 실행 중에 잡습니다 ★
g++ -fsanitize=thread -g -O1 app.cpp -o app
./app
#   WARNING: ThreadSanitizer: data race
#     Write of size 8 at 0x... by thread T2:
#       #0 update() app.cpp:42
#     Previous read of size 8 at 0x... by thread T1:
#       #0 read() app.cpp:31
#   → 두 지점을 정확히 짚어 줍니다

# ⚠ ASan 과 함께 못 씁니다 (별도 빌드로 돌리세요)
# ⚠ 실행이 5~15배 느려집니다 → CI 의 별도 잡으로

# 파이썬 확장을 검사하려면
TSAN_OPTIONS="suppressions=tsan.supp" \
  LD_PRELOAD=$(gcc -print-file-name=libtsan.so) python test.py

# 교착 탐지
gdb -p PID -batch -ex "thread apply all bt"     # 모든 스레드가 어디서 멈췄나
# helgrind (valgrind) — 락 순서 문제도 잡습니다
valgrind --tool=helgrind ./app
동시성 버그는 테스트로 못 잡습니다 — 재현이 확률적이라 운 좋게 통과하는 경우가 대부분입니다. TSan 을 CI 에 넣는 것이 사실상 유일한 체계적 방어입니다(🛠️ b10).
정리 — C++ 스레드는 진짜로 동시에 돕니다. 가장 좋은 대책은 공유하지 않는 것이고, 공유해야 하면 lock_guard(RAII)로 짧게 잡으세요. 스레드를 직접 만들기 전에 병렬 알고리즘과 async 를 먼저 보고, TSan 으로 검증하세요.
GIL 이 없다 = 진짜 동시에 돈다 = 진짜로 깨질 수 있다 데이터 경쟁 — 두 스레드가 같은 변수를 동시에 count++ 는 읽고 · 더하고 · 쓰는 세 단계입니다. 겹치면 하나가 사라집니다 C++ 에서는 단순한 버그가 아니라 미정의 동작입니다 — 최적화와 겹치면 더 이상해집니다 ① 공유하지 않기 데이터를 나눠 각자 처리하고 끝에서 합칩니다 가장 빠르고 안전합니다 ② 메시지로 주고받기 큐에 넣고 꺼내 씁니다 소유권이 명확해집니다 생산자-소비자 구조 ③ 잠금 · 원자 mutex + lock_guard 단순한 카운터는 atomic 교착과 성능 저하에 주의 잠금은 직접 lock/unlock 하지 말고 lock_guard 나 scoped_lock 을 쓰세요 — RAII 가 풀어 줍니다 여러 잠금을 잡아야 하면 순서를 정해 두거나 scoped_lock 으로 한 번에 잡으세요 — 교착 예방 의심스러우면 ThreadSanitizer 를 켜세요 — 데이터 경쟁을 실행 중에 찾아 줍니다
병렬화의 순서는 늘 같습니다 — 먼저 나누고, 안 되면 주고받고, 마지막에 잠급니다.
pybind11 · nanobind · numpy 제로카피 · GIL 해제

느린 함수 하나만
C++ 로 갈아 끼웁니다

파이썬을 버리는 게 아닙니다. 프로파일러가 계속 가리키는 그 함수 하나를 C++ 로 만들어 import 해서 쓰는 것이 목표입니다. 쓰는 사람은 C++ 가 있는지도 모릅니다. 파일 세 개면 첫 모듈이 돕니다.

12SECTIONS
3파일이면 시작
10~100×기대 배수
4깊이 층
PY 01

왜 C++ 로 만드나 — 판단 기준이 먼저

가장 중요한 결정은 “C++ 로 안 만든다” 일 때가 많습니다. 네이티브 확장은 빌드·배포·디버깅 비용이 함께 따라오기 때문입니다.

C++ 로 옮기기 전에 걸러 내야 할 것들 — 여기까지 오는 코드는 많지 않습니다 ① 정말 여기가 병목인가 py-spy · cProfile 로 먼저 재세요. 체감상 느린 곳과 실제 병목은 대개 다릅니다 ② numpy · pandas 로 벡터화되나 파이썬 for 문을 배열 연산으로 바꾸는 것만으로 10~100배가 나오는 경우가 흔합니다 ③ 알고리즘을 바꿔서 되나 O(n²)를 O(n log n)으로 바꾸는 게 언어를 바꾸는 것보다 거의 항상 큰 차이를 냅니다 ④ numba · Cython 으로 되나 파이썬 문법을 거의 그대로 두고 배포도 단순합니다 — 빌드 부담이 없습니다
이 네 개를 통과하고도 남는 것 — 세밀한 루프 · 기존 C/C++ 라이브러리 · 하드웨어 접근이 진짜 후보입니다.

먼저 이 순서를 다 밟았는지 확인하세요

# ① 진짜 병목이 어디인지 측정  ★ 이걸 건너뛰면 대부분 헛수고입니다
python -m cProfile -s cumtime app.py | head -20
py-spy record -o flame.svg -- python app.py

# ② 알고리즘·자료구조 (O(n²) → O(n))
#    여기서 100배가 나오는 경우가 C++ 로 얻는 것보다 흔합니다

# ③ I/O·쿼리 횟수 줄이기
#    프로파일 1위가 read·recv·execute 면 C++ 로 바꿔도 그대로입니다

# ④ 이미 있는 C 라이브러리로 대체
import numpy as np            # 배열 연산
import polars as pl           # 표 연산 (pandas 보다 빠름)
import scipy, sklearn         # 수치·통계

# ⑤ 파이썬을 그대로 두고 컴파일
#    Cython · mypyc · Numba(@njit) — 코드를 거의 안 바꾸고 수십 배

# ⑥ 그래도 남는 병목 하나 → 여기서 C++ 를 꺼냅니다 ★

C++ 가 실제로 이기는 자리

이럴 때 유리이럴 땐 별로
원소마다 분기·상태가 있는 루프단순 배열 연산(numpy 가 이미 C)
numpy 로 벡터화가 안 되는 계산I/O·네트워크 대기가 대부분
이미 있는 C/C++ 라이브러리를 써야 함호출이 아주 잦고 계산은 짧음
GIL 없이 여러 코어를 써야 함파이썬 객체를 많이 오가야 함
지연 시간이 일정해야 함(GC 없음)요구가 자주 바뀌는 초기 코드
메모리를 정확히 통제해야 함팀에 C++ 를 아는 사람이 없음
호출 자체의 비용을 기억하세요 — 파이썬↔C++ 경계를 한 번 넘는 데 대략 수십~수백 나노초가 듭니다. 100만 번 호출하는 짧은 함수라면 경계 비용이 계산보다 커집니다. 그래서 “한 번 불러 배열 전체를 처리” 하는 모양으로 설계해야 합니다 — 이 원칙이 이 탭 전체를 관통합니다.

이렇게도 쓸 수 있다 — 선택지 비교 🟡 중급

네이티브로 가는 길은 여러 갈래입니다

방법장점단점
pybind11C++ 답고 자료 많음 · numpy 연동 ★빌드 필요 · 컴파일 느림
nanobindpybind11 보다 작고 빠름C++17+ · 자료가 적음
ctypes빌드 불필요 · 표준 라이브러리C ABI 만 · 타입 안전성 없음
cffictypes 보다 안전 · 빠름의존성 추가
Cython파이썬 문법 그대로별도 언어를 배워야 함
Rust + PyO3메모리 안전 · 빌드 편함Rust 학습
C API 직접의존성 0참조 카운트를 손으로 — 권하지 않음
고르는 기준 — 새로 C++ 코드를 쓴다 → pybind11(또는 nanobind). 이미 있는 dll·so 를 부른다 → ctypes(🔗 탭). 파이썬 코드를 그냥 빠르게 → Cython·Numba. 새로 시작하고 팀이 배울 여력이 있다 → Rust + PyO3(🦀 가이드).

현실적인 기대 배수

같은 알고리즘 기준의 대략적인 감각 (직접 재 보셔야 합니다)

  순수 파이썬 루프            1×    기준
  numpy 벡터화               10~100×   (이미 C 로 도는 것)
  numba @njit                10~100×
  C++ (단순 이식)            10~50×
  C++ (캐시·SIMD 최적화)      50~200×
  C++ + 멀티스레드            × 코어 수

  ⚠ 단, 다음이면 배수가 확 줄어듭니다
     · 파이썬 객체를 자주 오간다      → 경계 비용이 지배
     · 데이터가 작다(수천 개 이하)    → 차이가 안 보임
     · 이미 numpy 로 벡터화돼 있다    → 2~3배에 그치는 경우가 흔함

  ★ "numpy 로 이미 짜 놓은 코드를 C++ 로 옮겼는데 별로 안 빨라졌다"
     는 아주 흔한 경험입니다. numpy 는 이미 최적화된 C 코드입니다.
     C++ 가 이기려면 numpy 가 못 하는 것(분기·상태·메모리 재사용)을 해야 합니다.

실무에선 이렇게 — 도입 전에 합의할 것 🔴 실무

비용은 코드가 아니라 운영에서 옵니다

  • 빌드 환경 — 팀원 전원과 CI 에 컴파일러가 필요합니다. wheel 을 미리 만들어 배포하면 쓰는 사람은 몰라도 됩니다(y03·🛠️ b06)
  • 플랫폼 수 × 파이썬 버전 수 = 만들어야 할 wheel 수. abi3 로 묶으면 파이썬 버전별 빌드를 줄일 수 있습니다
  • 디버깅 난이도 — 파이썬에서 나던 ValueError 가 세그폴트로 인터프리터 전체가 죽는 형태로 바뀝니다
  • 버스 팩터 — 그 모듈을 고칠 수 있는 사람이 팀에 몇 명인지
  • 되돌릴 수 있게 — 순수 파이썬 구현을 같이 남겨 두고 환경변수로 전환 가능하게 해 두면 사고 시 즉시 되돌립니다 ★
가장 안전한 도입 형태 — “순수 파이썬 함수와 완전히 같은 결과를 내는 C++ 함수를 만들고, 테스트로 두 결과를 대조한다”. 그러면 언제든 되돌릴 수 있고, 정확성 검증도 자동으로 됩니다.

인터페이스를 먼저 설계합니다

# ❌ 파이썬 객체를 그대로 넘기는 설계 — 경계를 수백만 번 넘습니다
for row in rows:
    result.append(fastmod.process_one(row))     # 호출마다 변환 비용

# ✅ 배열 하나를 통째로 넘기고 결과 배열을 받습니다 ★
result = fastmod.process_all(arr)               # 경계는 단 한 번

# ✅ 상태가 필요하면 객체 하나로 유지 (매번 재구성하지 않기)
engine = fastmod.Engine(config)     # 무거운 준비는 한 번만
for chunk in stream:
    engine.feed(chunk)              # 가벼운 호출

# ✅ 데이터는 numpy 로 주고받습니다 (복사 없이 — y06)
import numpy as np
out = np.empty_like(arr)
fastmod.transform(arr, out)         # 결과를 미리 만든 버퍼에 씁니다
이 설계 하나가 성패를 가릅니다 — 잘못 설계하면 C++ 로 옮겼는데 더 느려지는 결과가 나옵니다. 기준은 하나입니다 — “경계를 몇 번 넘는가”. 가능하면 1번, 안 되면 데이터 크기와 무관한 횟수로 만드세요.

한 단계 더 — 경계 비용을 실제로 재 보기 🧬 전문가

빈 함수를 부르는 데 얼마나 드나

// noop.cpp — 아무것도 안 하는 함수들
#include <pybind11/pybind11.h>
#include <pybind11/numpy.h>
#include <pybind11/stl.h>          // ★ 없으면 take_list 호출이 TypeError
namespace py = pybind11;

void noop() {}
void take_int(int) {}
void take_list(const std::vector<double>&) {}          // 자동 변환 = 복사
void take_arr(py::array_t<double>) {}                   // 버퍼 = 복사 없음

PYBIND11_MODULE(noop, m) {
    m.def("noop", &noop);
    m.def("take_int", &take_int);
    m.def("take_list", &take_list);
    m.def("take_arr", &take_arr);
}
import timeit, numpy as np, noop
a = np.zeros(1_000_000); L = a.tolist()

timeit.timeit(lambda: noop.noop(), number=100_000)
timeit.timeit(lambda: noop.take_arr(a), number=100_000)    # 크기와 무관
timeit.timeit(lambda: noop.take_list(L), number=100)       # ★ 크기에 비례

#  결과의 형태 (숫자는 환경마다 다릅니다 — 직접 재세요)
#    noop        : 호출당 수십~수백 ns
#    take_arr    : 비슷함 (버퍼는 포인터만 넘어감)
#    take_list   : 100만 원소를 매번 vector 로 복사 → 수 ms ★
#  → 자동 변환이 편해 보이지만 '큰 컨테이너'에는 절대 쓰면 안 됩니다

손익분기점을 계산해 두기

# 언제부터 C++ 가 이기는가
#   총시간_py   = n × t_py
#   총시간_cpp  = 경계비용 + n × t_cpp
#   → n × (t_py - t_cpp) > 경계비용  일 때만 이득

# 실제로는 이렇게 재 봅니다
import numpy as np, timeit, fastmod
for n in (100, 1_000, 10_000, 100_000, 1_000_000):
    a = np.random.rand(n)
    tp = timeit.timeit(lambda: pure_python(a), number=10) / 10
    tc = timeit.timeit(lambda: fastmod.run(a),  number=10) / 10
    print(f"{n:>9}  py {tp*1e3:8.3f}ms  cpp {tc*1e3:8.3f}ms  {tp/tc:6.1f}×")

#  전형적인 결과 — 작은 n 에서는 배수가 1 근처이거나 오히려 손해,
#  n 이 커질수록 배수가 올라가다 어느 지점에서 평평해집니다(메모리 대역폭 한계).
#  ★ 이 표를 팀에 보여 주는 것이 "빨라졌습니다"보다 100배 설득력 있습니다
이 측정이 실무에서 하는 일 — “언제 C++ 경로를 쓰고 언제 파이썬으로 두는가”를 코드에 조건으로 넣을 수 있게 됩니다. if n < 5000: return pure_python(a) 같은 분기는 과한 최적화가 아니라 측정에 근거한 설계입니다.
정리 — C++ 모듈은 마지막 카드입니다. ① 측정 → ② 알고리즘 → ③ I/O → ④ numpy·polars → ⑤ Cython·Numba 를 다 해 보고, 그래도 남는 병목 하나에만 씁니다. 그리고 설계의 핵심은 단 하나 — 경계를 최대한 적게 넘기.
PY 02

pybind11 — 첫 모듈까지 5분

파일 세 개면 됩니다. C++ 함수 하나를 파이썬에서 import 해 쓰는 것까지 처음부터 끝까지 해 보겠습니다.

① C++ 파일 하나

// src/fastmod.cpp
#include <pybind11/pybind11.h>
#include <pybind11/stl.h>      // vector ↔ list 자동 변환

namespace py = pybind11;

int add(int a, int b) {
    return a + b;
}

double mean(const std::vector<double>& v) {
    double s = 0;
    for (double x : v) s += x;
    return v.empty() ? 0.0 : s / v.size();
}

PYBIND11_MODULE(fastmod, m) {        // ★ 이름이 파이썬 모듈명과 같아야 합니다
    m.doc() = "느린 부분만 C++ 로";

    m.def("add", &add, "두 정수를 더한다",
          py::arg("a"), py::arg("b"));           // 키워드 인자 이름

    m.def("mean", &mean, "평균을 구한다",
          py::arg("values"));
}
가장 흔한 첫 실패 — PYBIND11_MODULE(fastmod, m) 의 이름과 빌드 결과 파일 이름(fastmod.cpython-312-x86_64-linux-gnu.so)이 다르면 import 가 실패합니다. 이름을 정확히 맞추세요.

② 빌드 파일 두 개

# pyproject.toml
[build-system]
requires = ["scikit-build-core>=0.10", "pybind11>=2.12"]
build-backend = "scikit_build_core.build"

[project]
name = "fastmod"
version = "0.1.0"
requires-python = ">=3.9"
# CMakeLists.txt
cmake_minimum_required(VERSION 3.20)
project(fastmod LANGUAGES CXX)

set(CMAKE_CXX_STANDARD 20)
find_package(pybind11 CONFIG REQUIRED)

pybind11_add_module(fastmod src/fastmod.cpp)
install(TARGETS fastmod DESTINATION .)
# ③ 설치하고 바로 쓰기
pip install .
#   또는 개발 중이라면 (editable 설치 — 자동 재빌드 설정은 아래 ①)
pip install -e . --no-build-isolation
>>> import fastmod
>>> fastmod.add(1, 2)
3
>>> fastmod.add(a=1, b=2)          # 키워드도 됩니다
3
>>> fastmod.mean([1.0, 2.0, 3.0])  # list 가 vector 로 변환됩니다
2.0
>>> help(fastmod.add)              # docstring 도 나옵니다

이렇게도 쓸 수 있다 — 파이썬답게 다듬기 🟡 중급

기본값 · 키워드 · 문서

using namespace pybind11::literals;      // "이름"_a 리터럴

m.def("smooth", &smooth,
      R"doc(신호를 이동평균으로 부드럽게 만든다.

Args:
    data: 입력 값들
    window: 창 크기 (기본 3)

Returns:
    같은 길이의 결과
)doc",
      "data"_a, "window"_a = 3);            // ★ 기본값

// 여러 이름으로 오버로드 노출 (파이썬은 오버로딩이 없으므로 순서대로 시도)
m.def("area", py::overload_cast<double>(&area), "r"_a);
m.def("area", py::overload_cast<double,double>(&area), "w"_a, "h"_a);

// 람다로 감싸 파이썬 쪽 인터페이스를 다듬기 ★ 매우 자주 씁니다
m.def("parse", [](const std::string& text, bool strict) {
    auto r = parse_impl(text, strict);
    return py::make_tuple(r.value, r.consumed);   // 튜플로 돌려주기
}, "text"_a, "strict"_a = false);

// 모듈 상수 · 버전
m.attr("__version__") = "0.1.0";
m.attr("MAX_WINDOW") = 1024;

개발 중에 편한 것들

# ① 고칠 때마다 자동 재빌드 (editable + 재빌드 훅)
pip install -e . --no-build-isolation \
    -C editable.rebuild=true -C build-dir=build/{wheel_tag}
#   이 두 설정(또는 pyproject 의 [tool.scikit-build])이 있어야
#   scikit-build-core 가 import 시점에 변경을 감지해 다시 빌드해 줍니다

# ② 빌드 로그 자세히 보기 (에러 원인 찾을 때)
pip install . -v --no-build-isolation

# ③ 컴파일 캐시로 재빌드 가속
pip install ccache
export CMAKE_CXX_COMPILER_LAUNCHER=ccache

# ④ 빌드된 모듈이 어디 있는지 확인
python -c "import fastmod; print(fastmod.__file__)"

# ⑤ 심볼 확인 (import 실패 진단의 시작점)
nm -C -D fastmod*.so | grep PyInit          # PyInit_fastmod 가 있어야 합니다
반복 주기를 짧게 만드는 것이 생산성의 전부입니다 — C++ 확장 개발의 체감 속도는 “고치고 → 파이썬에서 확인”까지 몇 초냐로 정해집니다. ccache + -e 설치 + 파일 분리(무거운 헤더 격리)로 보통 10초 이내까지 줄일 수 있습니다.

실무에선 이렇게 — 패키지 구조와 폴백 🔴 실무

C++ 모듈을 파이썬 패키지 안에 감춥니다

fastmod/
  pyproject.toml
  CMakeLists.txt
  src/
    _core.cpp            ← C++ (내부용 이름에 _ 를 붙입니다)
  python/fastmod/
    __init__.py          ← 사용자에게 보이는 얼굴
    _fallback.py         ← 순수 파이썬 구현 (되돌릴 수 있게) ★
    py.typed             ← 타입 힌트 제공 표시
    _core.pyi            ← 스텁 (y10)
  tests/
    test_parity.py       ← C++ 와 파이썬 결과가 같은지 대조 ★
# python/fastmod/__init__.py
import os

_use_pure = os.environ.get("FASTMOD_PURE") == "1"

if not _use_pure:
    try:
        from ._core import mean, smooth        # C++ 구현
    except ImportError:                        # 빌드 안 된 환경
        _use_pure = True

if _use_pure:
    from ._fallback import mean, smooth        # 파이썬 구현

__all__ = ["mean", "smooth"]
이 구조의 값어치 — 운영에서 문제가 생기면 FASTMOD_PURE=1 하나로 재배포 없이 즉시 되돌릴 수 있습니다. 그리고 두 구현의 결과를 대조하는 테스트가 정확성을 자동으로 지켜 줍니다.

정확성을 자동으로 지키기

# tests/test_parity.py
import numpy as np, pytest
from fastmod import _core
from fastmod import _fallback

@pytest.mark.parametrize("n", [0, 1, 2, 1000])
def test_mean_matches(n):
    a = np.random.rand(n)
    assert _core.mean(a) == pytest.approx(_fallback.mean(a), rel=1e-12)

# 속성 기반 테스트 — 랜덤 입력으로 두 구현을 계속 대조합니다 ★
from hypothesis import given, strategies as st
import hypothesis.extra.numpy as hnp

@given(hnp.arrays(np.float64, hnp.array_shapes(max_dims=1),
                  elements=st.floats(-1e6, 1e6)))
def test_smooth_matches(a):
    np.testing.assert_allclose(_core.smooth(a, 3),
                               _fallback.smooth(a, 3), rtol=1e-10)

# 경계 조건은 꼭 따로 — 여기서 세그폴트가 납니다
def test_empty_and_huge():
    assert _core.mean(np.array([])) == 0.0
    _core.smooth(np.zeros(0), 3)
    with pytest.raises(ValueError):
        _core.smooth(np.zeros(10), 0)      # 잘못된 인자는 예외로 ★
빈 배열 · 크기 0 · 음수 인자 — 이 세 가지가 확장 모듈에서 세그폴트를 내는 압도적 1위입니다. 파이썬이라면 예외로 끝났을 것이 인터프리터 전체를 죽입니다. 테스트에 반드시 넣으세요.

한 단계 더 — import 되는 순간 무슨 일이 일어나나 🧬 전문가

PYBIND11_MODULE 이 펼쳐지면

// 이 매크로는 결국 CPython 이 찾는 진입점 함수를 만듭니다
//
//   extern "C" PyObject* PyInit_fastmod() { ... }
//
// import fastmod 를 하면 CPython 이
//   ① sys.path 에서 fastmod*.so 를 찾고
//   ② dlopen 으로 공유 라이브러리를 로드하고
//   ③ PyInit_fastmod 심볼을 찾아 호출하고
//   ④ 돌려받은 모듈 객체를 sys.modules 에 등록합니다

// 확인
nm -D fastmod*.so | grep PyInit_fastmod       // T PyInit_fastmod
python -c "import fastmod, sys; print(sys.modules['fastmod'])"

// ★ 그래서 import 실패의 원인은 대개 이 셋 중 하나입니다
//   · 파일 이름과 모듈 이름 불일치        → PyInit_ 이름이 안 맞음
//   · undefined symbol (링크 누락)        → ldd / nm -D 로 확인
//   · 파이썬 버전·ABI 불일치              → 다른 버전으로 빌드된 .so

ldd fastmod*.so                    // 필요한 라이브러리가 다 있는지
python -c "import fastmod" 2>&1    // ImportError 메시지에 심볼 이름이 나옵니다

모듈 로딩 비용과 바이너리 크기

# 모듈이 얼마나 큰가 · 로딩이 얼마나 걸리는가
ls -lh fastmod*.so
python -X importtime -c "import fastmod" 2>&1 | tail -3

# pybind11 은 템플릿을 많이 써서 바이너리가 커집니다. 줄이는 방법:
#   ① 심볼 가시성 숨기기 (pybind11_add_module 이 이미 해 줍니다)
#   ② 링크 시 안 쓰는 것 제거
target_link_options(fastmod PRIVATE -Wl,--gc-sections)
target_compile_options(fastmod PRIVATE -ffunction-sections -fdata-sections)
#   ③ 릴리즈 빌드에서 디버그 심볼 분리
strip --strip-unneeded fastmod*.so     # 크기가 절반 이하로 줄기도 합니다
#   ④ 크기가 정말 중요하면 nanobind 로 (y11) — 보통 3~10배 작습니다

# 무엇이 크기를 차지하는지
nm -C --size-sort -S fastmod*.so | tail -20
bloaty fastmod*.so                     # 더 자세한 분석
왜 크기가 중요한가 — 여러 플랫폼 × 여러 파이썬 버전의 wheel 을 만들면 저장소 용량과 설치 시간이 곧바로 늘어납니다. 서버리스(람다)처럼 콜드 스타트가 중요한 환경에서는 로딩 시간도 직접 비용이 됩니다.
정리 — C++ 파일 하나 + CMakeLists.txt + pyproject.toml, 그리고 pip install . — 이게 전부입니다. PYBIND11_MODULE 의 이름과 모듈 파일 이름을 맞추는 것만 조심하면 첫 import 는 5분 안에 성공합니다.
PY 03

빌드 붙이기 — 쓰는 사람은 C++ 를 몰라도 되게

목표는 pip install fastmod 한 줄입니다. 컴파일러도, CMake 도, C++ 도 쓰는 쪽에서는 보이지 않아야 합니다.

빌드 백엔드 고르기

방법언제
scikit-build-core★ 기본 추천. CMake 를 그대로 쓰고 외부 라이브러리 연동이 자유롭습니다
setuptools + Pybind11Extension파일 몇 개짜리 아주 단순한 모듈
meson-pythonMeson 을 쓰는 프로젝트(numpy·scipy 가 이 방식)
# pyproject.toml — 실무에서 쓰는 형태
[build-system]
requires = ["scikit-build-core>=0.10", "pybind11>=2.12"]
build-backend = "scikit_build_core.build"

[project]
name = "fastmod"
version = "0.1.0"
requires-python = ">=3.9"
dependencies = ["numpy>=1.23"]

[tool.scikit-build]
wheel.packages = ["python/fastmod"]     # 파이썬 코드 위치
cmake.version = ">=3.20"
build-dir = "build/{wheel_tag}"         # 재빌드 캐시 유지 ★

[tool.scikit-build.cmake.define]
CMAKE_BUILD_TYPE = "Release"

CMake 쪽 — 파이썬을 찾고 모듈을 만듭니다

cmake_minimum_required(VERSION 3.20)
project(fastmod LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 20)

# scikit-build-core 가 알맞은 파이썬을 알려 줍니다
find_package(Python REQUIRED COMPONENTS Interpreter Development.Module)
find_package(pybind11 CONFIG REQUIRED)

pybind11_add_module(_core src/core.cpp src/algo.cpp)
target_include_directories(_core PRIVATE src)
target_compile_definitions(_core PRIVATE VERSION_INFO=${SKBUILD_PROJECT_VERSION})
#   ↑ scikit-build-core 가 pyproject 의 version 을 넘겨 줍니다
#     (project() 에 VERSION 이 없어 ${PROJECT_VERSION} 은 비어 있습니다)

# 최적화 — 릴리즈에서만
target_compile_options(_core PRIVATE
  $<$<AND:$<CONFIG:Release>,$<NOT:$<CXX_COMPILER_ID:MSVC>>>:-O3>)

install(TARGETS _core DESTINATION fastmod)   # 패키지 안으로 ★
-march=native 를 배포용 빌드에 넣지 마세요 — 내 CPU 에만 있는 명령을 쓰게 되어, 다른 기계에서 Illegal instruction 으로 즉사합니다. 배포 wheel 은 보수적인 기준(x86-64-v2 정도)으로 빌드하고, 필요하면 실행 시점 분기(⚡ e06)를 쓰세요.

이렇게도 쓸 수 있다 — 외부 라이브러리 붙이기 🟡 중급

의존성을 가져오는 두 방식

# ① FetchContent — 소스를 받아 같이 빌드 (의존성 관리자 불필요) ★
include(FetchContent)
FetchContent_Declare(fmt
  GIT_REPOSITORY https://github.com/fmtlib/fmt.git
  GIT_TAG 11.0.2)                       # ★ 태그를 고정하세요
FetchContent_MakeAvailable(fmt)
target_link_libraries(_core PRIVATE fmt::fmt)

# ② vcpkg — 미리 빌드된 패키지 (큰 의존성에 유리)
#    vcpkg.json 에 적고 CMAKE_TOOLCHAIN_FILE 지정
find_package(Eigen3 CONFIG REQUIRED)
target_link_libraries(_core PRIVATE Eigen3::Eigen)

# ③ 헤더 전용 라이브러리는 그냥 include 경로만
target_include_directories(_core PRIVATE third_party/nlohmann)
확장 모듈에서는 정적 링크가 유리합니다 — 외부 .so 의존성이 있으면 사용자 환경에 그 라이브러리가 있어야 합니다. 가능하면 정적으로 묶어 확장 파일 하나로 완결시키세요(🛠️ b08). auditwheel·delocate 가 이 작업을 자동으로 해 줍니다.

setuptools 로 아주 간단하게

# setup.py — CMake 없이 파일 몇 개만 있을 때
from setuptools import setup
from pybind11.setup_helpers import Pybind11Extension, build_ext

ext = [Pybind11Extension(
    "fastmod._core",
    ["src/core.cpp"],
    cxx_std=20,
    define_macros=[("VERSION_INFO", '"0.1.0"')],
)]

setup(ext_modules=ext, cmdclass={"build_ext": build_ext})
# 개발 중 반복 주기
pip install -e . --no-build-isolation -v

# 빌드 산출물 정리 (이상할 때 제일 먼저)
rm -rf build/ *.egg-info/ **/*.so
pip cache purge

# 어떤 컴파일러로 빌드됐는지 확인
python -c "import sysconfig; print(sysconfig.get_config_var('CC'))"
--no-build-isolation 은 왜 붙이나 — pip 는 기본적으로 격리된 임시 환경에서 빌드합니다. 그래서 매번 pybind11·CMake 를 새로 받고, -e 설치의 재빌드가 어긋나기도 합니다. 개발 중에는 이 옵션으로 현재 환경을 그대로 쓰는 편이 빠릅니다 (대신 빌드 의존성을 직접 설치해 두어야 합니다).

실무에선 이렇게 — 사용자는 컴파일하지 않습니다 🔴 실무

wheel 을 미리 만들어 올립니다

# .github/workflows/wheels.yml
name: wheels
on: {push: {tags: ['v*']}}

jobs:
  build:
    strategy:
      matrix:
        os: [ubuntu-latest, windows-latest, macos-14]
    runs-on: ${{ matrix.os }}
    steps:
      - uses: actions/checkout@v4
      - uses: pypa/cibuildwheel@v2.21          # ★ 이 액션이 전부 해 줍니다
        env:
          CIBW_BUILD: "cp39-* cp310-* cp311-* cp312-* cp313-*"
          CIBW_SKIP: "*-musllinux_i686 pp*"
          CIBW_TEST_REQUIRES: pytest numpy
          CIBW_TEST_COMMAND: "pytest {project}/tests"
      - uses: actions/upload-artifact@v4
        with: {name: "wheels-${{ matrix.os }}", path: wheelhouse/*.whl}   # v4 는 이름이 겹치면 실패
cibuildwheel 이 해 주는 일 — 리눅스는 manylinux 컨테이너에서 빌드해 오래된 배포판에서도 돌게 하고, auditwheel·delocate 로 의존 라이브러리를 wheel 안에 넣어 줍니다. 직접 하면 며칠 걸릴 일이 설정 몇 줄로 끝납니다(🛠️ b06).

파이썬 버전마다 빌드하지 않는 법 — abi3

# 안정 ABI(abi3)로 빌드하면 wheel 하나가 여러 파이썬 버전에서 동작합니다
# ⚠ pybind11 은 Py_LIMITED_API(안정 ABI)를 지원하지 않습니다
#   → abi3 가 필요하면 nanobind 로 (파이썬 3.12+, y11)
nanobind_add_module(_core STABLE_ABI src/core.cpp)
#   3.12 미만 파이썬으로 빌드하면 STABLE_ABI 는 무시되고 일반 빌드가 됩니다
#   파일 이름: _core.abi3.so (리눅스/맥) · _core.pyd (윈도우)
# pyproject.toml
[tool.scikit-build]
wheel.py-api = "cp312"       # cp312-abi3 태그로 빌드 (3.12 이상 전부)
비교 — 파이썬 5개 버전(모두 3.12 이상) × 3 OS × 2 아키텍처

  일반 빌드 : 30개 wheel · CI 시간 30분 · 새 파이썬 나오면 다시 빌드
  abi3      :  6개 wheel · CI 시간  6분 · 새 버전에서도 그대로 동작 ★

대가
  · CPython 안정 API 만 쓸 수 있어 일부 최적화가 제한됩니다
  · abi3 는 nanobind + 3.12 이상에서만 — 3.9~3.11 도 지원하려면 그 버전은 따로 빌드합니다
  · 성능 차이는 대개 미미하지만, 극단적으로 호출이 잦으면 재 보세요

한 단계 더 — 빌드 시간과 재현성 🧬 전문가

pybind11 은 왜 컴파일이 느린가

// 바인딩 코드는 템플릿 인스턴스화가 폭발적으로 일어납니다.
// m.def 하나마다 인자 타입 조합만큼 코드가 생성됩니다.

// ① 바인딩을 여러 파일로 쪼개기 — 병렬 컴파일이 가능해집니다 ★
// bind_array.cpp
void bind_array(py::module_& m) { m.def("smooth", &smooth); }
// bind_stats.cpp
void bind_stats(py::module_& m) { m.def("mean", &mean); }
// main.cpp
PYBIND11_MODULE(_core, m) { bind_array(m); bind_stats(m); }

// ② 실제 로직과 바인딩을 분리 — 로직은 순수 C++ 로 두고 테스트도 C++ 로
//    (바인딩 파일만 느리게 컴파일되고, 로직 파일은 빠릅니다)

// ③ 컴파일 시간 측정
//    clang++ -ftime-trace -c bind_array.cpp  → ui.perfetto.dev

// ④ 링크 시간 최적화는 릴리즈에서만 (개발 중에는 매우 느려집니다)
set(CMAKE_INTERPROCEDURAL_OPTIMIZATION_RELEASE ON)

재현 가능한 빌드

# ① 의존성 버전을 전부 고정
#    pyproject.toml 의 requires 에 상한을 두고
#    FetchContent 는 GIT_TAG 를 커밋 해시로

# ② 빌드 환경을 컨테이너로 고정
docker run --rm -v $PWD:/io quay.io/pypa/manylinux_2_28_x86_64 \
    /io/build_wheels.sh

# ③ 어떤 플래그로 빌드됐는지 wheel 에 기록
#    CMake 에서 컴파일 정보를 모듈 속성으로 노출
target_compile_definitions(_core PRIVATE
    BUILD_INFO="${CMAKE_CXX_COMPILER_ID} ${CMAKE_CXX_COMPILER_VERSION}")
#    파이썬에서 fastmod.__build_info__ 로 확인 → 장애 대응에 유용 ★

# ④ wheel 안에 뭐가 들었는지 확인
unzip -l dist/fastmod-0.1.0-*.whl
auditwheel show dist/*.whl          # 어떤 .so 에 의존하는지 · manylinux 등급
운영에서 반드시 필요해지는 것 — “이 wheel 이 어떤 컴파일러·어떤 플래그로 언제 빌드됐나”를 모듈 속성으로 남겨 두세요. 성능 이상이나 크래시가 보고됐을 때 가장 먼저 확인하게 되는 정보입니다.
정리 — pyproject.toml + CMakeLists.txt 두 파일이면 pip install . 로 빌드됩니다. 배포는 cibuildwheel 이 3 OS × 여러 버전을 자동으로 만들어 주고, abi3 를 쓰면 wheel 수를 5분의 1로 줄일 수 있습니다. 개발 중에는 pip install -e . --no-build-isolation + ccache.
PY 04

타입 변환 — 어디서 복사가 일어나는가

pybind11 은 list↔vector 를 알아서 바꿔 줍니다. 편하지만 전부 복사입니다. 언제 복사되는지 아는 것이 성능의 절반입니다.

자동으로 변환되는 것들

#include <pybind11/stl.h>        // ★ 이 헤더가 있어야 컨테이너 변환됨

// 파이썬              C++
// int                 int · long · size_t
// float               double · float
// str                 std::string          (UTF-8 로 인코딩)
// bytes               std::string · std::vector<uint8_t>
// bool                bool
// None                std::nullptr_t · optional 의 비어 있음
// list                std::vector<T>       ★ 복사
// tuple               std::tuple · std::pair
// dict                std::map · unordered_map  ★ 복사
// set                 std::set · unordered_set  ★ 복사
// datetime            std::chrono::system_clock::time_point
//                       (<pybind11/chrono.h> 필요)

// ⚠ 복사는 '양방향' 입니다
std::vector<double> process(const std::vector<double>& in);
//   파이썬 list → vector 복사 (들어올 때)
//   vector → 파이썬 list 복사 (나갈 때)
//   100만 원소면 왕복 두 번의 전체 복사입니다 ★

복사를 피하는 세 가지 길

// ① numpy 배열로 주고받기 ★ 가장 중요 (y06 에서 자세히)
#include <pybind11/numpy.h>
double mean(py::array_t<double> a) {
    auto buf = a.request();               // 포인터만 얻습니다 — 복사 없음
    const double* p = static_cast<double*>(buf.ptr);
    ...
}

// ② 파이썬 객체를 그대로 다루기 (변환하지 않음)
void inspect(const py::object& o) {
    py::len(o);
    o.attr("method")();
}
py::list  lst;  py::dict d;              // 파이썬 타입 그대로

// ③ C++ 객체를 참조로 노출 (y05 의 클래스 바인딩)
//    반환 정책으로 '복사할지 참조할지' 정합니다
m.def("get_buffer", &get_buffer,
      py::return_value_policy::reference_internal);   // 복사 없이 참조
기억할 기준 — 수백 개까지는 자동 변환이 편하고 문제없습니다. 수만 개를 넘어가면 반드시 numpy 버퍼나 py::object 로 바꾸세요. “편한 API 인데 왜 느리지?”의 원인은 대개 여기입니다.

이렇게도 쓸 수 있다 — 파이썬 객체를 직접 다루기 🟡 중급

py::object 계열

// 파이썬 값을 C++ 로 가져오지 않고 그대로 조작합니다
py::object make_config(const py::dict& base) {
    py::dict d = base;                       // 참조 (얕은 복사 아님)
    d["timeout"] = 30;
    if (d.contains("host")) { ... }
    return d;
}

// 형변환은 필요할 때만
int n = d["count"].cast<int>();
auto s = py::cast<std::string>(d["name"]);

// C++ → 파이썬
py::object o = py::cast(42);
py::list  l = py::cast(std::vector<int>{1,2,3});   // 여기서 복사 발생

// 파이썬 함수·모듈 부르기
py::module_ np = py::module_::import("numpy");
py::object arr = np.attr("zeros")(10);
py::print("파이썬 print 호출");

// 타입 검사
if (py::isinstance<py::str>(o)) ...
if (py::isinstance<py::array_t<double>>(o)) ...

optional · variant · 사용자 타입

#include <pybind11/stl.h>

// None ↔ optional
std::optional<int> find(const std::string& k);   // 없으면 None 이 됩니다

// 여러 타입을 받기 ↔ variant
using Value = std::variant<int, double, std::string>;
void set(const std::string& k, Value v);          // 파이썬에서 아무거나 넘겨도 됨

// 내 타입을 자동 변환하게 만들기 (type caster) — 고급이지만 유용합니다
namespace pybind11::detail {
template<> struct type_caster<MyPoint> {
    PYBIND11_TYPE_CASTER(MyPoint, const_name("MyPoint"));

    bool load(handle src, bool) {                 // 파이썬 → C++
        if (!py::isinstance<py::tuple>(src)) return false;
        auto t = py::cast<py::tuple>(src);
        if (t.size() != 2) return false;
        value = MyPoint{t[0].cast<double>(), t[1].cast<double>()};
        return true;
    }
    static handle cast(const MyPoint& p, return_value_policy, handle) {
        return py::make_tuple(p.x, p.y).release(); // C++ → 파이썬
    }
};
}
//   이러면 파이썬에서 (1.0, 2.0) 튜플을 그냥 넘길 수 있게 됩니다

실무에선 이렇게 — 인터페이스를 파이썬답게 🔴 실무

C++ 스타일을 그대로 노출하지 않습니다

// ❌ C++ 관습을 그대로 — 파이썬 사용자에게 낯섭니다
m.def("get_result", [](Engine& e, std::vector<double>& out) {
    e.fill(out);                     // 출력 인자
});

// ✅ 파이썬은 '돌려받는' 것이 자연스럽습니다
m.def("get_result", [](Engine& e) {
    std::vector<double> out(e.size());
    e.fill(out);
    return as_numpy(std::move(out));   // numpy 배열로 (복사 없이 이동)
});

// ✅ 예외로 실패를 알립니다 (에러 코드 반환 대신 — y08)
m.def("parse", [](std::string_view s) {
    auto r = parse_impl(s);
    if (!r) throw py::value_error("파싱 실패: " + r.error());
    return *r;
});

// ✅ 이름은 snake_case, 불린 인자는 키워드 전용으로
m.def("smooth", &smooth, "data"_a, py::kw_only(), "window"_a = 3);
//   파이썬: smooth(arr, window=5)   ← 위치로 못 넘기게 강제

문자열과 바이트 다루기

// str ↔ std::string 은 UTF-8 인코딩·디코딩이 일어납니다 (비용 있음)
void f(const std::string& s);        // 파이썬 str → UTF-8 바이트로 복사

// 바이너리 데이터는 bytes 로 — 인코딩 검사가 없어 더 빠릅니다
void g(const py::bytes& b) {
    std::string_view sv = b;          // 복사 없이 들여다보기 (pybind11 2.12+)
}

// 복사 없이 읽기만 할 때 (수명에 주의)
void h(std::string_view sv);          // 파이썬 str 은 임시 버퍼가 생길 수 있습니다
//   ★ 함수가 끝난 뒤에도 보관하려면 반드시 std::string 으로 복사하세요

// 대용량 바이너리는 buffer protocol 로 (복사 0)
void process(py::buffer b) {
    py::buffer_info info = b.request();
    auto* p = static_cast<uint8_t*>(info.ptr);
    // bytes · bytearray · numpy · memoryview 전부 이 경로로 옵니다 ★
}

// 결과를 bytes 로 돌려주기
py::bytes make_packet() {
    std::string buf = build();
    return py::bytes(buf);            // 여기서 한 번 복사됩니다
}
한글 문자열 — 파이썬 str 은 유니코드, std::string 은 UTF-8 바이트입니다. 변환은 pybind11 이 해 주지만, C++ 안에서 바이트 단위로 자르면 글자가 깨집니다. 인덱싱이 필요하면 파이썬 쪽에서 처리하고 C++ 로는 통째로 넘기세요.

한 단계 더 — 반환 정책과 수명 🧬 전문가

return_value_policy — 잘못 고르면 크래시

정책의미
automatic기본값. 값이면 복사, 포인터면 소유권 가져감
copy항상 복사 — 가장 안전
move이동 (반환값이 임시일 때)
reference참조만 — C++ 가 계속 살려 둬야 함 ★
reference_internal참조 + 부모 객체를 살려 둠(keep_alive)
take_ownership파이썬이 delete 책임을 짐
class Engine {
    std::vector<double> buf_;
public:
    const std::vector<double>& buffer() const { return buf_; }
};

// ❌ 기본 정책이면 매번 전체 복사
c.def("buffer", &Engine::buffer);

// ✅ 참조로 노출 + Engine 이 살아 있는 동안만 유효하게 묶기
//   ⚠ 단, stl.h 로 자동 변환되는 vector 는 정책과 무관하게 항상 list 로 복사됩니다
//     → PYBIND11_MAKE_OPAQUE(std::vector<double>) + py::bind_vector 로 등록해야 참조가 됩니다
c.def("buffer", &Engine::buffer,
      py::return_value_policy::reference_internal);
//   파이썬에서 engine 이 GC 되면 그 참조도 함께 무효가 되는데,
//   reference_internal 이 '부모를 살려 두어' 그 사고를 막습니다 ★

// 인자 사이의 수명 관계를 명시 (keep_alive<나, 인자>)
c.def("set_source", &Engine::set_source, py::keep_alive<1, 2>());
//   1 = this, 2 = 첫 인자 → this 가 사는 동안 인자를 살려 둡니다

진단 — 수명 버그를 찾는 법

# 전형적인 증상: 잠시 뒤에 값이 이상해지거나 세그폴트
buf = engine.buffer()      # 참조를 받고
del engine                 # 부모를 없애면
print(buf[0])              # ← 여기서 죽습니다 (정책이 잘못된 경우)

# 확인 절차
import gc, sys
sys.getrefcount(buf)       # 참조 카운트
gc.get_referrers(buf)      # 누가 붙잡고 있나

# 재현 스크립트를 만든 뒤 sanitizer 빌드로 돌리면 정확한 위치가 나옵니다
CXXFLAGS="-fsanitize=address -g" pip install -e . --no-build-isolation   # C++ 라 CXXFLAGS
PYTHONMALLOC=malloc LD_PRELOAD=$(gcc -print-file-name=libasan.so) \
    python repro.py
#   ★ PYTHONMALLOC=malloc 을 줘야 ASan 이 파이썬 힙도 감시합니다
규칙 하나로 줄이기 — 확신이 없으면 copy 를 쓰세요. 느려지는 것은 측정으로 확인한 뒤 최적화하면 되지만, 수명 버그는 운영에서 재현 안 되는 크래시로 나타나 훨씬 비쌉니다.
정리 — list↔vector 자동 변환은 편하지만 전부 복사입니다. 수백 개까지는 그냥 쓰고, 수만 개를 넘으면 numpy 버퍼로 바꾸세요. 반환할 때는 정책을 의식하고, 확신이 없으면 복사가 안전합니다.
어디서 복사가 일어나는가 — 여기가 성능의 전부 list[float] → std::vector<double> · 전부 복사 원소마다 파이썬 객체에서 값을 꺼내 옮깁니다 — 길이에 비례합니다 str → std::string · 복사 읽기만 한다면 std::string_view 로 빌려 받으세요 — 다만 수명에 주의해야 합니다 numpy 배열 → py::array_t · 복사 없음 메모리 배치가 같아 그대로 들여다봅니다 — 큰 데이터는 항상 이 경로로 넘기세요 반환할 때는 소유권 정책을 반드시 정하세요 잘못 지정하면 C++ 가 이미 지운 메모리를 파이썬이 붙잡고 있게 됩니다 — 나중에 이상하게 죽습니다 호출 한 번마다 고정 비용이 있습니다 — 작게 백만 번 부르지 말고 크게 한 번 넘기세요
바인딩 성능의 승부처는 C++ 코드가 아니라 경계에서 무엇을 복사하느냐입니다.
PY 05

C++ 클래스를 파이썬 객체로

상태를 들고 있어야 하는 경우 — 무거운 준비를 한 번만 하고 여러 번 호출하는 구조입니다. 경계를 넘는 횟수를 줄이는 가장 실용적인 방법이기도 합니다.

클래스 노출 기본

class Engine {
public:
    explicit Engine(int window) : window_(window) {}
    void   feed(double x)      { buf_.push_back(x); }
    double result() const      { return compute(); }
    int    window() const      { return window_; }
    void   set_window(int w)   { window_ = w; }
    std::size_t size() const   { return buf_.size(); }
private:
    int window_;
    std::vector<double> buf_;
};

PYBIND11_MODULE(_core, m) {
    py::class_<Engine>(m, "Engine")
        .def(py::init<int>(), "window"_a = 3)          // 생성자
        .def("feed",   &Engine::feed,   "x"_a)
        .def("result", &Engine::result)
        .def_property("window",                          // 파이썬 property
                      &Engine::window, &Engine::set_window)
        .def("__len__", &Engine::size)                   // len(engine)
        .def("__repr__", [](const Engine& e) {
            return "<Engine window=" + std::to_string(e.window()) + ">";
        });
}
>>> e = _core.Engine(window=5)
>>> e.feed(1.0); e.feed(2.0)
>>> len(e), e.window, e.result()
(2, 5, 1.5)

파이썬답게 만드는 것들

py::class_<Engine>(m, "Engine")
    // with 문 지원 — 자원을 쓰는 객체라면 필수 ★
    .def("__enter__", [](Engine& e) -> Engine& { e.open(); return e; })
    .def("__exit__",  [](Engine& e, py::object, py::object, py::object) {
        e.close();
        return false;                       // 예외를 삼키지 않음
    })

    // 반복 지원 — for x in engine
    .def("__iter__", [](const Engine& e) {
        return py::make_iterator(e.begin(), e.end());
    }, py::keep_alive<0, 1>())              // ★ 반복 중 객체를 살려 둡니다

    // 인덱싱 · 비교 · 해시
    .def("__getitem__", [](const Engine& e, std::size_t i) {
        if (i >= e.size()) throw py::index_error();     // ★ IndexError 로
        return e[i];
    })
    .def(py::self == py::self)
    .def("__hash__", [](const Engine& e){ return std::hash<int>{}(e.window()); })

    // 복사·pickle
    .def("__copy__",  [](const Engine& e){ return Engine(e); })
    .def(py::pickle(
        [](const Engine& e) { return py::make_tuple(e.window(), e.data()); },
        [](py::tuple t) { return Engine(t[0].cast<int>(), t[1].cast<std::vector<double>>()); }
    ));
pickle 을 지원해야 하는 이유 — multiprocessing·Celery·joblib 이 객체를 프로세스 간에 보낼 때 pickle 을 씁니다. 지원하지 않으면 “왜 멀티프로세싱에서만 안 되지?”가 됩니다.

이렇게도 쓸 수 있다 — 상속과 소유권 🟡 중급

파이썬에서 C++ 클래스를 상속하기

// 파이썬 쪽에서 가상 함수를 재정의하게 하려면 '트램폴린'이 필요합니다
class Filter {
public:
    virtual ~Filter() = default;
    virtual double apply(double x) = 0;
    double run(double x) { return apply(x) * 2; }
};

class PyFilter : public Filter {          // 트램폴린 클래스
public:
    using Filter::Filter;
    double apply(double x) override {
        PYBIND11_OVERRIDE_PURE(double,    // 반환 타입
                               Filter,    // 부모 클래스
                               apply,     // 함수 이름
                               x);        // 인자들
    }
};

py::class_<Filter, PyFilter>(m, "Filter")
    .def(py::init<>())
    .def("apply", &Filter::apply)
    .def("run",   &Filter::run);
class Double(_core.Filter):
    def apply(self, x):        # C++ 가 이 파이썬 함수를 부릅니다
        return x + 1

d = Double()
d.run(3)                       # C++ run() → 파이썬 apply() → 8
비용을 알고 쓰세요 — 이 경로는 호출마다 GIL 획득 + 파이썬 함수 호출이 일어납니다. 루프 안에서 원소마다 부르면 C++ 로 만든 의미가 사라집니다. 플러그인처럼 드물게 불리는 자리에만 쓰고, 핫 루프는 배열 전체를 한 번에 넘기는 구조로 설계하세요.

누가 객체를 소유하나

// 기본 — 파이썬이 소유합니다 (참조가 사라지면 C++ 소멸자 호출)
py::class_<Engine>(m, "Engine").def(py::init<>());

// shared_ptr 로 관리되는 객체
py::class_<Session, std::shared_ptr<Session>>(m, "Session")
    .def(py::init<>());
//   C++ 쪽에서도 shared_ptr 로 들고 있다면 이렇게 해야 안전합니다

// C++ 가 소유하고 파이썬은 빌려 보기만
m.def("get_global", []() -> Engine* { return &global_engine; },
      py::return_value_policy::reference);
//   ★ 파이썬이 delete 하지 않습니다. C++ 가 계속 살려 둬야 합니다.

// 상속 관계 등록 — 다형적 반환이 올바른 파이썬 타입이 되게
py::class_<Base>(m, "Base");
py::class_<Derived, Base>(m, "Derived");     // ★ 부모를 두 번째 인자로
m.def("make", &make);      // Base* 를 돌려줘도 파이썬에서는 Derived 로 보입니다
//   (Base 에 가상 함수가 하나라도 있어야 동작합니다)

// 복사·이동 불가 타입
py::class_<Socket>(m, "Socket")
    .def(py::init<>());
//   복사 생성자가 delete 돼 있으면 pybind11 이 알아서 복사를 막습니다

실무에선 이렇게 — 클래스보다 함수가 나을 때가 많습니다 🔴 실무

노출 표면을 최소로

// ❌ C++ 클래스 계층을 그대로 옮기기 — 바인딩 코드가 코드보다 커집니다
py::class_<AbstractProcessor>(m, "AbstractProcessor")...
py::class_<BaseProcessor, AbstractProcessor>(m, "BaseProcessor")...
py::class_<FastProcessor, BaseProcessor>(m, "FastProcessor")...

// ✅ 파이썬에 필요한 것만 — 대개 '설정 + 실행' 두 개면 됩니다
py::class_<Processor>(m, "Processor")
    .def(py::init<const Config&>())
    .def("run", &Processor::run);
//   내부 계층은 C++ 안에 숨깁니다

// ✅ 상태가 없으면 그냥 함수로
m.def("smooth", &smooth, "data"_a, "window"_a = 3);

// ✅ 설정은 dataclass 스타일 struct 로
py::class_<Config>(m, "Config")
    .def(py::init<>())
    .def_readwrite("window",  &Config::window)
    .def_readwrite("threads", &Config::threads)
    .def("__repr__", [](const Config& c){ ... });
//   파이썬: cfg = Config(); cfg.window = 5

얇은 파이썬 래퍼를 한 겹 두기 ★

# python/fastmod/__init__.py
from ._core import Engine as _Engine     # C++ 는 언더바로 감춥니다
import numpy as np

class Engine:
    """사용자에게 보이는 얼굴 — 여기서 파이썬다움을 만듭니다."""

    def __init__(self, window: int = 3, *, dtype=np.float64):
        self._impl = _Engine(window)
        self._dtype = dtype

    def feed(self, data) -> None:
        arr = np.ascontiguousarray(data, dtype=self._dtype)  # 검증·정규화 ★
        self._impl.feed_array(arr)

    def __enter__(self): return self
    def __exit__(self, *exc): self._impl.close(); return False

    @property
    def result(self) -> np.ndarray:
        return self._impl.result()
이 한 겹이 주는 것 — ① 입력 검증·타입 정규화를 파이썬에서 처리해 C++ 쪽 방어 코드를 줄이고, ② docstring·타입 힌트를 파이썬 문법 그대로 쓸 수 있고, ③ 나중에 구현을 Rust 로 바꿔도 사용자 코드는 그대로입니다. 바인딩은 얇게, 파이썬다움은 파이썬에서.

한 단계 더 — 객체 하나에 드는 실제 비용 🧬 전문가

인스턴스 구조와 GC

import sys, _core
e = _core.Engine(3)
sys.getsizeof(e)          # 파이썬 객체 헤더 + pybind11 인스턴스 정보
#   실제 C++ 객체는 별도 힙에 있고, 파이썬 객체는 그것을 가리킵니다
#   → getsizeof 는 C++ 쪽 메모리를 포함하지 않습니다 ★

# 메모리를 정확히 보고하려면 __sizeof__ 를 직접 구현합니다
.def("__sizeof__", [](const Engine& e) {
    return sizeof(Engine) + e.capacity() * sizeof(double);
})
#   메모리 프로파일러(memray·tracemalloc)가 이 값을 씁니다

# 순환 참조 — C++ 객체가 파이썬 객체를 붙잡고 있으면 GC 가 못 봅니다
#   Engine 이 py::object 콜백을 멤버로 들고 있고
#   그 콜백이 다시 engine 을 참조하면 → 영원히 해제되지 않습니다
#   해결: py::weakref 를 쓰거나, 명시적 close()/컨텍스트 매니저로 끊기 ★

호출 오버헤드를 줄이는 기법

// ① 메서드 호출도 경계 비용이 듭니다. 잦은 호출은 배치로 바꾸세요.
c.def("feed",       &Engine::feed);            // 한 개씩 — 느림
c.def("feed_array", &Engine::feed_array);      // 배열 통째로 ★

// ② 반환 객체를 매번 만들지 않기 — 내부 버퍼를 재사용
c.def("result", [](Engine& e) {
    return py::array_t<double>(
        {e.size()}, {sizeof(double)},
        e.data(),                    // 내부 포인터
        py::cast(e));                // ★ base 로 engine 을 지정 → 수명 보장
}, py::return_value_policy::reference_internal);

// ③ 자주 쓰는 속성은 파이썬 쪽에 캐시 (래퍼 클래스에서)
//    self._n = self._impl.size()   ← 호출마다 경계를 넘지 않게

// ④ 측정
//    python -X importtime · timeit · py-spy record 로
//    "경계 비용 : 계산 비용" 비율을 확인하세요.
//    경계가 30% 를 넘으면 인터페이스 설계를 다시 봐야 합니다 ★
내부 포인터를 노출할 때 — py::array_t 의 base 인자로 소유 객체를 지정하지 않으면, 파이썬이 배열을 들고 있는 동안 C++ 객체가 먼저 사라져 해제된 메모리를 읽게 됩니다. 배열을 반환할 때는 항상 base 를 지정하세요.
정리 — py::class_ 로 C++ 클래스를 파이썬 객체로 만들 수 있고, __len__·__iter__·__enter__ 를 붙이면 파이썬답게 됩니다. 다만 클래스 계층을 그대로 옮기지 마세요 — 노출은 얇게, 파이썬다움은 파이썬 래퍼에서 만드는 편이 유지보수가 훨씬 쉽습니다.
클래스를 넘길 때 정해야 할 것은 하나 — 누가 메모리를 쥐는가 C++ 객체 수명 = 소멸자가 결정 Engine { Model m; } py::class_ 파이썬 객체 수명 = 참조 계수가 결정 e = mypkg.Engine("m.bin") 서로 규칙이 다릅니다 그 사이를 잇는 것이 holder 와 반환 정책입니다 새로 만들어 돌려줄 때 기본 정책(take_ownership)으로 파이썬이 가져갑니다 참조가 0 이 되면 C++ 소멸자가 불립니다 공유가 필요하면 holder 를 shared_ptr 로 내부를 가리키는 포인터를 돌려줄 때 &engine.model 을 그냥 돌려주면 engine 이 사라진 뒤 해제된 메모리를 가리킵니다 — 세그폴트 → keep_alive<0,1> 로 부모를 살려 둡니다 파이썬 쪽에서는 평범한 객체로 보입니다 — 실수할 여지를 API 설계로 없애 두세요. 내부 참조는 노출하지 않는 편이 안전합니다
여기서 나는 버그는 대개 예외가 아니라 세그폴트입니다 — 정책을 명시적으로 적으세요.
클래스를 넘길 때 정해야 할 것은 하나 — 누가 메모리를 쥐는가 C++ 객체 수명 = 소멸자가 결정 Engine { Model m; } py::class_ 파이썬 객체 수명 = 참조 계수가 결정 e = mypkg.Engine("m.bin") 서로 규칙이 다릅니다 그 사이를 잇는 것이 holder 와 반환 정책입니다 새로 만들어 돌려줄 때 기본 정책(take_ownership)으로 파이썬이 가져갑니다 참조가 0 이 되면 C++ 소멸자가 불립니다 공유가 필요하면 holder 를 shared_ptr 로 내부를 가리키는 포인터를 돌려줄 때 &engine.model 을 그냥 돌려주면 engine 이 사라진 뒤 해제된 메모리를 가리킵니다 — 세그폴트 → keep_alive<0,1> 로 부모를 살려 둡니다 파이썬 쪽에서는 평범한 객체로 보입니다 — 그래서 사용자가 실수할 여지를 우리가 API 설계로 없애 두어야 합니다. 내부 참조를 노출하지 않는 편이 가장 안전합니다
여기서 나는 버그는 대개 예외가 아니라 세그폴트입니다 — 정책을 명시적으로 적으세요.
PY 06

numpy 배열 — 복사 없이 주고받기

여기가 이 탭의 핵심입니다. 100만 개짜리 배열을 복사 없이 포인터로 받아 C++ 속도로 처리하고, 결과도 복사 없이 돌려줍니다.

100만 개짜리 배열을 넘길 때 — 복사가 일어나는가 ① 파이썬 list 로 받기 파이썬 list — 원소마다 객체 하나 100만 번 언팩 · 타입 검사 · 변환 std::vector — 새 메모리 4MB 변환 시간이 계산 시간보다 큰 경우가 흔합니다 ② py::array_t · 버퍼 프로토콜 numpy 배열 — 이미 연속된 메모리 C++ 는 같은 주소를 그대로 가리킨다 복사 0회 · 추가 메모리 0바이트 배열이 클수록 차이가 커집니다 제로카피가 조용히 깨지는 두 가지 ① 배열이 연속이 아닐 때(슬라이싱 결과) ② dtype 이 다를 때 — 둘 다 pybind11 이 말없이 복사본을 만듭니다
py::array_t<double, py::array::c_style | py::array::forcecast> 로 받으면 조건을 강제할 수 있습니다.

배열을 받아서 읽기

#include <pybind11/numpy.h>

// ★ py::array_t<T, py::array::c_style | py::array::forcecast>
//    c_style   : 연속 메모리를 요구
//    forcecast : 타입이 다르면 자동 변환 (복사 발생) — 편의를 위해 보통 켭니다
using Arr = py::array_t<double, py::array::c_style | py::array::forcecast>;

double mean(Arr a) {
    py::buffer_info buf = a.request();          // 복사 없이 정보만
    if (buf.ndim != 1)
        throw std::runtime_error("1차원 배열이어야 합니다");

    const double* p = static_cast<const double*>(buf.ptr);
    const std::size_t n = static_cast<std::size_t>(buf.shape[0]);

    double s = 0;
    for (std::size_t i = 0; i < n; ++i) s += p[i];
    return n ? s / n : 0.0;
}

// 더 간단한 접근자 (차원은 한 번 확인, 원소 접근은 경계 검사 없음)
double mean2(Arr a) {
    auto r = a.unchecked<1>();      // 1차원, 검사 없음(빠름)
    double s = 0;
    for (py::ssize_t i = 0; i < r.shape(0); ++i) s += r(i);
    return s / r.shape(0);
}

결과 배열 돌려주기

// ① 새 배열을 만들어 돌려주기
py::array_t<double> smooth(Arr a, int window) {
    auto r = a.unchecked<1>();
    const py::ssize_t n = r.shape(0);

    py::array_t<double> out(n);              // 새 numpy 배열 할당
    auto w = out.mutable_unchecked<1>();

    for (py::ssize_t i = 0; i < n; ++i) {
        double s = 0; int c = 0;
        for (int k = -window/2; k <= window/2; ++k) {
            py::ssize_t j = i + k;
            if (j >= 0 && j < n) { s += r(j); ++c; }
        }
        w(i) = s / c;
    }
    return out;
}

// ② 이미 있는 버퍼에 쓰기 (할당조차 안 함) ★ 가장 빠릅니다
void smooth_into(Arr a, py::array_t<double> out, int window) {
    if (out.size() != a.size()) throw py::value_error("크기 불일치");
    auto w = out.mutable_unchecked<1>();
    ...
}
// 파이썬: out = np.empty_like(arr); smooth_into(arr, out, 3)
unchecked vs mutable_unchecked — 읽기 전용이면 앞의 것. 차원 수를 템플릿 인자로 주면 컴파일 시점에 인덱싱이 최적화되어 buffer_info 로 직접 계산하는 것과 같은 속도가 나옵니다.

이렇게도 쓸 수 있다 — 2차원 · 스트라이드 · 제자리 연산 🟡 중급

2차원 배열과 스트라이드

// 2차원 — 행렬 연산
py::array_t<double> row_sum(py::array_t<double, py::array::c_style> m) {
    auto r = m.unchecked<2>();                 // 2차원
    py::array_t<double> out(r.shape(0));
    auto w = out.mutable_unchecked<1>();

    for (py::ssize_t i = 0; i < r.shape(0); ++i) {
        double s = 0;
        for (py::ssize_t j = 0; j < r.shape(1); ++j) s += r(i, j);
        w(i) = s;
    }
    return out;
}

// ⚠ 슬라이스는 연속이 아닐 수 있습니다
//    arr[::2] · arr.T · arr[:, 1] → 스트라이드가 불규칙
//    c_style 을 요구하면 pybind11 이 '연속 복사본'을 만듭니다 (안전하지만 복사)
//    복사를 원치 않으면 스트라이드를 직접 다뤄야 합니다
void generic(py::array_t<double> a) {
    auto buf = a.request();
    auto* base = static_cast<char*>(buf.ptr);
    for (py::ssize_t i = 0; i < buf.shape[0]; ++i) {
        double v = *reinterpret_cast<double*>(base + i * buf.strides[0]);
    }
}

제자리 수정과 소유권

// 파이썬 배열을 직접 수정 (복사 없이, 반환도 없이)
void scale_inplace(py::array_t<double> a, double k) {
    auto r = a.mutable_unchecked<1>();
    for (py::ssize_t i = 0; i < r.shape(0); ++i) r(i) *= k;
}
//   파이썬: scale_inplace(arr, 2.0)   → arr 자체가 바뀝니다

// C++ vector 를 복사 없이 numpy 로 넘기기 (소유권 이전) ★
py::array_t<double> make_result(std::size_t n) {
    auto* v = new std::vector<double>(n);
    compute_into(*v);

    // capsule — 파이썬이 배열을 버릴 때 이 람다가 불립니다
    py::capsule owner(v, [](void* p) {
        delete static_cast<std::vector<double>*>(p);
    });

    return py::array_t<double>(
        {v->size()},                    // shape
        {sizeof(double)},               // strides
        v->data(),                      // 데이터 포인터 (복사 없음)
        owner);                         // 수명 관리자 ★
}
//   이러면 100MB 결과도 복사 0 으로 파이썬에 넘어갑니다
읽기 전용 배열에 쓰면 안 됩니다 — 파이썬 쪽에서 arr.flags.writeable = False 인 배열이 올 수 있습니다. mutable_unchecked 는 이 경우 예외를 던집니다. 제자리 수정 함수는 문서에 “입력을 수정함”을 반드시 명시하세요.

실무에선 이렇게 — 방어와 검증 🔴 실무

입력 검증이 세그폴트를 막습니다 ★

py::array_t<double> process(Arr a, int window) {
    // ① 차원 검사
    if (a.ndim() != 1)
        throw py::value_error("1차원 배열이 필요합니다 (받은 차원: "
                              + std::to_string(a.ndim()) + ")");
    // ② 크기 검사 — 빈 배열은 반드시 따로 처리 ★
    if (a.size() == 0)
        return py::array_t<double>(0);
    // ③ 인자 범위 검사
    if (window <= 0 || window > a.size())
        throw py::value_error("window 는 1 이상 배열 크기 이하여야 합니다");
    // ④ 연속성 (c_style 을 안 쓴 경우)
    if (!(a.flags() & py::array::c_style))
        throw py::value_error("연속 배열이 필요합니다. "
                              "np.ascontiguousarray() 를 쓰세요");
    ...
}

// ★ 검사는 '경계에서 한 번', 내부 루프에서는 하지 않습니다
//   → 안전성과 속도를 동시에 얻습니다
이 네 가지가 확장 모듈 크래시의 대부분입니다 — 빈 배열 · 차원 불일치 · 음수/0 인자 · 비연속 배열. assert 로 막으면 릴리즈에서 사라지므로 반드시 예외로 막으세요. 파이썬 사용자는 당연히 예외를 기대하지, 인터프리터가 죽을 거라고는 생각하지 않습니다.

파이썬 래퍼에서 정규화하기

# python/fastmod/__init__.py
import numpy as np
from . import _core

def smooth(data, window: int = 3) -> np.ndarray:
    """이동평균. 어떤 시퀀스를 넣어도 동작합니다."""
    arr = np.ascontiguousarray(data, dtype=np.float64)   # ★ 여기서 통일
    if arr.ndim != 1:
        raise ValueError(f"1차원이 필요합니다 (받음: {arr.ndim}차원)")
    if arr.size == 0:
        return arr.copy()
    return _core.smooth(arr, int(window))

# 이러면 C++ 쪽은 '항상 연속인 float64 1차원'만 가정하면 됩니다
#   → C++ 코드가 단순해지고, 에러 메시지는 파이썬답게 나옵니다 ★
# 성능 비교를 습관으로
import numpy as np, timeit, fastmod
a = np.random.rand(1_000_000)

def py_smooth(a, w=3):
    return np.convolve(a, np.ones(w)/w, mode="same")

t1 = timeit.timeit(lambda: py_smooth(a),        number=20) / 20
t2 = timeit.timeit(lambda: fastmod.smooth(a,3), number=20) / 20
print(f"numpy {t1*1e3:.2f}ms · cpp {t2*1e3:.2f}ms · {t1/t2:.1f}×")
# 양 끝 1칸은 나누는 수가 다릅니다(convolve 는 항상 w, C++ 는 실제 개수) → 안쪽만 비교
np.testing.assert_allclose(py_smooth(a)[1:-1], fastmod.smooth(a,3)[1:-1], rtol=1e-9)

한 단계 더 — 버퍼 프로토콜과 메모리 레이아웃 🧬 전문가

numpy 없이도 통하는 길 — buffer protocol

// py::buffer 로 받으면 numpy · bytes · bytearray · memoryview ·
// PIL 이미지 · pyarrow 등 '버퍼 프로토콜을 지원하는 모든 것'이 옵니다 ★
void process(py::buffer b) {
    py::buffer_info info = b.request();
    if (info.format != py::format_descriptor<float>::format())
        throw py::type_error("float32 버퍼가 필요합니다");
    auto* p = static_cast<float*>(info.ptr);
    ...
}

// 내 C++ 클래스를 '버퍼로 노출' — numpy 가 복사 없이 감쌉니다
py::class_<Matrix>(m, "Matrix", py::buffer_protocol())
    .def_buffer([](Matrix& mat) -> py::buffer_info {
        return py::buffer_info(
            mat.data(),                              // 포인터
            sizeof(double),                          // 원소 크기
            py::format_descriptor<double>::format(),
            2,                                       // 차원
            { mat.rows(), mat.cols() },              // shape
            { sizeof(double) * mat.cols(),           // strides
              sizeof(double) });
    });
// 파이썬: np.asarray(mat)   ← 복사 없이 view 가 만들어집니다 ★

복사가 정말 없는지 확인하기

import numpy as np, fastmod

a = np.arange(10, dtype=np.float64)
b = np.asarray(fastmod.get_view())      # C++ 버퍼를 감싼 배열

# ① 메모리를 공유하는가
np.shares_memory(a, b)
b.base                                   # None 이 아니면 view

# ② 포인터 주소를 직접 비교
a.__array_interface__["data"][0]         # 데이터 시작 주소
a.flags                                  # C_CONTIGUOUS · OWNDATA · WRITEABLE

# ③ 큰 배열로 메모리 증가를 측정 (복사되면 두 배가 됩니다)
import tracemalloc
tracemalloc.start()
big = np.zeros(50_000_000)               # 400MB
before = tracemalloc.get_traced_memory()[0]
r = fastmod.process(big)
after = tracemalloc.get_traced_memory()[0]
print(f"증가 {(after-before)/1e6:.1f}MB")   # 0 에 가까워야 제로카피 ★

# ④ dtype 이 안 맞으면 forcecast 가 조용히 복사합니다 — 확인하세요
big32 = big.astype(np.float32)
fastmod.process(big32)                   # double 을 요구하면 여기서 400MB 복사!
가장 흔한 “왜 안 빨라지지” — dtype 불일치입니다. float32 배열을 double 인터페이스에 넘기면 forcecast 가 매 호출마다 전체 복사를 합니다. 파이썬 래퍼에서 dtype 을 강제하거나, 두 타입 모두에 오버로드를 제공하세요.
정리 — py::array_t<double> 로 받고 unchecked<1>() 로 접근하면 복사 없이 C++ 속도가 납니다. 반환은 py::array_t 를 새로 만들거나, 미리 받은 출력 버퍼에 쓰는 것이 가장 빠릅니다. 그리고 빈 배열 · 차원 · dtype 검사를 경계에서 반드시 하세요 — 세그폴트의 90%가 여기서 예방됩니다.
PY 07

GIL 해제 — 진짜 병렬로 도는 지점

파이썬은 GIL 때문에 스레드를 여러 개 써도 CPU 계산은 한 번에 하나뿐이었습니다. C++ 확장은 계산하는 동안 GIL 을 놓을 수 있습니다 — 파이썬의 한계를 넘는 자리입니다.

GIL 을 놓는 한 줄이 만드는 차이 — 코어 4개 기준 ① GIL 을 쥔 채로 — 한 순간에 한 스레드만 스레드 1 스레드 2 스레드 3 스레드 4 시간 → 코어가 네 개여도 순서대로 — 전체 시간이 그대로입니다 ② 계산 구간에서 GIL 을 놓으면 — 네 개가 동시에 스레드 1 스레드 2 스레드 3 스레드 4 시간 → 전체 시간이 1/4 파이썬이 오랫동안 못 하던 일이 이 지점에서 가능해집니다 단, 놓은 구간에서는 파이썬 객체를 절대 건드리면 안 됩니다 건드리면 인터프리터가 통째로 죽습니다 — 예외가 아니라 크래시입니다
pybind11 에서는 py::call_guard<py::gil_scoped_release>() 한 줄이면 됩니다 — 가장 값싼 큰 이득입니다.

한 줄이면 됩니다

double heavy(py::array_t<double, py::array::c_style> a) {
    auto buf = a.request();
    const double* p = static_cast<const double*>(buf.ptr);
    const std::size_t n = buf.shape[0];

    double result = 0;
    {
        py::gil_scoped_release release;      // ★ 여기서 GIL 을 놓습니다
        // ─────────────────────────────────────────
        // 이 블록 안에서는 파이썬 객체를 절대 건드리면 안 됩니다
        for (std::size_t i = 0; i < n; ++i)
            result += std::sqrt(p[i]) * std::log(p[i] + 1);
        // ─────────────────────────────────────────
    }                                        // 블록을 나가며 GIL 을 되찾습니다
    return result;
}

// pybind11 에는 더 짧은 방법도 있습니다
m.def("heavy_vec", &heavy_vec, py::call_guard<py::gil_scoped_release>());
//   함수 전체에서 GIL 을 놓습니다 (인자 변환은 GIL 을 잡은 상태로 끝난 뒤)
//   ⚠ 위 heavy 처럼 안에서 이미 놓는 함수나, py::array_t 같은 파이썬 객체를
//     인자로 받아 만지는 함수에는 쓰면 안 됩니다 — 인자가 순수 C++ 타입일 때만
절대 규칙 — GIL 을 놓은 구간에서 py:: 로 시작하는 것이나 파이썬 객체를 건드리면 인터프리터가 깨집니다(즉시 죽지 않고 나중에 이상하게 죽습니다). 포인터와 C++ 자료구조만 만지세요.

효과 — 파이썬 스레드로 코어를 다 씁니다

from concurrent.futures import ThreadPoolExecutor
import numpy as np, fastmod, time

chunks = [np.random.rand(5_000_000) for _ in range(8)]

# ① 순차
t = time.perf_counter()
r1 = [fastmod.heavy(c) for c in chunks]
print(f"순차     {time.perf_counter()-t:.2f}s")

# ② 스레드 8개 — GIL 을 놓았으므로 실제로 병렬입니다 ★
t = time.perf_counter()
with ThreadPoolExecutor(8) as ex:
    r2 = list(ex.map(fastmod.heavy, chunks))
print(f"스레드 8 {time.perf_counter()-t:.2f}s")

#  GIL 을 놓지 않았다면 ②가 ①과 거의 같습니다 (그게 파이썬의 한계였습니다)
#  놓았다면 코어 수에 가깝게 줄어듭니다
#
#  ★ 프로세스가 아니라 스레드라는 점이 중요합니다
#    - 메모리를 공유하므로 큰 배열을 복사·pickle 하지 않습니다
#    - multiprocessing 의 직렬화 비용이 통째로 사라집니다

이렇게도 쓸 수 있다 — C++ 안에서 직접 병렬화 🟡 중급

표준 병렬 알고리즘 · OpenMP

// ① C++17 병렬 알고리즘 (GCC 는 TBB 링크 필요)
#include <execution>
#include <numeric>
double sum(const double* p, std::size_t n) {
    return std::reduce(std::execution::par_unseq, p, p + n, 0.0);
}

// ② OpenMP — 루프 하나에 지시문 한 줄
#pragma omp parallel for reduction(+:total) schedule(static)
for (std::ptrdiff_t i = 0; i < n; ++i)
    total += work(p[i]);

// CMake
find_package(OpenMP)
if(OpenMP_CXX_FOUND)
  target_link_libraries(_core PRIVATE OpenMP::OpenMP_CXX)
endif()

// ③ 직접 스레드 — 청크로 나누기
unsigned t = std::thread::hardware_concurrency();
std::vector<std::jthread> ths;
for (unsigned k = 0; k < t; ++k)
    ths.emplace_back([&, k]{ work_range(p, n*k/t, n*(k+1)/t); });
// jthread 는 소멸자에서 join 합니다 (C++20)
스레드 수를 사용자가 정하게 하세요 — 확장 모듈이 마음대로 모든 코어를 쓰면, 이미 멀티프로세싱으로 돌고 있는 워크로드에서 코어 경합이 일어나 오히려 느려집니다. set_num_threads(n) 를 노출하고 기본값은 1로 두는 라이브러리도 많습니다.

GIL 을 다시 잡아야 할 때

// 해제한 구간 안에서 파이썬을 만져야 하면 다시 획득합니다
{
    py::gil_scoped_release rel;

    for (auto& chunk : chunks) {
        auto r = compute(chunk);             // GIL 없이 계산

        if (progress) {
            py::gil_scoped_acquire acq;      // ★ 잠깐 다시 잡기
            progress(r.done, r.total);       // 파이썬 콜백 호출
        }                                    // 다시 놓습니다
    }
}

// ⚠ 워커 스레드에서 파이썬을 부르려면 반드시 획득해야 합니다
std::thread([cb]() mutable {
    py::gil_scoped_acquire acq;              // 이 줄이 없으면 크래시
    cb(42);
    cb = py::function();                     // ★ GIL 을 쥔 채로 참조 해제 (람다 소멸 땐 GIL 없음)
}).detach();

// ⚠ 콜백을 자주 부르면 GIL 경합으로 병렬 효과가 사라집니다
//    → 진행률은 '가끔' (예: 1% 단위로) 보고하세요 ★

실무에선 이렇게 — 언제 놓고 언제 놓지 않나 🔴 실무

판단 기준

상황GIL
계산이 수 ms 이상 걸림놓기 ★
파일·네트워크 I/O 대기놓기
계산이 마이크로초 단위놓지 않기 (해제 비용이 더 큼)
파이썬 객체를 다룸놓을 수 없음
콜백을 자주 부름놓되 콜백 빈도를 줄이기
전역 상태를 수정놓되 C++ 락으로 보호
// GIL 을 놓으면 '파이썬이 지켜 주던 보호'도 사라집니다 ★
static std::vector<double> g_cache;      // 전역 상태
static std::mutex g_mtx;

void update(py::array_t<double> a) {
    auto buf = a.request();
    py::gil_scoped_release rel;
    {
        std::lock_guard lk(g_mtx);        // ★ 직접 보호해야 합니다
        g_cache.assign(...);
    }
}
//   GIL 이 있을 때는 사실상 직렬이라 문제가 없었지만,
//   놓는 순간 진짜 데이터 경쟁이 생깁니다.

3.13 free-threading 시대의 주의점

파이썬 3.13 부터 GIL 이 없는 빌드(free-threaded)가 공식 실험 단계로,
3.14 에서 공식 지원 단계로 올라갔습니다. 확장 모듈에는 큰 변화입니다.

  · 지금까지: GIL 이 있어 확장 모듈의 전역 상태가 '어쩌다 안전'했습니다
  · 앞으로  : 여러 스레드가 진짜 동시에 내 확장에 들어옵니다 ★

준비할 것
  ① 전역·정적 가변 상태를 없애거나 직접 락으로 보호
  ② 스레드 안전함을 선언
       PYBIND11_MODULE(_core, m, py::mod_gil_not_used())   // pybind11 2.13+
  ③ 선언하지 않으면 free-threaded 인터프리터가 그 모듈을 위해
     GIL 을 다시 켭니다 (동작은 하지만 병렬 이점이 사라집니다)
  ④ TSan(ThreadSanitizer) 으로 데이터 경쟁을 미리 잡기 (🛠️ b10)

  ⚠ 검증 없이 mod_gil_not_used() 를 선언하면
    조용한 데이터 손상으로 이어집니다. 선언은 '약속'입니다.
지금 할 일 — 새로 만드는 확장은 전역 가변 상태를 두지 않는 설계로 가세요. 함수가 인자로 받은 데이터만 만지면 free-threading 전환이 그냥 됩니다.

한 단계 더 — GIL 해제의 실제 비용과 확장성 🧬 전문가

해제·획득 자체의 비용

// gil_scoped_release 는 공짜가 아닙니다
//   · 상태 저장 + 조건 변수 조작 = 대략 수백 나노초 수준
//   · 다시 얻을 때는 다른 스레드와 경합할 수 있어 더 걸릴 수 있습니다
//
// 그래서 손익 기준이 생깁니다
//   계산 시간이 < 1μs      → 놓지 마세요 (오히려 손해)
//   계산 시간이 > 100μs    → 거의 항상 놓으세요
//   그 사이                → 측정으로 결정

// 측정 방법 — 같은 함수를 두 버전으로 노출해 비교
m.def("work_gil",   &work);
m.def("work_nogil", &work, py::call_guard<py::gil_scoped_release>());
import timeit
for n in (100, 10_000, 1_000_000):
    a = np.random.rand(n)
    g  = timeit.timeit(lambda: fastmod.work_gil(a),   number=1000)
    ng = timeit.timeit(lambda: fastmod.work_nogil(a), number=1000)
    print(f"n={n:>9}  gil {g:.4f}  nogil {ng:.4f}")
#   작은 n 에서는 nogil 이 더 느립니다 — 그 지점을 찾아 분기하세요

왜 코어 수만큼 안 빨라지나

스레드를 8개 썼는데 3배밖에 안 빨라지는 흔한 이유들

  ① 메모리 대역폭 한계 ★ 가장 흔함
     단순한 배열 연산은 CPU 가 아니라 '메모리에서 읽는 속도'가 한계입니다.
     코어를 늘려도 대역폭은 그대로라 4~5코어에서 평평해집니다.
     → 계산 밀도(원소당 연산 수)를 높이거나 캐시에 맞게 쪼개세요(⚡ e02)

  ② false sharing
     스레드들이 같은 캐시 라인(64바이트)에 쓰면 서로를 무효화합니다.
     → 스레드별 결과를 64바이트 간격으로 떨어뜨리거나 지역 변수에 누적(⚡ e08)

  ③ 작업 분배 불균형
     원소마다 계산량이 다르면 한 스레드만 오래 돕니다.
     → schedule(dynamic) 이나 작업 큐

  ④ 하이퍼스레딩
     논리 코어 16개여도 물리 코어는 8개인 경우가 많습니다.

  ⑤ 다른 프로세스와의 경합
     이미 멀티프로세싱으로 8개 워커가 돌고 있다면 코어는 남아 있지 않습니다.

측정
  perf stat -e cache-misses,LLC-load-misses python bench.py
  htop 으로 실제 코어 사용률 확인 (전부 100% 인지)
실무에서 보고할 때 — “8스레드로 3.2배”는 실패가 아닙니다. 메모리 대역폭이 한계라면 그게 물리적 상한입니다. 상한이 무엇인지 근거를 대는 것이 배수 자체보다 중요합니다.
정리 — py::gil_scoped_release 한 줄이면 파이썬 스레드로 여러 코어를 실제로 쓸 수 있습니다 — 파이썬만으로는 불가능했던 지점입니다. 규칙은 하나 — GIL 을 놓은 구간에서는 파이썬 객체를 절대 만지지 않기. 그리고 계산이 짧으면 놓지 않는 편이 빠릅니다.
PY 08

예외를 파이썬 예외로

쓰는 사람은 try/except ValueError 로 잡기를 기대합니다. C++ 예외가 그대로 새어 나가면 인터프리터가 죽습니다.

자동으로 변환되는 것들

// pybind11 이 기본으로 매핑해 주는 표준 예외
//   std::invalid_argument   → ValueError
//   std::domain_error       → ValueError
//   std::out_of_range       → IndexError
//   std::range_error        → ValueError
//   std::length_error       → ValueError
//   std::overflow_error     → OverflowError
//   std::bad_alloc          → MemoryError
//   std::runtime_error      → RuntimeError
//   그 외 std::exception    → RuntimeError

throw std::invalid_argument("window 는 1 이상이어야 합니다");
//   파이썬: ValueError: window 는 1 이상이어야 합니다  ★

// 파이썬 예외를 직접 던지기 — 의도가 가장 분명합니다
throw py::value_error("1차원 배열이 필요합니다");
throw py::type_error("float64 배열이 필요합니다");
throw py::index_error();                 // IndexError → __getitem__ 에서 필수
throw py::key_error("없는 키");
throw py::stop_iteration();              // 반복 종료
throw py::buffer_error("연속 배열이 아닙니다");
throw py::import_error("의존 모듈 없음");

내 예외 타입을 파이썬에 등록하기

// C++ 쪽 예외
class ParseError : public std::runtime_error {
public:
    ParseError(int line, const std::string& msg)
        : std::runtime_error("행 " + std::to_string(line) + ": " + msg),
          line_(line) {}
    int line() const noexcept { return line_; }
private:
    int line_;
};

PYBIND11_MODULE(_core, m) {
    // ① 간단히 — 파이썬에 같은 이름의 예외 클래스를 만듭니다
    //    py::register_exception<ParseError>(m, "ParseError");
    //    ⚠ ①·② 는 둘 중 하나만 — 같은 이름을 두 번 등록하면 import 가 실패합니다

    // ② 정보를 담아 넘기려면 직접 변환합니다 ★
    static py::exception<ParseError> exc(m, "ParseError", PyExc_ValueError);
    py::register_exception_translator([](std::exception_ptr p) {
        try { if (p) std::rethrow_exception(p); }
        catch (const ParseError& e) {
            py::object err = py::module_::import("fastmod").attr("ParseError");
            PyErr_SetObject(err.ptr(),
                py::make_tuple(e.what(), e.line()).ptr());
        }
    });
}
try:
    fastmod.parse(text)
except fastmod.ParseError as e:
    print(e.args)          # ('행 3: 형식 오류', 3)

이렇게도 쓸 수 있다 — 양방향 예외 전달 🟡 중급

파이썬 예외를 C++ 에서 잡기

// 파이썬 콜백이 예외를 던지면 py::error_already_set 으로 옵니다
void run(const py::function& cb) {
    try {
        cb(42);
    } catch (const py::error_already_set& e) {
        if (e.matches(PyExc_KeyboardInterrupt))
            throw;                          // Ctrl+C 는 그대로 올려보냅니다 ★
        if (e.matches(PyExc_ValueError)) {
            log("콜백이 값 오류: " + std::string(e.what()));
            return;                         // 회복 가능하면 처리
        }
        throw;                              // 나머지는 그대로 전파
    }
}

// ⚠ error_already_set 을 잡고 아무것도 안 하면 파이썬 에러 상태가 남아
//   나중에 엉뚱한 곳에서 예외가 튀어나옵니다.
//   반드시 e.restore() 로 되돌리거나 다시 던지거나 discard 하세요.

// 오래 도는 계산에서 Ctrl+C 를 받게 하기 ★ 실무에서 매우 중요
for (std::size_t i = 0; i < n; ++i) {
    if (i % 100000 == 0) {
        py::gil_scoped_acquire acq;
        if (PyErr_CheckSignals() != 0)      // Ctrl+C 가 눌렸는지
            throw py::error_already_set();  // → KeyboardInterrupt 로 중단
    }
    heavy_work(i);
}

예외 정보를 잃지 않기

// 중첩 예외로 원인 체인 만들기 (파이썬의 raise ... from e)
try {
    load_config(path);
} catch (const std::exception&) {
    std::throw_with_nested(
        std::runtime_error("설정 로딩 실패: " + path));
}

// 파이썬 쪽에서 __cause__ 로 보이게 하려면 직접 연결합니다
py::register_exception_translator([](std::exception_ptr p) {
    try { if (p) std::rethrow_exception(p); }
    catch (const std::exception& e) {
        std::string msg = e.what();
        try { std::rethrow_if_nested(e); }
        catch (const std::exception& inner) {
            msg += "\n  원인: " + std::string(inner.what());
        } catch (...) {}
        PyErr_SetString(PyExc_RuntimeError, msg.c_str());
    }
});

// 에러 메시지에 반드시 넣을 것 ★
//   · 무엇이 잘못됐는지 (기대값 vs 실제값)
//   · 어떻게 고치는지
throw py::value_error(
    "window(" + std::to_string(w) + ")가 배열 크기(" +
    std::to_string(n) + ")보다 큽니다. window 를 줄이세요.");

실무에선 이렇게 — 경계에서 전부 막습니다 🔴 실무

예외가 새면 프로세스가 죽습니다

// ① noexcept 함수에서 던지면 즉시 std::terminate — 스택 정보도 없이 종료
void callback() noexcept {
    risky();                     // ❌ 여기서 던지면 프로세스가 죽습니다
}

// ② GIL 을 놓은 상태에서 던진 예외도 위험합니다
{
    py::gil_scoped_release rel;
    compute();                   // 여기서 던지면 GIL 없이 되감기가 진행됩니다
}
// ✅ 해제 구간을 try 로 감싸고, 예외는 GIL 을 되찾은 뒤 변환
std::optional<std::string> err;
{
    py::gil_scoped_release rel;
    try { compute(); }
    catch (const std::exception& e) { err = e.what(); }
}
if (err) throw py::value_error(*err);        // GIL 을 가진 상태에서 던지기 ★

// ③ 워커 스레드의 예외는 반드시 모아서 옮깁니다
std::exception_ptr eptr;
std::thread t([&]{
    try { work(); } catch (...) { eptr = std::current_exception(); }
});
t.join();
if (eptr) std::rethrow_exception(eptr);      // 메인 스레드에서 다시 던지기

에러 메시지 설계

# ❌ 원인을 알 수 없는 메시지
RuntimeError: error

# ❌ C++ 내부 사정을 그대로 노출
RuntimeError: std::bad_variant_access in detail::apply_impl<...>

# ✅ 파이썬 사용자가 고칠 수 있는 메시지
ValueError: window(10)가 배열 크기(5)보다 큽니다. window 를 줄이세요.
TypeError: float64 배열이 필요합니다 (받은 dtype: float32).
           arr.astype(np.float64) 로 변환하세요.
ValueError: 1차원 배열이 필요합니다 (받은 차원: 2).
# 파이썬 래퍼에서 한 겹 더 다듬기
def smooth(data, window=3):
    arr = np.ascontiguousarray(data, dtype=np.float64)
    if window <= 0:
        raise ValueError(f"window 는 1 이상이어야 합니다 (받음: {window})")
    try:
        return _core.smooth(arr, window)
    except RuntimeError as e:            # C++ 내부 오류를 감싸기
        raise RuntimeError(f"smooth 실패 (n={arr.size}, w={window})") from e
기준 한 줄 — 에러 메시지는 “무엇이 · 어떤 값이 · 어떻게 고치는지” 세 가지가 들어가야 합니다. 확장 모듈은 디버깅이 어려우므로 메시지의 품질이 곧 사용성입니다.

한 단계 더 — 예외가 경계를 넘는 실제 메커니즘 🧬 전문가

C++ 예외와 파이썬 에러의 차이

C++ 예외      : 스택 되감기(unwinding) 로 전파. 소멸자가 불립니다.
파이썬 에러   : 함수가 NULL 을 반환하고 '스레드 에러 상태'에 기록합니다.
                되감기가 아니라 '반환값 검사'로 전파됩니다.

pybind11 이 하는 일
  ① C++ → 파이썬 : catch 로 잡아 PyErr_SetString 등으로 상태를 설정하고
                    NULL 을 반환합니다.
  ② 파이썬 → C++ : 반환값이 NULL 이면 py::error_already_set 을 던집니다.

★ 그래서 이 규칙이 나옵니다
  · C++ 예외를 파이썬 경계 너머로 그대로 보내면 안 됩니다
    (되감기 정보가 없는 곳으로 넘어가면 UB)
  · 파이썬 에러 상태를 지우지 않고 두면 다음 API 호출이 실패합니다
  · GIL 없이 파이썬 에러 상태를 만지면 손상됩니다

진단과 디버깅

# 예외가 던져지는 순간 멈추기
gdb --args python repro.py
(gdb) catch throw
(gdb) run
(gdb) bt                       # C++ 스택
(gdb) py-bt                    # 파이썬 스택 ★ (python-gdb.py 필요)

# terminate 로 죽었을 때
(gdb) break abort
#   백트레이스에 __cxa_throw 가 보이면 예외가 새어 나간 것입니다

# 파이썬 에러 상태가 남아 있는지 확인 (C++ 안에서)
if (PyErr_Occurred()) {
    PyErr_Print();             // 무엇이 남았는지 출력
    PyErr_Clear();
}

# 확장 모듈 크래시를 파이썬 트레이스백과 함께 보기
python -X faulthandler repro.py
PYTHONFAULTHANDLER=1 python repro.py
#   세그폴트 시점의 파이썬 스택이 출력됩니다 ★ 확장 개발 필수
faulthandler 를 항상 켜세요 — 확장 모듈이 죽으면 기본적으로는 Segmentation fault 한 줄만 나옵니다. 이걸 켜면 어느 파이썬 코드에서 호출했는지가 나와 재현 범위를 즉시 좁힐 수 있습니다.
정리 — std::invalid_argument 는 ValueError 로, std::out_of_range 는 IndexError 로 자동 변환됩니다. 직접 던질 땐 py::value_error 가 가장 분명하고, GIL 을 놓은 구간과 워커 스레드에서는 예외를 밖으로 내보내지 말고 모아 두었다가 GIL 을 가진 상태에서 던지세요.
경계에서 예외를 옮겨 싣습니다 — 그냥 새어 나가면 프로세스가 죽습니다 C++ 쪽 throw invalid_argument throw std::out_of_range throw MyDomainError throw 42; ← 표준 밖 pybind11 경계 catch 로 받아 파이썬 예외로 바꿔 던집니다 ValueError IndexError register_exception 으로 이름 붙여 등록 terminate — 인터프리터가 통째로 죽습니다 규칙 — 던지는 것은 반드시 std::exception 을 상속한 타입으로 도메인 예외는 py::register_exception<MyDomainError>(m, "MyDomainError") 로 등록하면 except 로 잡힙니다 GIL 을 놓은 구간에서 던져진 예외도 경계까지는 안전하게 올라옵니다 — 다만 그 구간에서 파이썬 API 를 만지면 안 됩니다
쓰는 사람 눈에는 평범한 파이썬 예외로 보여야 합니다 — 그것이 잘 만든 바인딩입니다.
경계에서 예외를 옮겨 싣습니다 — 그냥 새어 나가면 프로세스가 죽습니다 C++ 쪽 throw std::invalid_argument throw std::out_of_range throw MyDomainError throw 42; ← 표준 밖 pybind11 경계 catch 로 받아 파이썬 예외로 바꿔 던집니다 ValueError IndexError register_exception 으로 이름 붙여 등록 terminate — 인터프리터가 통째로 죽습니다 규칙 — 던지는 것은 반드시 std::exception 을 상속한 타입으로 도메인 예외는 py::register_exception<MyDomainError>(m, "MyDomainError") 한 줄로 등록해 두면 파이썬에서 except mypkg.MyDomainError 로 잡습니다 GIL 을 놓은 구간에서 던져진 예외도 경계까지는 안전하게 올라옵니다 — 다만 그 구간에서 파이썬 API 를 만지면 안 됩니다
쓰는 사람 눈에는 평범한 파이썬 예외로 보여야 합니다 — 그것이 잘 만든 바인딩입니다.
PY 09

콜백 — C++ 에서 파이썬 함수 부르기

진행률 보고, 사용자 정의 규칙, 이벤트 통지 — C++ 가 파이썬을 거꾸로 부르는 경우입니다. GIL 규칙이 까다로워집니다.

기본 형태

#include <pybind11/functional.h>     // std::function 변환에 필요

// ① std::function 으로 받기 — 가장 편합니다
void each(py::array_t<double> a,
          const std::function<void(std::size_t, double)>& cb) {
    auto r = a.unchecked<1>();
    for (py::ssize_t i = 0; i < r.shape(0); ++i)
        cb(i, r(i));                  // 파이썬 함수 호출 (GIL 필요)
}

// ② py::function 으로 받기 — 더 직접적입니다
void each2(py::array_t<double> a, const py::function& cb) {
    ...
    py::object res = cb(i, val);
    bool stop = res.cast<bool>();     // 반환값도 받을 수 있습니다
}

// 파이썬 쪽
// fastmod.each(arr, lambda i, v: print(i, v))
원소마다 콜백은 거의 항상 잘못된 설계입니다 — 100만 원소면 파이썬 함수 호출 100만 번이라, C++ 로 옮긴 이득이 통째로 사라집니다. 진행률은 1% 단위로, 규칙은 배열째 넘기는 형태로 바꾸세요.

GIL 을 놓은 구간에서 부르기

void process(py::array_t<double> a, const py::function& progress) {
    auto buf = a.request();
    const double* p = static_cast<const double*>(buf.ptr);
    const std::size_t n = buf.shape[0];
    const std::size_t step = std::max<std::size_t>(n / 100, 1);  // 1% 단위

    {
        py::gil_scoped_release rel;        // 계산 중엔 GIL 없음

        for (std::size_t i = 0; i < n; ++i) {
            heavy(p[i]);

            if (i % step == 0) {
                py::gil_scoped_acquire acq;    // ★ 부를 때만 잠깐 잡기
                progress(double(i) / n);
                if (PyErr_CheckSignals() != 0)  // Ctrl+C 확인
                    throw py::error_already_set();
            }
        }
    }
}
//   콜백 100번 × GIL 획득 — 무시할 만한 비용입니다
//   원소마다 잡았다면 100만 번 → 계산보다 오래 걸립니다

이렇게도 쓸 수 있다 — 콜백을 보관할 때 🟡 중급

멤버로 들고 있으면 수명 문제가 생깁니다

class Engine {
    py::function on_done_;            // ★ 파이썬 객체를 C++ 가 붙잡습니다
public:
    void set_callback(py::function f) { on_done_ = std::move(f); }
    ~Engine() {
        py::gil_scoped_acquire acq;   // ★ 소멸자에서 GIL 필요
        on_done_ = py::function();    //   참조 카운트 감소가 일어나므로
    }
};

// ⚠ 순환 참조 — GC 가 회수하지 못합니다
//   engine.set_callback(lambda: engine.stop())
//     → 파이썬 engine → C++ Engine → py::function → 파이썬 engine
//   해결 ① 약한 참조를 쓰게 하거나
//   해결 ② 명시적 해제 API 를 제공합니다
    void clear_callback() { py::gil_scoped_acquire a; on_done_ = {}; }
//   해결 ③ 컨텍스트 매니저로 범위를 한정 (__exit__ 에서 해제) ★

// 소멸자가 파이썬 종료 후에 불릴 수도 있습니다 (인터프리터 종료 시)
    ~Engine() {
        if (Py_IsInitialized()) {     // ★ 방어
            py::gil_scoped_acquire acq;
            on_done_ = {};
        }
    }

워커 스레드에서 콜백 부르기

// C++ 스레드에서 파이썬을 부르려면 반드시 GIL 을 획득해야 합니다
void run_async(py::function cb) {
    // ★ 콜백 객체를 스레드로 넘기기 전에 GIL 을 가진 상태에서 복사
    std::thread([cb = std::move(cb)]() mutable {
        auto result = compute();               // GIL 없이 계산

        py::gil_scoped_acquire acq;            // 부를 때만 획득
        cb(result);
        cb = py::function();                   // ★ GIL 을 쥔 채로 참조 해제
    }).detach();                               //   (람다가 소멸할 때는 GIL 이 없습니다)
}

// ⚠ detach 된 스레드는 인터프리터 종료 시 문제가 됩니다.
//   가능하면 join 하거나, 스레드 풀 + 명시적 종료를 쓰세요.

// ✅ 더 파이썬다운 방법 — Future 를 돌려주기
py::object run_future(py::array_t<double> a) {
    py::object fut = py::module_::import("concurrent.futures")
                        .attr("Future")();
    auto buf = a.request();                    // GIL 을 쥔 지금 포인터를 얻어 둡니다
    py::object keep = a;                       // 스레드가 끝날 때까지 배열을 살려 둠
    std::thread([keep, fut, p = static_cast<const double*>(buf.ptr),
                 n = static_cast<std::size_t>(buf.size)]() mutable {
        double r = compute(p, n);              // 새 스레드는 처음부터 GIL 이 없습니다
        py::gil_scoped_acquire acq;
        fut.attr("set_result")(r);
        keep = py::none(); fut = py::none();   // ★ GIL 을 쥔 채로 참조 해제
    }).detach();
    return fut;
}
//   파이썬: f = fastmod.run_future(arr); f.result()
더 간단한 대안 — 비동기가 필요하면 C++ 에서 스레드를 만들지 말고, GIL 을 놓는 동기 함수를 만든 뒤 파이썬 쪽에서 ThreadPoolExecutor 로 감싸는 편이 훨씬 안전하고 디버깅도 쉽습니다(y07).

실무에선 이렇게 — 콜백을 쓰지 않는 설계 🔴 실무

대안이 거의 항상 더 낫습니다

# ❌ 원소마다 파이썬 규칙을 부르는 설계
fastmod.filter_rows(arr, lambda row: row.score > 0.5)

# ✅ ① 규칙을 데이터로 표현 — C++ 안에서 처리
fastmod.filter_rows(arr, column="score", op=">", value=0.5)

# ✅ ② 마스크를 파이썬(numpy)에서 만들고 C++ 는 적용만
mask = arr[:, 1] > 0.5                 # numpy 가 C 속도로 계산
fastmod.apply_mask(arr, mask)

# ✅ ③ 청크 단위 콜백 — 호출 횟수를 데이터 크기와 분리
fastmod.process(arr, on_chunk=lambda done, total: bar.update(done))

# ✅ ④ 제너레이터로 결과를 흘려보내기 (파이썬이 주도권을 가짐)
for chunk in fastmod.iter_results(arr, chunk_size=10000):
    handle(chunk)                       # 파이썬 루프는 청크 단위로만

진행률·취소의 표준 형태

// C++ 쪽 — 인터페이스를 단순하게
struct Progress {
    std::function<bool(double)> cb;   // 반환 false 면 중단 ★
    double last = -1;
    bool report(double frac) {
        if (!cb || frac - last < 0.01) return true;   // 1% 미만이면 생략
        last = frac;
        py::gil_scoped_acquire acq;
        if (PyErr_CheckSignals() != 0) return false;   // Ctrl+C
        return cb(frac);
    }
};

void run(py::array_t<double> a, py::object progress) {
    Progress pr;
    if (!progress.is_none())
        pr.cb = progress.cast<std::function<bool(double)>>();

    py::gil_scoped_release rel;
    for (...) {
        work();
        if (!pr.report(double(i)/n))
            throw std::runtime_error("사용자가 중단했습니다");
    }
}
from tqdm import tqdm
bar = tqdm(total=100)
fastmod.run(arr, lambda f: (bar.update(f*100 - bar.n), True)[1])

한 단계 더 — 콜백 비용을 재 보기 🧬 전문가

호출 하나에 드는 것

C++ → 파이썬 콜백 한 번에 일어나는 일

  ① GIL 획득            (경합이 없으면 수백 ns, 있으면 수 μs)
  ② 인자를 파이썬 객체로 변환  (int·float 도 객체 할당이 필요)
  ③ 파이썬 함수 호출     (프레임 생성 · 바이트코드 실행)
  ④ 반환값을 C++ 로 변환
  ⑤ GIL 해제

  합계 대략 1μs 안팎 — 계산이 그보다 짧으면 콜백이 지배합니다.

비교 감각
  · C++ 함수 호출          ~1ns
  · 가상 함수 호출          ~2ns
  · 파이썬 함수 호출        ~50ns  (파이썬 안에서)
  · C++ → 파이썬 콜백      ~1000ns ★ 1000배 차이

  → "원소마다 콜백"이 왜 안 되는지가 숫자로 설명됩니다.

직접 측정하고 최적화하기

import timeit, numpy as np, fastmod
a = np.random.rand(1_000_000)

t0 = timeit.timeit(lambda: fastmod.each_nocb(a),          number=5) / 5
t1 = timeit.timeit(lambda: fastmod.each(a, lambda i,v: None), number=5) / 5
print(f"콜백 없음 {t0*1e3:.1f}ms · 원소마다 콜백 {t1*1e3:.1f}ms")
#   보통 수십 배 차이가 납니다

# 콜백을 줄인 버전과 비교
t2 = timeit.timeit(lambda: fastmod.process(a, lambda f: None), number=5)/5
print(f"1% 콜백 {t2*1e3:.1f}ms")     # t0 와 거의 같아야 정상 ★
// 콜백이 꼭 필요하고 자주 불려야 한다면
//   ① 파이썬 함수 대신 '설정 구조체'로 받기 (분기를 C++ 안에서)
//   ② 자주 쓰는 콜백을 C++ 구현으로 미리 제공 (이름으로 선택)
m.def("run", &run, "mode"_a = "default");   // "sum" · "max" · "custom"
//   ③ 정말 사용자 코드가 필요하면 Cython·numba 로 받은
//      C 함수 포인터를 넘기는 방법도 있습니다 (고급)
//   ④ GIL 획득 비용만 줄이려면 여러 결과를 모았다가 한 번에 콜백
설계 원칙 — 콜백은 “얼마나 자주 불리는가”로 판단합니다. 전체에서 100번 이하면 마음껏 쓰고, 데이터 크기에 비례한다면 설계를 바꿔야 합니다.
정리 — #include <pybind11/functional.h> 를 넣으면 파이썬 함수를 std::function 으로 받을 수 있습니다. 규칙은 둘 — 부를 때 GIL 을 잡을 것, 그리고 호출 횟수를 데이터 크기와 무관하게 유지할 것. 진행률은 1% 단위, 규칙은 배열째 넘기는 형태가 정답입니다.
콜백은 GIL 을 다시 잡습니다 — 자주 부르면 병렬이 무너집니다 GIL 놓고 계산 여러 스레드가 진짜로 동시에 콜백 호출 직전 GIL 을 다시 잡아야 합니다 그동안 다른 스레드는 전부 대기합니다 그래서 이렇게 씁니다 진행률은 1% 마다 한 번씩만 — 매 원소마다 부르면 순차 실행보다 느려집니다 결과를 C++ 쪽에 모아 두었다가 끝나고 한 번에 넘기는 편이 거의 항상 빠릅니다 콜백 안에서 파이썬 예외가 나면 C++ 쪽으로 전달해 정리해야 합니다 — 그냥 두면 조용히 사라집니다 다른 스레드에서 파이썬 객체를 만지려면 반드시 GIL 을 잡으세요 — 안 잡으면 인터프리터가 깨집니다 가능하면 방향을 뒤집으세요 — C++ 가 파이썬을 부르는 대신, 파이썬이 결과를 받아 처리하게
콜백은 편하지만 병렬 구간에 구멍을 냅니다 — 횟수부터 세어 보세요.
PY 10

타입 힌트와 .pyi 스텁

확장 모듈은 편집기가 안을 들여다볼 수 없습니다. 스텁 파일을 붙이면 자동완성·타입 검사가 살아나 체감 품질이 크게 달라집니다.

시그니처를 바인딩에 심기

// pybind11 은 docstring 에 시그니처를 자동으로 넣어 줍니다
m.def("smooth", &smooth, "data"_a, "window"_a = 3);
//   help(smooth) → smooth(data: numpy.ndarray[numpy.float64], window: int = 3) -> numpy.ndarray[numpy.float64]

// 타입 이름을 직접 지정하고 싶으면 — nanobind 는 nb::sig 로 시그니처를 통째로 덮어씁니다 ★
m.def("load", &load,
      nb::arg("path"),
      nb::sig("def load(path: str | os.PathLike) -> dict[str, float]"));
//   pybind11 에는 nb::sig 같은 것이 없습니다 — 2.12+ 의 py::typing 타입
//   (예: 반환형을 py::typing::Dict<py::str, py::float_> 로)으로 표기를 바꿉니다

// 모듈 수준 문서
m.doc() = R"doc(
fastmod — 느린 구간을 C++ 로 처리하는 확장 모듈

Examples:
    >>> import numpy as np, fastmod
    >>> fastmod.smooth(np.arange(10.0), window=3)
)doc";

스텁 자동 생성

# ① pybind11-stubgen (가장 널리 쓰입니다)
pip install pybind11-stubgen
pybind11-stubgen fastmod._core -o python/

#   → python/fastmod/_core.pyi 가 생성됩니다

# ② mypy 의 stubgen 도 가능
stubgen -m fastmod._core -o python/

# ③ 생성된 것을 손으로 다듬는 것이 보통입니다
#    자동 생성은 numpy 타입·오버로드를 완벽히 표현하지 못합니다
# python/fastmod/_core.pyi — 다듬은 결과
from typing import overload
import numpy as np
import numpy.typing as npt

def smooth(
    data: npt.NDArray[np.float64],
    window: int = 3,
) -> npt.NDArray[np.float64]:
    """이동평균으로 신호를 부드럽게 만든다."""
    ...

class Engine:
    def __init__(self, window: int = 3) -> None: ...
    @property
    def window(self) -> int: ...
    def feed(self, x: float) -> None: ...
    def result(self) -> float: ...
    def __len__(self) -> int: ...
    def __enter__(self) -> "Engine": ...
    def __exit__(self, *exc: object) -> bool: ...

이렇게도 쓸 수 있다 — 패키지에 제대로 담기 🟡 중급

py.typed 가 없으면 무시됩니다 ★

python/fastmod/
    __init__.py
    __init__.pyi        ← 래퍼의 스텁 (있으면 이쪽이 우선)
    _core.pyi           ← 확장 모듈 스텁
    py.typed            ← ★ 빈 파일. 이게 없으면 mypy 가 스텁을 무시합니다
# pyproject.toml — 패키지에 포함되게
[tool.scikit-build]
wheel.packages = ["python/fastmod"]

# setuptools 라면
[tool.setuptools.package-data]
fastmod = ["py.typed", "*.pyi"]
# 실제로 먹히는지 확인
mypy -c "import fastmod; fastmod.smooth('문자열')"
#   error: Argument 1 has incompatible type "str" ... ★ 이렇게 나와야 성공

# wheel 에 들어갔는지
unzip -l dist/*.whl | grep -E "pyi|py.typed"

CI 로 스텁을 최신으로 유지하기

# 바인딩을 고쳤는데 스텁을 안 고치면 거짓 정보가 됩니다
# .github/workflows/stubs.yml
- name: 스텁이 최신인지 검사
  run: |
    pip install -e . pybind11-stubgen
    pybind11-stubgen fastmod._core -o /tmp/stubs
    diff -u python/fastmod/_core.pyi /tmp/stubs/fastmod/_core.pyi \
      || (echo "::error::스텁이 오래됐습니다. 다시 생성하세요"; exit 1)
# 런타임 시그니처와 스텁이 맞는지 테스트로 확인
import inspect, fastmod

def test_signature():
    sig = inspect.signature(fastmod.smooth)
    assert list(sig.parameters) == ["data", "window"]
    assert sig.parameters["window"].default == 3

# docstring 예제를 실제로 실행해 검증 ★
# pytest --doctest-modules python/fastmod/
왜 이걸 자동화하나 — 스텁은 틀리면 없느니만 못합니다. 잘못된 자동완성은 사용자를 잘못된 코드로 이끕니다. CI 에서 diff 로 강제하는 것이 가장 확실합니다.

실무에선 이렇게 — 문서와 예제까지 🔴 실무

사용자가 처음 만나는 것들

  • 설치 한 줄 — pip install fastmod 가 컴파일 없이 되는가(wheel)
  • help() · 자동완성 — docstring 과 스텁
  • 에러 메시지 — 잘못 썼을 때 고칠 방법을 알려 주는가(y08)
  • 순수 파이썬 폴백 — 빌드가 없는 환경에서도 동작하는가(y02)
  • 버전과 빌드 정보 — fastmod.__version__, 어떤 컴파일러·플래그로 빌드됐는지
  • 스레드 안전성 명시 — GIL 을 놓는지, 동시 호출이 안전한지(y07)

README 에 반드시 넣을 것

## 설치
pip install fastmod          # 리눅스·맥·윈도우 wheel 제공 (3.9~3.13)

## 빠른 시작
import numpy as np, fastmod
arr = np.random.rand(1_000_000)
out = fastmod.smooth(arr, window=5)

## 성능
| n         | numpy   | fastmod | 배수  |
|-----------|---------|---------|-------|
| 10,000    | 0.21 ms | 0.08 ms |  2.6× |
| 1,000,000 | 24.5 ms | 3.1 ms  |  7.9× |
(측정 환경: ... · 재현 스크립트: bench/run.py)   ← ★ 반드시 밝히기

## 주의
- 입력은 float64 연속 배열이어야 합니다(다르면 자동 변환되며 복사가 생깁니다)
- 계산 중 GIL 을 해제하므로 ThreadPoolExecutor 로 병렬화할 수 있습니다
- 순수 파이썬 폴백: FASTMOD_PURE=1
성능 수치는 재현 방법과 함께 — 환경을 밝히지 않은 배수는 신뢰를 잃습니다. 벤치 스크립트를 저장소에 넣고 누구나 자기 환경에서 재 볼 수 있게 하세요.

한 단계 더 — 타입을 더 정확하게 🧬 전문가

numpy 타입을 제대로 표현하기

from typing import Any, Literal, overload, TypeVar
import numpy as np
import numpy.typing as npt

# dtype 을 타입으로 고정
F64 = npt.NDArray[np.float64]
F32 = npt.NDArray[np.float32]

# 오버로드 — 입력 dtype 에 따라 반환 dtype 이 달라지는 경우
@overload
def smooth(data: F64, window: int = ...) -> F64: ...
@overload
def smooth(data: F32, window: int = ...) -> F32: ...
def smooth(data: Any, window: int = 3) -> Any: ...

# 차원까지 표현 (numpy 2.1+ 는 Shape 제네릭이 개선됐습니다)
Vector = np.ndarray[tuple[int], np.dtype[np.float64]]
Matrix = np.ndarray[tuple[int, int], np.dtype[np.float64]]

def row_sum(m: Matrix) -> Vector: ...

# 열거형 인자는 Literal 로 — 오타를 편집기가 잡습니다
def resize(a: F64, mode: Literal["nearest", "linear", "cubic"] = "linear") -> F64: ...

문서를 자동으로 만들기

# Sphinx 로 API 문서 생성 — 확장 모듈도 됩니다
# docs/conf.py
extensions = [
    "sphinx.ext.autodoc",
    "sphinx.ext.napoleon",       # Google 스타일 docstring
    "sphinx_autodoc_typehints",  # 힌트를 문서에 반영
]
autodoc_mock_imports = []        # 확장은 실제로 import 되어야 합니다

# docs/api.rst
# .. automodule:: fastmod
#    :members:
#    :undoc-members:
// docstring 을 C++ 에서 Google 스타일로 작성해 두면 그대로 문서가 됩니다
m.def("smooth", &smooth, R"doc(
이동평균으로 신호를 부드럽게 만든다.

Args:
    data: 입력 배열 (float64, 1차원, 연속)
    window: 창 크기. 홀수를 권장합니다.

Returns:
    입력과 같은 길이의 배열

Raises:
    ValueError: window 가 배열 크기보다 클 때

Note:
    계산 중 GIL 을 해제하므로 여러 스레드에서 동시에 호출할 수 있습니다.
)doc", "data"_a, "window"_a = 3);
docstring 을 C++ 에 두는 이유 — 함수 시그니처를 고칠 때 문서가 바로 옆에 있어야 같이 고치게 됩니다. 별도 문서로 떼어 놓으면 반드시 어긋납니다.
정리 — 확장 모듈은 편집기가 안을 못 보므로 .pyi 스텁과 빈 py.typed 파일을 함께 넣어야 자동완성과 mypy 검사가 동작합니다. pybind11-stubgen 으로 만들고 손으로 다듬은 뒤, CI 에서 최신인지 검사하세요.
PY 11

nanobind 로 갈아타기

pybind11 을 만든 사람이 같은 개념을 다시 설계한 라이브러리입니다. 바이너리가 작고 빌드도 빠릅니다. 옮길지 말지 판단 기준을 정리합니다.

코드는 거의 같습니다

// pybind11
#include <pybind11/pybind11.h>
namespace py = pybind11;
PYBIND11_MODULE(_core, m) {
    m.def("add", &add, py::arg("a"), py::arg("b") = 0);
    py::class_<Engine>(m, "Engine").def(py::init<int>());
}

// nanobind — 이름만 바뀐 수준
#include <nanobind/nanobind.h>
namespace nb = nanobind;
NB_MODULE(_core, m) {
    m.def("add", &add, nb::arg("a"), nb::arg("b") = 0);
    nb::class_<Engine>(m, "Engine").def(nb::init<int>());
}

// 배열도 비슷하지만 이름이 다릅니다
#include <nanobind/ndarray.h>
double mean(nb::ndarray<const double, nb::ndim<1>, nb::c_contig> a) {
    const double* p = a.data();
    for (size_t i = 0; i < a.shape(0); ++i) ...
}
//   ★ numpy 뿐 아니라 PyTorch · TensorFlow · JAX 배열도 같은 타입으로 받습니다

무엇이 좋아지나

pybind11nanobind
바이너리 크기기준1/3 ~ 1/10
컴파일 시간기준3~5배 빠름
호출 오버헤드기준2~10배 빠름
필요 표준C++11C++17
파이썬3.7+3.8+
자료·예제매우 많음적음
STL 자동 변환폭넓음일부는 명시 필요
ML 프레임워크 배열numpy 위주torch·jax 통합 ★
왜 작고 빠른가 — pybind11 이 지원하던 낡은 컴파일러·구식 파이썬 호환을 버리고, 파이썬 3.12+ 의 새 API 를 적극적으로 씁니다. 레거시를 포기한 대가로 얻은 성능입니다.

이렇게도 쓸 수 있다 — 옮길 때 달라지는 것들 🟡 중급

주의할 차이점

// ① STL 변환 헤더를 명시해야 합니다
#include <nanobind/stl/vector.h>      // pybind11 은 stl.h 하나였습니다
#include <nanobind/stl/string.h>
#include <nanobind/stl/optional.h>
#include <nanobind/stl/function.h>
//   필요한 것만 넣으므로 컴파일이 빨라집니다

// ② 소유권 정책 이름이 다릅니다
nb::rv_policy::reference_internal     // py::return_value_policy::reference_internal
nb::rv_policy::copy

// ③ 기본적으로 모든 클래스가 '이동 가능'을 가정합니다
//    복사 불가 타입은 명시적으로
nb::class_<Socket>(m, "Socket")
    .def(nb::init<>());

// ④ 예외 등록
nb::exception<ParseError>(m, "ParseError");

// ⑤ GIL
nb::gil_scoped_release rel;           // 이름은 같습니다
m.def("heavy", &heavy, nb::call_guard<nb::gil_scoped_release>());

// ⑥ ★ 안정 ABI(abi3)를 훨씬 잘 지원합니다 (3.12+)
//    wheel 하나로 여러 파이썬 버전을 커버하기 쉬워집니다

빌드 설정

cmake_minimum_required(VERSION 3.20)
project(fastmod LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 17)

find_package(Python 3.8 COMPONENTS Interpreter Development.Module REQUIRED
             OPTIONAL_COMPONENTS Development.SABIModule)   # STABLE_ABI 용

# nanobind 를 찾아 옵니다
execute_process(
  COMMAND "${Python_EXECUTABLE}" -m nanobind --cmake_dir
  OUTPUT_VARIABLE nanobind_ROOT OUTPUT_STRIP_TRAILING_WHITESPACE)
find_package(nanobind CONFIG REQUIRED)

nanobind_add_module(_core
    STABLE_ABI            # abi3 로 빌드 (3.12+)
    NB_STATIC             # nanobind 런타임을 정적 링크
    src/core.cpp)

install(TARGETS _core DESTINATION fastmod)
[build-system]
requires = ["scikit-build-core>=0.10", "nanobind>=2.0"]
build-backend = "scikit_build_core.build"

[tool.scikit-build]
wheel.py-api = "cp312"       # STABLE_ABI 와 맞춥니다

실무에선 이렇게 — 옮길까 말까 🔴 실무

판단 기준

이럴 때 nanobind이럴 땐 pybind11 유지
새로 시작하는 프로젝트이미 잘 돌고 있는 코드
바이너리 크기가 중요
람다·모바일·여러 wheel
파이썬 3.7 을 지원해야 함
빌드 시간이 병목C++11·14 로 고정
호출이 아주 잦음자료·예제가 많이 필요
torch·jax 배열을 받아야 함 ★복잡한 STL 자동 변환에 의존
abi3 로 wheel 을 줄이고 싶음팀이 pybind11 에 익숙
이전 비용은 생각보다 작지만 0 은 아닙니다 — py:: → nb:: 치환은 기계적이지만, STL 헤더 · 소유권 정책 · 예외 등록에서 손이 갑니다. 테스트가 갖춰져 있지 않다면 옮기지 마세요.

이전 절차

# ① 먼저 테스트를 갖춥니다 (동작이 같은지 확인할 기준)
pytest tests/ --cov

# ② 바인딩 파일만 새 브랜치에서 치환
sed -i 's/pybind11/nanobind/g; s/\bpy::/nb::/g; s/PYBIND11_MODULE/NB_MODULE/g' \
    src/bindings.cpp
#   → 컴파일 에러를 하나씩 잡아 갑니다 (대개 STL 헤더 누락)

# ③ 성능·크기를 실측해 이득을 확인
ls -lh build/*/_core*.so
python -X importtime -c "import fastmod" 2>&1 | tail -2
pytest tests/bench.py

# ④ 두 구현을 잠시 함께 유지 (플래그로 선택)
cmake -B build -DUSE_NANOBIND=ON
실제로 얻는 것의 예 (프로젝트마다 다릅니다 — 직접 재세요)

  바인딩 100개 규모 모듈
    pybind11 : .so 4.2MB · 빌드 95초
    nanobind : .so 0.9MB · 빌드 24초

  wheel 개수 (3 OS × 5 버전, 모두 3.12 이상일 때)
    pybind11 : 15개
    nanobind + STABLE_ABI : 3개 ★

한 단계 더 — 왜 더 빠른가 🧬 전문가

설계 차이

① 함수 디스패치
   pybind11 : 호출마다 오버로드 후보를 순회하며 타입 변환을 시도
   nanobind : 시그니처 정보를 컴파일 시점에 압축해 두고
              런타임에는 최소한의 검사만 — 호출 경로가 훨씬 짧습니다

② 타입 정보 저장
   pybind11 : 타입마다 상당한 메타데이터를 런타임 객체로 생성
   nanobind : 상당 부분을 constexpr 로 처리해 코드·데이터가 줄어듭니다

③ 인스턴스 표현
   nanobind 는 파이썬 객체 헤더 뒤에 C++ 객체를 바로 붙여
   간접 참조를 줄입니다(작은 객체에서 유리)

④ 템플릿 인스턴스화 억제
   내부적으로 타입 소거를 적극 사용해
   m.def 하나가 만들어 내는 코드량 자체가 작습니다 ★

⑤ 파이썬 3.12+ 의 새 C API 사용
   벡터콜(vectorcall) 규약을 활용해 호출 비용을 줄입니다

직접 비교해 보기

# 같은 함수를 두 라이브러리로 빌드해 재 봅니다
import timeit
import fastmod_pb as pb      # pybind11 빌드
import fastmod_nb as nb      # nanobind 빌드
import numpy as np

a = np.random.rand(1000)

for name, mod in (("pybind11", pb), ("nanobind", nb)):
    t_call = timeit.timeit(lambda: mod.noop(),      number=1_000_000)
    t_arr  = timeit.timeit(lambda: mod.mean(a),     number=100_000)
    print(f"{name:10} noop {t_call:.3f}s  mean {t_arr:.3f}s")

# 크기·로딩
import os, subprocess
for m in ("fastmod_pb", "fastmod_nb"):
    p = __import__(m).__file__
    print(m, os.path.getsize(p)//1024, "KB")
결론적으로 — 계산이 무거운 함수라면 두 라이브러리의 차이는 무의미합니다 (계산이 지배하므로). 호출이 아주 잦고 계산이 짧은 인터페이스거나, 바이너리 크기·빌드 시간이 문제일 때 nanobind 의 이점이 실제로 체감됩니다.
정리 — nanobind 는 pybind11 의 후속으로 더 작고 빠르지만 자료가 적습니다. 새 프로젝트이고 C++17 이상을 쓸 수 있다면 nanobind 가 좋은 선택이고, 이미 잘 돌고 있는 pybind11 코드를 성능 때문에 급히 옮길 이유는 대개 없습니다.
PY 12

흔한 실패 12가지 — 원인과 해결

확장 모듈을 처음 만들 때 거의 모든 사람이 밟는 것들입니다. 증상만 보고 원인을 찾을 수 있게 정리했습니다.

증상원인해결
■ import 조차 안 될 때
ModuleNotFoundError: _core모듈 이름 불일치 또는 설치 경로NB_MODULE/PYBIND11_MODULE 의 이름과 파일명을 맞추고, install(TARGETS ... DESTINATION) 확인
ImportError: undefined symbol: _ZN...구현이 링크되지 않음그 .cpp 를 add_module 에 넣었는지. nm -D *.so | grep 심볼
ImportError: ... GLIBCXX_3.4.29 not found빌드 환경의 표준 라이브러리가 더 최신manylinux 컨테이너로 빌드(🛠️ b06) 또는 정적 링크
윈도우 DLL load failed의존 DLL 없음 · 런타임 불일치dumpbin /dependents 로 확인. VC++ 재배포 패키지 또는 정적 CRT
■ 실행 중 죽을 때
Segmentation fault (메시지 없이)빈 배열 · 범위 초과 · 널 접근PYTHONFAULTHANDLER=1 로 위치 확인 → 입력 검증 추가(y06)
가끔만 죽음 · 값이 이상함GIL 해제 구간에서 파이썬 객체 접근해제 구간에서는 포인터만. py:: 호출 전부 제거(y07)
수명이 끝난 뒤 접근반환 정책이 referencereference_internal 이나 copy. 배열은 base 지정(y04·y06)
terminate called after throwing예외가 noexcept·스레드 밖으로 샘워커 스레드·소멸자·C 경계를 try 로 감싸기(y08)
■ 되긴 하는데 이상할 때
C++ 인데 안 빨라짐경계를 너무 자주 넘음 · 자동 변환 복사배열 통째로 넘기기 · py::array_t 사용(y01·y04)
큰 배열에서만 느림dtype 불일치로 매번 전체 복사np.ascontiguousarray(a, dtype=np.float64) 로 정규화(y06)
스레드를 늘려도 그대로GIL 을 해제하지 않음py::call_guard<py::gil_scoped_release>()(y07)
메모리가 계속 늘어남순환 참조 · 캐시 미해제콜백 보관 시 해제 API 제공. tracemalloc·memray 로 추적(y05)

이렇게도 쓸 수 있다 — 진단 도구 모음 🟡 중급

증상별 첫 명령

# 세그폴트 → 어느 파이썬 코드에서 났는지
PYTHONFAULTHANDLER=1 python repro.py

# import 실패 → 무슨 심볼이 없는지
python -c "import fastmod" 2>&1 | tail -3
nm -C -D build/**/_core*.so | grep " U " | head       # 필요한데 없는 것
ldd build/**/_core*.so                                # 의존 라이브러리

# 메모리 오류 → 정확한 줄 번호
CMAKE_ARGS="-DCMAKE_CXX_FLAGS=-fsanitize=address" \
  pip install -e . --no-build-isolation
PYTHONMALLOC=malloc \
  LD_PRELOAD=$(gcc -print-file-name=libasan.so) python repro.py

# 성능 → 어디에 시간이 가는지 (C++ 프레임까지 보입니다) ★
py-spy record --native -o flame.svg -- python bench.py

# 메모리 증가 → 어느 할당이 안 풀리는지
memray run --native bench.py && memray flamegraph memray-*.bin

디버거로 붙기

# 디버그 정보를 포함해 빌드
pip install -e . --no-build-isolation -C cmake.build-type=RelWithDebInfo
#   (환경 변수 CMAKE_BUILD_TYPE 은 scikit-build-core 가 넘기는 값에 덮여 무시됩니다)

# gdb 로 파이썬째 실행
gdb --args python repro.py
(gdb) run
#   죽으면
(gdb) bt                 # C++ 스택
(gdb) py-bt              # 파이썬 스택 ★
(gdb) info locals
(gdb) p *arr@10          # 배열 앞 10개 출력

# 이미 도는 프로세스에 붙기
gdb -p $(pgrep -f "python server.py")

# 윈도우 — Visual Studio 에서 python.exe 에 '연결(Attach)'
#   Debug > Attach to Process > python.exe > Native code 선택
py-bt 가 안 되면 — 배포판의 python3-dbg 나 python-gdb.py 를 설치하고 gdb 에서 source 하세요. C++ 스택과 파이썬 스택을 함께 보는 것이 확장 디버깅의 핵심입니다.

실무에선 이렇게 — 사고를 미리 막는 체크리스트 🔴 실무

배포 전 확인

  • 빈 입력 — 크기 0 배열, 빈 리스트, None 을 넣어 봤는가
  • 잘못된 인자 — 음수·0·범위 초과가 예외로 나오는가(세그폴트 아니라)
  • dtype·차원 — float32·2차원·비연속 배열을 넣어 봤는가
  • 큰 입력 — 메모리 한계 근처에서 MemoryError 로 끝나는가
  • Ctrl+C — 오래 도는 계산을 중단할 수 있는가(y09)
  • 스레드 — 동시에 호출해도 안전한가(TSan 으로 검증)
  • 반복 호출 — 10만 번 부른 뒤 메모리가 늘지 않는가 ★
  • 세 OS — CI 에서 전부 테스트가 도는가

테스트로 자동화하기

import gc, numpy as np, pytest, fastmod

# ① 경계 입력
@pytest.mark.parametrize("a", [
    np.array([]), np.zeros(1), np.zeros(2), np.full(3, np.nan),
])
def test_edge(a):
    fastmod.smooth(a, 1)              # 죽지 않기만 하면 통과

# ② 잘못된 인자는 예외여야 합니다
@pytest.mark.parametrize("w", [0, -1, 10**9])
def test_bad_window(w):
    with pytest.raises(ValueError):
        fastmod.smooth(np.zeros(10), w)

# ③ 누수 검사 — 반복 호출 후 메모리 비교 ★
def test_no_leak():
    a = np.random.rand(100_000)
    fastmod.smooth(a, 3); gc.collect()
    import resource
    before = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss
    for _ in range(1000):
        fastmod.smooth(a, 3)
    gc.collect()
    after = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss
    assert after - before < 50_000      # 50MB 이상 늘면 실패

# ④ 스레드 동시 호출
def test_threads():
    from concurrent.futures import ThreadPoolExecutor
    a = np.random.rand(100_000)
    with ThreadPoolExecutor(8) as ex:
        rs = list(ex.map(lambda _: fastmod.smooth(a, 3), range(64)))
    for r in rs: np.testing.assert_allclose(r, rs[0])

한 단계 더 — 운영에서 터졌을 때 🧬 전문가

재현 안 되는 크래시 추적

# ① 코어 덤프를 남기게 설정
ulimit -c unlimited
echo "/tmp/core.%e.%p" | sudo tee /proc/sys/kernel/core_pattern

# ② 덤프 분석 (🔬 d11 에서 더 자세히)
gdb python /tmp/core.python.12345
(gdb) bt full
(gdb) py-bt
(gdb) info sharedlibrary          # 어느 .so 에서 났는지

# ③ 심볼이 필요합니다 — 릴리즈 빌드에서도 분리 보관하세요
cmake -DCMAKE_BUILD_TYPE=RelWithDebInfo
objcopy --only-keep-debug _core.so _core.so.debug
objcopy --strip-debug --add-gnu-debuglink=_core.so.debug _core.so
#   배포는 strip 된 것, 심볼 파일은 따로 보관 → 덤프 분석 때 사용 ★

# ④ 운영 프로세스를 죽이지 않고 들여다보기
py-spy dump --pid 12345 --native      # 모든 스레드의 C++ + 파이썬 스택
gdb -p 12345 -batch -ex "thread apply all bt" > stacks.txt

방어적으로 배포하기

# ① 폴백 경로를 항상 유지 (y02)
FASTMOD_PURE=1 python app.py       # 즉시 순수 파이썬으로 되돌리기

# ② 빌드 정보를 모듈에 노출 — 장애 보고에 반드시 필요합니다
>>> fastmod.__build_info__
{'compiler': 'GNU 13.2.0', 'flags': '-O2 -march=x86-64-v2',
 'built': '2026-08-06', 'commit': 'a1b2c3d', 'pybind11': '2.13.6'}

# ③ 자체 진단 함수
>>> fastmod.selftest()
{'simd': 'AVX2', 'threads': 8, 'gil_released': True, 'ok': True}

# ④ 크래시 시 최소 정보 남기기 (프로세스가 죽기 직전)
import faulthandler, sys
faulthandler.enable(file=open("/var/log/app/fault.log", "a"))
faulthandler.dump_traceback_later(300, exit=True)   # 5분 뒤 덤프하고 종료
#   ↑ 한 번 거는 타이머입니다 — 작업 루프마다 다시 불러 타이머를 미뤄야
#     '5분 멈추면 덤프' 워치독이 됩니다 (안 그러면 멀쩡해도 5분 뒤 종료)

# ⑤ 카나리 배포 — 일부 트래픽에만 C++ 경로를 켜고 지표 비교
if random.random() < float(os.environ.get("FASTMOD_RATIO", "1.0")):
    result = fastmod.smooth(a)
else:
    result = pure_smooth(a)
확장 모듈의 장애는 파급이 큽니다 — 파이썬 예외는 요청 하나가 실패하지만, 세그폴트는 프로세스 전체(진행 중인 다른 요청 포함)를 죽입니다. 폴백 경로와 즉시 되돌릴 스위치는 선택이 아니라 필수입니다.
정리 — 확장 모듈의 실패는 세 종류입니다 — import 실패(이름·링크·의존성), 크래시(입력 검증·GIL·수명), 안 빨라짐(경계 횟수·복사). 막혔을 때 첫 명령은 PYTHONFAULTHANDLER=1, 그리고 AddressSanitizer 와 py-spy — 이 셋이면 대부분 원인이 나옵니다.
N-API · node-addon-api · 제로카피 Buffer · AsyncWorker

이벤트 루프를
막지 않고
C++ 를 부릅니다

Node 는 단일 스레드입니다. 그래서 C++ 를 붙일 때 가장 중요한 것은 속도가 아니라 이벤트 루프를 막지 않는 것입니다. N-API 로 만들면 Node 버전이 올라가도 다시 빌드할 필요가 없습니다.

10SECTIONS
N-APIABI 안정
0재빌드
4깊이 층
NODE 01

애드온 · WASM · 별도 프로세스 — 무엇을 고를까

네이티브 애드온이 항상 답은 아닙니다. 배포와 유지보수 비용까지 보면 다른 선택이 나은 경우가 많습니다.

Node 에서 C++ 를 쓰는 세 갈래 — 무엇을 포기할 것인가 네이티브 애드온 호출 비용이 사실상 0 시스템 API 를 전부 쓴다 스레드 · SIMD 제약 없음 플랫폼마다 빌드가 필요 크래시가 프로세스를 죽인다 최고 성능이 필요할 때 WebAssembly 한 번 빌드해 어디서나 브라우저에서도 그대로 샌드박스 — 죽어도 안전 파일 · 소켓 접근에 제약 네이티브의 50~80% 속도 배포 단순함이 우선일 때 별도 프로세스 언어를 아무거나 써도 됨 죽어도 Node 는 멀쩡 따로 배포 · 따로 확장 호출마다 직렬화 비용 운영할 것이 하나 늘어난다 호출이 잦지 않을 때 고르는 기준은 "호출 빈도 × 한 번의 계산량"입니다 초당 수만 번 부르며 한 번이 짧다면 애드온, 가끔 부르며 오래 걸린다면 별도 프로세스가 대개 맞습니다
셋 중 무엇을 골라도 먼저 순수 JS 로 얼마나 빠른지 재 보세요 — V8 은 생각보다 훨씬 빠릅니다.

세 가지 방식 비교

네이티브 애드온WASM별도 프로세스
속도최고0.7~0.9배IPC 비용
배포OS별 바이너리파일 하나 ★실행 파일
격리없음(크래시=전체)샌드박스완전 격리 ★
스레드자유제한적자유
메모리 공유제로카피 ★선형 메모리 복사불가(파이프)
시스템 접근전부WASI 범위만전부
브라우저❌✅❌
고르는 기준 — 큰 데이터를 자주 주고받으면 애드온, 배포 편의와 안전이 우선이면 WASM, 크래시가 서버를 죽이면 안 되거나 GPL 라이브러리를 쓴다면 별도 프로세스입니다.

FFI 와도 비교해야 합니다

같은 C++ 코드를 Node 에서 쓰는 네 가지 길

  ① 네이티브 애드온 (N-API)     ← 이 탭
     · 가장 빠름 · JS 객체를 자유롭게 다룸
     · 빌드 필요 · prebuild 배포 · 이 탭의 주제

  ② FFI (koffi · Deno.dlopen)   ← 🔗 f06
     · 빌드 불필요 · 여러 언어가 같은 .so 공유
     · 호출 비용이 조금 큼 · JS 객체를 다루기 어려움

  ③ WASM (Emscripten)
     · 파일 하나로 배포 · 브라우저에서도 동작
     · 메모리 경계를 넘을 때 복사 · 시스템 호출 제한

  ④ 별도 프로세스 + IPC
     · 크래시 격리 · 라이선스 분리 · 언어 자유
     · 직렬화 비용 · 프로세스 관리 부담

★ 실무 조언
  먼저 ②(FFI)로 만들어 동작을 검증하고,
  호출 비용이 문제가 되면 ①(애드온)으로 옮기는 순서가 안전합니다.
  C API 를 잘 만들어 두면 둘 사이 이동이 쉽습니다(🔗 f03).

이렇게도 쓸 수 있다 — 언제 애드온이 정답인가 🟡 중급

실제로 쓰이는 곳

  • 이미지·영상 처리 — sharp(libvips), ffmpeg 바인딩. Buffer 를 복사 없이 넘길 수 있는 것이 결정적입니다
  • 암호·해시 — bcrypt, argon2. CPU 를 오래 쓰므로 워커 스레드로 빼는 것이 필수입니다
  • DB 드라이버 — better-sqlite3, node-oracledb. 기존 C 라이브러리를 그대로 씁니다
  • 직렬화 — protobuf, msgpack 가속
  • OS 통합 — 트레이 아이콘, 전역 단축키, 화면 캡처(Electron)
  • ML 추론 — onnxruntime-node, node-llama-cpp
애드온이 답이 아닌 경우 — JSON 파싱·문자열 처리 같은 것은 V8 이 이미 매우 빠릅니다. 경계 비용 때문에 C++ 로 옮기면 오히려 느려지는 경우가 흔합니다. 반드시 먼저 측정하세요.

N-API 를 써야 하는 이유 ★

Node 애드온 API 의 역사

  ① V8 API 직접 사용 (옛 방식 · nan)
     · V8 버전이 바뀔 때마다 코드가 깨집니다
     · Node 메이저 버전마다 재빌드 필요
     · Electron 은 또 다른 V8 버전 → 별도 빌드

  ② N-API / Node-API (Node 8.6+, 안정화 10+)  ★ 현재 표준
     · C 로 된 ABI 안정 인터페이스
     · 한 번 빌드하면 Node 여러 메이저 버전에서 동작
     · Electron·Bun·Deno 도 일부 지원
     · node-addon-api 는 이것의 C++ 래퍼(헤더 전용)

★ 지금 새로 만든다면 선택지는 하나입니다 — node-addon-api
  nan(Native Abstractions for Node) 은 레거시입니다.

버전 확인
  napi_version 3 → Node 10+
  napi_version 8 → Node 16+   (대부분 여기면 충분)
  napi_version 9 → Node 18.17+

실무에선 이렇게 — 도입 전에 계산할 것 🔴 실무

진짜 비용은 배포입니다

지원해야 할 조합
  OS      : linux · darwin · win32
  아키텍처 : x64 · arm64
  libc    : glibc · musl(alpine) ★ 도커에서 자주 문제
  런타임   : Node · Electron · Bun

  → 최소 6~12개 바이너리. prebuild 로 자동화하지 않으면 감당 못 합니다(j09)

사용자 쪽에서 나는 문제
  · 컴파일러가 없어 설치 실패 (윈도우가 특히)
  · node-gyp 이 Python 을 요구 (예전 버전)
  · 알파인 리눅스에서 musl 불일치
  · Electron 버전이 다르면 재빌드 필요
  · CI 캐시에 안 맞는 바이너리가 남아 있음

★ 그래서 prebuildify + node-gyp-build 조합이 사실상 표준입니다
  (모든 바이너리를 패키지에 넣고 런타임에 골라 로드)

되돌릴 수 있게 만들기

// JS 폴백을 함께 두면 배포 사고에 강해집니다 ★
let impl
if (process.env.MYLIB_PURE_JS === '1') {           // 환경변수로 강제 전환 (먼저 확인)
  impl = require('./fallback.js')
} else {
  try {
    impl = require('node-gyp-build')(__dirname)    // 네이티브
  } catch (err) {
    console.warn('[mylib] 네이티브 로드 실패, JS 구현 사용:', err.message)
    impl = require('./fallback.js')                // 순수 JS
  }
}
exports.smooth = impl.smooth      // ★ 선택이 끝난 뒤에 내보내야 전환이 반영됩니다

// 두 구현이 같은 결과를 내는지 테스트로 대조
test('parity', () => {
  const a = Float64Array.from({length: 1000}, Math.random)
  expect(native.smooth(a, 3)).toEqual(js.smooth(a, 3))
})
이 구조의 값어치 — 네이티브가 없는 환경(알파인·새 아키텍처)에서도 느리게라도 동작합니다. 설치 실패로 서비스가 뜨지 않는 것보다 훨씬 나은 실패 모드입니다.

한 단계 더 — Node 아키텍처와의 관계 🧬 전문가

이벤트 루프와 스레드 풀

Node 의 구조
  · 메인 스레드 하나가 JS 를 실행합니다 (이벤트 루프)
  · libuv 스레드 풀(기본 4개)이 파일 I/O·DNS·zlib 을 처리합니다
  · 네이티브 애드온의 AsyncWorker 도 이 풀을 씁니다 ★

★ 그래서 생기는 일
  ① 동기 애드온 호출은 이벤트 루프를 통째로 멈춥니다
     500ms 걸리면 그동안 모든 HTTP 요청이 대기합니다
  ② AsyncWorker 를 많이 쓰면 스레드 풀이 고갈됩니다
     → 파일 I/O 까지 느려집니다
     UV_THREADPOOL_SIZE 로 늘릴 수 있습니다 (최대 1024)
  ③ 자체 스레드를 쓰면 풀과 무관하지만
     JS 를 부를 때 ThreadSafeFunction 이 필요합니다(j07)

측정
  node --cpu-prof app.js          # CPU 프로파일
  process.env.UV_THREADPOOL_SIZE  # 현재 설정
  perf_hooks.monitorEventLoopDelay()   # 이벤트 루프 지연 ★

이벤트 루프 지연 측정하기

import { monitorEventLoopDelay } from 'node:perf_hooks'

const h = monitorEventLoopDelay({ resolution: 10 })
h.enable()

// 동기 애드온 호출
for (let i = 0; i < 100; i++) addon.heavySync(data)

h.disable()
console.log(`평균 ${(h.mean/1e6).toFixed(1)}ms  최대 ${(h.max/1e6).toFixed(1)}ms`)
//   ★ 최대값이 수백 ms 면 사용자가 체감하는 응답 지연입니다

// 비동기 버전과 비교
h.reset(); h.enable()
await Promise.all(Array.from({length:100}, () => addon.heavyAsync(data)))
h.disable()
console.log(`비동기: 최대 ${(h.max/1e6).toFixed(1)}ms`)   // 훨씬 작아야 정상
기준선 — 웹 서버라면 이벤트 루프 지연이 10ms 를 넘으면 문제로 봅니다. 1ms 이상 걸리는 애드온 함수는 비동기로 만드세요(j06).
정리 — 애드온은 큰 데이터를 복사 없이 다뤄야 할 때 가장 강력합니다. 배포가 부담이면 WASM, 크래시 격리가 필요하면 별도 프로세스, 빠르게 검증하려면 FFI 를 먼저 보세요. 그리고 무엇을 고르든 이벤트 루프를 막지 않는 것이 첫 번째 원칙입니다.
NODE 02

node-addon-api — 첫 모듈

C++ 함수를 require() 로 부르기까지, 파일 네 개면 됩니다.

① C++ 파일

// src/addon.cpp
#include <napi.h>
#include <vector>

// 모든 함수는 CallbackInfo 를 받고 Napi::Value 를 돌려줍니다
Napi::Value Add(const Napi::CallbackInfo& info) {
    Napi::Env env = info.Env();

    // ★ 인자 검증을 반드시 하세요 (안 하면 크래시)
    if (info.Length() < 2 || !info[0].IsNumber() || !info[1].IsNumber()) {
        Napi::TypeError::New(env, "숫자 두 개가 필요합니다")
            .ThrowAsJavaScriptException();
        return env.Null();
    }

    double a = info[0].As<Napi::Number>().DoubleValue();
    double b = info[1].As<Napi::Number>().DoubleValue();
    return Napi::Number::New(env, a + b);
}

// 모듈 등록
Napi::Object Init(Napi::Env env, Napi::Object exports) {
    exports.Set("add", Napi::Function::New(env, Add));
    return exports;
}
NODE_API_MODULE(addon, Init)      // ★ 첫 인자는 binding.gyp 의 target_name

② 빌드 설정 · ③ 실행

// binding.gyp
{
  "targets": [{
    "target_name": "addon",
    "sources": ["src/addon.cpp"],
    "include_dirs": ["<!@(node -p \"require('node-addon-api').include\")"],
    "cflags!": ["-fno-exceptions"],
    "cflags_cc!": ["-fno-exceptions"],
    "cflags_cc": ["-std=c++20"],
    "defines": ["NAPI_CPP_EXCEPTIONS"],
    "xcode_settings": {
      "GCC_ENABLE_CPP_EXCEPTIONS": "YES",
      "CLANG_CXX_LIBRARY": "libc++",
      "MACOSX_DEPLOYMENT_TARGET": "10.15"
    },
    "msvs_settings": {
      "VCCLCompilerTool": { "ExceptionHandling": 1, "AdditionalOptions": ["/std:c++20"] }
    }
  }]
}
// package.json
{
  "name": "myaddon",
  "gypfile": true,
  "dependencies": { "node-addon-api": "^8.0.0" },
  "devDependencies": { "node-gyp": "^10.0.0" },
  "scripts": { "build": "node-gyp rebuild" }
}
// index.js
const addon = require('./build/Release/addon.node')
console.log(addon.add(1, 2))      // 3

이렇게도 쓸 수 있다 — JS 답게 만들기 🟡 중급

인자 검증을 헬퍼로

// 매번 쓰는 검증 코드를 줄이는 관용구
namespace {
double RequireNumber(const Napi::CallbackInfo& info, std::size_t i,
                     const char* name) {
    if (info.Length() <= i || !info[i].IsNumber())
        throw Napi::TypeError::New(info.Env(),
            std::string(name) + " 는 숫자여야 합니다");
    return info[i].As<Napi::Number>().DoubleValue();
}
int RequireInt(const Napi::CallbackInfo& info, std::size_t i,
               const char* name, int def) {
    if (info.Length() <= i || info[i].IsUndefined()) return def;
    return RequireNumber(info, i, name);
}
}  // namespace

Napi::Value Smooth(const Napi::CallbackInfo& info) {
    Napi::Env env = info.Env();
    try {
        auto window = RequireInt(info, 1, "window", 3);
        ...
    } catch (const Napi::Error& e) {
        e.ThrowAsJavaScriptException();     // ★ NAPI_CPP_EXCEPTIONS 필요
        return env.Null();
    }
}

// C++ 예외를 JS 예외로 (모든 진입점에서)
#define WRAP_TRY(env, body) \
    try { body } \
    catch (const std::exception& e) { \
        Napi::Error::New(env, e.what()).ThrowAsJavaScriptException(); \
        return env.Null(); \
    }

TypeScript 타입 붙이기

// index.d.ts — 사용자가 처음 만나는 얼굴 ★
export interface SmoothOptions {
  window?: number
  mode?: 'nearest' | 'reflect'
}

export function smooth(
  data: Float64Array,
  options?: SmoothOptions
): Float64Array

export function smoothAsync(
  data: Float64Array,
  options?: SmoothOptions
): Promise<Float64Array>

export class Engine {
  constructor(window?: number)
  feed(data: Float64Array): void
  readonly size: number
  close(): void
}
// index.js — 얇은 JS 래퍼로 검증·기본값을 처리 ★
const native = require('node-gyp-build')(__dirname)

function smooth(data, { window = 3, mode = 'nearest' } = {}) {
  if (!(data instanceof Float64Array))
    data = Float64Array.from(data)          // 정규화
  if (!Number.isInteger(window) || window < 1)
    throw new RangeError(`window 는 1 이상 정수여야 합니다 (받음: ${window})`)
  return native.smooth(data, window, mode === 'reflect' ? 1 : 0)
}
module.exports = { smooth }                 // require·__dirname 을 쓰므로 CommonJS 로
파이썬 탭과 같은 원칙 — C++ 쪽은 단순하게, JS 래퍼에서 파이썬답게. 옵션 객체·기본값·에러 메시지는 JS 에서 처리하는 편이 코드가 짧고 유지보수도 쉽습니다.

실무에선 이렇게 — 패키지 구조 🔴 실무

저장소 배치

myaddon/
  binding.gyp
  package.json
  src/
    addon.cpp          ← N-API 바인딩만 (얇게)
    core.cpp / core.hpp ← 순수 C++ 로직 (테스트 가능) ★
  index.js             ← JS 래퍼 (검증·기본값)
  index.d.ts           ← 타입
  fallback.js          ← 순수 JS 폴백
  test/
    addon.test.js
    parity.test.js     ← 네이티브 vs JS 결과 대조
  prebuilds/           ← prebuildify 산출물 (j09)
    linux-x64/node.napi.node
    darwin-arm64/node.napi.node
    win32-x64/node.napi.node
로직과 바인딩을 분리하세요 — core.cpp 는 Node 를 모르는 순수 C++ 이라 C++ 테스트로 검증할 수 있고, 같은 코드를 파이썬 바인딩에도 재사용할 수 있습니다.

개발 반복 주기 줄이기

# 빌드
npx node-gyp rebuild            # 전체
npx node-gyp build              # 증분 (빠름)
npx node-gyp build --debug      # 디버그 심볼

# 병렬 빌드
npx node-gyp build -j max      # 또는 -j 8

# ccache 붙이기 (재빌드가 크게 빨라집니다) ★
export CC="ccache gcc" CXX="ccache g++"

# 자동 재빌드 + 테스트
npx nodemon --watch src -e cpp,hpp --exec "npm run build && npm test"

# 로드된 애드온 확인
node -p "require('./build/Release/addon.node')"

# 심볼 확인 (로드 실패 진단)
nm -D build/Release/addon.node | grep napi_register
애드온 파일이 잠기는 문제(윈도우) — Node 프로세스가 살아 있으면 .node 파일을 덮어쓸 수 없습니다. 테스트 프로세스를 확실히 종료한 뒤 빌드하세요. watch 모드에서 자주 겪습니다.

한 단계 더 — 모듈이 로드되는 과정 🧬 전문가

NODE_API_MODULE 의 실체

// 이 매크로는 결국 등록 함수를 만듭니다
//   napi_value napi_register_module_v1(napi_env, napi_value exports)
//
// require('./addon.node') 를 하면
//   ① process.dlopen 으로 공유 라이브러리를 로드
//   ② napi_register_module_v1 심볼을 찾아 호출
//   ③ 돌려받은 exports 를 모듈 캐시에 등록

// 확인
nm -D addon.node | grep napi_register_module_v1

// ⚠ 공유 라이브러리는 프로세스당 한 번만 로드됩니다 (Init 은 환경마다 불리지만 static 은 하나)
//   전역 상태를 두면 여러 worker_threads 가 공유합니다 ★
static std::vector<double> g_cache;     // ❌ 스레드 간 경쟁

// ✅ 인스턴스별 데이터를 쓰세요 (context-aware addon)
class AddonData {
public:
    std::vector<double> cache;
};
Napi::Object Init(Napi::Env env, Napi::Object exports) {
    auto* data = new AddonData();
    env.SetInstanceData(data);          // ★ 환경(스레드)마다 별도
    ...
}
// 꺼내기: env.GetInstanceData<AddonData>()

worker_threads 대응

// Node 는 worker_threads 마다 별도 V8 격리 환경을 만듭니다.
// 애드온이 'context-aware' 가 아니면 로드가 거부됩니다 ★
import { Worker } from 'node:worker_threads'
new Worker('./worker.js')        // 애드온을 쓰면 여기서 실패할 수 있습니다

// node-addon-api 는 기본적으로 context-aware 입니다.
// 다만 전역 static 을 쓰면 스레드 간 공유가 되어 위험합니다.
// 인스턴스 데이터 패턴 (권장) ★
struct PerEnv {
    std::mt19937 rng;                 // 스레드마다 별도
    std::vector<double> scratch;
};

Napi::Value Run(const Napi::CallbackInfo& info);   // Init 에서 쓰므로 먼저 선언

Napi::Object Init(Napi::Env env, Napi::Object exports) {
    env.SetInstanceData<PerEnv>(new PerEnv{});
    exports.Set("run", Napi::Function::New(env, Run));
    return exports;
}

Napi::Value Run(const Napi::CallbackInfo& info) {
    auto* d = info.Env().GetInstanceData<PerEnv>();
    d->scratch.clear();               // 이 스레드 전용 버퍼 ★
    ...
}
// 소멸자는 환경이 정리될 때 자동으로 불립니다
왜 중요한가 — Node 서버에서 worker_threads 로 CPU 작업을 분산하는 것은 흔한 패턴입니다. 전역 상태를 둔 애드온은 그 순간 조용히 깨집니다. 처음부터 SetInstanceData 를 쓰세요.
정리 — napi.h 를 include 하고 NODE_API_MODULE 로 등록하면 끝입니다. C++ 쪽은 인자 검증과 예외 변환만 하고, 옵션·기본값·타입은 JS 래퍼와 .d.ts 에서 처리하세요. 전역 상태 대신 SetInstanceData 를 쓰면 worker_threads 에서도 안전합니다.
NODE 03

빌드 — node-gyp 과 cmake-js

binding.gyp 은 Node 전용 빌드 설명 파일입니다. 외부 라이브러리를 붙일 일이 많다면 CMake 로 가는 편이 편합니다.

node-gyp — 기본 경로

// binding.gyp — 조건부 설정까지
{
  "targets": [{
    "target_name": "addon",
    "sources": ["src/addon.cpp", "src/core.cpp"],
    "include_dirs": [
      "<!@(node -p \"require('node-addon-api').include\")",
      "src"
    ],
    "defines": ["NAPI_VERSION=8"],
    "conditions": [
      ["OS=='linux'", { "libraries": ["-lpthread"] }],
      ["OS=='mac'",   { "xcode_settings": { "MACOSX_DEPLOYMENT_TARGET": "10.15" } }],
      ["OS=='win'",   { "libraries": ["ws2_32.lib"] }]
    ],
    "cflags_cc": ["-std=c++20", "-O3"],
    "msvs_settings": {
      "VCCLCompilerTool": { "AdditionalOptions": ["/std:c++20", "/O2"] }
    }
  }]
}
binding.gyp 은 JSON 이 아니라 파이썬 딕셔너리 문법입니다 — 주석은 안 되고, 오타가 나면 에러 메시지가 불친절합니다. <!@(...) 같은 확장 문법도 gyp 전용이라 학습 비용이 있습니다.

cmake-js — 외부 의존성이 있다면 ★

# CMakeLists.txt
cmake_minimum_required(VERSION 3.20)
project(addon LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 20)

# cmake-js 가 주는 변수들
include_directories(${CMAKE_JS_INC})
file(GLOB SRC "src/*.cpp")
add_library(${PROJECT_NAME} SHARED ${SRC} ${CMAKE_JS_SRC})
set_target_properties(${PROJECT_NAME} PROPERTIES
    PREFIX "" SUFFIX ".node")
target_link_libraries(${PROJECT_NAME} ${CMAKE_JS_LIB})

# node-addon-api 헤더
execute_process(COMMAND node -p "require('node-addon-api').include"
    OUTPUT_VARIABLE NAPI_DIR OUTPUT_STRIP_TRAILING_WHITESPACE)
string(REPLACE "\"" "" NAPI_DIR ${NAPI_DIR})
target_include_directories(${PROJECT_NAME} PRIVATE ${NAPI_DIR})
target_compile_definitions(${PROJECT_NAME} PRIVATE NAPI_VERSION=8)

# ★ 여기서부터 CMake 생태계를 그대로 씁니다
find_package(OpenCV REQUIRED)
target_link_libraries(${PROJECT_NAME} ${OpenCV_LIBS})
# 또는 FetchContent · vcpkg
// package.json
{ "scripts": { "build": "cmake-js compile -B Release" },
  "devDependencies": { "cmake-js": "^7.3.0" } }

이렇게도 쓸 수 있다 — 고르는 기준과 함정 🟡 중급

둘 중 무엇을

node-gypcmake-js
파일 몇 개짜리 단순 애드온외부 라이브러리 링크 ★
npm 생태계 표준C++ 팀이 이미 CMake 를 씀
prebuild 도구 지원이 넓음같은 코드를 파이썬에도 재사용
설정이 gyp 문법vcpkg·FetchContent 사용 가능
# node-gyp 이 실패할 때 확인 순서
node-gyp configure --verbose        # 어느 단계에서 막히는지
node -p "process.versions"          # node · v8 · napi 버전
npx node-gyp list                   # 받아 둔 헤더 목록

# 윈도우 — 빌드 도구
npm i -g windows-build-tools        # (구버전) 또는
# Visual Studio Build Tools + "C++ 데스크톱 개발" 워크로드 설치

# 헤더 캐시가 꼬였을 때
rm -rf ~/.node-gyp ~/.cache/node-gyp
npx node-gyp rebuild

플랫폼별 함정

① 맥 — 배포 대상 버전
   MACOSX_DEPLOYMENT_TARGET 을 낮게 잡아야 옛 맥에서 돕니다.
   arm64 와 x64 를 둘 다 지원하려면 유니버설 빌드 또는 각각 빌드.

② 리눅스 — glibc 버전
   최신 우분투에서 빌드하면 옛 배포판에서 로드 실패합니다.
   → 도커(예: ubuntu:20.04)에서 빌드하거나 정적 링크.
   알파인(musl)은 별도 바이너리가 필요합니다 ★

③ 윈도우 — 런타임 라이브러리
   /MD(동적) 로 빌드하면 VC++ 재배포 패키지가 필요합니다.
   /MT(정적) 로 하면 파일이 커지지만 의존성이 없어집니다.
   msvs_settings 의 RuntimeLibrary 로 지정합니다.

④ 예외 처리
   node-gyp 기본 설정은 -fno-exceptions 입니다.
   node-addon-api 의 C++ 예외를 쓰려면 반드시 꺼야 합니다:
     "cflags!": ["-fno-exceptions"], "cflags_cc!": ["-fno-exceptions"]
     "defines": ["NAPI_CPP_EXCEPTIONS"]
   ★ 이걸 빠뜨리면 예외를 던지는 순간 프로세스가 죽습니다

실무에선 이렇게 — CI 로 자동화 🔴 실무

3 OS 빌드 워크플로

# .github/workflows/build.yml
name: build
on: [push, pull_request]

jobs:
  test:
    strategy:
      matrix:
        os: [ubuntu-latest, macos-14, windows-latest]
        node: [18, 20, 22]
    runs-on: ${{ matrix.os }}
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: '${{ matrix.node }}' }
      - run: npm ci
      - run: npm run build
      - run: npm test

  # 알파인(musl)도 별도로 ★
  musl:
    runs-on: ubuntu-latest
    container: node:20-alpine
    steps:
      - uses: actions/checkout@v4
      - run: apk add --no-cache build-base python3
      - run: npm ci && npm test

빌드 시간과 캐시

# ccache 로 재빌드 가속
- uses: hendrikmuhs/ccache-action@v1
  with: { key: '${{ matrix.os }}-${{ matrix.node }}' }
- run: |
    export CC="ccache gcc" CXX="ccache g++"
    npm run build

# node-gyp 헤더 캐시
- uses: actions/cache@v4
  with:
    path: ~/.cache/node-gyp
    key: node-gyp-${{ matrix.node }}
# 로컬에서 도커로 리눅스 빌드 재현
docker run --rm -v $PWD:/app -w /app node:20 \
  sh -c "npm ci && npm run build && npm test"

# 알파인
docker run --rm -v $PWD:/app -w /app node:20-alpine \
  sh -c "apk add build-base python3 && npm ci && npm test"

# 결과물이 어떤 것에 의존하는지
ldd build/Release/addon.node        # 리눅스
otool -L build/Release/addon.node   # 맥
dumpbin /dependents addon.node      # 윈도우
의존성을 줄이는 것이 배포를 쉽게 합니다 — 외부 .so 에 의존하면 사용자 환경에 그것이 있어야 합니다. 가능하면 정적 링크로 애드온 파일 하나에 담으세요(🛠️ b08).

한 단계 더 — 빌드 산출물 들여다보기 🧬 전문가

.node 파일의 정체

# 그냥 공유 라이브러리입니다 (확장자만 다름)
file build/Release/addon.node
#   ELF 64-bit LSB shared object, x86-64 ...

# 내보내는 심볼 — 이것 하나만 있으면 됩니다
nm -D build/Release/addon.node | grep napi_register
#   T napi_register_module_v1

# 크기 줄이기
strip --strip-unneeded build/Release/addon.node    # 디버그 심볼 제거
#   보통 절반 이하로 줄어듭니다

# 무엇이 크기를 차지하나
nm -C --size-sort -S build/Release/addon.node | tail -20
bloaty build/Release/addon.node

# NAPI 버전 확인 (호환성 문제 진단)
strings build/Release/addon.node | grep -i napi | head

최적화 플래그와 검증 빌드

// 릴리즈 최적화 (binding.gyp)
"configurations": {
  "Release": {
    "cflags_cc": ["-O3", "-fvisibility=hidden"],
    "ldflags": ["-Wl,--gc-sections"],
    "msvs_settings": {
      "VCCLCompilerTool": { "Optimization": 2, "FavorSizeOrSpeed": 1 }
    }
  },
  "Debug": {
    "cflags_cc": ["-O0", "-g", "-fsanitize=address"],
    "ldflags": ["-fsanitize=address"]
  }
}
# Sanitizer 빌드로 테스트 (메모리 버그 잡기) ★
npx node-gyp rebuild --debug
LD_PRELOAD=$(gcc -print-file-name=libasan.so) \
  node --expose-gc test/stress.js

# ⚠ -march=native 는 넣지 마세요 — 다른 기계에서 즉사합니다
#   배포용은 보수적인 기준으로, 필요하면 런타임 분기(⚡ e06)

# 반복 호출 누수 검사
node --expose-gc -e "
const addon = require('./build/Release/addon.node')
const before = process.memoryUsage().rss
for (let i = 0; i < 1e5; i++) addon.smooth(new Float64Array(1000), 3)
global.gc()
console.log('증가', (process.memoryUsage().rss - before) / 1e6, 'MB')
"
가시성을 숨기세요 — -fvisibility=hidden 을 안 쓰면 애드온의 내부 심볼이 전부 노출되어, 같은 프로세스의 다른 애드온과 충돌할 수 있습니다. 두 애드온이 같은 정적 라이브러리를 다른 버전으로 링크한 경우 특히 위험합니다.
정리 — 단순하면 node-gyp, 외부 라이브러리를 붙이면 cmake-js 입니다. 반드시 확인할 것 셋 — 예외 설정(-fno-exceptions 끄기), 맥 배포 타깃, 리눅스 glibc·musl 구분. 그리고 가시성은 hidden 으로 두세요.
NODE 04

값 · 객체 · 배열 변환

JS 값과 C++ 값 사이를 오갑니다. 모든 변환에는 비용이 있고, 그 비용이 애드온 성능의 대부분을 정합니다.

기본 타입

Napi::Env env = info.Env();

// JS → C++
double d   = info[0].As<Napi::Number>().DoubleValue();
int32_t i  = info[0].As<Napi::Number>().Int32Value();
int64_t l  = info[0].As<Napi::Number>().Int64Value();
bool b     = info[0].As<Napi::Boolean>().Value();
std::string s = info[0].As<Napi::String>().Utf8Value();   // ★ 복사 발생
std::u16string w = info[0].As<Napi::String>().Utf16Value();

// BigInt (64비트 정수) — 정밀도 손실 없이
bool lossless;
int64_t big = info[0].As<Napi::BigInt>().Int64Value(&lossless);

// C++ → JS
Napi::Number::New(env, 3.14);
Napi::String::New(env, "안녕");         // UTF-8 → UTF-16 변환 ★
Napi::Boolean::New(env, true);
Napi::BigInt::New(env, int64_t{1} << 40);
env.Null();  env.Undefined();

// 타입 검사 — 반드시 하세요
info[0].IsNumber()  IsString()  IsBoolean()  IsArray()
info[0].IsBuffer()  IsTypedArray()  IsFunction()  IsObject()
info[0].IsNull()    IsUndefined()  IsBigInt()  IsPromise()
JS 숫자는 전부 double 입니다 — 정수처럼 보여도 내부는 배정도 실수라 2^53 을 넘는 정수는 정밀도를 잃습니다. 큰 정수를 주고받아야 하면 BigInt 를 쓰거나 문자열로 넘기세요.

객체와 배열

// 객체 읽기
Napi::Object opts = info[0].As<Napi::Object>();
if (opts.Has("window")) {
    int w = opts.Get("window").As<Napi::Number>().Int32Value();
}
// 안전한 헬퍼
int GetInt(const Napi::Object& o, const char* key, int def) {
    if (!o.Has(key)) return def;
    auto v = o.Get(key);
    if (!v.IsNumber()) return def;
    return v.As<Napi::Number>().Int32Value();
}

// 객체 만들기
Napi::Object r = Napi::Object::New(env);
r.Set("count", Napi::Number::New(env, 42));
r.Set("name",  Napi::String::New(env, "결과"));
r.Set("ok",    Napi::Boolean::New(env, true));

// 배열 — ⚠ 원소마다 경계를 넘습니다 (느립니다)
Napi::Array arr = info[0].As<Napi::Array>();
std::vector<double> v;
v.reserve(arr.Length());
for (uint32_t i = 0; i < arr.Length(); ++i)
    v.push_back(arr.Get(i).As<Napi::Number>().DoubleValue());
//   ★ 10만 개면 10만 번의 개별 변환 — TypedArray 를 쓰세요(j05)

Napi::Array out = Napi::Array::New(env, n);
for (uint32_t i = 0; i < n; ++i)
    out.Set(i, Napi::Number::New(env, data[i]));

이렇게도 쓸 수 있다 — 인터페이스 설계 🟡 중급

옵션 객체 패턴

// JS 관용구 — 옵션은 객체 하나로 받습니다
struct Options {
    int  window = 3;
    bool reflect = false;
    int  threads = 1;
};

Options ParseOptions(const Napi::Value& v) {
    Options o;
    if (!v.IsObject()) return o;
    auto obj = v.As<Napi::Object>();
    if (obj.Has("window"))  o.window  = obj.Get("window").ToNumber().Int32Value();
    if (obj.Has("reflect")) o.reflect = obj.Get("reflect").ToBoolean().Value();
    if (obj.Has("threads")) o.threads = obj.Get("threads").ToNumber().Int32Value();
    return o;
}
//   ★ ToNumber()/ToBoolean() 은 JS 의 암묵 변환을 따릅니다.
//     엄격하게 하려면 IsNumber() 로 검사하고 TypeError 를 던지세요.

// 결과도 객체로 — 필드가 늘어도 호환이 유지됩니다
Napi::Object MakeResult(Napi::Env env, const Stats& s) {
    auto r = Napi::Object::New(env);
    r.Set("mean",   s.mean);
    r.Set("stddev", s.stddev);
    r.Set("count",  static_cast<double>(s.count));
    return r;
}
// JS: const { mean, stddev } = addon.analyze(data)

에러 던지기

// JS 표준 에러 타입으로 던지세요
Napi::TypeError::New(env, "Float64Array 가 필요합니다")
    .ThrowAsJavaScriptException();
Napi::RangeError::New(env, "window 는 1 이상이어야 합니다")
    .ThrowAsJavaScriptException();
Napi::Error::New(env, "내부 오류").ThrowAsJavaScriptException();

// C++ 예외를 쓰려면 (NAPI_CPP_EXCEPTIONS 정의 시)
throw Napi::TypeError::New(env, "잘못된 인자");
//   ★ 이 경우 노드가 알아서 JS 예외로 변환합니다

// 에러에 부가 정보 붙이기
auto err = Napi::Error::New(env, "처리 실패");
err.Set("code", Napi::String::New(env, "E_PARSE"));
err.Set("line", Napi::Number::New(env, 12));
err.ThrowAsJavaScriptException();
// JS: try { ... } catch (e) { console.log(e.code, e.line) }

// ⚠ ThrowAsJavaScriptException() 후에는 반드시 즉시 반환하세요
//   JS 예외 상태가 설정된 채로 다른 N-API 호출을 하면 동작이 깨집니다 ★
if (bad) {
    Napi::TypeError::New(env, "...").ThrowAsJavaScriptException();
    return env.Null();          // ★ 이 return 이 없으면 위험
}

실무에선 이렇게 — 변환 비용을 줄이는 설계 🔴 실무

경계를 넘는 횟수가 전부입니다

// ❌ 원소마다 호출 — 최악
for (const row of rows) result.push(addon.processOne(row))

// ❌ 일반 Array 를 넘기기 — 원소마다 변환 ★
addon.process(rows.map(r => r.value))       // 10만 개면 10만 번 변환

// ✅ TypedArray 로 한 번에 (j05)
const values = Float64Array.from(rows, r => r.value)
const out = addon.process(values)            // 경계 1회 · 복사 0

// ✅ 상태가 필요하면 객체로 유지
const engine = new addon.Engine({ window: 5 })
for (const chunk of stream) engine.feed(chunk)
const result = engine.result()

// ✅ 구조체 배열은 '열 단위'로 분해해 넘기기 (SoA) ★
//    { x, y, z } 객체 10만 개  →  Float64Array 세 개
addon.transform(xs, ys, zs, n)
//    JS 객체 배열을 C++ 로 옮기는 비용이 통째로 사라집니다

문자열 다루기

// JS 문자열은 UTF-16 입니다. Utf8Value() 는 변환 + 할당이 일어납니다 ★
std::string s = info[0].As<Napi::String>().Utf8Value();

// 자주 부르는 경로라면
//   ① 문자열 대신 숫자 코드로 (enum) ★
addon.setMode(0)          // "nearest" 대신
//   ② 한 번만 변환해 캐시
//   ③ 대량 텍스트는 Buffer 로 (UTF-8 바이트 그대로)
Napi::Buffer<uint8_t> buf = info[0].As<Napi::Buffer<uint8_t>>();
std::string_view sv(reinterpret_cast<const char*>(buf.Data()), buf.Length());
//   ★ 복사 없이 바로 파싱할 수 있습니다

// 한글 주의 — JS 는 UTF-16, C++ std::string 은 UTF-8 바이트
// "안녕".length === 2 (JS)   vs   s.size() == 6 (C++)
// 길이 제한·자르기는 JS 쪽에서 처리하는 편이 안전합니다
측정으로 확인하세요 — 빈 함수 호출과 실제 함수의 시간을 비교하면 변환 비용이 차지하는 비율이 나옵니다. 30% 를 넘으면 인터페이스를 다시 설계해야 한다는 신호입니다.

한 단계 더 — V8 값의 수명과 핸들 스코프 🧬 전문가

Napi::Value 는 GC 대상입니다

// Napi::Value 는 V8 힙의 객체를 가리키는 '핸들' 입니다.
// 함수가 끝나면 그 스코프의 핸들은 전부 무효가 됩니다 ★

// ❌ 함수 밖으로 들고 나가면 안 됩니다
static Napi::Value g_cached;         // 크래시의 원인

// ✅ 오래 보관하려면 Reference 를 쓰세요
class Holder {
    Napi::ObjectReference ref_;
public:
    void Save(Napi::Object o) {
        ref_ = Napi::Persistent(o);   // 참조 카운트 +1 → GC 되지 않음
    }
    ~Holder() { ref_.Reset(); }       // 반드시 해제 (안 하면 누수) ★
};

// 루프에서 값을 많이 만들면 핸들이 쌓입니다
for (int i = 0; i < 1'000'000; ++i) {
    Napi::HandleScope scope(env);     // ★ 반복마다 정리
    auto v = Napi::Number::New(env, i);
    ...
}
//   없으면 함수가 끝날 때까지 100만 개 핸들이 유지됩니다 (메모리 급증)

GC 와 외부 메모리

// C++ 가 큰 메모리를 잡고 있으면 V8 이 그걸 모릅니다.
// → GC 가 늦게 돌아 실제 메모리가 계속 늘어납니다 ★
Napi::MemoryManagement::AdjustExternalMemory(env, +size_bytes);
// 해제할 때
Napi::MemoryManagement::AdjustExternalMemory(env, -size_bytes);
//   이걸 알려 주면 V8 이 GC 시점을 조절합니다

// Buffer 는 자동으로 처리됩니다 (외부 메모리로 등록됨)

// 객체가 GC 될 때 C++ 자원 정리하기
Napi::Object obj = Napi::Object::New(env);
obj.AddFinalizer([](Napi::Env, MyData* d){ delete d; }, data);
//   ⚠ Finalizer 는 '언제 불릴지 모릅니다' (GC 시점)
//     확정적 해제가 필요하면 명시적 close() 를 제공하세요 ★

// 진단
node --expose-gc --trace-gc app.js
process.memoryUsage()      // heapUsed vs external ★ external 이 네이티브 메모리
v8.getHeapStatistics()
“메모리가 계속 늘어난다”의 1순위 원인 — Persistent 참조를 Reset 하지 않았거나, AdjustExternalMemory 로 알려 주지 않아 GC 가 압박을 못 느끼는 경우입니다. process.memoryUsage().external 을 모니터링하세요.
정리 — JS↔C++ 변환은 모두 비용입니다. 일반 Array 는 원소마다 변환되므로 큰 데이터에는 쓰지 마세요 — TypedArray 로 한 번에 넘기는 것이 정답입니다(다음 섹션). 옵션은 객체로, 에러는 표준 에러 타입으로 던지고 즉시 반환하세요.
JS 값은 V8 힙에 있습니다 — C++ 는 핸들로만 만집니다 V8 힙 (JS 객체) GC 가 움직일 수 있습니다 Napi::Value 핸들 Env 와 스코프 안에서만 유효 그래서 주소를 그대로 보관하면 안 됩니다 오래 쥐려면 Napi::Reference 로 자바스크립트 C++ 경계를 넘는 비용 number · boolean double · int32_t · bool 없음 — 값 그대로입니다 string std::string UTF-16 → UTF-8 재인코딩 object · Array struct · std::vector 프로퍼티를 하나씩 읽어 복사 Buffer · TypedArray uint8_t* · span 복사 없음 — 같은 메모리를 봅니다 규칙은 하나입니다 — 경계는 드물게, 대신 크게 넘으세요. 원소마다 호출하는 API 는 그 자체가 병목이 됩니다
느린 이유는 대개 C++ 코드가 아니라 경계를 넘나든 횟수에 있습니다.
JS 값은 V8 힙에 있습니다 — C++ 는 핸들로만 만집니다 V8 힙 (JS 객체) GC 가 움직일 수 있습니다 Napi::Value 핸들 Env 와 스코프 안에서만 유효 그래서 주소를 그대로 보관하면 안 됩니다 오래 쥐려면 Napi::Reference 로 자바스크립트 C++ 경계를 넘는 비용 number · boolean double · int32_t · bool 없음 — 값 그대로입니다 string std::string UTF-16 → UTF-8 재인코딩 object · Array struct · std::vector 프로퍼티를 하나씩 읽어 복사 Buffer · TypedArray uint8_t* · span 복사 없음 — 같은 메모리를 봅니다 규칙은 하나입니다 — 경계는 드물게, 대신 크게 넘으세요. 원소마다 호출하는 API 는 그 자체가 병목이 됩니다
느린 이유는 대개 C++ 코드가 아니라 경계를 넘나든 횟수에 있습니다.
NODE 05

Buffer · TypedArray — 복사 없이

이미지 한 장이 수십 MB 입니다. 복사하면 그 자체가 병목이 됩니다. Node 애드온이 진짜 힘을 내는 지점입니다.

받아서 그대로 읽기

Napi::Value Smooth(const Napi::CallbackInfo& info) {
    Napi::Env env = info.Env();

    if (!info[0].IsTypedArray() ||        // ★ 종류까지 확인 (Float32Array 등이면 엉뚱한 메모리를 읽음)
        info[0].As<Napi::TypedArray>().TypedArrayType() != napi_float64_array) {
        Napi::TypeError::New(env, "Float64Array 가 필요합니다")
            .ThrowAsJavaScriptException();
        return env.Null();
    }
    auto input = info[0].As<Napi::Float64Array>();

    const double* p = input.Data();      // ★ 포인터를 그대로 얻습니다
    const std::size_t n = input.ElementLength();
    //   복사 0 — JS 힙의 메모리를 직접 읽습니다

    // 결과 배열을 만들어 돌려주기
    auto output = Napi::Float64Array::New(env, n);
    double* q = output.Data();

    core::smooth(p, n, q, window);       // 순수 C++ 로직
    return output;
}
const a = new Float64Array(1_000_000)
const out = addon.smooth(a, 3)          // 8MB 를 복사 없이 처리

Buffer 와 ArrayBuffer

// Buffer — Node 고유 타입 (Uint8Array 의 하위 클래스)
auto buf = info[0].As<Napi::Buffer<uint8_t>>();
uint8_t* data = buf.Data();
std::size_t len = buf.Length();

// ArrayBuffer 를 직접
auto ab = info[0].As<Napi::ArrayBuffer>();
void* p = ab.Data();  std::size_t n = ab.ByteLength();

// ⚠ TypedArray 는 ArrayBuffer 의 '일부'일 수 있습니다 ★
auto ta = info[0].As<Napi::TypedArray>();
ta.ByteOffset();          // 시작 오프셋 — 0이 아닐 수 있습니다!
ta.ElementLength();
auto base = static_cast<uint8_t*>(ta.ArrayBuffer().Data()) + ta.ByteOffset();
//   const view = new Float64Array(bigBuffer, 800, 100) 같은 경우

// 만들어 돌려주기 — 세 가지 방법
// ① 새로 할당 (Node 가 소유)
auto out = Napi::Buffer<uint8_t>::New(env, size);

// ② 기존 데이터를 복사
auto out = Napi::Buffer<uint8_t>::Copy(env, src, size);

// ③ C++ 메모리를 소유권째 넘기기 ★ 복사 0
auto* raw = new std::vector<uint8_t>(std::move(result));
auto out = Napi::Buffer<uint8_t>::New(
    env, raw->data(), raw->size(),
    [](Napi::Env, uint8_t*, std::vector<uint8_t>* v){ delete v; },  // finalizer
    raw);

이렇게도 쓸 수 있다 — 타입별 처리와 검증 🟡 중급

여러 dtype 을 받기

Napi::Value Process(const Napi::CallbackInfo& info) {
    Napi::Env env = info.Env();
    auto ta = info[0].As<Napi::TypedArray>();

    switch (ta.TypedArrayType()) {
        case napi_float64_array: {
            auto a = ta.As<Napi::Float64Array>();
            core::run(a.Data(), a.ElementLength());
            break;
        }
        case napi_float32_array: {
            auto a = ta.As<Napi::Float32Array>();
            core::run(a.Data(), a.ElementLength());
            break;
        }
        case napi_uint8_array: { ... }
        default:
            Napi::TypeError::New(env, "float32 · float64 · uint8 만 지원합니다")
                .ThrowAsJavaScriptException();
            return env.Null();
    }
    return env.Undefined();
}

// 템플릿으로 줄이기
template<class T, class Arr>
void RunTyped(const Napi::Value& v) {
    auto a = v.As<Arr>();
    core::run<T>(a.Data(), a.ElementLength());
}

JS 쪽에서 정규화하기 ★

// C++ 쪽을 단순하게 유지하려면 JS 에서 통일합니다
function toF64(data) {
  if (data instanceof Float64Array) return data
  if (ArrayBuffer.isView(data))
    return Float64Array.from(data)          // 다른 TypedArray → 변환
  if (Array.isArray(data)) return Float64Array.from(data)
  throw new TypeError('배열 또는 TypedArray 가 필요합니다')
}

export function smooth(data, window = 3) {
  const a = toF64(data)
  if (a.length === 0) return new Float64Array(0)      // ★ 빈 입력 처리
  if (!Number.isInteger(window) || window < 1 || window > a.length)
    throw new RangeError(`window 는 1~${a.length} 여야 합니다 (받음: ${window})`)
  return native.smooth(a, window)
}

// 출력 버퍼를 재사용하는 API 도 함께 제공 (할당 없음) ★
export function smoothInto(data, out, window = 3) {
  if (out.length !== data.length) throw new RangeError('크기 불일치')
  native.smoothInto(data, out, window)
  return out
}
// 사용: const out = new Float64Array(n); for (...) smoothInto(chunk, out)

실무에선 이렇게 — 이미지·스트림 처리 🔴 실무

제자리 수정과 소유권

// 이미지 필터 — 입력 Buffer 를 직접 수정 (할당 0)
Napi::Value Grayscale(const Napi::CallbackInfo& info) {
    auto buf = info[0].As<Napi::Buffer<uint8_t>>();
    const int w = info[1].ToNumber().Int32Value();
    const int h = info[2].ToNumber().Int32Value();

    if (buf.Length() < static_cast<std::size_t>(w) * h * 4) {
        Napi::RangeError::New(info.Env(), "버퍼가 너무 작습니다")
            .ThrowAsJavaScriptException();
        return info.Env().Null();
    }

    uint8_t* p = buf.Data();
    for (int i = 0; i < w * h; ++i) {
        uint8_t g = static_cast<uint8_t>(
            0.299 * p[i*4] + 0.587 * p[i*4+1] + 0.114 * p[i*4+2]);
        p[i*4] = p[i*4+1] = p[i*4+2] = g;
    }
    return info.Env().Undefined();     // 입력을 직접 바꿨습니다
}
//   ★ 문서에 "입력 버퍼를 수정합니다" 를 반드시 명시하세요
버퍼 길이 검증은 필수입니다 — JS 에서 잘못된 크기를 넘기면 버퍼 오버런으로 프로세스가 죽거나 메모리가 손상됩니다. width * height * channels 를 항상 계산해 비교하세요. 곱셈 오버플로도 조심해야 합니다.

스트림과 청크 처리

import { Transform } from 'node:stream'

// 애드온을 Transform 스트림으로 감싸기
class Resampler extends Transform {
  #engine
  constructor(opts) {
    super(opts)
    this.#engine = new addon.Engine(opts)
  }
  _transform(chunk, _enc, cb) {
    try {
      cb(null, this.#engine.process(chunk))   // Buffer 를 그대로
    } catch (e) { cb(e) }
  }
  _flush(cb) {
    try { cb(null, this.#engine.finish()) } catch (e) { cb(e) }
  }
  _destroy(err, cb) { this.#engine.close(); cb(err) }   // ★ 자원 해제
}

// 사용
createReadStream('in.raw')
  .pipe(new Resampler({ rate: 48000 }))
  .pipe(createWriteStream('out.raw'))
스트림으로 감싸면 좋은 이유 — 메모리를 일정하게 유지하고, 역압(backpressure)이 자동으로 처리됩니다. 큰 파일을 통째로 Buffer 에 올리면 2GB 한계에도 걸립니다.

한 단계 더 — GC 와 버퍼 수명 🧬 전문가

포인터를 보관하면 안 되는 이유

// ❌ Buffer 의 포인터를 멤버로 저장
class Engine {
    uint8_t* data_;          // ★ JS 가 버퍼를 놓으면 GC 됩니다
public:
    void SetSource(const Napi::CallbackInfo& info) {
        data_ = info[0].As<Napi::Buffer<uint8_t>>().Data();   // 위험
    }
};

// ✅ ① 참조를 유지해 GC 를 막기
class Engine {
    Napi::Reference<Napi::Buffer<uint8_t>> ref_;
    uint8_t* data_ = nullptr;
public:
    void SetSource(const Napi::CallbackInfo& info) {
        auto buf = info[0].As<Napi::Buffer<uint8_t>>();
        ref_ = Napi::Persistent(buf);          // ★ 살려 둡니다
        data_ = buf.Data();
    }
    ~Engine() { ref_.Reset(); }
};

// ✅ ② 또는 C++ 쪽으로 복사해 소유 (안전하지만 복사 비용)
std::vector<uint8_t> owned_(buf.Data(), buf.Data() + buf.Length());

// ⚠ 비동기 작업 중에도 마찬가지입니다 — AsyncWorker 가 도는 동안
//   JS 쪽에서 버퍼를 놓으면 GC 될 수 있습니다.
//   → Worker 안에서 Reference 를 유지하세요 (j06)

외부 메모리와 GC 압박

// Buffer::New 로 만든 것은 V8 이 '외부 메모리'로 인식합니다.
// 하지만 C++ 가 따로 잡은 큰 메모리는 모릅니다 ★
class BigEngine {
    std::vector<double> cache_;   // 100MB
    Napi::Env env_;
public:
    void Grow(std::size_t bytes) {
        cache_.resize(...);
        Napi::MemoryManagement::AdjustExternalMemory(env_, +bytes);  // 알려주기
    }
    ~BigEngine() {
        Napi::MemoryManagement::AdjustExternalMemory(env_, -total_);
    }
};
//   이걸 안 하면 RSS 는 계속 느는데 V8 은 GC 를 안 돌립니다

// 진단
node --expose-gc -e "
const addon = require('./build/Release/addon.node')
const before = process.memoryUsage()
for (let i=0;i<1000;i++) { const e = new addon.Engine(); e.load(bigBuf) }
global.gc()
const after = process.memoryUsage()
console.log('heap', (after.heapUsed-before.heapUsed)/1e6,
            'external', (after.external-before.external)/1e6,
            'rss', (after.rss-before.rss)/1e6)
"
//   rss 만 늘고 external 이 그대로면 → AdjustExternalMemory 누락 ★
메모리 문제의 진단 순서 — process.memoryUsage() 에서 heapUsed 는 그대로인데 rss 가 늘면 네이티브 쪽 문제입니다. --expose-gc 로 강제 GC 후에도 줄지 않으면 해제 누락, 줄면 GC 압박 신호 누락입니다.
정리 — Float64Array·Buffer 는 .Data() 로 포인터를 그대로 얻을 수 있어 복사가 0입니다. 길이 검증과 ByteOffset 확인을 빠뜨리지 마세요. 포인터를 보관해야 하면 Napi::Persistent 로 GC 를 막아야 합니다.
Buffer — 복사할 것인가, 그대로 가리킬 것인가 복사 — std::vector<uint8_t> 로 받기 같은 내용이 메모리에 두 벌 · 100MB 면 복사만으로 수십 밀리초 대신 이후에 마음대로 다뤄도 안전합니다 — 비동기 작업에 넘겨도 됩니다 그대로 가리키기 — Data() 포인터 사용 비용이 사실상 0 입니다 — 큰 데이터를 다룰 때는 이쪽이어야 합니다 단 그 메모리는 JS 소유입니다. 함수가 끝난 뒤에도 들고 있으면 안 됩니다 비동기로 넘길 때가 가장 위험합니다 워커가 도는 동안 JS 쪽에서 수거되면 이미 없는 메모리를 읽습니다 — 참조를 잡아 두거나 복사하세요 돌려줄 때도 같습니다 — 누가 그 메모리를 해제할지 정해 두고, 정리 콜백을 함께 등록하세요
네이티브 애드온의 크래시는 대부분 수명이 끝난 메모리를 붙잡고 있어서 납니다.
NODE 06

비동기 — AsyncWorker

무거운 계산을 백그라운드 스레드에서 돌려 이벤트 루프를 살려 둡니다. Node 애드온에서 가장 중요한 패턴입니다.

무거운 계산을 동기로 부르면 서버 전체가 멈춥니다 ① 동기 호출 — 이벤트 루프를 점유 이벤트 루프 C++ 계산 500ms — 루프 정지 그제서야 다음 요청 이 500ms 동안 들어온 모든 요청이 줄을 섭니다 — 타이머와 소켓 처리까지 전부 멈춥니다 ② AsyncWorker — libuv 스레드풀로 넘기기 이벤트 루프 다른 요청을 계속 처리 완료 콜백 · Promise 해결 워커 스레드에서 C++ 계산 500ms 워커 스레드 안에서는 JS 값을 만지면 안 됩니다 — Execute 에서는 C++ 데이터만, OnOK 에서만 JS 로 돌려주세요.
기본 스레드풀은 4개입니다 — 무거운 작업이 많다면 UV_THREADPOOL_SIZE 를 함께 조정하세요.

AsyncWorker 기본형

class SmoothWorker : public Napi::AsyncWorker {
public:
    SmoothWorker(Napi::Function& cb, Napi::Float64Array input, int window)
        : Napi::AsyncWorker(cb),
          window_(window),
          n_(input.ElementLength()),
          ref_(Napi::Persistent(input))          // ★ GC 방지
    {
        src_ = input.Data();
        out_.resize(n_);
    }

    // ⚠ 워커 스레드에서 실행 — 여기서 JS 를 만지면 절대 안 됩니다 ★
    void Execute() override {
        core::smooth(src_, n_, out_.data(), window_);
    }

    // 메인 스레드로 돌아와서 실행 — 여기서만 JS 를 다룹니다
    void OnOK() override {
        Napi::HandleScope scope(Env());
        auto arr = Napi::Float64Array::New(Env(), n_);
        std::copy(out_.begin(), out_.end(), arr.Data());
        Callback().Call({ Env().Null(), arr });   // (err, result)
    }

    void OnError(const Napi::Error& e) override {
        Napi::HandleScope scope(Env());
        Callback().Call({ e.Value(), Env().Undefined() });
    }

private:
    const double* src_; int window_; std::size_t n_;
    std::vector<double> out_;
    Napi::Reference<Napi::Float64Array> ref_;
};

Napi::Value SmoothAsync(const Napi::CallbackInfo& info) {
    auto cb = info[1].As<Napi::Function>();
    auto* w = new SmoothWorker(cb, info[0].As<Napi::Float64Array>(),
                               info[2].ToNumber().Int32Value());
    w->Queue();                    // ★ 스레드 풀에 넣습니다 (delete 는 자동)
    return info.Env().Undefined();
}

규칙 세 가지 ★

  • Execute() 안에서는 JS 를 만지지 마세요 — Napi:: 로 시작하는 것, Env(), 콜백 호출 전부 금지입니다. 순수 C++ 데이터만 다뤄야 합니다
  • 필요한 데이터는 생성자에서 미리 복사·참조해 두세요. Execute() 시점에는 JS 값에 접근할 수 없습니다
  • Worker 는 new 로 만들고 Queue() 하면 자동 삭제됩니다. 직접 delete 하지 마세요
// 에러 전달 — Execute 안에서 예외를 던지면 OnError 로 갑니다
void Execute() override {
    if (window_ > n_) {
        SetError("window 가 데이터 길이보다 큽니다");   // ★ 이 방법
        return;
    }
    try {
        core::smooth(...);
    } catch (const std::exception& e) {
        SetError(e.what());          // C++ 예외를 문자열로
    }
}
Execute() 에서 예외가 밖으로 나가면 프로세스가 죽습니다 — 반드시 try/catch 로 감싸고 SetError() 로 전달하세요. 워커 스레드에는 JS 예외 처리기가 없습니다.

이렇게도 쓸 수 있다 — 진행률과 취소 🟡 중급

AsyncProgressWorker

class ProgressWorker : public Napi::AsyncProgressWorker<double> {
public:
    ProgressWorker(Napi::Function& cb, Napi::Function& onProgress, ...)
        : AsyncProgressWorker(cb), prog_(Napi::Persistent(onProgress)) {}

    void Execute(const ExecutionProgress& p) override {
        const std::size_t step = std::max<std::size_t>(n_ / 100, 1);
        for (std::size_t i = 0; i < n_; ++i) {
            if (cancel_.load(std::memory_order_relaxed)) {
                SetError("취소됨");
                return;
            }
            work(i);
            if (i % step == 0) {
                double frac = double(i) / n_;
                p.Send(&frac, 1);          // ★ 메인 스레드로 신호
            }
        }
    }

    // 메인 스레드에서 실행 — 여기서 JS 콜백을 부릅니다
    void OnProgress(const double* data, std::size_t /*count*/) override {
        Napi::HandleScope scope(Env());
        prog_.Call({ Napi::Number::New(Env(), *data) });
    }

    void Cancel() { cancel_.store(true); }

private:
    Napi::FunctionReference prog_;
    std::atomic<bool> cancel_{false};
    std::size_t n_ = 0;
};

JS 쪽 인터페이스

// 취소 가능한 작업 — AbortSignal 과 연결 ★
export function smoothAsync(data, { window = 3, signal, onProgress } = {}) {
  return new Promise((resolve, reject) => {
    const task = native.smoothAsync(
      toF64(data), window,
      (err, result) => err ? reject(err) : resolve(result),
      onProgress
    )
    if (signal) {
      if (signal.aborted) { task.cancel(); return reject(signal.reason) }
      signal.addEventListener('abort', () => task.cancel(), { once: true })
    }
  })
}

// 사용
const ac = new AbortController()
setTimeout(() => ac.abort(), 5000)          // 5초 타임아웃
const out = await smoothAsync(data, {
  signal: ac.signal,
  onProgress: (f) => bar.update(f * 100),
})
AbortSignal 을 지원하세요 — Node 생태계의 표준 취소 방식입니다. fetch·타이머와 같은 방식으로 다룰 수 있어 사용자가 배울 것이 없어집니다.

실무에선 이렇게 — 스레드 풀 관리 🔴 실무

libuv 풀 고갈에 주의 ★

AsyncWorker 는 libuv 스레드 풀을 씁니다 (기본 크기 4).
같은 풀을 파일 I/O · DNS · zlib · crypto 도 씁니다.

★ 그래서 생기는 일
  · 긴 계산 4개가 동시에 돌면 파일 읽기가 전부 대기합니다
  · 서버 응답이 갑자기 느려지는데 CPU 는 놀고 있는 상태

대책
  ① UV_THREADPOOL_SIZE 를 늘리기 (프로세스 시작 전에 설정해야 함)
       UV_THREADPOOL_SIZE=16 node app.js
     ⚠ 프로세스 시작 후 변경은 반영되지 않습니다
  ② 자체 스레드 풀을 쓰고 ThreadSafeFunction 으로 결과 전달 (j07) ★
     → libuv 풀에 영향을 주지 않습니다
  ③ 아주 무거운 작업은 worker_threads 나 별도 프로세스로

측정
  · 이벤트 루프 지연 (monitorEventLoopDelay)
  · 큐 대기 시간을 직접 기록 (Queue 시각 vs Execute 시작 시각)

자체 스레드 풀 쓰기

// libuv 풀을 피하고 싶을 때 — 직접 스레드에서 돌리고 TSFN 으로 알림
#include <thread>

Napi::Value RunOwnThread(const Napi::CallbackInfo& info) {
    auto env = info.Env();
    auto deferred = Napi::Promise::Deferred::New(env);
    auto input = info[0].As<Napi::Float64Array>();

    // 데이터를 C++ 로 복사 (스레드가 안전하게 소유)
    auto data = std::make_shared<std::vector<double>>(
        input.Data(), input.Data() + input.ElementLength());

    auto tsfn = Napi::ThreadSafeFunction::New(
        env, Napi::Function::New(env, [](const Napi::CallbackInfo&){}),
        "worker", 0, 1);

    std::thread([data, tsfn, deferred]() mutable {
        auto result = std::make_shared<std::vector<double>>(core::run(*data));
        tsfn.BlockingCall([deferred, result](Napi::Env env, Napi::Function) {
            auto arr = Napi::Float64Array::New(env, result->size());
            std::copy(result->begin(), result->end(), arr.Data());
            deferred.Resolve(arr);            // ★ 메인 스레드에서 resolve
        });
        tsfn.Release();
    }).detach();

    return deferred.Promise();
}
자체 스레드를 쓸 때의 대가 — 스레드 수를 직접 관리해야 하고, 프로세스 종료 시 정리도 직접 해야 합니다. 대부분은 AsyncWorker + UV_THREADPOOL_SIZE 조정으로 충분합니다.

한 단계 더 — 비동기 비용과 배치 🧬 전문가

언제 비동기가 손해인가

AsyncWorker 한 번의 오버헤드
  ① Worker 객체 할당
  ② libuv 큐 삽입 + 스레드 깨우기
  ③ 작업 실행
  ④ 메인 스레드로 완료 통지 (이벤트 루프 한 바퀴)
  ⑤ HandleScope · 결과 변환 · 콜백 호출
  합계 대략 수십~수백 μs ★

  → 계산이 100μs 미만이면 동기가 더 빠릅니다.
    게다가 이벤트 루프도 그 정도는 막아도 문제없습니다.

판단 기준
  < 0.1ms  : 동기 (비동기 오버헤드가 더 큼)
  0.1~1ms  : 동기여도 대개 괜찮음 (동시 요청 수에 따라)
  > 1ms    : 비동기 ★
  > 100ms  : 비동기 + 진행률 + 취소

★ 둘 다 제공하는 것이 실무 표준입니다
  addon.smoothSync(a, 3)      // 작은 데이터 · CLI 도구
  await addon.smooth(a, 3)    // 큰 데이터 · 서버

배치로 오버헤드 줄이기

// ❌ 요청마다 Worker 하나 — 1000개면 오버헤드도 1000배
for (const chunk of chunks) await addon.process(chunk)

// ✅ 한 Worker 가 여러 청크를 처리
class BatchWorker : public Napi::AsyncWorker {
    std::vector<std::vector<double>> inputs_;
    std::vector<std::vector<double>> outputs_;
public:
    void Execute() override {
        outputs_.resize(inputs_.size());
        // 내부에서 병렬화까지 (⚡ e07)
        std::transform(std::execution::par,
                       inputs_.begin(), inputs_.end(), outputs_.begin(),
                       [](const auto& in){ return core::run(in); });
    }
    void OnOK() override {
        Napi::HandleScope scope(Env());
        auto arr = Napi::Array::New(Env(), outputs_.size());
        for (std::size_t i = 0; i < outputs_.size(); ++i) {
            auto ta = Napi::Float64Array::New(Env(), outputs_[i].size());
            std::copy(outputs_[i].begin(), outputs_[i].end(), ta.Data());
            arr.Set(i, ta);
        }
        Callback().Call({ Env().Null(), arr });
    }
};
// JS: const results = await addon.processBatch(chunks)   // 경계 1회 ★
이 가이드 전체를 관통하는 원칙 — 파이썬이든 Node 든 경계를 넘는 횟수를 데이터 크기와 분리하는 것이 네이티브 확장 설계의 핵심입니다.
정리 — 1ms 이상 걸리는 함수는 AsyncWorker 로 만드세요. 규칙은 하나 — Execute() 안에서 JS 를 만지지 않기. 입력은 생성자에서 Persistent 로 잡아 두고, 결과는 OnOK() 에서 만듭니다. 그리고 동기·비동기 둘 다 제공하는 것이 표준입니다.
NODE 07

Promise 와 ThreadSafeFunction

콜백 대신 await 로 쓰게 만들고, C++ 스레드에서 JS 를 안전하게 부릅니다.

Promise 반환하기

class PromiseWorker : public Napi::AsyncWorker {
public:
    PromiseWorker(Napi::Env env, Napi::Float64Array input, int window)
        : Napi::AsyncWorker(env),                 // ★ 콜백 없는 생성자
          deferred_(Napi::Promise::Deferred::New(env)),
          ref_(Napi::Persistent(input)),
          window_(window), n_(input.ElementLength())
    {
        src_ = input.Data();
        out_.resize(n_);
    }

    Napi::Promise Promise() { return deferred_.Promise(); }

    void Execute() override { core::smooth(src_, n_, out_.data(), window_); }

    void OnOK() override {
        Napi::HandleScope scope(Env());
        auto arr = Napi::Float64Array::New(Env(), n_);
        std::copy(out_.begin(), out_.end(), arr.Data());
        deferred_.Resolve(arr);                    // ★ 성공
    }
    void OnError(const Napi::Error& e) override {
        Napi::HandleScope scope(Env());
        deferred_.Reject(e.Value());               // ★ 실패
    }

private:
    Napi::Promise::Deferred deferred_;
    Napi::Reference<Napi::Float64Array> ref_;
    const double* src_; int window_; std::size_t n_;
    std::vector<double> out_;
};

Napi::Value SmoothAsync(const Napi::CallbackInfo& info) {
    auto* w = new PromiseWorker(info.Env(),
                                info[0].As<Napi::Float64Array>(),
                                info[1].ToNumber().Int32Value());
    auto p = w->Promise();
    w->Queue();
    return p;
}
const out = await addon.smoothAsync(data, 3)     // ★ 자연스러운 JS

ThreadSafeFunction — 다른 스레드에서 JS 부르기

// C++ 가 만든 스레드에서는 JS 를 직접 부를 수 없습니다.
// TSFN 이 '메인 스레드로 안전하게 전달' 해 줍니다 ★

Napi::Value Watch(const Napi::CallbackInfo& info) {
    auto env = info.Env();
    auto jsCallback = info[0].As<Napi::Function>();

    auto tsfn = Napi::ThreadSafeFunction::New(
        env,
        jsCallback,          // 부를 JS 함수
        "watcher",           // 리소스 이름 (프로파일러 표시용)
        0,                   // 큐 최대 크기 (0 = 무제한)
        1,                   // 초기 스레드 수
        [](Napi::Env) { /* 마지막 Release 후 정리 */ });

    std::thread([tsfn]() mutable {
        while (running) {
            auto ev = wait_for_event();           // 블로킹 (JS 무관)

            // 메인 스레드에서 실행될 람다를 큐에 넣습니다
            tsfn.BlockingCall([ev](Napi::Env env, Napi::Function cb) {
                cb.Call({ Napi::String::New(env, ev.name) });
            });
        }
        tsfn.Release();       // ★ 반드시 해제 (안 하면 프로세스가 안 끝납니다)
    }).detach();

    return env.Undefined();
}
Release() 를 빼먹으면 프로세스가 종료되지 않습니다 — TSFN 이 살아 있는 동안 이벤트 루프가 참조를 유지하기 때문입니다. tsfn.Unref(env) 로 이벤트 루프 참조를 끊을 수도 있습니다.

이렇게도 쓸 수 있다 — TSFN 실전 패턴 🟡 중급

BlockingCall vs NonBlockingCall

// BlockingCall — 큐가 꽉 차면 대기합니다 (역압) ★
tsfn.BlockingCall(data, callback);
//   생산자가 소비자보다 빠를 때 메모리 폭증을 막아 줍니다

// NonBlockingCall — 큐가 꽉 차면 즉시 실패
auto status = tsfn.NonBlockingCall(data, callback);
if (status != napi_ok) {
    dropped_++;                    // 이벤트를 버리고 계속
}
//   실시간 데이터(오디오·센서)처럼 '최신이 중요한' 경우에 적합

// 큐 크기를 정해 역압 걸기
auto tsfn = Napi::ThreadSafeFunction::New(env, cb, "stream",
                                          1000,   // ★ 최대 1000개
                                          1);

// 데이터 전달 — 타입 안전한 형태
struct Event { std::string name; double value; };
using TSFN = Napi::TypedThreadSafeFunction<std::nullptr_t, Event,
    [](Napi::Env env, Napi::Function cb, std::nullptr_t*, Event* e) {
        cb.Call({ Napi::String::New(env, e->name),
                  Napi::Number::New(env, e->value) });
        delete e;                  // ★ 전달한 데이터 해제
    }>;

이벤트 스트림을 JS 로

// C++ 이벤트를 EventEmitter 로 감싸기 ★
import { EventEmitter } from 'node:events'

class Watcher extends EventEmitter {
  #handle
  constructor(path) {
    super()
    this.#handle = native.watch(path, (type, file) => {
      this.emit(type, file)                // C++ 스레드 → TSFN → 여기
    })
  }
  close() { native.unwatch(this.#handle) }
}

const w = new Watcher('/data')
w.on('change', f => console.log('변경', f))
w.on('error', e => console.error(e))

// 또는 async iterator 로 (소비하는 쪽 속도로 꺼내 씁니다) ★
//   ⚠ 아래 queue 는 무제한 — 생산이 더 빠르면 쌓이니 진짜 역압은 TSFN 큐 크기로
async function* events(path) {
  const queue = []
  let resolve
  const w = native.watch(path, (t, f) => {
    queue.push({ t, f })
    resolve?.()
  })
  try {
    while (true) {
      if (!queue.length) await new Promise(r => { resolve = r })
      yield queue.shift()
    }
  } finally { native.unwatch(w) }
}
for await (const ev of events('/data')) console.log(ev)

실무에선 이렇게 — 종료 처리 🔴 실무

프로세스가 안 끝나는 문제 ★

// 원인 1 — TSFN 을 Release 하지 않음
tsfn.Release();                // 스레드 종료 시 반드시

// 원인 2 — detach 한 스레드가 살아 있음
//   → jthread 나 명시적 join 을 쓰고, 종료 신호를 주세요
class Watcher {
    std::atomic<bool> stop_{false};  // ★ th_ 보다 먼저 선언 — 멤버는 역순 소멸이라
    std::jthread th_;                //   join 하는 동안 stop_ 이 아직 살아 있어야 합니다
public:
    ~Watcher() { stop_ = true; }    // jthread 소멸자가 join
};

// 원인 3 — 이벤트 루프 참조가 남음
tsfn.Unref(env);               // ★ "이것 때문에 프로세스를 붙잡지 마라"
//   백그라운드 감시처럼 '있어도 프로세스는 끝나도 되는' 경우

// Node 종료 훅에서 정리
napi_add_env_cleanup_hook(env, [](void* arg){
    static_cast<Watcher*>(arg)->Stop();
}, watcher);

// 확인 — 무엇이 프로세스를 붙잡고 있나
node -e "
process.on('exit', () => console.log('종료'))
setTimeout(() => { console.log(process._getActiveHandles().length) }, 1000)
"
// 또는 why-is-node-running 패키지

에러 처리 계층

// ① Promise 는 reject 로 — try/catch 로 잡힙니다
try {
  await addon.smoothAsync(data, 3)
} catch (e) {
  console.error(e.code, e.message)
}

// ② EventEmitter 는 'error' 이벤트를 반드시 처리해야 합니다
//    안 하면 uncaught exception 으로 프로세스가 죽습니다 ★
watcher.on('error', e => log(e))

// ③ 콜백 스타일은 (err, result) 관례를 지키세요
addon.smooth(data, 3, (err, out) => { if (err) return handle(err) })

// ④ 네이티브 크래시는 잡을 수 없습니다
//    → 세그폴트가 나면 프로세스 전체가 죽습니다
//    대비: 클러스터·PM2 로 자동 재시작 + 코어 덤프 수집
process.on('uncaughtException', (e) => { log(e); process.exit(1) })
//    ⚠ 네이티브 세그폴트는 이것으로도 못 잡습니다 (신호 수준)
네이티브 크래시는 격리할 수 없습니다 — JS 예외는 요청 하나가 실패하지만 세그폴트는 서버 전체가 죽습니다. 입력 검증을 철저히 하고, 불안한 코드는 별도 프로세스로 빼는 것이 운영 관점에서 안전합니다(j01).

한 단계 더 — TSFN 의 내부와 성능 🧬 전문가

한 번의 호출에 드는 것

tsfn.BlockingCall() 이 하는 일
  ① 뮤텍스 잡고 큐에 항목 추가
  ② uv_async_send 로 이벤트 루프를 깨움
  ③ (다음 루프 틱) 메인 스레드가 큐를 비우며 콜백 실행
  ④ HandleScope 생성 · 값 변환 · JS 함수 호출

  → 호출당 대략 수~수십 μs.
    게다가 ③은 '다음 틱'이라 지연이 이벤트 루프 상태에 좌우됩니다 ★

그래서
  · 초당 수천 회까지는 문제없습니다
  · 초당 수십만 회는 불가능합니다 → 배치로 묶으세요
  · 지연이 중요한 실시간 처리에는 부적합합니다
    (오디오 콜백은 C++ 안에서 끝내고 JS 는 상태만 폴링)

배치 패턴
  워커 스레드에서 결과를 버퍼에 모으고,
  N개 또는 M밀리초마다 한 번만 TSFN 호출 ★

측정과 튜닝

// 이벤트 전달 지연 측정
const t0 = process.hrtime.bigint()
native.fireEvent(() => {
  const dt = Number(process.hrtime.bigint() - t0) / 1e6
  console.log(`전달 지연 ${dt.toFixed(3)}ms`)
})

// 큐가 쌓이는지 확인 — 생산자가 소비자보다 빠른가
let sent = 0, received = 0
setInterval(() => {
  console.log(`대기 중 ${sent - received}`)     // 계속 늘면 역압 필요 ★
}, 1000)

// 이벤트 루프가 막히면 TSFN 콜백도 늦어집니다
import { monitorEventLoopDelay } from 'node:perf_hooks'
const h = monitorEventLoopDelay(); h.enable()
// ... 부하 ...
console.log('p99 지연', h.percentile(99) / 1e6, 'ms')
// 배치 전달 — 지연과 처리량의 균형
std::vector<Event> batch;
auto last = std::chrono::steady_clock::now();
while (running) {
    batch.push_back(wait_event());
    auto now = std::chrono::steady_clock::now();
    if (batch.size() >= 100 || now - last > std::chrono::milliseconds(16)) {
        tsfn.BlockingCall(new std::vector<Event>(std::move(batch)), send_batch);
        batch.clear();
        last = now;
    }
}
//   16ms = 한 프레임 — UI 갱신에는 이 정도면 충분합니다 ★
정리 — Promise::Deferred 로 Promise 를 돌려주면 사용자가 await 로 자연스럽게 씁니다. C++ 스레드에서 JS 를 부르려면 ThreadSafeFunction 이 필수이고, Release() 를 빼먹으면 프로세스가 끝나지 않습니다. 이벤트가 잦으면 배치로 묶어 전달하세요.
V8 은 단일 스레드입니다 — 다른 스레드에서 직접 부를 수 없습니다 워커 스레드 오래 걸리는 C++ 계산 진행률 30% · 60% · 100% tsfn.BlockingCall ThreadSafeFunction 호출 요청을 큐에 넣습니다 여러 스레드에서 안전합니다 이벤트 루프 (메인) 차례가 오면 JS 콜백 실행 여기서만 V8 을 만집니다 잊으면 프로세스가 안 끝납니다 tsfn 이 살아 있는 동안 이벤트 루프가 유지됩니다 다 쓰면 Release() — 안 하면 node 가 종료되지 않습니다 한 번만 끝나는 일이라면 Promise 가 낫습니다 Promise::Deferred 를 만들어 JS 에 먼저 돌려주고, 작업이 끝나면 Resolve — await 로 쓸 수 있습니다 고르는 기준 — 결과가 하나면 Promise, 진행률·스트림처럼 여러 번이면 ThreadSafeFunction 입니다 둘 다 짧은 작업이면 AsyncWorker 하나로 충분합니다 — 필요 없는 스레드를 만들지 마세요
비동기 애드온의 버그는 대부분 스레드 경계와 Release 를 놓친 자리에서 납니다.
V8 은 단일 스레드입니다 — 다른 스레드에서 직접 부를 수 없습니다 워커 스레드 오래 걸리는 C++ 계산 진행률 30% · 60% · 100% tsfn.BlockingCall ThreadSafeFunction 호출 요청을 큐에 넣습니다 여러 스레드에서 안전합니다 이벤트 루프 (메인) 차례가 오면 JS 콜백 실행 여기서만 V8 을 만집니다 잊으면 프로세스가 안 끝납니다 tsfn 이 살아 있는 동안 이벤트 루프가 유지됩니다 다 쓰면 Release() — 안 하면 node 가 종료되지 않습니다 한 번만 끝나는 일이라면 Promise 가 낫습니다 Promise::Deferred 를 만들어 JS 에 먼저 돌려주고, 작업이 끝나면 Resolve — await 로 쓸 수 있습니다 고르는 기준 — 결과가 하나면 Promise, 진행률·스트림처럼 여러 번이면 ThreadSafeFunction 입니다 둘 다 짧은 작업이면 AsyncWorker 하나로 충분합니다 — 필요 없는 스레드를 만들지 마세요
비동기 애드온의 버그는 대부분 스레드 경계와 Release 를 놓친 자리에서 납니다.
NODE 08

클래스 — ObjectWrap

C++ 객체를 new 로 만드는 JS 클래스로 노출합니다. 무거운 준비를 한 번만 하고 여러 번 호출하는 구조에 필요합니다.

기본 형태

class Engine : public Napi::ObjectWrap<Engine> {
public:
    static Napi::Object Init(Napi::Env env, Napi::Object exports) {
        Napi::Function func = DefineClass(env, "Engine", {
            InstanceMethod("feed",   &Engine::Feed),
            InstanceMethod("result", &Engine::Result),
            InstanceMethod("close",  &Engine::Close),
            InstanceAccessor("size", &Engine::GetSize, nullptr),   // 읽기 전용
            StaticMethod("version",  &Engine::Version),
        });
        // ★ 생성자를 인스턴스 데이터에 보관 (worker_threads 안전)
        auto* ctor = new Napi::FunctionReference();
        *ctor = Napi::Persistent(func);
        env.SetInstanceData(ctor);

        exports.Set("Engine", func);
        return exports;
    }

    explicit Engine(const Napi::CallbackInfo& info)
        : Napi::ObjectWrap<Engine>(info)
    {
        int window = 3;
        if (info.Length() > 0 && info[0].IsObject())
            window = info[0].As<Napi::Object>()
                        .Get("window").ToNumber().Int32Value();
        if (window <= 0)
            throw Napi::RangeError::New(info.Env(), "window 는 1 이상");
        impl_ = std::make_unique<core::Engine>(window);
    }

private:
    std::unique_ptr<core::Engine> impl_;

    Napi::Value Feed(const Napi::CallbackInfo& info) {
        auto a = info[0].As<Napi::Float64Array>();
        impl_->feed(a.Data(), a.ElementLength());
        return info.Env().Undefined();
    }
    Napi::Value GetSize(const Napi::CallbackInfo& info) {
        return Napi::Number::New(info.Env(),
                                 static_cast<double>(impl_->size()));
    }
    Napi::Value Close(const Napi::CallbackInfo& info) {
        impl_.reset();                     // ★ 명시적 해제
        return info.Env().Undefined();
    }
};

JS 답게 다듬기

// 얇은 JS 래퍼로 감싸면 훨씬 자연스러워집니다 ★
const native = require('node-gyp-build')(__dirname)

class Engine {
  #impl
  #closed = false

  constructor({ window = 3 } = {}) {
    this.#impl = new native.Engine({ window })
  }

  feed(data) {
    if (this.#closed) throw new Error('이미 닫힌 엔진입니다')
    this.#impl.feed(toF64(data))
    return this                      // 메서드 체이닝
  }

  get size() { return this.#impl.size }

  close() {
    if (!this.#closed) { this.#impl.close(); this.#closed = true }
  }

  // ★ using 문 지원 (TC39 explicit resource management)
  [Symbol.dispose]() { this.close() }

  // 반복 가능하게
  *[Symbol.iterator]() { yield* this.#impl.result() }

  // console.log 출력
  [Symbol.for('nodejs.util.inspect.custom')]() {
    return `Engine(size=${this.size})`
  }
}
module.exports = { Engine }          // require·__dirname 을 쓰므로 CommonJS 로

// 사용 (using 은 블록·함수 안에서)
using engine = new Engine({ window: 5 })   // 스코프를 벗어나면 자동 close
engine.feed(a).feed(b)

이렇게도 쓸 수 있다 — 수명 관리 🟡 중급

GC 는 언제 부를지 모릅니다 ★

// ObjectWrap 의 소멸자는 GC 시점에 불립니다.
// 즉 '언제 불릴지 모르고, 아예 안 불릴 수도' 있습니다.
class Engine : public Napi::ObjectWrap<Engine> {
    ~Engine() {
        // 여기서 파일·소켓을 닫으면 언제 닫힐지 알 수 없습니다 ★
    }
};

// ✅ 확정적 해제를 위한 close() 를 반드시 제공하세요
Napi::Value Close(const Napi::CallbackInfo& info) {
    if (impl_) { impl_->flush(); impl_.reset(); }
    return info.Env().Undefined();
}
//   소멸자는 '안전망'으로만 두고, 실제 해제는 close() 로

// ⚠ 닫힌 뒤 호출 방어
Napi::Value Feed(const Napi::CallbackInfo& info) {
    if (!impl_) {
        Napi::Error::New(info.Env(), "닫힌 엔진입니다")
            .ThrowAsJavaScriptException();
        return info.Env().Undefined();
    }
    ...
}
//   이 검사가 없으면 널 역참조로 프로세스가 죽습니다

// 큰 메모리를 잡고 있다면 GC 에 알려 주기
Napi::MemoryManagement::AdjustExternalMemory(env, +bytes);

객체를 돌려주고 받기

// C++ 에서 새 인스턴스 만들기
Napi::Value CreateEngine(const Napi::CallbackInfo& info) {
    auto env = info.Env();
    auto* ctor = env.GetInstanceData<Napi::FunctionReference>();
    return ctor->New({ info[0] });        // ★ 저장해 둔 생성자로
}

// 인자로 받은 객체에서 C++ 포인터 꺼내기
Napi::Value Merge(const Napi::CallbackInfo& info) {
    Engine* other = Napi::ObjectWrap<Engine>::Unwrap(
                        info[0].As<Napi::Object>());
    if (!other) {
        Napi::TypeError::New(info.Env(), "Engine 인스턴스가 필요합니다")
            .ThrowAsJavaScriptException();
        return info.Env().Undefined();
    }
    impl_->merge(*other->impl_);
    return info.Env().Undefined();
}
//   ⚠ Unwrap 은 타입 검사를 하지 않습니다.
//     다른 클래스 객체를 넘기면 엉뚱한 포인터가 나옵니다 ★
//     instanceof 검사를 JS 래퍼에서 하거나, 타입 태그를 두세요

// 타입 태그 (N-API 8+)
static constexpr napi_type_tag kTag = { 0x1234..., 0x5678... };
info.This().As<Napi::Object>().TypeTag(&kTag);      // 생성자에서
if (!obj.CheckTypeTag(&kTag)) throw ...;             // 사용 전 검사 ★

실무에선 이렇게 — 클래스보다 함수가 나을 때 🔴 실무

노출 표면을 최소로

// ❌ C++ 클래스 계층을 그대로 옮기기
new addon.AbstractProcessor()
new addon.BaseFilter()
new addon.AdvancedFilter()

// ✅ 상태가 없으면 그냥 함수
const out = addon.smooth(data, 3)

// ✅ 상태가 필요할 때만 클래스 — 그리고 메서드는 최소로
const engine = new addon.Engine({ window: 5 })
engine.feed(chunk)
engine.result()
engine.close()

// ✅ 팩토리 함수로 감싸면 나중에 구현을 바꾸기 쉽습니다
export function createEngine(opts) {
  return new Engine(opts)          // 내부가 네이티브든 JS 든 무관
}
파이썬 탭과 같은 결론 — 바인딩은 얇게 유지하고 편의 기능은 JS 래퍼에서 만드세요. C++ 클래스를 통째로 노출하면 바인딩 코드가 로직보다 커지고 구현을 바꿀 때마다 그것도 같이 고쳐야 합니다.

리소스 누수 테스트

// 반복 생성·해제 후 메모리 확인 ★
test('엔진 누수 없음', async () => {
  const before = process.memoryUsage().rss
  for (let i = 0; i < 10_000; i++) {
    const e = new Engine({ window: 3 })
    e.feed(new Float64Array(1000))
    e.close()                            // ★ 명시적 해제
  }
  global.gc?.()
  await new Promise(r => setImmediate(r))
  const after = process.memoryUsage().rss
  expect((after - before) / 1e6).toBeLessThan(50)     // 50MB 미만
})

// close() 를 안 부르면 어떻게 되는지도 확인
test('GC 로도 회수됨', async () => {
  for (let i = 0; i < 10_000; i++) new Engine({ window: 3 })
  global.gc?.()
  // 소멸자가 불려 결국 회수되어야 합니다 (다만 시점은 불확정)
})

// 닫힌 뒤 사용 방어 확인
test('닫힌 엔진 사용 시 예외', () => {
  const e = new Engine(); e.close()
  expect(() => e.feed(new Float64Array(1))).toThrow()
})

한 단계 더 — ObjectWrap 의 내부 🧬 전문가

JS 객체와 C++ 객체의 연결

new addon.Engine() 이 하는 일

  ① V8 이 JS 객체를 만듭니다
  ② napi_wrap 으로 그 객체에 C++ 포인터를 붙입니다
     (내부 필드 또는 숨은 속성에 저장)
  ③ finalizer 를 등록합니다 — GC 될 때 C++ 소멸자를 부르도록
  ④ 메서드 호출 시 napi_unwrap 으로 포인터를 꺼내 씁니다

★ 그래서 생기는 특성
  · JS 객체가 살아 있는 동안 C++ 객체도 살아 있습니다
  · JS 참조가 사라지면 GC 시점에 C++ 소멸자가 불립니다
  · GC 가 안 돌면 소멸자도 안 불립니다 (프로세스 종료 시 보장 없음) ★
  · 그래서 close()/dispose 패턴이 필요합니다

메서드 호출 비용
  ① unwrap (포인터 조회)  ② 인자 변환  ③ C++ 호출  ④ 반환값 변환
  → 대략 100~300ns. 초당 수백만 회는 무리입니다.
    핫 루프라면 배열 단위 API 로 바꾸세요.

진단

# 힙 스냅샷으로 JS 객체 수 확인
node --expose-gc -e "
const v8 = require('v8')
const { Engine } = require('./index.js')
const arr = []
for (let i=0;i<1000;i++) arr.push(new Engine())
v8.writeHeapSnapshot('/tmp/heap.heapsnapshot')
"
#   Chrome DevTools 에서 열어 Engine 인스턴스 수를 확인

# 네이티브 메모리 추적
valgrind --leak-check=full --show-leak-kinds=definite \
  node test/leak.js 2>&1 | grep -A10 "definitely lost"

# ASan 빌드로
npx node-gyp rebuild --debug
LD_PRELOAD=$(gcc -print-file-name=libasan.so) node test/leak.js

# 메서드 호출 비용 측정
node -e "
const { Engine } = require('./index.js')
const e = new Engine()
const t = process.hrtime.bigint()
for (let i=0;i<1e6;i++) e.size
console.log(Number(process.hrtime.bigint()-t)/1e6, 'ms / 1M')
"
속성 접근도 경계를 넘습니다 — engine.size 를 루프 안에서 읽으면 매번 네이티브 호출입니다. 루프 밖에서 한 번 읽어 지역 변수에 담으세요.
정리 — Napi::ObjectWrap 으로 C++ 객체를 JS 클래스로 만들 수 있습니다. 핵심은 수명 관리 — 소멸자는 GC 시점이라 언제 불릴지 모르므로 반드시 close() 를 제공하고 닫힌 뒤 호출을 방어하세요. 그리고 노출은 최소로, 편의는 JS 래퍼에서.
NODE 09

prebuild — 사용자는 컴파일하지 않습니다

npm install 이 컴파일러를 요구하면 절반의 사용자가 실패합니다. 미리 빌드한 바이너리를 함께 배포하는 것이 사실상 필수입니다.

prebuildify — 패키지에 전부 담기 ★

// package.json
{
  "name": "myaddon",
  "main": "index.js",
  "files": ["index.js", "index.d.ts", "prebuilds/", "src/", "binding.gyp"],
  "dependencies": {
    "node-addon-api": "^8.0.0",
    "node-gyp-build": "^4.8.0"
  },
  "devDependencies": {
    "prebuildify": "^6.0.0",
    "prebuildify-cross": "^5.0.0"
  },
  "scripts": {
    "install": "node-gyp-build",
    "prebuild": "prebuildify --napi --strip -t 18.0.0"
  }
}
// index.js — 런타임에 알맞은 바이너리를 고릅니다
const native = require('node-gyp-build')(__dirname)
//   prebuilds/{platform}-{arch}/node.napi.node 를 찾고
//   없으면 소스에서 빌드합니다 (폴백)

module.exports = native
prebuilds/
  linux-x64/node.napi.node
  linux-x64/node.napi.musl.node      ← 알파인용 ★
  linux-arm64/node.napi.node
  darwin-x64+arm64/node.napi.node    ← 유니버설
  win32-x64/node.napi.node
N-API 덕분에 파일 수가 적습니다 — Node 버전마다 빌드할 필요가 없어 플랫폼 × 아키텍처만 필요합니다. --napi 플래그가 그 뜻입니다.

CI 로 전부 만들기

# .github/workflows/release.yml
name: release
on: { push: { tags: ['v*'] } }

jobs:
  prebuild:
    strategy:
      matrix:
        include:
          - { os: ubuntu-latest,  arch: x64 }
          - { os: ubuntu-24.04-arm, arch: arm64 }    # arm 러너에서 네이티브 빌드
          - { os: macos-14,       arch: arm64 }
          - { os: macos-13,       arch: x64 }
          - { os: windows-latest, arch: x64 }
    runs-on: ${{ matrix.os }}
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: 20 }
      - run: npm ci
      - run: npx prebuildify --napi --strip --arch ${{ matrix.arch }}
      - uses: actions/upload-artifact@v4
        with: { name: 'prebuilds-${{ matrix.os }}-${{ matrix.arch }}',
                path: prebuilds/ }

  # 알파인(musl) — 컨테이너에서
  musl:
    runs-on: ubuntu-latest
    container: node:20-alpine
    steps:
      - uses: actions/checkout@v4
      - run: apk add --no-cache build-base python3 git
      - run: npm ci && npx prebuildify --napi --strip --libc musl
      - uses: actions/upload-artifact@v4
        with: { name: prebuilds-musl, path: prebuilds/ }

  publish:
    needs: [prebuild, musl]
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/download-artifact@v4
        with: { path: prebuilds/, merge-multiple: true }
      - run: npm publish --provenance --access public

이렇게도 쓸 수 있다 — 다른 배포 방식 🟡 중급

세 가지 전략

방식특징
prebuildify
+ node-gyp-build
패키지에 전부 포함 · 오프라인 설치 가능 · 패키지 크기 큼 ★ 권장
prebuild-install설치 시 GitHub Releases 에서 다운로드 · 패키지는 작지만 네트워크 필요
플랫폼별 패키지optionalDependencies 로 분리
esbuild·swc 방식 · 필요한 것만 설치됨
// 플랫폼별 패키지 방식 (esbuild 스타일)
{
  "optionalDependencies": {
    "myaddon-linux-x64": "1.0.0",
    "myaddon-darwin-arm64": "1.0.0",
    "myaddon-win32-x64": "1.0.0"
  }
}
// 각 하위 패키지의 package.json 에
{ "os": ["linux"], "cpu": ["x64"] }
//   → npm 이 현재 플랫폼에 맞는 것만 설치합니다 ★
고르는 기준 — 바이너리가 작으면 prebuildify(전부 포함해도 부담 없음), 수십 MB 이면 플랫폼별 패키지가 낫습니다. 사내망처럼 네트워크가 제한된 환경이면 반드시 전부 포함 방식으로.

설치 실패를 부드럽게

// install 스크립트가 실패해도 설치는 성공하게
{
  "scripts": {
    "install": "node-gyp-build || node scripts/warn-no-native.js"
  }
}
// scripts/warn-no-native.js
console.warn(`
[myaddon] 네이티브 바이너리를 찾지 못했고 빌드도 실패했습니다.
순수 JS 폴백으로 동작합니다 (약 20배 느림).

빌드하려면:
  · Linux : apt install build-essential
  · macOS : xcode-select --install
  · Windows : Visual Studio Build Tools + "C++ 데스크톱 개발"
`)
process.exit(0)      // ★ 0 으로 끝내야 npm install 이 성공합니다
// index.js — 폴백 (j01)
let impl
try { impl = require('node-gyp-build')(__dirname) }
catch { impl = require('./fallback.js') }
exports.isNative = impl !== require('./fallback.js')

실무에선 이렇게 — 검증과 운영 🔴 실무

배포 전 체크리스트

  • 깨끗한 환경에서 설치 테스트 — docker run node:20 npm i myaddon (컴파일러 없는 이미지)
  • 알파인에서도 — node:20-alpine
  • 패키지 내용 확인 — npm pack --dry-run 으로 prebuilds 가 들어갔는지
  • 크기 확인 — npm pack 후 tarball 크기. 수십 MB 면 사용자가 싫어합니다
  • 바이너리에 디버그 심볼이 없는지 — --strip
  • 의존 라이브러리 — ldd 로 외부 .so 확인
  • provenance — npm publish --provenance 로 빌드 출처 증명(공급망 보안)
# 컴파일러 없는 환경에서 설치되는지 확인 ★
docker run --rm -it node:20-slim sh -c "
  npm i myaddon && node -e \"
    const m = require('myaddon');
    console.log('native:', m.isNative);
    console.log(m.smooth(new Float64Array([1,2,3]), 3));
  \""

버전 정보를 남기기

// 빌드 정보를 모듈에 노출 — 장애 대응에 반드시 필요합니다 ★
exports.Set("buildInfo", [&]{
    auto o = Napi::Object::New(env);
    o.Set("compiler",  Napi::String::New(env,
#if defined(__clang__)
        "clang " __clang_version__
#elif defined(__GNUC__)
        "gcc " __VERSION__
#elif defined(_MSC_VER)
        "msvc"
#endif
    ));
    o.Set("napiVersion", Napi::Number::New(env, NAPI_VERSION));
    o.Set("date", Napi::String::New(env, __DATE__));
    o.Set("commit", Napi::String::New(env, GIT_COMMIT));   // -D 로 주입
    return o;
}());
> require('myaddon').buildInfo
{ compiler: 'gcc 13.2.0', napiVersion: 8,
  date: 'Aug  6 2026', commit: 'a1b2c3d' }

// 이슈 리포트 템플릿에 이걸 넣게 하세요
console.log(JSON.stringify({
  addon: require('myaddon').buildInfo,
  node: process.versions,
  platform: `${process.platform}-${process.arch}`,
}, null, 2))
“내 환경에선 안 돼요” 를 30초에 해결하는 방법 — 빌드 정보와 플랫폼을 한 번에 출력하는 명령을 문서에 넣으세요. 네이티브 모듈 이슈의 절반은 잘못된 바이너리가 로드된 것입니다.

한 단계 더 — 바이너리 크기와 호환성 🧬 전문가

glibc 버전 문제

# 최신 우분투에서 빌드하면 옛 배포판에서 로드 실패합니다
node -e "require('./addon.node')"
#   Error: /lib/x86_64-linux-gnu/libm.so.6: version `GLIBC_2.38' not found

# 어떤 버전을 요구하는지 확인
objdump -T addon.node | grep GLIBC | sed 's/.*GLIBC_\([0-9.]*\).*/\1/' | sort -uV
#   2.17  2.29  2.38   ← 가장 높은 것이 최소 요구 버전 ★

# 해결 ① 옛 배포판 컨테이너에서 빌드
docker run --rm -v $PWD:/app -w /app node:20-bullseye \
  sh -c "npm ci && npx prebuildify --napi --strip"

# 해결 ② zig cc 로 glibc 버전을 지정해 크로스 컴파일
CC="zig cc -target x86_64-linux-gnu.2.17" \
CXX="zig c++ -target x86_64-linux-gnu.2.17" npx prebuildify --napi

# 해결 ③ 정적 링크 (libstdc++ 만이라도)
"ldflags": ["-static-libstdc++", "-static-libgcc"]
#   ★ C++ 표준 라이브러리 버전 문제를 통째로 없앱니다

크기 줄이기

// binding.gyp
"cflags_cc": [
  "-Os",                        // 크기 우선 최적화
  "-ffunction-sections", "-fdata-sections",
  "-fvisibility=hidden", "-fvisibility-inlines-hidden",
  "-fno-rtti"                   // RTTI 를 안 쓴다면
],
"ldflags": [
  "-Wl,--gc-sections",          // 안 쓰는 섹션 제거
  "-Wl,--as-needed",
  "-Wl,-s"                      // 심볼 제거
]
# 무엇이 크기를 차지하나
bloaty prebuilds/linux-x64/node.napi.node -d sections,symbols | head -20
nm -C --size-sort -S addon.node | tail -20

# 템플릿 폭발이 원인인 경우가 많습니다 (🔬 d08)
#   → 바인딩 코드에서 템플릿 사용을 줄이세요

# 압축 후 크기도 확인 (npm 은 tarball 로 전송)
gzip -9 -c addon.node | wc -c

# 전형적인 크기
#   단순 애드온        50~200KB
#   OpenCV 정적 링크   20~80MB   ★ 이 정도면 플랫폼별 패키지로 분리
#   ONNX Runtime      100MB+
패키지 크기는 사용자 경험입니다 — CI 마다 npm ci 를 도는 팀에서 100MB 패키지는 실질적인 비용입니다. 20MB 를 넘으면 플랫폼별 optionalDependencies 로 분리하세요.
정리 — prebuildify + node-gyp-build 가 사실상 표준입니다. N-API 덕분에 Node 버전마다 빌드할 필요가 없고, 필요한 것은 플랫폼 × 아키텍처 × libc 조합뿐입니다. 컴파일러 없는 도커 이미지에서 설치 테스트를 CI 에 꼭 넣으세요.
사용자가 컴파일하게 두지 마세요 미리 빌드가 없을 때 사용자 PC 에서 직접 컴파일합니다 빌드 도구 필요 · 수 분 · 실패 가능 "설치가 안 돼요" 문의의 주범 미리 빌드가 있을 때 맞는 파일만 내려받습니다 몇 초 · 도구 불필요 CI 에서도 그대로 통과합니다 CI 에서 만들어 둘 조합 linux x64 · arm64 macOS x64 · arm64 windows x64 musl (알파인) 별도 Node 메이저 버전이 올라가면 ABI 도 바뀝니다 — N-API 를 쓰면 이 재빌드에서 자유로워집니다 설치 시 다운로드가 실패하면 소스 빌드로 넘어가게 해 두세요 — 완전히 막히는 것보다 낫습니다
네이티브 패키지의 성패는 성능이 아니라 설치가 조용히 끝나는가입니다.
NODE 10

Electron 과 ABI 버전 함정

Electron 은 Node 와 다른 V8 버전을 씁니다. N-API 를 쓰면 대부분 해결되지만, 남는 함정들이 있습니다.

왜 깨지나

애드온이 로드되지 않는 대표 메시지

  Error: The module was compiled against a different Node.js version
  using NODE_MODULE_VERSION 108. This version requires 115.

  → V8 API 를 직접 쓴 애드온(nan)의 문제입니다.
    N-API 로 만들면 이 에러 자체가 나지 않습니다 ★

NODE_MODULE_VERSION (ABI)
  Node 18 → 108
  Node 20 → 115
  Node 22 → 127
  Electron 28 → 119   ← 같은 Node 버전이어도 다릅니다

N-API 를 쓰면
  · napi_version 만 맞으면 됩니다 (하위 호환 보장)
  · Electron 도 N-API 를 지원합니다
  · 재빌드 없이 그대로 로드됩니다 ★

확인
  node -p "process.versions.modules"       # ABI 버전
  node -p "process.versions.napi"          # 지원하는 최대 napi 버전
  npx electron -p "process.versions.napi"

Electron 에서 실제로 겪는 것들

// ① 렌더러에서는 쓸 수 없습니다 (contextIsolation 기본값)
//    → 메인 프로세스에서만 로드하고 IPC 로 노출 ★
// main.js
import { ipcMain } from 'electron'
const addon = require('myaddon')
ipcMain.handle('smooth', async (_e, data, w) => addon.smoothAsync(data, w))

// preload.js
import { contextBridge, ipcRenderer } from 'electron'
contextBridge.exposeInMainWorld('native', {
  smooth: (data, w) => ipcRenderer.invoke('smooth', data, w),
})

// ⚠ IPC 는 구조적 복제를 씁니다 — TypedArray 는 복사됩니다
//   큰 데이터는 SharedArrayBuffer 나 파일 경로를 주고받으세요 ★
// ② 패키징 — asar 안에서는 로드 못 합니다
// electron-builder 설정
{
  "build": {
    "asarUnpack": ["**/*.node", "**/prebuilds/**"],
    "files": ["!**/*.{cpp,hpp,gyp}"]
  }
}
// ③ 코드 서명 (맥) — .node 파일도 서명 대상입니다
//    "hardenedRuntime": true 면 서명 안 된 바이너리는 로드 거부됩니다 ★

이렇게도 쓸 수 있다 — 다른 런타임 지원 🟡 중급

Bun · Deno

Bun
  · N-API 를 상당 부분 지원합니다 (완전하지는 않습니다)
  · 대부분의 애드온이 그대로 동작하지만 검증이 필요합니다
  · Bun 자체 FFI(bun:ffi)가 더 빠르므로, C API 도 함께 제공하면 좋습니다

Deno
  · N-API 지원 (Deno 2.x 에서 npm 패키지와 함께)
  · 권한 모델 때문에 --allow-ffi 등이 필요할 수 있습니다
  · Deno.dlopen(FFI)가 더 자연스러운 경로입니다(🔗 f06)

★ 실무 조언
  하나의 C++ 코어를 두 가지로 노출하면 모든 런타임을 덮습니다
    ① N-API 애드온  → Node · Electron (성능 최우선)
    ② C ABI 공유 라이브러리 → Deno · Bun · 파이썬 · 그 외 (🔗 탭)
  코어 로직을 순수 C++ 로 분리해 두면 어렵지 않습니다 (j02 구조)

호환성 테스트
  matrix: [node18, node20, node22, electron28, bun, deno]

Electron 재빌드가 필요한 경우

# nan 기반 옛 애드온이거나 N-API 미사용 시
npx electron-rebuild
npx @electron/rebuild -f -w myaddon

# 또는 직접
npx node-gyp rebuild \
  --target=28.0.0 \
  --arch=x64 \
  --dist-url=https://electronjs.org/headers

# prebuildify 로 Electron 용도 함께 만들기
npx prebuildify --napi --strip \
  -t 18.0.0 -t electron@28.0.0

# 확인 — 어떤 런타임에서 로드되는지
npx electron -e "console.log(require('myaddon').buildInfo)"
N-API 로 만들었다면 이 명령들이 대부분 필요 없습니다. 기존 프로젝트가 electron-rebuild 를 계속 돌리고 있다면 nan 을 쓰고 있다는 신호이고, N-API 로 이전할 이유가 됩니다.

실무에선 이렇게 — 데스크탑 앱에서의 운영 🔴 실무

크래시가 앱 전체를 죽입니다

// ★ 네이티브 크래시는 try/catch 로 못 잡습니다
// 대책 ① utilityProcess 로 격리 (Electron 22+)
import { utilityProcess } from 'electron'
const child = utilityProcess.fork(path.join(__dirname, 'native-worker.js'))
child.on('exit', (code) => {
  log.error(`네이티브 워커 종료 (${code}) — 재시작`)
  restart()                       // 앱은 살아 있습니다 ★
})
child.postMessage({ cmd: 'smooth', data })

// 대책 ② 크래시 리포터
import { crashReporter } from 'electron'
crashReporter.start({ submitURL: 'https://crash.example.com', uploadToServer: true })
//   덤프에서 네이티브 스택을 복원할 수 있습니다 (🔬 d11)

// 대책 ③ 입력 검증을 IPC 경계에서
ipcMain.handle('smooth', (_e, data, w) => {
  if (!(data instanceof Float64Array)) throw new TypeError('...')
  if (!Number.isInteger(w) || w < 1 || w > data.length) throw new RangeError('...')
  return addon.smoothAsync(data, w)
})
//   렌더러(신뢰할 수 없는 입력)와 네이티브 사이에 방벽을 두세요 ★

큰 데이터를 렌더러와 주고받기

// ❌ IPC 로 100MB 를 보내면 구조적 복제가 두 번 일어납니다
ipcRenderer.invoke('process', bigArray)

// ✅ ① 파일 경로를 주고받기 (가장 단순)
const outPath = await ipcRenderer.invoke('process', inputPath)

// ✅ ② SharedArrayBuffer (같은 메모리를 공유)
//    Electron 에서 쓰려면 crossOriginIsolated 설정이 필요합니다
const sab = new SharedArrayBuffer(n * 8)
const view = new Float64Array(sab)
await ipcRenderer.invoke('processShared', sab)   // 복사 없음 ★

// ✅ ③ MessagePort 로 transferable 전달 (소유권 이전)
const { port1, port2 } = new MessageChannel()
ipcRenderer.postMessage('port', null, [port2])
port1.postMessage(buffer, [buffer])              // 복사 대신 이전

// 측정 — IPC 지연
const t = performance.now()
await ipcRenderer.invoke('smooth', data, 3)
console.log(`왕복 ${(performance.now()-t).toFixed(1)}ms`)
Electron 에서 네이티브를 쓰는 진짜 이유는 대개 OS 기능 접근(캡처·후킹·드라이버)이지 순수 계산이 아닙니다. 계산이 목적이라면 IPC 비용이 이득을 깎아먹지 않는지 먼저 재 보세요.

한 단계 더 — 호환성을 지키는 설계 🧬 전문가

napi_version 을 낮게 잡기

// 필요한 최소 버전만 요구하면 더 많은 런타임을 덮습니다
#define NAPI_VERSION 8        // Node 16+ · Electron 대부분

// 상위 버전 기능을 조건부로 쓰기
#if NAPI_VERSION >= 9
    node_api_symbol_for(...);          // napi_version 9 에서 추가된 API
#endif

// 런타임에 지원 여부 확인
uint32_t ver;
napi_get_version(env, &ver);
if (ver >= 9) { /* 새 기능 */ } else { /* 대체 경로 */ }

// 실험적 기능은 피하세요
#define NAPI_EXPERIMENTAL     // ⚠ 다음 버전에서 바뀔 수 있습니다

// node-addon-api 도 버전을 맞춰야 합니다
// package.json
"dependencies": { "node-addon-api": "^8.0.0" }
// binding.gyp
"defines": ["NAPI_VERSION=8", "NODE_ADDON_API_DISABLE_DEPRECATED"]

호환성 매트릭스 테스트

# 한 번 빌드한 바이너리가 여러 런타임에서 도는지 ★
jobs:
  compat:
    needs: prebuild
    runs-on: ubuntu-latest
    strategy:
      matrix:
        runtime:
          - { name: node, ver: '18' }
          - { name: node, ver: '20' }
          - { name: node, ver: '22' }
          - { name: electron, ver: '28' }
          - { name: bun, ver: 'latest' }
    steps:
      - uses: actions/download-artifact@v4      # 빌드된 바이너리
      - name: 로드 테스트
        run: |
          # 재빌드 없이 그대로 로드되어야 합니다
          npx ${{ matrix.runtime.name }} -e "
            const m = require('./index.js')
            console.log(m.buildInfo)
            console.assert(m.smooth(new Float64Array([1,2,3]), 3).length === 3)
          "
이 테스트가 N-API 의 값어치를 증명합니다 — 한 번 빌드한 바이너리가 Node 18~22 와 Electron 에서 재빌드 없이 그대로 도는 것을 CI 가 계속 확인해 줍니다. nan 기반이었다면 조합마다 별도 빌드가 필요했습니다.
정리 — N-API 로 만들면 ABI 문제가 대부분 사라집니다 — Node 버전이 올라가도, Electron 에서도 재빌드가 필요 없습니다. Electron 에서는 메인 프로세스에서만 로드하고 IPC 로 노출하며, asarUnpack 을 잊지 마세요. 크래시가 앱을 죽이는 것이 문제라면 utilityProcess 로 격리하세요.
일렉트론에서만 안 되는 이유 — 품고 있는 Node 가 다릅니다 시스템 Node 여기에 맞춰 빌드한 애드온 일렉트론이 품은 Node ABI 버전이 다릅니다 → 로드 실패 해결 ① 대상 지정해 재빌드 electron-rebuild 로 일렉트론 버전에 맞춤 일렉트론을 올릴 때마다 다시 해야 합니다 해결 ② 처음부터 N-API 로 ABI 가 안정적이라 버전에 묶이지 않습니다 새로 만든다면 거의 항상 이쪽 개발은 x64 인데 배포는 arm64 인 경우도 흔합니다 — 실제 배포 대상에서 반드시 확인하세요 렌더러가 아니라 메인 프로세스에서 로드하세요 — 렌더러에는 Node 권한을 주지 않는 것이 원칙입니다
"내 PC 에선 되는데 앱에선 안 된다" 의 흔한 정체가 ABI 버전입니다.
extern "C" · ctypes · 불투명 포인터 · 소유권 규약

한 번 만들어
모든 언어에서
부릅니다

바인딩 라이브러리 없이, 컴파일도 없이 파이썬에서 .dll·.so 를 바로 부르는 길입니다. 문법은 언어마다 달라도 C 함수를 부르는 법은 모든 언어가 압니다. 그래서 C ABI 가 언어 사이의 공용어입니다.

10SECTIONS
0바인딩 라이브러리
6+부를 수 있는 언어
4깊이 층
FFI 01

C ABI 가 공용어인 이유

C++ 의 클래스도, 템플릿도, 예외도 다른 언어는 이해하지 못합니다. 모든 언어가 공통으로 아는 것은 “C 함수를 부르는 방법” 하나뿐입니다.

왜 모두가 C 로 이야기하는가 — C ABI 가 공용어인 이유 Python Node C# Java Go Rust C ABI — 함수 이름 · 인자 전달 규칙 · 구조체 배치만 정한 최소 규약 1970년대에 정해진 뒤 거의 바뀌지 않았고, 모든 OS 와 모든 언어가 이것만은 지킵니다 libmine.so · libmine.dll C++ 를 그대로 내보내지 못하는 네 가지 이유 ① 이름 맹글링이 컴파일러마다 다름 ② 예외가 경계를 못 넘음 ③ 템플릿은 실체가 없음 ④ 표준 라이브러리 구현이 다름
그래서 공개하는 함수만 extern "C" 로 감쌉니다 — 안은 마음껏 C++ 를 써도 됩니다.

ABI 란 무엇인가

API : 소스 코드 수준의 약속 — "이 함수는 이런 인자를 받는다"
ABI : 기계어 수준의 약속 — 실제로 어떻게 부르는가

  · 인자를 어느 레지스터·스택 위치에 놓는가
  · 반환값은 어디로 받는가
  · 스택 정리는 누가 하는가
  · 구조체는 메모리에 어떻게 배치되는가
  · 함수 이름이 심볼로 어떻게 기록되는가

C ABI 는 이 규칙이 OS·아키텍처마다 '문서로 고정'되어 있습니다.
그래서 어떤 언어로 만들었든 이 규칙만 지키면 서로 부를 수 있습니다.

★ C++ 는 표준 ABI 가 없습니다
  같은 리눅스에서도 GCC 와 Clang 의 이름 맹글링·예외 구현이 다를 수 있고,
  MSVC 는 아예 다릅니다. 그래서 C++ 클래스를 그대로 노출하면
  '같은 컴파일러로 빌드한 경우에만' 동작합니다(🔬 d07).

두 가지 길 — 무엇을 고를까

바인딩(pybind11)C ABI + FFI
대상 언어파이썬 전용거의 모든 언어
빌드파이썬 버전마다한 번만 ★
표현력클래스·예외·numpy함수·숫자·포인터만
타입 안전성컴파일러가 검사없음 — 틀리면 크래시
호출 비용낮음ctypes 는 조금 큼
설치wheel 빌드 필요파일 하나 복사
실무에서의 선택 — 파이썬만 쓴다면 pybind11 이 압도적으로 편합니다(🐍 탭). 여러 언어에서 써야 하거나, 이미 있는 .dll/.so 를 부른다면 C ABI 가 답입니다. 둘을 같이 제공하는 라이브러리도 많습니다 — 핵심은 C API 로 만들고, 파이썬용 편의 래퍼를 pybind11 로 얹는 형태입니다.

이렇게도 쓸 수 있다 — C ABI 로 표현할 수 있는 것 🟡 중급

넘길 수 있는 것 / 없는 것

// ✅ 안전하게 넘길 수 있는 것
int32_t  int64_t  uint8_t  double  float      // 고정폭 숫자
bool                                          // ⚠ 크기가 언어마다 다를 수 있음
const char*                                   // 널 종료 문자열
void*                                         // 불투명 핸들 ★
struct { int32_t a; double b; }               // 표준 배치 구조체
int (*)(int, void*)                           // 함수 포인터 (콜백)
포인터 + 길이                                  // 배열 (항상 이 쌍으로) ★

// ❌ 넘길 수 없는 것 (C++ 내부에만 두세요)
std::string  std::vector  std::map            // 구현이 컴파일러마다 다름
클래스 · 상속 · 가상 함수                       // vtable 배치가 표준이 아님
템플릿                                         // 컴파일 시점 개념
예외                                           // 경계를 넘으면 UB ★
참조(&)                                        // 포인터로 바꾸세요
오버로딩                                        // 이름이 하나여야 함

// 그래서 C API 는 이런 모양이 됩니다
extern "C" {
    int32_t mylib_smooth(const double* in, int64_t n,
                         double* out, int32_t window);
}

C++ 구현을 C API 로 감싸기

// ── 내부는 자유롭게 현대 C++ 로
namespace impl {
std::vector<double> smooth(std::span<const double> in, int window);
}

// ── 밖으로는 C 함수 하나만
extern "C" int32_t mylib_smooth(const double* in, int64_t n,
                                double* out, int32_t window) {
    if (!in || !out || n < 0)      return MYLIB_ERR_INVALID;
    if (window <= 0 || window > n) return MYLIB_ERR_INVALID;

    try {                                        // ★ 예외를 여기서 전부 잡기
        auto r = impl::smooth({in, static_cast<std::size_t>(n)}, window);
        std::memcpy(out, r.data(), r.size() * sizeof(double));
        return MYLIB_OK;
    } catch (const std::bad_alloc&) {
        return MYLIB_ERR_NOMEM;
    } catch (...) {
        return MYLIB_ERR_UNKNOWN;                // 절대 밖으로 안 나가게
    }
}
이 형태가 규칙입니다 — 얇은 C 껍질 + 풍부한 C++ 내부. 껍질에서 하는 일은 셋뿐입니다 — 인자 검증 · 예외를 에러 코드로 변환 · 메모리 복사.

실무에선 이렇게 — 누가 이 방식을 쓰나 🔴 실무

우리가 매일 쓰는 것들이 이 구조입니다

  • SQLite — C API 하나로 파이썬·자바·Go·러스트·모바일 전부가 씁니다
  • OpenSSL · zlib · libcurl — 시스템 라이브러리의 표준 형태
  • CUDA · OpenCL — GPU 드라이버 인터페이스
  • ffmpeg(libav*) — 거의 모든 영상 처리 도구의 뿌리
  • ONNX Runtime · llama.cpp — C++ 로 만들고 C API 로 노출한 최신 사례
  • OS API 전부 — Win32 · POSIX · 시스템 콜
공통점 — 수명이 긴 라이브러리일수록 C API 를 택합니다. 언어 하나에 묶이지 않고, 수십 년 뒤에도 그대로 부를 수 있기 때문입니다. 사내 공용 엔진을 만든다면 진지하게 고려할 만한 이유입니다.

설계할 때 먼저 정할 것

/* ① 이름 규칙 — 접두어를 반드시 붙입니다 (전역 심볼이므로) */
mylib_init  mylib_free  mylib_smooth        /* ✅ */
init  process                                /* ❌ 다른 라이브러리와 충돌 */

/* ② 에러 전달 방식 — 반환값 하나로 통일 */
typedef enum {
    MYLIB_OK = 0,
    MYLIB_ERR_INVALID = -1,
    MYLIB_ERR_NOMEM   = -2,
    MYLIB_ERR_IO      = -3,
} mylib_status;

/* ③ 메모리 소유권 — 문서에 못 박기 (f07) */
/*    "돌려준 포인터는 mylib_free() 로 해제해야 합니다" */

/* ④ 버전 — 구조체와 함수에 (f09) */
int32_t mylib_version(void);        /* 10203 = 1.2.3 */

/* ⑤ 스레드 안전성 — 명시 */
/*    "핸들 하나를 여러 스레드에서 동시에 쓰면 안 됩니다" */

한 단계 더 — 호출 규약과 스택 🧬 전문가

인자가 실제로 어디로 가나

x86-64 System V (리눅스 · 맥)
  정수·포인터 인자 : RDI, RSI, RDX, RCX, R8, R9  순서대로
  실수 인자        : XMM0~XMM7
  나머지           : 스택
  반환             : RAX (정수) · XMM0 (실수)
  큰 구조체        : 호출자가 공간을 잡고 그 주소를 숨은 첫 인자로

Windows x64
  정수 인자 : RCX, RDX, R8, R9  (4개만!)
  실수 인자 : XMM0~XMM3
  ★ 레지스터 순서가 다릅니다 — 그래서 규약이 안 맞으면 인자가 뒤섞입니다

32비트 시절의 유산 (윈도우 DLL 에서 아직 보입니다)
  __cdecl    : 호출자가 스택 정리 (C 기본)
  __stdcall  : 피호출자가 정리 (Win32 API)
  → 안 맞으면 스택이 망가져 호출 직후 크래시합니다
  → 64비트에서는 규약이 하나로 통일돼 이 문제가 거의 사라졌습니다

직접 확인하기

# 어셈블리로 인자 배치 보기
g++ -O2 -S -masm=intel -o - test.cpp | head -30
#   mov edi, ...  ← 첫 인자가 RDI 로 들어가는 것이 보입니다

# godbolt.org 에서 컴파일러를 바꿔 가며 비교하면 더 명확합니다

# 심볼이 C 이름인지 확인 (맹글링 여부)
nm -D mylib.so | grep mylib_
#   T mylib_smooth        ← extern "C" 가 적용된 모습 ✅
#   T _Z12mylib_smoothPKdlPdi  ← C++ 맹글링된 이름 ❌

# 윈도우
dumpbin /exports mylib.dll

# 함수 포인터로 직접 부르기 (dlopen 경로)
#include <dlfcn.h>
void* h = dlopen("./mylib.so", RTLD_NOW);
auto f = reinterpret_cast<int32_t(*)(const double*,int64_t,double*,int32_t)>(
             dlsym(h, "mylib_smooth"));
f(in, n, out, 3);
dlclose(h);
왜 알아야 하나 — FFI 에서 인자 타입을 잘못 선언하면 컴파일러도 인터프리터도 잡아 주지 못하고 실행 중에 이상한 값이나 크래시로 나타납니다. 규약을 알면 “왜 세 번째 인자만 쓰레기 값이 오는가” 같은 증상을 추론할 수 있게 됩니다.
정리 — C ABI 는 언어 사이의 유일한 공용어입니다. C++ 클래스·템플릿·예외는 넘길 수 없고, 숫자 · 포인터 · 함수 포인터 · 표준 배치 구조체만 넘어갑니다. 구조는 항상 같습니다 — 얇은 extern "C" 껍질 + 자유로운 C++ 내부.
FFI 02

extern "C" 와 이름 맹글링

C++ 컴파일러는 함수 이름을 내부적으로 바꿔 버립니다. 오버로딩 때문에 그래야 하는데, 그러면 다른 언어가 찾지 못합니다.

이름이 어떻게 바뀌나

void f(int);
void f(double);
void f(int, char*);
//  세 함수의 이름이 전부 "f" 라면 링커가 구분할 수 없습니다.
//  그래서 컴파일러가 인자 타입을 이름에 인코딩합니다.

//   _Z1fi          ← f(int)          (GCC/Clang · Itanium ABI)
//   _Z1fd          ← f(double)
//   _Z1fiPc        ← f(int, char*)
//   ?f@@YAXH@Z     ← MSVC 는 형식이 완전히 다릅니다

// extern "C" 를 붙이면 맹글링하지 않습니다
extern "C" void g(int);      // 심볼 이름이 그냥 "g" (또는 "_g")
//   → 대신 오버로딩을 쓸 수 없습니다 (이름이 하나여야 하므로)

// 헤더 전체에 적용하는 관용구
#ifdef __cplusplus
extern "C" {
#endif

int32_t mylib_smooth(const double*, int64_t, double*, int32_t);

#ifdef __cplusplus
}
#endif
//   ★ 이 헤더를 C 에서 include 하면 extern "C" 가 없고,
//     C++ 에서 include 하면 적용됩니다 — 양쪽에서 쓸 수 있게 됩니다

확인하고 진단하기

# 심볼 목록 (-C 는 사람이 읽을 수 있게 복원)
nm -D --defined-only mylib.so | grep mylib
nm -DC --defined-only mylib.so | grep mylib     # 복원된 이름

# 맹글링된 이름을 원래대로
echo "_Z1fi" | c++filt          # → f(int)

# 윈도우
dumpbin /exports mylib.dll
undname "?f@@YAXH@Z"

# ★ FFI 에서 "심볼을 못 찾음" 이 나오면 첫 번째로 이걸 확인합니다
python -c "
import ctypes
lib = ctypes.CDLL('./mylib.so')
lib.mylib_smooth           # AttributeError 면 심볼 이름 문제
"
가장 흔한 실수 — 헤더에는 extern "C" 를 썼는데 .cpp 에서 그 헤더를 include 하지 않고 함수를 정의한 경우입니다. 그러면 정의 쪽은 맹글링되어 선언과 정의가 서로 다른 심볼이 됩니다. 구현 파일에서 반드시 자기 헤더를 include 하세요.

이렇게도 쓸 수 있다 — 내보내기와 가시성 🟡 중급

세 OS 를 하나의 매크로로

// mylib_export.h
#pragma once

#if defined(_WIN32)
  #ifdef MYLIB_BUILDING              // 라이브러리를 빌드할 때 정의
    #define MYLIB_API __declspec(dllexport)
  #else                              // 쓰는 쪽에서 include 할 때
    #define MYLIB_API __declspec(dllimport)
  #endif
#else
  #define MYLIB_API __attribute__((visibility("default")))
#endif

#ifdef __cplusplus
  #define MYLIB_EXTERN extern "C"
#else
  #define MYLIB_EXTERN
#endif

#define MYLIB_EXPORT MYLIB_EXTERN MYLIB_API

// 사용
MYLIB_EXPORT int32_t mylib_smooth(const double*, int64_t, double*, int32_t);
add_library(mylib SHARED src/api.cpp)
target_compile_definitions(mylib PRIVATE MYLIB_BUILDING)
set_target_properties(mylib PROPERTIES
    CXX_VISIBILITY_PRESET hidden        # ★ 기본은 숨기고
    VISIBILITY_INLINES_HIDDEN ON)       #   표시한 것만 내보내기

내보낼 것을 명시적으로 통제하기

# 리눅스 — 버전 스크립트로 심볼 목록을 고정
# mylib.map
MYLIB_1.0 {
  global:
    mylib_*;          # 이것만 내보냅니다
  local:
    *;                # 나머지는 전부 숨김
};
target_link_options(mylib PRIVATE
    "-Wl,--version-script=${CMAKE_CURRENT_SOURCE_DIR}/mylib.map")
왜 숨기는가

  ① 심볼이 적을수록 로딩이 빠릅니다 (동적 링커의 재배치 작업 감소)
  ② 내부 심볼이 다른 라이브러리와 충돌하지 않습니다 ★
     — 같은 프로세스에 두 확장 모듈이 같은 이름을 내보내면
       먼저 로드된 쪽이 나중 것을 가릴 수 있습니다 (아래 인터포지션)
  ③ 컴파일러가 더 공격적으로 최적화할 수 있습니다
     (밖에서 부를 수 없다면 인라인·제거가 자유로워집니다)
  ④ API 표면이 문서와 일치하게 유지됩니다

실무에선 이렇게 — 링크 에러를 심볼 수준에서 읽기 🔴 실무

증상별 진단

증상원인과 확인
undefined reference to 'f'
(맹글링 안 된 이름)
C 로 선언했는데 C++ 로 정의. nm 로 실제 심볼 확인
undefined reference to '_Z1fi'
(맹글링된 이름)
C++ 로 선언했는데 extern "C" 로 정의했거나 그 반대
ctypes AttributeError심볼이 안 내보내짐 — 가시성·__declspec 확인
윈도우 LNK2019dllimport/dllexport 불일치
로드는 되는데 호출 시 크래시인자 타입·개수 불일치(f04) 또는 호출 규약
# 진단 3종 세트
nm -DC mylib.so | grep 함수이름       # 있는가 · 이름이 맞는가
ldd mylib.so                          # 의존 라이브러리가 다 있는가
LD_DEBUG=bindings python app.py 2>&1 | grep 함수이름   # 실제로 뭘 찾는지

C 와 C++ 를 함께 쓰는 프로젝트

// C 라이브러리 헤더를 C++ 에서 include 할 때
// (헤더에 extern "C" 가드가 없는 옛 라이브러리라면 직접 감쌉니다)
extern "C" {
#include "old_c_lib.h"
}

// 반대로, C++ 로 만든 것을 C 에서 쓰게 하려면
// ① 헤더에는 C 문법만 (클래스·네임스페이스·기본 인자 금지)
// ② 불투명 포인터로 객체를 감춤 (f03)
// ③ C++ 표준 라이브러리를 정적 링크하거나 의존성 명시

// C 링커로 링크할 때는 C++ 런타임을 직접 붙여야 합니다
//   gcc main.c -lmylib -lstdc++      ← C++ 표준 라이브러리 명시
//   또는 라이브러리를 g++ 로 링크해 런타임을 안에 포함

// 전역 객체 초기화에 주의 ★
//   C++ 전역 객체의 생성자는 라이브러리 로드 시점에 불립니다.
//   그 안에서 예외가 나거나 다른 라이브러리를 쓰면
//   초기화 순서 문제로 crash 가 납니다.
//   → 지연 초기화(함수 안 static)로 피하세요
Engine& instance() {
    static Engine e;          // 첫 호출 때 만들어집니다 (스레드 안전)
    return e;
}

한 단계 더 — 심볼 해석의 실제 순서 🧬 전문가

동적 링커가 하는 일

프로그램 시작 시
  ① 실행 파일의 DT_NEEDED 목록을 읽어 필요한 .so 를 찾습니다
     탐색 순서: DT_RPATH → LD_LIBRARY_PATH → DT_RUNPATH
                → /etc/ld.so.cache → 기본 경로
  ② 각 .so 를 메모리에 매핑
  ③ 심볼을 해석(binding) — 기본은 지연(lazy), 첫 호출 때 결정
  ④ 초기화 함수 실행 (C++ 전역 생성자 포함)

★ 전역 심볼 인터포지션
  먼저 로드된 라이브러리의 심볼이 나중 것을 '가립니다'.
  두 확장 모듈이 같은 이름의 전역 함수를 내보내면
  둘 다 '먼저 로드된 쪽'을 부르게 됩니다 — 조용한 버그의 원인 ★

확인
  LD_DEBUG=libs    python app.py    # 어떤 파일을 어디서 찾았는지
  LD_DEBUG=bindings python app.py   # 어느 심볼이 어디에 묶였는지
  ldd -r mylib.so                   # 해석되지 않은 심볼 표시

RPATH 와 배포

# 내 .so 옆에 있는 의존 라이브러리를 찾게 하기
set_target_properties(mylib PROPERTIES
    INSTALL_RPATH "$ORIGIN"          # 리눅스: 자기 위치 기준
    BUILD_WITH_INSTALL_RPATH TRUE)
# 맥은 @loader_path
if(APPLE)
  set_target_properties(mylib PROPERTIES INSTALL_RPATH "@loader_path")
endif()
# 확인·수정
readelf -d mylib.so | grep -E "RPATH|RUNPATH|NEEDED"
patchelf --set-rpath '$ORIGIN' mylib.so          # 사후 수정
otool -L mylib.dylib                              # 맥
install_name_tool -change 옛경로 @loader_path/새경로 mylib.dylib

# 윈도우는 RPATH 가 없습니다 — DLL 을 같은 폴더에 두거나
# AddDllDirectory / os.add_dll_directory() 를 씁니다
python -c "
import os, ctypes
os.add_dll_directory(r'C:\\mylib\\bin')    # 파이썬 3.8+ 에서 필수 ★
ctypes.CDLL('mylib.dll')
"
윈도우 파이썬 3.8+ 의 함정 — 보안 강화로 PATH 에 있어도 DLL 을 자동으로 찾지 않습니다. os.add_dll_directory() 를 명시적으로 불러야 합니다. “리눅스에선 되는데 윈도우에서만 못 찾는” 문제의 단골 원인입니다.
정리 — C++ 는 오버로딩 때문에 함수 이름을 바꿉니다(맹글링). 다른 언어가 찾을 수 있게 하려면 extern "C" 를 붙이고, __declspec(dllexport) / visibility("default") 로 내보내야 합니다. 문제가 생기면 nm -DC 로 실제 심볼 이름부터 확인하세요.
이름 맹글링 — 밖에서 부르려면 꺼야 합니다 C++ 기본 — 이름이 바뀝니다 int add(int, int) 가 _Z3addii 같은 이름으로 나갑니다 같은 이름의 오버로딩을 구분하기 위해서인데, 규칙이 컴파일러마다 달라 예측할 수 없습니다 extern "C" — 이름을 그대로 내보냅니다 add 라는 이름 그대로 나갑니다 — 파이썬 · C# · 자바 · Go 어디서든 이 이름으로 찾습니다 대신 그 함수에서는 오버로딩과 템플릿을 쓸 수 없습니다 (이름이 하나뿐이므로) 안쪽은 C++ 그대로 쓰고, 바깥으로 내보내는 얇은 층에만 extern "C" 를 붙입니다 헤더에서는 __cplusplus 로 감싸 두면 C 와 C++ 양쪽에서 그대로 포함할 수 있습니다 실제로 어떤 이름이 나갔는지는 nm 이나 dumpbin 으로 확인할 수 있습니다 — 못 찾을 때 먼저 보세요
FFI 의 첫 관문은 타입이 아니라 이름이 그대로 나가는가입니다.
FFI 03

헤더 설계 — 불투명 포인터

C++ 객체를 다른 언어에 노출하는 표준 방법입니다. 내부는 감추고 포인터 하나만 넘깁니다 — 라이브러리 배포의 정석입니다.

헤더에 구조체 내용을 적으면 안 되는 이유 ① 구조체를 헤더에 공개 typedef struct { int a; int b; } Conn; 쓰는 쪽이 크기 8 을 소스에 박아 넣습니다 필드를 하나 추가 → 크기가 12 로 이미 배포된 프로그램은 엉뚱한 자리를 읽습니다 쓰는 쪽 전부를 다시 컴파일해야 합니다 ② 불투명 포인터 typedef struct Conn Conn; // 내용 없음 쓰는 쪽은 Conn* 만 들고 다닙니다 필드를 열 개 추가해도 아무 영향 없음 크기를 모르니 잘못 계산할 수도 없습니다 .so 만 바꿔 끼워도 그대로 돕니다 불투명 포인터를 쓸 때 함께 제공해야 하는 것 conn_create() 만들어서 포인터를 돌려줌 conn_get_id(c) 필드 접근은 함수로만 conn_free(c) 만든 쪽이 지운다
라이브러리를 버전을 올려도 안 깨지게 만드는 가장 확실한 방법입니다.

핸들 패턴

/* ── mylib.h — 쓰는 쪽이 보는 전부 ─────────────── */
#ifndef MYLIB_H
#define MYLIB_H
#include <stdint.h>
#include "mylib_export.h"

typedef struct mylib_engine mylib_engine;   /* ★ 정의 없이 이름만 */

MYLIB_EXPORT mylib_engine* mylib_engine_create(int32_t window);
MYLIB_EXPORT void          mylib_engine_destroy(mylib_engine* e);

MYLIB_EXPORT int32_t mylib_engine_feed(mylib_engine* e,
                                       const double* data, int64_t n);
MYLIB_EXPORT int32_t mylib_engine_result(const mylib_engine* e,
                                         double* out, int64_t* out_n);
#endif
// ── api.cpp — 구현 ────────────────────────────
#include "mylib.h"
#include "engine.hpp"          // 진짜 C++ 클래스

// 불완전 타입 mylib_engine 을 실제 클래스로 정의
struct mylib_engine {
    impl::Engine e;            // 안에 무엇이 있든 밖에서는 모릅니다
};

extern "C" mylib_engine* mylib_engine_create(int32_t window) {
    if (window <= 0) return nullptr;
    try { return new mylib_engine{impl::Engine(window)}; }
    catch (...) { return nullptr; }
}

extern "C" void mylib_engine_destroy(mylib_engine* e) {
    delete e;                  // nullptr 도 안전합니다
}

왜 이 구조인가

  • 내부를 바꿔도 쓰는 쪽이 다시 컴파일할 필요가 없습니다 — 구조체 크기가 헤더에 없으므로 ABI 가 안 깨집니다(🔬 d07)
  • C++ 표준 라이브러리가 헤더에 새어 나가지 않습니다 — std::vector 를 헤더에 쓰면 C 에서 못 씁니다
  • 수명 관리가 명확합니다 — create/destroy 쌍
  • 모든 언어에서 같은 모양으로 감쌀 수 있습니다
/* 타입 안전성을 조금 더 — void* 대신 이름 있는 구조체 포인터 */
typedef struct mylib_engine  mylib_engine;
typedef struct mylib_session mylib_session;
/*  둘을 바꿔 넣으면 C 컴파일러가 경고를 줍니다.
    void* 로 하면 아무것도 못 잡습니다. ★                */
SQLite 가 정확히 이 형태입니다 — sqlite3*, sqlite3_stmt* 가 불투명 핸들이고, sqlite3_open/sqlite3_close 가 수명을 관리합니다. 20년 넘게 ABI 를 유지할 수 있었던 이유이기도 합니다.

이렇게도 쓸 수 있다 — API 형태 다듬기 🟡 중급

출력 크기를 모를 때의 관용구

/* 2단계 호출 — 먼저 크기를 묻고, 버퍼를 잡아 다시 부릅니다 ★ */
MYLIB_EXPORT int32_t mylib_result(const mylib_engine* e,
                                  double* out, int64_t* n);
/*  out == NULL 이면 필요한 크기만 *n 에 채워 돌려줍니다  */

/* 쓰는 쪽 */
int64_t n = 0;
mylib_result(e, NULL, &n);              /* ① 크기 조회 */
double* buf = malloc(n * sizeof(double));
mylib_result(e, buf, &n);               /* ② 실제 채우기 */
free(buf);

/* 대안 — 라이브러리가 할당하고 전용 해제 함수를 제공 (f07) */
MYLIB_EXPORT int32_t mylib_result_alloc(const mylib_engine* e,
                                        double** out, int64_t* n);
MYLIB_EXPORT void    mylib_free(void* p);
/*  ★ 반드시 같은 라이브러리의 free 를 쓰게 해야 합니다
    (윈도우에서 DLL 마다 힙이 달라 malloc/free 가 어긋나면 크래시) */

구조체를 주고받을 때

/* 설정처럼 값이 많으면 구조체가 편합니다 */
typedef struct {
    int32_t version;        /* ★ 첫 필드는 버전 (f09) */
    int32_t window;
    int32_t threads;
    double  tolerance;
} mylib_config;

MYLIB_EXPORT void    mylib_config_init(mylib_config* c);  /* 기본값 채우기 */
MYLIB_EXPORT int32_t mylib_run(const mylib_config* c, ...);

/* 쓰는 쪽 — init 을 부르게 하면 새 필드가 생겨도 안전합니다 */
mylib_config cfg;
mylib_config_init(&cfg);
cfg.window = 5;
mylib_run(&cfg, ...);

/* 규칙
   · 필드는 뒤에만 추가 (순서 변경·삭제 금지)
   · 크기가 고정된 타입만 (int32_t · double · 포인터)
   · bool 대신 int32_t (언어마다 bool 크기가 다름) ★
   · 포인터 정렬을 위해 큰 것부터 배치하면 패딩이 줄어듭니다 */

실무에선 이렇게 — 쓰기 좋은 C API 의 조건 🔴 실무

체크리스트

  • 모든 함수에 접두어 — mylib_
  • create/destroy 쌍이 명확하고, destroy(NULL) 이 안전한가
  • 에러는 반환값으로, 값은 출력 인자로(f09)
  • 포인터 + 길이를 항상 쌍으로 (배열만 넘기지 않기)
  • const 를 정확히 — 입력은 const T*
  • 예외가 절대 새지 않게 — 모든 extern "C" 함수에 try/catch
  • 스레드 안전성 문서화 — 핸들 공유 가능 여부
  • 버전 함수 — mylib_version()
  • 재진입 가능 — 전역 상태를 두지 않기
전역 상태는 특히 위험합니다 — 파이썬에서 여러 스레드가 부르거나, 같은 프로세스에 여러 사용자가 있으면 서로 간섭합니다. 상태는 전부 핸들 안에 두세요.

테스트를 C 로도 작성하기

/* tests/test_c_api.c — 헤더가 정말 C 에서 컴파일되는지 검증 ★ */
#include "mylib.h"
#undef NDEBUG                  /* ★ Release(NDEBUG) 빌드에서도 assert 안의 호출이 사라지지 않게 */
#include <assert.h>
#include <stdio.h>

int main(void) {
    mylib_engine* e = mylib_engine_create(3);
    assert(e != NULL);

    double data[] = {1, 2, 3, 4, 5};
    assert(mylib_engine_feed(e, data, 5) == MYLIB_OK);

    double out[5]; int64_t n = 5;
    assert(mylib_engine_result(e, out, &n) == MYLIB_OK);

    mylib_engine_destroy(e);
    mylib_engine_destroy(NULL);        /* NULL 도 안전해야 합니다 */

    assert(mylib_engine_create(-1) == NULL);   /* 잘못된 인자 */
    printf("모두 통과\n");
    return 0;
}
# ★ C 컴파일러로 빌드해야 의미가 있습니다
enable_language(C)
add_executable(test_c_api tests/test_c_api.c)
target_link_libraries(test_c_api PRIVATE mylib)
add_test(NAME c_api COMMAND test_c_api)

한 단계 더 — 핸들을 안전하게 만들기 🧬 전문가

잘못된 핸들 방어

// FFI 경계에서는 사용자가 아무 값이나 넘길 수 있습니다.
// 해제된 핸들·엉뚱한 포인터·정수를 그대로 넘기는 일이 실제로 일어납니다.

struct mylib_engine {
    uint32_t magic;              // ★ 검증용 서명
    impl::Engine e;
};
static constexpr uint32_t kMagic = 0x4D594C42;   // 'MYLB'

static impl::Engine* check(mylib_engine* h) {
    if (!h || h->magic != kMagic) return nullptr;
    return &h->e;
}

extern "C" int32_t mylib_engine_feed(mylib_engine* h,
                                     const double* d, int64_t n) {
    auto* e = check(h);
    if (!e) return MYLIB_ERR_INVALID_HANDLE;      // 크래시 대신 에러 ★
    ...
}

extern "C" void mylib_engine_destroy(mylib_engine* h) {
    if (!check(h)) return;       // ★ 이미 해제됐거나 엉뚱한 핸들이면 무시
    h->magic = 0;                // ★ 해제 표시 → 이중 해제를 (대개) 잡아냅니다
    delete h;                    //   해제된 메모리를 읽는 것 자체는 UB 라 보장은 아닙니다
}

// 더 강한 방법 — 핸들을 '정수 ID' 로 주고 내부 테이블에서 조회
//   포인터를 아예 노출하지 않아 잘못된 주소 접근이 불가능해집니다
//   (대신 조회 비용과 락이 필요합니다)

스레드 안전성 설계

/* 문서에 반드시 명시할 세 가지 등급 */

/* ① 스레드 안전 없음 (기본)
      "하나의 핸들을 여러 스레드에서 동시에 쓰면 안 됩니다.
       서로 다른 핸들은 동시에 써도 됩니다."       ← 가장 흔한 형태 */

/* ② 읽기 동시 가능
      "여러 스레드가 동시에 읽을 수 있습니다.
       쓰는 동안에는 다른 접근이 없어야 합니다."   ← const 함수만 */

/* ③ 완전 스레드 안전
      "모든 함수를 동시에 호출할 수 있습니다."     ← 내부 락 필요 */

/* 라이브러리 전역 초기화가 필요하면 명시 */
MYLIB_EXPORT int32_t mylib_global_init(void);    /* 한 번만 · 스레드 하나에서 */
MYLIB_EXPORT void    mylib_global_shutdown(void);
파이썬에서 쓸 때 중요합니다 — GIL 이 있으면 ctypes 호출이 사실상 직렬화되지만, ctypes 는 호출 중 GIL 을 해제합니다. 즉 C 함수는 진짜로 동시에 실행될 수 있습니다. “파이썬이니까 안전하겠지”는 FFI 에서는 성립하지 않습니다.
정리 — C++ 객체는 불투명 포인터(핸들)로 넘깁니다. 헤더에는 typedef struct mylib_engine mylib_engine; 처럼 이름만 두고, 실제 정의는 .cpp 안에. create/destroy 쌍과 포인터+길이 쌍, 이 두 관용구가 C API 설계의 기본입니다.
FFI 04

파이썬 ctypes 로 바로 부르기

설치할 것도, 빌드할 것도 없습니다. 파이썬 표준 라이브러리만으로 .dll·.so 를 즉시 불러 씁니다.

5분 만에 부르기

import ctypes, numpy as np
from ctypes import c_double, c_int32, c_int64, POINTER

# ① 라이브러리 로드
lib = ctypes.CDLL("./libmylib.so")        # 리눅스
# lib = ctypes.CDLL("./mylib.dll")        # 윈도우
# lib = ctypes.CDLL("./libmylib.dylib")   # 맥

# ② ★ 시그니처를 반드시 선언하세요 (이걸 빼면 크래시의 원인)
lib.mylib_smooth.argtypes = [
    POINTER(c_double),   # const double* in
    c_int64,             # int64_t n
    POINTER(c_double),   # double* out
    c_int32,             # int32_t window
]
lib.mylib_smooth.restype = c_int32        # 반환 타입

# ③ numpy 배열을 그대로 넘깁니다 — 복사 없음 ★
a   = np.random.rand(1_000_000)
out = np.empty_like(a)

rc = lib.mylib_smooth(
    a.ctypes.data_as(POINTER(c_double)),
    a.size,
    out.ctypes.data_as(POINTER(c_double)),
    3,
)
if rc != 0:
    raise RuntimeError(f"mylib_smooth 실패 (코드 {rc})")
argtypes 를 빼면 안 됩니다 — 선언하지 않으면 ctypes 가 모든 인자를 int 로 추측합니다. 64비트에서 포인터가 잘리거나 double 이 정수 레지스터로 들어가 즉시 크래시하거나 쓰레기 값이 됩니다. “가끔 되고 가끔 안 되는” FFI 코드의 1순위 원인입니다.

타입 대응표

Cctypes
int32_tc_int32
int64_t · size_tc_int64 · c_size_t
double · floatc_double · c_float
uint8_tc_uint8
const char*c_char_p ★
void* · 핸들c_void_p
double*POINTER(c_double)
boolc_bool — 크기 주의
구조체class X(Structure)
함수 포인터CFUNCTYPE(...)
출력 인자byref(x)
# 출력 인자 받기
n = c_int64()
lib.mylib_result(handle, None, ctypes.byref(n))   # 크기 조회
print(n.value)

# 문자열
lib.mylib_name.restype = c_char_p
name = lib.mylib_name().decode("utf-8")           # bytes → str ★

이렇게도 쓸 수 있다 — 핸들과 구조체 다루기 🟡 중급

불투명 핸들을 파이썬 클래스로

import ctypes
from ctypes import c_void_p, c_int32, c_int64, c_double, POINTER

lib.mylib_engine_create.argtypes  = [c_int32]
lib.mylib_engine_create.restype   = c_void_p        # ★ 불투명 핸들
lib.mylib_engine_destroy.argtypes = [c_void_p]
lib.mylib_engine_destroy.restype  = None
lib.mylib_engine_feed.argtypes    = [c_void_p, POINTER(c_double), c_int64]
lib.mylib_engine_feed.restype     = c_int32

class Engine:
    """C 핸들을 파이썬 객체로 감쌉니다."""

    def __init__(self, window: int = 3):
        self._h = lib.mylib_engine_create(window)
        if not self._h:
            raise ValueError(f"엔진 생성 실패 (window={window})")

    def feed(self, data) -> None:
        a = np.ascontiguousarray(data, dtype=np.float64)
        rc = lib.mylib_engine_feed(
            self._h, a.ctypes.data_as(POINTER(c_double)), a.size)
        if rc != 0:
            raise RuntimeError(_msg(rc))

    def close(self) -> None:
        if getattr(self, "_h", None):
            lib.mylib_engine_destroy(self._h)
            self._h = None                    # ★ 이중 해제 방지

    __del__ = close                            # GC 될 때도 정리
    def __enter__(self):  return self
    def __exit__(self, *e): self.close(); return False
restype = c_void_p 를 꼭 지정하세요 — 기본값은 c_int(32비트)라 64비트 포인터가 잘립니다. 핸들을 다시 넘길 때 엉뚱한 주소가 되어 크래시합니다.

구조체 주고받기

class Config(ctypes.Structure):
    _fields_ = [
        ("version",   c_int32),
        ("window",    c_int32),
        ("threads",   c_int32),
        ("_pad",      c_int32),      # ★ 패딩을 명시하면 오해가 없습니다
        ("tolerance", c_double),
    ]

# 정렬을 C 와 맞추기 — 기본은 자연 정렬(C 와 동일)
# _pack_ = 1 은 C 쪽도 #pragma pack(1) 일 때만 쓰세요

lib.mylib_config_init.argtypes = [POINTER(Config)]
lib.mylib_run.argtypes = [POINTER(Config)]
lib.mylib_run.restype  = c_int32

cfg = Config()
lib.mylib_config_init(ctypes.byref(cfg))      # 기본값 채우기
cfg.window = 5
lib.mylib_run(ctypes.byref(cfg))

# ★ 크기를 반드시 검증하세요 — 안 맞으면 조용히 값이 깨집니다
assert ctypes.sizeof(Config) == lib.mylib_config_size(), \
       "구조체 크기 불일치 — 헤더와 파이썬 정의가 어긋났습니다"
C 쪽에 크기 확인 함수를 두세요 — int32_t mylib_config_size(void) { return sizeof(mylib_config); } 한 줄이면 구조체 정의가 어긋난 것을 즉시 잡을 수 있습니다. 이 버그는 안 잡으면 몇 시간을 태웁니다.

실무에선 이렇게 — 라이브러리 찾기와 배포 🔴 실무

경로 문제를 확실히 해결하기

import ctypes, os, sys
from ctypes.util import find_library
from pathlib import Path

def load_mylib() -> ctypes.CDLL:
    here = Path(__file__).resolve().parent      # ★ 패키지 안에 동봉

    names = {
        "win32":  ["mylib.dll"],
        "darwin": ["libmylib.dylib"],
    }.get(sys.platform, ["libmylib.so"])

    # 윈도우 3.8+ 는 DLL 검색 경로를 명시해야 합니다 ★
    if sys.platform == "win32":
        os.add_dll_directory(str(here))

    for n in names:
        p = here / n
        if p.exists():
            return ctypes.CDLL(str(p))

    # 시스템에 설치된 것 찾기
    if (found := find_library("mylib")):
        return ctypes.CDLL(found)

    raise ImportError(
        f"mylib 을 찾을 수 없습니다. 찾은 경로: {here}\n"
        f"설치: pip install mylib-binary 또는 LD_LIBRARY_PATH 설정")

lib = load_mylib()

GIL 과 스레드

# ★ ctypes 의 CDLL 은 호출 중 GIL 을 해제합니다
#   → C 함수가 오래 걸려도 다른 파이썬 스레드가 돕니다 (좋음)
#   → 동시에, C 함수가 진짜로 병렬 실행됩니다 (스레드 안전성 필요)
from concurrent.futures import ThreadPoolExecutor
with ThreadPoolExecutor(8) as ex:
    list(ex.map(run_native, chunks))     # 실제로 8코어를 씁니다

# GIL 을 유지한 채 부르려면 PyDLL 을 씁니다
lib_gil = ctypes.PyDLL("./libmylib.so")  # 파이썬 객체를 만지는 C 코드용

# ⚠ 콜백 안에서 파이썬을 부를 때는 CFUNCTYPE 이 GIL 을 알아서 잡아 줍니다
CB = ctypes.CFUNCTYPE(None, c_double)    # GIL 획득 O
CB_NO = ctypes.WINFUNCTYPE(None, c_double)  # 윈도우 stdcall

# 콜백 객체는 반드시 살려 두세요 ★
_cb = CB(lambda x: print(x))             # 지역변수로 두면 GC 되어 크래시
lib.mylib_set_callback(_cb)              # 전역이나 인스턴스 멤버로 보관
콜백 객체 수명이 대표적인 함정입니다 — 파이썬이 콜백 래퍼를 회수하면 C 쪽에는 죽은 함수 포인터가 남습니다. 반드시 모듈 전역이나 객체 멤버로 참조를 유지하세요(f08).

한 단계 더 — ctypes 의 호출 비용 🧬 전문가

얼마나 느린가

import ctypes, timeit
lib = ctypes.CDLL("./libnoop.so")
lib.noop.argtypes = []
lib.noop.restype  = None

t = timeit.timeit(lambda: lib.noop(), number=1_000_000)
print(f"ctypes  호출당 {t:.3f} μs")

# 대략적인 감각 (환경마다 다릅니다 — 직접 재세요)
#   순수 파이썬 함수 호출   : ~0.05 μs
#   pybind11 확장 호출      : ~0.1  μs
#   ctypes 호출             : ~0.5~1 μs   ★ 5~10배
#   cffi (ABI 모드)         : ctypes 와 비슷
#   cffi (API 모드)         : pybind11 급 (컴파일하므로)
#
# 이유 — ctypes 는 호출 때마다 인자 타입을 '해석'하고
#        libffi 로 동적으로 호출 프레임을 만듭니다(런타임 작업).
#        pybind11 은 컴파일 시점에 고정된 코드를 생성합니다.

# ★ 결론: 호출 횟수가 적고 계산이 크면 ctypes 로 충분합니다.
#   호출이 잦으면(초당 수십만 회) cffi API 모드나 pybind11 로 가세요.

최적화 기법

# ① 함수 객체를 지역 변수로 캐시 (속성 조회 제거)
smooth = lib.mylib_smooth          # 루프 밖에서 한 번
for chunk in chunks:
    smooth(...)

# ② 배열 포인터를 미리 만들어 재사용
ptr_in  = a.ctypes.data_as(POINTER(c_double))    # 매번 만들지 않기
ptr_out = out.ctypes.data_as(POINTER(c_double))

# ③ 더 빠른 포인터 변환 — numpy 의 ctypes 속성은 매번 객체를 만듭니다
addr_in = a.ctypes.data                # 정수 주소
lib.f.argtypes = [c_void_p, c_int64]
lib.f(addr_in, a.size)                 # 정수를 그대로 넘기면 더 쌉니다

# ④ 애초에 호출 횟수를 줄이기 (이 가이드의 일관된 원칙)
lib.mylib_process_batch(ptrs, counts, n_chunks)   # 한 번에 여러 개

# ⑤ numpy.ctypeslib 로 배열 타입을 선언하면 검증까지 됩니다
from numpy.ctypeslib import ndpointer
lib.mylib_smooth.argtypes = [
    ndpointer(np.float64, flags="C_CONTIGUOUS"),   # ★ 자동 검증
    c_int64,
    ndpointer(np.float64, flags="C_CONTIGUOUS, WRITEABLE"),
    c_int32,
]
lib.mylib_smooth(a, a.size, out, 3)    # 배열을 그대로 넘길 수 있습니다
numpy.ctypeslib.ndpointer 를 쓰세요 — dtype·연속성·쓰기 가능 여부를 ctypes 가 자동으로 검사해 주고, 배열을 그대로 넘길 수 있어 코드도 짧아집니다. 수동 data_as 보다 안전합니다.
정리 — ctypes.CDLL 로 로드하고 argtypes·restype 을 반드시 선언하면 끝입니다. numpy 배열은 ndpointer 로 복사 없이 넘어가고, 핸들은 c_void_p 로 받아 파이썬 클래스로 감싸세요. 빌드도 설치도 없다는 것이 이 방식의 가장 큰 장점입니다.
FFI 05

cffi 와 구조체 정렬

cffi 는 C 헤더를 그대로 붙여넣어 쓰는 방식입니다. 타입을 손으로 옮겨 적지 않아도 되고, API 모드로 컴파일하면 ctypes 보다 훨씬 빠릅니다.

ABI 모드 — 컴파일 없이

from cffi import FFI
ffi = FFI()

# ★ C 선언을 그대로 붙여넣습니다 (헤더에서 복사)
ffi.cdef("""
    typedef struct mylib_engine mylib_engine;

    mylib_engine* mylib_engine_create(int32_t window);
    void          mylib_engine_destroy(mylib_engine* e);
    int32_t       mylib_smooth(const double* in, int64_t n,
                               double* out, int32_t window);
""")

lib = ffi.dlopen("./libmylib.so")      # 컴파일 없이 로드

# 배열 넘기기 — numpy 와 연결
import numpy as np
a   = np.random.rand(1000)
out = np.empty_like(a)
p_in  = ffi.cast("const double*", ffi.from_buffer(a))    # 복사 없음 ★
p_out = ffi.cast("double*",       ffi.from_buffer(out))
rc = lib.mylib_smooth(p_in, a.size, p_out, 3)

# 핸들
e = lib.mylib_engine_create(3)
try:
    ...
finally:
    lib.mylib_engine_destroy(e)

API 모드 — 빠르고 안전합니다 ★

# build_mylib.py — 빌드 시점에 C 확장을 생성합니다
from cffi import FFI
ffibuilder = FFI()

ffibuilder.cdef("""
    int32_t mylib_smooth(const double*, int64_t, double*, int32_t);
""")

ffibuilder.set_source(
    "_mylib_cffi",
    '#include "mylib.h"',              # ★ 진짜 헤더를 include
    libraries=["mylib"],
    include_dirs=["include"],
    library_dirs=["build"],
)

if __name__ == "__main__":
    ffibuilder.compile(verbose=True)
from _mylib_cffi import lib, ffi      # 컴파일된 확장을 import
lib.mylib_smooth(p_in, n, p_out, 3)   # pybind11 급 속도

# API 모드의 이점
#   · 컴파일러가 시그니처를 실제 헤더와 대조 → 타입 오류를 빌드 때 잡음 ★
#   · 호출 비용이 훨씬 낮음
#   · 매크로·인라인 함수·enum 값도 쓸 수 있음
고르는 기준 — 이미 배포된 .so 를 부르기만 하면 ABI 모드, 내 라이브러리와 함께 배포한다면 API 모드입니다. API 모드는 빌드가 필요한 대신 타입 안전성과 속도를 둘 다 얻습니다.

이렇게도 쓸 수 있다 — 구조체 정렬과 패딩 🟡 중급

왜 값이 깨지는가

/* C 쪽 */
typedef struct {
    char    a;      /* 오프셋 0 */
                    /* 패딩 7바이트 ★ */
    double  b;      /* 오프셋 8  (8의 배수여야 함) */
    int32_t c;      /* 오프셋 16 */
                    /* 패딩 4바이트 (전체가 8의 배수가 되도록) */
} Rec;              /* sizeof == 24  (x86-64 · arm64 기준) */
# ❌ 패딩을 모르고 선언하면 오프셋이 어긋납니다
class Rec(ctypes.Structure):
    _pack_ = 1                       # ← C 는 pack 을 안 했는데 여기서 1로
    _fields_ = [("a", c_char), ("b", c_double), ("c", c_int32)]
# sizeof == 13 → b 를 잘못된 위치에서 읽습니다 (값이 쓰레기)

# ✅ 기본 정렬을 그대로 쓰고, 크기를 검증합니다
class Rec(ctypes.Structure):
    _fields_ = [("a", c_char), ("b", c_double), ("c", c_int32)]

assert ctypes.sizeof(Rec) == 24
assert Rec.b.offset == 8             # ★ 오프셋까지 확인
print([(f[0], getattr(Rec, f[0]).offset) for f in Rec._fields_])
디버깅이 가장 어려운 버그 중 하나입니다 — 컴파일도 되고 크래시도 안 나는데 값만 이상합니다. 구조체를 주고받는다면 C 쪽에 sizeof 와 offsetof 를 돌려주는 함수를 두고 파이썬에서 검증하세요.

애초에 안 겪는 방법

/* ① 구조체를 아예 넘기지 않기 — 인자로 풀어서 ★ 가장 안전 */
int32_t mylib_run(int32_t window, int32_t threads, double tol);

/* ② 넘겨야 한다면 정렬 걱정이 없는 배치로 설계 */
typedef struct {
    double  tolerance;   /* 큰 것부터 */
    int64_t count;
    int32_t window;
    int32_t threads;     /* 4+4 = 8 로 딱 맞음 → 패딩 0 */
} mylib_config;          /* sizeof == 24, 어느 컴파일러에서나 동일 */

/* ③ 검증 함수를 노출 */
int32_t mylib_config_size(void)   { return (int32_t)sizeof(mylib_config); }
int32_t mylib_config_offset_window(void) {
    return (int32_t)offsetof(mylib_config, window);
}

/* ④ C 쪽에서도 컴파일 시점에 못 박기 */
static_assert(sizeof(mylib_config) == 24, "ABI 변경 감지");
static_assert(offsetof(mylib_config, window) == 16, "필드 위치 변경");
cffi 의 장점이 여기서 드러납니다 — API 모드는 실제 헤더를 컴파일러로 파싱하므로 정렬이 자동으로 맞습니다. 구조체를 많이 주고받는다면 cffi API 모드가 ctypes 보다 훨씬 안전합니다.

실무에선 이렇게 — 세 방식 비교 🔴 실무

언제 무엇을

ctypescffi ABIcffi API
의존성없음cfficffi + 컴파일러
선언 방식손으로C 문법 복사진짜 헤더
타입 검증없음실행 시빌드 시 ★
속도느림비슷빠름
정렬 안전수동수동자동
배포가장 쉬움쉬움wheel 필요
실무 권장 — 시스템 라이브러리를 몇 번 부르는 정도면 ctypes, 내 C 라이브러리를 파이썬 패키지로 배포하면 cffi API 모드, C++ 기능을 풍부하게 노출해야 하면 pybind11(🐍 탭).

공통으로 지킬 것

# ① 파이썬 쪽 래퍼로 감싸 raw FFI 를 노출하지 않기
#    사용자가 c_void_p 를 직접 만지게 하면 반드시 사고가 납니다

# ② 에러 코드를 예외로 변환
_MSG = {-1: "잘못된 인자", -2: "메모리 부족", -3: "입출력 오류"}
def _check(rc: int) -> None:
    if rc != 0:
        raise RuntimeError(f"mylib 오류 {rc}: {_MSG.get(rc, '알 수 없음')}")

# ③ 버전 검사 — 헤더와 .so 가 어긋나면 조용히 깨집니다 ★
EXPECTED = (1, 2)
v = lib.mylib_version()
if (v // 10000, (v // 100) % 100) != EXPECTED:
    raise ImportError(f"mylib 버전 불일치: {v} (기대 {EXPECTED})")

# ④ 배열은 항상 정규화해서 넘기기
a = np.ascontiguousarray(data, dtype=np.float64)

# ⑤ 반드시 테스트 — 특히 빈 배열과 경계값

한 단계 더 — 엔디언과 이식성 🧬 전문가

플랫폼마다 다른 것들

① 정수 크기
   long : 리눅스 64비트 8바이트 · 윈도우 64비트 4바이트 ★
   → 인터페이스에는 int32_t · int64_t 만 쓰세요

② bool 크기
   C++ bool 은 대개 1바이트지만 표준이 보장하지 않습니다
   → int32_t 로 주고받으세요

③ enum 크기
   구현 정의입니다 (보통 int 지만 최적화로 작아질 수 있음)
   → 인터페이스에는 int32_t 로, 값만 enum 상수로 정의

④ 엔디언
   x86 · ARM(리틀) 이 대부분이지만, 네트워크로 보낼 때는 다릅니다
   → 파일·프로토콜은 명시적 직렬화, 같은 프로세스 내 FFI 는 신경 안 써도 됨

⑤ 구조체 반환
   작은 구조체는 레지스터로, 큰 것은 숨은 포인터로 반환됩니다.
   규칙이 ABI 마다 달라 오류가 잦으므로
   ★ 구조체는 '포인터로 받아 채우는' 형태를 쓰세요
     int32_t mylib_get(mylib_config* out);   ← 이 형태가 안전

자동 생성으로 실수 없애기

# ① 헤더에서 파이썬 바인딩을 자동 생성
pip install ctypesgen
ctypesgen -l mylib include/mylib.h -o mylib_ffi.py
#   → argtypes·restype·구조체 정의가 자동으로 만들어집니다 ★

# ② C++ 쪽에서 헤더를 자동 생성 (Rust 진영의 cbindgen 과 같은 자리)
#    C++ 는 표준 도구가 없어 보통 손으로 관리합니다.
#    대신 검증을 자동화하세요:

# ③ CI 에서 ABI 검증
abidiff old/libmylib.so new/libmylib.so    # ABI 가 깨졌는지 (🔬 d07)

# ④ 구조체 레이아웃 덤프해 비교
pahole -C mylib_config build/libmylib.so   # 필드 오프셋·패딩을 표로
#   커밋에 이 출력을 남겨 두면 변경이 리뷰에서 보입니다 ★

# ⑤ 파이썬 쪽 정의와 실제를 대조하는 테스트
python -c "
from mylib import _ffi, lib
assert _ffi.sizeof('mylib_config') == lib.mylib_config_size()
"
자동화가 답입니다 — 헤더와 FFI 선언을 손으로 두 곳에 유지하면 반드시 어긋납니다. 생성하거나, 최소한 CI 에서 크기·오프셋을 대조하세요. 이 검사 하나가 가장 찾기 어려운 버그를 통째로 막아 줍니다.
정리 — cffi 는 C 선언을 그대로 붙여넣어 쓰는 방식입니다. ABI 모드는 컴파일 없이 즉시, API 모드는 컴파일해서 빠르고 안전하게. 구조체를 주고받을 때는 패딩 때문에 오프셋이 어긋나기 쉬우니 sizeof·offsetof 를 양쪽에서 대조하세요.
구조체 크기는 필드 크기의 합이 아닙니다 — 사이에 빈칸이 낍니다 ① char · int · char 순서 — 12바이트 a 패딩 3바이트 b (int, 4바이트) c 패딩 3바이트 쓰는 값은 6바이트 ② int · char · char 순서 — 8바이트 b (int, 4바이트) a c 패딩 2 같은 필드인데 4바이트가 줄었습니다 — 큰 것부터 선언하세요 경계를 넘길 때 오프셋을 손으로 가정하면 안 됩니다 파이썬 ctypes 의 Structure 필드 순서가 다르거나 컴파일러 옵션이 다르면, 에러 없이 값만 조용히 어긋납니다 안전한 길 — offsetof · sizeof 를 확인하는 스모크 테스트를 두거나, cffi 에 헤더를 그대로 읽히세요 #pragma pack 으로 패딩을 없애는 방법은 이식성이 떨어집니다 — 정렬되지 않은 접근이 느리거나 금지된 CPU 가 있습니다
FFI 에서 가장 찾기 어려운 버그는 크래시가 아니라 값이 살짝 틀린 것입니다.
구조체 크기는 필드 크기의 합이 아닙니다 — 사이에 빈칸이 낍니다 ① char · int · char 순서 — 12바이트 a 패딩 3바이트 b (int, 4바이트) c 패딩 3바이트 쓰는 값은 6바이트 ② int · char · char 순서 — 8바이트 b (int, 4바이트) a c 패딩 2 같은 필드인데 4바이트가 줄었습니다 — 큰 것부터 선언하세요 경계를 넘길 때 오프셋을 손으로 가정하면 안 됩니다 파이썬 ctypes 의 Structure 필드 순서가 다르거나 컴파일러 옵션이 다르면, 에러 없이 값만 조용히 어긋납니다 안전한 길 — offsetof · sizeof 를 확인하는 스모크 테스트를 두거나, cffi 에 헤더를 그대로 읽히세요 #pragma pack 으로 패딩을 없애는 방법은 이식성이 떨어집니다 — 정렬되지 않은 접근이 느리거나 금지된 CPU 가 있습니다
FFI 에서 가장 찾기 어려운 버그는 크래시가 아니라 값이 살짝 틀린 것입니다.
FFI 06

Node · Deno 에서 컴파일 없이 부르기

같은 .so·.dll 을 JavaScript 에서도 그대로 부릅니다. 네이티브 애드온을 빌드하지 않아도 됩니다.

Node — koffi (현재 표준)

npm install koffi
#   ffi-napi 는 오래 방치돼 최신 Node 에서 자주 깨집니다.
#   koffi 가 사실상 후속입니다 (더 빠르고 유지보수됨) ★
import koffi from 'koffi'

const lib = koffi.load('./libmylib.so')      // .dll · .dylib

// 시그니처를 C 문법 그대로 적습니다
const smooth = lib.func(
  'int32_t mylib_smooth(const double* in, int64_t n, double* out, int32_t w)')

const n = 1_000_000
const input  = new Float64Array(n).fill(1)
const output = new Float64Array(n)

const rc = smooth(input, n, output, 3)       // TypedArray 를 그대로 ★
if (rc !== 0) throw new Error(`실패 코드 ${rc}`)

// 불투명 핸들
const Engine = koffi.opaque('mylib_engine')
const create  = lib.func('mylib_engine* mylib_engine_create(int32_t)')
const destroy = lib.func('void mylib_engine_destroy(mylib_engine*)')

const e = create(3)
try { /* ... */ } finally { destroy(e) }

비동기 호출 — 이벤트 루프를 막지 않게

// ★ 동기 호출은 Node 의 이벤트 루프를 통째로 멈춥니다
const rc = smooth(input, n, output, 3)      // 500ms 걸리면 서버가 500ms 멈춤

// koffi 의 비동기 호출 — 워커 스레드에서 실행됩니다
smooth.async(input, n, output, 3, (err, rc) => {
  if (err) return console.error(err)
  console.log('완료', rc)
})

// Promise 로 감싸기
import { promisify } from 'node:util'
const smoothAsync = promisify(smooth.async)
const rc2 = await smoothAsync(input, n, output, 3)

// ⚠ 비동기 호출 중에 입력 버퍼를 건드리면 안 됩니다
//   (C 함수가 다른 스레드에서 그 메모리를 읽고 있습니다)

// 구조체
const Config = koffi.struct('mylib_config', {
  tolerance: 'double',
  count:     'int64_t',
  window:    'int32_t',
  threads:   'int32_t',
})
const run = lib.func('int32_t mylib_run(const mylib_config* c)')
run({ tolerance: 0.01, count: 100n, window: 5, threads: 4 })
서버에서는 반드시 비동기로 — Node 는 단일 스레드라 동기 FFI 호출이 모든 요청을 멈춥니다. 계산이 1ms 를 넘으면 비동기를 쓰세요.

이렇게도 쓸 수 있다 — Deno · Bun 🟡 중급

Deno — 런타임에 내장돼 있습니다

// deno run --allow-ffi --unstable-ffi main.ts
const lib = Deno.dlopen("./libmylib.so", {
  mylib_smooth: {
    parameters: ["buffer", "i64", "buffer", "i32"],
    result: "i32",
  },
  mylib_version: { parameters: [], result: "i32" },

  // 비동기 — 별도 스레드에서 실행 (nonblocking)
  mylib_heavy: {
    parameters: ["buffer", "i64"],
    result: "f64",
    nonblocking: true,        // ★ Promise 를 반환합니다
  },
} as const);

const input  = new Float64Array(1000).fill(1);
const output = new Float64Array(1000);
const rc = lib.symbols.mylib_smooth(input, 1000n, output, 3);

const r = await lib.symbols.mylib_heavy(input, 1000n);   // 비동기
lib.close();
Deno 의 장점 — FFI 가 런타임에 내장되어 있어 npm 패키지가 필요 없고, --allow-ffi 권한 모델로 보안 경계가 명시적입니다. 타입 선언이 TypeScript 로 되어 있어 오타도 잡힙니다.

Bun — 가장 빠른 FFI

import { dlopen, FFIType, ptr } from "bun:ffi"

const { symbols } = dlopen("./libmylib.so", {
  mylib_smooth: {
    args: [FFIType.ptr, FFIType.i64, FFIType.ptr, FFIType.i32],
    returns: FFIType.i32,
  },
})

const input  = new Float64Array(1000).fill(1)
const output = new Float64Array(1000)
symbols.mylib_smooth(ptr(input), 1000n, ptr(output), 3)

// Bun 은 FFI 호출을 JIT 컴파일해 오버헤드가 거의 없습니다 ★
//   (벤치마크상 네이티브 애드온에 근접)
세 런타임 비교

  koffi (Node) : 생태계가 가장 크고 안정적. 비동기 지원 좋음
  Deno         : 내장 · 권한 모델 · TypeScript 타입
  Bun          : 가장 빠름. 다만 생태계가 아직 작음

공통 주의점
  · TypedArray 의 메모리는 GC 가 옮길 수 있습니다.
    비동기 호출 중에는 참조를 유지해 회수를 막으세요 ★
  · Node 는 Buffer 와 TypedArray 가 다릅니다(Buffer 는 ArrayBuffer 뷰)
  · 64비트 정수는 BigInt 로 주고받습니다 (1000n)

실무에선 이렇게 — FFI 와 네이티브 애드온 중 무엇을 🔴 실무

판단표

FFI (koffi·Deno)네이티브 애드온 (N-API)
빌드 불필요 · 설치가 쉬움빌드 필요 · prebuild 배포
여러 언어가 같은 .so 공유 ★Node 전용
호출 비용이 조금 큼가장 빠름
JS 객체를 다루기 어려움JS 객체·클래스 자유롭게
타입 오류가 런타임 크래시컴파일러가 검사
ABI 안정 — Node 버전과 무관Node 버전마다 재빌드
같은 코어를 두 방식으로 노출할 수 있습니다 — C API 를 만들어 두면 FFI 로도, N-API 애드온으로도 감쌀 수 있습니다. 먼저 FFI 로 빠르게 검증하고, 성능이 필요해지면 애드온을 얹는 순서가 실용적입니다.

Electron 에서 쓸 때

// ★ Electron 은 Node 와 V8 버전이 달라 네이티브 애드온이 자주 깨집니다.
//   FFI 는 ABI 에 의존하지 않아 이 문제가 없습니다 — 큰 장점입니다.

// 메인 프로세스에서만 FFI 를 쓰세요 (렌더러는 보안상 격리)
// main.js
import koffi from 'koffi'
const lib = koffi.load(path.join(process.resourcesPath, 'libmylib.so'))

ipcMain.handle('smooth', async (_e, arr, window) => {
  const out = new Float64Array(arr.length)
  await smoothAsync(arr, arr.length, out, window)
  return out
})

// 패키징 — 네이티브 파일을 asar 밖으로 빼야 합니다 ★
// electron-builder 설정
// "asarUnpack": ["**/*.node", "**/lib*.so", "**/*.dll"]
// "extraResources": [{ "from": "native/", "to": "." }]
asar 안의 파일은 로드할 수 없습니다 — 공유 라이브러리는 실제 파일 경로가 필요하므로 asarUnpack 또는 extraResources 로 빼내야 합니다. “개발에선 되는데 패키징하면 못 찾는” 문제의 원인입니다.

한 단계 더 — JS 엔진과 메모리 경계 🧬 전문가

GC 가 버퍼를 옮길 수 있습니다

// V8 의 TypedArray 는 두 종류의 메모리를 가집니다
//   ① 힙 안 (작은 배열) — GC 가 이동시킬 수 있습니다 ★
//   ② 외부 메모리 (큰 배열 · ArrayBuffer) — 고정

// 동기 호출은 안전합니다 (호출 중 GC 가 돌지 않음)
smooth(input, n, output, 3)

// ❌ 비동기 호출에서 위험 — C 가 읽는 동안 GC 가 이동시킬 수 있습니다
smooth.async(input, n, output, 3, cb)
input = null                      // 참조를 지우면 회수 대상이 됩니다

// ✅ 콜백이 끝날 때까지 참조를 유지
const keep = { input, output }
smooth.async(input, n, output, 3, (err, rc) => {
  void keep                       // 이 시점까지 살아 있음이 보장됩니다
  ...
})

// ✅ 더 확실한 방법 — 네이티브 쪽에서 메모리를 할당해 쓰기
//   C 가 소유한 버퍼라면 JS GC 와 무관합니다
const buf = koffi.alloc('double', n)
smooth.async(buf, n, outBuf, 3, cb)
// koffi.free(buf)  ← 명시적 해제

호출 비용 비교와 최적화

// 벤치마크 — 빈 함수 호출
import { performance } from 'node:perf_hooks'
const t0 = performance.now()
for (let i = 0; i < 1_000_000; i++) noop()
console.log(`${(performance.now()-t0).toFixed(1)}ms / 1M calls`)

// 대략적인 감각 (직접 재세요)
//   순수 JS 함수      : ~1ns
//   N-API 애드온      : ~50~100ns
//   koffi (동기)      : ~100~300ns
//   koffi (비동기)    : ~수 μs (스레드 전환 비용)
//   Bun FFI           : ~10~50ns  ★ JIT 로 거의 네이티브

// 최적화
//   ① 함수 핸들을 모듈 레벨에 캐시
//   ② 배열을 재사용 (매번 new Float64Array 하지 않기)
//   ③ 호출을 배치로 묶기 — 이 가이드의 일관된 원칙
//   ④ 비동기는 계산이 충분히 클 때만 (전환 비용 때문에)

// 실측으로 손익분기 찾기
for (const n of [100, 10_000, 1_000_000]) {
  // 동기 vs 비동기 vs 순수 JS 를 같은 n 으로 비교
}
Node 에서 특히 중요한 판단 — 계산이 1ms 미만이면 동기 FFI 가 낫고, 10ms 이상이면 반드시 비동기입니다. 그 사이는 동시 요청 수에 따라 갈립니다 — 요청이 몰리는 서버라면 짧아도 비동기가 안전합니다.
정리 — Node 는 koffi, Deno 는 내장 Deno.dlopen, Bun 은 bun:ffi 로 같은 .so 를 부릅니다. 핵심 규칙 둘 — 서버에서는 비동기로 부르고, 비동기 중에는 버퍼 참조를 유지하세요.
FFI 07

문자열과 메모리 소유권 규약

FFI 버그의 절반이 여기서 납니다 — “이 메모리는 누가 해제하는가”를 정하지 않으면 반드시 새거나 두 번 해제됩니다.

경계를 넘는 메모리 — 누가 해제할 것인가를 문서가 아니라 API 로 정하세요 ① 만든 쪽이 해제한다 — 전용 해제 함수를 반드시 함께 제공 char* mine_render(...); 그리고 반드시 void mine_free(char*); 쓰는 쪽이 free() 를 직접 부르면 안 됩니다 — 다른 힙일 수 있어 그 자체가 크래시입니다 ② 호출자가 버퍼를 마련한다 — 두 번 호출 int mine_render(char* buf, int cap); buf 가 NULL 이면 필요한 크기를 돌려줍니다 해제 문제가 아예 사라집니다 — 파이썬 · C# · Go 어느 쪽에서 불러도 안전합니다 ③ 정적 · 불변 데이터 — 해제 금지 const char* mine_version(void); 프로그램이 끝날 때까지 그대로 있는 문자열 반환 타입에 const 를 붙여 "건드리지 말라"를 타입으로 말해 주세요 셋 중 어느 것을 골랐는지 헤더 주석에 한 줄로 적어 두세요 — 이 한 줄이 없어서 나는 크래시가 가장 많습니다.
가능하면 ②번을 고르세요 — 상대 언어가 무엇이든 걱정할 것이 없어집니다.

네 가지 소유권 패턴

/* ① 호출자가 버퍼를 주고, 라이브러리가 채운다  ★ 가장 안전 */
int32_t mylib_get_name(mylib_engine* e, char* buf, int32_t buf_size);
/*   호출자가 할당·해제 → 소유권 이동이 없어 사고가 없습니다        */

/* ② 라이브러리가 할당하고, 전용 해제 함수를 제공한다 */
int32_t mylib_get_name_alloc(mylib_engine* e, char** out);
void    mylib_free(void* p);       /* ★ 반드시 같은 라이브러리의 free */

/* ③ 라이브러리가 소유한 것을 '빌려준다' (해제 금지) */
const char* mylib_version_string(void);
/*   정적 문자열이나 내부 버퍼 → 다음 호출까지만 유효할 수 있음 ★    */

/* ④ 호출자가 준 것을 라이브러리가 보관한다 */
int32_t mylib_set_name(mylib_engine* e, const char* name);
/*   → 라이브러리가 내부에 복사해야 합니다 (포인터만 저장하면 위험)  */

/* 어떤 패턴인지 반드시 문서와 이름으로 드러내세요 */
/*   _alloc 접미사 · @return 주석 · 헤더 상단의 규약 설명           */
윈도우에서는 더 심각합니다 — DLL 마다 다른 힙을 쓸 수 있어, A 에서 malloc 한 것을 B 에서 free 하면 즉시 크래시합니다. 반드시 할당한 쪽이 제공하는 해제 함수를 쓰게 하세요.

문자열 다루기

// C++ std::string 은 넘길 수 없습니다. 세 가지 방법이 있습니다.

// ① 호출자 버퍼 (권장) — 크기 조회 후 채우기
extern "C" int32_t mylib_name(const mylib_engine* h,
                              char* buf, int32_t* size) {
    const std::string& s = h->e.name();
    const int32_t need = static_cast<int32_t>(s.size()) + 1;   // +1 = '\0'
    if (!buf) { *size = need; return MYLIB_OK; }               // 크기만
    if (*size < need) { *size = need; return MYLIB_ERR_BUFFER; }
    std::memcpy(buf, s.c_str(), need);
    *size = need;
    return MYLIB_OK;
}

// ② 라이브러리 할당 + 전용 해제
extern "C" char* mylib_name_alloc(const mylib_engine* h) {
    const std::string& s = h->e.name();
    auto* p = static_cast<char*>(std::malloc(s.size() + 1));
    if (p) std::memcpy(p, s.c_str(), s.size() + 1);
    return p;                       // NULL 이면 실패
}
extern "C" void mylib_string_free(char* p) { std::free(p); }

// ③ 정적 문자열 빌려주기 (버전 등 변하지 않는 것만)
extern "C" const char* mylib_version_string(void) {
    return "1.2.3";                 // 정적 저장 기간 → 항상 유효
}

이렇게도 쓸 수 있다 — 인코딩과 파이썬 쪽 처리 🟡 중급

한글이 깨지지 않게

import ctypes
from ctypes import c_char_p, c_int32, c_void_p, POINTER, create_string_buffer, byref

# C 문자열은 '바이트'입니다. 인코딩을 반드시 명시하세요 ★
lib.mylib_set_name.argtypes = [c_void_p, c_char_p]
lib.mylib_set_name(h, "김민준".encode("utf-8"))       # str → bytes

# 받을 때
lib.mylib_version_string.restype = c_char_p
ver = lib.mylib_version_string().decode("utf-8")      # bytes → str

# ① 호출자 버퍼 패턴
lib.mylib_name.argtypes = [c_void_p, c_char_p, POINTER(c_int32)]
size = c_int32(0)
lib.mylib_name(h, None, byref(size))                  # 크기 조회
buf = create_string_buffer(size.value)                # 버퍼 할당
lib.mylib_name(h, buf, byref(size))
name = buf.value.decode("utf-8")                      # ★ .value 는 널까지

# ② 라이브러리 할당 패턴 — 반드시 해제해야 합니다
lib.mylib_name_alloc.argtypes = [c_void_p]
lib.mylib_name_alloc.restype = c_void_p               # ★ c_char_p 아님!
lib.mylib_string_free.argtypes = [c_void_p]           # ★ 없으면 포인터가 int(32비트)로 잘립니다
p = lib.mylib_name_alloc(h)
try:
    name = ctypes.cast(p, c_char_p).value.decode("utf-8")
finally:
    lib.mylib_string_free(p)
restype = c_char_p 의 함정 ★ — ctypes 가 자동으로 bytes 로 변환하면서 원래 포인터를 잃어버립니다. 그러면 해제할 수 없어 메모리가 샙니다. 해제해야 하는 문자열은 c_void_p 로 받고 ctypes.cast 로 읽으세요.

윈도우 경로와 와이드 문자

/* 윈도우는 파일 경로에 UTF-16(wchar_t) 를 씁니다.
   한글 경로를 다루려면 두 가지 방법이 있습니다.       */

/* ① UTF-8 을 받아 내부에서 변환 (권장) ★ 인터페이스가 단순해집니다 */
int32_t mylib_open(const char* utf8_path);
/*   구현에서 MultiByteToWideChar 로 변환해 _wfopen 사용            */

/* ② 플랫폼별 함수를 따로 노출 */
#ifdef _WIN32
int32_t mylib_open_w(const wchar_t* path);
#endif
// 구현 — 윈도우에서 UTF-8 경로를 여는 표준적인 방법
#ifdef _WIN32
static std::wstring to_wide(const char* s) {
    int n = MultiByteToWideChar(CP_UTF8, 0, s, -1, nullptr, 0);
    std::wstring w(n, L'\0');
    MultiByteToWideChar(CP_UTF8, 0, s, -1, w.data(), n);
    w.resize(n - 1);
    return w;
}
std::ifstream f(to_wide(path));      // ★ MSVC 는 wstring 오버로드가 있습니다
#else
std::ifstream f(path);
#endif

// C++17 이후에는 filesystem::path 로 통일할 수도 있습니다
std::filesystem::path p = std::filesystem::u8path(utf8_path);
인터페이스는 UTF-8 로 통일하세요 — 파이썬·Node·Go 모두 UTF-8 이 기본입니다. 윈도우 변환은 라이브러리 안에서 처리하는 것이 쓰는 쪽 코드를 플랫폼 독립적으로 유지하는 길입니다.

실무에선 이렇게 — 규약을 문서와 코드로 못 박기 🔴 실무

헤더에 규약을 적습니다

/**
 * 엔진의 이름을 가져옵니다.
 *
 * @param h     [in]     엔진 핸들. NULL 이면 MYLIB_ERR_INVALID.
 * @param buf   [out]    결과를 담을 버퍼. NULL 이면 필요한 크기만 *size 에.
 * @param size  [in,out] 입력: buf 크기. 출력: 필요한 크기(널 문자 포함).
 *
 * @return MYLIB_OK           성공
 *         MYLIB_ERR_BUFFER   buf 가 작음 (*size 에 필요한 크기)
 *         MYLIB_ERR_INVALID  잘못된 인자
 *
 * @note 문자열은 UTF-8 입니다.
 * @note 반환된 buf 는 호출자가 소유합니다. 라이브러리는 보관하지 않습니다.
 * @threadsafety 같은 핸들에 대한 동시 호출은 안전하지 않습니다.
 */
MYLIB_EXPORT int32_t mylib_name(const mylib_engine* h,
                                char* buf, int32_t* size);
이 다섯 줄이 라이브러리의 품질을 정합니다 — [in]/[out] · NULL 허용 여부 · 소유권 · 인코딩 · 스레드 안전성. FFI 사용자는 소스를 볼 수 없으므로 헤더 주석이 유일한 계약서입니다.

누수를 자동으로 잡기

# 파이썬 래퍼는 항상 컨텍스트 매니저로
class Engine:
    def __enter__(self): return self
    def __exit__(self, *e): self.close(); return False

with Engine(3) as e:      # ★ 예외가 나도 반드시 해제됩니다
    e.feed(data)

# 반복 호출 후 메모리 검사 (테스트로 자동화)
def test_no_leak():
    import resource
    before = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss
    for _ in range(10_000):
        with Engine(3) as e:
            e.feed(np.zeros(1000))
    after = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss
    assert after - before < 20_000, f"{after-before}KB 누수 의심"
# C 쪽 누수는 valgrind·ASan 으로
valgrind --leak-check=full python test_ffi.py 2>&1 | grep -A5 "definitely lost"

# 파이썬 자체의 노이즈가 많으므로 억제 파일을 쓰세요
valgrind --suppressions=$(python -c "import sysconfig;print(sysconfig.get_config_var('srcdir'))")/Misc/valgrind-python.supp ...

# 더 간단한 방법 — C 테스트를 따로 두고 거기서 검사 ★
valgrind --leak-check=full ./build/test_c_api

한 단계 더 — 할당자를 주입하는 설계 🧬 전문가

호출자가 메모리를 관리하게 하기

/* 고급 라이브러리들이 쓰는 패턴 — 할당자를 사용자가 지정 */
typedef struct {
    void* (*alloc)(size_t size, void* user);
    void  (*free)(void* p, void* user);
    void* user;
} mylib_allocator;

MYLIB_EXPORT int32_t mylib_init(const mylib_allocator* alloc);
/*   NULL 이면 기본(malloc/free) 사용                             */

/* 왜 이렇게 하나
   · 게임 엔진 : 프레임 단위 아레나 할당자를 쓰고 싶음
   · 임베디드  : 힙을 아예 못 쓰는 환경
   · 추적      : 라이브러리가 얼마나 쓰는지 정확히 계측
   · 파이썬    : PyMem_Malloc 을 써서 파이썬 메모리 통계에 잡히게 ★ */
# 파이썬에서 할당 추적을 붙이는 예
ALLOC = ctypes.CFUNCTYPE(c_void_p, c_size_t, c_void_p)
FREE  = ctypes.CFUNCTYPE(None, c_void_p, c_void_p)

_total = 0
@ALLOC
def _alloc(size, user):
    global _total; _total += size
    return ctypes.cast(ctypes.create_string_buffer(size), c_void_p).value
#   ⚠ 실제 구현은 더 정교해야 합니다 (버퍼 수명 유지 등)

경계에서의 메모리 규칙 정리

지켜야 할 다섯 가지

  ① 할당한 쪽이 해제한다
     라이브러리가 준 것은 라이브러리 함수로, 내가 준 것은 내가.

  ② 포인터는 항상 길이와 함께
     길이 없는 포인터는 버퍼 오버런의 예약입니다.

  ③ 빌려준 포인터의 유효 기간을 명시
     "다음 호출까지" · "핸들이 살아 있는 동안" · "영구"

  ④ 라이브러리는 받은 포인터를 보관하지 않는다
     보관해야 하면 반드시 복사하고, 그 사실을 문서에 적습니다.

  ⑤ NULL 을 언제나 허용하거나, 언제나 거부하거나
     함수마다 다르면 사용자가 반드시 틀립니다.
     → destroy(NULL) 은 안전하게, 나머지는 에러 코드로.

검증
  · ASan 으로 이중 해제·누수·해제 후 사용을 잡습니다
  · C 테스트를 valgrind 로 돌립니다
  · 파이썬 래퍼에 반복 호출 누수 테스트를 둡니다
이 규칙들이 지켜지지 않으면 — 증상이 “한참 뒤에 엉뚱한 곳에서 크래시”로 나타납니다. FFI 에서 가장 비싼 디버깅이고, 규약을 문서화하는 30분이 며칠을 아낍니다.
정리 — “누가 해제하는가”를 함수마다 정하고 헤더에 적으세요. 가장 안전한 형태는 호출자가 버퍼를 주고 라이브러리가 채우는 방식이고, 라이브러리가 할당했다면 반드시 전용 해제 함수를 제공해야 합니다. 문자열은 UTF-8 바이트로 통일하세요.
FFI 08

콜백을 C ABI 로 넘기기

진행률 보고, 이벤트 통지, 사용자 정의 규칙 — 다른 언어의 함수를 C++ 가 거꾸로 부르는 경우입니다.

함수 포인터 + user data 관용구 ★

/* C 콜백의 표준 형태 — 반드시 void* user 를 함께 받습니다 */
typedef int32_t (*mylib_progress_fn)(double frac, void* user);
/*   반환 0 = 계속, 그 외 = 중단                                  */

MYLIB_EXPORT int32_t mylib_run(mylib_engine* h,
                               mylib_progress_fn cb, void* user);

/* ★ void* user 가 없으면 콜백이 '전역 상태'에만 접근할 수 있어
     여러 인스턴스를 동시에 쓸 수 없습니다. C API 설계의 기본 관용구입니다. */
// 구현 — C++ 쪽
extern "C" int32_t mylib_run(mylib_engine* h,
                             mylib_progress_fn cb, void* user) {
    if (!h) return MYLIB_ERR_INVALID;
    try {
        return h->e.run([&](double f) -> bool {
            if (!cb) return true;
            return cb(f, user) == 0;          // 0 이면 계속
        }) ? MYLIB_OK : MYLIB_ERR_CANCELED;
    } catch (...) {
        return MYLIB_ERR_UNKNOWN;             // ★ 예외를 절대 밖으로
    }
}
//  ⚠ 콜백이 예외를 던질 수 있다는 것도 고려해야 합니다
//    (다른 언어의 예외가 C 프레임을 통과하면 UB)

파이썬에서 콜백 넘기기

import ctypes
from ctypes import c_double, c_int32, c_void_p, CFUNCTYPE

# 시그니처 정의 — C 선언과 정확히 일치해야 합니다
PROGRESS = CFUNCTYPE(c_int32, c_double, c_void_p)

lib.mylib_run.argtypes = [c_void_p, PROGRESS, c_void_p]
lib.mylib_run.restype  = c_int32

class Engine:
    def run(self, on_progress=None):
        def _cb(frac, _user):
            try:
                if on_progress:
                    on_progress(frac)
                return 0                      # 계속
            except KeyboardInterrupt:
                return 1                      # 중단 ★
            except Exception:
                return 1                      # 예외를 C 로 넘기지 않습니다

        self._cb = PROGRESS(_cb)              # ★ 인스턴스에 보관 (GC 방지)
        rc = lib.mylib_run(self._h, self._cb, None)
        if rc != 0:
            raise RuntimeError(f"실행 실패 {rc}")
두 가지를 반드시 지키세요 — ① 콜백 객체를 살려 두기(self._cb 처럼). 지역 변수로 두면 GC 후 죽은 포인터가 남습니다. ② 파이썬 예외를 콜백 밖으로 내보내지 않기. try/except 로 감싸 에러 코드로 변환해야 합니다.

이렇게도 쓸 수 있다 — 언어별 콜백 🟡 중급

Node · Deno

// koffi
const ProgressFn = koffi.proto('int32_t Progress(double frac, void* user)')
const run = lib.func('int32_t mylib_run(mylib_engine* h, Progress* cb, void* user)')

const cb = koffi.register((frac, _user) => {
  process.stdout.write(`\r${(frac*100).toFixed(0)}%`)
  return 0
}, koffi.pointer(ProgressFn))

try {
  run(engine, cb, null)
} finally {
  koffi.unregister(cb)          // ★ 반드시 해제 (안 하면 누수)
}

// ⚠ 콜백은 '호출한 스레드'에서 실행됩니다.
//   비동기(run.async) 로 부르면 워커 스레드에서 JS 를 부르게 되어
//   대부분의 런타임에서 문제가 됩니다 — 동기 호출에서만 콜백을 쓰거나,
//   네이티브 애드온의 ThreadSafeFunction 을 쓰세요(🟨 Node 탭).

콜백 대신 폴링 — 훨씬 안전합니다 ★

/* 콜백은 언어 경계·스레드·예외·수명이 전부 얽힙니다.
   가능하면 '상태를 물어보는' 형태로 바꾸세요.                    */

/* 라이브러리는 백그라운드로 진행하고, 상태만 노출 */
MYLIB_EXPORT int32_t mylib_start(mylib_engine* h);
MYLIB_EXPORT int32_t mylib_poll(const mylib_engine* h,
                                double* progress, int32_t* done);
MYLIB_EXPORT int32_t mylib_cancel(mylib_engine* h);
# 파이썬 쪽 — 훨씬 단순하고 안전합니다
lib.mylib_start(h)
prog, done = c_double(), c_int32()
while True:
    lib.mylib_poll(h, byref(prog), byref(done))
    if done.value:
        break
    bar.update(prog.value)
    try:
        time.sleep(0.05)
    except KeyboardInterrupt:
        lib.mylib_cancel(h)          # ★ Ctrl+C 도 자연스럽게 처리
        raise
폴링의 장점 — GIL·GC·예외 전파 문제가 전부 사라집니다. 진행률처럼 정밀도가 중요하지 않은 용도라면 거의 항상 폴링이 낫습니다. 콜백은 이벤트를 놓치면 안 되는 경우에만 쓰세요.

실무에선 이렇게 — 스레드를 넘나드는 콜백 🔴 실무

어느 스레드에서 불리는지가 전부입니다

/* 헤더에 반드시 명시하세요 */

/* ① 같은 스레드에서 불림 (동기) — 가장 안전 ★ */
/*    "콜백은 mylib_run 을 호출한 스레드에서 실행됩니다."          */

/* ② 라이브러리 워커 스레드에서 불림 */
/*    "콜백은 내부 스레드에서 실행됩니다.
       파이썬 객체를 만지려면 GIL 을 획득해야 하고,
       JS 는 ThreadSafeFunction 이 필요합니다."                  */

/* ③ 여러 스레드에서 동시에 불릴 수 있음 */
/*    "콜백은 재진입 가능해야 하며 스레드 안전해야 합니다."         */
# ② 인 경우 파이썬에서 GIL 을 잡아야 합니다
# ★ ctypes 의 CFUNCTYPE 은 GIL 을 자동으로 획득합니다 (안전)
#    다만 그 순간 다른 파이썬 스레드가 멈추므로 짧게 유지하세요

# 워커 스레드 콜백에서 무거운 파이썬 코드를 실행하면
# 병렬 계산이 GIL 때문에 직렬화됩니다 → 큐에 넣고 메인에서 처리
import queue
q = queue.Queue()

def _cb(frac, _user):
    q.put(frac)          # 넣기만 (아주 짧음)
    return 0

# 메인 스레드에서 소비
while not done:
    frac = q.get(timeout=1)
    update_ui(frac)      # 무거운 작업은 여기서

취소와 타임아웃

// 취소는 '플래그를 보는' 방식이 가장 단순합니다
struct mylib_engine {
    impl::Engine e;
    std::atomic<bool> cancel{false};      // ★ atomic 필수
};

extern "C" int32_t mylib_cancel(mylib_engine* h) {
    if (!h) return MYLIB_ERR_INVALID;
    h->cancel.store(true, std::memory_order_relaxed);
    return MYLIB_OK;
}

// 계산 루프 안에서 주기적으로 확인
for (std::size_t i = 0; i < n; ++i) {
    if ((i & 0xFFFF) == 0 &&                       // 65536 번마다
        h->cancel.load(std::memory_order_relaxed))
        return MYLIB_ERR_CANCELED;
    work(i);
}
//   ★ 확인 주기가 너무 잦으면 성능이 떨어지고,
//     너무 드물면 Ctrl+C 반응이 느려집니다. 수십 ms 간격이 적당합니다.
Ctrl+C 가 안 먹는 확장 모듈은 사용자를 가장 화나게 합니다. 취소 플래그를 반드시 제공하고, 파이썬 래퍼에서 KeyboardInterrupt 를 잡아 cancel() 을 부르게 하세요.

한 단계 더 — 콜백의 비용과 대안 🧬 전문가

경계를 거꾸로 넘는 비용

C → 파이썬 콜백 한 번 (ctypes CFUNCTYPE)
  ① GIL 획득
  ② libffi 로 인자 변환 (C 값 → 파이썬 객체)
  ③ 파이썬 함수 호출
  ④ 반환값 변환
  ⑤ GIL 해제
  합계 대략 1~3 μs — 정방향 호출보다 오히려 비쌉니다 ★

C → JS 콜백 (koffi register)
  ① V8 컨텍스트 진입 · 인자 변환 · 호출
  대략 0.5~2 μs

★ 즉 '원소마다 콜백'은 어떤 언어에서도 불가능합니다.
  호출 횟수를 데이터 크기와 분리하는 것이 유일한 답입니다.

대안 비교
  폴링      : 콜백 0회 · 가장 안전 · 정밀도 낮음        ← 기본 선택
  배치 콜백 : 청크마다 1회 · 균형                        ← 진행률에 적합
  규칙 주입 : 콜백 대신 '설정값'으로 표현                ← 필터·정렬 기준
  결과 반환 : 전부 계산해 배열로 한 번에                 ← 가능하면 이것

콜백 없이 사용자 규칙을 받는 법

/* ① 조건을 데이터로 표현 */
typedef enum { OP_GT, OP_LT, OP_EQ } mylib_op;
int32_t mylib_filter(const double* in, int64_t n,
                     int32_t col, mylib_op op, double value,
                     double* out, int64_t* out_n);

/* ② 미리 정의된 함수를 이름으로 선택 */
int32_t mylib_transform(const double* in, int64_t n,
                        const char* mode,      /* "log" · "sqrt" · "zscore" */
                        double* out);

/* ③ 간단한 수식을 문자열로 받아 내부에서 파싱·컴파일 */
int32_t mylib_eval(const char* expr,           /* "x * 2 + 1" */
                   const double* in, int64_t n, double* out);
/*    exprtk · muparser 같은 라이브러리로 구현 —
      한 번 파싱해 두면 루프 안에서는 경계를 안 넘습니다 ★         */

/* ④ 사용자가 진짜 코드를 넣어야 한다면
      → 그 부분만 C ABI 함수로 컴파일해 넘기게 합니다
        (파이썬이면 numba @cfunc · Cython 이 C 함수 포인터를 만들어 줍니다) */
from numba import cfunc, types
sig = types.int32(types.float64, types.voidptr)

@cfunc(sig)                       # ★ 진짜 기계어 함수가 만들어집니다
def my_rule(x, user):
    return 1 if x > 0.5 else 0

lib.mylib_run(h, my_rule.ctypes, None)    # 경계 비용 없이 호출됩니다
numba @cfunc 은 강력한 우회로입니다 — 파이썬 코드를 C ABI 함수 포인터로 컴파일해 주므로, 원소마다 불려도 GIL 도 인터프리터도 거치지 않습니다. 사용자 정의 규칙이 꼭 필요한 라이브러리에서 고려할 만합니다.
정리 — C 콜백은 함수 포인터 + void* user 가 표준형입니다. 파이썬에서는 콜백 객체를 반드시 살려 두고, 예외를 콜백 밖으로 내보내지 마세요(에러 코드로 변환). 그리고 가능하면 콜백 대신 폴링이 훨씬 안전합니다.
멤버 함수는 함수 포인터가 아닙니다 — 정적 중계자를 하나 둡니다 C 쪽 API set_cb(void(*)(void*, int), void* user) 등록 정적 중계자 (thunk) static void tramp(void* u, int v) { static_cast<Obj*>(u)->on(v); } C++ 객체 obj->on(v) 가 불립니다 평범한 멤버 함수 멤버 함수는 숨은 this 인자를 받아 시그니처가 맞지 않습니다 — 객체 포인터를 user data 로 넘기고 중계자에서 되살립니다 캡처가 있는 람다도 같은 이유로 함수 포인터가 되지 못합니다 — 캡처 없는 람다만 변환됩니다 중계자에서 반드시 할 일 try { ... } catch (...) { 코드로 변환 } 예외가 C 프레임을 지나가면 정의되지 않은 동작입니다 경계 함수는 noexcept 로 표시해 두세요 수명은 등록한 쪽 책임입니다 콜백이 등록된 동안 객체가 살아 있어야 합니다 해제 함수(unset_cb)를 반드시 짝으로 제공하고, 소멸자에서 먼저 해제하도록 문서에 적어 두세요
C ABI 는 함수 포인터와 void* 한 쌍이 전부입니다 — 이 관용구를 그대로 따르세요.
멤버 함수는 함수 포인터가 아닙니다 — 정적 중계자를 하나 둡니다 C 쪽 API set_cb(void(*)(void*, int), void* user) 등록 정적 중계자 (thunk) static void tramp(void* u, int v) { static_cast<Obj*>(u)->on(v); } C++ 객체 obj->on(v) 가 불립니다 평범한 멤버 함수 멤버 함수는 숨은 this 인자를 받습니다 — 그래서 시그니처가 맞지 않습니다. 객체 포인터를 user data 로 함께 넘겨 두고, 중계자 안에서 되살리는 것이 정석입니다 캡처가 있는 람다도 같은 이유로 함수 포인터가 되지 못합니다 — 캡처 없는 람다만 변환됩니다 중계자에서 반드시 할 일 try { ... } catch (...) { 코드로 변환 } 예외가 C 프레임을 지나가면 정의되지 않은 동작입니다 경계 함수는 noexcept 로 표시해 두세요 수명은 등록한 쪽 책임입니다 콜백이 등록된 동안 객체가 살아 있어야 합니다 해제 함수(unset_cb)를 반드시 짝으로 제공하고, 소멸자에서 먼저 해제하도록 문서에 적어 두세요
C ABI 는 함수 포인터와 void* 한 쌍이 전부입니다 — 이 관용구를 그대로 따르세요.
FFI 09

에러 코드와 버전 관리

예외를 쓸 수 없으니 반환값으로 실패를 알립니다. 그리고 나중에 기능을 추가해도 옛 사용자가 안 깨지게 설계해야 합니다.

에러 코드 설계

/* ① 값을 명시적으로 고정 — 나중에 순서가 바뀌면 안 됩니다 */
typedef enum {
    MYLIB_OK              =  0,
    MYLIB_ERR_INVALID     = -1,   /* 잘못된 인자 */
    MYLIB_ERR_NOMEM       = -2,   /* 메모리 부족 */
    MYLIB_ERR_IO          = -3,   /* 파일·네트워크 */
    MYLIB_ERR_BUFFER      = -4,   /* 버퍼가 작음 */
    MYLIB_ERR_CANCELED    = -5,   /* 사용자 취소 */
    MYLIB_ERR_UNSUPPORTED = -6,   /* 이 빌드에서 지원 안 함 */
    MYLIB_ERR_HANDLE      = -7,   /* 잘못된 핸들 */
    MYLIB_ERR_UNKNOWN     = -99,  /* 예상 못 한 예외 */
} mylib_status;

/* ② 사람이 읽을 메시지도 제공 */
MYLIB_EXPORT const char* mylib_strerror(int32_t code);

/* ③ 더 자세한 정보는 스레드 로컬로 (errno 방식) */
MYLIB_EXPORT const char* mylib_last_error(void);
/*   "행 12: 예상치 못한 토큰 ']'" 처럼 맥락이 담긴 메시지 ★      */
// 구현 — 스레드 로컬 저장
static thread_local std::string g_last_error;

extern "C" const char* mylib_last_error(void) {
    return g_last_error.c_str();          // 다음 호출까지 유효 ★ 문서화
}

extern "C" int32_t mylib_parse(const char* text) {
    try {
        impl::parse(text);
        g_last_error.clear();
        return MYLIB_OK;
    } catch (const impl::ParseError& e) {
        g_last_error = e.what();          // 상세 메시지 저장
        return MYLIB_ERR_INVALID;
    } catch (const std::bad_alloc&) {
        g_last_error = "메모리 부족";
        return MYLIB_ERR_NOMEM;
    } catch (...) {
        g_last_error = "알 수 없는 오류";
        return MYLIB_ERR_UNKNOWN;
    }
}

파이썬에서 예외로 바꾸기

class MylibError(Exception):
    """모든 mylib 오류의 부모."""
    def __init__(self, code: int, detail: str = ""):
        self.code = code
        super().__init__(f"[{code}] {detail}" if detail else f"코드 {code}")

class MylibValueError(MylibError, ValueError): pass
class MylibMemoryError(MylibError, MemoryError): pass
class MylibIOError(MylibError, OSError): pass

_MAP = {
    -1: MylibValueError,
    -2: MylibMemoryError,
    -3: MylibIOError,
    -4: MylibValueError,
    -5: KeyboardInterrupt,
}

lib.mylib_last_error.restype = ctypes.c_char_p       # ★ 없으면 int 가 와서 .decode 에서 실패

def _check(rc: int) -> None:
    if rc == 0:
        return
    detail = lib.mylib_last_error().decode("utf-8", "replace")
    exc = _MAP.get(rc, MylibError)
    if exc is KeyboardInterrupt:
        raise KeyboardInterrupt(detail)
    raise exc(rc, detail)

# 사용
_check(lib.mylib_parse(text.encode()))
파이썬 표준 예외를 다중 상속하세요 — MylibValueError(MylibError, ValueError) 로 만들면 사용자가 except ValueError 로도 잡을 수 있어 기존 코드에 자연스럽게 녹아듭니다.

이렇게도 쓸 수 있다 — 확장 가능한 인터페이스 🟡 중급

구조체에 버전 · 크기 필드를 두기 ★

typedef struct {
    int32_t struct_size;      /* ★ 첫 필드는 sizeof(자기 자신) */
    int32_t window;
    int32_t threads;
    int32_t _reserved;
    double  tolerance;
    /* v2 에서 추가 — 옛 사용자는 여기까지 모릅니다 */
    int32_t mode;
} mylib_config;

MYLIB_EXPORT void mylib_config_init(mylib_config* c);
/*   구현: memset 후 c->struct_size = sizeof(mylib_config) 로 채움 */

MYLIB_EXPORT int32_t mylib_run(const mylib_config* c);
/*   구현에서 크기로 버전을 판단합니다                              */
extern "C" int32_t mylib_run(const mylib_config* c) {
    if (!c || c->struct_size < 24) return MYLIB_ERR_INVALID;

    int32_t mode = 0;                          // 기본값
    if (c->struct_size >= offsetof(mylib_config, mode) + 4)
        mode = c->mode;                        // ★ 새 필드가 있을 때만 읽기

    ...
}
//  → 옛 프로그램이 새 .so 를 써도 안전하고,
//    새 프로그램이 옛 .so 를 쓰면, 옛 구현이 '모르는 큰 크기'를 거부하게
//    짜여 있을 때만 에러로 걸립니다 (아니면 mode 가 조용히 무시됩니다)

버전 조회와 기능 탐지

/* ① 숫자 버전 — 비교하기 쉽게 */
MYLIB_EXPORT int32_t mylib_version(void);        /* 10203 = 1.2.3 */
MYLIB_EXPORT const char* mylib_version_string(void);  /* "1.2.3-rc1" */

/* ② 기능 탐지 — 버전보다 이쪽이 견고합니다 ★ */
MYLIB_EXPORT int32_t mylib_has_feature(const char* name);
/*   mylib_has_feature("simd_avx512")
     mylib_has_feature("gpu")            → 있으면 1                */

/* ③ 빌드 정보 */
MYLIB_EXPORT const char* mylib_build_info(void);
/*   "GCC 13.2 · AVX2 · 2026-08-06 · a1b2c3d"                     */
# 파이썬 래퍼에서 로드 직후 검증
_MIN = 10200        # 1.2.0 이상 필요
v = lib.mylib_version()
if v < _MIN:
    raise ImportError(
        f"mylib {v//10000}.{v//100%100}.{v%100} 은 너무 낮습니다 "
        f"(최소 1.2.0). 업그레이드하세요.")

if not lib.mylib_has_feature(b"simd_avx2"):
    warnings.warn("AVX2 미지원 빌드입니다. 성능이 낮을 수 있습니다.")

실무에선 이렇게 — 호환성을 지키는 규칙 🔴 실무

해도 되는 변경 / 하면 안 되는 변경

✅ 안전 (하위 호환)❌ ABI 파괴
새 함수 추가함수 시그니처 변경
구조체 끝에 필드 추가
(크기 필드가 있을 때)
구조체 중간에 삽입 · 순서 변경
새 에러 코드 추가기존 에러 코드 값 변경
내부 구현 변경불투명 타입을 공개 구조체로
새 enum 값을 끝에enum 값 재배치
동작 개선(같은 결과)기본값·의미 변경 ★
가장 교묘한 파괴 — 기본값이나 동작의 의미를 바꾸는 것입니다. 컴파일도 되고 링크도 되지만 결과가 달라집니다. 이건 ABI 도구로 잡히지 않으므로 테스트와 문서로만 막을 수 있습니다.

CI 로 ABI 를 지키기

# ① abidiff — 이전 릴리스와 비교 (libabigail)
sudo apt install abigail-tools
abidiff --headers-dir1 old/include --headers-dir2 new/include \
        old/libmylib.so new/libmylib.so
#   ABI 가 깨지면 0 이 아닌 코드로 종료 → CI 에서 실패시킵니다 ★

# ② 심볼 목록을 커밋에 남기기 (리뷰에서 변경이 보이게)
nm -D --defined-only libmylib.so | awk '{print $3}' | sort > abi/symbols.txt
git diff --exit-code abi/symbols.txt || echo "심볼 변경됨 — 리뷰 필요"

# ③ 구조체 레이아웃 기록
pahole -C mylib_config libmylib.so > abi/layout.txt

# ④ 옛 클라이언트로 새 라이브러리 테스트
#    이전 태그의 테스트 바이너리를 보관해 두고 새 .so 로 실행
git checkout v1.0 -- tests/
./build/test_c_api_v1_0            # 새 .so 를 로드해서 통과해야 합니다
버전 번호 규칙 (semver)
  주(major) : ABI 파괴 — soname 도 올립니다 (libmylib.so.2)
  부(minor) : 기능 추가 (하위 호환)
  수(patch) : 버그 수정

  libmylib.so       → 심볼릭 링크 (개발용)
  libmylib.so.1     → soname (런타임이 찾는 이름) ★
  libmylib.so.1.2.3 → 실제 파일

한 단계 더 — 심볼 버전과 다중 공존 🧬 전문가

같은 함수의 여러 버전을 동시에

glibc 가 쓰는 기법 — 한 .so 안에 같은 이름의 여러 버전을 둡니다

  mylib.map
    MYLIB_1.0 { global: mylib_run; local: *; };
    MYLIB_2.0 { global: mylib_run; } MYLIB_1.0;

  구현
    __asm__(".symver mylib_run_v1, mylib_run@MYLIB_1.0");
    __asm__(".symver mylib_run_v2, mylib_run@@MYLIB_2.0");   /* 기본 */

  결과
    · 옛 프로그램은 mylib_run@MYLIB_1.0 에 묶여 옛 동작을 유지
    · 새로 컴파일하면 자동으로 2.0 에 묶임
    → ABI 를 깨지 않고 동작을 바꿀 수 있습니다 ★

  ⚠ 리눅스(ELF) 전용입니다. 윈도우·맥에는 이 기능이 없어
    보통 함수 이름에 버전을 넣습니다 — mylib_run2()

실전에서는 더 단순한 길

/* ① 새 이름으로 함수를 추가하고 옛 것은 유지 (가장 흔한 방법) ★ */
MYLIB_EXPORT int32_t mylib_run(const mylib_config*);        /* v1 */
MYLIB_EXPORT int32_t mylib_run_ex(const mylib_config*, int32_t flags); /* v2 */
/*   Win32 API 가 CreateWindowEx · GetVersionEx 로 해 온 방식입니다   */

/* ② 옛 함수는 새 것을 부르게 구현 — 코드 중복 없음 */
int32_t mylib_run(const mylib_config* c) {
    return mylib_run_ex(c, 0);          /* 기본 플래그 */
}

/* ③ 폐기 예고 — 컴파일 시 경고 */
#if defined(__GNUC__)
  #define MYLIB_DEPRECATED __attribute__((deprecated("mylib_run_ex 를 쓰세요")))
#elif defined(_MSC_VER)
  #define MYLIB_DEPRECATED __declspec(deprecated("mylib_run_ex 를 쓰세요"))
#else
  #define MYLIB_DEPRECATED
#endif

MYLIB_EXPORT MYLIB_DEPRECATED int32_t mylib_run(const mylib_config*);
/*   ↑ extern "C" 앞에는 속성을 둘 수 없어 MYLIB_EXPORT 뒤에 붙입니다 */

/* ④ 폐기 일정을 문서에 명시
      "mylib_run 은 2.0 에서 폐기 예고, 3.0 에서 제거 예정" */
실무 조언 — 심볼 버전은 glibc 급 라이브러리가 아니면 과합니다. _ex 접미사로 함수를 추가하고 옛 것을 유지하는 방식이 세 OS 에서 모두 통하고 이해하기도 쉽습니다.
정리 — 예외 대신 에러 코드를 반환하고, 상세 메시지는 스레드 로컬 last_error 로 제공합니다. 파이썬 래퍼에서 표준 예외를 다중 상속한 예외로 변환하세요. 호환성은 구조체에 크기 필드를 두고 필드는 뒤에만 추가하면 지킬 수 있습니다.
FFI 10

C# · 자바 · Go 에서 같은 라이브러리를

한 번 만든 C API 를 여러 언어에서 그대로 씁니다. 이것이 C ABI 를 택하는 가장 큰 이유입니다.

C# — P/Invoke

using System.Runtime.InteropServices;

internal static partial class Native
{
    private const string Lib = "mylib";     // mylib.dll · libmylib.so

    // .NET 7+ 소스 제너레이터 방식 (권장 — 마샬링 코드가 생성됩니다)
    [LibraryImport(Lib)]
    internal static partial int mylib_smooth(
        ReadOnlySpan<double> input, long n,
        Span<double> output, int window);

    [LibraryImport(Lib)]
    internal static partial IntPtr mylib_engine_create(int window);

    [LibraryImport(Lib)]
    internal static partial void mylib_engine_destroy(IntPtr h);
}

// 안전한 래퍼 — SafeHandle 로 확실하게 해제
sealed class Engine : SafeHandleZeroOrMinusOneIsInvalid
{
    public Engine(int window) : base(true)
        => SetHandle(Native.mylib_engine_create(window));

    protected override bool ReleaseHandle()
    {
        Native.mylib_engine_destroy(handle);
        return true;
    }
}

자바 — FFM API (Java 22+)

import java.lang.foreign.*;
import java.lang.invoke.MethodHandle;

// ★ JNI 는 이제 마지막 수단입니다. FFM(Panama)이 표준이 됐습니다.
try (Arena arena = Arena.ofConfined()) {
    SymbolLookup lib = SymbolLookup.libraryLookup(
        System.mapLibraryName("mylib"), arena);   // → libmylib.so · mylib.dll
    Linker linker = Linker.nativeLinker();

    MethodHandle smooth = linker.downcallHandle(
        lib.find("mylib_smooth").orElseThrow(),
        FunctionDescriptor.of(ValueLayout.JAVA_INT,      // 반환
            ValueLayout.ADDRESS, ValueLayout.JAVA_LONG,
            ValueLayout.ADDRESS, ValueLayout.JAVA_INT));

    MemorySegment in  = arena.allocateFrom(ValueLayout.JAVA_DOUBLE, data);   // Java 22+
    MemorySegment out = arena.allocate(ValueLayout.JAVA_DOUBLE, data.length);

    int rc = (int) smooth.invokeExact(in, (long) data.length, out, 3);
    double[] result = out.toArray(ValueLayout.JAVA_DOUBLE);
}   // Arena 를 닫으면 메모리가 자동 해제됩니다 ★

// jextract 로 헤더에서 바인딩을 자동 생성할 수도 있습니다
//   jextract --output src -t com.example.mylib include/mylib.h

이렇게도 쓸 수 있다 — Go · Rust · 그 외 🟡 중급

Go — cgo

package mylib

/*
#cgo LDFLAGS: -L${SRCDIR}/lib -lmylib
#cgo CFLAGS: -I${SRCDIR}/include
#include "mylib.h"
#include <stdlib.h>
*/
import "C"
import (
    "errors"
    "unsafe"
)

func Smooth(in []float64, window int) ([]float64, error) {
    if len(in) == 0 {
        return []float64{}, nil   // &in[0] 은 빈 슬라이스에서 패닉
    }
    out := make([]float64, len(in))
    rc := C.mylib_smooth(
        (*C.double)(unsafe.Pointer(&in[0])),
        C.int64_t(len(in)),
        (*C.double)(unsafe.Pointer(&out[0])),
        C.int32_t(window),
    )
    if rc != 0 {
        return nil, errors.New(C.GoString(C.mylib_last_error()))
    }
    return out, nil
}

// ⚠ Go 의 GC 가 슬라이스를 옮길 수 있습니다.
//   cgo 호출 중에는 고정되지만, C 가 포인터를 '보관'하면 안 됩니다 ★
// ⚠ cgo 호출은 goroutine 스케줄링을 막아 비용이 있습니다(~수백 ns)

Rust · 그 외 언어

// Rust — extern "C" 블록 (🦀 가이드에서 자세히)
#[link(name = "mylib")]
extern "C" {
    fn mylib_smooth(input: *const f64, n: i64,
                    output: *mut f64, window: i32) -> i32;
}

pub fn smooth(input: &[f64], window: i32) -> Result<Vec<f64>, i32> {
    let mut out = vec![0.0; input.len()];
    let rc = unsafe {
        mylib_smooth(input.as_ptr(), input.len() as i64,
                     out.as_mut_ptr(), window)
    };
    if rc == 0 { Ok(out) } else { Err(rc) }
}
// bindgen 으로 헤더에서 자동 생성하는 것이 표준입니다
그 외 — 거의 모든 언어에 FFI 가 있습니다

  Ruby    : Fiddle (표준) · ffi gem
  PHP     : FFI 확장 (7.4+)
  Lua     : LuaJIT ffi (아주 빠름)
  Julia   : ccall (내장 · 오버헤드 거의 0)
  R       : .C / .Call · Rcpp
  Swift   : 모듈 맵으로 C 헤더를 직접 import
  Kotlin/Native : cinterop
  Zig     : @cImport 로 헤더를 그대로 ★
  WASM    : Emscripten 으로 컴파일하면 브라우저에서도

실무에선 이렇게 — 다언어 배포 전략 🔴 실무

저장소 구조

mylib/
  include/mylib/*.h          ← 하나의 C API (진실의 원천) ★
  src/*.cpp                  ← C++ 구현
  tests/test_c_api.c         ← C 로 작성한 계약 테스트

  bindings/
    python/                  ← ctypes 또는 cffi 래퍼 + wheel
    node/                    ← koffi 래퍼 + npm 패키지
    csharp/                  ← P/Invoke + NuGet
    java/                    ← FFM + Maven
    go/                      ← cgo 모듈

  .github/workflows/
    build-native.yml         ← 3 OS × 2 아키텍처 → 바이너리 아티팩트
    publish-python.yml       ← 그 바이너리를 담아 wheel 배포
    publish-node.yml
    ...
핵심은 “한 번 빌드, 여러 번 포장” — 네이티브 바이너리를 한 CI 잡에서 만들어 아티팩트로 올리고, 각 언어 패키지는 그것을 내려받아 포장만 합니다. 언어마다 컴파일하면 버전이 어긋나기 시작합니다.

계약 테스트로 일관성 유지

# 모든 언어 바인딩이 같은 테스트 케이스를 통과해야 합니다
# tests/cases.yaml — 언어 중립적인 명세 ★
- name: smooth_basic
  input:  [1.0, 2.0, 3.0, 4.0, 5.0]
  window: 3
  expect: [1.5, 2.0, 3.0, 4.0, 4.5]
  tolerance: 1e-9

- name: smooth_empty
  input:  []
  window: 3
  expect: []

- name: smooth_invalid_window
  input:  [1.0, 2.0]
  window: 0
  error:  MYLIB_ERR_INVALID
# 각 언어의 테스트가 이 파일을 읽어 실행합니다
import yaml, numpy as np, mylib
for case in yaml.safe_load(open("tests/cases.yaml")):
    if "error" in case:
        with pytest.raises(mylib.MylibError):
            mylib.smooth(case["input"], case["window"])
    else:
        np.testing.assert_allclose(
            mylib.smooth(case["input"], case["window"]),
            case["expect"], rtol=case.get("tolerance", 1e-9))
언어별로 결과가 미묘하게 다른 경우가 실제로 생깁니다 — 기본 인자 처리, 빈 입력, 에러 매핑에서 갈립니다. 공통 케이스 파일을 두고 모든 바인딩이 통과하게 하면 이 문제를 구조적으로 막을 수 있습니다.

한 단계 더 — 언어별 FFI 비용 비교 🧬 전문가

호출 오버헤드의 차이

같은 C 함수를 부를 때의 대략적인 비용 (직접 재 보세요)

  Zig · Rust · C++      ~0ns    직접 호출과 동일 (컴파일 시점에 결정)
  Julia ccall           ~1ns    JIT 가 인라인
  Bun FFI               ~10ns   JIT
  C# LibraryImport      ~5~20ns 소스 제너레이터 (DllImport 보다 빠름)
  Java FFM              ~10~30ns
  Go cgo                ~50~200ns  ★ 고루틴 스케줄러 전환 비용
  Node koffi            ~100~300ns
  Python ctypes         ~500~1000ns  ★ 가장 비쌈
  Python cffi(API)      ~100ns

★ 함의
  · Go 는 cgo 호출이 의외로 비쌉니다 — 배치 처리가 특히 중요합니다
  · 파이썬 ctypes 는 '큰 작업을 가끔' 부르는 용도로 설계됐습니다
  · 어느 언어든 결론은 같습니다 — 경계를 자주 넘지 마세요

GC 언어와의 메모리 경계

GC 가 있는 언어(Java · C# · Go · JS)의 공통 함정

  ① 객체 이동(compaction)
     GC 가 메모리를 정리하며 객체를 옮길 수 있습니다.
     C 에 넘긴 포인터가 그 사이 무효가 됩니다.
     → 고정(pinning) 하거나, 네이티브 메모리를 따로 할당

     C#  : fixed 블록 · GCHandle.Alloc(pinned)
     Java: Arena 로 할당한 MemorySegment (힙 밖) ★
     Go  : cgo 호출 중에는 고정되지만 보관은 금지
     JS  : 큰 TypedArray 는 외부 메모리라 비교적 안전

  ② 콜백 수명
     콜백 객체가 GC 되면 C 쪽 함수 포인터가 죽습니다.
     → 전역 참조로 유지 (C# GCHandle · Java Arena · Python 멤버)

  ③ 예외·패닉
     각 언어의 예외가 C 프레임을 통과하면 UB 입니다.
     → 콜백 안에서 전부 잡아 에러 코드로 변환

★ 이 세 가지가 언어를 불문하고 동일하게 나타납니다.
  C API 를 설계할 때 '콜백 대신 폴링'을 권하는 이유이기도 합니다(f08).
다언어를 목표로 한다면 — 인터페이스를 “큰 배열을 한 번에 넘기고 결과를 받는” 형태로 만드세요. 그러면 어느 언어에서도 오버헤드가 무의미해지고, GC·콜백 문제도 대부분 피할 수 있습니다.
정리 — 같은 .so 하나를 C#(LibraryImport) · 자바(FFM) · Go(cgo) · Rust · Ruby · Julia 에서 전부 부를 수 있습니다. 언어별 함정은 거의 동일합니다 — GC 의 객체 이동 · 콜백 수명 · 예외 전파. 배열을 한 번에 넘기는 설계가 이 모두를 한꺼번에 해결합니다.
한 번 만들어 네 언어에서 — 규약만 지키면 됩니다 core (C++) → 공유 라이브러리 .so · .dll · .dylib Python ctypes · cffi C# DllImport Java FFM · JNA Go cgo 공통 규약 네 가지 extern "C" · 구조체는 C 배치 · 에러는 정수 코드 · 해제 함수를 함께 제공 예외가 경계를 넘지 않게 막는 것도 규약의 일부입니다 — 넘어가면 그대로 프로세스가 죽습니다 불투명 포인터로 내부 구조를 감추면 나중에 내부를 바꿔도 쓰는 쪽을 다시 빌드하지 않아도 됩니다
여러 언어를 지원하는 값은 성능보다 같은 로직을 한 번만 관리한다는 데 있습니다.
CMake · vcpkg · wheel · CI · Sanitizer

내 PC 에서만
되는 코드는
없는 것과 같습니다

C++ 에서 가장 많은 시간을 잡아먹는 것은 문법이 아니라 빌드와 배포입니다. 세 OS 에서 도는 CMake 하나, 사용자가 컴파일하지 않는 배포, 버그를 자동으로 잡아 주는 Sanitizer — 이 셋이면 대부분 해결됩니다.

10SECTIONS
3OS 동시
0사용자 컴파일
4깊이 층
BUILD 01

컴파일과 링크 — 실제로 무슨 일이

에러 메시지를 읽으려면 어느 단계에서 났는지부터 알아야 합니다. 네 단계만 이해하면 대부분의 빌드 문제가 풀립니다.

링커가 하는 일은 하나뿐입니다 — 심볼의 짝 맞추기 main.o 정의 · main 미정의 · calc, log calc.o 정의 · calc 미정의 · log libutil.a 정의 · log 미정의 · 없음 링커 — 미정의 심볼마다 정의를 찾는다 정확히 하나씩 짝이 맞아야 합니다 짝이 없다 — undefined reference .cpp 를 빌드에 안 넣었거나 -l 라이브러리 누락 선언만 하고 정의를 안 쓴 경우도 여기 짝이 둘 이상 — multiple definition 헤더에 함수 본문을 그냥 쓴 경우가 대부분 inline · static · 익명 네임스페이스로 해결
링커 에러는 문법 문제가 아닙니다 — "정의가 어디 있나"만 물어보면 거의 다 풀립니다.

네 단계

# ① 전처리 — #include 를 붙여넣고 매크로를 펼칩니다
g++ -E main.cpp -o main.i
wc -l main.i          # 50줄 파일이 <iostream> 하나로 3만 줄이 됩니다

# ② 컴파일 — 어셈블리로
g++ -S main.i -o main.s

# ③ 어셈블 — 오브젝트 파일(.o)로. 아직 실행 못 합니다
g++ -c main.cpp -o main.o
nm -C main.o
#   T main            ← 내가 제공 (Text = 코드)
#   U printf          ← 내가 필요로 함 (Undefined) ★

# ④ 링크 — 여러 .o 와 라이브러리를 이어 붙여 실행 파일로
g++ main.o util.o -lpthread -o app
ldd app               # 실행할 때 필요한 공유 라이브러리
왜 나눠서 컴파일하나 — 파일 하나를 고쳤을 때 그 파일만 다시 번역하면 됩니다. 파일이 수백 개인 프로젝트에서 이 차이가 빌드 30초 vs 20분이 됩니다. 빌드 시스템(CMake·Ninja)이 이 의존 관계를 관리해 줍니다.

에러를 단계로 구분하기 ★

메시지단계 · 원인
No such file or directory전처리 — include 경로(-I)
'x' was not declared컴파일 — 선언 누락·오타
invalid conversion컴파일 — 타입 불일치
undefined reference to 'f()'링크 — 정의가 없음 ★
multiple definition of 'f()'링크 — 헤더에 정의를 둠
cannot find -lfoo링크 — 라이브러리 경로(-L)
version GLIBC_2.38 not found실행 — 빌드 환경이 더 최신
error while loading shared libraries실행 — .so 를 못 찾음
구분법 한 줄 — 파일명과 줄 번호가 있으면 컴파일, 없으면 링크입니다. 링크 에러는 코드가 아니라 빌드 구성의 문제라 고쳐야 할 곳이 완전히 다릅니다.

이렇게도 쓸 수 있다 — 링크 에러를 심볼로 추적 🟡 중급

undefined reference 진단 순서

# ① 그 심볼을 '제공하는' 오브젝트가 있는가
nm -C --defined-only build/*.o | grep "함수이름"

# ② 있는데도 안 되면 → 링크 순서 문제 (정적 라이브러리는 순서가 중요) ★
g++ main.o -lfoo -lbar     # foo 가 bar 를 쓰면 이 순서
g++ main.o -lbar -lfoo     # ❌ 실패할 수 있습니다
#   해결: --start-group 으로 감싸기
g++ main.o -Wl,--start-group -lfoo -lbar -Wl,--end-group

# ③ 없으면 → 그 .cpp 를 빌드에 안 넣었거나
#            extern "C" 불일치(🔗 f02)이거나
#            선언과 정의의 시그니처가 미묘하게 다름 (const 유무 등)

# ④ 맹글링된 이름 확인
c++filt _Z3fooi           # → foo(int)
nm -C app | grep foo      # -C 는 복원해서 보여 줍니다

# ⑤ 어떤 라이브러리가 그 심볼을 갖고 있나 (시스템 전체 검색)
for f in /usr/lib/x86_64-linux-gnu/*.so; do
  nm -D "$f" 2>/dev/null | grep -q " T 심볼" && echo "$f"
done

헤더와 라이브러리 찾기

# 컴파일러가 어디를 뒤지는지
g++ -E -x c++ - -v < /dev/null       # include 검색 경로
g++ -print-search-dirs                # 라이브러리 경로

# 어떤 헤더를 실제로 집었는지 (중첩 깊이가 점으로 표시됩니다)
g++ -H -fsyntax-only main.cpp 2>&1 | head -30

# pkg-config — 설치된 라이브러리의 플래그를 알려 줍니다
pkg-config --cflags --libs opencv4
g++ main.cpp $(pkg-config --cflags --libs opencv4)

# 실행 시 .so 를 어디서 찾는지
ldd app
LD_DEBUG=libs ./app 2>&1 | head -20
readelf -d app | grep -E "RPATH|RUNPATH|NEEDED"

# 윈도우
dumpbin /dependents app.exe
dumpbin /exports mylib.dll
이 명령들을 알면 달라지는 것 — 링크 에러 앞에서 추측으로 CMake 를 만지는 사람과 nm 한 번으로 “이 심볼은 어디에도 없다”를 확인하고 원인으로 직행하는 사람의 차이가 여기서 납니다.

실무에선 이렇게 — 빌드 시간을 줄입니다 🔴 실무

효과가 큰 순서대로

# ① ccache — 같은 입력이면 캐시된 결과를 재사용 (재빌드가 몇 배 빨라집니다) ★
sudo apt install ccache
cmake -B build -DCMAKE_CXX_COMPILER_LAUNCHER=ccache
ccache -s                        # 적중률 확인

# ② Ninja — make 보다 빠르고 병렬화가 좋습니다
cmake -B build -G Ninja
cmake --build build              # 자동으로 모든 코어 사용

# ③ 빠른 링커 (링크가 병목인 경우가 많습니다)
cmake -B build -DCMAKE_EXE_LINKER_FLAGS="-fuse-ld=mold"
#   mold > lld > gold > bfd(기본)  순으로 빠릅니다

# ④ 미리 컴파일된 헤더 (CMake 3.16+)
target_precompile_headers(app PRIVATE <vector> <string> <memory>)

# ⑤ Unity 빌드 — 여러 .cpp 를 합쳐 컴파일
set(CMAKE_UNITY_BUILD ON)
#   ⚠ 익명 namespace 충돌·매크로 누출이 생길 수 있습니다

# ⑥ 헤더 의존성 줄이기 (근본 해결) ★
#    전방 선언 · Pimpl · 무거운 include 를 .cpp 로 (탭 1 x09)

무엇이 느린지 측정

# 파일별 컴파일 시간
clang++ -ftime-trace -c big.cpp
#   big.json 을 ui.perfetto.dev 에 넣으면 어느 헤더·템플릿이 오래 걸리는지 그림으로

# 전체 빌드에서 느린 파일 찾기
cmake -B build -DCMAKE_CXX_FLAGS="-ftime-report"
# 또는 ninja 의 빌드 로그 분석
ninja -C build -t commands | wc -l
python3 -m ninjatracing build/.ninja_log > trace.json

# 전처리 결과 크기로 대략 감 잡기
for f in src/*.cpp; do
  echo "$(g++ -E -Iinclude $f 2>/dev/null | wc -l) $f"
done | sort -rn | head

# 포함 관계 정리 도구
include-what-you-use -Xiwyu --no_fwd_decls src/core.cpp
빌드 시간은 팀 전체의 생산성입니다 — 5분 걸리는 빌드를 하루 20번 돌리면 한 사람당 100분입니다. ccache + Ninja + mold 세 줄만 넣어도 대개 절반 이하로 줄어듭니다.

한 단계 더 — 링커가 실제로 하는 일 🧬 전문가

심볼 해석과 재배치

링커의 작업
  ① 각 오브젝트의 심볼 테이블을 읽습니다 (제공 T · 필요 U)
  ② 필요한 심볼을 제공하는 오브젝트를 찾아 연결합니다
  ③ 섹션(.text · .data · .rodata · .bss)을 합칩니다
  ④ 주소를 확정하고 참조를 고쳐 씁니다 (relocation)
  ⑤ 실행 파일 헤더를 만듭니다

정적 라이브러리(.a)는 '오브젝트 묶음'일 뿐입니다
  · 링커는 필요한 오브젝트만 꺼내 씁니다
  · 그래서 순서가 중요합니다 (왼쪽에서 오른쪽으로 훑음)

약한 심볼(W)
  · inline 함수 · 템플릿 인스턴스는 여러 오브젝트에 중복 생성됩니다
  · 링커가 하나만 남기고 나머지를 버립니다 (중복 정의 에러가 안 나는 이유)
  · ★ 단, 모든 정의가 동일해야 합니다 (ODR — 🔬 d01)

확인
  nm -C app | awk '{print $2}' | sort | uniq -c | sort -rn
  size -A app                    # 섹션별 크기
  readelf -S app                 # 섹션 헤더

LTO 와 바이너리 크기

# 링크 시점 최적화 — 번역 단위를 넘어 인라인·제거가 가능해집니다
set(CMAKE_INTERPROCEDURAL_OPTIMIZATION_RELEASE ON)
#   또는 -flto=thin (clang, 훨씬 빠름)

# 효과: 5~20% 성능 향상 · 바이너리 축소
# 대가: 링크 시간이 크게 늘어납니다 → 릴리즈 빌드에만
# 안 쓰는 코드 제거
g++ -ffunction-sections -fdata-sections -c main.cpp
g++ -Wl,--gc-sections main.o -o app
#   함수 단위로 섹션을 나누고, 참조되지 않은 것을 버립니다

# 무엇이 크기를 차지하나
bloaty app -d compileunits,symbols | head -20
nm -C --size-sort -S app | tail -20

# 디버그 심볼 분리 (배포는 작게, 분석은 가능하게) ★
objcopy --only-keep-debug app app.debug
objcopy --strip-debug --add-gnu-debuglink=app.debug app
#   운영에는 app 만 배포하고 app.debug 는 보관 → 크래시 분석 때 사용(🔬 d11)

# 심볼 가시성 (라이브러리에서 특히 중요 — 🔗 f02)
-fvisibility=hidden -fvisibility-inlines-hidden
배포 바이너리의 기본 조합 — -O2 -DNDEBUG -fvisibility=hidden + LTO + 심볼 분리. 이렇게 하면 크기는 작고 성능은 최대이면서 크래시 분석 능력은 유지됩니다.
정리 — 빌드는 전처리 → 컴파일 → 어셈블 → 링크 네 단계입니다. 에러가 나면 줄 번호가 있으면 컴파일, 없으면 링크로 먼저 구분하고, 링크 에러는 nm -C 로 심볼을 직접 확인하세요. 빌드가 느리면 ccache · Ninja · mold 세 줄부터.
BUILD 02

CMake — 실무에 필요한 만큼만

CMake 는 거대하지만, 실제로 쓰는 것은 20줄 남짓입니다. 현대적인 방식(타깃 중심)만 익히면 나머지는 검색으로 충분합니다.

기본 골격

cmake_minimum_required(VERSION 3.20)
project(myproj VERSION 1.0.0 LANGUAGES CXX)

set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)          # ★ -std=c++20 (gnu++20 아님)

# 라이브러리
add_library(core src/core.cpp src/algo.cpp)
target_include_directories(core
    PUBLIC  $<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/include>
            $<INSTALL_INTERFACE:include>
    PRIVATE src)                        # src 는 나만 볼 수 있습니다
target_compile_features(core PUBLIC cxx_std_20)

# 실행 파일
add_executable(app src/main.cpp)
target_link_libraries(app PRIVATE core)  # ★ include 경로가 자동으로 따라옵니다

# 경고 켜기 (컴파일러별로)
target_compile_options(core PRIVATE
    $<$<CXX_COMPILER_ID:MSVC>:/W4>
    "$<$<NOT:$<CXX_COMPILER_ID:MSVC>>:-Wall;-Wextra;-Wpedantic>")
#   ↑ 여러 플래그는 따옴표 + ';' 로 — 공백으로 쓰면 표현식이 쪼개져 오류

# 테스트
enable_testing()
add_executable(test_core tests/test_core.cpp)
target_link_libraries(test_core PRIVATE core)
add_test(NAME core COMMAND test_core)
cmake -B build -G Ninja -DCMAKE_BUILD_TYPE=Release
cmake --build build
ctest --test-dir build --output-on-failure

PUBLIC · PRIVATE · INTERFACE ★

이 세 키워드가 현대 CMake 의 핵심입니다

  PRIVATE   : 나만 씁니다 (쓰는 쪽에 전파되지 않음)
  PUBLIC    : 나도 쓰고, 나를 링크하는 쪽에도 전파됩니다
  INTERFACE : 나는 안 쓰고, 링크하는 쪽에만 전파됩니다
              (헤더 전용 라이브러리)

예시
  target_link_libraries(core
      PUBLIC  fmt::fmt        # core 의 '헤더'가 fmt 를 쓴다 → 전파 필요
      PRIVATE zlib)           # core 의 '.cpp' 만 zlib 을 쓴다 → 감춤

  → app 은 fmt 를 자동으로 쓸 수 있지만 zlib 은 모릅니다 ★

★ 옛 방식은 쓰지 마세요 (전역 변수처럼 동작해 관리가 안 됩니다)
  include_directories()      → target_include_directories()
  link_libraries()           → target_link_libraries()
  add_definitions()          → target_compile_definitions()
  set(CMAKE_CXX_FLAGS ...)   → target_compile_options()
“타깃이 모든 것을 안다”가 현대 CMake 의 철학입니다. 타깃에 붙여 두면 링크하는 쪽이 아무것도 몰라도 include 경로·컴파일 옵션·의존성이 전부 따라옵니다.

이렇게도 쓸 수 있다 — 자주 쓰는 조각들 🟡 중급

조건 분기와 옵션

# 플랫폼별
if(WIN32)
    target_link_libraries(core PRIVATE ws2_32)
elseif(APPLE)
    target_link_libraries(core PRIVATE "-framework CoreFoundation")
else()
    target_link_libraries(core PRIVATE pthread dl)
endif()

# 제너레이터 표현식 — 빌드 타입별 (설정 시점이 아니라 빌드 시점에 평가)
target_compile_definitions(core PRIVATE
    $<$<CONFIG:Debug>:MYLIB_DEBUG>
    $<$<CONFIG:Release>:NDEBUG>)

# 사용자 옵션
option(MYLIB_BUILD_TESTS "테스트 빌드" ON)
option(MYLIB_USE_SIMD    "SIMD 사용"  ON)
if(MYLIB_BUILD_TESTS)
    add_subdirectory(tests)
endif()

# 버전 정보를 코드에 주입 ★
target_compile_definitions(core PRIVATE
    MYLIB_VERSION="${PROJECT_VERSION}"
    MYLIB_COMMIT="$ENV{GIT_COMMIT}")

# 설정 파일 생성 (더 깔끔한 방법)
configure_file(config.h.in config.h @ONLY)
target_include_directories(core PRIVATE ${CMAKE_CURRENT_BINARY_DIR})

CMakePresets — 명령을 짧게 ★

// CMakePresets.json  ("version": 3 은 CMake 3.21+ · 프리셋 자체는 3.19+)
{
  "version": 3,
  "configurePresets": [
    {
      "name": "dev",
      "generator": "Ninja",
      "binaryDir": "build/dev",
      "cacheVariables": {
        "CMAKE_BUILD_TYPE": "Debug",
        "CMAKE_EXPORT_COMPILE_COMMANDS": "ON",
        "CMAKE_CXX_COMPILER_LAUNCHER": "ccache"
      }
    },
    {
      "name": "release",
      "inherits": "dev",
      "binaryDir": "build/release",
      "cacheVariables": { "CMAKE_BUILD_TYPE": "Release" }
    },
    {
      "name": "asan",
      "inherits": "dev",
      "binaryDir": "build/asan",
      "cacheVariables": {
        "CMAKE_CXX_FLAGS": "-fsanitize=address,undefined -g"
      }
    }
  ],
  "buildPresets": [
    { "name": "dev", "configurePreset": "dev" },
    { "name": "release", "configurePreset": "release" },
    { "name": "asan", "configurePreset": "asan" }
  ],
  "testPresets": [
    { "name": "dev", "configurePreset": "dev" },
    { "name": "asan", "configurePreset": "asan" }
  ]
}
cmake --preset dev && cmake --build --preset dev
cmake --preset asan && cmake --build --preset asan
#   IDE(VSCode·CLion)도 이 프리셋을 자동으로 읽습니다 ★

실무에선 이렇게 — 설치와 패키지 제공 🔴 실무

남이 find_package 로 쓰게 하기

include(GNUInstallDirs)
include(CMakePackageConfigHelpers)

install(TARGETS core
    EXPORT myprojTargets
    LIBRARY  DESTINATION ${CMAKE_INSTALL_LIBDIR}
    ARCHIVE  DESTINATION ${CMAKE_INSTALL_LIBDIR}
    RUNTIME  DESTINATION ${CMAKE_INSTALL_BINDIR})

install(DIRECTORY include/ DESTINATION ${CMAKE_INSTALL_INCLUDEDIR})

install(EXPORT myprojTargets
    FILE myprojTargets.cmake
    NAMESPACE myproj::
    DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/myproj)

write_basic_package_version_file(
    myprojConfigVersion.cmake
    VERSION ${PROJECT_VERSION}
    COMPATIBILITY SameMajorVersion)

configure_package_config_file(
    cmake/myprojConfig.cmake.in myprojConfig.cmake
    INSTALL_DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/myproj)

install(FILES
    ${CMAKE_CURRENT_BINARY_DIR}/myprojConfig.cmake
    ${CMAKE_CURRENT_BINARY_DIR}/myprojConfigVersion.cmake
    DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/myproj)
# 쓰는 쪽은 이 두 줄이면 끝입니다 ★
find_package(myproj 1.0 REQUIRED)
target_link_libraries(app PRIVATE myproj::core)

피해야 할 습관

# ❌ 소스 파일을 GLOB 로 모으기
file(GLOB SRC "src/*.cpp")
#   파일을 추가해도 CMake 가 모르므로 재설정이 필요합니다
#   (CONFIGURE_DEPENDS 를 붙이면 낫지만 여전히 명시가 권장됩니다)
add_library(core src/core.cpp src/algo.cpp)     # ✅ 명시

# ❌ 소스 폴더에 빌드하기
cmake .                          # 산출물이 소스와 섞입니다
cmake -B build                   # ✅ 항상 별도 폴더

# ❌ 전역 플래그 조작
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -O3")
target_compile_options(core PRIVATE -O3)        # ✅ 타깃에

# ❌ -march=native 를 배포 빌드에
#   내 CPU 전용 명령이 들어가 다른 기계에서 즉사합니다 ★

# ❌ 절대 경로 하드코딩
include_directories(/home/me/libs/include)
find_package(...)  또는  FetchContent               # ✅

# ⚠ 빌드 타입을 안 정하면 최적화가 안 됩니다 (기본값이 빈 문자열)
if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES)
    set(CMAKE_BUILD_TYPE Release CACHE STRING "" FORCE)
endif()
가장 흔한 실수 — CMAKE_BUILD_TYPE 을 지정하지 않아 최적화 없이 빌드하고 “C++ 인데 왜 느리지?”라고 하는 경우입니다. 단일 설정 제너레이터(Ninja·Make)에서는 반드시 지정해야 합니다.

한 단계 더 — CMake 디버깅 🧬 전문가

무슨 일이 벌어지는지 보기

# 실제 컴파일 명령 확인 ★
cmake --build build --verbose
# 또는
cat build/compile_commands.json | jq '.[0]'

# 타깃의 속성 들여다보기
cmake --build build --target help
cmake -B build --graphviz=deps.dot && dot -Tpng deps.dot -o deps.png

# 변수 추적
cmake -B build --trace-expand 2>&1 | grep "MY_VAR"
message(STATUS "flags=${CMAKE_CXX_FLAGS}")

# 타깃 속성 출력
get_target_property(inc core INTERFACE_INCLUDE_DIRECTORIES)
message(STATUS "core include: ${inc}")

# 캐시 확인·수정
cmake -B build -LAH | less           # 모든 옵션과 설명
ccmake build                          # 대화형 편집

# 설정을 처음부터 다시 (캐시가 꼬였을 때)
rm -rf build && cmake --preset dev

재현 가능한 빌드

# ① 컴파일러를 고정
set(CMAKE_CXX_COMPILER "/usr/bin/g++-13" CACHE FILEPATH "")

# ② 의존성 버전 고정 (b03)
FetchContent_Declare(fmt GIT_TAG 11.0.2)    # 해시나 태그로

# ③ 빌드 정보를 바이너리에 기록 ★
execute_process(COMMAND git rev-parse --short HEAD
    OUTPUT_VARIABLE GIT_COMMIT OUTPUT_STRIP_TRAILING_WHITESPACE
    ERROR_QUIET)
target_compile_definitions(core PRIVATE
    BUILD_COMMIT="${GIT_COMMIT}"
    BUILD_COMPILER="${CMAKE_CXX_COMPILER_ID} ${CMAKE_CXX_COMPILER_VERSION}")

# ④ 타임스탬프 제거 (바이너리가 매번 달라지지 않게)
#    __DATE__ · __TIME__ 을 쓰지 마세요
target_compile_options(core PRIVATE -Wdate-time)   # 경고로 잡기

# ⑤ 경로 정규화 (디버그 정보에 절대 경로가 박히는 것 방지)
target_compile_options(core PRIVATE
    -ffile-prefix-map=${CMAKE_SOURCE_DIR}=.)
왜 재현성이 중요한가 — “어제 빌드한 것과 오늘 것이 다르다”는 버그 추적을 불가능하게 만듭니다. 특히 배포한 바이너리와 같은 것을 다시 만들 수 있어야 크래시 덤프를 분석할 수 있습니다(🔬 d11).
정리 — CMake 는 타깃 중심으로 쓰세요 — target_link_libraries·target_include_directories 에 PUBLIC/PRIVATE 만 정확히 붙이면 나머지는 자동입니다. 빌드 타입 지정과 compile_commands.json 생성을 잊지 마세요.
CMake — 빌드 방법이 아니라 무엇을 만들지 적습니다 CMakeLists.txt 무엇을 만들지 선언 cmake 구성 환경에 맞는 빌드 파일 생성 Makefile · Ninja · MSVC 같은 파일로 세 OS 를 다룹니다 실무에 필요한 것은 사실 이 정도입니다 add_executable / add_library — 무엇을 만들 것인가 target_include_directories / target_link_libraries — 이 목표에 무엇이 필요한가 target_compile_features — 어떤 표준으로 (예: cxx_std_20) 전역 설정(include_directories 등)을 뿌리지 마세요 — 목표마다 붙여야 전파가 됩니다 PUBLIC 은 나와 나를 쓰는 쪽 모두에, PRIVATE 은 나에게만 적용됩니다 — 이 구분이 핵심입니다 인터넷의 옛 예제를 그대로 베끼면 전역 방식인 경우가 많습니다 — 목표 기준 방식으로 쓰세요
CMake 가 어려운 이유의 절반은 옛 방식과 새 방식이 섞여 있어서입니다.
BUILD 03

의존성 — vcpkg · FetchContent

C++ 에는 npm·pip 같은 공식 패키지 관리자가 없습니다. 대신 몇 가지 방식이 있고, 프로젝트 성격에 따라 고릅니다.

FetchContent — 소스를 받아 함께 빌드

include(FetchContent)

FetchContent_Declare(fmt
    GIT_REPOSITORY https://github.com/fmtlib/fmt.git
    GIT_TAG        11.0.2          # ★ 반드시 고정 (main 금지)
    GIT_SHALLOW    TRUE)           # 전체 이력을 안 받아 빠릅니다

FetchContent_Declare(googletest
    URL https://github.com/google/googletest/archive/v1.15.2.tar.gz
    URL_HASH SHA256=7b42b4d6ed48810c5362c265a17faebe90dc2373c885e5216439d37927f02926)
    # ★ 해시까지 넣으면 공급망 공격에 안전합니다

FetchContent_MakeAvailable(fmt googletest)

target_link_libraries(core PUBLIC fmt::fmt)
target_link_libraries(test_core PRIVATE GTest::gtest_main)
장점 — 별도 도구가 필요 없고, git clone 후 cmake -B build 만으로 끝납니다. 단점 — 의존성이 많아지면 빌드 시간이 크게 늘어납니다 (전부 소스에서 컴파일하므로). 소규모 프로젝트에 적합합니다.

vcpkg — 미리 빌드된 패키지

// vcpkg.json — 매니페스트 모드 (권장) ★
{
  "name": "myproj",
  "version": "1.0.0",
  "dependencies": [
    "fmt",
    { "name": "opencv4", "features": ["contrib"] },
    { "name": "boost-asio", "version>=": "1.85.0" }
  ],
  "builtin-baseline": "a1b2c3d4...",   // ★ 저장소 커밋 = 버전 잠금
  "overrides": [ { "name": "fmt", "version": "11.0.2" } ]
}
git clone https://github.com/microsoft/vcpkg
./vcpkg/bootstrap-vcpkg.sh

cmake -B build \
  -DCMAKE_TOOLCHAIN_FILE=./vcpkg/scripts/buildsystems/vcpkg.cmake
#   → vcpkg.json 을 읽고 자동으로 설치·빌드합니다

# 캐시로 재빌드 시간 없애기 (CI 에서 특히) ★
export VCPKG_BINARY_SOURCES="clear;files,/path/to/cache,readwrite"
find_package(fmt CONFIG REQUIRED)
find_package(OpenCV CONFIG REQUIRED)
target_link_libraries(core PRIVATE fmt::fmt ${OpenCV_LIBS})

이렇게도 쓸 수 있다 — 방식 비교와 선택 🟡 중급

다섯 가지 방식

방식언제
FetchContent의존성 3~5개 이하 · 도구 설치를 강요하고 싶지 않을 때 ★
vcpkg큰 라이브러리(OpenCV·Qt·Boost) · 윈도우 중심 팀
Conan사내 아티팩트 서버 · 세밀한 버전 정책이 필요할 때
시스템 패키지
apt·brew
리눅스 배포용 · 버전을 배포판에 맡길 수 있을 때
서브모듈·벤더링소스를 직접 관리해야 할 때 · 헤더 전용 라이브러리
# 하이브리드 — 있으면 시스템 것을, 없으면 받아서 (가장 유연) ★
find_package(fmt 11 QUIET)
if(NOT fmt_FOUND)
    include(FetchContent)
    FetchContent_Declare(fmt GIT_REPOSITORY ... GIT_TAG 11.0.2)
    FetchContent_MakeAvailable(fmt)
endif()
target_link_libraries(core PUBLIC fmt::fmt)

헤더 전용 라이브러리는 그냥

# 파일 하나짜리는 벤더링이 가장 단순합니다
# third_party/nlohmann/json.hpp 를 저장소에 넣고
target_include_directories(core PRIVATE third_party)

# 또는 INTERFACE 라이브러리로 정리
add_library(json INTERFACE)
target_include_directories(json INTERFACE third_party/nlohmann)
target_link_libraries(core PRIVATE json)
자주 쓰는 헤더 전용 라이브러리
  nlohmann/json      JSON
  fmt                포맷 (std::format 대체)
  spdlog             로깅
  doctest · catch2   테스트
  cxxopts · CLI11    명령행 인자
  xxhash · robin_hood  해시
  Eigen              선형대수
  stb_image          이미지 로딩 (단일 헤더)

★ 이런 것들은 vcpkg 를 쓸 이유가 없습니다.
  파일을 복사해 넣거나 FetchContent 로 충분합니다.
의존성은 부채입니다 — 라이브러리 하나가 빌드 시간 · 배포 크기 · 보안 패치 추적 · 라이선스 검토를 함께 가져옵니다. 표준 라이브러리로 되는 일인지 먼저 확인하세요.

실무에선 이렇게 — 버전 고정과 공급망 🔴 실무

재현 가능하게 고정하기

# ❌ 절대 하면 안 되는 것
GIT_TAG main                    # 언제 빌드하느냐에 따라 결과가 다릅니다
GIT_TAG master
FetchContent_Declare(x URL "https://.../latest.tar.gz")

# ✅ 태그 + 해시
FetchContent_Declare(fmt
    GIT_REPOSITORY https://github.com/fmtlib/fmt.git
    GIT_TAG 0c9fce2ffefecfdce794e1859584e25877b7b592)   # 커밋 해시 ★

# ✅ tarball + SHA256
FetchContent_Declare(zlib
    URL https://zlib.net/zlib-1.3.1.tar.gz
    URL_HASH SHA256=9a93b2b7dfdac77ceba5a558a580e74667dd6fede4585b91eefb60f03b72df23)

# vcpkg 는 baseline 커밋으로 전체를 잠급니다
"builtin-baseline": "a1b2c3..."
# 의존성 목록과 라이선스 뽑기 (감사용) ★
vcpkg list --x-full-desc
find _deps -name "LICENSE*" -o -name "COPYING*" | xargs -I{} sh -c 'echo "== {}"; head -3 {}'

# 취약점 확인
grype dir:.                     # 또는 trivy fs .
# SBOM 생성
syft dir:. -o spdx-json > sbom.json

CI 캐시로 시간 줄이기

# vcpkg 바이너리 캐시 — 두 번째부터 빌드가 거의 즉시 ★
- name: vcpkg 캐시
  uses: actions/cache@v4
  with:
    path: ~/.cache/vcpkg/archives
    key: vcpkg-${{ runner.os }}-${{ hashFiles('vcpkg.json') }}

# FetchContent 소스 캐시
- uses: actions/cache@v4
  with:
    path: build/_deps
    key: deps-${{ runner.os }}-${{ hashFiles('CMakeLists.txt') }}

# ccache (컴파일 결과)
- uses: hendrikmuhs/ccache-action@v1
  with: { key: '${{ runner.os }}' }
# 사내 캐시 서버 (여러 사람이 공유)
export VCPKG_BINARY_SOURCES="clear;nuget,https://내사내피드,readwrite"

# 오프라인 빌드 준비 (폐쇄망)
vcpkg export fmt opencv4 --raw --output-dir=/deps
#   → 이 폴더를 통째로 옮기면 인터넷 없이 빌드됩니다
사내 폐쇄망 대응 — C++ 프로젝트는 인터넷이 없는 환경에서 빌드해야 하는 경우가 많습니다. vcpkg export 나 의존성 벤더링을 미리 준비해 두면 급할 때 고생하지 않습니다.

한 단계 더 — 의존성이 만드는 문제들 🧬 전문가

다이아몬드 의존과 ODR 위반

내 앱 → A → fmt 10.0
      → B → fmt 11.0

★ C++ 에는 npm 처럼 '버전별로 따로 담기'가 없습니다.
  하나만 링크되고, 둘 다 그것을 씁니다.
  → 헤더가 다른데 구현이 하나면 ODR 위반 → 조용한 메모리 손상 (🔬 d01)

대책
  ① 버전을 하나로 통일 (vcpkg overrides · FetchContent 명시)
  ② 의존성을 정적으로 링크하고 심볼을 숨기기 (-fvisibility=hidden)
  ③ 내부 네임스페이스로 격리 (일부 라이브러리가 제공: FMT_BEGIN_NAMESPACE)
  ④ 애초에 의존성을 줄이기 ★

진단
  # 같은 심볼이 여러 라이브러리에 있는지
  nm -DC libA.so libB.so | grep " T " | awk '{print $3}' | sort | uniq -d
  # 실행 시 어느 것이 선택됐는지
  LD_DEBUG=bindings ./app 2>&1 | grep fmt

전이 의존성 통제

# 의존성이 밖으로 새지 않게 — PRIVATE 로 감추기 ★
target_link_libraries(core
    PRIVATE zlib::zlib)        # core 를 쓰는 쪽은 zlib 을 모릅니다
#   ⚠ 단, core 의 '공개 헤더'가 zlib 타입을 노출하면 PUBLIC 이어야 합니다
#     → 공개 헤더에서 서드파티 타입을 쓰지 않는 것이 좋은 설계입니다

# 정적 라이브러리를 만들 때 의존성을 안에 포함시키기
add_library(core STATIC ...)
#   ⚠ 정적 라이브러리는 의존성을 '포함하지 않습니다' (링크 정보만 전달)
#     완전히 포함하려면 별도 작업이 필요합니다
#     (libtool · CMake 의 OBJECT 라이브러리 조합 등)

# 의존성 트리 확인
cmake -B build --graphviz=deps.dot
dot -Tsvg deps.dot -o deps.svg

# 실제로 링크된 것 확인
ldd -v build/app | head -30
readelf -d build/libcore.so | grep NEEDED
공개 헤더에 서드파티 타입을 노출하지 마세요 — void f(nlohmann::json&) 같은 API 는 쓰는 쪽도 그 라이브러리를 같은 버전으로 써야 합니다. 경계에서는 표준 타입이나 내 타입만 쓰는 것이 원칙입니다(🔗 f03).
정리 — 의존성이 적으면 FetchContent, 크고 무거우면 vcpkg 입니다. 무엇을 쓰든 버전을 커밋 해시나 태그로 고정하고, CI 캐시를 붙이세요. 그리고 공개 헤더에 서드파티 타입을 노출하지 않는 것이 나중에 가장 큰 차이를 만듭니다.
가져오는 길은 셋 — 무엇을 고정하고 싶은지로 갈립니다 ① 시스템 패키지 apt install libfmt-dev find_package(fmt) 설치가 빠릅니다 버전이 배포판에 묶입니다 윈도우에는 대응이 없습니다 ② vcpkg · conan vcpkg.json 에 버전 고정 툴체인 파일로 CMake 에 연결 세 OS 에서 같은 버전 빌드 결과를 캐시로 재사용 도구를 하나 더 설치해야 합니다 ③ FetchContent CMake 가 소스를 내려받아 내 프로젝트와 같이 빌드 추가 도구가 없습니다 git 태그로 버전 고정 의존성이 늘수록 빌드가 길어집니다 실무 조합 — 작은 헤더 라이브러리는 ③, 무거운 것(boost·opencv)은 ② 어느 쪽이든 버전은 반드시 파일에 적어 고정합니다. "최신을 받아라"로 두면 어제 되던 빌드가 오늘 깨집니다 CI 에서는 캐시가 핵심입니다 — 의존성 빌드를 매번 다시 하면 파이프라인 시간의 대부분을 여기서 씁니다
C++ 의존성 관리는 정답이 하나가 아닙니다 — 고른 뒤 팀 안에서 통일하는 편이 중요합니다.
가져오는 길은 셋 — 무엇을 고정하고 싶은지로 갈립니다 ① 시스템 패키지 apt install libfmt-dev find_package(fmt) 설치가 빠릅니다 버전이 배포판에 묶입니다 윈도우에는 대응이 없습니다 ② vcpkg · conan vcpkg.json 에 버전 고정 툴체인 파일로 CMake 에 연결 세 OS 에서 같은 버전 빌드 결과를 캐시로 재사용 도구를 하나 더 설치해야 합니다 ③ FetchContent CMake 가 소스를 내려받아 내 프로젝트와 같이 빌드 추가 도구가 없습니다 git 태그로 버전 고정 의존성이 늘수록 빌드가 길어집니다 실무 조합 — 작은 헤더 라이브러리는 ③, 무거운 것(boost·opencv)은 ② 어느 쪽이든 버전은 반드시 파일에 적어 고정합니다. "최신을 받아라"로 두면 어제 되던 빌드가 오늘 깨집니다 CI 에서는 캐시가 핵심입니다 — 의존성 빌드를 매번 다시 하면 파이프라인 시간의 대부분을 여기서 씁니다
C++ 의존성 관리는 정답이 하나가 아닙니다 — 고른 뒤 팀 안에서 통일하는 편이 중요합니다.
BUILD 04

디버그 vs 릴리즈 — 플래그 하나에 10배

최적화를 켜지 않은 C++ 는 파이썬보다 느릴 수도 있습니다. 성능을 이야기하기 전에 어떤 빌드로 쟀는지부터 확인해야 합니다.

빌드 타입

cmake -B build -DCMAKE_BUILD_TYPE=Release

# CMake 기본 빌드 타입이 주는 플래그
#   Debug          -g              최적화 없음 · 디버깅 최적
#   Release        -O3 -DNDEBUG    최적화 최대 · 디버그 정보 없음
#   RelWithDebInfo -O2 -g -DNDEBUG 최적화 + 디버그 정보 ★ 운영 권장
#   MinSizeRel     -Os -DNDEBUG    크기 우선

# ⚠ 단일 설정 제너레이터(Ninja·Make)는 반드시 지정해야 합니다
#   안 하면 빈 문자열 = 최적화 없음 ★
# ⚠ 다중 설정(Visual Studio·Xcode)은 빌드할 때 지정합니다
cmake --build build --config Release
실제 차이 (같은 코드 · 경향)

  -O0 (Debug)     1.0×   기준
  -O1             2~4×
  -O2             3~10×  ★ 실무 기본
  -O3             -O2 와 비슷하거나 조금 나음 (때로 더 느림)
  -O2 + LTO       -O2 대비 5~20% 추가
  -Ofast          부동소수 규칙을 깨므로 결과가 달라질 수 있습니다 ⚠

★ "C++ 로 옮겼는데 안 빨라졌다"의 1순위 원인이 Debug 빌드입니다

NDEBUG 가 하는 일

// NDEBUG 가 정의되면 assert 가 통째로 사라집니다
#include <cassert>
assert(index < size);        // Release 에서는 없는 코드

// ⚠ 그래서 이런 코드는 위험합니다
assert(pop() == 3);          // Release 에서 pop() 자체가 안 불립니다 ★

// ✅ 부작용은 밖으로
int v = pop();
assert(v == 3);

// ✅ 릴리즈에서도 살아야 하는 검사는 직접 만드세요
#define ENSURE(cond, msg) \
    do { if (!(cond)) throw std::logic_error(msg); } while (0)

// 표준 라이브러리의 디버그 검사
//   MSVC : Debug 빌드에서 반복자 검사가 기본으로 켜집니다 (느립니다)
//   libstdc++ : -D_GLIBCXX_DEBUG 로 켜기 ★ 개발 중에 유용
//   libc++    : -D_LIBCPP_HARDENING_MODE=_LIBCPP_HARDENING_MODE_DEBUG
//               (LLVM 18+ · 옛 -D_LIBCPP_ENABLE_ASSERTIONS=1 은 폐기)
//   → v[100] 같은 범위 초과를 UB 대신 명확한 에러로 잡아 줍니다
파이썬 모듈에서 특히 중요합니다 — assert 로만 막아 둔 인덱스 검사는 Release 에서 사라져, 잘못된 입력이 예외가 아니라 세그폴트로 인터프리터를 죽입니다(🐍 y12). 외부 입력 검증은 항상 진짜 검사로.

이렇게도 쓸 수 있다 — 알아 둘 만한 플래그 🟡 중급

경고와 안전

# 경고 — C++ 에서는 대부분 진짜 버그입니다 ★
-Wall -Wextra -Wpedantic
-Wshadow              # 변수 가리기
-Wconversion          # 암묵 형변환으로 값이 바뀔 수 있음
-Wsign-conversion     # 부호 변환
-Wnon-virtual-dtor    # 가상 소멸자 누락 ★
-Wold-style-cast      # C 스타일 캐스트
-Wcast-align          # 정렬이 안 맞는 캐스트
-Werror               # 경고를 에러로 (CI 에서)

# MSVC
/W4 /permissive- /Zc:__cplusplus /utf-8

# 런타임 보호 (배포 빌드에 권장) ★
-D_FORTIFY_SOURCE=3           # 버퍼 오버플로 검사 (최적화 필요)
-fstack-protector-strong      # 스택 스매싱 탐지
-fstack-clash-protection
-D_GLIBCXX_ASSERTIONS         # 표준 컨테이너 경계 검사 (가벼움)
-fPIE -pie                    # 주소 랜덤화
-Wl,-z,relro,-z,now           # GOT 보호
#   ⚠ 약간의 성능 비용이 있지만 대부분 1% 미만입니다

플래그를 CMake 에 담기

# 공통 옵션을 INTERFACE 라이브러리로 모으기 ★
add_library(project_options INTERFACE)
target_compile_features(project_options INTERFACE cxx_std_20)

target_compile_options(project_options INTERFACE
    "$<$<CXX_COMPILER_ID:MSVC>:/W4;/permissive-;/utf-8>"
    "$<$<NOT:$<CXX_COMPILER_ID:MSVC>>:-Wall;-Wextra;-Wshadow;-Wnon-virtual-dtor>")
#   ★ 플래그 여러 개는 따옴표로 감싸고 ';' 로 구분 (공백·줄바꿈이면 표현식이 깨집니다)

# 릴리즈에만 보호 옵션
target_compile_options(project_options INTERFACE
    "$<$<AND:$<CONFIG:Release>,$<NOT:$<CXX_COMPILER_ID:MSVC>>>:-D_FORTIFY_SOURCE=3;-fstack-protector-strong>")

# 모든 타깃에 붙이기
target_link_libraries(core PRIVATE project_options)
target_link_libraries(app  PRIVATE project_options)

# Sanitizer 도 같은 방식으로 (b10)
add_library(project_asan INTERFACE)
target_compile_options(project_asan INTERFACE -fsanitize=address,undefined -g)
target_link_options(project_asan INTERFACE -fsanitize=address,undefined)
이 패턴을 쓰면 새 타깃을 추가할 때 target_link_libraries(new_target PRIVATE project_options) 한 줄이면 모든 규칙이 자동으로 적용됩니다. 플래그를 여러 곳에 복사하다 어긋나는 문제가 사라집니다.

실무에선 이렇게 — 빌드 타입 전략 🔴 실무

네 가지를 준비합니다

용도설정
개발Debug + _GLIBCXX_DEBUG + ccache
CI 검증Debug + ASan·UBSan·TSan (b10) ★
성능 측정RelWithDebInfo — 프로파일러가 함수명을 보려면 -g 필요
배포Release + LTO + 보호 옵션 + 심볼 분리
# 성능 측정은 반드시 최적화 빌드로 ★
cmake --preset release && cmake --build --preset release
perf record -g ./build/release/bench

# ⚠ Debug 빌드로 벤치마크를 돌리고 "느리다"고 하는 실수가 정말 많습니다
#   벤치마크 코드에 빌드 타입을 출력하게 해 두세요
#ifdef NDEBUG
    std::puts("Release 빌드");
#else
    std::puts("⚠ Debug 빌드 — 성능 수치가 의미 없습니다");
#endif

최적화가 코드를 바꿉니다

// Debug 에서만 되고 Release 에서 깨진다면 → 거의 항상 UB 입니다 ★
//   (🔬 d02 에서 자세히)

// ① 초기화하지 않은 변수 — Debug 는 0 이 들어가기도 합니다
int n;                    // Release 에서는 쓰레기 값

// ② 부호 있는 오버플로
if (n + 1 < n) { }        // Release 에서 이 검사가 삭제됩니다

// ③ 널 체크 순서
int x = *p; if (!p) return;   // Release 에서 널 검사가 사라집니다

// ④ strict aliasing 위반
float f = 1.0f; int i = *(int*)&f;      // -O2 에서 결과가 달라집니다

// 진단
//   -fsanitize=undefined 로 빌드해 실행하면 그 자리에서 알려 줍니다 ★
//   -fno-strict-aliasing 로 증상이 사라지면 ④번이 원인입니다
//   -O0 에서만 되면 UB 를 의심하고 Sanitizer 를 돌리세요
“릴리즈에서만 터진다”는 컴파일러 버그가 아닙니다 — 99% 는 내 코드의 UB 입니다. 최적화는 “UB 가 없다”는 가정 위에서 코드를 변형하므로, UB 가 있으면 Debug 에서 우연히 동작하던 것이 드러날 뿐입니다.

한 단계 더 — 프로파일 기반 최적화 🧬 전문가

PGO — 실제 실행 패턴으로 최적화

# ① 계측 빌드로 실행 데이터를 수집
g++ -O2 -fprofile-generate -o app_prof main.cpp
./app_prof < typical_workload.txt        # ★ 실제와 비슷한 입력으로

# ② 그 데이터를 써서 다시 빌드
g++ -O2 -fprofile-use -o app main.cpp
#   분기 예측 · 인라인 · 코드 배치가 실제 패턴에 맞춰집니다
#   → 대개 5~15% 추가 향상 (분기가 많은 코드에서 특히)

# clang 은 두 단계
clang++ -fprofile-instr-generate -O2 main.cpp -o app_prof
LLVM_PROFILE_FILE=app.profraw ./app_prof
llvm-profdata merge -output=app.profdata app.profraw
clang++ -fprofile-instr-use=app.profdata -O2 main.cpp -o app
# CMake 에 넣기
option(ENABLE_PGO_GEN "PGO 데이터 생성" OFF)
option(ENABLE_PGO_USE "PGO 데이터 사용" OFF)
if(ENABLE_PGO_GEN)
    target_compile_options(app PRIVATE -fprofile-generate)
    target_link_options(app PRIVATE -fprofile-generate)
elseif(ENABLE_PGO_USE)
    target_compile_options(app PRIVATE -fprofile-use -fprofile-correction)
endif()

최적화 결과 확인하기

# 최적화 리포트 — 무엇이 적용되고 무엇이 안 됐는지
g++ -O3 -fopt-info-vec-optimized -fopt-info-vec-missed main.cpp
clang++ -O3 -Rpass=loop-vectorize -Rpass-missed=loop-vectorize main.cpp
#   "loop not vectorized: ..." 이유까지 알려 줍니다 ★

# 인라인 여부
g++ -O2 -Winline main.cpp
clang++ -O2 -Rpass=inline main.cpp

# 어셈블리 직접 확인 (⚡ e09)
g++ -O2 -S -masm=intel -o - main.cpp | grep -A30 "hot_function"
#   또는 godbolt.org

# 빌드 타입이 실제로 뭔지 확인
strings app | grep -i "GCC:"          # 컴파일러 정보
readelf -p .comment app
nm app | grep -c " T "                # 심볼 수 (strip 여부)
PGO 는 마지막 카드입니다 — 빌드 파이프라인이 복잡해지고 대표적인 워크로드를 정하는 것이 어렵습니다. 알고리즘·메모리·I/O 를 다 손본 뒤, 여전히 몇 %가 아쉬울 때만 꺼내세요.
정리 — -DCMAKE_BUILD_TYPE=Release 를 빠뜨리면 최적화가 안 됩니다 — 성능 이야기의 출발점입니다. 운영 배포는 RelWithDebInfo + 심볼 분리가 좋고, 경고는 전부 켜서 에러로 만드세요. Debug 에서만 되는 코드는 UB 를 의심하고 Sanitizer 를 돌리세요.
플래그 하나에 10배 — 무엇을 재고 있는지 확인하세요 Debug (-O0 -g) 최적화 끔 · 디버그 정보 포함 한 줄씩 따라가며 변수를 볼 수 있습니다 assert 와 표준 라이브러리 검사가 켜집니다 아주 느립니다 — 개발용입니다 Release (-O2 · -O3) 최적화 켬 · 검사 코드 제거 인라인 · 벡터화 · 죽은 코드 제거 변수가 사라지고 실행 순서가 달라집니다 수 배에서 수십 배 빠릅니다 중간 선택지 — RelWithDebInfo 최적화를 켠 채 디버그 정보도 넣습니다. 운영에서 크래시 덤프를 분석하려면 이 조합이 필요합니다 성능은 반드시 릴리즈로 재세요 — 디버그 수치는 비교에도 쓸 수 없습니다 반대로 릴리즈에서만 나는 버그는 미정의 동작을 의심하세요 — Sanitizer 로 확인하는 것이 빠릅니다 CMAKE_BUILD_TYPE 을 안 주면 아무 최적화도 안 걸립니다 — 가장 흔한 "왜 이렇게 느리지" 의 원인입니다
"C++ 인데 파이썬보다 느리다" 는 신고의 상당수가 디버그 빌드였습니다.
BUILD 05

윈도우 · 리눅스 · 맥 — 실제로 다른 것들

“크로스 플랫폼”이라고 해도 부딪히는 지점은 정해져 있습니다. 그 목록만 알면 대부분 피할 수 있습니다.

차이 목록

WindowsLinuxmacOS
컴파일러MSVCGCCClang
공유 라이브러리.dll+.lib.so.dylib
기호 내보내기명시 필요 ★기본 전부기본 전부
경로 구분\ (그러나 /도 됨)//
파일명 대소문자구분 안 함 ★구분기본 구분 안 함
long 크기4바이트 ★8바이트8바이트
라이브러리 경로같은 폴더·PATHRPATH·ld.so@rpath
런타임MSVCRT 재배포glibc·musl시스템
가장 자주 물리는 셋 — ① 윈도우에서 심볼을 안 내보내 링크 실패, ② 파일명 대소문자(윈도우에서 되던 #include "Utils.h" 가 리눅스에서 실패), ③ long 크기가 달라 바이너리 포맷이 깨짐.

코드로 흡수하기

// ① 경로 — std::filesystem 으로 통일 ★
#include <filesystem>
namespace fs = std::filesystem;
fs::path p = fs::path("data") / "input.csv";     // 구분자 자동
p.string();          // 플랫폼 기본 형식
p.generic_string();  // 항상 '/' (설정 파일에 저장할 때)

// ⚠ 윈도우에서 한글 경로 — u8path 나 wstring 필요
std::ifstream f(p);   // fs::path 를 넘기면 MSVC 가 알아서 처리합니다 ★

// ② 정수 크기 — 고정폭만 쓰기
#include <cstdint>
int64_t n;           // long 대신 (탭 1 x02)

// ③ 줄바꿈 — 텍스트 모드가 다릅니다
std::ofstream f(p, std::ios::binary);   // ★ 바이너리로 열면 변환 없음

// ④ 시간 — 표준으로
#include <chrono>
auto now = std::chrono::system_clock::now();

// ⑤ 스레드·동기화 — 표준으로 (pthread·Win32 직접 호출 금지)
#include <thread>  <mutex>

// ⑥ 정말 다른 부분만 얇은 계층으로 (탭 1 x10)
namespace plat { void set_thread_name(const char*); }

이렇게도 쓸 수 있다 — CMake 로 흡수하기 🟡 중급

플랫폼별 설정

# 소스 파일 자체를 나누기 (#ifdef 산재보다 낫습니다) ★
add_library(core src/core.cpp)
if(WIN32)
    target_sources(core PRIVATE src/platform_win.cpp)
    target_link_libraries(core PRIVATE ws2_32 shlwapi)
    target_compile_definitions(core PRIVATE
        NOMINMAX                      # ★ min/max 매크로 방지
        WIN32_LEAN_AND_MEAN           # windows.h 를 가볍게
        _CRT_SECURE_NO_WARNINGS)
elseif(APPLE)
    target_sources(core PRIVATE src/platform_mac.cpp)
    target_link_libraries(core PRIVATE "-framework CoreFoundation")
    set(CMAKE_OSX_DEPLOYMENT_TARGET "11.0")     # ★ 지원할 최소 버전
    #   ⚠ 원래는 첫 project() 보다 먼저(또는 -D·프리셋으로) 정해야 확실히 적용됩니다
else()
    target_sources(core PRIVATE src/platform_posix.cpp)
    target_link_libraries(core PRIVATE pthread dl)
endif()

# 공유 라이브러리 심볼 (🔗 f02)
set_target_properties(core PROPERTIES
    CXX_VISIBILITY_PRESET hidden
    VISIBILITY_INLINES_HIDDEN ON
    WINDOWS_EXPORT_ALL_SYMBOLS OFF)   # 명시적으로 표시한 것만

# 출력 위치 통일 (윈도우는 Release/ 하위 폴더가 생깁니다)
set_target_properties(app PROPERTIES
    RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/bin
    RUNTIME_OUTPUT_DIRECTORY_RELEASE ${CMAKE_BINARY_DIR}/bin)

런타임 라이브러리 찾기

# 리눅스·맥 — 자기 옆의 .so 를 찾게
set_target_properties(app PROPERTIES
    BUILD_RPATH_USE_ORIGIN TRUE
    INSTALL_RPATH "$ORIGIN/../lib")
if(APPLE)
    set_target_properties(app PROPERTIES INSTALL_RPATH "@loader_path/../lib")
endif()

# 윈도우 — RPATH 가 없습니다. DLL 을 실행 파일 옆에 복사
if(WIN32)
    add_custom_command(TARGET app POST_BUILD
        COMMAND ${CMAKE_COMMAND} -E copy_if_different
            $<TARGET_RUNTIME_DLLS:app> $<TARGET_FILE_DIR:app>
        COMMAND_EXPAND_LISTS)         # ★ CMake 3.21+
endif()

# MSVC 런타임 정적 링크 (재배포 패키지 없이 배포)
set(CMAKE_MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>")
#   → /MT · /MTd. 파일이 커지지만 의존성이 없어집니다 ★
윈도우 배포에서 가장 흔한 문제 — “DLL 을 찾을 수 없습니다”. $<TARGET_RUNTIME_DLLS:app> 로 필요한 DLL 을 자동 복사하거나, 정적 런타임으로 빌드하면 사라집니다.

실무에선 이렇게 — 차이를 CI 가 잡게 🔴 실무

세 OS 를 항상 돌립니다

# 개발은 한 OS 에서 하더라도 CI 는 셋 다 ★
jobs:
  build:
    strategy:
      fail-fast: false                # ★ 하나 실패해도 나머지는 계속
      matrix:
        include:
          - { os: ubuntu-latest,  cc: gcc,   cxx: g++ }
          - { os: ubuntu-latest,  cc: clang, cxx: clang++ }
          - { os: macos-14,       cc: clang, cxx: clang++ }
          - { os: windows-latest, cc: cl,    cxx: cl }
    runs-on: ${{ matrix.os }}
    steps:
      - uses: actions/checkout@v4
      - uses: lukka/get-cmake@latest
      - uses: ilammy/msvc-dev-cmd@v1          # ★ Ninja + cl 은 MSVC 환경이 먼저 잡혀야 합니다
        if: runner.os == 'Windows'
      - name: 설정 · 빌드 · 테스트
        run: |
          cmake -B build -G Ninja -DCMAKE_BUILD_TYPE=Release
          cmake --build build
          ctest --test-dir build --output-on-failure
        env:
          CC: ${{ matrix.cc }}
          CXX: ${{ matrix.cxx }}
컴파일러를 두 개 이상 돌리세요 — GCC 와 Clang 은 경고와 에러가 서로 다릅니다. 한쪽에서만 잡히는 문제가 많아 사실상 무료로 정적 분석을 하나 더 얻는 셈입니다.

자주 겪는 증상과 해결

증상원인 · 해결
리눅스에서만 No such file파일명 대소문자 ★
MSVC 만 min/max 에러NOMINMAX 정의
MSVC 만 한글 깨짐·C4819/utf-8 옵션
맥에서 library not loaded@rpath·install_name_tool
리눅스만 undefined reference to pthreadThreads::Threads 링크
윈도우만 링크 실패__declspec(dllexport) 누락
맥 arm64 에서 실패x86 전용 인트린식·라이브러리
Release 에서만 크래시UB (b04·🔬 d02)
# pthread 는 이렇게 (플랫폼 무관)
find_package(Threads REQUIRED)
target_link_libraries(core PRIVATE Threads::Threads)

한 단계 더 — 크로스 컴파일 🧬 전문가

한 기계에서 여러 대상 빌드

# zig 를 크로스 컴파일러로 쓰기 (설치가 간단하고 잘 됩니다) ★
cmake -B build-arm \
  -DCMAKE_C_COMPILER="zig;cc;-target;aarch64-linux-gnu.2.28" \
  -DCMAKE_CXX_COMPILER="zig;c++;-target;aarch64-linux-gnu.2.28" \
  -DCMAKE_SYSTEM_NAME=Linux -DCMAKE_SYSTEM_PROCESSOR=aarch64

# ★ glibc 버전까지 지정할 수 있어 호환성 문제가 해결됩니다(b08)

# 맥 유니버설 바이너리 (x64 + arm64)
cmake -B build -DCMAKE_OSX_ARCHITECTURES="x86_64;arm64"
lipo -info build/libcore.dylib

# 도커로 옛 배포판 타깃
docker run --rm -v $PWD:/src -w /src quay.io/pypa/manylinux_2_28_x86_64 \
    sh -c "cmake -B build && cmake --build build"

# 툴체인 파일 방식 (정석)
cmake -B build -DCMAKE_TOOLCHAIN_FILE=cmake/arm64.cmake

툴체인 파일 예시

# cmake/arm64.cmake
set(CMAKE_SYSTEM_NAME Linux)
set(CMAKE_SYSTEM_PROCESSOR aarch64)

set(CMAKE_C_COMPILER   aarch64-linux-gnu-gcc)
set(CMAKE_CXX_COMPILER aarch64-linux-gnu-g++)
set(CMAKE_SYSROOT /usr/aarch64-linux-gnu)

# ★ 타깃용 라이브러리만 찾고, 호스트 프로그램은 호스트에서
set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER)
set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY)
set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY)
# 결과 확인
file build/libcore.so
#   ELF 64-bit LSB shared object, ARM aarch64 ★
readelf -h build/libcore.so | grep Machine

# 크로스 빌드에서 테스트는 어떻게?
#   · QEMU 로 에뮬레이션: qemu-aarch64 -L /usr/aarch64-linux-gnu ./test
#   · 또는 실제 기기·CI 러너(GitHub 의 ubuntu-24.04-arm)
#   ⚠ 크로스 빌드는 '컴파일만' 검증합니다. 반드시 실행 테스트를 따로 하세요
크로스 빌드의 함정 — 빌드는 되는데 실행하면 다른 결과가 나올 수 있습니다. 엔디언·정렬·부동소수 처리가 아키텍처마다 미묘하게 다릅니다. 실제 기기나 에뮬레이터에서 테스트를 돌리는 CI가 반드시 필요합니다.
정리 — 플랫폼 차이는 목록이 정해져 있습니다 — 심볼 내보내기(윈도우), 파일명 대소문자(리눅스), long 크기, 라이브러리 경로. 코드에서는 std::filesystem·고정폭 정수·표준 스레드로 흡수하고, 나머지는 CMake 조건 분기와 파일 분리로 처리하세요.
소스는 같아도 배포는 셋 다 다릅니다 항목 윈도우 리눅스 맥 컴파일러 MSVC (cl) gcc · clang apple clang 동적 라이브러리 .dll + .lib 한 쌍 .so .dylib 심볼 내보내기 __declspec 필수 기본 전부 공개 기본 전부 공개 찾는 규칙 실행 파일 옆 · PATH rpath · ldconfig install_name · @rpath 배포에서 걸리는 것 런타임 재배포 패키지 glibc 버전 서명 · 공증 · arm64 가장 자주 깨지는 자리 윈도우에서 심볼을 내보내지 않아 링크가 안 되고, 리눅스에서는 최신 배포판 빌드가 낡은 서버에서 죽습니다 막는 법은 하나입니다 CI 에 세 OS 러너를 두고 매 커밋마다 셋 다 빌드합니다 배포 직전에 한 번 확인하면 언제나 늦습니다
"내 PC 에서는 됩니다"가 진짜 문제가 되는 언어입니다 — CI 가 유일한 답입니다.
소스는 같아도 배포는 셋 다 다릅니다 항목 윈도우 리눅스 맥 컴파일러 MSVC (cl) gcc · clang apple clang 동적 라이브러리 .dll + .lib 한 쌍 .so .dylib 심볼 내보내기 __declspec 필수 기본 전부 공개 기본 전부 공개 찾는 규칙 실행 파일 옆 · PATH rpath · ldconfig install_name · @rpath 배포에서 걸리는 것 런타임 재배포 패키지 glibc 버전 서명 · 공증 · arm64 가장 자주 깨지는 자리 윈도우에서 심볼을 내보내지 않아 링크가 안 되고, 리눅스에서는 최신 배포판 빌드가 낡은 서버에서 죽습니다 막는 법은 하나입니다 CI 에 세 OS 러너를 두고 매 커밋마다 셋 다 빌드합니다 배포 직전에 한 번 확인하면 언제나 늦습니다
"내 PC 에서는 됩니다"가 진짜 문제가 되는 언어입니다 — CI 가 유일한 답입니다.
BUILD 06

파이썬 wheel 만들기

pip install 이 컴파일을 요구하면 절반의 사용자가 실패합니다. cibuildwheel 이 세 OS × 여러 파이썬 버전을 자동으로 만들어 줍니다.

wheel 이름이 말해 주는 것

fastmod-0.1.0-cp312-cp312-manylinux_2_28_x86_64.whl
        ─────  ─────  ─────  ────────────────────────
        버전   파이썬  ABI    플랫폼

  cp312       : CPython 3.12 전용
  cp39-abi3   : 3.9 이상 전부에서 동작 ★ (안정 ABI)
  py3-none-any: 순수 파이썬 (컴파일 없음)

  manylinux_2_28 : glibc 2.28 이상 리눅스
  musllinux_1_2  : 알파인
  macosx_11_0_arm64 · macosx_10_15_x86_64
  win_amd64

★ 조합 수
  일반 : 파이썬 5버전 × 3 OS × 2 아키 = 30개
  abi3 : 3 OS × 2 아키 = 6개  ← 5분의 1 (🐍 y03)

cibuildwheel 설정

# pyproject.toml
[build-system]
requires = ["scikit-build-core>=0.10", "pybind11>=2.12"]
build-backend = "scikit_build_core.build"

[tool.cibuildwheel]
build = "cp39-* cp310-* cp311-* cp312-* cp313-*"
skip  = "*-musllinux_i686 pp* *_i686"
test-requires = "pytest numpy"
test-command = "pytest {project}/tests"
build-verbosity = 1

[tool.cibuildwheel.linux]
before-all = "yum install -y zlib-devel || apk add zlib-dev"
archs = ["x86_64", "aarch64"]

[tool.cibuildwheel.macos]
archs = ["x86_64", "arm64"]
environment = { MACOSX_DEPLOYMENT_TARGET = "11.0" }

[tool.cibuildwheel.windows]
archs = ["AMD64"]
pipx run cibuildwheel --platform linux     # 로컬에서도 (도커 필요)
ls wheelhouse/

이렇게도 쓸 수 있다 — cibuildwheel 이 해 주는 일 🟡 중급

직접 하면 며칠 걸리는 것들

  • manylinux 컨테이너에서 빌드 — 오래된 glibc 로 빌드해 대부분의 배포판에서 동작하게 만듭니다
  • auditwheel·delocate — 의존하는 .so·.dylib 를 wheel 안에 넣고 RPATH 를 고쳐 줍니다 ★
  • 파이썬 버전마다 격리된 환경에서 빌드
  • 빌드 직후 테스트 — 격리된 환경에 설치해 실행
  • ARM 크로스 빌드 — QEMU 로 aarch64 도
  • 태그 정규화 — 올바른 플랫폼 태그를 붙여 줍니다
# 결과 확인
auditwheel show wheelhouse/*.whl
#   fastmod-0.1.0-cp312-...whl is consistent with manylinux_2_28_x86_64
#   The wheel references external versioned symbols: GLIBC_2.17 ...

unzip -l wheelhouse/*.whl | grep "\.so"
#   fastmod/_core.so
#   fastmod.libs/libzlib-abc123.so   ★ 함께 포장된 의존성

GitHub Actions 전체 흐름

name: wheels
on: { push: { tags: ['v*'] }, workflow_dispatch: {} }

jobs:
  wheels:
    strategy:
      matrix:
        os: [ubuntu-latest, ubuntu-24.04-arm, macos-14, windows-latest]
    runs-on: ${{ matrix.os }}
    steps:
      - uses: actions/checkout@v4
      - uses: pypa/cibuildwheel@v2.21
      - uses: actions/upload-artifact@v4
        with: { name: 'wheels-${{ matrix.os }}', path: wheelhouse/*.whl }

  sdist:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: pipx run build --sdist
      - uses: actions/upload-artifact@v4
        with: { name: sdist, path: dist/*.tar.gz }

  publish:
    needs: [wheels, sdist]
    runs-on: ubuntu-latest
    environment: pypi
    permissions: { id-token: write }        # ★ 신뢰 발행 (토큰 불필요)
    steps:
      - uses: actions/download-artifact@v4
        with: { path: dist, merge-multiple: true }
      - uses: pypa/gh-action-pypi-publish@release/v1
sdist 도 함께 올리세요 — wheel 이 없는 환경(새 아키텍처·특이한 OS)에서 소스에서 빌드할 수 있는 경로가 됩니다. pip 는 맞는 wheel 이 없으면 자동으로 sdist 를 시도합니다.

실무에선 이렇게 — 배포 전 검증 🔴 실무

깨끗한 환경에서 설치 테스트

# ★ 컴파일러가 없는 이미지에서 되는지 확인
docker run --rm -v $PWD/wheelhouse:/w python:3.12-slim sh -c "
  pip install /w/*cp312*manylinux*.whl &&
  python -c 'import fastmod, numpy as np;
             print(fastmod.__version__);
             print(fastmod.smooth(np.arange(5.0), 3))'"

# 알파인
docker run --rm -v $PWD/wheelhouse:/w python:3.12-alpine sh -c "
  pip install /w/*musllinux*.whl && python -c 'import fastmod'"

# 의존성이 wheel 안에 다 들어갔는지
unzip -p wheelhouse/*.whl fastmod/_core*.so > /tmp/c.so && ldd /tmp/c.so

# 테스트 PyPI 로 먼저
twine upload --repository testpypi dist/*
pip install -i https://test.pypi.org/simple/ fastmod

# 메타데이터 검사
twine check dist/*

버전과 릴리스 관리

# 버전을 git 태그에서 자동으로 ★
[build-system]
requires = ["scikit-build-core", "pybind11", "setuptools-scm"]

[project]
name = "fastmod"
dynamic = ["version"]          # ★ version 을 적지 않고 '동적'이라고 선언

[tool.scikit-build]
metadata.version.provider = "scikit_build_core.metadata.setuptools_scm"

[tool.setuptools_scm]
# v1.2.3 태그 → 버전 1.2.3
# 태그 이후 커밋이 있으면 1.2.4.dev5+g1a2b3c
# 런타임에 버전·빌드 정보 노출 (🐍 y03)
>>> fastmod.__version__
'1.2.3'
>>> fastmod.__build_info__
{'compiler': 'GNU 13.2.0', 'flags': '-O3', 'commit': 'a1b2c3d',
 'pybind11': '2.13.6', 'built': '2026-08-06'}
PyPI 는 같은 버전을 다시 올릴 수 없습니다 — 잘못 올리면 버전을 올려서 다시 올려야 합니다. 반드시 TestPyPI 에서 먼저 검증하세요. 그리고 신뢰 발행(Trusted Publishing)을 쓰면 토큰을 저장할 필요가 없습니다.

한 단계 더 — wheel 안을 들여다보기 🧬 전문가

auditwheel 이 하는 일

# ① 외부 의존성을 찾아
auditwheel show dist/fastmod-0.1.0-cp312-linux_x86_64.whl

# ② wheel 안으로 복사하고 이름을 바꾸고 RPATH 를 고칩니다
auditwheel repair dist/*.whl -w wheelhouse/
#   fastmod.libs/libzlib-a1b2c3.so   ← 해시가 붙은 이름 ★
#   (다른 패키지의 같은 라이브러리와 충돌하지 않게)

# ③ 결과 확인
unzip -l wheelhouse/*.whl
patchelf --print-rpath /tmp/extracted/fastmod/_core.so
#   $ORIGIN/../fastmod.libs

# 맥은 delocate
delocate-listdeps dist/*.whl
delocate-wheel -w wheelhouse/ dist/*.whl

# 윈도우는 자동 도구가 없습니다 — DLL 을 직접 패키지에 넣고
# os.add_dll_directory() 로 경로를 알려 줍니다(🔗 f02)

크기와 로딩 시간

# wheel 크기 확인
ls -lh wheelhouse/*.whl
unzip -l wheelhouse/*.whl | sort -k1 -rn | head

# 큰 이유 대부분은 ① 디버그 심볼 ② 정적 링크한 라이브러리 ③ 템플릿
# 심볼 제거는 cibuildwheel 이 기본으로 하지 않습니다 ★
[tool.cibuildwheel]
environment = { CFLAGS = "-g0", CXXFLAGS = "-g0" }
# 또는 CMake 에서
target_link_options(_core PRIVATE $<$<CONFIG:Release>:-s>)

# 임포트 시간
python -X importtime -c "import fastmod" 2>&1 | tail -3

# 여러 wheel 의 크기를 한눈에
for w in wheelhouse/*.whl; do printf "%8s %s\n" "$(du -h "$w" | cut -f1)" "$(basename $w)"; done
전형적인 크기
  단순 확장 (pybind11)      200KB ~ 1MB
  numpy 연동                +수백 KB
  OpenCV 정적 링크          30~80MB   ★ 사용자가 싫어합니다
  ONNX Runtime             100MB+

  → 큰 의존성은 별도 패키지로 분리하거나
    시스템 설치를 요구하는 편이 나을 수 있습니다
abi3 를 진지하게 검토하세요 — wheel 수가 5분의 1로 줄고, 새 파이썬이 나와도 재빌드가 필요 없습니다(🐍 y03). nanobind 는 3.12+ 에서 abi3 지원이 특히 좋습니다(🐍 y11).
정리 — cibuildwheel 설정 몇 줄이면 세 OS × 여러 파이썬 버전 wheel 이 자동으로 만들어집니다. auditwheel 이 의존 라이브러리를 wheel 안에 포장해 주므로 사용자는 아무것도 설치할 필요가 없습니다. 배포 전에 컴파일러 없는 도커 이미지에서 설치 테스트를 반드시 하세요.
BUILD 07

CI — 태그를 붙이면 배포까지

C++ 프로젝트에서 CI 는 선택이 아닙니다. 세 OS · 두 컴파일러 · Sanitizer 를 사람이 매번 돌릴 수는 없습니다.

CI 가 해야 할 다섯 가지

① 빌드 — 세 OS × GCC·Clang·MSVC
   컴파일러마다 잡아 주는 경고가 다릅니다 (무료 정적 분석)

② 테스트 — ctest · 파이썬·Node 바인딩 테스트까지

③ Sanitizer — ASan·UBSan·TSan (b10) ★
   메모리·경쟁 버그를 배포 전에 잡는 유일하게 확실한 방법

④ 정적 분석 — clang-tidy · cppcheck · 포맷 검사

⑤ 배포 — 태그가 붙으면 wheel·npm·릴리스 아티팩트 생성

★ 실행 시간이 걱정된다면
   PR 에서는 ①②(리눅스만) → 빠른 피드백
   main 병합 시 전체 매트릭스
   태그에서 ⑤ 배포
   야간에 ③④ 전체

기본 워크플로

name: ci
on:
  push: { branches: [main], tags: ['v*'] }
  pull_request:

jobs:
  quick:                                  # PR 은 빠르게
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: hendrikmuhs/ccache-action@v1
      - run: |
          cmake --preset dev -DCMAKE_CXX_COMPILER_LAUNCHER=ccache
          cmake --build --preset dev
          ctest --preset dev --output-on-failure

  full:                                   # main 은 전체
    if: github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/')
    strategy:
      fail-fast: false
      matrix:
        include:
          - { os: ubuntu-latest,  cxx: g++-13 }
          - { os: ubuntu-latest,  cxx: clang++-17 }
          - { os: macos-14,       cxx: clang++ }
          - { os: windows-latest, cxx: cl }
    runs-on: ${{ matrix.os }}
    steps:
      - uses: actions/checkout@v4
      - uses: lukka/get-cmake@latest
      - uses: ilammy/msvc-dev-cmd@v1          # 윈도우: cl 을 PATH 에
        if: runner.os == 'Windows'
      - run: cmake -B build -G Ninja -DCMAKE_BUILD_TYPE=Release
        env: { CXX: '${{ matrix.cxx }}' }
      - run: cmake --build build
      - run: ctest --test-dir build --output-on-failure

이렇게도 쓸 수 있다 — 품질 검사 붙이기 🟡 중급

포맷과 정적 분석

  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      # 포맷 검사 (파이썬의 ruff format 자리)
      - run: |
          sudo apt install -y clang-format-17
          find src include -name '*.cpp' -o -name '*.hpp' \
            | xargs clang-format-17 --dry-run --Werror

      # 정적 분석
      - run: |
          cmake -B build -DCMAKE_EXPORT_COMPILE_COMMANDS=ON
          run-clang-tidy -p build -quiet src/

      # 헤더 정리
      - run: iwyu_tool.py -p build src/core.cpp   # compile_commands.json 을 읽는 래퍼
# .clang-format — 팀 규칙을 파일 하나로
BasedOnStyle: Google
IndentWidth: 4
ColumnLimit: 100
AccessModifierOffset: -4
AllowShortFunctionsOnASingleLine: Inline
PointerAlignment: Left      # int* p (int *p 아님)
# .clang-tidy — 실무에서 효과가 큰 규칙들 ★
Checks: >
  bugprone-*,
  performance-*,
  modernize-use-nullptr,
  modernize-use-override,
  modernize-make-unique,
  cppcoreguidelines-owning-memory,
  cppcoreguidelines-pro-type-member-init,
  -bugprone-easily-swappable-parameters
WarningsAsErrors: 'bugprone-use-after-move,performance-for-range-copy'

pre-commit 으로 앞당기기

# .pre-commit-config.yaml — 커밋 순간에 검사 ★
repos:
  - repo: https://github.com/pre-commit/mirrors-clang-format
    rev: v17.0.6
    hooks:
      - id: clang-format
        types_or: [c++, c]

  - repo: https://github.com/pre-commit/pre-commit-hooks
    rev: v4.6.0
    hooks:
      - id: trailing-whitespace
      - id: end-of-file-fixer
      - id: check-merge-conflict
      - id: mixed-line-ending
        args: [--fix=lf]          # ★ 윈도우·리눅스 혼용 방지

  - repo: local
    hooks:
      - id: cmake-format
        name: cmake-format
        entry: cmake-format -i
        language: python
        additional_dependencies: [cmakelang]   # cmake-format 이 든 패키지
        files: CMakeLists.txt|\.cmake$
pip install pre-commit && pre-commit install
pre-commit run --all-files          # 처음 도입할 때 전체 적용

# ★ 전체 포맷 커밋은 git blame 을 망가뜨립니다 (파이썬 탭 p01 과 같은 문제)
git config blame.ignoreRevsFile .git-blame-ignore-revs
CI 보다 pre-commit 이 먼저입니다 — 포맷 문제로 CI 가 실패하면 5분을 기다렸다 다시 푸시해야 합니다. 커밋 시점에 잡으면 즉시 해결됩니다.

실무에선 이렇게 — 릴리스 자동화 🔴 실무

태그 하나로 전부

  release:
    if: startsWith(github.ref, 'refs/tags/v')
    needs: [full]
    strategy:
      matrix:
        os: [ubuntu-latest, ubuntu-24.04-arm, macos-14, windows-latest]
    runs-on: ${{ matrix.os }}
    steps:
      - uses: actions/checkout@v4
      # 파이썬 wheel (b06)
      - uses: pypa/cibuildwheel@v2.21
      # Node prebuilds (🟨 j09)
      - run: npx prebuildify --napi --strip
      # C++ 라이브러리 아카이브
      - run: |
          cmake -B build -DCMAKE_BUILD_TYPE=Release \
                -DCMAKE_INSTALL_PREFIX=install
          cmake --build build --target install
          tar czf mylib-${{ matrix.os }}.tar.gz -C install .
      - uses: actions/upload-artifact@v4
        with:
          name: release-${{ matrix.os }}       # v4 는 이름이 겹치면 실패
          path: |                              # ★ 여러 경로는 블록(|)으로 — 따옴표 안 줄바꿈은 공백이 됨
            wheelhouse/*.whl
            prebuilds/**
            mylib-*.tar.gz

  publish:
    needs: [release]
    runs-on: ubuntu-latest
    permissions: { id-token: write, contents: write }
    steps:
      - uses: actions/download-artifact@v4
        with: { path: dist, merge-multiple: true }
      - uses: pypa/gh-action-pypi-publish@release/v1
        with: { packages-dir: dist/wheelhouse/ }   # PyPI 에는 wheel 만
      - uses: softprops/action-gh-release@v2
        with: { files: 'dist/*' }

CI 시간 관리

# ① 캐시 — 가장 효과가 큽니다
- uses: hendrikmuhs/ccache-action@v1
  with: { key: '${{ matrix.os }}-${{ matrix.cxx }}', max-size: 500M }

# ② 변경된 것만 검사
- uses: dorny/paths-filter@v3
  id: changes
  with:
    filters: |
      cpp: ['src/**', 'include/**', 'CMakeLists.txt']
- if: steps.changes.outputs.cpp == 'true'
  run: cmake --build build

# ③ 중복 실행 취소 (연속 푸시 시)
concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

# ④ 타임아웃 (무한 대기 방지)
timeout-minutes: 30
전형적인 시간 (중간 규모 프로젝트)
  캐시 없음     : 8~15분
  ccache 적중   : 1~3분   ★
  Sanitizer 빌드: 별도 5~10분 (실행이 느립니다)
  wheel 전체    : 20~40분  (플랫폼 병렬)
CI 시간은 개발 속도입니다 — PR 검증이 15분이면 하루에 몇 번밖에 못 돌립니다. PR 은 3분 안에 끝나게 만들고, 무거운 검사는 main·야간으로 미루세요.

한 단계 더 — 공급망 보안 🧬 전문가

빌드 출처 증명

# ① 신뢰 발행 — 토큰 없이 PyPI·npm 에 배포 ★
permissions: { id-token: write }
- uses: pypa/gh-action-pypi-publish@release/v1     # OIDC 로 인증
- run: npm publish --provenance                    # npm provenance

# ② 액션을 커밋 해시로 고정 (태그는 옮겨질 수 있습니다)
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11  # v4.1.1

# ③ SBOM 생성
- uses: anchore/sbom-action@v0
  with: { path: ., format: spdx-json }

# ④ 취약점 스캔
- uses: anchore/scan-action@v3
  with: { path: ., fail-build: true, severity-cutoff: high }

# ⑤ 아티팩트 서명
- uses: sigstore/gh-action-sigstore-python@v3
  with: { inputs: 'dist/*.whl' }

C++ 특유의 위험

① 의존성 소스를 그대로 컴파일합니다
   FetchContent 는 임의의 코드를 받아 빌드에 포함시킵니다.
   → GIT_TAG 를 커밋 해시로, URL_HASH 를 반드시 (b03) ★

② CMake 스크립트 자체가 코드입니다
   execute_process 로 무엇이든 실행할 수 있습니다.
   → 서드파티 CMake 모듈을 검토 없이 include 하지 마세요

③ 빌드 시점 코드 생성
   protoc · moc · 코드 제너레이터가 실행됩니다.
   → 도구 버전도 고정하세요

④ 정적 링크된 취약점
   zlib·openssl 을 정적 링크하면 패치가 나와도
   '내가 다시 빌드해야' 반영됩니다.
   → SBOM 으로 무엇이 들어갔는지 추적 가능하게 ★

⑤ 재현 가능한 빌드
   같은 소스 → 같은 바이너리여야 검증할 수 있습니다 (b02)
   __DATE__ · 절대 경로 · 타임스탬프를 제거하세요
네이티브 확장은 공급망 관점에서 특히 민감합니다 — 바이너리를 받아 실행하는 것이라 소스보다 검증이 어렵습니다. 신뢰 발행 + 서명 + SBOM 세 가지를 갖추면 사용자가 “이 wheel 이 정말 그 저장소에서 나왔는지” 확인할 수 있습니다.
정리 — CI 는 세 OS × 두 컴파일러가 기본이고, Sanitizer 잡을 따로 두는 것이 핵심입니다. PR 은 3분 안에 끝나게 하고 무거운 검사는 main·야간으로 미루세요. 포맷은 pre-commit 으로 앞당겨 CI 실패를 줄이고, 배포는 태그 하나로 자동화하세요.
BUILD 08

동적 · 정적 링크와 배포 의존성

“내 PC 에선 되는데 배포하면 .so 를 못 찾습니다” — C++ 배포에서 가장 흔한 문제이고, 원인은 몇 가지뿐입니다.

정적 링크와 동적 링크 — 배포할 때 실제로 갈리는 지점 ① 정적 링크 (.a · .lib) 실행 파일 하나 (라이브러리 포함) 복사만 하면 돕니다 — 의존성 지옥이 없습니다 크기가 커지고, 보안 패치는 다시 빌드해야 합니다 ② 동적 링크 (.so · .dll · .dylib) 실행 파일 libfoo.so 파일이 작고, 패치 하나로 전부가 고쳐집니다 대상 기계에 그 버전이 없으면 실행조차 안 됩니다 배포 전에 반드시 확인할 것 무엇에 의존하나 리눅스 · ldd 맥 · otool -L 윈도우 · dumpbin 런타임 함정 glibc 버전이 더 높으면 낮은 배포판에서 안 돕니다 가장 낮은 환경에서 빌드 보통의 정답 내 코드와 서드파티는 정적 libc 계열만 동적 배포가 가장 편합니다
파이썬 wheel 이나 Node prebuild 로 낼 거라면 정적 링크가 사실상 필수입니다.

정적 vs 동적

정적 (.a · .lib)동적 (.so · .dll)
배포파일 하나 ★함께 배포 필요
크기큼작음(공유)
시작 시간빠름로딩·재배치 비용
보안 패치재빌드 필요 ★파일만 교체
메모리프로세스마다여러 프로세스 공유
최적화LTO 가능제한적
심볼 충돌적음가능(🔗 f02)
add_library(core STATIC ...)     # .a / .lib
add_library(core SHARED ...)     # .so / .dll / .dylib
add_library(core ...)            # BUILD_SHARED_LIBS 를 따름

# 부분적으로 정적 — C++ 런타임만 (호환성 문제 해결) ★
target_link_options(app PRIVATE -static-libstdc++ -static-libgcc)
확장 모듈·배포용 도구는 정적 링크가 대개 낫습니다 — 파일 하나로 완결되어 “의존성이 없다”는 상태가 만들어집니다. 시스템 라이브러리로 배포할 때만 동적이 유리합니다.

런타임에 라이브러리를 찾는 순서

리눅스
  ① DT_RPATH (구식)
  ② LD_LIBRARY_PATH 환경변수
  ③ DT_RUNPATH        ★ 현대적 방식
  ④ /etc/ld.so.cache  (ldconfig 로 갱신)
  ⑤ /lib · /usr/lib

맥
  ① @executable_path · @loader_path · @rpath
  ② DYLD_LIBRARY_PATH
  ③ 시스템 경로
  ※ 라이브러리 안에 '설치 이름'이 박혀 있습니다 (install_name)

윈도우
  ① 실행 파일과 같은 폴더 ★ 가장 단순한 해결
  ② 시스템 폴더
  ③ PATH
  ※ RPATH 가 없습니다. 파이썬 3.8+ 는 PATH 도 안 봅니다(🔗 f02)
# 진단
ldd app                      # 리눅스 — not found 가 있는지
otool -L app                 # 맥
dumpbin /dependents app.exe  # 윈도우

LD_DEBUG=libs ./app 2>&1 | head -30      # 어디를 뒤졌는지
readelf -d app | grep -E "RPATH|RUNPATH|NEEDED"

이렇게도 쓸 수 있다 — 배포 형태 만들기 🟡 중급

자기 완결형 폴더

# 실행 파일이 자기 옆·아래의 lib 를 찾게 ★
set_target_properties(app PROPERTIES
    INSTALL_RPATH "$ORIGIN:$ORIGIN/../lib"
    BUILD_WITH_INSTALL_RPATH TRUE)
if(APPLE)
    set_target_properties(app PROPERTIES
        INSTALL_RPATH "@loader_path;@loader_path/../lib")
endif()

install(TARGETS app RUNTIME_DEPENDENCY_SET deps   # ★ 아래 deps 집합에 등록
        RUNTIME DESTINATION bin)
install(TARGETS core LIBRARY DESTINATION lib)

# 의존 라이브러리까지 함께 설치 (CMake 3.21+)
install(RUNTIME_DEPENDENCY_SET deps
    PRE_EXCLUDE_REGEXES "api-ms-" "ext-ms-"
    POST_EXCLUDE_REGEXES ".*system32/.*\\.dll" "^/lib" "^/usr/lib"
    DESTINATION lib)
# 결과 구조
myapp/
  bin/app
  lib/libcore.so
  lib/libfmt.so.11

# 검증 — 다른 환경에서 진짜 도는지 ★
docker run --rm -v $PWD/install:/app debian:12-slim /app/bin/app
#   개발 환경의 라이브러리에 의존하지 않는지 확인됩니다

단일 파일로 배포하기

# ① 완전 정적 링크 (리눅스)
g++ -static -o app main.cpp
#   ⚠ glibc 정적 링크는 getaddrinfo·NSS 에서 문제가 생깁니다
#   → musl 툴체인을 쓰는 편이 안전합니다
zig cc -target x86_64-linux-musl -static ...

# ② AppImage — 의존성을 통째로 포장 (리눅스 GUI 앱)
linuxdeploy --appdir AppDir -e app --output appimage

# ③ 맥 — 앱 번들 안에 dylib 포함
macdeployqt MyApp.app        # Qt 앱
install_name_tool -change /usr/local/lib/libfoo.dylib \
    @executable_path/../Frameworks/libfoo.dylib MyApp.app/Contents/MacOS/MyApp

# ④ 윈도우 — 정적 CRT + DLL 동봉
cmake -DCMAKE_MSVC_RUNTIME_LIBRARY=MultiThreaded ...

# ⑤ 도커 — 가장 확실한 방법 (환경째 배포)
FROM gcc:13 AS build
...
FROM debian:12-slim
COPY --from=build /app/install /app
#   멀티스테이지로 빌드 도구 없이 결과만
정적 링크의 라이선스 주의 — LGPL 라이브러리를 정적 링크하면 내 코드도 공개해야 할 수 있습니다. LGPL 은 동적 링크로 쓰면 대개 문제가 없습니다. 의존성 라이선스를 SBOM 으로 추적하세요(b07).

실무에선 이렇게 — glibc 버전 문제 🔴 실무

가장 자주 겪는 배포 장애

# 증상
./app
#   /lib/x86_64-linux-gnu/libm.so.6: version `GLIBC_2.38' not found

# 원인 — 최신 배포판에서 빌드했고, 실행 환경이 더 오래됐습니다
#   glibc 는 '앞으로만' 호환됩니다 (하위 호환 O, 상위 호환 X) ★

# 무엇을 요구하는지 확인
objdump -T app | grep GLIBC_ | sed 's/.*GLIBC_\([0-9.]*\).*/\1/' | sort -uV | tail -1
#   2.38   ← 이 버전 이상이 필요합니다

# 어떤 심볼이 그 버전을 요구하는지
objdump -T app | grep GLIBC_2.38

# 해결 ① 오래된 환경에서 빌드 (가장 확실) ★
docker run --rm -v $PWD:/src -w /src quay.io/pypa/manylinux_2_28_x86_64 \
    sh -c "cmake -B build && cmake --build build"

# 해결 ② zig 로 glibc 버전 지정
CC="zig cc -target x86_64-linux-gnu.2.17" cmake ...

# 해결 ③ musl 로 정적 링크 (glibc 의존 자체를 없앰)
zig cc -target x86_64-linux-musl -static ...

# 해결 ④ C++ 런타임만이라도 정적으로
-static-libstdc++ -static-libgcc

C++ ABI 호환성

같은 문제의 다른 형태 — libstdc++ 버전

  GCC 5 에서 std::string 의 ABI 가 바뀌었습니다 (_GLIBCXX_USE_CXX11_ABI)
  → 옛 ABI 로 빌드된 라이브러리와 새 ABI 코드를 링크하면
    undefined reference to std::__cxx11::basic_string...  ★

확인
  nm -DC libfoo.so | grep __cxx11 | head
  strings libfoo.so | grep GLIBCXX

해결
  · 모든 것을 같은 컴파일러·같은 표준 라이브러리로 빌드
  · 또는 -D_GLIBCXX_USE_CXX11_ABI=0 으로 맞추기 (옛 라이브러리에 맞춤)
  · 근본 해결: 경계를 C ABI 로 (🔗 f01) ★
    → C++ 타입을 인터페이스에 노출하지 않으면 이 문제가 사라집니다

★ 그래서 배포용 라이브러리는 C API 로 만드는 것이 정석입니다.
  파이썬·Node 확장도 각 런타임의 안정 ABI(abi3 · N-API)를 쓰는 이유가 같습니다.
이 절의 결론 — 배포 대상보다 오래된 환경에서 빌드하고, C++ 런타임은 정적으로, 공개 인터페이스는 C ABI 로. 이 셋이면 리눅스 배포 문제의 대부분이 사라집니다.

한 단계 더 — 로딩 과정과 최적화 🧬 전문가

동적 링크의 실행 비용

# 프로그램 시작 시 동적 링커가 하는 일
#   ① 필요한 .so 를 찾아 매핑
#   ② 심볼 재배치 (relocation) — 심볼이 많으면 오래 걸립니다 ★
#   ③ 초기화 함수 실행 (C++ 전역 생성자)

# 측정
LD_DEBUG=statistics ./app 2>&1 | head -20
#   total startup time in dynamic loader: ...

# 즉시 바인딩 vs 지연 바인딩
LD_BIND_NOW=1 ./app          # 시작 시 전부 해석 (보안 ↑, 시작 ↓)
# 기본은 지연 — 처음 호출될 때 해석

# 심볼 수 줄이기 = 로딩 빨라짐 (🔗 f02)
-fvisibility=hidden
nm -D libcore.so | wc -l     # 내보내는 심볼 수

# prelink · 정적 링크로 아예 없애기
# 시작 시간이 중요한 CLI 도구는 정적 링크가 유리합니다 ★

플러그인 구조

// 런타임에 .so 를 골라 로드하기
#include <dlfcn.h>

void* h = dlopen("./plugins/fast.so", RTLD_NOW | RTLD_LOCAL);
//   RTLD_LOCAL ★ 심볼을 전역에 노출하지 않음 (충돌 방지)
if (!h) { std::fprintf(stderr, "%s\n", dlerror()); return; }

using CreateFn = Plugin* (*)();
auto create = reinterpret_cast<CreateFn>(dlsym(h, "create_plugin"));
if (!create) { ... }

Plugin* p = create();
...
destroy(p);
dlclose(h);      // ⚠ 그 .so 의 코드를 아직 쓰고 있으면 크래시

// ★ 플러그인 인터페이스는 반드시 C ABI 로 (🔗 f03)
//   C++ 클래스를 노출하면 컴파일러·표준 라이브러리 버전이 같아야 합니다
extern "C" {
    int  plugin_abi_version(void);       // 버전 확인 먼저
    void* plugin_create(void);
    void  plugin_destroy(void*);
}

// 윈도우
HMODULE h = LoadLibraryW(L"plugin.dll");
auto f = (CreateFn)GetProcAddress(h, "plugin_create");
FreeLibrary(h);
dlclose 는 생각보다 위험합니다 — 그 라이브러리의 전역 객체·스레드·콜백이 아직 살아 있으면 즉시 크래시합니다. 많은 프로젝트가 일부러 dlclose 를 하지 않습니다 (프로세스 종료 시 어차피 정리되므로).
정리 — 배포에서 막히면 ldd·otool -L·dumpbin 부터. 확장 모듈과 도구는 정적 링크가 대개 편하고, 리눅스는 배포 대상보다 오래된 환경에서 빌드하는 것이 핵심입니다. C++ 런타임은 정적으로, 공개 인터페이스는 C ABI 로.
BUILD 09

디버거로 붙기

printf 도 좋지만, 크래시한 지점의 변수 값을 보는 것만큼 빠른 방법은 없습니다.

gdb · lldb 최소 명령

# 디버그 정보를 포함해 빌드
cmake -B build -DCMAKE_BUILD_TYPE=RelWithDebInfo

gdb ./build/app                # lldb ./build/app
(gdb) run arg1 arg2
(gdb) bt                       # 백트레이스 ★ 가장 먼저
(gdb) bt full                  # 지역 변수까지
(gdb) frame 2                  # 그 프레임으로 이동
(gdb) info locals
(gdb) print v                  # 값 출력 (STL 컨테이너도 예쁘게)
(gdb) print *ptr@10            # 배열 앞 10개 ★
(gdb) print obj.member

(gdb) break main.cpp:42        # 중단점
(gdb) break Engine::run
(gdb) break main.cpp:42 if i == 100    # 조건부 ★
(gdb) watch counter            # 값이 바뀌면 멈춤 ★★
(gdb) catch throw              # 예외가 던져지는 순간

(gdb) next / step / finish / continue
(gdb) info threads             # 스레드 목록
(gdb) thread apply all bt      # 모든 스레드 스택 ★ 교착 진단
watch 를 알아 두세요 — “이 값이 언제 바뀌는지 모르겠다”를 한 번에 해결합니다. 메모리 손상 추적에도 유용합니다. watch -l *(int*)0x7fff... 처럼 주소로도 걸 수 있습니다.

붙는 여러 방법

# ① 이미 도는 프로세스에
gdb -p $(pgrep -f myapp)
#   ⚠ 리눅스는 권한이 필요할 수 있습니다
#     sudo sysctl kernel.yama.ptrace_scope=0

# ② 코어 덤프 분석 (🔬 d11)
ulimit -c unlimited
./app                        # 크래시
gdb ./app core.12345
(gdb) bt full

# ③ 파이썬 확장 디버깅 (🐍 y12)
gdb --args python repro.py
(gdb) run
(gdb) bt                     # C++ 스택
(gdb) py-bt                  # 파이썬 스택 ★

# ④ Node 애드온
gdb --args node test.js

# ⑤ 배치 모드 (CI 에서 자동 수집)
gdb -batch -ex run -ex "bt full" -ex "thread apply all bt" \
    --args ./app 2>&1 | tee crash.log

# ⑥ 윈도우 — Visual Studio 에서 프로세스에 연결
#    Debug > Attach to Process > python.exe / node.exe
#    "Native code" 를 반드시 선택 ★

이렇게도 쓸 수 있다 — 더 편하게 보기 🟡 중급

STL 예쁘게 출력하기

# GCC 는 기본으로 되지만, 안 되면
# ~/.gdbinit
python
import sys
sys.path.insert(0, '/usr/share/gcc/python')
from libstdcxx.v6.printers import register_libstdcxx_printers
register_libstdcxx_printers(None)
end
set print pretty on
set print object on

# 결과
(gdb) p vec
#   $1 = std::vector of length 3, capacity 4 = {1, 2, 3}   ★
(gdb) p m
#   $2 = std::map with 2 elements = {["a"] = 1, ["b"] = 2}

# lldb 는 기본 지원 (clang 과 함께)
(lldb) frame variable
(lldb) v vec
# 자주 쓰는 gdb 설정 (~/.gdbinit)
set history save on
set confirm off
set disassembly-flavor intel
set print elements 200        # 컨테이너를 몇 개까지 출력할지

# 사용자 명령 정의
define bta
  thread apply all bt full
end

GUI 와 IDE

// VSCode — .vscode/launch.json
{
  "version": "0.2.0",
  "configurations": [{
    "name": "디버그",
    "type": "cppdbg",
    "request": "launch",
    "program": "${workspaceFolder}/build/dev/app",
    "args": [],
    "cwd": "${workspaceFolder}",
    "MIMode": "gdb",
    "setupCommands": [
      { "text": "-enable-pretty-printing", "ignoreFailures": true }
    ],
    "preLaunchTask": "cmake-build"
  },
  {
    "name": "파이썬 확장에 붙기",
    "type": "cppdbg",
    "request": "attach",
    "program": "/usr/bin/python3",
    "processId": "${command:pickProcess}",
    "MIMode": "gdb"
  }]
}
도구
  · gdb TUI      : gdb -tui  (Ctrl+X A 로 전환)
  · gef · pwndbg : gdb 확장 (메모리 시각화)
  · rr           : 실행을 녹화하고 '되감기' 가능 ★★
                   rr record ./app  →  rr replay  →  reverse-continue
                   재현이 어려운 버그에 압도적으로 유용합니다
  · CLion · VS   : 통합 환경
rr 을 꼭 한 번 써 보세요 — 실행을 녹화해 시간을 거꾸로 되감으며 디버깅할 수 있습니다. “이 값이 언제 망가졌지?”를 거꾸로 추적하는 것이 가능해집니다. (리눅스 x86 전용)

실무에선 이렇게 — 디버거보다 먼저 할 것 🔴 실무

순서를 지키면 빠릅니다

  • ① Sanitizer 로 돌려 보기(b10) — 메모리 버그면 정확한 줄과 스택이 즉시 나옵니다. 디버거보다 빠릅니다 ★
  • ② 최소 재현 케이스 만들기 — 입력을 줄여 가며. 대개 이 과정에서 원인이 보입니다
  • ③ 로그·assert 추가 — 어느 구간까지 정상인지 좁히기
  • ④ 디버거 — 위에서 안 잡히는 논리 오류일 때
  • ⑤ rr·코어 덤프 — 재현이 어려울 때
// 임시 진단 매크로 — 릴리즈에서 사라지게
#ifndef NDEBUG
  #define TRACE(fmt, ...) std::fprintf(stderr, "[%s:%d] " fmt, \
                                  __FILE__, __LINE__ __VA_OPT__(,) __VA_ARGS__)
  //   ★ 파일·줄 번호가 형식 문자열의 %s:%d 와 순서가 맞게 먼저 옵니다 (C++20 __VA_OPT__)
#else
  #define TRACE(...) ((void)0)
#endif
TRACE("n=%zu window=%d\n", n, window);

// 값이 예상 범위인지 계속 확인
assert(idx < size && "인덱스 범위 초과");

최적화 빌드 디버깅

Release 빌드에서 디버거를 붙이면

  · 변수가 <optimized out> 으로 나옵니다
  · 실행 순서가 소스와 다릅니다 (재배치)
  · 함수가 인라인되어 스택에 안 보입니다

대책
  ① RelWithDebInfo (-O2 -g) 로 빌드 ★
     최적화는 유지하면서 심볼과 줄 정보는 남습니다
  ② 문제 파일만 최적화 끄기
     set_source_files_properties(bad.cpp PROPERTIES COMPILE_OPTIONS "-O0")
  ③ 인라인만 막기
     -fno-inline  또는  __attribute__((noinline))
  ④ 프레임 포인터 유지 (프로파일러·백트레이스 정확도 ↑)
     -fno-omit-frame-pointer   ★ 성능 손실은 대개 1% 미만

★ "Debug 에서는 안 나는 버그" 는 대개 UB 입니다(b04).
  디버거로 쫓기 전에 UBSan 을 먼저 돌리세요.
운영 바이너리도 심볼을 남기세요 — strip 한 바이너리는 크래시 덤프에서 주소만 보입니다. 분리 보관(objcopy --only-keep-debug)하면 배포는 작게 유지하면서 분석은 가능합니다(b01·🔬 d11).

한 단계 더 — 디버그 정보의 구조 🧬 전문가

DWARF 와 심볼

# 디버그 정보는 별도 섹션에 들어갑니다
readelf -S app | grep debug
#   .debug_info  .debug_line  .debug_str  .debug_abbrev ...

size -A app | grep debug          # 크기 (보통 바이너리의 절반 이상)

# 주소 → 소스 위치
addr2line -e app -f -C 0x401234
#   Engine::run(int)
#   /src/core.cpp:42               ★ 크래시 로그 분석의 기본

# 디버그 정보 압축 (크기 절감)
-gz=zlib                          # 또는 -Wl,--compress-debug-sections=zlib

# 분리 보관 (b01)
objcopy --only-keep-debug app app.debug
objcopy --strip-debug --add-gnu-debuglink=app.debug app
gdb app                           # .debug 파일을 자동으로 찾습니다

# 심볼 서버 방식 (build-id 로 조회)
readelf -n app | grep "Build ID"
#   /usr/lib/debug/.build-id/ab/cdef...debug

스크립트로 자동화

# gdb 는 파이썬으로 확장할 수 있습니다
# mypretty.py
import gdb.printing          # ★ 하위 모듈은 따로 import 해야 합니다

class EnginePrinter:
    def __init__(self, val): self.val = val
    def to_string(self):
        return f"Engine(window={self.val['window_']}, n={self.val['n_']})"

def build_pp():
    pp = gdb.printing.RegexpCollectionPrettyPrinter("mylib")
    pp.add_printer('Engine', '^Engine$', EnginePrinter)
    return pp

gdb.printing.register_pretty_printer(gdb.current_objfile(), build_pp())
# 자동 로드 — 바이너리 옆에 <binary>-gdb.py 를 두면 됩니다
# 또는 ~/.gdbinit 에서 source mypretty.py

# CI 에서 크래시 정보 자동 수집
gdb -batch \
  -ex "set pagination off" \
  -ex "run" \
  -ex "thread apply all bt full" \
  -ex "info registers" \
  -ex "quit" \
  --args ./build/test_all 2>&1 | tee crash-report.txt

# 코어 덤프를 아티팩트로 업로드해 나중에 분석
- uses: actions/upload-artifact@v4
  if: failure()
  with: { name: cores, path: '/tmp/core.*' }
커스텀 프린터의 값어치 — 복잡한 자료구조를 사람이 읽을 수 있는 한 줄로 만들면 디버깅 속도가 크게 달라집니다. 팀 공용 타입 몇 개에만 붙여도 효과가 큽니다.
정리 — 크래시가 나면 bt full 부터, 값이 언제 바뀌는지 모르면 watch, 교착이면 thread apply all bt. 다만 메모리 버그는 디버거보다 Sanitizer 가 빠릅니다 — 다음 섹션입니다.
BUILD 10

Sanitizer — 버그를 자동으로 잡습니다

플래그 하나면 메모리 오류·미정의 동작·데이터 경쟁을 정확한 줄 번호와 함께 잡아 줍니다. C++ 를 안전하게 쓰는 가장 실용적인 방법입니다.

새니타이저 — 플래그 하나로 메모리 버그를 자동으로 잡습니다 AddressSanitizer -fsanitize=address 버퍼 넘침 (읽기 · 쓰기) 해제 후 사용 이중 해제 · 메모리 누수 속도 약 2배 느려짐 가장 먼저 켜야 할 것 UBSanitizer -fsanitize=undefined 부호 있는 정수 오버플로 널 포인터 역참조 잘못된 형변환 · 정렬 느려짐이 거의 없음 ASan 과 같이 켤 수 있음 ThreadSanitizer -fsanitize=thread 데이터 경쟁 락 순서 뒤집힘 동기화 없는 공유 접근 속도 5~15배 · 메모리 많이 ASan 과 동시에 못 켬 한 가지 한계 — 실행되지 않은 코드는 못 잡습니다 정적 분석이 아니라 실행 중 감시입니다. 그래서 "테스트가 얼마나 많은 경로를 밟느냐"가 곧 탐지율이 됩니다
CI 에 ASan + UBSan 을 켠 테스트 잡을 하나 추가해 두면, 릴리스 전에 잡히는 버그가 눈에 띄게 늘어납니다.

세 가지 Sanitizer

# ① AddressSanitizer — 메모리 오류 ★ 가장 중요
g++ -fsanitize=address -g -O1 main.cpp -o app
#   잡는 것: 버퍼 오버런 · 해제 후 사용 · 이중 해제 · 누수 · 스택 오버플로
#   비용: 실행 2배 느림 · 메모리 3배

# ② UndefinedBehaviorSanitizer — UB
g++ -fsanitize=undefined -g main.cpp -o app
#   잡는 것: 정수 오버플로 · 널 역참조 · 정렬 위반 · 잘못된 형변환
#   비용: 거의 없음 (20% 내외) ★ 항상 켜도 됩니다

# ③ ThreadSanitizer — 데이터 경쟁
g++ -fsanitize=thread -g -O1 main.cpp -o app
#   잡는 것: 락 없는 동시 접근 · 교착 가능성
#   비용: 5~15배 느림 · 메모리 5~10배
#   ⚠ ASan 과 동시에 못 씁니다 (별도 빌드)

# 조합 (실무 권장)
-fsanitize=address,undefined -fno-omit-frame-pointer -g -O1
UBSan 은 비용이 거의 없습니다 — 개발 빌드에는 항상 켜 두는 것을 권합니다. 정수 오버플로처럼 조용히 잘못된 결과를 내는 문제를 바로 알려 줍니다.

출력이 이렇게 나옵니다

==12345==ERROR: AddressSanitizer: heap-buffer-overflow
  on address 0x602000000078 at pc 0x00000040123a
WRITE of size 4 at 0x602000000078 thread T0
    #0 0x401239 in fill(int*, int) /src/core.cpp:12    ★ 정확한 위치
    #1 0x4012f8 in main /src/main.cpp:8

0x602000000078 is located 0 bytes to the right of
  8-byte region [0x602000000070,0x602000000078)
allocated by thread T0 here:                          ★ 어디서 할당됐는지
    #0 0x7f...  in operator new(unsigned long)
    #1 0x4011d5 in main /src/main.cpp:6

SUMMARY: AddressSanitizer: heap-buffer-overflow /src/core.cpp:12 in fill
UBSan
  core.cpp:42:15: runtime error: signed integer overflow:
    2147483647 + 1 cannot be represented in type 'int'

TSan
  WARNING: ThreadSanitizer: data race
    Write of size 8 by thread T2 at core.cpp:88
    Previous read of size 8 by thread T1 at core.cpp:71
    Location: global 'g_cache' of size 24

이렇게도 쓸 수 있다 — 설정과 옵션 🟡 중급

CMake 에 넣기

add_library(sanitizers INTERFACE)

option(ENABLE_ASAN "AddressSanitizer" OFF)
option(ENABLE_UBSAN "UndefinedBehaviorSanitizer" OFF)
option(ENABLE_TSAN "ThreadSanitizer" OFF)

if(ENABLE_ASAN)
    target_compile_options(sanitizers INTERFACE
        -fsanitize=address -fno-omit-frame-pointer)
    target_link_options(sanitizers INTERFACE -fsanitize=address)
endif()
if(ENABLE_UBSAN)
    target_compile_options(sanitizers INTERFACE
        -fsanitize=undefined -fno-sanitize-recover=all)  # ★ 즉시 중단
    target_link_options(sanitizers INTERFACE -fsanitize=undefined)
endif()
if(ENABLE_TSAN)
    target_compile_options(sanitizers INTERFACE -fsanitize=thread)
    target_link_options(sanitizers INTERFACE -fsanitize=thread)
endif()

target_link_libraries(core PRIVATE sanitizers)
target_link_libraries(test_all PRIVATE sanitizers)
cmake --preset asan && cmake --build --preset asan
ctest --preset asan --output-on-failure

# MSVC 도 ASan 을 지원합니다 (VS 2019 16.9+)
cl /fsanitize=address /Zi main.cpp

런타임 옵션

# ASan
export ASAN_OPTIONS="detect_leaks=1:abort_on_error=1:\
  strict_string_checks=1:detect_stack_use_after_return=1:\
  check_initialization_order=1:log_path=/tmp/asan"

# UBSan — 기본은 경고만 하고 계속 실행합니다
export UBSAN_OPTIONS="print_stacktrace=1:halt_on_error=1"

# TSan
export TSAN_OPTIONS="halt_on_error=1:second_deadlock_stack=1"

# 심볼이 안 보이면
export ASAN_SYMBOLIZER_PATH=$(which llvm-symbolizer)

# 특정 함수·파일을 검사에서 제외 (서드파티 코드 등)
# lsan.supp
#   leak:libthirdparty.so
export LSAN_OPTIONS="suppressions=$PWD/lsan.supp"
# tsan.supp (데이터 경쟁은 TSan 쪽 파일에)
#   race:known_benign_function
export TSAN_OPTIONS="suppressions=$PWD/tsan.supp"

# 소스 수준에서 제외
__attribute__((no_sanitize("address"))) void raw_access() { ... }
-fno-sanitize-recover=all 을 쓰세요 — UBSan 은 기본적으로 경고만 출력하고 계속 실행합니다. CI 에서는 즉시 실패해야 문제를 놓치지 않습니다.

실무에선 이렇게 — CI 에 붙이기 🔴 실무

별도 잡으로 운영

  sanitize:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        san: [address, undefined, thread]
    steps:
      - uses: actions/checkout@v4
      - run: |
          cmake -B build -G Ninja \
            -DCMAKE_BUILD_TYPE=Debug \
            -DCMAKE_CXX_FLAGS="-fsanitize=${{ matrix.san }} -g -O1 \
                               -fno-omit-frame-pointer -fno-sanitize-recover=all"
          cmake --build build
      - run: ctest --test-dir build --output-on-failure
        env:
          ASAN_OPTIONS: detect_leaks=1:abort_on_error=1
          UBSAN_OPTIONS: print_stacktrace=1:halt_on_error=1
          TSAN_OPTIONS: halt_on_error=1
# 파이썬 확장 검사 (🐍 y12)
CXXFLAGS="-fsanitize=address -g" pip install -e . --no-build-isolation   # C++ 확장이라 CXXFLAGS
PYTHONMALLOC=malloc \
  LD_PRELOAD=$(gcc -print-file-name=libasan.so) \
  ASAN_OPTIONS=detect_leaks=0 \
  python -m pytest
#   ★ PYTHONMALLOC=malloc 이 없으면 파이썬 힙을 감시하지 못합니다
#   ★ 파이썬 자체의 '누수'가 많이 잡히므로 detect_leaks=0 으로 끄는 편이 실용적

더 있는 도구들

도구잡는 것
ASan버퍼 오버런 · UAF · 누수 ★
UBSan정수 오버플로 · 널 · 정렬
TSan데이터 경쟁 · 교착
MSan초기화 안 된 메모리 읽기
(모든 의존성을 MSan 으로 빌드해야 함)
Valgrind재빌드 없이 검사 · 훨씬 느림(20~50배)
퍼징(libFuzzer)입력을 자동 생성해 크래시 탐색 ★
// 퍼징 — 파서·디코더가 있다면 반드시 해 보세요
// fuzz.cpp
extern "C" int LLVMFuzzerTestOneInput(const uint8_t* data, size_t size) {
    if (size < 4) return 0;
    core::parse({reinterpret_cast<const char*>(data), size});   // 크래시하면 발견
    return 0;
}
clang++ -fsanitize=fuzzer,address -g fuzz.cpp core.cpp -o fuzz
./fuzz corpus/ -max_total_time=300
#   ★ 몇 분만 돌려도 사람이 생각 못 한 입력을 찾아냅니다
#   OSS-Fuzz 는 오픈소스 프로젝트에 무료로 이걸 상시 돌려 줍니다

한 단계 더 — Sanitizer 는 어떻게 잡아내나 🧬 전문가

ASan 의 원리 — 그림자 메모리

① 컴파일러가 모든 메모리 접근 앞에 검사 코드를 넣습니다
② 실행 시 '그림자 메모리'를 유지합니다
   실제 메모리 8바이트 → 그림자 1바이트로 상태를 기록
   (접근 가능 / 레드존 / 해제됨 …)
③ 할당된 영역 주위에 '레드존'을 둡니다
   → 조금만 벗어나도 즉시 감지 ★
④ 해제된 메모리는 '격리 구역'에 일정 시간 보관
   → 해제 후 사용을 잡을 수 있습니다

그래서
  · 메모리 3배 사용 (그림자 + 레드존 + 격리)
  · 실행 2배 느림 (검사 코드)
  · 정확한 위치와 할당 스택까지 알려 줍니다

한계
  · 검사 코드가 들어간 부분만 감지합니다
    → 서드파티 .so 안의 버그는 못 잡습니다 (같이 빌드해야 함)
  · 레드존을 크게 넘어가는 접근은 놓칠 수 있습니다
  · 논리 오류는 대상이 아닙니다

운영에서 쓸 수 있는 것들

# Sanitizer 는 운영에 쓰기엔 무겁습니다. 대안:

# ① 하드웨어 지원 (ARM64)
-fsanitize=hwaddress          # HWASan — 메모리 부담 수십 % 수준으로 ASan 보다 훨씬 가벼움
# ARM MTE(Memory Tagging) 지원 기기에서는 거의 무비용 ★

# ② GWP-ASan — 확률적 샘플링 방식
#    할당의 일부만 감시해 운영 환경에서도 켤 수 있습니다
#    (Chrome·Android 가 실제로 씁니다)

# ③ 가벼운 강화 옵션 (b04)
-D_FORTIFY_SOURCE=3 -fstack-protector-strong -D_GLIBCXX_ASSERTIONS
#    비용 1% 미만으로 상당수의 오버런을 잡습니다 ★

# ④ Control Flow Integrity
-fsanitize=cfi -flto -fvisibility=hidden

# ⑤ 스택 카나리·ASLR·RELRO (기본으로 켜 두세요)
-fPIE -pie -Wl,-z,relro,-z,now

# 확인
checksec --file=./app         # 어떤 보호가 켜졌는지
현실적인 전략 — CI 에서 ASan·UBSan·TSan 전부, 운영에는 가벼운 강화 옵션. 여기에 퍼징을 더하면 C++ 로 할 수 있는 자동 검증은 거의 다 한 것입니다.
정리 — -fsanitize=address,undefined 한 줄이면 메모리 버그의 대부분이 정확한 줄 번호와 함께 잡힙니다. UBSan 은 비용이 거의 없으니 개발 빌드에 항상, ASan·TSan 은 CI 별도 잡으로 돌리세요. 파서가 있다면 퍼징도 몇 분만 돌려 보세요 — 반드시 뭔가 나옵니다.
측정 · 캐시 · 할당 · SIMD · 병렬

C++ 가 빠른 게
아니라
내가 빠르게 씁니다

생각 없이 쓴 C++ 는 numpy 보다 느립니다. C++ 가 빠른 이유는 메모리 배치와 복사를 내가 정할 수 있기 때문이고, 그걸 안 하면 이점이 없습니다. 이 탭은 "왜 안 빨라지나"에 답합니다.

10SECTIONS
①측정이 먼저
5최적화 순서
4깊이 층
PERF 01

측정 먼저 — 벤치마크를 신뢰할 수 있게

“빨라진 것 같다”는 증거가 아닙니다. 제대로 재는 법부터 잡지 않으면 나머지 아홉 섹션은 의미가 없습니다.

최적화의 순서 ★

이 순서를 지키면 대부분 ①②에서 끝납니다

  ① 알고리즘 · 자료구조     O(n²) → O(n)        수십~수백 배
  ② I/O · 쿼리 횟수         N+1 제거 · 배치      수십 배
  ③ 메모리 접근 패턴        캐시 친화적 배치     2~10배   ★ C++ 의 영역
  ④ 복사 · 할당 제거        이동 · reserve       1.5~3배
  ⑤ 벡터화 · 병렬           SIMD · 멀티스레드    2~수십 배
  ⑥ 마이크로 최적화         분기 · 인라인        수 %

★ ③부터가 C++ 를 쓰는 진짜 이유입니다.
  ①②는 파이썬에서도 할 수 있고, 거기서 못 얻은 이득을
  C++ 로 옮긴다고 얻어지지 않습니다.

⚠ 순서를 건너뛰면
  · O(n²) 알고리즘을 SIMD 로 최적화 → 여전히 O(n²)
  · 쿼리가 1만 번인데 루프를 튜닝 → 아무 변화 없음

제대로 재기

#include <chrono>

// ★ 최소한의 규칙
//   ① Release 빌드로 (b04)
//   ② steady_clock 이나 perf_counter 를 쓸 것 (system_clock ❌)
//   ③ 여러 번 재고 '최솟값'을 볼 것 (평균은 노이즈에 흔들립니다)
//   ④ 결과를 실제로 쓸 것 (안 쓰면 컴파일러가 통째로 지웁니다) ★

template<class F>
double bench_ms(F&& f, int reps = 10) {
    double best = 1e300;
    for (int i = 0; i < reps; ++i) {
        auto t0 = std::chrono::steady_clock::now();
        f();
        auto t1 = std::chrono::steady_clock::now();
        best = std::min(best,
            std::chrono::duration<double, std::milli>(t1 - t0).count());
    }
    return best;
}

// 컴파일러가 코드를 지우지 못하게 (최적화 방지 장벽)
template<class T>
inline void do_not_optimize(T const& v) {
    asm volatile("" : : "r,m"(v) : "memory");
}
//   또는 benchmark::DoNotOptimize (Google Benchmark)

// 사용
auto t = bench_ms([&]{ auto r = compute(data); do_not_optimize(r); });
가장 흔한 벤치마크 실수 — 결과를 안 써서 컴파일러가 계산 자체를 삭제한 것을 “0.001ms 로 빨라졌다”고 보고하는 것입니다. 결과가 비현실적으로 빠르면 먼저 이것을 의심하세요.

이렇게도 쓸 수 있다 — Google Benchmark 🟡 중급

제대로 된 벤치마크 프레임워크

#include <benchmark/benchmark.h>

static void BM_Smooth(benchmark::State& state) {
    const std::size_t n = state.range(0);
    std::vector<double> in(n, 1.0), out(n);

    for (auto _ : state) {                    // ★ 이 루프가 자동 반복
        core::smooth(in.data(), n, out.data(), 3);
        benchmark::DoNotOptimize(out.data());
        benchmark::ClobberMemory();
    }
    state.SetItemsProcessed(state.iterations() * n);
    state.SetBytesProcessed(state.iterations() * n * sizeof(double));
}
BENCHMARK(BM_Smooth)->Range(1<<10, 1<<22);    // 1K ~ 4M 까지 자동으로

BENCHMARK_MAIN();
--------------------------------------------------------------------
Benchmark            Time     CPU   Iterations  items/s   bytes/s
--------------------------------------------------------------------
BM_Smooth/1024     1.2 us  1.2 us      580000   853M/s    6.8GB/s
BM_Smooth/1048576  1.8 ms  1.8 ms         389   582M/s    4.6GB/s  ★
BM_Smooth/4194304  9.1 ms  9.1 ms          77   461M/s    3.7GB/s

★ bytes/s 가 크기에 따라 떨어지면 → 캐시를 벗어났다는 신호 (e02)
  메모리 대역폭 한계(보통 10~50GB/s)에 가까우면 → 계산이 아니라 전송이 병목

노이즈 줄이기

# ① CPU 주파수 고정 (터보·절전으로 결과가 흔들립니다) ★
sudo cpupower frequency-set --governor performance

# ② 특정 코어에 고정
taskset -c 2 ./bench
# 또는 코어를 격리 (커널 파라미터 isolcpus=2,3)

# ③ 다른 부하 제거 · 여러 번 반복
./bench --benchmark_repetitions=10 --benchmark_report_aggregates_only=true

# ④ Google Benchmark 가 경고해 줍니다
#   ***WARNING*** CPU scaling is enabled

# ⑤ 결과 비교 도구
./bench --benchmark_format=json > before.json
# ... 최적화 ...
./bench --benchmark_format=json > after.json
compare.py benchmarks before.json after.json     # 통계적 유의성까지 ★

# ⑥ 하드웨어 카운터로 원인 파악
perf stat -e cycles,instructions,cache-misses,branch-misses ./bench
5% 차이는 노이즈일 수 있습니다 — 같은 코드를 두 번 재도 3~5% 는 흔들립니다. “최적화했더니 7% 빨라졌다”를 주장하려면 반복 측정과 통계가 필요합니다.

실무에선 이렇게 — 프로파일링으로 병목 찾기 🔴 실무

추측하지 말고 프로파일러를

# ★ perf — 리눅스 표준. 가장 먼저 쓰는 도구
perf record -g --call-graph=dwarf ./app
perf report                       # 대화형
perf report --stdio | head -30

#   ⚠ 정확한 스택을 위해 빌드 옵션이 필요합니다
#     -g -fno-omit-frame-pointer

# 플레임 그래프 (한눈에 보입니다) ★
perf record -F 999 -g ./app
perf script | stackcollapse-perf.pl | flamegraph.pl > flame.svg

# 무엇이 문제인지 요약
perf stat -d ./app
#   cycles · instructions · IPC · cache-misses · branch-misses

# 맥
xctrace record --template 'Time Profiler' --launch ./app
# 윈도우
#   Visual Studio 성능 프로파일러 · Intel VTune

# 파이썬 확장이라면 (🐍 y12)
py-spy record --native -o flame.svg -- python bench.py

숫자를 해석하기

perf stat 결과 읽는 법

  IPC (instructions per cycle)
    > 2.0  : 좋음 — CPU 를 잘 쓰고 있습니다
    < 1.0  : 뭔가 기다리고 있습니다 (메모리·분기) ★
    < 0.5  : 심각 — 캐시 미스나 의존성 사슬을 의심

  cache-misses / cache-references
    > 10%  : 메모리 레이아웃 문제 (e02)

  branch-misses / branches
    > 5%   : 예측 불가능한 분기 (e05)

  page-faults 가 많다
    → 할당이 잦거나 메모리가 부족

★ 프로파일 1위가 memcpy · operator new 면 → e03·e04
  1위가 read · recv · execute 면 → I/O 문제 (C++ 최적화 무의미)
  1위가 내 계산 함수인데 IPC 가 낮으면 → e02 (캐시)
  IPC 도 높은데 느리면 → 알고리즘 자체 (①로 돌아가기)
프로파일 결과의 맨 위가 대개 내 코드가 아닙니다 — memcpy·operator new·__memmove_avx 같은 것이 1위라면 복사와 할당이 진짜 문제입니다(e03·e04). 그 자리를 “라이브러리가 느리다”고 해석하면 안 됩니다.

한 단계 더 — 측정을 코드에 심기 🧬 전문가

운영에서도 재기

// 개발 벤치마크는 실제 부하를 재현하지 못합니다.
// 운영에서 계속 측정하는 것이 훨씬 정확합니다 ★

class Timer {
    std::chrono::steady_clock::time_point t0_;
    const char* name_;
public:
    explicit Timer(const char* n)
        : t0_(std::chrono::steady_clock::now()), name_(n) {}
    ~Timer() {
        auto us = std::chrono::duration_cast<std::chrono::microseconds>(
            std::chrono::steady_clock::now() - t0_).count();
        metrics::record(name_, us);        // 히스토그램에 누적
    }
};
#define SCOPED_TIMER(n) Timer _t##__LINE__{n}

void process() {
    SCOPED_TIMER("process");
    ...
}

// ★ 평균이 아니라 백분위를 보세요
//   평균 2ms 인데 p99 가 200ms 인 경우가 실제로 많습니다
//   원인: 할당 스파이크 · 캐시 미스 · GC 없는 C++ 에서도 페이지 폴트

// 샘플링으로 비용 줄이기
if ((counter++ & 0x3F) == 0) { SCOPED_TIMER("process"); ... }

CI 에서 성능 회귀 잡기

# 벤치마크를 CI 에 넣고 이전 결과와 비교 ★
- name: 벤치마크
  run: |
    ./build/bench --benchmark_format=json > current.json
- name: 회귀 검사
  run: |
    python tools/compare_bench.py baseline.json current.json --threshold 1.10
    # 10% 이상 느려지면 실패
# tools/compare_bench.py — 핵심 로직
import json, sys
base = {b["name"]: b["real_time"] for b in json.load(open(sys.argv[1]))["benchmarks"]}
cur  = {b["name"]: b["real_time"] for b in json.load(open(sys.argv[2]))["benchmarks"]}
bad = []
for name, t in cur.items():
    if name in base and t > base[name] * 1.10:
        bad.append(f"{name}: {base[name]:.1f} → {t:.1f} ({t/base[name]:.2f}×)")
if bad:
    print("성능 회귀:\n  " + "\n  ".join(bad)); sys.exit(1)
CI 벤치마크의 한계 — 공용 러너는 노이즈가 심합니다(같은 코드가 20% 흔들림). 임계값을 넉넉히(10~20%) 잡거나, 전용 러너를 두거나, instruction count(cachegrind)처럼 결정적인 지표를 쓰세요.
정리 — 최적화는 알고리즘 → I/O → 메모리 → 복사 → 병렬 순서입니다. 측정은 Release 빌드로, 최솟값을 보고, 결과를 실제로 써야 합니다. perf stat 의 IPC 와 cache-miss 만 봐도 “계산이 문제인지 메모리가 문제인지”가 갈립니다.
PERF 02

메모리 레이아웃과 캐시

같은 알고리즘도 데이터가 어떻게 놓여 있느냐에 따라 수 배가 갈립니다. C++ 를 쓰는 진짜 이유가 여기 있습니다.

같은 1바이트를 읽어도 어디에 있느냐에 따라 50배가 다릅니다 한 번 읽는 데 걸리는 CPU 사이클 L1 약 4 사이클 — 코어 바로 옆, 보통 32KB L2 약 12 사이클 — 코어당 수백 KB L3 약 40 사이클 — 코어들이 공유, 수십 MB RAM 200 사이클 이상 CPU 는 한 번에 64바이트(캐시 라인)를 통째로 끌어옵니다 — 옆에 붙어 있는 데이터는 공짜로 딸려 옵니다 그래서 같은 100만 개라도 이렇게 갈립니다 vector 순회 붙어 있어 캐시 라인 하나에 여러 개 — 예측도 잘 맞습니다 list · 포인터 추적 노드마다 흩어져 있어 건널 때마다 캐시 미스
"알고리즘 복잡도는 같은데 왜 10배 차이가 나지"의 답은 거의 항상 이 그림입니다.

메모리는 계층입니다

대략적인 지연 시간 (현대 x86 · 사이클)

  레지스터        0
  L1 캐시         ~4      (32~48KB)
  L2 캐시         ~14     (512KB~2MB)
  L3 캐시         ~40     (8~32MB, 코어 공유)
  메인 메모리     ~200    ★ L1 의 50배
  SSD             ~100,000
  네트워크        ~수백만

핵심
  · CPU 는 메모리에서 '64바이트 단위(캐시 라인)'로 가져옵니다
  · double 하나(8바이트)를 읽어도 64바이트가 올라옵니다
  · 그래서 '연속으로 붙어 있으면' 공짜로 따라옵니다 ★
  · 흩어져 있으면 매번 200 사이클을 기다립니다

→ 200 사이클이면 덧셈 수십 개를 할 수 있습니다.
  즉 대부분의 프로그램은 '계산이 느린' 게 아니라 '기다리는' 중입니다.

눈으로 보이는 차이

// ① 순차 접근 vs 무작위 접근
std::vector<int> v(64 * 1024 * 1024);      // 256MB

for (std::size_t i = 0; i < v.size(); ++i) sum += v[i];        // 순차
for (std::size_t i : random_indices)        sum += v[i];        // 무작위
//   같은 횟수인데 무작위가 5~20배 느립니다 ★

// ② 2차원 배열 순회 순서
double m[N][N];
for (int i = 0; i < N; ++i)
    for (int j = 0; j < N; ++j) sum += m[i][j];   // ✅ 행 우선 (연속)
for (int j = 0; j < N; ++j)
    for (int i = 0; i < N; ++i) sum += m[i][j];   // ❌ 열 우선 (건너뜀)
//   N=2048 이면 5~10배 차이 — 코드는 두 글자만 다릅니다 ★

// ③ 포인터 추적 (연결 리스트가 느린 이유)
for (Node* p = head; p; p = p->next) sum += p->value;
//   매 노드마다 캐시 미스 가능 → vector 대비 10배 이상 느릴 수 있습니다
//   (탭 2 m05 에서 "list 는 거의 답이 아니다"라고 한 이유)
numpy 가 빠른 이유도 같습니다 — 파이썬 list 는 포인터 배열이라 값이 흩어져 있고, numpy 배열은 연속된 숫자 블록입니다. C++ 로 옮겨도 vector<shared_ptr<T>> 를 쓰면 같은 문제가 생깁니다.

이렇게도 쓸 수 있다 — AoS 와 SoA 🟡 중급

구조체 배열 vs 배열 구조체 ★

// AoS (Array of Structs) — 직관적이지만 일부 필드만 쓸 때 손해
struct Particle { float x, y, z, vx, vy, vz; float mass; bool alive; };
std::vector<Particle> ps(1'000'000);        // 32바이트 × 100만

for (auto& p : ps) p.x += p.vx;             // x, vx 만 쓰는데
//   캐시 라인 64바이트에 Particle 2개 → 필요한 건 8바이트, 나머지는 낭비 ★

// SoA (Struct of Arrays) — 쓰는 것만 연속으로
struct Particles {
    std::vector<float> x, y, z, vx, vy, vz, mass;
    std::vector<uint8_t> alive;
};
Particles ps;

for (std::size_t i = 0; i < n; ++i) ps.x[i] += ps.vx[i];
//   ✅ x 와 vx 만 캐시에 올라옵니다 → 미스가 크게 줄고
//   ✅ 벡터화도 훨씬 잘 됩니다 (e06)
//   보통 2~5배, 많으면 10배까지 차이 납니다

// ⚠ 대신 '한 입자의 모든 필드'를 쓰는 코드는 AoS 가 낫습니다
//   → 접근 패턴을 보고 정하세요
파이썬 모듈을 만들 때 자연스럽게 SoA 가 됩니다 — numpy 배열을 열 단위로 받으면(xs, ys, zs) 그것이 곧 SoA 입니다(🐍 y01 · 🟨 j04). JS 객체 배열을 그대로 넘기는 것보다 훨씬 빠른 이유이기도 합니다.

구조체를 작게 만들기

// ① 필드 순서로 패딩 줄이기 (탭 1 x02)
struct Bad  { char a; double b; char c; };      // 24바이트
struct Good { double b; char a; char c; };      // 16바이트 ★
static_assert(sizeof(Good) == 16);

// ② 타입 줄이기
struct Node {
    int64_t id;        // 정말 64비트가 필요한가?
    double  score;     // float 로 충분한가?
    bool    flags[8];  // 비트필드로 1바이트에?
};
struct Compact {
    int32_t id;
    float   score;
    uint8_t flags;     // 비트 8개
};                     // 24바이트 → 12바이트 → 캐시에 2배 담김 ★

// ③ 인덱스로 포인터 대체 (8바이트 → 4바이트, 게다가 재할당에 안전)
struct Edge { uint32_t from, to; };     // Node* 대신

// ④ 뜨거운 필드와 차가운 필드 분리 ★
struct Hot  { float x, y; };            // 매 프레임 쓰는 것
struct Cold { std::string name; Meta m; };  // 가끔 쓰는 것
std::vector<Hot> hot; std::vector<Cold> cold;   // 인덱스로 연결

// 확인
pahole -C Node build/libcore.so       # 패딩을 표로 보여 줍니다 ★

실무에선 이렇게 — 접근 패턴 설계 🔴 실무

블로킹 — 캐시에 맞게 쪼개기

// 행렬 곱 — 고전적인 예
// ❌ 단순 구현 — B 를 열 방향으로 읽어 캐시 미스가 많습니다
for (int i = 0; i < N; ++i)
  for (int j = 0; j < N; ++j) {
    double s = 0;
    for (int k = 0; k < N; ++k) s += A[i][k] * B[k][j];
    C[i][j] = s;
  }

// ✅ 타일링 — 캐시에 들어가는 블록 단위로
constexpr int T = 64;                       // L1 에 맞게 조정
for (int ii = 0; ii < N; ii += T)
 for (int jj = 0; jj < N; jj += T)
  for (int kk = 0; kk < N; kk += T)
   for (int i = ii; i < std::min(ii+T, N); ++i)
    for (int j = jj; j < std::min(jj+T, N); ++j) {
      double s = C[i][j];
      for (int k = kk; k < std::min(kk+T, N); ++k) s += A[i][k] * B[k][j];
      C[i][j] = s;
    }
//   N=1024 에서 3~5배 빨라집니다 ★

// ⚠ 실무에서는 직접 짜지 말고 BLAS 를 쓰세요
//   OpenBLAS · MKL 은 이걸 극한까지 최적화해 두었습니다 (10배 이상)
//   → "라이브러리를 쓸 수 있는지" 를 항상 먼저 확인

캐시 미스를 실제로 재기

# 전체 요약
perf stat -e cache-references,cache-misses,\
LLC-load-misses,L1-dcache-load-misses ./app

#   cache-misses / cache-references > 10%  → 레이아웃 문제 ★

# 어느 줄에서 미스가 나는지
perf record -e cache-misses -g ./app
perf report

# 더 자세히 (메모리 접근 프로파일)
perf mem record ./app && perf mem report

# 캐시 시뮬레이션 (결정적 — CI 에 적합)
valgrind --tool=cachegrind ./app
cg_annotate cachegrind.out.*        # 소스 줄별 미스 수 ★

# 내 CPU 의 캐시 크기
lscpu | grep -i cache
getconf -a | grep CACHE
“캐시 미스가 많다”만으로는 부족합니다 — 데이터가 크면 미스는 불가피합니다. 중요한 것은 “같은 데이터를 여러 번 읽는데 매번 미스가 나는가” 입니다. 그때가 블로킹·레이아웃 개선이 효과를 내는 자리입니다.

한 단계 더 — 프리페치와 TLB 🧬 전문가

하드웨어가 알아서 하는 것

// CPU 는 접근 패턴을 감지해 '미리 가져옵니다'(하드웨어 프리페처)
//   · 순차 접근 → 완벽하게 예측
//   · 일정 간격(stride) → 대개 예측
//   · 무작위·포인터 추적 → 예측 불가 ★

// 소프트웨어 프리페치 — 정말 필요할 때만
for (std::size_t i = 0; i < n; ++i) {
    __builtin_prefetch(&data[indices[i + 16]], 0, 1);   // 16개 앞을 미리
    sum += data[indices[i]];
}
//   무작위 접근인데 인덱스를 미리 알 때 효과가 있습니다
//   ⚠ 잘못 쓰면 오히려 느려집니다. 반드시 측정하세요

// 더 나은 접근 — 무작위성을 없애기
std::ranges::sort(indices);          // 정렬하면 순차에 가까워집니다 ★
//   해시 조인·그래프 탐색에서 실제로 쓰는 기법입니다

// 의존성 사슬 끊기 — 프리페처가 일할 시간을 주기
// ❌ 다음 주소가 이전 결과에 의존 (포인터 추적)
while (p) { sum += p->v; p = p->next; }
// ✅ 여러 사슬을 동시에 (배치 처리)
while (p1 && p2) { s1 += p1->v; s2 += p2->v; p1 = p1->next; p2 = p2->next; }

TLB 와 huge page

가상 주소 → 물리 주소 변환도 캐시됩니다 (TLB)

  · 기본 페이지 4KB · TLB 엔트리 수백~수천 개
  · 즉 TLB 로 커버되는 메모리는 수 MB 수준 ★
  · 그보다 큰 데이터를 무작위로 접근하면 TLB 미스가 추가로 발생
    (캐시 미스와 별개로 페이지 테이블을 걸어야 함)

huge page (2MB) 를 쓰면 커버 범위가 512배로 늘어납니다
// 리눅스 — 투명 huge page 힌트
#include <sys/mman.h>
void* p = std::aligned_alloc(2*1024*1024, size);
madvise(p, size, MADV_HUGEPAGE);        // ★ 큰 배열에 효과적

// 정렬된 할당 (SIMD 에도 필요 — e06)
alignas(64) double buf[1024];           // 캐시 라인 정렬
auto* p = static_cast<double*>(std::aligned_alloc(64, n * sizeof(double)));
std::free(p);                            // ★ aligned_alloc 은 free 로 해제

// C++17 표준 방법
auto* q = new (std::align_val_t{64}) double[n];
operator delete[](q, std::align_val_t{64});
# TLB 미스 측정
perf stat -e dTLB-load-misses,iTLB-load-misses ./app
# huge page 상태
cat /sys/kernel/mm/transparent_hugepage/enabled
grep AnonHugePages /proc/meminfo
언제 여기까지 가나 — 수 GB 데이터를 무작위 접근하는 데이터베이스·그래프 처리·대규모 시뮬레이션에서 의미가 있습니다. 일반적인 배열 처리에서는 e02 앞부분(연속 배치·SoA)만으로 충분합니다.
정리 — 메모리는 64바이트 캐시 라인 단위로 움직입니다. 연속으로 붙여 두고 순차로 읽는 것이 성능의 8할입니다. 일부 필드만 쓴다면 SoA, 구조체는 큰 것부터 배치해 작게, 같은 데이터를 반복해서 쓴다면 블록으로 쪼개서.
PERF 03

할당 줄이기 — reserve · 재사용 · 풀

메모리를 새로 얻는 일은 비쌉니다. 게다가 시간이 일정하지 않아 p99 지연을 망칩니다. 미리 잡아 두고 재사용하는 것이 답입니다.

할당 비용은 얼마나

대략적인 감각 (glibc malloc 기준)

  스택 할당            ~0 사이클   (포인터만 이동)
  작은 힙 할당 (캐시됨) ~20~50 사이클
  큰 힙 할당           ~100~수천 사이클
  새 페이지가 필요할 때  수 μs      ★ 커널 진입 (mmap)

★ 문제는 평균이 아니라 '가끔 아주 느린 것' 입니다
  · 페이지 폴트 · 아레나 확장 · 락 경합(멀티스레드)
  · p50 은 1μs 인데 p99 가 500μs 인 경우가 흔합니다

그리고 할당은 캐시도 망칩니다
  · 새 메모리는 캐시에 없습니다 (콜드)
  · 해제·재할당을 반복하면 메모리가 조각납니다
// 할당 횟수를 세는 도구 — 프로젝트에 하나 두세요 ★
static std::atomic<std::size_t> g_allocs{0}, g_bytes{0};
void* operator new(std::size_t n) {
    g_allocs.fetch_add(1, std::memory_order_relaxed);
    g_bytes.fetch_add(n, std::memory_order_relaxed);
    return std::malloc(n);
}
void operator delete(void* p) noexcept { std::free(p); }
void operator delete(void* p, std::size_t) noexcept { std::free(p); }

// 벤치마크 전후로 비교하면 어디서 할당하는지 바로 보입니다

기본 대책 세 가지

// ① reserve — 크기를 알면 미리 (탭 2 m05)
std::vector<Row> rows;
rows.reserve(expected);          // 재할당 0회 ★

// ② 버퍼 재사용 — 루프 밖에 두고 clear
std::vector<double> tmp;
tmp.reserve(max_size);
for (const auto& chunk : chunks) {
    tmp.clear();                 // size 만 0, 용량은 유지 ★
    process(chunk, tmp);
}

// ③ 스택에 잡기 — 작고 크기를 알면
std::array<double, 256> buf;     // 할당 0

// 크기가 대개 작지만 가끔 클 때 — small buffer optimization
#include <boost/container/small_vector.hpp>
boost::container::small_vector<int, 16> v;   // 16개까지는 스택 ★
//   std::string 의 SSO 와 같은 원리 (탭 2 m11)

// 객체를 멤버로 들고 재사용 (파이썬·Node 모듈에서 특히 중요)
class Engine {
    std::vector<double> scratch_;    // 호출마다 새로 만들지 않음 ★
public:
    void run(std::span<const double> in, std::span<double> out) {
        scratch_.resize(in.size());   // 이미 크면 할당 없음
        ...
    }
};

이렇게도 쓸 수 있다 — 아레나와 메모리 리소스 🟡 중급

C++17 다형적 메모리 자원(PMR)

#include <memory_resource>

void handle_request() {
    // 스택 버퍼에서 할당 — 힙을 아예 안 씁니다 ★
    std::array<std::byte, 64 * 1024> buf;
    std::pmr::monotonic_buffer_resource pool{buf.data(), buf.size()};

    std::pmr::vector<int>               v{&pool};
    std::pmr::string                    s{&pool};
    std::pmr::unordered_map<int, int>   m{&pool};

    v.reserve(1000);        // pool 에서 가져옵니다
    ...
}   // ★ 해제는 pool 이 통째로 — 개별 free 가 없어 매우 빠릅니다

// 버퍼가 부족하면 상위 자원에서 더 받아 옵니다
std::pmr::monotonic_buffer_resource pool{
    buf.data(), buf.size(), std::pmr::new_delete_resource()};

// 크기가 다양한 객체를 반복 할당·해제한다면
std::pmr::unsynchronized_pool_resource pool;    // 크기별 풀 (단일 스레드)
std::pmr::synchronized_pool_resource   spool;   // 스레드 안전

// 디버깅 — 할당을 추적하는 자원
class TracingResource : public std::pmr::memory_resource {
    void* do_allocate(std::size_t n, std::size_t a) override {
        std::printf("alloc %zu\n", n);
        return std::pmr::new_delete_resource()->allocate(n, a);
    }
    ...
};
monotonic 이 가장 유용합니다 — “요청 하나를 처리하고 통째로 버리는” 패턴에 완벽히 맞습니다. 해제가 사실상 공짜이고, 메모리가 연속이라 캐시에도 좋습니다(e02).

객체 풀

// 같은 타입을 자주 만들고 버린다면 — 재사용 풀
template<class T>
class Pool {
    std::vector<std::unique_ptr<T>> free_;
public:
    struct Deleter {
        Pool* p;
        void operator()(T* t) const {
            t->reset();                       // 상태 초기화
            p->free_.emplace_back(t);         // 풀로 반납 ★
        }
    };
    using Handle = std::unique_ptr<T, Deleter>;

    Handle acquire() {
        if (free_.empty()) return Handle{new T{}, Deleter{this}};
        auto* t = free_.back().release();
        free_.pop_back();
        return Handle{t, Deleter{this}};
    }
};

Pool<Buffer> pool;
{
    auto b = pool.acquire();      // 할당 없이 재사용
    use(*b);
}                                  // 자동으로 반납 (RAII)

// ⚠ 주의
//   · 풀이 무한히 자라지 않게 상한을 두세요
//   · 멀티스레드면 스레드별 풀(thread_local)이 락보다 빠릅니다 ★
//   · 재사용 전에 상태를 확실히 초기화 (이전 데이터 누출 방지)

실무에선 이렇게 — 지연 시간을 일정하게 🔴 실무

p99 를 망치는 것들

평균은 1ms 인데 p99 가 100ms 인 전형적 원인

  ① vector 재할당 — 100만 개를 통째로 복사
     → reserve 로 예방 ★
  ② 페이지 폴트 — 처음 만지는 메모리
     → 미리 터치(memset)하거나 mlock
  ③ malloc 아레나 락 경합 (멀티스레드)
     → tcmalloc·jemalloc·mimalloc 로 교체 ★
  ④ 메모리 조각화 — 오래 돌수록 심해짐
     → 풀·아레나로 패턴 고정
  ⑤ 캐시 콜드 스타트
     → 워밍업 후 측정

측정
  · 히스토그램으로 (평균 말고) — HdrHistogram
  · perf stat -e page-faults,minor-faults
  · /proc/<pid>/status 의 VmRSS 추이
# 할당자 교체만으로 개선되는 경우가 많습니다 ★
LD_PRELOAD=/usr/lib/libtcmalloc_minimal.so ./app
LD_PRELOAD=/usr/lib/libjemalloc.so ./app
#   멀티스레드 할당이 잦으면 2~3배 차이가 나기도 합니다

# CMake 로 링크
find_library(MIMALLOC mimalloc)
target_link_libraries(app PRIVATE ${MIMALLOC})

모듈에서 특히 중요합니다

// 파이썬·Node 확장은 '짧은 호출이 반복'되는 구조입니다.
// 호출마다 할당하면 그것이 지배적 비용이 됩니다 ★

// ❌ 호출마다 벡터 생성
py::array_t<double> smooth(py::array_t<double> a, int w) {
    std::vector<double> tmp(a.size());        // 매번 할당
    ...
}

// ✅ ① 출력 버퍼를 받기 (호출자가 재사용)
void smooth_into(py::array_t<double> a, py::array_t<double> out, int w);
//   파이썬: out = np.empty_like(arr); for ...: smooth_into(chunk, out, 3)

// ✅ ② 엔진 객체에 버퍼를 두기 (🐍 y05)
class Engine {
    std::vector<double> scratch_;
public:
    void run(...) { scratch_.resize(n); /* 두 번째부터 할당 없음 */ }
};

// ✅ ③ thread_local 스크래치 (스레드 안전 + 재사용)
static thread_local std::vector<double> tls_buf;
void run(std::size_t n) {
    tls_buf.resize(n);
    ...
}
//   ⚠ 스레드가 많으면 메모리를 그만큼 쓰고, 반환 시점이 불명확합니다
측정 방법 — 위의 operator new 카운터를 켜고 “호출 한 번에 할당이 몇 번인가”를 세어 보세요. 0에 가깝게 만드는 것이 목표이고, 대개 그것만으로 2~3배가 나옵니다.

한 단계 더 — 할당자의 내부 🧬 전문가

malloc 은 무엇을 하나

glibc malloc (ptmalloc2) 개요

  · 크기별 빈(bin) 목록을 유지합니다
      tcache   : 스레드 로컬 캐시 (가장 빠름, 락 없음) ★
      fastbin  : 작은 크기 (~128B)
      smallbin · largebin · unsorted
  · 큰 요청(기본 128KB 이상)은 mmap 으로 직접 (해제 시 munmap)
  · 아레나: 스레드마다 별도 힙 영역 (경합 감소)
      기본 최대 = 코어 수 × 8

그래서
  · 작은 할당의 반복은 tcache 덕에 꽤 빠릅니다
  · 128KB 근처를 오가면 mmap/munmap 이 반복돼 매우 느립니다 ★
      → M_MMAP_THRESHOLD 를 조정하거나 크기를 고정
  · 스레드가 많으면 아레나가 늘어 메모리 사용량이 커집니다

튜닝
  mallopt(M_MMAP_THRESHOLD, 1<<20);
  MALLOC_ARENA_MAX=2 ./app          # 메모리 사용량 억제
  malloc_stats();                    # 현재 상태 출력

할당 프로파일링

# heaptrack — 어디서 얼마나 할당하는지 ★
heaptrack ./app
heaptrack_gui heaptrack.app.12345.gz
#   · 할당 횟수 · 피크 메모리 · 누수 · 임시 할당까지

# massif (valgrind)
valgrind --tool=massif ./app
ms_print massif.out.*

# 파이썬 확장이라면 (🐍 y12)
memray run --native bench.py && memray flamegraph memray-*.bin

# perf 로 간단히
perf record -e page-faults -g ./app

# jemalloc 프로파일링
MALLOC_CONF=prof:true,prof_prefix:jeprof ./app
jeprof --show_bytes ./app jeprof.*.heap
// 커스텀 할당자를 컨테이너에 (탭 2 m05 · 🔬 d09)
template<class T>
struct CountingAlloc {
    using value_type = T;
    T* allocate(std::size_t n) {
        counter += n * sizeof(T);
        return static_cast<T*>(::operator new(n * sizeof(T)));
    }
    void deallocate(T* p, std::size_t) noexcept { ::operator delete(p); }
    static inline std::size_t counter = 0;
};
std::vector<int, CountingAlloc<int>> v;
heaptrack 을 한 번 돌려 보세요 — “임시 할당(만들자마자 버리는 것)”이 전체의 절반 이상인 경우가 정말 흔합니다. 그것만 없애도 체감이 크게 달라집니다.
정리 — 할당은 느린 것보다 “일정하지 않은 것”이 문제입니다. reserve · 버퍼 재사용 · 아레나(PMR) 세 가지면 대부분 해결됩니다. 모듈을 만든다면 호출당 할당 횟수를 0에 가깝게 만드는 것이 목표입니다.
할당을 줄이는 세 가지 — 대부분 여기서 해결됩니다 문제 — 반복문 안에서 새로 잡기 v.push_back() 을 반복하면 공간이 모자랄 때마다 더 큰 곳을 잡고 전부 옮깁니다 문자열 + 연산도 매번 새 버퍼를 만듭니다 — 로그 한 줄 만드는 데 할당이 여러 번 생깁니다 ① reserve 개수를 알면 미리 잡습니다 옮기는 일이 사라집니다 가장 손쉬운 개선 ② 버퍼 재사용 clear() 는 용량을 유지합니다 밖에 두고 비워 가며 씁니다 할당이 첫 회 한 번뿐 ③ 풀 · 아레나 같은 크기를 아주 많이 만들 때 미리 잡아 두고 꺼내 씁니다 마지막 수단입니다 읽기만 하는 문자열은 std::string_view 로 받으세요 — 복사가 통째로 사라집니다 함수 인자는 큰 객체면 const 참조로, 안에서 보관할 거면 값으로 받고 이동시키세요 확인은 도구로 — heaptrack 이나 프로파일러로 할당 횟수를 직접 세어 보세요
C++ 성능 작업의 대부분은 알고리즘이 아니라 메모리를 몇 번 잡느냐입니다.
PERF 04

복사 제거 — 값 vs 참조

자기도 모르게 큰 객체를 복사하고 있는 자리를 찾아 없앱니다. 코드는 그대로인데 수 배가 빨라지는 경우가 흔합니다.

복사가 숨어 있는 다섯 자리 ★

// ① 범위 for 에서 & 누락 — 가장 흔합니다
for (auto s : names)          // ❌ 문자열마다 복사
for (const auto& s : names)   // ✅

// ② 함수 인자를 값으로
void f(std::vector<int> v);        // ❌ 통째로 복사
void f(const std::vector<int>& v); // ✅ (탭 1 x03)

// ③ map 순회에서 타입을 잘못 적기
std::map<std::string, Big> m;
for (const std::pair<std::string, Big>& p : m)   // ❌ 실제 타입은
                                                  //   pair<const string, Big>
                                                  //   → 변환 복사 발생
for (const auto& p : m)                           // ✅

// ④ auto 가 참조를 떼어 버림 (탭 1 x02)
auto v = obj.data();          // ❌ 복사
const auto& v = obj.data();   // ✅

// ⑤ 반환값을 참조에 안 받기 · 임시를 여러 번 만들기
std::string s = a + "-" + b + "-" + c;   // 중간 임시 여러 개
std::string s = std::format("{}-{}-{}", a, b, c);   // ✅ (탭 2 m11)
이 다섯 개가 실무 복사 문제의 90% 입니다. clang-tidy 의 performance-* 규칙이 전부 자동으로 잡아 줍니다(🛠️ b07).

복사를 찾아내기

// 진단 구조체 — 어디서 복사되는지 출력으로 확인
struct Trace {
    Trace() { std::puts("생성"); }
    Trace(const Trace&) { std::puts("★복사★"); }
    Trace(Trace&&) noexcept { std::puts("이동"); }
    ~Trace() { std::puts("소멸"); }
};

// 복사 생성자를 아예 막아 컴파일 시점에 찾기 ★
struct Big {
    Big(const Big&) = delete;      // 임시로 막아 두면
    Big(Big&&) = default;          // 복사하는 곳이 전부 에러로 드러납니다
};
# clang-tidy — 자동 검출
run-clang-tidy -p build -checks='performance-*' src/
#   performance-for-range-copy            ← ① 잡습니다 ★
#   performance-unnecessary-value-param   ← ②
#   performance-implicit-conversion-in-loop ← ③
#   performance-move-const-arg
#   performance-unnecessary-copy-initialization ← ④

# 컴파일러 경고
g++ -Wall -Wextra -Wpessimizing-move -Wredundant-move

# 프로파일러에서
perf record -g ./app && perf report
#   memcpy · operator new · basic_string 생성자가 상위면 복사 문제 ★

이렇게도 쓸 수 있다 — 이동과 뷰 🟡 중급

복사 대신 옮기기 (탭 2 m03)

// 보관할 것은 값으로 받고 move
class Logger {
    std::string name_;
public:
    explicit Logger(std::string n) : name_(std::move(n)) {}   // ★
};

// 컨테이너에 넣을 때
v.push_back(std::move(w));         // 이동
v.emplace_back(args...);           // 그 자리에서 생성 (임시 없음) ★
m.try_emplace(key, args...);       // 이미 있으면 값을 만들지도 않음

// 반환 — RVO 를 믿으세요
std::vector<int> make() {
    std::vector<int> v(1000);
    return v;                      // ✅ 복사도 이동도 없습니다
    // return std::move(v);        // ❌ RVO 를 막습니다
}

// 이동 생성자에 noexcept 를 반드시 (없으면 vector 가 복사로 후퇴) ★
static_assert(std::is_nothrow_move_constructible_v<MyType>);

// 컨테이너 통째로 옮기기
other = std::move(v);              // 포인터 세 개만 이동
v.swap(other);                     // 같은 효과, 예외 없음

뷰로 복사 자체를 없애기

// ① std::span — 배열 구간을 복사 없이 (탭 1 x05)
void process(std::span<const double> data);
process(vec);                      // vector
process(arr);                      // 배열
process({ptr, n});                 // 원시 포인터
process(std::span(vec).subspan(10, 100));   // 부분도 복사 없음 ★

// ② std::string_view — 문자열 (탭 2 m11)
void log(std::string_view msg);
log("리터럴");                      // 할당 0

// ③ ranges::view — 변환도 지연 평가 (탭 2 m06)
auto evens = data | std::views::filter(is_even);
//   중간 벡터를 만들지 않습니다

// ④ 참조 래퍼 — 컨테이너에 참조를 담기
std::vector<std::reference_wrapper<Big>> refs;
refs.push_back(std::ref(big));

// ⑤ 인덱스로 간접 참조 — 정렬 시 큰 객체를 안 옮기기 ★
std::vector<std::uint32_t> idx(n);
std::iota(idx.begin(), idx.end(), 0);
std::ranges::sort(idx, {}, [&](auto i){ return items[i].key; });
//   items 는 그대로 두고 순서만 정합니다 (Big 이 클수록 유리)
뷰는 수명에 주의 — 원본이 사라지거나 재할당되면 댕글링이 됩니다(탭 2 m11). 인자로만 쓰고 보관하지 않는다는 규칙을 지키면 안전합니다.

실무에선 이렇게 — 데이터가 흘러가는 경로 설계 🔴 실무

경계에서 몇 번 복사되는지 세기

파이썬 모듈의 전형적인 데이터 경로

  ❌ 나쁜 경로 (복사 4회)
    파이썬 list
      → std::vector 변환 (복사 1)
      → 내부 함수에 값으로 전달 (복사 2)
      → 결과 vector 생성 (복사 3)
      → 파이썬 list 로 변환 (복사 4)

  ✅ 좋은 경로 (복사 0)
    numpy 배열
      → py::array_t 로 포인터만 (복사 0) 🐍 y06
      → std::span 으로 전달 (복사 0)
      → 미리 받은 출력 버퍼에 직접 쓰기 (복사 0)
      → 그대로 반환 (복사 0)

★ 100MB 데이터라면 400MB 복사 vs 0 — 이것이 설계의 차이입니다
// 인터페이스를 이렇게 설계하세요
void transform(std::span<const double> in,     // 입력: 읽기 전용 뷰
               std::span<double> out);          // 출력: 미리 받은 버퍼
//   호출자가 메모리를 소유 → 재사용 가능 · 복사 없음 ★

복사가 필요한 경우도 있습니다

// ⚠ 복사 제거가 항상 옳지는 않습니다

// ① 작은 객체는 값이 더 빠릅니다
void f(int x);                 // ✅
void f(const int& x);          // ❌ 간접 접근이 추가됨
//   기준: 포인터 두 개(16바이트) 이하면 값으로

// ② 참조는 aliasing 가정을 만들어 최적화를 막습니다 (탭 1 x04)
void add(std::span<double> out, std::span<const double> a);
//   컴파일러는 out 과 a 가 겹칠 수 있다고 가정 → 벡터화 실패 가능
//   → __restrict 로 알려 주거나, 값 복사가 오히려 나을 수도

// ③ 캐시 지역성 — 참조를 따라가면 흩어진 메모리를 만집니다
for (const auto& item : ptr_vector) use(*item);   // 포인터 추적 (느림)
for (const auto& item : value_vector) use(item);  // 연속 (빠름) ★

// ④ 스레드 간 공유 — 복사가 락보다 쌀 수 있습니다
//   각 스레드가 자기 사본을 갖는 편이 경합보다 나은 경우가 많습니다

// ★ 결론: 측정하세요. "복사 = 나쁨" 은 규칙이 아니라 경향입니다.
판단 기준 — 큰 객체(수백 바이트 이상)를 자주 복사하면 문제이고, 작은 값이나 한 번뿐인 복사는 신경 쓸 가치가 없습니다. 프로파일러가 memcpy 를 가리킬 때만 손대세요.

한 단계 더 — 복사 생략과 ABI 🧬 전문가

RVO 는 언제 보장되나

// C++17 부터 '보장된 복사 생략'이 있습니다
std::vector<int> make() { return std::vector<int>(1000); }   // prvalue
auto v = make();     // ★ 복사·이동 생성자가 없어도 컴파일됩니다 (의무 생략)

// 이름 있는 지역 변수(NRVO)는 '허용'이지 '의무'가 아닙니다
std::vector<int> make2() {
    std::vector<int> v(1000);
    return v;        // 대개 생략되지만 표준이 강제하지 않습니다
}

// NRVO 가 안 되는 경우
std::vector<int> f(bool c) {
    std::vector<int> a, b;
    return c ? a : b;      // ❌ 어느 것을 생략할지 정할 수 없음 → 이동
}
std::vector<int> g(std::vector<int> param) {
    return param;          // ❌ 매개변수는 NRVO 대상이 아님 → 이동
}

// 확인 — 복사·이동 생성자에 로그를 심고 -fno-elide-constructors 와 비교
g++ -fno-elide-constructors test.cpp    // 생략을 끄고 실제 호출 확인

반환값이 실제로 어떻게 전달되나

ABI 규칙 (x86-64 System V)

  · 작고 trivially copyable 한 타입 → 레지스터로 반환 (공짜)
      struct P { double x, y; };        // XMM0, XMM1
  · 크거나 non-trivial 한 타입 → 호출자가 공간을 잡고
    그 주소를 숨은 첫 인자로 전달 (RVO 의 실체) ★
      std::vector<int>                   // 호출자 스택에 직접 구성

  → 그래서 "큰 객체 반환은 느리다"는 옛말입니다.
    C++17 이후로는 반환이 대개 공짜입니다.

trivially copyable 여부가 갈림길
  static_assert(std::is_trivially_copyable_v<P>);
  · memcpy 로 옮길 수 있는 타입 → 레지스터·memcpy 최적화 가능
  · 소멸자나 복사 생성자를 직접 쓰면 이 성질이 깨집니다 ★

확인
  g++ -O2 -S -masm=intel -o - test.cpp | grep -A20 "make"
  godbolt.org 에서 반환 방식 비교
실무 함의 — 값으로 반환하는 것을 두려워하지 마세요. C++17 이후로는 대개 비용이 없습니다. 오히려 출력 인자(void f(T& out))가 코드를 읽기 어렵게 만드는 대가만 치르는 경우가 많습니다 (버퍼 재사용이 목적일 때는 예외 — e03).
정리 — 복사는 다섯 자리에 숨어 있습니다 — 범위 for 의 & 누락, 값 인자, map 순회 타입, auto, 문자열 조립. clang-tidy performance-* 가 전부 자동으로 잡아 줍니다. 그리고 값 반환은 이제 공짜이니 두려워하지 마세요.
반환값은 이제 복사되지 않습니다 — 애초에 그 자리에 만듭니다 ① 예전(C++03) — 복사본이 생겼습니다 함수 안의 v 원소 100만 개 임시 복사본 전부 다시 복사 호출한 쪽 변수 또 복사 2회 복사 ② 지금 — 복사 생략(RVO)이 표준으로 보장됩니다 함수가 '호출한 쪽 변수의 자리'에 직접 만듭니다 복사도 이동도 0회 — 생성자가 한 번 불릴 뿐입니다 그래서 큰 컨테이너를 값으로 돌려주는 코드가 지금은 가장 빠르고 가장 읽기 좋습니다 인자를 받는 기준 — 세 줄이면 충분합니다 읽기만 한다 → const T& · 안에서 보관한다 → T 로 받고 std::move · 배열을 훑는다 → std::span std::move 는 옮기는 코드가 아니라 "옮겨도 된다"는 표시입니다 — 반환문에 붙이면 오히려 RVO 를 막습니다
"복사가 무서워 포인터로 돌려주던" 습관은 이제 필요 없습니다.
반환값은 이제 복사되지 않습니다 — 애초에 그 자리에 만듭니다 ① 예전(C++03) — 복사본이 생겼습니다 함수 안의 v 원소 100만 개 임시 복사본 전부 다시 복사 호출한 쪽 변수 또 복사 2회 복사 ② 지금 — 복사 생략(RVO)이 표준으로 보장됩니다 함수가 '호출한 쪽 변수의 자리'에 직접 만듭니다 복사도 이동도 0회 — 생성자가 한 번 불릴 뿐입니다 그래서 큰 컨테이너를 값으로 돌려주는 코드가 지금은 가장 빠르고 가장 읽기 좋습니다 인자를 받는 기준 — 세 줄이면 충분합니다 읽기만 한다 → const T& · 안에서 보관한다 → T 로 받고 std::move · 배열을 훑는다 → std::span std::move 는 옮기는 코드가 아니라 "옮겨도 된다"는 표시입니다 — 반환문에 붙이면 오히려 RVO 를 막습니다
"복사가 무서워 포인터로 돌려주던" 습관은 이제 필요 없습니다.
PERF 05

분기 예측과 브랜치리스

if 하나가 예측을 빗나가면 15~20 사이클이 날아갑니다. 예측 불가능한 분기가 루프 안에 있으면 그것이 병목이 됩니다.

분기 예측 — 정렬된 배열이 정렬 안 된 배열보다 빠른 이유 ① 예측이 맞았을 때 분기 판단 중 미리 시작한 작업 그대로 계속 손실 0 ② 예측이 틀렸을 때 분기 판단 중 미리 시작한 작업 전부 버리고 다시 15~20 사이클 손실 줄이는 방법 세 가지 ① 데이터를 정렬 같은 결과가 이어지면 예측기가 거의 안 틀립니다 ② 분기를 없앤다 조건 대입 · 산술로 대체 짧은 분기에서만 효과 ③ 애초에 안 만든다 루프 밖으로 조건을 뺀다 가장 효과가 큽니다
여기까지 신경 쓸 코드는 정말 뜨거운 루프뿐입니다 — 나머지는 가독성이 이깁니다.

왜 비싼가

현대 CPU 는 파이프라인이 깊습니다 (15~20 단계)

  · 분기를 만나면 결과를 기다리지 않고 '예측'해서 계속 실행합니다
  · 맞으면 손해 없음 (예측 정확도는 보통 95% 이상)
  · 틀리면 파이프라인을 비우고 다시 시작 → 15~20 사이클 손실 ★

예측이 잘 되는 것
  · 항상 참/거짓인 조건
  · 규칙적인 패턴 (참참거참참거…)
  · 루프 종료 조건 (마지막 한 번만 틀림)

예측이 안 되는 것
  · 무작위 데이터에 의존하는 조건 ★
  · 가상 함수 호출 대상이 매번 바뀔 때

유명한 실험
  정렬된 배열에 if (v[i] > 128) 를 도는 것이
  정렬 안 된 배열보다 3~6배 빠릅니다.
  같은 코드, 같은 데이터 개수인데 '순서'만 다릅니다 ★

확인하고 줄이기

perf stat -e branches,branch-misses ./app
#   branch-misses / branches > 5%  → 분기가 문제일 수 있습니다 ★

perf record -e branch-misses -g ./app && perf report
#   어느 함수·어느 줄인지
// ① 데이터를 정렬해 예측 가능하게 (가능하다면 가장 효과적) ★
std::ranges::sort(data);
for (auto x : data) if (x > threshold) sum += x;

// ② 분기를 산술로 바꾸기 (브랜치리스)
sum += (x > t) ? x : 0;          // 컴파일러가 cmov 로 바꿔 주기도 합니다
sum += x * (x > t);              // 명시적으로 분기 제거
count += (x > t);                // bool → 0/1

// ③ 조건을 루프 밖으로
if (mode == A) { for (...) doA(); }
else           { for (...) doB(); }
//   ❌ for (...) { if (mode == A) doA(); else doB(); }

// ④ 힌트 (C++20)
if (rare_condition) [[unlikely]] { handle(); }
if (common) [[likely]] { fast_path(); }
//   ⚠ 잘못 주면 오히려 느려집니다. PGO 가 더 정확합니다(🛠️ b04)

이렇게도 쓸 수 있다 — 분기 없는 패턴 🟡 중급

자주 쓰는 브랜치리스 기법

// ① 최대·최소 — 컴파일러가 대개 cmov 로 처리합니다
int m = std::max(a, b);          // 분기 없음

// ② 클램프
x = std::clamp(x, lo, hi);

// ③ 조건부 누적
sum += cond * value;             // cond 가 0/1

// ④ 조건부 복사 (분기 대신 마스크)
out[k] = v;
k += (v > t);                    // ★ 조건을 만족할 때만 인덱스 증가
//   → std::copy_if 를 브랜치리스로 구현하는 관용구

// ⑤ 룩업 테이블로 분기 대체
static constexpr std::array<int, 256> kTable = make_table();
result = kTable[byte];           // if-else 사슬 대신 ★
//   ⚠ 테이블이 캐시를 벗어나면 오히려 손해입니다 (e02)

// ⑥ 비트 연산
int abs_v = (x ^ (x >> 31)) - (x >> 31);    // 분기 없는 절댓값
//   요즘은 std::abs 도 브랜치리스로 컴파일됩니다 — 직접 쓸 필요 없음

// ★ 대부분의 경우 컴파일러가 알아서 합니다.
//   godbolt 에서 cmov·setcc 가 나오는지 확인하고,
//   안 나올 때만 손으로 바꾸세요.

가상 호출도 분기입니다

// 간접 호출은 '분기 대상 예측'을 씁니다.
// 대상이 매번 바뀌면 예측이 실패합니다 (탭 1 x08)

// ❌ 섞여 있는 다형적 컨테이너
for (auto& s : shapes) total += s->area();   // 매번 다른 타입 → 예측 실패

// ✅ 타입별로 묶어서 처리
for (auto& c : circles) total += c.area();   // 예측 성공 · 인라인 · 벡터화
for (auto& r : rects)   total += r.area();

// ✅ 정렬로 같은 타입을 모으기
std::ranges::sort(shapes, {}, [](auto& s){ return s->type_id(); });

// ✅ variant + visit (탭 2 m10)
for (const auto& s : shapes) total += std::visit(AreaOf{}, s);
//   점프 테이블 · 인라인 가능

// 측정
perf stat -e branch-misses,iTLB-load-misses ./app
“분기를 없애라”가 아니라 “예측 가능하게 만들라”가 정확한 표현입니다. 데이터를 정렬하거나 타입별로 묶는 것이 브랜치리스 트릭보다 효과가 크고 코드도 읽기 좋습니다.

실무에선 이렇게 — 언제 손대나 🔴 실무

우선순위는 낮습니다

분기 최적화가 의미 있는 조건 (셋이 다 맞아야 합니다)

  ① 아주 자주 실행되는 루프 (수백만 회 이상)
  ② branch-misses 비율이 실제로 높음 (> 5%)
  ③ 루프 본문이 짧음 (분기 비용이 상대적으로 큼)

그렇지 않다면
  · 알고리즘(①) · 메모리(e02) · 할당(e03) 이 먼저입니다
  · 브랜치리스 코드는 읽기 어렵고 유지보수 비용이 있습니다
  · 컴파일러가 이미 최적화했을 가능성이 높습니다

★ 실무에서 실제로 효과를 본 경우
  · 파서·디코더의 바이트 단위 루프
  · 정렬·필터링의 비교 함수
  · 이미지 픽셀 처리
  · 게임의 충돌 판정
  공통점: 데이터 의존 분기가 원소마다 있는 코드

측정 없이 하지 마세요

// 실험 — 정렬 여부로 분기 예측 효과 재기
std::vector<int> data(1'000'000);
std::generate(data.begin(), data.end(), []{ return rand() % 256; });

auto run = [&]{
    long sum = 0;
    for (int x : data) if (x > 128) sum += x;
    return sum;
};

auto t1 = bench_ms(run);              // 정렬 안 됨
std::ranges::sort(data);
auto t2 = bench_ms(run);              // 정렬됨
std::printf("무작위 %.2fms · 정렬 %.2fms · %.1f배\n", t1, t2, t1/t2);
//   ★ 대개 2~6배 차이가 납니다 (컴파일러가 cmov 로 바꾸면 차이가 작아집니다)

// 브랜치리스 버전과도 비교
auto run2 = [&]{
    long sum = 0;
    for (int x : data) sum += x * (x > 128);
    return sum;
};
컴파일러가 이미 했을 수 있습니다 — -O2 에서 조건부 이동(cmov) 으로 바꾸는 경우가 많아, 손으로 브랜치리스를 써도 차이가 없거나 오히려 느려질 수 있습니다. godbolt 로 어셈블리를 확인하고 판단하세요(e09).

한 단계 더 — 분기 예측기의 동작 🧬 전문가

어떻게 예측하나

현대 예측기는 여러 층으로 되어 있습니다

  · 2비트 포화 카운터 — 최근 결과를 기억 (강한참·약한참·약한거짓·강한거짓)
  · 전역 이력 레지스터 — 최근 N개 분기의 패턴
  · TAGE 등 하이브리드 — 여러 길이의 이력을 조합해 예측
  · 간접 분기 예측기 (BTB) — 가상 호출·함수 포인터 대상

그래서
  · 규칙적인 패턴(ABABAB)은 잘 예측합니다 ★
  · 무작위는 원리상 50% 밖에 못 맞춥니다
  · 분기가 아주 많으면 예측기 자원이 부족해집니다 (aliasing)
  · 함수가 커지면 명령 캐시(L1i)와 BTB 압박이 생깁니다
    → 인라인을 무조건 늘리는 것이 답이 아닌 이유 ★

Spectre 이후
  · 투기 실행 완화 옵션이 간접 분기 비용을 크게 올렸습니다
    (retpoline 등) → 가상 호출이 예전보다 비싸졌습니다
  · -mindirect-branch=thunk 같은 옵션의 영향을 재 보세요

코드 배치도 성능입니다

// 드물게 실행되는 코드를 핫 경로에서 밀어내기
if (error) [[unlikely]] {
    log_and_recover();               // 컴파일러가 멀리 배치합니다 ★
}

// 함수 단위 힌트
__attribute__((hot))  void fast_path();
__attribute__((cold)) void error_handler();
//   → .text.hot · .text.unlikely 섹션으로 분리되어
//     핫 코드가 명령 캐시에 밀집합니다

// 인라인 통제
__attribute__((always_inline)) inline int small();
__attribute__((noinline))      void big();   // 코드 팽창 방지

// PGO 가 이것을 자동으로 해 줍니다 (🛠️ b04) ★
//   실제 실행 빈도로 분기 힌트·코드 배치·인라인을 결정

// BOLT — 링크 후 바이너리 재배치 (대형 서버에서 5~15% 향상)
llvm-bolt app -o app.bolt -data=perf.fdata -reorder-blocks=ext-tsp

// 측정
perf stat -e L1-icache-load-misses,iTLB-load-misses ./app
대규모 서버에서 실제로 쓰는 기법 — 코드가 수십 MB 인 서비스는 명령 캐시 미스가 주요 병목이 됩니다. PGO·BOLT 로 핫 코드를 모으는 것만으로 두 자릿수 % 가 나오기도 합니다.
정리 — 분기는 예측이 빗나갈 때만 비쌉니다. perf stat 의 branch-misses 가 5% 를 넘을 때만 손대세요. 최선의 해법은 브랜치리스 트릭이 아니라 데이터를 정렬하거나 타입별로 묶어 예측 가능하게 만드는 것입니다.
PERF 06

SIMD — 한 번에 여러 개

CPU 는 double 4~8개를 한 명령으로 계산할 수 있습니다. 먼저 컴파일러가 자동으로 하게 유도하고, 그래도 안 되면 직접 씁니다.

SIMD — 명령 하나로 여러 개를 동시에 ① 보통 연산 — 한 번에 하나 a0+b0 a1+b1 a2+b2 a3+b3 ... 명령 여덟 번 ② SIMD — 256비트 레지스터에 float 여덟 개 a0..a7 + b0..b7 — 명령 한 번 이론상 8배 · 실제로는 3~6배 직접 쓰기 전에 이 순서로 시도하세요 ① 자동 벡터화 -O3 로 컴파일하고 되는 형태로 루프를 다듬기 ② 라이브러리 Eigen · xsimd · Highway 이식성까지 챙겨 줍니다 ③ 인트린식 직접 CPU 마다 다른 코드가 필요 런타임 판별까지 감수할 때만
자동 벡터화를 막는 것은 대개 포인터가 겹칠 수 있다는 가능성입니다 — 그래서 __restrict 가 효과를 냅니다.

자동 벡터화가 먼저입니다

// 이런 루프는 컴파일러가 알아서 벡터화합니다
void add(double* __restrict out,
         const double* __restrict a,
         const double* __restrict b, std::size_t n) {
    for (std::size_t i = 0; i < n; ++i)
        out[i] = a[i] + b[i];
}
//   -O3 로 컴파일하면 addpd(SSE) · vaddpd(AVX) 명령이 나옵니다
//   → 한 번에 2~8개씩 처리 ★

// ★ __restrict 가 핵심입니다
//   없으면 out 과 a 가 겹칠 수 있다고 가정해 벡터화를 포기합니다 (탭 1 x04)
//   표준은 아니지만 gcc·clang·msvc 모두 지원합니다

// 확인
// g++ -O3 -fopt-info-vec-optimized add.cpp
//   add.cpp:5:24: optimized: loop vectorized using 32 byte vectors ★
// clang++ -O3 -Rpass=loop-vectorize add.cpp
순서를 지키세요 — ① 자동 벡터화가 되게 코드를 정리, ② 컴파일러 리포트로 확인, ③ 그래도 안 되면 인트린식. 바로 인트린식으로 가면 이식성과 유지보수를 잃고 컴파일러가 더 잘하는 경우도 많습니다.

벡터화를 막는 것들 ★

// ① 포인터가 겹칠 가능성 (aliasing)
void f(double* out, const double* in, int n);   // ❌
void f(double* __restrict out, const double* __restrict in, int n);  // ✅

// ② 루프 안의 함수 호출 (인라인이 안 되면)
for (int i = 0; i < n; ++i) out[i] = slow_call(a[i]);   // ❌

// ③ 데이터 의존성
for (int i = 1; i < n; ++i) v[i] += v[i-1];             // ❌ 순차 필수

// ④ 조건 분기 (마스크로 처리되기도 하지만 대개 손해)
for (...) if (cond(a[i])) out[i] = ...;                 // ⚠

// ⑤ float 연산 순서 — 결합법칙이 성립하지 않아 재배치 금지
double sum = 0;
for (...) sum += a[i];      // ❌ 기본적으로 벡터화 안 됨
#pragma omp simd reduction(+:sum)                        // ✅ 명시적 허용
for (...) sum += a[i];
//   또는 -ffast-math (정확도 포기 — 신중하게)

// ⑥ 정렬되지 않은 접근 · 불규칙한 stride
for (...) out[i] = a[idx[i]];                            // ❌ gather 필요

// ⑦ 반복 횟수를 모름 (런타임 결정) → 대개 처리됩니다 (꼬리 루프 생성)

이렇게도 쓸 수 있다 — 직접 쓰기 🟡 중급

인트린식

#include <immintrin.h>

// AVX2 — double 4개씩
void add_avx2(double* out, const double* a, const double* b, std::size_t n) {
    std::size_t i = 0;
    for (; i + 4 <= n; i += 4) {
        __m256d va = _mm256_loadu_pd(a + i);      // 4개 로드
        __m256d vb = _mm256_loadu_pd(b + i);
        __m256d vs = _mm256_add_pd(va, vb);       // 4개 동시 덧셈 ★
        _mm256_storeu_pd(out + i, vs);
    }
    for (; i < n; ++i) out[i] = a[i] + b[i];      // 꼬리 처리
}

// 수평 합 (reduction)
double sum_avx2(const double* a, std::size_t n) {
    __m256d acc = _mm256_setzero_pd();
    std::size_t i = 0;
    for (; i + 4 <= n; i += 4)
        acc = _mm256_add_pd(acc, _mm256_loadu_pd(a + i));
    alignas(32) double tmp[4];
    _mm256_store_pd(tmp, acc);
    double s = tmp[0] + tmp[1] + tmp[2] + tmp[3];
    for (; i < n; ++i) s += a[i];
    return s;
}
//   ⚠ 합산 순서가 달라져 결과가 미세하게 다를 수 있습니다

이식성 있는 방법 ★

// ① std::experimental::simd (C++26 표준화 예정)
#include <experimental/simd>
namespace stdx = std::experimental;

void add(double* out, const double* a, const double* b, std::size_t n) {
    using V = stdx::native_simd<double>;       // 플랫폼에 맞는 폭
    std::size_t i = 0;
    for (; i + V::size() <= n; i += V::size()) {
        V va(a + i, stdx::element_aligned);
        V vb(b + i, stdx::element_aligned);
        (va + vb).copy_to(out + i, stdx::element_aligned);
    }
    for (; i < n; ++i) out[i] = a[i] + b[i];
}
//   x86 · ARM 어디서나 같은 코드 ★

// ② 라이브러리 — 더 실용적입니다
//    xsimd · highway(google) · Eigen · std::simd 백포트
#include <hwy/highway.h>
//   highway 는 '런타임 CPU 감지 + 여러 버전 컴파일'까지 해 줍니다

// ③ OpenMP SIMD 지시문 — 가장 간단
#pragma omp simd
for (std::size_t i = 0; i < n; ++i) out[i] = a[i] * b[i] + c[i];
인트린식은 마지막 수단입니다 — x86 전용 코드가 되고, ARM(애플 실리콘·서버)에서 다시 써야 합니다. highway 나 std::simd 를 쓰면 한 번 써서 양쪽에서 돕니다.

실무에선 이렇게 — 배포와 CPU 감지 🔴 실무

-march=native 는 배포에 쓰면 안 됩니다 ★

# ❌ 내 CPU 전용 명령이 들어가 다른 기계에서 즉사합니다
g++ -O3 -march=native      # Illegal instruction (core dumped)

# ✅ 보수적인 기준선
g++ -O3 -march=x86-64-v2   # SSE4.2 · POPCNT (2009년 이후 CPU)
g++ -O3 -march=x86-64-v3   # AVX2 · FMA (2013년 이후) ★ 요즘 무난
g++ -O3 -march=x86-64-v4   # AVX-512 (서버 위주)

# ✅ 더 나은 방법 — 함수별 다중 버전 + 런타임 선택
__attribute__((target_clones("default,avx2,avx512f")))
void process(double* out, const double* in, std::size_t n) { ... }
//   컴파일러가 여러 버전을 만들고 CPU 에 맞는 것을 자동 선택 ★ (GCC)

# 수동 디스패치
__builtin_cpu_supports("avx2") ? process_avx2(...) : process_sse(...);
// 함수 단위 타깃 지정 (파일 전체 옵션 대신)
__attribute__((target("avx2,fma")))
void process_avx2(...) { ... }

// 실행 시 확인
#include <cpuid.h>
bool has_avx2() { return __builtin_cpu_supports("avx2"); }

기대 성능과 한계

이론적 배수
  SSE  (128비트) : double 2개 · float 4개
  AVX2 (256비트) : double 4개 · float 8개
  AVX-512        : double 8개 · float 16개
  ARM NEON       : double 2개 · float 4개
  SVE            : 가변 폭

★ 실제로는 이론치가 안 나옵니다
  · 메모리 대역폭이 먼저 한계에 걸립니다 (e02)
      단순 add 는 계산보다 로드·스토어가 많아
      2~3배에서 평평해집니다
  · AVX-512 는 클럭을 낮추는 CPU 가 있어 오히려 느려질 수 있습니다
  · 정렬되지 않은 접근·꼬리 처리 오버헤드

효과가 큰 경우
  · 원소당 연산이 많은 계산 (다항식·필터·변환) ★
  · 데이터가 캐시에 들어가는 크기
  · float 로 충분한 경우 (double 대비 2배 폭)

측정
  perf stat -e fp_arith_inst_retired.256b_packed_double ./app
먼저 확인할 것 — 메모리 대역폭에 이미 걸려 있다면 SIMD 를 써도 소용없습니다(e02). perf stat 의 bytes/s 가 하드웨어 한계에 근접하면 레이아웃·알고리즘을 먼저 손봐야 합니다.

한 단계 더 — 라이브러리를 쓰는 것이 정답일 때 🧬 전문가

이미 극한까지 최적화된 것들

직접 SIMD 를 짜기 전에 확인하세요

  선형대수    BLAS(OpenBLAS·MKL·Accelerate) · Eigen
              → 행렬 곱은 직접 짜서 이기기 거의 불가능합니다 ★
  FFT         FFTW · pocketfft · MKL
  이미지      libvips · OpenCV(이미 SIMD) · stb
  압축        zstd · lz4 (SIMD 최적화됨)
  문자열·파싱 simdjson · fast_float · ryu
              → JSON 파싱이 GB/s 수준입니다
  해시        xxHash · wyhash
  정렬        pdqsort · vqsort(highway) · ips4o

★ "내가 SIMD 로 짜면 3배 빨라지겠다" 싶은 것은
  대개 누군가 이미 10배 빠르게 만들어 두었습니다.
  검색이 코딩보다 먼저입니다.

직접 써야 한다면

// ① 정확성 검증을 반드시 — 스칼라 버전과 대조 ★
TEST(simd, matches_scalar) {
    std::vector<double> a(1000), b(1000), r1(1000), r2(1000);
    fill_random(a); fill_random(b);
    add_scalar(r1.data(), a.data(), b.data(), 1000);
    add_avx2  (r2.data(), a.data(), b.data(), 1000);
    for (std::size_t i = 0; i < 1000; ++i)
        EXPECT_NEAR(r1[i], r2[i], 1e-12);
}
//   ★ 꼬리 처리(n 이 4의 배수가 아닐 때)를 반드시 테스트하세요
//     n = 0, 1, 3, 4, 5, 7, 8 … 로 파라미터화

// ② 정렬 요구 확인 — loadu(비정렬) vs load(정렬)
//   최신 CPU 는 차이가 작지만, 정렬된 메모리가 여전히 유리합니다
auto* p = static_cast<double*>(std::aligned_alloc(32, n * sizeof(double)));

// ③ Sanitizer 와 함께 돌리기 — 범위 초과가 나기 쉽습니다(🛠️ b10)

// ④ 어셈블리 확인 (e09)
g++ -O3 -mavx2 -S -masm=intel -o - simd.cpp | grep vaddpd
SIMD 코드의 버그는 찾기 어렵습니다 — 꼬리 처리 실수로 마지막 몇 개만 틀리는 경우가 전형적입니다. 스칼라 구현을 남겨 두고 항상 대조하는 테스트가 필수입니다.
정리 — SIMD 는 컴파일러가 자동으로 하게 만드는 것이 먼저입니다 — __restrict 와 단순한 루프 구조. -fopt-info-vec 로 실제로 벡터화됐는지 확인하고, 직접 쓸 때는 highway·std::simd 로 이식성을 지키세요. 그리고 이미 있는 라이브러리를 먼저 찾아보세요.
PERF 07

병렬 — 여러 코어를 실제로

파이썬 혼자서는 불가능한 지점입니다. 다만 코어를 8개 써도 8배가 되지는 않습니다 — 왜 그런지가 이 섹션의 핵심입니다.

가장 쉬운 방법부터

// ① 표준 병렬 알고리즘 (탭 2 m06) — 스레드를 직접 안 만듭니다 ★
#include <execution>
std::sort(std::execution::par, v.begin(), v.end());
std::transform(std::execution::par_unseq,
               a.begin(), a.end(), out.begin(), f);
double s = std::reduce(std::execution::par, v.begin(), v.end());
//   GCC 는 TBB 링크 필요: target_link_libraries(app PRIVATE TBB::tbb)

// ② OpenMP — 지시문 한 줄
#pragma omp parallel for schedule(static) reduction(+:total)
for (std::ptrdiff_t i = 0; i < n; ++i)
    total += heavy(data[i]);

// 작업량이 불균등하면
#pragma omp parallel for schedule(dynamic, 64)

// ③ 직접 청크 나누기 — 의존성이 없을 때 가장 단순합니다
const unsigned T = std::thread::hardware_concurrency();
std::vector<std::jthread> ths;
for (unsigned k = 0; k < T; ++k)
    ths.emplace_back([&, k] {
        const std::size_t lo = n * k / T, hi = n * (k+1) / T;
        for (std::size_t i = lo; i < hi; ++i) out[i] = f(in[i]);
    });
//   jthread 는 소멸자에서 join 합니다 (C++20)

왜 코어 수만큼 안 빨라지나 ★

8스레드인데 3배밖에 안 나오는 흔한 이유

  ① 메모리 대역폭 한계 ★ 가장 흔함
     단순 배열 연산은 CPU 가 아니라 '메모리에서 읽는 속도'가 한계입니다.
     코어를 늘려도 대역폭은 그대로라 4~5코어에서 평평해집니다.
     → 원소당 계산량을 늘리거나 캐시에 맞게 쪼개세요 (e02)

  ② false sharing — 다음 섹션(e08)

  ③ 작업 분배 불균등
     원소마다 계산량이 다르면 한 스레드만 오래 돕니다.
     → schedule(dynamic) · 작업 큐

  ④ 하이퍼스레딩
     논리 16코어여도 물리는 8개인 경우가 많습니다.

  ⑤ 동기화·할당 경합
     공유 뮤텍스 · malloc 아레나 락 (e03)

  ⑥ 암달의 법칙
     10%가 순차라면 아무리 코어를 늘려도 최대 10배입니다.

측정
  htop 으로 코어 사용률 확인 (전부 100% 인지)
  perf stat -e cache-misses,LLC-load-misses ./app

이렇게도 쓸 수 있다 — 잘 나누는 법 🟡 중급

공유를 없애는 것이 최선

// ❌ 공유 결과 + 락 — 스레드를 늘려도 안 빨라집니다
std::mutex m; std::vector<double> results;
parallel_for(n, [&](std::size_t i) {
    auto r = compute(i);
    std::lock_guard lk(m);
    results.push_back(r);        // ★ 전부 여기서 줄을 섭니다
});

// ✅ ① 자리를 미리 잡고 각자 자기 칸에
std::vector<double> results(n);
parallel_for(n, [&](std::size_t i) { results[i] = compute(i); });

// ✅ ② 스레드별로 모았다가 마지막에 합치기 ★
std::vector<std::vector<double>> per_thread(T);
parallel_chunks(T, [&](unsigned t, std::size_t lo, std::size_t hi) {
    auto& local = per_thread[t];
    local.reserve(hi - lo);
    for (std::size_t i = lo; i < hi; ++i)
        if (keep(i)) local.push_back(compute(i));
});
for (auto& v : per_thread)
    results.insert(results.end(), v.begin(), v.end());

// ✅ ③ 부분 합을 지역 변수에 누적 (원자 연산 대신)
double total = 0;
#pragma omp parallel
{
    double local = 0;                       // ★ 스레드 지역
    #pragma omp for nowait
    for (std::ptrdiff_t i = 0; i < n; ++i) local += f(data[i]);
    #pragma omp atomic
    total += local;                          // 마지막에 한 번만
}

청크 크기 정하기

// 너무 작으면 오버헤드, 너무 크면 불균형
//   경험칙: 청크 하나가 최소 수십 μs 는 되게

const std::size_t min_chunk = 4096;          // 원소 수 기준
if (n < min_chunk * 2) {
    // 작으면 그냥 순차로 ★ 스레드 생성 비용이 더 큽니다
    for (std::size_t i = 0; i < n; ++i) out[i] = f(in[i]);
    return;
}

// 캐시 라인 경계에 맞춰 나누기 (false sharing 방지 — e08)
const std::size_t grain = ((n / T) + 7) & ~std::size_t{7};   // 8의 배수

// 작업 훔치기(work stealing) — 불균등할 때
//   TBB · OpenMP taskloop 이 알아서 해 줍니다
#pragma omp parallel
#pragma omp single
#pragma omp taskloop grainsize(1024)
for (std::size_t i = 0; i < n; ++i) heavy(i);

// 중첩 병렬 주의 — 스레드가 폭발합니다
omp_set_max_active_levels(1);
//   라이브러리 안에서 병렬화했는데 호출자도 병렬이면 T² 개가 됩니다 ★
작은 데이터에는 병렬화가 손해입니다 — 스레드 생성·동기화에 수십 μs가 듭니다. 전체 작업이 그보다 짧으면 순차가 빠릅니다. 임계값을 두고 분기하세요.

실무에선 이렇게 — 모듈에서의 병렬화 🔴 실무

스레드 수를 사용자가 정하게 ★

// ⚠ 라이브러리가 마음대로 모든 코어를 쓰면
//   이미 멀티프로세싱으로 도는 환경에서 경합이 일어나 오히려 느려집니다

class Engine {
    int threads_ = 1;                        // ★ 기본값은 1
public:
    void set_threads(int t) {
        threads_ = t > 0 ? t : static_cast<int>(
            std::thread::hardware_concurrency());
    }
};

// 환경변수 관례를 따르기
int default_threads() {
    if (const char* s = std::getenv("MYLIB_NUM_THREADS")) return std::atoi(s);
    if (const char* s = std::getenv("OMP_NUM_THREADS"))   return std::atoi(s);
    return 1;
}

// 파이썬·Node 에 노출 (🐍 y07 · 🟨 j06)
m.def("set_threads", &set_threads, "n"_a);
m.def("run", &run, py::call_guard<py::gil_scoped_release>());
//   ★ GIL 을 놓아야 파이썬 스레드도 병렬로 돕니다
numpy·scipy 도 같은 문제를 겪었습니다 — BLAS 가 모든 코어를 잡아 multiprocessing 과 충돌하는 일이 흔했습니다. 그래서 OMP_NUM_THREADS=1 을 설정하는 관행이 생겼습니다. 내 라이브러리도 같은 배려를 해야 합니다.

프로세스 vs 스레드

스레드(C++)프로세스(파이썬)
메모리 공유가능 ★복사·직렬화
큰 배열 전달공짜pickle 비용
시작 비용수십 μs수십 ms
크래시 격리없음있음
GIL해제하면 무관 ★애초에 별개
# ★ C++ 확장 + GIL 해제 = 파이썬 스레드로 진짜 병렬 (🐍 y07)
from concurrent.futures import ThreadPoolExecutor
with ThreadPoolExecutor(8) as ex:
    results = list(ex.map(fastmod.heavy, chunks))
#   · multiprocessing 과 달리 큰 배열을 복사하지 않습니다
#   · 프로세스 시작 비용도 없습니다
#   → 확장 모듈을 만드는 큰 이유 중 하나입니다

# 이중 병렬화 주의 ★
#   프로세스 8개 × 내부 스레드 8개 = 64 → 코어 경합
fastmod.set_threads(1)      # 프로세스 풀을 쓴다면 내부는 1로

한 단계 더 — 확장성 분석 🧬 전문가

스케일링 곡선을 그려 보기

// 스레드 수를 바꿔 가며 측정하면 병목이 드러납니다 ★
for (int t = 1; t <= max_threads; t *= 2) {
    engine.set_threads(t);
    auto ms = bench_ms([&]{ engine.run(data); });
    std::printf("%2d 스레드  %8.2fms  %5.2f배  효율 %.0f%%\n",
                t, ms, base/ms, 100.0 * (base/ms) / t);
}
전형적인 결과와 해석

  스레드  시간     배수   효율
   1     100ms   1.00×  100%
   2      52ms   1.92×   96%   ✅ 좋음
   4      28ms   3.57×   89%   ✅
   8      21ms   4.76×   60%   ⚠ 여기서 꺾임
  16      20ms   5.00×   31%   ❌ 늘려도 소용없음

★ 8에서 꺾이는 이유를 찾아야 합니다
  · 물리 코어가 8개인가? (하이퍼스레딩)
  · 메모리 대역폭 한계인가? → bytes/s 를 계산해 하드웨어 사양과 비교
  · false sharing 인가? → e08
  · 순차 구간이 있나? → 암달의 법칙으로 상한 계산

암달의 법칙
  최대 배수 = 1 / (순차비율 + 병렬비율/코어수)
  순차 10% → 코어 무한대라도 최대 10배

NUMA 와 스레드 배치

# 서버는 CPU 소켓마다 별도 메모리를 가집니다 (NUMA)
#   다른 소켓의 메모리에 접근하면 2배 이상 느립니다 ★
numactl --hardware
lscpu | grep NUMA

# 메모리와 스레드를 같은 노드에
numactl --cpunodebind=0 --membind=0 ./app

# first-touch 정책 — '처음 만진 스레드'의 노드에 할당됩니다
#   → 초기화도 병렬로 해야 합니다 ★
#pragma omp parallel for
for (std::size_t i = 0; i < n; ++i) data[i] = 0;   // 각 스레드가 자기 몫을 터치

# 스레드 고정 (마이그레이션 방지)
OMP_PROC_BIND=close OMP_PLACES=cores ./app

# C++ 에서
#include <pthread.h>
cpu_set_t set; CPU_ZERO(&set); CPU_SET(core_id, &set);
pthread_setaffinity_np(pthread_self(), sizeof(set), &set);

# 측정
perf stat -e node-loads,node-load-misses ./app
NUMA 는 서버에서만 문제입니다 — 데스크탑·노트북은 단일 노드라 신경 쓸 필요가 없습니다. 다만 클라우드 대형 인스턴스는 대개 여러 노드라, 거기서 확장이 안 되는 이유가 되곤 합니다.
정리 — 병렬화는 표준 병렬 알고리즘·OpenMP 부터 시작하세요. 핵심은 공유하지 않는 것 — 각자 자기 칸에 쓰고 마지막에 합칩니다. 8스레드에 3배가 나왔다면 실패가 아니라 메모리 대역폭이라는 물리적 상한일 수 있습니다 — 그것을 근거로 설명할 수 있어야 합니다.
코어를 실제로 다 쓰는 세 가지 길 ① 실행 정책 std::sort(std::execution::par, v.begin(), v.end()) 인자 하나 추가로 끝 표준 알고리즘에만 붙습니다 ② 스레드 직접 std::jthread 로 구간을 나눠 각자 자기 몫만 계산 통제가 자유롭습니다 나누기·합치기를 직접 씁니다 ③ 스레드 풀 작업을 큐에 넣고 고정된 스레드가 꺼내 처리 작업이 많고 짧을 때 유리 표준에 없어 라이브러리를 씁니다 공유 상태에 쓰는 순간 값이 사라집니다 스레드마다 부분 결과를 따로 모았다가 마지막에 한 번만 합치세요 루프 안에서 락을 잡으면 코어를 늘려도 느려집니다 C++ 는 데이터 경쟁을 컴파일러가 막아 주지 않습니다 병렬화보다 먼저 볼 것 e02(캐시 배치) · e03(할당 줄이기)를 먼저 손보세요 단순 합산처럼 메모리 대역폭이 한계인 작업은 코어를 늘려도 그대로입니다 — 측정으로 확인하세요
스레드를 늘리는 것보다 합치는 지점을 줄이는 것이 대개 더 큰 차이를 만듭니다.
코어를 실제로 다 쓰는 세 가지 길 ① 실행 정책 std::sort(std::execution::par, v.begin(), v.end()) 인자 하나 추가로 끝 표준 알고리즘에만 붙습니다 ② 스레드 직접 std::jthread 로 구간을 나눠 각자 자기 몫만 계산 통제가 자유롭습니다 나누기·합치기를 직접 씁니다 ③ 스레드 풀 작업을 큐에 넣고 고정된 스레드가 꺼내 처리 작업이 많고 짧을 때 유리 표준에 없어 라이브러리를 씁니다 공유 상태에 쓰는 순간 값이 사라집니다 스레드마다 부분 결과를 따로 모았다가 마지막에 한 번만 합치세요 루프 안에서 락을 잡으면 코어를 늘려도 느려집니다 C++ 는 데이터 경쟁을 컴파일러가 막아 주지 않습니다 병렬화보다 먼저 볼 것 e02(캐시 배치) · e03(할당 줄이기)를 먼저 손보세요 단순 합산처럼 메모리 대역폭이 한계인 작업은 코어를 늘려도 그대로입니다 — 측정으로 확인하세요
스레드를 늘리는 것보다 합치는 지점을 줄이는 것이 대개 더 큰 차이를 만듭니다.
PERF 08

false sharing 과 원자 연산 비용

스레드를 늘렸는데 오히려 느려지는 대표적 원인입니다. 서로 다른 변수를 쓰는데도 같은 캐시 라인에 있으면 충돌합니다.

무슨 일이 일어나나

// ❌ 스레드마다 다른 인덱스에 쓰는데도 느립니다
struct Counters { std::int64_t c[8]; };      // 64바이트 = 캐시 라인 하나 ★
Counters counters;

// 스레드 k 가 counters.c[k] 만 건드립니다 — 겹치지 않는데도
for (...) counters.c[k]++;
//   ★ 8개가 전부 같은 캐시 라인에 있어
//     한 스레드가 쓸 때마다 다른 코어의 캐시가 무효화됩니다
//     → 매번 캐시 라인을 주고받으며 수십~수백 사이클 낭비
//     8스레드가 1스레드보다 느려지기도 합니다

// ✅ 방법 ① 캐시 라인만큼 떼어 놓기
struct alignas(64) PaddedCounter {
    std::int64_t v = 0;
};
std::vector<PaddedCounter> counters(T);      // 각자 다른 라인 ★

// C++17 표준 상수
#include <new>
alignas(std::hardware_destructive_interference_size) std::int64_t v;

// ✅ 방법 ② 지역 변수에 누적하고 마지막에 한 번만 (더 좋습니다) ★
std::int64_t local = 0;
for (...) local++;                           // 레지스터에서 돕니다
counters[k].v = local;                       // 끝나고 한 번

확인하는 법

# 증상: 스레드를 늘렸는데 느려지거나 전혀 안 빨라짐

perf stat -e cache-misses,LLC-store-misses ./app
#   스레드를 늘릴수록 store-miss 가 급증하면 의심 ★

perf c2c record ./app && perf c2c report
#   ★ false sharing 전용 도구 — 어느 캐시 라인이 경합하는지 정확히

# 간단한 실험 — 패딩 유무로 비교
struct NoPad  { std::int64_t v; };
struct Padded { alignas(64) std::int64_t v; };
//   같은 코드로 두 버전을 재 보면 차이가 바로 보입니다
//   (대개 2~10배)
// 캐시 라인 크기 확인
#include <new>
std::hardware_destructive_interference_size;   // 보통 64 (x86) · 128 (일부 ARM)
std::hardware_constructive_interference_size;

// 런타임 확인 (리눅스)
// cat /sys/devices/system/cpu/cpu0/cache/index0/coherency_line_size
구조체 배열에서 특히 자주 납니다 — 스레드별 통계·카운터·결과를 vector 에 담고 각자 자기 인덱스에 쓰는 패턴이 정확히 이 문제를 만듭니다.

이렇게도 쓸 수 있다 — 원자 연산의 실제 비용 🟡 중급

얼마나 비싼가

대략적인 비용 (경합 정도에 따라 크게 달라집니다)

  일반 변수 증가              ~1 사이클
  atomic (relaxed, 무경합)     ~5~20 사이클
  atomic (seq_cst, 무경합)     ~20~40 사이클
  atomic (경합 있음)           ~100~500 사이클 ★
  뮤텍스 (무경합)              ~20~50 사이클
  뮤텍스 (경합 → 커널 대기)    ~수 μs

★ 핵심: 원자 연산 자체보다 '캐시 라인을 주고받는 것'이 비쌉니다
  여러 코어가 같은 원자 변수를 두드리면 그 라인이 코어 사이를 오갑니다

그래서
  · 루프 안에서 공유 카운터를 증가시키면 병렬화가 무의미해집니다
  · 지역 변수에 누적하고 마지막에 한 번 합치세요 ★
  · shared_ptr 복사도 원자 연산입니다 (탭 2 m02)
// ❌ 매 원소마다 원자 증가
std::atomic<std::int64_t> total{0};
parallel_for(n, [&](std::size_t i) { total += data[i]; });

// ✅ 지역 누적 + 최종 합산
parallel_chunks(T, [&](unsigned t, auto lo, auto hi) {
    std::int64_t local = 0;
    for (auto i = lo; i < hi; ++i) local += data[i];
    total.fetch_add(local, std::memory_order_relaxed);   // 스레드당 1회 ★
});

memory_order 고르기 (탭 2 m12)

// 카운터처럼 '값만 정확하면 될 때' — relaxed
counter.fetch_add(1, std::memory_order_relaxed);     // 가장 저렴 ★

// 생산자-소비자 — acquire/release
data = compute();                                     // 먼저 쓰고
ready.store(true, std::memory_order_release);         // 공개
if (ready.load(std::memory_order_acquire))            // 관찰
    use(data);                                        // 안전

// 기본값(seq_cst)은 가장 안전하지만 가장 비쌉니다
flag = true;                                          // seq_cst 암묵 적용

// ★ 확신이 없으면 seq_cst 를 쓰세요.
//   대부분의 프로그램에서 차이는 무시할 만하고,
//   잘못된 relaxed 는 재현 불가능한 버그를 만듭니다.

// x86 은 강한 메모리 모델이라 acquire/release 가 사실상 공짜입니다
//   ARM 은 약한 모델이라 실제로 장벽 명령이 생깁니다 ★
//   → 애플 실리콘·ARM 서버에서 성능 차이가 더 큽니다
원자 연산을 줄이는 것이 memory_order 튜닝보다 효과적입니다 — relaxed 로 바꿔 몇 %를 얻기보다, 호출 횟수를 1/1000 로 줄이는 설계가 답입니다.

실무에선 이렇게 — 락 없는 구조로 가기 전에 🔴 실무

순서를 지키세요

  • ① 공유하지 않기 — 스레드별 데이터. 대부분 여기서 끝납니다 ★
  • ② 읽기 위주면 불변 데이터 — 만들고 안 바꾸면 동기화가 필요 없습니다
  • ③ shared_mutex — 읽기는 동시에, 쓰기만 배타적
  • ④ 세밀한 락 — 락 하나를 여러 개로 쪼개기(샤딩)
  • ⑤ 원자 연산 — 단순한 카운터·플래그
  • ⑥ 락 프리 자료구조 — 정말 마지막. 직접 구현하지 말고 검증된 라이브러리를 쓰세요
// 샤딩 예 — 락 하나를 16개로
struct ShardedMap {
    static constexpr int N = 16;
    struct alignas(64) Shard {            // ★ 패딩 필수
        std::mutex m;
        std::unordered_map<Key, Val> data;
    };
    std::array<Shard, N> shards;

    Val get(const Key& k) {
        auto& s = shards[std::hash<Key>{}(k) % N];
        std::lock_guard lk(s.m);
        return s.data.at(k);
    }
};
//   경합이 1/16 로 줄어듭니다 — 대개 이것으로 충분합니다 ★

검증된 라이브러리를 쓰세요

락 프리 자료구조를 직접 만들면
  · ABA 문제 · 메모리 회수(reclamation) · 메모리 순서
  세 가지에서 반드시 버그가 납니다.
  게다가 TSan 으로도 잡히지 않는 경우가 있습니다.

검증된 것들
  · moodycamel::ConcurrentQueue   — MPMC 큐 (헤더 하나)
  · boost::lockfree::queue/stack
  · folly::MPMCQueue · AtomicHashMap
  · Intel TBB 의 concurrent_* 컨테이너
  · libcds (다양한 락프리 컨테이너)

★ 그리고 대부분의 경우
  '샤딩된 뮤텍스'가 락 프리만큼 빠르고 훨씬 안전합니다.

검증
  · TSan 으로 데이터 경쟁 확인 (🛠️ b10)
  · 스트레스 테스트 — 스레드 수를 바꿔 가며 장시간
  · relacy · CDSChecker 같은 모델 체커 (연구 수준)
락 프리는 "빠르다"가 아니라 "블로킹되지 않는다"입니다 — 실시간성이 필요한 경우(오디오 콜백 등)에 의미가 있고, 처리량만 놓고 보면 뮤텍스가 더 빠른 경우도 많습니다. 목적을 분명히 하고 선택하세요.

한 단계 더 — 캐시 일관성 프로토콜 🧬 전문가

MESI — 왜 그렇게 비싼가

각 캐시 라인은 상태를 가집니다 (MESI)

  M (Modified)  : 내가 수정했고 나만 가짐
  E (Exclusive) : 나만 가짐 (수정 안 함)
  S (Shared)    : 여럿이 가짐 (읽기 전용)
  I (Invalid)   : 무효

쓰기가 일어나면
  ① 다른 코어에 "무효화(invalidate)" 메시지를 보냅니다
  ② 그 코어들이 응답할 때까지 기다립니다
  ③ 내 캐시를 M 으로 바꾸고 씁니다

★ 두 코어가 번갈아 같은 라인에 쓰면
  라인이 코어 사이를 계속 오갑니다 (cache line ping-pong)
  → 각 쓰기가 100~300 사이클로 늘어납니다

false sharing 이 치명적인 이유가 이것입니다.
논리적으로 무관한 변수인데 물리적으로 같은 라인이면
하드웨어는 구분하지 못합니다.

측정
  perf c2c report      # HITM (Hit Modified) 이벤트를 보여 줍니다 ★
  perf stat -e mem_load_l3_hit_retired.xsnp_hitm ./app

실전 대응 정리

// ① 쓰기 공유를 없애기 (최선)
//    스레드별 지역 데이터 → 마지막에 합치기

// ② 어쩔 수 없으면 패딩
struct alignas(64) ThreadState {
    std::int64_t count = 0;
    double sum = 0;
    // 나머지는 패딩으로 채워짐
};
static_assert(sizeof(ThreadState) == 64);

// ③ 읽기 전용으로 만들기 — S 상태는 여럿이 가져도 무료 ★
const std::vector<double> table = build();     // 만들고 안 바꿈
//   여러 스레드가 동시에 읽어도 아무 비용이 없습니다

// ④ 인접한 인덱스를 다른 스레드에 주지 않기
//    ❌ 스레드 k 가 i % T == k 인 원소를 처리 (인터리브)
//    ✅ 스레드 k 가 [n*k/T, n*(k+1)/T) 를 처리 (블록) ★

// ⑤ 원자 변수를 배열로 둘 때
std::vector<std::atomic<int>> counters(T);      // ❌ 붙어 있음
struct alignas(64) AtomicCounter { std::atomic<int> v{0}; };
std::vector<AtomicCounter> counters(T);          // ✅
읽기 전용 데이터는 공유해도 공짜입니다 — 여러 코어가 S 상태로 각자 캐시에 갖고 있으면 됩니다. “쓰기만 분리하면 된다”는 것이 이 섹션의 결론입니다.
정리 — 스레드를 늘렸는데 느려지면 false sharing 을 의심하세요. 서로 다른 변수라도 같은 64바이트 라인에 있으면 충돌합니다. 해법은 지역 변수에 누적하고 마지막에 한 번만 합치기, 그래도 공유해야 하면 alignas(64) 패딩입니다.
서로 다른 변수인데 왜 느려질까 — 거짓 공유 같은 캐시 라인(64바이트) 안에 있을 때 counter[0] — 코어 A counter[1] — 코어 B … 같은 64바이트 덩어리 A 가 쓸 때마다 B 의 캐시가 무효화됩니다 — 서로 다른 변수인데도 계속 다툽니다 여백을 넣어 다른 덩어리로 떨어뜨리면 각자 자기 캐시에서 처리합니다 — 코어 수만큼 그대로 빨라집니다 alignas(64) 로 정렬하거나, 아예 스레드마다 지역 변수로 세고 끝에 합치세요 원자 연산도 공짜가 아닙니다 — 같은 변수를 여러 코어가 다투면 확장이 멈춥니다 스레드를 늘렸는데 안 빨라지면 이것부터 의심하세요 — 코드만 봐서는 절대 안 보입니다 가장 좋은 해결은 애초에 공유하지 않는 것입니다 — 각자 세고 마지막에 한 번 합치세요
"코어를 늘렸는데 그대로다" 의 흔한 정체가 거짓 공유입니다.
PERF 09

컴파일러가 만든 코드 읽기

“최적화가 됐겠지”를 확인으로 바꾸는 방법입니다. 어셈블리를 다 읽을 필요는 없고, 몇 가지 신호만 볼 줄 알면 됩니다.

보는 방법

# ① godbolt.org — 가장 편합니다 ★
#   코드를 붙여넣고 컴파일러·플래그를 고르면 어셈블리가 나란히 나옵니다
#   소스 줄과 어셈블리가 색으로 연결됩니다

# ② 로컬에서
g++ -O2 -S -masm=intel -o - main.cpp | c++filt | less
clang++ -O2 -S -o - main.cpp

# ③ 이미 빌드된 것에서
objdump -dC --no-show-raw-insn build/app | less
objdump -dC build/app | grep -A40 "<hot_function>:"

# ④ 최적화 리포트 (어셈블리를 안 읽고도) ★
g++ -O3 -fopt-info-vec-optimized -fopt-info-vec-missed main.cpp
clang++ -O3 -Rpass=loop-vectorize -Rpass-missed=loop-vectorize \
        -Rpass-analysis=loop-vectorize main.cpp
#   "loop not vectorized: cannot identify array bounds" 처럼
#   왜 안 됐는지까지 알려 줍니다

무엇을 보나 — 신호 목록 ★

찾을 것의미
vaddpd · vmulps
xmm·ymm·zmm
벡터화됨 ★ (e06)
call 이 루프 안에인라인 실패 — 벡터화도 막힘
cmov·setcc분기가 제거됨 (e05)
jmp·je 가 많음분기가 남아 있음
memcpy·memmove복사 발생 (e04)
operator new할당 발생 (e03)
루프가 여러 번 펼쳐짐언롤링됨
함수가 통째로 사라짐인라인 또는 UB 로 삭제 ⚠
// 벤치마크에서 코드가 사라지지 않게 (e01)
template<class T> void keep(T const& v) {
    asm volatile("" : : "r,m"(v) : "memory");
}

이렇게도 쓸 수 있다 — 확인해 볼 만한 것들 🟡 중급

추상화가 정말 공짜인지

// C++ 의 "제로 오버헤드 추상화"를 직접 확인해 보세요

// ① 범위 for vs 인덱스 루프 → 동일한 어셈블리 ★
for (int x : v) sum += x;
for (std::size_t i = 0; i < v.size(); ++i) sum += v[i];

// ② 람다 + 템플릿 → 완전히 인라인 (탭 2 m07)
template<class F> void each(std::span<int> v, F f) { for (int& x : v) f(x); }
each(v, [](int& x){ x *= 2; });
//   → 손으로 쓴 루프와 동일한 코드

// ③ std::function → 간접 호출이 남습니다 (인라인 불가)
void each(std::span<int> v, std::function<void(int&)> f);
//   → call 명령이 루프 안에 보입니다

// ④ unique_ptr → 원시 포인터와 동일
// ⑤ optional<int> → 태그 검사만 추가
// ⑥ span → 포인터 + 길이, 오버헤드 없음

// ★ godbolt 에서 두 버전을 나란히 두고 비교하면
//   "추상화 비용"에 대한 감이 정확해집니다

최적화가 안 된 이유 찾기

# 벡터화 실패 이유
clang++ -O3 -Rpass-missed=loop-vectorize -Rpass-analysis=loop-vectorize f.cpp
#   remark: loop not vectorized: cannot prove pointers do not alias ★
#   → __restrict 를 붙이세요 (e06)

#   remark: loop not vectorized: value that could not be identified as
#           reduction is used outside the loop
#   → 누적 변수 사용 방식을 바꾸세요

# 인라인 실패 이유
clang++ -O2 -Rpass-missed=inline f.cpp
#   remark: 'foo' not inlined into 'bar' because too costly
#   → 함수가 크거나 재귀. always_inline 이나 구조 변경

# 어느 함수가 큰지
nm -C --size-sort -S build/app | tail -20

# 최적화 통계 (clang)
clang++ -O2 -mllvm -stats f.cpp 2>&1 | grep -i vector
리포트가 어셈블리보다 실용적입니다 — -Rpass-missed 는 “왜 안 됐는지”를 문장으로 알려 줍니다. 어셈블리는 그 결과를 확인할 때 보면 됩니다.

실무에선 이렇게 — 어셈블리를 읽는 최소 지식 🔴 실무

x86-64 최소 문법 (Intel 문법)

mov  dst, src        dst = src
add  dst, src        dst += src
lea  dst, [a+b*4]    주소 계산 (곱셈·덧셈에도 씁니다)
cmp  a, b            비교 (플래그 설정)
je / jne / jl        조건 점프
call / ret           함수 호출·반환
push / pop           스택

레지스터
  rdi rsi rdx rcx r8 r9   정수 인자 1~6번 (System V)
  xmm0~xmm7               실수 인자
  rax                     반환값
  rsp rbp                 스택 포인터·프레임

메모리 접근
  [rdi]           rdi 가 가리키는 곳
  [rdi + 8]       오프셋
  [rdi + rax*8]   배열 인덱싱 ★

SIMD (e06)
  xmm (128비트) · ymm (256) · zmm (512)
  vaddpd ymm0, ymm1, ymm2    → double 4개 동시 덧셈 ★
  vmovupd                     → 비정렬 로드/스토어

읽는 요령

① 전부 읽지 마세요
   루프 본문만 봅니다. 대개 점프로 돌아오는 구간을 찾으면 됩니다.

② 명령 개수를 세어 보세요
   원소 하나당 몇 개의 명령이 나오는지.
   단순 덧셈인데 20개가 나오면 뭔가 잘못된 것입니다.

③ 특정 명령을 grep 하세요
   objdump -dC app | grep -c vaddpd       # 벡터화됐나
   objdump -dC app | grep -c "call.*new"  # 할당하나 ★

④ 두 버전을 비교하세요
   최적화 전후를 나란히 두면 차이가 바로 보입니다.
   godbolt 의 "diff" 기능이 유용합니다.

⑤ 소스 매핑을 켜세요
   g++ -O2 -g -S  → 어셈블리에 .loc 지시자가 들어갑니다
   godbolt 는 색으로 연결해 줍니다

★ 목표는 '어셈블리를 쓰는 것'이 아니라
  '내 가정이 맞는지 확인하는 것' 입니다.
어셈블리로 최적화하려 하지 마세요 — 현대 컴파일러는 사람보다 잘합니다. 손으로 어셈블리를 쓰는 것이 이득인 경우는 인트린식으로도 안 되는 특수 명령이 필요할 때뿐입니다(e06).

한 단계 더 — CPU 파이프라인 분석 🧬 전문가

명령 수만으로는 부족합니다

# llvm-mca — 어셈블리를 CPU 모델로 시뮬레이션 ★
llvm-mca -mcpu=skylake -timeline < loop.s
#   · 처리량(throughput) · 지연(latency)
#   · 어느 포트가 병목인지
#   · 명령 간 의존성 사슬

# 출력 예
#   Iterations:        100
#   Total Cycles:      315
#   IPC:               2.54
#   Block RThroughput: 2.0        ★ 반복당 최소 사이클
#
#   Resource pressure by instruction:
#   [0] [1] [2] ...
#   0.5 0.5  -   vaddpd
#   ...

# godbolt 에도 내장돼 있습니다 (Analysis 탭)

# uiCA · OSACA 도 비슷한 도구입니다
알아 둘 개념

  지연(latency)   : 결과가 나오기까지의 사이클
  처리량(throughput): 연속으로 넣을 때 몇 사이클마다 하나
  포트            : 실행 유닛. 같은 포트를 쓰는 명령은 경쟁합니다
  의존성 사슬     : 앞 결과를 기다려야 하는 연쇄 ★

★ 곱셈 지연이 4사이클이어도 처리량이 0.5면
  독립적인 곱셈 2개를 매 사이클 넣을 수 있습니다.
  → 의존성을 끊으면 훨씬 빨라집니다 (누적 변수를 여러 개로)

의존성 사슬 끊기

// ❌ 누적 변수 하나 — 매 덧셈이 앞 결과를 기다립니다
double sum = 0;
for (std::size_t i = 0; i < n; ++i) sum += a[i];
//   float 덧셈 지연이 4사이클이면 원소당 4사이클 ★

// ✅ 누적 변수 여러 개 — 독립적으로 진행됩니다
double s0=0, s1=0, s2=0, s3=0;
std::size_t i = 0;
for (; i + 4 <= n; i += 4) {
    s0 += a[i];   s1 += a[i+1];
    s2 += a[i+2]; s3 += a[i+3];
}
double sum = s0 + s1 + s2 + s3;
for (; i < n; ++i) sum += a[i];
//   → 처리량 한계까지 채웁니다 (2~4배 향상)
//   ⚠ 부동소수 합산 순서가 달라져 결과가 미세하게 다를 수 있습니다

// 컴파일러는 float 재배치를 기본적으로 못 합니다 (결합법칙 불성립)
//   -ffast-math 를 켜면 알아서 해 주지만 정확도를 포기합니다
//   → 직접 쓰는 편이 통제 가능합니다

// 정수는 결합법칙이 성립하므로 컴파일러가 알아서 합니다
이 기법이 실제로 쓰이는 곳 — 내적·놈 계산·해시·체크섬처럼 누적이 핵심인 루프입니다. BLAS·해시 라이브러리의 소스를 보면 대부분 이렇게 되어 있습니다.
정리 — godbolt 에 붙여넣고 루프 본문만 보세요. vaddpd·ymm 이 있으면 벡터화, call 이 있으면 인라인 실패, memcpy·new 가 있으면 복사·할당입니다. -Rpass-missed 리포트가 "왜 안 됐는지"를 문장으로 알려 줍니다.
PERF 10

파이썬 대비 실제로 몇 배인가

과장 없이 정리합니다. 어떤 경우에 몇 배가 나오고, 어떤 경우에 안 나오는지 — 이걸 알아야 도입 판단을 할 수 있습니다.

경우별 기대 배수

작업순수 파이썬 대비
원소마다 분기·상태가 있는 루프30~100배 ★
문자열·바이트 파싱20~100배
재귀·트리 탐색20~50배
단순 배열 연산50~200배 (단, numpy 대비 1~3배)
이미 numpy 로 벡터화된 것1~3배 ⚠
pandas 그룹 연산3~20배 (polars 와 비슷)
I/O 대기가 대부분1배 — 의미 없음 ❌
멀티코어 활용× 코어 수 (GIL 해제 시) ★
가장 흔한 실망 — “numpy 로 짠 코드를 C++ 로 옮겼는데 2배밖에 안 빨라졌다”. 당연합니다. numpy 는 이미 최적화된 C 코드입니다. C++ 가 이기려면 numpy 가 못 하는 것(원소별 분기·상태·메모리 재사용)을 해야 합니다.

왜 그렇게 차이가 나나

파이썬 루프 한 바퀴에 일어나는 일
  · 바이트코드 디스패치
  · 객체 참조 카운트 증감
  · 정수·실수도 힙 객체 (PyLongObject · PyFloatObject)
  · 타입 검사 · 메서드 조회
  → 원소당 수십~수백 나노초

C++ 루프 한 바퀴
  · 레지스터 연산 몇 개
  · 값이 연속 메모리에 (e02)
  → 원소당 수 나노초, 벡터화되면 1 미만

★ 즉 차이는 '언어가 빨라서'가 아니라
  '객체 오버헤드와 메모리 배치'에서 옵니다.
  numpy 가 빠른 이유도 정확히 같습니다 —
  파이썬 객체를 벗기고 연속 배열로 만들었기 때문입니다.

이렇게도 쓸 수 있다 — 직접 재 보기 🟡 중급

공정하게 비교하는 법

import numpy as np, timeit, fastmod

def compare(n):
    a = np.random.rand(n)
    L = a.tolist()

    # ① 순수 파이썬
    def py():
        out = [0.0] * len(L)
        for i in range(1, len(L)-1):
            out[i] = (L[i-1] + L[i] + L[i+1]) / 3
        return out

    # ② numpy 벡터화
    def np_():
        return np.convolve(a, np.ones(3)/3, mode="same")

    # ③ C++ 확장
    def cpp():
        return fastmod.smooth(a, 3)

    for name, f in (("python", py), ("numpy", np_), ("cpp", cpp)):
        t = min(timeit.repeat(f, number=1, repeat=5))
        print(f"{name:8} {t*1e3:9.3f}ms")

for n in (1_000, 100_000, 10_000_000):
    print(f"\n=== n={n:,}")
    compare(n)
세 가지를 다 재세요 — 순수 파이썬만 비교하면 부풀려진 배수가 나옵니다. 의사결정에 필요한 것은 “지금 쓰는 방법(대개 numpy) 대비 몇 배인가” 입니다.

크기별로 재야 하는 이유

전형적인 결과 형태

  n=1,000
    python   0.180ms
    numpy    0.021ms
    cpp      0.008ms      ← 2.6배 (경계 비용이 큰 비중)

  n=100,000
    python  18.500ms
    numpy    0.850ms
    cpp      0.210ms      ← 4.0배

  n=10,000,000
    python   1900ms
    numpy      92ms
    cpp        31ms       ← 3.0배 (메모리 대역폭 한계)

★ 배수가 크기에 따라 변합니다
  · 작으면 경계 비용이 지배 (🐍 y01)
  · 중간에서 최대
  · 아주 크면 메모리 대역폭 한계 (e02)

→ '내 실제 데이터 크기'에서 재야 의미가 있습니다.

실무에선 이렇게 — 도입 판단과 보고 🔴 실무

숫자를 정직하게 제시하기

## 성능 비교

측정 환경: AMD Ryzen 9 7900X · 32GB · Ubuntu 24.04 · GCC 13.2 -O3
재현: `python bench/run.py` (저장소 포함)

| n          | 현행(numpy) | C++ 확장 | 배수 |
|------------|------------:|---------:|-----:|
| 1,000      |     0.021ms |  0.008ms | 2.6× |
| 100,000    |     0.850ms |  0.210ms | 4.0× |
| 10,000,000 |       92 ms |    31 ms | 3.0× |

스레드 4개 사용 시 (GIL 해제):
| 10,000,000 |       92 ms |   9.5 ms | 9.7× |

### 주의
- 데이터가 1만 개 미만이면 이득이 크지 않습니다(경계 비용)
- 메모리 대역폭이 한계라 스레드는 4개 이상에서 효과가 줄어듭니다
- 결과는 numpy 와 1e-12 이내로 일치함을 테스트로 검증합니다
환경과 재현 방법을 반드시 밝히세요 — 환경 없는 배수는 신뢰를 잃습니다. 그리고 한계와 주의점을 함께 적으면 오히려 설득력이 올라갑니다.

비용도 함께 계산하기

도입 판단에 필요한 것은 배수만이 아닙니다

  얻는 것
    · 처리 시간 92ms → 9.5ms (요청당 82ms 절감)
    · 하루 100만 요청이면 22시간 상당의 CPU 시간
    · 서버 대수나 응답 지연으로 환산하면?

  치르는 것
    · 개발 2주 + 유지보수
    · 빌드 파이프라인 (3 OS × wheel) — 🛠️ b06
    · 팀에서 고칠 수 있는 사람 수
    · 크래시 시 프로세스 전체 종료 위험 — 🐍 y12
    · 디버깅 난이도 상승

  ★ 판단 기준
    · 그 함수가 전체 시간의 몇 %인가? (암달의 법칙)
      10% 를 100배 빠르게 해도 전체는 1.11배입니다
    · 대안은 없는가? (polars · numba · 알고리즘 개선)
    · 되돌릴 수 있는가? (순수 파이썬 폴백 — 🐍 y02)
전체에서 차지하는 비중을 먼저 보세요 — 프로파일에서 30% 이상을 차지하는 함수가 아니라면 C++ 로 옮겨도 체감이 거의 없습니다.

한 단계 더 — 한계와 대안 🧬 전문가

C++ 로도 안 되는 것

① 메모리 대역폭 한계
   단순 배열 연산은 DRAM 속도(수십 GB/s)가 상한입니다.
   → 알고리즘을 바꿔 데이터를 덜 읽거나, 캐시에 맞게 쪼개기 (e02)

② I/O 한계
   디스크·네트워크가 병목이면 계산 언어는 무관합니다.
   → 비동기 I/O · 배치 · 압축

③ 알고리즘 한계
   O(n²) 는 어떤 언어로도 O(n²) 입니다.

④ 분기가 지배적인 코드
   벡터화도 병렬화도 안 되는 경우가 있습니다.

그때의 대안
  · GPU (CUDA · SYCL) — 대규모 병렬 · 대역폭이 훨씬 큼
  · 분산 처리 (Ray · Dask · Spark)
  · 근사 알고리즘 · 샘플링
  · 사전 계산 · 캐싱 ★ 종종 가장 효과적
  · 문제 자체를 다시 정의하기

C++ 말고도 선택지가 있습니다

선택지언제
numba @njit파이썬 코드를 거의 그대로 · 수치 루프 ★
Cython파이썬 문법 + 타입 · 기존 코드 점진 전환
polars · DuckDB표 형태 데이터 — 직접 짜는 것보다 대개 빠릅니다
Rust + PyO3메모리 안전 · 빌드가 편함 (🦀 가이드)
C++기존 C/C++ 자산 · 최고 성능 · 세밀한 제어
GPU대규모 병렬 · 행렬·이미지·딥러닝
# numba 로 먼저 시도해 보는 것을 권합니다 ★
from numba import njit
@njit(cache=True, fastmath=True)
def smooth(a, w):
    out = np.empty_like(a)
    for i in range(len(a)):
        ...
    return out
#   · 파이썬 문법 그대로 · 빌드 파이프라인 불필요
#   · 대개 C++ 의 70~90% 성능
#   · 여기서 충분하면 C++ 로 갈 이유가 없습니다
정직한 결론 — C++ 확장은 강력하지만 비쌉니다. numba·polars 로 충분한 경우가 생각보다 많고, 그것을 먼저 시도해 보는 것이 프로다운 판단입니다(🐍 y01).
정리 — 순수 파이썬 대비 30~100배지만, numpy 대비는 1~3배인 경우가 흔합니다. 멀티코어를 쓰면(GIL 해제) 거기에 코어 수만큼 곱해집니다. 도입 판단은 배수가 아니라 “전체에서 차지하는 비중 × 치르는 비용”으로 하세요.
ODR · UB · ABI · 메모리 모델 · 크래시 덤프

“컴파일러 버그”가
아니라
내 코드의 UB 입니다

여기부터는 “왜 그렇게 동작하는가”를 다룹니다. Release 에서만 터지는 버그, 라이브러리가 안 붙는 이유, 운영 중 크래시 — 원인을 추측이 아니라 구조로 설명할 수 있게 됩니다.

12SECTIONS
UB1순위 원인
ABI배포의 벽
4깊이 층
DEEP 01

번역 단위 · ODR · 링커

진단 없이 조용히 메모리가 깨지는 유일한 규칙 위반이 ODR 입니다. 링크는 성공하는데 실행이 이상하다면 여기를 의심해야 합니다.

헤더에 함수 본문을 쓰면 왜 링커가 화를 내나 — ODR util.h int add(int a,int b){...} main.o add 정의 1 calc.o add 정의 2 draw.o add 정의 3 multiple definition of 'add' 해결 — 상황에 맞는 것 하나만 고르면 됩니다 inline (헤더에 두고 싶을 때) · static · 익명 네임스페이스 (그 파일 안에서만) · 헤더엔 선언만 두고 .cpp 에 정의
클래스 안에 쓴 멤버 함수는 자동으로 inline이라 이 문제가 안 납니다 — 헷갈리는 이유가 여기 있습니다.

하나의 정의 규칙(ODR)

규칙
  · 함수·변수의 '정의'는 프로그램 전체에서 정확히 하나여야 합니다
  · 클래스·inline 함수·템플릿은 여러 번역 단위에 나와도 되지만
    ★ 모든 정의가 '완전히 동일'해야 합니다
  · 어기면 ill-formed, no diagnostic required
    → 컴파일러도 링커도 알려 주지 않고, 실행이 이상해집니다 ★

왜 중복이 허용되나
  헤더에 클래스 정의를 두면 그것을 포함한 모든 .cpp 에 생깁니다.
  링커는 '약한 심볼(W)'로 표시된 것 중 하나만 남기고 버립니다.
  → 남은 하나가 '어느 것'인지는 정해져 있지 않습니다 ★
# 심볼 종류 확인
nm -C build/core.o
#   T  텍스트(코드) — 이 파일이 정의
#   W  약한 심볼 — inline·템플릿 (중복 허용, 하나만 남김)
#   U  미정의 — 다른 곳에서 와야 함
#   t  소문자 = 내부 링키지 (static · 익명 namespace)
#   B/D  BSS/데이터 — 전역 변수

실제로 나는 사고 ★

// ① 빌드 플래그가 달라 구조체 크기가 달라지는 경우
// a.cpp 는 -DUSE_EXTRA 로, b.cpp 는 없이 컴파일
struct Cfg {
    int a;
#ifdef USE_EXTRA
    int b;                  // ★ 한쪽에만 있습니다
#endif
};
//   → 링크는 성공하고, 실행 중에 메모리가 밀려 깨집니다
//     원인 추적이 극악입니다

// ② 표준 라이브러리 디버그 모드 불일치
// a.cpp: -D_GLIBCXX_DEBUG   b.cpp: 없음
//   → std::vector 의 내부 구조가 달라집니다 → 크래시

// ③ 서로 다른 라이브러리가 같은 이름의 클래스를 정의
namespace { }               // ✅ 익명 namespace 로 격리하세요
static void helper();       // ✅ 또는 static

// ④ inline 함수를 두 헤더에 다르게 정의
// v1.h: inline int f() { return 1; }
// v2.h: inline int f() { return 2; }
//   → 어느 것이 남을지 모릅니다
파이썬·Node 확장에서 특히 위험합니다 — 같은 프로세스에 여러 확장이 로드되고, 서로 다른 버전의 같은 라이브러리를 정적 링크했다면 심볼이 섞일 수 있습니다. 가시성을 hidden 으로 두는 이유입니다(🔗 f02).

이렇게도 쓸 수 있다 — 링커가 하는 일 🟡 중급

심볼 해석 과정

① 각 오브젝트의 심볼 테이블을 읽습니다 (제공 T · 필요 U)
② 필요한 심볼을 제공하는 오브젝트를 찾아 연결합니다
③ 섹션(.text · .data · .rodata · .bss)을 합칩니다
④ 주소를 확정하고 참조를 고쳐 씁니다 (relocation)
⑤ 실행 파일 헤더를 만듭니다

★ 정적 라이브러리(.a)는 '오브젝트 묶음'일 뿐입니다
  · 링커는 필요한 오브젝트만 꺼내 씁니다
  · 왼쪽→오른쪽으로 훑으므로 순서가 중요합니다 (🛠️ b01)
  · 이미 지나간 라이브러리는 다시 안 봅니다
    → g++ main.o -lfoo -lbar (foo 가 bar 를 쓰면 이 순서)
    → 순환 의존이면 -Wl,--start-group ... --end-group

약한 심볼과 강한 심볼
  · inline·템플릿 = 약한 심볼 → 중복 허용, 하나만 채택
  · 일반 함수 = 강한 심볼 → 중복이면 multiple definition 에러
  · 강한 것이 약한 것을 이깁니다 (라이브러리 함수를 덮어쓸 때 이용)

ODR 위반 찾기

# ① LTO 를 켜면 링커가 타입 불일치를 잡아 주기도 합니다 ★
g++ -flto -O2 *.cpp
#   warning: type of 'f' does not match original declaration

# ② gold/lld 의 ODR 검사
g++ -fuse-ld=gold -Wl,--detect-odr-violations

# ③ ASan 은 전역 변수 ODR 위반을 잡습니다
g++ -fsanitize=address ...
#   ERROR: AddressSanitizer: odr-violation

# ④ 심볼 크기 비교 — 같은 심볼인데 크기가 다르면 의심
nm -S --defined-only a.o b.o | sort -k3 | awk '{print $2, $3}' | uniq -d

# ⑤ 근본 대책 ★
#   · 빌드 플래그로 구조체 내용을 바꾸지 않기
#   · 모든 번역 단위에 같은 플래그 (CMake 로 통일 — 🛠️ b04)
#   · 내부 심볼은 익명 namespace 로
#   · 공개 헤더에 서드파티 타입을 노출하지 않기 (🛠️ b03)

# 확인 — 어떤 플래그로 컴파일됐는지
readelf -p .comment build/core.o
strings build/app | grep "GCC:"

실무에선 이렇게 — 심볼 충돌 관리 🔴 실무

같은 프로세스에 여러 라이브러리

전형적인 사고

  파이썬 프로세스
    ├─ 확장 A (libfmt 10.0 정적 링크)
    └─ 확장 B (libfmt 11.0 정적 링크)

  기본 가시성이면 두 확장의 fmt 심볼이 전역에 노출됩니다.
  → 먼저 로드된 쪽이 나중 것을 가립니다 (전역 심볼 인터포지션)
  → B 가 A 의 fmt 10.0 코드를 부르게 됩니다
  → 헤더는 11.0 인데 구현은 10.0 → ODR 위반 → 조용한 손상 ★

대책
  ① 가시성을 hidden 으로 (🔗 f02) ★ 가장 중요
     CXX_VISIBILITY_PRESET hidden
  ② dlopen 시 RTLD_LOCAL (기본값)
  ③ 의존성을 동적으로 공유 (버전 통일)
  ④ 내부 namespace 로 격리
     일부 라이브러리가 제공: FMT_BEGIN_NAMESPACE 등
  ⑤ 링커 버전 스크립트로 내보낼 심볼 명시 (🔗 f02)

검증하기

# 내 확장이 무엇을 내보내는지 — 최소여야 합니다 ★
nm -D --defined-only build/_core.so | grep " T " | wc -l
#   pybind11 확장이면 PyInit_ 하나만 나와야 이상적입니다

nm -D --defined-only build/_core.so | grep " T " | head
#   fmt · nlohmann 같은 것이 보이면 가시성 설정이 안 된 것 ★

# 두 라이브러리가 같은 심볼을 내보내는지
nm -DC --defined-only libA.so libB.so | grep " T " \
  | awk '{print $3}' | sort | uniq -d | head

# 실행 시 어느 것이 선택됐는지
LD_DEBUG=bindings python -c "import mymod" 2>&1 | grep fmt

# CI 에 넣을 검사
test $(nm -D --defined-only _core.so | grep -c " T ") -le 2 \
  || { echo "내보내는 심볼이 너무 많습니다"; exit 1; }
확장 모듈의 심볼 수를 CI 로 지키세요 — PyInit_ 하나만 내보내는 것이 이상적입니다. 늘어났다면 가시성 설정이 깨졌다는 신호이고, 나중에 재현 불가능한 버그로 돌아옵니다.

한 단계 더 — 인라인과 링키지의 정확한 규칙 🧬 전문가

무엇이 어디에 있어야 하나

// 외부 링키지 (다른 번역 단위에서 보임)
void f();                    // 선언
void f() { }                 // 정의 — 한 곳에만 ★
extern int g;                // 선언
int g = 0;                   // 정의 — 한 곳에만

// 내부 링키지 (이 파일 전용)
static void h() { }          // C 스타일
namespace { void h2() { } }  // C++ 권장 ★
const int k = 1;             // ★ const 는 기본이 내부 링키지 (C++)
constexpr int m = 2;         // 마찬가지

// inline — "중복 정의를 허용하라"는 링커 지시 (탭 1 x09)
inline void i() { }          // 헤더에 정의 가능
inline int counter = 0;      // C++17 인라인 변수 ★

// 템플릿 — 암묵적으로 inline 과 같은 취급
template<class T> void t(T) { }     // 헤더에 정의

// 클래스 안에 정의한 멤버 함수는 자동으로 inline
struct S { void f() { } };   // inline

// ⚠ 헤더에 이것을 두면 multiple definition
int bad = 0;                 // ❌ 각 .cpp 에 별도 정의가 생깁니다
inline int ok = 0;           // ✅
extern int decl;             // ✅ 선언만, 정의는 .cpp 에

정적 초기화 순서 문제

// ★ 서로 다른 번역 단위의 전역 객체 초기화 순서는 정해져 있지 않습니다
// a.cpp
Logger g_logger;                     // b.cpp 의 g_config 를 씁니다
// b.cpp
Config g_config;
//   → g_config 가 아직 초기화 안 됐을 수 있습니다 (크래시 또는 쓰레기 값)

// ✅ 해법 ① 함수 안 static (첫 호출 때 초기화 · 스레드 안전) ★
Config& config() {
    static Config c;                 // C++11 부터 스레드 안전 보장
    return c;
}
Logger& logger() {
    static Logger l{config()};       // 순서가 보장됩니다
    return l;
}

// ✅ 해법 ② constinit (C++20) — 컴파일 시점 초기화를 강제
constinit static int g_count = 0;    // 동적 초기화면 컴파일 에러

// ⚠ 소멸 순서도 문제입니다 (생성의 역순, 하지만 TU 간에는 불명확)
//   → 함수 안 static 은 프로그램 종료 시 역순으로 소멸됩니다
//   → 소멸자에서 다른 전역을 쓰면 이미 파괴됐을 수 있습니다 (static destruction order fiasco)
//   → 정말 필요하면 의도적으로 누수시키는 방법도 씁니다
Logger& logger() {
    static Logger* p = new Logger{};  // 일부러 delete 안 함
    return *p;
}
확장 모듈에서 자주 겪습니다 — 라이브러리 로드 시점에 전역 생성자가 실행되는데, 그 안에서 파이썬 API 를 부르거나 다른 라이브러리를 쓰면 초기화 순서 문제가 생깁니다. 지연 초기화(함수 안 static)가 안전합니다.
정리 — ODR 위반은 진단 없이 조용히 깨지는 유일한 규칙 위반입니다. 모든 번역 단위에 같은 플래그, 내부 심볼은 익명 namespace, 가시성은 hidden — 이 셋으로 대부분 예방됩니다. 전역 객체는 함수 안 static 으로 두세요.
DEEP 02

UB — 왜 그렇게 위험한가

“동작이 정의되지 않음”은 틀린 값이 나오는 정도가 아닙니다. 코드가 통째로 사라지거나 시간 여행처럼 앞선 코드가 영향을 받습니다.

최적화와 만나면

// 컴파일러의 대전제: "이 프로그램에 UB 는 없다"
// → UB 를 일으키는 경로는 '실행되지 않는다'고 가정하고 최적화합니다

// ① 널 체크가 사라집니다 (탭 1 x05)
void f(int* p) {
    int x = *p;              // ① 역참조 → "p 는 널이 아니다"
    if (!p) return;          // ② 그러므로 항상 거짓
    use(x);                  // ③ → 검사가 통째로 삭제 ★
}

// ② 오버플로 검사가 사라집니다
if (n + 1 < n) { /* 오버플로 처리 */ }
//   부호 있는 오버플로는 UB → "일어날 리 없다" → 조건이 거짓 → 삭제

// ③ 무한 루프가 제거됩니다
while (true) { }             // 부수효과 없는 무한 루프는 UB
//   → 컴파일러가 통째로 없애고 다음 코드로 흘러갈 수 있습니다

// ④ 시간 여행 — UB 이후가 아니라 '이전' 코드도 영향받습니다 ★
printf("시작\n");            // 이 출력이 안 나올 수 있습니다
*(int*)nullptr = 1;          // UB
//   컴파일러는 UB 에 도달하는 경로 전체를 자유롭게 다룰 수 있습니다
“Release 에서만 터진다”의 정체 — 컴파일러 버그가 아니라 내 코드의 UB 입니다. Debug 에서는 최적화가 없어 우연히 의도대로 동작했을 뿐입니다.

자주 밟는 UB 목록 ★

UB탐지
초기화 안 된 변수 읽기MSan · 경고
배열 범위 초과ASan ★
해제 후 사용 · 이중 해제ASan
널 포인터 역참조UBSan
부호 있는 정수 오버플로UBSan ★
0으로 나누기 · INT_MIN / -1UBSan
시프트 폭이 비트 수 이상UBSan
strict aliasing 위반⚠ 탐지 어려움
정렬되지 않은 접근UBSan
수명이 끝난 객체 접근ASan
데이터 경쟁TSan
반환값 없는 non-void 함수경고 · UBSan
소멸자에서 예외 던지기—
대부분 Sanitizer 로 잡힙니다(🛠️ b10) — -fsanitize=address,undefined 를 개발·CI 에 켜 두는 것이 UB 를 다루는 유일하게 실용적인 방법입니다.

이렇게도 쓸 수 있다 — UB 를 피하는 습관 🟡 중급

안전한 대안

// ① 정수 오버플로 — 미리 검사하거나 안전한 함수
#include <numeric>
if (a > INT_MAX - b) { /* 넘칠 상황 */ }
int r;
if (__builtin_add_overflow(a, b, &r)) { /* 오버플로 */ }   // GCC·Clang
//   C++26 에는 표준 함수가 들어옵니다

// 부호 없는 정수는 랩어라운드가 '정의'되어 있습니다 (UB 아님)
unsigned u = UINT_MAX; ++u;      // 0 — 정의된 동작

// ② 타입 재해석 — bit_cast (탭 1 x05)
float f = 1.0f;
int i = std::bit_cast<int>(f);   // ✅ C++20 · UB 아님 · 비용 0
int j = *(int*)&f;                // ❌ strict aliasing 위반
std::memcpy(&j, &f, 4);           // ✅ 예전부터 쓰던 합법적 방법

// ③ 범위 접근
v.at(i);                          // 예외를 던짐
if (i < v.size()) v[i];           // 검사 후
std::span sp{v}; sp[i];           // 디버그 빌드에서 검사되기도

// ④ 부호 비교 (탭 1 x02)
std::cmp_less(i, v.size());       // C++20 · 수학적으로 올바른 비교

// ⑤ 정렬 — memcpy 로 안전하게
Header h;
std::memcpy(&h, buf, sizeof h);   // ✅ 컴파일러가 최적화로 없애 줍니다

strict aliasing — 가장 미묘한 것

// 규칙: 객체를 '그 타입이 아닌 타입'으로 접근하면 UB
//   예외: char · unsigned char · std::byte 는 무엇이든 볼 수 있습니다

// ❌ 전형적인 위반
float f = 1.0f;
unsigned bits = *(unsigned*)&f;        // UB
//   컴파일러는 float* 와 unsigned* 가 다른 객체를 가리킨다고 가정합니다
//   → 쓰기와 읽기 순서를 자유롭게 바꿀 수 있습니다

// ❌ union 으로 타입 재해석 (C 에서는 합법, C++ 에서는 UB)
union U { float f; unsigned u; };
U x; x.f = 1.0f; use(x.u);             // ⚠ 실제로는 대부분 동작하지만 표준상 UB

// ✅ 합법적인 방법
std::bit_cast<unsigned>(f);            // C++20
std::memcpy(&bits, &f, sizeof bits);

// 진단이 어려운 이유
//   · Sanitizer 가 잘 못 잡습니다
//   · -O0 에서는 증상이 안 나옵니다
//   · -fno-strict-aliasing 을 켜면 증상이 사라집니다 ← 이것이 단서 ★

// 확인
g++ -O2 -Wstrict-aliasing=2 main.cpp
g++ -O2 -fno-strict-aliasing main.cpp   // 증상이 사라지면 위반입니다
바이너리 데이터를 다룬다면 꼭 알아야 합니다 — 네트워크 패킷·파일 포맷을 구조체로 캐스팅해 읽는 코드가 가장 흔한 위반입니다. memcpy 로 바꾸세요 — 컴파일러가 최적화로 없애 주므로 비용도 없습니다.

실무에선 이렇게 — UB 를 관리하기 🔴 실무

방어 체계 4중

  • ① 경고를 전부 켜고 에러로 — -Wall -Wextra -Werror. 컴파일러가 잡아 주는 것이 가장 쌉니다
  • ② Sanitizer 를 CI 에(🛠️ b10) — ASan·UBSan·TSan. UB 대응의 핵심입니다 ★
  • ③ 정적 분석 — clang-tidy·cppcheck. 실행하지 않고 잡히는 것들
  • ④ 표준 라이브러리 검사 모드 — -D_GLIBCXX_ASSERTIONS(가벼움, 릴리즈에도 가능), -D_GLIBCXX_DEBUG(무겁지만 철저)
  • ⑤ 퍼징 — 파서가 있다면 반드시(🛠️ b10)
# 릴리즈에도 켤 만한 가벼운 보호 (🛠️ b04)
-D_FORTIFY_SOURCE=3 -fstack-protector-strong -D_GLIBCXX_ASSERTIONS
#   비용 1% 미만으로 상당수의 오버런을 런타임에 잡습니다 ★

# 정수 오버플로를 정의된 동작으로 (표준은 아니지만 실용적)
-fwrapv          # 부호 있는 오버플로를 랩어라운드로
-ftrapv          # 오버플로 시 중단

증상으로 원인 좁히기

증상의심
Debug 는 되고 Release 만 실패UB ★ UBSan 먼저
-O0 은 되고 -O2 실패UB · aliasing
-fno-strict-aliasing 으로 해결aliasing 위반
printf 를 넣으면 사라짐초기화 · 경쟁 · UB
가끔만 크래시경쟁(TSan) · 수명
컴파일러 버전을 올리니 실패UB (최적화가 강해짐) ★
다른 기계에서만 실패정렬 · 엔디언 · CPU 명령
“컴파일러를 업그레이드했더니 코드가 깨졌다” — 거의 항상 원래 있던 UB 가 드러난 것입니다. 새 컴파일러가 더 공격적으로 최적화하면서 이전에는 우연히 동작하던 것이 무너집니다.

한 단계 더 — UB 를 직접 관찰하기 🧬 전문가

코드가 사라지는 것을 보기

// godbolt.org 에 붙여넣고 -O2 어셈블리를 보세요
int f(int* p) {
    int x = *p;
    if (p == nullptr) return -1;     // ★ 이 검사가 사라집니다
    return x;
}
//   -O0: cmp / je 가 보입니다
//   -O2: 검사가 통째로 없어지고 mov eax, [rdi]; ret 만 남습니다

bool overflow_check(int n) {
    return n + 1 < n;                // ★ 항상 false 로 접힙니다
}
//   -O2: xor eax, eax; ret   (그냥 0 반환)

// 반대로 부호 없는 것은 살아남습니다
bool ok(unsigned n) { return n + 1 < n; }   // 랩어라운드가 정의됨 → 검사 유지

// 무한 루프
int g() { while (true) { } }
//   부수효과가 없으면 UB → 컴파일러가 제거할 수 있습니다
//   (실제로 clang 은 함수를 비우기도 합니다)

언어가 나아가는 방향

C++ 는 UB 를 줄이는 쪽으로 움직이고 있습니다

  C++20  · 부호 있는 정수를 2의 보수로 확정
         · std::bit_cast 로 안전한 재해석
         · constinit 로 초기화 순서 문제 완화
  C++23  · 일부 UB 를 erroneous behavior 로 재분류
         · std::start_lifetime_as (버퍼를 객체로 안전하게)
  C++26  · 초기화되지 않은 읽기를 erroneous 로 (진단 가능) ★
         · 계약(contracts) — 사전·사후 조건을 언어로

erroneous behavior 란
  · 여전히 잘못됐지만 '진단 가능'하고
  · 컴파일러가 그것을 근거로 최적화하지 않습니다
  · 즉 "코드가 사라지는" 종류의 사고가 줄어듭니다 ★

그래도
  · 포인터·수명·경쟁 관련 UB 는 남습니다
  · 그래서 Rust 같은 언어가 주목받는 것이고(🦀 가이드)
  · C++ 에서는 Sanitizer 와 정적 분석이 계속 중요합니다
실무적 결론 — UB 목록을 외우는 것보다 Sanitizer 를 항상 켜 두는 습관이 훨씬 효과적입니다. 그리고 “Release 에서만 실패”를 만나면 UBSan 부터 돌리세요.
정리 — UB 는 틀린 값이 나오는 것이 아니라 코드가 사라지는 것입니다. 컴파일러는 “UB 는 없다”를 전제로 최적화하기 때문입니다. Debug 는 되고 Release 만 실패하면 컴파일러 버그가 아니라 UB 이고, -fsanitize=undefined 가 대개 그 자리를 짚어 줍니다.
미정의 동작 — 버그가 아니라 "가정해도 되는 상태" 왜 그냥 버그보다 위험한가 컴파일러는 "UB 는 일어나지 않는다" 고 가정하고 최적화합니다 그래서 오버플로를 검사하는 if 문이 통째로 지워지거나, 널 검사가 사라지기도 합니다 내가 쓴 방어 코드가 없어지는 것입니다 — 증상이 원인과 아주 멀리서 나타납니다 자주 밟는 것들 배열 범위 밖 접근 · 해제된 메모리 사용 · 초기화 안 한 변수 읽기 부호 있는 정수 오버플로 · 널 포인터 역참조 · 잘못된 타입으로 읽기 데이터 경쟁도 UB 입니다 — 그래서 동시성 버그가 그렇게 이상하게 나타납니다 Sanitizer 를 켜 두세요 — 주소 · 미정의 동작 · 스레드 검사기가 실행 중에 잡아 줍니다 디버그에서는 되는데 릴리즈에서만 이상하다면 거의 항상 UB 입니다 — 운으로 넘어가고 있었던 것입니다 개발과 CI 에서 늘 켜 두세요 — 느려지지만 운영에 나가기 전에 잡는 값이 훨씬 큽니다
C++ 에서 "잘 도는 것 같다" 는 맞다는 증거가 아닙니다.
DEEP 03

객체 수명과 aliasing

“메모리가 있다”와 “객체가 살아 있다”는 다른 이야기입니다. 이 구분이 바이너리 데이터를 다룰 때 미묘한 버그를 만듭니다.

객체의 수명

// 객체 수명은 '생성자가 끝날 때 시작 · 소멸자가 시작될 때 끝'
// 그 밖에서 접근하면 UB 입니다

// ① 소멸 후 접근
{
    Widget w;
    Widget* p = &w;
}   // w 소멸
// *p 접근 → UB (ASan 이 잡습니다)

// ② 생성 전 접근 — 기반 클래스 생성자에서 가상 함수
struct Base {
    Base() { init(); }               // ⚠ 파생 클래스 부분이 아직 없습니다
    virtual void init() { }
};
struct Derived : Base {
    std::vector<int> v;
    void init() override { v.push_back(1); }   // ★ v 가 아직 생성 전 → UB
};
//   생성자·소멸자 안에서 가상 함수는 '그 클래스의 버전'이 불립니다

// ③ 원시 메모리를 객체로 다루기 — placement new 가 필요합니다
alignas(Widget) std::byte buf[sizeof(Widget)];
Widget* p = new (buf) Widget{};      // ★ 여기서 객체 수명이 시작됩니다
p->~Widget();                        // 명시적 소멸

// ④ 버퍼를 구조체로 '그냥 캐스팅'하면 객체가 없습니다
Header* h = reinterpret_cast<Header*>(buf);   // ⚠ 엄밀히는 UB
h->field;                                      // 객체가 생성된 적이 없음
//   ✅ memcpy 로 복사하거나 (탭 1 x05)
Header h2; std::memcpy(&h2, buf, sizeof h2);
//   ✅ C++23 std::start_lifetime_as<Header>(buf)

실무에서 부딪히는 지점

// 네트워크 패킷·파일 포맷 파싱 — 가장 흔한 자리 ★
struct PacketHeader {
    std::uint32_t magic;
    std::uint16_t version;
    std::uint16_t length;
};
static_assert(std::is_trivially_copyable_v<PacketHeader>);
static_assert(sizeof(PacketHeader) == 8);

// ❌ 캐스팅 — aliasing 위반 + 정렬 문제 + 객체 수명
auto* h = reinterpret_cast<const PacketHeader*>(buffer);

// ✅ memcpy — 안전하고, 컴파일러가 최적화로 없애 줍니다
PacketHeader h;
if (buffer.size() < sizeof h) return error();
std::memcpy(&h, buffer.data(), sizeof h);

// ✅ 필드 단위로 읽기 (엔디언까지 처리 · 정렬 무관) ★
std::uint32_t read_u32_le(const std::byte* p) {
    return std::uint32_t(p[0]) | (std::uint32_t(p[1]) << 8)
         | (std::uint32_t(p[2]) << 16) | (std::uint32_t(p[3]) << 24);
}
//   컴파일러가 대개 단일 mov 명령으로 최적화합니다

// C++23
auto* h3 = std::start_lifetime_as<PacketHeader>(buffer.data());
실제로 대부분의 코드가 캐스팅을 씁니다 — 그리고 대개 동작합니다. 다만 정렬이 다른 아키텍처(ARM)나 공격적 최적화에서 깨집니다. memcpy 는 비용이 없으니 쓰지 않을 이유가 없습니다.

이렇게도 쓸 수 있다 — 수명을 다루는 도구 🟡 중급

placement new 와 수동 관리

// 컨테이너·optional·variant 가 내부적으로 쓰는 기법
template<class T>
class SmallOpt {
    alignas(T) std::byte storage_[sizeof(T)];
    bool has_ = false;
public:
    template<class... A>
    T& emplace(A&&... a) {
        reset();
        T* p = new (storage_) T(std::forward<A>(a)...);   // ★ 수명 시작
        has_ = true;
        return *p;
    }
    void reset() {
        if (has_) { std::launder(reinterpret_cast<T*>(storage_))->~T(); has_ = false; }
    }
    T& operator*() {
        return *std::launder(reinterpret_cast<T*>(storage_));   // ★ launder
    }
    ~SmallOpt() { reset(); }
};

// std::launder (C++17) — "여기 새 객체가 생겼다"고 컴파일러에 알림
//   객체를 파괴하고 같은 자리에 새로 만들었을 때 필요합니다
//   (컴파일러가 옛 객체의 정보를 계속 쓰는 것을 막습니다)

// 실무에서는 직접 만들지 마세요
std::optional<T> · std::variant<...> · std::vector 를 쓰면 됩니다 (탭 2 m10)

restrict — aliasing 을 알려 주기

// 컴파일러는 두 포인터가 겹칠 수 있다고 가정합니다 (⚡ e06)
void add(double* out, const double* a, const double* b, std::size_t n) {
    for (std::size_t i = 0; i < n; ++i) out[i] = a[i] + b[i];
}
//   out 과 a 가 같을 수 있으므로 매 반복마다 다시 로드해야 합니다
//   → 벡터화 실패

// __restrict 로 "겹치지 않는다"고 약속
void add(double* __restrict out,
         const double* __restrict a,
         const double* __restrict b, std::size_t n);
//   ★ 표준은 아니지만 gcc·clang·msvc 모두 지원합니다
//   ⚠ 약속을 어기면 UB — 실제로 겹치는 포인터를 넘기면 결과가 틀립니다

// 확인 — 어셈블리에서 vaddpd 가 나오는지 (⚡ e09)
// g++ -O3 -fopt-info-vec-optimized add.cpp

// 타입이 다르면 컴파일러가 이미 겹치지 않는다고 가정합니다 (strict aliasing)
void f(int* i, float* f);      // 서로 다른 타입 → 겹칠 수 없다고 가정
//   ★ 그래서 타입 재해석이 위험한 것입니다 (d02)
파이썬 확장에서 유용합니다 — numpy 배열 두 개를 받아 하나에 쓰는 함수라면 __restrict 를 붙이는 것만으로 벡터화가 걸려 2~4배가 나올 수 있습니다. 다만 같은 배열을 입력과 출력으로 넘기지 못하게 문서에 명시하세요.

실무에선 이렇게 — 수명 버그를 구조로 막기 🔴 실무

규칙으로 예방하기

  • 소유는 unique_ptr·컨테이너, 원시 포인터는 빌려 보기만(탭 2 m02)
  • 참조·string_view·span 은 인자 전용 — 멤버로 보관하지 않기(탭 2 m11)
  • 컨테이너 재할당 후 포인터·반복자 무효 — 인덱스를 보관(탭 2 m05)
  • 생성자·소멸자에서 가상 함수 호출 금지
  • 비동기·스레드에는 값 캡처(탭 2 m07)
  • C API 경계에서는 수명을 문서화(🔗 f07)
// 도구로 잡기
// clang-tidy
//   bugprone-dangling-handle
//   bugprone-use-after-move
//   cppcoreguidelines-owning-memory

// clang 의 수명 분석 (실험적이지만 유용)
clang++ -Wlifetime -Wdangling-gsl main.cpp

// GCC
g++ -Wdangling-pointer -Wdangling-reference -Wuse-after-free

진단 절차

# ① ASan 이 대부분 잡습니다 ★
g++ -fsanitize=address -g main.cpp && ./a.out
#   ERROR: AddressSanitizer: heap-use-after-free
#     READ of size 4 at 0x... thread T0
#       #0 in use() main.cpp:20
#     freed by thread T0 here:
#       #0 in operator delete
#       #1 in main main.cpp:15          ★ 어디서 해제됐는지까지

# ② 스택 객체의 수명
-fsanitize=address -fsanitize-address-use-after-scope
#   ERROR: stack-use-after-scope ★

# ③ 반환 후 사용
ASAN_OPTIONS=detect_stack_use_after_return=1 ./a.out

# ④ 확장 모듈이라면 (🐍 y12)
PYTHONMALLOC=malloc LD_PRELOAD=$(gcc -print-file-name=libasan.so) python t.py

# ⑤ 재현이 어려우면 rr 로 녹화하고 되감기 (🛠️ b09)
rr record ./app && rr replay
(rr) watch -l *0x7fff1234
(rr) reverse-continue        # ★ 값이 망가진 시점으로 거꾸로
수명 버그는 ASan 이 거의 다 잡습니다 — 해제 위치와 접근 위치를 둘 다 보여 주기 때문에 원인 파악이 빠릅니다. 개발 빌드에 항상 켜 두세요.

한 단계 더 — 포인터 프로버넌스 🧬 전문가

포인터는 주소만이 아닙니다

// 표준은 포인터에 '어느 객체에서 유래했는가'(provenance)를 부여합니다
int a[10], b[10];
int* p = a + 10;            // ✅ 마지막 다음까지는 합법
int* q = b;
if (p == q) {               // 주소는 같을 수 있지만
    *p;                     // ❌ p 로는 b 에 접근할 수 없습니다 (UB)
}

// 정수로 왕복하면 프로버넌스를 잃습니다
std::uintptr_t addr = reinterpret_cast<std::uintptr_t>(ptr);
int* p2 = reinterpret_cast<int*>(addr);
//   구현 정의 — 대부분 동작하지만 표준상 보장이 약합니다
//   최적화가 "이 포인터는 어디서 왔는지 모른다"고 판단해
//   보수적으로 처리하거나, 반대로 잘못 가정할 수 있습니다

// 포인터 태깅 (하위 비트에 정보 넣기) — 흔한 기법이지만 주의
std::uintptr_t tagged = addr | 1;      // 정렬 덕에 하위 비트가 비어 있음
//   ⚠ 실무에서 널리 쓰이지만 엄밀히는 회색지대입니다
//   → std::launder · memcpy 로 다루거나, 별도 필드로 분리하는 편이 안전

왜 이런 규칙이 있나

컴파일러가 '별칭 분석(alias analysis)'을 할 수 있어야
최적화가 가능하기 때문입니다

  · 두 포인터가 절대 겹치지 않는다면
    → 로드·스토어 순서를 바꾸고, 레지스터에 캐시하고, 벡터화합니다
  · 겹칠 수 있다면
    → 매번 메모리를 다시 읽어야 합니다 (느림)

프로버넌스 규칙이 없으면
  "정수로 바꿨다가 되돌린 포인터"가 무엇이든 가리킬 수 있어
  컴파일러는 모든 최적화를 포기해야 합니다.

실무 지침
  · 포인터 산술은 같은 배열 안에서만
  · 타입 재해석은 memcpy · bit_cast (d02)
  · 정수↔포인터 왕복은 최소화, 필요하면 격리된 함수에서
  · 저수준 트릭을 쓸 때는 -fno-strict-aliasing 이 필요한지 검토

★ C++26 · C 표준에서 프로버넌스를 명확히 하는 작업이 진행 중입니다
  (P2318 · TS 6010) — 지금은 '보수적으로 쓰는 것'이 답입니다
여기까지 신경 쓸 자리 — 할당자·GC·직렬화·JIT 처럼 메모리를 직접 관리하는 코드입니다. 일반 응용 코드에서는 d02 의 규칙(memcpy·bit_cast)만 지켜도 충분합니다.
정리 — “메모리가 있다”와 “객체가 살아 있다”는 다릅니다. 버퍼를 구조체로 캐스팅하지 말고 memcpy 하세요 — 비용은 0 입니다. __restrict 는 벡터화를 여는 열쇠이지만 약속을 어기면 UB 입니다. 수명 버그는 ASan 이 해제 위치까지 알려 줍니다.
DEEP 04

vtable 과 가상 호출 비용

가상 함수는 “느리다”가 아니라 “인라인을 막는다”가 정확한 표현입니다. 실제 비용을 수치로 확인하고 대안을 정리합니다.

virtual 을 붙이면 호출이 두 단계가 됩니다 Dog 객체 vptr → (표를 가리킴) name, age ... 실제 데이터 Dog 의 vtable [0] speak → Dog::speak [1] ~Dog Dog::speak 실행 주소는 실행할 때 결정 그래서 인라인이 안 됩니다 비용은 어디에 있나 — 표를 한 번 더 읽는 것 자체는 싸다 간접 호출 한 번 보통 몇 사이클 그 자체는 거의 문제 없음 인라인이 막힌다 작은 함수를 루프에서 부르면 이쪽 손해가 훨씬 큽니다 객체가 8바이트 커진다 vptr 이 하나 붙습니다 작은 객체 수백만 개면 유의미 상속받을 클래스의 소멸자에 virtual 을 안 붙이면, 부모 포인터로 delete 할 때 자식 소멸자가 안 불립니다 — UB 입니다.
더 상속되지 않을 클래스에는 final 을 붙이세요 — 컴파일러가 가상 호출을 직접 호출로 되돌립니다.

메모리에 어떻게 놓이나

struct B { virtual void f(); virtual void g(); int x; };
struct D : B { void f() override; int y; };

sizeof(B);      // 16 = vptr(8) + int(4) + 패딩(4)
sizeof(D);      // 24 = vptr(8) + x(4) + y(4) + 패딩(8)

// 객체 배치
//   [vptr][x][패딩]          ← B
//   [vptr][x][y][패딩]       ← D (같은 vptr 자리, 다른 테이블을 가리킴)
//
// vtable (클래스마다 하나, 읽기 전용 섹션에)
//   B 의 vtable: [&B::f, &B::g, RTTI 정보, ...]
//   D 의 vtable: [&D::f, &B::g, RTTI 정보, ...]

// b.f() 가 하는 일
//   ① 객체에서 vptr 을 읽는다        (메모리 접근 1)
//   ② 테이블에서 f 의 주소를 읽는다   (메모리 접근 2)
//   ③ 그 주소로 간접 점프            (분기 예측 필요)

// 확인
// g++ -fdump-lang-class main.cpp     # vtable 레이아웃 출력
// clang++ -Xclang -fdump-record-layouts main.cpp   ★
// objdump -sj .rodata app | grep -A4 vtable
가상 함수가 하나라도 있으면 객체 앞에 vptr 8바이트가 붙고 is_standard_layout 이 깨집니다. C ABI 로 구조체를 그대로 넘길 수 없게 되는 이유입니다(🔗 f01 · 탭 1 x07).

실제 비용

직접 측정한 경향 (환경마다 다릅니다 — 직접 재세요)

  1억 회 호출, 본문이 한 줄인 함수

    직접 호출 (인라인)     0.03 ns/회   ← 사실상 사라짐
    비가상 함수 호출        1.0 ns
    가상 함수 (같은 타입)    1.5 ns      ← 예측 성공
    가상 함수 (타입 섞임)    4~8 ns      ← 예측 실패 ★
    std::function          3~10 ns

★ 진짜 비용은 '호출 자체'가 아니라 인라인 불가입니다
  · 한 줄짜리 접근자가 인라인되면 0사이클
  · 가상이면 호출이 남고, 주변 최적화(벡터화·상수 전파)도 막힙니다
  · 루프 안에서 원소마다 부르면 그 차이가 누적됩니다

언제 문제가 되나 (셋이 겹칠 때)
  ① 수백만 회 이상 호출  ② 본문이 짧음  ③ 루프 안 · 벡터화 대상
perf stat -e branch-misses,iTLB-load-misses ./app
#   간접 분기 예측 실패가 많으면 다형적 호출이 원인일 수 있습니다

이렇게도 쓸 수 있다 — 가상 호출을 없애는 방법 🟡 중급

컴파일러가 직접 호출로 바꾸게

// ① final — 더 파생될 수 없다면 대상이 확정됩니다 ★
struct D final : B { void f() override; };
D d; d.f();                  // 직접 호출 · 인라인 가능

struct B2 { virtual void f() final; };   // 함수 단위로도

// ② 정적 타입이 확실한 경로
void use(D& d) { d.f(); }    // B& 가 아니라 D& 로 받으면 직접 호출

// ③ LTO — 전체 프로그램을 보고 파생 클래스가 하나뿐이면 확정 ★
//    (speculative devirtualization)
set(CMAKE_INTERPROCEDURAL_OPTIMIZATION_RELEASE ON)

// ④ 익명 namespace — 이 파일 밖에서 상속할 수 없음을 알려 줍니다
namespace { struct Impl : Base { ... }; }

// 확인 — 어셈블리에 call *%rax (간접) 가 있는지 (⚡ e09)
// objdump -dC app | grep "call.*\*"

설계로 없애기

// ① 호출 단위를 키우기 ★ 거의 항상 최선입니다
// ❌ 원소마다 가상 호출
struct Filter { virtual double apply(double) = 0; };
for (auto& x : v) x = f->apply(x);       // 100만 번 간접 호출

// ✅ 배열 전체를 가상 함수로
struct Filter { virtual void apply(std::span<double>) = 0; };
f->apply(v);                              // 가상 호출 1번 + 내부는 순수 루프
//   → 비용이 사실상 사라지고 벡터화도 됩니다 (탭 1 x08)

// ② variant + visit — 종류가 고정이면 (탭 2 m10)
using Shape = std::variant<Circle, Rect>;
double area(const Shape& s) { return std::visit([](auto& x){ return x.area(); }, s); }
//   점프 테이블 · 인라인 가능 · 힙 할당 없음

// ③ 템플릿 (CRTP) — 컴파일 시점 다형성 (탭 2 m08)
template<class D> struct Base {
    double area() const { return static_cast<const D*>(this)->area_impl(); }
};
//   비용 0 · 대신 런타임 교체 불가

// ④ 타입별로 묶어 처리 (⚡ e05)
for (auto& c : circles) total += c.area();
for (auto& r : rects)   total += r.area();
“호출 단위를 키우기”가 핵심입니다 — 이 가이드 전체에서 반복되는 원칙과 같습니다. 파이썬 경계든 가상 함수든, 경계를 넘는 횟수를 데이터 크기와 분리하면 비용이 사라집니다.

실무에선 이렇게 — 과잉 반응하지 않기 🔴 실무

대부분은 문제가 아닙니다

가상 호출이 실제 병목인 경우는 드뭅니다

  · 초당 100만 번 불려도 1.5ns × 100만 = 1.5ms
  · 웹 요청 하나에 수십 번 부르는 정도면 무시할 수준
  · 프로파일에 나타나지 않으면 손대지 마세요 ★

과잉 반응의 대가
  · 전부 템플릿으로 바꾸면 빌드 시간·바이너리 크기 폭발 (d08)
  · CRTP 는 읽기 어렵고 에러 메시지가 나쁩니다
  · variant 는 종류를 나중에 추가하기 어렵습니다
  · 런타임 교체가 필요한 곳에서는 애초에 불가능합니다

★ 가상 함수를 그냥 쓰세요.
  프로파일러가 그 자리를 가리킬 때만 위의 기법을 꺼내면 됩니다.

가상 함수가 오히려 나은 경우
  · 플러그인 · 설정으로 구현 선택
  · 테스트용 가짜 객체 (탭 1 x08)
  · 컴파일 시간이 중요한 대형 프로젝트

그래도 지킬 것

// ① 상속할 클래스에는 virtual 소멸자 (탭 1 x08)
struct Base { virtual ~Base() = default; };
//   없으면 Base* 로 delete 할 때 파생 소멸자가 안 불립니다 (UB)

// ② 상속을 안 할 클래스는 final ★
struct Impl final : Base { ... };
//   최적화 여지 + 의도 표현

// ③ override 를 반드시
void f(int) override;        // 시그니처가 다르면 컴파일 에러

// ④ 다중 상속은 인터페이스에만
//   상태를 가진 클래스를 여럿 상속하면 vtable 이 복잡해지고
//   thunk(조정 코드)가 생겨 호출 비용이 늘어납니다

// ⑤ 가상 함수를 많이 만들지 마세요
//   vtable 이 커지면 캐시에도 부담입니다

// 검사
// clang-tidy: cppcoreguidelines-virtual-class-destructor
//             modernize-use-override
//             cppcoreguidelines-explicit-virtual-functions
가장 중요한 것은 virtual 소멸자입니다. 성능보다 정확성 문제이고, 빠뜨리면 메모리가 샙니다. -Wnon-virtual-dtor 를 켜 두세요(🛠️ b04).

한 단계 더 — vtable 의 세부 🧬 전문가

다중 상속과 thunk

struct A { virtual void f(); int a; };
struct B { virtual void g(); int b; };
struct C : A, B { void f() override; void g() override; };

// C 객체는 vptr 을 두 개 가집니다
//   [vptrA][a][vptrB][b]
//        ↑ A 로 볼 때        ↑ B 로 볼 때 (this 가 다릅니다!)

C c;
B* pb = &c;                  // ★ 주소가 조정됩니다 (+16 정도)
pb->g();                     // vptrB 의 테이블 → thunk → this 를 되돌림 → C::g

// thunk: this 포인터를 조정하고 진짜 함수로 점프하는 작은 코드
//   → 다중 상속에서 가상 호출이 조금 더 비싼 이유

// dynamic_cast 는 RTTI 를 순회합니다 (더 비쌈)
if (auto* p = dynamic_cast<C*>(pb)) { ... }     // 수십~수백 사이클
//   자주 부르면 병목이 됩니다 → 설계를 다시 보세요
//   (가상 함수로 표현할 수 있으면 그쪽이 낫습니다)

// 가상 상속 (다이아몬드 해결)은 더 복잡합니다
struct D : virtual A { };
//   vbase offset 을 통한 간접 접근 → 멤버 접근조차 느려집니다
//   ★ 꼭 필요한 경우가 아니면 피하세요

RTTI 와 예외를 끄는 선택

# 임베디드·게임 엔진에서 종종 씁니다
g++ -fno-rtti -fno-exceptions

# -fno-rtti 로 잃는 것
#   dynamic_cast · typeid
#   (vtable 의 RTTI 포인터가 사라져 바이너리가 조금 작아집니다)

# -fno-exceptions 로 잃는 것 (탭 1 x11)
#   try/catch/throw
#   std::vector::at · std::stoi 등 예외에 의존하는 표준 함수
#   → 자체 컨테이너를 쓰거나 expected 로 대체 (탭 2 m10)

# 효과
#   바이너리 5~20% 감소 · 예외 테이블 제거
#   ⚠ 성능은 대개 차이가 없습니다 (예외는 던지지 않으면 비용 0 — d05)

# 확인
size -A app | grep -E "eh_frame|gcc_except"
// RTTI 없이 타입 구분이 필요하면 — 직접 태그
struct Base {
    enum class Kind { Circle, Rect } kind;
    explicit Base(Kind k) : kind(k) {}
};
//   variant 가 이것을 타입 안전하게 해 주는 것입니다 (탭 2 m10)
확장 모듈에서는 예외를 켜 두세요 — pybind11·node-addon-api 가 예외로 에러를 전달합니다(🐍 y08 · 🟨 j02). 끄면 표준 라이브러리 일부도 못 쓰게 되어 얻는 것보다 잃는 것이 큽니다.
정리 — 가상 호출의 진짜 비용은 인라인 불가 입니다. final 을 붙이고 호출 단위를 배열 전체로 키우면 대부분 사라집니다. 다만 프로파일러가 가리킬 때만 손대세요 — 전부 템플릿으로 바꾸면 빌드 시간과 가독성을 잃습니다.
DEEP 05

예외의 실제 구현

“예외는 느리다”는 절반만 맞습니다. 던지지 않으면 비용이 0 이고, 던지면 매우 비쌉니다. 그 구조를 알면 언제 쓸지 판단할 수 있습니다.

테이블 기반(zero-cost) 방식

현대 구현(Itanium ABI · MSVC x64)은 이렇게 동작합니다

  컴파일 시점
    · try 블록에 들어가는 명령을 넣지 않습니다 ★
    · 대신 '어디서 예외가 나면 무엇을 정리할지' 표를 만들어
      바이너리의 별도 섹션에 넣습니다
        .eh_frame · .gcc_except_table (ELF)
        .pdata · .xdata (Windows)

  던져질 때
    ① __cxa_allocate_exception 으로 예외 객체 할당
    ② __cxa_throw 호출
    ③ 언와인더가 스택을 거슬러 올라가며 표를 조회
       (2단계: 먼저 핸들러를 찾고, 그다음 실제로 되감기)
    ④ 각 프레임의 소멸자를 호출
    ⑤ 핸들러로 점프

비용 구조
  · 던지지 않을 때 : 실행 비용 0 · 바이너리 +5~15% ★
  · 던질 때        : 수 μs (표 조회 + 되감기) · 시간이 일정하지 않음
  · 게다가 언와인더가 전역 락을 잡는 구현이 있어
    멀티스레드에서 동시에 던지면 직렬화됩니다 ★

그래서 나오는 결론

// ✅ 정상 흐름에는 예외를 쓰지 마세요
// ❌ 루프 안에서 흐름 제어로
for (auto& s : lines) {
    try { v.push_back(std::stoi(s)); }
    catch (...) { /* 무시 */ }        // 실패가 잦으면 수백 배 느려집니다 ★
}
// ✅ 실패가 흔하면 반환값으로 (탭 2 m10)
for (auto& s : lines)
    if (auto n = to_int(s)) v.push_back(*n);

// ✅ 진짜 예외적인 상황에는 마음껏
if (!file) throw std::runtime_error("설정 파일 없음");
//   프로그램 시작에 한 번 나는 것이라면 비용을 걱정할 이유가 없습니다

// 측정 — 던지는 비용을 직접 재 보기
auto t = bench_ms([]{
    for (int i = 0; i < 100000; ++i)
        try { throw 1; } catch (int) { }
});
//   대개 호출당 1~5μs — 함수 호출의 1000배 수준입니다
실시간 시스템에서 예외를 끄는 이유 — 느려서가 아니라 시간이 예측 불가능하기 때문입니다. 게임 프레임·오디오 콜백·거래 시스템처럼 최악 지연이 중요한 곳에서는 -fno-exceptions 를 씁니다.

이렇게도 쓸 수 있다 — 예외 안전성 다시 보기 🟡 중급

noexcept 의 의미

void f() noexcept;
//   "이 함수는 예외를 던지지 않는다"
//   어기면 std::terminate — catch 로도 못 막습니다 ★

// 얻는 것
//   ① 컴파일러가 예외 처리 코드를 생략 → 바이너리 축소
//   ② ★ 이동 생성자에 noexcept 가 있어야 vector 재할당이 '이동'을 씁니다
//      (탭 2 m03) — 없으면 복사로 후퇴해 성능이 몇 배 나빠집니다
static_assert(std::is_nothrow_move_constructible_v<MyType>);

// 반드시 noexcept 여야 하는 것
//   소멸자 (기본으로 noexcept)
//   이동 생성자 · 이동 대입
//   swap
//   해제 함수

// 조건부 noexcept
template<class T>
void g(T t) noexcept(std::is_nothrow_move_constructible_v<T>);

// 검사
noexcept(expr)               // 이 식이 noexcept 인가 (컴파일 시점)
static_assert(noexcept(a.swap(b)));

스택 되감기 관찰하기

struct Trace {
    const char* n;
    explicit Trace(const char* s) : n(s) { std::printf("+ %s\n", n); }
    ~Trace() { std::printf("- %s\n", n); }
};

void c() { Trace t{"c"}; throw std::runtime_error("실패"); }
void b() { Trace t{"b"}; c(); }
void a() { Trace t{"a"}; b(); }

int main() {
    try { a(); }
    catch (const std::exception& e) { std::printf("잡음: %s\n", e.what()); }
}
// 출력
//   + a  + b  + c
//   - c  - b  - a        ★ 소멸자가 역순으로 전부 불립니다 (RAII)
//   잡음: 실패
# 던지는 순간 멈추기 (🛠️ b09)
gdb ./app
(gdb) catch throw
(gdb) run
(gdb) bt                     # 어디서 던졌는지

# 잡히지 않은 예외로 죽었을 때
(gdb) break __cxa_throw
(gdb) break std::terminate

실무에선 이렇게 — 경계에서의 처리 🔴 실무

예외가 새면 프로세스가 죽습니다

// ① main 을 감싸기 (탭 1 x11)
int main() try { run(); return 0; }
catch (const std::exception& e) { log(e.what()); return 1; }
catch (...) { log("알 수 없는 오류"); return 2; }

// ② 스레드 함수 (탭 2 m12)
std::jthread t([]{
    try { work(); } catch (const std::exception& e) { log(e.what()); }
});

// ③ C ABI 경계 — 반드시 (🔗 f09)
extern "C" int32_t mylib_run(...) {
    try { impl(); return 0; }
    catch (const std::bad_alloc&) { return -2; }
    catch (...) { return -1; }
}
//   C++ 예외가 C 프레임을 통과하면 UB 입니다 ★

// ④ 파이썬·Node 경계 (🐍 y08 · 🟨 j02)
//   pybind11 이 자동 변환하지만, GIL 해제 구간은 직접 처리해야 합니다
std::optional<std::string> err;
{
    py::gil_scoped_release rel;
    try { compute(); } catch (const std::exception& e) { err = e.what(); }
}
if (err) throw py::value_error(*err);   // GIL 을 가진 상태에서 던지기 ★

// ⑤ 소멸자 (탭 2 m01)
~File() { try { flush(); } catch (...) { /* 로그만 */ } }

예외를 쓸까 말까

상황선택
흔한 실패
파싱·조회·검증
expected·optional
드물고 회복 불가
메모리 부족·설정 손상
예외
생성자에서 실패예외 (반환값이 없음)
실시간·최악 지연 중요예외 끄기
언어 경계 밖에러 코드로 변환 ★
라이브러리 공개 API둘 다 제공 고려
프로젝트 단위로 정하세요 — 예외를 쓰는 코드와 안 쓰는 코드가 섞이면 예외를 잡지 않는 코드 사이로 예외가 지나가 최악입니다. 라이브러리라면 예외를 끈 프로젝트도 쓸 수 있게 expected 기반 API 를 함께 제공하는 것이 안전합니다.

한 단계 더 — 예외의 미래와 대안 🧬 전문가

현재 구현의 문제

① 던질 때 힙 할당이 일어납니다
   메모리 부족 상황에서 bad_alloc 을 던지려는데
   그 예외 객체를 할당할 메모리가 없을 수 있습니다
   (구현은 비상용 버퍼를 둡니다)

② 언와인더가 전역 락을 잡는 구현이 있습니다
   여러 스레드가 동시에 던지면 직렬화됩니다 ★
   서버에서 예외를 흐름 제어로 쓰면 확장성이 무너집니다

③ 바이너리가 커집니다 (5~15%)
   임베디드에서 문제가 됩니다

④ 되감기 시간이 스택 깊이에 비례합니다
   최악 지연을 보장할 수 없습니다

⑤ 표(테이블)를 읽어야 해서 캐시를 오염시킵니다

→ P0709 "Zero-overhead deterministic exceptions" 제안
   · 예외를 반환값처럼 전달 (레지스터)
   · 할당 없음 · 결정적 시간
   · 아직 표준에 들어가지 않았습니다

실무적 절충

// 계층별로 다르게 (탭 2 m10)
//   안쪽 핫 경로  : expected — 예외 없음, 결정적
//   중간 계층     : 전파
//   경계          : 예외 또는 에러 코드로 변환

// 예외를 끈 환경에서도 동작하는 코드
#if __cpp_exceptions
    #define MYLIB_THROW(x) throw x
#else
    #define MYLIB_THROW(x) (std::fprintf(stderr, "%s\n", #x), std::abort())
#endif

// 표준 라이브러리도 예외 없이 쓰기
v.at(i);                        // ❌ 예외에 의존
if (i < v.size()) v[i];         // ✅
std::from_chars(...);           // ✅ 예외를 안 던집니다 (탭 2 m11)
std::filesystem::file_size(p, ec);   // ✅ error_code 오버로드

// 측정 — 예외 테이블이 차지하는 크기
size -A app | grep -E "eh_frame|except"
// 예외를 끄면 얼마나 줄어드는지
g++ -O2 -fno-exceptions -fno-rtti app.cpp -o app_small
대부분의 응용 프로그램은 예외를 그냥 쓰면 됩니다 — 위의 문제들은 실시간·임베디드·초고성능 서버에서 의미가 있습니다. 중요한 것은 “정상 흐름에 쓰지 않는다”는 원칙 하나입니다.
정리 — 예외는 던지지 않으면 비용이 0, 던지면 마이크로초 단위입니다. 그래서 정상 흐름에는 쓰지 말고 진짜 예외적인 상황에만 쓰세요. 이동 생성자의 noexcept 는 성능에 직결되니 반드시 붙이고, 언어 경계에서는 예외를 통과시키지 마세요.
예외는 '던지지 않으면 공짜, 던지면 매우 비싸다'로 구현됩니다 정상 경로 — 명령이 하나도 추가되지 않습니다 try 블록에 진입하는 코드도, 검사하는 코드도 없습니다 C 로 짠 것과 같은 기계어가 나옵니다 — 에러 코드를 매번 확인하는 쪽보다 오히려 빠릅니다 대신 바이너리에 표가 붙습니다 .eh_frame · unwind table 던지는 순간 — 표를 뒤지며 되감습니다 throw 예외 객체를 힙에 표에서 현재 주소 검색 정리할 객체 목록 확인 소멸자 호출 · 한 칸 위로 catch 를 만날 때까지 반복 수 마이크로초 그래서 판단이 갈립니다 — 드문 실패는 예외, 자주 일어나는 실패는 반환값 파일이 없는 것은 예외로, 캐시에 없는 것은 optional 로. 루프 안에서 초당 수천 번 던지면 그 자체가 병목이 됩니다 임베디드처럼 표를 넣을 공간이 없으면 -fno-exceptions 로 끕니다 — 이때는 표준 라이브러리 일부도 함께 못 씁니다
"예외는 느리다"는 반쪽만 맞습니다 — 던지지 않는 동안은 비용이 0입니다.
예외는 '던지지 않으면 공짜, 던지면 매우 비싸다'로 구현됩니다 정상 경로 — 명령이 하나도 추가되지 않습니다 try 블록에 진입하는 코드도, 검사하는 코드도 없습니다 C 로 짠 것과 같은 기계어가 나옵니다 — 에러 코드를 매번 확인하는 쪽보다 오히려 빠릅니다 대신 바이너리에 표가 붙습니다 .eh_frame · unwind table 던지는 순간 — 표를 뒤지며 되감습니다 throw 예외 객체를 힙에 표에서 현재 주소 검색 정리할 객체 목록 확인 소멸자 호출 · 한 칸 위로 catch 를 만날 때까지 반복 수 마이크로초 그래서 판단이 갈립니다 — 드문 실패는 예외, 자주 일어나는 실패는 반환값 파일이 없는 것은 예외로, 캐시에 없는 것은 optional 로. 루프 안에서 초당 수천 번 던지면 그 자체가 병목이 됩니다 임베디드처럼 표를 넣을 공간이 없으면 -fno-exceptions 로 끕니다 — 이때는 표준 라이브러리 일부도 함께 못 씁니다
"예외는 느리다"는 반쪽만 맞습니다 — 던지지 않는 동안은 비용이 0입니다.
DEEP 06

메모리 모델과 memory_order

멀티스레드에서 다른 CPU 가 내 쓰기를 언제 보게 되는가 를 정하는 규칙입니다. 락 프리 코드를 쓰려면 반드시 알아야 합니다.

memory_order — 무엇까지 보장받고 싶은가 relaxed 이 값 하나의 원자성만 앞뒤 순서는 보장 안 함 컴파일러 · CPU 가 재배치 쓰는 곳 · 단순 카운터 통계 집계처럼 순서가 아무 의미 없을 때만 acquire · release 두 스레드 사이의 약속 release 이전의 쓰기가 acquire 이후에 다 보인다 쓰는 곳 · 플래그 · 큐 락 없는 자료구조의 거의 모든 곳 seq_cst (기본값) 모든 스레드가 같은 하나의 순서를 본다 가장 비싸고 가장 안전 쓰는 곳 · 확신이 없을 때 먼저 이걸로 맞게 만들고 측정한 뒤에 낮추세요 여기서 틀린 코드는 테스트로 안 잡힙니다 내 PC 에서는 몇 달 잘 돌다가 다른 CPU · 다른 부하에서만 깨집니다. ThreadSanitizer 와 검증 도구를 꼭 함께 쓰세요
순서를 낮춰서 얻는 이득은 대개 몇 퍼센트입니다 — 잃을 수 있는 것에 비하면 싸지 않습니다.

왜 순서가 뒤바뀌나

세 곳에서 재배치가 일어납니다

  ① 컴파일러 — 최적화로 명령 순서를 바꿉니다
  ② CPU — 비순차 실행(out-of-order)
  ③ 캐시·스토어 버퍼 — 쓰기가 다른 코어에 보이는 시점이 늦어집니다

단일 스레드에서는 결과가 같도록 보장되지만
다른 스레드가 보는 순서는 다를 수 있습니다 ★

아키텍처별 강도
  x86-64 : 강한 모델 (StoreLoad 만 재배치)
           → acquire/release 가 사실상 공짜
  ARM · RISC-V : 약한 모델
           → 실제 장벽 명령이 생깁니다 ★
  ⚠ x86 에서 테스트한 락 프리 코드가 ARM(애플 실리콘·서버)에서
    깨지는 일이 실제로 있습니다
// 고전적인 실패
bool ready = false;
int  data  = 0;
// 스레드 A
data = 42;
ready = true;          // ★ 컴파일러·CPU 가 순서를 바꿀 수 있습니다
// 스레드 B
if (ready) use(data);  // data 가 아직 0일 수 있습니다

memory_order 여섯 가지

order의미
relaxed원자성만. 순서 보장 없음 — 카운터에
acquire이후 읽기·쓰기가 앞으로 못 옴 (load 에)
release이전 읽기·쓰기가 뒤로 못 감 (store 에)
acq_rel둘 다 (RMW 연산에)
seq_cst기본값. 전역 순서까지 보장 · 가장 안전
consume사실상 폐기 — 쓰지 마세요
// ✅ 생산자-소비자 — acquire/release 쌍 ★
std::atomic<bool> ready{false};
int data = 0;

// 생산자
data = 42;
ready.store(true, std::memory_order_release);   // 앞의 쓰기를 모두 공개

// 소비자
if (ready.load(std::memory_order_acquire))      // 그 이후를 모두 관찰
    use(data);                                   // ✅ 42 가 보장됩니다

// 카운터 — 순서가 필요 없으면 relaxed
counter.fetch_add(1, std::memory_order_relaxed);

이렇게도 쓸 수 있다 — 패턴으로 익히기 🟡 중급

자주 쓰는 세 가지

// ① 플래그로 종료 신호 — relaxed 로 충분한 경우가 많습니다
std::atomic<bool> stop{false};
// 워커
while (!stop.load(std::memory_order_relaxed)) { work(); }
// 메인
stop.store(true, std::memory_order_relaxed);
//   ★ 단, 종료 후 다른 데이터를 읽어야 하면 acquire/release 가 필요합니다

// ② 한 번만 초기화 — 표준 도구를 쓰세요
static std::once_flag flag;
std::call_once(flag, []{ init(); });
Logger& get() { static Logger l; return l; }    // ✅ 이것도 스레드 안전

// ③ 스핀 락 (아주 짧은 임계 구역에만)
class SpinLock {
    std::atomic_flag f_ = ATOMIC_FLAG_INIT;
public:
    void lock() {
        while (f_.test_and_set(std::memory_order_acquire))
            f_.wait(true, std::memory_order_relaxed);   // C++20 ★
    }
    void unlock() {
        f_.clear(std::memory_order_release);
        f_.notify_one();
    }
};
//   ⚠ 대기가 길면 뮤텍스가 낫습니다 (CPU 를 태우지 않으므로)

// C++20 atomic wait/notify — 스핀 없이 대기
std::atomic<int> v{0};
v.wait(0);                     // 0 이 아닐 때까지 대기 (효율적)
v.store(1); v.notify_all();

실수하기 쉬운 것들

// ① atomic 이면 안전하다는 오해 (탭 2 m12)
std::atomic<int> n{0};
n = n + 1;                     // ❌ 읽기와 쓰기가 분리 — 원자적이지 않음
n.fetch_add(1);                // ✅

// ② 두 atomic 을 함께 보는 것은 원자적이지 않습니다
if (a.load() > 0 && b.load() > 0) { }   // ❌ 그 사이에 바뀔 수 있습니다
//   → 뮤텍스나 단일 원자 구조체가 필요합니다

// ③ CAS 루프 — compare_exchange_weak 는 '가짜 실패' 가 있습니다
int expected = cur.load(std::memory_order_relaxed);
while (!cur.compare_exchange_weak(expected, expected + 1,
                                  std::memory_order_release,
                                  std::memory_order_relaxed)) {
    // expected 가 자동으로 갱신됩니다 ★
}
//   weak 는 루프 안에서, strong 은 단발 시도에

// ④ volatile 은 스레드 동기화 도구가 아닙니다 ★
volatile bool flag;            // ❌ 재배치를 막지 못합니다
std::atomic<bool> flag;        // ✅
//   volatile 은 '메모리 맵 I/O' 용입니다

// ⑤ 큰 타입의 atomic 은 락을 씁니다
std::atomic<BigStruct> a;
a.is_lock_free();              // false 면 내부적으로 뮤텍스 ★

실무에선 이렇게 — 확신이 없으면 seq_cst 🔴 실무

최적화보다 정확성

실무 지침

  ① 가능하면 공유하지 마세요 (탭 2 m12 · ⚡ e07)
  ② 공유해야 하면 뮤텍스 — 대부분 충분히 빠릅니다
  ③ 원자 변수는 단순한 카운터·플래그에
  ④ memory_order 는 기본값(seq_cst)으로 시작
  ⑤ 프로파일러가 그 자리를 가리킬 때만 relaxed·acquire/release 검토

왜 이 순서인가
  · seq_cst 와 acquire/release 의 차이는 x86 에서 거의 없습니다
  · ARM 에서도 대개 수 나노초 수준입니다
  · 반면 잘못된 relaxed 는 '재현 불가능한 버그' 를 만듭니다 ★
    · 테스트에서 안 나옵니다
    · 특정 CPU·특정 부하에서만 나타납니다
    · 디버거를 붙이면 사라집니다

★ 성능 이득은 몇 나노초, 잘못됐을 때의 대가는 며칠입니다.

검증 방법

# ① TSan — 데이터 경쟁 탐지 ★ (🛠️ b10)
g++ -fsanitize=thread -g -O1 app.cpp
#   WARNING: ThreadSanitizer: data race
#     Write of size 8 by thread T2 at core.cpp:88
#     Previous read of size 8 by thread T1 at core.cpp:71

# ② 스트레스 테스트 — 스레드 수·부하를 바꿔 가며 장시간
#    ⚠ 통과해도 '경쟁이 없다'는 증명은 아닙니다

# ③ 여러 아키텍처에서 테스트 ★
#    x86 에서만 테스트한 락 프리 코드는 ARM 에서 깨질 수 있습니다
#    CI 에 ubuntu-24.04-arm 러너를 넣으세요 (🛠️ b07)

# ④ 모델 체커 (연구 수준)
#    CDSChecker · GenMC · relacy
#    가능한 모든 인터리빙을 탐색합니다

# ⑤ 가장 확실한 방법 — 직접 쓰지 않기
#    moodycamel::ConcurrentQueue · folly · TBB (⚡ e08)
락 프리 자료구조를 직접 만들지 마세요 — ABA 문제·메모리 회수·메모리 순서 세 가지에서 반드시 버그가 납니다. 검증된 라이브러리를 쓰거나 샤딩된 뮤텍스로 충분한지 먼저 보세요(⚡ e08).

한 단계 더 — happens-before 와 동기화 🧬 전문가

표준이 정의하는 것

핵심 개념

  · sequenced-before   : 같은 스레드 안의 순서
  · synchronizes-with  : release store 와 그것을 읽은 acquire load 사이
  · happens-before     : 위 둘의 이행적 폐포

데이터 경쟁의 정의
  두 접근이 (1) 같은 메모리 위치에 (2) 최소 하나가 쓰기이고
  (3) happens-before 관계가 없으면 → 데이터 경쟁 → UB ★

즉 '동시에 접근하면 안 된다'가 아니라
'순서 관계를 만들지 않고 접근하면 안 된다' 입니다.

release/acquire 가 만드는 것
  스레드 A: X 를 쓰고 → flag.store(release)
  스레드 B: flag.load(acquire) 가 true 를 보면
            → A 의 모든 이전 쓰기가 B 에게 보입니다 ★
            (X 가 atomic 이 아니어도)

seq_cst 가 추가로 주는 것
  모든 seq_cst 연산에 대한 '단일 전역 순서'
  → 여러 변수 사이의 순서까지 일관되게 보입니다
  (독립 읽기 독립 쓰기 문제를 해결)

어셈블리로 확인하기

// godbolt 에서 아키텍처를 바꿔 가며 보세요 (⚡ e09)
std::atomic<int> a;
void f() {
    a.store(1, std::memory_order_relaxed);
    a.store(1, std::memory_order_release);
    a.store(1, std::memory_order_seq_cst);
}

// x86-64 결과
//   relaxed : mov  DWORD PTR a[rip], 1
//   release : mov  DWORD PTR a[rip], 1        ★ 같습니다 (공짜)
//   seq_cst : mov  eax, 1
//             xchg DWORD PTR a[rip], eax      ★ 이것만 비쌉니다

// AArch64 결과
//   relaxed : str  w8, [x9]
//   release : stlr w8, [x9]                   ★ 장벽 명령
//   seq_cst : stlr w8, [x9]                   (load 쪽에서 차이)

// → x86 에서는 release 가 공짜이므로 굳이 relaxed 를 쓸 이유가 적고,
//   ARM 에서는 차이가 있으니 핫 경로라면 검토할 가치가 있습니다 ★
“x86 에서 잘 되니 괜찮다”는 위험합니다 — x86 의 강한 메모리 모델이 잘못된 코드를 가려 줍니다. 애플 실리콘·ARM 서버가 흔해진 지금은 ARM 에서도 반드시 테스트해야 합니다.
정리 — 멀티스레드에서 쓰기가 다른 코어에 보이는 시점을 정하는 것이 메모리 모델입니다. 생산자-소비자는 release/acquire 쌍, 단순 카운터는 relaxed, 확신이 없으면 기본값을 쓰세요. 그리고 x86 에서만 테스트하지 마세요 — ARM 에서 드러납니다.
DEEP 07

ABI 호환성 — 배포의 벽

C++ 에는 표준 ABI 가 없습니다. 컴파일러나 표준 라이브러리 버전이 다르면 같은 소스도 안 붙습니다. 라이브러리를 배포할 때 가장 자주 막히는 지점입니다.

헤더를 바꿨을 때 — 다시 컴파일하지 않은 프로그램이 깨지는가 깨집니다 — 조용히, 크래시로 클래스에 멤버 변수 추가 · 삭제 → 객체 크기와 필드 위치가 바뀝니다 가상 함수 추가 · 순서 변경 → vtable 칸 번호가 밀립니다 인자 · 반환 타입 변경 · 인라인 함수 본문 수정 안전합니다 새 함수 · 새 클래스 추가 → 기존 심볼은 그대로 남습니다 .cpp 안의 구현만 수정 → 헤더가 안 바뀌면 영향 없습니다 private 함수 추가 (가상이 아닌 경우) 피하는 방법 C ABI 로만 공개 가장 확실합니다 Pimpl 관용구 멤버를 전부 숨깁니다 정적 링크로 배포 문제 자체가 사라집니다
ABI 문제의 무서움은 컴파일도 되고 링크도 되는데 실행만 이상하다는 데 있습니다.

무엇이 ABI 를 깨나

바이너리 호환을 깨는 변경 (재컴파일 없이는 못 씁니다)

  · 클래스에 멤버 변수 추가·삭제·순서 변경  ★ 크기가 바뀜
  · 가상 함수 추가·삭제·순서 변경           ★ vtable 배치가 바뀜
  · 기반 클래스 추가·변경
  · 함수 시그니처 변경 (인자 타입·개수·const)
  · 인라인 함수의 내용 변경                  ★ 이미 인라인된 코드가 남음
  · 템플릿 정의 변경
  · enum 값 변경 · 순서 변경
  · 기본 인자 변경 (호출부에 박힘)
  · 구조체 정렬·패딩 변경 (빌드 플래그 포함)

소스 호환은 되는데 ABI 는 깨지는 예
  private 멤버 하나만 추가 → 재컴파일하면 되지만
  기존 바이너리와는 못 씁니다 ★
컴파일러·표준 라이브러리 차이

  · GCC 와 Clang : 대체로 호환 (같은 Itanium ABI · libstdc++)
  · MSVC : 완전히 다릅니다 (이름 맹글링·예외·레이아웃)
  · libstdc++ vs libc++ : std:: 타입 레이아웃이 다릅니다 ★
  · GCC 5 의 _GLIBCXX_USE_CXX11_ABI 전환
      → std::string 의 ABI 가 바뀌었습니다
      undefined reference to std::__cxx11::basic_string...

해법 — 경계를 C ABI 로 ★

// ❌ C++ 타입을 공개 인터페이스에 노출
class MYLIB_API Engine {
public:
    std::vector<double> process(const std::string& input);   // ⚠
    //   쓰는 쪽이 같은 컴파일러·같은 표준 라이브러리여야 합니다
private:
    std::vector<double> buf_;      // 멤버를 추가하면 ABI 파괴
};

// ✅ C ABI 로 (🔗 f01·f03)
typedef struct mylib_engine mylib_engine;
MYLIB_EXPORT mylib_engine* mylib_engine_create(void);
MYLIB_EXPORT int32_t mylib_process(mylib_engine*, const double*, int64_t,
                                   double*, int64_t*);
//   숫자와 포인터만 → 어느 컴파일러에서든 동작합니다

// ✅ 절충 — Pimpl 로 멤버를 감추기 (탭 1 x09)
class MYLIB_API Engine {
public:
    Engine(); ~Engine();
    Engine(Engine&&) noexcept;
    void process(...);
private:
    struct Impl;
    std::unique_ptr<Impl> p_;      // ★ 크기가 항상 8바이트
};
//   내부를 바꿔도 크기가 안 변합니다
//   ⚠ 다만 인터페이스의 std:: 타입은 여전히 위험합니다
이 가이드의 모든 탭이 같은 결론에 닿습니다 — 파이썬은 abi3, Node 는 N-API, C++ 라이브러리는 C ABI. 안정된 좁은 경계를 두는 것이 배포 문제를 근본적으로 없앱니다.

이렇게도 쓸 수 있다 — ABI 변경 감지 🟡 중급

도구로 검사하기

# ① abidiff — 두 버전의 ABI 를 비교 ★
sudo apt install abigail-tools
abidiff --headers-dir1 v1/include --headers-dir2 v2/include \
        v1/libmylib.so v2/libmylib.so
#   Functions changes summary: 0 Removed, 1 Changed, 2 Added
#   Variables changes summary: ...
#   → 0 이 아닌 종료 코드로 CI 에서 실패시킬 수 있습니다

# ② 심볼 목록을 커밋에 기록 (리뷰에서 변경이 보이게)
nm -DC --defined-only libmylib.so | awk '{print $3}' | sort > abi/symbols.txt
git diff --exit-code abi/symbols.txt

# ③ 구조체 레이아웃 기록
pahole -C mylib_config libmylib.so > abi/layout.txt
#   필드 오프셋·패딩까지 표로 나옵니다 ★

# ④ 컴파일 시점 검증
static_assert(sizeof(mylib_config) == 24, "ABI 변경 감지");
static_assert(offsetof(mylib_config, window) == 16);

# ⑤ 옛 클라이언트로 새 라이브러리 테스트
git checkout v1.0 -- tests/
./build/test_v1_0          # 새 .so 를 로드해 통과해야 합니다 ★

버전 관리 규칙

# soname — 런타임이 찾는 이름
set_target_properties(mylib PROPERTIES
    VERSION   ${PROJECT_VERSION}      # 1.2.3
    SOVERSION 1)                       # ★ ABI 세대

# 결과
#   libmylib.so       → 심볼릭 링크 (개발용)
#   libmylib.so.1     → soname (실행 파일이 기록하는 이름) ★
#   libmylib.so.1.2.3 → 실제 파일

# 규칙
#   ABI 호환 변경 (기능 추가)  → 1.2.3 → 1.3.0, soname 유지
#   ABI 파괴                  → soname 을 2로 올림
#     → 옛 프로그램은 libmylib.so.1 을 계속 쓸 수 있습니다 ★

# 확인
readelf -d libmylib.so.1.2.3 | grep SONAME
objdump -p app | grep NEEDED
soname 을 올리지 않고 ABI 를 깨면 — 사용자 시스템에서 기존 프로그램이 조용히 깨집니다. 크래시하면 그나마 낫고, 잘못된 값으로 계속 도는 경우가 최악입니다.

실무에선 이렇게 — 배포 전략 🔴 실무

상황별 선택

배포 형태전략
소스 배포 (헤더 전용)ABI 걱정 없음 — 가장 단순 ★
사내 정적 라이브러리같은 컴파일러로 통일 · CI 로 강제
공개 공유 라이브러리C ABI + Pimpl ★
파이썬 확장abi3(🐍 y03)
Node 애드온N-API(🟨 j10)
플러그인C ABI + 버전 확인 함수(🔗 f09)
// 플러그인은 반드시 버전을 먼저 확인하게
extern "C" int32_t plugin_abi_version(void) { return MYLIB_ABI_VERSION; }

// 호스트 쪽
auto ver = (int32_t(*)())dlsym(h, "plugin_abi_version");
if (!ver || ver() != MYLIB_ABI_VERSION) {
    log("플러그인 ABI 불일치 — 로드하지 않습니다");
    dlclose(h);
    return;
}
//   ★ 이 검사 없이 로드하면 조용히 메모리가 깨집니다

C++ 런타임 문제 회피

# ① C++ 표준 라이브러리를 정적으로 (🛠️ b08)
-static-libstdc++ -static-libgcc
#   libstdc++ 버전 불일치가 통째로 사라집니다 ★
#   ⚠ 예외를 라이브러리 경계 너머로 던지면 안 됩니다 (런타임이 둘이 됨)

# ② 오래된 환경에서 빌드
docker run quay.io/pypa/manylinux_2_28_x86_64 ...

# ③ 요구하는 심볼 버전 확인
objdump -T libmylib.so | grep -E "GLIBCXX|CXXABI" \
  | sed 's/.*\(GLIBCXX_[0-9.]*\).*/\1/' | sort -uV | tail -3
#   GLIBCXX_3.4.29   ← 이 이상의 libstdc++ 이 필요합니다

# ④ 어느 표준 라이브러리를 쓰는지
ldd libmylib.so | grep -E "libstdc|libc\+\+"

# ⑤ MSVC 런타임
#   /MD  동적 (VC++ 재배포 패키지 필요)
#   /MT  정적 (파일이 커지지만 의존성 없음) ★
#   ⚠ 한 프로그램 안에서 섞이면 힙이 달라져 크래시합니다 (🔗 f07)
가장 확실한 조합 — C ABI 인터페이스 + C++ 런타임 정적 링크 + 오래된 환경에서 빌드. 이 셋이면 리눅스 배포 문제의 대부분이 사라집니다.

한 단계 더 — 왜 표준 ABI 가 없나 🧬 전문가

역사와 현실

· C++ 표준은 ABI 를 정의하지 않습니다
  (구현이 자유롭게 최적화할 수 있게 하려고)

· 사실상의 표준
    Itanium C++ ABI  : GCC · Clang · 대부분의 유닉스
    MSVC ABI         : 윈도우 (문서화되어 있지만 다릅니다)

· 그래서 같은 플랫폼에서도
    libstdc++ 로 빌드한 것과 libc++ 로 빌드한 것이 안 붙습니다
    std::string · std::list 등의 내부 배치가 다릅니다 ★

★ ABI 안정성이 표준 발전을 막기도 합니다
  예: std::regex 는 성능이 나쁘다고 알려져 있지만
      고치면 ABI 가 깨져서 그대로 두고 있습니다
      (std::unordered_map 도 비슷한 상황)
  → C++ 위원회에서 "ABI break" 는 오래된 논쟁 주제입니다

실무 결론
  · 라이브러리 경계에서는 std:: 타입을 노출하지 않는다
  · 필요하면 C ABI 로 감싼다
  · 사내 코드는 컴파일러를 통일한다

맹글링으로 보는 ABI

# 같은 함수가 컴파일러마다 다른 이름을 갖습니다 (🔗 f02)
void f(const std::string&);

# GCC/Clang (Itanium ABI · 새 ABI)
_Z1fRKNSt7__cxx1112basic_stringIcSt11char_traitsIcESaIcEEE

# GCC (옛 ABI · _GLIBCXX_USE_CXX11_ABI=0)
_Z1fRKSs

# MSVC
?f@@YAXAEBV?$basic_string@DU?$char_traits@D@std@@V?$allocator@D@2@@std@@@Z

# 복원
c++filt _Z1fRKSs
undname "?f@@YAXAEBV..."

# ★ 이름이 다르다 = 링크가 안 된다
#   undefined reference to `f(std::__cxx11::basic_string<...>)'
#   → 이 메시지에 __cxx11 이 보이면 ABI 불일치입니다

# 강제로 맞추기 (옛 라이브러리와 연동할 때)
g++ -D_GLIBCXX_USE_CXX11_ABI=0 ...
에러 메시지에서 __cxx11 을 찾으세요 — undefined reference 에 이것이 보이면 ABI 불일치입니다. 라이브러리를 다시 빌드하거나 플래그를 맞춰야 합니다.
정리 — C++ 에는 표준 ABI 가 없습니다 — 컴파일러·표준 라이브러리·빌드 플래그가 다르면 안 붙습니다. 해법은 하나입니다 — 공개 경계를 C ABI 로 만들고 C++ 타입을 인터페이스에 노출하지 않는 것. ABI 변경은 abidiff 로 CI 에서 감시하세요.
DEEP 08

템플릿 인스턴스화와 컴파일 시간

템플릿은 쓰는 곳마다 코드를 새로 생성합니다. 그래서 빌드가 느려지고 바이너리가 커집니다 — 그 구조와 대책입니다.

무슨 일이 일어나나

template<class T> T max_of(T a, T b) { return a < b ? b : a; }

max_of(1, 2);        // max_of<int> 생성
max_of(1.0, 2.0);    // max_of<double> 생성      ★ 별개의 코드
max_of<long>(1, 2);  // max_of<long> 생성

// 각 인스턴스는
//   · 별도의 코드를 만듭니다 (바이너리 증가)
//   · 약한 심볼로 표시되어 링커가 중복을 제거합니다
//   · 그 파일을 포함한 '모든 번역 단위'에서 다시 생성됩니다 ★
//     (링커가 나중에 버리지만 컴파일 시간은 이미 소모)

// std::vector<T> 하나를 쓰면
//   생성자·소멸자·push_back·reserve·... 수십 개 함수가 T 마다 생성됩니다
//   vector<int> · vector<double> · vector<MyType> → 전부 별개

// 조합 폭발
template<class T, class Alloc, class Policy, int N> class Matrix;
//   타입 3개 × 정책 3개 = 9개 인스턴스 ★
# 실제로 얼마나 생성됐는지
nm -C --defined-only build/app | grep " W " | wc -l
nm -C --size-sort -S build/app | tail -20        # 큰 인스턴스

측정하기

# ★ clang 의 -ftime-trace — 가장 유용합니다
clang++ -ftime-trace -c src/big.cpp
#   big.json 을 ui.perfetto.dev 나 chrome://tracing 에 넣으면
#   어느 헤더·어느 템플릿이 시간을 먹는지 그림으로 보입니다 ★

# 전체 빌드 분석
cmake -B build -G Ninja -DCMAKE_CXX_FLAGS="-ftime-trace"
cmake --build build
python3 -m ninjatracing build/.ninja_log > trace.json

# GCC
g++ -ftime-report -c big.cpp
#   phase parsing / template instantiation 시간이 나옵니다

# 전처리 결과 크기 (헤더 부담의 대리 지표)
for f in src/*.cpp; do
  echo "$(g++ -E -Iinclude $f 2>/dev/null | wc -l) $f"
done | sort -rn | head

# 인스턴스화 횟수 (clang)
clang++ -Xclang -print-instantiation-stats -c big.cpp
-ftime-trace 를 한 번 돌려 보세요 — “빌드가 느리다”가 “이 헤더 하나가 40%를 먹는다”로 바뀝니다. 대개 한두 개의 무거운 헤더가 원인입니다.

이렇게도 쓸 수 있다 — 줄이는 기법 🟡 중급

명시적 인스턴스화

// matrix.h
template<class T> class Matrix { ... };
extern template class Matrix<double>;     // ★ "여기서 만들지 마"
extern template class Matrix<float>;

// matrix.cpp
template class Matrix<double>;             // 여기서 한 번만 생성
template class Matrix<float>;

//   → 헤더를 포함한 다른 파일들은 인스턴스화를 건너뜁니다
//     빌드 시간이 크게 줄어듭니다 (파일이 많을수록 효과가 큼) ★

// 표준 라이브러리도 이렇게 되어 있습니다
extern template class std::basic_string<char>;   // libstdc++ 내부

// 함수 템플릿도 가능
extern template int max_of<int>(int, int);
// 타입 소거로 코드 팽창 막기 (🛠️ b03)
// 헤더에는 얇은 템플릿만, 실제 구현은 .cpp 의 비템플릿 함수로
void process_impl(const void* data, std::size_t n, int type_tag);   // .cpp

template<class T>
void process(std::span<const T> v) {                    // 헤더
    process_impl(v.data(), v.size(), tag_of<T>());
}
//   → 인스턴스화되는 코드가 아주 작아집니다 ★

헤더 부담 줄이기

// ① 전방 선언 (탭 1 x09)
class Engine;                        // #include "engine.h" 대신
void f(Engine&);                     // 포인터·참조는 정의가 불필요

// ② Pimpl — 무거운 의존성을 .cpp 에 가두기
class Widget {
    struct Impl;
    std::unique_ptr<Impl> p_;
};

// ③ 미리 컴파일된 헤더 (CMake 3.16+)
target_precompile_headers(app PRIVATE
    <vector> <string> <memory> <algorithm>)
//   자주 쓰는 무거운 헤더를 한 번만 파싱 ★

// ④ 표준 헤더도 무겁습니다
#include <iostream>      // 3만 줄 · 전역 객체 초기화까지
#include <cstdio>        // 훨씬 가볍습니다
#include <regex>         // 매우 무겁습니다 (성능도 나쁩니다)
#include <format>        // <iostream> 보다 가볍고 빠릅니다 ★

// ⑤ include-what-you-use
include-what-you-use -p build src/core.cpp
//   안 쓰는 include 를 찾아 줍니다

// ⑥ Unity 빌드 (여러 .cpp 를 합쳐 컴파일)
set(CMAKE_UNITY_BUILD ON)
//   ⚠ 익명 namespace 충돌·매크로 누출 주의

실무에선 이렇게 — 빌드 시간 관리 🔴 실무

효과가 큰 순서

① ccache — 재빌드가 몇 배 빨라집니다 (🛠️ b01) ★
   가장 쉽고 효과가 큽니다

② 빠른 링커 — mold · lld
   링크가 병목인 프로젝트가 많습니다

③ 헤더 의존성 줄이기
   전방 선언 · Pimpl · 무거운 헤더를 .cpp 로
   → -ftime-trace 로 대상을 찾으세요

④ 명시적 인스턴스화
   같은 템플릿을 여러 파일에서 쓰는 경우

⑤ PCH · Unity 빌드
   설정이 간단하지만 부작용이 있을 수 있습니다

⑥ 모듈 (C++20)
   근본 해결이지만 2026년에도 도구 지원이 고르지 않습니다
   → 확장 모듈과 섞어 쓰기에는 아직 이릅니다

측정 없이 하지 마세요
  -ftime-trace 로 상위 5개를 찾고 거기만 손대면
  대개 절반 이하로 줄어듭니다 ★

바이너리 크기

# 무엇이 크기를 차지하나
bloaty build/app -d compileunits,symbols | head -20
nm -C --size-sort -S build/app | tail -20

# 템플릿 인스턴스가 상위에 몰려 있으면 팽창 문제입니다 ★

# 줄이기
-ffunction-sections -fdata-sections -Wl,--gc-sections
-Os                          # 크기 우선
-fvisibility=hidden          # (🔗 f02)
strip --strip-unneeded

# identical code folding — 같은 코드를 합칩니다
-Wl,--icf=all                # gold · lld
#   템플릿 인스턴스는 내용이 같은 경우가 많아 효과가 큽니다 ★

# 확인
size -A build/app
ls -lh build/app
확장 모듈에서 특히 중요합니다 — pybind11 은 템플릿을 많이 써서 바인딩 파일 하나가 수십 초 걸리기도 합니다. 바인딩을 여러 파일로 쪼개면 병렬 컴파일이 되어 크게 빨라집니다(🐍 y03).

한 단계 더 — 템플릿 메타프로그래밍의 비용 🧬 전문가

재귀보다 폴드·팩 확장

// ❌ 재귀 템플릿 — 인스턴스가 N개 생깁니다
template<int N> struct Sum { static constexpr int v = N + Sum<N-1>::v; };
template<> struct Sum<0> { static constexpr int v = 0; };
Sum<1000>::v;                    // 1001개의 인스턴스 ★

// ✅ constexpr 함수 — 인스턴스가 하나
constexpr int sum(int n) { int s = 0; for (int i = 1; i <= n; ++i) s += i; return s; }
constexpr int v = sum(1000);

// ✅ 폴드 표현식 (C++17) — 재귀 없이 (탭 2 m08)
template<class... Ts> auto total(Ts... xs) { return (xs + ...); }

// ❌ 재귀로 타입 리스트 처리
template<class... Ts> struct Count;
// ✅ sizeof...
constexpr std::size_t n = sizeof...(Ts);

// 컴파일 시간 비교 — 직접 재 보세요
// clang++ -ftime-trace 로 InstantiateFunction 시간 확인

// 실무 지침
//   · 깊은 재귀 템플릿을 피하세요 (컴파일러 제한에도 걸립니다)
//   · constexpr 함수로 되는 것은 그쪽으로 (탭 2 m04)
//   · 컨셉으로 제약을 표현하면 SFINAE 보다 훨씬 빠릅니다 (탭 2 m09) ★

에러 메시지와 디버깅

# 템플릿 에러가 수백 줄일 때
clang++ -fcaret-diagnostics -ftemplate-backtrace-limit=3 main.cpp
g++ -ftemplate-backtrace-limit=0 main.cpp    # 전체 보기

# 컨셉을 쓰면 근본적으로 짧아집니다 (탭 2 m09) ★
template<std::ranges::range R> void f(R&&);
#   error: constraints not satisfied
#   note: because 'int' does not satisfy 'range'   ← 3줄

# 타입이 무엇으로 추론됐는지 확인
template<class T> struct ShowType;      // 정의를 일부러 안 함
ShowType<decltype(x)> _;                 // 에러 메시지에 타입이 나옵니다 ★

# 인스턴스화 깊이 제한
-ftemplate-depth=256

# 컴파일러가 만든 코드 확인
clang++ -Xclang -ast-print -fsyntax-only main.cpp | head -50
컨셉 도입이 가장 효과적입니다 — 에러 메시지가 짧아질 뿐 아니라 SFINAE 보다 컴파일도 빠릅니다. 기존 템플릿에 제약만 붙여도 개선됩니다(탭 2 m09).
정리 — 템플릿은 쓰는 곳마다 코드를 새로 만듭니다 — 빌드 시간과 바이너리 크기가 그 대가입니다. -ftime-trace 로 상위 원인을 찾고, ccache·전방 선언·명시적 인스턴스화로 줄이세요. 재귀 템플릿보다 constexpr 함수와 폴드 표현식이 훨씬 쌉니다.
같은 코드를 여러 번 만들었다가, 링커가 하나만 남기고 버립니다 헤더의 template<T> f() a.cpp f<int> 를 통째로 생성 b.cpp f<int> 를 또 생성 c.cpp f<int> 를 또 생성 링커가 중복을 버립니다 — 만든 시간은 그대로 손해 줄이는 순서 — 위에서부터 효과가 큽니다 ① 헤더에서 무거운 헤더 빼기(전방 선언·pimpl) · ② 자주 쓰는 타입은 extern template 으로 한 곳에서만 · ③ ccache · ④ 유니티 빌드
C++ 빌드가 느린 진짜 이유는 대개 헤더가 헤더를 끌고 오는 구조에 있습니다.
같은 코드를 여러 번 만들었다가, 링커가 하나만 남기고 버립니다 헤더의 template<T> f() a.cpp f<int> 를 통째로 생성 b.cpp f<int> 를 또 생성 c.cpp f<int> 를 또 생성 링커가 중복을 버립니다 — 만든 시간은 그대로 손해 줄이는 순서 — 위에서부터 효과가 큽니다 ① 헤더에서 무거운 헤더 빼기(전방 선언·pimpl) · ② 자주 쓰는 타입은 extern template 으로 한 곳에서만 · ③ ccache · ④ 유니티 빌드
C++ 빌드가 느린 진짜 이유는 대개 헤더가 헤더를 끌고 오는 구조에 있습니다.
DEEP 09

커스텀 할당자

메모리를 어떻게 나눠 줄지 직접 정합니다. 지연 시간이 일정해야 하는 게임·거래 시스템에서 씁니다.

언제 필요한가

기본 malloc 으로 충분하지 않은 경우

  · 지연 시간이 일정해야 함 (p99 가 중요) ⚡ e03
  · 할당·해제가 아주 잦음
  · 수명이 같은 객체를 묶어서 관리 (요청 단위)
  · 특수 메모리 (공유 메모리 · GPU · 정렬 요구)
  · 메모리 사용량을 정확히 통제·계측
  · 임베디드 — 힙이 아예 없거나 제한적

먼저 시도할 것 (대개 여기서 끝납니다) ⚡ e03
  ① reserve · 버퍼 재사용
  ② PMR monotonic_buffer_resource
  ③ 할당자 교체 (tcmalloc · jemalloc · mimalloc)
  ④ 그래도 부족하면 커스텀

★ 커스텀 할당자는 만들기 쉽지만 '올바르게' 만들기는 어렵습니다.
  정렬·예외 안전·스레드 안전·재사용 규칙을 전부 지켜야 합니다.

PMR 이 대부분의 답입니다 (C++17)

#include <memory_resource>

// ① 아레나 — 요청 하나를 처리하고 통째로 버리기 ★
std::array<std::byte, 1<<20> buf;
std::pmr::monotonic_buffer_resource arena{buf.data(), buf.size()};
std::pmr::vector<Node> nodes{&arena};
std::pmr::string       s{&arena};
//   해제는 arena 소멸 시 통째로 — 개별 free 가 없어 매우 빠릅니다

// ② 풀 — 크기별로 미리 나눠 두기
std::pmr::unsynchronized_pool_resource pool;    // 단일 스레드
std::pmr::synchronized_pool_resource   spool;   // 스레드 안전

// ③ 계측 — 얼마나 쓰는지 추적
class Tracking : public std::pmr::memory_resource {
    std::pmr::memory_resource* up_ = std::pmr::new_delete_resource();
    std::size_t total_ = 0, peak_ = 0;
    void* do_allocate(std::size_t n, std::size_t a) override {
        total_ += n; peak_ = std::max(peak_, total_);
        return up_->allocate(n, a);
    }
    void do_deallocate(void* p, std::size_t n, std::size_t a) override {
        total_ -= n; up_->deallocate(p, n, a);
    }
    bool do_is_equal(const memory_resource& o) const noexcept override {
        return this == &o;
    }
public:
    std::size_t peak() const { return peak_; }
};
PMR 의 장점 — 할당자가 타입의 일부가 아니라 런타임 값입니다. std::vector<T, MyAlloc> 는 다른 타입이 되지만 std::pmr::vector<T> 는 하나의 타입이라 인터페이스가 오염되지 않습니다.

이렇게도 쓸 수 있다 — 직접 만들기 🟡 중급

아레나 할당자

class Arena {
    std::byte* buf_;
    std::size_t cap_, off_ = 0;
public:
    Arena(std::byte* b, std::size_t n) : buf_(b), cap_(n) {}

    void* alloc(std::size_t n, std::size_t align) {
        std::size_t cur = reinterpret_cast<std::uintptr_t>(buf_ + off_);
        std::size_t pad = (align - (cur % align)) % align;    // ★ 정렬 보정
        if (off_ + pad + n > cap_) throw std::bad_alloc{};
        void* p = buf_ + off_ + pad;
        off_ += pad + n;
        return p;
    }
    void reset() noexcept { off_ = 0; }      // ★ 해제는 통째로
};

// PMR 로 감싸면 표준 컨테이너에 바로 쓸 수 있습니다
class ArenaResource : public std::pmr::memory_resource {
    Arena& a_;
    void* do_allocate(std::size_t n, std::size_t align) override {
        return a_.alloc(n, align);
    }
    void do_deallocate(void*, std::size_t, std::size_t) override { }  // no-op
    bool do_is_equal(const memory_resource& o) const noexcept override {
        return this == &o;
    }
public:
    explicit ArenaResource(Arena& a) : a_(a) {}
};

고정 크기 풀

// 같은 크기 객체를 아주 많이 만들고 버릴 때 (게임 엔티티·패킷)
template<std::size_t Size, std::size_t Align>
class FixedPool {
    union Slot { std::byte data[Size]; Slot* next; };
    static_assert(Size >= sizeof(Slot*));

    std::vector<std::unique_ptr<Slot[]>> blocks_;
    Slot* free_ = nullptr;
    std::size_t block_size_ = 256;

    void grow() {
        auto blk = std::make_unique<Slot[]>(block_size_);
        for (std::size_t i = 0; i < block_size_; ++i) {
            blk[i].next = free_;
            free_ = &blk[i];
        }
        blocks_.push_back(std::move(blk));
        block_size_ *= 2;
    }
public:
    void* allocate() {
        if (!free_) grow();
        Slot* s = free_;
        free_ = s->next;
        return s;                            // ★ O(1) · 포인터 하나 이동
    }
    void deallocate(void* p) noexcept {
        auto* s = static_cast<Slot*>(p);
        s->next = free_;
        free_ = s;
    }
};
//   할당·해제가 각각 몇 사이클 — malloc 대비 10배 이상 빠릅니다 ★

실무에선 이렇게 — 주의할 점 🔴 실무

지켜야 할 규칙

  • 정렬을 지키세요 — alignof(T) 를 만족하지 않으면 UB 이고 ARM 에서는 크래시합니다
  • 수명 관리 — 아레나가 죽으면 그 안의 객체도 전부 무효입니다. 소멸자가 필요한 객체는 명시적으로 파괴해야 합니다 ★
  • 스레드 안전성을 명시 — 대부분의 커스텀 할당자는 단일 스레드용입니다
  • 예외 안전 — 할당 실패 시 std::bad_alloc
  • 계측 가능하게 — 사용량·피크·실패를 기록
  • Sanitizer 와 함께 검증 — ASan 은 커스텀 할당자를 모릅니다. __asan_poison_memory_region 으로 알려 줄 수 있습니다
// ⚠ 아레나에서 소멸자가 있는 객체를 쓸 때
std::pmr::vector<std::pmr::string> v{&arena};
v.push_back("긴 문자열...");
arena.release();                 // ★ string 의 소멸자가 안 불립니다
//   → monotonic_buffer_resource 는 이것을 허용합니다
//     (trivially destructible 이 아니면 주의)
//   → 안전하게 하려면 v 를 먼저 파괴하세요

측정으로 정당화하기

// 커스텀 할당자를 도입하기 전후를 반드시 비교하세요
// ⚡ e03 의 할당 카운터 + 지연 히스토그램

struct Stats {
    std::vector<double> samples;
    void record(double us) { samples.push_back(us); }
    void report() {
        std::ranges::sort(samples);
        auto pct = [&](double p) { return samples[samples.size() * p]; };
        std::printf("p50 %.1f  p99 %.1f  p999 %.1f  max %.1f μs\n",
                    pct(0.5), pct(0.99), pct(0.999), samples.back());
    }
};

// 전형적인 개선
//   기본 malloc : p50 0.8μs  p99 45μs   max 800μs   ★ 튀는 값
//   아레나      : p50 0.1μs  p99 0.2μs  max 3μs     ★ 일정함
//
// ★ 평균은 비슷해도 p99 가 크게 달라집니다.
//   이것이 커스텀 할당자를 쓰는 진짜 이유입니다.
대부분의 프로젝트는 필요 없습니다 — reserve · 버퍼 재사용 · PMR 아레나로 충분합니다(⚡ e03). 커스텀 할당자는 유지보수 부담이 크니 측정으로 정당화된 경우에만 도입하세요.

한 단계 더 — Allocator 요구사항 🧬 전문가

표준 할당자 인터페이스

// C++17 이후 최소 요구사항은 간단해졌습니다
template<class T>
struct MyAlloc {
    using value_type = T;

    MyAlloc() = default;
    template<class U> constexpr MyAlloc(const MyAlloc<U>&) noexcept {}

    T* allocate(std::size_t n) {
        if (n > std::numeric_limits<std::size_t>::max() / sizeof(T))
            throw std::bad_array_new_length{};
        void* p = ::operator new(n * sizeof(T), std::align_val_t{alignof(T)});
        return static_cast<T*>(p);
    }
    void deallocate(T* p, std::size_t) noexcept {
        ::operator delete(p, std::align_val_t{alignof(T)});
    }

    template<class U> bool operator==(const MyAlloc<U>&) const noexcept
        { return true; }        // ★ 상태가 있으면 정확히 비교해야 합니다
};

// 상태가 있는 할당자의 함정
//   · 두 컨테이너가 다른 할당자를 가지면 swap · 이동이 복잡해집니다
//   · propagate_on_container_* 특성으로 동작을 정합니다
using POCCA = std::true_type;   // copy assignment 시 전파
using POCMA = std::true_type;   // move assignment 시 전파
using POCS  = std::true_type;   // swap 시 전파
//   ★ 이것이 PMR 이 나온 이유입니다 — 타입이 아니라 값으로 다룹니다

전역 operator new 교체

// 프로그램 전체의 할당을 가로채기
void* operator new(std::size_t n) {
    if (void* p = my_malloc(n)) return p;
    throw std::bad_alloc{};
}
void* operator new(std::size_t n, std::align_val_t a) { ... }
void  operator delete(void* p) noexcept { my_free(p); }
void  operator delete(void* p, std::size_t) noexcept { my_free(p); }
void  operator delete(void* p, std::align_val_t) noexcept { my_free(p); }
//   ★ 모든 오버로드를 제공해야 합니다 (안 그러면 섞여서 크래시)

// 더 간단한 방법 — 링크 시점에 교체 (⚡ e03)
LD_PRELOAD=/usr/lib/libjemalloc.so ./app
target_link_libraries(app PRIVATE mimalloc)

// ⚠ 확장 모듈에서는 하지 마세요
//   파이썬·Node 프로세스 전체의 할당을 바꾸게 됩니다
//   → 호스트 런타임과 충돌할 수 있습니다 ★

// 계측만 하려면 (⚡ e03)
static std::atomic<std::size_t> g_allocs{0};
void* operator new(std::size_t n) { ++g_allocs; return std::malloc(n); }
확장 모듈에서 전역 operator new 를 교체하지 마세요 — 같은 프로세스의 다른 모듈과 호스트 런타임까지 영향을 받습니다. 필요하면 컨테이너별 PMR 로 범위를 한정하세요.
정리 — 커스텀 할당자는 지연 시간을 일정하게 만들기 위한 도구입니다. 대부분은 PMR 아레나(monotonic_buffer_resource)로 충분하고, 직접 만들 때는 정렬·수명·스레드 안전성을 반드시 지켜야 합니다. 그리고 p99 개선을 수치로 보여 도입을 정당화하세요.
DEEP 10

인트린식과 인라인 어셈블리

컴파일러가 뽑아 주지 못하는 명령을 직접 씁니다. 마지막 수단이고, 대가는 이식성과 유지보수입니다.

인트린식이 필요한 경우

// ① 컴파일러가 자동으로 못 내는 명령
#include <immintrin.h>
_mm_popcnt_u64(x);              // 비트 세기 (std::popcount 로 대체 가능 ★)
_pdep_u64(x, mask);             // 비트 분배 (BMI2)
_mm_crc32_u64(crc, x);          // CRC32 하드웨어 가속
_mm256_shuffle_epi8(v, idx);    // 바이트 셔플

// ② 표준에 있으면 표준을 쓰세요 (C++20) ★
#include <bit>
std::popcount(x);       std::countl_zero(x);   std::countr_zero(x);
std::rotl(x, 3);        std::has_single_bit(x); std::bit_width(x);
//   → 이식성 있고 컴파일러가 최적 명령으로 바꿔 줍니다

// ③ 원자·장벽
std::atomic_thread_fence(std::memory_order_acquire);   // 표준 (d06)
_mm_pause();                    // 스핀 루프에서 (하이퍼스레딩 양보)

// ④ 프리페치 (⚡ e02)
__builtin_prefetch(p, 0, 3);
_mm_prefetch(p, _MM_HINT_T0);

// ⑤ 캐시 우회 쓰기 (큰 데이터를 한 번만 쓸 때)
_mm256_stream_pd(dst, v);       // 캐시를 오염시키지 않음
_mm_sfence();                   // ★ 스트리밍 후 반드시

인라인 어셈블리는 정말 마지막

// GCC/Clang 확장 인라인 어셈블리
std::uint64_t rdtsc() {
    std::uint32_t lo, hi;
    asm volatile("rdtsc" : "=a"(lo), "=d"(hi));    // 출력 제약
    return (std::uint64_t(hi) << 32) | lo;
}
//   → 표준 대안이 있습니다: std::chrono::steady_clock

// 최적화 방지 장벽 (벤치마크에 — ⚡ e01)
template<class T> void keep(T const& v) {
    asm volatile("" : : "r,m"(v) : "memory");
}

// ⚠ 인라인 어셈블리의 문제
//   · 컴파일러가 최적화하지 못하는 블랙박스가 됩니다
//   · 제약(constraint) 문법이 어렵고 실수하면 조용히 깨집니다
//   · 아키텍처·컴파일러마다 문법이 다릅니다 (MSVC 는 x64 에서 금지)
//   · 레지스터 할당을 방해해 오히려 느려질 수 있습니다 ★

// 대안 순서
//   ① 표준 함수 (<bit> · <atomic> · <chrono>)
//   ② 컴파일러 내장 함수 (__builtin_*)
//   ③ 인트린식
//   ④ 인라인 어셈블리     ← 여기까지 오는 경우는 드뭅니다

이렇게도 쓸 수 있다 — 이식성 지키기 🟡 중급

런타임 CPU 감지 (⚡ e06)

// 배포 바이너리는 보수적으로 빌드하고 실행 시 분기 ★
#if defined(__x86_64__)
  #include <immintrin.h>
  static bool has_avx2() { return __builtin_cpu_supports("avx2"); }
#elif defined(__aarch64__)
  #include <arm_neon.h>
  static bool has_avx2() { return false; }
#endif

using Fn = void(*)(const double*, double*, std::size_t);
static Fn select() {
#if defined(__x86_64__)
    if (has_avx2()) return &process_avx2;
    return &process_sse2;
#elif defined(__aarch64__)
    return &process_neon;
#else
    return &process_scalar;
#endif
}
static const Fn g_process = select();     // 한 번만 결정

// GCC 의 함수 다중 버전 — 컴파일러가 자동으로
__attribute__((target_clones("default,avx2,avx512f")))
void process(const double* in, double* out, std::size_t n) { ... }

// 함수 단위 타깃 (파일 전체 옵션 대신) ★
__attribute__((target("avx2,fma")))
void process_avx2(...) { ... }

스칼라 버전을 반드시 남기세요

// ① 정확성 검증용 기준 구현
void process_scalar(const double* in, double* out, std::size_t n) {
    for (std::size_t i = 0; i < n; ++i) out[i] = std::sqrt(in[i]);
}

// ② 테스트로 항상 대조 ★
TEST(simd, matches_scalar) {
    for (std::size_t n : {0u, 1u, 3u, 4u, 7u, 8u, 1000u}) {   // ★ 꼬리 케이스
        std::vector<double> in(n), a(n), b(n);
        fill_random(in);
        process_scalar(in.data(), a.data(), n);
        process_avx2  (in.data(), b.data(), n);
        for (std::size_t i = 0; i < n; ++i)
            EXPECT_NEAR(a[i], b[i], 1e-12) << "n=" << n << " i=" << i;
    }
}

// ③ 환경변수로 강제 전환 (운영 사고 대비)
if (std::getenv("MYLIB_NO_SIMD")) return &process_scalar;

// ④ Sanitizer 와 함께 (🛠️ b10)
//    SIMD 코드는 범위 초과가 나기 쉽습니다
꼬리 처리가 버그의 온상입니다 — n 이 벡터 폭의 배수가 아닐 때 마지막 몇 개만 틀리는 경우가 전형적입니다. 0, 1, 폭-1, 폭, 폭+1 을 반드시 테스트하세요.

실무에선 이렇게 — 라이브러리를 먼저 🔴 실무

직접 쓰기 전에 확인 (⚡ e06)

이식성 있는 SIMD 라이브러리
  · google/highway  — 런타임 디스패치까지 포함 ★
  · xsimd           — 간결한 API
  · std::simd       — C++26 표준화 예정 (experimental 로 사용 가능)
  · Eigen           — 선형대수 전반

이미 최적화된 도메인 라이브러리
  · BLAS (OpenBLAS · MKL)  선형대수
  · simdjson               JSON (GB/s)
  · fast_float             문자열→실수
  · xxHash · wyhash        해시
  · zstd · lz4             압축
  · pdqsort · vqsort       정렬

★ "SIMD 로 3배 빠르게 하겠다" 싶은 것은
  대개 누군가 이미 10배 빠르게 만들어 두었습니다.

직접 써야 하는 경우
  · 도메인 특화 연산 (특수한 필터·인코딩)
  · 라이브러리 의존성을 늘릴 수 없는 환경
  · 프로파일러가 그 루프만 계속 가리킬 때

유지보수 비용

인트린식 코드를 두면 생기는 일

  · x86 과 ARM 두 벌을 유지해야 합니다 (애플 실리콘 · ARM 서버)
  · 새 명령 세트가 나오면 또 한 벌
  · 읽을 수 있는 사람이 팀에 한두 명뿐이 됩니다
  · 리팩터링 때마다 스칼라·SIMD 양쪽을 고쳐야 합니다
  · 테스트 케이스가 늘어납니다 (꼬리·정렬·경계)

그래서
  · 인트린식 코드는 '한곳에 격리'하세요
      src/kernels/ 에 모으고 인터페이스는 단순하게
  · 반드시 스칼라 기준 구현과 대조 테스트 ★
  · 주석에 '왜 이렇게 썼는지'와 측정 결과를 남기세요
      // AVX2 버전: 스칼라 대비 3.2배 (n=1M, Zen4, 2026-08 측정)

★ 성능 이득이 2배 미만이면 대개 유지보수 비용이 더 큽니다.
먼저 자동 벡터화를 노리세요(⚡ e06) — __restrict 와 단순한 루프 구조만으로 인트린식의 70~90% 성능이 나오는 경우가 많습니다. 코드는 그대로 읽히고요.

한 단계 더 — 컴파일러 내장 함수 🧬 전문가

알아 두면 유용한 __builtin_*

// 오버플로 검사 (d02)
int r;
if (__builtin_add_overflow(a, b, &r)) { /* 오버플로 */ }
__builtin_mul_overflow · __builtin_sub_overflow

// 비트 조작 — 표준 <bit> 가 있으면 그쪽을 쓰세요
__builtin_popcountll(x)   → std::popcount(x)
__builtin_clzll(x)        → std::countl_zero(x)
__builtin_bswap64(x)      → std::byteswap(x)   (C++23)

// 분기 힌트 (⚡ e05) — C++20 [[likely]] 가 표준
__builtin_expect(x, 0)
__builtin_unreachable()          // "여기 도달할 수 없다" ★
//   std::unreachable() (C++23)

// 정렬 힌트
__builtin_assume_aligned(p, 64)

// 함수 정보
__builtin_FILE() · __builtin_LINE() · __builtin_FUNCTION()
//   → std::source_location (C++20)

// 프리페치 (⚡ e02)
__builtin_prefetch(addr, rw, locality)

// 컴파일 시점 판단
__builtin_constant_p(x)          // 상수인가
if consteval { }                 // C++23 표준

위험한 것 — unreachable

// "여기 도달할 수 없다"고 약속하면 컴파일러가 그것을 이용합니다
int f(int x) {
    switch (x) {
        case 0: return 10;
        case 1: return 20;
        default: std::unreachable();     // ★ 다른 값이 오면 UB
    }
}
//   → 범위 검사가 사라지고 점프 테이블이 최적화됩니다
//   ⚠ 실제로 2가 들어오면 무슨 일이든 일어납니다 (d02)

// 안전한 사용
//   · 입력을 이미 검증한 뒤 내부 함수에서만
//   · 디버그 빌드에서는 assert 로 확인
#ifndef NDEBUG
    #define MYLIB_UNREACHABLE() assert(false && "도달 불가")
#else
    #define MYLIB_UNREACHABLE() std::unreachable()
#endif

// 확인 — 어셈블리에서 검사가 사라졌는지 (⚡ e09)
// godbolt 에서 unreachable 있는 버전과 없는 버전 비교
unreachable 은 컴파일러에게 주는 약속입니다 — 어기면 d02 의 모든 무서운 일이 일어납니다. 성능 이득이 확실히 측정된 자리에만, 디버그 빌드에서는 assert 로 이중 방어하세요.
정리 — 순서는 표준 함수 → __builtin_* → 인트린식 → 인라인 어셈블리 입니다. <bit> 헤더에 이미 있는 것을 인트린식으로 쓰지 마세요. 직접 쓴다면 스칼라 기준 구현과 대조 테스트를 반드시 두고, 런타임 CPU 감지로 이식성을 지키세요.
DEEP 11

크래시 덤프 분석

운영 중 죽은 프로그램의 덤프 파일 하나로 원인을 찾아냅니다. 재현할 수 없는 버그를 다루는 유일한 방법입니다.

덤프를 남기게 설정하기

# ① 코어 덤프 활성화
ulimit -c unlimited
echo "/tmp/core.%e.%p.%t" | sudo tee /proc/sys/kernel/core_pattern
#   %e 실행파일 %p PID %t 시각

# systemd 환경이면 systemd-coredump 가 받습니다
coredumpctl list
coredumpctl gdb 12345        # 바로 gdb 로 열기 ★

# 컨테이너에서
docker run --ulimit core=-1 --security-opt seccomp=unconfined ...
#   호스트의 core_pattern 이 적용됩니다 (컨테이너 안 경로가 아님 주의)

# ② 프로그램에서 직접
#include <sys/resource.h>
struct rlimit rl{RLIM_INFINITY, RLIM_INFINITY};
setrlimit(RLIMIT_CORE, &rl);

# ③ 윈도우 — 미니덤프
#   SetUnhandledExceptionFilter + MiniDumpWriteDump
#   또는 레지스트리의 LocalDumps 설정
심볼을 미리 보관하세요(🛠️ b01) — strip 한 바이너리의 덤프는 주소만 보입니다. objcopy --only-keep-debug 로 분리해 두고 배포한 바이너리와 짝을 맞춰 저장해야 합니다.

분석 절차

gdb ./app /tmp/core.app.12345

(gdb) bt full                    # ★ 가장 먼저 — 스택과 지역 변수
(gdb) info registers
(gdb) info threads               # 여러 스레드면
(gdb) thread apply all bt        # ★ 교착·경쟁 진단
(gdb) frame 2                    # 관심 프레임으로
(gdb) print *this
(gdb) print *ptr@10              # 배열 앞 10개
(gdb) info sharedlibrary         # 어느 .so 에서 났는지
(gdb) x/16xb 0x7fff1234          # 메모리 덤프

# 파이썬 확장이라면 (🐍 y12)
(gdb) py-bt                      # 파이썬 스택까지 ★

# 주소만 있을 때 (심볼 분리 보관한 경우)
addr2line -e app.debug -f -C 0x401234
#   Engine::run(int)
#   /src/core.cpp:42              ★

# build-id 로 심볼 찾기
readelf -n core | grep -i build
#   /usr/lib/debug/.build-id/ab/cdef...debug

이렇게도 쓸 수 있다 — 증상으로 원인 좁히기 🟡 중급

신호와 스택 패턴

신호 · 패턴흔한 원인
SIGSEGV
주소 0x0 근처
널 포인터 역참조
SIGSEGV
주소가 이상한 값
해제 후 사용 · 손상된 포인터 ★
SIGABRT
__cxa_throw 가 보임
잡히지 않은 예외 (d05)
SIGABRT
free() 근처
힙 손상 · 이중 해제
SIGILL지원하지 않는 CPU 명령
-march=native 로 빌드(🛠️ b04)
SIGBUS정렬 위반 (ARM) · mmap 문제
스택이 재귀로 가득무한 재귀 · 스택 오버플로
스택이 ?? 뿐스택 손상 · 심볼 없음
# 힙 손상이면 다음 실행에 ASan 을 붙여 재현을 시도하세요 (🛠️ b10)
# 스택 손상이면
(gdb) bt
#0  0x00007f... in ?? ()          ← 심볼이 깨짐
#   → -fstack-protector-strong 로 다시 빌드해 조기 탐지

죽이지 않고 들여다보기

# 운영 중인 프로세스의 스택 (재시작 없이) ★
gdb -p $(pgrep -f myapp) -batch -ex "thread apply all bt" > stacks.txt

# 파이썬 확장이라면 (🐍 y12)
py-spy dump --pid 12345 --native
#   파이썬 + C++ 스택을 함께 보여 줍니다 ★

# 살아 있는 프로세스의 코어 덤프 뜨기 (계속 실행됨)
gcore -o /tmp/snapshot 12345

# 무한 루프·교착 진단
#   1초 간격으로 여러 번 떠서 스택이 그대로면 멈춘 것입니다
for i in 1 2 3; do
  gdb -p 12345 -batch -ex "thread apply all bt" >> hang.txt
  sleep 1
done

# 파일 디스크립터·메모리 상태
ls -l /proc/12345/fd | wc -l
cat /proc/12345/status | grep -E "VmRSS|Threads"
운영 장애 대응의 첫 명령 — 스택을 뜨는 것입니다. 재시작하면 증거가 사라지므로, 재시작 전에 gcore 나 스택 덤프를 반드시 남기세요.

실무에선 이렇게 — 덤프 수집 체계 🔴 실무

자동 수집

// 프로그램 안에서 크래시 정보를 남기기
#include <csignal>
#include <execinfo.h>

void crash_handler(int sig) {
    void* frames[64];
    int n = backtrace(frames, 64);

    // ★ async-signal-safe 함수만 써야 합니다 (printf 도 위험)
    int fd = ::open("/var/log/app/crash.log", O_WRONLY|O_CREAT|O_APPEND, 0644);
    backtrace_symbols_fd(frames, n, fd);
    ::close(fd);

    std::signal(sig, SIG_DFL);
    std::raise(sig);              // 기본 처리로 → 코어 덤프 생성 ★
}

int main() {
    std::signal(SIGSEGV, crash_handler);
    std::signal(SIGABRT, crash_handler);
    std::signal(SIGFPE,  crash_handler);
    ...
}
# 더 나은 도구들
#   · Google Breakpad · Crashpad — 미니덤프 생성·업로드
#   · Sentry Native — 크래시 리포트 수집 서비스
#   · systemd-coredump — 자동 수집·압축·보관
#   · backward-cpp — 예쁜 스택 트레이스 출력 (헤더 하나)

분석에 필요한 것들

  • 바이너리와 심볼 — 배포한 것과 정확히 같은 빌드 ★
  • build-id — 덤프와 심볼을 짝지을 유일한 열쇠
  • 빌드 정보 — 컴파일러·플래그·커밋(🛠️ b02·🐍 y03)
  • 공유 라이브러리 — 시스템 버전이 다르면 스택이 안 풀립니다
  • 로그 — 크래시 직전의 요청·상태
  • 재현 조건 — 부하·입력·타이밍
# 심볼 서버 구성 (build-id 기반)
readelf -n app | grep "Build ID"
#   Build ID: abcdef1234...
mkdir -p /srv/symbols/.build-id/ab
cp app.debug /srv/symbols/.build-id/ab/cdef1234....debug

# gdb 가 자동으로 찾게
(gdb) set debug-file-directory /srv/symbols

# CI 에서 심볼을 아티팩트로 보관 (🛠️ b07)
- uses: actions/upload-artifact@v4
  with: { name: symbols, path: 'build/*.debug', retention-days: 90 }
심볼 없는 덤프는 거의 쓸모없습니다 — 릴리스마다 심볼을 반드시 보관하세요. “운영에서 크래시가 났는데 분석할 수 없다”는 대부분 이 준비가 없어서입니다.

한 단계 더 — 스택이 깨졌을 때 🧬 전문가

수동으로 복원하기

# bt 가 ?? 만 보여 줄 때
(gdb) info registers rsp rbp rip
(gdb) x/64gx $rsp                # 스택 메모리를 직접 보기
#   반환 주소처럼 보이는 값(코드 영역 주소)을 찾습니다

(gdb) info proc mappings         # 어느 영역이 코드인지
(gdb) info symbol 0x401234       # 그 주소가 어느 함수인지 ★

# 프레임 포인터가 있으면 훨씬 쉽습니다
#   -fno-omit-frame-pointer 로 빌드하세요 (⚡ e01 · 🛠️ b09)
#   성능 손실은 대개 1% 미만인데 진단 능력이 크게 올라갑니다

# 스택 손상의 원인
#   · 버퍼 오버런 (지역 배열) → -fstack-protector-strong 로 탐지
#   · 함수 포인터 손상
#   · 스택 오버플로 (깊은 재귀)
(gdb) p $sp - (char*)&stack_start    # 스택을 얼마나 썼는지

# 힙 손상 진단
(gdb) call malloc_stats()
#   또는 다음 실행에 ASan (🛠️ b10)

최적화 빌드의 어려움

Release 덤프에서 흔한 상황

  · 변수가 <optimized out>
      → 레지스터에만 있거나 아예 사라졌습니다
      → 주변 변수·인자로 추론하거나 RelWithDebInfo 로 재현
  · 함수가 인라인되어 스택에 없음
      → gdb 는 인라인 프레임을 보여 주기도 합니다 (DWARF 정보)
      → bt 에 "(inlined by)" 표시를 찾으세요
  · 실행 순서가 소스와 다름
      → 줄 번호가 이상해 보일 수 있습니다
  · 꼬리 호출 최적화로 프레임이 사라짐

대책
  · RelWithDebInfo 로 배포 (-O2 -g) ★
  · -fno-omit-frame-pointer
  · 중요한 상태를 전역 로그 버퍼에 남기기
      static thread_local char g_last_op[64];
      → 덤프에서 이 값을 읽으면 무엇을 하던 중이었는지 알 수 있습니다 ★
  · 크래시 직전 로그를 링 버퍼로 유지
“마지막 작업” 흔적을 남기는 기법 — thread_local 버퍼에 현재 처리 중인 요청 ID·단계를 기록해 두면, 덤프에서 그 값을 읽어 재현 범위를 즉시 좁힐 수 있습니다. 비용은 거의 없고 효과는 큽니다.
정리 — 운영 크래시는 덤프와 심볼이 있어야 분석할 수 있습니다. ulimit -c unlimited · 심볼 분리 보관 · RelWithDebInfo 배포 세 가지를 미리 준비하세요. 분석은 bt full 과 thread apply all bt 로 시작합니다.
DEEP 12

C++ 를 쓰지 말아야 할 때

마지막 섹션은 안 쓰는 판단입니다. 기술 선택은 팀이 6개월 뒤에도 고칠 수 있는가로 결정됩니다.

C++ 가 답이 아닌 경우

  • I/O 가 병목 — 계산 언어를 바꿔도 그대로입니다(⚡ e01)
  • 이미 numpy·polars 로 충분 — 1~3배를 위해 치를 비용이 아닙니다(⚡ e10)
  • 요구가 자주 바뀌는 초기 코드 — 빌드·배포 부담이 개발 속도를 깎습니다
  • 팀에 C++ 경험자가 없음 — 버스 팩터 1 은 위험합니다
  • 크래시가 서비스를 죽이면 안 됨 — 세그폴트는 프로세스 전체를 죽입니다(🐍 y12)
  • numba·Cython 으로 충분 — 빌드 파이프라인 없이 70~90% 성능(⚡ e10)
  • 보안이 최우선 — 메모리 안전 언어가 구조적으로 유리합니다
전체에서 차지하는 비중을 먼저 보세요 — 프로파일에서 30% 미만인 함수는 100배 빠르게 해도 전체는 1.4배입니다(암달의 법칙).

대안 비교

선택지고르는 이유
numba파이썬 코드 그대로 · 빌드 불필요 ★
Cython점진 전환 · 기존 코드 유지
polars · DuckDB표 데이터는 직접 짜는 것보다 빠릅니다
Rust메모리 안전 · 빌드 편함 · 배포 쉬움 (🦀)
Go서비스 · 동시성 · 배포 단순
C++기존 C/C++ 자산 · 최고 성능 · 세밀한 제어
GPU대규모 병렬 · 대역폭
새로 시작한다면 Rust 를 진지하게 보세요 — 메모리 안전이 컴파일러로 보장되고, cargo 로 빌드·의존성이 해결되며, PyO3·napi-rs 의 개발 경험이 훨씬 매끄럽습니다. 이 가이드의 🦀 Rust 편에서 같은 주제를 다룹니다.

이렇게도 쓸 수 있다 — C++ 가 여전히 최선인 경우 🟡 중급

대체하기 어려운 영역

  • 기존 C/C++ 자산 — OpenCV·ffmpeg·PCL·Eigen 을 써야 한다면 같은 언어가 가장 매끄럽습니다
  • 게임 엔진·그래픽스 — Unreal·언리티 네이티브 플러그인
  • 임베디드·실시간 — 런타임 없이 하드웨어에 가깝게
  • 초저지연 — 거래 시스템·오디오. 할당자·캐시까지 통제(d09·⚡ e02)
  • 과학 계산 — BLAS·MPI 생태계
  • 여러 언어에 동시에 노출 — C ABI 가 공용어(🔗 f01)
  • 수십 년 유지되는 시스템 — ABI 안정성과 방대한 생태계

이 가이드가 다룬 관점에서

"파이썬·Node 에서 불러 쓸 모듈" 이라는 목적에서 보면

  C++ 가 유리한 경우
    · 이미 있는 C/C++ 라이브러리를 감싸야 한다  ★ 가장 흔한 이유
    · 팀에 C++ 경험이 있다
    · 성능을 마지막 한 방울까지 짜내야 한다
    · 여러 언어에서 같은 코어를 쓴다 (🔗 f10)

  Rust 가 유리한 경우
    · 새로 작성한다
    · 빌드·배포 부담을 줄이고 싶다 (cargo · maturin)
    · 메모리 안전 버그를 구조적으로 막고 싶다
    · 팀이 배울 여력이 있다

  둘 다 필요 없는 경우
    · numba · Cython · polars 로 충분하다  ★ 먼저 확인하세요 (⚡ e10)

★ 정직한 조언
  "C++ 를 배웠으니 써야 한다" 가 아니라
  "이 문제에 이것이 맞는가" 로 판단하세요.
  안 쓰는 결정을 근거와 함께 제시하는 것도 실력입니다.

실무에선 이렇게 — 도입 결정 체크리스트 🔴 실무

결정 전에 답할 질문

  • ① 측정했는가 — 프로파일에서 그 함수가 몇 %인가(⚡ e01)
  • ② 더 싼 방법을 다 해 봤는가 — 알고리즘·I/O·numpy·numba(🐍 y01)
  • ③ 얼마나 빨라지는가 — 프로토타입으로 실측(⚡ e10)
  • ④ 누가 유지보수하는가 — 고칠 수 있는 사람이 몇 명인가
  • ⑤ 배포 부담은 — wheel·prebuild·3 OS(🛠️ b06 · 🟨 j09)
  • ⑥ 되돌릴 수 있는가 — 순수 파이썬 폴백(🐍 y02)
  • ⑦ 실패하면 어떻게 되는가 — 세그폴트가 서비스를 죽이는가
★ 안전한 도입 형태
  ① 순수 파이썬 구현을 먼저 만들고 테스트를 갖춘다
  ② 같은 결과를 내는 C++ 구현을 추가한다
  ③ 두 결과를 대조하는 테스트를 둔다 (parity test)
  ④ 환경변수로 전환 가능하게 한다
  ⑤ 일부 트래픽에만 켜고 지표를 비교한다 (카나리)

  → 언제든 되돌릴 수 있고, 정확성도 자동으로 검증됩니다

팀에 제안할 때

## 제안: 정산 집계를 C++ 확장으로

### 현황
- 프로파일 결과 `aggregate()` 가 전체의 68% (평균 4.2초/건)
- numpy 벡터화 시도 → 원소별 분기 때문에 적용 불가
- numba 시도 → 2.1배 (객체 타입 때문에 nopython 모드 실패)

### 제안
pybind11 로 `aggregate()` 만 C++ 로. 인터페이스는 numpy 배열 in/out.

### 예상 효과 (프로토타입 실측)
| 건수    | 현행   | C++   | 배수  |
|---------|-------:|------:|------:|
| 10,000  | 4.2s   | 0.31s | 13.5× |
| 100,000 | 41s    | 2.8s  | 14.6× |
전체 배치: 6분 → 1.4분

### 비용
- 개발 2주 · CI 에 wheel 빌드 추가(3 OS × 5 버전 → abi3 로 6개)
- 유지보수: 정산팀 2명이 리뷰 가능

### 위험과 대응
- 크래시 시 프로세스 종료 → 입력 검증 + ASan CI + 순수 파이썬 폴백
- `FASTAGG_PURE=1` 로 즉시 되돌릴 수 있음
- 두 구현 결과를 대조하는 테스트를 CI 에 상시
이런 형태의 제안이 통과됩니다 — 측정 → 대안 검토 → 실측 → 비용 → 위험 대응. “C++ 로 하면 빠릅니다”만으로는 설득되지 않고, 설득되어서도 안 됩니다.

한 단계 더 — 여기서 어디로 🧬 전문가

이 가이드에서 이어지는 길

  • 🦀 Rust 가이드 — 같은 목적(모듈 만들기)을 Rust 로. PyO3·maturin·napi-rs. C++ 와 비교하며 읽으면 두 언어의 설계 차이가 보입니다
  • 🐍 파이썬 가이드 — 확장을 쓰는 쪽의 관점. GIL·free-threading·프로파일링
  • 🟨 JS·TS 가이드 — Node 런타임·이벤트 루프
  • 🗄️ Database · 🌐 서버기술 — 병목이 계산이 아니라 I/O 일 때
C++ 를 더 깊이 보려면
  · cppreference.com          — 표준 레퍼런스 (가장 정확)
  · C++ Core Guidelines       — 스타일·설계 지침
  · Compiler Explorer         — 어셈블리 확인 (⚡ e09)
  · CppCon 강연               — 최신 주제
  · "Effective Modern C++"    — 이동·스마트 포인터
  · "C++ Concurrency in Action" — 동시성 (탭 2 m12 · d06)

이 가이드가 반복한 원칙

탭이 달라도 결론은 같았습니다

  ① 경계를 넘는 횟수를 데이터 크기와 분리하라
     파이썬 호출 · Node 호출 · C ABI · 가상 함수 · 콜백
     → 전부 "배열 통째로 한 번" 이 답이었습니다

  ② 좁고 안정된 경계를 두라
     C ABI · abi3 · N-API · 불투명 포인터
     → 내부는 자유롭게, 표면은 단순하게

  ③ 측정하고 결정하라
     프로파일러 · 벤치마크 · Sanitizer
     → 추측으로 최적화하지 않기

  ④ 되돌릴 수 있게 만들라
     순수 구현 폴백 · 환경변수 전환 · parity 테스트

  ⑤ 안 쓰는 판단도 실력이다
     numba 로 충분하면 그것이 정답입니다

★ 이 다섯 가지는 언어가 바뀌어도 유효합니다.
  Rust 가이드에서도 같은 원칙이 반복될 것입니다.
마지막 한 문장 — 목표는 C++ 개발자가 되는 것이 아니라 파이썬·Node 가 못 하는 일을 대신할 모듈을 만드는 것이었습니다. 그 목적에는 기초 탭 + 현대 C++ 앞부분 + 모듈 탭 하나면 충분합니다.
정리 — C++ 는 강력하지만 비쌉니다 — 빌드·배포·디버깅·유지보수가 함께 옵니다. 측정으로 정당화되고 되돌릴 수 있게 만든 뒤에 도입하세요. 그리고 numba·polars·Rust 가 더 나은 경우를 인정하는 것이 좋은 기술 판단입니다.
MSVC v14.4x · GCC 15 · Clang 20 · CMake 4.x · vcpkg · VS Code — 2026 기준

아무것도 없는 PC 에
C / C++ 개발 환경을
처음부터 세웁니다

C++ 를 포기하는 사람의 절반은 문법이 아니라 설치에서 포기합니다. “g++ 은(는) 내부 또는 외부 명령이 아닙니다”, “cl.exe 를 찾을 수 없습니다” — 이 탭은 그 벽을 넘기 위한 것입니다. 내려받을 정확한 주소, 설치 마법사에서 체크할 항목, PATH 를 넣는 화면 경로, 설치가 됐는지 확인하는 명령까지 하나도 건너뛰지 않고 적었습니다. 이 탭을 마치면 빈 PC 를 받아도 30분 안에 빌드가 도는 상태로 만들 수 있습니다.

12SECTIONS
3OS 전부
0사전 지식
30분목표 시간
SETUP 01

컴파일러 고르기 — MSVC · GCC · Clang 무엇을 언제

파이썬은 “파이썬을 깐다”가 끝입니다. C++ 는 컴파일러가 여러 종류이고, 어느 것을 깔았느냐에 따라 명령어도 에러 메시지도 만들어지는 파일도 달라집니다. 그래서 가장 먼저 결정할 것은 컴파일러입니다. 5분이면 정해집니다.

컴파일러 고르기 — 질문 하나로 갈립니다 무엇을 만드나? 윈도우 전용 앱 DLL · 게임 · 드라이버 MSVC 만 되는 SDK 필요 리눅스와 같은 코드 표준 C++ · 오픈소스 서버로 그대로 옮김 최신 문법 · 진단 clang-tidy · sanitizer 에러 메시지가 친절 MSVC · cl.exe VS Build Tools 설치 GCC · g++ MSYS2 UCRT64 설치 Clang · clang++ LLVM 설치 이 가이드의 기본 — 윈도우는 MSVC, 맥·리눅스는 기본 컴파일러 실무에서는 셋을 다 깔아 두고 CMake 로 골라 씁니다. 서로 방해하지 않습니다
“무엇을 깔까”는 무엇을 만들 것인가로 정해집니다 — 성능 차이로 고르는 것이 아닙니다.

셋의 정체 — 이름부터 정리

이름실행 파일2026 기준 버전
MSVC
마이크로소프트
cl.exe
link.exe
툴셋 v14.4x
VS 2022 · VS 2026
GCC
GNU
gcc (C)
g++ (C++)
GCC 15
리눅스 기본
Clang
LLVM
clang
clang++
clang-cl
LLVM 20 이상
맥 기본
“Visual Studio 를 깔아야 하나요” — 아닙니다. 컴파일러만 필요하면 Build Tools(편집기 없는 버전)로 충분하고 용량도 훨씬 작습니다. IDE 를 쓰고 싶으면 Community 를 깔면 됩니다 — 컴파일러는 같은 것이 들어갑니다. s02 에서 둘 다 다룹니다.

같은 코드, 다른 명령 — 첫 인상

# 같은 hello.cpp 를 셋으로 빌드하면

# ① MSVC — 개발자 명령 프롬프트에서
cl /std:c++20 /EHsc /O2 hello.cpp
# → hello.obj · hello.exe 가 나옵니다

# ② GCC (MSYS2 UCRT64 · 리눅스 · 맥)
g++ -std=c++20 -O2 hello.cpp -o hello

# ③ Clang
clang++ -std=c++20 -O2 hello.cpp -o hello

# ④ Clang 을 MSVC 처럼 쓰기 (윈도우 전용 · 플래그가 cl 과 같음)
clang-cl /std:c++20 /EHsc /O2 hello.cpp
플래그 모양이 다릅니다 — MSVC 는 / 로 시작하고 GCC·Clang 은 - 로 시작합니다. 인터넷에서 복사한 명령이 안 먹는다면 대부분 이 차이입니다. 그래서 실무에서는 손으로 치지 않고 CMake 에 맡깁니다(s06).

한 단계 더 — 진짜 차이는 “런타임”입니다 🟡 중급

왜 섞어 쓰면 안 되나 — ABI

컴파일러가 다르면 만들어 낸 .lib/.a 파일을 서로 못 씁니다. std::string 이 메모리에 놓이는 모양, 예외를 던지는 방식, 이름을 붙이는 규칙(맹글링)이 컴파일러마다 다르기 때문입니다. 이것을 ABI(Application Binary Interface)라고 합니다.

표준 라이브러리C 런타임
MSVCMS STLUCRT (msvcp140.dll)
MinGW UCRT64libstdc++UCRT
MinGW MINGW64libstdc++msvcrt.dll (옛것)
리눅스 GCClibstdc++glibc
맥 Clanglibc++libSystem
실전 규칙 — 하나의 실행 파일 안에서는 컴파일러를 하나만 씁니다. MSVC 로 만든 .lib 를 g++ 로 링크하면 undefined reference 폭탄을 맞습니다. 꼭 섞어야 하면 경계를 C 함수(extern "C")로 만들어야 합니다.

파이썬·Node 확장을 만들 거라면 ★

여기에는 선택의 여지가 거의 없습니다. 확장 모듈은 그 파이썬(또는 Node)을 빌드한 컴파일러와 같은 계열로 빌드해야 합니다.

대상써야 하는 컴파일러
윈도우 · python.org 파이썬MSVC (cl.exe) ★
윈도우 · Node LTSMSVC (node-gyp) ★
리눅스 휠(manylinux)GCC (도커 안)
맥 휠Apple Clang
MSYS2 파이썬MSYS2 GCC (짝이 맞을 때만)
가장 흔한 함정 — 윈도우에서 MSYS2 의 g++ 로 python.org 파이썬용 .pyd 를 만들려다 실패합니다. 둘의 런타임이 다르기 때문입니다. 윈도우에서 파이썬 확장을 만들 계획이라면 s02 의 MSVC 는 선택이 아니라 필수입니다.

실무에선 이렇게 — 셋 다 깔고 CMake 로 고릅니다 🔴 실무

같은 소스, 컴파일러만 바꿔 빌드

# 빌드 폴더를 컴파일러마다 따로 두는 것이 핵심입니다
# (한 폴더에서 컴파일러를 바꾸면 CMakeCache 가 꼬입니다 — s12)

cmake -S . -B build-msvc  -G "Visual Studio 17 2022" -A x64
cmake -S . -B build-gcc   -G Ninja -DCMAKE_CXX_COMPILER=g++
cmake -S . -B build-clang -G Ninja -DCMAKE_CXX_COMPILER=clang++

cmake --build build-msvc  --config Release
cmake --build build-gcc
cmake --build build-clang

# 셋 다 통과하면 "이식성이 있다"고 말할 수 있습니다.
# 실제로 CI 는 이걸 매 커밋마다 돌립니다.
왜 셋 다 돌려 보나 — 컴파일러마다 잡아내는 실수가 다릅니다. GCC 만 잡는 경고, Clang 만 잡는 경고, MSVC 에서만 나는 링크 에러가 각각 있습니다. 공짜 코드 리뷰어 세 명이라고 생각하면 됩니다.

지금 뭐가 깔려 있는지부터 확인

# 윈도우 PowerShell — 셋 다 물어봅니다
g++ --version
clang++ --version
cl                       # 버전만 뱉고 끝나면 정상

# 어디에 있는 것을 쓰고 있나 (★ 이게 더 중요합니다 — s11)
where.exe g++
where.exe cl

# 맥 · 리눅스
g++ --version ; clang++ --version
which -a g++ clang++

# 아무 것도 안 나오면 → 아직 아무것도 안 깔린 상태입니다.
# s02(윈도우) 또는 s05(맥·리눅스)로 가세요.
당장 깔기 싫다면 — godbolt.org(Compiler Explorer)에서 브라우저로 MSVC·GCC·Clang 전부를 바로 돌려 볼 수 있습니다. 문법을 익히는 동안은 이걸로 충분하지만, 라이브러리를 쓰고 파일을 읽는 순간부터는 로컬 환경이 필요합니다.
정리 — 컴파일러는 MSVC · GCC · Clang 셋입니다. 윈도우에서 파이썬·Node 확장이나 윈도우 전용 앱을 만들면 MSVC, 리눅스 서버에 올릴 코드면 GCC, 진단 도구가 필요하면 Clang. 하나의 실행 파일 안에서는 섞지 않습니다.
SETUP 02

윈도우 ① — Visual Studio Build Tools 설치

윈도우에서 C++ 를 한다면 여기서 시작합니다. 파이썬 확장도, Node 애드온도, 대부분의 윈도우용 라이브러리도 전부 이 컴파일러를 전제로 합니다. 화면에 뭐가 뜨는지, 어디를 체크해야 하는지 하나씩 따라가겠습니다.

Build Tools 설치 — 다섯 단계면 끝납니다 1 visualstudio.microsoft.com/downloads 접속 페이지 맨 아래 "Tools for Visual Studio" 를 펼칩니다. 위쪽 큰 버튼은 IDE 입니다 2 "Build Tools for Visual Studio" 내려받기 vs_BuildTools.exe (4MB 부트스트래퍼)를 받아 실행하면 진짜 설치기가 뜹니다 3 워크로드 탭 — "C++ 를 사용한 데스크톱 개발" 체크 ★ 이거 하나만 체크하면 컴파일러 · 링커 · 표준 라이브러리 · Windows SDK 가 함께 들어옵니다 4 개별 구성 요소 탭 — SDK · CMake · clang 확인 오른쪽 요약 패널에 "MSVC v143 x64/x86 빌드 도구"와 "Windows 11 SDK" 가 보여야 합니다 5 시작 메뉴 → x64 Native Tools Command Prompt → cl 버전 문구가 나오면 성공입니다. 일반 PowerShell 에서는 아직 안 됩니다 (아래 그림)
체크할 것은 사실상 워크로드 하나뿐입니다 — 나머지는 자동으로 딸려 옵니다.

① 어디서 받나 — 정확한 경로

https://visualstudio.microsoft.com/ko/downloads/

  ↓ 페이지를 끝까지 내립니다
  ▸ "Visual Studio 2022용 도구" (Tools for Visual Studio)
      ↓ 이 접힌 목록을 펼칩니다
      · Build Tools for Visual Studio 2022   ← ★ 컴파일러만 (약 2~4GB)
      · Remote Tools / Agents for VS         ← 필요 없습니다

파일명: vs_BuildTools.exe   (또는 vs_buildtools__숫자.exe)

── IDE 까지 원한다면 ──
페이지 위쪽 "Community 2022 다운로드" → vs_community.exe
  · 무료 · 개인 / 학생 / 오픈소스 / 소규모 회사(250명 미만) 사용 가능
  · 컴파일러는 Build Tools 와 똑같은 것이 들어갑니다
둘 중 뭘 받나 — VS Code 나 CLion 을 쓸 거면 Build Tools, Visual Studio IDE 자체를 쓸 거면 Community. 나중에 마음이 바뀌어도 Visual Studio Installer 를 다시 열어 추가할 수 있습니다.

② 명령 한 줄로 설치 (winget) ★

# 윈도우 10/11 에는 winget 이 기본 탑재입니다.
# 관리자 PowerShell 을 열고 (시작 → PowerShell 우클릭 → 관리자 권한)

winget install --id Microsoft.VisualStudio.2022.BuildTools ^
  --override "--quiet --wait --norestart --nocache ^
    --add Microsoft.VisualStudio.Workload.VCTools ^
    --add Microsoft.VisualStudio.Component.VC.CMake.Project ^
    --includeRecommended"

# PowerShell 에서는 줄바꿈 문자가 ` (백틱)입니다.
# 헷갈리면 그냥 한 줄로 이어서 치세요 —

winget install --id Microsoft.VisualStudio.2022.BuildTools --override "--quiet --wait --norestart --add Microsoft.VisualStudio.Workload.VCTools --includeRecommended"

# 설치가 끝났는지 확인
winget list --id Microsoft.VisualStudio.2022.BuildTools
왜 이 방법이 좋은가 — 새 PC 를 세팅할 때 이 한 줄을 메모장에 저장해 두면 다음번엔 클릭 없이 끝납니다. CI(GitHub Actions)와 도커 이미지도 정확히 이 방식을 씁니다.

설치 마법사 화면 — 어디를 체크하나 🟢 기초

워크로드 탭 — 이것만 체크

[워크로드] [개별 구성 요소] [언어 팩] [설치 위치]
 ▲ 이 탭

 데스크톱 및 모바일
  ☑ C++ 를 사용한 데스크톱 개발      ← ★★★ 이것 하나
  ☐ .NET 데스크톱 빌드 도구
  ☐ Node.js 빌드 도구               ← Node 애드온만 할 거여도 위 항목이 필요합니다

 웹 및 클라우드
  ☐ 웹 개발 빌드 도구

──────────────── 오른쪽 요약 패널 ────────────────
 ▾ C++ 를 사용한 데스크톱 개발
   포함됨
     · MSVC v143 - VS 2022 C++ x64/x86 빌드 도구(최신)
     · Windows 11 SDK (10.0.2xxxx.x)
     · C++ CMake tools for Windows          ← 있으면 좋습니다
   선택 사항
     ☑ C++ ATL / ☐ MFC                     ← 옛 윈도우 GUI. 보통 불필요
     ☑ Windows용 C++ Clang 도구             ← clang-cl · clang-tidy ★
     ☐ v142 (VS 2019) 빌드 도구             ← 옛 프로젝트 열 때만

 총 필요 공간: 약 5~8 GB
용량 주의 — C 드라이브에 최소 10GB 는 비워 두세요. “설치 위치” 탭에서 드라이브를 바꿀 수 있지만 공유 구성 요소와 SDK 는 여전히 C 드라이브에 들어갑니다(약 3GB 는 못 옮깁니다).

설치된 위치와 파일들

C:\Program Files\Microsoft Visual Studio\2022\BuildTools\
 └ VC\
    ├ Auxiliary\Build\
    │   ├ vcvars64.bat        ← ★ 64비트 개발 환경 켜기
    │   ├ vcvars32.bat        ← 32비트
    │   ├ vcvarsall.bat       ← 인자로 아키텍처 지정
    │   └ vcvarsamd64_arm64.bat
    └ Tools\MSVC\14.4x.xxxxx\bin\Hostx64\x64\
        ├ cl.exe              ← 컴파일러 ★
        ├ link.exe            ← 링커
        ├ lib.exe             ← 정적 라이브러리 만들기
        └ dumpbin.exe         ← 바이너리 들여다보기

C:\Program Files (x86)\Windows Kits\10\
 ├ Include\10.0.2xxxx.0\      ← windows.h · stdio.h 등
 └ Lib\10.0.2xxxx.0\um\x64\   ← kernel32.lib 등

C:\Program Files (x86)\Microsoft Visual Studio\Installer\
 ├ vswhere.exe                ← ★ 설치 위치를 알려 주는 도구
 └ setup.exe                  ← "Visual Studio Installer" 본체
Community 를 깔았다면 경로의 BuildTools 자리가 Community 로 바뀝니다(...\2022\Community\VC\...). Enterprise·Professional 도 마찬가지입니다.
cl.exe 는 왜 그냥은 안 도나 — vcvars64.bat 이 하는 일 그냥 연 PowerShell PATH — cl.exe 폴더 없음 INCLUDE — 비어 있음 LIB — 비어 있음 cl : 용어가 인식되지 않습니다 vcvars64 개발자 명령 프롬프트 PATH += ...\bin\Hostx64\x64 INCLUDE = MSVC 헤더 + SDK LIB = .lib 검색 경로 cl · link · dumpbin 즉시 사용 이 환경은 "그 창 안에서만" 삽니다 창을 닫으면 사라집니다. 시스템 PATH 에 직접 넣지 마세요 — INCLUDE·LIB 까지 필요합니다 그래서 실무에서는 — 시작 메뉴의 개발자 프롬프트를 씁니다 VS Code · CLion · CMake 도 vswhere 로 찾아 알아서 이 환경을 켜 줍니다
MSVC 는 PATH 하나로는 안 됩니다 — INCLUDE·LIB까지 있어야 합니다.

확인과 사용 — 개발자 프롬프트 · vcvars64.bat 🟡 중급

③ 설치 확인 — 이 순서대로

# 방법 A — 시작 메뉴에서 (가장 쉬움)
#   시작 → "x64 Native Tools Command Prompt for VS 2022" 검색 → 실행
cl
# Microsoft (R) C/C++ Optimizing Compiler Version 19.4x.xxxxx for x64
# usage: cl [ option... ] filename... [ /link linkoption... ]
#   ↑ 이렇게 나오면 성공입니다 (에러처럼 보이지만 정상)

# 방법 B — 아무 PowerShell 에서 환경만 켜기
cmd /k "`"C:\Program Files\Microsoft Visual Studio\2022\BuildTools\VC\Auxiliary\Build\vcvars64.bat`""

# 방법 C — 설치 위치를 자동으로 찾아서 (경로가 다를 때 ★)
& "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" `
    -latest -products * -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 `
    -property installationPath

# 실제로 컴파일해 보기
"int main(){return 0;}" | Out-File -Encoding ascii t.cpp
cl /EHsc /std:c++20 t.cpp
.\t.exe ; echo "exit=$LASTEXITCODE"      # exit=0 이면 완전 성공
cl 만 쳤을 때 usage 가 나오는 것이 정상입니다 — --version 같은 옵션이 없어서 그렇습니다. “용어가 인식되지 않습니다”가 나오면 일반 창에서 친 것입니다.

④ PowerShell 에서 개발 환경 켜기 ★

# .bat 을 PowerShell 에서 그냥 실행하면 환경이 "안 남습니다".
# (자식 프로세스에서 설정되고 끝나기 때문입니다 — s12)
# 공식 해법은 Microsoft.VisualStudio.DevShell.dll 모듈입니다.

$vs = & "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" `
        -latest -property installationPath
Import-Module "$vs\Common7\Tools\Microsoft.VisualStudio.DevShell.dll"
Enter-VsDevShell -VsInstallPath $vs -SkipAutomaticLocation `
                 -DevCmdArguments "-arch=x64 -host_arch=x64"

cl          # ← 이제 같은 창에서 됩니다
where.exe cl

# 매번 치기 싫다면 PowerShell 프로필에 함수로 넣어 둡니다 (s11)
#   notepad $PROFILE
#   function vs64 { ...위 세 줄... }
#   → 앞으로는 창을 열고 vs64 만 치면 됩니다
주의 — -SkipAutomaticLocation 을 빼면 현재 폴더가 소스 폴더로 강제로 바뀝니다. 스크립트에서는 꼭 붙이세요. 그리고 32비트/64비트를 섞으면 LNK1112 가 납니다 — -arch=x64 를 명시하는 습관을 들이세요.
✗ 흔한 실수 — 시스템 PATH 에 cl.exe 폴더를 넣기
환경 변수 → Path → 새로 만들기
  C:\Program Files\Microsoft Visual Studio\2022\
    BuildTools\VC\Tools\MSVC\14.4x.xxxxx\bin\Hostx64\x64

→ cl 은 실행됩니다. 그런데 —
  fatal error C1034: iostream: 포함 경로가 설정되지 않았습니다
  LINK : fatal error LNK1104: 'kernel32.lib' 파일을 열 수 없습니다

이유: INCLUDE · LIB · WindowsSdkDir 이 비어 있기 때문입니다.
그리고 MSVC 버전을 올리면 경로 숫자가 바뀌어 또 깨집니다.
✓ 올바른 방법 — 창 단위로 환경을 켠다
· 시작 메뉴의 "x64 Native Tools Command Prompt" 사용
· 또는 PowerShell 프로필에 Enter-VsDevShell 함수
· 또는 CMake / VS Code CMake Tools 에 맡기기
     → 이들은 vswhere 로 알아서 찾습니다

무엇이 좋은가
  · MSVC 버전이 올라가도 그대로 동작
  · 32/64비트를 창마다 다르게 쓸 수 있음
  · 다른 툴체인(GCC)과 PATH 가 충돌하지 않음  ← ★
C 언어만 쓸 거라도 같습니다 — MSVC 는 cl.exe 하나가 C 와 C++ 를 모두 컴파일합니다. 확장자가 .c 면 C 로, .cpp 면 C++ 로 처리합니다. 강제하려면 /TC(C) · /TP(C++) 를 씁니다. 참고로 MSVC 의 C 지원은 C11 · C17 이며(/std:c11, /std:c17), C23 지원은 아직 부분적입니다 — 최신 C 표준이 필요하면 s03 의 GCC 를 쓰세요.
정리 — Build Tools 를 받아 “C++ 를 사용한 데스크톱 개발” 하나만 체크하면 끝입니다. 단, 일반 PowerShell 에서는 cl 이 안 됩니다 — x64 Native Tools Command Prompt 를 쓰거나 Enter-VsDevShell 로 환경을 켜세요.
SETUP 03

윈도우 ② — MSYS2 + GCC(UCRT64) 로 g++ 갖추기

인터넷의 C++ 예제 대부분은 g++ -std=c++20 a.cpp -o a 로 시작합니다. 윈도우에서 그 명령을 그대로 쓰려면 MSYS2 를 깔면 됩니다. 단, MSYS2 에는 셸이 네 종류 있어서 여기서 대부분 헤맵니다 — 그것부터 정리합니다.

MSYS2 를 깔면 시작 메뉴에 셸이 네 개 생깁니다 — 뭘 열어야 하나 MSYS 패키지 관리 전용 /usr/bin 개발에 쓰지 마세요 UCRT64 ★ GCC + UCRT /ucrt64/bin 지금은 이것이 기본 MINGW64 GCC + msvcrt /mingw64/bin 옛 프로젝트 호환용 CLANG64 Clang + libc++ /clang64/bin Clang 을 쓰고 싶다면 셸마다 g++ 의 실체가 다릅니다 — 설치 명령의 접두사도 다릅니다 UCRT64 → mingw-w64-ucrt-x86_64-* · MINGW64 → mingw-w64-x86_64-* · CLANG64 → mingw-w64-clang-x86_64-* 고르는 법 — 특별한 이유가 없으면 UCRT64 UCRT 는 윈도우 10 이후의 공식 C 런타임이라 MSVC 로 만든 DLL 과 문자·로케일 처리가 어긋나지 않습니다 윈도우 8.1 이하까지 지원해야 하는 옛 프로젝트만 MINGW64 를 씁니다
MSYS2 에서 헤매는 이유의 90%는 셸을 잘못 연 것입니다 — 개발은 UCRT64.

① 내려받기와 설치

https://www.msys2.org/

  ▸ 첫 화면 가운데 큰 버튼
      msys2-x86_64-2025xxxx.exe   (약 100MB)
  ▸ 실행 → 설치 폴더는 기본값 C:\msys64 를 그대로 두세요 ★
      (경로에 공백이나 한글이 들어가면 나중에 반드시 깨집니다 — s12)
  ▸ 마지막 화면의 "Run MSYS2 now" 체크 → 검은 셸 창이 뜹니다

또는 winget 한 줄로:
  winget install --id MSYS2.MSYS2

설치 뒤 시작 메뉴에 이렇게 생깁니다:
  MSYS2 MSYS       ← 보라색. 패키지 관리용
  MSYS2 UCRT64     ← ★ 개발은 여기
  MSYS2 MINGW64
  MSYS2 CLANG64
설치 경로에 한글·공백 금지 — C:\사용자\내 문서\msys64 같은 경로는 pacman·make·CMake 가 줄줄이 깨집니다. C:\msys64 를 그대로 두세요.

② 먼저 시스템 업데이트 (필수) ★

# "MSYS2 MSYS" 셸을 열고 (보라색 프롬프트)

pacman -Syu
#   패키지 DB 를 갱신하고 전부 올립니다.
#   ★ 중간에 "terminate batch job (Y/N)" 이 뜨며 창이 닫힐 수 있습니다.
#     이것은 정상입니다 — pacman 자신을 업데이트하느라 셸을 재시작하는 것입니다.

# 창이 닫혔으면 다시 열고 한 번 더 (이번엔 끝까지 돕니다)
pacman -Syu

# 확인
pacman -Q | wc -l          # 설치된 패키지 개수
pacman -V                  # pacman 버전
왜 두 번 돌리나 — MSYS2 는 자기 자신(런타임·pacman)을 먼저 갈아 끼운 뒤 나머지를 올립니다. 첫 실행에서 창이 강제로 닫히는 것이 정상 동작이라 여기서 “설치가 실패했다”고 오해하는 경우가 아주 많습니다.

툴체인 설치 — pacman 한 줄 🟢 기초

③ GCC 툴체인 + 빌드 도구

# ★ 여기서부터는 "MSYS2 UCRT64" 셸을 엽니다 (프롬프트에 UCRT64 표시)

# 컴파일러 묶음 — gcc · g++ · gdb · binutils · make 등이 한꺼번에
pacman -S --needed mingw-w64-ucrt-x86_64-toolchain
#   "Enter a selection (default=all):" 이 나오면 그냥 Enter (전부 설치)

# 빌드 도구도 같이
pacman -S --needed \
  mingw-w64-ucrt-x86_64-cmake \
  mingw-w64-ucrt-x86_64-ninja \
  mingw-w64-ucrt-x86_64-pkgconf \
  mingw-w64-ucrt-x86_64-clang-tools-extra   # clangd · clang-tidy

# git 은 MSYS 쪽 패키지를 씁니다 (접두사 없음)
pacman -S --needed git

# 확인
g++ --version        # g++ (Rev..., Built by MSYS2 project) 15.x.x
gdb --version
cmake --version
ninja --version
--needed 를 붙이는 이유 — 이미 깔린 패키지를 다시 내려받지 않습니다. 업데이트 스크립트에 넣어 두기 좋습니다.

패키지 이름 규칙 — 헷갈리지 않기

환경접두사
UCRT64 ★mingw-w64-ucrt-x86_64-
MINGW64mingw-w64-x86_64-
CLANG64mingw-w64-clang-x86_64-
MSYS (도구)접두사 없음 (git, vim…)
# 이름을 모를 때 — 검색
pacman -Ss fmt | head -20
pacman -Ss ucrt.*boost | head

# 뭐가 깔렸는지
pacman -Qs gcc

# 어느 패키지가 이 파일을 줬나 (★ 문제 추적에 유용)
pacman -Qo /ucrt64/bin/g++.exe

# 지우기 (의존까지)
pacman -Rns mingw-w64-ucrt-x86_64-boost

# 캐시 정리 (용량이 부담될 때)
pacman -Sc
접두사를 틀리면 — MINGW64 용 라이브러리를 UCRT64 에서 링크하게 되어 런타임에 이상하게 죽습니다. pacman -Ss 결과에서 앞에 붙은 저장소 이름(ucrt64/)을 항상 확인하세요.

PATH 에 등록 — 일반 PowerShell 에서도 g++ 쓰기 🟢 기초

④ 환경 변수 화면까지 가는 길 (클릭 그대로)

방법 A — 가장 빠름
  Win + R  →  아래 한 줄을 붙여넣고 Enter
     rundll32 sysdm.cpl,EditEnvironmentVariables
  → "환경 변수" 창이 바로 뜹니다

방법 B — 설정에서 클릭으로
  설정 → 시스템 → 정보 → 고급 시스템 설정
       → [고급] 탭 → 아래쪽 [환경 변수(N)...] 버튼

방법 C — 검색창에서
  시작 버튼 → "환경 변수" 입력
       → "시스템 환경 변수 편집" 선택 → [환경 변수(N)...]

──────── 환경 변수 창에서 ────────
  위 칸 = 사용자 변수 (나만)      ← ★ 여기를 씁니다
  아래 칸 = 시스템 변수 (모든 계정, 관리자 권한 필요)

  1) 위 칸에서 Path 를 선택 → [편집(E)...]
  2) [새로 만들기(N)] → 아래를 입력
         C:\msys64\ucrt64\bin
  3) [위로 이동(U)] 으로 순서를 조정 (s11 참고)
  4) 확인 → 확인 → 확인   ← 세 번 다 눌러야 저장됩니다 ★
  5) ★ 열려 있던 터미널을 전부 닫고 새로 엽니다
“확인”을 세 번 눌러야 합니다 — Path 편집 창, 환경 변수 창, 시스템 속성 창. 하나라도 X 로 닫으면 저장이 안 됩니다. 이걸로 30분 날리는 사람이 정말 많습니다.

명령으로 PATH 넣기 (재부팅 불필요)

# PowerShell — 사용자 PATH 에 안전하게 "덧붙이기"
$p = [Environment]::GetEnvironmentVariable('Path','User')
if ($p -notlike '*msys64\ucrt64\bin*') {
  [Environment]::SetEnvironmentVariable('Path', "C:\msys64\ucrt64\bin;$p", 'User')
}

# 현재 창에도 바로 반영 (새 창을 안 열어도 되게)
$env:Path = "C:\msys64\ucrt64\bin;$env:Path"

# 확인
where.exe g++        # C:\msys64\ucrt64\bin\g++.exe
g++ --version

# ❌ setx 는 쓰지 마세요
#   setx PATH "%PATH%;C:\msys64\ucrt64\bin"
#   → 1024자에서 잘리고, 사용자 PATH 와 시스템 PATH 를 합쳐 덮어씁니다.
#     실제로 PATH 를 통째로 날린 사고가 흔합니다. (s11 에서 자세히)
백업 먼저 — 손대기 전에 [Environment]::GetEnvironmentVariable('Path','User') | Out-File path-backup.txt 로 한 번 저장해 두면 언제든 되돌릴 수 있습니다.

확인 — 첫 빌드까지 🟡 중급

⑤ 진짜 되는지 끝까지 확인

# 새 PowerShell 창에서
mkdir C:\dev\hello ; cd C:\dev\hello

@'
#include <iostream>
#include <vector>
int main(){
    std::vector<int> v{3,1,2};
    for (int x : v) std::cout << x << ' ';
    std::cout << "\nOK\n";
}
'@ | Out-File -Encoding utf8 hello.cpp

g++ -std=c++20 -O2 -Wall -Wextra hello.cpp -o hello.exe
.\hello.exe
# 3 1 2
# OK          ← 여기까지 나오면 완전 성공입니다

# C 도 확인
g++ -dumpmachine        # x86_64-w64-mingw32
gcc --version
실행은 되는데 libstdc++-6.dll 이 없습니다 창이 뜬다면 — exe 를 다른 PC 나 다른 폴더에서 실행한 것입니다. 정적 링크로 해결합니다: -static-libgcc -static-libstdc++ -static 를 붙이세요.

MSYS2 를 유지 관리하기

# 정기 업데이트 (한 달에 한 번이면 충분)
pacman -Syu

# 특정 패키지만
pacman -S mingw-w64-ucrt-x86_64-gcc

# ★ 업데이트 후 빌드가 깨지면
#   빌드 폴더를 지우고 다시 구성합니다 (컴파일러 버전이 바뀌었으므로)
rm -rf build ; cmake -S . -B build -G Ninja

# MSYS2 셸 안에서 윈도우 경로 다루기
cygpath -w /ucrt64/bin        # → C:\msys64\ucrt64\bin
cygpath -u 'C:\dev\hello'     # → /c/dev/hello

# 통째로 지우고 싶다면 — C:\msys64 폴더 삭제 + PATH 항목 제거가 전부입니다
# (레지스트리에 남기는 것이 거의 없습니다)
MSYS2 와 Git Bash 는 다릅니다 — Git for Windows 에 딸려 오는 Git Bash 도 비슷하게 생겼지만 컴파일러가 없습니다. 거기서 g++ 를 쳐서 안 된다고 MSYS2 설치를 의심하지 마세요. UCRT64 셸이나 PATH 를 등록한 PowerShell에서 확인하세요.
정리 — C:\msys64 에 설치 → MSYS 셸에서 pacman -Syu 두 번 → UCRT64 셸에서 pacman -S mingw-w64-ucrt-x86_64-toolchain → PATH 에 C:\msys64\ucrt64\bin. 개발용 셸은 언제나 UCRT64 입니다.
SETUP 04

윈도우 ③ — LLVM/Clang 과 WSL2

MSVC 와 GCC 를 갖췄다면 기본은 끝났습니다. 여기서는 두 가지를 더합니다 — 진단이 가장 친절한 Clang, 그리고 윈도우 안에서 진짜 리눅스로 빌드하는 WSL2. 둘 다 “있으면 좋은” 정도가 아니라, 어떤 일에는 이것 말고 답이 없습니다.

① LLVM/Clang 내려받기

https://releases.llvm.org/          ← 버전 목록 · 릴리스 노트
실제 파일은 GitHub 릴리스에 있습니다:
https://github.com/llvm/llvm-project/releases

  ▸ 최신 릴리스(llvmorg-20.x.x 이상)의 Assets 를 펼칩니다
  ▸ LLVM-20.x.x-win64.exe          ← ★ 64비트 윈도우용 (약 400MB)
      (ARM 노트북이라면 LLVM-20.x.x-woa64.exe)

설치 마법사에서 딱 하나 체크:
  ◉ Add LLVM to the system PATH for all users     ← ★
  ○ Add LLVM to the system PATH for current user
  ○ Do not add LLVM to the system PATH

기본 설치 위치: C:\Program Files\LLVM\bin\
더 쉬운 방법 — winget install --id LLVM.LLVM 한 줄이면 됩니다. 다만 winget 설치는 PATH 를 자동으로 넣어 주지 않는 버전이 있어 설치 후 where.exe clang 로 꼭 확인하세요.

② 무엇이 깔리나 · 확인

clang --version
clang++ --version
# clang version 20.x.x
# Target: x86_64-pc-windows-msvc         ← ★ 이 줄을 보세요
# Thread model: posix

# 함께 들어오는 도구들 (이게 진짜 목적입니다)
clang-format --version     # 코드 자동 정렬        (s09)
clang-tidy --version       # 정적 분석            (s09)
clangd --version           # 편집기 자동완성 엔진  (s08)
lld-link --version         # 아주 빠른 링커
llvm-symbolizer --version  # sanitizer 스택 해석   (s09)

where.exe clang clang-cl clangd
Target: x86_64-pc-windows-msvc 의 의미 — 윈도우용 Clang 은 MSVC 의 헤더와 라이브러리를 빌려 씁니다. 즉 s02 의 Build Tools 가 먼저 깔려 있어야 동작합니다. Clang 만 깔고 iostream 을 찾을 수 없다가 나오면 원인이 이것입니다.

clang-cl — MSVC 인 척하는 Clang 🟡 중급

왜 clang-cl 인가

clang-cl.exe 는 플래그와 ABI 를 MSVC 와 똑같이 맞춘 Clang 입니다. MSVC 로 빌드한 .lib 와 섞어 쓸 수 있으면서, 진단 메시지와 sanitizer 는 Clang 것을 씁니다.

# 개발자 명령 프롬프트(s02)에서 — cl 대신 clang-cl 만 바꿔 치기
clang-cl /std:c++20 /EHsc /W4 /O2 hello.cpp

# CMake 에서 통째로 바꾸기
cmake -S . -B build-clangcl -G Ninja ^
  -DCMAKE_C_COMPILER=clang-cl ^
  -DCMAKE_CXX_COMPILER=clang-cl ^
  -DCMAKE_LINKER=lld-link

# Visual Studio 프로젝트에서도 고를 수 있습니다
#   프로젝트 속성 → 일반 → 플랫폼 도구 집합 → "LLVM (clang-cl)"
#   (VS 설치기에서 "Windows용 C++ Clang 도구" 를 체크했을 때만 보입니다)
실무에서 언제 쓰나 — ① MSVC 가 못 잡는 버그를 ASan 으로 잡을 때, ② 링크가 너무 느려 lld-link 로 바꿀 때, ③ 팀 전체가 clang-tidy 규칙을 쓸 때.

세 가지 Clang 을 구분하기 ★

실행 파일플래그ABI · 표준 라이브러리
clang-cl/W4 식MSVC · MS STL
clang++
(윈도우 LLVM)
-Wall 식MSVC · MS STL
clang++
(MSYS2 CLANG64)
-Wall 식MinGW · libc++
clang++
(WSL · 리눅스)
-Wall 식ELF · libstdc++
이름이 같아 사고가 납니다 — PATH 에 LLVM 과 MSYS2 CLANG64 가 둘 다 있으면 clang++ 가 어느 쪽인지 알 수 없게 됩니다. where.exe clang++ 로 항상 확인하고, 헷갈리면 CMake 에 절대 경로를 주세요(s11).
WSL2 — 윈도우 안에서 진짜 리눅스 커널이 하나 더 돕니다 Windows 11 / 10 — Hyper-V 경량 가상화 윈도우 네이티브 컴파일러 — MSVC · MinGW · LLVM 소스 위치 — C:\dev\proj (NTFS) 결과물 — app.exe · mylib.dll · module.pyd 윈도우로 배포할 것은 반드시 여기서 WSL2 · Ubuntu LTS 컴파일러 — gcc · clang · gdb · valgrind 소스 위치 — /home/me/proj (ext4) 결과물 — ELF 실행 파일 · .so · manylinux 휠 리눅스 서버로 배포할 것은 여기서 경계를 넘나들면 아주 느립니다 — /mnt/c 에서 빌드하지 마세요 WSL 안에서 /mnt/c/dev 를 빌드하면 파일 접근이 10배 이상 느려집니다 (9p 프로토콜) 소스는 리눅스 홈(~/proj)에 두고 VS Code 의 "WSL" 원격 확장으로 여는 것이 정답입니다
WSL2 는 “윈도우 위의 리눅스”가 아니라 옆방에서 도는 진짜 리눅스입니다 — 파일은 각자 방에.

WSL2 설치 — 명령 한 줄 🟢 기초

③ 설치와 첫 실행

# 관리자 PowerShell 에서
wsl --install
#   기능 켜기 + 커널 + 기본 배포판(Ubuntu) 설치까지 한 번에.
#   ★ 끝나면 재부팅이 필요합니다.

# 배포판을 골라 깔고 싶다면
wsl --list --online          # 설치 가능한 목록
wsl --install -d Ubuntu-24.04

# 재부팅 후 자동으로 뜨는 창에서 리눅스 사용자 이름·비밀번호를 정합니다
#   (윈도우 계정과 무관합니다. 비밀번호는 sudo 에 씁니다)

# 상태 확인
wsl --status                 # 기본 버전이 2 인지
wsl --list --verbose         # NAME / STATE / VERSION
wsl --update                 # 커널 업데이트

# 들어가기 / 나오기
wsl                          # 또는 시작 메뉴의 "Ubuntu"
exit
WslRegisterDistribution failed with error: 0x80370102 가 나오면 BIOS/UEFI 에서 가상화가 꺼져 있는 것입니다. 재부팅 → BIOS → Intel VT-x 또는 AMD-V(SVM) 를 켜세요. 작업 관리자 → 성능 → CPU 에서 “가상화: 사용”인지 먼저 확인하면 빠릅니다.

④ WSL 안에 빌드 도구 깔기

# Ubuntu 셸 안에서
sudo apt update && sudo apt upgrade -y

# ★ build-essential = gcc · g++ · make · libc 헤더 묶음
sudo apt install -y build-essential cmake ninja-build gdb git pkg-config

# Clang 계열까지
sudo apt install -y clang clangd clang-format clang-tidy lldb lld

# 파이썬 확장을 만들 거라면 (s10)
sudo apt install -y python3-dev python3-pip python3-venv

# 확인
g++ --version ; cmake --version ; ninja --version ; gdb --version

# ★ 소스는 리눅스 홈에 둡니다
mkdir -p ~/proj && cd ~/proj
# 윈도우에서 이 폴더를 열려면 탐색기 주소창에:  \\wsl$\Ubuntu\home\내이름\proj
VS Code 연동이 핵심입니다 — WSL 셸에서 code . 를 치면 윈도우의 VS Code 가 열리면서 컴파일러·디버거는 리눅스 것을 씁니다. 처음 한 번은 서버 컴포넌트를 내려받느라 몇 초 걸립니다. 자세한 설정은 s08.

언제 WSL 을 쓰나 — 판단 기준 🟡 중급

상황WSL 을 쓴다이유
리눅스 서버에 올릴 코드예 ★배포 환경과 같은 glibc·경로 규칙
manylinux 휠 만들기예도커 + 리눅스 필수
valgrind · perf 를 쓰고 싶다예 ★윈도우에 대응물이 없습니다
Makefile · autotools 프로젝트예./configure 가 그대로 돕니다
윈도우용 .exe / .dll 배포아니오WSL 은 ELF 만 만듭니다
python.org 파이썬용 .pyd아니오MSVC 로 빌드해야 합니다(s01)
DirectX · 윈도우 API아니오MSVC + Windows SDK
그냥 C++ 문법 공부취향MSYS2 로도 충분합니다
디스크가 계속 커진다면 — WSL2 의 가상 디스크(ext4.vhdx)는 한 번 커지면 파일을 지워도 자동으로 줄지 않습니다. wsl --shutdown 후 diskpart 의 compact vdisk 로 줄이거나, 최신 WSL 의 %UserProfile%\.wslconfig 에 [experimental] / sparseVhd=true 를 넣어 두면 자동으로 관리됩니다.
정리 — LLVM 은 진단 도구(clang-tidy·clangd·sanitizer) 때문에 깝니다 — 단 MSVC 가 먼저 깔려 있어야 합니다. WSL2 는 wsl --install 한 줄이며, 리눅스에 배포할 코드와 valgrind·perf 가 필요할 때 씁니다. 소스는 리눅스 홈에 두세요.
SETUP 05

macOS 와 Linux — 명령 한두 줄이면 끝납니다

윈도우에 비하면 허무할 정도로 간단합니다. 컴파일러가 OS 에 이미 있거나 패키지 관리자 한 줄이면 들어옵니다. 대신 “기본 컴파일러가 너무 낡았다”는 다른 종류의 문제가 생기는데, 그 해결까지 같이 봅니다.

맥 · 리눅스 — 운영체제별로 이 한 줄이 전부입니다 macOS xcode-select --install → Apple Clang · make · git brew install cmake ninja → 최신 도구 추가 /opt/homebrew (Apple 실리콘) Ubuntu · Debian sudo apt install build-essential → gcc · g++ · make · 헤더 + cmake ninja-build gdb 서버 배포 기준 환경입니다 Fedora · Arch dnf group install "Development Tools" pacman -S base-devel → 같은 GCC 묶음 패키지가 더 최신입니다 설치 뒤에는 반드시 "버전 확인 → 한 파일 컴파일" 까지 합니다 g++ --version · cmake --version · 그리고 실제로 hello.cpp 하나를 빌드해 실행해 보세요 주의 — 배포판의 GCC 가 낡아 최신 C++ 문법이 막힐 수 있습니다 Ubuntu LTS 는 보수적입니다. 아래 카드의 PPA · update-alternatives 로 최신 버전을 얹습니다
맥·리눅스의 어려움은 설치가 아니라 버전이 낡은 것입니다 — 그 해결이 이 섹션의 핵심.

macOS — 명령줄 도구부터

# ① Xcode Command Line Tools (컴파일러 · make · git · 헤더)
xcode-select --install
#   → 팝업이 뜹니다. "설치"를 누르고 몇 분 기다립니다 (약 1~2GB)
#   Xcode 앱 전체(15GB)는 필요 없습니다. iOS 앱을 만들 때만 받으세요.

# ② 확인
clang --version         # Apple clang version 1x.x.x
xcode-select -p         # /Library/Developer/CommandLineTools
xcrun --show-sdk-path   # SDK 위치 — 헤더를 못 찾을 때 확인
make --version
git --version

# ③ 문제가 생겼을 때 (업데이트 후 헤더를 못 찾는 경우)
sudo rm -rf /Library/Developer/CommandLineTools
sudo xcode-select --install

# Xcode 앱도 깔려 있다면 어느 쪽을 쓸지 정합니다
sudo xcode-select -s /Applications/Xcode.app/Contents/Developer
sudo xcode-select -s /Library/Developer/CommandLineTools   # 되돌리기
Apple Clang 은 “진짜 Clang”과 다릅니다 — 버전 번호도 다르고 일부 최신 C++ 기능이 빠져 있습니다. 최신 표준이 필요하면 아래처럼 Homebrew LLVM 을 따로 깝니다.

macOS — Homebrew 로 최신 도구

# ① Homebrew 설치 (없다면)
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
#   설치 끝에 안내되는 두 줄(shellenv)을 ~/.zprofile 에 넣으라고 알려 줍니다 — 꼭 하세요

# ② 빌드 도구
brew install cmake ninja pkg-config ccache

# ③ 최신 LLVM (keg-only — PATH 에 자동 등록되지 않습니다 ★)
brew install llvm
echo 'export PATH="/opt/homebrew/opt/llvm/bin:$PATH"' >> ~/.zprofile
source ~/.zprofile
clang++ --version        # 이제 Homebrew LLVM 이 잡힙니다

# ④ GNU GCC 도 필요하다면 (이름이 g++-15 처럼 버전이 붙습니다)
brew install gcc
g++-15 --version
#   주의: 맥에서 그냥 'g++' 는 여전히 Apple Clang 의 별칭입니다 ★

# ⑤ 디버거 — 맥은 gdb 보다 lldb 를 씁니다 (Xcode 도구에 포함)
lldb --version
Intel 맥이라면 경로가 다릅니다 — Apple 실리콘은 /opt/homebrew, Intel 은 /usr/local 입니다. 인터넷 예제를 복사할 때 이 경로 차이 때문에 안 되는 경우가 매우 흔합니다. brew --prefix 로 확인하세요.

Linux — 배포판별 한 줄 🟢 기초

Ubuntu · Debian (가장 흔함)

sudo apt update
sudo apt install -y build-essential cmake ninja-build gdb git pkg-config

#   build-essential 이 담고 있는 것:
#     gcc  g++  make  libc6-dev  dpkg-dev
#   ★ libc6-dev 가 stdio.h · unistd.h 같은 시스템 헤더를 줍니다.
#     이게 없으면 "fatal error: stdio.h: No such file" 이 납니다.

# 자주 같이 깝니다
sudo apt install -y clang clangd clang-format clang-tidy lldb lld \
                    valgrind ccache python3-dev libssl-dev

# 확인
g++ --version ; cmake --version ; ninja --version

# ── 기본 GCC 가 낡았을 때 (Ubuntu LTS 는 보수적입니다) ──
sudo add-apt-repository -y ppa:ubuntu-toolchain-r/test
sudo apt update && sudo apt install -y g++-15

# 여러 버전을 깔았다면 기본값을 정합니다
sudo update-alternatives --install /usr/bin/g++ g++ /usr/bin/g++-15 150
sudo update-alternatives --install /usr/bin/g++ g++ /usr/bin/g++-13 130
sudo update-alternatives --config g++      # 대화형으로 선택
최신 Clang 을 통째로 — apt.llvm.org 의 스크립트가 가장 편합니다: bash -c "$(wget -O - https://apt.llvm.org/llvm.sh)" -- 20 all. clang-20, clang-tidy-20 처럼 버전이 붙은 이름으로 깔립니다.

Fedora · RHEL · Arch · Alpine

# Fedora / RHEL / Rocky
sudo dnf group install -y "Development Tools"        # 또는 groupinstall
sudo dnf install -y gcc-c++ cmake ninja-build gdb clang clang-tools-extra
#   RHEL 계열은 헤더 패키지 이름이 -devel 로 끝납니다: openssl-devel, python3-devel

# Arch / Manjaro  (가장 최신 패키지)
sudo pacman -S --needed base-devel cmake ninja gdb clang lldb

# openSUSE
sudo zypper install -t pattern devel_C_C++
sudo zypper install cmake ninja gdb

# Alpine (도커 이미지에서 자주 씁니다 — musl libc 라 주의)
apk add --no-cache build-base cmake ninja gdb
#   ★ musl 은 glibc 가 아닙니다. glibc 전용 확장(예: __GLIBC__ 분기)이
#     들어간 코드는 여기서 안 됩니다. manylinux 휠도 못 씁니다.

# 어떤 배포판인지 모를 때
cat /etc/os-release
헤더 패키지를 잊지 마세요 — 리눅스에서는 라이브러리와 개발용 헤더가 따로입니다. libssl3 은 실행용, libssl-dev(RHEL 은 openssl-devel)가 빌드용입니다. openssl/ssl.h: No such file 는 거의 전부 이 문제입니다.

설치했으면 확인 — 여기까지 해야 끝입니다 🟡 중급

공통 검증 스크립트

#!/usr/bin/env bash
set -e
echo "== 컴파일러 =="; g++ --version | head -1
echo "== 표준 =="; echo '__cplusplus' | g++ -std=c++23 -E -x c++ - | tail -1
echo "== 빌드도구 =="; cmake --version | head -1; ninja --version
echo "== 디버거 =="; (gdb --version || lldb --version) | head -1

cat > /tmp/t.cpp <<'EOF'
#include <print>
#include <vector>
#include <algorithm>
int main(){
    std::vector<int> v{3,1,2};
    std::ranges::sort(v);
    for (int x : v) std::print("{} ", x);
    std::print("\nOK\n");
}
EOF
g++ -std=c++23 -O2 -Wall -Wextra /tmp/t.cpp -o /tmp/t && /tmp/t
echo "== 모두 통과 =="
<print> 가 없다고 나오면 컴파일러가 낡은 것입니다 (std::print 는 C++23, GCC 14 이상 · Clang 18 이상에서 -std=c++23 필요). 위 PPA·llvm.sh 로 올리거나, 예제를 std::cout 으로 바꿔 확인하세요.

깔기 싫으면 — 컨테이너로

# 호스트를 더럽히지 않고 툴체인을 통째로 쓰는 방법
docker run --rm -it -v "$PWD":/src -w /src gcc:15 bash
#   컨테이너 안에서 바로:  g++ -std=c++23 main.cpp -o app

# CMake 까지 갖춘 이미지
docker run --rm -it -v "$PWD":/src -w /src silkeh/clang:20 bash

# 리눅스 휠(manylinux)을 만들 때 쓰는 공식 이미지
docker run --rm -it -v "$PWD":/io \
  quay.io/pypa/manylinux_2_28_x86_64 bash

# 팀 전체가 같은 환경을 쓰게 하려면 — devcontainer
#   .devcontainer/devcontainer.json 을 두면 VS Code 가 자동으로 열어 줍니다 (s08)
“내 PC 에서는 되는데”를 없애는 방법 — 컴파일러 버전까지 포함해 이미지 태그로 못 박는 것입니다. CI 와 로컬이 같은 이미지를 쓰면 환경 때문에 생기는 버그가 사라집니다.
정리 — 맥은 xcode-select --install + brew install cmake ninja, 우분투는 apt install build-essential cmake ninja-build gdb, 페도라는 dnf group install "Development Tools", 아치는 pacman -S base-devel. 설치보다 “버전이 충분히 최신인가”를 확인하는 것이 더 중요합니다.
SETUP 06

CMake 와 Ninja — 첫 프로젝트를 만들어 봅니다

컴파일러를 깔았으니 이제 손으로 g++ 를 치지 않는 법을 배웁니다. CMake 는 “어떤 OS·어떤 컴파일러든 같은 두 줄로 빌드되게” 해 주는 도구이고, C++ 세계의 사실상 표준입니다. vcpkg·IDE·CI 가 전부 CMake 를 전제로 돌아갑니다.

CMake 빌드 흐름 — 명령은 사실상 두 줄뿐입니다 1 구성 — cmake -S . -B build -G Ninja 컴파일러를 찾고, 작은 프로그램을 실제로 시험 컴파일해 기능·라이브러리를 확인합니다 2 생성 — build/build.ninja · CMakeCache.txt 탐지 결과가 캐시에 저장됩니다. 컴파일러를 바꾸려면 이 폴더를 지워야 합니다 ★ 3 빌드 — cmake --build build ninja 가 바뀐 파일만 다시 컴파일하고 링크합니다. 코어 수만큼 동시에 돕니다 4 검증 · 설치 — ctest --test-dir build · cmake --install build 테스트와 배포까지 같은 도구로 처리합니다 5 이후에는 3번만 반복합니다 CMakeLists.txt 를 고치면 ninja 가 알아서 1~2 단계를 다시 돌려 줍니다 빌드 폴더(build/)는 언제든 통째로 지워도 됩니다 — 소스가 아니라 산출물입니다. .gitignore 에 넣으세요
외울 명령은 구성 한 줄 · 빌드 한 줄입니다 — 나머지는 CMake 가 알아서 합니다.

① CMake 설치 — 윈도우

https://cmake.org/download/

  ▸ "Binary distributions" 표에서
      Windows x64 Installer:  cmake-4.x.x-windows-x86_64.msi   ← ★
      (ARM 노트북이면 windows-arm64.msi)

  ▸ 설치 마법사 두 번째 화면 — 반드시 고르세요
      ○ Do not add CMake to the system PATH
      ◉ Add CMake to the system PATH for all users     ← ★ 이것
      ○ Add CMake to the system PATH for current user
      ☐ Create CMake Desktop Icon

또는 명령 한 줄:
  winget install --id Kitware.CMake

MSYS2 를 쓴다면 (UCRT64 셸에서):
  pacman -S mingw-w64-ucrt-x86_64-cmake

확인:
  cmake --version         # cmake version 4.x.x
  where.exe cmake
“Add to PATH” 를 놓쳤다면 다시 깔 필요 없습니다 — 제어판에서 지우고 다시 설치하거나, s03 의 PATH 등록 방법으로 C:\Program Files\CMake\bin 을 직접 넣으면 됩니다.

② Ninja 설치 — 왜 필요한가

# Ninja 는 "실제로 컴파일을 실행하는" 도구입니다.
# CMake 는 지시서를 만들고, Ninja 가 그걸 최대 속도로 수행합니다.
# make 보다 빠르고, 윈도우에서도 잘 돕니다.

# 윈도우
winget install --id Ninja-build.Ninja
#   수동: https://github.com/ninja-build/ninja/releases
#         ninja-win.zip → ninja.exe 하나뿐입니다.
#         C:\tools\ninja\ 에 풀고 그 폴더를 PATH 에 추가

# MSYS2
pacman -S mingw-w64-ucrt-x86_64-ninja

# 맥 / 리눅스
brew install ninja
sudo apt install ninja-build

# 확인
ninja --version          # 1.1x.x

# ★ Visual Studio Build Tools 를 깔았다면 이미 들어 있습니다
#   ...\BuildTools\Common7\IDE\CommonExtensions\Microsoft\CMake\Ninja\ninja.exe
얼마나 빨라지나 — 파일 200개 프로젝트에서 make 대비 체감 1.5~3배, 특히 “바뀐 것이 없을 때 다시 빌드”가 거의 즉시 끝납니다. 개발 중에는 이 차이가 가장 크게 느껴집니다.

첫 프로젝트 — 파일 두 개 🟢 기초

③ 폴더와 파일 만들기

hello/
├── CMakeLists.txt      ← 빌드 설명서 (내가 쓰는 파일)
├── src/
│   └── main.cpp        ← 소스
└── build/              ← ★ CMake 가 만드는 폴더. 손대지 않습니다
    ├── CMakeCache.txt      탐지 결과
    ├── build.ninja         실제 빌드 지시서
    ├── compile_commands.json   편집기가 읽는 파일 (s08)
    └── hello.exe           결과물

── 규칙 두 가지 ──
 ① 소스 폴더와 빌드 폴더를 분리합니다 (out-of-source build)
    소스 폴더에서 cmake . 를 치면 산출물이 소스에 섞여 지옥이 됩니다
 ② build/ 는 .gitignore 에 넣습니다
    언제든 지우고 다시 만들 수 있어야 정상입니다
// src/main.cpp
#include <iostream>
#include <string>

int main() {
    std::string who = "world";
    std::cout << "hello, " << who << "!\n";
    return 0;
}

④ 최소 CMakeLists.txt — 이 12줄이면 충분

cmake_minimum_required(VERSION 3.25)

project(hello VERSION 1.0 LANGUAGES CXX)

set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)          # -std=gnu++20 대신 -std=c++20

# 편집기 자동완성용 파일을 만들게 합니다 (s08 에서 씁니다) ★
set(CMAKE_EXPORT_COMPILE_COMMANDS ON)

add_executable(hello src/main.cpp)

# 경고를 컴파일러별로 켜기
target_compile_options(hello PRIVATE
  $<$<CXX_COMPILER_ID:MSVC>:/W4>
  $<$<NOT:$<CXX_COMPILER_ID:MSVC>>:-Wall;-Wextra;-Wpedantic>)
$<...> 는 뭔가요 — 제너레이터 표현식입니다. “MSVC 일 때만 /W4” 처럼 구성 시점이 아니라 빌드 시점에 결정되는 값을 씁니다. 지금은 복사해 쓰는 관용구로 생각하면 됩니다.

빌드하고 실행 — 명령 두 줄 🟢 기초

⑤ 실제로 돌려 보기

# ① 구성 — 한 번만 (컴파일러 탐지 · 빌드 폴더 생성)
cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release

#   -S .        소스 폴더 (CMakeLists.txt 가 있는 곳)
#   -B build    빌드 폴더 (없으면 만듭니다)
#   -G Ninja    생성기. 안 주면 OS 기본값(윈도우=VS, 리눅스=Make)
#   -DCMAKE_BUILD_TYPE=Release   최적화 켜기 (Ninja/Make 에서 필수 ★)

# ② 빌드 — 고칠 때마다 이것만
cmake --build build

# 병렬 · 자세히 보기
cmake --build build -j 8
cmake --build build --verbose        # 실제 컴파일 명령을 보여 줍니다

# ③ 실행
./build/hello              # 윈도우: .\build\hello.exe

# 청소
cmake --build build --target clean
rm -rf build               # 완전 초기화 (윈도우: Remove-Item -Recurse build)
Visual Studio 생성기는 다릅니다 — -G "Visual Studio 17 2022" 는 멀티 구성이라 CMAKE_BUILD_TYPE 이 무시됩니다. 대신 빌드할 때 --config Release 를 줍니다. 결과물도 build\Release\hello.exe 처럼 한 층 더 들어갑니다.

자주 쓰는 CMake 명령 모음

명령하는 일
cmake -S . -B build구성
cmake --build build빌드
cmake --build build -t clean산출물 지우기
cmake --install build --prefix out설치
ctest --test-dir build테스트 실행
cmake -L build설정된 옵션 보기
cmake --fresh -S . -B build캐시 버리고 재구성 ★
cmake --help-module FindZLIB내장 문서
cmake -G쓸 수 있는 생성기 목록
--fresh 를 기억하세요(CMake 3.24+) — “컴파일러를 바꿨는데 안 먹는다”, “옵션이 반영이 안 된다” 의 90%는 낡은 CMakeCache.txt 때문입니다. 이 옵션이 그걸 버리고 다시 구성합니다.

CMakePresets.json — 긴 명령을 이름으로 🟡 중급

CMakePresets.json 기본형

{
  "version": 6,
  "configurePresets": [
    {
      "name": "default",
      "displayName": "Ninja · Release",
      "generator": "Ninja",
      "binaryDir": "${sourceDir}/build/${presetName}",
      "cacheVariables": {
        "CMAKE_BUILD_TYPE": "Release",
        "CMAKE_EXPORT_COMPILE_COMMANDS": "ON"
      }
    },
    {
      "name": "debug",
      "inherits": "default",
      "displayName": "Ninja · Debug + Sanitizer",
      "cacheVariables": {
        "CMAKE_BUILD_TYPE": "Debug",
        "CMAKE_CXX_FLAGS": "-fsanitize=address,undefined -g"
      }
    },
    {
      "name": "msvc",
      "displayName": "Visual Studio 2022 x64",
      "generator": "Visual Studio 17 2022",
      "architecture": "x64",
      "binaryDir": "${sourceDir}/build/msvc"
    }
  ],
  "buildPresets": [
    { "name": "default", "configurePreset": "default" },
    { "name": "debug",   "configurePreset": "debug" }
  ]
}

프리셋 쓰기 · 왜 좋은가

# 목록 보기
cmake --list-presets

# 구성 · 빌드
cmake --preset debug
cmake --build --preset debug

# 팀원은 이 두 줄만 알면 됩니다. 긴 -D 옵션을 외울 필요가 없습니다.

# ── 개인 설정은 따로 ──
#   CMakeUserPresets.json  (이 파일은 .gitignore 에 넣습니다)
#   → 내 PC 에만 있는 경로(vcpkg 위치 등)를 여기에 둡니다
이게 실무에서 중요한 이유 — VS Code 의 CMake Tools, Visual Studio, CLion 이 전부 CMakePresets.json 을 읽어 빌드 구성 목록을 만듭니다. 이 파일 하나를 커밋해 두면 팀원이 IDE 를 열자마자 바로 빌드가 됩니다. vcpkg 툴체인 연결도 여기에 적습니다(s07).
라이브러리를 여러 개로 나눌 때 — 규모가 커지면 add_library(core STATIC src/a.cpp src/b.cpp) 로 묶고 target_include_directories(core PUBLIC include), target_link_libraries(hello PRIVATE core) 로 연결합니다. 핵심 규칙 하나 — include_directories() 같은 전역 명령을 쓰지 말고 target_* 명령만 쓰세요. 이것이 “현대 CMake” 의 전부라고 해도 과언이 아닙니다.
정리 — CMake 는 설치 시 PATH 체크박스가 중요하고, 프로젝트는 CMakeLists.txt 12줄이면 시작됩니다. 명령은 cmake -S . -B build -G Ninja 와 cmake --build build 두 줄. 안 될 때는 --fresh 를 떠올리세요.
SETUP 07

패키지 관리자 — vcpkg 와 Conan 2

파이썬에는 pip install, Node 에는 npm i 가 있습니다. C++ 에는 오랫동안 그런 게 없어서 라이브러리 하나 쓰려고 하루를 쓰는 일이 흔했습니다. 지금은 vcpkg 와 Conan 이 그 자리를 채웁니다. fmt 하나를 끝까지 붙여 보겠습니다.

vcpkg 매니페스트 모드 — 라이브러리가 들어오는 길 ① 내가 적는 것은 두 파일뿐입니다 vcpkg.json 에 라이브러리 이름 · CMakeLists.txt 에 find_package 와 target_link_libraries ② cmake 구성 — 툴체인 파일이 개입합니다 -DCMAKE_TOOLCHAIN_FILE=.../scripts/buildsystems/vcpkg.cmake 가 vcpkg.json 을 읽습니다 ③ vcpkg 가 소스를 내려받아 이 PC 에서 직접 빌드합니다 결과는 프로젝트 안 vcpkg_installed/ 에 헤더와 .lib/.a 로 놓입니다 — 내 컴파일러와 ABI 가 같습니다 ④ find_package(fmt CONFIG REQUIRED) 가 성공 → 링크 완료 첫 빌드는 오래 걸리지만 캐시되어 두 번째부터는 즉시입니다 팀원은 같은 두 파일만 받으면 같은 버전을 씁니다 — "내 PC 에서만 되는" 문제가 사라집니다
핵심은 미리 만들어진 바이너리를 받는 게 아니라 내 컴파일러로 빌드한다는 점입니다 — ABI 가 맞습니다.

① vcpkg 설치 — git clone 이 전부

# 윈도우 (PowerShell)
git clone https://github.com/microsoft/vcpkg C:\dev\vcpkg
C:\dev\vcpkg\bootstrap-vcpkg.bat
#   → vcpkg.exe 가 만들어집니다 (약 1분)

# 환경 변수 VCPKG_ROOT 등록 — CMake 프리셋에서 씁니다 ★
[Environment]::SetEnvironmentVariable('VCPKG_ROOT','C:\dev\vcpkg','User')
$env:VCPKG_ROOT = 'C:\dev\vcpkg'          # 현재 창에도 반영
$env:Path = "$env:VCPKG_ROOT;$env:Path"

# 맥 / 리눅스
git clone https://github.com/microsoft/vcpkg ~/vcpkg
~/vcpkg/bootstrap-vcpkg.sh
echo 'export VCPKG_ROOT=$HOME/vcpkg'      >> ~/.bashrc
echo 'export PATH=$VCPKG_ROOT:$PATH'      >> ~/.bashrc

# 확인
vcpkg version
vcpkg search fmt                # 있는지 찾아보기
vcpkg help triplet              # 대상 플랫폼 목록
설치 위치 주의 — 경로가 길거나 한글·공백이 있으면 일부 포트(라이브러리)의 빌드가 깨집니다. C:\dev\vcpkg 처럼 짧고 영문인 경로를 쓰세요. Visual Studio 를 설치했다면 이미 들어 있을 수도 있습니다 (...\BuildTools\VC\vcpkg) — 그래도 별도 clone 을 권합니다.

두 가지 사용 모드 ★

클래식 모드매니페스트 모드
설치 방법vcpkg install fmtvcpkg.json 에 적기
설치 위치vcpkg 폴더 (전역)프로젝트 안 vcpkg_installed/
버전 고정어려움baseline 으로 고정
팀 공유말로 전달파일이 곧 목록 ★
추천잠깐 시험할 때실제 프로젝트 전부
# 클래식 — 빠르게 시험만 해 볼 때
vcpkg install fmt:x64-windows
vcpkg list

# 매니페스트 — 프로젝트 폴더에서
vcpkg new --application            # vcpkg.json 을 만들어 줍니다
vcpkg add port fmt                 # 의존성 한 줄 추가
vcpkg x-update-baseline --add-initial-baseline
이 탭에서는 매니페스트 모드만 씁니다 — 클래식 모드는 “이 PC 에 뭐가 깔렸는지”에 결과가 좌우돼서 팀·CI 에서 반드시 사고가 납니다.

완전한 예제 — fmt 를 붙여 빌드까지 🟢 기초

② 파일 네 개

// vcpkg.json  ← 프로젝트 루트
{
  "name": "hello-fmt",
  "version": "0.1.0",
  "dependencies": [
    "fmt"
  ],
  "builtin-baseline": "여기에-vcpkg-커밋해시"
}
// baseline 은 vcpkg x-update-baseline --add-initial-baseline 이
// 자동으로 채워 줍니다. 이 해시가 "라이브러리 버전 잠금" 역할을 합니다 ★
# CMakeLists.txt
cmake_minimum_required(VERSION 3.25)
project(hello_fmt LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_EXPORT_COMPILE_COMMANDS ON)

find_package(fmt CONFIG REQUIRED)          # ★ 이 한 줄

add_executable(app src/main.cpp)
target_link_libraries(app PRIVATE fmt::fmt) # ★ 그리고 이 한 줄
// src/main.cpp
#include <fmt/core.h>
#include <fmt/ranges.h>
#include <vector>

int main() {
    std::vector<int> v{3, 1, 2};
    fmt::print("hello {}! v = {}\n", "vcpkg", v);
    // hello vcpkg! v = [3, 1, 2]
}

③ 툴체인을 연결해 빌드

# 방법 A — 명령줄에서 직접
cmake -S . -B build -G Ninja `
  -DCMAKE_TOOLCHAIN_FILE="$env:VCPKG_ROOT/scripts/buildsystems/vcpkg.cmake" `
  -DCMAKE_BUILD_TYPE=Release

# 맥 / 리눅스
cmake -S . -B build -G Ninja \
  -DCMAKE_TOOLCHAIN_FILE="$VCPKG_ROOT/scripts/buildsystems/vcpkg.cmake" \
  -DCMAKE_BUILD_TYPE=Release

# ★ 첫 구성에서 fmt 를 내려받아 빌드하느라 몇 분 걸립니다. 정상입니다.
#   Detecting compiler hash for triplet x64-windows...
#   Building fmt:x64-windows...

cmake --build build
./build/app          # 윈도우: .\build\app.exe
# hello vcpkg! v = [3, 1, 2]
// 방법 B — CMakePresets.json 에 박아 두기 (권장 ★)
{
  "version": 6,
  "configurePresets": [{
    "name": "vcpkg",
    "generator": "Ninja",
    "binaryDir": "${sourceDir}/build",
    "toolchainFile": "$env{VCPKG_ROOT}/scripts/buildsystems/vcpkg.cmake",
    "cacheVariables": { "CMAKE_BUILD_TYPE": "Release" }
  }]
}
// 이후로는 그냥:  cmake --preset vcpkg  &&  cmake --build build
find_package(fmt CONFIG REQUIRED) 가 실패한다면 — ① 툴체인 파일을 안 줬거나, ② CONFIG 키워드를 빠뜨렸거나, ③ 트리플릿이 안 맞는 경우입니다. 실패 메시지 아래쪽에 vcpkg 가 찾아본 경로가 그대로 찍히니 그걸 먼저 읽으세요.

트리플릿과 버전 고정 — 실무에서 물리는 곳 🟡 중급

트리플릿 — “어떤 형태로 만들까”

트리플릿결과물
x64-windows (기본)DLL + import lib
x64-windows-static정적 .lib · /MT
x64-windows-static-md정적 .lib · /MD ★
x64-linux정적 .a
arm64-osx / x64-osx맥
x64-mingw-dynamicMSYS2/MinGW 용
# 지정 방법
cmake -S . -B build -DVCPKG_TARGET_TRIPLET=x64-windows-static-md ^
      -DCMAKE_TOOLCHAIN_FILE=%VCPKG_ROOT%\scripts\buildsystems\vcpkg.cmake

# vcpkg.json 안에서 플랫폼별로 다르게 (기능 선택)
#   "dependencies": [ { "name": "fmt" },
#                     { "name": "curl", "platform": "!windows" } ]
static vs static-md 를 꼭 구분하세요 — 파이썬 확장(.pyd)이나 Node 애드온을 만들 때는 반드시 -static-md 입니다. 그냥 static 은 /MT 로 빌드돼서 파이썬(/MD)과 링크할 때 LNK2038: RuntimeLibrary 불일치 가 납니다(s12).

버전 고정 · 진단

// vcpkg.json — 특정 버전으로 못 박기
{
  "name": "myapp",
  "version": "1.0.0",
  "dependencies": ["fmt", "spdlog", "nlohmann-json"],
  "builtin-baseline": "abcdef0123456789abcdef0123456789abcdef01",
  "overrides": [
    { "name": "fmt", "version": "11.0.2" }
  ]
}
# 무엇이 어떤 버전으로 들어왔나
cat vcpkg_installed/vcpkg/status | head -40
vcpkg list

# 빌드 로그 — 라이브러리 빌드가 실패했을 때 여기를 봅니다 ★
#   buildtrees/<포트이름>/*-out.log
ls C:\dev\vcpkg\buildtrees\fmt\

# 캐시 (두 번째부터 빨라지는 이유)
#   윈도우: %LOCALAPPDATA%\vcpkg\archives
#   리눅스: ~/.cache/vcpkg/archives
vcpkg  --help  # 전체 명령 목록

# baseline 을 최신으로 올리기 (라이브러리 업데이트)
cd $env:VCPKG_ROOT ; git pull
cd 프로젝트폴더 ; vcpkg x-update-baseline
디스크가 부담될 때 — buildtrees/ 와 downloads/ 는 지워도 됩니다(다시 받으면 됩니다). packages/ 와 아카이브 캐시만 남기세요.

Conan 2 — 다른 선택지 🟡 중급

설치와 최소 예제

# Conan 은 파이썬 패키지입니다
pip install --upgrade "conan>=2"
conan --version                # Conan 2.x

# 이 PC 의 컴파일러를 감지해 프로필을 만듭니다 (최초 1회) ★
conan profile detect --force
conan profile show             # 어떤 컴파일러·표준·ABI 로 잡혔는지 확인
# conanfile.txt  ← 프로젝트 루트
[requires]
fmt/[>=11.0 <12]

[generators]
CMakeDeps
CMakeToolchain

[layout]
cmake_layout
# 의존성 설치 + 툴체인 생성 (없으면 소스로 빌드)
conan install . --output-folder=build --build=missing -s build_type=Release

# Conan 이 만들어 준 프리셋으로 빌드
cmake --preset conan-release
cmake --build --preset conan-release

# 또는 툴체인 파일을 직접 지정
cmake -S . -B build -G Ninja \
  -DCMAKE_TOOLCHAIN_FILE=build/conan_toolchain.cmake \
  -DCMAKE_BUILD_TYPE=Release
CMakeLists.txt 는 vcpkg 예제와 완전히 같습니다 — find_package(fmt CONFIG REQUIRED) + target_link_libraries. 패키지 관리자를 바꿔도 프로젝트 코드는 그대로라는 뜻입니다.

언제 어느 것을 쓰나 ★

상황선택
윈도우 중심 · Visual Studiovcpkg
CMake 만 쓰는 소규모 팀vcpkg ★
사내 라이브러리를 배포해야 함Conan (사설 저장소)
같은 코드를 여러 ABI 로Conan (프로필)
CI 에서 바이너리 캐시가 절실둘 다 가능
Makefile · Bazel 등 비CMakeConan
일단 fmt 하나 써 보고 싶다vcpkg
세 번째 선택지 — CMake FetchContent
헤더 몇 개짜리 작은 라이브러리라면 패키지 관리자 없이도 됩니다: FetchContent_Declare(fmt GIT_REPOSITORY ... GIT_TAG 11.0.2) + FetchContent_MakeAvailable(fmt). 의존성이 서너 개 이하면 이게 가장 간단합니다. 다만 빌드할 때마다 같이 컴파일되므로 무거운 라이브러리에는 맞지 않습니다.
둘을 한 프로젝트에서 섞지 마세요 — 툴체인 파일은 하나만 지정할 수 있고, 같은 라이브러리가 두 벌 들어오면 중복 심볼과 ABI 충돌로 원인 찾기 어려운 크래시가 납니다.
정리 — vcpkg 는 git clone + bootstrap 이 설치의 전부이고, 프로젝트에는 vcpkg.json 에 이름 한 줄, CMake 에 find_package + target_link_libraries 두 줄, 그리고 툴체인 파일 지정이면 끝납니다. 파이썬·Node 확장을 만들 거라면 트리플릿은 -static-md.
SETUP 08

편집기와 IDE — VS Code · Visual Studio · CLion

컴파일러가 있어도 빨간 줄이 잔뜩 뜨고 자동완성이 안 되면 개발이 안 됩니다. C++ 편집기 설정의 핵심은 딱 하나 — compile_commands.json 입니다. 이 파일이 무엇이고 왜 전부인지부터 보고, 실제 설정 파일을 통째로 붙여 두겠습니다.

VS Code 에서 C++ — 누가 무엇을 하나 CMake Tools 확장 구성 · 빌드 · 파일 생성 compile_commands.json 파일마다의 컴파일 명령 clangd 확장 자동완성 · 진단 · 리팩터 C/C++ (cpptools) 같은 일 · 디버깅 담당 F5 — 디버거가 붙습니다 윈도우 MSVC → cppvsdbg · MinGW/리눅스 GDB → cppdbg · 맥/LLVM → CodeLLDB clangd 와 cpptools 의 자동완성을 동시에 켜면 충돌합니다 — 반드시 하나만 켜세요
compile_commands.json 은 “이 파일은 이 옵션으로 컴파일된다”는 목록입니다 — 편집기 지능의 전부.

① VS Code 와 확장 설치

# VS Code 본체
#   https://code.visualstudio.com/  또는
winget install --id Microsoft.VisualStudioCode

# 확장 — 명령줄로 한 번에 (확장 ID 를 그대로 씁니다)
code --install-extension ms-vscode.cpptools-extension-pack
code --install-extension ms-vscode.cmake-tools
code --install-extension llvm-vs-code-extensions.vscode-clangd
code --install-extension vadimcn.vscode-lldb          # CodeLLDB
code --install-extension twxs.cmake                    # CMake 문법 강조
code --install-extension ms-vscode-remote.remote-wsl   # WSL 쓸 때 (s04)
code --install-extension ms-vscode-remote.remote-containers

# 확인
code --list-extensions | Select-String -Pattern "cpp|clangd|cmake|lldb"
cpptools-extension-pack 에는 C/C++ 확장 · CMake Tools · 테마가 함께 들어 있습니다. clangd 를 쓸 계획이어도 cpptools 는 깔아 두세요 — 디버깅(F5)은 cpptools 가 담당하기 때문입니다.

② 자동완성 엔진 고르기 ★

cpptoolsclangd
설정 난이도쉬움보통
큰 프로젝트 속도느려짐빠름 ★
진단 정확도보통컴파일러와 동일
리팩터링제한적이름 바꾸기 등
MSVC 전용 문법강함일부 약함
디버깅담당안 함
추천 — 처음이면 cpptools 그대로, 파일이 수백 개가 되어 느려지면 clangd 로 갈아타세요. clangd 를 켤 때는 반드시 cpptools 의 IntelliSense 를 꺼야 합니다 (아래 settings.json 참고). 둘 다 켜면 빨간 줄이 두 벌로 뜹니다.

설정 파일 — 그대로 복사해 쓰세요 🟢 기초

.vscode/settings.json

{
  // ── CMake Tools ──
  "cmake.configureOnOpen": true,
  "cmake.generator": "Ninja",
  "cmake.buildDirectory": "${workspaceFolder}/build",
  "cmake.copyCompileCommands": "${workspaceFolder}/compile_commands.json",
  "cmake.configureArgs": [
    "-DCMAKE_EXPORT_COMPILE_COMMANDS=ON"
  ],

  // ── clangd 를 쓸 때 (cpptools IntelliSense 는 끕니다) ★ ──
  "C_Cpp.intelliSenseEngine": "disabled",
  "clangd.arguments": [
    "--compile-commands-dir=${workspaceFolder}/build",
    "--background-index",
    "--clang-tidy",
    "--header-insertion=iwyu",
    "--completion-style=detailed",
    "-j=8"
  ],

  // ── cpptools 를 쓸 때 (clangd 확장을 끄고 이쪽만) ──
  // "C_Cpp.default.compileCommands": "${workspaceFolder}/build/compile_commands.json",
  // "C_Cpp.default.cppStandard": "c++20",

  // ── 편집 ──
  "editor.formatOnSave": true,
  "[cpp]":  { "editor.defaultFormatter": "llvm-vs-code-extensions.vscode-clangd" },
  "files.associations": { "*.ipp": "cpp", "*.tpp": "cpp" },
  "files.exclude": { "**/build": true }
}
cmake.copyCompileCommands 가 중요합니다 — clangd 는 기본적으로 프로젝트 루트에서 이 파일을 찾습니다. 빌드 폴더 안에만 있으면 못 찾아서 모든 include 에 빨간 줄이 뜹니다. 루트로 복사하거나 --compile-commands-dir 로 알려 주세요.

.vscode/launch.json — F5 디버깅

{
  "version": "0.2.0",
  "configurations": [
    {
      // ① 윈도우 · MSVC 로 빌드했을 때 ★
      "name": "디버그 (MSVC)",
      "type": "cppvsdbg",
      "request": "launch",
      "program": "${command:cmake.launchTargetPath}",
      "args": [],
      "cwd": "${workspaceFolder}",
      "console": "integratedTerminal",
      "preLaunchTask": "CMake: build"
    },
    {
      // ② MinGW · 리눅스 · WSL (GDB)
      "name": "디버그 (GDB)",
      "type": "cppdbg",
      "request": "launch",
      "program": "${command:cmake.launchTargetPath}",
      "cwd": "${workspaceFolder}",
      "MIMode": "gdb",
      "miDebuggerPath": "gdb",
      "setupCommands": [
        { "description": "STL 을 보기 좋게 출력",
          "text": "-enable-pretty-printing", "ignoreFailures": true }
      ]
    },
    {
      // ③ 맥 · LLVM (CodeLLDB 확장)
      "name": "디버그 (LLDB)",
      "type": "lldb",
      "request": "launch",
      "program": "${command:cmake.launchTargetPath}",
      "cwd": "${workspaceFolder}"
    }
  ]
}
${command:cmake.launchTargetPath} 를 쓰면 경로를 손으로 안 적어도 됩니다 — CMake Tools 가 지금 선택된 타깃의 실행 파일 경로를 넣어 줍니다. 하단 상태 표시줄에서 타깃을 바꾸면 F5 대상도 같이 바뀝니다.

첫 디버깅까지 — 클릭 순서 🟢 기초

③ 폴더를 열고 F5 까지

1) 프로젝트 폴더를 엽니다
     code .          (또는 파일 → 폴더 열기)

2) 오른쪽 아래 알림: "이 폴더에 CMake 프로젝트가 있습니다. 구성할까요?"
     → [예]

3) 킷(kit) 선택 창이 뜹니다  ★ 여기가 컴파일러 선택입니다
     Visual Studio Build Tools 2022 Release - amd64     ← MSVC
     GCC 15.x.x  (C:\msys64\ucrt64\bin\g++.exe)         ← MinGW
     Clang 20.x.x (C:\Program Files\LLVM\bin)           ← LLVM
     [Scan for kits]   ← 목록이 비었으면 이걸 먼저

4) 하단 상태 표시줄이 이렇게 바뀝니다
     [Build] [Debug] [Release ▾] [킷 이름 ▾] [빌드 ▾] [실행 ▾]

5) F7  = 빌드      ·  Ctrl+F5 = 실행(디버거 없이)
   F5  = 디버깅    ·  Shift+F5 = 중지
   Ctrl+Shift+P → "CMake: Delete Cache and Reconfigure"   ← ★ 막혔을 때

6) 왼쪽 줄 번호 옆을 클릭 → 빨간 점(중단점) → F5
     변수 값이 왼쪽 패널에 뜹니다. F10 한 줄 실행 · F11 함수 안으로
“킷 목록이 비어 있다” — 컴파일러가 PATH 에 없거나(s11), VS Build Tools 설치 후 VS Code 를 재시작하지 않은 경우입니다. Ctrl+Shift+P → “CMake: Scan for Kits” 를 먼저 해 보세요.

.vscode/tasks.json (선택)

{
  "version": "2.0.0",
  "tasks": [
    {
      "label": "cmake: configure",
      "type": "shell",
      "command": "cmake",
      "args": ["-S", ".", "-B", "build", "-G", "Ninja",
               "-DCMAKE_BUILD_TYPE=Debug",
               "-DCMAKE_EXPORT_COMPILE_COMMANDS=ON"]
    },
    {
      "label": "cmake: build",
      "type": "shell",
      "command": "cmake",
      "args": ["--build", "build", "-j"],
      "group": { "kind": "build", "isDefault": true },
      "problemMatcher": ["$gcc"]
    },
    {
      "label": "ctest",
      "type": "shell",
      "command": "ctest",
      "args": ["--test-dir", "build", "--output-on-failure"],
      "group": "test"
    }
  ]
}
problemMatcher 가 컴파일 에러를 “문제” 패널의 클릭 가능한 목록으로 바꿔 줍니다. MSVC 로 빌드한다면 "$msCompile" 을 쓰세요. CMake Tools 를 쓰면 이 파일 없이도 되지만, 빌드 명령을 명시적으로 통제하고 싶을 때 유용합니다.

clangd 를 제대로 — .clangd 파일 🟡 중급

프로젝트 루트의 .clangd

# .clangd  (YAML)
CompileFlags:
  Add: [-std=c++20, -Wall, -Wextra]
  # MSVC 전용 플래그를 clangd 가 못 알아들을 때 지웁니다
  Remove: [-Zc*, /permissive-, -m*, -fno-*]
  CompilationDatabase: build

Diagnostics:
  ClangTidy:
    Add: [modernize-*, performance-*, bugprone-*]
    Remove: [modernize-use-trailing-return-type]
  UnusedIncludes: Strict

InlayHints:
  Enabled: true
  ParameterNames: true
  DeducedTypes: true

Index:
  Background: Build
“clangd 가 헤더를 못 찾는다” 진단 순서 — ① compile_commands.json 이 있는가, ② 그 안에 지금 연 파일이 들어 있는가(새 파일은 재구성 필요), ③ 출력 패널에서 clangd 채널의 로그를 보세요. 원인이 거의 그대로 적혀 있습니다.

Visual Studio · CLion

Visual Studio 2022 / 2026 — CMake 프로젝트를 바로 엽니다.

파일 → 열기 → CMake...       (또는 "폴더 열기")
  → CMakeLists.txt 를 자동 인식, CMakePresets.json 이 있으면 그대로 사용
  → 상단 드롭다운에서 구성(Debug/Release)과 시작 항목 선택
  → F5 로 디버깅. 윈도우 디버거는 여전히 가장 강력합니다

CMakeSettings.json 은 옛 방식입니다 — 새 프로젝트는 CMakePresets.json 을 쓰세요.

CLion(JetBrains) — 2025년부터 비상업 용도는 무료입니다.

· 설치: jetbrains.com/clion  또는  JetBrains Toolbox
· 첫 실행에서 툴체인을 고릅니다
    Settings → Build, Execution, Deployment → Toolchains
      [+] Visual Studio / MinGW / WSL / Docker / Remote Host
· CMake 프로필: Settings → ... → CMake  (프리셋도 읽습니다)
· 장점: 리팩터링 · 디버거 UI · 프로파일러가 통합되어 있습니다
· 단점: 무겁고, 큰 프로젝트에서 인덱싱이 오래 걸립니다
무엇을 고르나 — VS Code 는 가볍고 어디서나 같습니다(WSL·컨테이너 포함). Visual Studio 는 윈도우 디버깅과 프로파일링이 최고입니다. CLion 은 코드 이해·리팩터링이 가장 강합니다. 셋 다 같은 CMakeLists.txt 를 읽으므로 팀 안에서 섞어 써도 됩니다.
정리 — C++ 편집기 설정의 90%는 compile_commands.json 을 만들고 편집기가 찾게 하는 것입니다. CMake 에 CMAKE_EXPORT_COMPILE_COMMANDS=ON, VS Code 에 cmake.copyCompileCommands. 자동완성 엔진은 clangd 와 cpptools 중 하나만 켜세요.
SETUP 09

코드 품질 도구 — format · tidy · sanitizer 를 켭니다

C++ 는 틀린 코드도 잘 컴파일됩니다. 배열 밖을 읽어도, 해제한 메모리를 써도 컴파일러는 아무 말 없이 .exe 를 만들어 줍니다. 그래서 도구를 켜는 것이 문법을 배우는 것만큼 중요합니다. 설치는 대부분 이미 끝났고, 여기서는 설정 파일을 두는 것이 전부입니다.

버그를 거르는 다섯 그물 — 잡는 것이 서로 다릅니다 ① 저장할 때 — clang-format 형식과 들여쓰기. 버그는 못 잡지만 코드 리뷰가 "괄호 위치" 얘기로 낭비되지 않게 합니다 ② 컴파일할 때 — -Wall -Wextra -Werror 초기화 안 한 변수 · 형 변환 실수 · 반환값 무시. 진짜 버그의 절반이 여기서 걸립니다 ③ 커밋 전 — clang-tidy · cppcheck 널 역참조 · 자원 누수 · 낡은 관용구. 실행하지 않고 코드만 읽어 찾습니다 ④ 실행할 때 — ASan · UBSan · TSan 버퍼 넘침 · 해제 후 사용 · 데이터 경쟁. 실행해야만 보입니다 ★ ⑤ CI — 위 넷을 매 커밋마다 자동으로 사람이 잊어도 기계는 안 잊습니다
단계마다 잡히는 버그의 종류가 다릅니다 — 하나로 다 잡을 수 있다는 도구는 없습니다.

① clang-format — .clang-format

# 프로젝트 루트에 .clang-format (YAML · 확장자 없음)
---
Language:        Cpp
BasedOnStyle:    Google        # LLVM · Google · Chromium · Mozilla · WebKit
Standard:        c++20

IndentWidth:     4
TabWidth:        4
UseTab:          Never
ColumnLimit:     100

AccessModifierOffset: -4
PointerAlignment: Left         # int* p  (Right 면 int *p)
AllowShortFunctionsOnASingleLine: Empty
AlwaysBreakTemplateDeclarations: Yes
SeparateDefinitionBlocks: Always

# include 를 그룹으로 묶어 정렬합니다
SortIncludes:    CaseSensitive
IncludeBlocks:   Regroup
IncludeCategories:
  - Regex:  '^<[a-z_]+>$'        # 표준 헤더
    Priority: 1
  - Regex:  '^<.*>$'             # 외부 라이브러리
    Priority: 2
  - Regex:  '^".*"$'             # 내 프로젝트
    Priority: 3
# 기본 스타일 파일을 만들어 놓고 고치는 것이 편합니다
clang-format -style=Google -dump-config > .clang-format

clang-format -i src/*.cpp include/*.h        # 실제로 고치기 (-i = in place)
clang-format --dry-run --Werror src/*.cpp    # 검사만 (CI 용) ★

# 바뀐 파일만 (git 과 함께 · LLVM 에 딸려 옵니다)
git diff -U0 --cached | clang-format-diff -p1 -i

② clang-tidy — .clang-tidy

# 프로젝트 루트에 .clang-tidy
---
Checks: >
  bugprone-*,
  performance-*,
  modernize-*,
  readability-*,
  cppcoreguidelines-*,
  -modernize-use-trailing-return-type,
  -readability-magic-numbers,
  -cppcoreguidelines-avoid-magic-numbers,
  -readability-identifier-length

WarningsAsErrors: 'bugprone-*'
HeaderFilterRegex: '^(src|include)/.*'
FormatStyle:   file
# 한 파일만
clang-tidy src/main.cpp -p build

# 프로젝트 전체를 병렬로 (LLVM 에 포함된 스크립트)
run-clang-tidy -p build -j 8

# 자동으로 고칠 수 있는 것은 고치기 ★ (git commit 후에 쓰세요)
run-clang-tidy -p build -fix -format

# 빌드할 때마다 함께 돌리기 — CMake 한 줄
#   set(CMAKE_CXX_CLANG_TIDY clang-tidy)
#   또는 cmake -S . -B build -DCMAKE_CXX_CLANG_TIDY=clang-tidy
#   ★ 빌드가 2~3배 느려집니다. 개발용 프리셋과 분리하세요
처음 켜면 경고가 수천 개 나옵니다 — 정상입니다. bugprone-* 하나만 켜고 시작해서 깨끗해지면 하나씩 추가하세요. 전부 켜 놓고 무시하는 것보다 하나를 켜고 지키는 것이 훨씬 낫습니다.

컴파일 경고 — 공짜로 얻는 가장 큰 이득 🟢 기초

켜야 하는 경고 · 컴파일러별

# CMakeLists.txt — 관용구를 그대로 쓰세요
if (MSVC)
  add_compile_options(/W4 /permissive- /Zc:__cplusplus /utf-8)
  # /WX  = 경고를 에러로 (CI 에서만 켜는 것을 권합니다)
else()
  add_compile_options(
    -Wall -Wextra -Wpedantic
    -Wshadow            # 안쪽 변수가 바깥 변수를 가림
    -Wconversion        # 값이 잘리는 암묵 변환
    -Wsign-conversion
    -Wnon-virtual-dtor  # 다형 기반 클래스인데 소멸자가 virtual 아님
    -Wold-style-cast    # C 스타일 캐스트
    -Wnull-dereference
    -Wdouble-promotion
    -Wformat=2)
  # -Werror  ← CI 에서만
endif()
/utf-8 은 한국어 개발자에게 특히 중요합니다 — MSVC 는 소스 인코딩을 시스템 코드 페이지(CP949)로 가정합니다. UTF-8 로 저장한 파일에 한글 문자열이 있으면 C4819 경고가 뜨거나 글자가 깨집니다. 이 플래그 하나로 해결됩니다.

cppcheck — 컴파일러와 다른 눈

# 설치
winget install --id Cppcheck.Cppcheck          # 윈도우
brew install cppcheck                           # 맥
sudo apt install cppcheck                       # 리눅스
pacman -S mingw-w64-ucrt-x86_64-cppcheck        # MSYS2

# compile_commands.json 을 그대로 먹입니다 (가장 정확) ★
cppcheck --project=build/compile_commands.json \
         --enable=warning,style,performance,portability \
         --inline-suppr --error-exitcode=1 \
         --suppress=missingIncludeSystem \
         -j 8 2>&1 | tee cppcheck.log

# 폴더만 지정해도 됩니다
cppcheck --enable=all --std=c++20 -I include src/

# 한 줄만 예외 처리 — 코드에 주석으로
#   // cppcheck-suppress nullPointer
#   int x = *p;
clang-tidy 와 겹치지 않나 — 겹치는 부분도 있지만 cppcheck 는 컴파일하지 않고 분석해서 헤더가 없어도 돌아가고, clang-tidy 는 실제 AST 를 보므로 템플릿 관련 문제를 더 잘 잡습니다. CI 에서는 둘 다 돌리는 팀이 많습니다.

Sanitizer — 이것 없이 C++ 를 쓰지 마세요 🟡 중급

켜는 법 · 컴파일러별

# GCC · Clang (리눅스 · 맥 · MSYS2 CLANG64)
g++ -std=c++20 -g -O1 -fsanitize=address,undefined \
    -fno-omit-frame-pointer -fno-optimize-sibling-calls \
    main.cpp -o app
./app

#   -g                        스택 트레이스에 줄 번호가 나오게
#   -O1                       0 이면 너무 느리고 2 이상은 정보가 뭉개집니다
#   -fno-omit-frame-pointer   스택을 제대로 되짚기 위해 ★

# 스레드 경쟁 검사 (ASan 과 동시에는 못 씁니다)
g++ -fsanitize=thread -g -O1 main.cpp -o app-tsan

# MSVC (Visual Studio 2019 16.9 이상)
cl /fsanitize=address /Zi /EHsc /std:c++20 main.cpp
#   ★ /INCREMENTAL 과 함께 쓸 수 없습니다. /RTC 옵션도 빼야 합니다

# 실행 시 동작 조정
$env:ASAN_OPTIONS  = "detect_leaks=1:abort_on_error=1:log_path=asan.log"
$env:UBSAN_OPTIONS = "print_stacktrace=1:halt_on_error=1"
# CMake 프리셋으로 만들어 두면 편합니다
#   cmake --preset asan
if (SANITIZE)
  add_compile_options(-fsanitize=address,undefined -fno-omit-frame-pointer -g)
  add_link_options(-fsanitize=address,undefined)   # ★ 링크에도 필요합니다
endif()

무엇을 잡아 주나 · 실제 출력

=================================================================
==12345==ERROR: AddressSanitizer: heap-use-after-free on address
    0x602000000010 at pc 0x... bp 0x... sp 0x...
READ of size 4 at 0x602000000010 thread T0
    #0 0x... in main /home/me/proj/src/main.cpp:12:16
                                          ↑ ★ 파일과 줄 번호

0x602000000010 is located 0 bytes inside of 4-byte region
freed by thread T0 here:
    #1 0x... in main /home/me/proj/src/main.cpp:11:5   ← 여기서 delete
previously allocated by thread T0 here:
    #2 0x... in main /home/me/proj/src/main.cpp:10:18  ← 여기서 new
SUMMARY: AddressSanitizer: heap-use-after-free
도구잡는 것느려짐
ASan버퍼 넘침 · 해제 후 사용 · 누수약 2배
UBSan정수 오버플로 · 널 역참조 · 정렬 위반거의 없음
TSan데이터 경쟁 (스레드)5~15배
MSan초기화 안 된 메모리 읽기3배
Valgrind비슷한 것들 (리눅스만)20~50배
배포판에는 절대 넣지 마세요 — 느리고 메모리를 훨씬 많이 씁니다. Debug/테스트 빌드에서만 켭니다. 그리고 ASan 과 TSan 은 동시에 못 켭니다 — 빌드를 나누세요.

자동화 — 사람이 잊어도 되게 🔴 실무

커밋 전 자동 검사 (pre-commit)

# .pre-commit-config.yaml
repos:
  - repo: https://github.com/pre-commit/mirrors-clang-format
    rev: v20.1.0
    hooks:
      - id: clang-format
        types_or: [c++, c]
  - repo: https://github.com/pre-commit/pre-commit-hooks
    rev: v5.0.0
    hooks:
      - id: trailing-whitespace
      - id: end-of-file-fixer
      - id: check-merge-conflict
pip install pre-commit
pre-commit install          # .git/hooks/pre-commit 에 등록
pre-commit run --all-files  # 처음 한 번 전체에 적용

# 직접 훅을 쓰고 싶다면 — .git/hooks/pre-commit
#!/bin/sh
files=$(git diff --cached --name-only --diff-filter=ACM | grep -E '\.(cpp|h|hpp)$')
[ -z "$files" ] && exit 0
clang-format --dry-run --Werror $files || exit 1

한 번에 확인하는 스크립트

#!/usr/bin/env bash
# tools/check.sh — 커밋 전에 이거 하나만 돌리면 됩니다
set -e

echo "▶ 형식"
clang-format --dry-run --Werror $(git ls-files '*.cpp' '*.h')

echo "▶ 빌드 (경고를 에러로)"
cmake -S . -B build-check -G Ninja -DCMAKE_BUILD_TYPE=Debug \
      -DCMAKE_EXPORT_COMPILE_COMMANDS=ON -DCMAKE_CXX_FLAGS="-Werror"
cmake --build build-check

echo "▶ 정적 분석"
run-clang-tidy -p build-check -quiet
cppcheck --project=build-check/compile_commands.json \
         --enable=warning,performance --error-exitcode=1 -q

echo "▶ Sanitizer 테스트"
cmake -S . -B build-asan -G Ninja -DCMAKE_BUILD_TYPE=Debug -DSANITIZE=ON
cmake --build build-asan
ctest --test-dir build-asan --output-on-failure

echo "✓ 전부 통과"
이 스크립트를 그대로 CI 에 넣으세요 — GitHub Actions 라면 runs-on: ubuntu-latest 에서 bash tools/check.sh 한 줄입니다. 로컬과 CI 가 같은 스크립트를 쓰는 것이 “CI 에서만 깨진다”를 없애는 가장 확실한 방법입니다.
정리 — 프로젝트 루트에 .clang-format 과 .clang-tidy 두 파일을 두고, CMake 에 경고 옵션을 켜고, 테스트는 ASan·UBSan 빌드로 돌립니다. 도구가 잡아 주는 버그를 손으로 찾느라 시간을 쓰지 마세요.
SETUP 10

파이썬 · Node 연동을 위한 추가 설치

이 가이드의 최종 목적은 파이썬·Node 에서 불러 쓸 모듈을 만드는 것입니다. 그러려면 컴파일러 말고 몇 가지가 더 필요합니다 — 파이썬의 개발용 헤더, 바인딩 라이브러리, 그리고 Node 쪽의 node-gyp 요구사항. 전부 명령 몇 줄이지만, 빠뜨리면 원인을 알기 어려운 에러가 납니다.

파이썬 확장을 빌드하려면 — 네 가지가 모여야 합니다 컴파일러 윈도우 → MSVC 리눅스·맥 → GCC/Clang 개발용 헤더 Python.h apt: python3-dev 링크 라이브러리 python313.lib 윈도우에서만 필요 바인딩 라이브러리 pybind11 · nanobind pip install CMake · pybind11_add_module() 헤더와 라이브러리 경로를 자동으로 찾아 줍니다 mymod.pyd (윈도우) · mymod.so (리눅스·맥) import mymod 로 바로 씁니다 버전이 하나라도 어긋나면 import 단계에서 실패합니다 3.13 용으로 빌드한 모듈은 3.14 에서 import 되지 않습니다 — ABI 태그가 다르기 때문입니다
가장 흔한 실패는 “빌드한 파이썬”과 “실행하는 파이썬”이 다른 것입니다 — 경로를 늘 확인하세요.

① 파이썬 개발 파일 — 있는지 확인부터

# 지금 쓰는 파이썬이 어느 것인지 (★ 가장 먼저)
python -c "import sys; print(sys.executable); print(sys.version)"

# 헤더 폴더 — 여기에 Python.h 가 있어야 합니다
python -c "import sysconfig; print(sysconfig.get_paths()['include'])"
#   윈도우: C:\Users\me\AppData\Local\Programs\Python\Python313\Include
#   리눅스: /usr/include/python3.13

# 실제로 있는지
python -c "import os,sysconfig; p=sysconfig.get_paths()['include']; print(os.path.exists(os.path.join(p,'Python.h')))"
# True 가 나와야 합니다

# 링크용 라이브러리 (윈도우)
python -c "import sysconfig; print(sysconfig.get_config_var('LIBDIR') or sysconfig.get_paths()['data'])"
#   ...\Python313\libs\python313.lib 가 있어야 합니다
윈도우 python.org 설치본은 헤더가 이미 들어 있습니다 — 설치 마법사에서 “py launcher” 와 “Add python.exe to PATH” 를 체크했는지만 확인하세요. Microsoft Store 판 파이썬은 경로가 특이해서 확장 빌드에 권하지 않습니다.

헤더가 없을 때 — OS 별 설치

# Ubuntu / Debian  ★ 이게 가장 흔한 실패 원인입니다
sudo apt install -y python3-dev
#   특정 버전을 쓴다면
sudo apt install -y python3.13-dev python3.13-venv

# Fedora / RHEL
sudo dnf install -y python3-devel

# Arch
sudo pacman -S python          # 헤더가 기본 포함입니다

# 맥 (Homebrew 파이썬은 헤더 포함)
brew install python@3.13

# MSYS2 (UCRT64 파이썬을 쓸 때만)
pacman -S mingw-w64-ucrt-x86_64-python

# 확인 — 이 명령이 실패하면 헤더가 없는 것입니다
echo '#include <Python.h>
int main(){return 0;}' > /tmp/t.c
gcc /tmp/t.c $(python3-config --includes) -o /tmp/t && echo OK
fatal error: Python.h: No such file or directory — 이 에러의 원인은 거의 100% python3-dev 미설치입니다. 도커 이미지에서도 python:3.13-slim 은 헤더가 없으니 python:3.13(전체)을 쓰거나 apt install python3-dev 를 넣으세요.

pybind11 · nanobind — 설치와 연결 🟢 기초

② 설치와 CMake 연결

# 가상 환경을 먼저 만드는 것을 권합니다
python -m venv .venv
.\.venv\Scripts\Activate.ps1        # 윈도우
source .venv/bin/activate            # 맥 · 리눅스

pip install --upgrade pip
pip install pybind11 nanobind scikit-build-core build

# CMake 가 찾을 수 있게 경로를 알려 줍니다 ★
python -m pybind11 --cmakedir
#   .../site-packages/pybind11/share/cmake/pybind11
python -m nanobind --cmake_dir

# 확인
python -c "import pybind11; print(pybind11.__version__)"
python -c "import nanobind; print(nanobind.__version__)"
# CMakeLists.txt
cmake_minimum_required(VERSION 3.25)
project(mymod LANGUAGES CXX)

# ★ Development.Module 만 요구하면 됩니다
#   (Development 전체를 요구하면 윈도우에서 실패하는 경우가 있습니다)
find_package(Python 3.10 REQUIRED COMPONENTS Interpreter Development.Module)
find_package(pybind11 CONFIG REQUIRED)

pybind11_add_module(mymod src/bind.cpp)
target_compile_features(mymod PRIVATE cxx_std_20)
cmake -S . -B build -G Ninja \
  -DCMAKE_PREFIX_PATH="$(python -m pybind11 --cmakedir)" \
  -DPython_EXECUTABLE="$(which python)"          # ★ 어떤 파이썬인지 못 박기
cmake --build build
python -c "import sys; sys.path.insert(0,'build'); import mymod; print(mymod)"

가장 간단한 방법 — pyproject.toml

# pyproject.toml — scikit-build-core 가 CMake 를 대신 불러 줍니다
[build-system]
requires = ["scikit-build-core>=0.10", "pybind11>=2.13"]
build-backend = "scikit_build_core.build"

[project]
name = "mymod"
version = "0.1.0"
requires-python = ">=3.10"

[tool.scikit-build]
cmake.version = ">=3.25"
build-dir = "build/{wheel_tag}"
wheel.packages = []
# 개발 중 — 편집 가능 설치
pip install -e . --no-build-isolation

# 배포용 휠 만들기
python -m build --wheel
ls dist/
#   mymod-0.1.0-cp313-cp313-win_amd64.whl
#          ↑ 파이썬 버전과 플랫폼이 파일 이름에 박힙니다 ★

# 여러 파이썬 버전용으로 한 번에 (CI 에서)
pip install cibuildwheel
cibuildwheel --platform windows
이 방식이 실무 표준입니다 — 사용자는 pip install mymod 만 하면 되고 컴파일러가 없어도 설치됩니다(미리 만든 휠을 받으므로). 빌드 환경 세팅은 모듈을 만드는 사람만 하면 됩니다.

Node 애드온 — node-gyp 요구사항 🟡 중급

③ 무엇이 필요한가 · 확인

# node-gyp 이 요구하는 것은 딱 두 가지입니다
#   ① 파이썬 3.x (빌드 스크립트를 돌리는 데 씁니다 — 실행만)
#   ② C++ 컴파일러  (윈도우 = MSVC, 리눅스 = GCC, 맥 = Xcode CLT)

node --version          # v24.x  (LTS)
npm --version
python --version        # 3.10 이상

# node-gyp 자체
npm install -g node-gyp
node-gyp --version

# ★ 윈도우 — 어떤 MSVC 를 쓸지 못 박기
npm config set msvs_version 2022
npm config set python "C:\Users\me\AppData\Local\Programs\Python\Python313\python.exe"
npm config list

# 확인 — 실제로 한 번 구성해 봅니다
node-gyp configure
#   gyp info find VS using VS2022 (17.x) found at: ...
#   gyp info find Python using Python 3.13.x  ← 두 줄이 나오면 준비 끝
윈도우에서 gyp ERR! find VS 가 나오면 — s02 의 “C++ 를 사용한 데스크톱 개발” 워크로드가 없는 것입니다. 예전에 쓰던 windows-build-tools npm 패키지는 지금은 쓰지 마세요(사용 중단됐고 낡은 VS 를 깝니다).

최소 애드온 프로젝트

// package.json
{
  "name": "myaddon",
  "version": "1.0.0",
  "gypfile": true,
  "dependencies": { "node-addon-api": "^8.0.0" },
  "scripts": {
    "build": "node-gyp rebuild",
    "clean": "node-gyp clean"
  }
}
// binding.gyp   ← node-gyp 의 빌드 설명서 (JSON 비슷한 형식)
{
  "targets": [{
    "target_name": "myaddon",
    "sources": [ "src/addon.cpp" ],
    "include_dirs": [
      "<!@(node -p \"require('node-addon-api').include\")"
    ],
    "cflags_cc!": [ "-fno-exceptions" ],
    "defines": [ "NAPI_CPP_EXCEPTIONS" ],
    "msvs_settings": {
      "VCCLCompilerTool": { "ExceptionHandling": 1, "AdditionalOptions": [ "/std:c++20" ] }
    },
    "xcode_settings": { "CLANG_CXX_LIBRARY": "libc++" }
  }]
}
npm install                 # 의존성 + 자동 빌드
npm run build               # 다시 빌드
node -e "console.log(require('./build/Release/myaddon.node'))"

# CMake 를 쓰고 싶다면 — cmake-js 가 대안입니다
npm install -g cmake-js
cmake-js compile
Node-API(N-API)를 쓰세요 — 옛 V8 API 로 만든 애드온은 Node 버전이 바뀔 때마다 다시 빌드해야 합니다. node-addon-api 는 안정된 ABI 를 쓰므로 한 번 빌드하면 여러 Node 버전에서 그대로 돕니다.
에러원인해결
Python.h: No such file개발 헤더 없음apt install python3-dev
LNK1104: python313.liblibs 폴더를 못 찾음-DPython_EXECUTABLE= 로 명시
ImportError: DLL load failed의존 DLL 누락 · 비트 불일치같은 트리플릿으로 재빌드(s07)
ModuleNotFoundError (빌드는 성공)sys.path 에 없음빌드 폴더를 경로에 추가
gyp ERR! find VSMSVC 워크로드 없음s02 재설치
gyp ERR! find Python파이썬을 못 찾음npm config set python ...
NODE_MODULE_VERSION 불일치다른 Node 로 빌드N-API 로 전환 · 재빌드
정리 — 파이썬 쪽은 개발 헤더(python3-dev) 와 pip install pybind11, Node 쪽은 파이썬 3.x + MSVC 워크로드가 전부입니다. 그리고 언제나 “어느 파이썬/노드로 빌드했는가”를 확인하세요 — 실패의 대부분이 여기서 옵니다.
SETUP 11

환경 변수 · PATH — 완전 정복

설치가 안 된 게 아니라 못 찾는 것인 경우가 압도적으로 많습니다. “왜 g++ 가 안 되지”, “왜 다른 버전이 잡히지”, “왜 VS Code 에서만 안 되지” — 전부 PATH 이야기입니다. 이 섹션만 이해하면 설치 문제의 절반이 사라집니다.

명령을 치면 무슨 일이 일어나나 — PATH 검색 순서 g++ --version 명령을 입력했습니다 C:\Windows\system32 없음 C:\Program Files\CMake\bin 없음 C:\msys64\ucrt64\bin 찾음 — 여기서 끝 C:\Program Files\LLVM\bin 보지 않습니다 순서가 결과를 바꿉니다 — 앞쪽에 있는 것이 항상 이깁니다 컴파일러를 여러 개 깔았다면 "어느 것이 잡히는가"는 오직 PATH 순서로 정해집니다 확인은 이 명령부터 — 추측하지 마세요 윈도우 — where.exe g++ (후보를 전부, 검색 순서대로 보여 줍니다) 맥 · 리눅스 — which -a g++ · type -a g++ · command -v g++
“설치했는데 안 된다”의 정체는 대부분 못 찾거나 다른 걸 찾은 것입니다.

사용자 변수 vs 시스템 변수

사용자 변수시스템 변수
적용 범위내 계정만모든 계정
권한필요 없음관리자
저장 위치HKCU\EnvironmentHKLM\...\Environment
PATH 합쳐지는 순서뒤앞 ★
서비스·스케줄러안 보임보임
권장개인 도구는 여기공용 도구만
# 실제로 어떻게 합쳐지는지 확인
[Environment]::GetEnvironmentVariable('Path','Machine')  # 시스템
[Environment]::GetEnvironmentVariable('Path','User')     # 사용자
$env:Path                                                # 합쳐진 결과

# 보기 좋게 한 줄씩
$env:Path -split ';' | Where-Object { $_ } | ForEach-Object { $_ }

# 중복 항목 찾기 (PATH 가 지저분해졌을 때)
$env:Path -split ';' | Group-Object | Where-Object Count -gt 1
시스템 PATH 가 앞에 옵니다 — 사용자 변수에 C:\msys64\ucrt64\bin 을 넣어도 시스템 변수에 다른 g++ 가 있으면 그쪽이 이깁니다. 이럴 때는 시스템 변수 안에서 순서를 바꾸거나, 문제의 항목을 지워야 합니다.

왜 터미널을 다시 열어야 하나 ★

프로세스는 시작할 때 환경 변수를 한 번 복사해 갖고 있습니다. 나중에 레지스트리를 고쳐도 이미 떠 있는 프로그램은 모릅니다. 그래서 PATH 를 바꾸면 터미널을, 때로는 VS Code 나 탐색기까지 다시 열어야 합니다.

# 새 창을 열지 않고 현재 세션에만 반영 (임시)
$env:Path = "C:\msys64\ucrt64\bin;$env:Path"

# 레지스트리에서 다시 읽어 현재 세션을 갱신 (반영구적으로 편함)
function Update-Env {
  $m = [Environment]::GetEnvironmentVariable('Path','Machine')
  $u = [Environment]::GetEnvironmentVariable('Path','User')
  $env:Path = "$m;$u"
}
Update-Env

# 맥 · 리눅스
export PATH="$HOME/.local/bin:$PATH"      # 이 셸에서만
source ~/.bashrc                          # 설정 파일 다시 읽기
exec $SHELL -l                            # 셸을 새로 시작
VS Code 가 유독 안 될 때 — VS Code 는 시작될 때의 환경을 그대로 씁니다. 탐색기에서 아이콘으로 실행했다면 탐색기(explorer.exe)의 환경을 물려받습니다. PATH 를 고친 뒤에는 VS Code 를 완전히 종료(모든 창)하고 다시 여세요. 가장 확실한 것은 로그아웃 후 다시 로그인입니다.

순서 충돌 — 실제로 겪는 사고 🟡 중급

증상과 진단

[증상] CMake 가 자꾸 엉뚱한 컴파일러를 잡습니다

PS> where.exe g++
C:\msys64\mingw64\bin\g++.exe        ← ★ 이게 먼저 (MINGW64!)
C:\msys64\ucrt64\bin\g++.exe         ← 내가 쓰려던 것 (UCRT64)

PS> where.exe python
C:\Users\me\AppData\Local\Microsoft\WindowsApps\python.exe   ← 스토어 스텁
C:\Users\me\AppData\Local\Programs\Python\Python313\python.exe

PS> where.exe cmake
C:\Program Files\CMake\bin\cmake.exe
C:\msys64\ucrt64\bin\cmake.exe
C:\Program Files\Microsoft Visual Studio\2022\BuildTools\
   Common7\IDE\CommonExtensions\Microsoft\CMake\CMake\bin\cmake.exe

→ 세 개 다 "먼저 나온 것"이 쓰입니다.
  버전이 서로 다르면 빌드 결과가 달라지고,
  ABI 가 다르면 링크 에러가 납니다 (s01).
가장 악질적인 경우 — MinGW 의 cmake 와 공식 cmake 가 섞이면 CMake 가 만든 캐시에 서로 다른 컴파일러 경로가 남아 “어제는 됐는데 오늘은 안 되는” 상태가 됩니다. cmake --fresh 로 캐시를 버리고 다시 구성하세요(s06).

해결 — 세 가지 방법

# ① PATH 순서를 고친다 (환경 변수 창에서 [위로 이동])
#    가장 근본적이지만, 다른 도구가 깨질 수 있으니 신중하게.

# ② PATH 에 아예 넣지 않고, 쓸 때만 켠다 (권장 ★)
#    PowerShell 프로필에 함수로 등록해 둡니다
function ucrt64 { $env:Path = "C:\msys64\ucrt64\bin;$env:Path" }
function llvm   { $env:Path = "C:\Program Files\LLVM\bin;$env:Path" }
function vs64 {
  $vs = & "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" `
          -latest -property installationPath
  Import-Module "$vs\Common7\Tools\Microsoft.VisualStudio.DevShell.dll"
  Enter-VsDevShell -VsInstallPath $vs -SkipAutomaticLocation -DevCmdArguments "-arch=x64"
}
#   → 창을 열고 ucrt64 또는 vs64 를 치면 그 창만 그 툴체인이 됩니다

# ③ 도구에 절대 경로로 못 박는다 (CI · 팀 프로젝트에서 가장 안전)
cmake -S . -B build -G Ninja `
  -DCMAKE_C_COMPILER="C:/msys64/ucrt64/bin/gcc.exe" `
  -DCMAKE_CXX_COMPILER="C:/msys64/ucrt64/bin/g++.exe" `
  -DCMAKE_MAKE_PROGRAM="C:/msys64/ucrt64/bin/ninja.exe"
#    ★ CMake 에는 백슬래시 대신 슬래시(/)를 쓰세요

프로필 파일 — 매번 치지 않으려면 🟢 기초

PowerShell 프로필

# 프로필 파일 경로 확인
$PROFILE
#   C:\Users\me\Documents\PowerShell\Microsoft.PowerShell_profile.ps1
#   (윈도우 기본 PowerShell 5.1 이면 WindowsPowerShell 폴더)

# 없으면 만들기
if (!(Test-Path $PROFILE)) { New-Item -ItemType File -Path $PROFILE -Force }
notepad $PROFILE

# ── 프로필에 넣어 두면 좋은 것들 ──
$env:VCPKG_ROOT   = 'C:\dev\vcpkg'
$env:CMAKE_GENERATOR = 'Ninja'         # -G 를 매번 안 써도 됩니다 ★
$env:NINJA_STATUS = '[%f/%t %e초] '     # 빌드 진행 표시를 보기 좋게

function ucrt64 { $env:Path = "C:\msys64\ucrt64\bin;$env:Path" }
function b  { cmake --build build }
function cfg { cmake -S . -B build -DCMAKE_EXPORT_COMPILE_COMMANDS=ON }

# 반영
. $PROFILE

# ★ 스크립트 실행이 막혀 있다면 (처음 한 번)
Set-ExecutionPolicy -Scope CurrentUser RemoteSigned
PowerShell 7 과 5.1 은 프로필이 다릅니다 — Documents\PowerShell\ 과 Documents\WindowsPowerShell\. “프로필에 넣었는데 안 먹는다”면 다른 PowerShell 을 쓰고 있는 것입니다. $PSVersionTable.PSVersion 으로 확인하세요.

맥 · 리눅스 프로필

# 어떤 파일이 언제 읽히나 (여기서 많이 헤맵니다)
#   bash 로그인 셸   : ~/.bash_profile  (없으면 ~/.profile)
#   bash 대화형 셸   : ~/.bashrc
#   zsh (맥 기본)    : ~/.zshrc      · 로그인 시 ~/.zprofile
#   공통 (권장)      : ~/.profile 에 두고 나머지에서 source

# ~/.zshrc 또는 ~/.bashrc 에 넣을 것들
export PATH="/opt/homebrew/opt/llvm/bin:$PATH"
export VCPKG_ROOT="$HOME/vcpkg"
export CMAKE_GENERATOR=Ninja
export CC=clang
export CXX=clang++
export MAKEFLAGS="-j$(nproc)"          # 맥은 $(sysctl -n hw.ncpu)

alias b='cmake --build build'
alias cfg='cmake -S . -B build -DCMAKE_EXPORT_COMPILE_COMMANDS=ON'

# 반영
source ~/.zshrc

# ★ GUI 앱(VS Code 를 Dock 에서 실행)은 ~/.zshrc 를 읽지 않을 수 있습니다.
#   그럴 땐 터미널에서 'code .' 로 여세요.
CC · CXX 환경 변수 — CMake 는 컴파일러를 지정하지 않으면 이 변수를 봅니다. 단, 이미 구성된 빌드 폴더에는 영향이 없습니다(캐시에 박혀 있으므로). 바꾸려면 빌드 폴더를 지우거나 --fresh 를 쓰세요.

알아 두면 좋은 변수들 · setx 경고 🔴 실무

C++ 개발에서 실제로 쓰이는 환경 변수

변수쓰임
PATH실행 파일 검색 경로
INCLUDE (MSVC)헤더 검색 — vcvars 가 채웁니다
LIB (MSVC)라이브러리 검색
CC / CXX기본 컴파일러
CMAKE_PREFIX_PATHfind_package 검색 경로 ★
CMAKE_GENERATOR기본 생성기
VCPKG_ROOTvcpkg 위치(s07)
LD_LIBRARY_PATH (리눅스)실행 시 .so 검색
DYLD_LIBRARY_PATH (맥)같은 용도 (SIP 제한 있음)
ASAN_OPTIONSSanitizer 동작(s09)
CCACHE_DIR빌드 캐시 위치
윈도우에는 LD_LIBRARY_PATH 가 없습니다 — DLL 은 실행 파일과 같은 폴더 → 시스템 폴더 → PATH 순으로 찾습니다. 그래서 윈도우에서는 DLL 을 exe 옆에 두는 것이 정석입니다.

setx 를 쓰지 말아야 하는 이유 ★

❌ setx PATH "%PATH%;C:\새경로"

무슨 일이 벌어지나
 ① %PATH% 는 "시스템 + 사용자"가 합쳐진 값입니다.
    이걸 사용자 PATH 에 저장하면 → 시스템 PATH 가 통째로 복제됩니다.
 ② setx 는 1024자를 넘으면 잘라 버립니다.
    → 뒤쪽 항목이 소리 없이 사라집니다.
 ③ 다음번에 또 하면 두 배씩 늘어납니다.

실제로 이렇게 PATH 를 날린 사례가 아주 많습니다.
──────────────────────────────────────────────
✓ 대신 이렇게 (PowerShell)

# 백업부터
[Environment]::GetEnvironmentVariable('Path','User') |
  Out-File "$HOME\path-backup-$(Get-Date -f yyyyMMdd).txt"

# 사용자 PATH 에만, 중복 없이 추가
$u = [Environment]::GetEnvironmentVariable('Path','User')
$new = 'C:\msys64\ucrt64\bin'
if ($u -split ';' -notcontains $new) {
  [Environment]::SetEnvironmentVariable('Path', "$new;$u", 'User')
}

# 제거
$u = [Environment]::GetEnvironmentVariable('Path','User')
$u = ($u -split ';' | Where-Object { $_ -ne $new }) -join ';'
[Environment]::SetEnvironmentVariable('Path', $u, 'User')
PATH 가 이미 깨졌다면 — GUI 의 “환경 변수” 창에서 Path 를 선택하고 [편집] → 목록으로 보이면 아직 살아 있습니다. “텍스트 편집” 버튼만 보인다면 이미 줄이 하나로 뭉개진 것이니 백업 파일이나 다른 PC 의 값을 참고해 복구하세요.
정리 — PATH 는 앞에서부터 찾다가 처음 만난 것을 씁니다. 그래서 순서가 곧 결과입니다. 바꿨으면 터미널(그리고 VS Code)을 새로 여세요. 확인은 where.exe / which -a, 추가는 setx 대신 PowerShell 스크립트로.
SETUP 12

설치 문제 사전 — 증상 → 원인 → 해결

여기까지 왔는데 안 되는 경우를 위한 섹션입니다. C++ 설치 문제는 종류가 의외로 적습니다 — 같은 20가지가 반복됩니다. 증상 문구로 찾아서 바로 해결할 수 있게 정리했습니다. 먼저 어느 단계에서 막혔는지 좁히는 것부터 시작합니다.

빌드가 안 될 때 — 이 순서로 좁힙니다 ① 컴파일러를 찾는가 where.exe g++ · where.exe cl · which -a g++ 아니오 → PATH 문제 (s11) ② 헤더를 찾는가 C1083 · fatal error: xxx.h: No such file 아니오 → INCLUDE · -dev 패키지 ③ 링크가 되는가 LNK2019 · undefined reference · LNK2038 아니오 → 라이브러리 · ABI (s01) ④ 실행이 되는가 DLL 없음 · 0xc000007b · GLIBC 버전 아니오 → 런타임 DLL · 비트 불일치 네 단계를 다 통과했다면 — 환경이 아니라 코드 문제입니다 이제부터는 컴파일 에러 메시지 자체를 읽으면 됩니다. 환경 탓을 그만두어도 됩니다
막힌 지점을 네 단계 중 하나로 좁히는 것이 해결의 8할입니다.

① 명령을 못 찾을 때 🟢 기초

증상원인해결
'g++' 은(는) 내부 또는 외부 명령...PATH 미등록 또는 터미널 미재시작C:\msys64\ucrt64\bin 추가 후 새 창 (s03·s11)
'cl' 용어가 인식되지 않습니다일반 PowerShell 에서 실행x64 Native Tools Command Prompt 또는 Enter-VsDevShell (s02)
cmake: command not found설치 시 “Add to PATH” 누락재설치하거나 C:\Program Files\CMake\bin 추가
ninja: not found (CMake 가)생성기는 Ninja 인데 실행 파일 없음winget install Ninja-build.Ninja 후 --fresh
MSYS2 셸에서만 g++ 가 됨PATH 는 셸이 알아서 잡아 준 것PowerShell 에서 쓰려면 PATH 등록 (s03)
Git Bash 에서 g++ 없음Git Bash 에는 컴파일러가 없음UCRT64 셸 또는 PATH 등록한 PowerShell
bash: pacman: command not foundMSYS2 가 아니라 Git Bash 임시작 메뉴에서 MSYS2 MSYS 실행

② 헤더를 못 찾을 때 🟢 기초

증상원인해결
fatal error C1083: 'iostream'INCLUDE 가 비어 있음vcvars 를 켜지 않고 cl 만 PATH 에 넣은 경우 (s02)
stdio.h: No such file (리눅스)libc6-dev 없음sudo apt install build-essential
Python.h: No such file파이썬 개발 헤더 없음apt install python3-dev (s10)
openssl/ssl.h: No such file런타임만 있고 -dev 가 없음libssl-dev / openssl-devel (s05)
fmt/core.h: No such file패키지 관리자 연결 누락vcpkg 툴체인 파일 지정 확인 (s07)
편집기에만 빨간 줄, 빌드는 성공compile_commands.json 못 찾음CMAKE_EXPORT_COMPILE_COMMANDS=ON + 루트 복사 (s08)
<print> · <format> 없음컴파일러가 낡음GCC 14+ / Clang 18+ 로 올리고 -std=c++23 (s05)

③ 링크 에러 — 자주 만나는 넷

[1] LNK2019: 확인할 수 없는 외부 기호 "void __cdecl foo(void)"
    undefined reference to `foo()'
  → 선언만 있고 정의가 없거나, 그 .cpp 를 빌드에 안 넣었습니다.
  → 라이브러리를 안 붙였을 수도 있습니다.
     확인:  nm -C build/*.o | grep foo        (리눅스)
            dumpbin /symbols x.obj | findstr foo   (MSVC)

[2] LNK2038: 'RuntimeLibrary'의 불일치가 검색되었습니다.
              값 'MT_StaticRelease' 와 'MD_DynamicRelease'
  → 정적 런타임(/MT)과 동적 런타임(/MD)을 섞었습니다. ★
  → vcpkg 트리플릿을 x64-windows-static-md 로 (s07)
  → 또는 CMake:
       set(CMAKE_MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>DLL")

[3] LNK1104: 'kernel32.lib' 파일을 열 수 없습니다.
  → LIB 환경 변수가 비어 있습니다 → 개발자 프롬프트에서 빌드 (s02)
  → 또는 Windows SDK 가 설치되지 않았습니다

[4] LNK1112: 모듈 컴퓨터 유형 'x86'이(가) 대상 'x64'와 충돌합니다.
  → 32비트와 64비트를 섞었습니다.
  → vcvars32 로 켜 놓고 x64 라이브러리를 링크한 경우가 대부분입니다.
링크 에러는 코드 문제가 아닙니다 — 빌드 구성의 문제입니다. error: 와 줄 번호가 있으면 컴파일 에러, 줄 번호가 없으면 링크 에러입니다. 고쳐야 할 곳이 완전히 다릅니다.

④ 빌드는 됐는데 실행이 안 될 때

[1] libstdc++-6.dll 을(를) 찾을 수 없어 코드 실행을 진행할 수 없습니다
  → MinGW 로 만든 exe 를 MSYS2 밖에서 실행했습니다.
  해결 A: 정적 링크
      g++ ... -static-libgcc -static-libstdc++ -static
  해결 B: 필요한 DLL 을 exe 옆에 복사
      ldd app.exe      (UCRT64 셸에서 확인)

[2] 0xc000007b — 응용 프로그램을 올바르게 시작하지 못했습니다
  → 32비트 exe 가 64비트 DLL 을 (또는 그 반대로) 물었습니다.
      dumpbin /headers app.exe | findstr machine

[3] VCRUNTIME140.dll 이 없습니다
  → 사용자 PC 에 Visual C++ 재배포 패키지가 없습니다.
     https://aka.ms/vs/17/release/vc_redist.x64.exe 를 함께 배포하거나
     /MT 로 정적 링크하세요.

[4] version `GLIBC_2.38' not found   (리눅스)
  → 빌드한 리눅스가 실행 대상보다 최신입니다.
     더 낮은 배포판(또는 manylinux 컨테이너)에서 빌드하세요. (s05)

[5] ImportError: DLL load failed while importing mymod
  → 파이썬 확장이 물고 있는 DLL 을 못 찾습니다.
     확인:  dumpbin /dependents mymod.pyd
의존 DLL 을 눈으로 보는 도구 — 윈도우는 dumpbin /dependents(MSVC) 또는 Dependencies(오픈소스 GUI 도구), 리눅스는 ldd, 맥은 otool -L 입니다. “왜 안 도는지”를 추측하지 말고 이 도구로 확인하세요.

환경 자체가 이상할 때 🟡 중급

CMake 가 엉뚱한 컴파일러를 잡을 때

# [증상] 컴파일러를 바꿨는데 계속 예전 것으로 빌드됩니다
#        또는 "CMake Error: Could not find CMAKE_CXX_COMPILER"

# 원인: CMakeCache.txt 에 첫 구성 때의 결과가 박혀 있습니다.
#       -D 옵션을 나중에 줘도 캐시가 이깁니다.

# 지금 무엇으로 잡혀 있나 확인
grep -i "CMAKE_CXX_COMPILER" build/CMakeCache.txt
findstr /i "CMAKE_CXX_COMPILER" build\CMakeCache.txt

# 해결 ① 가장 확실 — 빌드 폴더를 통째로 지웁니다
rm -rf build              # PowerShell: Remove-Item -Recurse -Force build

# 해결 ② CMake 3.24+ — 캐시만 버리고 재구성
cmake --fresh -S . -B build -G Ninja

# 해결 ③ 처음부터 못 박기 (슬래시로 씁니다 ★)
cmake -S . -B build -G Ninja \
  -DCMAKE_C_COMPILER=/usr/bin/gcc-15 \
  -DCMAKE_CXX_COMPILER=/usr/bin/g++-15

# 구성 로그를 자세히 (왜 못 찾는지 그대로 나옵니다)
cmake -S . -B build --debug-find --log-level=DEBUG
cat build/CMakeFiles/CMakeConfigureLog.yaml | head -60
“어제는 됐는데”의 90%가 이것입니다 — 컴파일러 업데이트, PATH 변경, vcpkg baseline 변경 뒤에는 빌드 폴더를 지우는 것을 먼저 해 보세요. 1분이면 끝나고 원인 추적보다 빠릅니다.

vcvars 가 안 먹히는 문제

# [증상] vcvars64.bat 을 실행했는데 여전히 cl 을 못 찾습니다

# 원인 A — PowerShell 에서 .bat 을 그냥 실행했습니다.
#   .bat 은 cmd.exe 자식 프로세스에서 돌고,
#   그 프로세스가 끝나면 환경도 같이 사라집니다. (s11 의 원리)
#   → 해결: Enter-VsDevShell 을 쓰세요 (s02)

# 원인 B — 32비트/64비트를 잘못 켰습니다.
#   vcvars32.bat 을 켜 놓고 x64 로 빌드 → LNK1112
#   → vcvars64.bat 또는 vcvarsall.bat x64

# 원인 C — 이미 켜진 창에서 또 켰습니다.
#   [ERROR:vcvarsall.bat] Environment initialization... failed
#   또는 PATH 가 너무 길어져 잘립니다
#   → ★ 새 창을 열고 한 번만 켜세요

# 원인 D — 경로에 공백/한글이 있어 따옴표가 필요합니다
cmd /k "\"C:\Program Files\Microsoft Visual Studio\2022\BuildTools\VC\Auxiliary\Build\vcvars64.bat\""

# 진단 — 지금 환경이 켜져 있나
echo $env:VSCMD_VER          # 17.x 가 나오면 켜진 상태
echo $env:INCLUDE
echo $env:LIB
echo $env:VSCMD_ARG_TGT_ARCH # x64 인지 x86 인지
VSCMD_VER 로 확인하세요 — 이 변수가 비어 있으면 개발자 환경이 아예 안 켜진 것입니다. “켰다고 생각했는데 안 켜진” 경우를 1초에 판별할 수 있습니다.

윈도우 특유의 함정 🟡 중급

한글 · 공백 · 긴 경로

[증상 1] 빌드 중간에 알 수 없는 오류. 경로가 이상하게 잘려 보임
  원인: 사용자 이름이 한글이라 경로가 C:\Users\홍길동\... 입니다.
  → 일부 도구(오래된 Makefile, 일부 vcpkg 포트, node-gyp)가 깨집니다.
  해결: 프로젝트를 C:\dev\myproj 처럼 짧은 영문 경로에 두세요. ★
        (사용자 폴더 자체를 옮길 필요는 없습니다)

[증상 2] error C2001 / 문자열이 깨져 출력됨
  원인: 소스가 UTF-8 인데 MSVC 가 CP949 로 읽었습니다.
  해결: 컴파일 옵션에 /utf-8 추가 (s09)
        또는 파일을 "UTF-8 with BOM" 으로 저장

[증상 3] 콘솔에 한글이 ??? 로 나옴
  chcp 65001                 # 코드 페이지를 UTF-8 로
  $OutputEncoding = [Console]::OutputEncoding = [Text.UTF8Encoding]::new()

[증상 4] The filename or extension is too long (260자 제한)
  · 프로젝트를 짧은 경로로 옮기기
  · 또는 긴 경로 지원 켜기 (관리자 PowerShell)
      New-ItemProperty -Path "HKLM:\SYSTEM\CurrentControlSet\Control\FileSystem" `
        -Name LongPathsEnabled -Value 1 -PropertyType DWORD -Force
      git config --system core.longpaths true

백신 · 권한 · 디스크

[증상 1] 빌드한 exe 가 갑자기 사라집니다 / 실행이 차단됩니다
  원인: Windows Defender 등 백신이 "알 수 없는 실행 파일"로 격리했습니다.
  해결: 제외 폴더를 등록합니다 ★
    설정 → 개인 정보 및 보안 → Windows 보안
         → 바이러스 및 위협 방지 → 설정 관리
         → 제외 → 제외 추가 → 폴더
    등록할 폴더:  C:\dev   ·  C:\msys64  ·  프로젝트의 build 폴더
  명령으로 (관리자 PowerShell):
    Add-MpPreference -ExclusionPath "C:\dev","C:\msys64"

[증상 2] 빌드가 유난히 느립니다 (파일마다 1초씩)
  원인: 실시간 검사가 .obj 파일을 하나하나 스캔합니다.
  → 위와 같은 제외 등록으로 2~3배 빨라지는 경우가 흔합니다.

[증상 3] Access is denied / 파일을 열 수 없습니다
  · 그 exe 가 아직 실행 중입니다 (작업 관리자에서 종료)
  · 또는 C:\Program Files 아래에 빌드하려 했습니다 (권한 필요)
  → 항상 사용자 폴더나 C:\dev 아래에서 빌드하세요

[증상 4] vcpkg 가 "No space left" / 빌드 실패
  → buildtrees · downloads 를 지우고 다시 (s07)
     C 드라이브에 최소 20GB 는 남겨 두세요
백신 제외는 신중하게 — 개발 폴더만 등록하고, 다운로드 폴더나 드라이브 전체를 제외하지 마세요. 회사 PC 라면 보안 정책상 불가능할 수 있으니 관리자에게 문의하세요.

그래도 안 될 때 — 상태 덤프 🔴 실무

환경 진단 한 방 스크립트 (윈도우)

# tools\doctor.ps1 — 도움을 요청할 때 이 출력을 함께 보내세요
Write-Host "=== OS ===";        [Environment]::OSVersion.VersionString
Write-Host "=== PowerShell ==="; $PSVersionTable.PSVersion

Write-Host "=== 컴파일러 ==="
foreach ($t in 'cl','g++','clang++','gcc') {
  $p = (Get-Command $t -ErrorAction SilentlyContinue).Source
  if ($p) { Write-Host "$t -> $p" } else { Write-Host "$t -> 없음" }
}

Write-Host "=== 빌드 도구 ==="
foreach ($t in 'cmake','ninja','make','git','python','node') {
  $c = Get-Command $t -ErrorAction SilentlyContinue
  if ($c) { Write-Host ("{0,-8} {1}" -f $t, $c.Source) }
}

Write-Host "=== MSVC 환경 ==="
"VSCMD_VER      = $env:VSCMD_VER"
"VSCMD_ARG_TGT  = $env:VSCMD_ARG_TGT_ARCH"
"INCLUDE 항목수 = $(($env:INCLUDE -split ';').Count)"
"LIB     항목수 = $(($env:LIB -split ';').Count)"

Write-Host "=== PATH (앞 15개) ==="
($env:Path -split ';' | Where-Object { $_ })[0..14]

Write-Host "=== vcpkg ==="; "VCPKG_ROOT = $env:VCPKG_ROOT"

최후의 수단 — 처음부터 다시

순서대로 하면 대부분 30분 안에 정상화됩니다.

1) 빌드 산출물부터 버립니다 (가장 흔한 원인)
     build/  ·  CMakeCache.txt  ·  vcpkg_installed/  ·  .vs/

2) PATH 를 정리합니다 (s11)
     · 안 쓰는 컴파일러 항목 제거
     · 중복 제거
     · 백업 후 작업

3) 도구를 하나씩 다시 깝니다 — 순서가 있습니다 ★
     ① Visual Studio Build Tools   (s02)  ← 가장 먼저
     ② CMake · Ninja               (s06)
     ③ MSYS2 또는 LLVM             (s03·s04)
     ④ vcpkg                       (s07)
     ⑤ VS Code 확장                (s08)

4) 각 단계마다 "확인 명령"까지 하고 넘어갑니다
     한꺼번에 다 깔고 나중에 확인하면 어디가 문제인지 알 수 없습니다.

5) 그래도 안 되면 — 깨끗한 환경에서 재현해 봅니다
     · WSL2 새 배포판:  wsl --install -d Ubuntu-24.04
     · 도커:            docker run --rm -it gcc:15 bash
   여기서는 되는데 로컬에서만 안 된다면
   → 100% 환경 설정 문제입니다. 위 1~3 을 다시 보세요.
도움을 요청할 때 — “안 돼요” 대신 ① 친 명령 그대로 ② 전체 에러 메시지 ③ 위 doctor 출력 ④ OS 와 컴파일러 버전 네 가지를 붙이세요. 이 네 개만 있으면 대부분 한 번에 답이 나옵니다.
마지막으로 — 이 탭을 마치면 갖춰진 것
컴파일러(MSVC / GCC / Clang) · 빌드 시스템(CMake + Ninja) · 패키지 관리자(vcpkg) · 편집기(VS Code + clangd) · 품질 도구(clang-format · clang-tidy · sanitizer) · 연동 도구(pybind11 · node-gyp). 이제 🅲 C 언어 나 🔵 C++ 기초 탭으로 넘어가 실제 코드를 쓰면 됩니다 — “환경이 안 돼서” 막히는 일은 이제 없습니다.
정리 — 문제는 ① 명령을 못 찾음 ② 헤더를 못 찾음 ③ 링크 실패 ④ 실행 실패 네 가지 중 하나입니다. 어느 단계인지부터 좁히고, 추측하지 말고 확인 명령을 치세요. 막히면 빌드 폴더 삭제 → PATH 정리 → 순서대로 재설치.
Drogon 1.9 · Boost.Asio(1.89) · C++20 코루틴 · gRPC · PostgreSQL · Redis · 2026 기준

C++ 로
API 서버를
만듭니다

Spring Boot 나 FastAPI 로 서버를 만들어 본 사람을 기준으로 씁니다. 같은 GET /users/1 를 C++ 로 받으려면 무엇이 다른지, 어떤 프레임워크를 고르고 DB·JSON·인증·로그·배포를 어떻게 붙이는지 한 줄씩 따라갑니다. 10줄짜리 첫 서버에서 시작해 Drogon + PostgreSQL + Redis 로 만든 URL 단축 서비스까지 갑니다. 중요한 건 속도가 아니라 C++ 서버가 필요한 상황인지 먼저 가려내는 눈이라, 그 얘기부터 합니다.

13SECTIONS
C++20/23기준
Drogon주력 스택
100%복붙해서 도는 예제
SRV 01

C++ 서버는 언제 쓰나 — 프레임워크 지도

가장 값진 판단은 대체로 “C++ 로 안 만든다” 입니다. 그 선을 먼저 긋고 나서, 넘어온 경우에 어떤 프레임워크를 고를지 봅니다. C++ 웹 프레임워크는 종류는 많은데 실서비스에서 검증된 건 서너 개뿐입니다.

무엇을 만드느냐가 프레임워크를 정합니다 — 2026년 기준 선택 흐름 C++ 로 HTTP API 서버를 만들기로 했다 cpp-httplib 배우기·사내 도구 Crow Flask 감성 Drogon 실서비스 기본값 userver 대규모 MSA uWebSockets 실시간 소켓 이 다섯으로 안 되면 한 층 내려갑니다 Boost.Beast — 프로토콜을 직접 · Seastar — 코어당 샤드로 초고성능 · oat++ — 스웨거 문서 자동 그런데 — 대부분의 팀은 C++ 서버가 필요 없습니다 초당 수천 건 아래이고 응답 대부분이 DB 대기라면 FastAPI·Spring 이 더 빠른 선택입니다
고민의 순서는 “정말 C++ 여야 하나” → “무엇을 만드나” → 프레임워크 입니다.

C++ 서버가 실제로 이기는 자리

이럴 때 유리이럴 땐 별로
요청당 CPU 계산이 실제로 무거움
이미지·영상·행렬·매칭 엔진
요청 대부분이 DB·외부 API 대기
지연 시간의 꼬리가 계약 조건
p99 를 5ms 안에 — GC 정지가 없음
초당 수백 건 규모의 사내 서비스
이미 C++ 자산이 있음
엔진·SDK·모델 추론 코드
요구사항이 매주 바뀌는 초기 제품
연결 수가 수십만
게임·채팅·시세 스트리밍
팀에 C++ 를 읽는 사람이 없음
서버 대수·메모리 비용이 큼
같은 트래픽을 1/5 대수로
관리형 서비스(람다 등)에 얹어야 함
솔직한 기준선 — FastAPI 한 대가 초당 3천~1만 건, Spring Boot(가상 스레드)가 초당 3만~8만 건을 냅니다. 지금 트래픽이 그 밑이라면 C++ 로 바꿔서 얻는 건 숫자가 아니라 고생입니다. C++ 는 그 위이거나, p99 지연이거나, 이미 C++ 코드가 있을 때의 선택입니다.

Spring Boot · FastAPI 를 아는 사람이 겪는 낙차

Spring / FastAPIC++
패키지Maven · pipvcpkg · Conan
실행./gradlew bootRun빌드 후 바이너리
라우팅애너테이션·데코레이터매크로 또는 .Get()
JSON 매핑자동(Jackson·pydantic)직접 또는 glaze
ORMJPA · SQLAlchemy얇음(Drogon ORM)
DI프레임워크가 해 줌내가 조립
핫 리로드있음없음 — 빌드 대기
메모리 누수GC 가 처리ASan·수명 설계
가장 큰 낙차는 성능이 아니라 “없는 것들”입니다 — 마이그레이션 도구, 검증 애너테이션, 시큐리티 스타터, 액추에이터 같은 게 기본 제공되지 않습니다. 이 탭은 그 빈칸을 어떤 라이브러리로 메우는지가 절반입니다.

한 단계 더 — 프레임워크 아홉 개를 한 표로 🟡 중급

비동기 모델 · 성숙도 · 쓰는 자리

이름동시성 모델성숙도·규모이럴 때 씁니다
cpp-httplib블로킹 + 스레드 풀
요청 1건 = 스레드 1개
헤더 파일 1개
매우 안정 · 의존성 0
배우기 · 사내 도구 · 테스트용 목 서버 · CLI 에 붙이는 관리 API
CrowAsio 이벤트 루프
핸들러는 동기
헤더 온리 · 활발Flask 처럼 라우트를 죽 나열하고 싶을 때
Drogon ★이벤트 루프 N개
+ C++20 코루틴
실서비스 다수
TechEmpower 상위 단골
REST API 의 기본값. ORM·필터·세션·설정 파일이 다 들어 있음
userver스택풀 코루틴
동기처럼 쓰고 비동기로 돎
Yandex 실운영
문서는 러시아색
대규모 MSA — PostgreSQL·Mongo·Redis·gRPC 드라이버가 한 벌로
uWebSocketsepoll 단일 루프
코어당 프로세스
Bun 런타임이 채택웹소켓 수만~수십만 연결 · 브로드캐스트
Boost.BeastAsio 를 직접 조립Boost 표준 라이브러리HTTP/WebSocket 을 프레임워크 없이 다뤄야 할 때 · 프록시·게이트웨이
oat++선택형(멀티스레드/코루틴)중간 · 커뮤니티 작음OpenAPI(스웨거) 문서를 코드에서 자동 생성하고 싶을 때
Pistache스레드 + epoll개발 속도 느림기존 코드 유지보수 외에는 새로 고르지 않습니다
Seastar코어당 샤드
share-nothing · 자체 할당자
ScyllaDB 가 사용초당 100만 건 급 · 표준 라이브러리를 통째로 바꿔 쓸 각오가 있을 때
TechEmpower 순위를 읽는 법 — C++ 프레임워크(drogon · lithium · h2o 계열)는 plaintext·JSON 항목에서 늘 최상단에 붙어 있습니다. 다만 그 벤치마크는 DB 없이 문자열만 돌려주는 항목이 상위를 차지하고, 실제 서비스와 가장 가까운 건 Fortunes(DB + 템플릿) 항목입니다. 그 항목에서는 언어 차이가 훨씬 줄어듭니다 — 순위표를 볼 때 항목 이름부터 보세요.

실무에선 이렇게 — 고르는 규칙 세 줄 🔴 실무

현실적인 결정 규칙

① 처음이거나, 서버가 부업이다        → cpp-httplib
   · 헤더 하나 복사, CMake 세 줄, 10분이면 뜹니다
   · 초당 수천 건까지는 이걸로 충분합니다

② 진짜 서비스를 C++ 로 만든다        → Drogon  ★
   · 라우팅·필터·세션·ORM·설정 파일이 다 들어 있습니다
   · C++20 코루틴으로 DB 호출을 동기처럼 씁니다
   · 한국어 자료는 적지만 공식 문서가 튼튼합니다

③ 팀이 마이크로서비스를 여러 개 굴린다 → userver
   · 트레이싱·메트릭·설정 핫리로드가 기본 탑재
   · 대신 학습 곡선과 빌드 시간이 만만치 않습니다

④ HTTP 가 아니라 소켓이 주인공이다    → uWebSockets / Beast
   · 게임 · 채팅 · 시세 스트리밍

⑤ gRPC 만 필요하다                   → grpc C++ 만
   · HTTP 프레임워크 자체가 필요 없습니다 (w08)
섞어 쓰는 게 정상입니다 — 실제 서비스는 Drogon 으로 외부 REST, gRPC 로 내부 통신, uWebSockets 로 실시간 채널 처럼 갈라 놓는 구성이 흔합니다. 하나로 다 하려다 프레임워크와 싸우지 마세요.

첫 판단을 도와주는 계산 한 번

지금 서버가 감당해야 하는 것을 숫자로 적어 봅니다.

  · 피크 RPS                     예) 1,200
  · 요청당 순수 CPU 시간          예) 0.4 ms
  · 요청당 대기(DB·외부 API)      예) 35 ms
  · 목표 p99                     예) 200 ms

CPU 만으로 필요한 코어 =  RPS × CPU시간
                       = 1200 × 0.0004 s = 0.48 코어

→ CPU 는 거의 안 씁니다. 병목은 35ms 의 '대기'입니다.
→ 언어를 바꿔도 35ms 는 그대로입니다.
→ 이 서비스는 C++ 로 옮길 이유가 없습니다.
   (커넥션 풀·인덱스·캐시를 먼저 손대야 합니다)

반대로 CPU시간이 8ms 였다면
   1200 × 0.008 = 9.6 코어  ← 여기서부터 언어가 돈입니다
이 계산을 안 하고 시작한 프로젝트가 정말 많습니다 — “C++ 로 바꿨는데 왜 안 빨라지죠?” 의 90%가 대기 시간이 병목인 경우입니다. 언어가 줄여 주는 건 CPU 시간뿐이고, 나머지는 구조·쿼리·캐시의 몫입니다.
정리 — C++ 서버는 CPU 가 실제로 병목이거나, p99 지연이 계약이거나, C++ 자산이 이미 있을 때의 선택입니다. 넘어왔다면 Drogon 을 기본값으로 잡고, 배우는 단계에서는 cpp-httplib, 소켓 중심이면 uWebSockets·Beast 로 갈라집니다.
SRV 02

첫 서버 — 10줄로 JSON 을 돌려줍니다

설명보다 먼저 뜨는 걸 봅니다. cpp-httplib 은 헤더 파일 하나라 vcpkg 없이도 되고, curl 로 확인까지 5분이면 끝납니다. 이 단계에서 C++ 서버가 요청을 받는 모양이 손에 붙습니다.

요청 한 건이 지나는 길 — cpp-httplib 은 요청 1건에 스레드 1개입니다 1 svr.listen("0.0.0.0", 8080) 소켓을 열고 accept 루프로 들어갑니다. 여기서 프로그램이 멈춘 것처럼 보이는 게 정상입니다 2 커널의 accept 큐(backlog) 연결이 큐에 쌓입니다. 큐가 꽉 차면 클라이언트는 연결 거부를 받습니다 3 스레드 풀에서 스레드 하나를 꺼내 붙입니다 gunicorn 의 sync 워커, 톰캣의 스레드 풀과 같은 모델입니다 — 요청 1건 = 스레드 1개 4 HTTP 파싱 → Request 객체 method · path · headers · body · 정규식 캡처(req.matches) 가 채워집니다 5 내가 등록한 람다 실행 ★ 여기가 전부입니다 여기서 DB 를 기다리면 그 스레드가 통째로 멈춥니다. 스레드 수 = 동시 처리 수 6 응답 직렬화 → 소켓에 쓰기 keep-alive 면 같은 스레드가 2번으로 되돌아가 다음 요청을 기다립니다 스레드 16개 · DB 응답 40ms 라면 이론상 400 rps 가 천장입니다 천장을 올리려면 스레드를 늘리는 게 아니라 모델을 바꿔야 합니다 — 그게 다음 절의 Drogon 입니다
이 그림의 5번만 이해하면 됩니다 — 블로킹 모델은 스레드 수가 곧 동시 처리 수입니다.

첫 서버 — 이게 전부입니다

// main.cpp
#include <httplib.h>
#include <nlohmann/json.hpp>
using json = nlohmann::json;

int main() {
    httplib::Server svr;                       // FastAPI 의 app = FastAPI()

    svr.Get("/health", [](const httplib::Request&, httplib::Response& res) {
        res.set_content("ok", "text/plain");
    });

    // GET /users/42  → 경로에 정규식을 씁니다 (애너테이션이 없으니까요)
    svr.Get(R"(/users/(\d+))", [](const httplib::Request& req, httplib::Response& res) {
        int id = std::stoi(req.matches[1]);    // 캡처 그룹 1번
        json body = {{"id", id}, {"name", "김개발"}, {"active", true}};
        res.set_content(body.dump(), "application/json");
    });

    // POST /users  → 본문 파싱
    svr.Post("/users", [](const httplib::Request& req, httplib::Response& res) {
        auto in = json::parse(req.body, nullptr, false);   // false = 예외 대신 discarded
        if (in.is_discarded() || !in.contains("name")) {
            res.status = 400;
            res.set_content(R"({"error":"name is required"})", "application/json");
            return;
        }
        json out = {{"id", 7}, {"name", in["name"]}};
        res.status = 201;
        res.set_content(out.dump(), "application/json");
    });

    svr.set_exception_handler([](const auto&, auto& res, std::exception_ptr) {
        res.status = 500;                       // 안 잡으면 서버가 죽습니다 ★
        res.set_content(R"({"error":"internal"})", "application/json");
    });

    std::cout << "listening on :8080\n";
    svr.listen("0.0.0.0", 8080);                // 여기서 블록됩니다
}
FastAPI 와 나란히 보면 — @app.get("/users/{id}") 가 svr.Get(R"(/users/(\d+))", ...) 입니다. 데코레이터가 없으니 경로 패턴을 정규식으로 쓰고, 타입 변환(int id)도 내가 합니다. R"(...)" 는 원시 문자열이라 백슬래시를 두 번 쓰지 않아도 됩니다.

설치와 빌드 — vcpkg 로

# ① vcpkg 설치 (한 번만)
git clone https://github.com/microsoft/vcpkg.git ~/vcpkg
~/vcpkg/bootstrap-vcpkg.sh          # 윈도우: bootstrap-vcpkg.bat
export VCPKG_ROOT=~/vcpkg

# ② 프로젝트에 매니페스트를 둡니다 — package.json 자리
cat vcpkg.json                       # 아래 내용
# ③ 빌드 — vcpkg 가 알아서 받아서 빌드합니다 (첫 빌드는 몇 분)
cmake -B build -S . -G Ninja \
  -DCMAKE_TOOLCHAIN_FILE=$VCPKG_ROOT/scripts/buildsystems/vcpkg.cmake \
  -DCMAKE_BUILD_TYPE=Release
cmake --build build -j

./build/api                          # 서버 실행
{
  "name": "my-api",
  "version": "0.1.0",
  "dependencies": [
    "cpp-httplib",
    "nlohmann-json"
  ],
  "builtin-baseline": "최신 커밋 해시를 넣습니다"
}
cmake_minimum_required(VERSION 3.24)
project(my-api LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)

find_package(httplib CONFIG REQUIRED)
find_package(nlohmann_json CONFIG REQUIRED)

add_executable(api main.cpp)
target_link_libraries(api PRIVATE
    httplib::httplib
    nlohmann_json::nlohmann_json)
vcpkg 를 처음 쓸 때 막히는 곳 — CMAKE_TOOLCHAIN_FILE 을 안 주면 find_package 가 전부 실패합니다. 이 한 줄이 “vcpkg 를 쓴다”는 선언입니다. VS Code 를 쓴다면 CMakeUserPresets.json 에 넣어 두면 매번 안 쳐도 됩니다.

확인 — curl 로 두드려 보기 🟢 기초

돌려 보는 명령들

curl -i localhost:8080/health
#  HTTP/1.1 200 OK
#  Content-Type: text/plain
#  ok

curl -s localhost:8080/users/42 | jq
#  { "active": true, "id": 42, "name": "김개발" }

curl -s -X POST localhost:8080/users \
     -H 'Content-Type: application/json' \
     -d '{"name":"이서버"}' | jq
#  { "id": 7, "name": "이서버" }

# 일부러 틀린 본문 — 400 이 나와야 합니다
curl -i -X POST localhost:8080/users -d 'not json'

# 아주 거친 부하 확인 (동시 50, 총 2000)
ab -c 50 -n 2000 http://localhost:8080/health 2>&1 | grep -E 'Requests|Time per'
포트가 안 열릴 때 — listen 이 false 를 돌려주면 대개 포트가 이미 사용 중입니다. ss -ltnp | grep 8080(맥은 lsof -i :8080) 로 확인하세요. 개발 중 재시작이 잦다면 svr.set_socket_options 로 SO_REUSEADDR 를 켜 두면 TIME_WAIT 때문에 못 뜨는 일이 줄어듭니다.

바로 필요해지는 옵션 다섯 개

httplib::Server svr;

// ① 스레드 풀 크기 — 이게 동시 처리 수의 천장입니다 ★
svr.new_task_queue = [] { return new httplib::ThreadPool(32); };

// ② 타임아웃 — 안 주면 느린 클라이언트 하나가 스레드를 오래 잡습니다
svr.set_read_timeout(5, 0);        // 5초
svr.set_write_timeout(5, 0);
svr.set_keep_alive_max_count(100); // keep-alive 로 처리할 요청 수
svr.set_payload_max_length(1024 * 1024);   // 1MB 넘는 본문 거부

// ③ 공통 처리(미들웨어 자리) — 전/후 훅
svr.set_pre_routing_handler([](const auto& req, auto& res) {
    res.set_header("X-Req-Id", make_uuid());
    return httplib::Server::HandlerResponse::Unhandled;  // 계속 진행
});
svr.set_logger([](const auto& req, const auto& res) {
    std::printf("%s %s -> %d\n", req.method.c_str(), req.path.c_str(), res.status);
});

// ④ CORS (브라우저에서 부를 거라면 필수 — w09 에서 제대로)
svr.Options(".*", [](const auto&, auto& res) {
    res.set_header("Access-Control-Allow-Origin", "*");
    res.set_header("Access-Control-Allow-Headers", "Content-Type, Authorization");
});

// ⑤ 정적 파일 — 프런트 빌드 결과를 같이 서빙
svr.set_mount_point("/", "./public");
이 라이브러리를 실서비스에 두려면 알아야 할 한계 — HTTP/2 가 없고, 스레드 하나가 요청 하나라 느린 외부 호출이 많으면 금방 막힙니다. 사내 도구·테스트 목 서버·관리 API 까지가 편안한 영역이고, 그 위로는 Drogon 으로 넘어가는 게 맞습니다.

cpp-httplib 으로 어디까지 되나

항목지원메모
HTTP/1.1 · keep-alive○기본으로 켜져 있습니다
HTTP/2 · HTTP/3×Nginx 뒤에 두면 클라이언트까지는 HTTP/2 가 됩니다
경로 파라미터△{id} 가 아니라 정규식 캡처 (\d+) 입니다
파일 업로드○req.get_file_value("file") — 멀티파트 파싱 내장
정적 파일 서빙○set_mount_point("/", "./public")
HTTPS○CPPHTTPLIB_OPENSSL_SUPPORT 를 정의하고 빌드
비동기 핸들러×요청 1건 = 스레드 1개 — 이게 이 라이브러리의 성격입니다
WebSocket×Beast(w05) 나 uWebSockets 로 갑니다
미들웨어△전/후 훅 두 개 — 체인 구조는 없습니다
HTTP 클라이언트○httplib::Client 로 외부 API 호출도 같은 헤더에서
“안 됨”이 많아 보이지만 사내 도구·관리 API·테스트용 목 서버에는 이 표의 ○ 만으로 충분합니다. × 가 필요해지는 순간이 곧 Drogon 으로 옮길 때입니다.
정리 — httplib::Server 에 람다를 등록하고 listen, 이게 C++ 서버의 최소 형태입니다. 빌드는 vcpkg 매니페스트 + 툴체인 파일 조합이 표준이고, 이 조합은 이 탭 내내 그대로 씁니다. 다만 블로킹 모델이라 동시 처리 수가 스레드 수에 묶인다는 점이 다음 절로 넘어가는 이유입니다.
SRV 03

Drogon 완전 입문 — C++ 의 Spring Boot 자리

라우팅·필터·세션·ORM·설정 파일·로거가 한 상자에 들어 있는 유일한 C++ 프레임워크입니다. 이벤트 루프 N개 + C++20 코루틴 조합이라, DB 를 기다리는 동안에도 스레드가 놀지 않습니다. 이 절만 끝내면 실서비스 API 의 뼈대가 완성됩니다.

Drogon 의 구조 — 받는 쪽(위)과 요청 하나가 지나는 체인(아래) 클라이언트 keep-alive Acceptor 메인 루프가 받습니다 이벤트 루프 N개 — 기본값은 코어 수 연결 하나는 루프 하나에 고정 — 콜백은 늘 같은 스레드 요청 하나가 한 루프 안에서 지나는 체인 라우팅 {id} 매칭 필터 체인 인증·CORS 컨트롤러 핸들러 실행 co_await DB·Redis 응답 콜백 JSON 직렬화 co_await 로 기다리는 동안 루프는 다른 요청을 처리합니다 스레드 8개로 동시 연결 수만 개를 다룹니다 — 코드는 동기처럼 위에서 아래로 읽힙니다 루프 스레드에서 블로킹하면 그 루프의 모든 연결이 함께 멈춥니다 동기 DB 드라이버 · sleep · 파일 전체 읽기 · 무거운 계산은 별도 스레드 풀로 던지세요
Node.js 를 떠올리면 정확합니다 — 루프가 여러 개인 Node, 그리고 블로킹은 똑같이 죄악입니다.

설치와 프로젝트 생성

# ① vcpkg 로 설치 — 필요한 기능만 골라 켭니다
vcpkg install "drogon[core,ctl,postgres,redis,orm]"
#   core     기본 HTTP 서버
#   ctl      drogon_ctl 코드 생성기
#   postgres libpq 기반 비동기 드라이버
#   redis    hiredis 기반 비동기 클라이언트

# ② 골격 생성 — Spring Initializr 자리입니다
drogon_ctl create project shortener
cd shortener
#   ├─ CMakeLists.txt
#   ├─ config.json          ← 포트·DB·로그 설정
#   ├─ main.cc
#   ├─ controllers/         ← 여기에 코드를 씁니다
#   ├─ filters/             ← 인증·CORS 같은 공통 처리
#   ├─ models/              ← ORM 모델 (자동 생성)
#   └─ views/               ← CSP 템플릿 (안 써도 됩니다)

# ③ 컨트롤러 뼈대 만들기
drogon_ctl create controller -h api::v1::User
#   controllers/api_v1_User.h / .cc 가 생깁니다

# ④ 빌드·실행
cmake -B build -DCMAKE_TOOLCHAIN_FILE=$VCPKG_ROOT/scripts/buildsystems/vcpkg.cmake
cmake --build build -j
./build/shortener            # config.json 을 같은 폴더에서 읽습니다
drogon_ctl 을 꼭 쓰세요 — 컨트롤러 클래스는 매크로 등록이 필요해서 손으로 쓰면 오타가 나기 쉽습니다. create controller·create filter· create model 세 개만 알면 충분합니다.

main.cc 와 config.json

// main.cc — 이게 전부입니다. 컨트롤러는 '자동 등록'됩니다
#include <drogon/drogon.h>

int main() {
    drogon::app()
        .loadConfigFile("./config.json")
        .registerPostHandlingAdvice(          // 모든 응답에 공통 헤더
            [](const drogon::HttpRequestPtr& req,
               const drogon::HttpResponsePtr& res) {
                res->addHeader("X-Powered-By", "drogon");
            })
        .run();                               // 여기서 블록됩니다
}
{
  "listeners": [
    { "address": "0.0.0.0", "port": 8080, "https": false }
  ],
  "app": {
    "threads_num": 0,
    "document_root": "./public",
    "max_connections": 100000,
    "keepalive_requests": 0,
    "idle_connection_timeout": 60,
    "enable_gzip": true,
    "enable_brotli": false,
    "log": {
      "log_path": "./logs",
      "logfile_base_name": "api",
      "log_size_limit": 100000000,
      "log_level": "INFO"
    }
  },
  "db_clients": [
    {
      "name": "default",
      "rdbms": "postgresql",
      "host": "127.0.0.1",
      "port": 5432,
      "dbname": "app",
      "user": "app",
      "passwd": "secret",
      "connection_number": 8,
      "is_fast": false
    }
  ]
}
"threads_num": 0 의 뜻 — 코어 수만큼 이벤트 루프를 만든다는 뜻입니다. 컨테이너에서 CPU 제한을 걸어 뒀다면 호스트 코어 수를 읽어 과하게 만들 수 있으니 운영에서는 숫자를 명시하는 편이 안전합니다. passwd 는 여기 박지 말고 환경 변수로 뺍니다(w10).

핵심 — 컨트롤러와 라우팅 🟡 중급

컨트롤러 — 매크로가 애너테이션 자리입니다

// controllers/UserController.h
#pragma once
#include <drogon/HttpController.h>
using namespace drogon;

class UserController : public drogon::HttpController<UserController> {
public:
    METHOD_LIST_BEGIN
      // 경로            ┌ 메서드   ┌ 이 뒤는 필터 이름들
      ADD_METHOD_TO(UserController::one,    "/api/v1/users/{id}", Get);
      ADD_METHOD_TO(UserController::create, "/api/v1/users",      Post, "AuthFilter");
      ADD_METHOD_TO(UserController::search, "/api/v1/users?q={1}&page={2}", Get);
    METHOD_LIST_END

    // 코루틴 핸들러 — 인자를 '값'으로 받는 것이 규칙입니다 ★
    Task<> one(HttpRequestPtr req,
               std::function<void(const HttpResponsePtr&)> cb,
               int64_t id);

    // 평범한 콜백 핸들러 — 이 형태도 됩니다
    void create(const HttpRequestPtr& req,
                std::function<void(const HttpResponsePtr&)>&& cb);

    void search(const HttpRequestPtr& req,
                std::function<void(const HttpResponsePtr&)>&& cb,
                std::string q, int page);
};
경로 변수가 함수 인자로 들어옵니다 — {id} 가 세 번째 인자 int64_t id 로 형변환까지 되어 도착합니다. Spring 의 @PathVariable, FastAPI 의 타입 힌트와 같은 역할인데 매크로의 순서가 곧 인자 순서라는 점만 다릅니다.

구현 — 요청 읽고 JSON 돌려주기

// controllers/UserController.cc
#include "UserController.h"

void UserController::create(const HttpRequestPtr& req,
                            std::function<void(const HttpResponsePtr&)>&& cb) {
    auto in = req->getJsonObject();            // shared_ptr<Json::Value>
    if (!in || !(*in)["name"].isString()) {
        Json::Value err;
        err["error"] = "name is required";
        auto res = HttpResponse::newHttpJsonResponse(err);
        res->setStatusCode(k400BadRequest);    // 상태 코드는 enum 입니다
        cb(res);
        return;
    }

    Json::Value out;
    out["id"]   = 7;
    out["name"] = (*in)["name"].asString();
    auto res = HttpResponse::newHttpJsonResponse(out);
    res->setStatusCode(k201Created);
    res->addHeader("Location", "/api/v1/users/7");
    cb(res);                                   // 콜백을 부르면 응답 전송
}

// 쿼리 파라미터·헤더·쿠키 읽기
//   req->getParameter("q")        ?q=...
//   req->getHeader("Authorization")
//   req->getCookie("session")
//   req->getBody()                std::string_view
//   req->getMethodString()        "GET"
//   req->getPeerAddr().toIp()     클라이언트 IP
콜백을 반드시 한 번 불러야 합니다 — 안 부르면 클라이언트가 타임아웃까지 대기하고, 두 번 부르면 정의되지 않은 동작입니다. return 앞에 cb(...) 가 있는지 분기마다 확인하세요. 코루틴 핸들러라면 co_return 전에 한 번입니다.

공통 처리 — 필터와 어드바이스 🟡 중급

필터 — 스프링 시큐리티 필터 체인 자리

// filters/AuthFilter.h
#pragma once
#include <drogon/HttpFilter.h>

class AuthFilter : public drogon::HttpFilter<AuthFilter> {
public:
    void doFilter(const drogon::HttpRequestPtr& req,
                  drogon::FilterCallback&& fail,        // 여기서 끊을 때
                  drogon::FilterChainCallback&& next) override
    {
        auto h = req->getHeader("Authorization");
        if (h.starts_with("Bearer ") && verify_jwt(h.substr(7))) {
            // 뒤 단계에서 쓸 값을 요청에 붙여 둡니다 (ThreadLocal 자리)
            req->attributes()->insert("uid", extract_uid(h.substr(7)));
            next();                                     // 통과 ★
            return;
        }
        auto res = drogon::HttpResponse::newHttpResponse();
        res->setStatusCode(drogon::k401Unauthorized);
        fail(res);                                      // 여기서 끝 ★
    }
};

// 컨트롤러에서 이름으로 붙입니다
//   ADD_METHOD_TO(C::m, "/api/v1/me", Get, "AuthFilter");
// 경로 전체에 걸려면 main 에서
//   drogon::app().registerFilter<AuthFilter>();   // 는 없고
//   config.json 의 "static_files_router" 대신 아래를 씁니다
//   drogon::app().registerHttpRequestAdvice(...)

어드바이스 — 전역 훅 다섯 자리

drogon::app()
  // ① 라우팅 전 — 모든 요청. CORS 프리플라이트를 여기서 끊습니다
  .registerSyncAdvice([](const HttpRequestPtr& req) -> HttpResponsePtr {
      if (req->method() == Options) {
          auto r = HttpResponse::newHttpResponse();
          r->addHeader("Access-Control-Allow-Origin", "*");
          r->addHeader("Access-Control-Allow-Headers", "*");
          return r;                       // null 이 아니면 여기서 응답 확정
      }
      return {};                          // 비어 있으면 계속 진행
  })
  // ② 응답 직전 — 공통 헤더·응답 시간 기록
  .registerPostHandlingAdvice([](const HttpRequestPtr& req,
                                 const HttpResponsePtr& res) {
      res->addHeader("Access-Control-Allow-Origin", "*");
  })
  // ③ 처리되지 않은 예외 → 500 JSON 으로 통일
  .setExceptionHandler([](const std::exception& e,
                          const HttpRequestPtr& req,
                          std::function<void(HttpResponsePtr&&)>&& cb) {
      LOG_ERROR << "unhandled: " << e.what();
      Json::Value j; j["error"] = "internal";
      auto r = HttpResponse::newHttpJsonResponse(j);
      r->setStatusCode(k500InternalServerError);
      cb(std::move(r));
  })
  // ④ 404 를 JSON 으로
  .setCustom404Page(HttpResponse::newHttpJsonResponse(
      []{ Json::Value j; j["error"] = "not found"; return j; }()));
Spring 대응표 — 필터 = OncePerRequestFilter, 어드바이스 = HandlerInterceptor, 예외 핸들러 = @ControllerAdvice, attributes() = 요청 스코프 빈. 이름만 다르고 자리는 같습니다.

진짜 무기 — 코루틴 핸들러와 비동기 ORM 🔴 실무

co_await 로 DB 를 부릅니다

using namespace drogon;
using namespace drogon::orm;

Task<> UserController::one(HttpRequestPtr req,
                           std::function<void(const HttpResponsePtr&)> cb,
                           int64_t id)
{
    auto db = app().getDbClient();             // config.json 의 "default"
    try {
        // ★ 여기서 스레드가 멈추지 않습니다. 루프는 다른 요청을 처리합니다
        auto rows = co_await db->execSqlCoro(
            "select id, name, created_at from users where id = $1", id);

        if (rows.empty()) {
            cb(HttpResponse::newNotFoundResponse());
            co_return;
        }
        Json::Value j;
        j["id"]   = rows[0]["id"].as<int64_t>();
        j["name"] = rows[0]["name"].as<std::string>();
        cb(HttpResponse::newHttpJsonResponse(j));
    }
    catch (const DrogonDbException& e) {
        LOG_ERROR << "db: " << e.base().what();
        auto r = HttpResponse::newHttpResponse();
        r->setStatusCode(k503ServiceUnavailable);
        cb(r);
    }
    co_return;
}
이 코드가 이 절의 결론입니다 — await 를 쓰는 FastAPI 코드와 모양이 거의 같은데, 스레드 8개로 동시 연결 수만 개를 처리합니다. $1 자리표시자를 쓰므로 SQL 인젝션이 원천 차단됩니다(w07).

ORM 모델 — 손으로 SQL 을 덜 쓰고 싶다면

# ① DB 스키마에서 모델 클래스를 생성합니다
#    models/ 폴더에 model.json 을 두고
drogon_ctl create model models
#    → models/Users.h / Users.cc 가 만들어집니다
#include "models/Users.h"
using namespace drogon_model::app;      // 스키마 이름

Task<> listUsers(HttpRequestPtr req,
                 std::function<void(const HttpResponsePtr&)> cb)
{
    auto db = app().getDbClient();
    Mapper<Users> mapper(db);

    // where + order + limit — JPA 의 Criteria 자리
    auto users = co_await mapper
        .orderBy(Users::Cols::_created_at, SortOrder::DESC)
        .limit(20)
        .findBy(Criteria(Users::Cols::_active, CompareOperator::EQ, true));

    Json::Value arr(Json::arrayValue);
    for (const auto& u : users) arr.append(u.toJson());   // 자동 직렬화 ★
    cb(HttpResponse::newHttpJsonResponse(arr));
    co_return;
}

// 저장·수정·삭제
//   Users u; u.setName("김개발");
//   co_await mapper.insert(u);      // insert 후 id 가 채워집니다
//   co_await mapper.update(u);
//   co_await mapper.deleteByPrimaryKey(7);
기대치를 정확히 — Drogon ORM 은 JPA·SQLAlchemy 만큼 강하지 않습니다. 조인·서브쿼리·집계는 그냥 SQL 을 쓰는 편이 빠르고 읽기 쉽습니다. 단순 CRUD 는 Mapper, 나머지는 execSqlCoro — 이 경계가 실무의 정답입니다. 마이그레이션 도구는 없으니 Flyway·sqlx-cli·dbmate 같은 외부 도구를 씁니다.

Spring Boot · FastAPI ↔ Drogon 대응표

하고 싶은 것Spring BootFastAPIDrogon
라우트 등록@GetMapping@app.getADD_METHOD_TO(..., Get)
경로 변수@PathVariable함수 인자{id} → 함수 인자
본문 파싱@RequestBodypydantic 모델req->getJsonObject()
응답ResponseEntitydict 반환newHttpJsonResponse
공통 전처리Filter · InterceptorMiddlewareFilter · Advice
예외 → 응답@ControllerAdviceexception_handlersetExceptionHandler
비동기 DBR2DBC · 가상 스레드awaitco_await execSqlCoro
설정application.ymlpydantic-settingsconfig.json + 환경 변수
로깅SLF4JloggingLOG_INFO << ... (또는 spdlog)
테스트MockMvcTestClientGoogleTest + HttpClient (w11)
없는 것 세 가지를 미리 알아 두세요 — 의존성 주입 컨테이너(직접 조립하거나 싱글턴), 스키마 마이그레이션(외부 도구), 선언적 검증(pydantic·Bean Validation 자리는 직접 함수를 짜거나 glaze 의 구조체 매핑으로 대신합니다 — w06).
정리 — Drogon 은 컨트롤러 매크로 + 필터 + config.json + co_await ORM 네 조각으로 이뤄져 있습니다. 이 절의 코드만으로도 실서비스 API 가 돌아가고, 남은 것은 JSON·DB·인증·관측·배포를 붙이는 일입니다. 딱 하나만 기억한다면 — 루프 스레드에서 절대 블로킹하지 않는다.
SRV 04

Boost.Asio 와 C++20 코루틴 — 그 밑에서 벌어지는 일

Drogon·Crow·Beast 는 전부 이 위에 얹혀 있습니다. io_context·strand·co_await 세 단어만 잡으면 프레임워크가 왜 그렇게 생겼는지가 보이고, 필요할 때 직접 서버를 조립할 수도 있습니다.

Asio 의 실행 모델 — io_context 하나에 run() 을 부르는 스레드 여러 개 커널 — epoll(리눅스) · kqueue(맥) · IOCP(윈도우) io_context — 실행할 핸들러 큐 하나 완료된 작업의 콜백이 여기에 쌓입니다 io.run() #1 큐에서 꺼내 실행 io.run() #2 큐에서 꺼내 실행 io.run() #3 큐에서 꺼내 실행 io.run() #4 큐에서 꺼내 실행 같은 strand 에 묶인 핸들러는 절대 동시에 실행되지 않습니다 공유 자료를 뮤텍스 없이 지키는 방법 — 락이 아니라 순서로 해결합니다 핸들러 안에서 블로킹하면 그 스레드가 통째로 묶입니다 동기 DB 드라이버 · sleep_for · 큰 파일 읽기는 별도 스레드 풀로 던지세요
스레드 수는 코어 수면 충분합니다 — 더 늘려도 대기 중인 I/O 는 스레드를 쓰지 않기 때문입니다.

코루틴 에코 서버 — 이게 전체 코드입니다

#include <boost/asio.hpp>
#include <boost/asio/co_spawn.hpp>
#include <boost/asio/detached.hpp>
#include <cstdio>

namespace asio = boost::asio;
using asio::ip::tcp;
using asio::awaitable;
using asio::use_awaitable;

// 연결 하나를 담당하는 코루틴 — 동기 코드처럼 읽힙니다 ★
awaitable<void> session(tcp::socket sock) {
    char buf[4096];
    try {
        for (;;) {
            std::size_t n = co_await sock.async_read_some(
                asio::buffer(buf), use_awaitable);        // 여기서 '양보'
            co_await asio::async_write(
                sock, asio::buffer(buf, n), use_awaitable);
        }
    } catch (const std::exception& e) {
        // 상대가 끊으면 예외로 옵니다 — 정상 흐름입니다
    }
}

awaitable<void> listener(unsigned short port) {
    auto ex = co_await asio::this_coro::executor;
    tcp::acceptor acc(ex, {tcp::v4(), port});
    acc.set_option(asio::socket_base::reuse_address(true));
    for (;;) {
        tcp::socket sock = co_await acc.async_accept(use_awaitable);
        // 연결마다 코루틴 하나 — 스레드가 아니라 '코루틴'입니다
        asio::co_spawn(ex, session(std::move(sock)), asio::detached);
    }
}

int main() {
    asio::io_context io(1);
    asio::signal_set sig(io, SIGINT, SIGTERM);
    sig.async_wait([&](auto, auto) { io.stop(); });   // Ctrl+C 로 정상 종료

    asio::co_spawn(io, listener(9000), asio::detached);
    io.run();
}
co_await 가 하는 일 — “여기서 멈추고, 결과가 오면 이 줄부터 다시”. 멈춘 동안 스레드는 다른 코루틴을 실행합니다. 파이썬 asyncio, 자바스크립트 await 와 개념이 정확히 같고, 차이는 스택 프레임이 힙에 저장되어 코루틴 하나가 수백 바이트밖에 안 든다는 점입니다.

스레드 풀 — 코어 수만큼 run() 을 부릅니다

int main() {
    const unsigned n = std::max(1u, std::thread::hardware_concurrency());
    asio::io_context io(n);                    // 힌트: 동시 실행 스레드 수

    asio::co_spawn(io, listener(9000), asio::detached);

    std::vector<std::thread> pool;
    pool.reserve(n - 1);
    for (unsigned i = 0; i + 1 < n; ++i)
        pool.emplace_back([&] { io.run(); });  // 같은 io_context 를 함께 돌립니다
    io.run();                                  // 메인 스레드도 일꾼으로
    for (auto& t : pool) t.join();
}

// 모델 비교
//   ① io_context 1개 + 스레드 N개   ← 기본. 부하가 자동으로 고르게 퍼집니다
//   ② io_context N개 + 스레드 1개씩 ← 연결을 루프에 고정. 캐시 지역성이 좋습니다
//                                     (Drogon 이 쓰는 모델)
//   ③ SO_REUSEPORT + 프로세스 N개   ← 커널이 분배. uWebSockets 이 쓰는 모델

// 일이 없으면 run() 이 바로 돌아옵니다 — 붙잡아 두려면
auto guard = asio::make_work_guard(io);
// guard.reset();   // 이걸 불러야 run() 이 빠져나옵니다
스레드를 많이 만든다고 빨라지지 않습니다 — Asio 의 스레드는 “대기”가 아니라 “실행”에만 쓰입니다. I/O 를 기다리는 연결은 스레드를 점유하지 않으므로 코어 수 = 스레드 수 가 거의 항상 최적입니다. 스레드를 늘려 이득이 나면, 그건 어딘가에서 블로킹하고 있다는 신호입니다.

공유 자료 — strand 로 락 없이 🔴 실무

strand — 실행을 직렬화하는 장치

// 문제: 스레드 4개가 같은 방(room)의 참가자 목록을 건드립니다
struct Room {
    std::vector<std::shared_ptr<Session>> members;   // 보호가 필요
};

// 해법 ①  뮤텍스 — 되긴 하는데 잠금 경합이 생깁니다
std::mutex mtx;

// 해법 ②  strand — 이 방의 모든 작업을 '한 줄'로 세웁니다 ★
class Room {
    asio::strand<asio::io_context::executor_type> strand_;
    std::vector<std::shared_ptr<Session>> members_;   // 락 필요 없음
public:
    explicit Room(asio::io_context& io) : strand_(asio::make_strand(io)) {}

    void join(std::shared_ptr<Session> s) {
        asio::post(strand_, [this, s = std::move(s)] {
            members_.push_back(s);      // 이 람다들은 절대 겹치지 않습니다
        });
    }
    void broadcast(std::string msg) {
        asio::post(strand_, [this, msg = std::move(msg)] {
            for (auto& m : members_) m->send(msg);
        });
    }
};

// 코루틴이라면 실행자를 strand 로 바꿔 co_spawn 합니다
asio::co_spawn(room.strand(), session(std::move(sock)), asio::detached);
strand 는 “가상의 스레드”라고 생각하면 됩니다 — 물리 스레드는 4개인데, 같은 strand 의 작업들은 마치 한 스레드에서 도는 것처럼 차례로 실행됩니다. Node.js 의 단일 루프 안정감을 필요한 자료 단위로만 얻는 셈입니다.

블로킹을 어디로 치우나 · 타임아웃

// ① 무거운 CPU 작업 · 동기 라이브러리는 별도 풀로
asio::thread_pool workers(4);

awaitable<std::string> render_pdf(Doc d) {
    // co_spawn 대신 다른 실행자에서 돌리고 결과만 기다립니다
    auto out = co_await asio::co_spawn(
        workers,
        [d = std::move(d)]() -> awaitable<std::string> {
            co_return heavy_blocking_render(d);   // 여기선 블로킹해도 됩니다
        },
        asio::use_awaitable);
    co_return out;
}

// ② 타임아웃 — 안 걸면 느린 상대가 코루틴을 영원히 붙잡습니다 ★
awaitable<void> with_timeout(tcp::socket& s) {
    asio::steady_timer timer(co_await asio::this_coro::executor);
    timer.expires_after(std::chrono::seconds(5));

    char buf[1024];
    // 둘 중 먼저 끝나는 쪽 — Boost 1.80+ 의 병렬 그룹
    auto [order, e1, n, e2] = co_await asio::experimental::make_parallel_group(
        s.async_read_some(asio::buffer(buf), asio::deferred),
        timer.async_wait(asio::deferred)
    ).async_wait(asio::experimental::wait_for_one(), asio::use_awaitable);

    if (order[0] == 1) {                 // 타이머가 먼저 → 타임아웃
        s.close();
        throw std::runtime_error("read timeout");
    }
}

// ③ 취소 — 코루틴에 취소 신호를 보냅니다
//    asio::cancellation_signal sig;
//    co_spawn(ex, work(), asio::bind_cancellation_slot(sig.slot(), detached));
//    sig.emit(asio::cancellation_type::terminal);
수명(lifetime)이 진짜 함정입니다 — 비동기 작업이 끝나기 전에 소켓·버퍼가 사라지면 즉시 크래시입니다. 전통적인 처방은 std::enable_shared_from_this 로 세션을 살려 두는 것이고, 코루틴을 쓰면 지역 변수가 코루틴 프레임에 살아 있어 이 문제의 8할이 사라집니다. 코루틴을 권하는 가장 큰 이유입니다.

Asio 용어를 아는 언어로 옮기면

Boost.Asio파이썬 asyncioNode.js하는 일
io_context이벤트 루프libuv 루프완료된 작업의 콜백을 담아 두는 큐
io.run()run_forever()(런타임이 자동)큐에서 꺼내 실행하는 일꾼
awaitable<T>코루틴Promise나중에 값을 주는 것
co_awaitawaitawait여기서 양보하고 결과가 오면 재개
co_spawncreate_task즉시 실행코루틴을 루프에 띄웁니다
strand(루프가 하나라 불필요)(스레드가 하나)실행 직렬화 — 락 대신 순서
thread_poolrun_in_executorworker_threads블로킹 작업 격리
steady_timerasyncio.sleepsetTimeout시간 대기
cancellation_signaltask.cancel()AbortController진행 중인 작업 취소
딱 한 칸만 다릅니다 — strand 는 파이썬·Node 에 대응어가 없습니다. 저쪽은 루프가 하나뿐이라 애초에 동시 실행이 없기 때문입니다. C++ 는 여러 스레드가 같은 루프를 돌리므로 이 장치가 필요합니다.
정리 — Asio 는 io_context(큐) + run() 스레드들 + strand(직렬화) 세 부품입니다. C++20 코루틴을 얹으면 콜백 지옥과 수명 문제가 함께 사라지고, 코드가 동기처럼 읽히면서 비동기로 돕니다. 프레임워크를 쓰더라도 이 모델을 알아야 “왜 여기서 블로킹하면 안 되는지”를 설명할 수 있습니다.
SRV 05

Boost.Beast — HTTP·WebSocket 을 직접 다룰 때

Beast 는 프레임워크가 아니라 부품입니다. 라우팅도 JSON 도 없습니다. 그래서 REST API 를 만들 때는 답이 아니지만, 프록시·게이트웨이·실시간 채널처럼 프레임워크가 오히려 방해되는 자리에서는 이만한 게 없습니다.

WebSocket 이 맺어지는 순서 — HTTP 로 시작해서 HTTP 를 벗어납니다 브라우저·wscat 서버 (Beast) GET /ws — Upgrade: websocket · Sec-WebSocket-Key 101 Switching Protocols · Sec-WebSocket-Accept 여기부터 프레임 — text · binary · ping · pong close 프레임(코드 1000) 을 주고받고 TCP 를 닫습니다 Beast 를 고르는 기준 — 프레임워크가 오히려 방해가 될 때 프록시 · API 게이트웨이 · 커스텀 프로토콜 · 초당 수십만 프레임의 브로드캐스트 대신 라우팅·JSON·세션·설정·로그를 전부 직접 만듭니다 REST API 가 목적이라면 Beast 로 시작하지 마세요 — 그건 Drogon 의 자리입니다
핸드셰이크는 평범한 HTTP 요청 한 번입니다 — 그래서 같은 포트·같은 프록시를 씁니다.

WebSocket 에코 서버 — 코루틴 버전

#include <boost/beast/core.hpp>
#include <boost/beast/http.hpp>
#include <boost/beast/websocket.hpp>
#include <boost/asio.hpp>

namespace beast = boost::beast;
namespace http  = beast::http;
namespace ws    = beast::websocket;
namespace asio  = boost::asio;
using tcp = asio::ip::tcp;
using asio::awaitable, asio::use_awaitable;

awaitable<void> ws_session(tcp::socket sock) {
    ws::stream<tcp::socket> s(std::move(sock));

    // ① 권장 타임아웃 한 벌 — 핸드셰이크·유휴·핑 자동 처리까지 포함됩니다 ★
    s.set_option(ws::stream_base::timeout::suggested(beast::role_type::server));

    // ② 응답 헤더 손보기
    s.set_option(ws::stream_base::decorator([](ws::response_type& res) {
        res.set(http::field::server, "beast-echo");
    }));

    co_await s.async_accept(use_awaitable);      // 101 까지 처리해 줍니다

    beast::flat_buffer buf;
    for (;;) {
        buf.clear();
        co_await s.async_read(buf, use_awaitable);   // 프레임 하나
        s.text(s.got_text());                        // 받은 그대로 되돌리기
        co_await s.async_write(buf.data(), use_awaitable);
    }
}

awaitable<void> listener(unsigned short port) {
    auto ex = co_await asio::this_coro::executor;
    tcp::acceptor acc(ex, {tcp::v4(), port});
    for (;;) {
        auto sock = co_await acc.async_accept(use_awaitable);
        asio::co_spawn(ex, [s = std::move(sock)]() mutable -> awaitable<void> {
            try { co_await ws_session(std::move(s)); }
            catch (const std::exception& e) { /* 끊김은 정상 흐름 */ }
        }, asio::detached);
    }
}
# 확인
npx wscat -c ws://localhost:9001
> 안녕
< 안녕

같은 포트에서 HTTP 와 WS 를 함께

awaitable<void> session(tcp::socket sock) {
    beast::flat_buffer buf;
    http::request<http::string_body> req;
    co_await http::async_read(sock, buf, req, use_awaitable);   // 첫 요청을 읽고

    if (ws::is_upgrade(req)) {                    // ★ 업그레이드 요청인가
        ws::stream<tcp::socket> s(std::move(sock));
        s.set_option(ws::stream_base::timeout::suggested(beast::role_type::server));
        co_await s.async_accept(req, use_awaitable);   // 읽어 둔 요청을 넘깁니다
        co_await ws_loop(s);
        co_return;
    }

    // 평범한 HTTP 응답
    http::response<http::string_body> res{http::status::ok, req.version()};
    res.set(http::field::content_type, "application/json");
    res.keep_alive(req.keep_alive());
    if (req.target() == "/health") res.body() = R"({"status":"ok"})";
    else { res.result(http::status::not_found); res.body() = R"({"error":"nf"})"; }
    res.prepare_payload();                        // Content-Length 계산 ★
    co_await http::async_write(sock, res, use_awaitable);

    if (!res.keep_alive()) {
        beast::error_code ec;
        sock.shutdown(tcp::socket::shutdown_send, ec);
    }
}
prepare_payload() 를 빼먹지 마세요 — 본문 길이 헤더가 안 붙어서 클라이언트가 영원히 기다리는 증상이 납니다. Beast 를 처음 쓸 때 가장 흔한 사고이고, “curl 이 멈춰 있다”의 대부분이 이것입니다.

브로드캐스트 — 채팅방 하나 만들기 🔴 실무

여러 연결에 동시에 보내기

class Session;   // 아래에서 정의

class Room {
    asio::strand<asio::any_io_executor> strand_;
    std::unordered_set<std::shared_ptr<Session>> members_;
public:
    explicit Room(asio::any_io_executor ex) : strand_(asio::make_strand(ex)) {}
    auto executor() { return strand_; }

    void join (std::shared_ptr<Session> s) {
        asio::post(strand_, [this, s] { members_.insert(s); });
    }
    void leave(std::shared_ptr<Session> s) {
        asio::post(strand_, [this, s] { members_.erase(s); });
    }
    void publish(std::string msg) {
        // strand 덕분에 members_ 를 락 없이 순회합니다 ★
        asio::post(strand_, [this, m = std::make_shared<std::string>(std::move(msg))] {
            for (auto& s : members_) s->enqueue(m);
        });
    }
};

// 각 세션은 '보낼 것 큐'를 하나 갖습니다 — 동시에 async_write 를 두 번 부르면
// 프레임이 섞여 프로토콜이 깨집니다. 반드시 한 번에 하나씩 ★
class Session : public std::enable_shared_from_this<Session> {
    ws::stream<tcp::socket> ws_;
    std::deque<std::shared_ptr<const std::string>> out_;
    bool writing_ = false;
public:
    void enqueue(std::shared_ptr<const std::string> m) {
        asio::post(ws_.get_executor(), [self = shared_from_this(), m] {
            self->out_.push_back(m);
            if (!self->writing_) self->do_write();
        });
    }
    void do_write() { /* out_.front() 를 async_write, 완료 콜백에서 다음 것 */ }
};
이 절에서 진짜 배워야 할 규칙 — 하나의 WebSocket 스트림에 동시에 async_write 를 두 번 걸면 안 됩니다. 프레임이 섞여 “가끔 이상한 메시지가 온다”는 재현 안 되는 버그가 됩니다. 해법은 언제나 세션마다 송신 큐 하나입니다.

TLS(wss) 를 직접 붙이려면

#include <boost/asio/ssl.hpp>
namespace ssl = asio::ssl;

ssl::context make_ssl_ctx() {
    ssl::context ctx(ssl::context::tls_server);
    ctx.set_options(ssl::context::default_workarounds
                  | ssl::context::no_sslv2 | ssl::context::no_sslv3
                  | ssl::context::no_tlsv1 | ssl::context::no_tlsv1_1
                  | ssl::context::single_dh_use);
    ctx.use_certificate_chain_file("/etc/ssl/fullchain.pem");
    ctx.use_private_key_file("/etc/ssl/privkey.pem", ssl::context::pem);
    return ctx;
}

awaitable<void> tls_session(tcp::socket sock, ssl::context& ctx) {
    ssl::stream<tcp::socket> tls(std::move(sock), ctx);
    co_await tls.async_handshake(ssl::stream_base::server, use_awaitable);

    // 그 위에 WebSocket 을 얹습니다 — 타입이 한 겹 늘어날 뿐입니다
    ws::stream<ssl::stream<tcp::socket>> s(std::move(tls));
    co_await s.async_accept(use_awaitable);
    // ... 이하 동일
}
# 개발용 자체 서명 인증서 (운영에는 쓰지 마세요)
openssl req -x509 -newkey rsa:2048 -nodes -days 365 \
  -keyout privkey.pem -out fullchain.pem \
  -subj "/CN=localhost"

# 확인
curl -k https://localhost:9443/health
npx wscat --no-check -c wss://localhost:9443/ws
실무에서는 대개 이 코드를 안 씁니다 — TLS 종료는 Nginx·Caddy·로드밸런서가 맡고 애플리케이션은 평문 HTTP 로 뒤에 섭니다. 인증서 갱신·OCSP·HTTP/2 협상을 직접 관리하지 않아도 되기 때문입니다. C++ 프로세스가 직접 TLS 를 하는 건 프록시를 둘 수 없는 환경(엣지 장비·게임 서버)일 때입니다. 자세한 구성은 w09 에서 봅니다.

WebSocket 을 운영에 올릴 때 확인할 것

항목안 챙기면처방
핑 · 퐁죽은 연결이 계속 쌓입니다timeout::suggested 가 자동으로 보냅니다
프록시 타임아웃60초마다 조용히 끊깁니다Nginx proxy_read_timeout 을 길게(300s)
동시 쓰기프레임이 섞여 프로토콜이 깨집니다세션마다 송신 큐 하나
백프레셔느린 클라이언트가 서버 메모리를 먹습니다큐 길이 상한 → 넘으면 연결을 끊습니다
메시지 크기큰 프레임 하나로 메모리 폭발read_message_max 를 설정
연결 수accept: too many open filesulimit -n 65535 · 노드당 상한 계산
재연결배포 때 전부 동시에 몰립니다클라이언트에 지수 백오프 + 지터
인증업그레이드 후에는 헤더를 못 봅니다핸드셰이크 요청에서 토큰을 검사
인증은 반드시 핸드셰이크에서 — 업그레이드가 끝나면 그 뒤는 프레임뿐이라 Authorization 헤더를 다시 볼 기회가 없습니다. 브라우저는 WebSocket 요청에 커스텀 헤더를 못 붙이므로 쿠키나 쿼리 파라미터의 단기 토큰을 쓰는 게 보통입니다.
정리 — Beast 는 HTTP·WebSocket 을 타입으로 표현한 부품 상자입니다. is_upgrade 로 갈라 같은 포트에서 둘을 처리하고, 브로드캐스트는 strand + 세션별 송신 큐가 정답입니다. 라우팅이 필요한 순간이 오면 그때가 Drogon 으로 옮길 때입니다.
SRV 06

JSON — pydantic 이 없는 세계에서 살아남기

C++ 서버에서 체감 성능과 코드 분량을 가장 크게 좌우하는 게 JSON 입니다. 편한 것(nlohmann)과 빠른 것(simdjson·glaze)이 다르고, 구조체 자동 매핑이 되느냐가 코드 길이를 절반으로 줄입니다.

파싱 처리량의 대략적인 순서 — 자릿수 감각만 가져가세요 0 1 2 3 GB/s nlohmann/json 0.15 GB/s · 가장 쓰기 편함 Boost.JSON 0.6 GB/s · Boost 를 이미 쓴다면 RapidJSON 0.8 GB/s · 오래됐지만 여전히 빠름 glaze 1.5 GB/s · 구조체 매핑이 매크로 없이 simdjson 3 GB/s 막대는 대략적인 파싱 처리량입니다. 문서 모양·필드 수·하드웨어에 따라 크게 달라집니다 고르는 규칙 — 편의는 nlohmann · 속도는 glaze · 대량 읽기는 simdjson 한 서비스에서 둘을 섞어 쓰는 것도 정상입니다 — 어느 경계에서 갈리는지만 정해 두세요
대부분의 API 서버에서 JSON 은 전체 CPU 의 10~30%를 먹습니다 — 공짜가 아닙니다.

nlohmann/json — 기본값으로 삼기 좋은 것

#include <nlohmann/json.hpp>
using json = nlohmann::json;

struct User {
    int64_t     id{};
    std::string name;
    bool        active{};
    std::vector<std::string> tags;
};
// 매크로 한 줄로 구조체 ↔ JSON 이 됩니다 ★ (pydantic 자리)
NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE(User, id, name, active, tags)

// 쓰기
User u{7, "김개발", true, {"cpp", "server"}};
std::string body = json(u).dump();            // {"active":true,"id":7,...}

// 읽기 — 예외를 던지지 않는 형태를 쓰세요 ★
auto j = json::parse(raw, nullptr, /*allow_exceptions=*/false);
if (j.is_discarded()) return bad_request("invalid json");

User in;
try {
    in = j.get<User>();                       // 필드가 없으면 예외
} catch (const json::exception& e) {
    return bad_request(e.what());
}

// 부분 접근 — 기본값을 주는 형태가 안전합니다
int  page  = j.value("page", 1);              // 없으면 1
auto name  = j.value("name", std::string{});
bool debug = j.contains("debug") && j["debug"].is_boolean()
             && j["debug"].get<bool>();
j["없는키"] 를 조심하세요 — const 가 아니면 그 자리에 null 을 만들어 넣습니다(파이썬 defaultdict 같은 동작). 읽기만 할 거라면 j.at("키")(없으면 예외) 또는 j.value("키", 기본값) 를 쓰세요. 이 한 줄 차이로 조용한 버그가 사라집니다.

glaze — 매크로 없이, 훨씬 빠르게

#include <glaze/glaze.hpp>

struct User {
    int64_t     id{};
    std::string name{};
    bool        active{};
    std::vector<std::string> tags{};
};
// 등록이 필요 없습니다 — 집합체(aggregate)면 그대로 리플렉션됩니다 ★

std::string out;
if (auto ec = glz::write_json(u, out); ec) { /* 실패 */ }

User in{};
if (auto ec = glz::read_json(in, raw)) {
    // 사람이 읽을 수 있는 에러 메시지를 만들어 줍니다
    return bad_request(glz::format_error(ec, raw));
}

// 필드 이름을 바꾸거나 일부만 노출하려면 meta 를 씁니다
template <>
struct glz::meta<User> {
    using T = User;
    static constexpr auto value = object(
        "id",        &T::id,
        "user_name", &T::name,     // JSON 키 이름만 다르게
        "active",    &T::active);  // tags 는 내보내지 않음
};

// 부분 읽기 — 큰 문서에서 필요한 필드만
std::string token;
glz::get_as<std::string>(raw, "/auth/token");   // JSON 포인터

// 같은 구조체를 다른 형식으로도 — 매크로 없이
// glz::write_beve(u, bin);   // 바이너리 — 내부 통신용
2026년에 새로 시작한다면 glaze 를 먼저 보세요 — 헤더 온리이고, 매크로도 코드 생성도 없이 구조체를 그대로 씁니다. 비슷한 계열로 reflect-cpp 가 있는데, 이쪽은 rfl::Validator 로 범위·정규식 검증까지 타입에 얹을 수 있어 pydantic 에 더 가깝습니다.

아주 큰 입력 — simdjson 온디맨드 🔴 실무

DOM 으로 통째로 올리기 — 편하지만 비쌉니다
// 50MB 로그 배열에서 error 만 세기
auto j = nlohmann::json::parse(big);        // 문서 전체를 객체 트리로
int n = 0;
for (const auto& e : j["events"])
    if (e["level"] == "error") ++n;

// 문제
//  · 안 쓰는 필드까지 전부 파싱하고 메모리에 올립니다
//  · 문자열마다 std::string 할당 — 수백만 번
//  · 50MB 입력에 300MB 넘게 쓰는 일이 흔합니다
온디맨드 — 필요한 것만 훑고 지나갑니다
#include <simdjson.h>
simdjson::ondemand::parser parser;
// 입력은 패딩된 버퍼여야 합니다 ★ (SIMD 가 뒤를 넘겨 읽습니다)
auto doc = parser.iterate(simdjson::padded_string(big));

int n = 0;
for (auto e : doc["events"]) {
    std::string_view lv = e["level"];        // 복사 없음 ★
    if (lv == "error") ++n;
}

// 특징
//  · 필요한 필드만 실제로 파싱합니다
//  · string_view 라 할당이 거의 없습니다
//  · 대신 '앞으로만' 읽을 수 있고 수정은 못 합니다
simdjson 을 쓸 때 두 가지만 기억하세요 — ① 입력은 padded_string (또는 뒤에 SIMDJSON_PADDING 바이트 여유가 있는 버퍼)여야 합니다. HTTP 본문을 그대로 넘기면 버퍼 밖을 읽습니다. ② 반환되는 string_view 는 원본 버퍼가 살아 있는 동안만 유효합니다 — 응답을 만들 때까지 원본을 붙잡아 두거나 복사하세요.

라이브러리 고르기 표

라이브러리구조체 매핑수정 가능빌드 부담이럴 때
nlohmann/json매크로 한 줄○헤더 온리
컴파일이 느린 편
기본값. 팀원이 다 아는 API, 예제가 가장 많음
glaze ★자동(리플렉션)○헤더 온리 · C++23새 프로젝트 · 직렬화가 뜨거운 경로일 때
reflect-cpp자동 + 검증○헤더 온리 · C++20pydantic 처럼 입력 검증까지 타입으로 하고 싶을 때
simdjson△ 수동× 읽기 전용라이브러리수십 MB 입력 · 로그·이벤트 수집기
RapidJSON×○헤더 온리기존 코드 유지 · SAX 스트리밍이 필요할 때
Boost.JSON△○Boost 일부이미 Boost 를 쓰고 의존성을 안 늘리고 싶을 때
jsoncpp×○라이브러리Drogon 이 내부에서 씁니다 — 그래서 자동으로 딸려 옵니다
Drogon 을 쓸 때의 현실적인 조합 — 프레임워크가 Json::Value(jsoncpp)를 주고받으므로 경계에서는 그걸 쓰고, 내부 도메인 객체 ↔ JSON 변환은 glaze 로 하는 팀이 많습니다. 뜨거운 엔드포인트만 HttpResponse::newHttpResponse() 에 glaze 로 만든 문자열을 그대로 넣어 jsoncpp 를 건너뛰는 것도 흔한 최적화입니다.

검증 — pydantic 자리를 메우기 🟡 중급

reflect-cpp 로 타입에 규칙 얹기

#include <rfl/json.hpp>
#include <rfl.hpp>

struct CreateUser {
    rfl::Validator<std::string, rfl::Size<rfl::Minimum<1>,
                                          rfl::Maximum<40>>> name;
    rfl::Validator<int, rfl::Minimum<0>, rfl::Maximum<150>>   age;
    std::optional<std::string> nickname;      // 없어도 되는 필드
};

auto parsed = rfl::json::read<CreateUser>(req_body);
if (!parsed) {
    // "Field 'age': Value 300 is greater than the maximum 150." 처럼 옵니다 ★
    return bad_request(parsed.error()->what());
}
const CreateUser& in = parsed.value();
std::string name = in.name.value();

// 응답도 같은 방식
std::string out = rfl::json::write(UserOut{.id = 7, .name = name});
“검증을 어디서 하나”가 설계의 갈림길입니다 — 컨트롤러마다 if 를 늘어놓으면 금방 무너집니다. 요청 DTO 구조체 하나 = 검증 규칙 한 벌로 묶어 두면 Spring 의 @Valid, FastAPI 의 pydantic 모델과 같은 구조가 됩니다.

직접 짜는 최소 검증 유틸

// 라이브러리를 더 못 들일 때 — 이 정도면 실무에서 버팁니다
struct Err { std::string field, msg; };

template <class T>
std::optional<T> pick(const Json::Value& j, const char* k,
                      std::vector<Err>& errs);

template <>
std::optional<std::string> pick<std::string>(const Json::Value& j, const char* k,
                                             std::vector<Err>& errs) {
    if (!j.isMember(k))      { errs.push_back({k, "required"});    return {}; }
    if (!j[k].isString())    { errs.push_back({k, "must be text"}); return {}; }
    return j[k].asString();
}

// 사용
std::vector<Err> errs;
auto name = pick<std::string>(body, "name", errs);
auto age  = pick<int>(body, "age", errs);
if (!errs.empty()) {
    Json::Value out, arr(Json::arrayValue);
    for (auto& e : errs) {
        Json::Value one;
        one["field"] = e.field; one["message"] = e.msg;
        arr.append(one);
    }
    out["errors"] = arr;                    // 422 로 한 번에 돌려줍니다 ★
    auto res = HttpResponse::newHttpJsonResponse(out);
    res->setStatusCode(k422UnprocessableEntity);
    return cb(res);
}
에러를 하나씩 돌려주지 마세요 — 첫 번째 문제에서 바로 400 을 내면 프런트가 고치고 다시 보내기를 반복해야 합니다. errs 에 모아서 422 로 한 번에 주는 게 pydantic·Bean Validation 의 방식이고, 그 습관을 C++ 에서도 그대로 가져가는 게 좋습니다.
정리 — nlohmann 으로 시작하고, 직렬화가 프로파일에 잡히면 glaze 로 바꾸고, 입력이 수십 MB 라면 simdjson 온디맨드로 훑습니다. 그리고 pydantic 이 없는 세계에서는 요청 DTO + 검증 규칙을 한 곳에 묶는 습관이 성능보다 훨씬 큰 차이를 만듭니다.
SRV 07

DB 연결 — 커넥션 풀이 성능의 8할입니다

JPA·SQLAlchemy 같은 두꺼운 ORM 이 없으니 SQL 을 직접 씁니다. 나쁘지 않습니다 — 문제가 되는 건 문법이 아니라 커넥션을 어떻게 관리하고, 어느 스레드에서 부르느냐입니다. 여기서 잘못 잡으면 C++ 인데 파이썬보다 느린 서버가 나옵니다.

커넥션 풀 — 요청은 수백 개라도 실제 연결은 여덟 개입니다 동시 요청 200건 코루틴 200개 커넥션 풀 — connection_number = 8 쓰는 중 쓰는 중 쓰는 중 쓰는 중 쓰는 중 쓰는 중 빈 것 빈 것 나머지 요청은 여기서 차례를 기다립니다 PostgreSQL max_connections 풀 크기는 코어 수의 두 배 근처에서 시작합니다 늘릴수록 빨라지지 않습니다 — DB 쪽 컨텍스트 스위칭과 잠금 경합이 먼저 늘어납니다 앱 인스턴스 수 × 풀 크기 ≤ DB 의 max_connections 인스턴스 10대 × 풀 20 = 200 커넥션. 기본값이 100 인 PostgreSQL 은 여기서 거부합니다 prepared statement 는 파싱과 실행 계획을 재사용합니다 자리표시자를 쓰면 SQL 인젝션도 함께 막힙니다 — 문자열을 이어 붙이지 마세요
C++ 로 바꿔도 느린 서버의 1번 원인 — 요청마다 커넥션을 새로 여는 코드입니다.

Drogon 비동기 클라이언트 — 권장 경로

using namespace drogon;
using namespace drogon::orm;

// config.json 의 db_clients 를 그대로 씁니다 (풀도 거기서 정합니다)
auto db = app().getDbClient();          // 이름을 주면 여러 개 구분: getDbClient("read")

Task<> handler(HttpRequestPtr req,
               std::function<void(const HttpResponsePtr&)> cb,
               int64_t id)
{
    // ① 단순 조회 — $1 은 자리표시자입니다 ★
    auto rows = co_await db->execSqlCoro(
        "select id, name, email from users where id = $1", id);

    // ② 결과 읽기 — 타입을 명시합니다
    for (const auto& row : rows) {
        int64_t     uid   = row["id"].as<int64_t>();
        std::string name  = row["name"].as<std::string>();
        bool        isNull = row["email"].isNull();
    }

    // ③ 여러 값 넘기기 — 인자를 그냥 이어 붙이면 됩니다
    auto r2 = co_await db->execSqlCoro(
        "insert into links(code, url, owner_id) values($1,$2,$3) returning id",
        code, url, id);
    int64_t newId = r2[0]["id"].as<int64_t>();

    // ④ 트랜잭션 — 소멸할 때 커밋됩니다. 중간에 예외가 나면 롤백 ★
    {
        auto tx = co_await db->newTransactionCoro();
        co_await tx->execSqlCoro(
            "update accounts set balance = balance - $1 where id = $2", amt, from);
        co_await tx->execSqlCoro(
            "update accounts set balance = balance + $1 where id = $2", amt, to);
        // tx->rollback();  // 명시적으로 되돌리고 싶을 때
    }   // 여기서 커밋

    co_return;
}
왜 이게 “권장 경로”인가 — Drogon 의 드라이버는 libpq 의 비동기 API 위에 있어서 co_await 하는 동안 이벤트 루프가 놀지 않습니다. 같은 스레드에서 다른 요청 수백 개가 계속 처리됩니다. 아래의 libpqxx 는 동기라 이 성질이 없습니다 — 그 차이가 핵심입니다.

libpqxx — 가장 널리 쓰이는 동기 클라이언트

#include <pqxx/pqxx>

// 커넥션은 스레드마다 하나 — 공유하면 안 됩니다 ★
thread_local pqxx::connection conn{
    "postgresql://app:secret@127.0.0.1:5432/app?connect_timeout=3"};

std::optional<User> find_user(int64_t id) {
    pqxx::work tx{conn};                       // 트랜잭션 시작
    // 자리표시자 — 문자열 이어 붙이기 금지 ★
    pqxx::result r = tx.exec_params(
        "select id, name from users where id = $1", id);
    tx.commit();

    if (r.empty()) return std::nullopt;
    return User{ r[0][0].as<int64_t>(), r[0][1].as<std::string>() };
}

// prepared statement — 같은 쿼리를 자주 부를 때
void setup(pqxx::connection& c) {
    c.prepare("find_user", "select id, name from users where id = $1");
}
auto r = tx.exec_prepared("find_user", id);

// 대량 삽입은 COPY 가 압도적으로 빠릅니다 (수십 배)
pqxx::stream_to st = pqxx::stream_to::table(tx, {"events"}, {"ts","level","msg"});
for (auto& e : batch) st << std::make_tuple(e.ts, e.level, e.msg);
st.complete();
이벤트 루프 스레드에서 libpqxx 를 부르면 안 됩니다 — 쿼리가 20ms 걸리면 그 루프에 붙은 모든 연결이 20ms 멈춥니다. 꼭 써야 한다면 asio::thread_pool 이나 Drogon 의 app().getLoop() 밖 워커 스레드로 던지세요. 동기 드라이버 + 이벤트 루프 조합은 C++ 서버에서 가장 흔한 사고입니다.

나머지 저장소 — Redis · SQLite · MySQL 🟡 중급

Redis — 캐시와 레이트 리밋

// ① Drogon 내장 비동기 클라이언트 (config.json 의 redis_clients)
auto rc = drogon::app().getRedisClient();

Task<std::optional<std::string>> cache_get(std::string key) {
    auto r = co_await rc->execCommandCoro("get %s", key.c_str());
    if (r.type() == drogon::nosql::RedisResultType::kNil) co_return std::nullopt;
    co_return std::string(r.asString());
}

Task<> cache_set(std::string key, std::string val, int ttl) {
    co_await rc->execCommandCoro("set %s %s EX %d", key.c_str(), val.c_str(), ttl);
    co_return;
}

// ② redis-plus-plus — 프레임워크 밖에서 쓸 때 (동기, 자체 풀 내장)
#include <sw/redis++/redis++.h>
sw::redis::ConnectionOptions opt;
opt.host = "127.0.0.1"; opt.port = 6379;
opt.socket_timeout = std::chrono::milliseconds(200);   // 꼭 주세요 ★
sw::redis::ConnectionPoolOptions pool;
pool.size = 8;
sw::redis::Redis redis(opt, pool);

redis.set("k", "v", std::chrono::seconds(60));
auto v = redis.get("k");                    // std::optional<std::string>

// 레이트 리밋 — 원자적으로 세고 만료를 겁니다
auto n = redis.incr("rl:" + ip);
if (n == 1) redis.expire("rl:" + ip, std::chrono::seconds(60));
if (n > 100) return too_many_requests();
캐시가 죽어도 서비스는 살아야 합니다 — Redis 호출에는 반드시 짧은 타임아웃(100~300ms) 을 걸고, 실패하면 DB 로 그냥 가는 경로를 두세요. 타임아웃 없는 캐시는 장애를 증폭시키는 장치가 됩니다.

SQLite 와 MySQL

// ── SQLite (SQLiteCpp) — 단일 프로세스·임베디드에 최고
#include <SQLiteCpp/SQLiteCpp.h>

SQLite::Database db("app.db",
    SQLite::OPEN_READWRITE | SQLite::OPEN_CREATE);
db.exec("PRAGMA journal_mode=WAL");        // 동시 읽기 성능이 확 달라집니다 ★
db.exec("PRAGMA synchronous=NORMAL");
db.exec("PRAGMA busy_timeout=3000");       // 잠금 대기

SQLite::Statement q(db, "select name from users where id = ?");
q.bind(1, 7);
while (q.executeStep()) {
    std::string name = q.getColumn(0);
}

{   // 트랜잭션 — 수천 건 삽입이 수백 배 빨라집니다
    SQLite::Transaction tr(db);
    for (auto& r : rows) { ins.bind(1, r.a); ins.exec(); ins.reset(); }
    tr.commit();
}

// ── MySQL (Connector/C++ 9.x, X DevAPI)
#include <mysqlx/xdevapi.h>
mysqlx::Session s("mysqlx://app:secret@127.0.0.1:33060/app");
auto res = s.sql("select id, name from users where id = ?")
             .bind(7).execute();
for (auto row : res)
    std::cout << row[0].get<int64_t>() << " "
              << row[1].get<std::string>() << "\n";
SQLite 를 얕보지 마세요 — WAL 모드에서 읽기는 동시에 얼마든지 됩니다. 읽기가 대부분인 서비스라면 단일 노드에서 초당 수만 건을 냅니다. 쓰기는 한 번에 하나뿐이라 쓰기 전용 스레드 하나로 직렬화하는 구조가 잘 맞습니다.

실무 — 풀 크기·N+1·마이그레이션 🔴 실무

숫자를 정하는 법

① 풀 크기 (connection_number)
   시작값 = 코어 수 × 2
   · 8코어 → 16. 여기서 부하를 걸고 p99 를 보면서 조정합니다
   · 늘렸는데 처리량이 안 늘면 병목은 DB 안입니다 (인덱스·잠금)

② 전체 커넥션 상한
   앱 인스턴스 수 × 풀 크기 ≤ DB max_connections × 0.8
   · 10대 × 16 = 160  → PostgreSQL 기본 100 이면 초과 ★
   · 해법: PgBouncer(트랜잭션 풀링)를 앞에 두거나 풀을 줄입니다

③ 타임아웃 — 전부 걸어 두세요
   connect_timeout   3s     연결 자체
   statement_timeout 2s     서버 쪽에서 쿼리를 자릅니다
   pool wait         200ms  빈 커넥션을 기다리는 한도
   · 이 셋이 없으면 DB 가 느려질 때 앱이 통째로 멈춥니다

④ 읽기/쓰기 분리
   getDbClient("write") / getDbClient("read")
   · 복제 지연(수십 ms)을 고려해 '방금 쓴 것 읽기'는 write 로
-- 서버 쪽 안전장치 (PostgreSQL)
alter role app set statement_timeout = '2s';
alter role app set idle_in_transaction_session_timeout = '10s';

-- 지금 무슨 쿼리가 도는지
select pid, state, now()-query_start as dur, query
  from pg_stat_activity
 where state <> 'idle' order by dur desc limit 10;

N+1 과 마이그레이션

// ✗ N+1 — 링크 20개마다 소유자를 한 번씩 더 조회 (쿼리 21번)
auto links = co_await db->execSqlCoro("select * from links limit 20");
for (auto& l : links) {
    auto o = co_await db->execSqlCoro(       // ★ 여기가 20번
        "select name from users where id = $1", l["owner_id"].as<int64_t>());
}

// ✓ 방법 A — 조인 한 번
auto rows = co_await db->execSqlCoro(
    "select l.*, u.name as owner_name "
    "  from links l join users u on u.id = l.owner_id limit 20");

// ✓ 방법 B — id 를 모아 한 번에 (any 연산자)
std::vector<int64_t> ids = collect_owner_ids(links);
auto owners = co_await db->execSqlCoro(
    "select id, name from users where id = any($1)", ids);

// ✓ 방법 C — 정말 병렬이 필요하면 코루틴을 동시에 띄웁니다
//    (풀 크기를 넘지 않도록 개수를 제한하세요)
# 마이그레이션 — C++ 생태계에는 표준 도구가 없습니다. 외부 도구를 씁니다
# dbmate (단일 바이너리 · 컨테이너에 넣기 쉬움)
dbmate new create_links_table
dbmate up                    # 컨테이너 시작 시 한 번 실행

# db/migrations/20260801120000_create_links_table.sql
# -- migrate:up
# create table links(...);
# -- migrate:down
# drop table links;

# 대안: Flyway(자바) · goose(Go) · Atlas · sqlx-cli(Rust)
# 원칙 — 마이그레이션은 '앱 시작 시'가 아니라 '배포 단계'에서 한 번만 ★
앱 부팅에 마이그레이션을 붙이지 마세요 — 인스턴스 10대가 동시에 뜨면 10번 동시에 실행됩니다. 쿠버네티스라면 Job 이나 initContainer 하나, 컴포즈라면 별도 서비스로 분리하는 게 정석입니다.

저장소별 드라이버 고르기

저장소비동기 (co_await 가능)동기메모
PostgreSQLDrogon DbClient ★libpqxx둘 다 libpq 위에 있습니다. 기능 차이는 거의 없습니다
MySQLDrogon DbClient(mysql)Connector/C++ 9.x비동기 쪽 성숙도는 PostgreSQL 이 더 낫습니다
SQLiteDrogon(sqlite3) — 내부 스레드 풀SQLiteCpp파일 I/O 라 진짜 비동기는 아닙니다
RedisDrogon RedisClient ★redis-plus-plus동기 쪽이 API 는 훨씬 풍부합니다
MongoDB없음mongocxx워커 스레드로 던져야 합니다
ClickHouse없음clickhouse-cpp배치 삽입이 기본 — 요청 경로에 두지 마세요
Kafka콜백 기반librdkafka프로듀서는 비동기, 전송 완료 콜백으로 확인
비동기 드라이버가 없는 저장소가 하나라도 있으면 구조가 정해집니다 — 이벤트 루프 + 워커 스레드 풀 두 층으로 가고, 워커에서만 그 드라이버를 부릅니다. 이 경계를 코드로 강제해 두면(예: repo/ 안에서만 접근) 실수를 줄일 수 있습니다.
정리 — 커넥션은 풀에서 빌려 쓰고, 자리표시자로 넘기고, 타임아웃을 전부 건다 — 이 셋이면 사고의 대부분이 사라집니다. 그리고 이벤트 루프 위에서는 비동기 드라이버(Drogon), 워커 스레드에서는 libpqxx 라는 경계를 분명히 하세요.
SRV 08

gRPC · Protobuf — 내부 통신의 표준

외부에는 REST, 내부에는 gRPC — 이게 요즘의 기본 구성입니다. .proto 파일 하나로 C++ 서버와 파이썬·자바 클라이언트가 같은 계약을 공유하고, HTTP/2 위에서 스트리밍까지 공짜로 얻습니다.

.proto 하나로 여러 언어를 잇습니다 — 계약이 코드보다 먼저 links.proto 계약(스키마) protoc + 플러그인 grpc_cpp_plugin 등 생성된 코드 .pb.cc · .grpc.pb.cc · 스텁 C++ 서버 CallbackService 파이썬 클라이언트 grpcio-tools 자바 클라이언트 protobuf-gradle Go · Node 같은 파일 하나로 필드 번호는 절대 재사용하지 마세요 지운 번호는 reserved 로 막아 둡니다. 번호를 돌려쓰면 옛 클라이언트가 값을 잘못 읽습니다 브라우저는 gRPC 를 직접 부르지 못합니다 grpc-web 프록시(Envoy)를 두거나, 외부용 REST 를 따로 내는 구성이 일반적입니다
gRPC 의 진짜 값어치는 속도가 아니라 여러 언어가 하나의 계약을 공유한다는 점입니다.

계약 파일과 빌드 연결

// proto/links.proto
syntax = "proto3";
package shortener.v1;

service Links {
  // 단항 — 요청 하나, 응답 하나
  rpc Resolve (ResolveRequest) returns (ResolveReply);
  // 서버 스트리밍 — 응답이 계속 흘러옵니다
  rpc WatchHits (WatchRequest) returns (stream HitEvent);
  // 클라이언트 스트리밍 — 요청을 계속 보내고 마지막에 하나 받습니다
  rpc BulkCreate (stream CreateRequest) returns (BulkReply);
  // 양방향
  rpc Chat (stream ChatMsg) returns (stream ChatMsg);
}

message ResolveRequest { string code = 1; }
message ResolveReply   { string url = 1; int64 hits = 2; }
message WatchRequest   { string code = 1; }
message HitEvent       { string code = 1; int64 ts = 2; string ip = 3; }
find_package(protobuf CONFIG REQUIRED)
find_package(gRPC CONFIG REQUIRED)

add_library(proto OBJECT proto/links.proto)
target_link_libraries(proto PUBLIC protobuf::libprotobuf gRPC::grpc++)
target_include_directories(proto PUBLIC "${CMAKE_CURRENT_BINARY_DIR}")

protobuf_generate(TARGET proto LANGUAGE cpp)
protobuf_generate(TARGET proto LANGUAGE grpc
  GENERATE_EXTENSIONS .grpc.pb.h .grpc.pb.cc
  PLUGIN "protoc-gen-grpc=$<TARGET_FILE:gRPC::grpc_cpp_plugin>")

add_executable(rpcsrv server.cc)
target_link_libraries(rpcsrv PRIVATE proto gRPC::grpc++ gRPC::grpc++_reflection)
vcpkg 로 gRPC 를 처음 설치하면 오래 걸립니다 — vcpkg install grpc protobuf 가 10~30분씩 걸립니다(의존성이 많습니다). CI 에서는 vcpkg 바이너리 캐시를 꼭 켜세요(w12). 안 그러면 매 빌드마다 이 시간을 냅니다.

서버 — 콜백 API 로 (권장)

#include <grpcpp/grpcpp.h>
#include <grpcpp/ext/proto_server_reflection_plugin.h>
#include "links.grpc.pb.h"

using namespace shortener::v1;

class LinksImpl final : public Links::CallbackService {
public:
    // 단항 — 리액터를 돌려주는 방식입니다
    grpc::ServerUnaryReactor* Resolve(grpc::CallbackServerContext* ctx,
                                      const ResolveRequest* req,
                                      ResolveReply* rep) override {
        auto* reactor = ctx->DefaultReactor();
        auto url = lookup(req->code());
        if (!url) {
            reactor->Finish(grpc::Status(grpc::StatusCode::NOT_FOUND, "no code"));
            return reactor;
        }
        rep->set_url(*url);
        rep->set_hits(hits_of(req->code()));
        reactor->Finish(grpc::Status::OK);
        return reactor;
    }
};

int main() {
    grpc::reflection::InitProtoReflectionServerBuilderPlugin();  // grpcurl 용
    LinksImpl svc;
    grpc::ServerBuilder b;
    b.AddListeningPort("0.0.0.0:50051", grpc::InsecureServerCredentials());
    b.RegisterService(&svc);
    b.SetMaxReceiveMessageSize(16 * 1024 * 1024);
    auto server = b.BuildAndStart();
    server->Wait();
}
동기 API 도 있습니다 — Links::Service 를 상속해 grpc::Status Resolve(...) 를 그냥 구현하면 됩니다. 훨씬 읽기 쉽지만 요청 1건 = 스레드 1개 모델이라 동시성이 스레드 수에 묶입니다. 처음엔 동기로, 부하가 보이면 콜백으로 가는 순서가 실용적입니다.

스트리밍 — 네 가지 모양 🟡 중급

서버 스트리밍 구현

// 이벤트가 생길 때마다 클라이언트로 밀어 보냅니다
class HitWatcher : public grpc::ServerWriteReactor<HitEvent> {
    std::deque<HitEvent> queue_;
    std::mutex          m_;
    bool                writing_ = false;
public:
    void push(HitEvent e) {                    // 다른 스레드에서 부릅니다
        std::lock_guard lk(m_);
        queue_.push_back(std::move(e));
        if (!writing_) next();
    }
    void OnWriteDone(bool ok) override {       // 한 건 전송 완료
        std::lock_guard lk(m_);
        writing_ = false;
        if (!ok) { Finish(grpc::Status::OK); return; }
        if (!queue_.empty()) next();
    }
    void OnDone() override { delete this; }    // 정리 ★
private:
    void next() {                              // m_ 을 잡은 상태에서 부릅니다
        writing_ = true;
        StartWrite(&queue_.front());
    }
};

grpc::ServerWriteReactor<HitEvent>* LinksImpl::WatchHits(
        grpc::CallbackServerContext* ctx, const WatchRequest* req) {
    auto* w = new HitWatcher();
    registry().subscribe(req->code(), w);      // 어딘가에 등록해 둡니다
    return w;
}
StartWrite 를 겹쳐 부르면 즉시 죽습니다 — 한 번에 하나가 규칙이고, 다음 쓰기는 OnWriteDone 에서 시작합니다. WebSocket 의 “세션마다 송신 큐 하나”와 똑같은 규칙입니다(w05).

다른 언어에서 부르기

# ── 파이썬 클라이언트
pip install grpcio grpcio-tools
python -m grpc_tools.protoc -I proto \
  --python_out=. --pyi_out=. --grpc_python_out=. proto/links.proto
import grpc, links_pb2, links_pb2_grpc

with grpc.insecure_channel("localhost:50051") as ch:
    stub = links_pb2_grpc.LinksStub(ch)
    # 단항 — 데드라인을 꼭 주세요
    rep = stub.Resolve(links_pb2.ResolveRequest(code="abc"), timeout=2.0)
    print(rep.url, rep.hits)

    # 서버 스트리밍 — 그냥 for 문입니다
    for ev in stub.WatchHits(links_pb2.WatchRequest(code="abc")):
        print(ev.ts, ev.ip)
// ── 자바 클라이언트 (protobuf-gradle-plugin 이 코드를 만들어 줍니다)
ManagedChannel ch = ManagedChannelBuilder
        .forAddress("localhost", 50051)
        .usePlaintext()
        .build();
LinksGrpc.LinksBlockingStub stub = LinksGrpc.newBlockingStub(ch)
        .withDeadlineAfter(2, TimeUnit.SECONDS);

ResolveReply rep = stub.resolve(
        ResolveRequest.newBuilder().setCode("abc").build());
System.out.println(rep.getUrl());
# ── 서버가 잘 도는지 확인 (리플렉션을 켜 뒀다면 .proto 없이도 됩니다)
grpcurl -plaintext localhost:50051 list
grpcurl -plaintext -d '{"code":"abc"}' \
        localhost:50051 shortener.v1.Links/Resolve

# 부하 테스트
ghz --insecure --proto proto/links.proto --call shortener.v1.Links.Resolve \
    -d '{"code":"abc"}' -c 50 -n 20000 localhost:50051

운영에서 챙기는 것 — 데드라인 · 헬스 · TLS 🔴 실무

꼭 켜 두는 것 다섯

// ① 헬스 체크 서비스 — 쿠버네티스 프로브가 이걸 씁니다
#include <grpcpp/health_check_service_interface.h>
grpc::EnableDefaultHealthCheckService(true);
// 상태 바꾸기: server->GetHealthCheckService()->SetServingStatus("", true);

// ② keepalive — 유휴 연결이 로드밸런서에 끊기는 걸 막습니다
grpc::ServerBuilder b;
b.AddChannelArgument(GRPC_ARG_KEEPALIVE_TIME_MS, 30000);
b.AddChannelArgument(GRPC_ARG_KEEPALIVE_TIMEOUT_MS, 10000);
b.AddChannelArgument(GRPC_ARG_KEEPALIVE_PERMIT_WITHOUT_CALLS, 1);

// ③ 메시지 크기 상한 (기본 4MB 입니다 — 자주 걸립니다)
b.SetMaxReceiveMessageSize(16 * 1024 * 1024);

// ④ TLS
grpc::SslServerCredentialsOptions opts;
opts.pem_key_cert_pairs.push_back({read_file("key.pem"), read_file("cert.pem")});
b.AddListeningPort("0.0.0.0:50051", grpc::SslServerCredentials(opts));

// ⑤ 서버 쪽에서 데드라인 확인 — 이미 늦었으면 일하지 않습니다 ★
if (ctx->IsCancelled()) {
    reactor->Finish(grpc::Status(grpc::StatusCode::CANCELLED, "client gone"));
    return reactor;
}

// 메타데이터(HTTP 헤더 자리) 읽기 — 인증 토큰이 여기로 옵니다
auto& md = ctx->client_metadata();
if (auto it = md.find("authorization"); it != md.end()) { /* ... */ }

REST 와 gRPC 를 어떻게 나누나

REST(JSON)gRPC
브라우저에서 직접○× (프록시 필요)
사람이 curl 로 확인○grpcurl 필요
스키마 강제느슨함강함
바이트 크기기준1/3~1/10
스트리밍SSE·WebSocket내장
다중 언어 클라이언트손으로자동 생성
캐싱(CDN)쉬움어려움
버전 관리URL 경로필드 번호 규칙
실무의 기본 구성 — 외부/프런트엔드는 REST + JSON, 서비스끼리는 gRPC. 게이트웨이 한 대가 REST 를 받아 내부에서 gRPC 로 부르는 모양이 가장 흔합니다. C++ 서비스는 대개 내부 계산 서비스로 들어가므로 gRPC 만 열고 HTTP 프레임워크는 아예 안 쓰는 경우도 많습니다.
정리 — .proto 가 계약이고 나머지는 생성물입니다. 서버는 동기 API 로 시작해 콜백 API 로, 운영에서는 데드라인·헬스 체크·keepalive·메시지 크기를 챙기세요. 그리고 필드 번호는 한 번 쓰면 영원히 그 뜻입니다.
SRV 09

인증과 보안 — 스타터가 없는 자리를 직접 채우기

Spring Security 도, fastapi-users 도 없습니다. 대신 필요한 조각은 다 있습니다 — jwt-cpp, Argon2, 그리고 Nginx. 중요한 건 라이브러리가 아니라 어디까지를 앱이 하고 어디부터를 앞단이 하느냐의 경계입니다.

TLS 는 앞단에서 끝내고, 앱은 평문으로 뒤에 섭니다 브라우저 · 앱 https 로 접속 Nginx · 로드밸런서 TLS 종료 · 레이트 리밋 C++ 앱 — 평문 HTTP 127.0.0.1:8080 만 듣습니다 로그인부터 토큰 검증까지 로그인 요청 id + 비밀번호 Argon2id 검증 해시 비교 JWT 발급 HS256 · RS256 Bearer 헤더 이후 모든 요청 서명·만료 검증 필터에서 절대 하지 말 것 — 비밀번호를 SHA-256 으로만 저장 GPU 로 초당 수십억 번 시도됩니다. 반드시 Argon2id 나 bcrypt 처럼 느린 해시를 쓰세요 토큰은 짧게(15분) · 갱신은 리프레시 토큰으로 JWT 는 서버가 취소할 수 없습니다 — 즉시 차단이 필요하면 Redis 블랙리스트를 둡니다
보안의 절반은 앱이 하지 않는 일을 정하는 것입니다 — TLS·레이트 리밋은 앞단의 몫.

비밀번호 — Argon2id 로 저장합니다

#include <argon2.h>
#include <random>

// 저장할 때 — 솔트는 매번 새로 만듭니다
std::string hash_password(std::string_view pwd) {
    uint8_t salt[16];
    std::random_device rd;
    for (auto& b : salt) b = static_cast<uint8_t>(rd());

    char encoded[128];
    int rc = argon2id_hash_encoded(
        /*t_cost=*/3,             // 반복 횟수
        /*m_cost=*/1 << 16,       // 메모리 64MB — GPU 공격을 비싸게 만듭니다 ★
        /*parallelism=*/1,
        pwd.data(), pwd.size(),
        salt, sizeof salt,
        /*hashlen=*/32,
        encoded, sizeof encoded);
    if (rc != ARGON2_OK) throw std::runtime_error(argon2_error_message(rc));
    return encoded;   // "$argon2id$v=19$m=65536,t=3,p=1$...$..." 통째로 저장
}

// 검증할 때 — 파라미터는 문자열 안에 들어 있습니다
bool verify_password(const std::string& encoded, std::string_view pwd) {
    return argon2id_verify(encoded.c_str(), pwd.data(), pwd.size()) == ARGON2_OK;
}

// libsodium 을 이미 쓴다면 이쪽이 더 간단합니다
//   char out[crypto_pwhash_STRBYTES];
//   crypto_pwhash_str(out, pwd, len,
//       crypto_pwhash_OPSLIMIT_INTERACTIVE,
//       crypto_pwhash_MEMLIMIT_INTERACTIVE);
//   crypto_pwhash_str_verify(out, pwd, len) == 0
해시는 느려야 합니다 — 로그인 한 번에 50~200ms 가 정상입니다. 그래서 이벤트 루프 스레드에서 부르면 안 됩니다. Drogon 이라면 app().getIOLoop() 밖의 워커나 asio::thread_pool 로 던지세요. 안 그러면 로그인 몇 건에 서버가 굳습니다.

JWT — jwt-cpp 로 만들고 검증하기

#include <jwt-cpp/jwt.h>

std::string issue_token(int64_t uid, std::string_view role) {
    const auto now = std::chrono::system_clock::now();
    return jwt::create()
        .set_type("JWT")
        .set_issuer("shortener")
        .set_audience("web")
        .set_subject(std::to_string(uid))
        .set_issued_at(now)
        .set_expires_at(now + std::chrono::minutes(15))    // 짧게 ★
        .set_id(make_uuid())                               // jti — 블랙리스트 키
        .set_payload_claim("role", jwt::claim(std::string(role)))
        .sign(jwt::algorithm::hs256{secret_from_env()});
}

struct Claims { int64_t uid; std::string role, jti; };

std::optional<Claims> verify_token(const std::string& token) {
    static const auto verifier = jwt::verify()
        .allow_algorithm(jwt::algorithm::hs256{secret_from_env()})
        .with_issuer("shortener")
        .with_audience("web")
        .leeway(30);                       // 서버 시계 오차 허용(초)
    try {
        auto d = jwt::decode(token);
        verifier.verify(d);                // 서명·만료·발급자 전부 확인
        return Claims{
            std::stoll(d.get_subject()),
            d.get_payload_claim("role").as_string(),
            d.get_id()
        };
    } catch (const std::exception&) {
        return std::nullopt;               // 이유를 클라이언트에 알리지 마세요 ★
    }
}
HS256 이냐 RS256 이냐 — 토큰을 발급하는 곳과 검증하는 곳이 같으면 HS256(대칭키)로 충분합니다. 서비스 여러 개가 검증만 한다면 RS256/ES256 을 써서 공개키만 나눠 주세요. 대칭키를 여러 서비스에 뿌리면 어느 하나만 털려도 전부 위조됩니다.

앞단 — Nginx 뒤에 서기 🟡 중급

리버스 프록시 설정 (요점만)

# /etc/nginx/conf.d/api.conf
limit_req_zone $binary_remote_addr zone=api:10m rate=20r/s;

server {
    listen 443 ssl;
    http2 on;
    server_name api.example.com;

    ssl_certificate     /etc/letsencrypt/live/api.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/api.example.com/privkey.pem;
    ssl_protocols       TLSv1.2 TLSv1.3;

    client_max_body_size 2m;          # 큰 본문은 여기서 자릅니다
    add_header Strict-Transport-Security "max-age=31536000" always;
    add_header X-Content-Type-Options nosniff always;

    location / {
        limit_req zone=api burst=40 nodelay;
        proxy_pass http://127.0.0.1:8080;
        proxy_set_header Host              $host;
        proxy_set_header X-Real-IP         $remote_addr;
        proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;   # 앱이 https 임을 알게 ★
        proxy_read_timeout 30s;
    }

    location /ws {                     # WebSocket 은 업그레이드를 통과시켜야 합니다
        proxy_pass http://127.0.0.1:8080;
        proxy_http_version 1.1;
        proxy_set_header Upgrade    $http_upgrade;
        proxy_set_header Connection "upgrade";
        proxy_read_timeout 300s;       # 유휴 연결이 끊기지 않게
    }
}
클라이언트 IP 를 잘못 읽는 사고 — 프록시 뒤에서는 req->getPeerAddr() 가 항상 127.0.0.1 입니다. 레이트 리밋·감사 로그에 이 값을 쓰면 전 세계가 한 사람이 됩니다. X-Forwarded-For 를 읽되, 신뢰하는 프록시에서 온 경우에만 믿으세요.

CORS 와 앱 쪽 방어선

// ── CORS — 와일드카드는 개발용입니다
static const std::set<std::string> kAllowed = {
    "https://app.example.com", "https://admin.example.com"
};

drogon::app().registerSyncAdvice(
  [](const HttpRequestPtr& req) -> HttpResponsePtr {
    auto origin = req->getHeader("Origin");
    if (origin.empty() || !kAllowed.contains(origin)) return {};

    if (req->method() == drogon::Options) {          // 프리플라이트
        auto r = HttpResponse::newHttpResponse();
        r->setStatusCode(drogon::k204NoContent);
        r->addHeader("Access-Control-Allow-Origin",      origin);
        r->addHeader("Access-Control-Allow-Methods",     "GET,POST,PATCH,DELETE");
        r->addHeader("Access-Control-Allow-Headers",     "Content-Type,Authorization");
        r->addHeader("Access-Control-Allow-Credentials", "true");
        r->addHeader("Access-Control-Max-Age",           "600");
        return r;
    }
    return {};
  });

// ── 앱 쪽에서 반드시 하는 것들
// ① 본문 크기 상한 (프록시가 있어도 한 번 더)
//    config.json: "client_max_body_size": "2M"
// ② 경로 조작 방지 — 사용자 입력으로 파일 경로를 만들지 않습니다
//    "../../etc/passwd" 를 걸러야 합니다
// ③ 상수 시간 비교 — API 키·서명 비교에 == 를 쓰지 않습니다
bool ct_equal(std::string_view a, std::string_view b) {
    if (a.size() != b.size()) return false;
    unsigned char diff = 0;
    for (size_t i = 0; i < a.size(); ++i) diff |= a[i] ^ b[i];
    return diff == 0;                       // 길이 정보만 새어 나갑니다
}
// ④ 에러 메시지에 내부 정보를 담지 않습니다 (SQL·경로·스택)

체크리스트 — 배포 전에 한 번 🔴 실무

항목틀린 모습맞는 모습
비밀번호SHA-256 · MD5 · 솔트 없음Argon2id(m=64MB, t=3) 또는 bcrypt(cost 12)
비밀 값config.json 에 평문환경 변수 · 시크릿 매니저. 레포에 올리지 않습니다
SQL문자열 이어 붙이기자리표시자($1, ?) 만 사용
토큰 만료없음 · 30일액세스 15분 + 리프레시 회전
로그아웃클라이언트에서 토큰만 삭제jti 를 Redis 블랙리스트에 만료 시각까지
레이트 리밋없음Nginx limit_req + 앱에서 사용자 단위
CORSAllow-Origin: * + credentials허용 목록 화이트리스트
입력 크기제한 없음본문·헤더·업로드 전부 상한
에러 응답예외 메시지를 그대로고정 문구 + 서버 로그에만 상세
의존성버전 고정 없음vcpkg baseline 고정 + trivy 스캔
메모리 안전테스트만CI 에서 ASan·UBSan 빌드로도 테스트(w11)
C++ 서버만의 추가 위험 — 버퍼 오버플로·use-after-free 는 다른 언어에는 없는 공격면입니다. 입력을 다루는 코드(파서·디코더)는 퍼징(libFuzzer)을 한 번은 돌려 보세요. -fsanitize=address,undefined 로 테스트를 돌리는 것만으로도 상당수가 걸립니다.
정리 — TLS·레이트 리밋은 앞단, 인증·검증·감사 로그는 앱입니다. 비밀번호는 느린 해시로 워커 스레드에서, 토큰은 짧게 발급하고 필터에서 검증, 그리고 비밀 값은 환경 변수로 — 이 네 줄이 이 절의 전부입니다.
SRV 10

로깅 · 설정 · 관측 — 액추에이터가 없는 서버 운영하기

배포하고 나면 무슨 일이 벌어지는지 보이는가가 전부입니다. C++ 에는 Spring Actuator 같은 게 없으니 spdlog · toml++ · prometheus-cpp · OpenTelemetry 네 조각을 직접 끼웁니다. 한 번 만들어 두면 다음 프로젝트에도 그대로 씁니다.

관측은 세 갈래입니다 — 로그 · 메트릭 · 트레이스 C++ 서버 프로세스 한 프로세스에서 셋 다 spdlog JSON 한 줄 로그 prometheus-cpp /metrics 엔드포인트 OpenTelemetry C++ OTLP 로 트레이스 전송 stdout → 수집기 Loki · CloudWatch Prometheus → Grafana 대시보드 · 알림 Tempo · Jaeger 요청 하나의 전 구간 설정은 파일 + 환경 변수 두 층으로 toml 로 기본값을 읽고 환경 변수가 덮어씁니다 — 컨테이너에서는 환경 변수가 표준입니다 요청 ID 하나를 세 갈래에 모두 실으세요 로그 필드 · 응답 헤더 · 트레이스에 같은 값을 넣으면 장애 추적 시간이 확 줄어듭니다
셋 중 하나만 고르라면 구조화 로그입니다 — 가장 싸고, 가장 자주 씁니다.

spdlog — 비동기 + JSON 한 줄

#include <spdlog/spdlog.h>
#include <spdlog/async.h>
#include <spdlog/sinks/stdout_color_sinks.h>
#include <spdlog/sinks/rotating_file_sink.h>

void init_log(const std::string& level) {
    spdlog::init_thread_pool(8192, 1);        // 큐 8192, 쓰기 스레드 1개

    auto con  = std::make_shared<spdlog::sinks::stdout_color_sink_mt>();
    auto file = std::make_shared<spdlog::sinks::rotating_file_sink_mt>(
        "logs/api.log", 50 * 1024 * 1024, 5);  // 50MB × 5개 회전

    auto logger = std::make_shared<spdlog::async_logger>(
        "api", spdlog::sinks_init_list{con, file},
        spdlog::thread_pool(),
        spdlog::async_overflow_policy::overrun_oldest);  // 막히면 옛 것을 버립니다 ★

    // 구조화 로그 — 수집기가 파싱하기 쉬운 한 줄 JSON
    logger->set_pattern(
        R"({"ts":"%Y-%m-%dT%H:%M:%S.%e%z","lvl":"%l","thread":%t,"msg":%v})");
    logger->set_level(spdlog::level::from_str(level));
    logger->flush_on(spdlog::level::warn);
    spdlog::set_default_logger(logger);
}

// 쓸 때 — fmt 문법입니다 (printf 보다 안전하고 빠릅니다)
SPDLOG_INFO(R"({{"event":"http","method":"{}","path":"{}","status":{},"ms":{:.1f},"rid":"{}"}})",
            method, path, status, elapsed_ms, request_id);

// 성능 메모
//  · 비동기 로거는 호출부에서 수백 나노초로 끝납니다
//  · 뜨거운 경로의 debug 로그는 SPDLOG_ACTIVE_LEVEL 로 아예 컴파일에서 제거
//    target_compile_definitions(api PRIVATE SPDLOG_ACTIVE_LEVEL=SPDLOG_LEVEL_INFO)
컨테이너에서는 파일 로그를 쓰지 마세요 — 표준 출력에만 내보내고 수집은 플랫폼(도커 로그 드라이버·Fluent Bit·CloudWatch)에 맡깁니다. 파일로 쓰면 디스크가 차서 죽는 흔한 사고를 스스로 만드는 셈입니다.

설정 — toml++ + 환경 변수 덮어쓰기

# config.toml — 기본값을 사람이 읽기 좋게
[server]
port        = 8080
threads     = 0          # 0 = 코어 수
log_level   = "info"

[db]
dsn         = "postgresql://app@127.0.0.1/app"
pool_size   = 16
timeout_ms  = 2000

[redis]
url         = "redis://127.0.0.1:6379"
ttl_seconds = 300
#include <toml++/toml.hpp>
#include <cstdlib>

struct Config {
    int         port, threads, pool_size;
    std::string dsn, redis_url, log_level;
};

// 환경 변수가 있으면 그게 이깁니다 — 12factor 원칙 ★
static std::string env_or(const char* key, std::string fallback) {
    if (const char* v = std::getenv(key); v && *v) return v;
    return fallback;
}

Config load_config(const std::string& path) {
    auto t = toml::parse_file(path);          // 파싱 실패 시 예외
    Config c;
    c.port      = std::stoi(env_or("PORT",
                    std::to_string(t["server"]["port"].value_or(8080))));
    c.threads   = t["server"]["threads"].value_or(0);
    c.log_level = env_or("LOG_LEVEL",
                    t["server"]["log_level"].value_or("info"));
    c.dsn       = env_or("DATABASE_URL",
                    t["db"]["dsn"].value_or(std::string{}));
    c.pool_size = t["db"]["pool_size"].value_or(16);
    c.redis_url = env_or("REDIS_URL",
                    t["redis"]["url"].value_or(std::string{}));

    if (c.dsn.empty()) throw std::runtime_error("DATABASE_URL 이 없습니다");
    return c;                                  // 시작할 때 한 번에 검증 ★
}
설정은 시작할 때 전부 읽고 검증하세요 — 요청 처리 중에 “설정이 없네” 하고 죽는 것보다 부팅에서 바로 실패하는 게 훨씬 낫습니다. 쿠버네티스라면 그 파드가 Ready 가 안 되고 이전 버전이 계속 뜹니다.

메트릭과 트레이스 — 숫자로 보게 만들기 🔴 실무

prometheus-cpp — /metrics 열기

#include <prometheus/exposer.h>
#include <prometheus/registry.h>
#include <prometheus/counter.h>
#include <prometheus/histogram.h>
#include <prometheus/gauge.h>

struct Metrics {
    std::shared_ptr<prometheus::Registry> reg =
        std::make_shared<prometheus::Registry>();

    prometheus::Family<prometheus::Counter>& reqs =
        prometheus::BuildCounter()
            .Name("http_requests_total")
            .Help("총 요청 수")
            .Register(*reg);

    prometheus::Family<prometheus::Histogram>& lat =
        prometheus::BuildHistogram()
            .Name("http_request_duration_seconds")
            .Help("요청 처리 시간")
            .Register(*reg);

    prometheus::Family<prometheus::Gauge>& conns =
        prometheus::BuildGauge()
            .Name("active_connections").Help("현재 연결 수").Register(*reg);
};

// 앱 포트와 분리된 포트에 노출합니다 (외부에 열지 마세요)
prometheus::Exposer exposer{"127.0.0.1:9100"};
exposer.RegisterCollectable(m.reg);

// 요청이 끝날 때 기록
void observe(Metrics& m, std::string route, int status, double sec) {
    m.reqs.Add({{"route", route}, {"status", std::to_string(status)}}).Increment();
    m.lat .Add({{"route", route}},
               prometheus::Histogram::BucketBoundaries{
                   .001, .005, .01, .025, .05, .1, .25, .5, 1, 2.5})
          .Observe(sec);
}
라벨에 무한한 값을 넣지 마세요 — route 에 실제 경로(/links/abc123)를 넣으면 시계열이 수백만 개가 되어 Prometheus 가 무너집니다. 라우트 패턴(/links/{code}) 을 넣으세요. 사용자 ID·요청 ID·IP 도 절대 라벨이 되면 안 됩니다.

OpenTelemetry — 요청 하나를 끝까지 따라가기

#include <opentelemetry/exporters/otlp/otlp_grpc_exporter_factory.h>
#include <opentelemetry/sdk/trace/batch_span_processor_factory.h>
#include <opentelemetry/sdk/trace/tracer_provider_factory.h>
#include <opentelemetry/trace/provider.h>

namespace otlp  = opentelemetry::exporter::otlp;
namespace tsdk  = opentelemetry::sdk::trace;
namespace trace = opentelemetry::trace;

void init_tracing() {
    otlp::OtlpGrpcExporterOptions opt;
    opt.endpoint = "otel-collector:4317";
    auto exporter  = otlp::OtlpGrpcExporterFactory::Create(opt);
    auto processor = tsdk::BatchSpanProcessorFactory::Create(
        std::move(exporter), {});             // 배치로 모아 보냅니다 ★
    auto provider  = tsdk::TracerProviderFactory::Create(std::move(processor));
    trace::Provider::SetTracerProvider(std::move(provider));
}

// 핸들러 안에서
auto tracer = trace::Provider::GetTracerProvider()->GetTracer("api");
{
    auto span  = tracer->StartSpan("resolve_link");
    auto scope = tracer->WithActiveSpan(span);
    span->SetAttribute("link.code", code);

    {   // DB 호출을 자식 스팬으로 — 어디서 시간을 쓰는지 보입니다
        auto db_span = tracer->StartSpan("db.select_link");
        auto rows = co_await db->execSqlCoro("select url from links where code=$1", code);
        db_span->End();
    }
    span->End();
}
순서를 지키면 비용이 적습니다 — ① 구조화 로그(하루면 됩니다) → ② /metrics 네 개(요청 수·지연·에러율·연결 수) → ③ 트레이스. ③ 은 서비스가 여러 개로 갈라진 뒤에 값어치가 생깁니다. 단일 서비스라면 ①②만으로 충분합니다.

꼭 만들어 두는 엔드포인트 네 개

경로응답쓰임새
/healthz200 ok (의존성 확인 없이)liveness — 프로세스가 살아 있는지. 실패하면 재시작됩니다
/readyzDB·Redis 핑 확인 후 200readiness — 트래픽을 받아도 되는지. 부팅 중엔 503
/metricsPrometheus 텍스트내부 포트에만. 외부에 열면 정보 노출입니다
/version커밋 해시 · 빌드 시각“지금 뜬 게 어느 버전이지?” 를 3초에 끝냅니다
/healthz 에서 DB 를 확인하지 마세요 — DB 가 잠깐 느려지면 모든 파드가 동시에 재시작되어 장애가 커집니다. liveness 는 프로세스 자체만, 의존성 확인은 readiness 에서 합니다. 이 구분 하나로 새벽에 깨는 횟수가 줄어듭니다.
정리 — 표준 출력 JSON 로그 + /metrics 네 개 + healthz·readyz 가 최소 한 벌입니다. 설정은 파일 기본값 + 환경 변수 덮어쓰기로 두 층, 그리고 요청 ID 하나를 로그·헤더·트레이스에 같이 실으세요. 관측이 없는 C++ 서버는 빠른 대신 아무것도 안 보이는 상자가 됩니다.
SRV 11

테스트와 벤치마크 — 평균은 거짓말을 합니다

C++ 서버를 만드는 이유가 성능이라면, 성능을 재는 법이 곧 실력입니다. 핸들러 테스트를 GoogleTest 로 자동화하고, oha·wrk 로 부하를 걸고, 평균이 아니라 꼬리(p99) 를 보는 습관까지 이 절에서 만듭니다.

같은 테스트 결과를 다르게 읽기 — 평균과 꼬리는 딴 세상입니다 0 100 200 300 ms 평균(mean) 8 ms — 보고서에 쓰기 좋은 숫자 p50 (중앙값) 4 ms — 절반은 이보다 빠릅니다 p90 12 ms — 여기까지는 대체로 괜찮습니다 p99 85 ms — 100명 중 1명이 겪는 시간 p99.9 320 ms 같은 서버 · 같은 30초 측정 · 같은 요청을 백분위수로만 다르게 읽은 것입니다 평균 8ms 인 서버가 1000명 중 1명에게는 320ms 입니다 화면 하나에 API 를 20번 부른다면, 사용자 절반이 꼬리를 한 번은 만납니다 꼬리를 만드는 것은 대개 넷 중 하나입니다 커넥션 풀 대기 · GC 없는 대신 메모리 할당 급증 · 잠금 경합 · 느린 외부 호출
부하 테스트 결과에서 평균과 최대만 적힌 보고서는 거의 쓸모가 없습니다 — 백분위수를 보세요.

단위 테스트 — GoogleTest

// tests/test_base62.cc
#include <gtest/gtest.h>
#include "core/base62.h"

TEST(Base62, RoundTrip) {
    for (int64_t n : {0LL, 1LL, 61LL, 62LL, 123456789LL, 1LL << 40}) {
        EXPECT_EQ(base62_decode(base62_encode(n)), n) << "n = " << n;
    }
}

TEST(Base62, KnownValues) {
    EXPECT_EQ(base62_encode(0),  "0");
    EXPECT_EQ(base62_encode(61), "z");
    EXPECT_EQ(base62_encode(62), "10");
}

TEST(Base62, RejectsBadInput) {
    EXPECT_THROW(base62_decode("!!"), std::invalid_argument);
}

// 파라미터 테스트 — 표를 돌립니다
struct UrlCase { std::string in; bool ok; };
class UrlValidation : public testing::TestWithParam<UrlCase> {};

TEST_P(UrlValidation, Works) {
    EXPECT_EQ(is_valid_url(GetParam().in), GetParam().ok);
}
INSTANTIATE_TEST_SUITE_P(Cases, UrlValidation, testing::Values(
    UrlCase{"https://a.com",   true},
    UrlCase{"http://a.com/x",  true},
    UrlCase{"javascript:x",    false},   // ★ 이런 게 통과하면 안 됩니다
    UrlCase{"ftp://a.com",     false},
    UrlCase{"",                false}));
enable_testing()
find_package(GTest CONFIG REQUIRED)
add_executable(unit_tests tests/test_base62.cc)
target_link_libraries(unit_tests PRIVATE core GTest::gtest_main)
include(GoogleTest)
gtest_discover_tests(unit_tests)     # ctest 가 개별 테스트를 인식합니다
테스트하기 좋은 코드는 핸들러 밖에 있습니다 — 코드·URL 검증·서명 같은 순수 함수를 core/ 라이브러리로 빼 두면 프레임워크 없이 빠르게 테스트됩니다. 핸들러에는 조립만 남기는 게 좋습니다.

통합 테스트 — 진짜 서버를 띄워서

// tests/test_api.cc — Drogon 내장 테스트 프레임워크
#include <drogon/drogon_test.h>
#include <drogon/HttpClient.h>
using namespace drogon;

DROGON_TEST(HealthEndpoint) {
    auto client = HttpClient::newHttpClient("http://127.0.0.1:8848");
    auto req = HttpRequest::newHttpRequest();
    req->setPath("/healthz");
    client->sendRequest(req, [TEST_CTX](ReqResult r, const HttpResponsePtr& res) {
        REQUIRE(r == ReqResult::Ok);              // 실패하면 여기서 중단
        CHECK(res->getStatusCode() == k200OK);
    });
}

DROGON_TEST(CreateLinkReturns201) {
    auto client = HttpClient::newHttpClient("http://127.0.0.1:8848");
    auto req = HttpRequest::newHttpJsonRequest(
        []{ Json::Value j; j["url"] = "https://example.com"; return j; }());
    req->setMethod(Post);
    req->setPath("/api/v1/links");
    client->sendRequest(req, [TEST_CTX](ReqResult r, const HttpResponsePtr& res) {
        REQUIRE(r == ReqResult::Ok);
        CHECK(res->getStatusCode() == k201Created);
        auto j = res->getJsonObject();
        REQUIRE(j != nullptr);
        CHECK((*j)["code"].asString().size() >= 4);
    });
}

int main(int argc, char** argv) {
    std::thread server([] {
        app().addListener("127.0.0.1", 8848)
             .setThreadNum(1)
             .loadConfigFile("./config.test.json")
             .run();
    });
    // 뜰 때까지 잠깐 기다린 뒤 테스트를 돌립니다
    int status = test::run(argc, argv);
    app().getLoop()->queueInLoop([] { app().quit(); });
    server.join();
    return status;
}
통합 테스트에 진짜 DB 를 쓰세요 — 모킹한 DB 는 SQL 오타·타입 불일치·제약 위반을 전혀 못 잡습니다. docker compose 로 PostgreSQL 을 띄우고 테스트마다 트랜잭션을 롤백하거나 스키마를 새로 만드는 방식이 실용적입니다. Testcontainers 의 C++ 판은 없으니 CI 서비스 컨테이너를 쓰면 됩니다(w12).

부하 — 숫자를 만드는 도구들 🟡 중급

wrk · oha · Google Benchmark

# ── oha (2026년에 가장 편합니다 — HTTP/2 지원, 실시간 TUI)
oha -c 200 -z 30s --latency-correction http://localhost:8080/healthz
#   -c 동시 연결 수   -z 시간   --latency-correction 은 조정된 지연 보정 ★

# ── wrk (오래된 표준. 스크립트가 강력합니다)
wrk -t8 -c256 -d30s --latency http://localhost:8080/healthz
#   -t 스레드   -c 커넥션   --latency 백분위수 출력

# POST 부하 — 루아 스크립트
cat > post.lua <<'EOF'
wrk.method = "POST"
wrk.body   = '{"url":"https://example.com"}'
wrk.headers["Content-Type"] = "application/json"
EOF
wrk -t8 -c128 -d30s -s post.lua --latency http://localhost:8080/api/v1/links

# ── 결과를 읽는 법
#   Requests/sec:  118,432.11      ← 처리량
#   Latency  50%   1.42ms
#            99%   8.91ms          ← 여기를 봅니다 ★
#            99.9% 41.20ms
#   Socket errors: connect 0, read 0, timeout 0   ← 0 이 아니면 숫자를 믿지 마세요

# ── 부하 생성기가 병목인지 확인 (아주 흔합니다)
#   클라이언트 CPU 가 100% 면 서버 수치가 아니라 클라이언트 수치입니다
#   → 부하 도구를 다른 장비에서 돌리거나, 여러 대에서 나눠 돌리세요
// 함수 하나의 성능은 Google Benchmark 로
#include <benchmark/benchmark.h>

static void BM_Base62Encode(benchmark::State& s) {
    int64_t n = 123456789;
    for (auto _ : s) {
        benchmark::DoNotOptimize(base62_encode(n));   // 최적화로 지워지는 것 방지 ★
    }
    s.SetItemsProcessed(s.iterations());
}
BENCHMARK(BM_Base62Encode);

static void BM_JsonSerialize(benchmark::State& s) {
    User u{7, "김개발", true, {"cpp"}};
    for (auto _ : s) benchmark::DoNotOptimize(glz::write_json(u).value());
}
BENCHMARK(BM_JsonSerialize);
BENCHMARK_MAIN();

튜닝 — 이 순서로 봅니다

① 먼저 '측정 환경'을 의심합니다
   · 부하 도구가 CPU 100% 인가
   · Release 빌드인가 (-O2/-O3, NDEBUG)  ← Debug 로 재는 사고가 정말 많습니다 ★
   · 로그 레벨이 debug 인가 (I/O 가 병목이 됩니다)
   · 파일 디스크립터 한도
       ulimit -n 65535
       sysctl -w net.core.somaxconn=4096

② 스레드 수 — 코어 수부터
   threads_num = 코어 수.  늘려서 좋아지면 어딘가 블로킹하고 있다는 신호입니다

③ keep-alive
   · 켜져 있는지 확인 (연결마다 TCP+TLS 핸드셰이크는 매우 비쌉니다)
   · idle_connection_timeout 을 프록시보다 '길게' 잡습니다
     (짧으면 프록시가 죽은 연결로 요청을 보내 502 가 납니다) ★

④ 할당자 — C++ 서버에서 의외로 큰 차이
   LD_PRELOAD=/usr/lib/libjemalloc.so ./api     # 또는 tcmalloc
   · 멀티스레드 할당 경합이 심하면 10~30% 가 그냥 나옵니다

⑤ 압축
   · 응답이 1KB 미만이면 gzip 이 손해입니다. 임계값을 두세요

⑥ 그 다음에야 코드
   perf record -g ./api  &&  perf report
   · 여기까지 안 하고 코드부터 고치면 대개 헛수고입니다
가장 흔한 “C++ 인데 왜 느리죠?” 의 답 세 개 — ① Debug 빌드로 쟀다, ② 동기 DB 드라이버를 이벤트 루프에서 불렀다, ③ 요청마다 커넥션을 새로 열었다. 이 셋이 8할입니다. 코드 최적화는 이 셋을 확인한 다음 이야기입니다.

CI 에서 함께 돌리는 것 — 새니타이저 🔴 실무

# 같은 테스트를 세 가지 빌드로 돌리면 C++ 특유의 버그가 대부분 잡힙니다
cmake -B build-asan -DCMAKE_BUILD_TYPE=RelWithDebInfo \
      -DCMAKE_CXX_FLAGS="-fsanitize=address,undefined -fno-omit-frame-pointer"
cmake --build build-asan -j && ctest --test-dir build-asan --output-on-failure
#   → use-after-free · 버퍼 오버플로 · 부호 있는 정수 오버플로

cmake -B build-tsan -DCMAKE_BUILD_TYPE=RelWithDebInfo \
      -DCMAKE_CXX_FLAGS="-fsanitize=thread"
#   → 데이터 레이스. 멀티스레드 서버에는 필수입니다 ★

# 입력 파서가 있다면 퍼징도 (libFuzzer)
clang++ -std=c++20 -fsanitize=fuzzer,address fuzz_parse.cc -o fuzz && ./fuzz -max_total_time=60
새니타이저 빌드는 2~20배 느립니다 — 그래서 부하 테스트에는 쓰지 않고 기능 테스트에만 씁니다. CI 에서는 “빠른 Release 테스트”와 “느린 ASan 테스트”를 다른 잡으로 나눠 돌리는 구성이 일반적입니다. TSan 은 ASan 과 같이 켤 수 없습니다 — 따로 돌려야 합니다.

부하 도구 고르기

도구강점약점 · 쓰는 자리
oha ★설치 한 줄 · 실시간 TUI · HTTP/2 · 지연 보정복잡한 시나리오는 못 씁니다. 일상적인 확인에 가장 편합니다
wrk루아 스크립트 · 아주 가벼움 · 백분위수 출력HTTP/2 없음. 오래된 표준이라 자료가 많습니다
bombardier단일 바이너리 · 간단기능이 적습니다. CI 스모크 부하에 적당
k6시나리오 · 임계값(threshold) · 리포트 · CI 통합자체 오버헤드가 큽니다. 회귀 방지에 씁니다
ghzgRPC 전용 · .proto 를 그대로 사용HTTP 는 못 잽니다(w08)
ab어디에나 깔려 있습니다단일 스레드 · keep-alive 처리가 부실. 수치를 믿지 마세요
두 종류를 나눠 쓰세요 — 개발 중에는 oha 로 지금 빨라졌나를 30초에 확인하고, CI 에는 k6 로 p99 가 기준을 넘으면 실패하는 임계값을 걸어 둡니다. 후자가 있어야 성능이 조용히 나빠지는 것을 막습니다.
정리 — 테스트는 순수 함수는 GoogleTest, 엔드포인트는 진짜 서버 + 진짜 DB 로 나눕니다. 부하는 oha·wrk 로 걸되 p99·p99.9 와 소켓 에러를 함께 보고, 튜닝은 측정 환경 → 스레드 → keep-alive → 할당자 → 코드 순서를 지키세요.
SRV 12

배포 — 2.5GB 빌드 이미지에서 40MB 로

C++ 의 배포 이점은 분명합니다 — 런타임도, 인터프리터도, JVM 도 필요 없습니다. 바이너리 하나면 끝나므로 이미지가 아주 작아지고 부팅이 즉시입니다. 대신 빌드 환경과 실행 환경을 분리하는 요령이 필요합니다.

멀티스테이지 빌드 — 컴파일러는 이미지에 남기지 않습니다 1단계 — builder ubuntu:24.04 · vcpkg · cmake · 오브젝트 파일 이 이미지는 2.5GB 가 넘습니다 — 배포되지 않습니다 COPY --from 2단계 — runtime distroless/cc — 셸도 패키지 매니저도 없습니다 바이너리 + 설정만 복사 → 약 40MB 같은 바이너리, 베이스 이미지만 바꿨을 때 — 맨 위와 맨 아래가 100배입니다 가로는 로그 눈금 ubuntu:24.04 · 약 1.2GB bookworm-slim · 약 130MB distroless/cc · 약 40MB scratch + 정적 · 약 12MB 10MB 100MB 1GB 작은 이미지의 진짜 이득은 속도가 아니라 공격면입니다 셸이 없으면 침입자가 컨테이너 안에서 할 수 있는 일이 거의 없습니다 alpine 을 쓰려면 musl 로 빌드해야 합니다 glibc 로 만든 바이너리는 alpine 에서 실행되지 않습니다 — 첫 배포의 단골 실패입니다
빌드 이미지와 실행 이미지를 나누는 것 — 이 한 가지가 배포의 8할입니다.

Dockerfile — 레이어 캐시까지 고려한 형태

# syntax=docker/dockerfile:1
# ───────── 1단계: 빌드 ─────────
FROM ubuntu:24.04 AS builder

RUN apt-get update && apt-get install -y --no-install-recommends \
      build-essential cmake ninja-build git curl zip unzip tar \
      pkg-config ca-certificates \
 && rm -rf /var/lib/apt/lists/*

ENV VCPKG_ROOT=/opt/vcpkg
RUN git clone --depth 1 https://github.com/microsoft/vcpkg $VCPKG_ROOT \
 && $VCPKG_ROOT/bootstrap-vcpkg.sh -disableMetrics

WORKDIR /src
# ★ 의존성 목록만 먼저 복사 → 소스가 바뀌어도 이 레이어는 캐시됩니다
COPY vcpkg.json vcpkg-configuration.json ./
RUN $VCPKG_ROOT/vcpkg install --triplet x64-linux

COPY CMakeLists.txt ./
COPY src/ ./src/
RUN cmake -B build -G Ninja -DCMAKE_BUILD_TYPE=Release \
      -DCMAKE_TOOLCHAIN_FILE=$VCPKG_ROOT/scripts/buildsystems/vcpkg.cmake \
 && cmake --build build -j"$(nproc)" \
 && strip build/api                       # 심볼 제거 — 수십 MB 가 줄어듭니다

# ───────── 2단계: 실행 ─────────
FROM gcr.io/distroless/cc-debian12:nonroot
WORKDIR /app
COPY --from=builder /src/build/api    /app/api
COPY --from=builder /src/config.toml  /app/config.toml
USER nonroot
EXPOSE 8080
ENTRYPOINT ["/app/api"]
# .dockerignore — 빌드 컨텍스트를 작게
build/
.git/
logs/
*.md

# 빌드 캐시를 살려서 빌드 (BuildKit)
docker buildx build --cache-to type=gha --cache-from type=gha -t api:latest .
docker run --rm -p 8080:8080 -e DATABASE_URL=... api:latest

정적 링크와 이미지 더 줄이기

# ① 표준 라이브러리만 정적으로 — 가장 실용적인 절충
target_link_options(api PRIVATE -static-libstdc++ -static-libgcc)
#   → glibc 는 동적으로 남습니다. distroless/cc 로 충분합니다

# ② 완전 정적 — scratch 이미지에 넣을 수 있습니다
#    glibc 정적 링크는 getaddrinfo 등에서 문제가 생기므로
#    musl 툴체인을 쓰는 게 정석입니다
set(CMAKE_EXE_LINKER_FLAGS "-static")

# ③ 크기 줄이기 플래그
target_compile_options(api PRIVATE
    -Os                      # 크기 최적화 (성능이 중요하면 -O2 유지)
    -ffunction-sections -fdata-sections)
target_link_options(api PRIVATE -Wl,--gc-sections -s)

# ④ 빌드 정보 심어 두기 — /version 엔드포인트용
target_compile_definitions(api PRIVATE
    GIT_SHA="${GIT_SHA}" BUILD_TIME="${BUILD_TIME}")
# /etc/systemd/system/api.service — 컨테이너를 안 쓸 때
[Unit]
Description=C++ API server
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
User=api
Group=api
WorkingDirectory=/opt/api
EnvironmentFile=/etc/api.env          # 비밀 값은 여기 (권한 600)
ExecStart=/opt/api/api
Restart=always
RestartSec=2
LimitNOFILE=65535                     # 파일 디스크립터 한도 ★

# 최소 권한
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
ReadWritePaths=/opt/api/logs

[Install]
WantedBy=multi-user.target
LimitNOFILE 을 꼭 올리세요 — 기본값(1024)이면 동시 연결 1000개쯤에서 accept: Too many open files 가 납니다. 컨테이너에서는 --ulimit nofile=65535:65535, 쿠버네티스에서는 노드 설정으로 잡습니다.

CI — 빌드 시간을 줄이는 것이 전부 🔴 실무

GitHub Actions

name: ci
on: [push, pull_request]

jobs:
  build-test:
    runs-on: ubuntu-24.04
    services:                       # 통합 테스트용 진짜 DB
      postgres:
        image: postgres:17
        env:
          POSTGRES_PASSWORD: test
          POSTGRES_DB: app
        options: >-
          --health-cmd pg_isready --health-interval 5s --health-retries 10
        ports: ['5432:5432']
      redis:
        image: redis:7
        ports: ['6379:6379']

    env:
      VCPKG_BINARY_SOURCES: 'clear;x-gha,readwrite'   # ★ 이게 핵심입니다
      DATABASE_URL: postgresql://postgres:test@localhost:5432/app

    steps:
      - uses: actions/checkout@v4
      - uses: lukka/run-vcpkg@v11
        with:
          vcpkgGitCommitId: 매니페스트의 baseline 과 같은 해시
      - uses: hendrikmuhs/ccache-action@v1        # 컴파일 캐시
      - name: Configure
        run: >-
          cmake -B build -G Ninja -DCMAKE_BUILD_TYPE=Release
          -DCMAKE_CXX_COMPILER_LAUNCHER=ccache
          -DCMAKE_TOOLCHAIN_FILE=$VCPKG_ROOT/scripts/buildsystems/vcpkg.cmake
      - name: Build
        run: cmake --build build -j
      - name: Test
        run: ctest --test-dir build --output-on-failure

  image:
    needs: build-test
    runs-on: ubuntu-24.04
    steps:
      - uses: actions/checkout@v4
      - uses: docker/setup-buildx-action@v3
      - uses: docker/build-push-action@v6
        with:
          push: false
          tags: api:${{ github.sha }}
          cache-from: type=gha
          cache-to: type=gha,mode=max
캐시가 없으면 매 빌드가 30분입니다 — vcpkg 가 gRPC·Boost 를 처음부터 컴파일합니다. VCPKG_BINARY_SOURCES 로 바이너리 캐시를 켜고 ccache 를 붙이면 2~4분으로 떨어집니다. C++ CI 에서 이 두 줄이 가장 큰 차이를 만듭니다.

무중단 배포 — 정상 종료가 조건입니다

// SIGTERM 을 받으면 ① 새 요청을 안 받고 ② 처리 중인 것을 끝내고 ③ 종료
int main() {
    auto& app = drogon::app();

    // 종료 직전에 부를 정리 작업
    app.registerBeginningAdvice([] { SPDLOG_INFO("server started"); });

    asio_free_signal_handler([&](int sig) {          // 또는 std::signal
        SPDLOG_INFO("SIGTERM — draining");
        app.getLoop()->queueInLoop([&] {
            app.quit();          // 리스너를 닫고 진행 중 요청을 마칩니다
        });
    });

    app.loadConfigFile("./config.toml").run();
    SPDLOG_INFO("bye");          // run() 이 돌아온 뒤
}
# 쿠버네티스 쪽 짝꿍 설정 — 이게 없으면 배포마다 502 가 납니다
spec:
  terminationGracePeriodSeconds: 30      # 드레이닝 시간
  containers:
    - name: api
      lifecycle:
        preStop:
          exec:
            # 엔드포인트에서 빠질 시간을 벌어 줍니다 ★
            command: ["/bin/sleep", "5"]   # distroless 면 앱이 직접 처리
      readinessProbe:
        httpGet: { path: /readyz, port: 8080 }
        periodSeconds: 3
      livenessProbe:
        httpGet: { path: /healthz, port: 8080 }
        periodSeconds: 10
        failureThreshold: 3
      resources:
        requests: { cpu: "500m", memory: "128Mi" }
        limits:   { memory: "512Mi" }     # cpu limit 은 신중하게
C++ 서버의 배포 이점을 여기서 체감합니다 — 부팅이 수십 밀리초라 JVM 처럼 워밍업을 기다릴 필요가 없고, 메모리도 수십 MB 수준입니다. requests.memory 를 작게 잡을 수 있어 같은 노드에 더 많이 올라갑니다. 다만 CPU limit 은 조심하세요 — 스로틀링이 p99 를 크게 망칩니다.

이미지 줄이기 — 조치별 효과

조치효과비고
멀티스테이지 분리약 2.4GB 감소가장 큰 한 방. 이것만 해도 대부분 끝납니다
strip (또는 -s)20~60MB디버그 심볼 제거. 심볼 파일은 따로 보관하세요
distroless/cc 로 교체약 90MB셸·패키지 매니저가 없습니다 — 디버깅이 어려워집니다
-Os -Wl,--gc-sections5~15%성능이 중요한 경로가 있으면 -O2 를 유지하세요
완전 정적 + scratch약 28MBmusl 툴체인 필요. DNS·NSS 동작을 반드시 확인
.dockerignore빌드 시간컨텍스트 전송이 줄어듭니다. build/, .git/ 는 필수
COPY 순서 조정빌드 시간vcpkg.json 을 먼저 복사해 의존성 레이어를 캐시
UPX 압축50~70%권하지 않습니다 — 시작 시간이 늘고 보안 도구가 오탐합니다
어디서 멈출지 — 대부분의 팀은 멀티스테이지 + strip + distroless 에서 멈춥니다. 40MB 를 12MB 로 더 줄이는 건 musl 로 옮기는 비용에 비해 얻는 게 작습니다. 정적 링크는 배포 대상에 컨테이너 런타임이 없을 때(엣지 장비·단일 바이너리 배포) 값어치가 생깁니다.
정리 — 멀티스테이지 + distroless 로 40MB 이미지를 만들고, CI 에서는 vcpkg 바이너리 캐시 + ccache 로 빌드 시간을 줄이고, 배포에는 SIGTERM 정상 종료 + readiness 프로브를 반드시 붙이세요. 이 셋이 없으면 “빠른 서버”가 배포할 때마다 502 를 내는 서버가 됩니다.
SRV 13

실전 — URL 단축 서비스를 끝까지 만듭니다

지금까지의 조각을 하나로 엮습니다. Drogon + PostgreSQL + Redis 로 POST /api/v1/links 와 GET /{code} 두 엔드포인트를 만들고, Docker Compose 로 띄우고, 같은 기능의 FastAPI 버전과 나란히 재 봅니다.

GET /{code} 가 지나는 길 — 캐시가 있느냐 없느냐로 갈립니다 GET /abc123 302 를 기대합니다 Drogon 핸들러 코루틴 하나 Redis GET TTL 300초 hit 302 Found 약 0.3ms miss PostgreSQL select url Redis SET + 302 약 2ms 캐시 히트 경로는 DB 를 아예 건드리지 않습니다 이 경로에서만 초당 십만 건대가 나옵니다 — 단축 서비스는 읽기가 99% 입니다 캐시가 빠지면 병목은 앱이 아니라 DB 로 옮겨 갑니다 언어를 바꾸는 것보다 적중률을 90%에서 99%로 올리는 편이 훨씬 큰 차이를 냅니다
이 그림이 이 절의 설계 전부입니다 — 읽기는 캐시로, 쓰기만 DB 로.
폴더 구조 — 프레임워크에 묶인 코드와 아닌 코드를 갈라 둡니다 shortener/ 최상위 — 빌드와 배포에 필요한 것만 ├─ CMakeLists.txt 빌드 정의 · vcpkg 툴체인 지정 ├─ vcpkg.json drogon · glaze · spdlog · gtest ├─ config.toml 기본값 — 환경 변수가 덮어씁니다 ├─ db/migrations/ dbmate 가 실행하는 SQL 파일 ├─ src/main.cc 설정 로드 · 로거 · 서버 기동 ├─ src/core/base62.h 코드 생성 — 프레임워크 없이 테스트 ├─ src/core/url.h URL 검증 — 순수 함수 ├─ src/repo/link_repo.cc SQL 은 전부 여기에만 둡니다 ├─ src/cache/link_cache.cc Redis 접근 · 실패해도 되는 경로 ├─ src/controllers/Link.cc 라우팅과 조립만 — 로직 금지 ├─ src/filters/RateLimit.cc IP 단위 제한 · 요청 ID 부여 ├─ tests/ GoogleTest + Drogon 통합 테스트 ├─ Dockerfile 멀티스테이지 → distroless └─ docker-compose.yml api + postgres + redis + migrate core 와 repo 에는 drogon 헤더가 들어가지 않습니다 — 그래야 테스트가 빠릅니다
규칙 하나만 지키면 됩니다 — 컨트롤러는 조립만, 로직은 core·repo 에.

스키마와 코드 생성

-- db/migrations/20260801120000_init.sql
-- migrate:up
create table links (
    id          bigserial   primary key,
    code        text        unique,
    url         text        not null,
    owner_id    bigint,
    hits        bigint      not null default 0,
    created_at  timestamptz not null default now(),
    expires_at  timestamptz
);
create index links_created_idx on links (created_at desc);
create index links_owner_idx   on links (owner_id) where owner_id is not null;

-- migrate:down
drop table links;
// src/core/base62.h — drogon 을 include 하지 않습니다 ★
#pragma once
#include <string>
#include <string_view>
#include <algorithm>
#include <stdexcept>

inline constexpr std::string_view kB62 =
    "0123456789abcdefghijklmnopqrstuvwxyz"
    "ABCDEFGHIJKLMNOPQRSTUVWXYZ";

inline std::string base62_encode(int64_t n) {
    if (n < 0) throw std::invalid_argument("negative");
    if (n == 0) return "0";
    std::string s;
    while (n > 0) { s.push_back(kB62[n % 62]); n /= 62; }
    std::reverse(s.begin(), s.end());
    return s;
}

inline int64_t base62_decode(std::string_view s) {
    if (s.empty()) throw std::invalid_argument("empty");
    int64_t n = 0;
    for (char c : s) {
        auto p = kB62.find(c);
        if (p == std::string_view::npos) throw std::invalid_argument("bad char");
        n = n * 62 + static_cast<int64_t>(p);
    }
    return n;
}
id 를 그대로 base62 로 바꾸면 코드가 순서대로 나옵니다 — 1, 2, 3 → 1, 2, 3. 남의 링크를 훑어볼 수 있다는 뜻이라 운영에서는 시작 오프셋을 크게 두거나(예: id + 1'000'000) 임의의 7자리 코드 + 충돌 시 재시도를 씁니다. 아래 저장소 코드에 둘 다 담았습니다.

저장소 계층 — SQL 은 여기에만

// src/repo/link_repo.h
#pragma once
#include <drogon/orm/DbClient.h>
#include <drogon/utils/coroutine.h>

struct Link { int64_t id; std::string code, url; int64_t hits; };

class LinkRepo {
    drogon::orm::DbClientPtr db_;
public:
    explicit LinkRepo(drogon::orm::DbClientPtr db) : db_(std::move(db)) {}

    // 코드로 찾기
    drogon::Task<std::optional<std::string>> url_of(std::string code) {
        auto r = co_await db_->execSqlCoro(
            "select url from links "
            " where code = $1 and (expires_at is null or expires_at > now())",
            code);
        if (r.empty()) co_return std::nullopt;
        co_return r[0]["url"].as<std::string>();
    }

    // 만들기 — 임의 코드 + 충돌 시 재시도 (경합에 안전합니다) ★
    drogon::Task<std::string> create(std::string url,
                                     std::optional<int64_t> owner) {
        for (int attempt = 0; attempt < 5; ++attempt) {
            std::string code = random_code(7);
            auto r = co_await db_->execSqlCoro(
                "insert into links(code, url, owner_id) values($1,$2,$3) "
                "on conflict (code) do nothing returning id",
                code, url, owner);
            if (!r.empty()) co_return code;      // 성공
        }
        throw std::runtime_error("code space exhausted");
    }

    // 조회 수는 나중에 몰아서 반영합니다 (요청 경로에서 update 하지 않습니다)
    drogon::Task<> flush_hits(std::vector<std::pair<std::string,int64_t>> batch) {
        auto tx = co_await db_->newTransactionCoro();
        for (auto& [code, n] : batch)
            co_await tx->execSqlCoro(
                "update links set hits = hits + $1 where code = $2", n, code);
        co_return;
    }
};
요청마다 update links set hits = hits + 1 을 하면 안 됩니다 — 인기 링크 한 줄에 쓰기 경합이 몰려 초당 수백 건에서 무너집니다. Redis 에서 세고 10초마다 한 번씩 몰아서 반영하세요. 읽기 전용 서비스에서 쓰기를 만들어 내는 게 가장 흔한 설계 실수입니다.

컨트롤러 — 조립만 합니다 🟡 중급

두 엔드포인트

// src/controllers/LinkController.h
#pragma once
#include <drogon/HttpController.h>

class LinkController : public drogon::HttpController<LinkController> {
public:
    using Cb = std::function<void(const drogon::HttpResponsePtr&)>;

    METHOD_LIST_BEGIN
      ADD_METHOD_TO(LinkController::create,  "/api/v1/links", Post, "RateLimitFilter");
      ADD_METHOD_TO(LinkController::resolve, "/{code}",       Get);
    METHOD_LIST_END

    drogon::Task<> create (drogon::HttpRequestPtr req, Cb cb);
    drogon::Task<> resolve(drogon::HttpRequestPtr req, Cb cb, std::string code);
};
// src/controllers/LinkController.cc
using namespace drogon;

Task<> LinkController::resolve(HttpRequestPtr req, Cb cb, std::string code) {
    // ① 캐시 — 실패해도 서비스는 계속돼야 합니다
    if (auto hit = co_await cache().get(code)) {
        cb(redirect(*hit));
        cache().bump(code);                    // 조회 수는 Redis 에서만 셉니다
        co_return;
    }
    // ② DB
    auto url = co_await repo().url_of(code);
    if (!url) {
        auto r = HttpResponse::newHttpJsonResponse(err("not found"));
        r->setStatusCode(k404NotFound);
        cb(r);
        co_return;
    }
    // ③ 캐시에 채우고 응답
    co_await cache().set(code, *url, 300);
    cb(redirect(*url));
    cache().bump(code);
    co_return;
}

Task<> LinkController::create(HttpRequestPtr req, Cb cb) {
    auto body = req->getJsonObject();
    if (!body || !(*body)["url"].isString())
        co_return cb(bad_request("url is required"));

    std::string url = (*body)["url"].asString();
    if (!is_valid_url(url))                    // core/url.h — 순수 함수
        co_return cb(bad_request("only http/https, max 2048 chars"));

    auto code = co_await repo().create(url, uid_of(req));
    Json::Value out;
    out["code"]      = code;
    out["short_url"] = base_url() + "/" + code;
    auto res = HttpResponse::newHttpJsonResponse(out);
    res->setStatusCode(k201Created);
    cb(res);
    co_return;
}

// 302 헬퍼 — 브라우저가 캐시하지 않도록 (조회 수를 세야 하니까)
static HttpResponsePtr redirect(const std::string& url) {
    auto r = HttpResponse::newHttpResponse();
    r->setStatusCode(k302Found);
    r->addHeader("Location", url);
    r->addHeader("Cache-Control", "no-store");
    return r;
}

캐시 계층과 조회 수 배치

// src/cache/link_cache.cc
class LinkCache {
    drogon::nosql::RedisClientPtr rc_;
public:
    // 캐시는 '있으면 좋은 것'입니다 — 실패를 예외로 올리지 않습니다 ★
    drogon::Task<std::optional<std::string>> get(std::string code) {
        try {
            auto r = co_await rc_->execCommandCoro("get l:%s", code.c_str());
            if (r.type() == drogon::nosql::RedisResultType::kNil) co_return std::nullopt;
            co_return std::string(r.asString());
        } catch (const std::exception& e) {
            SPDLOG_WARN(R"({{"event":"cache_down","err":"{}"}})", e.what());
            co_return std::nullopt;            // DB 로 그냥 갑니다
        }
    }

    drogon::Task<> set(std::string code, std::string url, int ttl) {
        try {
            co_await rc_->execCommandCoro("set l:%s %s EX %d",
                                          code.c_str(), url.c_str(), ttl);
        } catch (...) {}
        co_return;
    }

    void bump(const std::string& code) {        // 기다리지 않습니다
        rc_->execCommandAsync([](auto&&) {}, [](auto&&) {},
                              "hincrby hits %s 1", code.c_str());
    }
};

// main.cc — 10초마다 조회 수를 DB 로 옮깁니다
drogon::app().getLoop()->runEvery(10.0, [] {
    drogon::async_run([]() -> drogon::Task<> {
        auto r = co_await redis()->execCommandCoro("hgetall hits");
        auto batch = parse_pairs(r);
        if (batch.empty()) co_return;
        co_await redis()->execCommandCoro("del hits");
        co_await repo().flush_hits(std::move(batch));   // 한 트랜잭션으로
        co_return;
    });
});
이 배치 하나가 성능 그래프를 바꿉니다 — 요청 경로에서 DB 쓰기를 없애면 캐시 히트 경로가 완전히 읽기 전용이 되고, 그때부터 초당 십만 건대가 나옵니다. “C++ 라서 빠른” 게 아니라 설계가 맞아서 빠른 것입니다.

띄우기 — docker compose up 한 줄 🟢 기초

docker-compose.yml

services:
  db:
    image: postgres:17-alpine
    environment:
      POSTGRES_USER: app
      POSTGRES_PASSWORD: secret
      POSTGRES_DB: app
    volumes: [pgdata:/var/lib/postgresql/data]
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app"]
      interval: 5s
      retries: 10

  redis:
    image: redis:7-alpine
    command: ["redis-server", "--maxmemory", "256mb",
              "--maxmemory-policy", "allkeys-lru"]   # 캐시니까 밀어내기 ★

  migrate:                                   # 앱 부팅이 아니라 여기서 한 번
    image: ghcr.io/amacneil/dbmate:latest
    depends_on:
      db: { condition: service_healthy }
    environment:
      DATABASE_URL: postgres://app:secret@db:5432/app?sslmode=disable
    volumes: ["./db:/db"]
    command: ["up"]

  api:
    build: .
    depends_on:
      db:      { condition: service_healthy }
      migrate: { condition: service_completed_successfully }
    environment:
      DATABASE_URL: postgresql://app:secret@db:5432/app
      REDIS_URL:    redis://redis:6379
      LOG_LEVEL:    info
    ports: ["8080:8080"]
    ulimits:
      nofile: { soft: 65535, hard: 65535 }

volumes:
  pgdata:
docker compose up --build -d
docker compose logs -f api

# 만들고
curl -s -X POST localhost:8080/api/v1/links \
  -H 'Content-Type: application/json' \
  -d '{"url":"https://example.com/very/long/path"}' | jq
#  { "code": "k3Qa9Zx", "short_url": "http://localhost:8080/k3Qa9Zx" }

# 따라가고
curl -i localhost:8080/k3Qa9Zx
#  HTTP/1.1 302 Found
#  Location: https://example.com/very/long/path

FastAPI 버전과 나란히 재 보기

# 같은 장비(8코어), 같은 PostgreSQL·Redis, 같은 데이터로
oha -c 200 -z 30s http://localhost:8080/k3Qa9Zx        # Drogon
oha -c 200 -z 30s http://localhost:8000/k3Qa9Zx        # FastAPI (uvicorn 4워커)
항목Drogon (C++)FastAPI (uvicorn ×4)
캐시 히트 리다이렉트약 118k rps약 9k rps
같은 경로 p993 ms42 ms
캐시 미스(DB 조회)약 21k rps약 6k rps
링크 생성(POST)약 14k rps약 5k rps
메모리(RSS)약 48MB약 210MB(4워커 합)
이미지 크기약 41MB약 190MB
콜드 스타트약 30ms약 1.2s
코드 줄 수약 620줄약 180줄
첫 동작까지반나절30분
이 숫자를 그대로 믿지 마세요 — 장비 한 대, 설정 한 벌의 결과입니다. 중요한 건 모양입니다. ① 캐시 히트 경로에서는 차이가 10배 이상 벌어지고, ② DB 를 거치는 경로에서는 3~4배로 줄고, ③ 코드 분량과 개발 속도는 파이썬이 3배 이상 유리합니다. 어느 축이 지금 문제인지가 언어 선택의 기준입니다.

마무리 — 여기서 더 갈 곳 🔴 실무

이 프로젝트에 이어 붙일 것들

① 인증 붙이기 (w09)
   · POST 는 JWT 필수, GET 은 공개
   · 사용자별 링크 목록 · 삭제

② 관측 붙이기 (w10)
   · /metrics 에 links_created_total · cache_hit_ratio
   · 캐시 적중률이 90% 밑으로 떨어지면 알림

③ 커스텀 코드 · 만료 · QR
   · POST 본문에 code 를 직접 지정 (충돌 검사)
   · expires_at 지난 링크는 410 Gone

④ gRPC 내부 API (w08)
   · 다른 서비스가 링크를 만들 수 있게 Links 서비스 노출

⑤ 대량 트래픽 대비
   · 읽기 전용 복제본으로 조회 분리
   · Redis 클러스터 · 로컬 LRU 캐시 한 층 더(수 μs)
   · CDN 앞단에서 인기 코드 캐싱

⑥ 배포 (w12)
   · 이미지 빌드 → 스테이징 → 카나리 → 전체
   · 마이그레이션은 항상 '앞으로 호환되게' 먼저

C++ 서버를 만들며 배운 것 정리

교훈어디서 나왔나
루프 스레드에서 절대 블로킹하지 않는다w03 · w04 · w07
커넥션은 풀에서 빌리고 타임아웃을 건다w07
비밀번호는 느린 해시를 워커에서w09
스트림에 동시 쓰기 금지 — 송신 큐 하나w05 · w08
메트릭 라벨에 무한 값 금지w10
평균이 아니라 p99 를 본다w11
이미지는 빌드/실행을 나눈다w12
요청 경로에서 DB 쓰기를 만들지 않는다w13
마지막으로 솔직한 얘기 — 이 서비스는 사실 FastAPI 로 30분이면 만듭니다. C++ 를 고를 이유는 트래픽이 그 위로 갔을 때, 지연의 꼬리가 계약일 때, 같은 트래픽을 훨씬 적은 서버로 받아야 할 때입니다. 그 조건이 맞을 때 이 탭의 내용은 서버 비용을 한 자릿수로 줄이는 무기가 되고, 조건이 안 맞을 때는 가장 비싼 선택이 됩니다. 판단은 w01 로 돌아가서 하세요.
정리 — URL 단축 서비스 하나에 이 탭의 전부가 들어 있습니다 — Drogon 라우팅 · 코루틴 DB · Redis 캐시 · 배치 쓰기 · 컴포즈 배포 · 부하 측정. 구조에서 딱 하나만 가져간다면 컨트롤러는 조립만, 로직은 core 와 repo 에 라는 경계입니다.
C++23 · Boost.Asio 1.89 · IOCP · io_uring · FlatBuffers · UE5 Dedicated · 2026 기준

수천 명이
같은 세계에서
동시에 움직이게

게임 서버는 웹 서버와 목표가 다릅니다. 웹은 요청 하나에 응답 하나를 잘 돌려주면 되지만, 게임은 아무도 요청하지 않아도 1초에 30~60번 세계를 굴려야 하고, 그 결과를 접속한 전원에게 수십 밀리초 안에 뿌려야 합니다. 그래서 스레드 모델도, 네트워크 모델도, 데이터 저장 방식도 전부 달라집니다. 이 탭은 틱 루프 · 신뢰 UDP · 패킷 설계 · IOCP/epoll · 넷코드(예측·보간·지연 보상) 를 거쳐 마지막에 실제로 도는 2D 실시간 대전 서버 한 벌을 끝까지 만듭니다. 국내 게임사 서버 팀에서 실제로 쓰는 용어와 구조를 기준으로 씁니다.

13SECTIONS
C++23기준
60틱/초 목표
1완성 서버
GAME 01

게임 서버의 종류와 구조 — 무엇을 만들 것인지부터

"게임 서버를 만들자"는 말은 너무 넓습니다. 방 하나에 8명이 들어가는 대전 게임과 한 세계에 수천 명이 돌아다니는 MMO 는 부품이 겹치는 게 거의 없습니다. 먼저 어떤 종류를 만드는지 정하고, 그다음 서버를 몇 개로 쪼갤지 정합니다.

표준 구성 — 서버 하나가 다 하지 않습니다 클라이언트 수백~수만 동시 접속 게이트웨이 인증·토큰 발급 연결 수 감당 로비·매치 대기열·파티 MMR 매칭 게임 룸 틱 루프·판정 여기가 핵심 DB·랭킹 전적·재화 Redis+RDB 연결 방식 — 판정을 누가 하느냐가 전부입니다 전용 서버 Dedicated 서버가 모든 판정을 소유 치트에 가장 강함 서버 비용·운영 부담 큼 경쟁성 있는 게임은 사실상 필수 릴레이 Relay 서버는 패킷 중계만 판정은 호스트 클라이언트 NAT 문제 해결·IP 숨김 호스트 치트는 못 막음 P2P 호스트 서버 비용 0 호스트가 나가면 판 종료 서로 IP 노출·NAT 뚫기 협동·캐주얼에만 권장 고르는 기준 한 줄 — 지면 억울한 게임이면 전용 서버 순위·재화·경쟁이 걸린 순간 클라이언트 판정은 무조건 뚫립니다. 협동 PvE 라면 릴레이로 충분합니다
서버를 역할별로 쪼개는 이유는 부하 특성이 완전히 다르기 때문입니다 — 게이트웨이는 연결 수, 게임 룸은 CPU.

왜 클라이언트를 못 믿나 — 한 줄로 끝나는 이유

// ✗ 클라이언트가 보내 주는 값을 그대로 믿는 서버
void on_move(Player& p, const MovePacket& pk) {
    p.x = pk.x;          // 클라가 "나 여기 있어" 하면 그대로 반영
    p.y = pk.y;          // → 메모리 조작 한 번이면 순간이동·벽 통과
}

// ✓ 서버가 직접 계산하는 서버 (server authoritative)
void on_move(Player& p, const MoveInput& in, double dt) {
    // 입력은 '방향'만 받습니다. 위치는 서버가 만듭니다
    Vec2 dir = normalize({in.dx, in.dy});
    Vec2 want = p.pos + dir * p.speed * dt;      // 속도 상한이 걸림

    if (world.is_walkable(want))  p.pos = want;  // 지형 검사도 서버가
    // 실패하면 그냥 안 움직입니다 — 클라에는 정정 패킷을 보냅니다
}
업계에서 가장 많이 반복되는 사고 — 개발 초반에 “일단 클라가 계산하고 서버는 저장만”으로 만들어 놓고 출시 직전에 뒤집으려다 프로젝트가 흔들립니다. 권위(authority)를 어디 두느냐는 나중에 바꿀 수 있는 설계가 아닙니다. 처음부터 서버가 상태를 소유하고 클라는 입력만 보낸다로 시작하세요.

서버를 몇 조각으로 나누나

서버병목확장 방법
게이트웨이동시 연결 수·소켓수평(무상태)
로비/매치Redis I/O수평 + 큐 샤딩
게임 룸CPU(틱 처리)룸 단위로 분산
존(MMO)CPU + 관심 영역지역 분할
채팅팬아웃Pub/Sub
DB 프록시디스크·커넥션샤딩·읽기 복제
처음부터 다 쪼갤 필요는 없습니다 — 프로토타입은 한 프로세스에 전부 넣고 모듈 경계만 지켜 두세요(로비 코드가 룸 내부 자료구조를 직접 만지지 않게). 경계만 지켰다면 나중에 프로세스로 떼어 내는 건 패킷 경로 하나 바꾸는 일입니다. 경계를 안 지켰다면 다시 짜야 합니다.

한 단계 더 — 실시간이냐 턴제냐가 모든 것을 정합니다 🟡 중급

장르별 틱 레이트와 요구 지연

장르틱허용 RTT전송
FPS·대전 격투60~128~50msUDP
MOBA30~60~80msUDP
액션 MMO10~30~150msUDP/TCP
타겟팅 MMORPG10~20~200msTCP
턴제·카드·보드이벤트~500msTCP/WS
방치형·소셜없음초 단위HTTP
틱 레이트를 올리면 반응성이 좋아지는 대신 CPU 와 대역폭이 그대로 비례해서 늘어납니다. 100명 방에서 30틱 → 60틱 은 서버 비용이 두 배입니다. 그래서 실무에서는 시뮬레이션 틱은 60, 전송(스냅샷) 틱은 20~30 으로 따로 두는 방식을 흔히 씁니다. n02 와 n07 에서 이 분리를 코드로 봅니다.

턴제라면 이 정도로 끝납니다

// 턴제는 '루프'가 없습니다 — 이벤트가 올 때만 깨어납니다
struct TurnRoom {
    std::vector<PlayerId> order;
    std::size_t           cur = 0;
    Timer                 turn_timer;   // 제한 시간(예: 30초)

    void on_action(PlayerId who, const Action& a) {
        if (order[cur] != who) return;          // 차례가 아니면 무시
        if (!rules::is_legal(state, a)) return; // 규칙 검증 ★ 서버에서
        rules::apply(state, a);
        broadcast(StateDelta{state});           // 바뀐 부분만
        next_turn();
    }
    void next_turn() {
        cur = (cur + 1) % order.size();
        turn_timer.expires_after(30s);          // 시간 초과 → 자동 패스
        turn_timer.async_wait([this](auto ec){ if(!ec) on_timeout(); });
    }
};
턴제는 게임 서버가 아니라 웹 서버에 가깝습니다 — WebSocket 이나 심지어 HTTP 폴링으로도 됩니다. C++ 로 짤 이유도 크지 않습니다. 이 탭이 필요한 건 실시간입니다.

실무에선 이렇게 — 직접 만들 것인가, 사서 쓸 것인가 🔴 실무

2026년 기준 선택지와 판단 기준

선택지형태맞는 경우걸리는 지점
자체 C++ 서버Asio/IOCP 직접대규모·장수 서비스, 비용 민감, 특수 시뮬레이션사람과 시간(서버 1~3명 × 수개월)
UE5 Dedicated엔진 내장UE 로 만드는 액션·슈터, 물리 판정이 곧 게임인스턴스당 메모리·CPU 가 큼
PhotonSaaS/온프렘소규모 방 게임, 빠른 출시CCU 과금, 로직 커스터마이즈 한계
Nakama오픈소스(Go)로비·매치·소셜을 통째로 받고 싶을 때실시간 시뮬은 결국 따로 짜야 함
PlayFab / GameLift클라우드 백엔드서버 인스턴스 관리·매치 대행락인, 요금 예측이 어려움
Agones + 자체K8s 오케스트레이션자체 서버를 쓰되 배치만 맡길 때K8s 운영 인력 필요
실무 판단 기준 — “우리 게임의 재미가 서버 시뮬레이션에서 나오는가”를 먼저 물으세요. 히트박스·투사체·물리가 곧 게임성이라면 남의 서버로는 못 만듭니다. 반대로 방을 만들고 상태를 주고받는 게 전부라면 상용을 쓰고 게임에 시간을 쓰는 편이 낫습니다. 국내 중견 이상 게임사는 대부분 자체 C++ 서버 프레임워크를 사내에 하나 갖고 프로젝트마다 재사용합니다.
정리 — 먼저 전용 / 릴레이 / P2P 중 무엇인지 정하고, 서버가 상태를 소유한다는 원칙을 첫 줄부터 지킵니다. 서버는 게이트웨이 · 로비 · 게임 룸 · DB 로 역할이 나뉘고, 병목이 서로 다릅니다. 틱 레이트는 장르가 정합니다 — 그 숫자가 이후 모든 설계의 예산이 됩니다.
GAME 02

틱 루프 — 아무도 부르지 않아도 돌아가는 심장

웹 서버는 요청이 없으면 아무 일도 하지 않습니다. 게임 서버는 반대입니다. 1초에 30~60번, 아무 입력이 없어도 세계를 한 칸씩 굴립니다. 이 루프의 정확도가 곧 게임의 체감 품질이 되므로, 처음부터 제대로 짜 두어야 합니다.

고정 틱 33.3ms — 일하는 시간과 남는 시간 0 33 66 100 133 166 경과(ms) work sleep work sleep work sleep work 46ms — 구간 초과 밀린 틱 work sleep 밀렸을 때 — 두 가지 대응 중 하나를 고르세요 ① 따라잡기 catch-up 밀린 만큼 update(dt) 를 연속으로 여러 번 돌립니다 물리·시뮬레이션이 어긋나지 않습니다 단, 한 번에 도는 횟수에 상한을 꼭 두세요 (예: 5회) ② 버리기 drop 밀린 틱을 포기하고 지금 시각으로 다시 맞춥니다 즉시 회복되지만 그 틱의 이동·판정이 사라집니다 전투 판정이 있는 게임에는 위험합니다 상한 없는 따라잡기가 최악입니다 — 밀려서 더 오래 걸리고, 더 밀리는 death spiral 이 됩니다
틱 예산은 33.3ms 가 아니라 그 절반으로 잡으세요 — 나머지는 GC 없는 언어에도 반드시 생기는 변동분입니다.

고정 틱 루프 — 이 40줄이 서버의 전부입니다

#include <chrono>
#include <thread>
using namespace std::chrono;

class GameLoop {
    // ★ steady_clock 을 씁니다. system_clock 은 NTP 보정으로 '뒤로 갈' 수 있습니다
    using Clock = steady_clock;

    static constexpr int      TICK_HZ  = 30;
    static constexpr nanoseconds DT     = nanoseconds(1'000'000'000 / TICK_HZ); // 33.33ms
    static constexpr int      MAX_CATCHUP = 5;   // 한 번에 따라잡을 최대 틱 수

    World       world_;
    std::atomic<bool> running_{true};
    std::uint64_t tick_ = 0;

public:
    void run() {
        auto next = Clock::now();            // 다음 틱이 시작돼야 할 '이상적인' 시각

        while (running_.load(std::memory_order_relaxed)) {
            auto now = Clock::now();

            int catchup = 0;
            while (now >= next && catchup < MAX_CATCHUP) {
                auto t0 = Clock::now();

                pump_network();                       // ① 들어온 패킷을 큐에서 꺼내 적용
                world_.update(1.0 / TICK_HZ);         // ② 세계를 dt 만큼 굴림
                broadcast_snapshot(tick_);            // ③ 결과를 뿌림

                auto used = duration_cast<microseconds>(Clock::now() - t0);
                stats_.record(used);                  // ④ 틱당 처리 시간 기록 ★ n12
                if (used > duration_cast<microseconds>(DT))
                    log_warn("tick {} overrun: {}us", tick_, used.count());

                ++tick_;
                next += DT;                           // ★ now 가 아니라 next 에 더합니다
                ++catchup;
            }

            if (catchup == MAX_CATCHUP) {             // 너무 밀렸으면 기준을 다시 잡음
                log_error("loop far behind, resync");
                next = Clock::now() + DT;
            }

            // 남는 시간은 자야 합니다 — 안 자면 코어 하나를 100% 태웁니다
            auto sleep_for = next - Clock::now();
            if (sleep_for > 1ms)  std::this_thread::sleep_for(sleep_for - 1ms);
            while (Clock::now() < next) std::this_thread::yield();  // 마지막 1ms 는 스핀
        }
    }
};
next += DT 가 핵심입니다 — next = now + DT 로 쓰면 매 틱마다 처리 시간만큼 오차가 누적되어 30틱이 27틱쯤으로 조용히 내려앉습니다. “이상적인 시각”을 기준으로 더해야 장시간 돌려도 틱 수가 정확합니다.
sleep_for 를 그대로 믿으면 안 됩니다 — 윈도우 기본 타이머 해상도는 약 15.6ms 라 1ms 를 재워 달라고 해도 15ms 를 잡니다. 그래서 마지막 1ms 는 스핀으로 채웁니다. 윈도우에서는 timeBeginPeriod(1) 또는 최신 API 인 SetProcessInformation 의 타이머 해상도 설정을 함께 씁니다.

한 단계 더 — 시뮬레이션 틱과 전송 틱을 분리합니다 🟡 중급

60틱 시뮬 · 20틱 전송

static constexpr int SIM_HZ  = 60;   // 판정 정확도를 위해 촘촘히
static constexpr int SEND_HZ = 20;   // 대역폭을 위해 드물게
static constexpr int SEND_EVERY = SIM_HZ / SEND_HZ;   // 3틱마다 한 번

void tick_once() {
    pump_network();
    world_.update(1.0 / SIM_HZ);

    if (tick_ % SEND_EVERY == 0)
        broadcast_snapshot(tick_);       // ★ 3틱에 한 번만 전송
}
왜 나누나 — 히트 판정은 촘촘할수록 정확하지만, 클라이언트는 어차피 화면을 보간해서 그립니다(n07). 대역폭은 정확히 전송 틱에 비례하므로, 60 → 20 으로 내리면 서버 트래픽 비용이 1/3 이 됩니다. 오버워치·발로란트류가 “틱레이트 128”을 말할 때도 전송 틱을 가리키는 경우가 많습니다.

한 프로세스에 방이 여러 개일 때

// 방마다 스레드를 하나씩? → 방 500개면 스레드 500개. 안 됩니다.
// 워커 스레드 N개가 방 목록을 나눠 갖습니다 (N = 코어 수)

struct RoomWorker {
    std::vector<std::unique_ptr<Room>> rooms;   // 이 워커 전용 — 락 필요 없음 ★
    void tick(double dt) {
        for (auto& r : rooms) {
            if (!r->alive()) continue;
            r->update(dt);          // 방끼리는 서로 안 건드립니다
        }
        std::erase_if(rooms, [](auto& r){ return !r->alive(); });
    }
};

// 방을 새로 만들 때는 가장 한가한 워커에 붙입니다
RoomWorker& pick_worker() {
    return *std::ranges::min_element(workers_, {},
        [](const auto& w){ return w.load_score(); });
}
방을 워커에 고정(affinity)하면 락이 사라집니다 — 한 방의 데이터를 한 스레드만 만지므로 뮤텍스가 필요 없습니다. 이것이 게임 서버 스레드 설계의 가장 실용적인 출발점입니다(n06).

실무에선 이렇게 — 틱이 밀리는 진짜 원인들 🔴 실무

틱 안에서 절대 하면 안 되는 것

하지 말 것대신
동기 DB 쿼리더티 큐에 넣고 별도 스레드(n09)
파일·로그 동기 쓰기비동기 로거(spdlog async)
메모리 할당 반복사전 예약·오브젝트 풀
std::regex·문자열 파싱바이너리 패킷(n04)
전체 순회 O(n²) 충돌공간 분할(그리드·쿼드트리)
블로킹 send()비동기 쓰기 큐(n05)
실제 사례 — 틱마다 std::map 을 순회하며 문자열 키를 만들던 코드 하나 때문에 100명 방에서 틱 시간이 4ms → 38ms 가 된 적이 있습니다. 틱 안의 코드는 “한 번 호출” 이 아니라 “초당 30번 × 방 수” 로 읽어야 합니다.

틱 시간 히스토그램을 항상 켜 두세요

struct TickStats {
    std::array<std::uint32_t, 8> bucket{};   // <1,2,4,8,16,33,66,그 이상 ms
    std::uint64_t total = 0, over = 0;

    void record(std::chrono::microseconds us) {
        double ms = us.count() / 1000.0;
        std::size_t i = ms < 1 ? 0 : ms < 2 ? 1 : ms < 4 ? 2 : ms < 8 ? 3
                      : ms < 16 ? 4 : ms < 33 ? 5 : ms < 66 ? 6 : 7;
        ++bucket[i]; ++total;
        if (ms > 33.3) ++over;               // 오버런 비율 ★ 가장 중요한 지표
    }
    double p99_estimate() const;             // 버킷 누적으로 근사
};
평균은 거짓말을 합니다 — 평균 5ms 여도 100틱 중 1번 80ms 가 나오면 플레이어는 그 순간 끊김으로 느낍니다. p99 와 오버런 비율만 대시보드에 띄워도 문제의 90%가 보입니다(n12에서 지표로 다시 다룹니다).
정리 — 틱 루프는 next += DT 로 이상 시각을 누적하고, 따라잡기에 상한을 두고, 남는 시간은 자야 합니다. 시뮬 틱과 전송 틱을 분리하면 정확도는 지키면서 대역폭만 줄일 수 있습니다. 틱 안에서는 절대 블로킹하지 않습니다 — 이 원칙 하나가 이후 모든 장의 전제입니다.
GAME 03

TCP 냐 UDP 냐 — 신뢰성을 직접 얹는 이유

TCP 는 “빠짐없이 순서대로” 를 보장합니다. 좋아 보이지만 실시간 게임에서는 그게 독이 됩니다. 패킷 하나가 늦으면 뒤에 도착한 최신 정보까지 전부 붙잡아 두기 때문입니다. 그래서 게임은 UDP 위에 필요한 만큼만 신뢰성을 얹습니다.

신뢰 UDP — 잃어버린 패킷만 골라서 다시 보냅니다 클라이언트 서버 seq 17 seq 18 ✗ 유실 seq 19 18 이 비었다 ack 19 · 받은 목록 17,19 seq 18 재전송 순서 복원 신뢰성 모드 — 채널마다 다르게 겁니다 신뢰 · 순서 보장 채팅 · 아이템 획득 스킬 사용 · 방 입장 잃으면 안 되고 순서도 중요 신뢰 · 무순서 피격 판정 · 이펙트 트리거 도착만 하면 순서는 무관 앞 패킷이 막지 않음 비신뢰 · 최신값 위치 · 회전 · 애니 상태 다시 보낼 이유가 없음 오래된 건 그냥 버립니다
위치 정보는 다시 보내는 순간 이미 낡았습니다 — 재전송이 항상 이득이 아닌 이유입니다.

TCP 의 진짜 문제 — 머리 막힘(HOL blocking)

TCP 로 위치를 60Hz 로 보낸다고 합시다.

  보낸 순서 :  P100  P101  P102  P103  P104  P105
  P101 이 유실 →  재전송에 RTT(예: 80ms) 가 걸립니다

  그동안 P102~P105 는 이미 도착해 있지만
  커널 수신 버퍼에 갇혀 앱으로 올라오지 못합니다 ★
  "순서대로" 를 지켜야 하기 때문입니다

  결과 : 80ms 뒤에 P101~P105 가 한꺼번에 올라옵니다
        → 화면에서는 '멈췄다가 순간이동' 으로 보입니다

UDP + 최신값 우선이라면 :
  P101 은 그냥 버립니다. P102 가 도착한 순간 그걸 씁니다
  → 16ms 뒤 정상 진행. 한 프레임 튄 것으로 끝납니다
“TCP 는 느리다”는 말은 부정확합니다 — 대역폭·처리량은 오히려 TCP 가 좋습니다. 문제는 지연의 분포입니다. 손실이 1%만 있어도 TCP 는 가끔 RTT 만큼 통째로 멈추고, 게임에서는 그 “가끔”이 곧 렉으로 체감됩니다.

그럼 언제 TCP/WebSocket 이 정답인가

상황선택
턴제·카드·보드TCP / WebSocket
타겟팅 MMORPGTCP 로도 충분
브라우저(WebGL) 클라WebSocket 또는 WebTransport
로비·매치·상점·인증TCP/HTTPS
사내망·모바일 3G 배려TCP 가 방화벽에 강함
FPS·격투·레이싱UDP 필수
MOBA·액션 MMOUDP 권장
대부분의 상용 게임은 둘 다 씁니다 — 로비·상점·인증은 TCP(또는 HTTPS), 게임 룸 안의 실시간 상태만 UDP. 국내 모바일 게임은 회사 방화벽·통신사 NAT 때문에 UDP 가 막히는 사용자가 실제로 존재하므로 TCP 폴백 경로를 함께 두는 경우가 많습니다.

한 단계 더 — 신뢰 UDP 라이브러리 고르기 🟡 중급

2026년 기준 실사용 후보

라이브러리특징암호화맞는 경우
GameNetworkingSockets
Valve
Steam 에서 실제로 도는 코드. 채널·혼잡 제어·릴레이내장(AES-GCM)PC 게임, Steam 연동, 가장 무난한 기본값
ENet가볍고 API 가 단순. 20년 된 검증된 코드없음(직접)학습·소규모·프로토타입
KCPARQ 만 제공. 전송은 직접 붙임. 지연에 공격적없음중국계 게임·모바일 고손실 환경
yojimboGlenn Fiedler. 암호화+연결 토큰 설계가 교과서적내장(libsodium)보안 설계를 배우고 싶을 때
QUIC
msquic·quiche
표준. 스트림별 독립 순서 보장, 0-RTT필수(TLS 1.3)모바일(회선 전환), 웹 연동
직접 구현seq·ack 비트필드·재전송을 손으로직접학습용. 상용에는 권장하지 않음
현실적인 조언 — 신뢰 UDP 를 처음부터 직접 만들면 혼잡 제어·MTU 탐색·재연결·NAT 유지 같은 “보이지 않는 8할”에서 반드시 막힙니다. 기본값은 GameNetworkingSockets 로 두고, 원리를 이해하기 위해 아래 코드를 읽어 보세요. 단, 브라우저 클라이언트가 있다면 WebTransport(QUIC 기반) 이 사실상 유일한 UDP 계열 선택지입니다.

실무에선 이렇게 — seq · ack 비트필드는 이렇게 생겼습니다 🔴 실무

32개를 한 번에 확인하는 ack 비트필드

// 헤더 12바이트로 "내가 받은 최근 33개" 를 전부 알려 줍니다
struct RelHeader {
    std::uint16_t seq;        // 이 패킷의 번호
    std::uint16_t ack;        // 내가 받은 것 중 가장 최신 번호
    std::uint32_t ack_bits;   // ack-1 ~ ack-32 를 비트 1개씩 ★
    std::uint32_t sent_at_ms; // RTT 계산용
};

// 받았을 때 — 내 수신 기록을 갱신
void on_recv(std::uint16_t s) {
    if (seq_greater(s, recv_ack_)) {          // 더 최신이면 창을 민다
        std::uint16_t shift = s - recv_ack_;
        recv_bits_ = (shift >= 32) ? 0 : (recv_bits_ << shift) | (1u << (shift-1));
        recv_ack_  = s;
    } else {                                   // 늦게 도착한 옛 패킷
        std::uint16_t diff = recv_ack_ - s;
        if (diff <= 32) recv_bits_ |= (1u << (diff-1));
    }
}

// ★ 순환 비교 — 65535 다음이 0 이므로 그냥 < 로 비교하면 틀립니다
inline bool seq_greater(std::uint16_t a, std::uint16_t b) {
    return (a > b && a - b <= 32768) || (a < b && b - a > 32768);
}

보낸 쪽 — 확인 안 된 것만 다시

struct SentInfo { std::uint32_t at_ms; bool acked; std::vector<std::byte> body; };
std::array<SentInfo, 1024> sent_;          // seq % 1024 로 원형 저장

void on_ack(const RelHeader& h, std::uint32_t now_ms) {
    auto confirm = [&](std::uint16_t s) {
        auto& e = sent_[s % 1024];
        if (e.acked) return;
        e.acked = true;
        rtt_ = rtt_ * 0.9 + (now_ms - e.at_ms) * 0.1;   // 지수 이동 평균
    };
    confirm(h.ack);
    for (int i = 0; i < 32; ++i)
        if (h.ack_bits & (1u << i)) confirm(h.ack - (i + 1));
}

void update(std::uint32_t now_ms) {
    // RTT 의 1.5배가 지나도 확인이 없으면 유실로 간주하고 재전송
    const std::uint32_t timeout = static_cast<std::uint32_t>(rtt_ * 1.5) + 10;
    for (auto& e : sent_)
        if (!e.acked && e.body.size() && now_ms - e.at_ms > timeout)
            { resend(e); e.at_ms = now_ms; }
}
ack 는 따로 보내지 않습니다 — 어차피 매 틱 패킷이 오가므로 헤더에 얹어 보냅니다(piggyback). ack 전용 패킷을 만들면 트래픽이 두 배가 됩니다. 보낼 게 없을 때만 200ms 주기로 빈 keep-alive 를 보내 NAT 매핑을 유지합니다.

패킷 크기 — MTU 를 넘기면 조각납니다

이더넷 MTU 1500 바이트에서 실제로 쓸 수 있는 크기는

  1500 - IP 헤더 20 - UDP 헤더 8 = 1472 바이트
  VPN·PPPoE·모바일 터널을 감안하면 실무 안전선은 ★ 1200 바이트

이 선을 넘으면 IP 단편화가 일어납니다
  · 조각 중 하나만 잃어도 패킷 전체가 무효가 됩니다
  · 손실률 1% 짜리 회선에서 3조각 패킷의 손실률은 약 3%
  · 일부 방화벽·NAT 는 조각난 UDP 를 아예 버립니다

그래서 게임 패킷은 항상 1200 바이트 안에 넣습니다
  · 스냅샷이 크면 '중요한 것부터' 잘라 담고 나머지는 다음 틱에
  · 그래도 커야 하는 것(맵 데이터·리소스)은 TCP/HTTP 로 따로 받습니다
1200 바이트라는 숫자를 외워 두세요 — QUIC 표준이 초기 패킷에 요구하는 최소값도 이 근처(1200)입니다. 신뢰 UDP 라이브러리들도 대부분 기본 MTU 를 1200 안팎으로 잡습니다. 직접 만든다면 접속 시 MTU 탐색을 한 번 돌리는 것이 정석입니다.

UDP 라고 마음대로 보내면 안 됩니다

// TCP 는 혼잡 제어가 커널에 들어 있습니다. UDP 는 없습니다.
// 아무 제어 없이 쏘면 회선을 스스로 막아 '내가 만든 렉' 이 됩니다

class SendPacer {
    double budget_bytes_ = 0;
    double rate_bps_     = 256'000;   // 초당 32KB 에서 시작
public:
    void update(double dt, double loss_ratio) {
        // ★ 손실이 보이면 줄이고, 깨끗하면 천천히 늘립니다 (AIMD)
        if (loss_ratio > 0.05)      rate_bps_ *= 0.75;          // 곱셈 감소
        else if (loss_ratio < 0.01) rate_bps_ += 8'000 * dt;    // 덧셈 증가
        rate_bps_ = std::clamp(rate_bps_, 64'000.0, 4'000'000.0);
        budget_bytes_ = std::min(budget_bytes_ + rate_bps_ / 8 * dt, 4096.0);
    }
    bool allow(std::size_t n) {                 // 예산 안에서만 보냅니다
        if (budget_bytes_ < double(n)) return false;
        budget_bytes_ -= double(n); return true;
    }
};

// 우선순위 — 예산이 모자랄 때 무엇부터 버리나
//   ① 신뢰 채널(아이템·스킬)          → 절대 버리지 않음
//   ② 가까운 적의 위치                 → 우선
//   ③ 먼 적의 위치 · 이펙트            → 이번 틱은 건너뜀 ★
//   ④ 통계·디버그 정보                 → 여유 있을 때만
모바일 회선에서 특히 중요합니다 — LTE·5G 는 버퍼가 커서(bufferbloat) 과하게 보내면 패킷이 버려지는 대신 수백 ms 씩 지연됩니다. “손실은 없는데 핑만 계속 오른다”면 거의 항상 내가 너무 많이 보내고 있는 것입니다.
정리 — TCP 의 문제는 속도가 아니라 머리 막힘입니다. 위치처럼 최신값만 의미 있는 데이터는 재전송하면 손해입니다. UDP 위에 신뢰 순서 / 신뢰 무순서 / 비신뢰 세 채널을 두고 데이터마다 골라 씁니다. 직접 만들지 말고 GameNetworkingSockets 같은 검증된 계층을 쓰되, seq·ack 원리는 알고 있어야 디버깅이 됩니다.
GAME 04

패킷 설계 — 바이트 흐름을 메시지로 되돌리기

TCP 에는 “메시지”라는 개념이 없습니다. send 를 세 번 했다고 recv 가 세 번 나오지 않습니다. 신입 서버 개발자가 가장 많이 만드는 버그가 여기서 나옵니다. 길이를 앞에 붙이고, 다 모일 때까지 기다리는 것 — 이 규칙 하나가 프로토콜의 절반입니다.

TCP 는 바이트 흐름 — recv 경계와 메시지 경계는 절대 일치하지 않습니다 보낸 쪽이 생각한 메시지 (67바이트) MOVE 20B SNAPSHOT 35B PING 12B 실제로 recv() 가 돌려준 덩어리 recv #1 — 30B (MOVE 전부 + SNAPSHOT 앞 10B) recv #2 — 25B recv #3 — 12B 파서가 하는 일 — 링버퍼에 쌓고 길이를 보고 꺼냅니다 1 받은 바이트를 그대로 링버퍼 뒤에 붙입니다 — 여기서는 아무 해석도 하지 않습니다 2 앞 4바이트(len 2 + id 2)가 모였나? 아니면 그대로 두고 다음 수신을 기다립니다 3 len 바이트가 다 모였으면 한 메시지를 꺼내 처리하고, 소비한 만큼 앞을 당깁니다 — 루프로 반복
한 번의 recv 로 0개, 1개, 혹은 3.5개의 메시지가 올 수 있습니다 — 파서는 항상 while 루프여야 합니다.

헤더와 파서 — 복붙해서 바로 쓰는 형태

#include <bit>        // std::byteswap (C++23), std::endian
#include <cstdint>
#include <span>
#include <vector>

// ── 헤더 4바이트 : 길이(본문만) + 메시지 id ──────────────────
#pragma pack(push, 1)
struct PacketHeader {
    std::uint16_t size;   // 본문 길이 (헤더 제외) — 최대 65535
    std::uint16_t id;     // 메시지 종류
};
#pragma pack(pop)
static_assert(sizeof(PacketHeader) == 4);

// ── 엔디안 : 네트워크는 빅엔디안이 관례입니다 ─────────────────
constexpr std::uint16_t hton16(std::uint16_t v) {
    if constexpr (std::endian::native == std::endian::little) return std::byteswap(v);
    else return v;
}
constexpr std::uint16_t ntoh16(std::uint16_t v) { return hton16(v); }

// ── 수신 버퍼 : 받은 것을 쌓고, 완성된 메시지만 꺼냅니다 ───────
class RecvBuffer {
    std::vector<std::byte> buf_;
    std::size_t            read_ = 0;   // 다음에 읽을 위치

public:
    void append(std::span<const std::byte> in) {
        buf_.insert(buf_.end(), in.begin(), in.end());
    }

    // 완성된 메시지가 있으면 꺼내 true. 없으면 false — ★ while 로 돌리세요
    bool next(PacketHeader& out_hdr, std::span<const std::byte>& out_body) {
        const std::size_t avail = buf_.size() - read_;
        if (avail < sizeof(PacketHeader)) return false;          // ① 헤더도 안 왔다

        PacketHeader h;
        std::memcpy(&h, buf_.data() + read_, sizeof h);
        h.size = ntoh16(h.size);
        h.id   = ntoh16(h.id);

        if (h.size > MAX_BODY) throw ProtocolError{"body too large"};   // ★ 반드시 상한
        if (avail < sizeof(PacketHeader) + h.size) return false;  // ② 본문이 덜 왔다

        out_hdr  = h;
        out_body = {buf_.data() + read_ + sizeof h, h.size};
        read_   += sizeof h + h.size;                             // ③ 소비
        return true;
    }

    void compact() {                       // 처리 후 앞부분을 버립니다
        if (read_ == 0) return;
        buf_.erase(buf_.begin(), buf_.begin() + read_);
        read_ = 0;
    }
    static constexpr std::uint16_t MAX_BODY = 8192;
};

// ── 사용 : 소켓에서 읽은 뒤 ────────────────────────────────
void on_bytes(Session& s, std::span<const std::byte> in) {
    s.rb.append(in);
    PacketHeader h; std::span<const std::byte> body;
    while (s.rb.next(h, body))        // ★ 한 번에 여러 개가 나올 수 있습니다
        dispatch(s, h.id, body);
    s.rb.compact();
}
길이 상한 검사를 빼면 그대로 서비스 거부 취약점입니다 — 공격자가 size = 65535 를 잔뜩 보내면 세션마다 64KB 씩 붙잡습니다. 연결 1만 개면 640MB. 메시지 id 별로 허용 최대 크기를 따로 두는 것이 더 안전합니다.
erase 대신 링버퍼 — 초당 수만 번 호출되면 vector::erase 의 이동 비용이 보입니다. 읽기·쓰기 오프셋 두 개를 둔 고정 크기 링버퍼로 바꾸면 복사가 사라집니다. n13 프로젝트에서는 링버퍼 버전을 씁니다.

한 단계 더 — 직렬화 — 손으로? Protobuf? FlatBuffers? 🟡 중급

세 방식의 실제 차이

수동 직렬화ProtobufFlatBuffers
읽는 비용복사 1회파싱+할당0 (제로카피)
크기가장 작음작음(varint)조금 큼(정렬 패딩)
스키마 변경손으로 버전 관리호환 잘 됨호환 잘 됨
클라 연동직접전 언어 지원C#·C++·TS 지원
디버깅어렵다텍스트 덤프 쉬움보통
쓰기 좋은 곳위치 갱신 같은 초고빈도로비·상점·전적게임 룸 스냅샷
실무 조합 — 로비·인증·상점처럼 초당 수십 번인 경로는 Protobuf(또는 JSON) 로 편하게 가고, 게임 룸 안의 초당 수만 번 경로만 FlatBuffers 나 수동으로 짭니다. 전부 하나로 통일해야 한다는 규칙은 없습니다 — 통일하려다 로비 코드가 읽기 힘들어지는 쪽이 손해입니다.

FlatBuffers 스키마 — 스냅샷에 맞는 이유

// game.fbs
namespace pk;

struct Vec2 { x:float; y:float; }          // struct = 고정 크기·인라인 ★

table EntityState {
  id:uint32;
  pos:Vec2;
  vel:Vec2;
  hp:uint16;
  flags:uint8;
}

table Snapshot {
  tick:uint32;
  ack_input:uint32;                        // 이 클라의 마지막 입력 번호 (n07)
  entities:[EntityState];
}

table Input {
  seq:uint32;
  dx:float; dy:float;
  buttons:uint16;
  client_tick:uint32;
}

union Body { Snapshot, Input }
table Envelope { body:Body; }
root_type Envelope;
# 코드 생성 (flatc 25.x)
flatc --cpp --scoped-enums -o gen/ game.fbs
# → gen/game_generated.h 하나가 나옵니다. 런타임 라이브러리 링크 불필요

읽을 때 복사가 없다는 뜻

#include "gen/game_generated.h"

// 보내는 쪽 — 버퍼를 한 번 만들고 그대로 소켓에 넣습니다
flatbuffers::FlatBufferBuilder fbb(1024);
std::vector<flatbuffers::Offset<pk::EntityState>> ents;
for (const auto& e : room.entities) {
    pk::Vec2 p{e.pos.x, e.pos.y}, v{e.vel.x, e.vel.y};
    ents.push_back(pk::CreateEntityState(fbb, e.id, &p, &v, e.hp, e.flags));
}
auto snap = pk::CreateSnapshot(fbb, room.tick, sess.ack_input, fbb.CreateVector(ents));
fbb.Finish(pk::CreateEnvelope(fbb, pk::Body::Snapshot, snap.Union()));
send_framed(sess, {fbb.GetBufferPointer(), fbb.GetSize()});

// 받는 쪽 — 파싱이 '없습니다'. 수신 버퍼를 그대로 가리킵니다 ★
auto* env = pk::GetEnvelope(body.data());
if (env->body_type() == pk::Body::Snapshot) {
    auto* s = env->body_as_Snapshot();
    for (auto* e : *s->entities())        // 접근하는 필드만 읽습니다
        world.apply(e->id(), {e->pos()->x(), e->pos()->y()}, e->hp());
}

// ★ 단, 신뢰할 수 없는 입력에는 검증기를 반드시 돌립니다
flatbuffers::Verifier v(body.data(), body.size());
if (!pk::VerifyEnvelopeBuffer(v)) { kick(sess, "malformed"); return; }
Verifier 를 건너뛰면 원격 코드 실행급 취약점이 됩니다 — 제로카피는 “버퍼 안의 오프셋을 그대로 믿는다”는 뜻이라, 조작된 오프셋 하나로 버퍼 밖을 읽습니다. 서버 → 클라 방향은 생략해도 되지만 클라 → 서버는 예외 없이 검증하세요.

실무에선 이렇게 — 패킷 id 관리와 버전 호환 🔴 실무

디스패치 테이블 — switch 문 대신

enum class MsgId : std::uint16_t {
    C2S_Login = 1, C2S_Input = 2, C2S_Chat = 3,
    S2C_Welcome = 101, S2C_Snapshot = 102, S2C_Chat = 103,
};

using Handler = void(*)(Session&, std::span<const std::byte>);

// 배열로 두면 O(1) 이고 캐시에도 좋습니다
inline constexpr std::size_t MAX_ID = 256;
std::array<Handler, MAX_ID> g_handlers{};

template <MsgId Id> void reg(Handler h) {
    static_assert(static_cast<std::size_t>(Id) < MAX_ID);
    g_handlers[static_cast<std::size_t>(Id)] = h;
}

void dispatch(Session& s, std::uint16_t id, std::span<const std::byte> b) {
    if (id >= MAX_ID || !g_handlers[id]) { kick(s, "unknown id"); return; }
    if (!s.authed && id != (std::uint16_t)MsgId::C2S_Login) { kick(s,"unauth"); return; }
    g_handlers[id](s, b);                 // ★ 인증 전 접근 차단이 여기 한 줄
}

클라와 서버 버전이 어긋날 때

// ① 접속 즉시 프로토콜 해시를 교환합니다
//    스키마 파일을 빌드 때 해시해서 상수로 박아 둡니다
inline constexpr std::uint32_t PROTO_HASH = 0x9F3A21C4;  // CI 가 생성

void on_login(Session& s, const LoginReq& req) {
    if (req.proto_hash != PROTO_HASH) {
        send(s, LoginRes{.ok = false, .reason = "UPDATE_REQUIRED"});
        return;                       // ★ 여기서 막지 않으면 원인 불명 크래시로 옵니다
    }
    ...
}

// ② 필드를 추가할 때 규칙
//    · FlatBuffers/Protobuf : 새 필드는 '끝에만' 추가, 기존 번호 재사용 금지
//    · 수동 직렬화          : 헤더에 ver 를 넣고 분기, 혹은 새 MsgId 를 만든다
//    · 필드 삭제            : 삭제하지 말고 deprecated 표시만 (번호를 비워 둠)
운영에서 가장 무서운 건 “일부만 업데이트된 상태” 입니다 — 스토어 심사·강제 업데이트 유예 때문에 구버전 클라가 며칠씩 남습니다. 프로토콜 해시로 즉시 거절하거나, 아예 구버전용 서버 풀을 따로 띄우는 것이 실무의 두 가지 답입니다.
정리 — TCP 는 메시지 경계를 지켜 주지 않으므로 길이 접두사 + while 루프 파서가 필수입니다. 길이 상한 검사를 빼면 그대로 취약점입니다. 직렬화는 로비는 Protobuf, 룸은 FlatBuffers 식으로 경로별로 나눠 쓰고, 제로카피를 쓴다면 클라에서 온 버퍼는 반드시 Verifier 를 통과시킵니다.
GAME 05

세션과 I/O 모델 — IOCP · epoll · Asio

연결 5천 개에 스레드 5천 개를 만들 수는 없습니다. 그래서 소수의 스레드가 수천 개의 소켓을 돌아가며 처리합니다. 윈도우는 IOCP, 리눅스는 epoll / io_uring. 이 둘은 사고방식이 다릅니다. Boost.Asio 는 그 차이를 덮어 하나의 코드로 만들어 줍니다.

IOCP — "다 되면 알려 줘" · epoll — "읽을 수 있게 되면 알려 줘" 1 WSARecv 등록 버퍼 주소만 넘기고 즉시 리턴 2 커널이 직접 복사 NIC 도착 → 내 버퍼로 DMA 3 완료 통지 큐에 넣습니다 I/O Completion Port 큐 워커 1 GQCS 에서 대기 워커 2 깨어나 처리 워커 3 깨어나 처리 워커 4 한도 초과 — 대기 커널이 동시에 깨우는 워커 수를 코어 수로 제한합니다 — 스레드를 많이 만들어도 문맥 전환이 폭발하지 않습니다
IOCP 는 복사까지 끝난 뒤 알려 주고, epoll 은 읽을 수 있다는 사실만 알려 줍니다 — 코드 구조가 달라지는 지점입니다.

두 모델의 차이 — 한 표로

IOCP (Windows)epoll (Linux)
통지 시점완료 후준비 됨
모델 이름proactorreactor
버퍼 소유커널이 들고 있음앱이 그때 준비
스레드 제어커널이 조절앱이 직접
연결 10k매우 좋음매우 좋음
대안RIO(초저지연)io_uring
io_uring 은 리눅스의 IOCP 라고 보면 거의 맞습니다 — 제출 큐에 요청을 넣고 완료 큐에서 결과를 꺼냅니다. 커널 5.10+ 에서 안정적이고, 2026년 기준 고성능 게임 서버의 리눅스 기본 선택으로 자리 잡았습니다. Boost.Asio 1.89 도 BOOST_ASIO_HAS_IO_URING 로 백엔드를 바꿀 수 있습니다.

IOCP 뼈대 — 등록 · 대기 · 재등록

// Windows. 실제로 도는 최소 형태입니다
struct IoCtx : OVERLAPPED {          // ★ OVERLAPPED 를 '상속' 하는 것이 관례
    enum class Op { Recv, Send } op;
    WSABUF   wsabuf;
    char     buf[8192];
    Session* sess;
};

HANDLE g_cp = CreateIoCompletionPort(INVALID_HANDLE_VALUE, nullptr, 0, 0);

void attach(SOCKET s, Session* sess) {
    CreateIoCompletionPort((HANDLE)s, g_cp, (ULONG_PTR)sess, 0);
    post_recv(sess);                 // 첫 수신 요청을 걸어 둡니다
}

void post_recv(Session* s) {
    auto* ctx = s->recv_ctx();
    ZeroMemory(static_cast<OVERLAPPED*>(ctx), sizeof(OVERLAPPED));
    ctx->op = IoCtx::Op::Recv;
    ctx->wsabuf = { sizeof ctx->buf, ctx->buf };
    DWORD flags = 0;
    int r = WSARecv(s->sock, &ctx->wsabuf, 1, nullptr, &flags, ctx, nullptr);
    if (r == SOCKET_ERROR && WSAGetLastError() != WSA_IO_PENDING)
        s->close();                  // 진짜 에러
}

void worker() {                      // 코어 수만큼 띄웁니다
    DWORD bytes; ULONG_PTR key; OVERLAPPED* ov;
    for (;;) {
        BOOL ok = GetQueuedCompletionStatus(g_cp, &bytes, &key, &ov, INFINITE);
        if (!ov) break;                            // 종료 신호(PostQueued…)
        auto* ctx  = static_cast<IoCtx*>(ov);
        auto* sess = reinterpret_cast<Session*>(key);

        if (!ok || bytes == 0) { sess->close(); continue; }   // ★ 0 = 정상 종료

        if (ctx->op == IoCtx::Op::Recv) {
            sess->on_bytes({reinterpret_cast<std::byte*>(ctx->buf), bytes});
            post_recv(sess);                        // ★ 다시 걸어야 계속 받습니다
        } else {
            sess->on_sent(bytes);
        }
    }
}
가장 흔한 IOCP 버그 두 가지 — ① post_recv 재등록을 잊어 첫 패킷만 받고 조용해지는 것, ② OVERLAPPED 를 스택에 두어 함수가 끝난 뒤 커널이 죽은 메모리에 쓰는 것. 비동기 I/O 의 버퍼와 컨텍스트는 완료 통지를 받을 때까지 반드시 살아 있어야 합니다.

한 단계 더 — Boost.Asio 로 한 코드가 세 OS 에서 🟡 중급

세션 클래스 — 수명 관리까지 포함한 완성형

#include <boost/asio.hpp>
#include <deque>
#include <memory>
namespace asio = boost::asio;
using tcp = asio::ip::tcp;

// ★ enable_shared_from_this : 비동기 콜백이 끝날 때까지 객체를 살려 둡니다
class Session : public std::enable_shared_from_this<Session> {
    tcp::socket                 sock_;
    asio::strand<asio::any_io_executor> strand_;   // ★ 이 세션의 콜백 직렬화
    std::array<std::byte, 8192> rbuf_{};
    RecvBuffer                  parser_;
    std::deque<std::vector<std::byte>> wq_;        // 쓰기 대기 큐
    bool                        writing_ = false;

public:
    explicit Session(tcp::socket s)
        : sock_(std::move(s)), strand_(asio::make_strand(sock_.get_executor())) {}

    void start() {
        sock_.set_option(tcp::no_delay(true));     // ★ Nagle 끄기 — 게임에서는 필수
        do_read();
    }

    // 어느 스레드에서 불러도 안전합니다 (strand 로 넘김)
    void send(std::vector<std::byte> msg) {
        asio::post(strand_, [self = shared_from_this(), m = std::move(msg)]() mutable {
            self->wq_.push_back(std::move(m));
            if (!self->writing_) self->do_write();
        });
    }

private:
    void do_read() {
        auto self = shared_from_this();
        sock_.async_read_some(asio::buffer(rbuf_),
            asio::bind_executor(strand_, [self](auto ec, std::size_t n) {
                if (ec) { self->on_close(ec); return; }
                self->parser_.append({self->rbuf_.data(), n});
                PacketHeader h; std::span<const std::byte> body;
                while (self->parser_.next(h, body))
                    self->enqueue_to_room(h.id, body);   // ★ 여기서 게임 로직을 부르지 않습니다
                self->parser_.compact();
                self->do_read();                          // 다시 읽기
            }));
    }

    void do_write() {
        writing_ = true;
        auto self = shared_from_this();
        asio::async_write(sock_, asio::buffer(wq_.front()),
            asio::bind_executor(strand_, [self](auto ec, std::size_t) {
                if (ec) { self->on_close(ec); return; }
                self->wq_.pop_front();
                if (!self->wq_.empty()) self->do_write();
                else                    self->writing_ = false;
            }));
    }

    void on_close(boost::system::error_code ec) {
        if (closed_) return; closed_ = true;
        boost::system::error_code ign;
        sock_.shutdown(tcp::socket::shutdown_both, ign);
        room_->post_leave(id_);        // 방에 "나갔다" 를 알립니다
    }
    bool closed_ = false;
};

// ── 서버 구동 ────────────────────────────────────────────────
int main() {
    asio::io_context io;                        // 윈도우면 내부적으로 IOCP,
    tcp::acceptor acc(io, {tcp::v4(), 7777});   // 리눅스면 epoll/io_uring 을 씁니다

    std::function<void()> accept_one = [&] {
        acc.async_accept([&](auto ec, tcp::socket s) {
            if (!ec) std::make_shared<Session>(std::move(s))->start();
            accept_one();                        // 계속 받습니다
        });
    };
    accept_one();

    std::vector<std::jthread> pool;              // 코어 수만큼
    for (unsigned i = 1; i < std::thread::hardware_concurrency(); ++i)
        pool.emplace_back([&]{ io.run(); });
    io.run();
}
strand 가 하는 일 — 스레드가 여러 개여도 같은 세션의 콜백은 절대 동시에 실행되지 않게 합니다. 세션 내부 자료구조에 뮤텍스가 필요 없어지는 이유입니다. Asio 를 쓰면서 std::mutex 를 세션마다 들고 있다면, 십중팔구 strand 를 안 쓴 것입니다.
no_delay(true) 를 빼면 40ms 가 그냥 사라집니다 — Nagle 알고리즘이 작은 패킷을 모아서 보내려고 기다립니다. 게임 패킷은 원래 작으므로 항상 끕니다. 여기에 상대편 지연 ACK 까지 겹치면 최악 200ms 가 나오는 것으로 유명합니다.

실무에선 이렇게 — 세션 수명 · 끊김 처리가 실제 난이도 🔴 실무

왜 shared_from_this 인가

// ✗ 이렇게 쓰면 크래시합니다
void Session::do_read() {
    sock_.async_read_some(buf, [this](auto ec, auto n) {  // this 캡처 ★
        this->handle(n);      // 콜백이 불릴 때 Session 이 이미 지워졌을 수 있음
    });
}
// 연결이 끊겨 Session 을 delete 했는데, 커널에 걸어 둔 읽기 요청의
// 완료 콜백은 그 뒤에 옵니다 → use-after-free

// ✓ shared_ptr 를 캡처해 콜백이 살아 있는 동안 객체를 붙잡습니다
void Session::do_read() {
    auto self = shared_from_this();
    sock_.async_read_some(buf, [self](auto ec, auto n) {
        self->handle(n);      // 마지막 콜백이 끝나면 참조수 0 → 소멸
    });
}

// 방(Room) 이 세션을 들고 있을 때는 weak_ptr 로 — 순환 참조 방지
std::unordered_map<PlayerId, std::weak_ptr<Session>> members_;

끊김을 감지하는 세 가지 층

층감지 방법걸리는 시간
소켓 에러read/write 실패즉시
정상 종료read 가 0바이트즉시
앱 하트비트N초간 패킷 없음N초 (권장 10~15)
TCP keepaliveOS 설정기본 2시간(쓸모없음)
// 틱마다 도는 타임아웃 검사 — 게임 서버의 필수 장치
void Room::check_timeouts(std::uint64_t now_ms) {
    for (auto& [id, p] : players_) {
        if (now_ms - p.last_packet_ms > 15'000) {
            log_info("player {} timed out", id);
            p.state = PlayerState::Disconnected;   // ★ 바로 지우지 않습니다
            p.grace_until_ms = now_ms + 60'000;    // 60초 재접속 유예
        }
    }
}
모바일은 “잠깐 끊김” 이 일상입니다 — 지하철·엘리베이터·앱 백그라운드 전환. 연결이 끊겼다고 캐릭터를 즉시 지우면 원성이 쏟아집니다. 세션(연결)과 플레이어(게임 안의 존재)를 분리하고, 재접속 토큰으로 같은 플레이어에 다시 붙이는 설계가 표준입니다.
정리 — IOCP 는 완료 통지, epoll 은 준비 통지입니다. 직접 쓸 게 아니라면 Boost.Asio 하나로 세 OS 를 덮습니다. 세션은 shared_from_this + strand 로 수명과 동시성을 동시에 해결하고, no_delay 를 켜고 앱 하트비트로 끊김을 감지하며, 세션과 플레이어는 분리합니다.
GAME 06

스레드 모델 — 락을 줄이는 가장 확실한 방법

멀티스레드 게임 로직은 “되긴 되는데 가끔 이상한” 버그의 온상입니다. 그래서 실무의 정답은 대개 스레드를 잘 쓰는 것이 아니라 공유를 없애서 락 자체를 지우는 것입니다.

공유 + 락 vs 액터(방마다 큐 하나 · 스레드 하나) 공유 데이터 + 뮤텍스 워커1 워커2 워커3 워커4 zone_mutex Zone 데이터 코어를 늘려도 처리량이 안 늘어납니다 락 순서가 엇갈리면 교착 · 재현이 어렵습니다 액터 — 방마다 큐 하나 Room A 메시지 큐 입력·입장·퇴장 Room B 메시지 큐 입력·입장·퇴장 워커1 전용 락 없음 워커2 전용 락 없음 데이터를 만지는 스레드가 항상 하나뿐입니다 방 수만큼 선형으로 늘어납니다 대신 방끼리의 상호작용은 메시지로만 합니다 즉시 응답이 필요한 조회는 설계가 까다롭습니다
게임 서버에서 가장 큰 성능 개선은 대개 락을 잘 쓰는 게 아니라 없애는 것입니다.

잡 큐 — 액터의 실제 구현

#include <functional>
#include <mutex>
#include <vector>

// 방 하나 = 액터 하나. 외부에서는 오직 post() 로만 접근합니다
class Room {
    std::mutex                            qmx_;      // ★ 큐에만 걸리는 짧은 락
    std::vector<std::function<void()>>    q_, swap_;
    WorldState                            state_;    // 락 없이 씁니다 ★

public:
    // 어느 스레드에서든 호출 가능 (I/O 스레드가 부릅니다)
    void post(std::function<void()> job) {
        std::lock_guard lk(qmx_);
        q_.push_back(std::move(job));
    }

    // 이 방을 소유한 워커 스레드에서만 호출됩니다
    void tick(double dt) {
        { std::lock_guard lk(qmx_); swap_.swap(q_); }   // ★ 통째로 훔쳐 옵니다
        for (auto& job : swap_) job();                  // 락 밖에서 실행
        swap_.clear();

        state_.step(dt);
        broadcast_snapshot();
    }
};

// I/O 스레드에서 패킷이 왔을 때 — 게임 로직을 직접 부르지 않습니다
void Session::enqueue_to_room(std::uint16_t id, std::span<const std::byte> body) {
    auto copy = std::vector<std::byte>(body.begin(), body.end());   // 소유권 이전
    room_->post([r = room_, pid = player_id_, id, b = std::move(copy)] {
        r->handle(pid, id, b);          // ★ 룸 스레드에서 안전하게 실행됩니다
    });
}
swap 트릭 — 큐를 통째로 바꿔치기하면 락을 잡는 시간이 포인터 교환 한 번으로 끝납니다. 잡 실행은 락 밖에서 하므로, 잡 안에서 다시 post 를 불러도 교착이 없습니다. 이 패턴 하나로 게임 서버 동시성의 8할이 해결됩니다.

그래도 락을 써야 할 때 — 순서 규칙

// 교착은 "A→B 로 잡는 스레드"와 "B→A 로 잡는 스레드"가 만나면 생깁니다
// 해결책은 단 하나 : 전역 순서를 정하고 예외 없이 지키기

// ① 주소 순서로 잡기 — std::scoped_lock 이 알아서 해 줍니다 (C++17)
void trade(Player& a, Player& b) {
    std::scoped_lock lk(a.mx, b.mx);      // ★ 교착 회피 알고리즘 내장
    // a.mx 먼저인지 b.mx 먼저인지 신경 쓸 필요가 없습니다
}

// ② 계층 번호를 붙여 낮은 쪽 → 높은 쪽만 허용 (디버그 빌드에서 검사)
struct HierMutex {
    std::mutex m; int level;
    static thread_local int cur;         // 지금 이 스레드가 잡은 최고 레벨
    void lock() {
        assert(level > cur && "lock order violation");   // ★ 개발 중에 잡습니다
        prev_ = cur; cur = level; m.lock();
    }
    void unlock() { cur = prev_; m.unlock(); }
    int prev_ = 0;
};
// 예: Room(10) → Player(20) → Inventory(30). 역방향은 assert 로 터집니다
“가끔 서버가 멈춘다”의 대부분은 교착입니다 — 재현이 안 되므로 사후 분석이 유일한 방법입니다. 리눅스는 gdb -p 후 thread apply all bt, 윈도우는 크래시 덤프를 WinDbg 로 열어 !locks 를 봅니다(n12).

한 단계 더 — MMO 의 존(zone) 은 어떻게 나누나 🟡 중급

존 분할 세 가지 방식

방식장점단점
맵 단위구현이 단순, 경계=로딩 화면인기 맵에 몰림
채널같은 맵을 복제해 인원 분산친구가 다른 채널에
그리드 분할이음매 없는 세계경계 이동 처리가 복잡
관심 영역
AoI
보내는 양이 확 줄어듦자료구조 관리 비용
국내 MMORPG 는 “채널”이 사실상 표준입니다 — 같은 맵을 프로세스별로 복제하고 플레이어가 채널을 골라 들어갑니다. 그리드 분할(이음매 없는 세계)은 훨씬 어렵고, 경계에서 객체 소유권 이전(handoff) 이라는 난제가 따라옵니다.

관심 영역(AoI) — 안 보이면 안 보냅니다

// 100명 방에서 전원에게 전원을 보내면 초당 100×100×30 = 30만 건입니다
// 시야 안(예: 40m)의 대상만 보내면 보통 10분의 1 이하로 줄어듭니다

class Grid {                       // 셀 크기 = 시야 반경
    static constexpr float CELL = 40.f;
    std::unordered_map<std::uint64_t, std::vector<EntityId>> cells_;

    static std::uint64_t key(int cx, int cy) {
        return (std::uint64_t)(std::uint32_t)cx << 32 | (std::uint32_t)cy;
    }
public:
    void move(EntityId id, Vec2 from, Vec2 to) {
        auto a = key(int(from.x/CELL), int(from.y/CELL));
        auto b = key(int(to.x  /CELL), int(to.y  /CELL));
        if (a == b) return;                       // 같은 셀이면 할 일 없음
        std::erase(cells_[a], id);
        cells_[b].push_back(id);
    }
    // 내 셀 + 인접 8칸만 보면 됩니다 — 3×3 = 최대 9칸
    template <class F> void for_near(Vec2 p, F&& f) const {
        int cx = int(p.x/CELL), cy = int(p.y/CELL);
        for (int dy = -1; dy <= 1; ++dy)
          for (int dx = -1; dx <= 1; ++dx)
            if (auto it = cells_.find(key(cx+dx, cy+dy)); it != cells_.end())
                for (auto id : it->second) f(id);
    }
};

한 단계 더 — 멀티스레드 게임 로직의 현실 🧬 전문가

실제로 병렬화가 이득인 곳과 아닌 곳

작업병렬화이유
방 여러 개의 틱매우 좋음완전히 독립 — 그냥 나눠 주면 됩니다
충돌 검사(읽기 전용 단계)좋음같은 데이터를 읽기만 하면 안전
스냅샷 직렬화·압축좋음플레이어별로 독립. 틱 시간의 큰 부분
경로 탐색(A*)좋음요청 단위로 잡 큐에 던지면 됨
한 방 안의 이동·전투 판정대체로 손해서로 참조가 많아 락 비용 > 이득
인벤토리·거래하지 말 것정합성이 생명 — 단일 스레드가 안전
한 방 안을 병렬화하려는 시도는 대개 실패합니다 — 이동 → 충돌 → 전투 → 사망 처리는 순서에 의미가 있고 서로를 참조합니다. 이걸 락으로 감싸면 락 경합이 원래 계산보다 비싸집니다. 방 하나를 더 빠르게 만드는 정답은 병렬화가 아니라 알고리즘·메모리 배치 개선입니다.
대신 이렇게 씁니다 — 방 틱은 단일 스레드로 두고, 틱 밖으로 뺄 수 있는 것 (스냅샷 압축·경로 탐색·DB 쓰기·로그)만 워커 풀로 던집니다. “한 방 = 한 스레드” 를 지키면 게임 로직 코드에서 동시성을 아예 생각하지 않아도 됩니다.
정리 — 게임 서버 동시성의 실전 답은 액터 — 방마다 큐 하나, 워커 하나입니다. I/O 스레드는 패킷을 큐에 넣기만 하고 게임 로직은 룸 스레드에서만 돕니다. 락이 꼭 필요하면 scoped_lock 과 계층 번호로 순서를 강제하고, 병렬화는 방 단위·스냅샷·경로 탐색처럼 독립적인 곳에만 씁니다.
GAME 07

넷코드 — 예측 · 리컨실리에이션 · 보간 · 지연 보상

서버가 권위를 가지면 “버튼을 누르고 RTT 뒤에 캐릭터가 움직이는” 문제가 생깁니다. 100ms 를 기다린 뒤 움직이는 게임은 아무도 못 합니다. 그래서 네 가지 기법으로 지연을 숨깁니다. 이것이 넷코드이고, 게임 서버의 핵심 지식입니다.

클라 예측 + 서버 리컨실리에이션 — 지연을 숨기는 기본기 ① 입력 #42 즉시 적용 내 화면은 지금 바로 움직입니다 입력을 버퍼에 보관해 둡니다 ② #43 #44 계속 예측 서버 응답을 기다리지 않습니다 전부 버퍼에 쌓아 둡니다 ③ 스냅샷 도착 → 재적용 서버 위치부터 #43 #44 다시 계산 어긋난 만큼만 부드럽게 보정 클라이언트 입력 전송 · RTT/2 스냅샷 + ack 42 서버 입력 #42 검증 후 적용 속도 상한 · 지형 · 쿨다운을 서버가 확인 권위 위치 + ack_input=42 전송 "내가 42번까지 처리했다" 를 반드시 함께 보냅니다
ack_input 하나가 리컨실리에이션의 전부입니다 — 그 번호 이후의 입력만 다시 적용하면 됩니다.

클라이언트 예측 — 클라 쪽 코드

// 서버와 '완전히 같은' 이동 함수를 클라도 갖고 있어야 합니다 ★
// 공용 라이브러리로 빼서 서버·클라가 같은 .cpp 를 씁니다
Vec2 step_move(Vec2 pos, const Input& in, double dt, const Map& map);

struct Prediction {
    std::uint32_t         next_seq = 1;
    std::deque<Input>     pending;      // 아직 서버가 확인 안 한 입력들
    Vec2                  shown;        // 화면에 그리는 위치

    void on_local_input(Input in, const Map& map, double dt) {
        in.seq = next_seq++;
        net::send(in);                  // ① 서버로 보내고
        shown = step_move(shown, in, dt, map);   // ② 기다리지 않고 바로 움직임
        pending.push_back(in);          // ③ 나중에 다시 쓰려고 보관
    }

    void on_snapshot(Vec2 server_pos, std::uint32_t ack, const Map& map, double dt) {
        // ④ 서버가 처리한 것까지는 버립니다
        while (!pending.empty() && pending.front().seq <= ack) pending.pop_front();

        // ⑤ 서버 위치에서 시작해, 남은 입력을 다시 적용 (re-simulation)
        Vec2 p = server_pos;
        for (const auto& in : pending) p = step_move(p, in, dt, map);

        // ⑥ 예측이 맞았으면 p == shown 이라 아무 일도 안 일어납니다
        if (dist(p, shown) > 0.01f) {
            if (dist(p, shown) > 2.0f) shown = p;          // 크게 틀렸으면 즉시 점프
            else shown = lerp(shown, p, 0.25f);            // 작게 틀렸으면 부드럽게
        }
    }
};
서버와 클라의 이동 코드가 조금이라도 다르면 계속 튑니다 — float 연산 순서, 프레임 dt, 난수 시드까지 같아야 합니다. 그래서 실무에서는 이동·충돌 코드를 공용 모듈로 빼고 클라·서버가 같은 소스를 컴파일합니다. UE5 처럼 엔진이 같은 코드를 쓰는 구조가 유리한 이유이기도 합니다.

보간(interpolation) — 남의 캐릭터는 과거를 그립니다

// 스냅샷은 20Hz(50ms 간격)로 옵니다. 화면은 60Hz 입니다
// 받은 즉시 그리면 초당 20번만 움직이는 뚝뚝 끊긴 화면이 됩니다

struct RemoteEntity {
    struct Sample { double t; Vec2 pos; float rot; };
    std::deque<Sample> hist;                 // 최근 1초치 보관
    static constexpr double DELAY = 0.10;    // ★ 100ms 뒤처져서 그립니다

    void on_snapshot(double server_t, Vec2 p, float r) {
        hist.push_back({server_t, p, r});
        while (hist.size() > 2 && hist[1].t < server_t - 1.0) hist.pop_front();
    }

    Vec2 render_pos(double now_t) const {
        const double target = now_t - DELAY;      // 과거 시점을 그립니다
        for (std::size_t i = 1; i < hist.size(); ++i) {
            if (hist[i].t >= target) {
                const auto& a = hist[i-1]; const auto& b = hist[i];
                double u = (target - a.t) / std::max(1e-6, b.t - a.t);
                return lerp(a.pos, b.pos, float(u));   // ★ 두 스냅샷 사이를 채움
            }
        }
        return hist.empty() ? Vec2{} : hist.back().pos;   // 데이터가 끊기면 마지막 값
    }
};
DELAY 는 “스냅샷 간격 × 2” 정도가 안전합니다(20Hz → 100ms). 이보다 짧으면 패킷이 하나만 늦어도 그릴 데이터가 없어 외삽(extrapolation) 으로 넘어가고, 외삽은 벽을 뚫고 미끄러지는 현상을 만듭니다. 내 캐릭터는 예측(미래), 남의 캐릭터는 보간(과거) — 이 비대칭이 넷코드의 핵심입니다.

실무에선 이렇게 — 지연 보상 — 서버가 시간을 되감습니다 🔴 실무

지연 보상 — 히스토리에서 그 순간으로 되감아 판정합니다 서버가 보관하는 틱별 위치 히스토리 (최근 20틱 ≈ 0.6초) tick 94 적 x=12.0 tick 95 적 x=12.6 tick 96 적 x=13.2 tick 97 적 x=13.8 tick 98 적 x=14.4 tick 99 적 x=15.0 tick 100 지금 x=15.6 되감기 4틱 — 클라가 본 시점으로 클라가 보낸 사격 패킷 조준 방향 · 발사 시각 view_tick = 96 (내가 본 화면의 틱) 서버는 이 값을 검증합니다 — 상한 밖이면 잘라 냅니다 서버 판정 히스토리[96] 로 히트박스를 되돌림 그 상태에서 광선 검사 → 명중 "화면에서 맞았으면 맞는다" 가 성립합니다 대가 — 맞은 쪽에서는 "엄폐물 뒤로 숨었는데 맞았다" 가 생깁니다 되감기 상한을 두고(예: 200ms), 핑이 아주 높은 사용자는 보상을 줄이는 것이 실무 절충입니다
지연 보상은 쏜 사람과 맞은 사람 중 누구 편을 들지를 정하는 문제입니다 — 공짜 해법은 없습니다.

서버 쪽 되감기 구현

class HitboxHistory {
    static constexpr std::size_t N = 32;            // 32틱 ≈ 1초(30Hz)
    struct Frame { std::uint32_t tick; std::vector<Aabb> boxes; };
    std::array<Frame, N> ring_{};

public:
    void snapshot(std::uint32_t tick, const World& w) {
        auto& f = ring_[tick % N];
        f.tick = tick; f.boxes.clear();
        for (const auto& e : w.entities) f.boxes.push_back(e.hitbox());
    }

    const Frame* at(std::uint32_t tick) const {
        const auto& f = ring_[tick % N];
        return f.tick == tick ? &f : nullptr;       // 너무 오래됐으면 nullptr
    }
};

void Room::on_fire(PlayerId who, const FireCmd& cmd) {
    auto& p = players_.at(who);

    // ① 클라가 주장하는 시점을 그대로 믿지 않습니다 ★
    const std::uint32_t max_rewind = 6;             // 200ms 상한
    std::uint32_t view = std::clamp(cmd.view_tick,
                                    tick_ > max_rewind ? tick_ - max_rewind : 0u,
                                    tick_);

    // ② 쿨다운·탄약도 서버가 검증
    if (now_ms() - p.last_fire_ms < p.weapon.cooldown_ms) return;
    if (p.ammo == 0) return;
    p.last_fire_ms = now_ms(); --p.ammo;

    // ③ 그 시점의 히트박스로 판정
    const auto* f = hist_.at(view);
    if (!f) return;
    if (auto hit = raycast(p.pos, cmd.dir, f->boxes, p.weapon.range)) {
        apply_damage(hit->entity, p.weapon.damage);
        broadcast(HitEvent{who, hit->entity, hit->point});
    }
}

스냅샷 델타 압축 — 대역폭이 반이 됩니다

// 100명 방에서 전체 스냅샷을 20Hz 로 보내면
//   100엔티티 × 20바이트 × 20Hz × 100명 = 4MB/s. 감당이 안 됩니다

// ① 플레이어마다 "그가 마지막으로 확인한 스냅샷" 을 기억합니다
struct ClientView {
    std::uint32_t                       acked_tick = 0;
    std::unordered_map<EntityId, State> acked_state;   // 그 시점 상태
};

// ② 바뀐 필드만, 바뀐 엔티티만 보냅니다
void build_delta(const World& w, ClientView& cv, Writer& out) {
    out.u32(w.tick); out.u32(cv.acked_tick);        // 무엇 대비 델타인지
    for (const auto& e : w.entities) {
        if (!cv.visible(e)) continue;               // ★ AoI 로 먼저 거릅니다
        const State* prev = cv.find(e.id);
        std::uint8_t mask = 0;
        if (!prev || prev->pos != e.pos) mask |= 1;
        if (!prev || prev->hp  != e.hp ) mask |= 2;
        if (!prev || prev->ani != e.ani) mask |= 4;
        if (mask == 0) continue;                    // 안 바뀌었으면 아예 생략
        out.u32(e.id); out.u8(mask);
        if (mask & 1) out.pos_q(e.pos);             // 양자화 ★
        if (mask & 2) out.u16(e.hp);
        if (mask & 4) out.u8(e.ani);
    }
}

// ③ 양자화 — float 4바이트를 2바이트로. 1cm 단위면 충분합니다
inline std::int16_t quantize(float m) { return std::int16_t(std::lround(m * 100)); }
양자화 + 델타 + AoI 세 가지를 다 적용하면 실측으로 대역폭이 1/8~1/15 로 떨어집니다. 서버 비용의 큰 부분이 아웃바운드 트래픽 이므로, 이 최적화가 곧 돈입니다. 단, 델타는 기준 스냅샷이 유실되면 복구가 필요하므로 몇 초에 한 번 전체 스냅샷(키프레임) 을 섞습니다.

넷코드 네 기법 — 언제 무엇을

기법어디서 돌까무엇을 숨기나부작용
클라 예측클라내 입력의 왕복 지연틀리면 튐(rubber-banding)
리컨실리에이션클라예측 오차 정정재계산 비용, 코드 일치 필요
보간클라낮은 스냅샷 주기남이 100ms 과거로 보임
지연 보상서버쏜 사람의 지연맞은 쪽이 억울함
롤백 넷코드양쪽격투 게임의 프레임 단위 지연구현 난도 최상
격투 게임의 롤백 넷코드는 다릅니다 — 서버가 아니라 양쪽이 상대 입력을 예측하고, 틀리면 몇 프레임을 통째로 되감아 다시 시뮬레이션합니다. 결정론적 시뮬레이션(같은 입력 → 반드시 같은 결과)이 전제라 부동소수점 하나까지 맞춰야 하고, 그래서 대개 고정소수점 연산으로 짭니다. GGPO 계열 라이브러리가 이 영역입니다.
정리 — 내 캐릭터는 예측(미래), 남의 캐릭터는 보간(과거) 로 그립니다. 서버는 ack_input 을 함께 보내 클라가 그 이후 입력만 다시 적용하게 합니다. 사격 판정은 히스토리를 되감아 하되 되감기 상한을 두고, 전송량은 AoI · 델타 · 양자화 세 가지로 줄입니다.
GAME 08

매치메이킹 · 로비 — Redis 로 대기열과 랭킹을

게임 룸이 잘 돌아도 거기까지 들어가는 길이 없으면 서비스가 아닙니다. 대기열 · 파티 · MMR 매칭 · 랭킹 · 세션 공유 — 이 영역은 CPU 가 아니라 상태 공유가 문제이고, 그래서 거의 항상 Redis 가 답이 됩니다.

대기 시간에 따라 허용 MMR 범위를 넓힙니다 내 MMR 1500 대기 0초 — 허용 ±50 · 거의 동급 10초 — ±150 · 이 구간에서 대부분 잡힙니다 30초 — ±400 800 1000 1200 1400 1600 1800 2000 2200 MMR Redis 자료구조 — 무엇을 어디에 담나 대기열 ZSET queue:solo score = MMR 범위 조회가 O(log n) 파티 · 세션 HASH party:{id} 멤버·초대·상태 TTL 로 자동 정리 랭킹 ZSET rank:season7 ZREVRANK 로 내 등수 상위 100 은 O(log n) 매칭 알림 PUB/SUB · Stream 게임 서버 배정 통보 여러 로비 서버에 팬아웃
좁게 시작해 기다린 만큼 넓히는 것이 핵심입니다 — 처음부터 넓히면 실력 차가 커집니다.

대기열 — Redis ZSET 로

# 대기열 등록 : score 를 MMR 로 두면 범위 조회가 그대로 됩니다
ZADD queue:solo 1500 player:8812
HSET wait:player:8812 since 1756100000 party 0

# 후보 찾기 : 내 MMR ±band 안의 사람들
ZRANGEBYSCORE queue:solo 1350 1650 LIMIT 0 20

# 뽑았으면 원자적으로 빼야 합니다 (두 매치에 동시에 들어가면 대참사)
ZREM queue:solo player:8812 player:9910 ...
#   → 반환값이 요청한 인원 수와 같을 때만 매치 성립 ★

# 실무에서는 Lua 스크립트로 '조회 + 제거 + 방 생성' 을 한 번에 원자 실행합니다
EVAL "..." 1 queue:solo 1350 1650 10
중복 매칭이 이 영역 최대의 버그 입니다 — 로비 서버가 여러 대면 두 서버가 같은 사람을 동시에 뽑을 수 있습니다. ZREM 의 반환값(실제로 지운 개수)을 반드시 확인하고, 전원을 다 못 지웠으면 지운 사람들을 되돌려 넣고 매치를 취소하세요.

매처 루프 — C++ 쪽

struct Waiting { PlayerId id; int mmr; std::uint64_t since_ms; int party_size; };

// 대기 시간에 따라 넓어지는 밴드
int band_for(std::uint64_t waited_ms) {
    if (waited_ms <  5'000) return 50;
    if (waited_ms < 15'000) return 150;
    if (waited_ms < 30'000) return 250;
    return std::min(400 + int((waited_ms - 30'000) / 1000) * 20, 900);
}

void Matcher::tick() {                     // 0.5초마다 돕니다
    auto now = now_ms();
    auto pool = redis_.zrange_all("queue:solo");     // MMR 순으로 정렬돼 옵니다

    // 오래 기다린 사람부터 처리해야 공평합니다 ★
    std::ranges::sort(pool, {}, &Waiting::since_ms);

    for (auto& anchor : pool) {
        if (taken_.contains(anchor.id)) continue;
        const int band = band_for(now - anchor.since_ms);

        std::vector<PlayerId> team;
        for (auto& c : pool) {                        // 정렬돼 있어 근처만 보면 됩니다
            if (taken_.contains(c.id)) continue;
            if (std::abs(c.mmr - anchor.mmr) > band) continue;
            team.push_back(c.id);
            if (team.size() == TEAM_TOTAL) break;
        }
        if (team.size() < TEAM_TOTAL) continue;

        if (!redis_.atomic_take("queue:solo", team)) continue;   // ★ 원자적 확보
        auto srv = alloc_.pick_game_server();                    // 가장 한가한 서버
        auto room = srv.create_room(balance_teams(team));
        for (auto id : team) notify_match(id, srv.addr, room.token);
        for (auto id : team) taken_.insert(id);
    }
}

한 단계 더 — MMR 은 어떻게 계산하나 🟡 중급

Elo — 이해하기 쉬운 기본형

// 기대 승률 : 점수 차 400 이면 약 91% 승리 기대
double expected(double ra, double rb) {
    return 1.0 / (1.0 + std::pow(10.0, (rb - ra) / 400.0));
}

// K 값이 클수록 한 판의 영향이 큽니다
int k_factor(int games_played, int rating) {
    if (games_played < 10)  return 60;   // 배치 고사 — 빨리 제자리 찾게
    if (rating > 2200)      return 16;   // 고랭크는 천천히
    return 32;
}

void apply_result(Player& a, Player& b, double score_a /*1 승, 0.5 무, 0 패*/) {
    double ea = expected(a.mmr, b.mmr);
    int k = k_factor(a.games, a.mmr);
    int delta = int(std::lround(k * (score_a - ea)));
    a.mmr += delta;  b.mmr -= delta;      // 제로섬
    ++a.games; ++b.games;
}
팀 게임이라면 Elo 만으로는 부족합니다 — 5대5 에서 “누가 얼마나 기여했나”를 Elo 는 모릅니다. 상용 게임은 TrueSkill(마이크로소프트) 이나 Glicko-2 처럼 불확실도(σ)를 함께 추적하는 모델을 씁니다 — 새 계정은 σ 가 커서 점수가 빠르게 움직입니다.

매칭 품질을 재는 지표

지표목표
중앙 대기 시간< 30초
p95 대기 시간< 120초
팀 MMR 차이 평균< 60
일방적 경기 비율< 20%
매치 취소율(수락 거절)< 5%
재매칭까지 시간< 10초
대기 시간과 매칭 품질은 정확히 반대로 움직입니다 — 한쪽을 좋게 하면 다른 쪽이 나빠집니다. 그래서 매처 튜닝은 둘을 동시에 보는 대시보드 없이는 불가능합니다. 새벽 시간대·저인구 지역은 밴드 확장 속도를 시간대별로 다르게 두는 것이 흔한 처방입니다.

실무에선 이렇게 — 파티 · 재접속 토큰 · 서버 배정 🔴 실무

로비에서 게임 서버까지 — 토큰 흐름

// ① 매치가 잡히면 로비가 '입장 토큰' 을 만들어 Redis 에 저장하고 클라에 줍니다
struct JoinTicket {
    std::string   room_id;
    std::string   server_addr;     // "10.0.3.17:7801"
    PlayerId      player;
    std::uint64_t expire_ms;       // 30초 뒤 만료
    std::array<std::byte,32> hmac; // 서버 공유키로 서명 ★
};

// Redis 에도 넣습니다 — 게임 서버가 이걸로 검증합니다
// SET ticket:{uuid} '{"room":"r91","player":8812}' PX 30000 NX

// ② 클라는 게임 서버에 직접 접속해 토큰을 냅니다
void GameServer::on_join(Session& s, const JoinReq& req) {
    auto j = redis_.getdel("ticket:" + req.ticket);   // ★ GETDEL — 1회용
    if (!j) { s.close("invalid ticket"); return; }    // 재사용·위조 차단

    auto info = parse(*j);
    if (info.room != /* 이 서버가 가진 방 */ ) { s.close("wrong server"); return; }
    rooms_.at(info.room)->post([&, pid = info.player] { room->join(pid, s.shared()); });
}

// ③ 재접속 : 방이 살아 있는 동안은 같은 플레이어로 다시 붙습니다
//    SET reconnect:{player} '{"room":"r91","server":"10.0.3.17:7801"}' PX 300000
//    클라는 접속이 끊기면 먼저 이 키를 조회해 원래 방으로 복귀를 시도합니다
GETDEL 이 핵심입니다 — 토큰을 읽는 동시에 지워야 같은 토큰으로 두 번 들어오는 것을 막습니다. Redis 6.2+ 에서 지원하고, 그 전 버전이면 Lua 로 GET+DEL 을 원자 실행합니다.
게임 서버 주소를 클라에 그대로 주는 것은 DDoS 표면이 됩니다. 규모가 커지면 릴레이/프록시 계층(예: Steam Datagram Relay, 자체 UDP 프록시)을 두어 실제 서버 IP 를 숨깁니다. 초기에는 방화벽 + 토큰 검증 만으로 시작해도 충분합니다.
정리 — 대기열은 Redis ZSET(score = MMR), 파티·세션은 HASH, 랭킹도 ZSET 입니다. 밴드는 좁게 시작해 대기 시간만큼 넓힙니다. ZREM 반환값 확인으로 중복 매칭을 막고, 로비 → 게임 서버 이동은 1회용 토큰(GETDEL) 으로 넘깁니다.
GAME 09

DB 와 영속화 — 틱을 멈추지 않고 저장하기

게임 서버에서 DB 쿼리를 틱 안에서 동기로 부르는 순간 끝입니다. 5ms 짜리 쿼리 하나가 방 전체를 5ms 멈춥니다. 그렇다고 아무렇게나 미루면 아이템 복사 버그가 납니다. 이 둘 사이의 균형이 이 절의 주제입니다.

지연 쓰기(write-behind) — 틱을 멈추지 않는 기본 경로 1 메모리 변경 인벤토리 갱신 · 즉시 응답 2 dirty 비트 세움 저장할 것만 표시 3 더티 큐 3초마다 수집 DB 워커 배치 UPSERT 대가 — 서버가 죽으면 마지막 저장 이후 몇 초가 사라집니다. 무엇을 잃어도 되는지 미리 정해 두세요 두 경로를 반드시 나눠야 합니다 지연 쓰기로 충분한 것 위치 · 방향 · 현재 맵 경험치 · 플레이 시간 · 통계 설정 · 단축키 · 퀘스트 진행도 잃어도 "조금 되돌아간" 정도로 끝납니다 → 초당 수만 건도 감당됩니다 즉시 트랜잭션이어야 하는 것 현금 결제 · 유료 재화 플레이어 간 거래 · 우편 경매장 · 길드 창고 어긋나면 아이템 복사가 됩니다 → 커밋 전에는 결과를 확정하지 않습니다
모든 것을 즉시 저장하면 서버가 느려지고, 모두 미루면 복사 버그가 납니다 — 데이터마다 다르게 다룹니다.

더티 플래그와 배치 저장

enum Dirty : std::uint32_t {
    None = 0, Pos = 1u<<0, Stats = 1u<<1, Inventory = 1u<<2, Quest = 1u<<3,
};

struct PlayerData {
    PlayerId      id;
    Vec2          pos;
    std::uint64_t exp;
    std::vector<ItemSlot> inv;
    std::uint32_t dirty = Dirty::None;
    std::uint64_t last_saved_ms = 0;
};

// 룸 스레드 — 아주 싸게 표시만 합니다
void Room::gain_exp(PlayerData& p, std::uint64_t amount) {
    p.exp += amount;
    p.dirty |= Dirty::Stats;          // ★ DB 는 건드리지 않습니다
}

// 3초마다 도는 수집 — 룸 스레드에서 스냅샷을 떠서 워커에 넘깁니다
void Room::flush_dirty(std::uint64_t now_ms) {
    if (now_ms - last_flush_ms_ < 3'000) return;
    last_flush_ms_ = now_ms;

    std::vector<SaveJob> batch;
    for (auto& [id, p] : players_) {
        if (p.dirty == Dirty::None) continue;
        batch.push_back(SaveJob{ .id = id, .mask = p.dirty,
                                 .snap = copy_of(p) });   // ★ 값 복사로 넘김
        p.dirty = Dirty::None;                            // 즉시 초기화
    }
    if (!batch.empty()) db_pool_.post(std::move(batch));  // 다른 스레드로
}
포인터를 넘기면 안 됩니다 — DB 워커가 저장하는 동안 룸 스레드가 같은 객체를 고칩니다. 값을 복사해서 넘기고, 그 사이 새로 생긴 변경은 다음 주기에 다시 dirty 로 잡히게 두세요. 복사 비용이 아깝다면 변경분만 담은 작은 구조체를 만들어 넘깁니다.

DB 워커 풀 — 커넥션은 스레드마다 하나

// MySQL/PostgreSQL C 클라이언트는 기본이 '블로킹' 입니다.
// 그래서 게임 서버는 대개 전용 스레드 풀로 감쌉니다 (진짜 비동기 API 는 까다롭습니다)

class DbPool {
    struct Worker { std::jthread th; MYSQL* conn; };
    std::vector<Worker>                 workers_;
    moodycamel::BlockingConcurrentQueue<std::vector<SaveJob>> q_;

public:
    explicit DbPool(int n, const DbCfg& cfg) {
        for (int i = 0; i < n; ++i) {
            auto* c = mysql_init(nullptr);
            mysql_real_connect(c, cfg.host, cfg.user, cfg.pw, cfg.db, cfg.port, nullptr, 0);
            workers_.push_back({std::jthread{[this, c](std::stop_token st){ loop(c, st); }}, c});
        }
    }
    void post(std::vector<SaveJob> batch) { q_.enqueue(std::move(batch)); }

private:
    void loop(MYSQL* c, std::stop_token st) {
        std::vector<SaveJob> batch;
        while (!st.stop_requested()) {
            if (!q_.wait_dequeue_timed(batch, std::chrono::milliseconds(200))) continue;
            // ★ 한 트랜잭션에 묶어야 왕복이 1회로 끝납니다
            mysql_query(c, "START TRANSACTION");
            for (const auto& j : batch) write_one(c, j);
            mysql_query(c, "COMMIT");
        }
    }
};
배치가 성능의 전부입니다 — 개별 UPDATE 를 1000번 보내면 왕복 1000번이지만, 한 트랜잭션에 묶으면 왕복이 사실상 1번입니다. 실측으로 10~50배 차이가 납니다. INSERT ... ON DUPLICATE KEY UPDATE(MySQL) 또는 INSERT ... ON CONFLICT DO UPDATE(PostgreSQL) 로 여러 행을 한 문장에 넣으면 더 좋습니다.

실무에선 이렇게 — 아이템 복사 버그를 막는 법 🔴 실무

✗ 복사 버그가 나는 거래
void trade(Player& a, Player& b, ItemId item) {
    a.inv.remove(item);        // ① 메모리에서 빼고
    b.inv.add(item);           // ② 메모리에 넣고
    a.dirty |= Inventory;      // ③ 나중에 저장
    b.dirty |= Inventory;
}
// ①과 ③ 사이에 서버가 죽으면?
//   → 둘 다 저장 안 됨 : a 가 그대로 갖고 있음 (그나마 다행)
// a 만 저장되고 b 는 다른 서버에 있어 저장이 늦었다면?
//   → 아이템이 사라집니다
// 반대로 b 만 저장되면?
//   → ★ 아이템이 두 개가 됩니다 (복사)
✓ 원장(ledger) 기반 거래
-- 거래는 '기록' 이 먼저, 반영은 그 다음입니다
START TRANSACTION;

-- ① 멱등 키로 중복 실행 차단 (재시도해도 한 번만)
INSERT INTO trade_log(trade_id, from_id, to_id, item_uid, state)
VALUES ('t-9f31', 8812, 9910, 55231, 'PENDING');
-- trade_id 가 PK 이므로 두 번째 시도는 여기서 실패합니다

-- ② 소유권을 '한 줄' 로 옮깁니다 — 조건부 UPDATE ★
UPDATE item SET owner_id = 9910
 WHERE uid = 55231 AND owner_id = 8812;
-- 영향 행이 0 이면 이미 남에게 갔다는 뜻 → ROLLBACK

UPDATE trade_log SET state='DONE' WHERE trade_id='t-9f31';
COMMIT;

복사 버그를 막는 다섯 가지 규칙

규칙이유
아이템에 고유 uid“몇 개”가 아니라 “그 물건”을 옮깁니다
소유자 컬럼 하나소유는 항상 한 곳에만 기록
조건부 UPDATEWHERE owner_id = 이전주인 이 락 역할
멱등 키재시도·중복 패킷에도 한 번만
거래는 지연 쓰기 금지커밋 전에는 성공을 알리지 않습니다
“서버 두 대가 같은 캐릭터를 동시에 들고 있는 상황” 이 복사 버그의 진짜 원인입니다. 접속 중복(다중 로그인)을 Redis 의 SET NX 잠금으로 막고, 캐릭터를 소유한 서버가 항상 하나 임을 보장하세요. 이것이 무너지면 어떤 SQL 로도 못 막습니다.

캐릭터 소유권 잠금

// 로그인할 때 — 이 캐릭터를 이 서버가 소유한다고 선언
bool acquire_char_lock(Redis& r, PlayerId pid, std::string_view server_id) {
    // NX = 없을 때만 성공, PX = 60초 뒤 자동 해제(서버가 죽어도 풀립니다)
    return r.set("lock:char:" + std::to_string(pid), server_id,
                 Redis::NX, std::chrono::seconds(60));
}

// 살아 있는 동안 20초마다 갱신 (lease 연장)
void renew_char_lock(Redis& r, PlayerId pid, std::string_view server_id) {
    // ★ 내 것일 때만 연장 — Lua 로 원자 실행
    static constexpr char LUA[] =
      "if redis.call('GET', KEYS[1]) == ARGV[1] then "
      "  return redis.call('PEXPIRE', KEYS[1], ARGV[2]) else return 0 end";
    r.eval(LUA, {"lock:char:" + std::to_string(pid)}, {server_id, "60000"});
}

// 로그아웃 — 저장이 '끝난 뒤' 에 풉니다 ★ 순서가 중요합니다
void release_char(Redis& r, PlayerId pid, std::string_view server_id) {
    save_now_and_wait(pid);                       // 동기 저장
    r.eval(DEL_IF_MINE, {"lock:char:" + std::to_string(pid)}, {server_id});
}
TTL 이 있는 잠금이어야 합니다 — 서버가 크래시하면 잠금을 풀 사람이 없습니다. TTL 이 없으면 그 캐릭터는 영원히 로그인 불가 상태가 되고, 실제로 운영 중 자주 겪는 사고입니다. 60초 TTL + 20초 갱신 정도가 안전한 기본값입니다.
정리 — 틱 안에서 DB 를 부르지 않습니다. 더티 플래그 + 배치 + 별도 워커 풀이 기본 경로입니다. 돈·아이템·거래만 즉시 트랜잭션으로 처리하고, 고유 uid + 조건부 UPDATE + 멱등 키로 복사를 막습니다. 그리고 무엇보다 캐릭터의 소유 서버가 항상 하나 임을 Redis 잠금으로 보장합니다.
GAME 10

언리얼 엔진 전용 서버 — 직접 안 짜도 되는 경우

UE5 로 게임을 만든다면 서버를 처음부터 짤 이유가 거의 없습니다. 엔진 안에 이미 리플리케이션 · RPC · 이동 예측 · 지연 보상이 들어 있고, 같은 코드가 서버와 클라에서 함께 돕니다. 대신 그 구조를 알아야 쓸 수 있습니다.

UE 리플리케이션 — 서버가 값을 밀고, RPC 로 요청과 통보를 주고받습니다 서버 액터 (권위) HasAuthority() == true NetDriver · 액터 채널 관련성·빈도·우선순위 판단 클라 복제본 OnRep_ 함수가 불림 RPC 세 종류 — 방향이 곧 이름입니다 Server RPC 클라 → 서버 (요청) "이 방향으로 이동할게요" WithValidation 으로 검증 필수 Client RPC 서버 → 소유 클라 1명 "당신만 보는 결과 알림" PlayerController 에서 주로 NetMulticast 서버 → 관련된 전원 "폭발 이펙트를 재생" Unreliable 로 두는 게 보통 어떤 클래스가 어디에 존재하나 GameMode 서버에만 존재합니다 규칙·승패·스폰을 여기서 GameState · PlayerState 전원에게 복제됩니다 점수·남은 시간을 여기에 PlayerController 서버 + 그 소유 클라에만 입력·UI 명령의 통로
UE 를 쓴다면 “서버를 만든다”가 아니라 “어떤 값을 복제하고 어떤 RPC 를 열지”를 설계하는 일이 됩니다.

전용 서버 타깃 만들기

// Source/MyGameServer.Target.cs  ★ 파일 이름이 규칙입니다
using UnrealBuildTool;

public class MyGameServerTarget : TargetRules
{
    public MyGameServerTarget(TargetInfo Target) : base(Target)
    {
        Type = TargetType.Server;                  // ★ 이 한 줄이 전용 서버
        DefaultBuildSettings = BuildSettingsVersion.Latest;
        IncludeOrderVersion  = EngineIncludeOrderVersion.Latest;
        ExtraModuleNames.Add("MyGame");

        // 서버에는 렌더링·사운드가 통째로 빠집니다 → 바이너리와 메모리가 훨씬 작아집니다
        bUsesSteam = false;
    }
}
# 전용 서버는 '소스 빌드된 엔진' 이 필요합니다 (에픽 런처 버전으로는 불가)
# ① GitHub 에서 UnrealEngine 클론 → Setup.sh / GenerateProjectFiles

# ② 서버 타깃 빌드 (리눅스)
./Engine/Build/BatchFiles/Linux/Build.sh MyGameServer Linux Development \
    -project="/path/MyGame.uproject"

# ③ 쿠킹 + 패키징 (배포용)
./Engine/Build/BatchFiles/RunUAT.sh BuildCookRun \
    -project="/path/MyGame.uproject" -noP4 -platform=Linux \
    -clientconfig=Development -serverconfig=Development \
    -cook -server -noclient -build -stage -pak -archive \
    -archivedirectory=/out

# ④ 실행
./MyGameServer.sh /Game/Maps/Arena -log -port=7777 -NOSTEAM
-noclient 와 -server 가 핵심 플래그입니다. 전용 서버 빌드는 텍스처·사운드·머티리얼이 빠져 패키지가 클라이언트의 1/5 이하가 됩니다. 도커 이미지로 만들 때 이 차이가 곧 배포 속도와 오토스케일 반응 시간이 됩니다.

리플리케이션 — 실제 코드

// MyCharacter.h
UCLASS()
class AMyCharacter : public ACharacter {
    GENERATED_BODY()
public:
    AMyCharacter() { bReplicates = true; SetReplicateMovement(true); }

    // ★ 복제할 속성은 UPROPERTY 에 표시하고 아래 함수에도 등록해야 합니다
    UPROPERTY(ReplicatedUsing = OnRep_Health)
    float Health = 100.f;

    UPROPERTY(Replicated)
    int32 Ammo = 30;

    UFUNCTION() void OnRep_Health();   // 클라에서 값이 바뀐 뒤 불립니다

    // 클라 → 서버 요청. 반드시 검증합니다
    UFUNCTION(Server, Reliable, WithValidation)
    void ServerFire(FVector_NetQuantize Dir);

    // 서버 → 전원 통보 (이펙트처럼 놓쳐도 되는 것)
    UFUNCTION(NetMulticast, Unreliable)
    void MulticastPlayHitFx(FVector_NetQuantize Loc);

    virtual void GetLifetimeReplicatedProps(
        TArray<FLifetimeProperty>& Out) const override;
};

// MyCharacter.cpp
void AMyCharacter::GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& Out) const {
    Super::GetLifetimeReplicatedProps(Out);
    DOREPLIFETIME(AMyCharacter, Health);
    DOREPLIFETIME_CONDITION(AMyCharacter, Ammo, COND_OwnerOnly);  // 나만 알면 됨 ★
}

bool AMyCharacter::ServerFire_Validate(FVector_NetQuantize Dir) {
    return Dir.IsNormalized();          // false 면 연결이 끊깁니다
}
void AMyCharacter::ServerFire_Implementation(FVector_NetQuantize Dir) {
    if (!HasAuthority()) return;        // 서버에서만 도는 코드 ★
    if (Ammo <= 0) return;              // 검증은 언제나 서버에서
    --Ammo;
    if (FHitResult Hit; TraceShot(Dir, Hit)) {
        ApplyDamage(Hit.GetActor(), 25.f);
        MulticastPlayHitFx(Hit.ImpactPoint);
    }
}

한 단계 더 — 서버 부하를 줄이는 UE 설정 🟡 중급

관련성 · 빈도 · 푸시 모델

AMyActor::AMyActor() {
    bReplicates            = true;
    NetUpdateFrequency     = 20.f;      // 초당 몇 번 검사할지 (기본 100 은 과합니다)
    MinNetUpdateFrequency  = 2.f;       // 변화가 없으면 여기까지 내려갑니다
    NetCullDistanceSquared = 150000.f * 150000.f;   // 이 거리 밖은 아예 안 보냅니다
    NetPriority            = 1.0f;      // 대역폭이 모자랄 때의 우선순위
    bAlwaysRelevant        = false;     // ★ true 는 정말 필요할 때만
}

// 푸시 모델 — "바뀌었을 때만 검사" (UE5, WITH_PUSH_MODEL)
// 기본 리플리케이션은 매 틱 모든 복제 속성을 이전 값과 '비교' 합니다.
// 액터가 수천 개면 이 비교 자체가 CPU 를 먹습니다
void AMyActor::SetHealth(float V) {
    Health = V;
    MARK_PROPERTY_DIRTY_FROM_NAME(AMyActor, Health, this);   // 바뀔 때만 표시
}
Iris 리플리케이션 시스템 — UE5.1 에서 실험적으로 들어온 차세대 복제 시스템으로, 기존 방식보다 대규모 액터 수에서 훨씬 잘 확장 됩니다(포트나이트 대규모 모드에서 검증). 2026년 기준으로는 대규모 프로젝트에서 실제로 검토할 만한 단계이며, 기존 프로젝트는 푸시 모델 + 관련성 튜닝 만으로도 큰 효과를 봅니다.

언제 UE 서버, 언제 직접 짜나

상황선택
UE 로 만드는 슈터·액션 대전UE 전용 서버
물리·애니가 곧 판정UE 전용 서버
방당 인원 ≤ 64, 세션 길이 짧음UE 전용 서버
동접 수천이 한 세계에자체 C++ + UE 클라
인스턴스당 비용이 관건자체 C++
로비·상점·랭킹항상 별도 백엔드
UE 전용 서버의 실제 비용 — 인스턴스 하나가 수백 MB 메모리와 코어 하나를 쓰는 일이 흔합니다. 방 하나당 프로세스 하나이므로, 동시 1000 방이면 서버 비용이 그대로 1000배입니다. 그래서 MMO 급 규모에서는 UE 클라 + 자체 C++ 서버 조합이 여전히 많습니다. 로비·랭킹·결제는 어떤 경우에도 UE 밖의 백엔드로 두세요.
정리 — UE5 전용 서버는 Type = TargetType.Server 한 줄에서 시작합니다. GameMode 는 서버에만 있고, 값은 DOREPLIFETIME 으로 복제, 요청은 Server RPC + WithValidation 입니다. 부하는 NetUpdateFrequency · NetCullDistance · 푸시 모델로 줄이고, 로비·랭킹·결제는 UE 밖의 백엔드에 둡니다.
GAME 11

보안과 치트 방지 — 클라이언트는 적의 컴퓨터입니다

게임 클라이언트는 공격자의 손 안에서 도는 코드입니다. 디버거를 붙이고, 메모리를 고치고, 패킷을 뜯어보고, 아예 클라를 새로 만들어 붙일 수도 있습니다. 그래서 방어의 원칙은 하나입니다 — 서버에서 검증되지 않은 것은 존재하지 않는다.

클라 패킷이 통과해야 하는 여섯 층 — 아래로 갈수록 게임 지식이 필요합니다 ① 전송 암호화 — TLS / DTLS 도청·중간자 변조 차단 · 라이브러리가 해 줍니다 ② 세션 · 토큰 검증 인증 전 패킷은 전부 거절 · 1회용 입장 토큰 ③ 속도 제한 (rate limit) 메시지 종류별 초당 상한 · 넘으면 경고 후 강제 종료 ④ 스키마 · 길이 검증 FlatBuffers Verifier · 배열 크기 상한 · 문자열 길이 ⑤ 게임 규칙 검증 ★ 핵심 속도·쿨다운·사거리·소유권을 서버가 계산 ⑥ 통계 이상 탐지 사람이 못 내는 정확도·반응 속도
①~④ 는 라이브러리가 해 주지만 ⑤ 는 게임을 아는 사람이 직접 짜야 합니다 — 여기가 실제 방어선입니다.

게임 규칙 검증 — 실제로 이렇게 생겼습니다

struct Limits {
    float  max_speed      = 6.0f;    // m/s — 이동 스킬 포함 상한
    float  max_accel      = 30.0f;
    float  interact_range = 3.0f;    // 상자·NPC 상호작용 거리
    int    max_inputs_per_sec = 90;  // 60틱 게임에서 90 이면 넉넉합니다
};

bool Room::validate_move(Player& p, const Input& in, double dt) {
    // ① 입력 폭탄 — 초당 개수 제한
    if (++p.input_count_this_sec > limits_.max_inputs_per_sec) {
        flag(p, Cheat::InputFlood); return false;
    }
    // ② dt 조작 — 클라가 보낸 dt 를 믿지 않습니다 ★
    dt = std::clamp(dt, 0.0, 1.0 / 20.0);

    // ③ 속도 상한 — 텔레포트·스피드핵의 1차 방어선
    Vec2 want = step_move(p.pos, in, dt, map_);
    float moved = dist(want, p.pos);
    if (moved > limits_.max_speed * dt * 1.15f) {   // 15% 여유(부동소수·지연)
        // 즉시 밴이 아니라 '보정' 이 정답입니다 ★
        send_correction(p);                          // 서버 위치를 강제로 알림
        flag(p, Cheat::SpeedSuspect);                // 누적 카운트만 올림
        return false;
    }
    // ④ 지형 통과 — 벽 뚫기
    if (!map_.is_walkable(want) || map_.crosses_wall(p.pos, want)) {
        send_correction(p); return false;
    }
    p.pos = want;
    return true;
}

// 상호작용은 '거리 + 소유권 + 상태' 세 가지를 항상 봅니다
bool Room::validate_loot(Player& p, EntityId chest_id) {
    auto* c = world_.find_chest(chest_id);
    if (!c)                                   return false;   // 존재하나
    if (dist(p.pos, c->pos) > limits_.interact_range) return false;   // 가까운가
    if (c->opened)                            return false;   // 이미 열렸나
    if (c->owner && c->owner != p.id)         return false;   // 내 것인가
    return true;
}
“한 번 이상하면 밴”은 반드시 오탐을 만듭니다 — 네트워크 지연, 프레임 튐, 부동소수 차이로 정상 유저도 걸립니다. 즉시 대응은 보정(correction) 으로 하고, 누적 카운트가 임계를 넘을 때 킥·조사 대상으로 올리는 2단 구조가 실무 표준입니다.

속도 제한과 재생 공격 방어

// ── 메시지 종류별 토큰 버킷 ──────────────────────────────
struct TokenBucket {
    double tokens, rate, cap;
    bool take(double now, double n = 1) {
        tokens = std::min(cap, tokens + (now - last) * rate); last = now;
        if (tokens < n) return false;
        tokens -= n; return true;
    }
    double last = 0;
};

// 종류마다 다른 상한 — 채팅은 초당 2회, 입력은 90회
std::array<TokenBucket, MAX_ID> buckets_;

bool Session::allow(std::uint16_t id, double now) {
    if (!buckets_[id].take(now)) {
        if (++violations_ > 20) { close("rate limit"); }
        return false;
    }
    return true;
}

// ── 재생 공격(replay) 방어 ──────────────────────────────
// 공격자가 예전에 캡처한 '스킬 사용' 패킷을 그대로 다시 보내는 공격입니다
bool Session::check_nonce(std::uint32_t seq) {
    if (seq <= last_seq_ - 64) return false;         // 너무 오래된 것
    if (seq <= last_seq_ && (seen_bits_ >> (last_seq_ - seq)) & 1)
        return false;                                 // ★ 이미 본 번호
    if (seq > last_seq_) { seen_bits_ <<= (seq - last_seq_); last_seq_ = seq; }
    seen_bits_ |= 1ull << (last_seq_ - seq);
    return true;
}
DTLS 나 GameNetworkingSockets 를 쓰면 재생 방어가 기본 포함 입니다. 직접 UDP 를 다룬다면 일련번호 + 슬라이딩 윈도우 비트맵 을 반드시 넣으세요. TCP 는 전송 계층이 순서를 보장하므로 앱 레벨 재생 공격 위험이 훨씬 낮습니다.

한 단계 더 — 암호화는 어디에 거나 🟡 중급

경로별 선택

경로선택이유
로그인·결제·상점HTTPS(TLS 1.3)토큰과 개인정보가 오갑니다. 협상 불가
로비 TCPTLS부담이 크지 않고 표준 라이브러리로 끝납니다
게임 룸 UDPDTLS 또는 라이브러리 내장GNS·yojimbo 는 AES-GCM 이 이미 들어 있습니다
서버 ↔ 서버사설망 + mTLS내부라고 평문으로 두면 침해 시 전면 노출
Redis · DB사설망 + 암호포트를 공개망에 여는 사고가 실제로 잦습니다
패킷 자체 난독화보조 수단시간을 벌 뿐 — 검증을 대체하지 못합니다
“패킷을 암호화했으니 안전하다”는 착각이 가장 위험합니다 — 클라이언트가 암호화 키를 갖고 있으므로 공격자도 그 키를 갖고 있습니다. 암호화가 막는 것은 제3자의 도청과 변조이지 사용자 본인의 조작이 아닙니다. 조작을 막는 것은 오직 서버 검증 뿐입니다.

실무에선 이렇게 — 봇 탐지와 로그 감사 🔴 실무

통계로 잡는 것들

// 사람은 이런 값을 만들 수 없습니다 — 통계로 걸러 조사 큐에 올립니다
struct BehaviorProfile {
    RollingStats aim_snap_deg;    // 조준 각속도(스냅 에임)
    RollingStats hit_ratio;       // 명중률
    RollingStats reaction_ms;     // 적 등장 → 발사까지
    RollingStats input_interval;  // 입력 간격의 '분산' ★ 매크로는 너무 일정합니다
    int          wallbang_kills;  // 시야에 없던 적을 맞힌 횟수

    int suspicion() const {
        int s = 0;
        if (hit_ratio.mean()      > 0.85 && hit_ratio.n() > 200) s += 30;
        if (reaction_ms.mean()    < 90)                          s += 25;
        if (aim_snap_deg.p99()    > 120)                         s += 25;
        if (input_interval.stdev() < 1.5)                        s += 20; // 매크로
        if (wallbang_kills        > 5)                           s += 40; // 월핵
        return s;
    }
};

// 서버는 판단하지 않습니다 — '증거를 남기고' 사람이 봅니다
void Room::on_kill(Player& killer, Player& victim, const HitInfo& h) {
    profiles_[killer.id].update(h);
    if (profiles_[killer.id].suspicion() >= 70)
        audit_.emit(AuditEvent{                    // ★ 리플레이용 원본 로그
            .type = "SUSPECT", .player = killer.id, .match = match_id_,
            .tick = tick_, .detail = h.to_json()});
}

감사 로그 — 무엇을 남기나

항목보관
재화 증감 전부영구(원장)
아이템 생성·소멸·이동영구
거래·우편·경매영구
로그인 IP·기기90일
매치 입력 리플레이7~30일
의심 이벤트1년
입력 리플레이가 가장 강력한 증거 입니다 — 매치의 모든 입력과 난수 시드를 남겨 두면 경기를 통째로 재생할 수 있습니다. 신고가 들어왔을 때 사람이 직접 보고 판단할 수 있고, 버그 재현에도 그대로 쓰입니다. 용량은 매치당 수백 KB 수준이라 충분히 감당됩니다.
클라이언트 안티치트(EAC · BattlEye 등) 는 보조 입니다. 커널 드라이버로 메모리 조작·후킹을 탐지해 공격 비용을 올리는 역할이지, 서버 검증을 대체하지 않습니다. 국내 게임은 여기에 더해 계정 인증·기기 식별·이상 거래 탐지를 운영 조직과 함께 돌리는 것이 보통입니다.
정리 — 서버에서 검증되지 않은 것은 존재하지 않습니다. 암호화는 제3자를 막을 뿐 사용자 본인의 조작은 못 막습니다. 이동은 속도 상한 + 지형 검사, 상호작용은 거리 + 소유권 + 상태, 의심은 즉시 밴이 아니라 보정 + 누적으로 다루고, 입력 리플레이를 증거로 남깁니다.
GAME 12

부하 테스트와 운영 — 몇 명까지 되는지 숫자로 말하기

“우리 서버는 몇 명까지 버팁니까?” 이 질문에 숫자로 답하지 못하면 출시할 수 없습니다. 답을 만드는 방법은 하나뿐입니다 — 봇으로 진짜 부하를 걸어 보는 것. 그리고 운영 중에는 지표 · 덤프 · 무중단 배포 세 가지가 있어야 살아남습니다.

봇으로 부하를 걸고, 지표로 한계를 찾고, 그 숫자로 오토스케일을 겁니다 봇 클라 5,000 실제와 같은 패킷 여러 대에 나눠 실행 게임 서버 틱 시간 · 큐 길이 1초마다 기록 지표 수집 · 대시보드 Prometheus · Grafana 임계 넘으면 알림 p99 > 예산 절반 → 신규 방 배정 중단 → 인스턴스 증설 반드시 보는 지표 네 개와 위험선 틱 처리 p99 목표: 틱 간격의 절반 30틱이면 16ms 이하 가장 먼저 무너지는 값 인당 대역폭 목표: 20KB/s 이하 넘으면 AoI·델타 점검 비용에 직결됩니다 세션당 메모리 상한을 정하고 감시 증가 추세면 누수 버퍼 누적을 먼저 의심 잡 큐 적체 항상 0 에 가까워야 쌓이기 시작하면 이미 한계를 넘었습니다
한계는 CPU 사용률이 아니라 틱 p99 로 판단합니다 — CPU 50% 여도 p99 가 튀면 이미 렉입니다.

봇 클라이언트 — 진짜 클라와 같은 패킷을

// 봇은 '가짜' 여서는 안 됩니다. 실제 프로토콜을 그대로 써야 의미가 있습니다
class BotClient {
    asio::io_context& io_;
    tcp::socket       sock_;
    asio::steady_timer tick_;
    std::mt19937      rng_;
    std::uint32_t     seq_ = 0;

public:
    void start(std::string_view host, std::uint16_t port) {
        asio::async_connect(sock_, resolve(host, port), [this](auto ec, auto) {
            if (ec) { ++g_stats.connect_fail; return; }
            send_login();
            schedule_input();
        });
    }
private:
    void schedule_input() {
        tick_.expires_after(std::chrono::milliseconds(33));    // 30Hz 입력
        tick_.async_wait([this](auto ec) {
            if (ec) return;
            Input in{.seq = ++seq_,
                     .dx = jitter(), .dy = jitter(),          // 랜덤 워크
                     .buttons = (rng_() % 40 == 0) ? FIRE : 0};
            send_framed(sock_, encode(in));
            ++g_stats.sent;
            schedule_input();
        });
    }
};

int main(int argc, char** argv) {
    const int n = std::atoi(argv[1]);            // 예: 2000
    asio::io_context io;
    std::vector<std::unique_ptr<BotClient>> bots;
    for (int i = 0; i < n; ++i) {
        bots.push_back(std::make_unique<BotClient>(io));
        bots.back()->start("10.0.3.17", 7777);
        // ★ 한꺼번에 접속시키지 말 것 — 실제 트래픽 패턴이 아닙니다
        std::this_thread::sleep_for(std::chrono::milliseconds(5));
    }
    std::vector<std::jthread> th;
    for (unsigned i = 1; i < std::thread::hardware_concurrency(); ++i)
        th.emplace_back([&]{ io.run(); });
    io.run();
}
봇 머신이 먼저 죽는 함정 — 봇 5000개를 한 대에서 돌리면 봇 쪽 CPU 가 병목 이 되어 서버가 멀쩡한데 지표가 나빠 보입니다. 봇 머신의 CPU·대역폭도 함께 재고, 모자라면 여러 대에 나눠 돌리세요. 그리고 접속을 서서히 늘리는 램프업이 실제 패턴에 가깝습니다.

서버 지표 내보내기

// 1초마다 스냅샷을 찍어 텍스트로 노출합니다 (Prometheus 형식)
struct Metrics {
    std::atomic<std::uint64_t> ccu{0}, packets_in{0}, bytes_out{0};
    TickStats                  tick;      // n02 의 히스토그램

    std::string render() const {
        std::string s;
        std::format_to(std::back_inserter(s),
            "game_ccu {}\n"
            "game_tick_p99_ms {:.2f}\n"
            "game_tick_overrun_ratio {:.4f}\n"
            "game_bytes_out_per_ccu {:.0f}\n"
            "game_jobqueue_depth {}\n",
            ccu.load(), tick.p99_estimate(), tick.overrun_ratio(),
            ccu ? double(bytes_out) / ccu : 0.0, job_depth());
        return s;
    }
};

// 아주 작은 HTTP 엔드포인트 하나면 충분합니다 — /metrics
// 게임 포트와 분리된 관리 포트(예: 9100)에 열고 사설망에서만 접근시킵니다 ★
지표는 “출시 전에 붙이는 것”이 아니라 “처음부터 있는 것” 입니다. 틱 히스토그램과 큐 깊이만 있어도 성능 회귀를 커밋 단위로 잡을 수 있습니다. CI 에서 봇 200개로 3분 돌리고 p99 가 이전보다 20% 이상 나빠지면 실패시키는 팀도 많습니다.

실무에선 이렇게 — 크래시 덤프 · 무중단 배포 · 오토스케일 🔴 실무

크래시가 나면 반드시 증거가 남아야 합니다

// ── Windows : 미니덤프 ────────────────────────────────
LONG WINAPI on_crash(EXCEPTION_POINTERS* ep) {
    HANDLE f = CreateFileW(dump_path().c_str(), GENERIC_WRITE, 0, nullptr,
                           CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr);
    MINIDUMP_EXCEPTION_INFORMATION mei{GetCurrentThreadId(), ep, FALSE};
    MiniDumpWriteDump(GetCurrentProcess(), GetCurrentProcessId(), f,
        MiniDumpWithIndirectlyReferencedMemory, &mei, nullptr, nullptr);
    CloseHandle(f);
    return EXCEPTION_EXECUTE_HANDLER;
}
// main 초기에 : SetUnhandledExceptionFilter(on_crash);
# ── Linux : 코어 덤프 ─────────────────────────────────
ulimit -c unlimited
sysctl -w kernel.core_pattern=/var/dumps/core.%e.%p.%t

# 디버그 심볼은 분리해서 보관합니다 (바이너리는 작게, 심볼은 서버에)
objcopy --only-keep-debug game_server game_server.debug
objcopy --strip-debug --add-gnu-debuglink=game_server.debug game_server

# 분석
gdb ./game_server /var/dumps/core.game_server.1841.1756100000
(gdb) thread apply all bt      # ★ 교착·크래시 모두 여기서 시작합니다

# 상용 환경에서는 Crashpad·Sentry 로 자동 수집하는 것이 표준입니다
심볼을 잃어버리면 덤프는 쓰레기가 됩니다 — 배포한 모든 빌드의 심볼(pdb/.debug)을 버전별로 보관하세요. “재현이 안 되는데 심볼도 없다”가 게임 서버 운영에서 가장 절망적인 상황입니다.

무중단 배포 — 드레인 방식

// 게임 서버는 '즉시 재시작' 이 불가능합니다. 진행 중인 매치가 있으니까요
enum class ServerState { Ready, Draining, Empty };

void GameServer::begin_drain() {
    state_ = ServerState::Draining;
    alloc_.unregister(self_id_);      // ★ 매처가 더 이상 새 방을 주지 않습니다
    log_info("draining: {} rooms left", rooms_.size());
}

void GameServer::tick() {
    ...
    if (state_ == ServerState::Draining && rooms_.empty()) {
        save_all_and_wait();          // 남은 데이터 저장
        state_ = ServerState::Empty;
        std::exit(0);                 // 오케스트레이터가 새 버전을 띄웁니다
    }
}

// SIGTERM 을 받으면 드레인을 시작합니다 (K8s 가 보내는 신호)
std::signal(SIGTERM, [](int){ g_server->begin_drain(); });
// terminationGracePeriodSeconds 를 매치 최대 길이보다 길게 두세요 ★
# Agones — 게임 서버 전용 K8s 오케스트레이터
apiVersion: "agones.dev/v1"
kind: Fleet
metadata: { name: arena-server }
spec:
  replicas: 20
  scheduling: Packed          # 빈 노드를 만들어 축소가 쉽게
  template:
    spec:
      ports:
        - name: game
          containerPort: 7777
          protocol: UDP
      health: { periodSeconds: 5, failureThreshold: 3 }
      template:
        spec:
          containers:
            - name: server
              image: registry/arena-server:2026.08.1
              resources:
                requests: { cpu: "1", memory: "512Mi" }
                limits:   { cpu: "2", memory: "1Gi" }

설정 핫 리로드 — 재시작 없이 숫자만 바꾸기

// 밸런스 수치(데미지·쿨다운·드랍률)를 코드에 박으면 배포 없이는 못 고칩니다.
// 게임 서버는 '데이터로 뺀 뒤 원자 교체' 가 정석입니다

struct BalanceTable { /* 무기·스킬·드랍 수치 전부 */ };

class Config {
    std::shared_ptr<const BalanceTable> cur_;      // ★ 읽기는 락 없이
public:
    std::shared_ptr<const BalanceTable> get() const {
        return std::atomic_load(&cur_);            // 스냅샷을 잡습니다
    }
    void reload(const std::filesystem::path& p) {
        auto next = std::make_shared<BalanceTable>(parse_json(read_file(p)));
        if (!validate(*next)) { log_error("bad config, keep old"); return; }  // ★
        std::atomic_store(&cur_, std::shared_ptr<const BalanceTable>(next));
        log_info("config reloaded rev={}", next->rev);
    }
};

// 틱 안에서는 한 번만 잡아서 씁니다 — 도중에 바뀌면 같은 틱에서 값이 달라집니다
void Room::tick(double dt) {
    auto cfg = g_config.get();          // ★ 이 틱 동안 고정
    for (auto& p : players_) apply_damage(p, cfg->weapon[p.weapon_id].damage);
}
검증 실패 시 이전 설정을 유지 하는 한 줄이 사고를 막습니다. 운영 중 잘못된 JSON 하나로 전 서버가 죽는 사고는 실제로 흔합니다. 가능하면 스테이징에서 먼저 적용 → 카나리 1대 → 전체 순서를 지키세요.
코드 핫 리로드는 다른 이야기 입니다 — C++ 에서 로직을 무중단 교체하려면 동적 라이브러리(.so/.dll) 분리 + 상태 직렬화 가 필요하고 위험합니다. 국내 실무에서는 로직은 드레인 배포, 수치는 핫 리로드 로 나누는 쪽이 압도적으로 흔합니다. (스크립트 계층을 두어 Lua 로 로직 일부를 리로드하는 회사도 있습니다.)
정리 — 한계는 봇으로 실제 프로토콜 부하를 걸어 찾습니다. 판단 기준은 CPU 가 아니라 틱 p99 · 인당 대역폭 · 큐 적체 입니다. 덤프와 심볼을 반드시 보관하고, 배포는 드레인으로, 수치는 핫 리로드(검증 실패 시 롤백) 로 바꿉니다.
GAME 13

실전 프로젝트 — 2D 탑다운 실시간 대전 서버 한 벌

앞의 열두 절을 하나로 합칩니다. Asio 세션 → 프레이밍 → FlatBuffers → 룸 잡 큐 → 60Hz 틱 → 델타 스냅샷 → Redis 매치 → Docker 배포 까지, 실제로 빌드해서 돌릴 수 있는 형태로 만듭니다. 코드는 짧게 유지하되 실무에서 빠지면 안 되는 것은 전부 넣었습니다.

완성 프로젝트의 데이터 흐름 — 입력은 위로, 결과는 아래로 클라이언트 입력 30Hz 전송 Asio 세션 strand · no_delay 프레이밍 + 검증 len 접두 · Verifier 룸 잡 큐 post() 로 전달 틱 루프 60Hz 검증 · 이동 · 판정 델타 스냅샷 AoI · 양자화 전송 20Hz ack_input 동봉 보간 · 재적용 화면이 부드러워짐 옆에서 돕는 것들 Redis 대기열 ZSET · 입장 티켓 GETDEL 로 1회용 소비 MySQL 전적 · MMR 배치 저장 더티 큐 → 워커 스레드 Docker Compose 서버 · Redis · MySQL 한 번에 docker compose up 으로 끝
이 그림의 모든 상자에 대응하는 파일이 아래 폴더 구조에 하나씩 있습니다.

폴더 구조

arena-server/
├─ CMakeLists.txt
├─ vcpkg.json                 의존성 선언 (매니페스트 모드)
├─ proto/
│   └─ arena.fbs              패킷 스키마 (클라와 공유) ★
├─ shared/                    ★ 서버와 클라가 같이 컴파일하는 코드
│   ├─ sim.h / sim.cpp        step_move · 충돌 — 예측이 맞으려면 반드시 공유
│   └─ types.h                Vec2 · 상수
├─ src/
│   ├─ main.cpp               구동 · 시그널 · 워커 스레드
│   ├─ net/
│   │   ├─ session.h/.cpp     Asio 세션 (strand · 쓰기 큐 · 프레이밍)
│   │   ├─ framing.h          길이 접두사 파서 (링버퍼)
│   │   └─ listener.cpp       accept 루프
│   ├─ room/
│   │   ├─ room.h/.cpp        잡 큐 + 60Hz 틱 + 판정
│   │   ├─ snapshot.cpp       AoI · 델타 · 양자화
│   │   └─ history.h          지연 보상용 히트박스 링
│   ├─ match/
│   │   └─ ticket.cpp         Redis 티켓 검증 (GETDEL)
│   ├─ db/
│   │   └─ dbpool.cpp         더티 큐 → 배치 UPSERT
│   └─ obs/
│       ├─ metrics.cpp        /metrics 엔드포인트
│       └─ crash.cpp          덤프 핸들러
├─ tools/
│   └─ bot/main.cpp           부하 테스트 봇 (n12)
├─ deploy/
│   ├─ Dockerfile
│   └─ docker-compose.yml
└─ tests/
    └─ sim_test.cpp           step_move 결정성 테스트 ★ 가장 중요한 테스트
shared/ 가 이 구조의 핵심 입니다 — 예측·리컨실리에이션이 성립하려면 서버와 클라가 같은 이동 코드를 써야 합니다(n07). 클라가 C# 이라면 이 부분만 수식과 상수를 문서로 고정하고 양쪽에서 테스트로 비교하세요.

빌드 설정

// vcpkg.json — 2026년 기준 안정 버전대
{
  "name": "arena-server",
  "version": "1.0.0",
  "dependencies": [
    "boost-asio",
    "flatbuffers",
    "hiredis",
    "spdlog",
    "libmysql",
    { "name": "gtest", "platform": "!android" }
  ]
}
cmake_minimum_required(VERSION 3.28)
project(arena_server LANGUAGES CXX)

set(CMAKE_CXX_STANDARD 23)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_EXPORT_COMPILE_COMMANDS ON)

find_package(Boost REQUIRED COMPONENTS system)
find_package(flatbuffers CONFIG REQUIRED)
find_package(spdlog CONFIG REQUIRED)

# ── 스키마 → 헤더 자동 생성 ──────────────────────────────
set(FBS ${CMAKE_SOURCE_DIR}/proto/arena.fbs)
set(GEN ${CMAKE_BINARY_DIR}/gen/arena_generated.h)
add_custom_command(OUTPUT ${GEN}
  COMMAND flatbuffers::flatc --cpp --scoped-enums
          -o ${CMAKE_BINARY_DIR}/gen ${FBS}
  DEPENDS ${FBS} VERBATIM)
add_custom_target(gen_proto DEPENDS ${GEN})

add_library(arena_shared STATIC shared/sim.cpp)
target_include_directories(arena_shared PUBLIC shared)

add_executable(arena_server
  src/main.cpp src/net/session.cpp src/net/listener.cpp
  src/room/room.cpp src/room/snapshot.cpp
  src/match/ticket.cpp src/db/dbpool.cpp
  src/obs/metrics.cpp src/obs/crash.cpp ${GEN})
add_dependencies(arena_server gen_proto)
target_include_directories(arena_server PRIVATE src ${CMAKE_BINARY_DIR}/gen)
target_link_libraries(arena_server PRIVATE
  arena_shared Boost::system flatbuffers::flatbuffers spdlog::spdlog)

target_compile_options(arena_server PRIVATE
  $<$<CXX_COMPILER_ID:MSVC>:/W4>
  $<$<NOT:$<CXX_COMPILER_ID:MSVC>>:-Wall -Wextra>)

enable_testing()
add_executable(sim_test tests/sim_test.cpp)
target_link_libraries(sim_test PRIVATE arena_shared GTest::gtest_main)
add_test(NAME sim COMMAND sim_test)

핵심 코드 — 룸과 틱 🟡 중급

room.h — 잡 큐 + 틱 + 판정 + 스냅샷

#pragma once
#include "shared/sim.h"
#include "room/history.h"
#include <chrono>
#include <functional>
#include <memory>
#include <mutex>
#include <unordered_map>

struct PlayerSlot {
    PlayerId                 id{};
    std::weak_ptr<Session>   sess;
    Vec2                     pos{}, vel{};
    std::uint16_t            hp = 100;
    std::uint32_t            last_input_seq = 0;   // ★ ack_input 으로 돌려줄 값
    std::uint64_t            last_packet_ms = 0;
    ClientView               view;                 // 델타 기준 (n07)
    BehaviorProfile          prof;                 // 이상 탐지 (n11)
    std::uint32_t            dirty = 0;            // 저장 대상 (n09)
};

class Room : public std::enable_shared_from_this<Room> {
public:
    static constexpr int SIM_HZ    = 60;
    static constexpr int SEND_EVERY = 3;                     // 20Hz 전송
    static constexpr double DT     = 1.0 / SIM_HZ;

    explicit Room(std::string id, const Map& map) : id_(std::move(id)), map_(map) {}

    // 어느 스레드에서든 호출 가능 — I/O 스레드가 부릅니다
    void post(std::function<void()> job) {
        std::lock_guard lk(qmx_);
        q_.push_back(std::move(job));
    }

    // 이 방을 소유한 워커 스레드에서만 호출
    void tick() {
        drain_jobs();                                        // ① 입력 반영
        for (auto& [id, p] : players_) integrate(p);         // ② 이동
        resolve_combat();                                    // ③ 전투 판정
        hist_.snapshot(tick_, players_);                     // ④ 되감기용 기록
        if (tick_ % SEND_EVERY == 0) broadcast_delta();      // ⑤ 전송
        check_timeouts();                                    // ⑥ 끊김 정리
        flush_dirty();                                       // ⑦ 저장은 비동기로
        ++tick_;
    }

    bool alive() const { return !finished_ && !players_.empty(); }

private:
    void drain_jobs() {
        { std::lock_guard lk(qmx_); swap_.swap(q_); }
        for (auto& j : swap_) j();
        swap_.clear();
    }

    void integrate(PlayerSlot& p) {
        // 입력은 이미 on_input 에서 검증되어 p.vel 에 반영돼 있습니다
        Vec2 want = step_move(p.pos, p.vel, DT, map_);       // ★ shared/ 의 함수
        if (map_.is_walkable(want)) p.pos = want;
        p.dirty |= Dirty::Pos;
    }

public:
    // ── 입력 처리 (잡으로 들어옵니다) ─────────────────────
    void on_input(PlayerId who, const pk::Input& in) {
        auto it = players_.find(who);
        if (it == players_.end()) return;
        auto& p = it->second;
        p.last_packet_ms = now_ms();

        if (in.seq() <= p.last_input_seq) return;            // 재생·중복 무시 ★
        p.last_input_seq = in.seq();

        Vec2 dir{in.dx(), in.dy()};
        if (float l = len(dir); l > 1.0f) dir = dir / l;     // 대각선 가속 방지 ★
        p.vel = dir * MOVE_SPEED;                            // 속도는 서버가 정합니다
    }

    void on_fire(PlayerId who, std::uint32_t view_tick, Vec2 dir) {
        auto& p = players_.at(who);
        if (now_ms() - p.last_fire_ms < FIRE_COOLDOWN_MS) return;   // 쿨다운 ★
        p.last_fire_ms = now_ms();

        const std::uint32_t v = std::clamp(view_tick,
            tick_ > MAX_REWIND ? tick_ - MAX_REWIND : 0u, tick_);   // 되감기 상한
        if (const auto* f = hist_.at(v))
            if (auto hit = raycast(p.pos, normalize(dir), *f, WEAPON_RANGE))
                apply_damage(hit->target, WEAPON_DAMAGE, who);
    }

private:
    std::string                          id_;
    const Map&                           map_;
    std::unordered_map<PlayerId, PlayerSlot> players_;
    HitboxHistory                        hist_;
    std::uint32_t                        tick_ = 0;
    bool                                 finished_ = false;
    std::mutex                           qmx_;
    std::vector<std::function<void()>>   q_, swap_;
};
대각선 정규화를 빼먹는 실수가 굉장히 흔합니다 — dx=1, dy=1 을 그대로 쓰면 대각선 이동이 1.414배 빨라집니다. 클라가 이걸 알아채면 그대로 스피드핵이 됩니다. 서버가 방향을 정규화하고 속도는 서버 상수를 쓰는 것이 정답입니다.

main.cpp — 구동 전체

int main(int argc, char** argv) {
    install_crash_handler();                       // n12
    auto cfg = Config::load("config/server.json");
    spdlog::set_level(spdlog::level::info);

    asio::io_context io;
    RoomManager  rooms(cfg->max_rooms);
    Redis        redis(cfg->redis_url);
    DbPool       db(4, cfg->db);
    Metrics      metrics;

    Listener listener(io, cfg->port, [&](tcp::socket s) {
        std::make_shared<Session>(std::move(s), rooms, redis, metrics)->start();
    });
    listener.run();

    // ── 룸 워커 : 코어 수만큼. 각 워커가 방들을 고정 소유합니다 (n06) ──
    std::vector<std::jthread> room_workers;
    const unsigned nw = std::max(1u, std::thread::hardware_concurrency() / 2);
    for (unsigned i = 0; i < nw; ++i)
        room_workers.emplace_back([&, i](std::stop_token st) {
            auto next = std::chrono::steady_clock::now();
            const auto dt = std::chrono::nanoseconds(1'000'000'000 / Room::SIM_HZ);
            while (!st.stop_requested()) {
                auto t0 = std::chrono::steady_clock::now();
                rooms.tick_shard(i, nw);                   // 이 워커가 맡은 방만
                metrics.tick.record(elapsed_us(t0));
                next += dt;
                sleep_until_precise(next);                 // n02
            }
        });

    // ── I/O 스레드 ──────────────────────────────────────
    std::vector<std::jthread> io_threads;
    for (unsigned i = 1; i < std::thread::hardware_concurrency(); ++i)
        io_threads.emplace_back([&]{ io.run(); });

    // ── 드레인 종료 (n12) ───────────────────────────────
    asio::signal_set sig(io, SIGINT, SIGTERM);
    sig.async_wait([&](auto, int){ rooms.begin_drain(); });

    metrics.serve(io, cfg->metrics_port);          // /metrics
    io.run();
    return 0;
}

배포 — Dockerfile 과 Compose

# deploy/Dockerfile — 멀티 스테이지
FROM ubuntu:24.04 AS build
RUN apt-get update && apt-get install -y --no-install-recommends \
    build-essential cmake ninja-build git curl zip unzip tar pkg-config \
 && rm -rf /var/lib/apt/lists/*
RUN git clone --depth 1 https://github.com/microsoft/vcpkg /opt/vcpkg \
 && /opt/vcpkg/bootstrap-vcpkg.sh
WORKDIR /src
COPY . .
RUN cmake -B build -G Ninja -DCMAKE_BUILD_TYPE=Release \
      -DCMAKE_TOOLCHAIN_FILE=/opt/vcpkg/scripts/buildsystems/vcpkg.cmake \
 && cmake --build build -j

FROM ubuntu:24.04
RUN apt-get update && apt-get install -y --no-install-recommends \
    libstdc++6 ca-certificates && rm -rf /var/lib/apt/lists/*
COPY --from=build /src/build/arena_server /usr/local/bin/
COPY config /etc/arena/config
EXPOSE 7777/tcp 9100/tcp
# ★ PID 1 이 시그널을 받아야 드레인이 동작합니다
ENTRYPOINT ["/usr/local/bin/arena_server", "--config", "/etc/arena/config/server.json"]
# deploy/docker-compose.yml — 로컬에서 전부 한 번에
services:
  redis:
    image: redis:7-alpine
    command: ["redis-server", "--save", "", "--appendonly", "no"]
  mysql:
    image: mysql:8.4
    environment:
      MYSQL_ROOT_PASSWORD: devpw
      MYSQL_DATABASE: arena
    volumes: ["./sql:/docker-entrypoint-initdb.d:ro"]
  server:
    build: { context: .., dockerfile: deploy/Dockerfile }
    depends_on: [redis, mysql]
    ports: ["7777:7777", "9100:9100"]
    environment:
      ARENA_REDIS: "redis://redis:6379"
      ARENA_DB:    "mysql://root:devpw@mysql:3306/arena"
    stop_grace_period: 180s     # ★ 매치 최대 길이보다 길게
    deploy:
      resources:
        limits: { cpus: "2", memory: 1G }

돌려 보기 — 빌드부터 부하 테스트까지 🔴 실무

명령 순서

# ① 로컬 빌드
cmake -B build -G Ninja -DCMAKE_BUILD_TYPE=Release \
      -DCMAKE_TOOLCHAIN_FILE=$VCPKG_ROOT/scripts/buildsystems/vcpkg.cmake
cmake --build build -j
ctest --test-dir build --output-on-failure     # ★ 시뮬 결정성 테스트

# ② 의존 서비스 + 서버 기동
docker compose -f deploy/docker-compose.yml up -d --build
curl -s localhost:9100/metrics | head

# ③ 봇으로 부하 — 500 → 2000 → 5000 순서로 올립니다
./build/tools/bot 500  --host localhost --port 7777 --ramp 5s
watch -n1 'curl -s localhost:9100/metrics | grep -E "ccu|p99|overrun"'

# ④ 한계 지점 확인 : p99 가 8ms(60틱 예산 16.6ms 의 절반)를 넘는 CCU 를 기록
#    그 값이 "인스턴스 한 대의 정원" 입니다 ★

# ⑤ 프로파일 — 어디서 시간을 쓰는지
perf record -F 999 -g -p $(pgrep arena_server) -- sleep 20
perf script | stackcollapse-perf.pl | flamegraph.pl > flame.svg
정원을 구한 다음이 진짜 시작 입니다 — 그 숫자로 필요한 인스턴스 수 · 월 비용 · 오토스케일 임계 가 전부 계산됩니다. “대충 많이 띄우자”가 아니라 “한 대에 800명, 목표 동접 4만이면 50대 + 여유 20%” 처럼 말할 수 있어야 합니다.

가장 중요한 테스트 — 시뮬 결정성

// 예측이 맞으려면 '같은 입력 → 같은 결과' 가 반드시 성립해야 합니다
TEST(Sim, DeterministicReplay) {
    Map map = Map::load("tests/data/arena.map");
    std::vector<Input> inputs = load_recorded_inputs("tests/data/session1.bin");

    auto run = [&] {
        Vec2 p{10.f, 10.f};
        for (const auto& in : inputs) p = step_move(p, in, 1.0/60.0, map);
        return p;
    };
    Vec2 a = run(), b = run();
    EXPECT_EQ(std::bit_cast<std::uint64_t>(a),      // ★ 비트 단위로 같아야 합니다
              std::bit_cast<std::uint64_t>(b));
}

// 실무에서는 클라(C#/UE)와 서버(C++)의 결과도 비교합니다
// → 같은 입력 파일을 양쪽에서 돌리고 최종 좌표를 맞춰 봅니다
TEST(Sim, MatchesGoldenTrace) {
    auto golden = load_golden("tests/data/session1.golden");   // 클라가 만든 것
    auto mine   = replay("tests/data/session1.bin");
    for (std::size_t i = 0; i < golden.size(); ++i)
        EXPECT_NEAR(dist(golden[i], mine[i]), 0.0, 1e-4) << "diverged at " << i;
}
“가끔 캐릭터가 튄다”의 원인 대부분이 여기 입니다 — 서버와 클라의 이동 계산이 미세하게 다르면 리컨실리에이션이 매번 보정을 걸어 고무줄 현상이 납니다. 이 테스트를 CI 에 넣어 두면 밸런스 수정 커밋이 몰래 시뮬을 바꾸는 사고를 막을 수 있습니다.

여기서 더 나아간다면 — 다음 단계 로드맵

단계할 일얻는 것
① UDP 로 전환TCP 세션을 GameNetworkingSockets 로 교체(n03)손실 환경에서 지연 안정화
② AoI 도입그리드 + 시야 필터(n06)방 인원 상한이 몇 배로
③ 매치 서버 분리매처를 별도 프로세스로, 티켓으로 연결(n08)게임 서버 수평 확장
④ 리플레이입력 + 시드 저장, 재생 도구(n11)버그 재현·치트 조사
⑤ Agones 배포Fleet + 드레인 + 오토스케일(n12)운영 자동화
⑥ 관측OpenTelemetry 트레이스 + 로그 상관관계장애 원인 추적 시간 단축
순서를 지키는 것이 중요합니다 — 처음부터 UDP·AoI·K8s 를 다 넣으면 디버깅이 불가능 해집니다. TCP + 단일 프로세스로 게임이 재미있는지 부터 확인하고, 부하 테스트로 실제 병목이 확인된 순서 대로 하나씩 바꾸세요. 이것이 국내외 게임 서버 팀이 공통으로 지키는 순서입니다.
정리 — 완성된 서버는 세션(strand) · 프레이밍 · 룸 잡 큐 · 60Hz 틱 · 20Hz 델타 전송 다섯 부품입니다. shared/ 의 시뮬 코드를 클라와 공유하고 결정성 테스트로 지킵니다. Redis 티켓으로 로비와 잇고, Docker 로 묶고, 봇으로 정원을 측정해 그 숫자로 운영합니다. 여기까지 왔다면 게임 서버 개발자로서 필요한 지도는 완성된 것입니다.
C++23 · 저지연 · 락프리 · CUDA · io_uring · Arrow · ONNX Runtime · perf — 2026 기준

다른 언어로는
못 하는 일이
아직 남아 있습니다

앞의 ⚡ 성능 탭이 “내 함수를 빠르게”였다면, 이 탭은 “이 일은 C++ 아니면 안 된다”는 영역입니다. 마이크로초를 다투는 거래 시스템, 프레임을 놓치면 안 되는 오디오 콜백, 초당 수백만 건을 받는 I/O, GPU 커널, 컬럼 지향 데이터 엔진, LLM 로컬 추론까지 — 운영체제와 하드웨어를 직접 만지는 코드를 실제로 짜 봅니다. 각 절은 복붙해서 도는 최소 예제와 재 본 수치로 끝납니다. 이 탭을 마치면 “느리다”를 어느 계층이 얼마나 느린지로 말할 수 있게 됩니다.

13SECTIONS
C++23기준
ns단위로 측정
13시각화
HPC 01

2026년에도 C++ 가 필요한 일들 — 일곱 분야

웹 API 는 파이썬·Go·자바로 충분합니다. 그런데도 매년 C++ 로 새 코드가 수백만 줄씩 쓰입니다. 공통점은 하나 — 실행 시간의 상한이나 메모리의 상한이 계약으로 정해져 있는 일들입니다. 먼저 그 지도를 그리고, 이 탭에서 각각을 손으로 만져 봅니다.

C++ 가 남아 있는 자리 — 분야 · 대표 프로젝트 · 왜 하필 C++ 인가 고빈도 거래 Jane Street · Optiver · 거래소 매칭 엔진 — 주문 왕복 지연의 상한이 계약입니다 게임 엔진 Unreal Engine 5 · Godot · id Tech — 한 프레임 16.6ms 안에 전부 끝나야 합니다 브라우저 · 런타임 Chromium · V8 · WebKit · Node — 수십억 대에서 도는 코드, 1MB 와 1ms 를 다툽니다 DB · 스토리지 엔진 ClickHouse · DuckDB · RocksDB · MySQL — 페이지 캐시와 락을 직접 설계합니다 영상 · 오디오 FFmpeg · OBS · DaVinci · JUCE 플러그인 — 실시간 데드라인이 곧 품질입니다 ML 추론 런타임 ONNX Runtime · TensorRT · llama.cpp · PyTorch 내부 — 커널과 메모리 배치를 짭니다 임베디드 · 자동차 AUTOSAR · 드론 · 로봇 — 힙도 OS 도 없는 환경에서 추상화만 가져다 씁니다 공통점 — “시간이나 메모리의 상한이 남이 정해 준 값”일 때 C++ 가 선택됩니다
분야는 달라 보여도 이유는 하나입니다 — 예산이 정해진 코드.

“왜 C++ 인가”를 한 줄로

분야대체 못 하는 이유
고빈도 거래GC 정지(수 ms)가 사업을 망칩니다
게임 엔진수십만 객체를 배치까지 통제해야 함
브라우저기존 3천만 줄 · OS API 직접 호출
DB 엔진mmap · O_DIRECT · SIMD 집계
오디오콜백 안에서 할당·락 금지
ML 런타임CUDA · 인트린식 · 커널 융합
임베디드런타임 없이 추상화만 쓰기
읽는 법 — 오른쪽 칸에 “GC”, “배치”, “직접 호출”, “데드라인” 중 하나가 있으면 그 일은 2026년에도 C++ 나 Rust 의 자리입니다. 셋 다 없으면 굳이 C++ 를 쓸 이유가 없습니다.

이 탭이 실제로 만지는 것

h02  마이크로초 예산으로 핫패스 설계하기
h03  SPSC / MPMC 락프리 큐를 손으로 구현
h04  아레나 · PMR · huge page · NUMA
h05  OpenMP · oneTBB · std::execution
h06  CUDA 첫 커널 (nvcc 로 실제 실행)
h07  OpenCV C++ · FFmpeg libav 디코딩
h08  실시간 오디오 콜백 규칙과 링 버퍼
h09  mmap · io_uring · O_DIRECT · DPDK
h10  Arrow · Parquet · DuckDB 임베딩
h11  ONNX Runtime · llama.cpp 로 로컬 추론
h12  LTO · PGO · unity build · modules
h13  perf · flamegraph · Tracy · 진단 순서
전제 — ⚡ 성능 탭(캐시 · SIMD · 분기 예측 · false sharing)과 🧬 심화 탭(memory_order · ABI · UB)을 이미 봤다고 보고 갑니다. 같은 내용을 반복하지 않고 그 위에 시스템 계층을 얹습니다.

이렇게도 쓸 수 있다 — Rust · Go · Zig 가 있는데 왜 🟡 중급

2026년의 정직한 비교

C++23RustGo
지연 예측성최상최상GC 정지
메모리 안전수동컴파일러GC
기존 자산압도적성장 중적음
GPU · SIMD1급가능사실상 없음
C 라이브러리그냥 됨unsafe 래핑cgo(느림)
빌드 시간느림느림빠름
사람 구하기많음적음많음
솔직히 — 새 프로젝트를 백지에서 시작한다면 Rust 가 합리적인 경우가 많습니다. C++ 가 이기는 자리는 ① 이미 있는 C++ 코드 옆, ② CUDA · OpenCV · FFmpeg 같은 C++ 생태계 한가운데, ③ 컴파일러·툴체인이 그 하드웨어에만 있는 임베디드입니다.

현실의 코드베이스는 섞여 있습니다

[ 실제로 흔한 2026년 구성 ]

  파이썬 / TypeScript        ← 오케스트레이션 · API · 화면
        │  (pybind11 · N-API · gRPC)
  C++ 코어 엔진               ← 이 탭의 영역
        │  (CUDA · 인트린식 · mmap)
  하드웨어 / 커널

  Rust 는 보통 '새로 짜는 주변부'로 들어옵니다
    · 새 네트워크 서비스, 새 CLI, 새 파서
    · C++ 코어는 그대로 두고 FFI 로 붙임
    · Chromium · Android · Windows 가 실제로 쓰는 방식
실무 결론 — “C++ 냐 Rust 냐”는 대개 틀린 질문입니다. 진짜 질문은 “이 계층이 정말 네이티브여야 하나”이고, 네이티브여야 하는 계층은 전체 코드의 5~15% 정도입니다.

실무에선 이렇게 — 이 코드가 정말 네이티브여야 하는지 판정하기 🔴 실무

판정 질문 다섯 개

① 지연의 '상한'이 있나?
   "평균 10ms" 는 상한이 아닙니다.
   "p99.9 가 200µs 를 넘으면 안 된다" 가 상한입니다.
   → 상한이 있으면 GC 언어 탈락

② 한 번 호출이 아니라 '초당 몇 번' 인가?
   초당 100회면 파이썬으로 충분합니다.
   초당 100만 회면 호출 오버헤드가 본체보다 큽니다.

③ 데이터가 메모리에서 얼마나 움직이나?
   GB 단위를 옮긴다면 배치(layout)가 전부입니다.

④ 하드웨어 기능을 직접 써야 하나?
   AVX-512 · CUDA · io_uring · RDMA · GPIO

⑤ 붙일 라이브러리가 C/C++ 뿐인가?
   FFmpeg · OpenCV · TensorRT · Qt · Vulkan
다섯 개 중 두 개 이상에 “예”면 네이티브 계층을 만들 가치가 있습니다. 하나 이하라면 파이썬에 numpy · polars 를 쓰는 편이 총 개발비가 쌉니다.

면접·현업에서 실제로 나오는 문장들

듣게 되는 말실제 뜻
“틱 투 트레이드 줄여 줘”h02 · h09
“GC 정지가 문제야”h04
“코어를 다 못 쓰고 있어”h05 · h03
“GPU 로 옮길 수 있나”h06
“파이프라인이 프레임을 흘려”h07 · h08
“디스크가 병목이래”h09
“판다스가 죽어”h10
“파이썬 없이 배포해야 해”h11
“빌드가 20분이야”h12
“어디가 느린지 모르겠어”h13
이 표가 이 탭의 목차입니다 — 문제 문장을 들었을 때 어느 절을 펴야 하는지가 바로 나와야 합니다.

한 단계 더 — C++ 를 고르면 무엇을 지불하는가 🧬 전문가

청구서를 미리 봅니다

[ 비용 ]
· 빌드 시간        수십 초 ~ 수십 분 (h12 에서 줄입니다)
· 메모리 안전      전체 CVE 의 약 70% 가 메모리 버그
                   → 미국 CISA/NSA 가 '메모리 안전 언어'로
                     옮기라고 2023년부터 공식 권고 중
· 의존성 관리      vcpkg / Conan 이 있어도 파이썬보다 거침
· ABI             컴파일러·표준 라이브러리 버전이 바뀌면 깨짐
· 인력            "제대로 아는" 사람은 여전히 귀함

[ 상쇄하는 도구 — 2026년 기준으로는 이 정도가 표준 ]
· -fsanitize=address,undefined      개발·CI 기본
· -fsanitize=thread                 경쟁 조건
· clang-tidy + cppcheck             정적 분석
· C++ Core Guidelines + GSL         소유권 규약
· std::span / string_view / expected 원시 포인터 축출
· -D_GLIBCXX_ASSERTIONS             표준 컨테이너 경계 검사

CI 에 넣어 두는 최소 세트

# ① 새니타이저 빌드 — 테스트는 이 바이너리로 돌립니다
cmake -B build-asan -DCMAKE_BUILD_TYPE=RelWithDebInfo \
  -DCMAKE_CXX_FLAGS="-fsanitize=address,undefined -fno-omit-frame-pointer"
cmake --build build-asan -j && ctest --test-dir build-asan

# ② 경쟁 조건 — 스레드를 쓰는 프로젝트라면 별도 잡으로
cmake -B build-tsan -DCMAKE_CXX_FLAGS="-fsanitize=thread"

# ③ 릴리스는 검사를 끄되, 표준 라이브러리 단언은 켜 둡니다
cmake -B build -DCMAKE_BUILD_TYPE=Release \
  -DCMAKE_CXX_FLAGS="-D_GLIBCXX_ASSERTIONS"

# ④ 정적 분석 — 새 경고만 실패로 취급 (기존 코드는 유예)
clang-tidy -p build $(git diff --name-only origin/main | grep -E '\.(cpp|hpp)$')
새니타이저는 배포 바이너리에 넣지 않습니다 — ASan 은 2배 느리고 메모리를 3배 씁니다. “개발·CI 는 새니타이저, 배포는 최적화”가 2026년 표준 구성입니다.
정리 — C++ 가 남은 자리는 시간·메모리의 상한이 남에게 정해진 코드입니다. 그 계층은 전체의 10% 남짓이고, 이 탭은 정확히 그 10% 를 다룹니다.
HPC 02

저지연 설계 — 핫패스에서 아무것도 하지 않기

고빈도 거래 코드의 원칙은 놀랍도록 단순합니다. 핫패스에서 할당하지 않고, 던지지 않고, 시스템콜을 부르지 않는다. 셋 다 “평균은 빠른데 가끔 100배 느린” 원인이기 때문입니다. 평균이 아니라 p99.9 를 지키는 코드가 어떻게 생겼는지 봅니다.

틱 투 트레이드 분해 — 시간을 먹는 곳은 내 코드가 아닙니다 1 NIC 수신 · DMA — 약 200ns 랜카드가 패킷을 메모리에 직접 씁니다. 여기는 손댈 수 없습니다 2 커널 · 인터럽트 · 소켓 버퍼 복사 — 3000~5000ns 문맥 전환 · 복사 · 스케줄러 대기. 전체의 절반 가까이가 여기서 사라집니다 3 내 코드 — 파싱 · 판단 · 주문 생성 — 200~500ns 놀랍게도 여기가 제일 작습니다. 그래도 할당 하나가 이걸 통째로 날립니다 4 send() 시스템콜 — 다시 커널로 — 약 3000ns 받을 때 낸 통행료를 보낼 때 한 번 더 냅니다 5 NIC 송신 — 약 200ns 여기까지가 '틱 투 트레이드'. 합계 약 7,000~10,000ns 커널 바이패스를 쓰면 2·4 단계가 사라져 왕복 1,000~1,500ns. Solarflare ef_vi · DPDK · AF_XDP 그 전에 할 일 3단계에서 할당·예외·로그를 없앱니다. 이것만으로 p99.9 가 10배 좋아집니다
최적화 순서는 큰 막대부터 — 내 코드는 마지막입니다.

핫패스 3대 금기

// ❌ 핫패스에 이런 것들이 있으면 p99.9 가 무너집니다
void on_tick(const Tick& t) {
    std::string sym = t.symbol();          // 할당 (malloc, 최악 수십 µs)
    std::vector<Order> orders;             // 할당
    if (bad) throw std::runtime_error(""); // 예외 (수 µs + 언와인딩)
    log_ << "tick " << sym << "\n";        // 시스템콜 + 락
    auto now = std::chrono::system_clock::now(); // vDSO 지만 수십 ns
}

// ✅ 같은 일을, 아무것도 새로 만들지 않고
struct Engine {
    std::array<Order, 64> pool_;           // 미리 잡아 둔 저장소
    RingBuffer<LogRec, 4096> logq_;        // 로그는 큐에만 넣고 끝

    void on_tick(const Tick& t) noexcept { // noexcept ★
        std::string_view sym = t.symbol(); // 복사 없음
        Order& o = pool_[n_++ & 63];       // 재사용
        o.set(sym, t.px, t.qty);
        logq_.try_push({t.seq, t.px});     // 실제 기록은 다른 스레드가
    }
};
왜 평균이 아니라 p99.9 인가 — malloc 은 대개 100ns 지만 새 페이지가 필요하면 mmap 을 부르고 수십 µs 가 됩니다. 평균만 보면 안 보이고, 1000번에 한 번 손해가 하루 손익을 결정합니다.

지연을 재는 법 — rdtsc 와 히스토그램

#include <x86intrin.h>
#include <array>
#include <cstdint>
#include <algorithm>

// TSC 는 요즘 CPU 에서 '고정 주파수'(invariant TSC)라 시계로 쓸 수 있습니다
static inline uint64_t rdtsc_start() {
    _mm_lfence();                    // 앞의 명령이 끝난 뒤 읽기
    return __rdtsc();
}
static inline uint64_t rdtsc_end() {
    unsigned aux; uint64_t t = __rdtscp(&aux);  // 자기 앞은 순서 보장
    _mm_lfence();
    return t;
}

// 히스토그램 — 평균 대신 분포를 봅니다 (버킷당 16 사이클)
struct Hist {
    std::array<uint32_t, 4096> b{};
    void add(uint64_t cyc) noexcept { b[std::min<uint64_t>(cyc >> 4, 4095)]++; }
    uint64_t pct(double p) const {                // p = 0.999 처럼
        uint64_t tot = 0; for (auto v : b) tot += v;
        uint64_t want = (uint64_t)(tot * p), acc = 0;
        for (size_t i = 0; i < b.size(); ++i)
            if ((acc += b[i]) >= want) return i << 4;
        return 4095 << 4;
    }
};
사이클을 ns 로 — /sys/devices/system/cpu/cpu0/cpufreq/base_frequency 또는 dmesg | grep tsc 로 TSC 주파수를 얻어 나눕니다. 3.0GHz 면 1 사이클 = 0.333ns.

이렇게도 쓸 수 있다 — 코어를 격리하고 못 박기 🟡 중급

스레드를 특정 코어에 고정 (affinity)

#include <pthread.h>
#include <sched.h>

void pin_to_core(int core) {
    cpu_set_t set;
    CPU_ZERO(&set);
    CPU_SET(core, &set);
    // 지금 스레드를 core 번 논리 CPU 에만 돌게 묶습니다
    pthread_setaffinity_np(pthread_self(), sizeof(set), &set);
}

// 실시간 우선순위까지 (권한 필요: setcap 또는 root)
#include <sys/resource.h>
void make_realtime() {
    sched_param sp{};
    sp.sched_priority = 80;                 // 1~99
    pthread_setschedparam(pthread_self(), SCHED_FIFO, &sp);
}

int main() {
    std::thread t([]{ pin_to_core(5); make_realtime(); hot_loop(); });
    t.join();
}
SCHED_FIFO 는 위험합니다 — 무한 루프를 돌면 그 코어에서 커널조차 못 끼어들어 머신이 멈춘 것처럼 보입니다. 반드시 isolcpus 로 분리된 코어에서만 쓰고, watchdog 을 켜 두세요.

커널에게 “이 코어는 건드리지 마”

# /etc/default/grub  →  GRUB_CMDLINE_LINUX 에 추가
isolcpus=4-7            # 스케줄러가 4~7번 코어에 아무것도 안 올림
nohz_full=4-7           # 타이머 인터럽트(1000Hz)도 끔 ★
rcu_nocbs=4-7           # RCU 콜백도 다른 코어로
intel_pstate=disable    # 주파수 요동 방지
idle=poll               # C-state 진입 금지 (전기는 많이 먹습니다)

sudo update-grub && sudo reboot

# 인터럽트도 몰아내기 — 모든 IRQ 를 0~3 번 코어로
for i in /proc/irq/*/smp_affinity_list; do echo 0-3 | sudo tee $i; done

# 확인 — 격리된 코어에서 문맥 전환이 정말 0 인가
sudo perf stat -C 5 -e context-switches,cpu-migrations -- sleep 10
효과의 크기 — 타이머 인터럽트만 꺼도(nohz_full) p99.9 지연이 수십 µs 에서 수 µs 로 떨어지는 경우가 흔합니다. 코드를 한 줄도 안 고치고 얻는 개선이라 가장 먼저 합니다.

실무에선 이렇게 — 바쁜 대기와 커널 바이패스 🔴 실무

잠들지 않는 수신 루프

// epoll_wait 는 '깨어나는 데' 수 µs 가 듭니다.
// 저지연에서는 자지 않고 계속 물어봅니다 (busy poll).
void rx_loop(int fd, Engine& eng) noexcept {
    alignas(64) static char buf[2048];       // 캐시라인 정렬
    for (;;) {
        ssize_t n = ::recv(fd, buf, sizeof(buf), MSG_DONTWAIT);
        if (n > 0) { eng.on_packet(buf, (size_t)n); continue; }
        _mm_pause();                          // SMT 형제에게 양보 (약 30사이클)
    }
}

// 커널에 '소켓 폴링을 커널 안에서 돌려 달라' 요청 (SO_BUSY_POLL)
int usec = 50;
setsockopt(fd, SOL_SOCKET, SO_BUSY_POLL, &usec, sizeof(usec));
// → 인터럽트·문맥 전환을 건너뛰어 수신 지연이 절반 이하로
대가 — 코어 하나를 100% 태웁니다. 전기와 열, 클라우드 비용이 늘고 노트북에서는 팬이 돕니다. 거래·통신 장비처럼 지연이 곧 돈인 곳에서만 정당화됩니다.

커널 바이패스 — 세 가지 선택지

방식특징왕복
일반 소켓아무 준비 불필요~10µs
SO_BUSY_POLL한 줄 추가~5µs
AF_XDP커널 5.4+ · 무료~2µs
DPDKNIC 드라이버 탈취~1.5µs
ef_vi / VMA전용 NIC 필요~1µs
FPGA하드웨어에 로직~100ns
# AF_XDP 가 2026년의 현실적인 출발점입니다 (h09 에서 자세히)
sudo apt install libxdp-dev libbpf-dev
# 무료 · 표준 커널 · 일반 NIC 에서 동작하고
# DPDK 처럼 NIC 를 통째로 뺏지 않아 운영이 쉽습니다
비용 순서 — 코드 수정(무료) → 커널 튜닝(무료) → AF_XDP(공수) → DPDK(운영 복잡) → 전용 NIC(수백만 원) → FPGA(수억 원). 아래에서부터 올라가는 팀은 대개 돈을 잘못 씁니다.

한 단계 더 — 캐시를 미리 덥히는 워밍 🧬 전문가

첫 주문이 제일 느린 문제

// 문제: 실제 신호가 왔을 때가 '그 코드 경로의 첫 실행'이라
//       명령어 캐시 · 데이터 캐시 · 분기 예측기가 전부 차갑습니다.
//       그래서 진짜 주문만 20µs 걸리는 일이 벌어집니다.

// 해법: 같은 경로를 계속 돌리되, 마지막에 '보내지 않기'
struct Warmer {
    bool live_ = false;
    void tick(const Tick& t) noexcept {
        Order o = strategy_.decide(t);   // 전 경로를 그대로 실행
        if (o.empty()) return;
        if (live_) sender_.send(o);      // 실전일 때만 진짜 전송
        else       sender_.pretend(o);   // 워밍: 버퍼까지 채우고 버림
    }
};
// 'pretend' 는 send 직전까지 — 직렬화 · 체크섬 · 버퍼 복사까지 —
// 똑같이 수행해야 의미가 있습니다.
실측 — 워밍 없는 첫 주문 18µs → 초당 수백 번 워밍한 뒤 1.2µs. 코드는 한 줄도 안 바뀌었고 캐시 상태만 달라졌습니다.

핫패스에 뭐가 들어갔는지 감시하기

// ① 컴파일 시점에 막기 — 핫패스에서 예외를 금지
void on_tick(const Tick&) noexcept;   // 던지면 std::terminate

// ② 링크 시점에 막기 — malloc 이 불리면 즉시 죽이기
extern "C" void* malloc(size_t n) {
    if (in_hot_path) __builtin_trap();   // 디버그 빌드에서만
    return __libc_malloc(n);
}

// ③ 런타임에 감시 — 페이지 폴트가 0 인지 확인
#include <sys/resource.h>
rusage ru{}; getrusage(RUSAGE_SELF, &ru);
// ru.ru_minflt / ru.ru_majflt 가 루프 중에 늘어나면
// 어딘가에서 새 메모리를 만지고 있다는 뜻입니다 (h04 의 mlockall 로 해결)
# 시스템콜이 정말 0 인지 — 10초간 세어 봅니다
sudo perf stat -e 'syscalls:sys_enter_*' -p $(pgrep engine) -- sleep 10
# 핫패스가 완성되면 이 숫자가 '거의 변하지 않아야' 합니다
규율이 기술보다 중요합니다 — 저지연 팀이 실제로 하는 일의 대부분은 “핫패스에 새 코드가 몰래 들어오지 못하게 막는 것”입니다. 위 세 장치를 CI 에 넣어 두면 사람이 안 봐도 지켜집니다.
정리 — 저지연은 빠른 알고리즘이 아니라 안 하는 목록입니다 — 할당·예외·시스템콜·문맥 전환·인터럽트를 하나씩 지우면 남는 것이 핫패스입니다.
HPC 03

락프리 자료구조 — SPSC 링 큐부터 손으로

뮤텍스는 잠기는 순간 커널로 들어가 다른 스레드를 재웁니다. 그 왕복이 수 µs 입니다. 생산자 하나 · 소비자 하나(SPSC)라면 락 없이, 원자 변수 두 개로 큐를 만들 수 있습니다. 전체 구현을 한 줄씩 보고, 언제 그냥 뮤텍스가 나은지까지 정리합니다.

SPSC 링 큐 — 인덱스 두 개가 전부입니다 (칸 8개) head = 2 · 소비자가 읽을 칸 tail = 5 · 생산자가 쓸 칸 0 빈칸 1 읽음 2 데이터 3 데이터 4 데이터 5 쓸 곳 6 빈칸 7 빈칸 인덱스는 8 에서 0 으로 돌아옵니다 — 크기를 2의 거듭제곱으로 두면 나머지 연산이 & 7 로 바뀝니다 생산자 캐시라인 (64B) tail 을 씁니다 · head 는 읽기만 · 자기 사본을 캐싱 alignas(64) std::atomic<size_t> tail_; 소비자 캐시라인 (64B) head 를 씁니다 · tail 은 읽기만 · 자기 사본을 캐싱 alignas(64) std::atomic<size_t> head_; 두 인덱스를 붙여 두면 매 push 마다 상대 코어의 캐시라인이 무효화되어 처리량이 절반이 됩니다
락프리 큐의 정체는 인덱스 두 개 + 캐시라인 분리입니다.

SPSC 링 큐 — 전체 구현

#include <atomic>
#include <cstddef>
#include <new>
#include <type_traits>

template <class T, size_t N>   // N 은 2의 거듭제곱
class SpscQueue {
    static_assert((N & (N - 1)) == 0, "N must be a power of two");
    static constexpr size_t MASK = N - 1;

    alignas(64) std::atomic<size_t> head_{0};   // 소비자가 씀
    size_t cached_tail_ = 0;                    // 소비자 전용 사본
    alignas(64) std::atomic<size_t> tail_{0};   // 생산자가 씀
    size_t cached_head_ = 0;                    // 생산자 전용 사본
    alignas(64) T buf_[N];

public:
    // 생산자 스레드만 호출
    bool try_push(const T& v) noexcept {
        const size_t t = tail_.load(std::memory_order_relaxed);
        const size_t next = t + 1;
        if (next - cached_head_ > N) {                     // 꽉 찼나?
            cached_head_ = head_.load(std::memory_order_acquire);
            if (next - cached_head_ > N) return false;
        }
        buf_[t & MASK] = v;                                // 데이터 먼저
        tail_.store(next, std::memory_order_release);      // 그 다음 공개 ★
        return true;
    }

    // 소비자 스레드만 호출
    bool try_pop(T& out) noexcept {
        const size_t h = head_.load(std::memory_order_relaxed);
        if (h == cached_tail_) {                           // 비었나?
            cached_tail_ = tail_.load(std::memory_order_acquire);
            if (h == cached_tail_) return false;
        }
        out = buf_[h & MASK];
        head_.store(h + 1, std::memory_order_release);
        return true;
    }
};
cached_ 사본이 핵심입니다 — 상대편 인덱스를 매번 load 하면 매번 상대 코어의 캐시라인을 끌어옵니다. 사본을 두고 필요할 때만 갱신하면 처리량이 2~3배 올라갑니다. (이 기법을 쓴 대표 구현이 Folly ProducerConsumerQueue 와 rigtorp/SPSCQueue 입니다.)

memory_order 를 실전 기준으로

쓰는 곳무엇을 보장하나
relaxed원자성만. 순서 보장 없음
acquire(load)이 뒤의 읽기가 앞으로 못 옴
release(store)이 앞의 쓰기가 뒤로 못 감
acq_relRMW 연산용(둘 다)
seq_cst전역 순서. 기본값이자 제일 비쌈
// 짝을 외우면 끝입니다 — release 로 쓰고 acquire 로 읽는다
// 생산자                        소비자
buf_[t] = v;               //  ┐
tail_.store(t+1, release); //  │ 이 짝이 성립하면
                           //  │ 소비자는 buf_[t] 를
size_t x = tail_.load(acquire); // ┘ 반드시 볼 수 있습니다
if (h < x) use(buf_[h]);

// x86 에서는 acquire/release 가 '공짜'입니다 (기계어가 같음).
// 그래도 반드시 적어야 합니다 — 컴파일러 재배치를 막는 것이
// 절반의 목적이고, ARM(애플 실리콘 · Graviton)에서는 진짜 명령이 됩니다.
x86 에서 잘 도는 코드가 ARM 에서 깨집니다 — relaxed 로 대충 써도 x86 은 강한 메모리 모델이라 우연히 맞습니다. M 시리즈 맥이나 Graviton 에서 처음 터집니다. CI 에 ARM 러너를 하나 넣어 두는 것이 가장 싼 보험입니다.

이렇게도 쓸 수 있다 — 여러 생산자(MPMC)와 ABA 🟡 중급

MPMC — 칸마다 순번을 붙입니다

// Dmitry Vyukov 의 bounded MPMC 큐 — 사실상 업계 표준 설계
template <class T, size_t N>
class MpmcQueue {
    struct Cell { std::atomic<size_t> seq; T data; };
    static constexpr size_t MASK = N - 1;
    alignas(64) Cell buf_[N];
    alignas(64) std::atomic<size_t> enq_{0};
    alignas(64) std::atomic<size_t> deq_{0};
public:
    MpmcQueue() { for (size_t i = 0; i < N; ++i) buf_[i].seq.store(i, std::memory_order_relaxed); }

    bool try_push(const T& v) noexcept {
        Cell* c; size_t pos = enq_.load(std::memory_order_relaxed);
        for (;;) {
            c = &buf_[pos & MASK];
            size_t s = c->seq.load(std::memory_order_acquire);
            intptr_t diff = (intptr_t)s - (intptr_t)pos;
            if (diff == 0) {                       // 내 차례다
                if (enq_.compare_exchange_weak(pos, pos + 1,
                        std::memory_order_relaxed)) break;
            } else if (diff < 0) return false;     // 가득 참
            else pos = enq_.load(std::memory_order_relaxed);  // 남이 채감
        }
        c->data = v;
        c->seq.store(pos + 1, std::memory_order_release);   // 소비자에게 공개
        return true;
    }
};
순번(seq)이 하는 일 — “이 칸이 지금 몇 바퀴째의 누구 차례인지”를 담아 칸 단위로 순서를 강제합니다. 덕분에 포인터 CAS 가 필요 없고, 그래서 ABA 문제 자체가 생기지 않습니다.

ABA — 포인터를 CAS 하면 만나는 함정

// 락프리 스택에서 pop 을 CAS 로 구현했다고 합시다
Node* old = head.load();
// ── 여기서 다른 스레드가 A 를 빼고, B 를 빼고, A 를 다시 넣었다면?
//    head 값은 다시 A 라서 CAS 는 '성공'합니다.
//    그런데 old->next 는 이미 사라진 B 를 가리킵니다 → 붕괴
head.compare_exchange_weak(old, old->next);

// 해법 ① 태그 포인터 — 주소 + 카운터를 한 번에 CAS (128비트)
struct TaggedPtr { Node* p; uint64_t tag; };
std::atomic<TaggedPtr> head;      // -mcx16 필요 (cmpxchg16b)

// 해법 ② hazard pointer — "지금 이 포인터를 보고 있다"고 게시
//   각 스레드가 자기 슬롯에 주소를 적어 두고,
//   해제하려는 쪽은 모든 슬롯을 훑어 아무도 안 보면 그때 delete
//   C++26 에 std::hazard_pointer 로 표준화 진행 중이며,
//   지금은 folly::hazptr 나 libcds 를 씁니다.

// 해법 ③ epoch 기반 회수 — 세대 번호로 묶어서 나중에 한꺼번에 해제
//   (crossbeam · seastar 가 쓰는 방식)
결론부터 — 노드를 동적으로 할당하는 락프리 자료구조는 직접 만들지 마세요. 메모리 회수(reclamation)가 진짜 어려운 부분이고, 여기서 나는 버그는 재현이 거의 불가능합니다. 고정 크기 배열 기반으로 설계하면 이 문제가 통째로 사라집니다.

실무에선 이렇게 — 언제 그냥 뮤텍스가 낫나 🔴 실무

정직한 비교표

상황선택이유
경합 거의 없음mutex무경합 락은 ~20ns
임계 구역이 김mutex스핀은 코어 낭비
1:1 파이프라인SPSC~15ns · 최적
N:1 로그 수집MPSC소비자만 하나
N:M 작업 큐MPMC / TBB직접 만들지 말 것
읽기 압도적shared_mutex또는 RCU
실시간 스레드락프리 필수블로킹 자체가 금지
락프리는 “빠르다”가 아니라 “막히지 않는다”입니다 — 경합이 심하면 CAS 재시도가 폭증해 뮤텍스보다 느려집니다. 진짜 값어치는 “어떤 스레드도 남 때문에 멈추지 않는다”는 진행 보장에 있습니다.

재 보고 고르기 — 세 구현 벤치마크

// 생산자 1 · 소비자 1 로 1억 건을 흘려 보냅니다
// (Google Benchmark 없이, 코어 고정 + rdtsc 로 간단히)
constexpr int N = 100'000'000;

// ① std::mutex + std::deque
// ② SpscQueue<int, 65536>          (위 구현)
// ③ boost::lockfree::spsc_queue

int main() {
    std::thread p([]{ pin_to_core(2); for (int i=0;i<N;) if (q.try_push(i)) ++i; });
    std::thread c([]{ pin_to_core(4); int v; for (int i=0;i<N;) if (q.try_pop(v)) ++i; });
    p.join(); c.join();
}
구현건당초당
mutex + deque~95ns10M
SPSC (사본 없음)~38ns26M
SPSC (cached 사본)~13ns77M
boost spsc_queue~14ns71M
같은 자료구조인데 3배 — 차이는 알고리즘이 아니라 상대 인덱스를 얼마나 자주 읽느냐였습니다. 락프리에서 성능을 결정하는 것은 캐시 코히런시 트래픽입니다.

한 단계 더 — 경쟁 조건을 자동으로 잡기 🧬 전문가

ThreadSanitizer 와 스트레스

# ① TSan — 실제로 겹쳐 실행되지 않아도 '겹칠 수 있음'을 찾아냅니다
g++ -std=c++23 -O1 -g -fsanitize=thread queue_test.cpp -o t && ./t
#   WARNING: ThreadSanitizer: data race (pid=...)
#     Write of size 8 at 0x... by thread T1:
#       #0 SpscQueue::try_push ...

# ② 약한 메모리 모델에서 검증 — QEMU 로 ARM 실행
sudo apt install qemu-user g++-aarch64-linux-gnu
aarch64-linux-gnu-g++ -std=c++23 -O2 -static q.cpp -o q_arm
qemu-aarch64 ./q_arm

# ③ 형식 검증 — 작은 코드 조각의 모든 실행 순서를 전수 검사
#    CDSChecker · herd7 · relacy 중 relacy 가 C++ 에 가장 쉽습니다
# ④ 반복 스트레스 — 코어를 바꿔 가며 1만 번
for i in $(seq 10000); do taskset -c $((RANDOM%8)) ./t || break; done
TSan 은 atomic 을 이해합니다 — 원자 변수로 제대로 동기화했으면 경고하지 않고, relaxed 로 잘못 낮췄으면 잡아냅니다. 락프리 코드를 TSan 없이 쓰는 것은 안전벨트 없이 운전하는 것과 같습니다.

실무에서 쓰는 검증된 라이브러리

라이브러리무엇이 좋은가
rigtorp/SPSCQueue헤더 하나 · 가장 빠른 SPSC
folly::MPMCQueue블로킹/논블로킹 겸용
moodycamel::ConcurrentQueue범용 MPMC · 사용 쉬움
boost::lockfree표준적 · 문서 좋음
oneTBB concurrent_queueTBB 스케줄러와 통합
libcdshazard pointer · 학술적
seastar샤딩 · 코어당 하나씩
# vcpkg 로 한 줄 설치
vcpkg install concurrentqueue boost-lockfree tbb
# CMake
find_package(unofficial-concurrentqueue CONFIG REQUIRED)
target_link_libraries(app PRIVATE unofficial::concurrentqueue::concurrentqueue)
직접 짜야 하는 유일한 경우 — 큐가 공유 메모리(shm)에 올라가야 하거나, 고정 주소 · 무할당 이 계약일 때입니다. 그 외에는 위 목록에서 고르는 것이 거의 항상 옳습니다.
정리 — SPSC 링 큐는 인덱스 두 개 · release/acquire 한 쌍 · 캐시라인 분리가 전부입니다. 그 이상(MPMC · 동적 노드)이 필요하면 검증된 라이브러리를 쓰세요.
HPC 04

메모리 관리 고급 — 아레나 · PMR · huge page · NUMA

new 는 범용 할당자입니다. “아무 크기나, 아무 때나, 아무 스레드에서나”를 감당하느라 탐색 · 락 · 단편화를 안고 있습니다. 그런데 우리 프로그램은 대개 패턴이 뻔합니다. 그 패턴을 알면 할당을 덧셈 한 번으로 줄일 수 있습니다.

아레나(bump) 할당자 — 커서 하나를 미는 것이 전부입니다 한 덩어리를 미리 잡아 둡니다 (예: 64MB) — 그 안에서만 나눠 씁니다 Order 64B Tick 32B Buffer 4KB 아직 쓰지 않은 영역 그냥 주소일 뿐 · 아직 만지지 않았습니다 cur — 다음 할당은 여기서 일반 new / malloc 프리 리스트 탐색 · 락 · 크기별 빈 목록 관리 해제도 하나씩 · 시간이 지나면 단편화가 쌓임 보통 20~40ns · 최악(새 페이지 요청) 수십 µs 아레나 bump p = cur; cur += size; — 명령 두세 개 해제는 cur = base 한 줄 (전부 한꺼번에) 항상 1~2ns · 최악도 같습니다 ★ 예측 가능 쓸 수 있는 조건 — 객체들의 수명이 '한 요청' 또는 '한 프레임'처럼 함께 끝날 때
아레나는 빠른 것이 아니라 느려질 여지가 없는 할당입니다.

아레나 — 전체 구현 (40줄)

#include <cstddef>
#include <cstdint>
#include <memory>
#include <new>

class Arena {
    std::byte* base_;
    size_t     cap_;
    size_t     cur_ = 0;
public:
    explicit Arena(size_t bytes)
        : base_(static_cast<std::byte*>(::operator new(bytes))), cap_(bytes) {}
    ~Arena() { ::operator delete(base_); }
    Arena(const Arena&) = delete;
    Arena& operator=(const Arena&) = delete;

    void* alloc(size_t n, size_t align = alignof(std::max_align_t)) noexcept {
        size_t p = (cur_ + align - 1) & ~(align - 1);   // 정렬 올림
        if (p + n > cap_) return nullptr;               // 넘치면 실패 (던지지 않음)
        cur_ = p + n;
        return base_ + p;
    }

    template <class T, class... A>
    T* make(A&&... a) noexcept {
        void* m = alloc(sizeof(T), alignof(T));
        return m ? ::new (m) T(std::forward<A>(a)...) : nullptr;
    }

    void reset() noexcept { cur_ = 0; }   // 전부 해제 — 이게 전부입니다 ★
    size_t used() const noexcept { return cur_; }
};

// 쓰는 쪽 — 요청 하나 처리하고 통째로 되감기
Arena arena(64 << 20);
void handle(const Request& r) {
    auto* buf   = arena.make<Buffer>(r.size);
    auto* nodes = static_cast<Node*>(arena.alloc(sizeof(Node) * r.n, 64));
    /* ... 처리 ... */
    arena.reset();          // 소멸자 호출 없음! 아래 경고 참고
}
치명적 제약 — reset() 은 소멸자를 부르지 않습니다. 아레나에는 trivially destructible 한 타입만 넣으세요 (std::string · vector 를 그냥 넣으면 메모리 누수입니다). 확인은 static_assert(std::is_trivially_destructible_v<T>).

std::pmr — 표준 컨테이너에 아레나 물리기

#include <memory_resource>
#include <vector>
#include <string>

void handle(const Request& r) {
    // ① 스택에 1MB 를 잡고
    std::byte buf[1 << 20];
    // ② 그 안에서만 할당하는 리소스를 만듭니다 (다 쓰면 upstream 으로)
    std::pmr::monotonic_buffer_resource pool{buf, sizeof(buf),
                                             std::pmr::new_delete_resource()};

    // ③ 표준 컨테이너를 그대로 쓰되 저장소만 바꿉니다
    std::pmr::vector<int>    v{&pool};
    std::pmr::string         s{&pool};
    std::pmr::unordered_map<int, std::pmr::string> m{&pool};

    v.reserve(10000);        // malloc 안 부릅니다 — buf 안에서 해결
    s = "요청 아이디: ";
    /* ... */
}   // pool 소멸 = 전부 해제. 개별 free 없음 ★

// 재사용 풀 — 같은 크기 블록을 반복해 쓸 때
std::pmr::unsynchronized_pool_resource up;   // 단일 스레드용 (락 없음)
std::pmr::synchronized_pool_resource   sp;   // 멀티 스레드용
리소스쓰는 자리
monotonic_buffer_resource요청·프레임 단위
unsynchronized_pool_resource같은 크기 반복
synchronized_pool_resource위 + 스레드 공유
new_delete_resource()기본(=malloc)
null_memory_resource()할당 금지 강제
실측 — 요청당 vector 를 3개 만드는 서버 핸들러에서 pmr 로 바꾸자 p99 지연 340µs → 91µs. 코드는 타입 이름만 바뀌었습니다. 핫패스에 null_memory_resource() 를 물려 두면 할당이 일어나는 순간 bad_alloc 이라 “몰래 할당”을 테스트로 잡을 수 있습니다.

이렇게도 쓸 수 있다 — huge page 로 TLB 미스 없애기 🟡 중급

4KB 대신 2MB 페이지

#include <sys/mman.h>

// 16GB 를 다루는데 페이지가 4KB 면 페이지가 400만 개 —
// TLB(주소 변환 캐시)는 보통 1500~3000개뿐이라 계속 미스가 납니다.
// 2MB 페이지로 바꾸면 페이지 수가 512분의 1이 됩니다.

void* alloc_huge(size_t bytes) {
    // 2MB 배수로 올림
    bytes = (bytes + (2u<<20) - 1) & ~((size_t)(2u<<20) - 1);
    void* p = mmap(nullptr, bytes, PROT_READ | PROT_WRITE,
                   MAP_PRIVATE | MAP_ANONYMOUS | MAP_HUGETLB, -1, 0);
    if (p == MAP_FAILED) {                       // hugetlbfs 미설정이면
        p = mmap(nullptr, bytes, PROT_READ | PROT_WRITE,
                 MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
        madvise(p, bytes, MADV_HUGEPAGE);        // THP 에 힌트만 주기
    }
    return p;
}

// 저지연이라면 여기에 두 줄 더
#include <sys/resource.h>
mlockall(MCL_CURRENT | MCL_FUTURE);   // 스왑 아웃 금지 ★
madvise(p, bytes, MADV_WILLNEED);     // 미리 페이지 폴트 일으켜 두기
std::memset(p, 0, bytes);             // 실제로 만져야 물리 페이지가 붙습니다
왜 memset 까지 하나 — 리눅스는 mmap 시점에 주소만 주고 처음 만질 때 물리 페이지를 붙입니다(demand paging). 핫패스에서 그 첫 접근이 일어나면 수 µs 짜리 페이지 폴트가 됩니다. 시작할 때 전부 만져 두는 것이 저지연의 기본 준비입니다.

시스템 쪽 설정과 확인

# ① 투명 huge page(THP) 상태 — 대부분 madvise 가 기본값
cat /sys/kernel/mm/transparent_hugepage/enabled
#   always [madvise] never

# ② 명시적 huge page 를 미리 예약 (2MB × 1024 = 2GB)
echo 1024 | sudo tee /proc/sys/vm/nr_hugepages
grep Huge /proc/meminfo

# ③ 내 프로세스가 실제로 huge page 를 쓰고 있나
grep -e AnonHugePages /proc/$(pgrep engine)/smaps_rollup

# ④ TLB 미스를 직접 세기 — 효과 확인은 이 숫자로
perf stat -e dTLB-load-misses,dTLB-loads,iTLB-load-misses ./app
#   전:  dTLB-load-misses  1,842,003,110   (12.4% of loads)
#   후:  dTLB-load-misses      9,551,208   ( 0.06% of loads)  ★
THP 가 오히려 해가 되는 경우 — always 로 두면 커널이 백그라운드에서 페이지를 모아 붙이느라(khugepaged) 예측 못 할 정지가 생깁니다. 데이터베이스(MongoDB · Redis · Oracle)가 THP 를 끄라고 안내하는 이유가 이것입니다. 저지연이라면 madvise + 명시적 huge page 조합이 안전합니다.

실무에선 이렇게 — NUMA 와 할당자 교체 🔴 실무

NUMA — 메모리에도 거리가 있습니다

# 소켓이 2개인 서버는 메모리도 두 덩어리로 나뉘어 있습니다
numactl --hardware
#   node 0 cpus: 0-15   node 0 size: 128000 MB
#   node 1 cpus: 16-31  node 1 size: 128000 MB
#   node distances:  0: 10 21   1: 21 10     ← 남의 노드는 2배 이상 멉니다

# 프로세스를 한 노드에 통째로 묶기 — 제일 쉬운 처방
numactl --cpunodebind=0 --membind=0 ./engine

# 지금 내 메모리가 어느 노드에 있나
numastat -p $(pgrep engine)
// 코드에서 다루려면 libnuma (apt install libnuma-dev, -lnuma)
#include <numa.h>
if (numa_available() >= 0) {
    numa_set_localalloc();                 // 항상 '내 노드'에서 할당
    void* p = numa_alloc_onnode(bytes, 1); // 특정 노드에 직접
}

// ★ first-touch 규칙 — 리눅스는 '처음 만진 스레드'의 노드에 페이지를 붙입니다
//    그래서 초기화를 단일 스레드로 하면 전부 노드 0 에 몰립니다
#pragma omp parallel for
for (size_t i = 0; i < n; ++i) a[i] = 0.0;   // 병렬로 초기화해야 흩어집니다
first-touch 를 모르면 병렬화가 안 먹습니다 — 배열을 memset 으로 초기화하면 전부 한 노드에 모여, 16스레드가 같은 메모리 컨트롤러 하나에 줄을 섭니다. “스레드를 늘렸는데 안 빨라진다”의 흔한 진짜 원인입니다.

malloc 을 통째로 갈아 끼우기

# 코드 수정 0 — 실행할 때 라이브러리만 끼워 넣습니다
sudo apt install libjemalloc-dev libtcmalloc-minimal4
LD_PRELOAD=/usr/lib/x86_64-linux-gnu/libjemalloc.so ./app

# mimalloc (마이크로소프트) — 2026년 기준 가장 균형이 좋습니다
git clone https://github.com/microsoft/mimalloc && cd mimalloc
cmake -B out -DCMAKE_BUILD_TYPE=Release && cmake --build out -j
LD_PRELOAD=$PWD/out/libmimalloc.so ./app

# 정적으로 링크하려면 (CMake)
#   find_package(mimalloc CONFIG REQUIRED)
#   target_link_libraries(app PRIVATE mimalloc-static)
할당자강점약점
glibc malloc기본 · 무난멀티스레드 약함
mimalloc빠름 · 작음비교적 새것
jemalloc단편화 최소메모리 더 씀
tcmalloc스레드 캐시설정 복잡
snmalloc메시지 패싱용도 특수
가장 싼 성능 개선 — 멀티스레드 서버에서 LD_PRELOAD 한 줄로 처리량이 20~60% 오르는 일이 흔합니다. 코드를 안 고치니 제일 먼저 시도할 것이고, 효과가 없으면 되돌리면 그만입니다.

한 단계 더 — 단편화를 수치로 보기 🧬 전문가

“메모리가 계속 늘어요”의 진단

// 누수가 아니라 '단편화'일 때가 많습니다.
// 판별 기준: 애플리케이션이 쥔 양(allocated) 과 OS 가 본 양(RSS) 의 비율

#include <malloc.h>
void dump_glibc() {
    mallinfo2 mi = mallinfo2();
    printf("사용 중 %zu MB / 힙 전체 %zu MB\n",
           mi.uordblks >> 20, mi.arena >> 20);
    malloc_info(0, stdout);          // XML 로 상세 (arena · bin 별)
}

// jemalloc 이라면 훨씬 자세히
#include <jemalloc/jemalloc.h>
void dump_je() {
    size_t sz = sizeof(size_t), allocated = 0, resident = 0;
    uint64_t epoch = 1;
    mallctl("epoch", nullptr, nullptr, &epoch, sizeof(epoch));  // 통계 갱신
    mallctl("stats.allocated", &allocated, &sz, nullptr, 0);
    mallctl("stats.resident", &resident,  &sz, nullptr, 0);
    // resident / allocated 가 1.5 를 넘으면 단편화를 의심합니다
}
판정 — allocated 는 평평한데 RSS 만 오르면 단편화, allocated 자체가 오르면 누수입니다. 처방이 완전히 다릅니다 — 단편화는 할당자 교체나 아레나, 누수는 ASan · heaptrack.

추적 도구와 실전 처방

# ① heaptrack — 누가 얼마나 할당했는지 그림으로 (KDE)
sudo apt install heaptrack heaptrack-gui
heaptrack ./app && heaptrack_gui heaptrack.app.*.zst
#   '할당 횟수' 순 정렬 → 대개 상위 3개가 전체의 80%

# ② ASan 으로 누수만 빠르게
g++ -fsanitize=address -g app.cpp -o app && ASAN_OPTIONS=detect_leaks=1 ./app

# ③ 시간에 따른 힙 모양 (valgrind)
valgrind --tool=massif ./app && ms_print massif.out.*

# ④ 급한 처방 — glibc 에게 안 쓰는 페이지를 돌려주라고 시키기
#    (long-running 서버에서 주기적으로 호출)
malloc_trim(0);
#    jemalloc 이면
mallctl("arena.4096.purge", nullptr, nullptr, nullptr, 0);
근본 처방은 하나입니다 — 수명이 같은 것들을 같은 아레나에 모으기. “요청 하나 = 아레나 하나”, “프레임 하나 = 아레나 하나”로 설계하면 단편화라는 개념 자체가 사라집니다. 게임 엔진과 컴파일러가 전부 이렇게 합니다 (LLVM 의 BumpPtrAllocator 가 정확히 위의 40줄입니다).
정리 — 범용 할당자를 버리는 순간이 아니라, 수명 패턴을 아는 순간 아레나를 씁니다. 표준 컨테이너를 그대로 쓰고 싶으면 std::pmr 이 답입니다.
HPC 05

병렬 계산 — OpenMP · oneTBB · std::execution

코어가 16개인데 하나만 쓰고 있다면 15배를 버리는 중입니다. 그런데 스레드를 16개 만든다고 16배가 되지는 않습니다. 어디까지 빨라지는지 미리 계산하고, 실제로 그만큼 나오는지 재는 법까지 봅니다. C++ 에서 병렬을 쓰는 세 가지 길을 실제 코드로 비교합니다.

스케일링 곡선 — 코어를 늘리면 어디서 눕는가 16x 8x 4x 1x 1 2 4 8 16 스레드 수 이상 (선형) 암달 상한 — 직렬 10% 면 아무리 늘려도 10배 실측 7.2x 곡선이 눕는 진짜 이유 ① 메모리 대역폭 포화 — 코어가 놀며 기다림 ② 동기화 · 원자 연산 · false sharing ③ 하이퍼스레딩 논리 코어는 절반 값 그래서 하는 일 직렬 구간을 먼저 줄입니다 (암달 상한을 올림) 작업 단위를 키워 동기화 횟수를 줄입니다 스레드 수는 물리 코어 수부터 재 봅니다
스레드를 늘리기 전에 상한을 먼저 계산합니다 — 안 그러면 헛수고입니다.

OpenMP — 한 줄로 시작하기

// 컴파일: g++ -std=c++23 -O2 -fopenmp a.cpp   (MSVC: /openmp:llvm)
#include <omp.h>
#include <vector>

double sum_of_squares(const std::vector<double>& v) {
    double s = 0.0;
    // reduction 이 스레드별 부분합을 만들고 마지막에 합칩니다 ★
    #pragma omp parallel for reduction(+:s) schedule(static)
    for (size_t i = 0; i < v.size(); ++i) s += v[i] * v[i];
    return s;
}

// 작업량이 원소마다 다를 때는 동적 분배
#pragma omp parallel for schedule(dynamic, 64)
for (int i = 0; i < n; ++i) heavy_but_uneven(i);

// 중첩 루프를 하나로 펴서 나누기
#pragma omp parallel for collapse(2)
for (int y = 0; y < H; ++y)
  for (int x = 0; x < W; ++x) img[y][x] = f(x, y);

// 작업(task) 기반 — 재귀에 씁니다
#pragma omp parallel
#pragma omp single
  quicksort_task(a, 0, n);          // 내부에서 #pragma omp task
schedule언제
static원소마다 비용이 같을 때(기본)
dynamic, k비용이 들쭉날쭉할 때
guided처음 크게, 갈수록 작게
auto런타임에 맡김
OpenMP 의 값어치 — 기존 루프에 주석 한 줄만 붙이면 병렬이 됩니다. -fopenmp 를 빼면 그 줄이 그냥 무시되어 원래 코드로 돌아갑니다. 과학 계산 · 이미지 처리에서 여전히 1순위입니다.

std::execution — 표준 병렬 알고리즘

#include <algorithm>
#include <execution>
#include <numeric>
#include <vector>

std::vector<double> v(100'000'000);

// ① 병렬 정렬 — 이것 하나로도 도입 가치가 있습니다
std::sort(std::execution::par, v.begin(), v.end());

// ② 병렬 + 벡터화(SIMD) 허용
std::transform(std::execution::par_unseq, v.begin(), v.end(), v.begin(),
               [](double x) { return std::sqrt(x) * 2.0; });

// ③ 리듀스 — accumulate 대신 reduce (결합 순서가 바뀔 수 있음에 주의)
double s = std::reduce(std::execution::par, v.begin(), v.end(), 0.0);

// ④ 변환 + 리듀스를 한 번에 (메모리를 한 번만 읽습니다) ★
double dot = std::transform_reduce(std::execution::par_unseq,
                a.begin(), a.end(), b.begin(), 0.0,
                std::plus<>{}, std::multiplies<>{});
# GCC/Clang 은 내부적으로 oneTBB 를 씁니다 — 링크가 필요합니다
sudo apt install libtbb-dev
g++ -std=c++23 -O2 par.cpp -ltbb -o par
# MSVC 는 추가 링크 없이 그대로 됩니다
par 정책의 두 함정 — ① 실행 순서 보장이 사라집니다. par_unseq 안에서 락을 잡거나 메모리를 할당하면 UB입니다. ② 부동소수 덧셈은 결합 법칙이 안 성립해서 reduce 결과가 accumulate 와 미세하게 다릅니다. 재현성이 필요한 계산(회계·검증)에서는 문제가 됩니다.

이렇게도 쓸 수 있다 — oneTBB — 작업 훔치기와 흐름 그래프 🟡 중급

parallel_for 와 parallel_reduce

#include <tbb/parallel_for.h>
#include <tbb/parallel_reduce.h>
#include <tbb/blocked_range.h>

// 범위를 트리처럼 쪼개고, 논 스레드가 남의 작업을 훔쳐 갑니다(work stealing)
tbb::parallel_for(tbb::blocked_range<size_t>(0, n, /*grain=*/1024),
    [&](const tbb::blocked_range<size_t>& r) {
        for (size_t i = r.begin(); i != r.end(); ++i) out[i] = f(in[i]);
    });

// 리듀스 — 부분 결과를 어떻게 합칠지까지 지정
double total = tbb::parallel_reduce(
    tbb::blocked_range<size_t>(0, n), 0.0,
    [&](const auto& r, double init) {
        for (size_t i = r.begin(); i != r.end(); ++i) init += v[i];
        return init;
    },
    std::plus<double>{});

// 2차원 범위도 그대로
tbb::parallel_for(tbb::blocked_range2d<int>(0, H, 32, 0, W, 32),
    [&](const auto& r) { /* r.rows() · r.cols() */ });
grain size 감각 — 한 덩어리가 최소 수 µs 어치는 되어야 합니다. 너무 잘게 쪼개면 스케줄링 비용이 계산보다 커집니다. 모르겠으면 생략하세요 — TBB 의 auto_partitioner 가 대개 잘 맞춥니다.

flow graph — 파이프라인을 그래프로

#include <tbb/flow_graph.h>
using namespace tbb::flow;

// 디코딩 → 리사이즈 → 인코딩 파이프라인을 노드로 표현합니다
graph g;

function_node<Path, Image> decode(g, unlimited,
    [](const Path& p) { return load_image(p); });

// 리사이즈는 CPU 를 많이 먹으니 동시 실행 수를 4로 제한
function_node<Image, Image> resize(g, 4,
    [](const Image& im) { return resize_to(im, 512, 512); });

// 저장은 디스크 경합을 막으려 하나씩만
function_node<Image, int> save(g, serial,
    [](const Image& im) { return write_jpeg(im); });

make_edge(decode, resize);
make_edge(resize, save);

for (auto& p : paths) decode.try_put(p);
g.wait_for_all();
언제 flow graph 인가 — 단계마다 병목의 성격이 다를 때입니다. (디코딩=CPU · 리사이즈=SIMD · 저장=디스크) 단계별로 동시 실행 수를 따로 줄 수 있어서 parallel_for 로는 못 하는 조절이 됩니다.

실무에선 이렇게 — 스레드 수를 어떻게 정하나 🔴 실무

hardware_concurrency 를 그대로 믿지 않기

#include <thread>
#include <fstream>
#include <sched.h>

unsigned usable_threads() {
    // ① 컨테이너 CPU 제한 (cgroup v2) — 쿠버네티스에서 필수 ★
    std::ifstream f("/sys/fs/cgroup/cpu.max");
    long quota, period;
    if (f >> quota >> period && quota > 0)
        return std::max<long>(1, quota / period);

    // ② affinity 로 묶여 있으면 그만큼만
    cpu_set_t set;
    if (sched_getaffinity(0, sizeof(set), &set) == 0)
        return (unsigned)CPU_COUNT(&set);

    return std::thread::hardware_concurrency();   // ③ 최후의 수단
}
// hardware_concurrency() 는 '논리 코어'를 셉니다.
// 8코어 16스레드 머신에서 16을 돌려주는데,
// 메모리 대역폭이 병목인 작업이라면 8이 더 빠릅니다.
쿠버네티스에서 제일 흔한 사고 — CPU limit 이 2인 파드에서 hardware_concurrency() 가 노드의 64 를 돌려줍니다. 스레드 64개가 2코어 몫을 나눠 쓰며 문맥 전환만 하다 끝납니다. cgroup 을 반드시 읽으세요.

재 보고 정하기 — 스케일링 실험

# ① 스레드 수를 바꿔 가며 시간을 잽니다 (강한 스케일링)
for t in 1 2 4 8 16 32; do
  OMP_NUM_THREADS=$t /usr/bin/time -f "$t threads: %e s" ./app
done
#   1 threads: 40.2 s
#   2 threads: 21.1 s   (1.9x)
#   4 threads: 11.4 s   (3.5x)
#   8 threads:  6.9 s   (5.8x)
#  16 threads:  5.6 s   (7.2x)  ← 여기서 눕습니다
#  32 threads:  6.1 s   (6.6x)  ← 오히려 나빠짐 (SMT + 대역폭)

# ② 대역폭이 한계인지 확인 — 이 값이 STREAM 근처면 코어를 늘려도 소용없음
perf stat -e uncore_imc/data_reads/,uncore_imc/data_writes/ ./app

# ③ 스레드가 실제로 일하는 비율
perf stat -e task-clock,context-switches,cpu-migrations ./app
#   task-clock 이 (실행시간 × 스레드수) 에 못 미치면 놀고 있는 것입니다
OMP_NUM_THREADS 는 std::execution 에도 안 먹습니다 — TBB 기반이라 TBB_NUM_THREADS 나 tbb::global_control 로 조절합니다. 벤치마크 스크립트를 짤 때 자주 놓치는 부분입니다.

한 단계 더 — 대역폭 한계와 루프라인 🧬 전문가

연산 강도로 상한을 계산합니다

연산 강도(AI) = 부동소수 연산 수 ÷ 메모리에서 읽은 바이트 수

예) c[i] = a[i] + b[i]  (double)
    연산 1회 · 메모리 24바이트(a 읽기 8 + b 읽기 8 + c 쓰기 8)
    AI = 1/24 ≈ 0.042 FLOP/byte

머신 성능:
    최대 연산  ~1,500 GFLOP/s  (16코어 · AVX-512 · FMA)
    최대 대역폭   ~200 GB/s

    대역폭이 결정하는 상한 = 0.042 × 200 = 8.4 GFLOP/s
    → 최대 연산 성능의 0.6% 밖에 못 씁니다 ★
    → 이런 루프는 코어를 늘려도 소용없습니다. 메모리가 병목입니다.

    AI 가 10 을 넘어야(예: 행렬 곱) 연산이 병목이 되고,
    그때부터 SIMD · 코어 수가 의미를 갖습니다.
이 계산이 하는 일 — “병렬화해도 소용없는 루프”를 코드를 짜기 전에 걸러 줍니다. 대역폭 한계 루프에서 할 일은 병렬화가 아니라 메모리를 덜 읽게 만드는 것(융합 · 타일링 · 자료형 축소)입니다.

내 머신의 상한 재기

# ① 메모리 대역폭 실측 — STREAM 벤치마크가 사실상 표준
git clone https://github.com/jeffhammond/STREAM && cd STREAM
gcc -O3 -march=native -fopenmp -DSTREAM_ARRAY_SIZE=200000000 stream.c -o stream
OMP_NUM_THREADS=16 ./stream
#   Triad:  182,441.3 MB/s      ← 이게 우리 머신의 천장입니다

# ② 루프라인 그림 자동 생성 (인텔 도구)
advisor --collect=roofline --project-dir=./adv -- ./app
advisor-gui ./adv
#   점이 대각선(대역폭 한계) 위에 붙어 있으면 메모리 병목,
#   수평선(연산 한계) 근처면 계산 병목입니다

# ③ 무료 대안 — likwid
likwid-perfctr -C 0-15 -g MEM_DP ./app
“코어를 더 사면 되지 않나”에 대한 답 — 대역폭 병목이면 코어를 두 배로 늘려도 그대로입니다. 같은 돈이면 채널 수가 많은 메모리 구성이나 알고리즘의 데이터 재사용에 쓰는 것이 맞습니다.
정리 — 병렬화는 상한을 먼저 계산하고 시작합니다 — 암달의 직렬 비율과 메모리 대역폭 중 낮은 쪽이 진짜 천장입니다.
HPC 06

GPU — CUDA 첫 커널부터 실제 이득 판정까지

GPU 는 코어가 수천 개인 대신 하나하나가 느리고, 전부 같은 일을 해야 합니다. 그래서 “빠르다/느리다”가 아니라 맞는 문제 / 안 맞는 문제가 있습니다. 진짜 도는 커널을 하나 짜 보고, PCIe 복사 비용을 넘길 수 있는지 계산하는 법까지 갑니다.

CUDA 데이터 흐름 — 복사 · 실행 · 복사 호스트 메모리 h_a · h_b · 일반 RAM 수십~수백 GB ① H2D 디바이스 전역 메모리 d_a · d_b · d_c (VRAM) 대역폭 ~1TB/s · 용량 8~80GB PCIe 4.0 ≈ 25GB/s 전역 메모리보다 40배 느립니다 복사가 병목이 되는 이유 ② 커널 실행 add<<<blocks, 256>>>(d_a, d_b, d_c) 수천 스레드가 같은 코드를 동시에 ③ D2H 결과 h_c 여기까지 와야 CPU 가 씁니다 이 왕복이 계산보다 클 때가 많습니다 grid → block → thread — 스레드 하나가 원소 하나를 맡습니다 block 0 thread 0..255 block 1 thread 0..255 block 2 thread 0..255 block 3 thread 0..255 int i = blockIdx.x * blockDim.x + threadIdx.x; → 0 부터 1023 까지 서로 다른 i 가 배정됩니다 warp = 32 스레드가 한 몸으로 움직입니다 — 같은 warp 안에서 if 가 갈리면 양쪽을 다 실행합니다
GPU 최적화의 절반은 커널이 아니라 복사를 줄이는 일입니다.

첫 커널 — 벡터 덧셈 (진짜 도는 코드)

// vecadd.cu
#include <cstdio>
#include <vector>

__global__ void add(const float* a, const float* b, float* c, int n) {
    int i = blockIdx.x * blockDim.x + threadIdx.x;
    if (i < n) c[i] = a[i] + b[i];      // 범위 검사 필수 (남는 스레드가 생김)
}

int main() {
    const int n = 1 << 24;              // 1600만 개
    std::vector<float> ha(n, 1.0f), hb(n, 2.0f), hc(n);

    float *da, *db, *dc;
    cudaMalloc(&da, n * sizeof(float));
    cudaMalloc(&db, n * sizeof(float));
    cudaMalloc(&dc, n * sizeof(float));

    cudaMemcpy(da, ha.data(), n * sizeof(float), cudaMemcpyHostToDevice);
    cudaMemcpy(db, hb.data(), n * sizeof(float), cudaMemcpyHostToDevice);

    int threads = 256;                              // 보통 128~512
    int blocks  = (n + threads - 1) / threads;      // 올림 나눗셈
    add<<<blocks, threads>>>(da, db, dc, n);

    cudaMemcpy(hc.data(), dc, n * sizeof(float), cudaMemcpyDeviceToHost);
    cudaDeviceSynchronize();                        // 커널 에러는 여기서 드러납니다

    printf("%f\n", hc[0]);                          // 3.000000
    cudaFree(da); cudaFree(db); cudaFree(dc);
}
# 설치 확인 → 컴파일 → 실행
nvidia-smi                       # 드라이버·GPU 확인
nvcc --version                   # CUDA 12.x / 13.x
nvcc -O3 -arch=sm_86 vecadd.cu -o vecadd   # sm_86 = Ampere(RTX 30xx)
./vecadd

에러 확인과 CMake 통합

// CUDA API 는 예외를 안 던집니다 — 반환값을 반드시 봅니다
#define CK(x) do { cudaError_t e = (x); \
    if (e != cudaSuccess) { \
      fprintf(stderr, "%s:%d %s\n", __FILE__, __LINE__, cudaGetErrorString(e)); \
      std::abort(); } } while (0)

CK(cudaMalloc(&da, bytes));
add<<<blocks, threads>>>(da, db, dc, n);
CK(cudaGetLastError());        // 커널 '실행 요청' 에러
CK(cudaDeviceSynchronize());   // 커널 '실행 중' 에러 ★ 둘 다 봐야 합니다
cmake_minimum_required(VERSION 3.24)
project(gpuapp LANGUAGES CXX CUDA)

set(CMAKE_CUDA_STANDARD 20)
set(CMAKE_CUDA_ARCHITECTURES native)   # 이 머신 GPU 에 맞춰 자동 ★

add_executable(gpuapp main.cu kernel.cu)
target_compile_options(gpuapp PRIVATE
  $<$<COMPILE_LANGUAGE:CUDA>:--expt-relaxed-constexpr>)
가장 흔한 첫 좌절 — 커널이 아무 일도 안 하고 조용히 끝납니다. 원인은 대개 ③ 결과를 복사해 오지 않았거나, 커널이 실패했는데 확인을 안 했거나, -arch 가 GPU 와 안 맞아서입니다. CK() 매크로를 처음부터 쓰세요.

이렇게도 쓸 수 있다 — Thrust — STL 처럼 쓰는 GPU 🟡 중급

커널을 안 짜고 GPU 쓰기

// Thrust 는 CUDA 툴킷에 포함되어 있습니다 (별도 설치 불필요)
#include <thrust/device_vector.h>
#include <thrust/host_vector.h>
#include <thrust/sort.h>
#include <thrust/transform_reduce.h>

thrust::host_vector<float>   h(1 << 24);
thrust::device_vector<float> d = h;      // 이 한 줄이 cudaMemcpy 입니다

// 정렬 — CPU std::sort 대비 10~30배
thrust::sort(d.begin(), d.end());

// 변환 + 리듀스
float s = thrust::transform_reduce(d.begin(), d.end(),
             [] __device__ (float x) { return x * x; },   // 람다에 __device__
             0.0f, thrust::plus<float>{});

h = d;                                   // 되돌리기 (D2H)

// CUDA 12.x 부터는 표준 알고리즘도 GPU 로 (nvc++ / --stdpar)
// nvc++ -stdpar=gpu app.cpp
// std::sort(std::execution::par, v.begin(), v.end());  ← 그대로 GPU 실행
먼저 Thrust 로 재 보세요 — 정렬 · 스캔 · 리듀스 · 히스토그램은 직접 짠 커널이 Thrust 를 이기기 어렵습니다. (내부적으로 CUB 라는 고도로 튜닝된 구현을 씁니다.) 직접 짜는 것은 Thrust 로 표현이 안 되는 계산부터입니다.

NVIDIA 밖 — SYCL · HIP · Metal

API대상메모
CUDANVIDIA생태계 압도적
HIP / ROCmAMDCUDA 를 거의 그대로
SYCL (oneAPI)전부표준 C++ 문법
OpenCL전부낡음 · 유지보수용
Metal애플맥·아이폰 전용
Vulkan compute전부그래픽과 함께 쓸 때
// SYCL — 표준 C++ 만으로 GPU 커널을 씁니다 (별도 언어 확장 없음)
#include <sycl/sycl.hpp>
sycl::queue q;                                  // 알아서 GPU 를 고릅니다
float* a = sycl::malloc_shared<float>(n, q);    // 통합 메모리 (복사 불필요)
q.parallel_for(n, [=](sycl::id<1> i) { a[i] = a[i] * 2.0f; }).wait();
sycl::free(a, q);

// HIP — CUDA 코드를 자동 변환할 수 있습니다
//   hipify-perl vecadd.cu > vecadd.hip.cpp
//   hipcc -O3 vecadd.hip.cpp -o vecadd
2026년의 현실 — 이식성이 진짜 필요하면 SYCL 이 답이지만, 튜닝된 라이브러리(cuBLAS · cuDNN · TensorRT)의 격차 때문에 실무는 여전히 CUDA 가 기본입니다. HIP 은 CUDA 소스를 90% 그대로 받아 AMD 로 옮기는 현실적인 통로입니다.

실무에선 이렇게 — GPU 가 이득인지 계산해 보기 🔴 실무

손익분기 계산

[ 벡터 덧셈 1600만 개 (float) — 위의 예제 ]
  데이터 전송   192MB (a, b 보내고 c 받기)
  PCIe 4.0     25GB/s  →  전송 시간 약 7.7ms
  커널 자체     약 0.4ms
  ─────────────────────────────────
  GPU 총합      약 8.1ms
  CPU (16스레드 AVX)  약 3.5ms
  → GPU 가 2배 느립니다 ★ 전송이 전부를 먹었습니다

[ 같은 데이터로 반복 계산을 100번 한다면 ]
  전송 7.7ms + 커널 0.4 × 100 = 47.7ms
  CPU 3.5 × 100 = 350ms
  → GPU 가 7배 빠릅니다

결론 — GPU 는 '한 번 올린 데이터로 얼마나 오래 계산하느냐'가 전부입니다.
       연산 강도(h05) 를 GPU 에도 똑같이 적용하면 됩니다.
GPU 가 안 맞는 일 — ① 데이터가 작을 때(수 MB 이하), ② 분기가 데이터마다 다를 때(warp divergence), ③ 임의 접근이 심할 때, ④ 매 호출마다 새 데이터를 올려야 할 때. 넷 중 하나라도 해당되면 CPU + SIMD 가 대개 낫습니다.

복사를 줄이는 세 가지 기법

// ① 고정 메모리(pinned) — 복사 속도가 2배 가까이 오릅니다
float* h;
cudaMallocHost(&h, bytes);          // 페이지 아웃되지 않는 메모리
// (일반 메모리는 커널이 임시 버퍼로 한 번 더 복사합니다)

// ② 스트림으로 겹치기 — 복사하는 동안 다른 청크를 계산
cudaStream_t s[4];
for (int i = 0; i < 4; ++i) cudaStreamCreate(&s[i]);
for (int i = 0; i < 4; ++i) {
    size_t off = i * chunk;
    cudaMemcpyAsync(d + off, h + off, chunk * 4, cudaMemcpyHostToDevice, s[i]);
    add<<<blk, 256, 0, s[i]>>>(d + off, ..., chunk);
    cudaMemcpyAsync(h + off, d + off, chunk * 4, cudaMemcpyDeviceToHost, s[i]);
}
cudaDeviceSynchronize();            // 전송과 계산이 겹쳐 총 시간이 줄어듭니다

// ③ 통합 메모리 — 코드가 간단해집니다 (성능은 상황에 따라)
float* u;
cudaMallocManaged(&u, bytes);       // CPU·GPU 가 같은 포인터를 씁니다
cudaMemPrefetchAsync(u, bytes, 0);  // 미리 GPU 로 옮겨 두기
스트림의 효과 — 위 예제에서 청크 4개로 나누자 8.1ms → 4.6ms. 커널을 한 줄도 안 고치고 전송과 계산을 겹친 것만으로 얻은 결과입니다.

한 단계 더 — 커널을 프로파일링하기 🧬 전문가

Nsight 두 가지

# ① Nsight Systems — '전체 타임라인' (복사와 커널이 겹치나?)
nsys profile -o report ./gpuapp
nsys stats report.nsys-rep
#   CUDA Memory Operations:  HtoD 7.71ms (65%)   ← 여기가 문제
#   CUDA Kernels:            add    0.42ms

# ② Nsight Compute — '커널 하나 내부' (왜 느린가?)
ncu --set full -o kern ./gpuapp
ncu-ui kern.ncu-rep
#   주요 지표:
#     Achieved Occupancy      63%   ← 스레드가 충분히 안 채워짐
#     Memory Throughput      812 GB/s (이론 대비 81%)  ← 대역폭 한계
#     Branch Efficiency       74%   ← warp divergence 있음

# ③ 코드 안에서 시간 재기 (CUDA 이벤트)
cudaEvent_t a, b; cudaEventCreate(&a); cudaEventCreate(&b);
cudaEventRecord(a); kernel<<<...>>>(); cudaEventRecord(b);
cudaEventSynchronize(b);
float ms; cudaEventElapsedTime(&ms, a, b);
보는 순서 — 먼저 nsys 로 타임라인을 봅니다. 커널이 전체의 20% 밖에 안 되면 커널을 튜닝해도 소용없습니다. 커널이 지배적일 때만 ncu 로 내부를 파고듭니다.

커널 최적화 체크리스트

증상처방
점유율(occupancy) 낮음블록 크기 조정 · 레지스터 줄이기
메모리 비합체 접근연속 스레드가 연속 주소를 읽게
warp divergence분기를 warp 경계로 정렬
전역 메모리 반복 읽기__shared__ 로 타일링
커널 호출이 잦음커널 융합 · CUDA Graph
동기화 과다__syncthreads() 줄이기
// 공유 메모리 타일링 — GPU 최적화의 대표 기법
__global__ void matmul(const float* A, const float* B, float* C, int N) {
    __shared__ float sA[16][16], sB[16][16];   // 블록 안에서 공유 (아주 빠름)
    int r = blockIdx.y * 16 + threadIdx.y;
    int c = blockIdx.x * 16 + threadIdx.x;
    float acc = 0.f;
    for (int t = 0; t < N / 16; ++t) {
        sA[threadIdx.y][threadIdx.x] = A[r * N + t * 16 + threadIdx.x];
        sB[threadIdx.y][threadIdx.x] = B[(t * 16 + threadIdx.y) * N + c];
        __syncthreads();
        for (int k = 0; k < 16; ++k) acc += sA[threadIdx.y][k] * sB[k][threadIdx.x];
        __syncthreads();
    }
    C[r * N + c] = acc;
}
// 전역 메모리 읽기가 16분의 1로 줄어 4~8배 빨라집니다.
// 그래도 cuBLAS 가 더 빠릅니다 — 실무에서는 cuBLAS 를 씁니다.
정리 — GPU 는 한 번 올린 데이터로 오래 계산할 때만 이깁니다. 커널을 짜기 전에 전송 시간부터 계산하고, 짤 때는 Thrust · cuBLAS 를 먼저 봅니다.
HPC 07

영상 · 이미지 — OpenCV C++ 와 FFmpeg libav

파이썬 OpenCV 는 편합니다. 그런데 프레임마다 파이썬 루프를 도는 순간 초당 30장도 못 냅니다. C++ 로 내려오면 디코딩된 프레임 버퍼를 그대로 넘겨받아 복사 없이 처리할 수 있습니다. OpenCV 를 붙이는 법부터 FFmpeg 로 직접 디코딩하는 코드까지 봅니다.

영상 디코딩 파이프라인 — 픽셀을 만지기까지 네 단계 파일 · 스트림 mp4 · rtsp 컨테이너 디먹서 av_read_frame AVPacket(압축) 디코더 H.264 · HEVC · AV1 여기가 제일 비쌈 AVFrame YUV420P 아직 BGR 아님 cv::Mat sws_scale 로 변환 이제 처리 가능 1080p 한 시간 영상을 처리할 때 시간이 가는 곳 — 가로 폭이 곧 비율 디코딩 78% 12% 8% demux 2% 색변환 처리 0 25 50 75 100 전체 처리 시간 대비 % 픽셀을 만지기도 전에 78% 를 씁니다 — 알고리즘을 아무리 다듬어도 초록 칸(8%) 안에서만 줄어듭니다 하드웨어 디코더를 켜면 NVDEC·VAAPI·VideoToolbox·QSV — 디코딩 78% → 6% 파이썬 OpenCV 와 갈리는 곳 픽셀 루프를 파이썬으로 돌면 여기서 무너집니다
영상 처리 최적화는 대개 디코더를 바꾸는 일부터 시작합니다.

OpenCV C++ — 설치와 첫 파이프라인

# vcpkg 로 (윈도우·리눅스·맥 동일)
git clone https://github.com/microsoft/vcpkg && ./vcpkg/bootstrap-vcpkg.sh
./vcpkg/vcpkg install opencv4[core,imgproc,videoio,dnn]

# 또는 리눅스 패키지로 (버전은 낡을 수 있습니다)
sudo apt install libopencv-dev
cmake_minimum_required(VERSION 3.24)
project(vid LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 23)

find_package(OpenCV REQUIRED)
add_executable(vid main.cpp)
target_link_libraries(vid PRIVATE ${OpenCV_LIBS})

# 빌드 (vcpkg 툴체인 지정)
# cmake -B build -DCMAKE_TOOLCHAIN_FILE=vcpkg/scripts/buildsystems/vcpkg.cmake
#include <opencv2/opencv.hpp>

int main() {
    cv::VideoCapture cap("input.mp4");
    if (!cap.isOpened()) return 1;

    cv::Mat frame, gray, edges;
    // 버퍼를 루프 밖에 두는 것이 핵심입니다 —
    // 안에 두면 프레임마다 수 MB 를 새로 할당합니다 ★
    while (cap.read(frame)) {
        cv::cvtColor(frame, gray, cv::COLOR_BGR2GRAY);
        cv::GaussianBlur(gray, gray, {5, 5}, 1.5);
        cv::Canny(gray, edges, 50, 150);
        cv::imshow("edges", edges);
        if (cv::waitKey(1) == 27) break;
    }
}
cv::Mat 은 참조 세는 얕은 복사입니다 — Mat b = a; 는 픽셀을 복사하지 않습니다(같은 버퍼를 가리킴). 진짜 복사는 a.clone() 또는 a.copyTo(b). 파이썬 numpy 의 뷰와 같은 함정이라 둘 다 아는 사람이 오히려 덜 헷갈립니다.

Mat 을 다룰 때 아는 만큼 빨라집니다

// ① 픽셀 접근 — at() 은 매번 경계 검사를 합니다 (디버그에서 특히 느림)
for (int y = 0; y < img.rows; ++y) {
    uchar* p = img.ptr<uchar>(y);         // 행 포인터 ★
    for (int x = 0; x < img.cols * img.channels(); ++x) p[x] = 255 - p[x];
}

// ② 메모리가 연속이면 1차원으로 펴서 돌 수 있습니다
if (img.isContinuous()) {
    uchar* p = img.data;
    size_t n = img.total() * img.channels();
    for (size_t i = 0; i < n; ++i) p[i] = lut[p[i]];
}

// ③ 그런데 대부분은 OpenCV 내장 함수가 이미 SIMD·멀티스레드입니다
cv::LUT(img, lut, out);                   // 직접 짠 루프보다 빠릅니다

// ④ 외부 버퍼를 복사 없이 감싸기 — FFmpeg · 카메라 SDK 와 붙일 때
cv::Mat wrap(h, w, CV_8UC3, external_ptr, stride);   // 소유권 없음 ★
// 원본 버퍼가 살아 있는 동안만 유효합니다

// ⑤ 관심 영역(ROI)도 복사가 아닙니다
cv::Mat roi = img(cv::Rect(100, 100, 200, 200));     // 같은 메모리를 봄
Canny 1080p 1000장시간
파이썬 + numpy 픽셀 루프~420초
파이썬 cv2 (함수 호출)~9.5초
C++ (버퍼 재사용)~7.8초
C++ + TBB 병렬~1.4초
정직한 결론 — cv2 함수만 부르는 파이썬 코드는 이미 C++ 입니다(9.5초 vs 7.8초). C++ 로 내려갈 값어치는 픽셀 루프를 직접 짜야 할 때, 프레임을 복사 없이 다른 라이브러리로 넘길 때, 병렬 파이프라인을 조립할 때 생깁니다.

이렇게도 쓸 수 있다 — FFmpeg libav 로 직접 디코딩 🟡 중급

디코딩 루프 전체 (FFmpeg 7.x · 8.x)

extern "C" {
#include <libavformat/avformat.h>
#include <libavcodec/avcodec.h>
#include <libswscale/swscale.h>
}

int main(int argc, char** argv) {
    AVFormatContext* fmt = nullptr;
    avformat_open_input(&fmt, argv[1], nullptr, nullptr);
    avformat_find_stream_info(fmt, nullptr);

    // 비디오 스트림 찾기
    const AVCodec* dec = nullptr;
    int vs = av_find_best_stream(fmt, AVMEDIA_TYPE_VIDEO, -1, -1, &dec, 0);

    AVCodecContext* ctx = avcodec_alloc_context3(dec);
    avcodec_parameters_to_context(ctx, fmt->streams[vs]->codecpar);
    ctx->thread_count = 0;              // 0 = 코어 수만큼 자동 ★
    avcodec_open2(ctx, dec, nullptr);

    AVPacket* pkt = av_packet_alloc();
    AVFrame*  frm = av_frame_alloc();

    while (av_read_frame(fmt, pkt) >= 0) {
        if (pkt->stream_index == vs) {
            if (avcodec_send_packet(ctx, pkt) == 0) {
                // 한 패킷에서 프레임이 0개일 수도, 여러 개일 수도 있습니다 ★
                while (avcodec_receive_frame(ctx, frm) == 0) {
                    // frm->data[0..2] = Y, U, V 평면
                    // frm->linesize[0] = 한 줄 바이트 수 (폭과 다릅니다!)
                    process(frm);
                }
            }
        }
        av_packet_unref(pkt);           // 참조를 놓아 줍니다 (안 하면 누수)
    }
    avcodec_send_packet(ctx, nullptr);  // 플러시 — 남은 프레임 뽑기
    while (avcodec_receive_frame(ctx, frm) == 0) process(frm);

    av_frame_free(&frm); av_packet_free(&pkt);
    avcodec_free_context(&ctx); avformat_close_input(&fmt);
}
초보가 반드시 틀리는 두 가지 — ① linesize 는 폭과 다릅니다 (정렬 때문에 패딩이 붙습니다). 폭으로 계산하면 화면이 비스듬히 밀립니다. ② send 한 번에 receive 는 여러 번 — B프레임 때문에 디코더가 프레임을 모았다가 한꺼번에 내놓습니다.

YUV 를 BGR 로 · 하드웨어 디코더

// ① 색 변환 — 컨텍스트를 매 프레임 만들면 안 됩니다 (한 번만)
SwsContext* sws = sws_getContext(
    ctx->width, ctx->height, ctx->pix_fmt,       // 입력
    ctx->width, ctx->height, AV_PIX_FMT_BGR24,   // 출력
    SWS_BILINEAR, nullptr, nullptr, nullptr);

cv::Mat bgr(ctx->height, ctx->width, CV_8UC3);
uint8_t* dst[4] = { bgr.data, nullptr, nullptr, nullptr };
int      lin[4] = { (int)bgr.step, 0, 0, 0 };
sws_scale(sws, frm->data, frm->linesize, 0, ctx->height, dst, lin);
// → 이제 bgr 은 OpenCV 로 바로 처리할 수 있습니다 (복사 한 번뿐)
// ② 하드웨어 디코더 붙이기 — CPU 사용률이 극적으로 떨어집니다
AVBufferRef* hw = nullptr;
av_hwdevice_ctx_create(&hw, AV_HWDEVICE_TYPE_CUDA, nullptr, nullptr, 0);
//   대안: AV_HWDEVICE_TYPE_VAAPI(인텔·AMD 리눅스)
//         AV_HWDEVICE_TYPE_VIDEOTOOLBOX(맥)
//         AV_HWDEVICE_TYPE_D3D11VA(윈도우)
ctx->hw_device_ctx = av_buffer_ref(hw);
avcodec_open2(ctx, dec, nullptr);

// 디코딩된 프레임은 GPU 메모리에 있습니다 —
// CPU 로 내리려면 명시적으로
AVFrame* sw = av_frame_alloc();
av_hwframe_transfer_data(sw, frm, 0);
// ★ 후처리도 GPU 에서 한다면 내리지 말고 그대로 두는 것이 정답입니다
실측 — 4K HEVC 를 8스레드 CPU 디코딩하면 CPU 780% 를 쓰며 24fps. NVDEC 로 바꾸면 CPU 45% 에 210fps. 영상 서버에서 장비 대수를 몇 배로 줄이는 변화입니다.

실무에선 이렇게 — 파이썬과 C++ 를 어떻게 나누나 🔴 실무

판단 기준

하는 일어디서
실험 · 파라미터 탐색파이썬
cv2 함수만 이어 붙이기파이썬
모델 학습파이썬
픽셀 단위 커스텀 알고리즘C++
실시간 30fps 이상 다중 스트림C++
디코더 → 추론 → 인코더 무복사C++
임베디드 · 카메라 장비 탑재C++
서버 배포(파이썬 없는 환경)C++
실무에서 가장 흔한 구조 — 파이썬으로 알고리즘을 확정하고, 확정된 것만 C++ 로 옮겨 pybind11 로 다시 파이썬에서 부릅니다. (🐍 파이썬 연동 탭 참고.) 전부 옮기는 팀은 대개 후회합니다.

다중 스트림 파이프라인 뼈대

// 카메라 8대를 동시에 처리하는 구조 — 단계마다 스레드를 분리합니다
struct Pipeline {
    SpscQueue<AVFrame*, 8>  decoded_;    // h03 의 큐
    SpscQueue<Result,   64> results_;

    void decode_thread(int cam) {        // 카메라당 하나
        while (running_) {
            AVFrame* f = decode_next(cam);
            if (!decoded_.try_push(f)) { av_frame_free(&f); drops_++; }
            // ★ 큐가 차면 '기다리지 않고 버립니다' —
            //   실시간 영상에서는 늦은 프레임이 없는 프레임보다 나쁩니다
        }
    }
    void infer_thread() {                // GPU 한 장을 공유
        std::vector<AVFrame*> batch;
        while (running_) {
            AVFrame* f;
            while (batch.size() < 8 && decoded_.try_pop(f)) batch.push_back(f);
            if (!batch.empty()) { results_.try_push(infer(batch)); batch.clear(); }
        }
    }
};
실시간 영상의 철칙 — 큐를 무한정 키우지 마세요. 처리가 못 따라가면 지연이 계속 쌓여 “10초 전 화면”을 보게 됩니다. 고정 크기 큐 + 오래된 프레임 버리기가 정답이고, 버린 수(drops_)를 지표로 내보내야 합니다.

한 단계 더 — 복사를 0으로 만드는 무복사 경로 🧬 전문가

GPU 안에서 끝내기

[ 흔한 (느린) 경로 — 복사 4번 ]
  NVDEC(GPU) → CPU 복사 → cv::Mat → GPU 복사 → 추론
             → CPU 복사 → 인코더로 GPU 복사
  1080p 프레임 하나가 3MB, 30fps × 8스트림이면 초당 2.8GB 를 PCIe 로 오갑니다

[ 무복사 경로 — 복사 0번 ★ ]
  NVDEC(GPU) ─┬→ CUDA 커널(전처리) ─→ TensorRT 추론 ─→ NVENC(GPU)
              └ 모든 단계가 같은 디바이스 메모리를 가리킵니다

  구현 도구:
   · DeepStream (NVIDIA) — 위 경로를 GStreamer 로 조립해 줍니다
   · cv::cuda::GpuMat     — OpenCV 의 GPU 버전 Mat
   · AVFrame 의 hw_frames_ctx 를 그대로 CUDA 포인터로 사용
   · CUDA-OpenGL/Vulkan interop — 화면에 그릴 때도 안 내려옵니다
효과 — 8스트림 실시간 객체 검출에서 복사 경로 GPU 하나당 6스트림 → 무복사 경로 GPU 하나당 22스트림. 모델을 바꾸지 않고 메모리 이동만 없앤 결과입니다.

인코딩과 스트리밍까지

// 처리한 프레임을 다시 내보내기 (H.264 · NVENC)
const AVCodec* enc = avcodec_find_encoder_by_name("h264_nvenc");
//   CPU 인코더라면 "libx264" · AV1 이라면 "av1_nvenc" / "libsvtav1"
AVCodecContext* ec = avcodec_alloc_context3(enc);
ec->width = W; ec->height = H;
ec->time_base = {1, 30};
ec->framerate = {30, 1};
ec->pix_fmt = AV_PIX_FMT_YUV420P;
ec->bit_rate = 4'000'000;
av_opt_set(ec->priv_data, "preset", "p4", 0);       // NVENC 프리셋
av_opt_set(ec->priv_data, "tune",   "ll",  0);      // 저지연
avcodec_open2(ec, enc, nullptr);

// 실무에서는 대개 GStreamer 나 OBS 계열을 쓰지만,
// libav 를 직접 다루면 지연·버퍼링을 완전히 통제할 수 있습니다.
도구쓰는 자리
libav 직접최대 제어 · 임베디드
GStreamer파이프라인 조립 · 플러그인
DeepStreamNVIDIA 다중 스트림 추론
OpenCV VideoIO프로토타입 · 간단한 도구
libvpx · SVT-AV1코덱 직접 제어
고르는 법 — 지연이 요구사항이면 libav 직접, 기능 조합이 요구사항이면 GStreamer 입니다. GStreamer 는 편한 대신 버퍼 큐가 곳곳에 숨어 있어 지연을 200ms 아래로 내리기가 까다롭습니다.
정리 — 영상에서 C++ 의 값어치는 픽셀 루프가 아니라 디코더 · 추론 · 인코더를 복사 없이 잇는 것에 있습니다.
HPC 08

오디오 실시간 — 콜백 안에서는 아무것도 하지 않습니다

오디오 드라이버는 몇 밀리초마다 정확히 콜백을 부르고, 그 안에서 다음 버퍼를 채워 달라고 합니다. 한 번이라도 늦으면 스피커에서 “틱” 소리가 납니다. 그래서 실시간 오디오 스레드에는 다른 어떤 분야보다 엄격한 규칙이 있습니다. 그 규칙과, UI 와 안전하게 교신하는 법을 봅니다.

오디오 콜백 데드라인 — 128프레임 · 48kHz → 주기 2.67ms 정상 — 매 주기 안에 끝냅니다 한 주기 2.67ms 중 1.1ms 사용 — 남는 시간이 안전 여유입니다 위반 — 한 번만 늦어도 소리가 튑니다 예산 초과 버퍼가 비어 xrun — 스피커에서 '틱' 콜백 안에서 금지 new · malloc · std::string · vector 재할당 mutex · condition_variable · 파일 · 소켓 로그 출력 · 예외 · 페이지 폴트 · sleep 이유 — 전부 '얼마나 걸릴지 모르는' 연산입니다 대신 이렇게 버퍼는 prepareToPlay 에서 미리 잡습니다 UI 와는 락프리 링 버퍼 · atomic 으로 교신 무거운 일은 다른 스레드에 넘기고 즉시 반환 파라미터는 atomic<float> 로 읽고 부드럽게 보간
실시간 오디오의 규칙은 하나입니다 — 시간이 정해지지 않은 연산 금지.

PortAudio — 가장 짧은 실동작 예제

sudo apt install portaudio19-dev      # 또는 vcpkg install portaudio
g++ -std=c++23 -O2 sine.cpp -lportaudio -o sine
#include <portaudio.h>
#include <cmath>
#include <atomic>

struct State {
    double phase = 0.0;
    std::atomic<float> freq{440.0f};   // UI 가 바꾸는 값 ★
};

// 이 함수는 '실시간 스레드'에서 불립니다 — 규칙이 적용됩니다
static int cb(const void*, void* out, unsigned long n,
              const PaStreamCallbackTimeInfo*, PaStreamCallbackFlags, void* ud) {
    auto* s = static_cast<State*>(ud);
    float* o = static_cast<float*>(out);
    const double f  = s->freq.load(std::memory_order_relaxed);
    const double dp = 2.0 * M_PI * f / 48000.0;
    for (unsigned long i = 0; i < n; ++i) {
        float v = (float)std::sin(s->phase);
        *o++ = v; *o++ = v;              // 스테레오
        s->phase += dp;
        if (s->phase > 2 * M_PI) s->phase -= 2 * M_PI;
    }
    return paContinue;
}

int main() {
    State st;
    Pa_Initialize();
    PaStream* stream;
    Pa_OpenDefaultStream(&stream, 0, 2, paFloat32, 48000, 128, cb, &st);
    Pa_StartStream(stream);
    std::getchar();                      // UI 스레드에서 st.freq 를 바꿔도 안전
    Pa_StopStream(stream); Pa_CloseStream(stream); Pa_Terminate();
}
버퍼 크기의 뜻 — 128프레임 @48kHz = 2.67ms 주기이자 지연입니다. 작을수록 반응이 빠르지만 콜백 여유가 줄어 xrun 위험이 커집니다. 라이브 연주는 64~128, 재생 전용은 512~1024 가 보통입니다.

JUCE — 플러그인·앱을 만들 때

// JUCE 8 기준 — VST3 · AU · AAX 플러그인을 한 코드로 만듭니다
class GainProcessor : public juce::AudioProcessor {
    juce::AudioBuffer<float> scratch_;      // 미리 잡아 둘 버퍼
    std::atomic<float> gain_{1.0f};
public:
    // ① 여기서 전부 할당합니다 (실시간 스레드가 아님)
    void prepareToPlay(double sr, int maxBlock) override {
        scratch_.setSize(2, maxBlock);
        smoothed_.reset(sr, 0.02);           // 20ms 램프
    }

    // ② 여기는 실시간 스레드 — 할당·락·로그 금지 ★
    void processBlock(juce::AudioBuffer<float>& buf,
                      juce::MidiBuffer&) override {
        juce::ScopedNoDenormals noDenormals;   // 비정규수로 느려지는 것 방지
        smoothed_.setTargetValue(gain_.load(std::memory_order_relaxed));
        for (int ch = 0; ch < buf.getNumChannels(); ++ch) {
            float* p = buf.getWritePointer(ch);
            for (int i = 0; i < buf.getNumSamples(); ++i)
                p[i] *= smoothed_.getNextValue();   // 급변하면 '툭' 소리가 납니다
        }
    }
};
# JUCE 프로젝트 시작 (CMake 방식이 표준이 되었습니다)
git clone https://github.com/juce-framework/JUCE
# CMakeLists.txt
#   add_subdirectory(JUCE)
#   juce_add_plugin(MyPlugin FORMATS VST3 AU Standalone ...)
파라미터를 그냥 곱하면 안 됩니다 — 사용자가 볼륨 노브를 움직일 때 값이 블록 경계에서 계단처럼 튀면 클릭 잡음이 들립니다. SmoothedValue 로 수십 ms 에 걸쳐 보간하는 것이 기본입니다.

이렇게도 쓸 수 있다 — UI 와 안전하게 교신하기 🟡 중급

방향별로 다른 도구를 씁니다

// ① UI → 오디오 : 값 하나면 atomic 으로 충분합니다
std::atomic<float> cutoff_{1000.0f};      // lock-free 인지 확인
static_assert(std::atomic<float>::is_always_lock_free);

// ② UI → 오디오 : 구조체를 통째로 바꿔야 한다면 '더블 버퍼 + 인덱스'
struct Params { float a, b, c; };
Params params_[2];
std::atomic<int> active_{0};
void ui_update(const Params& p) {          // UI 스레드
    int next = 1 - active_.load(std::memory_order_relaxed);
    params_[next] = p;                     // 안 쓰는 쪽에 씁니다
    active_.store(next, std::memory_order_release);   // 그 다음 전환
}
const Params& rt_read() {                  // 오디오 스레드
    return params_[active_.load(std::memory_order_acquire)];
}

// ③ 오디오 → UI : 미터 · 파형은 락프리 링 버퍼로
SpscQueue<float, 4096> meter_;             // h03 의 큐
// 오디오: meter_.try_push(peak);   실패하면 그냥 버립니다 ★
// UI: 타이머로 30Hz 정도 비웁니다

// ④ 오디오 → 오디오 아닌 스레드 : '해제'도 위험합니다
//    콜백에서 shared_ptr 을 놓으면 마지막 참조일 때 delete 가 불릴 수 있습니다
//    → 다 쓴 객체를 '쓰레기 큐'에 밀어 넣고 다른 스레드가 지우게 합니다
④ 를 놓치는 사람이 많습니다 — std::shared_ptr 복사는 원자 연산이라 안전해 보이지만, 참조 수가 0이 되는 순간 소멸자와 free 가 실행됩니다. 실시간 스레드에서는 객체를 놓는 행위 자체가 할당자 호출일 수 있습니다.

xrun 을 세고 원인을 찾기

// 콜백 소요 시간을 직접 재서 '예산 대비 비율'을 기록합니다
struct RtWatch {
    std::atomic<uint32_t> overruns{0};
    std::atomic<uint32_t> worst_pct{0};
    void measure(uint64_t cycles, double budget_ns, double ghz) {
        double used = cycles / ghz;               // ns
        uint32_t pct = (uint32_t)(used / budget_ns * 100);
        uint32_t w = worst_pct.load(std::memory_order_relaxed);
        while (pct > w && !worst_pct.compare_exchange_weak(w, pct)) {}
        if (pct >= 100) overruns.fetch_add(1, std::memory_order_relaxed);
    }
};
// UI 나 로그 스레드가 주기적으로 읽어 표시합니다.
// '평균 40%, 최악 92%' 처럼 보이면 아직 여유가 있는 것입니다.
# 리눅스 오디오 환경 점검
# ① 실시간 우선순위 권한 (JACK/PipeWire 는 이걸 요구합니다)
cat /etc/security/limits.d/audio.conf
#   @audio - rtprio 95
#   @audio - memlock unlimited

# ② PipeWire 지연 확인 (2026년 리눅스 오디오의 기본)
pw-top
#   QUANT 는 버퍼 크기, RATE 는 샘플레이트, ERR 이 xrun 횟수입니다

# ③ 지연을 실제로 재기
jack_iodelay      # 출력을 입력에 물려 왕복 지연 측정
xrun 이 간헐적일 때 첫 용의자 — 페이지 폴트입니다. mlockall(MCL_CURRENT|MCL_FUTURE) 로 스왑을 막고, 모든 버퍼를 시작할 때 한 번 만져 두면 대부분 사라집니다(h04 참고).

실무에선 이렇게 — DSP 를 실제로 짜 보기 🔴 실무

할당 없는 딜레이 이펙트

// 링 버퍼 하나로 딜레이·에코·코러스가 전부 됩니다
class Delay {
    std::vector<float> buf_;     // prepareToPlay 에서만 크기 결정
    size_t w_ = 0, mask_ = 0;
public:
    void prepare(double sr, double maxSec) {
        size_t n = 1; while (n < (size_t)(sr * maxSec)) n <<= 1;  // 2의 거듭제곱
        buf_.assign(n, 0.0f);
        mask_ = n - 1;
    }
    // 실시간 스레드 — 할당 없음, 분기 없음
    float process(float x, float delaySamples, float fb, float mix) noexcept {
        float rp = (float)w_ - delaySamples;
        while (rp < 0) rp += (float)buf_.size();
        size_t i0 = (size_t)rp & mask_;
        size_t i1 = (i0 + 1) & mask_;
        float  fr = rp - std::floor(rp);
        float  d  = buf_[i0] + fr * (buf_[i1] - buf_[i0]);   // 선형 보간
        buf_[w_ & mask_] = x + d * fb;
        w_ = (w_ + 1) & mask_;
        return x * (1.0f - mix) + d * mix;
    }
};
왜 보간이 필요한가 — 딜레이 시간을 부드럽게 바꾸려면 정수가 아닌 위치를 읽어야 합니다. 정수로 반올림하면 노브를 돌릴 때 지지직 소리가 납니다.

SIMD 와 비정규수 함정

// ① 비정규수(denormal) — 아주 작은 값이 남으면 CPU 가 100배 느려집니다
//    잔향·필터 꼬리에서 흔히 발생합니다
#include <xmmintrin.h>
#include <pmmintrin.h>
void kill_denormals() {
    _MM_SET_FLUSH_ZERO_MODE(_MM_FLUSH_ZERO_ON);          // FTZ
    _MM_SET_DENORMALS_ZERO_MODE(_MM_DENORMALS_ZERO_ON);  // DAZ
}
// JUCE 는 ScopedNoDenormals 가 이것을 해 줍니다.
// 직접 짤 때는 콜백 진입 시 한 번 설정하세요.

// ② 채널 처리를 SIMD 로 — 4샘플씩
#include <immintrin.h>
void gain_simd(float* p, size_t n, float g) noexcept {
    __m128 gv = _mm_set1_ps(g);
    size_t i = 0;
    for (; i + 4 <= n; i += 4)
        _mm_storeu_ps(p + i, _mm_mul_ps(_mm_loadu_ps(p + i), gv));
    for (; i < n; ++i) p[i] *= g;
}
// 실제로는 -O3 -ffast-math 로 컴파일러가 알아서 하는 경우가 많습니다.
// 다만 오디오에서 -ffast-math 는 조심 — 비정규수·NaN 처리가 바뀝니다.
비정규수는 실제로 겪는 사고입니다 — 곡이 끝나고 정적이 흐를 때 갑자기 CPU 사용률이 3%에서 60%로 튀는 현상. 원인은 리버브 꼬리의 1e-40 같은 값이고, FTZ/DAZ 두 줄로 끝납니다.

한 단계 더 — 실시간 안전성을 자동으로 검사하기 🧬 전문가

RealtimeSanitizer (Clang 20+)

// 함수에 '실시간'이라고 표시하면 컴파일러가 감시합니다
[[clang::nonblocking]]
void processBlock(float* out, int n) {
    std::vector<float> tmp(n);      // ← 컴파일 시점에 경고
    mtx_.lock();                    // ← 런타임에 잡힙니다
}

// 빌드
//   clang++ -std=c++23 -fsanitize=realtime -g rt.cpp -o rt
// 실행 시:
//   ==ERROR: RealtimeSanitizer: unsafe-library-call
//     Intercepted call to real-time unsafe function `malloc` in
//     real-time context! processBlock(float*, int) rt.cpp:12
2026년의 판도를 바꾼 도구 — 전에는 “콜백에서 할당 금지”가 사람의 규율이었는데, 이제 컴파일러가 강제합니다. 오디오·저지연 프로젝트라면 CI 에 RTSan 빌드를 반드시 넣으세요 (h02 의 핫패스에도 똑같이 유용합니다).

이 규칙이 통하는 다른 분야

분야같은 규칙이 적용되는 곳
오디오드라이버 콜백
게임프레임 루프 · 물리 스텝
저지연 거래틱 핸들러(h02)
로보틱스제어 루프(1kHz)
자동차ECU 태스크 · AUTOSAR
커널 · 드라이버인터럽트 핸들러
항공 · 의료DO-178C · IEC 62304
공통 규칙 다섯 줄 — 외워 두면 분야가 바뀌어도 그대로 씁니다

  1. 최악 실행 시간(WCET)이 정해지지 않은 호출을 하지 않는다
  2. 메모리는 시작할 때 전부 잡고, 만져 두고, 잠근다
  3. 다른 스레드를 기다리지 않는다 (락 · join · 조건 변수 금지)
  4. 실패하면 '기다리지 말고 버린다' — 늦은 결과는 없는 것만 못하다
  5. 예산 대비 사용률을 항상 재서 내보낸다
이 다섯 줄이 이 탭의 뼈대입니다 — h02(거래) · h08(오디오) · 게임 루프 · 로봇 제어가 전부 같은 문제이고, 해법도 같은 도구(아레나 · 링 큐 · atomic)입니다.
정리 — 실시간 스레드는 빠를 필요가 없고 늦지 않으면 됩니다. “언제 끝날지 모르는 호출”을 전부 밖으로 밀어내는 것이 설계의 전부입니다.
HPC 09

고속 I/O — mmap · io_uring · O_DIRECT

read() 한 번은 시스템콜 한 번입니다. 초당 100만 건을 읽어야 하면 시스템콜만으로 CPU 한 코어가 날아갑니다. 리눅스는 이 문제를 위해 io_uring 이라는 완전히 새로운 인터페이스를 만들었습니다. 실제 코드와 실측 수치로 봅니다.

io_uring — 큐 두 개를 커널과 공유합니다 애플리케이션 io_uring_get_sqe() io_uring_submit() io_uring_wait_cqe() io_uring_cqe_seen() SQ — 제출 큐 mmap 으로 커널과 함께 보는 메모리 CQ — 완료 큐 커널이 결과를 여기에 적습니다 커널 SQ 를 읽어 I/O 수행 끝나면 CQ 에 기록 SQPOLL 이면 커널 스레드가 알아서 SQ 를 감시합니다 시스템콜 한 번에 요청 수천 개 · SQPOLL 을 켜면 시스템콜조차 0 이 됩니다 4KB 랜덤 읽기 100만 건 — NVMe SSD 실측 (막대 길이가 곧 걸린 시간) read() 반복 62초 시스템콜 100만 번 · 큐 깊이 1 이라 SSD 가 놉니다 pread + 스레드 풀 32 18초 병렬은 되지만 문맥 전환과 스레드 비용이 큽니다 io_uring (큐 깊이 128) 4.1초 — 15배 스레드 1개 · SSD 의 병렬성을 제대로 씁니다 io_uring + O_DIRECT 2.9초 — 21배 페이지 캐시를 건너뛰어 복사와 메모리 압박이 사라집니다 0 20 40 60 소요(초) 주의 — 같은 파일을 반복해 읽는 워크로드라면 O_DIRECT 가 오히려 손해입니다
I/O 는 “빨리 하는” 것이 아니라 여러 건을 동시에 걸어 두는 것입니다.

io_uring — liburing 으로 실제 읽기

sudo apt install liburing-dev          # 커널 5.10+ 권장, 6.x 에서 기능 완성
g++ -std=c++23 -O2 ur.cpp -luring -o ur
uname -r                               # 커널 버전 확인
#include <liburing.h>
#include <fcntl.h>
#include <vector>

constexpr int QD = 128;                 // 큐 깊이 — 동시에 걸어 둘 요청 수

int main() {
    io_uring ring;
    io_uring_queue_init(QD, &ring, 0);  // SQ/CQ 를 만들고 mmap 합니다

    int fd = ::open("big.bin", O_RDONLY);
    std::vector<std::vector<char>> bufs(QD, std::vector<char>(4096));

    size_t submitted = 0, done = 0, total = 1'000'000;
    while (done < total) {
        // ① 빈 슬롯만큼 요청을 채웁니다
        int n = 0;
        while (submitted - done < QD && submitted < total) {
            io_uring_sqe* sqe = io_uring_get_sqe(&ring);
            if (!sqe) break;
            size_t i = submitted % QD;
            off_t off = (off_t)(rand() % 100000) * 4096;
            io_uring_prep_read(sqe, fd, bufs[i].data(), 4096, off);
            io_uring_sqe_set_data64(sqe, submitted);   // 나중에 누구 결과인지
            ++submitted; ++n;
        }
        if (n) io_uring_submit(&ring);   // ② 시스템콜 한 번 — n 건을 한꺼번에 ★

        // ③ 완료된 것들을 한꺼번에 수확
        io_uring_cqe* cqe;
        unsigned head; unsigned count = 0;
        io_uring_wait_cqe(&ring, &cqe);
        io_uring_for_each_cqe(&ring, head, cqe) {
            if (cqe->res < 0) { /* 에러: -errno */ }
            ++done; ++count;
        }
        io_uring_cq_advance(&ring, count);
    }
    io_uring_queue_exit(&ring);
}
핵심은 큐 깊이입니다 — NVMe SSD 는 내부적으로 수십 개를 동시에 처리합니다. 요청을 하나씩 보내면 장치가 놀고, 128개를 걸어 두면 그제야 제 성능이 납니다. read() 가 느린 진짜 이유는 시스템콜이 아니라 큐 깊이 1 입니다.

io_uring 의 진짜 강점 — 파일만이 아닙니다

// 소켓 · 파일 · 타이머 · 연결 수락까지 같은 링에서 처리합니다
io_uring_prep_accept(sqe, listen_fd, nullptr, nullptr, 0);
io_uring_prep_recv(sqe, conn_fd, buf, len, 0);
io_uring_prep_send(sqe, conn_fd, buf, len, 0);
io_uring_prep_timeout(sqe, &ts, 0, 0);
io_uring_prep_openat(sqe, AT_FDCWD, path, O_RDONLY, 0);
io_uring_prep_fsync(sqe, fd, 0);

// ① 요청 사슬 — "읽은 다음 바로 쓰기"를 커널 안에서 이어 붙입니다
sqe->flags |= IOSQE_IO_LINK;         // 이 요청이 끝나야 다음이 실행

// ② 고정 버퍼 · 고정 파일 — 매번 하는 검증을 미리 해 둡니다
io_uring_register_buffers(&ring, iov, n);
io_uring_register_files(&ring, fds, n);
//   → prep_read_fixed / prep_write_fixed 사용, 10~20% 추가 이득

// ③ SQPOLL — 커널 스레드가 SQ 를 감시해 시스템콜이 0 이 됩니다
io_uring_params p{};
p.flags = IORING_SETUP_SQPOLL;
p.sq_thread_idle = 2000;             // 2초 놀면 잠듦
io_uring_queue_init_params(QD, &ring, &p);
2026년의 주의 — io_uring 은 커널 공격 표면이 넓다는 이유로 구글·클라우드플레어 등이 일부 환경에서 기본 비활성화했고, 도커 기본 seccomp 프로필도 막습니다. 배포 환경에서 실제로 쓸 수 있는지 먼저 확인하고, 안 되면 epoll 경로로 자동 대체하도록 짜 두세요.

이렇게도 쓸 수 있다 — mmap 으로 대용량 파일 다루기 🟡 중급

파일을 메모리처럼

#include <sys/mman.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>

class MappedFile {
    void*  p_ = nullptr;
    size_t n_ = 0;
public:
    explicit MappedFile(const char* path) {
        int fd = ::open(path, O_RDONLY);
        struct stat st{}; fstat(fd, &st);
        n_ = (size_t)st.st_size;
        p_ = mmap(nullptr, n_, PROT_READ, MAP_PRIVATE, fd, 0);
        ::close(fd);                       // 매핑은 fd 없이도 살아 있습니다 ★
        madvise(p_, n_, MADV_SEQUENTIAL);  // 순차로 읽을 거라고 알려 줍니다
    }
    ~MappedFile() { if (p_) munmap(p_, n_); }
    const char* data() const { return (const char*)p_; }
    size_t size() const { return n_; }
};

// 100GB 로그에서 줄 세기 — read 버퍼 관리가 통째로 사라집니다
MappedFile f("huge.log");
size_t lines = std::count(f.data(), f.data() + f.size(), '\n');
madvise 힌트뜻
MADV_SEQUENTIAL앞서 읽어 두기 강화
MADV_RANDOM미리 읽기 끄기
MADV_WILLNEED지금 미리 올려 두기
MADV_DONTNEED버려도 됨
MADV_HUGEPAGE2MB 페이지로
mmap 이 빠른 이유 — 커널 버퍼에서 내 버퍼로 복사하는 과정이 없습니다. 페이지 캐시를 그대로 내 주소 공간에 붙입니다. 읽기 전용 대용량 파일(인덱스 · 모델 가중치 · 로그)에 특히 잘 맞습니다.

mmap 의 함정 세 가지

// ① 접근이 곧 페이지 폴트 — 지연이 튀는 원인이 됩니다
//    핫패스에서 mmap 영역을 처음 만지면 수 µs~수백 µs 가 걸립니다
madvise(p, n, MADV_WILLNEED);      // 미리 올리기
mlock(p, n);                        // 아예 고정 (RLIMIT_MEMLOCK 필요)

// ② I/O 에러가 SIGBUS 로 옵니다 — try/catch 로 못 잡습니다
//    네트워크 파일 시스템(NFS)이나 디스크 오류에서 프로세스가 죽습니다
//    → 중요한 서비스라면 시그널 핸들러를 두거나 read() 를 쓰세요

// ③ 파일이 줄어들면(truncate) 그 영역 접근이 SIGBUS
//    다른 프로세스가 쓰는 파일을 mmap 하는 것은 위험합니다

// ★ 쓰기 매핑은 특히 조심 — 언제 디스크에 반영되는지 보장이 없습니다
void* w = mmap(nullptr, n, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
msync(w, n, MS_SYNC);              // 명시적으로 내려야 안전합니다
상황고르는 것
대용량 읽기 전용mmap
랜덤 접근 인덱스mmap
초당 수십만 건 I/Oio_uring
순차 스트리밍read + 큰 버퍼
캐시가 방해될 때O_DIRECT
내구성이 중요write + fsync

실무에선 이렇게 — O_DIRECT 와 정렬 규칙 🔴 실무

페이지 캐시를 건너뛰기

// O_DIRECT 는 세 가지 정렬을 전부 지켜야 합니다 — 하나라도 어기면 EINVAL
//   ① 버퍼 주소가 논리 블록 크기(보통 512B 또는 4096B)의 배수
//   ② 읽을 길이가 블록 크기의 배수
//   ③ 파일 오프셋이 블록 크기의 배수

#include <cstdlib>
constexpr size_t BS = 4096;

int fd = ::open("data.bin", O_RDONLY | O_DIRECT);
void* buf = nullptr;
posix_memalign(&buf, BS, BS * 256);        // ① 정렬된 버퍼 ★
ssize_t n = ::pread(fd, buf, BS * 256, off);   // ② ③ 배수여야 함

// 블록 크기 확인
// $ blockdev --getss /dev/nvme0n1      (논리)
// $ blockdev --getpbsz /dev/nvme0n1    (물리)

// C++ 에서 정렬 할당은 이쪽이 더 깔끔합니다
std::unique_ptr<std::byte[]> b{
    static_cast<std::byte*>(::operator new(BS * 256, std::align_val_t{BS}))};
O_DIRECT 를 쓰면 안 되는 경우 — 같은 데이터를 여러 번 읽는 워크로드입니다. 페이지 캐시가 해 주던 일을 내가 직접 캐시로 구현해야 합니다. 데이터베이스가 O_DIRECT 를 쓰는 이유는 자기 버퍼 풀이 이미 있어서입니다.

내구성 — 언제 정말 디스크에 있나

// write() 가 성공해도 데이터는 아직 페이지 캐시에만 있습니다.
// 전원이 나가면 사라집니다.

::write(fd, buf, n);
::fsync(fd);            // 파일 데이터 + 메타데이터를 디스크로 ★
::fdatasync(fd);        // 데이터만 (크기가 안 바뀌면 이쪽이 빠릅니다)

// 새 파일을 만들었다면 '디렉터리'도 동기화해야 합니다 ★
int dfd = ::open("/data", O_RDONLY | O_DIRECTORY);
::fsync(dfd);           // 이걸 빠뜨려 파일이 통째로 사라지는 사고가 흔합니다

// io_uring 으로는 이렇게
io_uring_prep_fsync(sqe, fd, IORING_FSYNC_DATASYNC);
NVMe SSD 기준대략
write() (캐시까지)~2µs
fdatasync()~60µs
fsync()~90µs
O_SYNC 로 매번~110µs
그룹 커밋 — 데이터베이스가 쓰는 기법입니다. 여러 트랜잭션의 쓰기를 모아 fsync 를 한 번만 부릅니다. 커밋 1000건에 fsync 1000번이면 90ms, 10번이면 0.9ms — 같은 하드웨어에서 100배가 됩니다.

한 단계 더 — 네트워크도 커널을 건너뜁니다 🧬 전문가

AF_XDP · DPDK 개요

[ 일반 소켓 ]
  NIC → 드라이버 → 커널 네트워크 스택 → 소켓 버퍼 → 내 버퍼
        (인터럽트 · 복사 2회 · 프로토콜 처리 전부 커널)

[ AF_XDP ]  커널 5.4+ · 일반 NIC · 무료
  NIC → 드라이버 → (eBPF 프로그램이 골라냄) → UMEM(내 메모리) 직행
        · 커널 스택을 건너뛰지만 커널 드라이버는 그대로 씁니다
        · NIC 를 통째로 뺏지 않아 같은 카드로 SSH 도 됩니다 ★
        · 왕복 ~2µs · 초당 1400만 패킷(단일 코어)

[ DPDK ]  전용 드라이버 · NIC 를 통째로 점유
  NIC → (유저 공간 드라이버) → 내 메모리
        · 폴링 모드 · 커널 전혀 안 씀 · 왕복 ~1.5µs
        · 그 NIC 로는 다른 통신을 못 합니다 (관리 포트 별도 필요)
        · TCP 를 직접 구현해야 합니다 (또는 f-stack · VPP)

[ RDMA / RoCE ]  상대 서버 메모리에 직접 쓰기
        · CPU 개입 없이 원격 메모리 접근 · 왕복 ~1µs
        · HPC · 분산 스토리지 · GPU 클러스터에서 표준
2026년의 실용적 선택 — 대부분의 팀에게는 AF_XDP 가 정답입니다. DPDK 는 성능이 조금 더 좋지만 운영 비용이 훨씬 큽니다 (NIC 전용화 · 드라이버 바인딩 · huge page 필수 · 디버깅 어려움).

그 전에 해 볼 무료 튜닝

# ① 수신 큐를 코어에 분산 (RSS) — 이것만으로 몇 배가 됩니다
ethtool -L eth0 combined 8
ethtool -X eth0 equal 8

# ② 인터럽트 병합 조정 — 지연 우선이면 낮게
ethtool -C eth0 rx-usecs 0 rx-frames 1     # 저지연
ethtool -C eth0 rx-usecs 50                # 처리량 우선

# ③ 큰 버퍼 · 큐 길이
sysctl -w net.core.rmem_max=134217728
sysctl -w net.core.netdev_max_backlog=250000
ethtool -G eth0 rx 4096 tx 4096

# ④ 소켓 옵션 — 코드 한 줄씩
setsockopt(fd, IPPROTO_TCP, TCP_NODELAY, &on, 4);       # 40ms 지연 제거
setsockopt(fd, SOL_SOCKET, SO_REUSEPORT, &on, 4);       # 코어별 리스너
setsockopt(fd, SOL_SOCKET, SO_ZEROCOPY, &on, 4);        # 큰 전송에 유효

# ⑤ 확인 — 패킷을 흘리고 있나
ethtool -S eth0 | grep -i -E 'drop|miss|error'
netstat -su | grep -i -E 'receive errors|buffer'
순서를 지키세요 — TCP_NODELAY 하나로 40ms 가 사라지는 서비스가 아직도 많습니다. 커널 바이패스를 검토하기 전에 위 다섯 가지를 전부 해 보는 것이 비용 대비 효과가 압도적입니다.
정리 — I/O 성능은 큐 깊이가 결정합니다 — io_uring 으로 여러 건을 동시에 걸고, 필요할 때만 캐시(O_DIRECT)와 커널(XDP)을 건너뜁니다.
HPC 10

데이터 처리 엔진 — Arrow · Parquet · DuckDB

“판다스가 메모리를 다 먹고 죽었어요”의 원인은 대개 데이터 배치입니다. 같은 데이터라도 행으로 두느냐 열로 두느냐에 따라 집계 속도가 열 배 넘게 갈립니다. C++ 로 컬럼 지향 데이터를 다루는 표준 도구(Arrow · Parquet)와, SQL 엔진을 라이브러리로 임베딩하는 법을 봅니다.

행 지향 vs 열 지향 — 같은 데이터, 다른 배치 ① 행 지향 — 레코드가 통째로 붙어 있습니다 (struct 배열 · CSV · OLTP DB) id name price qty id name price qty id name price qty 레코드 0 레코드 1 레코드 2 price 합계만 필요한데 캐시라인마다 나머지 세 필드가 딸려 옵니다 — 읽은 바이트의 25%만 씁니다 ② 열 지향 — 같은 열끼리 붙어 있습니다 (Arrow · Parquet · ClickHouse) id id id name name name price price price qty qty qty id 열 name 열 price 열 qty 열 price 가 연속이라 캐시라인이 전부 쓸모 있고, SIMD 하나로 8개씩 더합니다 — 압축률도 훨씬 좋습니다 1억 행 SUM(price) 실측 — 막대 길이가 곧 걸린 시간입니다 판다스 3.1초 C++ struct 배열 0.82초 — 3.8배 Arrow 열 배열 0.11초 — 28배 DuckDB 0.09초 — 34배 0 1 2 3 소요(초) 배치를 바꾸면 코드를 안 고쳐도 7배가 됩니다 그 위에 SIMD 와 병렬을 얹으면 다시 몇 배 — 순서를 거꾸로 하면 효과가 없습니다
데이터 엔진 성능의 첫 번째 결정은 알고리즘이 아니라 배치입니다.

Apache Arrow C++ — 설치와 기본

# vcpkg (권장 — Parquet · compute 커널까지)
vcpkg install arrow[parquet,dataset,compression]

# 또는 apt (Arrow 공식 저장소 추가 필요)
sudo apt install -y libarrow-dev libparquet-dev
#include <arrow/api.h>
#include <arrow/compute/api.h>

// ① 열 하나 만들기 — 빌더로 채웁니다
arrow::DoubleBuilder b;
ARROW_RETURN_NOT_OK(b.Reserve(n));           // 미리 잡아 두기
for (size_t i = 0; i < n; ++i) b.UnsafeAppend(price[i]);   // 검사 생략
std::shared_ptr<arrow::Array> col;
ARROW_RETURN_NOT_OK(b.Finish(&col));

// ② 테이블 조립
auto schema = arrow::schema({
    arrow::field("id",    arrow::int64()),
    arrow::field("price", arrow::float64()),
});
auto table = arrow::Table::Make(schema, {id_col, col});

// ③ 집계 — Arrow 의 compute 커널이 SIMD·멀티스레드로 처리합니다
ARROW_ASSIGN_OR_RAISE(auto sum, arrow::compute::Sum(col));
double total = sum.scalar_as<arrow::DoubleScalar>().value;

// ④ 필터 + 집계 (price > 100 인 것만)
ARROW_ASSIGN_OR_RAISE(auto mask,
    arrow::compute::CallFunction("greater", {col, arrow::Datum(100.0)}));
ARROW_ASSIGN_OR_RAISE(auto filtered,
    arrow::compute::Filter(col, mask));
Arrow 가 표준인 이유 — 메모리 형식 자체가 규격입니다. 파이썬 pandas/polars · C++ · Rust · 자바가 같은 바이트를 복사 없이 주고받습니다. 판다스에서 df.to_arrow() 로 넘긴 버퍼를 C++ 이 포인터 그대로 읽을 수 있습니다.

Parquet 읽기 — 필요한 열만

#include <parquet/arrow/reader.h>
#include <arrow/io/api.h>

std::shared_ptr<arrow::io::RandomAccessFile> input;
ARROW_ASSIGN_OR_RAISE(input,
    arrow::io::ReadableFile::Open("sales.parquet"));

std::unique_ptr<parquet::arrow::FileReader> reader;
ARROW_RETURN_NOT_OK(parquet::arrow::OpenFile(
    input, arrow::default_memory_pool(), &reader));
reader->set_use_threads(true);            // 열 병렬 디코딩 ★

// ★ 열 두 개만 읽습니다 — 파일에 40개가 있어도 나머지는 디스크에서 안 읽습니다
std::shared_ptr<arrow::Table> table;
ARROW_RETURN_NOT_OK(reader->ReadTable({2, 7}, &table));

// row group 단위로 스트리밍 (메모리에 다 못 올릴 때)
for (int g = 0; g < reader->num_row_groups(); ++g) {
    std::shared_ptr<arrow::Table> chunk;
    ARROW_RETURN_NOT_OK(reader->ReadRowGroup(g, {2, 7}, &chunk));
    process(chunk);
}
형식1억 행 크기SUM 시간
CSV9.4GB84초
JSON21GB210초
Parquet(snappy)1.1GB0.9초
Parquet(zstd)0.8GB1.1초
Arrow IPC(메모리)3.2GB0.11초
Parquet 가 빠른 두 번째 이유 — 파일에 열별 최소·최대값이 적혀 있습니다. WHERE date = '2026-08' 같은 조건이면 해당 없는 row group 을 통째로 건너뜁니다. 이것을 프레디케이트 푸시다운이라고 부릅니다.

이렇게도 쓸 수 있다 — DuckDB 를 라이브러리로 임베딩 🟡 중급

SQL 엔진 하나를 프로그램 안에

# 방법 ① 배포된 헤더 두 개 — 가장 간단합니다
wget https://github.com/duckdb/duckdb/releases/latest/download/libduckdb-linux-amd64.zip
unzip libduckdb-linux-amd64.zip     # duckdb.h · duckdb.hpp · libduckdb.so
g++ -std=c++23 -O2 q.cpp -L. -lduckdb -o q

# 방법 ② vcpkg
vcpkg install duckdb
#include "duckdb.hpp"

int main() {
    duckdb::DuckDB db(nullptr);            // nullptr = 메모리 전용
    duckdb::Connection con(db);

    // ★ 파일을 '불러오지 않고' 그 자리에서 질의합니다
    auto r = con.Query(R"(
        SELECT region, SUM(price * qty) AS revenue
        FROM read_parquet('sales/*.parquet')
        WHERE order_date >= DATE '2026-01-01'
        GROUP BY region
        ORDER BY revenue DESC
        LIMIT 10
    )");
    if (r->HasError()) { fprintf(stderr, "%s\n", r->GetError().c_str()); return 1; }

    for (auto& row : *r) {
        printf("%s  %.2f\n",
               row.GetValue<std::string>(0).c_str(),
               row.GetValue<double>(1));
    }

    // 내 메모리의 Arrow 테이블을 그대로 질의할 수도 있습니다 (복사 없음)
    // con.Query("SELECT * FROM arrow_scan(...)");
}
왜 놀라운가 — 서버도, 포트도, 설치도 없습니다. .so 하나가 컬럼 지향 실행 엔진 · 벡터화 연산자 · 병렬 스캔 · 조인 · 정렬을 전부 갖고 있습니다. “C++ 로 집계 코드를 직접 짜는 것”보다 DuckDB 를 부르는 편이 빠르고 정확한 경우가 아주 많습니다.

언제 직접 짜고 언제 엔진을 부르나

하는 일선택
집계 · 조인 · 그룹바이DuckDB
파일 형식 변환(CSV→Parquet)DuckDB
탐색적 분석 · 리포트DuckDB
도메인 특화 수식 계산Arrow + 직접
스트리밍 실시간 집계직접
초저지연(µs) 조회직접(전용 인덱스)
분산 · 수십 TBClickHouse · Spark
// 직접 짤 때의 뼈대 — '벡터 단위'로 처리합니다 (한 행씩 아님)
// DuckDB · ClickHouse 가 쓰는 실행 모델이기도 합니다
constexpr size_t VEC = 2048;              // 캐시에 들어가는 크기

double sum_filtered(const double* px, const int32_t* qty, size_t n) {
    double acc = 0.0;
    for (size_t base = 0; base < n; base += VEC) {
        size_t m = std::min(VEC, n - base);
        // ① 필터를 먼저 '선택 벡터'로 만들고
        alignas(64) uint16_t sel[VEC]; size_t k = 0;
        for (size_t i = 0; i < m; ++i) if (qty[base+i] > 0) sel[k++] = (uint16_t)i;
        // ② 통과한 것만 계산 — 분기 없는 연속 루프가 됩니다
        for (size_t j = 0; j < k; ++j) acc += px[base + sel[j]];
    }
    return acc;
}
벡터화 실행 모델 — 한 행씩 처리하면 함수 호출과 분기가 데이터보다 비쌉니다. 2048행씩 묶어 처리하면 캐시에 머물면서 SIMD 가 걸립니다. 현대 분석 엔진이 전부 이 구조입니다.

실무에선 이렇게 — SIMD 로 집계를 직접 짜기 🔴 실무

합계 · 최댓값 · 개수를 한 번에

#include <immintrin.h>

// AVX2 — double 4개씩. 누산기를 4개 두어 의존 사슬을 끊습니다 ★
double simd_sum(const double* p, size_t n) {
    __m256d a0 = _mm256_setzero_pd(), a1 = a0, a2 = a0, a3 = a0;
    size_t i = 0;
    for (; i + 16 <= n; i += 16) {
        a0 = _mm256_add_pd(a0, _mm256_loadu_pd(p + i));
        a1 = _mm256_add_pd(a1, _mm256_loadu_pd(p + i + 4));
        a2 = _mm256_add_pd(a2, _mm256_loadu_pd(p + i + 8));
        a3 = _mm256_add_pd(a3, _mm256_loadu_pd(p + i + 12));
    }
    __m256d s = _mm256_add_pd(_mm256_add_pd(a0, a1), _mm256_add_pd(a2, a3));
    alignas(32) double t[4]; _mm256_store_pd(t, s);
    double acc = t[0] + t[1] + t[2] + t[3];
    for (; i < n; ++i) acc += p[i];       // 남는 것
    return acc;
}
// 누산기 하나만 쓰면 add 의 지연(4사이클)에 묶여 성능이 4분의 1이 됩니다.

// 조건부 합계 — 분기 대신 마스크
double simd_sum_gt(const double* p, size_t n, double thr) {
    __m256d th = _mm256_set1_pd(thr), acc = _mm256_setzero_pd();
    size_t i = 0;
    for (; i + 4 <= n; i += 4) {
        __m256d v = _mm256_loadu_pd(p + i);
        __m256d m = _mm256_cmp_pd(v, th, _CMP_GT_OQ);   // 조건 → 마스크
        acc = _mm256_add_pd(acc, _mm256_and_pd(v, m));  // 통과한 것만 더함
    }
    alignas(32) double t[4]; _mm256_store_pd(t, acc);
    double r = t[0] + t[1] + t[2] + t[3];
    for (; i < n; ++i) if (p[i] > thr) r += p[i];
    return r;
}
먼저 확인할 것 — -O3 -march=native 만 줘도 컴파일러가 이 정도는 알아서 벡터화합니다. -fopt-info-vec-missed 로 “왜 못 했는지”를 보고, 이유를 없애는 편이 인트린식을 직접 쓰는 것보다 유지보수가 쉽습니다.

C++23 이후 — 표준 SIMD

// std::simd 는 C++26 에 들어갑니다.
// 지금은 experimental 로 GCC 11+ · Clang 에서 쓸 수 있습니다.
#include <experimental/simd>
namespace stdx = std::experimental;

double portable_sum(const double* p, size_t n) {
    using V = stdx::native_simd<double>;   // AVX2 면 4개, AVX-512 면 8개
    V acc = 0.0;
    size_t i = 0, W = V::size();
    for (; i + W <= n; i += W) {
        V v; v.copy_from(p + i, stdx::element_aligned);
        acc += v;
    }
    double r = stdx::reduce(acc);
    for (; i < n; ++i) r += p[i];
    return r;
}
// ★ 같은 코드가 x86 AVX2/AVX-512 와 ARM NEON 에서 전부 돕니다.
//   인트린식과 달리 CPU 별로 다시 짤 필요가 없습니다.

// 대안 라이브러리 (지금 당장 쓰기 좋은 것들)
//   xsimd     — 헤더 온리 · Arrow 내부에서도 사용
//   Highway   — 구글 · 런타임 CPU 감지까지
//   EVE       — C++20 · 함수형 스타일
2026년의 권장 순서 — ① 라이브러리 함수(Arrow compute · DuckDB) → ② 컴파일러 자동 벡터화 → ③ Highway · xsimd → ④ 인트린식 직접. 아래로 갈수록 이식성과 유지보수가 급격히 나빠집니다.

한 단계 더 — 판다스와 정면 비교 🧬 전문가

같은 질의를 네 가지로

질의: 1억 행 Parquet 에서 지역별 매출 합계 Top 10
장비: 16코어 · 64GB · NVMe

  ① 판다스 (read_parquet + groupby)     31.2초 · 메모리 18GB
  ② polars (lazy · streaming)            2.4초 · 메모리 3.1GB
  ③ DuckDB (C++ 임베딩)                  1.9초 · 메모리 2.2GB
  ④ 직접 짠 C++ (Arrow + TBB + SIMD)     1.6초 · 메모리 1.8GB

관찰:
  · ①→② 만으로 13배 — 언어가 아니라 '엔진'의 차이입니다
  · ③→④ 는 20% — 직접 짜서 얻는 이득이 생각보다 작습니다
  · ④ 가 이기는 진짜 자리는 'SQL 로 표현이 안 되는 계산'입니다
    (도메인 수식 · 시계열 상태 기계 · 커스텀 조인 조건)
이 표를 보고 배울 것 — “C++ 로 다시 짜자”보다 “엔진을 바꾸자”가 거의 항상 먼저입니다. C++ 을 꺼내는 것은 ③ 까지 해 보고도 부족할 때입니다.

파이썬과 무복사로 주고받기

// pybind11 + Arrow C 데이터 인터페이스 — 복사 없이 넘깁니다
#include <arrow/c/bridge.h>
#include <pybind11/pybind11.h>

// C++ → 파이썬: Arrow 배열을 C 구조체로 내보내기
py::object export_array(std::shared_ptr<arrow::Array> arr) {
    ArrowArray  c_arr{};
    ArrowSchema c_sch{};
    arrow::ExportArray(*arr, &c_arr, &c_sch);   // 소유권이 넘어갑니다
    // 파이썬 쪽에서 pyarrow.Array._import_from_c(...) 로 받습니다
    return py::make_tuple((uintptr_t)&c_arr, (uintptr_t)&c_sch);
}

// 파이썬 → C++: 판다스/polars 데이터프레임을 그대로
// (파이썬) tbl = df.to_arrow();  mymod.process(tbl)
# 파이썬 쪽 — 데이터는 한 번도 복사되지 않습니다
import pyarrow as pa, polars as pl, mymod

df  = pl.read_parquet("sales.parquet")
tbl = df.to_arrow()                    # 뷰
res = mymod.heavy_domain_calc(tbl)     # C++ 이 같은 버퍼를 읽습니다
out = pl.from_arrow(res)               # 결과도 뷰
이 구조가 2026년의 표준 형태입니다 — 파이썬이 조립과 오케스트레이션, C++ 이 커널, 그 사이는 Arrow 버퍼. pandas 2.x · polars · DuckDB · DataFusion 이 전부 이 규격을 공유하므로 어느 쪽과 붙여도 복사가 없습니다.
정리 — 데이터 처리에서 첫 번째 지렛대는 열 지향 배치(Arrow · Parquet), 두 번째는 검증된 엔진(DuckDB), 직접 짠 SIMD 는 세 번째입니다.
HPC 11

ML 추론 런타임 — 파이썬 없이 배포하기

모델 학습은 파이썬이 압도적입니다. 그런데 배포는 사정이 다릅니다. 파이썬 인터프리터 · 수 GB 짜리 의존성 · GIL 없이 실행 파일 하나로 끝내고 싶을 때 C++ 추론 런타임을 씁니다. ONNX Runtime 으로 일반 모델을, llama.cpp 로 LLM 을 돌려 봅니다.

로컬 LLM 추론 — 토큰 하나를 만드는 경로 입력 텍스트 프롬프트 토크나이저 BPE · 토큰 정수 배열 prefill 프롬프트 전체 한 번에 decode 토큰 하나씩 생성 출력 텍스트 디토크나이즈 토큰마다 반복 — KV 캐시가 앞선 계산을 재사용합니다 70억 파라미터 모델의 크기 — 양자화 수준별 VRAM 8GB 선 — 넘으면 안 올라갑니다 FP16 13.5GB Q8_0 7.2GB Q5_K_M 4.8GB Q4_K_M 4.1GB 0 4 8 12 GB Q8 은 품질 차이가 거의 없고 Q5·Q4 가 크기와 품질의 균형점입니다 — Q4 가 가장 흔한 선택입니다 VRAM 8GB 면 Q4 로 7B, 24GB 면 Q4 로 32B 급이 올라갑니다 — 메모리가 곧 모델 크기의 상한입니다 속도의 상한도 메모리입니다 — 토큰마다 가중치 전체를 읽으므로 초당 토큰 수 ≈ 대역폭 ÷ 모델 크기
로컬 추론의 병목은 연산이 아니라 메모리 대역폭입니다.

ONNX Runtime C++ — 모델 하나 돌리기

# ① 파이썬에서 모델을 ONNX 로 내보냅니다 (학습 쪽에서 한 번만)
#   torch.onnx.export(model, dummy, "model.onnx", opset_version=17,
#                     dynamic_axes={"input": {0: "batch"}})

# ② C++ 쪽 설치
wget https://github.com/microsoft/onnxruntime/releases/latest/download/\
onnxruntime-linux-x64-gpu-1.22.0.tgz
tar xf onnxruntime-*.tgz
g++ -std=c++23 -O2 infer.cpp -I onnxruntime/include \
    -L onnxruntime/lib -lonnxruntime -o infer
#include <onnxruntime_cxx_api.h>
#include <vector>

int main() {
    Ort::Env env(ORT_LOGGING_LEVEL_WARNING, "app");

    Ort::SessionOptions so;
    so.SetIntraOpNumThreads(8);                    // 연산 하나 안의 병렬
    so.SetGraphOptimizationLevel(ORT_ENABLE_ALL);  // 그래프 융합 ★
    // GPU 를 쓰려면 (CUDA 프로바이더)
    // OrtSessionOptionsAppendExecutionProvider_CUDA(so, 0);

    Ort::Session session(env, "model.onnx", so);

    // 입력 텐서 만들기 — 우리 벡터를 '감싸기만' 합니다 (복사 없음)
    std::vector<float>   input(1 * 3 * 224 * 224);
    std::vector<int64_t> shape{1, 3, 224, 224};
    auto mem = Ort::MemoryInfo::CreateCpu(OrtArenaAllocator, OrtMemTypeDefault);
    Ort::Value in = Ort::Value::CreateTensor<float>(
        mem, input.data(), input.size(), shape.data(), shape.size());

    const char* in_names[]  = {"input"};
    const char* out_names[] = {"output"};

    auto outs = session.Run(Ort::RunOptions{nullptr},
                            in_names, &in, 1, out_names, 1);

    float* p = outs[0].GetTensorMutableData<float>();
    auto info = outs[0].GetTensorTypeAndShapeInfo();
    size_t n = info.GetElementCount();
    // p[0..n) 가 결과입니다
}
배포가 간단해집니다 — 결과물은 실행 파일 + .so + model.onnx 뿐입니다. 파이썬 · PyTorch(2GB) · CUDA 파이썬 바인딩이 전부 사라집니다. 컨테이너 이미지가 4GB 에서 180MB 로 줄어드는 일이 흔합니다.

더 빠르게 — 프로바이더와 배치

// ① 실행 프로바이더 — 하드웨어에 맞춰 골라 넣습니다
//    CUDA / TensorRT / DirectML / CoreML / OpenVINO / ROCm / CPU
OrtTensorRTProviderOptions trt{};
trt.device_id = 0;
trt.trt_fp16_enable = 1;                       // FP16 로 2배 ★
trt.trt_engine_cache_enable = 1;               // 최적화 결과 캐시
trt.trt_engine_cache_path = "./trt_cache";
so.AppendExecutionProvider_TensorRT(trt);

// ② IO 바인딩 — 입출력 버퍼를 미리 잡아 두고 재사용합니다
Ort::IoBinding binding(session);
binding.BindInput("input", in);
binding.BindOutput("output", out_value);       // 출력 버퍼도 내가 소유
for (auto& frame : frames) {
    fill(input);                                // 같은 버퍼에 덮어쓰기
    session.Run(Ort::RunOptions{nullptr}, binding);
}
// → 매 호출 할당이 사라져 지연 편차가 크게 줄어듭니다

// ③ 배치 — GPU 는 배치가 커야 이득입니다 (h06 과 같은 이야기)
std::vector<int64_t> shape{16, 3, 224, 224};   // 16장씩 묶기
ResNet-50 · 1장 추론지연
파이썬 PyTorch (CPU)48ms
ORT CPU19ms
ORT CUDA3.1ms
ORT TensorRT FP161.2ms
ORT TensorRT INT80.7ms
TensorRT 의 대가 — 첫 실행 때 모델을 그 GPU 에 맞춰 컴파일하느라 수 분이 걸리고, 그 결과물은 GPU 모델·드라이버 버전에 묶입니다. 캐시를 반드시 켜고, 배포 대상 GPU 에서 미리 빌드해 두세요.

이렇게도 쓸 수 있다 — llama.cpp 로 LLM 을 로컬에서 🟡 중급

빌드와 라이브러리로 쓰기

git clone https://github.com/ggml-org/llama.cpp && cd llama.cpp

# CPU 만 (AVX2 자동 감지)
cmake -B build -DCMAKE_BUILD_TYPE=Release
# NVIDIA GPU
cmake -B build -DGGML_CUDA=ON
# 애플 실리콘 (Metal 이 기본으로 켜집니다)
cmake -B build
# AMD
cmake -B build -DGGML_HIPBLAS=ON

cmake --build build -j

# 모델은 GGUF 형식으로 받습니다 (허깅페이스에 양자화본이 올라와 있습니다)
./build/bin/llama-cli -m model-Q4_K_M.gguf -p "안녕" -n 128 -ngl 99
#   -ngl 99 = 가능한 만큼 레이어를 GPU 로 올리기 ★
// 라이브러리로 임베딩 — API 는 자주 바뀌므로
// examples/simple 의 최신 코드를 기준으로 삼으세요.
#include "llama.h"

llama_backend_init();

llama_model_params mp = llama_model_default_params();
mp.n_gpu_layers = 99;                                   // GPU 오프로드
llama_model* model = llama_model_load_from_file("m.gguf", &mp);
const llama_vocab* vocab = llama_model_get_vocab(model);

llama_context_params cp = llama_context_default_params();
cp.n_ctx   = 4096;                                      // 컨텍스트 길이
cp.n_batch = 512;
llama_context* ctx = llama_init_from_model(model, cp);

// 토크나이즈
std::vector<llama_token> toks(1024);
int n = llama_tokenize(vocab, prompt.c_str(), (int)prompt.size(),
                       toks.data(), (int)toks.size(), true, true);
toks.resize(n);

// 샘플러 체인
llama_sampler* smpl = llama_sampler_chain_init(llama_sampler_chain_default_params());
llama_sampler_chain_add(smpl, llama_sampler_init_top_k(40));
llama_sampler_chain_add(smpl, llama_sampler_init_temp(0.7f));
llama_sampler_chain_add(smpl, llama_sampler_init_dist(LLAMA_DEFAULT_SEED));

// 생성 루프
llama_batch batch = llama_batch_get_one(toks.data(), (int)toks.size());
for (int i = 0; i < 256; ++i) {
    if (llama_decode(ctx, batch) != 0) break;
    llama_token t = llama_sampler_sample(smpl, ctx, -1);
    if (llama_vocab_is_eog(vocab, t)) break;
    char buf[256];
    int m = llama_token_to_piece(vocab, t, buf, sizeof(buf), 0, true);
    fwrite(buf, 1, m, stdout);
    batch = llama_batch_get_one(&t, 1);                 // 다음 입력은 방금 토큰
}
llama.cpp 는 API 안정성을 보장하지 않습니다 — 커밋 해시를 고정하고, 업그레이드는 examples/simple.cpp 의 변경분을 보며 하세요. 이 코드는 구조를 보여 주는 것이 목적입니다.

속도를 결정하는 것

[ 토큰 생성 속도의 이론적 상한 ]
  decode 단계는 토큰마다 '가중치 전체'를 한 번 읽습니다.
  → 초당 토큰 수 ≈ 메모리 대역폭 ÷ 모델 크기

  예) Q4 7B 모델(4.1GB)
      · DDR5 데스크톱   ~60GB/s  →  약 14 tok/s
      · M4 Max 통합메모리 ~410GB/s →  약 100 tok/s
      · RTX 4090 VRAM   ~1000GB/s →  약 240 tok/s

  ★ 연산 능력(TFLOPS)이 아니라 대역폭이 답을 정합니다.
     그래서 애플 실리콘이 로컬 LLM 에 유독 강합니다.

[ prefill 은 반대입니다 ]
  프롬프트 전체를 한 번에 행렬 곱으로 처리 → 연산 병목
  → 긴 프롬프트는 GPU 가 압도적으로 유리합니다

[ 그래서 지표를 두 개로 봅니다 ]
  · TTFT (첫 토큰까지) — prefill 성능
  · TPS  (초당 토큰)   — decode 성능
배치가 판을 바꿉니다 — 요청 하나만 처리하면 대역폭에 묶이지만, 여러 요청을 함께 decode 하면 같은 가중치 읽기를 나눠 쓰므로 총 처리량이 몇 배가 됩니다. vLLM · TensorRT-LLM 이 하는 일이 이것입니다.

실무에선 이렇게 — 토크나이저와 전처리를 C++ 로 🔴 실무

파이썬 없이 토크나이즈하기

// 모델을 C++ 로 옮겨도 토크나이저가 파이썬이면 의미가 없습니다.
// 선택지는 세 가지입니다.

// ① SentencePiece (C++ 원본이 있습니다 — 가장 흔함)
#include <sentencepiece_processor.h>
sentencepiece::SentencePieceProcessor sp;
sp.Load("tokenizer.model");
std::vector<int> ids;
sp.Encode("안녕하세요", &ids);

// ② HuggingFace tokenizers 의 C 바인딩 (Rust 구현 · tokenizers-cpp)
//    BPE · WordPiece · tokenizer.json 을 그대로 읽습니다
#include <tokenizers_cpp.h>
auto tok = tokenizers::Tokenizer::FromBlobJSON(read_file("tokenizer.json"));
std::vector<int32_t> ids2 = tok->Encode("안녕하세요");

// ③ 모델 파일에 토크나이저가 들어 있는 경우 (GGUF · ONNX 확장)
//    llama.cpp 처럼 llama_tokenize 하나로 끝납니다 — 가장 편합니다

// ★ 검증은 반드시 하세요 — 파이썬 결과와 토큰 열이 '완전히 같은지'
//   특수 토큰(BOS/EOS) · 공백 처리 · 정규화(NFKC)에서 자주 어긋납니다
가장 흔한 배포 사고 — “C++ 로 옮겼더니 정확도가 조금 떨어져요”의 원인 1위가 토크나이저 불일치입니다. 모델이 아니라 입력이 달라진 것이라, 파이썬과 C++ 의 토큰 열을 테스트로 비교하는 것을 CI 에 넣으세요.

추론 서버 뼈대

// 요청을 모아 배치로 처리하는 구조 — GPU 활용률이 몇 배가 됩니다
class BatchServer {
    SpscQueue<Req*, 1024> inbox_;              // h03
    Ort::Session& sess_;
    static constexpr int MAX_B = 16;
    static constexpr auto MAX_WAIT = std::chrono::milliseconds(5);

    void worker() {
        std::vector<Req*> batch;
        auto deadline = Clock::now() + MAX_WAIT;
        while (running_) {
            Req* r;
            // ★ 배치가 차거나 마감 시간이 되면 실행 — 지연과 처리량의 타협
            while (batch.size() < MAX_B && Clock::now() < deadline)
                if (inbox_.try_pop(r)) batch.push_back(r);
            if (batch.empty()) { deadline = Clock::now() + MAX_WAIT; continue; }

            run_batch(batch);                   // 한 번의 Run 으로 전부
            for (auto* q : batch) q->done.set_value();
            batch.clear();
            deadline = Clock::now() + MAX_WAIT;
        }
    }
};
동적 배치의 효과 — 이미지 분류 서버에서 배치 없이 초당 320건, 마감 5ms 동적 배치로 초당 2,100건. p99 지연은 3.1ms → 7.4ms 로 늘었지만 장비 수는 6분의 1이 됩니다. 요구 지연이 허락하면 거의 항상 옳은 거래입니다.

한 단계 더 — 직접 짜 볼 때와 도구 지도 🧬 전문가

2026년 추론 도구 지도

도구강점자리
ONNX Runtime이식성 · 전 하드웨어범용 1순위
TensorRTNVIDIA 최속고정 GPU
OpenVINO인텔 CPU · iGPU엣지 · 산업
llama.cpp / GGML양자화 · CPU로컬 LLM
vLLM연속 배치 · PagedAttnLLM 서버
ExecuTorch모바일 · 임베디드온디바이스
TFLite안드로이드 · MCU초소형
ggml 직접완전 통제연구 · 특수
고르는 순서 — ① 배포 대상 하드웨어가 하나로 정해졌나 → 그 벤더 런타임(TensorRT · OpenVINO · CoreML). ② 여러 곳에 나가나 → ONNX Runtime. ③ LLM 인가 → 서버면 vLLM, 데스크톱·엣지면 llama.cpp.

직접 짜 보면 이해가 달라집니다

// 추론 엔진의 핵심은 결국 행렬 곱 하나입니다.
// 양자화된 가중치(4비트)를 풀면서 곱하는 것이 GGML 이 하는 일입니다.

// Q4 블록 — 32개 값을 16바이트 + 스케일 하나로 저장 (2.5배 압축)
struct BlockQ4 {
    uint16_t d;         // FP16 스케일
    uint8_t  qs[16];    // 4비트 × 32개
};

// 한 블록을 풀어 float 32개로 (실제로는 SIMD 로 처리합니다)
inline void dequant_q4(const BlockQ4& b, float* out) {
    const float d = fp16_to_fp32(b.d);
    for (int i = 0; i < 16; ++i) {
        int lo = (b.qs[i] & 0x0F) - 8;      // 하위 4비트
        int hi = (b.qs[i] >>   4) - 8;      // 상위 4비트
        out[i]      = lo * d;
        out[i + 16] = hi * d;
    }
}
// 실제 커널은 '풀어서 저장'하지 않고 곱셈에 바로 넣습니다 —
// 메모리를 한 번만 읽는 것이 전부이기 때문입니다(위 대역폭 계산 참고).
직접 짜는 것을 권하지는 않습니다 — 다만 이 구조를 알면 “왜 Q4 가 2.5배 작은데 품질은 별로 안 떨어지나”, “왜 대역폭이 속도를 정하나”, “왜 배치가 이득인가”가 전부 설명됩니다. 수치를 해석할 수 있게 되는 것이 목적입니다.
정리 — 추론을 C++ 로 옮기는 값어치는 속도보다 배포에 있습니다 — 실행 파일 하나, 의존성 없음, 지연 편차 없음. 병목은 대개 메모리 대역폭입니다.
HPC 12

컴파일 · 바이너리 최적화 — LTO · PGO · 빌드 시간

코드를 한 줄도 안 고치고 10~30% 빨라지는 방법이 있습니다. 컴파일러에게 더 넓게 보게 하고(LTO), 실제로 어떻게 도는지 알려 주는(PGO) 것입니다. 동시에 C++ 의 가장 큰 불편인 빌드 시간을 줄이는 실전 처방도 함께 봅니다.

LTO 와 PGO — 코드를 안 고치고 얻는 성능 ① 일반 빌드 — 파일마다 따로 최적화합니다 a.cpp · b.cpp 번역 단위 각각 각각 .o 로 파일 안에서만 인라인 링커 심볼을 이어 붙일 뿐 실행 파일 기준 속도 ② LTO — 링크할 때 프로그램 전체를 다시 최적화합니다 -flto 로 컴파일 같은 소스 .o 에 IR 저장 기계어 대신 중간 표현 링커가 재최적화 파일 경계 넘어 인라인 5~20% 빠름 크기도 보통 줄어듭니다 ③ PGO — 한 번 돌려 보고, 그 결과로 다시 컴파일합니다 계측 빌드 -fprofile-generate 대표 작업 실행 진짜 워크로드로 ★ 프로파일 파일 .gcda · .profdata 재빌드 -fprofile-use · 10~30% 둘은 함께 씁니다 — 그리고 둘 다 '빌드 시간'을 대가로 냅니다 LTO 는 링크가 몇 배 느려지고, PGO 는 빌드를 두 번 해야 합니다 — 그래서 릴리스 빌드에서만 켭니다 -march=native 는 그 머신에서만 도는 바이너리를 만듭니다 — 배포용이면 -mtune 이나 함수 다중 버전을 쓰세요
코드를 안 고치고 얻는 성능은 여기까지입니다 — 대신 확실합니다.

LTO 켜기 — CMake 와 직접

# CMake 방식 (권장) — 컴파일러별 플래그를 알아서 넣습니다
include(CheckIPOSupported)
check_ipo_supported(RESULT ok OUTPUT msg)
if(ok)
  set_property(TARGET app PROPERTY INTERPROCEDURAL_OPTIMIZATION TRUE)
else()
  message(WARNING "LTO 미지원: ${msg}")
endif()

# 릴리스에서만 켜기
set_property(TARGET app PROPERTY
  INTERPROCEDURAL_OPTIMIZATION_RELEASE TRUE)
# 직접 줄 때 — 컴파일과 링크 '양쪽 모두'에 붙여야 합니다 ★
g++ -std=c++23 -O2 -flto=auto -c a.cpp
g++ -std=c++23 -O2 -flto=auto -c b.cpp
g++ -O2 -flto=auto a.o b.o -o app

# Clang 은 ThinLTO 를 쓰세요 — 병렬로 돌아 훨씬 빠릅니다
clang++ -O2 -flto=thin -c a.cpp
clang++ -O2 -flto=thin a.o b.o -o app -fuse-ld=lld

# MSVC
cl /O2 /GL a.cpp b.cpp /link /LTCG
LTO 가 드러내는 버그 — 파일 경계를 넘어 최적화하면서 ODR 위반(같은 이름 다른 정의)이나 UB 가 그제야 터집니다. “LTO 를 켰더니 크래시가 난다”는 대개 원래 있던 버그이고, -Wodr 경고를 먼저 읽어 보세요.

PGO — 세 단계 절차

# ───── GCC ─────
# ① 계측 빌드
g++ -O2 -fprofile-generate -o app_gen *.cpp
# ② 대표 워크로드로 실행 (★ 여기가 전부입니다)
./app_gen < representative_input.dat
# → *.gcda 파일들이 생깁니다
# ③ 프로파일로 재빌드
g++ -O2 -fprofile-use -fprofile-correction -o app *.cpp

# ───── Clang ─────
clang++ -O2 -fprofile-instr-generate -o app_gen *.cpp
LLVM_PROFILE_FILE="app-%p.profraw" ./app_gen < input.dat
llvm-profdata merge -output=app.profdata app-*.profraw
clang++ -O2 -fprofile-use=app.profdata -o app *.cpp

# ───── 샘플링 PGO (계측 없이, 운영 중인 프로세스에서) ─────
perf record -b -o perf.data -- ./app          # -b = 분기 기록(LBR)
create_llvm_prof --binary=./app --profile=perf.data --out=app.prof
clang++ -O2 -fprofile-sample-use=app.prof -o app *.cpp
PGO 가 실제로 하는 일 — ① 자주 도는 분기를 예측 힌트로, ② 뜨거운 함수만 인라인(차가운 함수는 크기 우선), ③ 코드 배치 재정렬로 명령어 캐시 적중률 향상. 분기가 많은 코드(파서 · 인터프리터 · 컴파일러)에서 효과가 가장 큽니다.

이렇게도 쓸 수 있다 — -march=native 의 함정 🟡 중급

벤치마크에서만 빠른 플래그

# 내 머신에서 재면 빠릅니다
g++ -O3 -march=native app.cpp -o app

# 그런데 배포하면...
./app
#   Illegal instruction (core dumped)      ← 다른 CPU 에는 그 명령이 없습니다

# 무엇이 켜지는지 보기
g++ -march=native -Q --help=target | grep -E '^\s+-m(avx|sse|fma|bmi)' | grep enabled

# 배포용 대안 ① 최소 기준선 + 튜닝 (명령어는 안전, 스케줄링만 맞춤)
g++ -O3 -march=x86-64-v2 -mtune=native app.cpp
#   x86-64-v1 : 원조 64비트 (거의 모든 CPU)
#   x86-64-v2 : SSE4.2 · POPCNT  (2009년 이후)
#   x86-64-v3 : AVX2 · FMA · BMI (2013년 이후) ← 2026년 서버 기본선
#   x86-64-v4 : AVX-512          (서버 일부만)

# 대안 ② 여러 벌 빌드해서 실행 시 고르기
g++ -O3 -march=x86-64-v3 -o app.v3 app.cpp
g++ -O3 -march=x86-64-v2 -o app.v2 app.cpp
# glibc-hwcaps 디렉터리에 두면 로더가 알아서 고릅니다
-ffast-math 는 더 위험합니다 — NaN·무한대 처리를 바꾸고 결합 법칙을 허용해 결과가 달라집니다. 금융·과학 계산에서는 절대 쓰지 말고, 필요하면 -fno-math-errno 처럼 영향이 좁은 것만 골라 켜세요.

함수 하나만 여러 버전으로

// ① target_clones — 컴파일러가 여러 버전을 만들고 실행 시 고릅니다
__attribute__((target_clones("avx512f","avx2","sse4.2","default")))
float dot(const float* a, const float* b, size_t n) {
    float s = 0;
    for (size_t i = 0; i < n; ++i) s += a[i] * b[i];
    return s;
}
// 한 함수에 대해 네 벌이 만들어지고, 첫 호출 때 CPU 를 보고 결정합니다(IFUNC)

// ② 수동 디스패치 — 통제가 필요할 때
#include <cpuid.h>
static bool has_avx2() {
    unsigned a, b, c, d;
    __get_cpuid_count(7, 0, &a, &b, &c, &d);
    return (b & bit_AVX2) != 0;
}
using DotFn = float(*)(const float*, const float*, size_t);
DotFn dot_impl = has_avx2() ? dot_avx2 : dot_sse;   // 시작할 때 한 번

// ③ 파일 단위로 다른 플래그 (CMake)
// set_source_files_properties(kernel_avx2.cpp PROPERTIES
//                             COMPILE_OPTIONS "-mavx2;-mfma")
실무의 표준 해법 — 기본 바이너리는 안전한 기준선으로 빌드하고, 정말 뜨거운 함수 몇 개만 다중 버전으로 만듭니다. 전체를 -march=native 로 빌드하는 것은 내 노트북에서 실험할 때만 하세요.

실무에선 이렇게 — 빌드 시간을 반으로 줄이기 🔴 실무

어디에 시간이 가는지 먼저 봅니다

# ① Clang 의 -ftime-trace — 파일마다 JSON 이 나옵니다
clang++ -std=c++23 -ftime-trace -c src/big.cpp
#   big.json 을 ui.perfetto.dev 에 올리면
#   어느 헤더 · 어느 템플릿 인스턴스화가 시간을 먹는지 그림으로 보입니다

# ② 전체 빌드를 한 장으로 (ClangBuildAnalyzer)
ClangBuildAnalyzer --all build/ capture.bin
ClangBuildAnalyzer --analyze capture.bin
#   **** Templates that took longest to instantiate:
#     12043 ms: std::_Hashtable<...> (312 times, avg 38 ms)
#   **** Expensive headers:
#     28104 ms: /usr/include/c++/14/regex  (included 41 times)   ← 범인 ★

# ③ GCC 는 -ftime-report (덜 자세하지만 무료)
g++ -ftime-report -c big.cpp

# ④ 쓸데없는 include 찾기
include-what-you-use -Xiwyu --no_fwd_decls src/foo.cpp -- -std=c++23
거의 항상 같은 범인 — <regex> · <iostream> · <chrono> 같은 무거운 표준 헤더가 공용 헤더에 들어가 있어서 수백 개 파일이 함께 파싱합니다. 헤더에서 빼고 .cpp 로 내리는 것만으로 빌드가 30% 줄어드는 일이 흔합니다.

효과 순서대로 적용하기

# ① 캐시 — 두 번째 빌드부터 몇 배 (제일 먼저)
sudo apt install ccache
cmake -B build -DCMAKE_CXX_COMPILER_LAUNCHER=ccache
ccache -s                       # 적중률 확인 (80% 넘겨야 정상)

# ② 링커 교체 — 링크 시간이 절반 이하 (특히 LTO 없이)
sudo apt install lld mold
cmake -B build -DCMAKE_EXE_LINKER_FLAGS="-fuse-ld=mold"

# ③ Ninja — make 보다 의존성 처리가 빠릅니다
cmake -B build -G Ninja

# ④ 유니티 빌드 — 여러 .cpp 를 묶어 한 번에 컴파일
cmake -B build -DCMAKE_UNITY_BUILD=ON -DCMAKE_UNITY_BUILD_BATCH_SIZE=16
#   ★ 헤더 파싱을 공유해 전체 빌드가 2~4배 빨라집니다
#   ★ 다만 익명 네임스페이스 충돌 · static 이름 충돌이 드러납니다

# ⑤ 미리 컴파일된 헤더
# target_precompile_headers(app PRIVATE <vector> <string> "common.hpp")

# ⑥ 분산 빌드 (팀 규모라면)
#   sccache (S3/Redis 공유 캐시) · distcc · icecc
처방전체 빌드증분
기본18분45초
+ Ninja + mold15분22초
+ 헤더 정리(IWYU)11분16초
+ 유니티 빌드4분28초
+ ccache(두 번째)50초16초
유니티 빌드의 뒷면 — 전체 빌드는 빨라지지만 증분 빌드는 오히려 느려집니다(한 파일만 고쳐도 묶음 전체 재컴파일). CI 는 유니티, 개발자 로컬은 일반으로 나누는 것이 보통입니다.

한 단계 더 — 모듈 현황과 바이너리 다이어트 🧬 전문가

C++20 모듈 — 2026년의 솔직한 상태

// math.cppm — 모듈 인터페이스 단위
export module math;

export int add(int a, int b) { return a + b; }
export template <class T> T square(T x) { return x * x; }

int helper() { return 42; }        // export 안 하면 밖에서 안 보입니다 ★

// main.cpp
import math;                        // 전처리가 아니라 '컴파일된 것'을 가져옵니다
import std;                         // 표준 라이브러리 통째로 (C++23)

int main() { std::println("{}", add(1, 2)); }
cmake_minimum_required(VERSION 3.28)   # 모듈 지원은 3.28+
add_library(math)
target_sources(math PUBLIC FILE_SET CXX_MODULES FILES math.cppm)
set_target_properties(math PROPERTIES CXX_STANDARD 20)
# 생성기는 Ninja 1.11+ 또는 VS 2022 여야 합니다
컴파일러모듈import std;
MSVC 2022실용 가능가능
Clang 18+대체로 동작설정 필요
GCC 14/15기본은 동작제한적
2026년 현실 — 새 프로젝트를 MSVC 단독으로 한다면 쓸 만합니다. 세 컴파일러를 모두 지원해야 하는 라이브러리라면 아직 이릅니다. 빌드 시간 개선 효과는 실제로 크지만 (헤더 파싱 반복이 사라짐), 도구 생태계가 따라오는 중입니다.

바이너리 크기와 코드 배치

# ① 안 쓰는 코드 잘라내기 — 임베디드·컨테이너에서 큰 효과
g++ -O2 -ffunction-sections -fdata-sections app.cpp \
    -Wl,--gc-sections -Wl,--print-gc-sections -o app
strip --strip-all app

# ② 무엇이 크기를 먹는지 보기
sudo apt install bloaty
bloaty -d compileunits app | head -20
nm -C --size-sort -S app | tail -20

# ③ 예외·RTTI 가 필요 없다면 (임베디드)
g++ -fno-exceptions -fno-rtti -Os app.cpp
#   보통 20~40% 작아집니다. 다만 표준 라이브러리 일부를 못 씁니다

# ④ BOLT — 링크 후 바이너리를 다시 배치합니다 (LTO·PGO 위에 추가로)
perf record -e cycles:u -j any,u -o perf.data -- ./app
perf2bolt -p perf.data -o app.fdata ./app
llvm-bolt ./app -o app.bolt -data=app.fdata \
    -reorder-blocks=ext-tsp -reorder-functions=hfsort -split-functions
#   페이스북·구글이 실제로 쓰는 마지막 5~15% ★
적용 순서 — -O2 → LTO → PGO → BOLT 순으로 얹습니다. 각 단계가 독립적으로 몇 %씩 더해지고, 코드는 한 줄도 안 바뀝니다. 대신 빌드 파이프라인이 복잡해지므로 정말 성능이 돈인 서비스에서만 끝까지 갑니다.
정리 — LTO · PGO 는 코드를 안 고치고 얻는 마지막 공짜 성능이고, 빌드 시간은 측정(-ftime-trace) → 헤더 정리 → 캐시 → 유니티 순으로 줄입니다.
HPC 13

프로파일링 실전 — 느린 것을 찾는 순서

성능 작업에서 가장 흔한 실패는 “빨라 보이는 곳”을 고치는 것입니다. 실제로는 아무도 예상 못 한 곳이 90%를 먹고 있습니다. 추측을 금지하고 순서대로 재는 방법을 정리하고, 마지막에 실제 사례 하나를 끝까지 따라갑니다.

느린 것을 찾는 순서 — 추측 금지 ① 무엇이 느린지 정의 지표 하나만 고릅니다 (p99? 처리량?) ② CPU 사용률을 봅니다 top · perf stat · 컨테이너면 cgroup CPU 가 낮다 = 기다리는 중 I/O · 락 · 네트워크 · 스케줄러 대기 코어를 늘려도 소용없습니다 CPU 가 높다 = 계산 중 perf record -g → 플레임그래프 어느 함수가 시간을 먹는지 봅니다 무엇을 기다리나 offcputime · perf sched · bpftrace 락이면 경합 프로파일 · I/O 면 iostat IPC 를 봅니다 IPC 1 미만 → 메모리 · 캐시 병목 IPC 2 초과 → 진짜 계산량 문제 ③ 고친 뒤 같은 지표를 다시 잽니다 — 재지 않으면 고친 것이 아닙니다 개선폭을 기록하고, 다음으로 큰 항목으로 넘어갑니다 · 한 번에 하나만 바꿉니다
순서를 지키면 대부분의 성능 문제는 두세 번 만에 잡힙니다.

perf — 리눅스의 기본 도구

sudo apt install linux-tools-common linux-tools-$(uname -r)
# 컨테이너/클라우드면 권한이 필요합니다
sudo sysctl -w kernel.perf_event_paranoid=1

# ① 전체 지표를 한눈에 — 여기서 방향이 정해집니다
perf stat -d ./app
#   12,043,112,884  cycles
#    8,102,338,201  instructions   #  0.67 insn per cycle   ← IPC ★
#      412,003,110  cache-misses   #  38.2% of all cache refs ★
#       92,001,443  branch-misses  #   4.1% of all branches

# ② 어느 함수인가 — 호출 그래프까지
perf record -g --call-graph=dwarf -F 999 -- ./app
perf report --stdio | head -40

# ③ 실행 중인 프로세스에 붙기 (운영 서버에서 흔한 방식)
sudo perf record -g -p $(pgrep engine) -- sleep 30

# ④ 플레임그래프 — 사람이 읽을 수 있는 형태로 ★
git clone https://github.com/brendangregg/FlameGraph
perf script | ./FlameGraph/stackcollapse-perf.pl \
            | ./FlameGraph/flamegraph.pl > flame.svg
#   가로 폭 = 그 함수에 머문 시간 · 위로 갈수록 깊은 호출
#   '넓고 평평한 봉우리' 를 찾으면 그것이 범인입니다
-fno-omit-frame-pointer 를 잊지 마세요 — 이게 없으면 호출 스택이 중간에 끊겨 플레임그래프가 쓸모없어집니다. 릴리스 빌드에도 넣으세요 — 손해는 1% 남짓입니다.

숫자를 해석하는 법

지표기준낮/높을 때
IPC1.0~3.0<1 이면 대기
cache-miss<5%>20% 면 배치 문제
branch-miss<2%>5% 면 예측 실패
dTLB-miss<0.5%huge page 검토
context-switch적을수록많으면 락·I/O
page-faults초기만계속이면 할당
# 더 파고들 때 쓰는 이벤트들
perf stat -e cycles,instructions,cache-misses,LLC-load-misses,\
dTLB-load-misses,branch-misses,stalled-cycles-frontend,\
stalled-cycles-backend ./app

# 인텔 CPU 면 톱다운 분석이 가장 강력합니다
perf stat --topdown -a -- ./app
#   Frontend Bound  8.1%   ← 명령어를 못 가져옴 (코드 크기 · iTLB)
#   Bad Speculation 4.2%   ← 분기 예측 실패
#   Backend Bound  61.3%   ← 데이터를 기다림 ★ 여기가 크면 메모리 문제
#   Retiring       26.4%   ← 실제로 일한 비율

# false sharing 을 직접 찾기 (h03 · 성능 탭)
perf c2c record ./app && perf c2c report
제일 먼저 볼 두 숫자 — IPC 와 Backend Bound. IPC 가 0.5 인데 CPU 는 100% 라면 “열심히 기다리는 중”이고, 그때 알고리즘을 고쳐 봐야 소용없습니다. 메모리 배치를 봐야 합니다.

이렇게도 쓸 수 있다 — 플랫폼별 도구 🟡 중급

Tracy · VTune · cachegrind

// ★ Tracy — 프레임 단위 실시간 프로파일러 (게임·오디오·실시간에 최적)
#include <tracy/Tracy.hpp>

void update() {
    ZoneScoped;                       // 이 스코프의 시간이 기록됩니다
    {
        ZoneScopedN("physics");       // 이름 붙인 구역
        step_physics();
    }
    TracyPlot("entities", (int64_t)n);   // 그래프로 값 추적
    FrameMark;                        // 프레임 경계
}
// 빌드: -DTRACY_ENABLE, tracy 클라이언트 링크
// 별도 앱(Tracy Profiler)을 켜 두면 실행 중인 프로그램이 실시간으로 보입니다
// 오버헤드가 매우 작아 '항상 켜 두고' 개발하는 팀도 많습니다
# VTune — 인텔 CPU 라면 가장 자세합니다 (무료)
vtune -collect hotspots -r res ./app
vtune -collect memory-access -r res2 ./app     # 메모리 병목 전용
vtune-gui res

# cachegrind — 실제 하드웨어 없이 캐시 동작을 시뮬레이션
valgrind --tool=cachegrind --cache-sim=yes ./app
cg_annotate cachegrind.out.*
#   50~100배 느리지만 '정확한 미스 횟수'를 줍니다
#   A/B 비교(고치기 전후)에 특히 유용합니다

# callgrind — 호출 횟수와 함수별 비용
valgrind --tool=callgrind ./app && kcachegrind callgrind.out.*
고르는 기준 — 서버·배치 작업이면 perf, 프레임이 있는 실시간이면 Tracy, 메모리 병목이 의심되면 VTune 이나 cachegrind. Tracy 는 “이 프레임이 왜 튀었나”를 볼 수 있는 거의 유일한 도구입니다.

윈도우 — ETW 와 WPA

[ 도구 ]
 · Windows Performance Recorder (WPR) — 수집
 · Windows Performance Analyzer (WPA) — 분석
 · 둘 다 Windows ADK 에 포함, 무료

[ 수집 ]
   wpr -start CPU -start DiskIO -start FileIO
   ... 재현 작업 수행 ...
   wpr -stop trace.etl

[ 분석 ]
   wpa trace.etl
   · CPU Usage (Sampled) → 함수별 시간
   · CPU Usage (Precise) → 컨텍스트 스위치 · 대기 원인 ★
     "Ready Thread" 열로 '누가 나를 깨웠는지'까지 추적됩니다
   · Disk Usage · File I/O → 파일별 지연

[ 더 쉬운 대안 ]
 · Visual Studio 성능 프로파일러 (F5 로 바로)
 · Superluminal (유료지만 게임 업계 표준에 가까움)
 · Intel VTune (윈도우도 지원)
WPA 의 강점 — “내 스레드가 왜 안 돌았는지”를 커널 수준에서 알려 줍니다. “CPU 는 놀고 있는데 느리다”는 문제에서 어느 스레드가 나를 막고 있었는지까지 나옵니다. 리눅스에서는 perf sched 나 offcputime 이 비슷한 역할입니다.

실무에선 이렇게 — 체크리스트로 굳혀 두기 🔴 실무

성능 작업 순서 — 인쇄해서 붙여 두세요

  0. 재현 가능한 벤치마크를 먼저 만든다
     · 5회 이상 돌려 편차를 본다 (편차가 개선폭보다 크면 무의미)
     · 터보 부스트 · 주파수 조절 · 다른 프로세스를 끈다
     · 릴리스 빌드로 잰다 (디버그 빌드 측정은 전부 거짓말)

  1. 목표 지표를 하나만 정한다   (p99 · 처리량 · 총 시간 중 하나)
  2. CPU 사용률을 본다           → 낮으면 대기, 높으면 계산
  3. 플레임그래프를 뜬다          → 넓은 봉우리 세 개를 적는다
  4. perf stat 으로 IPC·미스율    → 성격(메모리 / 분기 / 계산)을 판정
  5. 가장 큰 것 하나만 고친다     → 두 개 동시에 고치면 원인을 모른다
  6. 다시 잰다                   → 개선폭을 기록
  7. 2번으로 돌아간다            → 목표에 닿거나, 남은 항목이 5% 미만일 때까지

  ★ 하지 말 것
     · 프로파일 없이 '느려 보이는 곳' 고치기
     · 마이크로 최적화부터 시작하기 (알고리즘 · I/O · 배치가 먼저)
     · 한 번 재고 결론 내리기
     · 개선 수치를 기록 안 하기
0번이 제일 중요합니다 — 벤치마크가 흔들리면 그 뒤 모든 작업이 운에 맡긴 것이 됩니다. taskset 으로 코어 고정, cpupower frequency-set -g performance 로 주파수 고정부터 하세요.

운영 중인 서비스에서 재기

# ① 상시 저빈도 샘플링 — 오버헤드 1% 미만
perf record -F 49 -a -g -o /var/perf/$(date +%s).data -- sleep 60
#   99Hz 대신 49Hz, 주기적으로 짧게 — 문제가 났을 때 과거를 볼 수 있습니다

# ② 연속 프로파일링 (2026년의 표준 방식)
#   Parca · Pyroscope · Google Cloud Profiler
#   eBPF 로 전 프로세스를 상시 샘플링하고 웹에서 플레임그래프를 봅니다

# ③ 특정 순간만 — 지연이 튀는 순간을 잡기
sudo bpftrace -e '
  uretprobe:/opt/app/engine:handle_request {
    @us = hist((nsecs - @start[tid]) / 1000);
  }
  uprobe:/opt/app/engine:handle_request { @start[tid] = nsecs; }'
#   히스토그램이 바로 뜹니다 — p99 가 어디인지 눈으로 확인

# ④ 코드에 심어 두는 상시 지표 (가장 신뢰도 높음)
#   구간별 rdtsc 히스토그램(h02)을 /metrics 로 노출
#   → 프로파일러 없이도 '어느 구간이 언제부터 느려졌는지' 추적됩니다
연속 프로파일링을 권합니다 — 성능 문제는 대개 재현이 안 됩니다. “어제 새벽 3시에 느렸다”를 조사하려면 그때의 플레임그래프가 이미 저장돼 있어야 합니다. 오버헤드는 1% 미만이고, 얻는 것은 비교가 안 됩니다.

한 단계 더 — 실제 사례 하나를 끝까지 🧬 전문가

증상부터 원인까지

[ 상황 ]
  JSON 요청을 받아 검증하고 DB 에 넣는 C++ 서비스.
  평균 지연 1.2ms 인데 p99 가 48ms. 트래픽이 늘면 p99 가 200ms 까지.

[ 1단계 — 지표 정하기 ]
  목표: p99 를 10ms 아래로. 평균은 신경 쓰지 않기로 함.

[ 2단계 — CPU 사용률 ]
  top: 프로세스 CPU 340% (8코어 중). 여유가 있는데 지연이 튄다.
  → '높지도 낮지도 않다' = 일부 요청만 뭔가를 기다린다는 뜻

[ 3단계 — 플레임그래프 ]
  perf record -F 99 -g -p PID -- sleep 60
  넓은 봉우리 셋:
    41%  operator new / _int_malloc / sysmalloc      ← 예상 밖 ★
    18%  nlohmann::json::parse
    11%  std::regex 매칭 (검증 코드)

[ 4단계 — 성격 판정 ]
  perf stat: IPC 0.58 · page-faults 초당 12만 건 (계속 증가)
  → 계산이 아니라 '메모리를 계속 새로 얻고 있다'

[ 5단계 — 원인 확정 ]
  heaptrack: 요청 하나당 할당 1,840회.
    · nlohmann::json 이 노드마다 개별 할당
    · std::regex 객체를 매 요청 생성 (컴파일 비용!)
    · 응답 문자열을 다섯 번 복사

고치고 다시 재기

// ① JSON 파서 교체 — 무할당 SIMD 파서로
//    nlohmann::json → simdjson (읽기 전용 파싱에 최적)
simdjson::ondemand::parser parser;          // 스레드당 하나, 재사용 ★
auto doc = parser.iterate(padded_body);
std::string_view name = doc["user"]["name"];  // 복사 없음

// ② regex 를 미리 컴파일해 재사용 (또는 아예 제거)
static const std::regex kEmail(R"(^[\w.]+@[\w.]+$)");   // 한 번만
// 더 나은 선택: 간단한 검증은 손으로 짠 함수가 100배 빠릅니다

// ③ 요청 단위 아레나 (h04)
std::pmr::monotonic_buffer_resource pool{stack_buf, sizeof(stack_buf)};
std::pmr::string response{&pool};

// ④ 할당자 교체 (h04) — 코드 수정 없이
// LD_PRELOAD=libmimalloc.so
[ 6단계 — 다시 재기 ]
  변경                        p99      할당/요청
  기준                        48ms      1,840
  + simdjson                  22ms        410
  + regex 미리 컴파일          14ms        380
  + pmr 아레나                 6.1ms        12
  + mimalloc                   5.4ms        12   ★ 목표 달성

[ 7단계 — 남은 것 ]
  플레임그래프 다시: 가장 큰 봉우리가 이제 DB 드라이버(31%).
  → 여기서 멈춤. 다음 라운드의 대상이 정해졌습니다.

[ 배운 것 ]
  · 범인은 '파싱'이 아니라 '할당'이었습니다
  · 평균(1.2ms)은 처음부터 끝까지 거의 안 변했습니다
  · 코드 변경량은 200줄 미만, 알고리즘은 하나도 안 바꿨습니다
이 사례의 교훈 — p99 문제의 원인은 거의 항상 “가끔 비싼 것”입니다. 할당 · 페이지 폴트 · 락 대기 · GC · 리해싱 · 로그 플러시. 평균을 보면 절대 안 보입니다 — 그래서 h02 의 히스토그램이 필요합니다.
정리 — 프로파일링은 도구가 아니라 순서입니다 — 지표 정의 → CPU 사용률 → 플레임그래프 → IPC → 하나만 고치고 다시 재기.
Qt 6.8 LTS · Dear ImGui · SDL3 · wxWidgets 3.3 · JUCE 8 · Slint · WinUI 3 · 2026 기준

화면이 있는
프로그램을
C++ 로 만듭니다

파이썬에서 PySide6 로 창을 띄워 본 사람을 기준으로 씁니다. 같은 QPushButton 을 C++ 에서 만들면 무엇이 같고 무엇이 달라지는지, 설치부터 첫 창 · 스레드 · DB · 다국어 · 설치 파일 만들기까지 한 줄씩 따라갑니다. 툴킷은 하나가 아니라서 무엇을 만드느냐에 따라 Qt · Dear ImGui · SDL3 · wxWidgets 중 무엇을 고를지부터 정리하고, 마지막에는 수백만 줄짜리 로그를 여는 뷰어 앱을 Qt 로 배포 파일까지 끝냅니다. 이 탭을 마치면 “설치 파일을 건네줄 수 있는” 상태가 됩니다.

12SECTIONS
Qt 6주력 스택
PySide6대조 기준
100%복붙해서 도는 예제
APP 01

왜 C++ 로 앱을 만드나 — 툴킷 일곱 개 중에 고르기

2026년에 데스크톱 앱을 만드는 가장 쉬운 길은 사실 Electron 이나 Tauri 입니다. 그래도 C++ 를 고르는 이유가 분명히 있고, 그 이유가 어떤 툴킷을 고를지까지 결정합니다. 먼저 그 지도를 그린 다음에 설치로 넘어갑니다.

무엇을 만드느냐가 툴킷을 정합니다 — 2026년 선택 흐름 C++ 로 화면 있는 프로그램을 만든다 Qt 6 파는 일반 앱 기본값 Dear ImGui 내부 도구 디버그 화면 SDL3 · raylib 게임 직접 그리는 화면 wxWidgets 옛 윈도우 코드 네이티브 룩 Win32 · WinUI 3 윈도우 전용 트레이 유틸 분야가 정해져 있으면 전용 툴킷이 훨씬 빠릅니다 JUCE — 오디오 플러그인(VST3·AU) · Slint — 임베디드 화면 · Qt Quick — 애니메이션 많은 UI 그런데 — 화면이 웹 기술로 충분하다면 C++ 가 아닙니다 CRUD 폼과 목록뿐이고 성능이 문제가 아니면 Tauri(Rust+웹) · Electron 이 사람 값이 훨씬 쌉니다 C++ 는 무거운 계산 · 큰 데이터 · 기존 C++ 자산 · 단일 실행 파일이 필요할 때의 선택입니다
순서는 “정말 C++ 여야 하나” → “무엇을 만드나” → 툴킷 입니다.

툴킷 일곱 개 한눈에 ★

툴킷강점약점
Qt 6위젯·QML·네트워크·DB·다국어가 한 상자. 문서·구인 시장 1위덩치 큼(런타임 40~120MB), 라이선스 판단 필요
Dear ImGui파일 몇 개 붙이면 끝. UI 코드가 로직 안에 들어감일반 사용자용 룩이 아님, 접근성·IME 약함
SDL3창·입력·오디오·GPU 의 얇은 표준층. 게임의 바닥버튼 하나도 직접 그림
raylib30줄이면 게임 하나. 배우기 가장 쉬움큰 프로젝트 구조는 직접 설계
wxWidgets진짜 OS 네이티브 위젯을 씀. LGPL 보다 느슨한 라이선스API 가 예스러움, QML 같은 선언형 없음
JUCE오디오 플러그인의 사실상 표준(VST3·AU·AAX)오디오 밖에서 쓸 이유가 없음
Slint선언형 .slint, 임베디드 화면에 가벼움생태계가 아직 얇음
고민이 길어지면 Qt 를 고르세요 — 이 표의 나머지는 전부 “특정 상황에서 Qt 보다 나은” 도구입니다. 상황이 특정되지 않았다면 Qt 가 통계적으로 맞는 선택입니다. 이 탭도 절반 이상을 Qt 에 씁니다(g02~g06, g12).

PySide6 를 아는 사람이 겪는 낙차

PySide6Qt C++
설치pip install pyside6온라인 설치기 · vcpkg
실행python app.pyCMake 빌드 후 실행
수명 관리GC + 부모 자식부모 자식이 전부
시그널 연결btn.clicked.connect(f)connect(btn, &QPushButton::clicked, …)
UI 파일런타임 로드 가능빌드 때 코드 생성(uic)
배포PyInstallerwindeployqt + 설치기
배포 크기80~200MB15~60MB
시작 속도0.5~2초0.05~0.2초
가장 큰 낙차는 “객체가 언제 죽나”입니다 — 파이썬은 참조가 사라지면 알아서 정리합니다. C++ Qt 는 부모가 있는 QObject 는 부모가 지우고, 부모가 없으면 내가 지웁니다. 이 규칙 하나만 몸에 익으면 Qt C++ 는 PySide6 보다 오히려 단순합니다(g03 에서 자세히).

그래서 C++ 를 고르는 진짜 이유 — 네 가지 🟢 기초

숫자로 보는 이유

① 시작 속도 — 사용자가 가장 먼저 느끼는 것
   Electron  창이 뜰 때까지  0.8 ~ 3.0초  (크로뮴 부팅)
   PySide6                   0.5 ~ 2.0초  (파이썬 + Qt import)
   Qt C++                    0.05 ~ 0.2초  ★ 클릭하면 이미 떠 있음

② 메모리 — 백그라운드에 늘 떠 있는 앱일수록 중요
   Electron  빈 창          150 ~ 400MB
   Qt C++    빈 창           25 ~ 60MB

③ 큰 데이터 — 앱이 죽느냐 마느냐가 갈립니다
   1,000만 행 테이블을 JS 배열로  →  수 GB, 사실상 불가
   1,000만 행을 C++ 구조체 배열로 →  200~400MB, 스크롤 부드러움

④ 기존 C++ 자산 — 이게 실무에서 제일 흔한 이유입니다
   이미 있는 엔진·SDK·영상 처리 코드에 화면만 붙일 때
   FFI 로 넘기는 것보다 같은 프로세스가 압도적으로 단순합니다
반대로 C++ 를 고르면 안 되는 신호 — 화면이 폼과 목록뿐이다 · 디자이너가 CSS 로 시안을 준다 · 2주 안에 시연해야 한다 · 팀에 C++ 를 읽는 사람이 없다. 이 중 둘 이상이면 Tauri 2 나 Electron 이 정답입니다.

라이선스 — 넘어가면 나중에 아픕니다 ★

툴킷라이선스상용 배포 시
Qt 6LGPLv3 또는 상용동적 링크면 LGPL 로 가능
Dear ImGuiMIT자유
SDL3zlib자유
raylibzlib자유
wxWidgetswxWindows(LGPL+예외)정적 링크도 자유
JUCEGPL 또는 상용매출 기준 유료
SlintGPL·상용·로열티프리조건부 무료
Qt LGPL 의 실무 요약 — ① Qt 를 DLL/so 로 동적 링크하고 ② Qt 자체를 고쳤다면 그 수정본 소스를 공개하고 ③ 사용자가 Qt 부분을 교체할 수 있게 두면, 내 앱 소스는 공개하지 않아도 됩니다. 정적 링크를 하거나 Qt 소스를 고쳐 넣으면 얘기가 완전히 달라집니다(g06). 임베디드처럼 교체가 불가능한 기기에 넣을 때도 상용 라이선스가 안전합니다. 법률 자문은 회사 법무팀에 — 여기 적은 건 개발자가 알아야 할 최소한입니다.

실무에선 이렇게 — 섞어 씁니다 🟡 중급

한 앱 안에 두 툴킷

실제 제품에서 자주 보는 조합

① Qt Widgets  +  Dear ImGui
   사용자가 보는 화면은 Qt, 개발자용 디버그 오버레이는 ImGui
   → QOpenGLWidget 안에 ImGui 를 그려 F12 로 토글

② Qt Widgets  +  Qt Quick(QML)
   메뉴·툴바·도킹은 Widgets, 그래프·애니메이션 패널만 QML
   → QQuickWidget 으로 QML 화면을 위젯처럼 끼워 넣습니다

③ SDL3(창·입력)  +  Dear ImGui(UI)
   게임 엔진 툴체인의 표준 조합. 에디터가 이 구조입니다

④ 네이티브 셸  +  웹뷰
   Qt WebEngine 이나 WebView2 로 일부 화면만 웹으로
   → 결제·약관·리포트처럼 자주 바뀌는 화면에 유용

⑤ C++ 코어  +  파이썬/Electron 프런트
   계산은 C++ 라이브러리, 화면은 다른 언어 — 🔗 FFI 탭 참고
   → 프로세스가 나뉘면 크래시 격리가 되는 장점도 있습니다
①~③ 은 전부 “보여 줄 사람이 다르다”가 이유입니다 — 사용자용 화면과 개발자용 화면의 요구가 완전히 다르기 때문입니다. 개발자용을 예쁘게 만드느라 시간을 쓰는 건 대부분 낭비고, 그래서 ImGui 가 사랑받습니다(g07).

이 탭이 가는 길

g02  Qt 설치        — 설치기 · vcpkg · CMake 최소 프로젝트
g03  Qt Widgets     ★ 첫 창 · 시그널/슬롯 · 레이아웃 · 표
g04  QML/Quick        선언형 UI 와 C++ 백엔드 연결
g05  Qt 실전          스레드 · HTTP · 설정 · SQLite · 다국어
g06  Qt 배포        ★ windeployqt · 설치 파일 · 서명
─────────── 여기까지가 "앱을 판다"의 전부 ───────────
g07  Dear ImGui     ★ 즉시 모드 · 도구와 에디터
g08  SDL3 · raylib    창·입력·게임 루프
g09  wxWidgets        네이티브 룩 · 옛 코드와 결합
g10  Win32 · WinUI 3  트레이 · 자동 시작 · C++/WinRT
g11  성능·아키텍처    MVVM · 스레드 분리 · 수백만 행
g12  실전 프로젝트  ★ 로그 뷰어를 배포까지
시간이 없다면 — ★ 다섯 개(g01·g02·g03·g06·g07·g12)만 읽어도 “앱을 만들어 설치 파일로 건네준다”는 한 바퀴가 돕니다. 나머지는 필요해질 때 돌아와 읽는 참고서로 쓰세요.
정리 — 툴킷 선택은 “사용자에게 파는 앱이면 Qt · 내부 도구면 ImGui · 게임이면 SDL3” 세 갈래가 8할이고, 나머지는 오디오(JUCE) · 임베디드(Slint) · 윈도우 전용(WinUI) 같은 분야 전용입니다.
APP 02

Qt 6 설치 — 설치기냐 vcpkg 냐, 그리고 첫 CMake

pip install pyside6 한 줄이던 것이 C++ 에서는 계정 만들기 · 컴포넌트 고르기 · 30GB 디스크가 됩니다. 여기서 막히는 사람이 정말 많아서, 무엇을 왜 고르는지를 화면 순서대로 적어 둡니다. 그리고 이 모든 걸 건너뛰는 vcpkg 경로도 같이 봅니다.

Qt 설치 두 경로 — 결국 CMake 가 Qt6Config.cmake 를 찾으면 끝납니다 경로 A — Qt 온라인 설치기 (권장) 경로 B — vcpkg 로 소스 빌드 1 Qt 계정 만들기 · 오픈소스 선택 상업적 이용 여부를 묻습니다 — LGPL 조건 확인 2 컴포넌트 — MSVC 2022 64-bit 여기서 다 고르면 30GB, 골라 고르면 3~5GB 3 C:\Qt\6.8.x\msvc2022_64\ 완성 Qt Creator · 디자이너 · windeployqt 가 같이 들어옵니다 vcpkg install qtbase[core,widgets] 명령 한 줄 — 대신 소스에서 컴파일합니다 첫 빌드에 30분~2시간 · 디스크 20GB+ CI 나 이미 vcpkg 를 쓰는 팀에만 권합니다 Qt Creator · 디자이너는 안 따라옵니다 배포 도구 windeployqt 위치도 직접 찾아야 합니다 CMake 가 Qt6Config.cmake 를 찾는다 — CMAKE_PREFIX_PATH 또는 vcpkg 툴체인 find_package(Qt6 REQUIRED COMPONENTS Widgets) 성공 — 여기서부터는 같습니다 디스크 — 고르기만으로 30GB 가 3~5GB 가 됩니다 A 설치기 · 전체 선택 30GB A 설치기 · 최소 조합 3~5GB B vcpkg · 첫 빌드 20GB+ 0 10 20 30 GB
설치가 어렵게 느껴지는 이유는 “CMake 에게 Qt 위치를 알려 주는 한 줄”이 안 보여서입니다.

경로 A — 온라인 설치기, 화면 순서대로 ★

① qt.io/download-open-source  →  Qt Online Installer 내려받기
   (윈도우 .exe / macOS .dmg / 리눅스 .run — chmod +x 필요)

② Qt 계정 로그인 — 계정이 없으면 여기서 만듭니다
   "I'm an individual and do not use Qt for commercial..." 체크
   → 회사에서 쓸 거면 여기서 멈추고 법무·구매 확인이 먼저입니다

③ 설치 방식: "Custom installation" 을 고르세요
   기본값(Qt 전체)을 그대로 두면 30GB 를 받습니다

④ 컴포넌트 선택 — 윈도우 기준 최소 조합
   Qt 6.8.x
     [v] MSVC 2022 64-bit          ★ 이거 하나가 본체
     [ ] MinGW 13.1.0 64-bit        (g++ 를 쓸 거면 이것도)
     [v] Qt 5 Compatibility Module  (예제·구버전 코드용, 작음)
     [ ] Sources                     ★ 끄세요 (3GB, 디버깅할 때만)
     Additional Libraries
       [v] Qt Multimedia            (동영상·오디오 재생이 필요하면)
       [ ] Qt WebEngine             ★ 2GB+ — 정말 필요할 때만
       [v] Qt Charts                (그래프)
   Developer and Designer Tools
     [v] Qt Creator                 ★ 디버거·디자이너가 여기 있음
     [v] CMake · Ninja              (이미 있으면 꺼도 됨)

⑤ 설치 경로는 기본값 C:\Qt 를 권합니다
   공백·한글이 든 경로는 나중에 반드시 문제를 냅니다
MSVC 냐 MinGW 냐 — 둘을 섞으면 링크가 안 됩니다. 윈도우에서 Visual Studio Build Tools 가 이미 깔려 있다면 MSVC 2022 64-bit 하나만 고르세요. vcpkg 로 받는 다른 라이브러리들도 기본이 MSVC 라 섞일 일이 없습니다. MinGW 는 “g++ 명령이 익숙하다”는 이유 하나로 고르기엔 나중에 치를 값이 큽니다.

경로 A 이후 — CMake 에게 위치 알려 주기

# ① 매번 옵션으로 (가장 확실 — CI 에서 이렇게 씁니다)
cmake -B build -DCMAKE_PREFIX_PATH="C:/Qt/6.8.3/msvc2022_64"

# ② 환경 변수로 한 번만 (개인 PC 에 권장)
#    윈도우 PowerShell
setx CMAKE_PREFIX_PATH "C:\Qt\6.8.3\msvc2022_64"
#    macOS / Linux — ~/.zshrc 나 ~/.bashrc 에
export CMAKE_PREFIX_PATH="$HOME/Qt/6.8.3/gcc_64"

# ③ CMakePresets.json 으로 팀과 공유 (실무 표준)
#    아래 카드 참고 — 사람마다 다른 경로를 프리셋으로 흡수합니다

# --- macOS / Linux 는 패키지 관리자도 됩니다 ---
brew install qt                 # macOS, /opt/homebrew/opt/qt
sudo apt install qt6-base-dev qt6-tools-dev   # Ubuntu 24.04+
sudo dnf install qt6-qtbase-devel             # Fedora
#   배포판 Qt 는 버전이 조금 낮을 수 있지만 배우기에는 충분합니다
#   단, 배포용 앱을 만들 거면 설치기 버전이 windeployqt 때문에 편합니다
# 잘 깔렸는지 30초 확인
qmake6 --version        # 또는 C:\Qt\6.8.3\msvc2022_64\bin\qmake.exe -v
cmake --version         # 3.21 이상이면 충분 (4.x 권장)

# find_package 가 실패하면 이 순서로 봅니다
#   1) 경로에 Qt6Config.cmake 가 실제로 있는지
#      dir C:\Qt\6.8.3\msvc2022_64\lib\cmake\Qt6\Qt6Config.cmake
#   2) 32/64비트가 섞이지 않았는지 (msvc2022_64 폴더가 맞는지)
#   3) build 폴더를 지우고 다시 (CMakeCache.txt 가 옛 경로를 기억합니다)
에러 딱 하나만 외운다면 — Could not find a package configuration file provided by "Qt6" 는 100% 경로 문제입니다. Qt 가 안 깔린 게 아니라 CMake 가 못 찾은 것이고, 고칠 곳은 코드가 아니라 CMAKE_PREFIX_PATH 입니다.

최소 프로젝트 — 파일 세 개면 창이 뜹니다 🟢 기초

CMakeLists.txt — 실무에서도 이 모양입니다 ★

cmake_minimum_required(VERSION 3.21)
project(qtfirst LANGUAGES CXX)

set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)

# Qt 가 만들어 내는 코드를 자동으로 처리합니다 ★ 세 줄 다 필요
set(CMAKE_AUTOMOC ON)   # Q_OBJECT 가 붙은 클래스 → moc 실행
set(CMAKE_AUTOUIC ON)   # .ui 파일 → ui_*.h 생성
set(CMAKE_AUTORCC ON)   # .qrc 리소스 → 코드로 embed

find_package(Qt6 6.5 REQUIRED COMPONENTS Widgets)

qt_standard_project_setup()      # Qt 6.3+ — 관례를 한 번에 켜 줍니다

qt_add_executable(qtfirst
    main.cpp
    mainwindow.cpp mainwindow.h
)

target_link_libraries(qtfirst PRIVATE Qt6::Widgets)

# 윈도우에서 콘솔 창이 같이 뜨지 않게 (배포용)
set_target_properties(qtfirst PROPERTIES
    WIN32_EXECUTABLE ON       # 윈도우: 콘솔 없음
    MACOSX_BUNDLE ON          # macOS: .app 번들로
)
qt_add_executable vs add_executable — Qt 6 전용 버전은 배포 도구 연동 · 안드로이드/WASM 대응 · 리소스 처리가 붙어 있습니다. Qt 프로젝트라면 항상 qt_ 접두사 버전을 쓰세요. qt_standard_project_setup() 은 AUTOMOC 같은 것들을 알아서 켜 주므로 위의 세 줄은 사실 생략해도 됩니다(설명을 위해 남겨 뒀습니다).

CMakePresets.json — 팀에서 경로 싸움을 없앱니다

{
  "version": 6,
  "configurePresets": [
    {
      "name": "win-msvc",
      "generator": "Ninja",
      "binaryDir": "${sourceDir}/build/win",
      "cacheVariables": {
        "CMAKE_BUILD_TYPE": "Debug",
        "CMAKE_PREFIX_PATH": "$env{QTDIR}"
      }
    },
    {
      "name": "win-release",
      "inherits": "win-msvc",
      "binaryDir": "${sourceDir}/build/rel",
      "cacheVariables": { "CMAKE_BUILD_TYPE": "RelWithDebInfo" }
    }
  ],
  "buildPresets": [
    { "name": "win-msvc",   "configurePreset": "win-msvc" },
    { "name": "win-release", "configurePreset": "win-release" }
  ]
}
# 각자 QTDIR 환경 변수만 자기 경로로 잡아 두면
setx QTDIR "C:\Qt\6.8.3\msvc2022_64"

# 팀원 전부 똑같은 두 줄로 빌드합니다
cmake --preset win-msvc
cmake --build --preset win-msvc

# VS Code · Qt Creator · Visual Studio 가 전부 이 파일을 읽습니다 ★
프리셋을 쓰면 IDE 설정 파일을 저장소에 안 넣어도 됩니다 — .vscode/settings.json 이나 CMakeLists.txt.user 를 커밋해서 남의 경로가 들어가는 사고가 사라집니다. CMakePresets.json 은 커밋하고 CMakeUserPresets.json 은 .gitignore 하는 게 관례입니다.

Qt Creator 냐 VS Code 냐 — 둘 다 씁니다 🟡 중급

역할이 다릅니다

Qt CreatorVS Code
Qt 프로젝트 인식기본CMake Tools 확장
Qt Designer(.ui)내장, 더블클릭외부 실행
QML 편집·미리보기가장 좋음확장으로 부분 지원
디버거 Qt 타입 표시QString 이 글자로 보임natvis 설정 필요
일반 코드 편집무난확장·AI 도구 풍부
Git·터미널기본만훨씬 좋음
메모리 사용가벼움무거움
실무에서 가장 흔한 조합 — 평소 코드는 VS Code(또는 CLion), .ui/QML 편집과 UI 디버깅만 Qt Creator 로 엽니다. CMakePresets.json 하나면 둘이 같은 빌드 폴더를 공유하므로 번갈아 써도 다시 빌드하지 않습니다.

VS Code 세팅 최소 조합

// .vscode/extensions.json — 팀에 추천 확장을 알려 줍니다
{
  "recommendations": [
    "ms-vscode.cpptools",           // 디버거 (윈도우는 이게 필요)
    "llvm-vs-code-extensions.vscode-clangd",  // 자동완성 ★ 이게 핵심
    "ms-vscode.cmake-tools",        // 프리셋 · 빌드 · 실행
    "tonka3000.qtvsctools"          // .ui / .qrc 를 Designer 로 열기
  ]
}
// .vscode/settings.json
{
  "cmake.useCMakePresets": "always",
  "clangd.arguments": [
    "--compile-commands-dir=${workspaceFolder}/build/win",
    "--header-insertion=never"
  ],
  // Qt 타입을 디버거에서 사람이 읽게 (MSVC)
  "cppvsdbg.visualizerFile": "C:/Qt/6.8.3/msvc2022_64/qt6.natvis"
}
자동완성이 안 되면 90%는 이것 — clangd 가 compile_commands.json 을 못 찾은 겁니다. CMake 에 -DCMAKE_EXPORT_COMPILE_COMMANDS=ON 을 주고 빌드 폴더에 그 파일이 생겼는지 확인하세요. Qt 는 moc 이 만든 헤더(moc_*.cpp, ui_*.h)를 빌드 폴더에 만들기 때문에, 한 번 빌드한 뒤에야 자동완성이 온전해집니다.
정리 — 설치의 핵심은 “MSVC 2022 64-bit 하나만 고르고, CMAKE_PREFIX_PATH 로 위치를 알려 준다” 두 줄입니다. 나머지 화면은 전부 안 골라도 되는 것들입니다.
APP 03

Qt Widgets 첫 앱 — 이벤트 루프와 시그널/슬롯

Qt 앱의 main() 은 딱 세 줄입니다. 그 세 줄이 무슨 일을 하는지 알면 “왜 창이 바로 닫히지”, “왜 UI 가 멈추지” 같은 질문이 한꺼번에 풀립니다. PySide6 코드와 나란히 놓고 보겠습니다.

Qt 이벤트 루프 — 한 스레드가 큐를 비우는 무한 반복 운영체제 클릭 · 키 · 리사이즈 이벤트 큐 순서대로 쌓입니다 app.exec() — 무한 루프 하나 꺼내 → 배달 → 다시 꺼내 위젯::event(QEvent*) mousePressEvent · paintEvent 로 갈라짐 시그널 emit clicked() 발사 내 슬롯 실행 람다 · 멤버 함수 슬롯이 끝나야 루프가 다음 이벤트를 꺼냅니다 그래서 — 슬롯 안에서 오래 걸리는 일을 하면 화면이 얼어붙습니다 파일 읽기 3초 = 큐가 3초 동안 안 비워짐 = 3초 동안 다시 그리기도 클릭 처리도 안 됨 윈도우가 "응답 없음" 이라고 띄우는 상태가 정확히 이것입니다 — 해결은 g05 PySide6 도 완전히 같은 구조입니다 — app.exec() 이 도는 그 스레드가 UI 스레드입니다
Qt 의 모든 이상 현상은 대개 “이 루프가 지금 무엇을 하느라 못 도는가”로 설명됩니다.

첫 창 — PySide6 와 나란히 ★

// main.cpp
#include <QApplication>
#include <QPushButton>

int main(int argc, char *argv[]) {
    QApplication app(argc, argv);   // ① 반드시 첫 줄. 스타일·폰트·이벤트 큐를 세웁니다

    QPushButton button("눌러 보세요");
    button.resize(240, 80);
    button.show();                  // ② show() 를 빠뜨리면 창이 안 보입니다

    QObject::connect(&button, &QPushButton::clicked, [] {
        qDebug() << "눌렸습니다";   // ③ 람다가 그대로 슬롯이 됩니다
    });

    return app.exec();              // ④ 여기서 이벤트 루프가 돕니다
}                                   //    창을 닫으면 exec() 이 반환됩니다
# 같은 코드 — PySide6
import sys
from PySide6.QtWidgets import QApplication, QPushButton

app = QApplication(sys.argv)        # ① 같습니다

button = QPushButton("눌러 보세요")
button.resize(240, 80)
button.show()                       # ② 같습니다

button.clicked.connect(lambda: print("눌렸습니다"))   # ③ 문법만 다름

sys.exit(app.exec())                # ④ 같습니다
구조가 완전히 같습니다 — PySide6 는 이 C++ Qt 를 그대로 감싼 것이라, Qt 문서(C++ 기준)를 읽고 파이썬으로 옮겨 쓰는 것이 원래 정상 사용법입니다. 반대 방향, 즉 PySide6 를 알던 사람이 C++ 로 오는 건 메모리 수명 하나만 새로 배우면 됩니다.

QObject 수명 — 여기만 파이썬과 다릅니다 ★

// ① 부모가 있으면 → 부모가 죽을 때 자식도 delete 됩니다
auto *win  = new QMainWindow;            // 부모 없음 → 내가 책임
auto *btn  = new QPushButton("확인", win);  // 부모 = win → win 이 지움
// delete btn;  ← 쓰면 안 됩니다. 이중 해제로 크래시

// ② 레이아웃에 넣어도 부모가 자동으로 잡힙니다 ★ 실무에서 이게 대부분
auto *layout = new QVBoxLayout;
layout->addWidget(new QPushButton("A"));   // 부모가 여기서 정해집니다
win->centralWidget()->setLayout(layout);

// ③ 스택에 두는 것도 완전히 정상입니다 (작은 창 · 다이얼로그)
void openDialog(QWidget *parent) {
    QMessageBox box(parent);
    box.setText("저장할까요?");
    box.exec();            // 이 함수가 끝나면 box 는 알아서 사라집니다
}

// ④ 최상위 창을 닫으면 스스로 지우게 하려면
win->setAttribute(Qt::WA_DeleteOnClose);

// ⑤ 스마트 포인터를 쓸 거면 unique_ptr 보다 이쪽이 안전합니다
QPointer<QWidget> weak = someWidget;   // 대상이 죽으면 자동으로 nullptr
if (weak) weak->show();                 // 죽은 포인터 접근을 막습니다
가장 흔한 초보 사고 두 가지 — ① 스택에 만든 위젯을 함수 끝에서 잃어버림: QPushButton b; b.show(); 를 함수 안에 쓰면 함수가 끝나는 순간 창이 사라집니다. ② 부모 있는 자식을 delete: 부모가 다시 지우면서 크래시합니다. 규칙은 “new 할 때 부모를 주면 신경 끄고, 안 주면 내가 책임진다” 하나뿐입니다.
시그널/슬롯 연결 — 네 가지 문법과 스레드 규칙 ① 옛 문법 — connect(btn, SIGNAL(clicked()), this, SLOT(onClick())) 문자열이라 오타가 컴파일에 안 걸립니다. 실행 중에 조용히 아무 일도 안 일어납니다 — 쓰지 마세요 ② 기본값 — connect(btn, &QPushButton::clicked, this, &MyWin::onClick) 함수 포인터라 이름·인자 타입을 컴파일러가 검사합니다. 오타는 빌드 에러가 됩니다 ③ 람다 — connect(btn, &QPushButton::clicked, this, [this]{ ... }) 세 번째 인자(this)를 꼭 주세요. 그 객체가 죽으면 연결이 자동으로 끊깁니다 ④ 인자 줄이기 — 시그널이 (int, QString) 이어도 슬롯은 (int) 만 받아도 됩니다 앞에서부터 잘라 씁니다. 반대로 슬롯이 더 많이 받으면 컴파일 에러입니다 같은 스레드면 즉시 호출 · 다른 스레드면 큐에 넣어 나중에 (Qt 가 자동 판단) 이 한 줄이 g05 스레드 처리의 근거입니다 — 워커 스레드에서 emit 만 하면 UI 가 안전해집니다
지금 코드에 SIGNAL( 이 보이면 2010년대 코드입니다 — ② 나 ③ 으로 바꾸세요.

제대로 된 창 — QMainWindow 와 레이아웃 🟢 기초

mainwindow.h — Q_OBJECT 가 있어야 합니다

#pragma once
#include <QMainWindow>

class QLineEdit;      // 전방 선언 — 헤더에서 include 를 줄입니다
class QListWidget;

class MainWindow : public QMainWindow {
    Q_OBJECT          // ★ 이 매크로가 있어야 시그널/슬롯이 생깁니다
                      //   빠뜨리면 "undefined reference to vtable" 링크 에러
public:
    explicit MainWindow(QWidget *parent = nullptr);

private slots:        // Qt 6 에서는 그냥 private 여도 되지만 관례로 남깁니다
    void onAddClicked();

private:
    QLineEdit   *m_input = nullptr;
    QListWidget *m_list  = nullptr;
};
// mainwindow.cpp
#include "mainwindow.h"
#include <QtWidgets>      // 배우는 동안엔 편합니다(빌드는 조금 느려짐)

MainWindow::MainWindow(QWidget *parent) : QMainWindow(parent) {
    setWindowTitle("할 일 목록");
    resize(640, 480);

    auto *central = new QWidget(this);
    setCentralWidget(central);              // 부모가 여기서 정해집니다

    m_input = new QLineEdit;
    m_input->setPlaceholderText("할 일을 적고 Enter");
    auto *addBtn = new QPushButton("추가");
    m_list = new QListWidget;

    auto *row = new QHBoxLayout;            // 가로로 나란히
    row->addWidget(m_input, 1);             // 1 = 남는 공간을 이쪽이 먹음
    row->addWidget(addBtn);

    auto *col = new QVBoxLayout(central);   // 세로로 쌓기
    col->addLayout(row);
    col->addWidget(m_list, 1);

    connect(addBtn,  &QPushButton::clicked,      this, &MainWindow::onAddClicked);
    connect(m_input, &QLineEdit::returnPressed,  this, &MainWindow::onAddClicked);

    statusBar()->showMessage("준비됨");      // 아래쪽 상태 표시줄
}

void MainWindow::onAddClicked() {
    const QString text = m_input->text().trimmed();
    if (text.isEmpty()) return;
    m_list->addItem(text);
    m_input->clear();
    statusBar()->showMessage(QString("%1개").arg(m_list->count()), 2000);
}

레이아웃 — 좌표를 직접 주지 마세요

레이아웃쓰는 곳
QVBoxLayout세로로 쌓기 — 화면의 큰 뼈대
QHBoxLayout가로 한 줄 — 툴바 · 버튼 묶음
QGridLayout표 모양 폼 — 라벨/입력 짝
QFormLayout설정 화면 — OS 관례에 맞게 정렬
QStackedLayout같은 자리에 화면 여러 개(탭 없이 전환)
QSplitter사용자가 끌어 크기 조절 (레이아웃은 아님)
// 크기 규칙 세 가지만 알면 됩니다
row->addWidget(w, 2);          // ① stretch — 남는 공간을 2:1 로 나눔
row->addStretch();             // ② 빈 스프링 — 오른쪽으로 밀어 붙이기
w->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed);   // ③ 정책

// 여백 조절 (기본값이 OS 마다 다릅니다)
col->setContentsMargins(12, 12, 12, 12);
col->setSpacing(8);

// 고정 크기가 정말 필요할 때만
btn->setFixedWidth(96);

// ★ 하지 말 것 — 좌표 직접 지정
btn->setGeometry(10, 10, 100, 30);   // 고해상도 · 다국어에서 전부 깨집니다
왜 좌표를 직접 주면 안 되나 — ① 사용자가 창 크기를 바꾸면 그대로 있고 ② 4K 모니터에서 배율이 150%면 글자만 커져 버튼 밖으로 넘치고 ③ 번역하면 “Save” 가 “저장하기”가 되면서 잘립니다. 레이아웃은 이 셋을 자동으로 처리합니다. PySide6 에서 하던 습관 그대로입니다.

표를 띄웁니다 — QTableView + 모델 🟡 중급

모델을 직접 만듭니다 (30줄)

// rowmodel.h — 데이터가 어디 있든 "표처럼 보이게" 하는 어댑터입니다
#include <QAbstractTableModel>

struct Row { QString name; int size; QString when; };

class RowModel : public QAbstractTableModel {
    Q_OBJECT
public:
    using QAbstractTableModel::QAbstractTableModel;

    void setRows(std::vector<Row> rows) {
        beginResetModel();          // ★ 뷰에게 "지금부터 바뀐다"
        m_rows = std::move(rows);
        endResetModel();            // ★ "끝났다" — 이 사이에서만 고칩니다
    }

    int rowCount(const QModelIndex& = {}) const override
        { return static_cast<int>(m_rows.size()); }
    int columnCount(const QModelIndex& = {}) const override { return 3; }

    QVariant data(const QModelIndex &ix, int role) const override {
        if (!ix.isValid()) return {};
        const Row &r = m_rows[ix.row()];
        if (role == Qt::DisplayRole) {
            switch (ix.column()) {
                case 0: return r.name;
                case 1: return QLocale().formattedDataSize(r.size);
                case 2: return r.when;
            }
        }
        if (role == Qt::TextAlignmentRole && ix.column() == 1)
            return int(Qt::AlignRight | Qt::AlignVCenter);
        return {};
    }

    QVariant headerData(int s, Qt::Orientation o, int role) const override {
        if (o != Qt::Horizontal || role != Qt::DisplayRole) return {};
        static const char *h[] = {"이름", "크기", "수정 시각"};
        return QString::fromUtf8(h[s]);
    }
private:
    std::vector<Row> m_rows;
};

뷰에 붙이고 정렬·필터까지

auto *model = new RowModel(this);
model->setRows(loadRows());

// 정렬과 필터는 프록시가 공짜로 해 줍니다 ★
auto *proxy = new QSortFilterProxyModel(this);
proxy->setSourceModel(model);
proxy->setFilterCaseSensitivity(Qt::CaseInsensitive);
proxy->setFilterKeyColumn(0);              // 0번 열에서만 검색

auto *view = new QTableView(this);
view->setModel(proxy);
view->setSortingEnabled(true);              // 헤더 클릭으로 정렬
view->setSelectionBehavior(QAbstractItemView::SelectRows);
view->setAlternatingRowColors(true);
view->horizontalHeader()->setStretchLastSection(true);
view->verticalHeader()->setDefaultSectionSize(24);   // 행 높이 고정 = 빠름

// 검색창을 연결하면 실시간 필터가 됩니다 — 두 줄
connect(searchEdit, &QLineEdit::textChanged,
        proxy, &QSortFilterProxyModel::setFilterFixedString);

// 더블클릭 처리 — 프록시 인덱스를 원본으로 되돌려야 합니다 ★
connect(view, &QTableView::doubleClicked, this, [=](const QModelIndex &ix){
    const QModelIndex src = proxy->mapToSource(ix);
    openFile(model->rowAt(src.row()).name);
});
왜 QTableWidget 대신 QTableView 인가 — QTableWidget 은 셀마다 객체를 하나씩 만듭니다. 1만 행 × 5열이면 5만 개가 생기고 메모리와 로딩이 무너집니다. QTableView + 모델은 화면에 보이는 만큼만 그리므로 100만 행도 즉시 뜹니다. 배울 때만 Widget 계열을 쓰고, 실무는 View 로 가세요(g11 에서 이어집니다).

.ui 파일과 Designer — 쓸까 말까 🟡 중급

.ui 를 쓰면 이렇게 됩니다

// mainwindow.h
namespace Ui { class MainWindow; }      // uic 가 만들어 줄 클래스

class MainWindow : public QMainWindow {
    Q_OBJECT
public:
    explicit MainWindow(QWidget *parent = nullptr);
    ~MainWindow();
private:
    Ui::MainWindow *ui;                 // 화면에 놓인 위젯들이 여기 들어옵니다
};

// mainwindow.cpp
#include "ui_mainwindow.h"              // ★ 빌드할 때 자동 생성되는 헤더

MainWindow::MainWindow(QWidget *parent)
    : QMainWindow(parent), ui(new Ui::MainWindow) {
    ui->setupUi(this);                  // 여기서 위젯이 전부 만들어집니다

    connect(ui->addButton, &QPushButton::clicked,
            this, &MainWindow::onAddClicked);   // 이름은 Designer 에서 준 objectName
}
MainWindow::~MainWindow() { delete ui; }
# CMake 에는 .ui 를 소스처럼 넣기만 하면 됩니다 (AUTOUIC 가 처리)
qt_add_executable(app main.cpp mainwindow.cpp mainwindow.h mainwindow.ui)
PySide6 와 다른 점 — 파이썬은 QUiLoader 로 실행 중에 .ui 를 읽을 수 있지만, C++ 은 빌드할 때 uic 가 C++ 코드로 바꿔 넣습니다. 그래서 더 빠르지만, .ui 를 고치면 다시 빌드해야 합니다.

.ui 냐 코드냐 — 팀 규칙을 정하세요

.ui (Designer)코드로 직접
만드는 속도빠름 — 끌어다 놓기느림
Git 병합XML 충돌이 지옥일반 코드처럼
조건부 UI어려움자유로움
리뷰diff 를 못 읽음읽힘
커스텀 위젯promote 과정 필요그냥 new
디자이너 협업가능불가
실무에서 자주 쓰는 절충 — 설정 창·다이얼로그처럼 정적인 화면은 .ui, 메인 화면처럼 자주 바뀌고 조건이 많은 곳은 코드. 한 프로젝트에 둘 다 있어도 전혀 문제없습니다. 다만 같은 화면을 둘로 나누지는 마세요 — 어디를 고쳐야 할지 아무도 모르게 됩니다.
정리 — Qt Widgets 는 QApplication → 위젯을 부모에 붙이고 → 레이아웃에 넣고 → connect → exec() 다섯 단계고, PySide6 와 다른 건 오직 부모가 수명을 관리한다는 규칙 하나입니다.
APP 04

Qt Quick · QML — 선언형 UI와 C++ 백엔드 잇기

Widgets 가 “버튼을 만들어 레이아웃에 넣어라”라면, QML 은 “여기에 버튼이 있다”고 적는 방식입니다. React 나 SwiftUI 를 써 봤다면 익숙한 감각이고, 애니메이션·터치·커스텀 디자인에서 압도적으로 유리합니다. 대신 C++ 데이터를 QML 로 넘기는 다리를 놓는 법을 알아야 합니다.

QML ↔ C++ — 다리는 세 개뿐입니다 QML 쪽 (화면) Main.qml · 컴포넌트들 선언형 · 프로퍼티 바인딩 애니메이션 · 터치 · 셰이더 JavaScript 로 간단한 로직 GPU 씬 그래프가 그립니다 고치면 다시 빌드 없이 확인 (qml 미리보기 도구) QQmlApplicationEngine QML 을 읽고 타입을 이어 줍니다 C++ 쪽 (로직) QObject 를 상속한 클래스 파일 · DB · 네트워크 · 계산 스레드 · 큰 데이터 테스트하기 쉬운 순수 코드 화면을 전혀 모릅니다 = 나중에 Widgets 로도 재사용 (이 경계가 핵심입니다) ① Q_PROPERTY — 값을 읽고 쓰기 · NOTIFY 시그널이 나면 화면이 저절로 갱신 text: backend.userName 이라고 쓰면 값이 바뀔 때마다 다시 그려집니다 ② Q_INVOKABLE — QML 이 C++ 함수를 부름 ③ signals — C++ 이 알리면 QML 이 onXxx 로 받음 옛 방식 setContextProperty 는 타입 검사도 자동완성도 없습니다 — QML_ELEMENT 를 쓰세요
경계를 지키면 C++ 로직은 화면을 모르고, 나중에 Widgets 로 갈아끼워도 그대로 삽니다.

최소 QML 앱 — 파일 네 개 ★

# CMakeLists.txt
cmake_minimum_required(VERSION 3.21)
project(qmlapp LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 20)

find_package(Qt6 6.5 REQUIRED COMPONENTS Quick)
qt_standard_project_setup(REQUIRES 6.5)

qt_add_executable(qmlapp main.cpp)

qt_add_qml_module(qmlapp
    URI      App                      # QML 에서 import App 로 씁니다
    VERSION  1.0
    QML_FILES  Main.qml
    SOURCES    backend.h backend.cpp   # ★ QML_ELEMENT 가 붙은 C++ 도 여기에
)

target_link_libraries(qmlapp PRIVATE Qt6::Quick)
// main.cpp
#include <QGuiApplication>              // Widgets 가 아니면 QGuiApplication
#include <QQmlApplicationEngine>

int main(int argc, char *argv[]) {
    QGuiApplication app(argc, argv);
    QQmlApplicationEngine engine;

    // QML 을 못 읽으면 조용히 아무것도 안 뜹니다 — 반드시 확인하세요 ★
    QObject::connect(&engine, &QQmlApplicationEngine::objectCreationFailed,
                     &app, []{ QCoreApplication::exit(-1); },
                     Qt::QueuedConnection);

    engine.loadFromModule("App", "Main");   // Qt 6.5+ 권장 방식
    return app.exec();
}
“빌드는 됐는데 창이 안 떠요” — QML 앱 초보가 겪는 1번 사고입니다. QML 파싱 에러는 표준 출력에만 찍히고 창은 그냥 안 뜹니다. 위의 objectCreationFailed 연결을 항상 넣고, 콘솔을 켠 채로 개발하세요(WIN32_EXECUTABLE 은 배포 때만 ON).

Main.qml — 이게 화면 전부입니다

import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import App                       // ★ 우리가 만든 C++ 타입

ApplicationWindow {
    width: 720; height: 480
    visible: true
    title: qsTr("QML 첫 앱")

    Backend { id: backend }      // C++ 클래스를 QML 에서 그냥 만듭니다

    ColumnLayout {
        anchors.fill: parent
        anchors.margins: 16
        spacing: 12

        RowLayout {
            TextField {
                id: input
                Layout.fillWidth: true
                placeholderText: qsTr("검색어")
                onAccepted: backend.search(text)     // ② Q_INVOKABLE 호출
            }
            Button {
                text: qsTr("검색")
                enabled: !backend.busy               // ① Q_PROPERTY 바인딩
                onClicked: backend.search(input.text)
            }
        }

        BusyIndicator { running: backend.busy; Layout.alignment: Qt.AlignHCenter }

        ListView {
            Layout.fillWidth: true; Layout.fillHeight: true
            model: backend.results                   // QStringList 도 모델이 됩니다
            clip: true
            delegate: ItemDelegate {
                width: ListView.view.width
                text: modelData
                onClicked: backend.open(modelData)
            }
        }
    }

    Connections {                 // ③ C++ 시그널 받기
        target: backend
        function onFailed(msg) { errorBanner.text = msg; errorBanner.open() }
    }
    Dialog { id: errorBanner; property alias text: lbl.text; Label { id: lbl } }
}

다리 놓기 — Q_PROPERTY 와 Q_INVOKABLE 🟡 중급

backend.h — 이 형태를 외우면 됩니다 ★

#pragma once
#include <QObject>
#include <QStringList>
#include <QtQml/qqmlregistration.h>   // QML_ELEMENT 에 필요

class Backend : public QObject {
    Q_OBJECT
    QML_ELEMENT                        // ★ 이 한 줄로 QML 에 Backend 로 등록됩니다

    // ① 읽기 전용 프로퍼티 — QML 이 backend.busy 로 읽습니다
    Q_PROPERTY(bool busy READ busy NOTIFY busyChanged)
    Q_PROPERTY(QStringList results READ results NOTIFY resultsChanged)

    // 읽고 쓰는 프로퍼티라면 WRITE 도 붙입니다
    Q_PROPERTY(QString keyword READ keyword WRITE setKeyword NOTIFY keywordChanged)

public:
    explicit Backend(QObject *parent = nullptr) : QObject(parent) {}

    bool busy() const { return m_busy; }
    QStringList results() const { return m_results; }
    QString keyword() const { return m_keyword; }

    void setKeyword(const QString &k) {
        if (k == m_keyword) return;      // ★ 같으면 즉시 반환 — 무한 루프 방지
        m_keyword = k;
        emit keywordChanged();
    }

    // ② QML 이 부를 수 있는 함수
    Q_INVOKABLE void search(const QString &q);
    Q_INVOKABLE void open(const QString &item);

signals:
    void busyChanged();
    void resultsChanged();
    void keywordChanged();
    void failed(const QString &message);   // ③ QML 이 onFailed 로 받습니다

private:
    void setBusy(bool b) { if (b != m_busy) { m_busy = b; emit busyChanged(); } }

    bool        m_busy = false;
    QStringList m_results;
    QString     m_keyword;
};
NOTIFY 를 빠뜨리면 “값은 바뀌는데 화면이 안 바뀝니다” — QML 의 자동 갱신은 전부 NOTIFY 시그널이 트리거입니다. 읽기 전용이고 정말 절대 안 바뀌는 값이면 CONSTANT 를 쓰고, 그 외에는 항상 NOTIFY 를 붙이세요. 이 실수가 QML 버그의 절반입니다.

등록 방식 세 가지 — 무엇을 언제

// ① QML_ELEMENT — 지금의 기본값 ★ (Qt 6.2+)
class Backend : public QObject { Q_OBJECT QML_ELEMENT ... };
//   QML: Backend { id: backend }      — QML 이 인스턴스를 만듭니다
//   장점: 자동완성 · 타입 검사 · qmllint 가 다 됩니다

// ② 싱글턴 — 앱 전체에서 하나만 (설정 · 로그인 상태)
class AppSettings : public QObject {
    Q_OBJECT
    QML_ELEMENT
    QML_SINGLETON                        // ★
public:
    static AppSettings *create(QQmlEngine*, QJSEngine*) { return new AppSettings; }
};
//   QML: import App;  Text { text: AppSettings.userName }

// ③ C++ 이 만든 객체를 넘기기 — 생성 순서를 내가 통제해야 할 때
class Backend : public QObject {
    Q_OBJECT
    QML_ELEMENT
    QML_UNCREATABLE("C++ 에서만 만듭니다")   // QML 이 못 만들게 막고
};
// main.cpp
Backend backend;                          // C++ 이 만들어서
engine.setInitialProperties({{"backend", QVariant::fromValue(&backend)}});
engine.loadFromModule("App", "Main");
// Main.qml:  property Backend backend

// ★ 옛 방식 — 새 코드에는 쓰지 마세요
engine.rootContext()->setContextProperty("backend", &backend);
//   타입 검사 · 자동완성 · qmllint 가 전부 안 됩니다
C++ 타입 → QML 에서 이렇게 보입니다
  int / double / bool / QString      그대로
  QStringList                        JS 배열처럼 (ListView model 로도 됨)
  QVariantMap                        JS 객체처럼 (obj.key)
  QDateTime                          JS Date
  QObject*                           객체 (프로퍼티·함수 접근 가능)
  QAbstractItemModel*                ListView·TableView 의 model ★
  std::vector<Row>                   ✗ 안 됩니다 — 모델로 감싸세요

Widgets 냐 QML 이냐 — 고르는 기준 🔴 실무

정직한 비교표 ★

Qt WidgetsQt Quick(QML)
업무용 폼·표·메뉴압도적으로 빠름직접 만들어야 함
애니메이션·전환고통기본 문법
커스텀 디자인QSS 로 한계 있음자유로움
도킹·MDI·툴바기본 제공없음(직접)
터치·모바일어색이쪽 전제
GPU 사용거의 없음(CPU 래스터)씬 그래프
저사양·가상머신잘 돎GPU 없으면 느림
고칠 때 재빌드필요QML 만 고치면 즉시
디버깅일반 C++QML+C++ 두 곳을 봄
메모리(빈 창)25~40MB60~110MB
한 줄 기준 — “엑셀처럼 생긴 앱”이면 Widgets, “앱스토어에 있을 법한 앱”이면 QML 입니다. 사내 업무 도구·엔지니어링 도구는 거의 항상 Widgets 가 이기고, 키오스크·차량 HMI·소비자용 제품은 QML 이 이깁니다.

섞어 쓰기와 QML 성능 함정

// Widgets 앱 안에 QML 화면 하나 끼우기 — 실무에서 흔합니다
#include <QQuickWidget>
auto *qw = new QQuickWidget(this);
qw->setResizeMode(QQuickWidget::SizeRootObjectToView);
qw->setSource(QUrl("qrc:/App/Chart.qml"));
dockLayout->addWidget(qw);            // 그냥 위젯처럼 다룹니다

// 반대로 QML 안에 위젯은 못 넣습니다 (씬 그래프가 달라서)
// QML 에서 느려지는 전형적인 세 가지

// ① 델리게이트 안에서 무거운 일 — 스크롤할 때마다 실행됩니다
delegate: Item {
    Component.onCompleted: heavyParse(modelData)   // ✗ 최악
}

// ② clip: true 를 남발 — 레이어가 늘어 GPU 부담
//    필요한 최상위 하나에만 주세요

// ③ 바인딩 안에서 함수 호출 — 값이 바뀔 때마다 다시 계산
Text { text: backend.formatAll(list) }             // ✗
//    → C++ 쪽에서 미리 계산해 프로퍼티로 노출하세요
Text { text: backend.formattedText }               // ○

// 성능 확인은 이걸로
//   QSG_RENDER_TIMING=1 로 실행하면 프레임별 시간이 찍힙니다
//   Qt Creator 의 QML Profiler 로 바인딩 재평가 횟수를 봅니다
QML 에 로직을 쓰기 시작하면 무너집니다 — QML 안의 JavaScript 는 테스트가 어렵고, 타입 검사가 없고, 느립니다. 경계를 “QML 은 보여 주기만, 판단은 전부 C++” 로 잡으세요. 이 규칙만 지키면 나중에 화면을 통째로 바꿔도 로직은 그대로 삽니다(g11 의 MVVM).
정리 — QML 은 Q_PROPERTY(값) · Q_INVOKABLE(호출) · signals(알림) 세 다리로 C++ 과 이어지고, 선택 기준은 “애니메이션과 커스텀 디자인이 중요하면 QML, 폼과 표가 중심이면 Widgets” 입니다.
APP 05

Qt 실전 — 스레드 · HTTP · 설정 · DB · 다국어

창을 띄우는 것과 쓸 만한 앱 사이의 거리는 대부분 이 다섯 가지입니다. 특히 “UI 가 멈춘다”는 문제는 신입이든 경력이든 한 번은 겪는데, Qt 의 해법은 놀랄 만큼 단정합니다 — 워커는 시그널만 쏘고 화면은 건드리지 않습니다.

UI 스레드와 워커 스레드 — 시그널이 스레드를 건너는 유일한 통로 UI 스레드 — app.exec() 이 도는 곳 위젯 그리기 · 클릭 처리 · 여기서만 QWidget 을 만질 수 있습니다 이 레인이 0.1초라도 막히면 사용자는 "멈췄다"고 느낍니다 ① start() 호출이 큐에 실려 워커로 ② progress(int) · finished(결과) 시그널이 큐에 실려 UI 로 워커 스레드 — 오래 걸리는 일 전담 파일 읽기 · 파싱 · 압축 · 계산 · 블로킹 IO. 여기서 몇 초가 걸려도 화면은 멀쩡합니다 여기서 만드는 건 순수 데이터뿐 — QWidget · QPixmap 은 만들지 않습니다 금지 — 워커에서 label->setText(...) 처럼 위젯을 직접 만지기 바로 안 죽고 며칠 뒤 재현 안 되는 크래시로 돌아옵니다. 반드시 시그널로 넘기세요 QtConcurrent::run — 한 번짜리 작업 "이 파일 읽고 결과 줘" 같은 단발성 QThread + 워커 객체 — 계속 사는 작업 감시 · 스트리밍 · 취소와 상태가 있는 작업
규칙은 한 줄입니다 — “워커는 시그널만 쏘고, 화면은 UI 스레드가 그린다.”

방법 A — QtConcurrent, 단발성이면 이걸로 ★

#include <QtConcurrent>
#include <QFutureWatcher>
// CMake: find_package(Qt6 COMPONENTS Concurrent) / Qt6::Concurrent

struct Result { int lines; qint64 bytes; };

void MainWindow::loadFile(const QString &path) {
    ui->loadButton->setEnabled(false);       // 중복 실행 방지

    // ① 스레드풀에서 실행 — 반환값이 QFuture 로 옵니다
    QFuture<Result> future = QtConcurrent::run([path] {
        QFile f(path);
        f.open(QIODevice::ReadOnly);
        int n = 0;
        while (!f.atEnd()) { f.readLine(); ++n; }
        return Result{ n, f.size() };        // 여기서는 위젯을 절대 안 만짐
    });

    // ② 끝나면 UI 스레드에서 이어받기 — Qt 6 의 then() 이 제일 깔끔합니다
    future.then(this, [this](Result r) {     // this 를 주면 UI 스레드에서 실행 ★
        ui->statusLabel->setText(QString("%1줄 / %2").arg(r.lines)
                                  .arg(QLocale().formattedDataSize(r.bytes)));
        ui->loadButton->setEnabled(true);
    }).onFailed(this, [this](const std::exception &e) {
        QMessageBox::warning(this, "실패", QString::fromUtf8(e.what()));
        ui->loadButton->setEnabled(true);
    });
}
// 리스트를 병렬로 처리하고 진행률까지 — 세 줄이면 됩니다
auto future = QtConcurrent::mapped(paths, &makeThumbnail);

auto *watcher = new QFutureWatcher<QImage>(this);
connect(watcher, &QFutureWatcherBase::progressValueChanged,
        ui->progressBar, &QProgressBar::setValue);
connect(watcher, &QFutureWatcherBase::finished, this, [=]{
    for (const QImage &img : watcher->future().results()) addThumb(img);
    watcher->deleteLater();
});
watcher->setFuture(future);

// 취소도 됩니다 (작업 쪽에서 isCanceled 를 봐 줘야 즉시 멈춥니다)
connect(ui->cancelButton, &QPushButton::clicked, watcher, &QFutureWatcherBase::cancel);
then(this, ...) 의 this 가 핵심입니다 — 컨텍스트 객체를 주면 그 객체가 사는 스레드(=UI 스레드)에서 콜백이 돕니다. 빼먹으면 워커 스레드에서 실행되어 위젯을 만지는 순간 위의 금지 사항을 어기게 됩니다.

방법 B — QThread + 워커 객체 ★

// worker.h — QThread 를 상속하지 마세요. 객체를 옮기는 방식이 정석입니다
class Worker : public QObject {
    Q_OBJECT
public slots:
    void scan(const QString &dir) {
        QDirIterator it(dir, QDirIterator::Subdirectories);
        int n = 0;
        while (it.hasNext()) {
            if (m_stop.loadRelaxed()) { emit canceled(); return; }
            it.next();
            if (++n % 500 == 0) emit progress(n);      // 너무 자주 쏘면 UI 가 밀립니다
        }
        emit finished(n);
    }
    void requestStop() { m_stop.storeRelaxed(1); }     // 다른 스레드에서 불러도 안전
signals:
    void progress(int count);
    void finished(int total);
    void canceled();
private:
    QAtomicInt m_stop{0};
};
// mainwindow.cpp — 조립은 여섯 줄입니다
auto *thread = new QThread(this);
auto *worker = new Worker;                 // ★ 부모를 주지 않습니다
worker->moveToThread(thread);              // 이 객체의 슬롯은 이제 저 스레드에서 돕니다

connect(thread, &QThread::finished, worker, &QObject::deleteLater);
connect(this, &MainWindow::startScan, worker, &Worker::scan);       // 큐 연결
connect(worker, &Worker::progress, this, &MainWindow::onProgress);  // 큐 연결
connect(worker, &Worker::finished, this, [=](int n){
    ui->statusLabel->setText(QString("%1개").arg(n));
    thread->quit();                        // 스레드 종료 요청
});
thread->start();
emit startScan("C:/data");                 // 직접 worker->scan() 을 부르면 안 됩니다 ★

// 앱을 닫을 때 — 이거 안 하면 "QThread: Destroyed while still running" 크래시
void MainWindow::closeEvent(QCloseEvent *e) {
    worker->requestStop();
    thread->quit();
    thread->wait(3000);                    // 최대 3초 기다립니다
    e->accept();
}
가장 흔한 오해 — QThread 를 상속하기. 상속해서 run() 을 덮어쓰면 그 객체 자신은 여전히 원래 스레드에 삽니다. 그 상태에서 멤버 슬롯을 연결하면 UI 스레드에서 실행되어 병렬화가 안 됩니다. 정답은 언제나 moveToThread 입니다.

네트워크 — QNetworkAccessManager 🟢 기초

HTTP 요청 — 스레드가 필요 없습니다

// CMake: COMPONENTS Network / Qt6::Network
#include <QNetworkAccessManager>
#include <QNetworkReply>
#include <QJsonDocument>

// ★ manager 는 앱에 하나만 만들어 멤버로 둡니다 (커넥션 재사용)
QNetworkAccessManager *m_net = new QNetworkAccessManager(this);

void MainWindow::fetchUser(int id) {
    QNetworkRequest req(QUrl(QString("https://api.example.com/users/%1").arg(id)));
    req.setRawHeader("Accept", "application/json");
    req.setTransferTimeout(10'000);          // 10초 (Qt 5.15+) ★ 꼭 주세요

    QNetworkReply *reply = m_net->get(req);  // 즉시 반환 — 블로킹하지 않습니다

    connect(reply, &QNetworkReply::finished, this, [this, reply] {
        reply->deleteLater();                // ★ 빠뜨리면 메모리가 샙니다
        if (reply->error() != QNetworkReply::NoError) {
            ui->statusLabel->setText("실패: " + reply->errorString());
            return;
        }
        const QByteArray body = reply->readAll();
        QJsonParseError err{};
        const QJsonDocument doc = QJsonDocument::fromJson(body, &err);
        if (err.error != QJsonParseError::NoError) return;

        const QJsonObject o = doc.object();
        ui->nameLabel->setText(o["name"].toString());
        ui->ageLabel->setNum(o["age"].toInt());
    });
}

// POST 로 JSON 보내기
QJsonObject payload{{"name", "홍길동"}, {"age", 30}};
QNetworkRequest req(QUrl("https://api.example.com/users"));
req.setHeader(QNetworkRequest::ContentTypeHeader, "application/json");
m_net->post(req, QJsonDocument(payload).toJson(QJsonDocument::Compact));

// 큰 파일 내려받기 — 진행률과 스트리밍 저장
QNetworkReply *dl = m_net->get(QNetworkRequest(url));
connect(dl, &QNetworkReply::downloadProgress, this, [=](qint64 got, qint64 total){
    if (total > 0) ui->progressBar->setValue(int(got * 100 / total));
});
connect(dl, &QIODevice::readyRead, this, [=]{ file.write(dl->readAll()); });
왜 스레드가 필요 없나 — QNetworkAccessManager 는 비동기라 이벤트 루프 위에서 돕니다. 파이썬에서 requests.get()(블로킹)을 쓰다가 온 사람이 UI 를 멈추는 첫 번째 이유가 이겁니다. 블로킹 HTTP 라이브러리를 UI 스레드에서 쓰지 마세요.

설정 저장 — QSettings ★

// main.cpp 에서 한 번만 정해 두면 어디서든 기본 생성자로 씁니다
QCoreApplication::setOrganizationName("Acme");
QCoreApplication::setOrganizationDomain("acme.example");
QCoreApplication::setApplicationName("LogViewer");

// 저장 위치는 OS 관례를 알아서 따릅니다
//   윈도우 : HKEY_CURRENT_USER\Software\Acme\LogViewer  (레지스트리)
//   macOS  : ~/Library/Preferences/com.acme.LogViewer.plist
//   Linux  : ~/.config/Acme/LogViewer.conf

void MainWindow::closeEvent(QCloseEvent *e) {
    QSettings s;
    s.setValue("win/geometry", saveGeometry());     // 창 위치·크기 통째로 ★
    s.setValue("win/state",    saveState());        // 툴바·도킹 배치
    s.setValue("view/columns", ui->table->horizontalHeader()->saveState());
    s.setValue("recent",       m_recentFiles);      // QStringList 도 그대로
    e->accept();
}

void MainWindow::restoreUi() {
    QSettings s;
    restoreGeometry(s.value("win/geometry").toByteArray());
    restoreState(s.value("win/state").toByteArray());
    m_recentFiles = s.value("recent").toStringList();
    m_theme = s.value("view/theme", "dark").toString();   // 기본값 지정
}

// 레지스트리 말고 파일로 강제하고 싶다면 (포터블 앱)
QSettings s(QCoreApplication::applicationDirPath() + "/config.ini",
            QSettings::IniFormat);
saveGeometry() 하나로 끝냅니다 — 창 위치·크기·최대화 여부· 어느 모니터에 있었는지까지 한 덩어리로 저장됩니다. 직접 x/y/w/h 를 저장하면 모니터를 뺐을 때 창이 화면 밖으로 나가는 버그가 납니다.

SQLite 와 다국어 — 앱을 제품답게 🟡 중급

QSqlDatabase — 로컬 저장은 대부분 SQLite

// CMake: COMPONENTS Sql / Qt6::Sql
#include <QSqlDatabase>
#include <QSqlQuery>
#include <QSqlError>
#include <QStandardPaths>

bool openDb() {
    // 앱 데이터 폴더 — OS 마다 알아서 맞는 곳을 줍니다 ★
    const QString dir = QStandardPaths::writableLocation(
                            QStandardPaths::AppDataLocation);
    QDir().mkpath(dir);

    auto db = QSqlDatabase::addDatabase("QSQLITE");
    db.setDatabaseName(dir + "/app.db");
    if (!db.open()) { qWarning() << db.lastError().text(); return false; }

    QSqlQuery q;
    q.exec("PRAGMA journal_mode=WAL");     // ★ 읽기와 쓰기가 서로 안 막힘
    q.exec("PRAGMA synchronous=NORMAL");   // 데스크톱 앱에는 이 정도면 충분
    q.exec(R"(CREATE TABLE IF NOT EXISTS notes(
                id      INTEGER PRIMARY KEY,
                title   TEXT NOT NULL,
                body    TEXT,
                created INTEGER NOT NULL))");
    return true;
}

// 값은 반드시 바인딩으로 — 문자열 이어 붙이면 SQL 인젝션입니다
void addNote(const QString &title, const QString &body) {
    QSqlQuery q;
    q.prepare("INSERT INTO notes(title, body, created) VALUES(?, ?, ?)");
    q.addBindValue(title);
    q.addBindValue(body);
    q.addBindValue(QDateTime::currentSecsSinceEpoch());
    if (!q.exec()) qWarning() << q.lastError().text();
}

// 표에 바로 붙이기 — 모델을 직접 안 만들어도 됩니다
auto *m = new QSqlTableModel(this);
m->setTable("notes");
m->setEditStrategy(QSqlTableModel::OnManualSubmit);
m->select();
ui->table->setModel(m);
// 사용자가 셀을 고치고 [저장] 을 누르면
connect(ui->saveButton, &QPushButton::clicked, m, &QSqlTableModel::submitAll);
DB 연결은 스레드마다 따로입니다 — QSqlDatabase 객체를 다른 스레드로 넘겨 쓰면 조용히 깨집니다. 워커 스레드에서 DB 를 쓸 거면 그 스레드 안에서 addDatabase("QSQLITE", "worker-conn") 처럼 이름 붙인 연결을 새로 여세요.

다국어 — tr() · lupdate · lrelease

// ① 사용자에게 보이는 모든 문자열을 tr() 로 감쌉니다
ui->saveButton->setText(tr("저장"));
setWindowTitle(tr("로그 뷰어"));

// 숫자가 끼면 %1 자리표시자를 쓰세요 (어순이 언어마다 다릅니다) ★
label->setText(tr("%1개 중 %2개 선택").arg(total).arg(selected));

// 단수/복수 처리 — 언어마다 규칙이 다른 걸 Qt 가 압니다
label->setText(tr("%n개 파일", "", count));

// QObject 를 상속하지 않는 곳에서는
QCoreApplication::translate("Report", "합계");

// ② main.cpp 에서 번역 파일을 싣습니다
QTranslator translator;
if (translator.load(QLocale(), "app", "_", ":/i18n"))   // :/i18n/app_ko.qm
    app.installTranslator(&translator);

// Qt 자체 문자열(확인/취소 버튼 등)도 같이
QTranslator qtBase;
qtBase.load(QLocale(), "qtbase", "_",
            QLibraryInfo::path(QLibraryInfo::TranslationsPath));
app.installTranslator(&qtBase);
# ③ CMake 가 번역 파일을 만들고 리소스에 넣어 줍니다 (Qt 6.2+)
find_package(Qt6 REQUIRED COMPONENTS LinguistTools)

qt_add_translations(app
    TS_FILES  i18n/app_ko.ts  i18n/app_en.ts  i18n/app_ja.ts
    RESOURCE_PREFIX "/i18n")

# 흐름:  소스의 tr() 를 긁어  → .ts (번역가가 채움)  → .qm (바이너리)
#   cmake --build build --target update_translations   # lupdate 실행
#   Qt Linguist 로 .ts 를 열어 번역
#   빌드하면 .qm 이 자동 생성되어 실행 파일 안에 들어갑니다
지금 당장 다국어가 필요 없어도 tr() 는 처음부터 붙이세요 — 나중에 수천 곳을 찾아 감싸는 일은 사실상 하지 않게 됩니다. 비용은 타자 네 글자고, 안 하면 “해외 고객사” 얘기가 나왔을 때 몇 주가 날아갑니다.
정리 — 실전 Qt 는 QtConcurrent/QThread(안 멈추게) · QNetworkAccessManager(비동기 HTTP) · QSettings(창 상태) · QSqlDatabase(로컬 DB) · tr()(다국어) 다섯 개면 대부분 덮입니다.
APP 06

Qt 배포 — exe 하나를 건네도 안 도는 이유

빌드가 끝나면 app.exe 가 생기지만, 그걸 다른 컴퓨터에 복사하면 거의 확실히 안 돕니다. Qt DLL 이 없기 때문입니다. 여기서는 필요한 파일을 자동으로 모으고 → 설치 파일로 묶고 → 서명하는 한 바퀴를 OS 별로 끝냅니다. 이 섹션을 마치면 남에게 건넬 수 있는 결과물이 나옵니다.

windeployqt 가 채워 넣는 것들 — 빠뜨리면 실행 즉시 죽습니다 빌드 직후 app.exe 딱 하나 windeployqt 배포 폴더 — 이대로 압축하면 도는 상태 실행 파일 + DLL + 플러그인 + 번역 app.exe 내가 만든 실행 파일 — WIN32_EXECUTABLE ON 이면 콘솔 창이 안 뜹니다 Qt6Core.dll · Qt6Gui.dll · Qt6Widgets.dll · Qt6Network.dll … 쓴 모듈만 골라 복사됩니다. Release 와 Debug DLL 을 섞으면 즉시 크래시합니다 platforms\qwindows.dll ★ 1위 사고 원인. 없으면 "could not find or load the Qt platform plugin" 대화상자 styles\ · imageformats\ · iconengines\ 네이티브 스타일, PNG·JPEG·SVG 로더. 없으면 아이콘이 전부 빈 사각형이 됩니다 sqldrivers\qsqlite.dll · translations\qtbase_ko.qm DB 를 쓰면 드라이버가, 다국어를 쓰면 Qt 자체 번역이 필요합니다 + Visual C++ 재배포 런타임 (대상 PC 에 없으면 실행 불가) 설치 파일에 같이 넣거나 /MT 로 정적 링크하거나 설치기가 받아 오게 합니다 macOS 는 macdeployqt 가 .app 번들 안 Frameworks 로, Linux 는 linuxdeployqt 가 AppImage 로 같은 일을 합니다
“내 PC 에선 되는데”의 정체는 대부분 Qt 설치 폴더가 PATH 에 있어서입니다.

윈도우 — windeployqt ★

# ① 릴리즈로 빌드 (Debug DLL 은 배포하면 안 됩니다 — 라이선스·크기·속도)
cmake --build build --config Release

# ② 배포 폴더를 따로 만들고 exe 만 복사
mkdir dist
copy build\Release\app.exe dist\

# ③ windeployqt 실행 — 필요한 걸 알아서 채웁니다
C:\Qt\6.8.3\msvc2022_64\bin\windeployqt.exe ^
    --release ^
    --no-translations ^          # 다국어를 안 쓰면 크기를 줄입니다
    --no-system-d3d-compiler ^
    --no-opengl-sw ^             # 소프트웨어 OpenGL 제외 (20MB 절약)
    dist\app.exe

# QML 앱이면 QML 파일 위치를 알려 줘야 합니다 ★
windeployqt.exe --qmldir src\qml dist\app.exe

# ④ 확인 — 진짜 확인은 "깨끗한 PC" 에서 해야 합니다
#    가상머신이나 다른 사람 PC 에서 dist 폴더째로 실행
#    PATH 에 Qt 가 있으면 로컬에선 빠진 DLL 이 있어도 잘 돕니다 ★ 함정
# CMake 에 배포 단계를 묶어 두면 매번 손으로 안 칩니다
if(WIN32)
  find_program(WINDEPLOYQT windeployqt HINTS "${Qt6_DIR}/../../../bin")
  add_custom_command(TARGET app POST_BUILD
      COMMAND "${CMAKE_COMMAND}" -E make_directory "$<TARGET_FILE_DIR:app>/dist"
      COMMAND "${CMAKE_COMMAND}" -E copy "$<TARGET_FILE:app>" "$<TARGET_FILE_DIR:app>/dist"
      COMMAND "${WINDEPLOYQT}" --release "$<TARGET_FILE_DIR:app>/dist/app.exe")
endif()

# Qt 6.5+ 에는 표준 설치 스크립트 생성기도 있습니다
qt_generate_deploy_app_script(TARGET app OUTPUT_SCRIPT deploy_script
                              NO_UNSUPPORTED_PLATFORM_ERROR)
install(SCRIPT ${deploy_script})
#   cmake --install build --prefix dist   한 줄로 끝납니다
가장 무서운 함정 — 내 PC 에서 dist 폴더를 실행하면 잘 돕니다. PATH 에 C:\Qt\...\bin 이 있어서 빠진 DLL 을 거기서 찾기 때문입니다. 반드시 Qt 가 없는 컴퓨터에서 확인하세요. 없다면 PATH 를 비운 새 명령창이나 Windows Sandbox 를 쓰면 됩니다.

macOS 와 Linux

# ===== macOS =====
# MACOSX_BUNDLE ON 이면 build/app.app 이 생깁니다
macdeployqt build/app.app -dmg      # 프레임워크 복사 + .dmg 까지 한 번에
#   -qmldir=src/qml     QML 앱이면
#   -codesign="Developer ID Application: 내이름 (TEAMID)"  서명까지

# 애플 공증(notarization) — 이게 없으면 사용자가 못 엽니다 ★
xcrun notarytool submit app.dmg --apple-id me@example.com \
      --team-id TEAMID --password "앱 전용 암호" --wait
xcrun stapler staple app.dmg        # 결과를 파일에 붙여 오프라인에서도 통과

# ===== Linux =====
# 배포판마다 라이브러리 버전이 달라 AppImage 가 사실상 표준입니다
export QMAKE=/opt/Qt/6.8.3/gcc_64/bin/qmake6
linuxdeployqt AppDir/usr/share/applications/app.desktop -appimage
#   → app-x86_64.AppImage 하나가 나옵니다. chmod +x 하면 어디서든 실행

# 요즘은 linuxdeploy + qt 플러그인 조합도 많이 씁니다
linuxdeploy --appdir AppDir -e build/app -d app.desktop -i app.png \
            --plugin qt --output appimage

# Flatpak 은 배포판 스토어에 올릴 때 (org.kde.Platform 런타임에 Qt 6 포함)
“빌드는 가장 오래된 배포판에서” — glibc 는 위로는 호환되지만 아래로는 안 됩니다. Ubuntu 24.04 에서 빌드한 바이너리는 22.04 에서 안 돕니다. 그래서 실무에서는 가장 낮게 지원할 배포판의 도커 이미지에서 빌드합니다 (예: ubuntu:22.04 컨테이너 안에서 CI 빌드).

설치 파일 만들기 — Inno Setup · NSIS · WiX 🟡 중급

Inno Setup — 가장 쉽고 가장 많이 씁니다 ★

; setup.iss — 이 30줄이면 제대로 된 설치 파일이 나옵니다
[Setup]
AppName=Log Viewer
AppVersion=1.2.0
AppPublisher=Acme Inc.
DefaultDirName={autopf}\LogViewer      ; 64비트면 Program Files
DefaultGroupName=Log Viewer
OutputBaseFilename=LogViewer-1.2.0-setup
Compression=lzma2/max
SolidCompression=yes
ArchitecturesInstallIn64BitMode=x64compatible
PrivilegesRequired=lowest              ; 관리자 없이 설치 (권장)
WizardStyle=modern
SetupIconFile=res\app.ico
UninstallDisplayIcon={app}\app.exe

[Languages]
Name: "korean"; MessagesFile: "compiler:Languages\Korean.isl"

[Files]
Source: "dist\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs

[Icons]
Name: "{group}\Log Viewer";        Filename: "{app}\app.exe"
Name: "{autodesktop}\Log Viewer";  Filename: "{app}\app.exe"; Tasks: desktopicon

[Tasks]
Name: "desktopicon"; Description: "바탕화면에 아이콘 만들기"; GroupDescription: "추가 아이콘:"

[Registry]
; .log 파일을 이 앱과 연결
Root: HKCU; Subkey: "Software\Classes\.log\OpenWithProgids"; \
  ValueType: string; ValueName: "Acme.LogViewer"; ValueData: ""; Flags: uninsdeletevalue

[Run]
Filename: "{app}\app.exe"; Description: "지금 실행"; Flags: nowait postinstall skipifsilent
# 컴파일 (CI 에서도 이 한 줄)
"C:\Program Files (x86)\Inno Setup 6\ISCC.exe" setup.iss
#   → Output\LogViewer-1.2.0-setup.exe

설치기 네 가지 비교

도구결과물고르는 이유
Inno Setup.exe가장 쉬움 · 스크립트가 읽힘 · 무료
NSIS.exe더 세밀한 제어 · 플러그인 많음 · 문법이 낯섦
WiX v4/v5.msi기업 배포 필수 — 그룹 정책·SCCM 이 msi 만 받음
MSIX.msix스토어 · 컨테이너 격리 · 서명 필수
Qt IFW.exe/.app3-OS 공통 · 온라인 업데이트 · Qt 가 만든 것
# ===== 코드 서명 — 이게 없으면 이런 일이 생깁니다 =====
#   윈도우: "Windows에서 PC를 보호했습니다" (SmartScreen) 파란 화면
#   macOS: "확인되지 않은 개발자" — 우클릭 열기를 해야만 실행

# 윈도우 서명 (2023년부터 인증서를 HSM/USB 토큰에 담아야 합니다)
signtool sign /fd SHA256 /tr http://timestamp.digicert.com /td SHA256 ^
         /n "Acme Inc." dist\app.exe
signtool sign /fd SHA256 /tr http://timestamp.digicert.com /td SHA256 ^
         /n "Acme Inc." Output\LogViewer-1.2.0-setup.exe
#   ★ exe 와 설치 파일 둘 다 서명해야 합니다
#   ★ /tr 타임스탬프를 빠뜨리면 인증서 만료일에 기존 배포본이 전부 경고를 냅니다

# macOS 서명 + 공증
codesign --deep --force --options runtime \
         --sign "Developer ID Application: Acme (TEAMID)" app.app
서명 인증서는 돈과 시간이 듭니다 — OV 코드 서명은 연 20~40만 원대, EV 는 그 두세 배지만 SmartScreen 경고가 바로 사라집니다(OV 는 평판이 쌓일 때까지 몇 주 걸립니다). 애플은 개발자 프로그램(연 99달러)이 사실상 필수입니다. 사내 배포만 한다면 서명 없이 가고, 대신 사용자에게 경고 화면을 넘기는 법을 안내하세요.

정적 빌드와 크기 줄이기 — DLL 없는 exe 하나 🔴 실무

정적 Qt — 가능하지만 값이 큽니다

# Qt 를 소스에서 정적으로 직접 빌드해야 합니다 (설치기에는 없습니다)
git clone --branch v6.8.3 https://code.qt.io/qt/qt5.git qt6
cd qt6 && perl init-repository --module-subset=qtbase,qtsvg,qttools

..\qt6\configure -static -release -prefix C:\Qt-static \
    -nomake examples -nomake tests \
    -skip qtwebengine -no-opengl-sw \
    -static-runtime            # MSVC /MT — VC 런타임까지 안으로

cmake --build . --parallel     # ★ 30분 ~ 2시간 걸립니다

# 그다음 내 앱을 그 Qt 로 빌드하면
cmake -B build -DCMAKE_PREFIX_PATH=C:\Qt-static
#   → app.exe 한 개, 12~25MB, DLL 없음
정적 빌드를 고르는 값 — 표로 정리하면

  얻는 것                        치르는 값
  ─────────────────────────      ────────────────────────────
  exe 파일 하나로 배포 끝        Qt 빌드에 30분~2시간, 20GB
  시작이 아주 조금 빠름          Qt 를 올릴 때마다 다시 빌드
  DLL 누락 사고 0                ★ LGPL 로는 사실상 불가
  안티바이러스 오탐 감소         플러그인을 Q_IMPORT_PLUGIN 으로
                                 일일이 명시해야 함

  ★ 라이선스가 결정적입니다 —
    정적 링크는 LGPL 조건(사용자가 Qt 를 교체할 수 있어야 함)을
    맞추기가 매우 까다롭습니다. 상용 라이선스가 있거나,
    앱 소스를 GPL 로 공개할 게 아니면 동적 링크로 가세요.

현실적인 크기 줄이기

기본 Qt Widgets 앱 배포 폴더 — 어디서 무게가 나가나

  Qt6Core.dll            ~6 MB
  Qt6Gui.dll             ~8 MB
  Qt6Widgets.dll         ~5 MB
  platforms\qwindows.dll ~1 MB
  opengl32sw.dll        ~20 MB   ← --no-opengl-sw 로 제외 ★
  d3dcompiler_47.dll     ~4 MB   ← --no-system-d3d-compiler
  translations\         ~2 MB   ← --no-translations
  imageformats\          ~1 MB
  ─────────────────────────────
  손 안 대면              ~48 MB
  옵션 세 개 빼면         ~22 MB
  UPX 압축까지            ~10 MB  (단, 안티바이러스 오탐 위험)
# 실제로 무엇이 필요한지 확인하는 법
#   ① Dependencies (구 Dependency Walker 대체) 로 exe 를 열어 봅니다
#   ② 의심되는 DLL 을 지우고 실행해서 죽는지 확인 (가장 확실)
#   ③ Qt 플러그인은 실행 중에 로드되므로 정적 분석에 안 잡힙니다 ★
#      QT_DEBUG_PLUGINS=1 로 실행하면 무엇을 찾고 있는지 다 찍힙니다

set QT_DEBUG_PLUGINS=1
dist\app.exe
#   → "loaded library ...\platforms\qwindows.dll" 같은 로그가 쏟아집니다
#      로드 실패 원인도 여기 나옵니다 (버전 불일치 · 의존 DLL 없음)
자동 업데이트가 필요하면 — 직접 만들지 마세요. 윈도우는 Squirrel.Windows·WinSparkle, macOS 는 Sparkle, 3-OS 공통이면 Qt Installer Framework 의 온라인 저장소 기능을 씁니다. “버전 확인 → 내려받기 → 교체 → 재시작”은 권한·롤백·서명 검증 때문에 직접 만들면 반드시 사고가 납니다.
정리 — 배포는 windeployqt(파일 모으기) → Inno Setup(묶기) → signtool(서명) 세 단계고, 확인은 반드시 Qt 가 안 깔린 컴퓨터에서 해야 합니다.
APP 07

Dear ImGui — 즉시 모드가 도구를 만드는 방식

Qt 로 디버그 창 하나를 만들려면 클래스 · 헤더 · 시그널 · 모델이 줄줄이 붙습니다. ImGui 는 그 자리에서 if (ImGui::Button("리셋")) reset(); 한 줄이면 끝입니다. 게임 엔진과 그래픽스 도구가 전부 이걸 쓰는 이유, 그리고 왜 사용자용 앱에는 안 쓰는지를 봅니다.

보존 모드(Qt) vs 즉시 모드(ImGui) — 위젯이 있느냐 없느냐 보존 모드 — Qt · 웹 · WinUI 즉시 모드 — Dear ImGui ① 위젯 객체를 만들어 둡니다 new QLabel · 트리에 보관 · 메모리에 계속 삽니다 ① 객체가 없습니다 ImGui::Text("...") 는 그냥 함수 호출입니다 ② 데이터가 바뀌면 위젯에 알려 줍니다 label->setText(...) · 모델 signal · 바인딩 ② 매 프레임 처음부터 다시 선언 지금 값을 그대로 읽어 그립니다 — 알릴 것이 없음 ③ 어긋날 수 있습니다 데이터는 바뀌었는데 화면이 안 바뀌는 버그의 근원 ③ 어긋날 수가 없습니다 화면은 항상 "지금 이 순간의 데이터" 입니다 ④ 놀고 있으면 CPU 0% 이벤트가 없으면 아무것도 안 합니다 — 노트북 배터리 ④ 가만히 있어도 초당 60프레임 CPU·GPU 를 계속 씁니다 (게임 안에서는 어차피 도니까 공짜) 그래서 — 사용자용 앱은 Qt, 개발자용 도구는 ImGui 접근성 · 한글 IME · OS 관례 · 배터리는 Qt 가 압도적이고, 만드는 속도는 ImGui 가 압도적입니다
즉시 모드의 본질은 “UI 상태를 따로 보관하지 않는다” — 그래서 동기화 버그가 사라집니다.

세팅 — vcpkg 로 3분 ★

// vcpkg.json — 프로젝트 루트에 두면 CMake 가 알아서 받습니다
{
  "name": "imgui-tool",
  "version": "0.1.0",
  "dependencies": [
    { "name": "imgui", "features": ["glfw-binding", "opengl3-binding", "docking-experimental"] },
    "glfw3",
    "glad"
  ]
}
cmake_minimum_required(VERSION 3.21)
project(imgui_tool LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 20)

find_package(imgui  CONFIG REQUIRED)
find_package(glfw3  CONFIG REQUIRED)
find_package(glad   CONFIG REQUIRED)

add_executable(imgui_tool main.cpp)
target_link_libraries(imgui_tool PRIVATE
    imgui::imgui glfw glad::glad)
# vcpkg 툴체인만 알려 주면 의존성을 알아서 받아 빌드합니다
cmake -B build -S . \
  -DCMAKE_TOOLCHAIN_FILE=%VCPKG_ROOT%/scripts/buildsystems/vcpkg.cmake
cmake --build build

# vcpkg 를 안 쓰고 소스를 직접 넣어도 됩니다 — 이게 원래 방식입니다
#   imgui 저장소에서 파일 열 개 남짓만 복사하면 끝
#     imgui.cpp imgui_draw.cpp imgui_tables.cpp imgui_widgets.cpp imgui_demo.cpp
#     backends/imgui_impl_glfw.cpp  backends/imgui_impl_opengl3.cpp
#   ★ 빌드 시스템 없이 "소스에 넣는" 게 가능한 게 ImGui 의 큰 장점입니다
백엔드는 두 겹입니다 — 플랫폼 백엔드(창·입력: GLFW · SDL3 · Win32)와 렌더 백엔드(그리기: OpenGL3 · DirectX11/12 · Vulkan · Metal)를 각각 고릅니다. 처음이라면 GLFW + OpenGL3 가 가장 무난하고 예제도 제일 많습니다. 이미 SDL3 나 DirectX 를 쓰는 프로젝트라면 그쪽 백엔드를 고르면 됩니다.

main.cpp — 이 골격이 전부입니다 ★

#include "imgui.h"
#include "imgui_impl_glfw.h"
#include "imgui_impl_opengl3.h"
#include <glad/glad.h>
#include <GLFW/glfw3.h>
#include <vector>
#include <string>

int main() {
    // ── ① 창 만들기 (GLFW) ─────────────────────────────
    glfwInit();
    glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 3);
    glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 3);
    glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE);
    GLFWwindow *win = glfwCreateWindow(1280, 800, "내 도구", nullptr, nullptr);
    glfwMakeContextCurrent(win);
    glfwSwapInterval(1);                 // VSync — 60fps 로 제한
    gladLoadGLLoader((GLADloadproc)glfwGetProcAddress);

    // ── ② ImGui 초기화 ────────────────────────────────
    IMGUI_CHECKVERSION();
    ImGui::CreateContext();
    ImGuiIO &io = ImGui::GetIO();
    io.ConfigFlags |= ImGuiConfigFlags_DockingEnable;   // 창을 도킹 가능하게
    io.IniFilename = "layout.ini";       // 창 배치를 자동 저장·복원 ★

    // 한글을 쓰려면 폰트를 반드시 지정해야 합니다 ★
    io.Fonts->AddFontFromFileTTF("C:/Windows/Fonts/malgun.ttf", 16.0f,
                                 nullptr, io.Fonts->GetGlyphRangesKorean());

    ImGui::StyleColorsDark();
    ImGui_ImplGlfw_InitForOpenGL(win, true);
    ImGui_ImplOpenGL3_Init("#version 330");

    // ── ③ 애플리케이션 상태 — 그냥 평범한 변수입니다 ──
    float  threshold = 0.5f;
    bool   showDemo  = false;
    int    selected  = -1;
    std::vector<std::string> items{"alpha", "beta", "gamma", "delta"};
    char   filter[128] = "";

    // ── ④ 메인 루프 ───────────────────────────────────
    while (!glfwWindowShouldClose(win)) {
        glfwPollEvents();
        ImGui_ImplOpenGL3_NewFrame();
        ImGui_ImplGlfw_NewFrame();
        ImGui::NewFrame();
        // ↓↓↓ 여기부터 UI — 매 프레임 처음부터 다시 씁니다 ↓↓↓

        ImGui::DockSpaceOverViewport();   // 전체 화면 도킹 영역

        ImGui::Begin("설정");
        ImGui::SliderFloat("임계값", &threshold, 0.0f, 1.0f);
        ImGui::Checkbox("데모 창 보기", &showDemo);
        if (ImGui::Button("초기화")) { threshold = 0.5f; selected = -1; }
        ImGui::SameLine();
        ImGui::TextDisabled("(%.1f FPS)", io.Framerate);
        ImGui::End();

        ImGui::Begin("목록");
        ImGui::InputTextWithHint("##f", "검색", filter, sizeof(filter));
        ImGui::Separator();
        for (int i = 0; i < (int)items.size(); ++i) {
            if (filter[0] && items[i].find(filter) == std::string::npos) continue;
            if (ImGui::Selectable(items[i].c_str(), selected == i)) selected = i;
        }
        ImGui::End();

        if (showDemo) ImGui::ShowDemoWindow(&showDemo);   // ★ 위젯 백과사전

        // ↑↑↑ UI 끝 ↑↑↑
        ImGui::Render();
        int w, h; glfwGetFramebufferSize(win, &w, &h);
        glViewport(0, 0, w, h);
        glClearColor(0.10f, 0.11f, 0.13f, 1.0f);
        glClear(GL_COLOR_BUFFER_BIT);
        ImGui_ImplOpenGL3_RenderDrawData(ImGui::GetDrawData());
        glfwSwapBuffers(win);
    }

    ImGui_ImplOpenGL3_Shutdown();
    ImGui_ImplGlfw_Shutdown();
    ImGui::DestroyContext();
    glfwDestroyWindow(win); glfwTerminate();
}

코드 감각 익히기 — Qt 와 나란히 🟢 기초

같은 화면, 두 방식

// ===== Qt — 값이 바뀌면 위젯에게 알려야 합니다 =====
// setup()
m_slider = new QSlider(Qt::Horizontal);
m_slider->setRange(0, 100);
m_label  = new QLabel;
connect(m_slider, &QSlider::valueChanged, this, [this](int v){
    m_threshold = v / 100.0;
    m_label->setText(QString::number(m_threshold, 'f', 2));   // 동기화 코드
    recompute();
});
// 다른 곳에서 m_threshold 를 바꾸면? 슬라이더도 라벨도 따로 갱신해야 합니다
void setThreshold(double t) {
    m_threshold = t;
    m_slider->blockSignals(true);            // 시그널 되먹임 방지 ★
    m_slider->setValue(int(t * 100));
    m_slider->blockSignals(false);
    m_label->setText(QString::number(t, 'f', 2));
}
// ===== ImGui — 변수 하나가 진실입니다 =====
if (ImGui::SliderFloat("임계값", &threshold, 0.f, 1.f))
    recompute();                    // 값이 바뀐 프레임에만 true
ImGui::Text("%.2f", threshold);     // 항상 지금 값

// 다른 곳에서 threshold 를 바꾸면? 그냥 바꾸면 됩니다.
// 다음 프레임에 슬라이더도 라벨도 저절로 그 값으로 그려집니다 ★
threshold = 0.8f;                   // 끝. 동기화 코드 없음
“위젯이 상태를 갖지 않는다”가 주는 것 — blockSignals, 되먹임 루프, “뷰만 안 바뀌는 버그” 가 구조적으로 존재할 수 없습니다. 도구를 빠르게 만들 때 이게 얼마나 큰 차이인지는 두 번째 도구부터 체감됩니다.

실무에서 자주 쓰는 위젯

// 표 — 정렬 · 열 크기 조절 · 고정 헤더가 전부 공짜입니다
if (ImGui::BeginTable("t", 3, ImGuiTableFlags_Sortable
                             | ImGuiTableFlags_Resizable
                             | ImGuiTableFlags_ScrollY
                             | ImGuiTableFlags_RowBg)) {
    ImGui::TableSetupColumn("이름");
    ImGui::TableSetupColumn("크기", ImGuiTableColumnFlags_WidthFixed, 90);
    ImGui::TableSetupColumn("시각");
    ImGui::TableSetupScrollFreeze(0, 1);          // 헤더 고정
    ImGui::TableHeadersRow();

    // ★ 100만 행도 이걸로 끊깁니다 — 보이는 만큼만 루프를 돕니다
    ImGuiListClipper clipper;
    clipper.Begin((int)rows.size());
    while (clipper.Step())
      for (int i = clipper.DisplayStart; i < clipper.DisplayEnd; ++i) {
        ImGui::TableNextRow();
        ImGui::TableNextColumn(); ImGui::TextUnformatted(rows[i].name.c_str());
        ImGui::TableNextColumn(); ImGui::Text("%lld", rows[i].size);
        ImGui::TableNextColumn(); ImGui::TextUnformatted(rows[i].when.c_str());
      }
    ImGui::EndTable();
}

// 트리 · 접기
if (ImGui::TreeNode("옵션")) { ImGui::Checkbox("A", &a); ImGui::TreePop(); }

// 팝업 · 컨텍스트 메뉴
if (ImGui::BeginPopupContextItem("ctx")) {
    if (ImGui::MenuItem("삭제")) remove(i);
    ImGui::EndPopup();
}

// 드래그로 값 조절 — 슬라이더보다 정밀합니다
ImGui::DragFloat3("위치", &pos.x, 0.01f);

// 그래프 — 한 줄로 실시간 플롯
ImGui::PlotLines("fps", history, IM_ARRAYSIZE(history), 0, nullptr, 0, 120, {0, 60});

// 색 · 이미지 · 진행률
ImGui::ColorEdit3("배경", &bg.x);
ImGui::Image((ImTextureID)(intptr_t)texId, {256, 256});
ImGui::ProgressBar(done / (float)total);
ImGui::ShowDemoWindow() 가 곧 문서입니다 — ImGui 는 별도 문서가 얇은 대신 imgui_demo.cpp 안에 모든 위젯의 사용 예제가 실행 가능한 형태로 들어 있습니다. 원하는 위젯을 데모에서 찾고 그 소스를 그대로 복사하는 게 정석 사용법입니다.

언제 쓰고 언제 안 쓰나 — 경계선 🔴 실무

ImGui 가 이기는 자리

이럴 때 강력이럴 땐 부적합
게임 엔진 안의 에디터
이미 렌더 루프가 돌고 있음
일반 사용자에게 파는 앱
실시간 파라미터 튜닝
슬라이더를 움직이며 결과를 봄
한글 입력이 많은 앱
디버그 오버레이
F12 로 켜는 내부 정보 창
접근성(스크린 리더) 요구
데이터 뷰어 · 프로파일러배터리로 오래 켜 두는 앱
로봇·장비 제어 패널OS 네이티브 룩이 요구될 때
혼자·소수가 쓰는 도구복잡한 폼 · 문서 편집
한글 입력은 되지만 완전하지 않습니다 — 폰트를 지정하면 표시는 잘 되고 Win32/GLFW 백엔드에서 IME 조합도 대체로 됩니다. 하지만 조합 중 미리보기 · 후보 창 위치 · 복잡한 편집은 Qt 만큼 매끄럽지 않습니다. 한국어 사용자가 긴 글을 입력하는 화면이라면 ImGui 를 피하세요.

함정 세 개와 대안 라이브러리

// 함정 ① ID 충돌 — 같은 라벨이면 같은 위젯으로 취급됩니다
for (auto &item : items) {
    ImGui::Button("삭제");          // ✗ 전부 같은 버튼으로 인식
}
for (int i = 0; i < n; ++i) {
    ImGui::PushID(i);               // ○ 반복문에서는 항상 PushID
    ImGui::Button("삭제");
    ImGui::PopID();
}
//   또는 라벨에 숨은 ID 를 붙입니다:  ImGui::Button("삭제##3")

// 함정 ② UI 코드 안에서 무거운 일 — 매 프레임 실행됩니다
ImGui::Text("%s", loadFileAndParse(path).c_str());   // ✗ 초당 60번 파싱
//   → 결과를 변수에 캐시하고 필요할 때만 갱신하세요

// 함정 ③ 상태를 UI 함수 안의 static 에 두기
void drawPanel() { static float v = 0; ImGui::SliderFloat("v", &v, 0, 1); }
//   ✗ 패널을 두 개 띄우면 값이 공유됩니다. 상태는 밖으로 빼세요
함께 쓰는 확장들 (전부 헤더 몇 개)

  ImPlot        과학·엔지니어링 그래프 — 실시간 플롯의 사실상 표준
  ImGuizmo      3D 씬의 이동·회전 조작 핸들 (에디터 필수)
  ImGuiFileDialog / ImGui-FileBrowser   파일 열기 대화상자
  imgui_memory_editor  바이트 단위 헥스 뷰어
  imgui_node_editor    노드 그래프 에디터 (셰이더·파이프라인 편집)
  imgui-sfml / imgui-node    각 프레임워크용 백엔드

비슷한 즉시 모드 대안
  Nuklear   C 로 된 단일 헤더 — C 프로젝트에서
  microui   극단적으로 작음 (임베디드)
  egui      러스트 진영의 즉시 모드 (참고용)
ImGui + ImPlot 조합은 “데이터를 보는 도구”를 만드는 가장 빠른 길입니다. 센서 로그를 실시간 그래프로 보고 파라미터를 슬라이더로 만지는 화면을 한나절이면 만들 수 있습니다. 같은 걸 Qt Charts 로 하면 며칠 걸립니다.
정리 — ImGui 는 “UI 상태를 보관하지 않는다”는 한 가지 결정으로 동기화 버그를 없애고 개발 속도를 몇 배로 올린 대신, OS 통합과 배터리를 내줬습니다 — 그래서 도구 전용입니다.
APP 08

SDL3 와 raylib — 창 · 입력 · 게임 루프를 직접

Qt 와 ImGui 는 “위젯”이라는 개념 위에 있습니다. SDL3 와 raylib 에는 그런 게 없습니다 — 창을 열고, 입력을 받고, 픽셀을 그리는 딱 그만큼만 줍니다. 게임뿐 아니라 영상 플레이어 · 시뮬레이터 · 시각화 도구처럼 화면 전체를 직접 그리는 프로그램의 바닥입니다.

게임 루프 — 시간을 다루는 세 가지 방식 ① 고정 간격 — 매 프레임 같은 양만큼 움직인다 x += 5 · 옛날 방식. 컴퓨터가 빠르면 게임도 빨라집니다 (도스 시절 터보 버튼의 이유) ② 델타 타임 — 지난 시간을 곱한다 x += speed * dt · 어느 PC 에서나 같은 속도. 다만 프레임이 크게 튀면 물체가 벽을 뚫습니다 ③ 누산기 + 고정 물리 스텝 — 상용 엔진의 방식 ★ acc += dt; while (acc >= 1/60) { physics(1/60); acc -= 1/60; } 물리는 항상 같은 간격으로 여러 번, 화면은 남은 비율(acc)로 보간해 부드럽게 한 프레임 안에서 벌어지는 일 — 16.6ms 시간 입력 처리 이벤트 큐 비우기 물리 · 게임 로직 고정 스텝으로 0~n회 그리기 클리어 → 도형 → 텍스트 Present · VSync 화면 갱신까지 대기 이 루프가 곧 프로그램의 심장입니다 — Qt 의 exec() 자리에 내가 쓴 while 문이 들어갑니다
델타 타임만 써도 대부분 충분하고, 충돌이 중요한 게임이면 ③ 누산기로 갑니다.

SDL3 — 전체 예제 (움직이는 사각형) ★

// vcpkg install sdl3   /   CMake: find_package(SDL3 CONFIG REQUIRED)
//                            target_link_libraries(app PRIVATE SDL3::SDL3)
#include <SDL3/SDL.h>
#include <cstdio>

int main(int, char**) {
    if (!SDL_Init(SDL_INIT_VIDEO)) {          // ★ SDL3 는 bool 을 돌려줍니다
        SDL_Log("init 실패: %s", SDL_GetError());
        return 1;
    }

    // SDL3 의 CreateWindow 에는 x, y 가 없습니다 (SDL2 와 다른 점)
    SDL_Window *win = SDL_CreateWindow("SDL3 첫 창", 960, 640,
                                       SDL_WINDOW_RESIZABLE);
    SDL_Renderer *ren = SDL_CreateRenderer(win, nullptr);   // nullptr = 자동 선택
    SDL_SetRenderVSync(ren, 1);               // 화면 주사율에 맞춰 제한

    SDL_FRect player{ 100.f, 100.f, 48.f, 48.f };   // SDL3 는 float 좌표가 기본
    float speed = 320.f;                             // 초당 320픽셀

    Uint64 prev = SDL_GetTicksNS();
    bool running = true;

    while (running) {
        // ── ① 입력 ────────────────────────────────
        SDL_Event e;
        while (SDL_PollEvent(&e)) {
            switch (e.type) {
                case SDL_EVENT_QUIT: running = false; break;
                case SDL_EVENT_KEY_DOWN:
                    if (e.key.key == SDLK_ESCAPE) running = false;
                    break;
                case SDL_EVENT_WINDOW_RESIZED:
                    SDL_Log("크기 %dx%d", e.window.data1, e.window.data2);
                    break;
            }
        }

        // ── ② 갱신 (델타 타임) ────────────────────
        Uint64 now = SDL_GetTicksNS();
        float dt = float(now - prev) / 1'000'000'000.0f;   // 초 단위
        prev = now;
        if (dt > 0.25f) dt = 0.25f;           // ★ 디버거로 멈췄다 재개했을 때 대비

        const bool *key = SDL_GetKeyboardState(nullptr);   // SDL3 는 bool 배열
        float dx = 0, dy = 0;
        if (key[SDL_SCANCODE_LEFT])  dx -= 1;
        if (key[SDL_SCANCODE_RIGHT]) dx += 1;
        if (key[SDL_SCANCODE_UP])    dy -= 1;
        if (key[SDL_SCANCODE_DOWN])  dy += 1;
        if (dx && dy) { dx *= 0.7071f; dy *= 0.7071f; }   // 대각선 보정
        player.x += dx * speed * dt;
        player.y += dy * speed * dt;

        // ── ③ 그리기 ──────────────────────────────
        SDL_SetRenderDrawColor(ren, 24, 28, 36, 255);
        SDL_RenderClear(ren);

        SDL_SetRenderDrawColor(ren, 96, 220, 160, 255);
        SDL_RenderFillRect(ren, &player);

        SDL_SetRenderDrawColor(ren, 200, 200, 210, 255);
        SDL_RenderDebugText(ren, 12, 12, "화살표로 이동 / ESC 종료");  // 디버그용

        SDL_RenderPresent(ren);               // 화면에 내보내기
    }

    SDL_DestroyRenderer(ren);
    SDL_DestroyWindow(win);
    SDL_Quit();
    return 0;
}
SDL2 를 알던 사람이 놓치는 SDL3 변경점 — ① 성공하면 true(SDL2 는 0) · ② 이벤트 이름이 SDL_EVENT_* 로 통일 · ③ e.key.keysym.sym → e.key.key · ④ 좌표가 float 기본(SDL_FRect) · ⑤ SDL_RenderCopy → SDL_RenderTexture. 인터넷의 SDL2 예제를 그대로 쓰면 컴파일이 안 됩니다.

SDL3 의 새 구조 — 메인 콜백

// SDL3 는 while 루프 대신 콜백 네 개로 쓸 수도 있습니다.
// 웹(Emscripten)·모바일·iOS 에 그대로 이식되는 게 큰 장점입니다 ★
#define SDL_MAIN_USE_CALLBACKS 1
#include <SDL3/SDL.h>
#include <SDL3/SDL_main.h>

struct App { SDL_Window *win; SDL_Renderer *ren; SDL_FRect p; Uint64 prev; };

SDL_AppResult SDL_AppInit(void **state, int, char**) {
    SDL_Init(SDL_INIT_VIDEO);
    auto *a = new App{};
    SDL_CreateWindowAndRenderer("app", 960, 640, 0, &a->win, &a->ren);
    a->p = {100, 100, 48, 48};
    a->prev = SDL_GetTicksNS();
    *state = a;
    return SDL_APP_CONTINUE;
}

SDL_AppResult SDL_AppEvent(void *state, SDL_Event *e) {
    if (e->type == SDL_EVENT_QUIT) return SDL_APP_SUCCESS;   // 정상 종료
    return SDL_APP_CONTINUE;
}

SDL_AppResult SDL_AppIterate(void *state) {     // 한 프레임 ★
    auto *a = static_cast<App*>(state);
    SDL_SetRenderDrawColor(a->ren, 24, 28, 36, 255);
    SDL_RenderClear(a->ren);
    SDL_SetRenderDrawColor(a->ren, 96, 220, 160, 255);
    SDL_RenderFillRect(a->ren, &a->p);
    SDL_RenderPresent(a->ren);
    return SDL_APP_CONTINUE;
}

void SDL_AppQuit(void *state, SDL_AppResult) { delete static_cast<App*>(state); }
SDL3 가 주는 것들 — "창만" 이 아닙니다

  창 · 입력      키보드 · 마우스 · 게임패드 · 터치 · 센서
  렌더           2D 렌더러 (내부는 D3D11/12 · Metal · Vulkan · OpenGL)
  GPU API        SDL_gpu — 셰이더를 한 번 쓰면 3-OS 에서 도는 새 층 ★
  오디오         스트림 기반 재생·녹음
  파일 · 경로    SDL_GetPrefPath (OS 별 설정 폴더) · 비동기 IO
  스레드 · 타이머 · 클립보드 · 다이얼로그 · 카메라

  ★ 그래서 "게임 라이브러리"라기보다 "OS 추상화 층" 입니다.
     Valve · Unity · 수많은 에뮬레이터가 이 위에 서 있습니다.

raylib — 30줄이면 게임 하나 🟢 기초

완성된 미니 게임 — 그대로 도는 코드 ★

// vcpkg install raylib  /  find_package(raylib CONFIG REQUIRED)
#include "raylib.h"
#include <vector>
#include <cstdlib>

int main() {
    const int W = 900, H = 600;
    InitWindow(W, H, "떨어지는 상자 피하기");
    SetTargetFPS(60);                       // 이 한 줄이 델타·VSync 를 대신합니다

    Vector2 player{ W / 2.0f, H - 60.0f };
    std::vector<Rectangle> boxes;
    float spawn = 0, score = 0;
    bool  dead  = false;

    while (!WindowShouldClose()) {           // ESC 또는 창 닫기
        float dt = GetFrameTime();

        if (!dead) {
            // ── 갱신 ──────────────────────────────
            if (IsKeyDown(KEY_LEFT))  player.x -= 420 * dt;
            if (IsKeyDown(KEY_RIGHT)) player.x += 420 * dt;
            player.x = Clamp(player.x, 20, W - 20);

            spawn -= dt;
            if (spawn <= 0) {
                boxes.push_back({ (float)GetRandomValue(0, W - 40), -40, 40, 40 });
                spawn = 0.35f;
            }
            for (auto &b : boxes) b.y += 260 * dt;
            std::erase_if(boxes, [&](const Rectangle &b){ return b.y > H; });

            for (const auto &b : boxes)
                if (CheckCollisionCircleRec(player, 18, b)) dead = true;

            score += dt * 10;
        } else if (IsKeyPressed(KEY_SPACE)) {
            boxes.clear(); score = 0; dead = false; player.x = W / 2.0f;
        }

        // ── 그리기 ────────────────────────────────
        BeginDrawing();
            ClearBackground(Color{ 24, 28, 36, 255 });
            for (const auto &b : boxes) DrawRectangleRec(b, MAROON);
            DrawCircleV(player, 18, dead ? GRAY : LIME);
            DrawText(TextFormat("SCORE %04i", (int)score), 16, 16, 24, RAYWHITE);
            if (dead) DrawText("GAME OVER - SPACE", W/2 - 190, H/2 - 20, 34, RED);
        EndDrawing();
    }

    CloseWindow();
    return 0;
}
raylib 이 배우기 좋은 이유 — 함수 이름이 하고 싶은 말 그대로입니다 (DrawCircleV, IsKeyDown, CheckCollisionCircleRec). 초기화·컨텍스트·핸들 같은 게 안 보이고, 전역 상태로 다 숨겨져 있습니다. 그게 큰 프로젝트에서는 단점이 되지만, 배우거나 프로토타입을 만들 때는 압도적입니다.

SDL3 냐 raylib 이냐

SDL3raylib
성격OS 추상화 층배우기용 프레임워크
배우는 시간며칠한두 시간
제어 수준세밀함정해진 길
상용 게임 사용많음드묾(인디는 있음)
3D직접(또는 SDL_gpu)기본 제공
오디오·폰트기본만편의 함수 풍부
웹(WASM) 이식공식 지원지원
C++ 스타일C APIC API
실전 선택 기준

  게임을 처음 만들어 본다              → raylib
  게임 잼 · 프로토타입 · 교육           → raylib
  실제로 스팀에 낼 생각                → SDL3
  에뮬레이터 · 미디어 플레이어 · 도구  → SDL3
  이미 ImGui 를 쓰고 있다              → SDL3 (백엔드가 있음)
  엔진을 직접 만든다                   → SDL3 (+ SDL_gpu 또는 bgfx)
  3D 게임을 제대로                     → 엔진을 쓰세요 ★
     Godot(C++ 확장 가능) · Unreal(C++ 이 본체) · O3DE
     "SDL3 로 3D 엔진을 처음부터"는 몇 년짜리 프로젝트입니다
화면을 직접 그린다는 건 전부 직접 한다는 뜻입니다 — 버튼, 스크롤, 텍스트 입력, 한글 IME, 파일 대화상자, 접근성, 고해상도 배율, 다국어 폰트… Qt 가 공짜로 주던 것이 전부 내 일이 됩니다. 그래서 실무에서는 SDL3 + Dear ImGui 조합이 사실상 표준입니다 — SDL3 가 창과 입력, ImGui 가 UI 를 맡습니다.

한 단계 더 — 텍스처 · 오디오 · 게임패드 🟡 중급

SDL3 로 이미지와 소리 넣기

// 이미지 — SDL_image 로 로드해서 텍스처로 (vcpkg: sdl3-image)
#include <SDL3_image/SDL_image.h>
SDL_Texture *tex = IMG_LoadTexture(ren, "assets/player.png");
SDL_SetTextureScaleMode(tex, SDL_SCALEMODE_NEAREST);   // 픽셀아트면 NEAREST ★

SDL_FRect src{ 0, 0, 32, 32 };          // 스프라이트 시트에서 잘라낼 영역
SDL_FRect dst{ x, y, 64, 64 };          // 화면에 그릴 위치·크기
SDL_RenderTexture(ren, tex, &src, &dst);
// 회전·뒤집기까지
SDL_RenderTextureRotated(ren, tex, &src, &dst, angle, nullptr, SDL_FLIP_NONE);

// 오디오 — SDL3 는 "스트림" 하나로 통일됐습니다
SDL_AudioSpec spec{ SDL_AUDIO_F32, 2, 48000 };
SDL_AudioStream *stream =
    SDL_OpenAudioDeviceStream(SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK, &spec,
                              nullptr, nullptr);
SDL_ResumeAudioStreamDevice(stream);
SDL_PutAudioStreamData(stream, pcmBuffer, byteCount);   // 밀어 넣기만 하면 됩니다

// 게임패드 — 연결/해제가 이벤트로 옵니다
case SDL_EVENT_GAMEPAD_ADDED:
    pad = SDL_OpenGamepad(e.gdevice.which); break;
float lx = SDL_GetGamepadAxis(pad, SDL_GAMEPAD_AXIS_LEFTX) / 32767.0f;
if (SDL_GetGamepadButton(pad, SDL_GAMEPAD_BUTTON_SOUTH)) jump();
SDL_RumbleGamepad(pad, 0xFFFF, 0x8000, 200);            // 진동 200ms
고해상도(HiDPI) 처리 — 창 크기(SDL_GetWindowSize)와 실제 픽셀 수(SDL_GetWindowSizeInPixels)가 다를 수 있습니다. 맥의 레티나나 윈도우 배율 150%에서 그림이 흐려지거나 마우스 좌표가 어긋나면 둘을 섞어 쓴 것입니다. SDL_SetRenderLogicalPresentation 으로 논리 해상도를 고정해 두면 대부분 해결됩니다.

누산기 루프 — 붙여 쓰는 형태

// 물리가 중요한 게임이라면 이 형태로 바꿉니다
constexpr double STEP = 1.0 / 120.0;      // 물리는 초당 120회 고정

double accumulator = 0.0;
Uint64 prev = SDL_GetTicksNS();

while (running) {
    Uint64 now = SDL_GetTicksNS();
    double frame = double(now - prev) / 1e9;
    prev = now;
    if (frame > 0.25) frame = 0.25;       // 스파이럴 오브 데스 방지 ★
    accumulator += frame;

    pollInput();

    while (accumulator >= STEP) {         // 밀린 만큼 여러 번 돕니다
        previousState = currentState;     // 보간용으로 직전 상태 보관
        physicsStep(currentState, STEP);  // 항상 같은 dt — 결과가 결정적입니다
        accumulator -= STEP;
    }

    const double alpha = accumulator / STEP;          // 0.0 ~ 1.0
    auto view = lerp(previousState, currentState, alpha);   // 부드럽게
    render(view);
}
왜 고정 스텝이 필요한가 — 세 가지 이유

  ① 터널링   dt 가 크면 총알이 벽을 그냥 통과합니다
             (한 프레임에 벽 두께보다 많이 이동)
  ② 결정성   같은 입력 → 항상 같은 결과. 리플레이·네트워크 동기화의 전제
             (게임 서버 얘기는 🎮 게임 서버 탭에서 이어집니다)
  ③ 안정성   스프링·마찰 같은 수치 적분은 dt 가 흔들리면 발산합니다

  "스파이럴 오브 데스" — 프레임이 느려지면 물리를 더 많이 돌리고,
  그래서 더 느려지고, 다시 더 많이 돌리는 악순환.
  frame 을 0.25초로 자르는 한 줄이 이걸 끊습니다 ★
이 루프는 게임만의 것이 아닙니다 — 로봇 제어, 시뮬레이션, 신호 처리, 실시간 차트 렌더링이 전부 같은 구조를 씁니다. “일정한 간격으로 계산하고, 화면은 남는 시간에 그린다”는 패턴 자체가 자산입니다.
정리 — SDL3 는 OS 를 감싸는 얇은 층이고 raylib 은 배우기 쉬운 포장입니다. 둘 다 UI 위젯을 주지 않으므로, 도구를 만든다면 SDL3 + Dear ImGui 조합으로 가세요.
APP 09

wxWidgets — 진짜 OS 위젯을 쓰는 유일한 선택

Qt 는 버튼을 직접 그립니다. 아주 비슷하게 그리지만 OS 가 만든 버튼은 아닙니다. wxWidgets 는 OS 의 진짜 버튼을 그대로 부릅니다. 그래서 윈도우에서는 윈도우 앱처럼, 맥에서는 맥 앱처럼 보이고, 라이선스가 가장 자유롭습니다. 어떤 상황에서 이게 결정적인지 봅니다.

화면을 누가 그리나 — Qt 는 직접, wx 는 OS 에게 시킵니다 Qt — 직접 그림 (에뮬레이션) wxWidgets — OS 컨트롤 그대로 내 코드: QPushButton 내 코드: wxButton QStyle · Qt 렌더 엔진 테두리·그림자를 Qt 가 계산합니다 얇은 껍데기 — 아무것도 안 그립니다 OS 컨트롤을 만들어 감쌀 뿐입니다 Direct2D · CoreGraphics · XCB 픽셀을 Qt 가 찍습니다 — QSS 로 자유 변형 가능 Win32 BUTTON · NSButton · GtkButton OS 가 만든 진짜 컨트롤 — 픽셀 하나까지 네이티브 wx 의 이득 — OS 가 디자인을 바꾸면 앱도 저절로 따라갑니다 · 접근성·IME·고해상도가 공짜 wx 의 값 — 플랫폼마다 미세한 동작 차이가 남습니다 · 커스텀 디자인은 사실상 불가
“우리 앱만 이상하게 생겼다”는 말을 듣지 않는 가장 확실한 방법이 wx 입니다.

설치와 첫 프레임 ★

# 설치 — vcpkg 가 가장 편합니다
vcpkg install wxwidgets
#   macOS:  brew install wxwidgets
#   Ubuntu: sudo apt install libwxgtk3.2-dev

# 소스 빌드도 어렵지 않습니다 (CMake 지원이 좋습니다)
git clone --recurse-submodules https://github.com/wxWidgets/wxWidgets.git
cmake -B build -S wxWidgets -DwxBUILD_SHARED=OFF
cmake --build build --config Release
cmake_minimum_required(VERSION 3.21)
project(wxapp LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 20)

find_package(wxWidgets CONFIG REQUIRED COMPONENTS core base)

add_executable(wxapp WIN32 MACOSX_BUNDLE main.cpp)
target_link_libraries(wxapp PRIVATE wx::core wx::base)
// main.cpp — 이게 전체입니다
#include <wx/wx.h>

class MyFrame : public wxFrame {
public:
    MyFrame() : wxFrame(nullptr, wxID_ANY, "wx 첫 창", wxDefaultPosition, {640, 420}) {
        // ── 메뉴 (맥에서는 자동으로 화면 위 메뉴바로 갑니다) ★
        auto *file = new wxMenu;
        file->Append(wxID_OPEN, "열기\tCtrl-O");
        file->AppendSeparator();
        file->Append(wxID_EXIT);            // 맥에선 자동으로 앱 메뉴로 이동
        auto *bar = new wxMenuBar;
        bar->Append(file, "파일(&F)");
        SetMenuBar(bar);
        CreateStatusBar();
        SetStatusText("준비됨");

        // ── 내용 ─────────────────────────────────
        auto *panel = new wxPanel(this);
        m_input = new wxTextCtrl(panel, wxID_ANY, "",
                                 wxDefaultPosition, wxDefaultSize,
                                 wxTE_PROCESS_ENTER);
        auto *btn = new wxButton(panel, wxID_ANY, "추가");
        m_list = new wxListBox(panel, wxID_ANY);

        auto *row = new wxBoxSizer(wxHORIZONTAL);
        row->Add(m_input, wxSizerFlags(1).Expand().Border(wxALL, 4));
        row->Add(btn,     wxSizerFlags(0).Border(wxALL, 4));

        auto *col = new wxBoxSizer(wxVERTICAL);
        col->Add(row,     wxSizerFlags(0).Expand());
        col->Add(m_list,  wxSizerFlags(1).Expand().Border(wxALL, 4));
        panel->SetSizer(col);

        // ── 이벤트 연결 — 현대식 Bind (람다도 됩니다) ★
        btn->Bind(wxEVT_BUTTON,          &MyFrame::OnAdd, this);
        m_input->Bind(wxEVT_TEXT_ENTER,  &MyFrame::OnAdd, this);
        Bind(wxEVT_MENU, [this](wxCommandEvent&){ Close(true); }, wxID_EXIT);
    }
private:
    void OnAdd(wxCommandEvent&) {
        wxString t = m_input->GetValue().Trim();
        if (t.empty()) return;
        m_list->Append(t);
        m_input->Clear();
        SetStatusText(wxString::Format("%zu개", m_list->GetCount()));
    }
    wxTextCtrl *m_input;
    wxListBox  *m_list;
};

class MyApp : public wxApp {
public:
    bool OnInit() override { (new MyFrame)->Show(); return true; }
};
wxIMPLEMENT_APP(MyApp);      // main() 을 대신 만들어 줍니다

Qt 를 아는 사람을 위한 대응표

QtwxWidgets
QApplicationwxApp + wxIMPLEMENT_APP
QMainWindowwxFrame
QWidgetwxWindow / wxPanel
QVBoxLayoutwxBoxSizer(wxVERTICAL)
QGridLayoutwxFlexGridSizer
connect(…)Bind(wxEVT_…, …)
QStringwxString (문자열 변환 주의)
QTableView + 모델wxDataViewCtrl + wxDataViewModel
QThreadwxThread
QSettingswxConfig
QFileDialogwxFileDialog
Qt DesignerwxFormBuilder · wxCrafter
tr("…")_("…") (gettext)
개념이 거의 1:1 로 대응됩니다 — 둘 다 1990년대 후반의 같은 설계 흐름에서 나왔기 때문입니다. Qt 를 아는 사람이 wx 로 옮기는 데 걸리는 시간은 이틀 정도고, 걸리는 부분은 대개 문자열 타입과 sizer 의 플래그 조합입니다.

이벤트 처리 — 옛 문법과 새 문법 🟡 중급

이벤트 테이블 vs Bind

// ── 옛 방식: 이벤트 테이블 (인터넷 예제 대부분이 이겁니다) ──
class MyFrame : public wxFrame {
    void OnQuit(wxCommandEvent&);
    void OnAbout(wxCommandEvent&);
    wxDECLARE_EVENT_TABLE();
};
wxBEGIN_EVENT_TABLE(MyFrame, wxFrame)
    EVT_MENU(wxID_EXIT,  MyFrame::OnQuit)
    EVT_MENU(wxID_ABOUT, MyFrame::OnAbout)
wxEND_EVENT_TABLE()
//   장점: 한눈에 목록이 보임
//   단점: 컴파일 시간에 고정 · 람다 불가 · 동적 연결 불가

// ── 새 방식: Bind (wx 2.9+, 지금의 권장) ★ ──
btn->Bind(wxEVT_BUTTON, &MyFrame::OnAdd, this);
btn->Bind(wxEVT_BUTTON, [this](wxCommandEvent &e){ ... });   // 람다 OK
Bind(wxEVT_MENU, &MyFrame::OnQuit, this, wxID_EXIT);        // ID 로 거르기

// 필요하면 끊을 수도 있습니다
btn->Unbind(wxEVT_BUTTON, &MyFrame::OnAdd, this);

// ── 이벤트 전파 — Qt 와 다른 중요한 점 ★ ──
void MyFrame::OnKey(wxKeyEvent &e) {
    if (e.GetKeyCode() == WXK_ESCAPE) { Close(); return; }
    e.Skip();      // ★ 처리 안 했으면 반드시 Skip() — 부모로 넘깁니다
}
//   Skip() 을 빠뜨리면 그 이벤트가 여기서 삼켜져
//   기본 동작(글자 입력 등)이 사라집니다. wx 초보 사고 1위입니다
e.Skip() 이 Qt 의 event->ignore() 자리입니다. Qt 는 기본이 “처리 안 함”이라 accept() 를 불러야 삼켜지지만, wx 는 기본이 “처리했음”이라 Skip() 을 안 부르면 삼켜집니다. 방향이 반대라 Qt 에서 온 사람이 반드시 한 번은 걸립니다.

기존 C++ 코드베이스와 붙이기

// ① MFC 앱을 wx 로 옮기는 중이라면 — 창 핸들로 이어 붙일 수 있습니다
#ifdef __WXMSW__
HWND hwnd = (HWND)panel->GetHandle();      // wx 창의 진짜 Win32 핸들
// 이 HWND 에 기존 MFC/Win32 자식 창을 그대로 붙입니다
::SetParent(existingMfcHwnd, hwnd);
#endif

// ② 반대로 wx 위젯을 네이티브 컨트롤 위에 얹기
wxWindow *w = new wxWindow();
w->AssociateHandle((WXWidget)someHwnd);

// ③ 스레드에서 UI 를 만지려면 — Qt 의 시그널 자리
void Worker::Entry() {
    for (...) {
        // ✗ frame->SetStatusText(...)  — 다른 스레드에서 금지
        // ○ 메인 스레드에 일감을 던집니다
        frame->CallAfter([frame, n]{ frame->SetStatusText(wxString::Format("%d", n)); });
    }
}
//   또는 wxQueueEvent(frame, new wxThreadEvent(...)) 로 이벤트를 보냅니다

// ④ 문자열 변환 — 여기서 한글이 깨지면 대부분 이 문제입니다 ★
std::string  s  = "한글";
wxString     ws = wxString::FromUTF8(s);          // ○ 인코딩을 명시
std::string  back = ws.ToStdString(wxConvUTF8);   // ○
// wxString(s.c_str())  ← ✗ 현재 로캘로 해석해 깨질 수 있습니다
이게 wx 를 고르는 가장 현실적인 이유입니다 — 20년 된 MFC/Win32 코드베이스를 한 번에 다 못 옮길 때, 창 핸들 수준에서 섞을 수 있다는 게 결정적입니다. Qt 도 QWidget::winId() 로 비슷하게 되지만, wx 쪽이 애초에 그런 결합을 전제로 설계돼 있어 훨씬 덜 아픕니다.

고를 이유와 피할 이유 — 정직하게 🔴 실무

wx 를 고르는 세 가지 상황

① 라이선스가 결정적일 때 ★
   wxWindows Licence = LGPL + "바이너리 배포에 조건을 걸지 않는다"
   → 정적 링크해서 소스 공개 없이 상용 배포가 명시적으로 허용됩니다
   → Qt 로 같은 걸 하려면 상용 라이선스(연 수백만 원대)가 필요합니다
   → 실행 파일 하나로 배포해야 하는 상용 도구에 결정적입니다

② 진짜 네이티브 룩이 요구될 때
   관공서·금융·산업 장비 소프트웨어의 심사 항목에
   "OS 표준 UI 준수" 가 들어 있는 경우가 실제로 있습니다
   Qt 는 "매우 비슷하게" 그리지만 같지는 않습니다

③ 기존 Win32/MFC 자산과 섞어야 할 때
   창 핸들 수준 결합이 필요하면 wx 가 가장 덜 아픕니다

── 참고: wx 로 만들어진 실제 소프트웨어 ──
   Audacity(오디오 편집) · Code::Blocks(IDE)
   FileZilla(FTP) · Kicad(전자 CAD) · Amaya
   전부 "오래 유지되는 도구"라는 공통점이 있습니다
wxWindows Licence 를 한 줄로 — “라이브러리 자체를 고쳤으면 그 부분은 공개하되, 내 앱의 바이너리 배포에는 어떤 조건도 걸지 않는다”. LGPL 의 동적 링크 요구가 없으므로 정적 링크한 단일 exe 상용 배포가 가능합니다. 이 한 줄 때문에 wx 를 고르는 회사가 지금도 있습니다.

피해야 할 상황과 현실

항목QtwxWidgets
커뮤니티 크기매우 큼작음
한국어 자료많음거의 없음
구인 시장존재함거의 없음
선언형 UIQML없음
모바일안드로이드·iOS실질적으로 없음
커스텀 디자인QSS·QML매우 어려움
부가 모듈네트워크·DB·차트…있지만 얇음
플랫폼 동작 차이적음있음 — 3-OS 테스트 필수
라이선스 자유도LGPL/상용가장 자유
배포 크기(정적)제한적10~20MB 단일 exe
“플랫폼 동작 차이”를 가볍게 보지 마세요 — 진짜 OS 컨트롤을 쓴다는 건 OS 마다 다르게 동작한다는 뜻이기도 합니다. 리스트의 다중 선택 방식, 탭 순서, 기본 여백, 리사이즈 타이밍이 미묘하게 다릅니다. 3-OS 를 지원한다면 3-OS 에서 실제로 눌러 봐야 합니다 — Qt 는 이 부담이 훨씬 적습니다. 2026년에 새 프로젝트를 시작한다면 기본값은 여전히 Qt 이고, wx 는 위의 세 가지 이유 중 하나가 확실할 때의 선택입니다.
정리 — wxWidgets 는 OS 의 진짜 컨트롤 + 가장 자유로운 라이선스가 전부이자 핵심입니다. 정적 링크한 단일 exe 를 상용으로 팔아야 하거나, 옛 Win32 코드와 섞어야 할 때 고르세요.
APP 10

Win32 · WinUI 3 — 트레이 유틸리티와 윈도우 전용 기능

백그라운드에 조용히 떠 있다가 트레이 아이콘으로만 존재를 알리는 프로그램 — 이런 건 Qt 를 통째로 얹는 게 과합니다. Win32 만으로 200KB 짜리 실행 파일을 만들 수 있고, 이 절의 메시지 루프는 모든 윈도우 GUI 의 바닥이라 알아 두면 Qt 디버깅에도 쓰입니다.

윈도우 메시지 루프 — 트레이 아이콘도 이 위에서 돕니다 운영체제 클릭 · 키 · 타이머 스레드 메시지 큐 프로그램마다 하나씩 while (GetMessage(...)) WinMain 안의 무한 루프 DispatchMessage WndProc(hwnd, msg, w, l) 거대한 switch 문 안 쓰는 건 DefWindowProc 으로 WM_PAINT · WM_COMMAND WM_DESTROY · WM_SIZE WM_APP+1 (내가 정한 트레이용) 내 처리 코드 메뉴 띄우기 창 보이기·숨기기 트레이 등록 — Shell_NotifyIcon(NIM_ADD, ...) 이때 uCallbackMessage = WM_APP+1 을 알려 줍니다 클릭이 같은 큐로 들어옵니다 lParam 이 WM_RBUTTONUP 이면 메뉴를 띄웁니다 Qt 의 app.exec() 은 내부적으로 정확히 이 루프를 돌립니다 — QAbstractEventDispatcher 가 감쌀 뿐입니다 그래서 — WndProc 안에서 오래 걸리는 일을 하면 트레이 아이콘도 메뉴도 전부 멈춥니다 (Qt 와 같은 이유)
Qt 든 wx 든 윈도우에서는 결국 이 루프 하나 위에서 돌고 있습니다.

최소 Win32 창 — 의존성 0 ★

// cl /std:c++20 /EHsc main.cpp user32.lib gdi32.lib  ← 이걸로 빌드됩니다
#include <windows.h>

LRESULT CALLBACK WndProc(HWND h, UINT m, WPARAM w, LPARAM l) {
    switch (m) {
        case WM_CREATE:
            CreateWindowW(L"BUTTON", L"눌러 보세요",
                          WS_CHILD | WS_VISIBLE | BS_PUSHBUTTON,
                          20, 20, 160, 40, h, (HMENU)1001, nullptr, nullptr);
            return 0;

        case WM_COMMAND:                       // 자식 컨트롤이 보낸 알림
            if (LOWORD(w) == 1001)
                MessageBoxW(h, L"안녕하세요", L"알림", MB_OK);
            return 0;

        case WM_PAINT: {
            PAINTSTRUCT ps;
            HDC dc = BeginPaint(h, &ps);
            TextOutW(dc, 20, 80, L"직접 그린 글자", 8);
            EndPaint(h, &ps);
            return 0;
        }

        case WM_DESTROY:
            PostQuitMessage(0);                // GetMessage 가 0 을 돌려주게 함
            return 0;
    }
    return DefWindowProcW(h, m, w, l);         // ★ 나머지는 기본 동작에 맡깁니다
}

int WINAPI wWinMain(HINSTANCE hi, HINSTANCE, PWSTR, int show) {
    // 고해상도 대응 — 안 하면 4K 에서 글자가 흐려집니다 ★
    SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2);

    WNDCLASSW wc{};
    wc.lpfnWndProc   = WndProc;
    wc.hInstance     = hi;
    wc.lpszClassName = L"MyWinClass";
    wc.hCursor       = LoadCursor(nullptr, IDC_ARROW);
    wc.hbrBackground = (HBRUSH)(COLOR_WINDOW + 1);
    RegisterClassW(&wc);

    HWND hwnd = CreateWindowExW(0, L"MyWinClass", L"Win32 첫 창",
                                WS_OVERLAPPEDWINDOW,
                                CW_USEDEFAULT, CW_USEDEFAULT, 480, 320,
                                nullptr, nullptr, hi, nullptr);
    ShowWindow(hwnd, show);

    MSG msg;
    while (GetMessageW(&msg, nullptr, 0, 0)) {   // ★ 이게 이벤트 루프입니다
        TranslateMessage(&msg);                  // 키 입력을 WM_CHAR 로도
        DispatchMessageW(&msg);                  // WndProc 호출
    }
    return (int)msg.wParam;
}
왜 W 가 붙나 — CreateWindowW, MessageBoxW 처럼 W 는 유니코드(UTF-16), A 는 옛 ANSI 버전입니다. 항상 W 버전과 L"…" 문자열을 쓰세요. A 버전을 쓰면 한글이 현재 시스템 코드페이지에 의존해 다른 나라 PC 에서 깨집니다.

시스템 트레이 아이콘 ★

#include <shellapi.h>
#pragma comment(lib, "shell32.lib")

constexpr UINT WM_TRAY = WM_APP + 1;      // 내가 정하는 메시지 번호
NOTIFYICONDATAW g_nid{};

void addTrayIcon(HWND h, HINSTANCE hi) {
    g_nid.cbSize = sizeof(g_nid);
    g_nid.hWnd   = h;
    g_nid.uID    = 1;
    g_nid.uFlags = NIF_ICON | NIF_MESSAGE | NIF_TIP;
    g_nid.uCallbackMessage = WM_TRAY;      // ★ 클릭하면 이 메시지가 옵니다
    g_nid.hIcon  = LoadIconW(hi, MAKEINTRESOURCEW(IDI_APP));
    wcscpy_s(g_nid.szTip, L"내 도구 — 실행 중");
    Shell_NotifyIconW(NIM_ADD, &g_nid);
}

void showBalloon(const wchar_t *title, const wchar_t *text) {
    g_nid.uFlags = NIF_INFO;
    wcscpy_s(g_nid.szInfoTitle, title);
    wcscpy_s(g_nid.szInfo, text);
    g_nid.dwInfoFlags = NIIF_INFO;
    Shell_NotifyIconW(NIM_MODIFY, &g_nid);   // 풍선 알림
}

// WndProc 안에 추가
case WM_TRAY:
    if (LOWORD(l) == WM_RBUTTONUP) {         // 우클릭 → 메뉴
        POINT p; GetCursorPos(&p);
        HMENU menu = CreatePopupMenu();
        AppendMenuW(menu, MF_STRING, 2001, L"창 열기");
        AppendMenuW(menu, MF_SEPARATOR, 0, nullptr);
        AppendMenuW(menu, MF_STRING, 2002, L"종료");
        SetForegroundWindow(h);              // ★ 없으면 메뉴가 안 닫힙니다
        TrackPopupMenu(menu, TPM_RIGHTBUTTON, p.x, p.y, 0, h, nullptr);
        DestroyMenu(menu);
    } else if (LOWORD(l) == WM_LBUTTONDBLCLK) {
        ShowWindow(h, SW_RESTORE); SetForegroundWindow(h);
    }
    return 0;

case WM_CLOSE:                                // X 를 눌러도 종료하지 않고 숨기기
    ShowWindow(h, SW_HIDE);
    return 0;

case WM_DESTROY:
    Shell_NotifyIconW(NIM_DELETE, &g_nid);    // ★ 안 지우면 유령 아이콘이 남습니다
    PostQuitMessage(0);
    return 0;
// Qt 로도 트레이는 열 줄이면 됩니다 — 가볍게 갈 이유가 없다면 이쪽
auto *tray = new QSystemTrayIcon(QIcon(":/app.png"), this);
auto *menu = new QMenu(this);
menu->addAction("창 열기", this, &QWidget::showNormal);
menu->addSeparator();
menu->addAction("종료", qApp, &QApplication::quit);
tray->setContextMenu(menu);
tray->setToolTip("내 도구 — 실행 중");
tray->show();
connect(tray, &QSystemTrayIcon::activated, this, [this](auto reason){
    if (reason == QSystemTrayIcon::DoubleClick) showNormal();
});
tray->showMessage("완료", "작업이 끝났습니다", QSystemTrayIcon::Information, 3000);
// ★ 3-OS 에서 다 돕니다. 실행 파일 크기만 신경 안 쓰면 Qt 가 훨씬 쉽습니다

윈도우 전용 실무 — 자동 시작 · 중복 실행 방지 🟡 중급

자동 시작 세 가지 방법

// ① 레지스트리 Run 키 — 가장 간단, 관리자 권한 불필요 ★
void setAutoStart(bool on) {
    HKEY key;
    RegOpenKeyExW(HKEY_CURRENT_USER,
        L"Software\\Microsoft\\Windows\\CurrentVersion\\Run",
        0, KEY_SET_VALUE, &key);
    if (on) {
        wchar_t path[MAX_PATH];
        GetModuleFileNameW(nullptr, path, MAX_PATH);
        std::wstring quoted = L"\"" + std::wstring(path) + L"\" --minimized";
        RegSetValueExW(key, L"MyTool", 0, REG_SZ,
                       (const BYTE*)quoted.c_str(),
                       DWORD((quoted.size() + 1) * sizeof(wchar_t)));
    } else {
        RegDeleteValueW(key, L"MyTool");
    }
    RegCloseKey(key);
}
//   ★ 경로에 공백이 있으므로 반드시 따옴표로 감싸세요
//   ★ HKEY_LOCAL_MACHINE 에 쓰면 모든 사용자 — 관리자 권한 필요

// Qt 로 같은 일
QSettings run("HKEY_CURRENT_USER\\Software\\Microsoft\\Windows\\"
              "CurrentVersion\\Run", QSettings::NativeFormat);
run.setValue("MyTool", '"' + QDir::toNativeSeparators(
                  QCoreApplication::applicationFilePath()) + '"');

// ② 시작 프로그램 폴더에 바로 가기 — 사용자가 눈으로 확인 가능
//    shell:startup 폴더에 .lnk 를 만듭니다 (IShellLink COM)

// ③ 작업 스케줄러 — 관리자 권한으로 시작하거나 조건이 필요할 때
//    schtasks /create /tn "MyTool" /tr "\"C:\...\app.exe\"" /sc onlogon /rl highest
자동 시작은 사용자 동의를 받으세요 — 몰래 등록하면 백신이 PUP(원치 않는 프로그램) 으로 잡습니다. 설정 화면에 체크박스를 두고, 제거할 때 반드시 지우세요. 그리고 자동 시작 시에는 --minimized 같은 인자로 창을 띄우지 않게 하는 게 관례입니다.

중복 실행 방지와 파일 연결

// 이미 떠 있으면 그 창을 앞으로 가져오고 나는 종료 ★
int wWinMain(...) {
    HANDLE mtx = CreateMutexW(nullptr, TRUE, L"Global\\MyTool_SingleInstance");
    if (GetLastError() == ERROR_ALREADY_EXISTS) {
        HWND prev = FindWindowW(L"MyWinClass", nullptr);
        if (prev) { ShowWindow(prev, SW_RESTORE); SetForegroundWindow(prev); }
        return 0;
    }
    // ... 정상 실행 ...
    ReleaseMutex(mtx); CloseHandle(mtx);
}

// Qt 로는 QLocalServer/QLocalSocket 을 씁니다 (3-OS 공통)
QLocalSocket probe;
probe.connectToServer("MyTool");
if (probe.waitForConnected(200)) {           // 이미 떠 있음
    probe.write(QCoreApplication::arguments().join('\n').toUtf8());
    probe.waitForBytesWritten(500);
    return 0;                                 // 인자만 넘기고 종료
}
auto *server = new QLocalServer;
QLocalServer::removeServer("MyTool");         // 비정상 종료 잔재 정리 ★
server->listen("MyTool");
; 파일 연결 — 설치기에서 레지스트리에 넣는 게 정석입니다
; HKEY_CURRENT_USER\Software\Classes\.log\OpenWithProgids
;   "Acme.LogViewer" = ""
; HKEY_CURRENT_USER\Software\Classes\Acme.LogViewer
;   (기본값) = "로그 파일"
; HKEY_CURRENT_USER\Software\Classes\Acme.LogViewer\DefaultIcon
;   (기본값) = "C:\Program Files\LogViewer\app.exe,0"
; HKEY_CURRENT_USER\Software\Classes\Acme.LogViewer\shell\open\command
;   (기본값) = "\"C:\Program Files\LogViewer\app.exe\" \"%1\""
;
; ★ %1 이 사용자가 더블클릭한 파일 경로로 치환됩니다
; ★ HKCU 에 쓰면 관리자 권한 없이 됩니다 (요즘 권장)
; ★ 기본 앱으로 강제 지정은 윈도우 10 이후 불가 — 설정 화면으로 안내만 가능

WinUI 3 와 C++/WinRT — 현대 윈도우 UI 🔴 실무

C++/WinRT 맛보기

// C++/WinRT — 마이크로소프트의 현대 API 를 C++20 스타일로 부릅니다
// vcpkg install cppwinrt   (또는 Windows SDK 에 포함된 cppwinrt.exe)
#include <winrt/Windows.Foundation.h>
#include <winrt/Windows.Storage.h>
#include <winrt/Windows.UI.Notifications.h>

using namespace winrt;
using namespace Windows::Storage;

// ① 코루틴으로 비동기 API 를 동기처럼 씁니다 ★
IAsyncAction readFileAsync() {
    StorageFolder folder = KnownFolders::DocumentsLibrary();
    StorageFile file = co_await folder.GetFileAsync(L"note.txt");
    hstring text = co_await FileIO::ReadTextAsync(file);
    OutputDebugStringW(text.c_str());
}

// ② 토스트 알림 — 윈도우 10/11 의 진짜 알림 센터에 뜹니다
void toast(std::wstring_view title, std::wstring_view body) {
    using namespace Windows::UI::Notifications;
    using namespace Windows::Data::Xml::Dom;

    XmlDocument doc = ToastNotificationManager::GetTemplateContent(
                          ToastTemplateType::ToastText02);
    auto nodes = doc.GetElementsByTagName(L"text");
    nodes.GetAt(0).AppendChild(doc.CreateTextNode(title));
    nodes.GetAt(1).AppendChild(doc.CreateTextNode(body));

    ToastNotificationManager::CreateToastNotifier(L"Acme.MyTool")
        .Show(ToastNotification(doc));
}
//   ★ 앱이 시작 메뉴에 AUMID 로 등록돼 있어야 알림이 뜹니다

// ③ 자원 관리는 자동입니다 — COM 참조 계수를 winrt 가 처리합니다
//    HRESULT 검사 · Release() 호출 · IUnknown 캐스팅이 전부 사라집니다
//    실패하면 winrt::hresult_error 예외가 날아옵니다
C++/WinRT 는 “UI 툴킷”이 아닙니다 — 알림, 파일 피커, 블루투스, 카메라, 지오로케이션 같은 현대 윈도우 API 를 부르는 방법입니다. 그래서 Qt 앱 안에서도 그대로 쓸 수 있습니다 — “Qt 로 만들었는데 윈도우 토스트 알림을 쓰고 싶다”가 아주 흔한 조합입니다.

WinUI 3 — 고를 만한가

WinUI 3 (C++)Qt
윈도우 11 디자인완벽 (Mica·Fluent)흉내만
다른 OS불가3-OS
UI 정의XAMLQML 또는 코드
C++ 지원있지만 C# 이 1급C++ 가 본체
문서·예제대부분 C#C++ 중심
배포Windows App SDK 필요DLL 복사
학습 자료(한글)적음많음
2026년 현실적인 판단

  · 윈도우 전용 + C# 팀        → WinUI 3 (또는 WPF · Avalonia)
  · 윈도우 전용 + C++ 팀       → 대부분 Qt 가 여전히 낫습니다
  · 3-OS 지원                  → Qt (선택의 여지 없음)
  · 트레이 유틸 · 초경량       → 순수 Win32 (실행 파일 200KB)
  · 기존 MFC 유지보수          → wxWidgets 또는 MFC 그대로

  ★ WinUI 3 의 C++ 경로는 문서와 예제가 C# 에 비해 눈에 띄게
    얇습니다. XAML + C++/WinRT 조합은 빌드 시간도 길고
    오류 메시지도 험합니다. "윈도우 11 다운 디자인"이
    제품 요구사항일 때만 감당할 값어치가 있습니다.
Qt 앱에 윈도우 11 감성만 입히고 싶다면 — WinUI 로 갈아탈 필요 없이 DwmSetWindowAttribute 로 Mica 배경 · 둥근 모서리 · 다크 타이틀바를 켤 수 있습니다. QWidget::winId() 로 HWND 를 얻어 호출하면 되고, 열 줄이면 “윈도우 11 앱처럼 보이는 Qt 앱”이 됩니다.
정리 — 윈도우 GUI 의 바닥은 메시지 큐 + WndProc 하나이고, 트레이 유틸리티라면 순수 Win32 로 200KB, 그 밖에는 Qt + 필요할 때만 C++/WinRT 가 현실적인 조합입니다.
APP 11

성능과 구조 — 수백만 행을 부드럽게 보여 주기

데스크톱 앱이 느려지는 자리는 거의 정해져 있습니다 — UI 스레드에서 뭔가를 오래 하거나, 화면 밖의 것까지 만들거나, 한 셀에 문자열을 매번 새로 만드는 것. 여기서는 그 셋을 구조로 막는 법과, 느린 곳을 찾는 순서를 정리합니다.

모델/뷰 — 뷰는 "보이는 것만" 물어봅니다 원본 데이터 1,000,000 행 std::vector 또는 mmap 200~400MB 모델 (어댑터) data(index, role) 물어볼 때만 값을 만듭니다 rowCount 는 그냥 숫자 뷰 + 델리게이트 보이는 40행만 스크롤하면 다시 물어봄 delegate 가 셀 하나를 그림 결과 — 100만 행이어도 data() 는 한 화면에 약 120번만 불립니다 (40행 × 3열) 반대로 — 셀마다 객체를 만드는 방식 QTableWidget · QListWidget 셀 하나마다 QTableWidgetItem 객체 하나 100만 행 × 3열 = 300만 객체 · 수 GB 델리게이트 안에서 매번 문자열 생성 QString::number · arg() 가 초당 수천 번 스크롤할 때만 끊기는 전형적 증상 데이터가 아직 없을 때 — 스크롤에 맞춰 읽어 오기 스크롤 끝 도달 뷰가 감지 canFetchMore() → true 더 있는지 모델에게 질문 fetchMore() — 5,000행 추가 beginInsertRows / endInsertRows
큰 표의 비밀은 단순합니다 — “보이지 않는 것은 만들지 않는다.”

100만 행 모델 — 실제로 도는 형태 ★

// ① 데이터를 "행 객체 배열"이 아니라 "열별 배열"로 두면 캐시가 살아납니다
struct LogStore {
    std::vector<qint64>  ts;        // 타임스탬프
    std::vector<quint8>  level;     // 0=DEBUG 1=INFO 2=WARN 3=ERROR
    std::vector<quint32> msgOffset; // 큰 문자열 버퍼 안의 위치
    QByteArray           blob;      // 메시지 원문을 한 덩어리로 (mmap 도 가능)
    size_t size() const { return ts.size(); }
};
// 행마다 QString 3개를 들고 있는 구조체 배열보다 메모리가 5~10배 적습니다

class LogModel : public QAbstractTableModel {
    Q_OBJECT
public:
    int rowCount(const QModelIndex& = {}) const override
        { return int(m_visible.size()); }        // 필터 결과 인덱스만 보관
    int columnCount(const QModelIndex& = {}) const override { return 3; }

    QVariant data(const QModelIndex &ix, int role) const override {
        if (!ix.isValid()) return {};
        const quint32 row = m_visible[ix.row()];   // 필터 → 원본 인덱스

        switch (role) {
        case Qt::DisplayRole:
            switch (ix.column()) {
            case 0: return m_fmtCache.timeString(m_s.ts[row]);  // ★ 캐시 사용
            case 1: return levelName(m_s.level[row]);           // ★ 정적 문자열
            case 2: return QString::fromUtf8(m_s.blob.constData()
                              + m_s.msgOffset[row]);
            }
            break;
        case Qt::ForegroundRole:
            if (m_s.level[row] >= 3) return QColor(0xff, 0x6b, 0x6b);
            break;
        case Qt::UserRole:                      // 정렬용 원시 값
            return qulonglong(m_s.ts[row]);
        }
        return {};
    }
private:
    const LogStore   &m_s;
    std::vector<quint32> m_visible;
    mutable TimeCache m_fmtCache;   // 같은 초는 문자열을 재사용합니다 ★
};
data() 는 “가장 자주 불리는 함수”라고 생각하세요 — 스크롤 한 번에 수백 번, 마우스를 움직이기만 해도 툴팁 role 로 또 불립니다. 여기서 파일을 읽거나 · 정규식을 돌리거나 · 로그를 찍으면 앱이 즉시 끈적해집니다. 무거운 계산은 미리 해서 캐시해 두고, data() 는 꺼내 주기만 해야 합니다.

뷰 쪽 설정 — 이 다섯 줄이 체감을 바꿉니다

// ① 행 높이를 고정 — 안 하면 뷰가 모든 행의 높이를 재려고 합니다 ★★
view->verticalHeader()->setSectionResizeMode(QHeaderView::Fixed);
view->verticalHeader()->setDefaultSectionSize(22);
// QTreeView 라면
tree->setUniformRowHeights(true);

// ② 열 너비 자동 계산 금지 — ResizeToContents 는 전 행을 훑습니다 ★
view->horizontalHeader()->setSectionResizeMode(QHeaderView::Interactive);
// (한 번만 맞추고 싶으면 처음 1,000행으로 계산해 setColumnWidth 하세요)

// ③ 픽셀 단위 스크롤 대신 항목 단위
view->setVerticalScrollMode(QAbstractItemView::ScrollPerItem);

// ④ 정렬은 프록시가 아니라 원본에서 (프록시 정렬은 100만 행에서 느립니다)
//    QSortFilterProxyModel 은 10만 행 아래에서만 쓰세요
//    그 이상은 모델이 직접 sort() 를 구현합니다

// ⑤ 필터는 워커 스레드에서 인덱스 배열만 만들고 통째로 교체
void LogModel::applyFilter(std::vector<quint32> result) {
    beginResetModel();
    m_visible = std::move(result);
    endResetModel();
}
// ★ 행을 하나씩 insert/remove 하면 뷰가 매번 다시 그립니다 — 통째로 교체가 빠릅니다
실측 감각 — 3열 테이블, Release 빌드 기준

  QTableWidget  10만 행 채우기        4~12초 · 1.5GB   ✗
  QTableView + 단순 모델  10만 행     즉시 · 40MB
  QTableView + 단순 모델  100만 행    즉시 · 300MB
  ResizeToContents 켠 채 100만 행     30초+ 멈춤       ✗ ★
  QSortFilterProxy 로 100만 행 정렬   8~20초 멈춤      ✗
  모델이 직접 정렬(인덱스 배열)       0.3~1초

  → "느리다"의 원인 대부분은 데이터 양이 아니라
     ResizeToContents 나 프록시 같은 한 줄짜리 설정입니다

구조 — MVVM 을 C++ 로 🟡 중급

층을 나누는 실질적 이유

src/
  core/          ★ Qt Widgets 를 전혀 include 하지 않습니다
    LogStore.h/.cpp        파일 읽기 · 파싱 · 인덱싱
    Filter.h/.cpp          필터 조건 → 인덱스 배열
    Settings.h/.cpp        설정 값 구조체
      → 이 폴더만 있으면 콘솔에서 테스트가 됩니다
      → GoogleTest 로 단위 테스트를 붙일 수 있는 유일한 곳

  viewmodel/     ★ QObject 는 쓰지만 위젯은 모릅니다
    LogViewModel.h/.cpp    상태 + 시그널 + 명령
      · 프로퍼티: rowCount, busy, currentFilter, selectedRow
      · 명령:     open(path), applyFilter(text), exportCsv(path)
      · 시그널:   busyChanged, errorOccurred, filterApplied
      → Widgets 로 그리든 QML 로 그리든 이 파일은 그대로입니다

  ui/            ★ 여기만 위젯을 압니다
    MainWindow.h/.cpp      위젯 조립 + 뷰모델 연결만
    LogModel.h/.cpp        QAbstractTableModel (뷰모델을 표로 노출)
    LogDelegate.h/.cpp     셀 그리기

  main.cpp       조립: core → viewmodel → ui 순서로 생성
이 분리가 실제로 값을 하는 순간 — ① 테스트: 창을 띄우지 않고 로직을 검증할 수 있습니다. ② UI 교체: Widgets → QML 로 옮길 때 ui/ 만 다시 씁니다. ③ CLI 버전: 같은 core/ 로 명령줄 도구를 30분이면 만듭니다. ④ 스레드: core/ 가 Qt UI 를 모르므로 워커 스레드에 통째로 올릴 수 있습니다.

뷰모델 — 코드로 보면 이렇습니다

// viewmodel/LogViewModel.h — 위젯을 하나도 모릅니다
class LogViewModel : public QObject {
    Q_OBJECT
    Q_PROPERTY(bool busy READ busy NOTIFY busyChanged)          // QML 도 씀
    Q_PROPERTY(int  total READ total NOTIFY dataChanged)
public:
    bool busy()  const { return m_busy; }
    int  total() const { return int(m_store.size()); }
    const LogStore& store() const { return m_store; }

public slots:
    void open(const QString &path);           // 워커에서 읽고 시그널로 알림
    void applyFilter(const QString &text);
    void cancel();

signals:
    void busyChanged();
    void dataChanged();
    void filterReady(std::vector<quint32> visible);   // 등록 필요 ★
    void errorOccurred(const QString &msg);

private:
    LogStore  m_store;
    bool      m_busy = false;
};

// ★ 커스텀 타입을 시그널로 넘기려면 등록해야 합니다 (스레드를 건널 때 필수)
// main.cpp 에서 한 번:
qRegisterMetaType<std::vector<quint32>>("std::vector<quint32>");
// ui/MainWindow.cpp — 조립과 연결만 있습니다. 로직이 없습니다 ★
MainWindow::MainWindow(LogViewModel *vm) : m_vm(vm) {
    buildWidgets();

    connect(ui->openAction, &QAction::triggered, this, [this]{
        const QString p = QFileDialog::getOpenFileName(this, "로그 열기");
        if (!p.isEmpty()) m_vm->open(p);            // 명령만 던집니다
    });
    connect(ui->searchEdit, &QLineEdit::textChanged,
            m_vm, &LogViewModel::applyFilter);

    connect(m_vm, &LogViewModel::busyChanged, this, [this]{
        ui->progress->setVisible(m_vm->busy());
        ui->openAction->setEnabled(!m_vm->busy());
    });
    connect(m_vm, &LogViewModel::filterReady, m_model, &LogModel::applyFilter);
    connect(m_vm, &LogViewModel::errorOccurred, this, [this](const QString &m){
        QMessageBox::warning(this, "오류", m);
    });
}
// MainWindow 안에 if 문이 거의 없다면 분리가 잘 된 것입니다
“일단 MainWindow 에 다 넣고 나중에 나누자”가 실패하는 이유 — 나중이 오지 않습니다. 파일 읽기·파싱·설정·스레드가 위젯 코드에 섞이면 테스트도 못 하고 UI 도 못 바꾸는 3,000줄짜리 클래스가 됩니다. 처음부터 폴더 세 개로 시작하세요 — 비용은 첫날 10분입니다.

느린 곳 찾기 — 순서가 정해져 있습니다 🔴 실무

측정 도구와 쓰는 순서

① 먼저 "무엇이 느린가"를 한 문장으로 — 여기서 절반이 끝납니다
   · 파일을 열 때 5초 멈춘다      → IO 또는 파싱
   · 스크롤이 끊긴다              → data() 또는 델리게이트 ★
   · 창 크기를 바꾸면 끊긴다      → 레이아웃 또는 ResizeToContents
   · 가만히 있어도 CPU 를 먹는다  → 타이머 · 애니메이션 · QML 바인딩
   · 시간이 갈수록 느려진다       → 메모리 누수 · 캐시 무한 증가

② 코드에 스톱워치를 박습니다 — 가장 싸고 가장 효과적
   QElapsedTimer t; t.start();
   parse();
   qDebug() << "parse" << t.elapsed() << "ms";

③ 샘플링 프로파일러로 전체 그림
   윈도우 : Superluminal(유료·최고) · Very Sleepy(무료)
            Windows Performance Analyzer(무료·깊음)
   리눅스 : perf record -g ./app  →  perf report
   macOS  : Instruments (Time Profiler)
   공통   : Tracy — 프레임 단위로 보는 실시간 프로파일러 ★

④ Qt 전용
   QML Profiler (Qt Creator) — 바인딩 재평가 · 델리게이트 생성 횟수
   QT_LOGGING_RULES="qt.scenegraph.time.*=true" 로 렌더 시간
   QSG_RENDER_TIMING=1  QML 프레임별 시간

⑤ 그래도 모르겠으면 — 반씩 지워 보세요
   기능을 절반 끄고 재보고, 느린 쪽을 다시 절반 끄고…
   프로파일러보다 이게 빠를 때가 실제로 많습니다
Tracy 를 한 번은 써 보세요 — 코드에 ZoneScoped; 한 줄만 넣으면 프레임마다 어느 함수가 몇 ms 를 먹었는지가 타임라인으로 보입니다. 게임 쪽에서 시작됐지만 UI 앱의 "가끔 끊김"을 잡는 데도 압도적입니다 (평균은 멀쩡한데 100프레임에 한 번 튀는 것을 평균 기반 도구는 못 잡습니다).

자주 나오는 원인과 처방

증상흔한 원인 → 처방
파일 열 때 앱이 하얗게 됨UI 스레드에서 읽음 → QtConcurrent(g05)
스크롤이 끊김data() 에서 문자열 생성 → 캐시
표가 뜨는 데 30초ResizeToContents → Interactive
검색할 때마다 멈춤동기 필터 → 워커 + 인덱스 교체
메모리가 계속 증가캐시 무한 · deleteLater 누락
가만히 둬도 CPU 5%짧은 QTimer · QML 애니메이션
창 이동 시 잔상커스텀 paintEvent 에서 과도한 작업
시작이 3초DLL 로드 · 폰트 스캔 → 지연 초기화
// 자주 쓰는 처방 세 가지

// ① 검색어 입력마다 필터 돌리지 않기 — 디바운스 ★
auto *debounce = new QTimer(this);
debounce->setSingleShot(true);
debounce->setInterval(250);
connect(ui->search, &QLineEdit::textChanged, debounce,
        qOverload<>(&QTimer::start));          // 칠 때마다 타이머 재시작
connect(debounce, &QTimer::timeout, this, [this]{
    m_vm->applyFilter(ui->search->text());     // 멈춘 뒤 250ms 후 한 번만
});

// ② 시작을 빠르게 — 무거운 것은 창이 뜬 뒤에
QTimer::singleShot(0, this, [this]{ loadPlugins(); checkUpdate(); });

// ③ 진행률 갱신을 줄이기 — 시그널 폭풍이 오히려 UI 를 막습니다
if (++n % 1000 == 0) emit progress(n);         // 매 건마다 emit 하지 말 것
Debug 빌드로 성능을 재지 마세요 — MSVC Debug 는 std::vector 접근마다 반복자 검사를 넣어 10~50배 느립니다. “C++ 인데 파이썬보다 느린데요?” 의 8할이 이것입니다. 측정은 반드시 Release 또는 RelWithDebInfo 로 하세요.
정리 — 큰 데이터는 모델/뷰로 “보이는 것만 그리고”, 무거운 일은 워커로 빼고, 구조는 core · viewmodel · ui 세 폴더로 나눕니다 — 이 셋이 성능과 유지보수를 동시에 해결합니다.
APP 12

실전 — 로그 뷰어를 배포 파일까지 만듭니다

앞의 열한 절을 하나로 묶습니다. 만들 것은 수 GB 짜리 로그 파일을 열어 즉시 검색하고 레벨로 거르는 뷰어입니다. 메모장으로는 못 열고, 파이썬으로는 느리고, 상용 도구는 비싼 — C++ 데스크톱 앱이 실제로 이기는 자리입니다.

프로젝트 구조 — 폴더가 곧 설계입니다 CMakeLists.txt · CMakePresets.json · vcpkg.json · .clang-format · .gitignore src/core/ — Qt Widgets 를 전혀 include 하지 않습니다 LogStore(mmap + 줄 인덱싱) · Filter(조건 → 인덱스 배열) · Parser(레벨·시각 추출) src/viewmodel/ — 상태 · 명령 · 시그널 LogViewModel : open() · applyFilter() · cancel() · busy · errorOccurred src/ui/ — 여기만 위젯을 압니다 MainWindow(조립) · LogModel(QAbstractTableModel) · LogDelegate(셀 그리기) 동작 흐름 — 파일 열기와 검색 파일 열기 사용자 조작 워커 — mmap + 인덱싱 3GB 를 3초에 (줄 시작 위치만) 뷰모델에 알림 시그널 (큐 연결) 표 갱신 즉시 표시 검색어 입력 한 글자마다 디바운스 250ms 멈춘 뒤 한 번만 실행 워커 — 인덱스 배열 취소 가능 통째로 교체 beginResetModel UI 스레드는 이 흐름 어디에서도 블로킹되지 않습니다 — 그것이 이 설계의 유일한 목적입니다
화살표가 항상 워커를 거쳐서 돌아옵니다 — 그게 “안 멈추는 앱”의 전부입니다.

① 프로젝트 뼈대 — CMake 와 매니페스트

// vcpkg.json — 의존성을 파일로 고정합니다
{
  "name": "logviewer",
  "version": "1.0.0",
  "builtin-baseline": "매니페스트를 만든 시점의 vcpkg 커밋 해시",
  "dependencies": []
}
//   Qt 는 온라인 설치기로 받았다고 가정합니다(g02 경로 A)
cmake_minimum_required(VERSION 3.21)
project(logviewer VERSION 1.0.0 LANGUAGES CXX)

set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_EXPORT_COMPILE_COMMANDS ON)     # clangd 자동완성

find_package(Qt6 6.5 REQUIRED COMPONENTS Widgets Concurrent)
qt_standard_project_setup()

# ── core : Qt Widgets 를 모르는 순수 라이브러리 ★
add_library(lv_core STATIC
    src/core/LogStore.cpp  src/core/LogStore.h
    src/core/Filter.cpp    src/core/Filter.h)
target_include_directories(lv_core PUBLIC src)
target_link_libraries(lv_core PUBLIC Qt6::Core)   # QString·QFile 만 씁니다

# ── 실행 파일
qt_add_executable(logviewer
    src/main.cpp
    src/viewmodel/LogViewModel.cpp  src/viewmodel/LogViewModel.h
    src/ui/MainWindow.cpp           src/ui/MainWindow.h
    src/ui/LogModel.cpp             src/ui/LogModel.h
    src/ui/LogDelegate.cpp          src/ui/LogDelegate.h
    res/app.qrc)

target_link_libraries(logviewer PRIVATE lv_core Qt6::Widgets Qt6::Concurrent)

set_target_properties(logviewer PROPERTIES
    WIN32_EXECUTABLE $<NOT:$<CONFIG:Debug>>   # Debug 는 콘솔 유지 ★
    MACOSX_BUNDLE ON)

# ── 테스트 (core 만 테스트합니다)
enable_testing()
find_package(Qt6 COMPONENTS Test)
qt_add_executable(lv_tests tests/test_filter.cpp)
target_link_libraries(lv_tests PRIVATE lv_core Qt6::Test)
add_test(NAME filter COMMAND lv_tests)

# ── 배포
qt_generate_deploy_app_script(TARGET logviewer OUTPUT_SCRIPT deploy
                              NO_UNSUPPORTED_PLATFORM_ERROR)
install(TARGETS logviewer RUNTIME DESTINATION . BUNDLE DESTINATION .)
install(SCRIPT ${deploy})
WIN32_EXECUTABLE $<NOT:$<CONFIG:Debug>> 한 줄이 개발을 편하게 합니다 — Debug 로 빌드하면 콘솔 창이 같이 떠서 qDebug() 출력이 보이고, Release 로 빌드하면 콘솔 없이 앱만 뜹니다. 매번 옵션을 바꿔 줄 필요가 없습니다.

② core — 3GB 파일을 3초에 여는 방법 ★

// src/core/LogStore.h — 파일 전체를 메모리에 올리지 않습니다
#pragma once
#include <QFile>
#include <QString>
#include <vector>

struct LineRef { quint64 offset; quint32 len; quint8 level; qint64 ts; };

class LogStore {
public:
    bool open(const QString &path, QString *err);
    size_t size() const { return m_lines.size(); }

    // 메시지는 물어볼 때만 문자열로 만듭니다 (data() 에서 호출) ★
    QString text(size_t i) const {
        const auto &r = m_lines[i];
        return QString::fromUtf8(
            reinterpret_cast<const char*>(m_map) + r.offset, int(r.len));
    }
    const LineRef& ref(size_t i) const { return m_lines[i]; }

    // 워커에서 호출 — 진행률 콜백과 취소 플래그를 받습니다
    template <class Fn>
    bool index(Fn onProgress, const std::atomic_bool &cancel);

private:
    QFile                 m_file;
    const uchar          *m_map = nullptr;   // ★ mmap
    qint64                m_size = 0;
    std::vector<LineRef>  m_lines;
};
// src/core/LogStore.cpp
bool LogStore::open(const QString &path, QString *err) {
    m_file.setFileName(path);
    if (!m_file.open(QIODevice::ReadOnly)) { *err = m_file.errorString(); return false; }
    m_size = m_file.size();
    m_map  = m_file.map(0, m_size);          // ★ OS 가 필요한 페이지만 읽어 옵니다
    if (!m_map) { *err = "메모리 매핑 실패"; return false; }
    return true;
}

template <class Fn>
bool LogStore::index(Fn onProgress, const std::atomic_bool &cancel) {
    m_lines.clear();
    m_lines.reserve(size_t(m_size / 120));   // 평균 줄 길이로 미리 확보 ★
    const char *p = reinterpret_cast<const char*>(m_map);
    quint64 start = 0;

    for (qint64 i = 0; i < m_size; ++i) {
        if (p[i] != '\n') continue;
        LineRef r;
        r.offset = start;
        r.len    = quint32(i - start);
        r.level  = detectLevel(p + start, r.len);    // "ERROR" 등을 앞부분에서만 찾음
        r.ts     = parseTimestamp(p + start, r.len); // 실패하면 -1
        m_lines.push_back(r);
        start = quint64(i + 1);

        if ((m_lines.size() & 0xFFFF) == 0) {        // 6.5만 줄마다 ★
            if (cancel.load(std::memory_order_relaxed)) return false;
            onProgress(int(i * 100 / m_size));
        }
    }
    return true;
}
왜 mmap 인가 — 3GB 파일을 readAll() 하면 메모리 3GB 를 씁니다. mmap 은 주소만 잡아 두고 실제로 읽는 페이지만 물리 메모리에 올립니다. 화면에 보이는 40줄만 만지면 그 40줄이 있는 페이지만 읽힙니다. 인덱싱은 전체를 한 번 훑지만 순차 읽기라 디스크가 가장 빠른 방식으로 돕니다.

③ 뷰모델과 워커 — 취소되는 필터 🟡 중급

필터를 워커로 — 취소까지 ★

// src/core/Filter.h
struct FilterSpec {
    QString  needle;          // 부분 문자열 (대소문자 무시)
    quint8   minLevel = 0;    // 0=전체 2=WARN 이상 3=ERROR 만
    qint64   fromTs = -1, toTs = -1;
    bool     regex = false;
};

std::vector<quint32> runFilter(const LogStore &s, const FilterSpec &f,
                               const std::atomic_bool &cancel);
// src/viewmodel/LogViewModel.cpp
void LogViewModel::applyFilter(const QString &text) {
    m_spec.needle = text;

    // ① 이전 작업 취소 — 사용자가 계속 타이핑하면 앞의 것은 버립니다 ★
    if (m_filterCancel) m_filterCancel->store(true);
    auto cancel = std::make_shared<std::atomic_bool>(false);
    m_filterCancel = cancel;

    setBusy(true);
    const LogStore *store = &m_store;
    FilterSpec spec = m_spec;
    const quint64 token = ++m_filterToken;      // ② 순서 보장용 표 ★

    QtConcurrent::run([store, spec, cancel] {
        return runFilter(*store, spec, *cancel);
    }).then(this, [this, token, cancel](std::vector<quint32> visible) {
        if (cancel->load()) return;              // 취소된 결과는 버립니다
        if (token != m_filterToken) return;      // ③ 늦게 온 옛 결과도 버립니다
        setBusy(false);
        emit filterReady(std::move(visible));
    });
}
“늦게 온 옛 결과”가 실무의 진짜 버그입니다 — 사용자가 err 를 치다가 error 까지 쳤는데, err 작업이 더 오래 걸려 나중에 도착하면 화면이 옛 결과로 덮입니다. 토큰(순번) 비교 한 줄이 이걸 막습니다. 취소 플래그만으로는 부족합니다.

④ 델리게이트 — 레벨별 색과 검색어 강조

// src/ui/LogDelegate.cpp — 셀 하나를 직접 그립니다
void LogDelegate::paint(QPainter *p, const QStyleOptionViewItem &opt,
                        const QModelIndex &ix) const {
    QStyleOptionViewItem o = opt;
    initStyleOption(&o, ix);

    // 선택 배경 · 교대 행 색은 기본 스타일에 맡깁니다
    o.widget->style()->drawPrimitive(QStyle::PE_PanelItemViewItem, &o, p, o.widget);

    const int level = ix.data(Qt::UserRole + 1).toInt();
    p->setPen(level >= 3 ? QColor(0xff, 0x6b, 0x6b)
             : level == 2 ? QColor(0xfb, 0xbf, 0x24)
                          : o.palette.text().color());

    const QString text = ix.data(Qt::DisplayRole).toString();
    QRect r = o.rect.adjusted(6, 0, -6, 0);

    // 검색어 강조 — 매칭 구간에만 배경을 칠합니다
    if (!m_needle.isEmpty()) {
        const int at = text.indexOf(m_needle, 0, Qt::CaseInsensitive);
        if (at >= 0) {
            const QFontMetrics fm(o.font);
            const int x1 = fm.horizontalAdvance(text.left(at));
            const int w  = fm.horizontalAdvance(text.mid(at, m_needle.size()));
            p->fillRect(QRect(r.x() + x1, r.y(), w, r.height()),
                        QColor(0x22, 0xc5, 0x5e, 0x55));
        }
    }
    p->drawText(r, Qt::AlignVCenter | Qt::TextSingleLine,
                o.fontMetrics.elidedText(text, Qt::ElideRight, r.width()));
}

// ★ sizeHint 는 고정값을 돌려주세요 — 계산하면 100만 행에서 멈춥니다
QSize LogDelegate::sizeHint(const QStyleOptionViewItem&, const QModelIndex&) const {
    return QSize(10, m_rowHeight);
}
델리게이트는 “셀 하나를 그리는 함수” 입니다. 스크롤 한 번에 수백 번 불리므로 여기서 QFontMetrics 를 매번 만들거나 정규식을 돌리면 즉시 끈적해집니다. 폰트 메트릭과 색은 멤버에 캐시해 두고, paint() 는 그리기만 하세요.

⑤ 마무리 — 설정 저장부터 설치 파일까지 🔴 실무

사용자가 느끼는 완성도는 여기서 갈립니다

// src/main.cpp — 조립 순서
int main(int argc, char *argv[]) {
    QApplication app(argc, argv);
    QCoreApplication::setOrganizationName("Acme");
    QCoreApplication::setApplicationName("LogViewer");
    QCoreApplication::setApplicationVersion(APP_VERSION);

    // 명령줄 인자 — 파일 연결(더블클릭)로 들어오는 경로를 받습니다 ★
    QCommandLineParser parser;
    parser.addHelpOption(); parser.addVersionOption();
    parser.addPositionalArgument("file", "열 로그 파일");
    parser.process(app);

    LogViewModel vm;
    MainWindow win(&vm);
    win.restoreUi();                       // 창 위치·열 너비 복원 (g05)
    win.show();

    if (!parser.positionalArguments().isEmpty())
        vm.open(parser.positionalArguments().first());

    return app.exec();
}
// 완성도를 만드는 자잘한 것들 — 전부 몇 줄씩입니다
setAcceptDrops(true);                       // ① 드래그 앤 드롭으로 파일 열기
void MainWindow::dropEvent(QDropEvent *e) {
    const auto urls = e->mimeData()->urls();
    if (!urls.isEmpty()) m_vm->open(urls.first().toLocalFile());
}
void MainWindow::dragEnterEvent(QDragEnterEvent *e) {
    if (e->mimeData()->hasUrls()) e->acceptProposedAction();
}

// ② 최근 파일 목록 (QSettings 에 저장 · 메뉴에 표시)
// ③ 단축키 — Ctrl+F 검색, Ctrl+G 줄 이동, F3 다음 찾기
new QShortcut(QKeySequence::Find, this, [this]{ ui->search->setFocus(); });

// ④ 선택 영역 복사 · CSV 내보내기 (표 앱의 기본 기대치)
// ⑤ 상태 표시줄에 "전체 1,204,553행 · 필터 3,201행 · 12.4초"
// ⑥ 다크/라이트 따라가기 — Qt 6.5+ 는 자동입니다
app.styleHints()->setColorScheme(Qt::ColorScheme::Unknown);   // OS 를 따름
// ⑦ 오류를 대화상자로만 띄우지 말고 상태 표시줄에도 남기기
// ⑧ 큰 파일 인덱싱 중에는 [취소] 버튼을 실제로 동작하게
이 여덟 개가 “써 볼 만한 도구”와 “쓰는 도구”를 가릅니다 — 기능은 같아도, 창 위치가 기억되지 않고 드래그가 안 되고 Ctrl+F 가 안 먹으면 사람들은 두 번째로 열지 않습니다. 전부 합쳐 코드 100줄 정도입니다.

빌드부터 설치 파일까지 — 스크립트 한 개

#!/usr/bin/env bash
# scripts/release.sh — 태그 하나로 배포본이 나오게 합니다
set -euo pipefail
VERSION="${1:?사용법: release.sh 1.0.0}"

cmake --preset win-release -DAPP_VERSION="$VERSION"
cmake --build --preset win-release
ctest --preset win-release --output-on-failure      # ★ 테스트가 실패하면 여기서 멈춤

rm -rf dist && mkdir dist
cmake --install build/rel --prefix dist             # windeployqt 가 여기서 돕니다

# 서명 (인증서가 있을 때만)
if [ -n "${SIGN_NAME:-}" ]; then
  signtool sign //fd SHA256 //tr http://timestamp.digicert.com //td SHA256 \
           //n "$SIGN_NAME" dist/logviewer.exe
fi

# 설치 파일 만들기
"/c/Program Files (x86)/Inno Setup 6/ISCC.exe" //DMyAppVersion="$VERSION" setup.iss

echo "완료 → Output/LogViewer-$VERSION-setup.exe"
# .github/workflows/release.yml — 태그를 밀면 알아서 만듭니다
name: release
on:
  push:
    tags: ['v*']
jobs:
  windows:
    runs-on: windows-2022
    steps:
      - uses: actions/checkout@v4
      - uses: jurplel/install-qt-action@v4
        with:
          version: '6.8.3'
          arch: 'win64_msvc2022_64'
          modules: ''
      - name: Configure
        run: cmake --preset win-release -DAPP_VERSION=${{ github.ref_name }}
      - name: Build
        run: cmake --build --preset win-release
      - name: Test
        run: ctest --preset win-release --output-on-failure
      - name: Deploy
        run: cmake --install build/rel --prefix dist
      - name: Installer
        run: '& "$env:ProgramFiles(x86)\Inno Setup 6\ISCC.exe" setup.iss'
      - uses: softprops/action-gh-release@v2
        with:
          files: Output/*.exe
배포를 손으로 하지 마세요 — “내 PC 에서 빌드해서 올린다”는 방식은 재현이 안 되고, 어느 버전을 무슨 옵션으로 만들었는지 아무도 모르게 됩니다. CI 에 올리는 데 반나절이 들지만, 두 번째 배포부터는 태그 하나가 전부입니다.

이 프로젝트에 들어간 것 — 한눈에

배운 것이 여기 쓰였습니다절
툴킷으로 Qt Widgets 를 고른 근거g01
CMake + CMAKE_PREFIX_PATH · 프리셋g02
QMainWindow · 레이아웃 · 모델/뷰g03
QtConcurrent 로 UI 안 멈추게 · QSettingsg05
windeployqt · Inno Setup · 서명g06
mmap · 인덱스 배열 · 고정 행 높이g11
core · viewmodel · ui 세 층 분리g11
다음에 붙일 만한 것 — ① 여러 파일 탭으로 열기(QTabWidget) · ② 실시간 tail(QFileSystemWatcher 로 파일 증가 감지) · ③ 정규식 하이라이트 규칙 저장 · ④ 북마크와 줄 이동 · ⑤ 선택 구간을 새 창으로 여는 “분할 보기”. 전부 지금 구조 위에 core 에 함수 하나, ui 에 위젯 하나로 붙습니다.

마지막으로 — 정직한 얘기

이 로그 뷰어를 다른 방법으로 만들면

  파이썬 + PySide6
    코드량 비슷 · 개발 시간 절반
    3GB 파일 인덱싱: 40~120초 (C++ 3~8초)
    배포 크기 150MB · 시작 1.5초
    → 파일이 100MB 급이면 이쪽이 합리적입니다

  Electron / Tauri
    UI 만들기는 가장 빠름
    3GB 파일: 사실상 불가 (또는 별도 네이티브 모듈 필요)
    → 화면이 화려해야 하고 데이터가 작으면 이쪽

  C++ + Qt  ← 이 탭
    3GB 를 3~8초에 · 메모리 300MB · 시작 0.1초
    단일 폴더 25MB 배포
    → "파일이 크다"가 요구사항의 중심일 때만 이깁니다

★ 결론: C++ 데스크톱 앱은 만능이 아니라
  "데이터가 크거나 · 계산이 무겁거나 · 이미 C++ 자산이 있을 때"
  압도적으로 이기는 도구입니다. 그 조건이 맞는지 판단하는 것이
  g01 이었고, 맞다면 여기까지가 한 바퀴입니다.
이어서 볼 곳 — 앱이 서버와 통신해야 하면 🌐 백엔드 서버 탭, 게임이나 실시간 통신이면 🎮 게임 서버 탭, 인덱싱을 더 빠르게(SIMD·병렬·io_uring) 하고 싶으면 🚀 고성능 탭, C++ 코어만 만들고 화면은 파이썬으로 붙이고 싶으면 🔗 FFI 탭으로 가세요.
정리 — 로그 뷰어 하나에 이 탭의 전부가 들어 있습니다 — mmap 인덱싱 · 모델/뷰 · 취소되는 워커 필터 · 설정 저장 · windeployqt 배포. 구조에서 딱 하나만 가져간다면 “UI 스레드는 아무것도 오래 하지 않는다” 는 원칙입니다.
.NET 10 · C# 14 · LibraryImport · C++/CLI · Unity 6 · NativeAOT · NuGet runtimes

C++ 로 만든 힘을
C# 이 그대로
가져다 씁니다

C# 은 생산성이 아주 높은 언어지만, 픽셀·샘플·행렬을 수천만 개씩 훑는 루프에서는 JIT 가 아무리 잘해도 손으로 벡터화한 C++ 를 따라잡기 어렵습니다. 그래서 실무에서는 무거운 알맹이만 C++ 로 만들고 나머지는 C# 으로 씁니다. 이 탭은 그 연결을 가장 간편한 길부터 보여 줍니다 — C ABI 함수 하나 + [LibraryImport] 한 줄이면 끝이고, 거기서 마샬링 · 구조체 · 핸들 · 콜백 · 예외 · Unity · NuGet 배포까지 넓혀 갑니다. 이 탭을 마치면 C++ 라이브러리를 C# 콘솔 · ASP.NET Core · Unity 어디에나 붙일 수 있게 됩니다.

12SECTIONS
.NET 10기준 (LTS)
1줄이면 첫 호출
0복사 (핀 고정)
C# 01

다섯 갈래 중 어디로 갈 것인가 — 2026년의 기본값

C++ 를 C# 에서 부르는 방법은 다섯 가지입니다. 그런데 실제로 고민할 가치가 있는 건 두 개뿐이고, 나머지 셋은 “이런 조건일 때만” 이라는 꼬리표가 붙습니다. 먼저 그 지도를 그려 놓고 시작합니다.

위에서부터 읽고 — 처음 “예”가 나온 줄에서 멈추면 됩니다 C++ 를 C 함수로 감쌀 수 있나? 내보낼 함수가 20~50개 정도로 정리된다 LibraryImport — 이게 기본값 ★ .NET 7+ 소스 생성기 · 윈도우 · 리눅스 · 맥 공통 Unity 에서 쓰나? 에디터와 빌드 양쪽에서 같은 코드가 돌아야 한다 DllImport + Plugins 폴더 규약 IL2CPP 빌드는 MonoPInvokeCallback 이 필수입니다 윈도우 전용 + 클래스가 수백 개? WPF 앱이 기존 C++ 엔진을 통째로 안고 간다 C++/CLI 래퍼 프로젝트 한 파일 안에서 두 세계를 섞습니다 — 윈도우 전용 헤더가 수천 줄이라 손으로 못 옮기나? 남이 만든 대형 SDK 를 통째로 감싸야 한다 CppSharp · SWIG · ClangSharp 로 생성 생성 후에도 손질은 필요합니다 — 절반만 자동 이미 COM 서버가 있나? 오래된 윈도우 컴포넌트를 이어받았다 COM interop 새로 시작할 이유는 2026년에 없습니다 거의 모든 새 프로젝트는 첫 줄에서 끝납니다 — 나머지 넷은 “조건이 맞을 때만”입니다
고민의 90%는 첫 줄에서 끝납니다 — C ABI 함수 + LibraryImport가 2026년의 기본값입니다.

다섯 갈래를 한 표로 ★

플랫폼C++ 클래스난이도NativeAOT
LibraryImport전부핸들로 감싸서낮음완전 호환
DllImport(구방식)전부핸들로 감싸서낮음일부 제약
C++/CLI윈도우만직접 감쌈중간불가
COM윈도우만인터페이스로높음부분
자동 생성전부생성기가 처리중간생성 결과에 따라
표를 한 줄로 요약하면 — 크로스 플랫폼이 필요하면 선택지는 P/Invoke 계열뿐입니다. C++/CLI 와 COM 은 윈도우에 못 박히고, 자동 생성은 결국 P/Invoke 코드를 대신 써 주는 도구입니다. 즉 다섯 갈래가 아니라 “P/Invoke 를 손으로 쓰느냐, 생성기에 맡기느냐, 윈도우 전용을 감수하느냐” 셋입니다.

가장 짧은 길 — 이 정도면 끝입니다

// native/mylib.cpp  — C++ 쪽은 이 한 겹만 얹으면 됩니다
#include <cstdint>

extern "C" {                       // 이름 맹글링을 끕니다 (🔗 C ABI 탭 참고)

#if defined(_WIN32)
  #define API __declspec(dllexport)
#else
  #define API __attribute__((visibility("default")))
#endif

API int32_t mylib_add(int32_t a, int32_t b) { return a + b; }

}   // extern "C"
// Program.cs — C# 쪽은 선언 한 개, 호출 한 줄
using System.Runtime.InteropServices;

internal static partial class Native
{
    [LibraryImport("mylib")]          // mylib.dll · libmylib.so · libmylib.dylib
    internal static partial int mylib_add(int a, int b);
}

Console.WriteLine(Native.mylib_add(2, 40));   // 42
이게 전부입니다 — 중간에 어떤 런타임도, 브리지 라이브러리도 없습니다. C# 이 OS 의 동적 로더로 .dll/.so 를 열고, 심볼 주소를 찾아, 그대로 점프합니다. 파이썬의 ctypes, 자바의 FFM 과 정확히 같은 원리입니다.

이렇게도 쓸 수 있다 — 네 방식이 실제로 어떻게 생겼나 🟡 중급

P/Invoke — 구방식과 새 방식

// ① 구방식 DllImport — .NET Framework 시절부터 쓰던 것
//    런타임이 실행 중에 마샬링 코드(IL 스텁)를 만들어 냅니다
[DllImport("mylib", CharSet = CharSet.Ansi,
           CallingConvention = CallingConvention.Cdecl,
           SetLastError = true)]
static extern int mylib_greet(string name, byte[] buf, int cap);


// ② 새 방식 LibraryImport — .NET 7 부터. 컴파일 시점에 코드 생성 ★
//    생성된 C# 소스를 눈으로 볼 수 있고, NativeAOT 에서도 그대로 돕니다
[LibraryImport("mylib", StringMarshalling = StringMarshalling.Utf8,
               SetLastError = true)]
[UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])]
internal static partial int mylib_greet(string name, Span<byte> buf, int cap);
//              ^^^^^^^ partial 필수 · 담는 class 도 partial 이어야 합니다
둘의 진짜 차이 — 문법이 아니라 마샬링 코드가 언제 만들어지느냐입니다. DllImport 는 실행 중에 런타임이 IL 을 짜냅니다(그래서 NativeAOT·트리밍에서 삐걱거립니다). LibraryImport 는 빌드할 때 평범한 C# 코드를 생성합니다 — 디버거로 한 줄씩 밟을 수 있고, 무엇이 복사되는지 눈으로 보입니다.

C++/CLI 와 자동 생성은 이런 모양

// ③ C++/CLI — 한 파일 안에서 관리 코드와 네이티브 코드를 섞습니다
//    (Wrapper.cpp · /clr 로 컴파일 · 윈도우 전용)
public ref class Engine {                 // ref class = .NET 클래스
    NativeEngine* impl_;                  // 네이티브 객체를 그대로 소유
public:
    Engine(int w)  { impl_ = new NativeEngine(w); }
    ~Engine()      { delete impl_; }      // → C# 의 Dispose()
    !Engine()      { delete impl_; }      // → 종료자(finalizer)
    double Score(System::String^ s);      // C# 이 그냥 호출합니다
};
# ④ 자동 생성 — 헤더를 읽어 C# 바인딩을 뱉습니다
#    SWIG (C# 백엔드)
swig -csharp -c++ -namespace MyLib -outdir gen/ mylib.i

#    CppSharp / ClangSharp 는 .NET 프로그램으로 실행합니다
dotnet tool install -g ClangSharpPInvokeGenerator
ClangSharpPInvokeGenerator @generate.rsp     # 설정 파일로 옵션을 넘깁니다

# 결과물은 결국 [DllImport]/[LibraryImport] 가 잔뜩 붙은 .cs 파일입니다
자동 생성의 위치 — “바인딩을 대신 타이핑해 주는 도구”지, 새로운 연결 방식이 아닙니다. 그래서 생성기가 만든 코드를 읽을 줄 알아야 쓸 수 있고, 그건 결국 이 탭의 k02~k07 을 아는 것과 같습니다.

실무에선 이렇게 — 고를 때 실제로 따지는 것 🔴 실무

이렇게 시작하면 나중에 아픕니다
// ✗ C++ 클래스를 그대로 노출하려고 발버둥
//    → C++ 의 vtable 배치·이름 맹글링은 컴파일러마다 다릅니다.
//      MSVC 로 만든 DLL 을 clang 빌드로 바꾸는 순간 전부 깨집니다.

// ✗ std::string · std::vector 를 경계 너머로 넘기기
[DllImport("mylib")]
static extern void process(std_string s);   // 그런 건 없습니다

// ✗ C++/CLI 로 시작했다가 리눅스 컨테이너 배포 요구가 들어옴
//    → /clr 는 윈도우 전용. 처음부터 다시 써야 합니다.

// ✗ 함수 100개를 한 번에 노출
//    → 경계가 넓을수록 마샬링 버그 표면적이 넓어집니다
이렇게 시작하면 끝까지 편합니다
// ✓ ① C++ 쪽에 얇은 C ABI 층을 하나 만든다 (함수 20개 이하)
//     mylib_engine_create / _destroy / _process / _last_error
//     ★ 이 층이 있으면 파이썬·Node·자바도 같은 것을 씁니다

// ✓ ② C# 에는 두 겹을 만든다
//     Native.cs  — [LibraryImport] 원시 선언 (internal)
//     Engine.cs  — SafeHandle · IDisposable · 예외로 감싼 공개 API
internal static partial class Native { /* 원시 */ }
public sealed class Engine : IDisposable { /* 사람이 쓸 것 */ }

// ✓ ③ 배포는 NuGet runtimes/<rid>/native 규약으로 (k11)
// ✓ ④ 경계 비용은 BenchmarkDotNet 으로 숫자를 확보 (k12)

언제 P/Invoke 를 “쓰지 않는” 게 맞나

상황더 나은 선택
단순 수치 루프C# 으로 Span+SIMD — 경계 비용이 이득을 먹습니다
호출이 초당 수백만 번C++ 쪽에서 배치로 묶어 한 번에
서로 다른 프로세스여도 됨gRPC · 파이프 — 크래시가 격리됩니다
C++ 코드가 아직 없음먼저 C# 으로 만들고 측정한 뒤 결정
GPU 를 쓰고 싶다ComputeSharp · ILGPU · CUDA 래퍼
가장 흔한 실수 — “C++ 가 빠르니까”로 시작해서, 정작 경계를 1억 번 넘나드는 설계를 만드는 것입니다. P/Invoke 한 번의 비용은 대략 1~5ns(빈 함수 기준)로 아주 작지만, 마샬링이 붙으면 수십~수백 ns가 됩니다. 이득은 한 번의 호출이 하는 일의 크기에서 나옵니다.

.NET 10 프로젝트 기본 설정

<!-- MyApp.csproj -->
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <Nullable>enable</Nullable>

    <!-- ★ LibraryImport 가 생성하는 코드는 unsafe 를 씁니다 -->
    <AllowUnsafeBlocks>true</AllowUnsafeBlocks>

    <!-- 생성된 소스를 눈으로 보고 싶을 때 (obj/generated 에 떨어집니다) -->
    <EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles>
  </PropertyGroup>
</Project>
EmitCompilerGeneratedFiles 를 꼭 켜 보세요 — obj/Debug/net10.0/generated/…/LibraryImportGenerator/… 에 실제로 실행될 마샬링 코드가 평범한 C# 으로 떨어집니다. “어디서 복사가 일어나는가”를 추측하지 않고 읽어서 알 수 있게 됩니다.

한 단계 더 — 왜 소스 생성기가 이겼나 🧬 전문가

런타임 IL 스텁 vs 컴파일 시점 생성

[DllImport] — 실행 중에 벌어지는 일
  ① 처음 호출되는 순간 런타임이 시그니처를 읽습니다
  ② 마샬링 규칙을 해석해 IL 스텁을 '즉석에서' 만듭니다
  ③ 그 IL 을 JIT 가 기계어로 번역합니다
  ④ 이후 호출은 그 기계어로 직행 (그래서 두 번째부터는 빠릅니다)

  문제 : ①~③ 이 '실행 중 코드 생성'입니다
     → NativeAOT 는 실행 중 코드 생성을 못 합니다
     → 트리머는 어떤 타입이 마샬링에 쓰이는지 알 수 없어 못 지웁니다
     → 무엇이 복사되는지 소스로 확인할 방법이 없습니다

[LibraryImport] — 빌드할 때 벌어지는 일
  ① Roslyn 생성기가 partial 선언을 봅니다
  ② 평범한 C# 파일을 만들어 컴파일에 끼워 넣습니다
       fixed / GCHandle / Utf8 인코딩이 전부 소스로 보입니다
  ③ 남는 것은 순수한 unsafe 호출 하나 (extern 그대로)

  결과 : NativeAOT 완전 호환 · 트리밍 안전 · 디버거로 밟기 가능

경계 비용을 실제로 재 보기

// dotnet add package BenchmarkDotNet
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;

[MemoryDiagnoser]
public class Boundary
{
    [Benchmark(Baseline = true)]
    public int Managed() => 2 + 40;                  // 순수 C#

    [Benchmark]
    public int PInvokeBlittable() => Native.mylib_add(2, 40);

    [Benchmark]
    public int PInvokeString() => Native.mylib_len("hello world");
}
BenchmarkRunner.Run<Boundary>();

/* 참고용 결과의 '모양' (수치는 기계마다 다릅니다)
   Managed              0.3 ns   1.00x
   PInvokeBlittable     2~4 ns   약 10x        ← blittable 만이면 거의 공짜
   PInvokeString      60~150 ns  수백 x        ← UTF-8 인코딩 + 할당이 붙음
*/
여기서 얻을 결론 — blittable 인자만 쓰는 호출은 사실상 공짜이고, 비싼 것은 P/Invoke 자체가 아니라 문자열·배열·구조체 변환입니다. 그래서 이 탭의 절반(k03·k04)이 “변환을 없애는 법”에 쓰입니다.
정리 — 새 프로젝트라면 고민하지 말고 extern "C" 함수 + [LibraryImport] 입니다. C++/CLI 는 “윈도우 전용 + 기존 C++ 클래스가 산더미”일 때만, 자동 생성은 “헤더가 수천 줄”일 때만, COM 은 “이미 있어서 어쩔 수 없을 때만” 꺼냅니다.
C# 02

첫 P/Invoke — 10분이면 호출됩니다

설명은 뒤로 미루고 실제로 도는 것을 먼저 만듭니다. C++ 파일 하나, C# 파일 하나, 그리고 DLL 을 어떻게 실행 폴더까지 데려가느냐 — 초보가 막히는 지점은 사실 마지막 하나뿐입니다.

선언 한 줄이 네이티브 함수에 도달하기까지 ① 빌드할 때 — 마샬링 코드가 만들어집니다 partial 선언 [LibraryImport("mylib")] 소스 생성기 컴파일 중에 코드 생성 *.g.cs 스텁 핀 고정 · 인코딩 · 호출 ② 실행할 때 — 주소를 찾아 그대로 점프합니다 내 C# 코드 Native.Add(2, 40) 생성된 스텁 마샬링 후 호출 라이브러리 해석 한 번만 · 이후 캐시 C++ 함수 extern "C" 심볼 라이브러리를 찾는 순서 — DllNotFoundException 은 전부 여기서 납니다 ① SetDllImportResolver 로 내가 지정한 것 → ② 실행 파일 폴더 → ③ deps.json 의 네이티브 경로 → ④ OS 기본 경로 경로 문제는 코드가 아니라 빌드 설정 문제입니다 — 출력 폴더에 파일이 있는지부터 봅니다
막히는 지점은 대부분 ①②가 아니라 “DLL 이 출력 폴더에 없다” 하나입니다.

C++ 쪽 — 공유 라이브러리 한 개

// native/mylib.cpp
#include <cstdint>
#include <cmath>

#if defined(_WIN32)
  #define MYLIB_API __declspec(dllexport)
#else
  #define MYLIB_API __attribute__((visibility("default")))
#endif

extern "C" {

MYLIB_API int32_t mylib_add(int32_t a, int32_t b) {
    return a + b;
}

// 배열을 제자리에서 처리 — C# 이 넘겨준 메모리를 그대로 씁니다
MYLIB_API void mylib_scale(double* data, int64_t n, double k) {
    for (int64_t i = 0; i < n; ++i) data[i] *= k;
}

// 버전 확인용 — 붙었는지 가장 먼저 확인하는 함수를 하나 두면 편합니다
MYLIB_API int32_t mylib_version(void) { return 1; }

}   // extern "C"
# native/CMakeLists.txt
cmake_minimum_required(VERSION 3.20)
project(mylib LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 20)

add_library(mylib SHARED mylib.cpp)          # ★ SHARED — .dll / .so / .dylib

# 내보내기를 명시한 것만 밖으로 (리눅스·맥에서 심볼 노출을 줄입니다)
set_target_properties(mylib PROPERTIES
    CXX_VISIBILITY_PRESET hidden
    VISIBILITY_INLINES_HIDDEN ON
    WINDOWS_EXPORT_ALL_SYMBOLS OFF)

C# 쪽 — 프로젝트 만들고 붙이기

# ① C++ 빌드
cmake -S native -B native/build -DCMAKE_BUILD_TYPE=Release
cmake --build native/build --config Release
#   윈도우 : native/build/Release/mylib.dll
#   리눅스 : native/build/libmylib.so
#   맥     : native/build/libmylib.dylib

# ② C# 콘솔 프로젝트
dotnet new console -o App
cd App

# ③ 실행 — DLL 이 출력 폴더에 있어야 합니다 (아래 csproj 참고)
dotnet run
// App/Native.cs
using System.Runtime.InteropServices;

internal static partial class Native
{
    private const string Lib = "mylib";   // 확장자·lib 접두사는 붙이지 않습니다

    [LibraryImport(Lib)]
    internal static partial int mylib_add(int a, int b);

    [LibraryImport(Lib)]
    internal static partial int mylib_version();

    // double[] 은 blittable → 핀 고정만 하고 그대로 넘어갑니다 (복사 없음)
    [LibraryImport(Lib)]
    internal static partial void mylib_scale(Span<double> data, long n, double k);
}
// App/Program.cs
Console.WriteLine($"lib v{Native.mylib_version()}");     // v1
Console.WriteLine(Native.mylib_add(2, 40));              // 42

double[] xs = [1, 2, 3];
Native.mylib_scale(xs, xs.Length, 10);
Console.WriteLine(string.Join(", ", xs));                // 10, 20, 30

이렇게도 쓸 수 있다 — DLL 을 출력 폴더로 데려가기 🟡 중급

가장 단순한 방법 — csproj 에 복사 규칙 ★

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net10.0</TargetFramework>
    <AllowUnsafeBlocks>true</AllowUnsafeBlocks>
  </PropertyGroup>

  <!-- ① 프로젝트 폴더 안에 있는 파일이라면 None Update 로 충분합니다 -->
  <ItemGroup>
    <None Update="mylib.dll">
      <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
    </None>
  </ItemGroup>

  <!-- ② 다른 폴더에서 끌어올 때는 Content Include + Link ★ -->
  <ItemGroup>
    <Content Include="..\native\build\Release\mylib.dll"
             Condition="$([MSBuild]::IsOSPlatform('Windows'))">
      <Link>mylib.dll</Link>
      <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
    </Content>
    <Content Include="..\native\build\libmylib.so"
             Condition="$([MSBuild]::IsOSPlatform('Linux'))">
      <Link>libmylib.so</Link>
      <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
    </Content>
  </ItemGroup>
</Project>
PreserveNewest vs Always — PreserveNewest 는 원본이 더 새로울 때만 복사합니다(빠름). C++ 를 자주 다시 빌드하는 개발 중에는 이게 정답이고, “고쳤는데 반영이 안 된다” 싶으면 bin/ 을 지우고 다시 돌려 보세요.

이름 규칙 — 왜 “mylib” 만 쓰나

[LibraryImport("mylib")] 이라고 쓰면 런타임이 이렇게 찾습니다

  윈도우   mylib.dll  →  mylib
  리눅스   libmylib.so  →  mylib.so  →  libmylib  →  mylib
  맥      libmylib.dylib → mylib.dylib → libmylib → mylib

  ★ 즉 "mylib" 한 이름으로 세 OS 가 전부 해결됩니다
    "mylib.dll" 이라고 쓰면 리눅스에서 lib 접두사 조합을 시도하지 않아
    오히려 더 잘 깨집니다 — 확장자는 쓰지 마세요

절대 경로를 그대로 넣는 것도 됩니다(테스트용으로만)
  [LibraryImport(@"C:\work\mylib.dll")]
  → 배포하면 100% 깨집니다. 임시 확인 외에는 쓰지 마세요
# 리눅스에서 "출력 폴더에 뒀는데도 못 찾는다" 는 경우
#   → .NET 은 실행 파일 폴더를 보지만, 그 이후는 OS 규칙을 따릅니다
LD_LIBRARY_PATH=$PWD dotnet run          # 임시 확인용

# 무엇을 열려고 했는지 추적 (리눅스)
strace -f -e trace=openat dotnet run 2>&1 | grep mylib

# 맥
DYLD_PRINT_LIBRARIES=1 dotnet run 2>&1 | grep mylib

# 윈도우 — 의존 DLL 이 빠졌는지 확인 (MSVC 런타임 등)
dumpbin /dependents mylib.dll
윈도우에서 가장 흔한 함정 — mylib.dll 은 출력 폴더에 있는데도 DllNotFoundException 이 납니다. 범인은 대개 그 DLL 이 의존하는 다른 DLL (MSVC 런타임 vcruntime140.dll, OpenCV 등)입니다. dumpbin /dependents 로 목록을 뽑아 같이 복사하거나, C++ 를 /MT(정적 CRT)로 빌드해 의존성을 줄입니다.

실무에선 이렇게 — C++ 빌드를 dotnet build 에 묶기 🔴 실무

한 줄로 전부 빌드되게

<!-- App.csproj — dotnet build 가 CMake 를 먼저 돌립니다 -->
<Target Name="BuildNative" BeforeTargets="BeforeBuild">
  <Exec Command="cmake -S ../native -B ../native/build -DCMAKE_BUILD_TYPE=Release" />
  <Exec Command="cmake --build ../native/build --config Release" />
</Target>

<!-- 빌드 결과를 '빌드가 끝난 뒤' 수집합니다 (경로가 그때 확정되므로) -->
<Target Name="CopyNative" AfterTargets="BuildNative">
  <ItemGroup>
    <NativeOut Include="../native/build/**/mylib.dll;
                        ../native/build/**/libmylib.so;
                        ../native/build/**/libmylib.dylib" />
    <Content Include="@(NativeOut)">
      <Link>%(Filename)%(Extension)</Link>
      <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
    </Content>
  </ItemGroup>
</Target>
왜 이렇게까지 하나 — 팀원이 git clone 후 dotnet run 한 줄로 돌아가야 진입 장벽이 사라지기 때문입니다. “먼저 CMake 를 이렇게 돌리고…”가 README 에 있으면 절반은 그 단계에서 포기합니다. 정식 배포 단계에서는 이걸 NuGet 으로 대체합니다(k11).

붙었는지 확인하는 스모크 테스트

// Tests/SmokeTests.cs  (xunit)
public class SmokeTests
{
    [Fact]
    public void 라이브러리가_로드되고_버전이_맞는다()
    {
        // ① 로드 자체가 되는지 — 여기서 터지면 경로 문제입니다
        int v = Native.mylib_version();
        Assert.Equal(1, v);
    }

    [Fact]
    public void 산술이_맞는다() =>
        Assert.Equal(42, Native.mylib_add(2, 40));

    [Fact]
    public void 배열이_제자리에서_바뀐다()
    {
        double[] xs = [1, 2, 3];
        Native.mylib_scale(xs, xs.Length, 2);
        Assert.Equal([2.0, 4.0, 6.0], xs);
    }
}
// 로드에 실패했을 때 '사람이 읽을 수 있는' 메시지로 바꾸기
static Native()
{
    try { _ = mylib_version(); }
    catch (DllNotFoundException e)
    {
        throw new InvalidOperationException(
            $"네이티브 라이브러리(mylib)를 찾지 못했습니다. " +
            $"탐색 기준 폴더: {AppContext.BaseDirectory}", e);
    }
}
정적 생성자에 확인 호출을 하나 두는 습관 — 실패가 “어떤 기능을 처음 쓰는 순간”이 아니라 “타입을 처음 만지는 순간”에 나서 원인 추적이 훨씬 쉬워집니다. 메시지에 AppContext.BaseDirectory 를 꼭 넣으세요 — 사용자 환경에서 이 한 줄이 대부분을 설명합니다.

한 단계 더 — 생성된 스텁을 직접 읽어 보기 🧬 전문가

생성기가 만든 코드는 이렇게 생겼습니다

// obj/Debug/net10.0/generated/.../Native.g.cs (의미만 옮긴 것)
internal static partial class Native
{
    internal static unsafe partial void mylib_scale(
        Span<double> data, long n, double k)
    {
        fixed (double* __data = &MemoryMarshal.GetReference(data))
        {                       // ★ GC 가 못 옮기게 고정 — 복사가 아닙니다
            __PInvoke(__data, n, k);
        }

        // 진짜 진입점 — 여기서부터는 순수한 네이티브 호출입니다
        [DllImport("mylib", EntryPoint = "mylib_scale",
                   ExactSpelling = true)]
        static extern unsafe void __PInvoke(double* d, long n, double k);
    }
}
읽는 요령 — 생성된 코드에 fixed 만 있으면 “복사 없음”, Marshal.AllocCoTaskMem · Encoding.UTF8.GetBytes · new byte[] 가 보이면 “복사 있음”입니다. 성능이 걱정될 때 프로파일러를 켜기 전에 이 파일부터 열어 보세요. 답이 5초 만에 나옵니다.

심볼이 정말 나가 있는지 확인

# 리눅스·맥 — 내보낸 심볼 목록
nm -D --defined-only libmylib.so | grep mylib_
#   0000000000001139 T mylib_add        ← T = 밖으로 나가 있음
#   0000000000001150 T mylib_scale

# 이름이 mylib_add 가 아니라 _Z9mylib_addii 처럼 보이면
#   → extern "C" 를 빠뜨린 것입니다 (이름 맹글링)

# 윈도우
dumpbin /exports mylib.dll | findstr mylib_

# 실제로 로드해서 심볼을 잡아 보기 (C# 으로 진단)
var h = NativeLibrary.Load("mylib",
            typeof(Native).Assembly, null);
bool ok = NativeLibrary.TryGetExport(h, "mylib_add", out var addr);
Console.WriteLine($"{ok} {addr:X}");
EntryPointNotFoundException 이 뜬다면 — 라이브러리는 찾았는데 그 안에 함수가 없다는 뜻입니다. 원인은 셋 중 하나입니다. ① extern "C" 누락, ② __declspec(dllexport) 누락, ③ 32비트 DLL 을 64비트 프로세스에서(또는 반대로) 여는 경우. 마지막 것은 윈도우에서 BadImageFormatException 으로 나기도 합니다.
정리 — extern "C" __declspec(dllexport) 로 내보내고, C# 에서는 확장자 없는 이름으로 [LibraryImport] 하고, csproj 로 DLL 을 출력 폴더에 복사합니다. 이 셋만 맞으면 첫 호출은 무조건 성공합니다.
C# 03

마샬링 규칙 — 무엇이 그냥 통과하고 무엇이 변환되나

P/Invoke 의 거의 모든 버그와 거의 모든 비용이 “타입이 경계에서 어떻게 변환되는가” 한 곳에 몰려 있습니다. 규칙은 의외로 짧습니다 — 메모리 모양이 같으면 그냥 통과하고, 다르면 변환기가 낍니다.

경계를 넘는 두 가지 방식 — 왼쪽이면 공짜, 오른쪽이면 비용 그대로 통과 — blittable 변환기가 낀다 — 비용 발생 int32_t ↔ int · int64_t ↔ long · uint8_t ↔ byte float ↔ float · double ↔ double 포인터 ↔ IntPtr · nint · T* blittable 필드만 가진 struct 그 struct 의 배열 · Span<T> · ReadOnlySpan<T> enum (기반 타입이 정수라면) bool — 폭이 1바이트인지 4바이트인지 모호 string ↔ char* — 인코딩 + 버퍼 할당 char ↔ UTF-16 문자 (기본값이 함정) std::string · std::vector · C++ class — 불가 delegate — 스텁 생성 + GC 고정 필요 class · 참조 타입 전반 — 별도 규칙 필요 함정 1위 — C++ 의 long 은 OS 마다 폭이 다릅니다 윈도우(LLP64) 4바이트 · 리눅스/맥(LP64) 8바이트 · C# 의 long 은 언제나 8바이트 → int64_t 로 못 박으세요 규칙 하나 — 경계에 노출하는 타입은 폭이 고정된 것만 씁니다
왼쪽만 쓰도록 API 를 설계하면 마샬링 버그가 구조적으로 사라집니다.

타입 대응표 — 붙여 놓고 쓰세요 ★

C / C++C#비고
int8_t / charsbyte부호 있음
uint8_t / unsigned charbyte바이트 버퍼
int16_t / shortshort
int32_t / intint가장 안전
int64_t / long longlong
long쓰지 마세요OS 마다 폭이 다름
size_t / ptrdiff_tnuint / nint포인터 폭
float / doublefloat / doubleIEEE754 동일
bool (C++)[MarshalAs(U1)] bool1바이트로 못 박음
BOOL (Win32)[MarshalAs(Bool)] bool4바이트
void* / T*IntPtr / T*k05 의 핸들
const char* (UTF-8)string + Utf8인코딩 비용
wchar_t* (윈도우)string + Utf16복사 없음
실무 규칙 한 줄 — 경계에 노출하는 헤더에서는 <cstdint> 의 고정폭 타입만 씁니다. int 는 사실상 모든 현대 플랫폼에서 32비트라 넘어가지만, long · size_t · bool 세 개는 실제로 사고를 냅니다.

bool 함정 — 왜 값이 뒤집히나

// C++ 쪽
extern "C" MYLIB_API bool mylib_is_ready(void);   // sizeof(bool) == 1
extern "C" MYLIB_API void mylib_set_flag(bool on);
// ✗ 이렇게 두면 LibraryImport 가 컴파일 에러를 냅니다
//    bool 은 blittable 이 아니라 폭을 알 수 없기 때문입니다
[LibraryImport("mylib")]
internal static partial bool mylib_is_ready();      // SYSLIB1051 계열 오류

// ✓ 폭을 명시합니다 — C++ 의 bool 은 1바이트
[LibraryImport("mylib")]
[return: MarshalAs(UnmanagedType.U1)]
internal static partial bool mylib_is_ready();

[LibraryImport("mylib")]
internal static partial void mylib_set_flag(
    [MarshalAs(UnmanagedType.U1)] bool on);

// ✓ 아예 오해가 불가능하게 — C++ 쪽을 int32_t 로 바꾸는 편이 낫습니다
//    extern "C" int32_t mylib_is_ready(void);
[LibraryImport("mylib")]
internal static partial int mylib_is_ready();       // 0 / 1 로만 약속
왜 이게 사고가 되나 — 구방식 [DllImport] 의 bool 기본값은 Win32 의 4바이트 BOOL 입니다. C++ 의 1바이트 bool 을 받으면 나머지 3바이트의 쓰레기값까지 읽습니다. 값이 “가끔” 틀리는, 최악의 종류의 버그입니다. LibraryImport 가 컴파일 에러로 막아 주는 것이 큰 장점 중 하나입니다.

이렇게도 쓸 수 있다 — 문자열을 주고받는 세 가지 모양 🟡 중급

① 넘기기 · ② 받아오기

// C++ — UTF-8 을 기준으로 삼습니다 (세 OS 공통)
extern "C" {
// ① 받기만 — 소유권은 C# 에 있고 C++ 는 읽기만
MYLIB_API int32_t mylib_len(const char* utf8);

// ② 돌려주기 — C# 이 버퍼를 주고 C++ 가 채웁니다 ★ 가장 안전
//    반환값 = 필요한 바이트 수 (buf 가 작으면 -1)
MYLIB_API int32_t mylib_greet(const char* name, char* buf, int32_t cap);
}
internal static partial class Native
{
    // ① string → const char* : 생성기가 UTF-8 로 인코딩합니다
    [LibraryImport("mylib", StringMarshalling = StringMarshalling.Utf8)]
    internal static partial int mylib_len(string utf8);

    // ② 버퍼는 Span<byte> 로 — 핀 고정만 되고 복사가 없습니다
    [LibraryImport("mylib", StringMarshalling = StringMarshalling.Utf8)]
    internal static partial int mylib_greet(string name, Span<byte> buf, int cap);
}

public static string Greet(string name)
{
    Span<byte> buf = stackalloc byte[256];        // 작은 건 스택에서
    int n = Native.mylib_greet(name, buf, buf.Length);
    if (n < 0) throw new InvalidOperationException("버퍼가 작습니다");
    return Encoding.UTF8.GetString(buf[..n]);     // 여기서 한 번만 복사
}
이 “버퍼를 주고 채워 받는” 모양이 표준입니다 — C++ 가 malloc 한 문자열을 돌려주면 누가 free 하느냐가 매번 문제가 됩니다. C# 이 버퍼를 소유하면 그 질문 자체가 사라집니다. Win32 API 도 대부분 이 모양입니다.

③ C++ 가 할당한 문자열을 받아야 할 때

// C++ 가 소유하는 두 가지 방식
extern "C" {
// (a) 수명이 긴 정적 문자열 — 해제하지 않습니다 (에러 메시지에 적합)
MYLIB_API const char* mylib_last_error(void);

// (b) 새로 할당해 돌려주고 전용 해제 함수를 함께 제공 ★
MYLIB_API char* mylib_render(const char* tpl);
MYLIB_API void  mylib_free_string(char* p);
}
// (a) 해제 불필요 — IntPtr 로 받아 직접 읽습니다
[LibraryImport("mylib")]
private static partial IntPtr mylib_last_error();

public static string LastError() =>
    Marshal.PtrToStringUTF8(mylib_last_error()) ?? "";

// (b) 반드시 같은 라이브러리의 해제 함수로 돌려줍니다
[LibraryImport("mylib", StringMarshalling = StringMarshalling.Utf8)]
private static partial IntPtr mylib_render(string tpl);

[LibraryImport("mylib")]
private static partial void mylib_free_string(IntPtr p);

public static string Render(string tpl)
{
    IntPtr p = mylib_render(tpl);
    if (p == IntPtr.Zero) throw new OutOfMemoryException();
    try { return Marshal.PtrToStringUTF8(p)!; }   // 여기서 C# 이 복사
    finally { mylib_free_string(p); }             // ★ finally 필수
}
절대 Marshal.FreeHGlobal 로 해제하지 마세요 — C++ 가 new[]/malloc 으로 잡은 메모리를 C# 의 할당자로 풀면 힙이 깨집니다. 윈도우에서는 DLL 마다 CRT 힙이 다를 수도 있어 더 위험합니다. “할당한 쪽이 해제한다”가 유일한 안전 규칙입니다.

실무에선 이렇게 — 호출 규약과 에러 코드 🔴 실무

CallingConvention — 언제 신경 쓰나

// 64비트에서는 호출 규약이 사실상 하나뿐이라 신경 쓸 일이 거의 없습니다.
// 문제가 되는 곳은 딱 둘입니다 — 32비트 윈도우, 그리고 Win32 API.

// ① 명시하는 법 (LibraryImport 방식)
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;

[LibraryImport("mylib")]
[UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])]   // 기본값
internal static partial int mylib_add(int a, int b);

[LibraryImport("user32")]
[UnmanagedCallConv(CallConvs = [typeof(CallConvStdcall)])] // Win32 API
internal static partial int MessageBoxW(IntPtr h, string t, string c, uint ty);

// ② 구방식이라면
[DllImport("mylib", CallingConvention = CallingConvention.Cdecl)]
static extern int mylib_add(int a, int b);
32비트에서 규약이 틀리면 — 값은 맞게 돌아오는데 스택이 조금씩 어긋납니다. 몇백 번 호출한 뒤 엉뚱한 곳에서 죽습니다. 증상이 “호출 직후”가 아니라 “한참 뒤에 랜덤하게”라서 원인을 찾기가 최악입니다. 64비트만 지원한다고 못 박는 것이 실무에서 가장 큰 절약입니다.

SetLastError — 진짜 필요할 때만

// SetLastError = true 는 '호출 직후 즉시' errno / GetLastError 를
// 런타임이 대신 읽어 보관해 줍니다. 그냥 두면 그 사이 다른 호출이
// 값을 덮어써서 못 읽습니다.

[LibraryImport("kernel32", SetLastError = true,
               StringMarshalling = StringMarshalling.Utf16)]
private static partial IntPtr CreateFileW(
    string path, uint access, uint share, IntPtr sa,
    uint disposition, uint flags, IntPtr template);

var h = CreateFileW(path, 0x80000000, 1, 0, 3, 0x80, 0);
if (h == new IntPtr(-1))
{
    int err = Marshal.GetLastPInvokeError();              // .NET 6+
    string msg = Marshal.GetLastPInvokeErrorMessage();    // .NET 8+
    throw new IOException($"열기 실패 ({err}): {msg}");
}
내가 만든 C++ 라이브러리에는 대개 필요 없습니다 — SetLastError 는 OS API 규약이지 일반 C 함수의 규약이 아닙니다. 내 라이브러리는 정수 에러 코드 반환 + mylib_last_error() 조합이 낫습니다(k07). 불필요하게 켜면 호출마다 약간의 오버헤드가 붙습니다.

한 단계 더 — 런타임 마샬링을 아예 끄기 🧬 전문가

DisableRuntimeMarshalling

// AssemblyInfo.cs — 어셈블리 전체에 한 번
using System.Runtime.CompilerServices;
[assembly: DisableRuntimeMarshalling]

// 이걸 켜면 어셈블리 안의 모든 P/Invoke 에서
//   · 자동 문자열 변환    → 금지 (직접 byte* 로 다뤄야 함)
//   · 자동 bool 변환      → 금지
//   · 자동 구조체 변환    → 금지 (blittable 만 허용)
//   · [MarshalAs]         → 무시
// 대신
//   · 호출이 '순수한 점프'가 되어 오버헤드가 최소가 됩니다
//   · NativeAOT 와 완전히 일치하는 동작을 보장합니다
//   · 무엇이 복사되는지 실수할 여지가 사라집니다
누가 쓰나 — 그래픽·오디오·게임 엔진 바인딩처럼 초당 수만~수백만 번 호출하고, 타입이 이미 전부 blittable 인 라이브러리입니다(Silk.NET · Vortice 등이 이 방식). 일반 업무 앱에서는 얻는 것보다 잃는 편의가 큽니다 — 필요할 때만 꺼내세요.

타입 크기를 실제로 확인하는 습관

// C++ 쪽 — 컴파일 시점에 못 박습니다
#include <cstdint>
static_assert(sizeof(int32_t) == 4);
static_assert(sizeof(void*)   == 8, "64비트만 지원합니다");
static_assert(sizeof(MyStruct) == 16, "레이아웃이 바뀌었습니다");
// C# 쪽 — 테스트로 못 박습니다
[Fact]
public void 구조체_크기가_C쪽과_같다()
{
    // 관리 코드가 보는 크기
    Assert.Equal(16, Unsafe.SizeOf<MyStruct>());
    // 마샬링 규칙을 적용했을 때의 크기 (blittable 이면 둘이 같아야 합니다)
    Assert.Equal(16, Marshal.SizeOf<MyStruct>());
    // C++ 에게 직접 물어보기 — 가장 확실합니다 ★
    Assert.Equal(16, Native.mylib_sizeof_mystruct());
}
Unsafe.SizeOf 와 Marshal.SizeOf 가 다르면 위험 신호입니다 — 그 타입은 blittable 이 아니라 변환이 일어나고 있다는 뜻입니다. 그리고 C++ 에 크기를 물어보는 함수를 하나 노출해 두는 습관은 “빌드 설정이 달라 패딩이 어긋났다”는 종류의 사고를 테스트에서 잡아 줍니다.
정리 — 메모리 모양이 같으면 공짜, 다르면 변환입니다. 경계에는 고정폭 정수 · 실수 · 포인터만 노출하고, long · bool · size_t 는 피하고, 문자열은 UTF-8 + 버퍼 채우기로 통일하세요.
C# 04

구조체 · 배열 — 복사 없이 넘기는 법

C++ 로 넘어가는 이유가 “빠르게”라면, 경계에서 데이터를 복사하는 순간 그 이유가 절반 사라집니다. 구조체 배치를 C++ 와 똑같이 맞추고 배열을 핀 고정해서 넘기면, 8천만 바이트도 0바이트 복사로 건네집니다.

두 가지를 맞추면 복사가 사라집니다 — 배치와 주소 ① 배치 — Sequential 이면 C++ 와 바이트 단위로 같습니다 int32 id (4바이트) 패딩 (4바이트) double score (8바이트) 0 4 8 16 = sizeof 패딩은 double 이 8의 배수 주소에 놓여야 해서 생깁니다 — C++ 도 똑같이 넣습니다 ② 주소 — 핀을 박으면 GC 가 못 옮깁니다 double[] 1천만 개 관리 힙 — GC 가 옮길 수 있음 fixed / Span 핀 이 블록 동안 이동 금지 C++ 가 같은 주소를 읽음 복사 0바이트 80MB 배열을 넘길 때 — 복사하면 밀리초 단위, 핀 고정은 사실상 0 복사 비용은 순수하게 메모리 대역폭입니다. 배열이 클수록 “복사 없이”가 전부를 결정합니다 핀은 길게 잡지 마세요 — 오래 잡으면 GC 가 힙을 정리하지 못합니다
복사가 있는지 없는지는 생성된 스텁에 fixed 가 보이는지로 확인합니다.

구조체를 양쪽에 똑같이 정의하기

// C++ — 경계용 구조체는 POD 로만 만듭니다
#include <cstdint>

struct Point {
    double x;
    double y;
};

struct Record {
    int32_t id;        // 0 ~ 3
                       // 4 ~ 7 : 컴파일러가 넣는 패딩
    double  score;     // 8 ~ 15
};
static_assert(sizeof(Record) == 16);

extern "C" MYLIB_API double mylib_dist(Point a, Point b);           // 값으로 전달
extern "C" MYLIB_API void   mylib_fill(Record* out, int32_t n);     // 포인터로
using System.Runtime.InteropServices;

// Sequential = 선언 순서 그대로 · 정렬도 C++ 규칙과 같게
[StructLayout(LayoutKind.Sequential)]
internal struct Point { public double X; public double Y; }

[StructLayout(LayoutKind.Sequential)]
internal struct Record { public int Id; public double Score; }
//  ★ C# 도 여기에 4바이트 패딩을 넣습니다 — 그래서 16바이트로 일치합니다

internal static partial class Native
{
    [LibraryImport("mylib")]
    internal static partial double mylib_dist(Point a, Point b);

    [LibraryImport("mylib")]
    internal static partial void mylib_fill(Span<Record> buf, int n);
}
Sequential 이 기본값 아닌가요? — C# 의 struct 는 명시하지 않으면 런타임이 필드 순서를 바꿀 수 있습니다(Auto). 실제로는 대부분 순서를 유지하지만 보장이 아닙니다. 경계에 나가는 구조체에는 반드시 [StructLayout(LayoutKind.Sequential)] 를 붙이세요.

패딩이 어긋나면 어떤 일이 벌어지나

C++ 가 #pragma pack(1) 로 빌드되었는데
C# 은 기본 정렬을 쓰는 경우

  C++  [ id(4) ][ score(8) ]                    = 12바이트
  C#   [ id(4) ][ pad(4) ][ score(8) ]          = 16바이트

  → C# 이 만든 배열의 1번 원소는 오프셋 16
    C++ 는 오프셋 12 를 1번 원소로 읽습니다
  → 값이 '조금씩 밀려서' 나옵니다. 크래시도 안 납니다 ★

가장 나쁜 종류의 버그입니다 — 조용히 틀린 답을 냅니다

해결
  ① 양쪽 다 기본 정렬을 쓴다 (권장)
  ② 굳이 팩킹한다면 양쪽 다 명시
       C++ : #pragma pack(push, 1) ... #pragma pack(pop)
       C#  : [StructLayout(LayoutKind.Sequential, Pack = 1)]
  ③ 크기를 테스트로 못 박는다
       Assert.Equal(Native.mylib_sizeof_record(), Marshal.SizeOf<Record>())
// 필드 오프셋을 직접 지정해야 할 때 (공용체 · 프로토콜 헤더)
[StructLayout(LayoutKind.Explicit)]
internal struct Packet
{
    [FieldOffset(0)]  public uint   Kind;
    [FieldOffset(4)]  public int    AsInt;      // ┐ 같은 4바이트를
    [FieldOffset(4)]  public float  AsFloat;    // ┘ 두 이름으로 (union)
    [FieldOffset(8)]  public double Payload;
}

이렇게도 쓸 수 있다 — 배열을 넘기는 네 가지 방법 🟡 중급

Span · fixed — 일상적으로 쓰는 두 가지 ★

// ① Span<T> / ReadOnlySpan<T> — 원소가 blittable 이면 그대로 핀 고정
[LibraryImport("mylib")]
internal static partial void mylib_scale(Span<double> data, long n, double k);

double[] xs = new double[10_000_000];
Native.mylib_scale(xs, xs.LongLength, 2.0);     // 복사 없음

// 배열 일부만 넘기기 — 오프셋도 복사 없이 표현됩니다
Native.mylib_scale(xs.AsSpan(1000, 500), 500, 2.0);

// 스택 버퍼도 그대로
Span<double> tmp = stackalloc double[64];
Native.mylib_scale(tmp, 64, 2.0);


// ② unsafe + fixed — 포인터를 여러 개 동시에 다룰 때
[LibraryImport("mylib")]
internal static unsafe partial void mylib_mix(double* a, double* b,
                                              double* outp, long n);

public static unsafe void Mix(double[] a, double[] b, double[] o)
{
    fixed (double* pa = a, pb = b, po = o)      // 셋을 한 번에 고정
        Native.mylib_mix(pa, pb, po, a.LongLength);
}   // 블록을 벗어나면 자동으로 핀 해제
빈 배열 조심 — fixed (double* p = arr) 에서 배열 길이가 0 이면 p 는 null 이 됩니다. C++ 쪽이 n == 0 을 처리하지 못하면 그대로 죽습니다. Span 방식도 마찬가지이므로 C++ 함수 첫 줄에 if (n <= 0) return; 를 넣어 두세요.

GCHandle · NativeMemory — 오래 붙잡아야 할 때

// ③ GCHandle.Alloc(Pinned) — 핀을 '블록' 이 아니라 '기간' 으로 잡습니다
//    C++ 가 포인터를 보관했다가 나중에 쓰는 경우에만 필요합니다
var handle = GCHandle.Alloc(buffer, GCHandleType.Pinned);
try
{
    IntPtr p = handle.AddrOfPinnedObject();
    Native.mylib_register_buffer(p, buffer.Length);   // C++ 가 보관
    ... 오래 사용 ...
    Native.mylib_unregister_buffer();
}
finally { handle.Free(); }        // ★ 잊으면 그 메모리는 영원히 고정됩니다

// ④ 아예 관리 힙 밖에 잡기 — 핀 걱정이 없습니다 (.NET 6+)
unsafe
{
    double* buf = (double*)NativeMemory.AlignedAlloc(
        (nuint)(n * sizeof(double)), 64);      // 64바이트 정렬 (SIMD 친화)
    try { Native.mylib_scale(buf, n, 2.0); }
    finally { NativeMemory.AlignedFree(buf); }
}
핀은 짧게 — 고정된 객체는 GC 가 힙을 압축할 때 옮길 수 없어 그 주변에 구멍을 남깁니다. 큰 배열을 오래 고정하면 단편화가 생겨 나중에 OutOfMemoryException 까지 갑니다. C++ 가 포인터를 보관해야 한다면 아예 ④번처럼 네이티브 메모리를 쓰는 편이 깨끗합니다.

실무에선 이렇게 — 큰 데이터의 실제 설계 🔴 실무

이미지 한 장을 넘기는 실전 모양

// C++ — 입력·출력 버퍼를 모두 호출자가 준비합니다
extern "C" MYLIB_API int32_t mylib_resize(
    const uint8_t* src, int32_t sw, int32_t sh, int32_t sstride,
    uint8_t*       dst, int32_t dw, int32_t dh, int32_t dstride,
    int32_t channels);
// 반환 0 = 성공. 라이브러리는 메모리를 전혀 할당하지 않습니다 ★
public static byte[] Resize(byte[] src, int sw, int sh, int dw, int dh, int ch)
{
    var dst = new byte[dw * dh * ch];
    int rc = Native.mylib_resize(
        src, sw, sh, sw * ch,
        dst, dw, dh, dw * ch, ch);
    if (rc != 0) throw new InvalidOperationException($"resize 실패 {rc}");
    return dst;
}

// 반복 호출이라면 버퍼를 재사용합니다 — 할당이 사라집니다
private readonly ArrayPool<byte> _pool = ArrayPool<byte>.Shared;

public void ResizeInto(ReadOnlySpan<byte> src, Span<byte> dst, ...)
{
    // 호출자가 버퍼를 소유 → 라이브러리는 순수 함수처럼 동작합니다
}
“라이브러리가 할당하지 않는다”가 왜 중요한가 — 할당하는 순간 누가 해제하나 · 어느 할당자로 해제하나 · 실패하면 누구 책임인가 세 질문이 따라옵니다. 호출자가 버퍼를 주면 셋 다 사라지고, 덤으로 ArrayPool 로 재사용까지 가능해집니다.

stride 를 반드시 받으세요

stride(= 한 줄의 바이트 수) 를 width * channels 로 계산해 버리면
아래 경우에 전부 깨집니다

  · 이미지 일부만 잘라 넘길 때 (ROI)
  · 4바이트·32바이트 정렬을 맞춘 버퍼 (SIMD · GPU 업로드)
  · 다른 라이브러리가 준 버퍼 (WPF WriteableBitmap, OpenCV Mat)

WriteableBitmap 예
  width = 1920, PixelFormat = Bgra32
  BackBufferStride 는 7680 이 아닐 수 있습니다 → 반드시 물어봐야 합니다

경계 함수 시그니처의 규칙
  ✓ 포인터 · 폭 · 높이 · stride · 채널 을 전부 받는다
  ✗ 포인터 · 폭 · 높이 만 받고 나머지를 추측한다
// WPF 비트맵을 복사 없이 그대로 처리하기
bmp.Lock();
try
{
    unsafe
    {
        Native.mylib_blur((byte*)bmp.BackBuffer,
                          bmp.PixelWidth, bmp.PixelHeight,
                          bmp.BackBufferStride, 4, radius);
    }
    bmp.AddDirtyRect(new Int32Rect(0, 0, bmp.PixelWidth, bmp.PixelHeight));
}
finally { bmp.Unlock(); }        // Lock 구간이 곧 핀 구간입니다

한 단계 더 — 복사가 남아 있는지 확인하는 법 🧬 전문가

할당을 숫자로 확인

[MemoryDiagnoser]                     // Allocated 열이 생깁니다
public class ArrayPass
{
    private readonly double[] _data = new double[10_000_000];

    [Benchmark]                       // Allocated = 0 B  ← 정답
    public void ViaSpan() => Native.mylib_scale(_data, _data.Length, 2);

    [Benchmark]                       // Allocated = 80 MB ← 복사가 있다
    public void ViaCopy()
    {
        var tmp = (double[])_data.Clone();
        Native.mylib_scale(tmp, tmp.Length, 2);
    }
}

// 더 간단한 확인 — GC 통계
long before = GC.GetAllocatedBytesForCurrentThread();
Native.mylib_scale(_data, _data.Length, 2);
long after  = GC.GetAllocatedBytesForCurrentThread();
Console.WriteLine($"할당 {after - before} 바이트");   // 0 이면 성공
이 한 줄짜리 검사를 테스트로 만들어 두세요 — 누군가 시그니처를 Span<double> 에서 double[] 로, 혹은 StringMarshalling 을 건드리는 순간 조용히 복사가 부활합니다. 성능 회귀는 기능 테스트로는 절대 잡히지 않습니다.

2차원 · 다차원 데이터

// C# 의 double[,] 는 '한 덩어리' 로 저장되므로 그대로 넘길 수 있습니다
double[,] m = new double[1000, 1000];
unsafe
{
    fixed (double* p = &m[0, 0])           // ★ 첫 원소의 주소
        Native.mylib_matmul(p, 1000, 1000);
}
// C# 의 다차원 배열은 행 우선(row-major) — C++ 의 기본과 같습니다
// (Fortran·MATLAB 계열 라이브러리는 열 우선이라 전치가 필요합니다)

// double[][] (배열의 배열) 은 조각조각 흩어져 있습니다 — 그대로 못 넘깁니다
//   → 평탄화하거나, 포인터 배열을 따로 만들어 넘겨야 합니다
var flat = new double[rows * cols];        // 이 형태를 기본으로 쓰세요

// 최신 방식 — Tensor 스타일 API 를 만들 때
Span2D 나 ReadOnlySpan2D 는 CommunityToolkit.HighPerformance 패키지에
있습니다. 경계 함수에는 결국 (포인터, 행, 열, stride) 로 풀어 넘깁니다
double[][] 를 넘기려다 막히는 사람이 정말 많습니다 — C# 의 [][] 는 각 행이 별도의 객체라 연속되어 있지 않습니다. [,](다차원)나 평탄화한 1차원 배열을 기본형으로 쓰세요. C++ 와 데이터를 주고받을 계획이 있다면 처음부터 평탄 배열로 설계하는 것이 훨씬 편합니다.
정리 — 구조체에는 [StructLayout(Sequential)], 배열에는 Span<T> 또는 fixed, 오래 붙잡아야 하면 GCHandle 대신 네이티브 메모리. 복사가 남았는지는 Allocated 0 바이트로 확인합니다.
C# 05

핸들과 수명 — SafeHandle 로 누수를 구조적으로 막기

C++ 객체는 경계를 넘지 못합니다. 대신 불투명 포인터(핸들)를 건네고, C# 은 그것을 객체처럼 보이게 감쌉니다. 이때 IntPtr 로 들고 있으면 해제를 잊는 순간 그냥 누수이고, SafeHandle 로 감싸면 런타임이 뒤를 봐 줍니다.

핸들 하나의 일생 — 마지막 줄이 SafeHandle 을 쓰는 이유입니다 1 생성 — mylib_engine_create() 가 포인터를 돌려줍니다 SafeHandle 이 그 포인터를 감싸 보관합니다. C# 쪽에서는 그냥 객체로 보입니다 2 사용 — P/Invoke 가 호출 동안 참조를 올립니다 다른 스레드가 동시에 Dispose 해도 이 호출이 끝나기 전에는 해제되지 않습니다 3 해제 — using 을 벗어나면 Dispose → ReleaseHandle 여기서 mylib_engine_destroy() 가 딱 한 번 불립니다. 두 번 불릴 수 없습니다 4 깜빡했을 때 — 임계 종료자가 뒤늦게라도 해제합니다 늦을 뿐 새지는 않습니다. 프로세스가 강제 종료돼도 OS 가 정리합니다 5 IntPtr 로 들고 있었다면 — 아무 일도 일어나지 않습니다 GC 는 그것이 포인터인지 그냥 숫자인지 모릅니다. 그대로 누수입니다 IntPtr 은 “숫자”, SafeHandle 은 “책임” — 공개 API 에는 SafeHandle 만 내보내세요
누수를 규율이 아니라 타입으로 막는 것이 핵심입니다.

C++ 쪽 — 불투명 핸들 세 함수

// mylib.h — 내부 구조를 절대 노출하지 않습니다
typedef struct MylibEngine MylibEngine;    // 정의 없이 이름만 ★

extern "C" {
MYLIB_API MylibEngine* mylib_engine_create(int32_t window);
MYLIB_API void         mylib_engine_destroy(MylibEngine* e);
MYLIB_API int32_t      mylib_engine_push(MylibEngine* e, double v);
MYLIB_API double       mylib_engine_value(const MylibEngine* e);
}
// mylib.cpp — 진짜 구현은 평범한 C++ 클래스입니다
struct MylibEngine {
    std::deque<double> buf;
    int window;
    explicit MylibEngine(int w) : window(w) {}
};

extern "C" MylibEngine* mylib_engine_create(int32_t window) {
    if (window <= 0) return nullptr;
    try { return new MylibEngine(window); }
    catch (...) { return nullptr; }        // ★ 예외를 경계 밖으로 내보내지 않습니다
}

extern "C" void mylib_engine_destroy(MylibEngine* e) {
    delete e;                              // nullptr 이어도 안전합니다
}
불투명 타입의 이득 — C# 은 MylibEngine 안에 무엇이 있는지 영원히 모릅니다. 덕분에 C++ 쪽이 std::deque 를 ring buffer 로 바꿔도 C# 을 다시 컴파일할 필요가 없습니다. 이게 ABI 안정성의 실체입니다.

C# 쪽 — SafeHandle 로 감싸기 ★

using Microsoft.Win32.SafeHandles;
using System.Runtime.InteropServices;

// 0 또는 -1 이면 무효로 보는 표준 베이스 클래스
internal sealed class EngineHandle : SafeHandleZeroOrMinusOneIsInvalid
{
    private EngineHandle() : base(ownsHandle: true) { }
    //       ^^^^^^^^ private — P/Invoke 반환값으로만 만들어집니다

    protected override bool ReleaseHandle()
    {
        Native.mylib_engine_destroy(handle);   // 딱 한 번만 불립니다
        return true;
    }
}

internal static partial class Native
{
    // 반환 타입을 SafeHandle 로 두면 생성기가 알아서 감쌉니다
    [LibraryImport("mylib")]
    internal static partial EngineHandle mylib_engine_create(int window);

    [LibraryImport("mylib")]
    internal static partial void mylib_engine_destroy(IntPtr e);

    [LibraryImport("mylib")]
    internal static partial int mylib_engine_push(EngineHandle e, double v);

    [LibraryImport("mylib")]
    internal static partial double mylib_engine_value(EngineHandle e);
}
ReleaseHandle 안에서는 아무것도 하지 마세요 — 로그도, 락도, 예외를 던지는 코드도 안 됩니다. 이 메서드는 임계 종료자(CriticalFinalizer) 컨텍스트에서 불릴 수 있어 실패하면 프로세스가 위험해집니다. 해제 함수 호출 한 줄이면 충분합니다.

이렇게도 쓸 수 있다 — 사람이 쓰기 좋은 겉껍질 🟡 중급

공개 클래스 — 이 층만 사용자에게 보입니다

public sealed class Engine : IDisposable
{
    private readonly EngineHandle _h;

    public Engine(int window)
    {
        _h = Native.mylib_engine_create(window);
        if (_h.IsInvalid)                       // 생성 실패를 즉시 예외로
            throw new ArgumentOutOfRangeException(nameof(window),
                "window 는 1 이상이어야 합니다");
    }

    public void Push(double v)
    {
        ObjectDisposedException.ThrowIf(_h.IsClosed, this);   // .NET 8+
        int rc = Native.mylib_engine_push(_h, v);
        if (rc != 0) throw new InvalidOperationException($"push 실패 {rc}");
    }

    public double Value => Native.mylib_engine_value(_h);

    public void Dispose() => _h.Dispose();      // 그게 전부입니다 ★
}
// 쓰는 쪽 — C# 사용자는 C++ 가 있다는 사실조차 몰라도 됩니다
using var e = new Engine(window: 4);
foreach (var v in samples) e.Push(v);
Console.WriteLine(e.Value);
두 겹으로 나누는 이유 — Native(원시) 는 C++ 헤더를 그대로 옮긴 거울이라 헤더가 바뀌면 그대로 따라 고치면 되고, Engine(공개) 은 C# 답게 이름·예외·IDisposable 을 갖춥니다. 섞어 놓으면 헤더가 바뀔 때마다 공개 API 까지 흔들립니다.

IntPtr 만 쓰던 코드를 옮길 때

// 과거 방식 — 지금도 아주 많이 보입니다
public sealed class OldEngine : IDisposable
{
    private IntPtr _p;

    public OldEngine(int w) => _p = Native.raw_create(w);

    public void Dispose()
    {
        if (_p != IntPtr.Zero)
        {
            Native.raw_destroy(_p);
            _p = IntPtr.Zero;
        }
        GC.SuppressFinalize(this);
    }
    ~OldEngine() => Dispose();     // 종료자로 보완은 되지만…
}

// 이 코드의 실제 문제 세 가지
//  ① Dispose 와 다른 스레드의 호출이 겹치면 use-after-free
//  ② 종료자는 '보장' 이 아닙니다 (프로세스 종료 시 안 돌 수 있음)
//  ③ Dispose 를 두 번 호출하는 경쟁 조건에서 이중 해제 가능
IntPtrSafeHandle
해제 누락그냥 누수종료자가 보완
이중 해제가능 (크래시)불가능
호출 중 해제use-after-free런타임이 막음
프로세스 급종료OS 가 정리OS 가 정리
코드량비슷비슷
“코드량이 비슷한데 안전성만 다르다” — 이게 SafeHandle 을 쓰지 않을 이유가 없는 이유입니다. 새 코드는 무조건 SafeHandle, 기존 코드는 공개 API 를 건드리지 않고 내부 IntPtr 만 갈아끼우면 되므로 옮기기도 쉽습니다.

실무에선 이렇게 — 소유권을 문서와 이름으로 못 박기 🔴 실무

헤더에 규약을 적습니다

// mylib.h — 주석이 곧 계약입니다
extern "C" {

/// 새 엔진을 만듭니다.
/// @return  소유권이 호출자에게 넘어갑니다.
///          반드시 mylib_engine_destroy() 로 해제하세요.
///          실패 시 nullptr.
MYLIB_API MylibEngine* mylib_engine_create(int32_t window);

/// 엔진 내부의 이름을 가리키는 포인터를 돌려줍니다.
/// @return  소유권은 여전히 엔진에 있습니다. 해제하지 마세요. ★
///          엔진이 살아 있는 동안에만 유효합니다.
MYLIB_API const char* mylib_engine_name(const MylibEngine* e);

/// 새로 할당한 문자열을 돌려줍니다.
/// @return  mylib_free_string() 으로 해제하세요.
MYLIB_API char* mylib_engine_report(const MylibEngine* e);

}
세 가지 반환 유형밖에 없습니다 — ① 내가 해제한다(create/destroy 쌍), ② 빌려 본다(수명은 부모에 붙음), ③ 새로 할당했으니 전용 함수로 해제한다. 함수마다 이 셋 중 무엇인지 한 줄로 적어 두면 바인딩을 쓰는 쪽의 사고가 90% 사라집니다.

“빌려 본” 포인터의 함정

// ✗ 위험 — 엔진이 죽은 뒤에도 문자열을 들고 있다고 착각
IntPtr namePtr;
using (var e = new Engine(4))
    namePtr = Native.mylib_engine_name(e.Handle);
var s = Marshal.PtrToStringUTF8(namePtr);      // ← 이미 해제된 메모리 ★

// ✓ 안전 — 경계 안에서 즉시 C# 문자열로 복사합니다
public string Name
{
    get
    {
        IntPtr p = Native.mylib_engine_name(_h);
        GC.KeepAlive(this);                     // 읽는 동안 살아 있게
        return Marshal.PtrToStringUTF8(p) ?? "";
    }
}
// 부모-자식 핸들 — 자식이 부모보다 오래 살면 안 될 때
public sealed class Cursor : IDisposable
{
    private readonly Engine _owner;      // ★ 참조를 들고 있어 GC 를 막습니다
    private readonly CursorHandle _h;

    internal Cursor(Engine owner) { _owner = owner; _h = ...; }
    public void Dispose() => _h.Dispose();
}
// 이렇게 두면 "부모가 먼저 수거되어 자식이 허공을 가리키는" 사고가 사라집니다
GC.KeepAlive 가 필요한 진짜 이유 — .NET 의 GC 는 “이 뒤로 더 안 쓰이는” 객체를 메서드가 끝나기 전에도 수거할 수 있습니다. _h 에서 꺼낸 포인터만 쓰고 있으면 객체 자체는 이미 죽은 것으로 판정될 수 있습니다. SafeHandle 을 인자로 직접 넘길 때는 런타임이 처리하지만, IntPtr 을 꺼내 쓴 순간부터는 내 책임입니다.

한 단계 더 — 핸들 오용을 타입으로 막기 🧬 전문가

서로 다른 핸들을 섞지 못하게

// ✗ 전부 IntPtr 이면 컴파일러가 아무것도 막아 주지 않습니다
Native.mylib_engine_push(cursorPtr, 1.0);      // 컴파일 통과 → 실행 중 크래시

// ✓ 핸들마다 별도 타입 — 잘못 넘기면 컴파일 에러
internal sealed class EngineHandle : SafeHandleZeroOrMinusOneIsInvalid { }
internal sealed class CursorHandle : SafeHandleZeroOrMinusOneIsInvalid { }

// 구조체 기반 강타입 핸들 (할당 0 · 최고 성능 경로용)
internal readonly record struct SessionId(nint Value)
{
    public bool IsValid => Value != 0;
}
[LibraryImport("mylib")]
internal static partial SessionId mylib_open(int flags);   // blittable 유지
readonly record struct 로 감싸도 blittable 입니다 — 필드가 nint 하나뿐이면 런타임이 그냥 정수 하나로 취급합니다. 즉 성능 손해 0으로 타입 안전성만 얻습니다. 다만 이 방식에는 자동 해제가 없으니 수명이 짧고 명확한 경우에만 쓰세요.

누수를 테스트로 잡기

// C++ 쪽에 살아 있는 객체 수를 세는 함수를 하나 노출합니다
static std::atomic<int32_t> g_alive{0};

extern "C" MylibEngine* mylib_engine_create(int32_t w) {
    auto* e = new MylibEngine(w);
    g_alive.fetch_add(1, std::memory_order_relaxed);
    return e;
}
extern "C" void mylib_engine_destroy(MylibEngine* e) {
    if (e) { g_alive.fetch_sub(1, std::memory_order_relaxed); delete e; }
}
extern "C" MYLIB_API int32_t mylib_debug_alive(void) { return g_alive.load(); }
[Fact]
public void 엔진이_남지_않는다()
{
    int before = Native.mylib_debug_alive();

    for (int i = 0; i < 100; i++)
        using (var e = new Engine(4)) e.Push(i);

    Assert.Equal(before, Native.mylib_debug_alive());
}

[Fact]
public void Dispose를_잊어도_종료자가_해제한다()
{
    for (int i = 0; i < 100; i++) _ = new Engine(4);   // 일부러 누락
    GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect();
    Assert.Equal(0, Native.mylib_debug_alive());
}
이 두 테스트가 실무에서 정말 값어치를 합니다 — 누수는 운영 며칠 뒤 메모리 그래프로 발견되는 게 보통인데, 살아 있는 객체 수 하나만 노출해 두면 CI 에서 5초 만에 잡힙니다. 디버그 빌드에만 넣어도 좋고, mylib_debug_* 접두사로 구분해 두면 깔끔합니다.
정리 — C++ 객체는 불투명 핸들로 넘기고, C# 에서는 SafeHandle 파생 클래스 + IDisposable 겉껍질 두 겹으로 감쌉니다. IntPtr 을 공개 API 에 절대 노출하지 마세요.
C# 06

콜백 — C++ 가 C# 을 부르게 만들기

진행률 보고, 이벤트 알림, 정렬 비교 함수 — 네이티브가 관리 코드를 되부르는 상황은 생각보다 자주 옵니다. 원리는 간단합니다. C# 메서드의 주소를 함수 포인터로 만들어 넘기는 것. 함정은 하나뿐입니다 — GC 가 그 대상을 치워 버릴 수 있다는 것.

콜백의 왕복 — 주소를 넘기고, 그 주소가 불립니다 ① 등록 — 관리 코드의 주소를 네이티브에 건네줍니다 C# 정적 메서드 [UnmanagedCallersOnly] 함수 포인터 &Method — 그냥 주소 C++ 가 보관 mylib_set_progress(fp) ② 호출 — 네이티브가 그 주소를 그대로 부릅니다 C# 메서드 실행 진행률을 UI 에 반영 함수 포인터 호출 cb(50, user) C++ 작업 루프 10%마다 보고 델리게이트를 넘겼다면 반드시 필드로 붙잡아 두세요 지역 변수 델리게이트는 GC 가 수거합니다 — 등록은 성공하고, 콜백이 도착하는 순간 프로세스가 죽습니다 정적 메서드 + 함수 포인터를 쓰면 이 문제 자체가 생기지 않습니다
“등록은 됐는데 나중에 죽는다”면 열에 아홉은 델리게이트가 수거된 것입니다.

C++ 쪽 — 표준형은 함수 포인터 + user 포인터

// mylib.h
extern "C" {

// ★ 반드시 void* user 를 함께 받습니다 — 이게 없으면
//   "어느 객체에 대한 콜백인지" 를 표현할 방법이 사라집니다
typedef void (*mylib_progress_fn)(int32_t percent, void* user);

MYLIB_API int32_t mylib_run(const double* data, int64_t n,
                            mylib_progress_fn cb, void* user);
}
// mylib.cpp
extern "C" int32_t mylib_run(const double* data, int64_t n,
                             mylib_progress_fn cb, void* user)
{
    const int64_t step = n / 10 > 0 ? n / 10 : 1;
    for (int64_t i = 0; i < n; ++i) {
        ... 무거운 계산 ...
        if (cb && i % step == 0)
            cb(static_cast<int32_t>(i * 100 / n), user);   // 되부르기
    }
    if (cb) cb(100, user);
    return 0;
}
void* user 는 선택이 아니라 필수입니다 — C# 쪽에서 [UnmanagedCallersOnly] 메서드는 정적 메서드여야 합니다. 인스턴스 상태를 되찾을 유일한 통로가 이 user 포인터입니다. C API 를 설계할 때 콜백 인자에 user 를 빠뜨리면 나중에 못 고칩니다(ABI 변경).

C# 쪽 ① — 함수 포인터 방식 (권장) ★

using System.Runtime.InteropServices;

internal static unsafe partial class Native
{
    [LibraryImport("mylib")]
    internal static partial int mylib_run(
        double* data, long n,
        delegate* unmanaged<int, void*, void> cb,   // ★ C# 9+ 함수 포인터
        void* user);
}

public sealed class Runner
{
    public event Action<int>? Progress;

    // 네이티브에서만 부를 수 있는 정적 진입점
    [UnmanagedCallersOnly]
    private static void OnProgress(int percent, void* user)
    {
        // user 로 넘겨 둔 GCHandle 을 되살려 인스턴스를 찾습니다
        var self = (Runner)GCHandle.FromIntPtr((IntPtr)user).Target!;
        self.Progress?.Invoke(percent);
    }

    public unsafe void Run(double[] data)
    {
        var gch = GCHandle.Alloc(this);            // 약한 참조가 아닌 보통 핸들
        try
        {
            fixed (double* p = data)
                Native.mylib_run(p, data.LongLength,
                                 &OnProgress,      // 주소를 그대로
                                 (void*)GCHandle.ToIntPtr(gch));
        }
        finally { gch.Free(); }
    }
}

이렇게도 쓸 수 있다 — 델리게이트 방식과 그 함정 🟡 중급

델리게이트 방식 — 편하지만 규율이 필요합니다

// 인스턴스 메서드와 람다를 쓸 수 있어 편합니다
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
internal delegate void ProgressDelegate(int percent, IntPtr user);

[DllImport("mylib")]
internal static extern int mylib_run(double[] data, long n,
                                     ProgressDelegate cb, IntPtr user);

// ✗ 이렇게 쓰면 언젠가 죽습니다
public void RunBad(double[] data)
{
    mylib_run(data, data.LongLength,
              (p, _) => Console.WriteLine(p),    // 지역 델리게이트 ★
              IntPtr.Zero);
}   // 호출이 진행되는 동안 GC 가 이 델리게이트를 수거할 수 있습니다

// ✓ 필드로 붙잡아 둡니다 — 등록이 살아 있는 동안 계속
private ProgressDelegate? _cb;

public void RunGood(double[] data)
{
    _cb = (p, _) => Progress?.Invoke(p);         // 필드에 보관 ★
    mylib_run(data, data.LongLength, _cb, IntPtr.Zero);
    GC.KeepAlive(_cb);                           // 확실히 하기 위해
}
왜 이런 일이 생기나 — 델리게이트를 네이티브로 넘길 때 런타임은 그 델리게이트를 부르는 작은 네이티브 스텁을 만들어 그 주소를 넘깁니다. C++ 는 그 스텁 주소만 보관하는데, GC 는 “관리 코드에서 아무도 이 델리게이트를 참조하지 않는다”고 판단합니다. 수거되면 스텁도 사라지고, C++ 는 없어진 주소로 점프합니다.

두 방식 비교 — 무엇을 언제

delegate함수 포인터
인스턴스 메서드가능불가 (static 만)
람다 · 클로저가능불가
GC 수거 위험있음없음
호출 비용스텁 경유직접 점프
NativeAOT제약 있음완전 지원
IL2CPP(Unity)속성 필요안전
코드 가독성쉬움unsafe 필요
// 절충안 — 함수 포인터로 넘기되, 인스턴스는 GCHandle 로 되찾기
//   (앞 카드의 Runner 가 정확히 이 형태입니다)
//
//   장점 : GC 수거 위험 0 · NativeAOT/IL2CPP 안전 · 인스턴스 상태 접근 가능
//   비용 : GCHandle 알로케이션 한 번 (등록 시 1회뿐)
//
// 콜백이 초당 수만 번 이상 불린다면 GCHandle 조회조차 아깝습니다.
//   → 인덱스를 user 로 넘기고 정적 배열에서 꺼내는 방식으로 바꿉니다
private static readonly Runner?[] _slots = new Runner[64];
// user = (void*)slotIndex  →  _slots[(int)user] 로 즉시 접근
새 코드의 기본값은 함수 포인터입니다. 델리게이트는 기존 코드와의 호환이나 클로저가 꼭 필요할 때만 쓰고, 그때도 “어디에 붙잡아 두는가”를 코드 옆에 주석으로 남기세요.

실무에선 이렇게 — 다른 스레드에서 오는 콜백 🔴 실무

UI 스레드로 되돌리기

// C++ 가 워커 스레드에서 콜백을 부르면
//   · WPF/WinForms 컨트롤을 직접 만지면 예외
//   · Unity 라면 대부분의 API 가 메인 스레드 전용

// ① 콜백에서는 '값만 받아 두고' 즉시 반환합니다 ★
[UnmanagedCallersOnly]
private static void OnProgress(int percent, void* user)
{
    var self = (Runner)GCHandle.FromIntPtr((IntPtr)user).Target!;
    self._latest = percent;                  // 원자적 저장만
    self._channel.Writer.TryWrite(percent);  // 또는 큐에 넣기
}

// ② 소비는 관리 코드 쪽에서
await foreach (var p in _channel.Reader.ReadAllAsync(ct))
    ProgressBar.Value = p;                   // UI 스레드 컨텍스트

// WPF 라면 Dispatcher 로도 되지만 콜백이 잦으면 큐가 폭발합니다
//   Application.Current.Dispatcher.BeginInvoke(() => ...);   // 신중히
네이티브 콜백 안에서 오래 머물지 마세요 — 그 시간 동안 C++ 의 작업 루프가 멈춰 있습니다. 콜백 안에서 파일을 쓰거나 락을 잡으면 성능이 눈에 띄게 무너지고, C++ 쪽이 락을 잡은 상태였다면 데드락까지 갑니다. “값 하나 저장하고 즉시 반환”이 유일하게 안전한 패턴입니다.

콜백에서 예외가 나면

// 관리 예외가 네이티브 프레임을 통과하려 하면
//   → 대부분의 경우 프로세스가 그대로 종료됩니다
//   → NativeAOT 나 IL2CPP 에서는 더 확실하게 죽습니다

// ✓ 콜백 본문 전체를 try/catch 로 감쌉니다 — 예외 없이
[UnmanagedCallersOnly]
private static void OnProgress(int percent, void* user)
{
    try
    {
        var self = (Runner)GCHandle.FromIntPtr((IntPtr)user).Target!;
        self.Progress?.Invoke(percent);       // 사용자 코드 — 뭘 할지 모름
    }
    catch (Exception ex)
    {
        // 여기서 던지면 안 되므로 기록만 하고 삼킵니다
        Interlocked.Exchange(ref _lastError, ex);
    }
}

// 작업이 끝난 뒤 관리 코드에서 다시 던져 줍니다
if (Interlocked.Exchange(ref _lastError, null) is { } e)
    throw new InvalidOperationException("콜백에서 예외", e);
// C++ 쪽에서 '취소' 를 지원하려면 콜백 반환값을 쓰면 됩니다
typedef int32_t (*mylib_progress_fn)(int32_t pct, void* user);  // 0 = 계속
...
if (cb && cb(pct, user) != 0) return MYLIB_CANCELLED;   // 사용자가 취소

한 단계 더 — 콜백 비용과 대안 🧬 전문가

콜백 한 번의 비용

대략적인 크기 감각 (기계마다 다르지만 순서는 안 바뀝니다)

  네이티브 → 네이티브 함수 포인터 호출        ~1 ns
  네이티브 → C# [UnmanagedCallersOnly]        ~5~15 ns
     (GC 모드 전환 · 스레드 등록 확인이 붙습니다)
  네이티브 → C# delegate 스텁                 ~20~50 ns
  네이티브 → C# + GCHandle 조회 + 이벤트 호출  ~50~100 ns

의미
  · 진행률 보고(초당 수십 번)      → 아무 상관 없습니다
  · 픽셀마다 콜백(초당 수천만 번)  → 콜백이 전체 시간을 지배합니다 ★

대안 — 콜백을 '뒤집습니다'
  ✗ C++ 가 원소마다 C# 을 부른다
  ✓ C# 이 배열을 통째로 넘기고 C++ 가 한 번에 처리한다
  ✓ C++ 가 결과를 버퍼에 모아 두고 C# 이 나중에 한 번에 읽는다
  ✓ 정렬 비교자 같은 건 C++ 안에 미리 정의된 것 중 '고르게' 한다
       mylib_sort(data, n, MYLIB_ORDER_DESC)   ← enum 하나로 해결

스레드에서 오는 콜백의 등록

// C++ 가 '자기가 만든' 스레드에서 콜백을 부르면
// 그 스레드는 .NET 런타임이 모르는 스레드입니다.
// 런타임은 첫 콜백 때 그 스레드를 자동으로 등록(attach)합니다.
//   → 첫 호출만 수백 마이크로초 걸릴 수 있습니다
//   → 이후는 정상 속도

// 진단 : 콜백이 어느 스레드에서 오는지 확인
[UnmanagedCallersOnly]
private static void OnEvent(int code, void* user)
{
    Debug.WriteLine($"tid={Environment.CurrentManagedThreadId} " +
                    $"pool={Thread.CurrentThread.IsThreadPoolThread}");
}

// 주의 : 그 스레드에서 async/await 를 쓰면 컨텍스트가 없어
//        재개가 스레드풀로 넘어갑니다. 콜백 안에서는 동기 코드만.
프로세스 종료 중에 오는 콜백이 가장 위험합니다 — 런타임이 이미 정리를 시작했는데 C++ 스레드가 살아서 콜백을 부르면 진단조차 어려운 크래시가 납니다. C API 에 mylib_shutdown() 을 반드시 두고, C# 쪽에서 등록을 해제한 뒤 스레드가 멎을 때까지 기다린 다음 종료하세요.
정리 — 콜백은 [UnmanagedCallersOnly] 정적 메서드 + delegate* unmanaged 가 기본형이고, 인스턴스는 void* user 에 실어 둔 GCHandle 로 되찾습니다. 콜백 안에서는 값만 저장하고 즉시 반환, 예외는 절대 밖으로 내보내지 않습니다.
C# 07

예외와 에러 — 경계는 넘지 못합니다

C++ 예외와 .NET 예외는 완전히 다른 메커니즘입니다. 서로를 알지 못하고, 서로를 잡지 못합니다. 경계를 넘으려 하는 순간 스택 트레이스도 로그도 없이 프로세스가 사라집니다. 해결은 단순합니다 — 경계에서 전부 잡아 에러 코드로 바꾸는 것.

예외가 경계를 만났을 때 — 두 갈래 그냥 두면 — 프로세스가 사라집니다 C++ 안에서 throw std::bad_alloc · 내가 던진 예외 extern "C" 경계를 통과 시도 언와인딩 정보가 여기서 끊깁니다 프로세스 즉시 종료 로그도 스택 트레이스도 남지 않습니다 경계에서 잡으면 — 정상적인 에러가 됩니다 C++ 안에서 throw 여기까지는 똑같은 상황입니다 경계 함수의 catch 가 흡수 메시지를 스레드 지역 버퍼에 저장 정수 에러 코드로 반환 return -3 · 문구는 별도 함수로 C# 은 그 코드를 보고 자기 예외로 승격시킵니다 rc 가 0 이 아니면 mylib_last_error() 를 읽어 예외를 던집니다 — 관리 코드 쪽 스택 트레이스가 그대로 살아납니다 규칙 하나 — extern "C" 함수의 본문은 통째로 try/catch 로 감쌉니다
경계 함수 하나하나가 “절대 예외를 내보내지 않는다”는 계약을 지켜야 합니다.

C++ 쪽 — 경계를 봉인하는 매크로

// mylib_error.h
#include <string>

enum : int32_t {
    MYLIB_OK          =  0,
    MYLIB_EINVAL      = -1,   // 인자가 잘못됨
    MYLIB_ENOMEM      = -2,   // 메모리 부족
    MYLIB_EIO         = -3,   // 입출력 실패
    MYLIB_EINTERNAL   = -99   // 알 수 없는 예외
};

// 스레드마다 따로 — 멀티스레드에서 메시지가 섞이지 않습니다 ★
inline thread_local std::string g_last_error;

extern "C" MYLIB_API const char* mylib_last_error(void) {
    return g_last_error.c_str();
}
// 모든 경계 함수를 이 매크로로 감쌉니다
#define MYLIB_GUARD(body)                               \
    try { g_last_error.clear(); body }                  \
    catch (const std::bad_alloc&) {                     \
        g_last_error = "메모리 부족"; return MYLIB_ENOMEM; } \
    catch (const std::exception& e) {                   \
        g_last_error = e.what();     return MYLIB_EIO; } \
    catch (...) {                                       \
        g_last_error = "알 수 없는 예외"; return MYLIB_EINTERNAL; }

extern "C" MYLIB_API int32_t mylib_load(const char* path) {
    MYLIB_GUARD({
        if (!path) { g_last_error = "path 가 null"; return MYLIB_EINVAL; }
        do_load(path);                 // 여기서 무엇을 던지든 안전합니다
        return MYLIB_OK;
    })
}
thread_local 이 핵심입니다 — 전역 하나로 두면 스레드 A 의 에러 메시지를 스레드 B 가 읽는 사고가 납니다. ASP.NET Core 처럼 요청마다 스레드가 다른 환경에서는 반드시 스레드 지역이어야 합니다.

C# 쪽 — 한 곳에서 예외로 바꾸기

public sealed class MylibException : Exception
{
    public int Code { get; }
    public MylibException(int code, string message)
        : base($"{message} (code={code})") => Code = code;
}

internal static class Check
{
    // 모든 호출을 이 한 줄로 감쌉니다 ★
    public static int Ok(int rc)
    {
        if (rc == 0) return rc;

        string msg = Marshal.PtrToStringUTF8(Native.mylib_last_error()) ?? "";
        throw rc switch
        {
            -1 => new ArgumentException(msg),
            -2 => new OutOfMemoryException(msg),
            -3 => new IOException(msg),
            _  => new MylibException(rc, msg),
        };
    }
}

// 사용하는 쪽 — C# 답게 보입니다
public void Load(string path) => Check.Ok(Native.mylib_load(path));
에러 코드를 .NET 표준 예외로 매핑하세요 — 인자 오류는 ArgumentException, 파일은 IOException 처럼. 전부 MylibException 하나로 던지면 쓰는 쪽이 catch 를 세밀하게 쓸 수 없습니다. 코드 값은 Code 프로퍼티로 남겨 두면 필요할 때 꺼내 쓸 수 있습니다.

이렇게도 쓸 수 있다 — 경계를 넘어오는 다른 종류의 사고 🟡 중급

세그폴트 · 접근 위반

C++ 에서 잘못된 포인터를 건드리면 예외가 아니라 '신호' 입니다

  리눅스   SIGSEGV
  윈도우   Access Violation (0xC0000005)

.NET 은 이것을 기본적으로 잡지 않습니다 — 프로세스가 죽습니다.
그리고 그게 맞습니다. 힙이 이미 깨진 상태에서 계속 도는 것보다
즉시 죽는 편이 안전합니다.

윈도우에서는 억지로 잡을 수 있긴 합니다 (권장하지 않음)
  <legacyCorruptedStateExceptionsPolicy>
  [HandleProcessCorruptedStateExceptions]
  → 힙이 깨진 채로 계속 도는 프로세스를 만듭니다. 하지 마세요.

진짜 해결책
  ① C++ 쪽에 sanitizer 를 걸고 테스트를 돌린다
       g++ -fsanitize=address,undefined -g
  ② 경계 함수에서 인자를 전부 검증한다 (null · 범위 · 크기)
  ③ 프로세스를 분리한다 — 크래시가 서비스를 죽이지 않게
“C# 앱이 갑자기 사라졌다” — 로그도 없고 예외도 없다면 거의 확실히 네이티브 크래시입니다. 윈도우는 이벤트 뷰어의 응용 프로그램 오류 항목에 모듈 이름과 오프셋이 남고, 리눅스는 coredumpctl 이나 dmesg 에 남습니다. 거기서부터 시작하세요 — 관리 코드 로그를 아무리 봐도 나오지 않습니다.

크래시 덤프에서 원인 찾기

# ① 덤프를 남기게 설정 (리눅스 컨테이너에서도 동작)
export DOTNET_DbgEnableMiniDump=1
export DOTNET_DbgMiniDumpType=4          # 4 = 전체 힙
export DOTNET_DbgMiniDumpName=/tmp/dump.%d
dotnet MyApp.dll

# ② 분석 — dotnet-dump 로 관리·네이티브 양쪽을 봅니다
dotnet tool install -g dotnet-dump
dotnet-dump analyze /tmp/dump.12345
  > clrstack -a            # 관리 스택
  > clrthreads             # 스레드 목록
  > dumpheap -stat         # 힙 통계

# ③ 네이티브 프레임까지 보려면
lldb --core /tmp/dump.12345 $(which dotnet)
  (lldb) bt all

# 윈도우 — Windows Debugger 로 같은 일을
#   .loadby sos coreclr ; !clrstack ; !analyze -v
// 크래시 직전 상황을 남겨 두는 값싼 보험
AppDomain.CurrentDomain.UnhandledException += (_, e) =>
    File.AppendAllText("crash.log",
        $"{DateTime.UtcNow:o} {e.ExceptionObject}\n");

// 네이티브 크래시는 이걸로 못 잡습니다 — 그래서 아래가 더 중요합니다
Log.Information("mylib_process 호출 직전 n={N} ptr={P}", n, ptr);

실무에선 이렇게 — 에러 규약을 계약으로 만들기 🔴 실무

한 페이지짜리 규약 문서

mylib C API 규약 (버전 1)

  1. 모든 함수는 int32_t 를 반환한다. 0 = 성공, 음수 = 실패.
     · 값을 돌려줘야 하면 출력 포인터로 받는다
         int32_t mylib_sum(const double* d, int64_t n, double* out);

  2. 실패했을 때만 mylib_last_error() 가 의미 있는 문구를 준다.
     · 스레드 지역이며 다음 호출까지만 유효하다
     · 반환된 포인터를 해제하지 않는다

  3. 어떤 함수도 예외를 밖으로 내보내지 않는다.

  4. 어떤 함수도 nullptr 인자에 대해 크래시하지 않는다.
     · nullptr 이면 MYLIB_EINVAL 을 돌려준다

  5. 에러 코드 값은 절대 재사용·재정의하지 않는다.
     · 새 에러는 새 번호를 받는다 (호환성)

  6. mylib_version() 은 언제나 호출 가능하며 실패하지 않는다.
이 여섯 줄이 팀을 구합니다 — 바인딩을 쓰는 사람(C#·파이썬·Node) 모두가 같은 방식으로 코드를 짜게 되고, 새 함수를 추가할 때 무엇을 지켜야 하는지가 명확해집니다. 규약 없는 C API 는 함수마다 다른 규칙을 갖게 되고, 결국 아무도 안 쓰게 됩니다.

규약을 테스트로 강제하기

public class ContractTests
{
    [Fact]
    public void null_인자는_크래시하지_않고_EINVAL()
    {
        Assert.Equal(-1, Native.mylib_load(null!));
    }

    [Fact]
    public void 실패하면_메시지가_비어있지_않다()
    {
        Native.mylib_load("존재하지 않는 경로");
        var msg = Marshal.PtrToStringUTF8(Native.mylib_last_error());
        Assert.False(string.IsNullOrWhiteSpace(msg));
    }

    [Fact]
    public void 성공하면_이전_메시지가_지워진다()
    {
        Native.mylib_load("없음");            // 실패시켜 메시지를 남기고
        Native.mylib_version();               // 성공하는 호출
        Native.mylib_load(TestFile);          // 성공
        Assert.Equal("", Marshal.PtrToStringUTF8(Native.mylib_last_error()));
    }

    [Fact]
    public async Task 스레드마다_메시지가_섞이지_않는다()
    {
        await Task.WhenAll(Enumerable.Range(0, 8).Select(i => Task.Run(() =>
        {
            Native.mylib_load($"없음-{i}");
            var m = Marshal.PtrToStringUTF8(Native.mylib_last_error());
            Assert.Contains($"없음-{i}", m);   // 내 것만 보여야 합니다
        })));
    }
}
마지막 테스트가 실제로 버그를 잡습니다 — thread_local 을 빠뜨린 라이브러리는 이 테스트에서 랜덤하게 실패합니다. “가끔 다른 요청의 에러 메시지가 보인다”는 운영 버그를 CI 에서 막을 수 있는 유일한 방법입니다.

한 단계 더 — Win32 방식과 내 방식의 차이 🧬 전문가

GetLastPInvokeError 의 정체

// SetLastError = true 를 켜면 런타임은 호출 직후 이렇게 합니다
//   윈도우 : GetLastError() 를 읽어 스레드 상태에 보관
//   리눅스 : errno 를 읽어 보관
// 그 뒤에 다른 P/Invoke 나 GC 가 값을 덮어써도 안전합니다

int err = Marshal.GetLastPInvokeError();          // .NET 6+ (권장 이름)
int old = Marshal.GetLastWin32Error();            // 같은 값 (옛 이름)
string s = Marshal.GetLastPInvokeErrorMessage();  // .NET 8+ 사람이 읽을 문구

// 직접 예외로 바꾸는 지름길 — 윈도우 코드를 그대로 해석해 줍니다
throw new Win32Exception(err);

// 내 값을 넣어 두고 싶을 때 (테스트용)
Marshal.SetLastPInvokeError(5);
SetLastError내 last_error
대상OS API내 라이브러리
메시지OS 문구내가 쓴 문구
스레드 안전보장내가 보장(thread_local)
비용호출마다 소폭실패할 때만

예외 없는 C++ 로 빌드할 때

// 게임·임베디드에서는 -fno-exceptions 로 빌드하기도 합니다
//   → try/catch 를 쓸 수 없습니다. 그러면 규약이 이렇게 바뀝니다

// ① 실패할 수 있는 모든 곳이 코드를 반환합니다
int32_t mylib_alloc(size_t n, void** out) {
    void* p = std::malloc(n);            // new 대신 malloc (던지지 않음)
    if (!p) return MYLIB_ENOMEM;
    *out = p;
    return MYLIB_OK;
}

// ② std::expected (C++23) 를 내부에서 쓰고 경계에서 코드로 변환
std::expected<Image, Err> load_impl(std::string_view path);

extern "C" int32_t mylib_load(const char* path, MylibImage** out) {
    auto r = load_impl(path);
    if (!r) { g_last_error = to_string(r.error()); return to_code(r.error()); }
    *out = new_handle(std::move(*r));
    return MYLIB_OK;
}
경계 규약은 그대로입니다 — 내부에서 예외를 쓰든 std::expected 를 쓰든 std::error_code 를 쓰든, 밖으로 나가는 모양은 “정수 코드 + 마지막 메시지” 하나로 통일합니다. 그래야 C#·파이썬·Node 바인딩을 전부 같은 방식으로 짤 수 있습니다.
정리 — 네이티브 예외는 경계를 못 넘습니다. extern "C" 함수 본문을 통째로 try/catch 로 감싸 정수 코드 + thread_local 메시지로 바꾸고, C# 쪽 한 곳(Check.Ok)에서만 .NET 예외로 승격시킵니다.
C# 08

C++/CLI — 클래스를 통째로 감싸는 길

P/Invoke 는 함수 단위라, C++ 클래스가 수백 개인 코드베이스를 감싸려면 손이 너무 많이 갑니다. C++/CLI 는 한 소스 파일 안에서 .NET 클래스와 C++ 클래스를 같이 쓸 수 있게 해 줍니다. 대가는 분명합니다 — 윈도우 전용.

C++/CLI 는 두 세계를 한 파일에 넣습니다 — 대신 윈도우에 못 박힙니다 관리 코드 C# · WPF · WinForms 앱 using MyLib; var e = new Engine(4); — 평범한 .NET 클래스로 보입니다 MSIL 경계 계층 C++/CLI 래퍼 — ref class Engine /clr 로 컴파일 · 한 파일 안에서 gcnew 와 new 를 함께 씁니다 혼합 어셈블리 네이티브 기존 C++ 코드 — 손대지 않습니다 헤더를 include 하고 그대로 링크합니다. 수정이 필요 없습니다 ★ 기계어 윈도우 전용입니다 — /clr 는 리눅스·맥에서 빌드되지 않습니다 NativeAOT · 단일 파일 배포 · 트리밍과도 함께 쓸 수 없습니다. 이 셋이 필요하면 P/Invoke 로 가세요 판단 기준 — “윈도우 데스크톱 + 이미 큰 C++ 코드베이스” 일 때만 이깁니다
C++/CLI 의 값은 속도가 아니라 손으로 쓸 바인딩 코드의 양입니다.

감쌀 C++ 클래스

// native/Engine.h — 아주 평범한 C++ 입니다
#pragma once
#include <string>
#include <vector>

class Engine {
public:
    explicit Engine(int window);
    void   Push(double v);
    double Value() const;
    std::string Report() const;
    void   ProcessAll(const std::vector<double>& xs);
private:
    int window_;
    std::vector<double> buf_;
};
P/Invoke 로 감싸려면 필요한 것
  · extern "C" 래퍼 함수 5개 (create/destroy/push/value/report)
  · 문자열 반환 규약 · 해제 함수
  · C# 쪽 SafeHandle + 공개 클래스
  → 클래스 하나에 대략 100줄

클래스가 200개라면
  → 20,000줄. 손으로 쓸 양이 아닙니다 ★

C++/CLI 로 감싸면
  · 헤더를 include 하고 ref class 를 하나 쓴다
  · 메서드마다 한두 줄
  → 클래스 하나에 대략 25줄, 그리고 C# 쪽 코드는 0줄

C++/CLI 래퍼 — 이게 전부입니다

// wrapper/EngineWrapper.cpp   (/clr 로 컴파일)
#include "../native/Engine.h"
#include <msclr/marshal_cppstd.h>

using namespace System;

namespace MyLib {

public ref class Engine          // ref class = .NET 클래스입니다
{
    ::Engine* impl_;             // 네이티브 객체를 그대로 소유 (raw 포인터)

public:
    Engine(int window) : impl_(new ::Engine(window)) {}

    ~Engine() { this->!Engine(); }          // → C# 의 Dispose()
    !Engine() { delete impl_; impl_ = nullptr; }   // → 종료자

    void Push(double v) { impl_->Push(v); }

    property double Value {                 // C# 의 프로퍼티가 됩니다
        double get() { return impl_->Value(); }
    }

    String^ Report() {                      // std::string → System::String
        return msclr::interop::marshal_as<String^>(impl_->Report());
    }
};

}   // namespace MyLib
~Engine() 과 !Engine() 의 뜻 — C++/CLI 에서 소멸자(~)는 IDisposable.Dispose() 로, 종료자(!)는 finalizer 로 컴파일됩니다. 즉 ref class 를 하나 쓰면 using 문이 그냥 동작합니다 — SafeHandle 을 직접 쓸 필요가 없습니다.

이렇게도 쓸 수 있다 — 프로젝트 설정과 타입 변환 🟡 중급

.NET 10 을 대상으로 하는 vcxproj

<!-- EngineWrapper.vcxproj — Visual Studio 2022 의
     "CLR 클래스 라이브러리 (.NET Core)" 템플릿 -->
<PropertyGroup>
  <ConfigurationType>DynamicLibrary</ConfigurationType>
  <CLRSupport>NetCore</CLRSupport>          <!-- ★ .NET Core 계열 CLR -->
  <TargetFramework>net10.0</TargetFramework>
  <PlatformToolset>v143</PlatformToolset>
  <LanguageStandard>stdcpp20</LanguageStandard>
</PropertyGroup>
<!-- App.csproj — C# 앱은 그냥 프로젝트 참조만 추가합니다 -->
<PropertyGroup>
  <TargetFramework>net10.0-windows</TargetFramework>
  <UseWPF>true</UseWPF>
  <Platforms>x64</Platforms>      <!-- ★ AnyCPU 는 쓸 수 없습니다 -->
</PropertyGroup>

<ItemGroup>
  <ProjectReference Include="..\wrapper\EngineWrapper.vcxproj" />
</ItemGroup>
AnyCPU 를 반드시 x64 로 바꾸세요 — C++/CLI 어셈블리는 특정 아키텍처에 고정됩니다. C# 프로젝트가 AnyCPU 로 남아 있으면 빌드는 되는데 실행 시 BadImageFormatException 이 납니다. ARM64 윈도우도 지원하려면 구성마다 따로 빌드해야 합니다.

타입 변환 — 자주 쓰는 것들

#include <msclr/marshal_cppstd.h>
using namespace msclr::interop;
using namespace System;
using namespace System::Collections::Generic;

// 문자열 — 양방향
std::string s = marshal_as<std::string>(managedString);
String^ m     = marshal_as<String^>(nativeString);

// 배열을 핀 고정해 그대로 넘기기 ★ 복사 없음
void Process(array<double>^ xs) {
    if (xs->Length == 0) return;
    pin_ptr<double> p = &xs[0];              // 이 스코프 동안 GC 고정
    impl_->ProcessRaw(p, xs->Length);
}

// std::vector → List<double>
List<double>^ ToList(const std::vector<double>& v) {
    auto out = gcnew List<double>((int)v.size());
    for (double x : v) out->Add(x);
    return out;
}

// C++ 예외 → .NET 예외 (여기서는 정말로 변환됩니다)
try { impl_->Load(path); }
catch (const std::exception& e) {
    throw gcnew InvalidOperationException(marshal_as<String^>(e.what()));
}
C++/CLI 의 진짜 장점이 여기 있습니다 — P/Invoke 에서는 불가능했던 “예외를 예외로 변환”이 그냥 됩니다. 같은 프레임 안에서 두 런타임이 함께 살아 있기 때문입니다. std::vector, std::map, 템플릿, 상속까지 그대로 다룰 수 있습니다.

실무에선 이렇게 — 언제 이 길을 고르나 🔴 실무

C++/CLI 가 맞지 않는 경우
✗ 리눅스 컨테이너에 배포할 계획이 조금이라도 있다
    → /clr 는 윈도우에서만 빌드됩니다. 대안이 없습니다.

✗ NativeAOT · 단일 파일 · 트리밍을 쓰고 싶다
    → 혼합 어셈블리는 전부 불가입니다.

✗ 감쌀 것이 함수 몇 개뿐이다
    → 빌드 복잡도만 늘어납니다. P/Invoke 20줄이면 끝납니다.

✗ Unity 에서 쓰려고 한다
    → IL2CPP 와 혼합 어셈블리는 함께 갈 수 없습니다.

✗ 팀에 C++ 를 아는 사람이 없다
    → C++/CLI 는 C++ 와 C# 을 '둘 다' 알아야 읽힙니다.
      배우는 사람이 가장 적은 문법이기도 합니다.
C++/CLI 가 압도적으로 이기는 경우
✓ 윈도우 데스크톱 앱(WPF·WinForms)이 확정이다

✓ 기존 C++ 코드베이스가 수십만 줄이고 클래스가 수백 개
    → 손으로 쓸 P/Invoke 양이 현실적이지 않습니다.

✓ 객체 그래프를 오가야 한다
    → 컬렉션 · 상속 · 콜백이 얽힌 API 를 함수 목록으로
      납작하게 펴는 일 자체가 큰 설계 작업입니다.

✓ C++ 쪽 소스를 고칠 수 없다 (다른 팀 · 서드파티)
    → include 만 하면 되므로 원본을 건드리지 않습니다.

✓ 예외 · 이벤트를 자연스럽게 이어 주고 싶다
    → 같은 프레임에서 변환되므로 코드가 짧습니다.

실무 요령 — 계층을 반드시 세 개로 나눕니다
    Native(순수 C++) / Wrapper(/clr) / App(C#)
    Wrapper 에는 로직을 절대 넣지 않습니다 — 변환만.

한 단계 더 — 혼합 어셈블리의 함정 🧬 전문가

로더 락과 초기화 순서

// 혼합 어셈블리는 로드될 때 두 초기화가 겹칩니다
//   ① 네이티브 CRT 초기화 (DllMain 컨텍스트 · 로더 락 보유)
//   ② .NET 어셈블리 로드 (다른 어셈블리를 끌어올 수 있음)
// 이 둘이 얽히면 데드락이 납니다 — 유명한 "로더 락" 문제입니다

// ✗ 하지 말 것 — DllMain 에서 관리 코드를 부르기
BOOL WINAPI DllMain(HMODULE, DWORD reason, LPVOID) {
    if (reason == DLL_PROCESS_ATTACH)
        MyLib::Registry::Init();      // 관리 코드 ★ 데드락 위험
    return TRUE;
}

// ✓ 관리 코드 초기화는 '처음 쓰일 때' 로 미룹니다
static initonly System::Lazy<Registry^>^ s_reg =
    gcnew System::Lazy<Registry^>(gcnew Func<Registry^>(&Create));

// 특정 함수만 네이티브로 강제하고 싶을 때
#pragma managed(push, off)
void HotLoop(double* p, int n) { /* 관리 코드 개입 없이 컴파일 */ }
#pragma managed(pop)
“앱이 시작하다가 멈춘다”는 증상의 단골 원인입니다. 혼합 어셈블리에서는 DllMain · 전역 객체 생성자에서 관리 코드를 절대 부르지 마세요. 지연 초기화로 미루는 것이 유일한 해법입니다.

성능 — 어디서 비용이 나나

C++/CLI 의 호출 비용은 P/Invoke 와 거의 같습니다
  관리 → 네이티브 전환마다 스택 프레임 표시 · GC 모드 전환

그래서 실수하기 쉬운 패턴
  ✗ ref class 의 메서드를 루프 안에서 100만 번 부른다
      for (int i = 0; i < n; i++) wrapper->Push(xs[i]);
      → 전환이 100만 번. 순수 C++ 대비 몇 배 느려집니다

  ✓ 배열째로 한 번 넘긴다
      wrapper->PushRange(xs);      // 안에서 pin_ptr 로 고정 후 루프

측정 요령
  · Visual Studio 프로파일러에서 'Interop' 프레임을 세어 봅니다
  · 전환 횟수 자체를 로그로 세는 것이 가장 확실합니다

정리하면 P/Invoke 든 C++/CLI 든 규칙은 같습니다 —
  "경계는 굵고 드물게, 안쪽 루프는 한쪽 세계에서만" ★
C++/CLI 를 “점진적 이주”의 도구로 쓰는 팀도 많습니다 — 기존 MFC/WinForms 앱에서 UI 만 WPF·C# 로 새로 쓰고 엔진은 그대로 두는 식입니다. 이때 C++/CLI 계층은 영구 설계가 아니라 다리이므로, 나중에 P/Invoke 로 갈아탈 수 있게 인터페이스를 얇게 유지해 두면 좋습니다.
정리 — C++/CLI 는 “윈도우 전용 + 기존 C++ 클래스가 산더미” 라는 조건에서만 이깁니다. ref class 하나로 Dispose · 프로퍼티 · 예외 변환이 전부 따라오지만, 리눅스 · NativeAOT · Unity 와는 함께 갈 수 없습니다.
C# 09

자동 바인딩 — 헤더에서 생성하고 손으로 다듬기

헤더가 수천 줄이면 손으로 옮기는 건 현실적이지 않습니다. CppSharp · SWIG · ClangSharp 가 헤더를 읽어 C# 선언을 뱉어 줍니다. 다만 절반만 자동입니다 — 소유권·수명·스레드 규약은 헤더에 적혀 있지 않기 때문에 생성기도 알 수 없습니다.

생성기는 “타이핑”을 대신할 뿐 “설계”를 대신하지 않습니다 ① 생성 파이프라인 C++ 헤더 mylib.h · 수천 줄 libclang 파서 진짜 컴파일러가 읽습니다 규칙 파일 무엇을 내보낼지 · 이름 규칙 ② 생성 결과를 쓰는 법 생성된 .cs — 원시 층 수천 줄 · 사람이 직접 읽을 것은 아님 손으로 쓴 얇은 공개 API SafeHandle · 예외 · IDisposable — 여기만 사람이 씁니다 자동으로 되지 않는 것 소유권(누가 해제하나) · 수명(언제까지 유효한가) · 콜백 스레드 규약 · 에러 규약 — 헤더에 적혀 있지 않습니다 생성기는 시그니처만 봅니다. char* 하나만 봐서는 빌린 것인지 준 것인지 알 방법이 없습니다 그래서 실무에서는 “생성 + 손 마샬링” 을 섞습니다 — 전부 자동은 없습니다
자동 생성은 원시 층까지입니다 — 공개 API 는 여전히 사람이 설계합니다.

세 도구 비교 ★

ClangSharpSWIGCppSharp
대상C 헤더C · C++C++ 클래스
중간 C++ 코드없음생성됨(.cxx)생성됨
결과 예측성매우 높음중간낮음
C++ 클래스불가가능가능
설정 방식rsp 파일.i 파일C# 코드
다른 언어도C# 만20여 개C# 중심
대표 사용처Vulkan · DirectX과학 라이브러리게임 SDK
고르는 기준 한 줄 — C ABI 헤더만 있다면 ClangSharp(결과가 예측 가능하고 중간 층이 없습니다). 파이썬·자바 바인딩도 함께 필요하면 SWIG(한 .i 파일로 전부 나옵니다). C++ 클래스를 그대로 노출해야 하고 윈도우 전용이 싫다면 CppSharp — 다만 C++ ABI 를 흉내 내는 방식이라 컴파일러·버전에 민감합니다.

ClangSharp — 가장 예측 가능한 길

dotnet tool install -g ClangSharpPInvokeGenerator

# generate.rsp — 옵션을 파일로 관리합니다
cat > generate.rsp <<'EOF'
--file include/mylib.h
--output gen
--namespace MyLib.Native
--libraryPath mylib
--methodClassName Native
--config latest-codegen generate-macro-bindings multi-file
--traverse include/mylib.h          # 이 헤더에 있는 것만 (표준 헤더 제외)
--remap                             # 이름 바꾸기
    mylib_engine=Engine
--exclude                           # 내보내지 않을 것
    mylib_internal_debug
--additional -std=c++20 -I include
EOF

ClangSharpPInvokeGenerator @generate.rsp
// gen/Native.cs — 이렇게 나옵니다 (그대로 컴파일됩니다)
namespace MyLib.Native;

public static unsafe partial class Native
{
    [DllImport("mylib", ExactSpelling = true)]
    public static extern MylibEngine* mylib_engine_create(int window);

    [DllImport("mylib", ExactSpelling = true)]
    public static extern void mylib_engine_destroy(MylibEngine* e);
}
public partial struct MylibEngine { }     // 불투명 타입도 그대로 옮겨 줍니다
--traverse 를 빠뜨리면 <stdio.h>·<stdint.h> 안의 것까지 전부 생성해 수만 줄이 나옵니다. 처음 돌릴 때 결과가 터무니없이 크면 거의 항상 이 옵션 문제입니다.

이렇게도 쓸 수 있다 — SWIG 와 CppSharp 🟡 중급

SWIG — 여러 언어를 한 번에

// mylib.i — 인터페이스 정의 파일
%module MyLib

%{
#include "mylib.h"        // 생성될 C++ glue 파일에 들어갈 헤더
%}

%include "std_string.i"   // std::string ↔ System.String 자동 변환
%include "std_vector.i"
%template(DoubleVector) std::vector<double>;

// 소유권을 알려 줍니다 — 이런 힌트가 SWIG 의 핵심입니다 ★
%newobject Engine::Report;        // 반환값을 C# 이 소유 → 자동 해제
%delobject Engine::Destroy;

%include "mylib.h"        // 이 헤더의 모든 선언을 노출
# 생성 — C# 파일 + C++ glue 파일이 함께 나옵니다
swig -csharp -c++ -namespace MyLib -outdir gen/cs -o gen/mylib_wrap.cxx mylib.i

# ★ glue 를 반드시 함께 컴파일해 별도의 DLL 로 만들어야 합니다
g++ -shared -fPIC gen/mylib_wrap.cxx src/*.cpp -o libMyLibSwig.so
#   즉 배포물이 두 개가 됩니다 : 원본 libmylib + 래퍼 libMyLibSwig
SWIG 의 장단이 여기 다 드러납니다 — std::string·std::vector 를 자동 변환해 주고 파이썬·자바·Node 바인딩이 같은 .i 파일에서 나오는 것이 장점이고, 중간 C++ 래퍼 DLL 이 하나 더 생기는 것이 단점입니다(빌드·배포가 한 겹 늘어납니다).

CppSharp — C# 코드로 생성 규칙을 씁니다

// Generator/Program.cs
using CppSharp;
using CppSharp.AST;
using CppSharp.Generators;

class MyLibrary : ILibrary
{
    public void Setup(Driver driver)
    {
        var o = driver.Options;
        o.GeneratorKind = GeneratorKind.CSharp;
        o.OutputDir = "gen";

        var m = o.AddModule("MyLib");
        m.IncludeDirs.Add("../include");
        m.Headers.Add("mylib.h");
        m.LibraryDirs.Add("../build");
        m.Libraries.Add("mylib");
    }

    public void SetupPasses(Driver driver) { }

    public void Preprocess(Driver d, ASTContext ctx)
    {
        // 내부용 심볼은 생성에서 제외합니다
        ctx.IgnoreClassWithName("MylibInternal");
        ctx.SetNameOfFunction("mylib_engine_push", "Push");
    }

    public void Postprocess(Driver d, ASTContext ctx) { }
}

ConsoleDriver.Run(new MyLibrary());
CppSharp 는 C++ 클래스를 “직접” 부릅니다 — vtable 배치와 이름 맹글링을 흉내 내서 P/Invoke 를 만듭니다. 그래서 컴파일러·표준 라이브러리 버전이 바뀌면 조용히 깨질 수 있습니다. 쓰기로 했다면 DLL 을 만든 컴파일러를 고정하고 스모크 테스트를 두텁게 깔아 두세요.

실무에선 이렇게 — 생성 결과를 어떻게 관리하나 🔴 실무

생성물을 커밋할까, CI 에서 만들까

커밋한다CI 에서 생성
빌드 준비물없음libclang · swig 필요
diff 로 변화 확인가능 ★불가
저장소 크기커짐작음
헤더 변경 감지PR 에서 바로빌드 후에
추천기본값생성물이 아주 클 때
커밋을 권하는 진짜 이유 ★

  헤더가 바뀌면 생성된 .cs 의 diff 에 그대로 드러납니다
    - public static extern int mylib_run(double* d, long n);
    + public static extern int mylib_run(double* d, long n, int flags);

  → PR 리뷰에서 "이거 ABI 가 바뀐 건데?" 를 잡을 수 있습니다
  → CI 생성 방식이면 이 변화가 아무 데도 안 보입니다

관리 요령
  · gen/ 폴더는 사람이 절대 손대지 않는다 (헤더에 명시)
  · 생성 명령은 스크립트 한 개로 고정 (tools/regen.sh)
  · CI 에서 "생성 후 diff 가 비어 있는지" 를 검사한다 ★

생성물 위에 얹는 손 마샬링 층

// gen/Native.cs 는 건드리지 않습니다 (다음 생성에 덮어써집니다)
// 대신 partial 로 확장하거나 별도 파일에 얇게 씁니다

// MyLib/Engine.cs — 사람이 쓰는 유일한 층
namespace MyLib;

public sealed class Engine : IDisposable
{
    private readonly EngineHandle _h;      // 생성물의 포인터를 감쌉니다

    public Engine(int window)
    {
        unsafe
        {
            var p = Native.Native.mylib_engine_create(window);
            if (p is null) throw new ArgumentOutOfRangeException(nameof(window));
            _h = new EngineHandle((IntPtr)p);
        }
    }

    public void Push(double v) => Check.Ok(
        Native.Native.mylib_engine_push(_h, v));

    public string Report()
    {
        IntPtr p = Native.Native.mylib_engine_report(_h);
        try { return Marshal.PtrToStringUTF8(p) ?? ""; }
        finally { Native.Native.mylib_free_string(p); }   // ★ 생성기가 모르는 규약
    }

    public void Dispose() => _h.Dispose();
}
Report() 가 이 층이 필요한 이유를 그대로 보여 줍니다 — 생성기는 char* 반환값을 봤을 뿐, 그것을 해제해야 하는지 모릅니다. “누가 해제하나”를 아는 사람은 헤더 주석을 읽은 사람뿐이고, 그 지식이 들어가는 자리가 바로 이 손으로 쓴 얇은 층입니다.

한 단계 더 — 생성물을 검증하는 법 🧬 전문가

ABI 회귀를 CI 로 막기

#!/usr/bin/env bash
# tools/check-bindings.sh — CI 에서 돌립니다
set -euo pipefail

ClangSharpPInvokeGenerator @generate.rsp

if ! git diff --quiet -- gen/; then
  echo "::error::헤더가 바뀌었는데 gen/ 이 갱신되지 않았습니다"
  git --no-pager diff -- gen/
  exit 1
fi
echo "바인딩이 헤더와 일치합니다"
# 심볼 목록 자체를 스냅샷으로 고정하는 방법도 좋습니다
nm -D --defined-only libmylib.so \
  | awk '$2=="T" {print $3}' | grep '^mylib_' | sort > abi.txt
git diff --exit-code abi.txt || {
  echo "내보내는 심볼이 바뀌었습니다 — 버전을 올렸는지 확인하세요"; exit 1; }
바인딩 프로젝트에서 가장 무서운 것은 “조용한 불일치”입니다. 헤더에 인자가 하나 추가됐는데 C# 쪽이 옛 시그니처 그대로면, 컴파일도 되고 실행도 되다가 스택이 어긋나 어딘가에서 죽습니다. 위 두 검사 중 하나만 있어도 이 종류의 사고는 사라집니다.

생성물 품질을 올리는 힌트들

// 헤더에 힌트를 넣으면 생성 품질이 눈에 띄게 좋아집니다

// ① 고정폭 타입만 쓰기 — 생성기가 헷갈릴 여지가 없습니다
MYLIB_API int32_t mylib_run(const double* d, int64_t n);

// ② enum 을 명시적 기반 타입으로
enum class MylibMode : int32_t { Fast = 0, Precise = 1 };

// ③ 배열 인자에 크기를 붙여 이름으로 알려 주기
MYLIB_API int32_t mylib_sum(const double* values, int64_t values_len,
                            double* out_result);
//                          ^^^ out_ 접두사 · _len 접미사 규칙을 정해 두면
//                              생성기 후처리에서 자동으로 Span 으로 바꿀 수 있습니다

// ④ 어노테이션 매크로 (도구가 읽을 수 있는 형태로)
#define MYLIB_OWNED     /* 반환값의 소유권이 호출자에게 */
#define MYLIB_BORROWED  /* 빌려 보는 포인터 */
MYLIB_API MYLIB_OWNED char* mylib_report(const MylibEngine* e);
이름 규칙 하나로 후처리가 가능해집니다 — xxx 와 xxx_len 이 나란히 오면 Span<T> 로, out_ 접두사면 out 매개변수로 바꾸는 작은 후처리 스크립트를 한 번 써 두면 이후 모든 생성에 적용됩니다. 생성기를 바꾸는 것보다 헤더 규칙을 정하는 편이 훨씬 값쌉니다.
정리 — 헤더가 크면 ClangSharp(C ABI) · SWIG(다언어) · CppSharp(C++ 클래스) 중에 고르되, 생성물은 원시 층으로만 쓰고 공개 API 는 손으로 얇게 얹습니다. 생성물을 커밋해 두면 ABI 변화가 diff 로 보입니다.
C# 10

Unity 네이티브 플러그인 — 폴더 규약과 IL2CPP

Unity 도 결국 C# 이라 P/Invoke 가 그대로 통합니다. 다른 점은 셋입니다 — 파일을 어디에 두느냐, IL2CPP 로 빌드하면 콜백 규칙이 달라진다는 것, 그리고 에디터가 DLL 을 놓아 주지 않는다는 악명 높은 문제입니다.

플랫폼마다 두는 자리가 다릅니다 — 이름은 대부분 같습니다 플랫폼 Assets/ 아래 두는 경로 DllImport 에 적는 이름 윈도우 x64 Plugins/x86_64/mylib.dll [DllImport("mylib")] macOS Plugins/mylib.bundle [DllImport("mylib")] 리눅스 x64 Plugins/x86_64/libmylib.so [DllImport("mylib")] 안드로이드 Plugins/Android/libs/arm64-v8a/libmylib.so [DllImport("mylib")] iOS Plugins/iOS/libmylib.a (정적 라이브러리) [DllImport("__Internal")] IL2CPP 빌드에서 반드시 지킬 두 가지 ① 네이티브가 되부르는 C# 메서드는 static 이어야 하고 [MonoPInvokeCallback] 을 붙여야 합니다 ② 에디터는 한 번 로드한 DLL 을 놓지 않습니다 — C++ 를 다시 빌드하려면 에디터를 재시작해야 합니다 iOS 만 규칙이 다릅니다 — 동적 로딩이 막혀 있어 정적 링크 후 __Internal 로 부릅니다
헷갈리면 Plugin Inspector 의 “Include Platforms” 체크박스부터 확인하세요.

가장 단순한 Unity 플러그인

// Assets/Scripts/MyLib.cs
using System;
using System.Runtime.InteropServices;
using UnityEngine;

public static class MyLib
{
#if UNITY_IOS && !UNITY_EDITOR
    private const string Lib = "__Internal";   // iOS 는 정적 링크
#else
    private const string Lib = "mylib";
#endif

    // Unity 의 C# 은 [LibraryImport] 소스 생성기를 쓰지 않습니다
    //   → 예전 방식 [DllImport] 를 그대로 씁니다
    [DllImport(Lib, CallingConvention = CallingConvention.Cdecl)]
    public static extern int mylib_version();

    [DllImport(Lib, CallingConvention = CallingConvention.Cdecl)]
    public static extern void mylib_scale(IntPtr data, long n, double k);
}

public class Boot : MonoBehaviour
{
    void Start() => Debug.Log($"mylib v{MyLib.mylib_version()}");
}
Unity 는 왜 LibraryImport 를 못 쓰나 — Unity 의 C# 컴파일 파이프라인은 Roslyn 소스 생성기를 제한적으로만 지원하고, 런타임도 .NET 10 이 아닌 Unity 자체 런타임(Mono / IL2CPP)입니다. 그래서 Unity 쪽 코드는 [DllImport] 기준으로 작성한다고 생각하면 됩니다.

NativeArray 를 복사 없이 넘기기 ★

using Unity.Collections;
using Unity.Collections.LowLevel.Unsafe;

public class Processor : MonoBehaviour
{
    NativeArray<float> _buf;

    void Awake() => _buf = new NativeArray<float>(1 << 20, Allocator.Persistent);
    void OnDestroy() { if (_buf.IsCreated) _buf.Dispose(); }

    unsafe void Update()
    {
        // NativeArray 는 애초에 관리 힙 밖에 있습니다 → 핀이 필요 없습니다 ★
        void* p = NativeArrayUnsafeUtility.GetUnsafePtr(_buf);
        MyLib.mylib_scale((IntPtr)p, _buf.Length, 1.01);
    }
}

// 관리 배열(float[])을 넘겨야 한다면 핀을 명시적으로 잡습니다
unsafe void Send(float[] xs)
{
    fixed (float* p = xs)
        MyLib.mylib_scale((IntPtr)p, xs.Length, 1.01);
}

// 텍스처 픽셀도 그대로 넘길 수 있습니다 — 복사 0
var raw = texture.GetRawTextureData<byte>();     // NativeArray<byte>
unsafe { MyLib.mylib_blur((IntPtr)raw.GetUnsafePtr(), w, h, stride, 4, 3); }
texture.Apply(false);
Unity 에서 NativeArray 를 쓰는 것이 정답인 이유 — 관리 배열은 GC 대상이라 핀을 잡아야 하고, 오래 잡으면 GC 를 방해합니다. NativeArray 는 처음부터 네이티브 메모리라 그 문제가 없고, Burst · Job System 과도 그대로 이어집니다. 단 Dispose() 를 잊으면 에디터가 누수를 경고합니다.

이렇게도 쓸 수 있다 — IL2CPP 와 콜백 🟡 중급

MonoPInvokeCallback — 없으면 빌드에서만 죽습니다

using AOT;    // ★ MonoPInvokeCallback 이 여기 있습니다

public static class MyLib
{
    [UnmanagedFunctionPointer(CallingConvention.Cdecl)]
    public delegate void LogFn(int level, IntPtr utf8Message);

    [DllImport(Lib, CallingConvention = CallingConvention.Cdecl)]
    public static extern void mylib_set_logger(LogFn fn);

    // ★ static + [MonoPInvokeCallback] 둘 다 필수입니다
    [MonoPInvokeCallback(typeof(LogFn))]
    private static void OnLog(int level, IntPtr msg)
    {
        try
        {
            string s = Marshal.PtrToStringUTF8(msg) ?? "";
            if (level >= 2) Debug.LogError(s); else Debug.Log(s);
        }
        catch { /* 콜백에서 예외를 내보내면 그대로 크래시입니다 */ }
    }

    // ★ 델리게이트를 static 필드로 붙잡아 둡니다 — GC 수거 방지
    private static readonly LogFn s_log = OnLog;

    public static void Init() => mylib_set_logger(s_log);
}
이 버그가 악명 높은 이유 — 에디터(Mono)에서는 잘 돕니다. IL2CPP 로 빌드해 실기기에 올린 뒤에야 죽습니다. IL2CPP 는 AOT 컴파일이라 실행 중에 델리게이트 스텁을 만들 수 없어서, [MonoPInvokeCallback] 이 붙은 정적 메서드만 미리 스텁을 준비합니다. “에디터에서 되는데 빌드에서 죽는다”면 가장 먼저 여기를 보세요.

스트리핑과 링커 설정

<!-- Assets/link.xml — IL2CPP 코드 스트리핑에서 지킬 것 -->
<linker>
  <assembly fullname="Assembly-CSharp">
    <type fullname="MyLib" preserve="all" />
  </assembly>
</linker>
// 개별 메서드를 지키는 방법
[UnityEngine.Scripting.Preserve]
private static void OnLog(int level, IntPtr msg) { ... }

// IL2CPP 최적화 옵션 — 핫 경로에서 검사 비용을 줄입니다
[Il2CppSetOption(Option.NullChecks, false)]
[Il2CppSetOption(Option.ArrayBoundsChecks, false)]
public static unsafe void HotLoop(float* p, int n) { ... }
//  ⚠ 검사를 끄면 잘못된 인덱스가 '조용히' 메모리를 밟습니다.
//     네이티브 경계에서 인자를 이미 검증한 코드에만 쓰세요.
Player Settings 에서 확인할 것

  Scripting Backend        IL2CPP  (모바일·콘솔은 사실상 강제)
  Api Compatibility Level  .NET Standard 2.1 이상
  Target Architectures     ARM64 (안드로이드) — ARMv7 은 이제 거의 안 씁니다
  Managed Stripping Level  Low 부터 시작해 문제 없으면 올립니다

플러그인 파일마다 Inspector 에서
  · CPU / OS 를 정확히 지정 (Any 로 두면 잘못된 것이 섞입니다)
  · Load on startup 은 특별한 이유 없으면 끕니다

실무에선 이렇게 — 에디터 재시작 지옥 벗어나기 🔴 실무

문제 — 한 번 로드하면 놓지 않습니다

증상
  ① C++ 를 고치고 다시 빌드한다
  ② 새 DLL 을 Assets/Plugins/x86_64/ 에 덮어쓰려 한다
  ③ "다른 프로세스가 사용 중입니다" (윈도우)
  ④ 유일한 해결 = Unity 에디터 재시작 (30초~2분)

C++ 를 하루에 50번 고친다면 그것만으로 하루가 날아갑니다 ★

원인
  [DllImport] 로 처음 호출되는 순간 Unity 가 DLL 을 로드하고,
  도메인 리로드가 일어나도 네이티브 모듈은 언로드하지 않습니다.
  (프로세스에 붙은 네이티브 모듈은 Unity 가 관리하지 않습니다)

해결 방향
  ✓ 개발 중에는 [DllImport] 대신 '직접 로드/해제' 를 씁니다
  ✓ 배포 빌드에서는 원래 [DllImport] 로 돌아갑니다
  ✓ 조건부 컴파일로 두 경로를 동시에 유지합니다

해법 — 에디터에서만 수동 로드

#if UNITY_EDITOR && UNITY_EDITOR_WIN
using System;
using System.Runtime.InteropServices;

public static class NativeLoader
{
    [DllImport("kernel32")] static extern IntPtr LoadLibrary(string p);
    [DllImport("kernel32")] static extern bool   FreeLibrary(IntPtr h);
    [DllImport("kernel32")] static extern IntPtr GetProcAddress(IntPtr h, string n);

    static IntPtr _h;

    public static void Load(string path)
    {
        Unload();
        // 원본을 잠그지 않도록 임시 복사본을 로드합니다 ★
        var tmp = $"{path}.{Guid.NewGuid():N}.dll";
        System.IO.File.Copy(path, tmp, true);
        _h = LoadLibrary(tmp);
        if (_h == IntPtr.Zero) throw new Exception($"로드 실패: {tmp}");
    }

    public static T Get<T>(string name) where T : Delegate =>
        Marshal.GetDelegateForFunctionPointer<T>(GetProcAddress(_h, name));

    public static void Unload()
    {
        if (_h != IntPtr.Zero) { FreeLibrary(_h); _h = IntPtr.Zero; }
    }
}
#endif
임시 복사본을 로드하는 것이 요령입니다 — 원본 파일이 잠기지 않으므로 에디터를 켜 둔 채로 C++ 를 다시 빌드할 수 있습니다. 플레이 모드에 들어갈 때 Load, 나올 때 Unload 를 걸어 두면 재시작 없이 반복 개발이 됩니다. 배포 빌드에는 이 코드가 들어가지 않게 #if UNITY_EDITOR 로 확실히 감싸세요.

한 단계 더 — 모바일 빌드의 실제 절차 🧬 전문가

안드로이드 — NDK 로 .so 만들기

# Android NDK 의 CMake 툴체인을 그대로 씁니다
ANDROID_NDK=$HOME/Android/Sdk/ndk/27.0.12077973

cmake -S native -B build/android-arm64 \
  -DCMAKE_TOOLCHAIN_FILE=$ANDROID_NDK/build/cmake/android.toolchain.cmake \
  -DANDROID_ABI=arm64-v8a \
  -DANDROID_PLATFORM=android-24 \
  -DCMAKE_BUILD_TYPE=Release
cmake --build build/android-arm64 -j

cp build/android-arm64/libmylib.so \
   unity/Assets/Plugins/Android/libs/arm64-v8a/

# 확인 — ABI 와 최소 API 레벨
readelf -h libmylib.so | grep Machine        # AArch64 여야 합니다
llvm-readelf --dynamic libmylib.so | grep NEEDED
C++ 표준 라이브러리 링크 방식이 안드로이드에서 자주 문제가 됩니다. ANDROID_STL=c++_shared 로 빌드했다면 libc++_shared.so 도 함께 넣어야 하고, 다른 플러그인이 다른 버전을 넣으면 충돌합니다. 가능하면 c++_static 으로 빌드해 의존성을 없애세요.

iOS — 정적 라이브러리 + __Internal

# iOS 는 앱이 자기 동적 라이브러리를 dlopen 하는 것이 제한됩니다
#   → 정적 라이브러리(.a)로 만들어 Xcode 프로젝트에 링크합니다

cmake -S native -B build/ios -G Xcode \
  -DCMAKE_SYSTEM_NAME=iOS \
  -DCMAKE_OSX_ARCHITECTURES=arm64 \
  -DCMAKE_IOS_INSTALL_COMBINED=YES \
  -DBUILD_SHARED_LIBS=OFF                    # ★ 정적
cmake --build build/ios --config Release

cp build/ios/Release-iphoneos/libmylib.a unity/Assets/Plugins/iOS/
// Unity 빌드 후 Xcode 프로젝트를 자동으로 손보기
#if UNITY_IOS
using UnityEditor.Callbacks;
using UnityEditor.iOS.Xcode;

public static class IosPostBuild
{
    [PostProcessBuild]
    public static void OnPostProcess(BuildTarget t, string path)
    {
        if (t != BuildTarget.iOS) return;
        var projPath = PBXProject.GetPBXProjectPath(path);
        var proj = new PBXProject();
        proj.ReadFromFile(projPath);
        var target = proj.GetUnityFrameworkTargetGuid();
        proj.AddFrameworkToProject(target, "Accelerate.framework", false);
        proj.SetBuildProperty(target, "ENABLE_BITCODE", "NO");
        proj.WriteToFile(projPath);
    }
}
#endif
iOS 에서 __Internal 이 뜻하는 것 — “별도 파일이 아니라 실행 파일 자체 안에 있는 심볼을 찾아라”는 지시입니다. 그래서 extern "C" 와 심볼 노출이 특히 중요하고, Xcode 가 쓰이지 않는다고 판단해 심볼을 떼어 내지 않도록 (필요하면 -force_load 로) 링크 설정을 확인해야 합니다.
정리 — Unity 에서는 [DllImport] + Plugins 폴더 규약, 콜백에는 [MonoPInvokeCallback] + static + 정적 필드 보관, 배열은 NativeArray. 그리고 개발 중에는 수동 로드/해제로 에디터 재시작을 피합니다.
C# 11

배포 — NuGet 규약 · NativeAOT · 크로스 플랫폼

내 컴퓨터에서 도는 것과 남의 컴퓨터에서 도는 것은 완전히 다른 문제입니다. 다행히 .NET 에는 네이티브 라이브러리를 담는 표준 규약이 있어서, 그 폴더 구조만 지키면 런타임이 알아서 맞는 파일을 골라 줍니다.

NuGet 패키지 안의 표준 배치 — 이 구조만 지키면 됩니다 MyLib.Native.1.0.0.nupkg lib/net10.0/MyLib.dll — 관리 래퍼 runtimes/ win-x64/native/mylib.dll linux-x64/native/libmylib.so osx-arm64/native/libmylib.dylib linux-arm64/native/libmylib.so 런타임이 대신 해 주는 일 ① 지금 도는 환경의 RID 를 알아냅니다 win-x64 · linux-arm64 · osx-arm64 … ② runtimes/<rid>/native 에서 맞는 파일 하나만 고릅니다 ③ 게시하면 그 하나만 결과물에 들어갑니다 — 나머지는 빠집니다 dotnet publish -r linux-x64 → 리눅스용만 담깁니다 쓰는 사람은 dotnet add package 한 줄이면 끝 — C++ 를 빌드할 필요가 없습니다
이 규약을 지키면 “내 컴퓨터에서는 되는데”가 구조적으로 사라집니다.

패키지를 만드는 csproj

<!-- MyLib/MyLib.csproj — 관리 래퍼 + 네이티브 자산을 함께 담습니다 -->
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <AllowUnsafeBlocks>true</AllowUnsafeBlocks>

    <PackageId>MyCompany.MyLib</PackageId>
    <Version>1.0.0</Version>
    <Description>C++ 이미지 처리 코어의 .NET 바인딩</Description>
    <PackageLicenseExpression>MIT</PackageLicenseExpression>
    <IncludeSymbols>true</IncludeSymbols>
    <SymbolPackageFormat>snupkg</SymbolPackageFormat>
  </PropertyGroup>

  <!-- ★ 핵심 — 각 RID 의 native 폴더로 넣습니다 -->
  <ItemGroup>
    <None Include="../artifacts/win-x64/mylib.dll"
          Pack="true" PackagePath="runtimes/win-x64/native/" />
    <None Include="../artifacts/win-arm64/mylib.dll"
          Pack="true" PackagePath="runtimes/win-arm64/native/" />
    <None Include="../artifacts/linux-x64/libmylib.so"
          Pack="true" PackagePath="runtimes/linux-x64/native/" />
    <None Include="../artifacts/linux-arm64/libmylib.so"
          Pack="true" PackagePath="runtimes/linux-arm64/native/" />
    <None Include="../artifacts/osx-arm64/libmylib.dylib"
          Pack="true" PackagePath="runtimes/osx-arm64/native/" />
  </ItemGroup>
</Project>
dotnet pack -c Release          # → bin/Release/MyCompany.MyLib.1.0.0.nupkg
dotnet nuget push bin/Release/*.nupkg -s https://api.nuget.org/v3/index.json -k $KEY

# 쓰는 쪽은 이 한 줄이면 끝입니다
dotnet add package MyCompany.MyLib

RID 목록 — 무엇까지 담을 것인가

RID대상우선순위
win-x64윈도우 데스크톱·서버필수
linux-x64대부분의 서버·컨테이너필수
osx-arm64애플 실리콘 맥사실상 필수
linux-arm64ARM 서버·라즈베리파이권장
win-arm64ARM 윈도우선택
osx-x64인텔 맥선택
linux-musl-x64알파인 컨테이너별도 빌드
알파인(musl)이 왜 별도인가

  glibc 로 링크한 .so 는 알파인에서 그냥 로드에 실패합니다
    Error loading shared library ld-linux-x86-64.so.2

  → musl 툴체인으로 다시 빌드해 linux-musl-x64 에 넣어야 합니다
  → 도커 이미지를 mcr.microsoft.com/dotnet/runtime:10.0-alpine 로
    쓰는 팀이 많아, 이걸 빠뜨리면 문의가 반드시 들어옵니다

glibc 버전 함정
  최신 우분투에서 빌드한 .so 는 오래된 배포판에서 못 돕니다
    version `GLIBC_2.38' not found

  → CI 에서 '가장 오래된 지원 대상' 으로 빌드합니다
    (예: ubuntu-22.04 컨테이너, 또는 manylinux 이미지)
이 두 가지가 실제로 가장 많은 이슈를 만듭니다 — 코드는 완벽한데 “고객사 컨테이너에서만 안 된다”는 리포트의 대부분이 musl 아니면 glibc 버전입니다. 처음부터 CI 매트릭스에 넣어 두세요.

이렇게도 쓸 수 있다 — 탐색 경로를 직접 정하기 🟡 중급

SetDllImportResolver — 만능 열쇠 ★

using System.Reflection;
using System.Runtime.InteropServices;

internal static class NativeResolver
{
    // 이 어셈블리의 P/Invoke 가 처음 라이브러리를 찾을 때 불립니다
    [ModuleInitializer]                       // 어셈블리 로드 시 자동 실행
    internal static void Init() =>
        NativeLibrary.SetDllImportResolver(typeof(NativeResolver).Assembly, Resolve);

    private static IntPtr Resolve(string name, Assembly asm, DllImportSearchPath? path)
    {
        if (name != "mylib") return IntPtr.Zero;   // 다른 건 기본 동작에 맡깁니다

        // ① 환경 변수로 지정된 경로를 최우선으로 (배포 후 진단에 유용)
        var env = Environment.GetEnvironmentVariable("MYLIB_PATH");
        if (!string.IsNullOrEmpty(env) && NativeLibrary.TryLoad(env, out var h1))
            return h1;

        // ② 내가 정한 폴더 구조에서 찾기
        string rid = RuntimeInformation.RuntimeIdentifier;      // "linux-x64" 등
        string file = OperatingSystem.IsWindows() ? "mylib.dll"
                    : OperatingSystem.IsMacOS()   ? "libmylib.dylib"
                    :                               "libmylib.so";
        string p = Path.Combine(AppContext.BaseDirectory, "native", rid, file);
        if (File.Exists(p) && NativeLibrary.TryLoad(p, out var h2)) return h2;

        return IntPtr.Zero;                     // 기본 탐색으로 넘깁니다
    }
}
언제 필요한가 — NuGet 규약을 못 쓰는 상황들입니다. 플러그인 폴더에서 로드해야 하거나, 고객이 라이브러리 경로를 지정해야 하거나, CPU 기능(AVX-512 유무)에 따라 다른 바이너리를 고르는 경우입니다. [ModuleInitializer] 와 짝지으면 사용자가 아무것도 호출하지 않아도 걸립니다.

CPU 기능에 따라 골라 싣기

using System.Runtime.Intrinsics.X86;

private static string PickVariant()
{
    if (Avx512F.IsSupported) return "mylib_avx512";
    if (Avx2.IsSupported)    return "mylib_avx2";
    return "mylib_sse2";               // 안전한 기본값
}

// Resolver 안에서 이 이름으로 로드합니다
if (NativeLibrary.TryLoad(Path.Combine(dir, PickVariant() + ext), out var h))
    return h;
# 같은 소스를 세 번 빌드합니다
for v in sse2 avx2 avx512; do
  case $v in
    sse2)   FLAGS="-msse2" ;;
    avx2)   FLAGS="-mavx2 -mfma" ;;
    avx512) FLAGS="-mavx512f -mavx512dq" ;;
  esac
  cmake -S native -B build/$v -DCMAKE_CXX_FLAGS="$FLAGS -O3"
  cmake --build build/$v -j
  cp build/$v/libmylib.so artifacts/linux-x64/libmylib_$v.so
done
왜 하나로 못 만드나 — -mavx512f 로 빌드한 바이너리를 AVX-512 가 없는 CPU 에서 로드하면 첫 명령에서 즉시 죽습니다(illegal instruction). C++ 안에서 런타임 디스패치(__builtin_cpu_supports)를 하는 방법도 있지만, 파일을 나누는 편이 빌드 플래그를 단순하게 유지해 줍니다.

실무에선 이렇게 — CI 로 다섯 플랫폼을 한 번에 🔴 실무

GitHub Actions 매트릭스

name: build-native
on: [push]

jobs:
  native:
    strategy:
      matrix:
        include:
          - { os: windows-latest, rid: win-x64,     out: mylib.dll }
          - { os: windows-latest, rid: win-arm64,   out: mylib.dll }
          - { os: ubuntu-22.04,   rid: linux-x64,   out: libmylib.so }
          - { os: ubuntu-22.04,   rid: linux-arm64, out: libmylib.so }
          - { os: macos-14,       rid: osx-arm64,   out: libmylib.dylib }
    runs-on: ${{ matrix.os }}
    steps:
      - uses: actions/checkout@v4
      - run: cmake -S native -B build -DCMAKE_BUILD_TYPE=Release
      - run: cmake --build build --config Release -j
      - uses: actions/upload-artifact@v4
        with:
          name: ${{ matrix.rid }}
          path: build/**/${{ matrix.out }}

  pack:
    needs: native
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/download-artifact@v4
        with: { path: artifacts }
      - run: dotnet pack MyLib/MyLib.csproj -c Release
      - run: dotnet nuget push "**/*.nupkg" -s nuget.org -k ${{ secrets.NUGET_KEY }}
ubuntu-22.04 를 고정한 이유 — ubuntu-latest 는 시간이 지나면 새 버전으로 바뀌고, 그때 glibc 요구 버전이 조용히 올라갑니다. 네이티브 자산을 만드는 잡에서는 OS 이미지를 반드시 못 박으세요. 어느 날 갑자기 “옛 서버에서 안 된다”는 이슈가 들어오는 것을 막아 줍니다.

단일 파일 · 컨테이너 배포

# 단일 실행 파일 — 네이티브 라이브러리까지 포함
dotnet publish -c Release -r win-x64 --self-contained \
  -p:PublishSingleFile=true \
  -p:IncludeNativeLibrariesForSelfExtract=true
#   ★ 두 번째 옵션이 없으면 네이티브 파일이 옆에 따로 남습니다
#   실행하면 임시 폴더에 풀어서 로드합니다 (첫 실행이 조금 느립니다)
# Dockerfile — 멀티스테이지
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
RUN apt-get update && apt-get install -y cmake g++
WORKDIR /src
COPY . .
RUN cmake -S native -B build -DCMAKE_BUILD_TYPE=Release \
 && cmake --build build -j \
 && dotnet publish App -c Release -r linux-x64 --no-self-contained -o /app

FROM mcr.microsoft.com/dotnet/aspnet:10.0
WORKDIR /app
COPY --from=build /app .
# 네이티브 .so 가 함께 복사되었는지 확인하는 습관 ★
RUN ls -l libmylib.so
ENTRYPOINT ["dotnet", "App.dll"]
런타임 이미지에 C++ 런타임이 없을 수 있습니다 — aspnet:10.0 이미지는 libstdc++ 를 갖고 있지만, chiseled·distroless 이미지는 최소 구성이라 없을 수 있습니다. ldd libmylib.so 로 의존성을 확인하고, 가능하면 -static-libstdc++ -static-libgcc 로 빌드해 의존성을 줄이세요.

한 단계 더 — NativeAOT — C# 자체를 네이티브로 🧬 전문가

C# 앱을 네이티브 실행 파일로

<PropertyGroup>
  <PublishAot>true</PublishAot>
  <InvariantGlobalization>true</InvariantGlobalization>
  <StripSymbols>true</StripSymbols>
</PropertyGroup>

<!-- ★ 네이티브 라이브러리를 실행 파일 안에 정적 링크까지 -->
<ItemGroup>
  <DirectPInvoke Include="mylib" />
  <NativeLibrary Include="../artifacts/linux-x64/libmylib.a" />
</ItemGroup>
dotnet publish -c Release -r linux-x64
#   → 실행 파일 하나. .NET 런타임 설치 불필요.
#   시작 시간 ~5ms (JIT 방식은 ~50ms)
#   메모리 사용량이 크게 줄고, 크기도 십수 MB 수준

# 확인 — 정말 정적으로 들어갔는지
ldd bin/Release/net10.0/linux-x64/publish/App | grep mylib
#   아무것도 안 나오면 성공 (안에 들어간 것)
DirectPInvoke 의 의미 — “이 라이브러리는 실행 중에 찾지 말고 링크 시점에 직접 연결하라”는 지시입니다. 그러면 dlopen 조차 없어져 호출이 평범한 함수 호출과 동일한 비용이 되고, 배포물이 파일 하나가 됩니다. CLI 도구·서버리스에서 특히 강력합니다.

AOT · 트리밍에서 지켜야 할 것

NativeAOT트리밍
[LibraryImport]완전 지원안전
[DllImport]대체로 동작경고 가능
delegate* 콜백완전 지원안전
delegate 콜백제약 있음주의
C++/CLI불가불가
런타임 리플렉션제한적지워질 수 있음
# 미리 경고를 켜서 확인합니다 — 빌드 단계에서 전부 드러납니다
dotnet publish -c Release -r linux-x64 \
  -p:PublishAot=true \
  -p:TrimmerSingleWarn=false

#   IL2026 · IL3050 · IL3053 같은 경고가 나오면
#   그 코드가 AOT 에서 깨질 수 있다는 뜻입니다

# 라이브러리 저자라면 미리 검사해 두세요
#   <IsAotCompatible>true</IsAotCompatible>   ← 분석기가 켜집니다
이 표가 이 탭 전체의 결론이기도 합니다 — LibraryImport + delegate* 조합만 쓰면 일반 실행 · 트리밍 · 단일 파일 · NativeAOT 어디서나 같은 코드가 그대로 돕니다. 구방식으로 시작하면 나중에 AOT 로 가려 할 때 전부 다시 써야 합니다.
정리 — 배포는 runtimes/<rid>/native NuGet 규약이 정답이고, 예외 상황은 SetDllImportResolver 로 덮습니다. musl · glibc 버전을 CI 매트릭스에 넣고, NativeAOT 를 염두에 두면 LibraryImport 로 통일하세요.
C# 12

실전 — 하나의 C++ 코어를 세 곳에서

앞의 열한 절을 한 프로젝트로 잇습니다. 이미지 리사이즈 C++ 모듈 하나를 만들고, 콘솔 CLI · ASP.NET Core Minimal API · Unity 세 곳에서 같은 NuGet 패키지로 씁니다. 마지막에는 BenchmarkDotNet 으로 경계 비용을 숫자로 확인합니다.

한 번 만들고 세 곳에서 — C ABI 하나가 만드는 재사용 C++ 이미지 리사이즈 코어 SIMD · 스레드 · 메모리를 할당하지 않는 순수 함수 C ABI 6개 함수 + NuGet 패키지 MyCompany.MyLib 1.0.0 — runtimes 규약 콘솔 CLI resize in.png out.png 800 ASP.NET Core POST /resize — 스트림 그대로 Unity 런타임 텍스처 리사이즈 마지막에 반드시 — 경계 비용을 숫자로 확인합니다 순수 C# 구현 대비 몇 배인지, P/Invoke 오버헤드가 전체 시간의 몇 퍼센트인지를 BenchmarkDotNet 으로 재 둡니다 숫자가 없으면 “C++ 로 옮길 가치가 있었나”를 아무도 답하지 못합니다
세 소비자가 같은 패키지를 씁니다 — 코어를 고치면 세 곳이 동시에 좋아집니다.

① C++ 코어와 C ABI

// native/resize.cpp — 바이리니어 축소. 메모리를 할당하지 않습니다
#include <cstdint>
#include <algorithm>
#include <thread>
#include <vector>

static void resize_rows(const uint8_t* src, int sw, int sh, int sstride,
                        uint8_t* dst, int dw, int dh, int dstride,
                        int ch, int y0, int y1)
{
    const float fx = static_cast<float>(sw) / dw;
    const float fy = static_cast<float>(sh) / dh;
    for (int y = y0; y < y1; ++y) {
        const float sy = (y + 0.5f) * fy - 0.5f;
        const int   y1i = std::clamp(static_cast<int>(sy), 0, sh - 1);
        const int   y2i = std::min(y1i + 1, sh - 1);
        const float wy = sy - y1i;
        for (int x = 0; x < dw; ++x) {
            const float sx = (x + 0.5f) * fx - 0.5f;
            const int   x1i = std::clamp(static_cast<int>(sx), 0, sw - 1);
            const int   x2i = std::min(x1i + 1, sw - 1);
            const float wx = sx - x1i;
            for (int c = 0; c < ch; ++c) {
                const float a = src[y1i*sstride + x1i*ch + c] * (1-wx)
                              + src[y1i*sstride + x2i*ch + c] * wx;
                const float b = src[y2i*sstride + x1i*ch + c] * (1-wx)
                              + src[y2i*sstride + x2i*ch + c] * wx;
                dst[y*dstride + x*ch + c] =
                    static_cast<uint8_t>(a * (1-wy) + b * wy + 0.5f);
            }
        }
    }
}
// 경계 함수 — 검증 · 스레드 분할 · 예외 봉인이 전부입니다
extern "C" MYLIB_API int32_t mylib_resize(
    const uint8_t* src, int32_t sw, int32_t sh, int32_t sstride,
    uint8_t*       dst, int32_t dw, int32_t dh, int32_t dstride,
    int32_t ch, int32_t threads)
{
    MYLIB_GUARD({
        if (!src || !dst)                  return MYLIB_EINVAL;
        if (sw <= 0 || sh <= 0)            return MYLIB_EINVAL;
        if (dw <= 0 || dh <= 0)            return MYLIB_EINVAL;
        if (ch != 1 && ch != 3 && ch != 4) return MYLIB_EINVAL;
        if (sstride < sw * ch || dstride < dw * ch) return MYLIB_EINVAL;

        int n = threads > 0 ? threads
              : std::max(1u, std::thread::hardware_concurrency());
        n = std::min(n, dh);

        std::vector<std::thread> ts;
        const int chunk = (dh + n - 1) / n;
        for (int i = 0; i < n; ++i)
            ts.emplace_back(resize_rows, src, sw, sh, sstride,
                            dst, dw, dh, dstride, ch,
                            i * chunk, std::min((i + 1) * chunk, dh));
        for (auto& t : ts) t.join();
        return MYLIB_OK;
    })
}

② C# 래퍼 라이브러리

// MyLib/Native.cs — 원시 층 (internal)
namespace MyCompany.MyLib;

internal static partial class Native
{
    [LibraryImport("mylib")]
    internal static partial int mylib_resize(
        ReadOnlySpan<byte> src, int sw, int sh, int sstride,
        Span<byte>         dst, int dw, int dh, int dstride,
        int ch, int threads);

    [LibraryImport("mylib")]
    internal static partial IntPtr mylib_last_error();

    [LibraryImport("mylib")]
    internal static partial int mylib_version();
}
// MyLib/Resizer.cs — 공개 층 (사람이 쓰는 유일한 API)
namespace MyCompany.MyLib;

public static class Resizer
{
    public static int Version => Native.mylib_version();

    /// <summary>RGBA/RGB/그레이 이미지를 축소·확대합니다.</summary>
    public static void Resize(
        ReadOnlySpan<byte> src, int srcW, int srcH,
        Span<byte> dst, int dstW, int dstH,
        int channels = 4, int threads = 0)
    {
        int need = dstW * dstH * channels;
        if (dst.Length < need)
            throw new ArgumentException($"dst 버퍼가 작습니다 ({need} 필요)");

        int rc = Native.mylib_resize(
            src, srcW, srcH, srcW * channels,
            dst, dstW, dstH, dstW * channels,
            channels, threads);

        if (rc != 0) Throw(rc);
    }

    // 배열을 새로 만들어 돌려주는 편의 오버로드
    public static byte[] Resize(byte[] src, int sw, int sh,
                                int dw, int dh, int ch = 4)
    {
        var dst = new byte[dw * dh * ch];
        Resize(src, sw, sh, dst, dw, dh, ch);
        return dst;
    }

    private static void Throw(int rc)
    {
        string msg = Marshal.PtrToStringUTF8(Native.mylib_last_error()) ?? "";
        throw rc switch
        {
            -1 => new ArgumentException(msg),
            -2 => new OutOfMemoryException(msg),
            _  => new InvalidOperationException($"{msg} (rc={rc})")
        };
    }
}
공개 API 가 Span 버전과 배열 버전 둘 다 있는 이유 — 성능이 중요한 호출자는 버퍼를 재사용하고, 편하게 쓰고 싶은 호출자는 배열을 받아 갑니다. 둘 다 제공하되 내부 구현은 Span 하나로 모읍니다.

이렇게도 쓸 수 있다 — 세 소비자 🟡 중급

콘솔 CLI

// Cli/Program.cs
using SkiaSharp;                       // 디코딩·인코딩만 담당
using MyCompany.MyLib;

if (args.Length < 3)
{
    Console.Error.WriteLine("사용법: resize <입력> <출력> <가로>");
    return 1;
}

using var input = SKBitmap.Decode(args[0]);
int dw = int.Parse(args[2]);
int dh = (int)Math.Round(input.Height * (double)dw / input.Width);

var srcPixels = input.Bytes;           // RGBA8888
var dstPixels = new byte[dw * dh * 4];

var sw = Stopwatch.StartNew();
Resizer.Resize(srcPixels, input.Width, input.Height, dstPixels, dw, dh);
sw.Stop();

using var outBmp = new SKBitmap(new SKImageInfo(dw, dh, SKColorType.Rgba8888));
outBmp.Bytes.CopyTo(dstPixels.AsSpan());       // 실제로는 InstallPixels 사용
using var img = SKImage.FromBitmap(outBmp);
using var fs  = File.OpenWrite(args[1]);
img.Encode(SKEncodedImageFormat.Png, 90).SaveTo(fs);

Console.WriteLine($"{input.Width}x{input.Height} → {dw}x{dh}  {sw.ElapsedMilliseconds}ms");
return 0;
역할 분담에 주목하세요 — 디코딩·인코딩은 성숙한 C# 라이브러리(SkiaSharp·ImageSharp)에 맡기고, C++ 는 픽셀 루프만 가져갑니다. C++ 로 PNG 디코더까지 옮기려 들면 일이 열 배가 되고 얻는 것은 없습니다. 경계는 핫스팟에만 긋습니다.

ASP.NET Core Minimal API

// Api/Program.cs
using MyCompany.MyLib;

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

app.MapGet("/healthz", () => Results.Ok(new { mylib = Resizer.Version }));

app.MapPost("/resize", async (HttpRequest req, int w, CancellationToken ct) =>
{
    // ① 업로드를 풀로 빌린 버퍼에 받습니다 (할당 최소화)
    using var ms = new MemoryStream();
    await req.Body.CopyToAsync(ms, ct);

    using var src = SKBitmap.Decode(ms.ToArray());
    if (src is null) return Results.BadRequest("이미지를 읽을 수 없습니다");

    int h = (int)Math.Round(src.Height * (double)w / src.Width);
    byte[] dst = ArrayPool<byte>.Shared.Rent(w * h * 4);
    try
    {
        // ② C++ 호출 — 요청 스레드를 잡습니다
        Resizer.Resize(src.Bytes, src.Width, src.Height,
                       dst.AsSpan(0, w * h * 4), w, h,
                       channels: 4, threads: 1);   // ★ threads: 1 이 중요합니다
        ...인코딩 후 반환...
    }
    finally { ArrayPool<byte>.Shared.Return(dst); }
});

app.Run();
threads: 1 이 왜 중요한가 — 웹 서버는 이미 요청마다 스레드를 씁니다. 요청 100개가 동시에 들어왔는데 각자 코어 수만큼 스레드를 만들면 수백 개의 스레드가 서로를 방해해 처리량이 오히려 떨어집니다. “병렬은 한 층에서만” — 서버에서는 요청 수준의 병렬성에 맡기세요.

실무에선 이렇게 — Unity 에서 같은 코어를 🔴 실무

런타임 텍스처 리사이즈

// Assets/Scripts/TextureResizer.cs
using Unity.Collections;
using Unity.Collections.LowLevel.Unsafe;
using UnityEngine;
using System.Runtime.InteropServices;

public static class TextureResizer
{
    const string Lib =
#if UNITY_IOS && !UNITY_EDITOR
        "__Internal";
#else
        "mylib";
#endif

    [DllImport(Lib, CallingConvention = CallingConvention.Cdecl)]
    static extern int mylib_resize(IntPtr src, int sw, int sh, int sstride,
                                   IntPtr dst, int dw, int dh, int dstride,
                                   int ch, int threads);

    public static unsafe Texture2D Resize(Texture2D src, int dw, int dh)
    {
        var dstTex = new Texture2D(dw, dh, TextureFormat.RGBA32, false);

        NativeArray<byte> s = src.GetRawTextureData<byte>();
        NativeArray<byte> d = dstTex.GetRawTextureData<byte>();

        int rc = mylib_resize(
            (IntPtr)s.GetUnsafeReadOnlyPtr(), src.width, src.height, src.width * 4,
            (IntPtr)d.GetUnsafePtr(),         dw,        dh,         dw * 4,
            4, 0);

        if (rc != 0) { Object.Destroy(dstTex); throw new Exception($"resize {rc}"); }

        dstTex.Apply(false);      // GPU 로 업로드
        return dstTex;
    }
}
여기서 복사가 몇 번 일어나나 — 0번입니다. GetRawTextureData 는 텍스처의 CPU 쪽 메모리를 그대로 가리키고, C++ 는 그 주소를 직접 읽고 씁니다. Apply() 만이 실제 전송(CPU→GPU)입니다. 같은 일을 GetPixels()/SetPixels() 로 하면 Color 배열 변환 때문에 수십 배 느립니다.

한 저장소로 세 소비자 관리하기

mylib/
  native/                      ← C++ 코어 (진실의 원천)
    include/mylib/resize.h     ← 경계 헤더 · 규약 주석
    src/resize.cpp
    tests/test_c_api.c         ← C 로 쓴 계약 테스트 ★

  dotnet/
    MyLib/                     ← 관리 래퍼 + NuGet 패키징
    MyLib.Tests/               ← 계약·누수·성능 테스트
    Cli/                       ← 콘솔
    Api/                       ← ASP.NET Core
    Bench/                     ← BenchmarkDotNet

  unity/
    Assets/Plugins/x86_64/     ← CI 가 채워 넣습니다
    Assets/Scripts/

  .github/workflows/
    native.yml                 ← 5개 RID 빌드 → artifacts
    pack.yml                   ← NuGet 패키징 · 배포
    unity.yml                  ← 플러그인 복사 · 빌드 검증

규칙
  · unity/Assets/Plugins 는 절대 손으로 채우지 않습니다 (CI 만)
  · dotnet/MyLib 이 참조하는 네이티브도 artifacts/ 에서만
  · 로컬 개발은 tools/dev-build.sh 한 줄로 전부
tests/test_c_api.c 를 C 로 쓰는 이유 — C 컴파일러로 컴파일된다는 것 자체가 “이 헤더가 정말 C ABI 다”라는 증명입니다. C++ 로만 테스트하면 실수로 bool·참조·기본 인자가 헤더에 들어가도 통과합니다. 다른 언어 바인딩이 전부 이 헤더에 의존하므로 이 테스트가 값어치를 합니다.

한 단계 더 — 숫자로 마무리하기 🧬 전문가

BenchmarkDotNet 설정

// Bench/ResizeBench.cs
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using MyCompany.MyLib;

[MemoryDiagnoser]
[SimpleJob(warmupCount: 3, iterationCount: 10)]
public class ResizeBench
{
    private byte[] _src = null!, _dst = null!;
    private const int SW = 4096, SH = 4096, DW = 1024, DH = 1024;

    [GlobalSetup]
    public void Setup()
    {
        _src = new byte[SW * SH * 4];
        Random.Shared.NextBytes(_src);
        _dst = new byte[DW * DH * 4];
    }

    [Benchmark(Baseline = true)]                 // 순수 C# 구현
    public void ManagedBilinear() => ManagedResize.Run(_src, SW, SH, _dst, DW, DH);

    [Benchmark]                                  // C++ · 단일 스레드
    public void NativeSingle() =>
        Resizer.Resize(_src, SW, SH, _dst, DW, DH, 4, threads: 1);

    [Benchmark]                                  // C++ · 전 코어
    public void NativeMulti() =>
        Resizer.Resize(_src, SW, SH, _dst, DW, DH, 4, threads: 0);

    [Benchmark]                                  // 경계 자체의 비용
    public int EmptyCall() => Resizer.Version;
}

BenchmarkRunner.Run<ResizeBench>();
dotnet run -c Release --project Bench
# 결과는 BenchmarkDotNet.Artifacts/results/ 에 md·csv·html 로 떨어집니다
# → 그대로 PR 에 붙이면 리뷰가 훨씬 쉬워집니다

결과를 읽는 법

| Method          |      Mean | Ratio | Allocated |
|-----------------|----------:|------:|----------:|
| ManagedBilinear | 210.4 ms  |  1.00 |       0 B |
| NativeSingle    |  38.2 ms  |  0.18 |       0 B |
| NativeMulti     |   6.9 ms  |  0.03 |       0 B |
| EmptyCall       |   2.7 ns  |     - |       0 B |
        (수치는 예시입니다 — 기계마다 다릅니다)

여기서 읽어야 할 것 네 가지

  ① 실제 이득    210ms → 6.9ms = 약 30배. 옮길 가치가 있었습니다.
  ② 경계 비용    EmptyCall 2.7ns 는 6.9ms 의 0.00004%.
                 즉 P/Invoke 오버헤드는 '없는 것과 같습니다'.
  ③ 할당 0 B     Span 경로가 살아 있다는 증거입니다 ★
                 여기가 0 이 아니면 어딘가에서 복사가 부활한 것입니다.
  ④ 병렬 효율    38.2 / 6.9 = 5.5배 (8코어 기준) — 메모리 대역폭 한계

반대로 이런 결과가 나왔다면 되돌아봐야 합니다
  · Ratio 가 0.7 정도  → 이득이 적습니다. 복잡도를 감수할 값이 있나?
  · Allocated 가 큼    → 마샬링에서 복사가 일어나고 있습니다 (k03·k04)
  · EmptyCall 이 수백 ns → 문자열·구조체 마샬링이 끼어 있습니다
이 표를 저장소에 커밋해 두세요 — “왜 이 부분이 C++ 인가”라는 질문은 6개월 뒤에 반드시 나옵니다. 그때 측정치 한 장이 있으면 토론이 5분에 끝나고, 없으면 “다시 C# 으로 되돌리자”는 논의가 며칠을 잡아먹습니다.
정리 — C++ 코어 하나 · C ABI 하나 · NuGet 패키지 하나로 콘솔 · 웹 API · Unity 를 전부 덮을 수 있습니다. 마지막에 이득 배수 · 경계 비용 · 할당 0바이트 세 숫자를 남겨 두면, 이 선택은 언제든 설명됩니다.
Java 25 LTS · JNI · Panama FFM · jextract · JNA · JNR · Android NDK r28 · Kotlin · Gradle

C++ 로 만든 알맹이를
자바와 코틀린이
그대로 불러 씁니다

JVM 은 서버에서 대단히 강한 런타임이지만, 픽셀 · 샘플 · 행렬을 수천만 개씩 훑는 루프나 이미 C++ 로만 존재하는 라이브러리(코덱 · 물리 엔진 · 벤더 SDK) 앞에서는 손이 묶입니다. 그래서 실무에서는 무거운 알맹이만 C++ 로 두고 나머지는 자바 · 코틀린으로 씁니다. 이 탭은 그 연결을 네 가지 길로 정리한 뒤, 지금도 가장 널리 쓰이는 JNI 를 끝까지 파고, Java 22 에서 정식이 된 Panama FFM 과 jextract, 손쉬운 JNA · JNR, 그리고 Gradle 패키징 · 안드로이드 NDK · 코틀린까지 이어 갑니다. 이 탭을 마치면 C++ 라이브러리를 Spring Boot 서버에도, 안드로이드 앱에도 붙일 수 있게 됩니다.

12SECTIONS
Java 25기준 (LTS)
4연결 방식
0복사 (DirectBuffer)
JVM 01

네 가지 길 — 어디로 갈 것인가를 먼저 정합니다

자바에서 C++ 를 부르는 방법은 크게 넷입니다. 넷 다 “된다”는 점에서는 같지만 호출 한 번의 비용 · 손으로 쓸 코드의 양 · 안드로이드 지원 여부가 전부 다릅니다. 코드를 한 줄 쓰기 전에 이 지도를 먼저 그려 놓아야 나중에 갈아엎지 않습니다.

네 가지 길 — 같은 C++ 함수 하나를 부르는 데 드는 비용이 이렇게 다릅니다 방식 호출 오버헤드 쓰기 편함 빌드 복잡도 JNI 1997년부터 · 안드로이드 포함 빠름 단순 호출 10~30ns 번거로움 헤더 생성 · 손코딩 높음 C++ 컴파일 필수 Panama FFM ★ Java 22 정식 · 25 LTS 에 포함 빠름 critical 옵션 시 JNI 급 쉬움 자바 코드만으로 선언 중간 extern "C" 만 있으면 JNR-FFI JRuby 가 쓰는 그 라이브러리 보통 JNI 의 2~5배 아주 쉬움 인터페이스 선언만 낮음 의존성 한 줄 JNA 가장 오래된 “쉬운 길” 느림 호출당 수백 ns~µs 가장 쉬움 인터페이스 하나면 끝 가장 낮음 jar 하나 추가 2026년의 기본값 — 새 JVM 프로젝트는 FFM, 안드로이드와 성숙한 스택은 JNI JNA · JNR 은 “프로토타입 하루 만에” 와 “관리 도구” 자리입니다 — 뜨거운 루프에는 두지 마세요
네 줄 중 어느 줄에 서느냐가 이 탭의 나머지를 결정합니다.
위에서부터 읽고 — 처음 “예”가 나온 줄에서 멈추면 됩니다 안드로이드 · KMP 인가? ART 에는 java.lang.foreign 이 아예 없습니다 JNI (안드로이드) · cinterop (KMP) v11 에서 다룹니다 — 선택의 여지가 없습니다 Java 22 이상을 쓸 수 있나? 25 가 LTS 이므로 새 서버라면 대개 예 Panama FFM + jextract ★ 기본값 C++ 쪽은 extern "C" 한 겹만 얹으면 끝납니다 Java 21 이하에 묶여 있나? 사내 표준 · 벤더 지원 때문에 못 올린다 JNI (뜨거운 길) · JNR (그 밖) 17 · 21 에도 FFM 은 preview 로만 있었습니다 하루 만에 붙여만 보면 되나? 호출이 초당 수천 번 이하인 관리 도구 JNA — 인터페이스 하나면 끝 뜨거운 루프에 들어가는 순간 후회합니다 공통 전제 — 어느 길이든 C++ 클래스는 그대로 못 넘깁니다. C ABI 한 겹이 항상 먼저입니다
넷 중 무엇을 고르든 C++ → C ABI 래퍼라는 첫 단계는 같습니다 — 🔗 C ABI 탭과 이어집니다.

네 가지 길을 한 표로 ★

JNIFFMJNRJNA
도입 시기JDK 1.1Java 22외부외부
손으로 쓸 C++많음extern "C" 만동일동일
손으로 쓸 자바적음중간거의 없음거의 없음
호출 비용10~30ns5~30ns50~200ns수백 ns~µs
메모리 안전낮음(JVM 크래시)중간(Arena)중간중간
C++ → 자바 콜백가능upcall제한적Callback
안드로이드표준불가불가가능(느림)
GraalVM native설정 필요부분 지원어려움어려움
표를 한 줄로 요약하면 — JNI 는 “빠르지만 내가 다 짠다”, FFM 은 “거의 같은 속도인데 자바 안에서 끝난다”입니다. JNA · JNR 은 속도를 팔아 편의를 사는 쪽이고, 그 거래가 이득인 자리도 분명히 있습니다.

같은 함수 하나, 네 가지 선언

// native/mylib.cpp — C++ 쪽은 어느 길이든 이 한 겹이 출발점입니다
#include <cstdint>

extern "C" {                       // 이름 맹글링을 끕니다 (🔗 C ABI 탭)
#if defined(_WIN32)
  #define API __declspec(dllexport)
#else
  #define API __attribute__((visibility("default")))
#endif

API int32_t mylib_add(int32_t a, int32_t b) { return a + b; }
}
// ① JNI — 이름 규칙이 정해져 있어 C++ 쪽을 따로 써야 합니다
public class Jni { static native int add(int a, int b); }

// ② FFM — 자바 코드 안에서 시그니처를 서술합니다 (Java 22+)
MethodHandle add = Linker.nativeLinker().downcallHandle(
    SymbolLookup.libraryLookup("mylib", Arena.global()).find("mylib_add").orElseThrow(),
    FunctionDescriptor.of(ValueLayout.JAVA_INT, ValueLayout.JAVA_INT, ValueLayout.JAVA_INT));

// ③ JNA — 인터페이스 하나면 끝
public interface MyLib extends Library {
    MyLib I = Native.load("mylib", MyLib.class);
    int mylib_add(int a, int b);
}

// ④ JNR-FFI — JNA 와 거의 같은 모양, 더 빠른 구현
public interface MyLibN { int mylib_add(int a, int b); }
MyLibN n = LibraryLoader.create(MyLibN.class).load("mylib");
눈에 띄는 차이 — ②③④ 는 자바 파일만 고치면 되지만, ① JNI 는 C++ 파일에도 정해진 이름의 함수를 만들어야 합니다. 이 “양쪽을 맞춰야 한다”가 JNI 의 번거로움 전부이자, 반대로 세밀한 제어의 근거이기도 합니다.

한 단계 더 — Java 24 부터 네이티브 호출은 “허가”가 필요합니다 🟡 중급

경고 한 줄의 정체

WARNING: A restricted method in java.lang.System has been called
WARNING: System::loadLibrary has been called by com.example.App
WARNING: Use --enable-native-access=ALL-UNNAMED to avoid a warning
WARNING: Restricted methods will be blocked in a future release
# 해결 — 모듈이 없으면 ALL-UNNAMED, 모듈이 있으면 모듈 이름
java --enable-native-access=ALL-UNNAMED -jar app.jar
java --enable-native-access=com.example.native -m com.example/...

# jar 매니페스트에 적어 두면 실행할 때 안 붙여도 됩니다
#   Enable-Native-Access: ALL-UNNAMED
“나중 릴리스에서는 막힌다”가 핵심 — JNI 든 FFM 이든 JVM 밖으로 나가는 호출은 전부 명시적 허가가 필요한 방향으로 가고 있습니다. 지금 경고를 무시하면 다음 LTS 로 올릴 때 앱이 아예 안 뜹니다. 라이브러리를 배포한다면 README 에 이 플래그를 반드시 적어 두세요.

언제 C++ 로 내려갈 가치가 있나

내려가면 손해
  • 호출 한 번에 하는 일이 수 µs 미만 — 경계 비용이 이득을 먹습니다
  • JSON 파싱 · DB 접근 · HTTP — JVM 쪽 라이브러리가 이미 충분히 빠릅니다
  • 단순 산술 루프 — JIT 와 Vector API 로 대개 따라잡힙니다
  • 배포 대상 OS 가 여럿인데 CI 가 하나뿐일 때
내려갈 가치
  • C++ 로만 존재하는 것 — 코덱 · 물리 엔진 · 벤더 SDK · CUDA
  • 한 번 호출에 수 ms 이상 일하는 무거운 커널
  • 수백 MB 버퍼를 복사 없이 훑어야 할 때(v04)
  • GC 정지를 못 견디는 실시간 오디오 · 신호 처리
판단의 한 줄 기준 — “경계를 한 번 넘는 데 드는 비용(20ns~1µs)보다 그 안에서 하는 일이 훨씬 크냐”입니다. 큰 일을 한 번 시키면 이득이고, 작은 일을 백만 번 시키면 손해입니다. 그래서 뒤에 나오는 모든 설계가 “호출 횟수를 줄이고 한 번에 크게”로 수렴합니다.
정리 — 새로 시작하는 JVM 프로젝트라면 Panama FFM, 안드로이드거나 이미 JNI 로 굴러가는 스택이라면 JNI가 정답입니다. 어느 쪽이든 C++ 쪽에는 extern "C" 한 겹이 필요하고, Java 24+ 에서는 --enable-native-access 가 필요합니다.
JVM 02

JNI 첫 모듈 — 30분이면 자바가 C++ 를 부릅니다

JNI 는 “복잡하다”는 평판과 달리 첫 호출까지는 네 단계뿐입니다. native 메서드를 선언하고, javac -h 로 헤더를 뽑고, 그 시그니처대로 C++ 를 구현하고, 공유 라이브러리로 만들어 System.loadLibrary 로 얹으면 끝입니다.

JNI 모듈이 만들어지고 연결되기까지 — 다섯 단계 1 자바 — native 메서드 선언 public class ImageOps { static native void resize(...); } — 본문이 없습니다 2 javac -h include — 헤더 생성 ★ Java_패키지_클래스_메서드 라는 이름 규칙이 여기서 확정됩니다 3 C++ — 그 시그니처 그대로 구현 첫 인자는 항상 JNIEnv*, 둘째는 jclass(static) 또는 jobject(인스턴스) 4 공유 라이브러리로 빌드 libimageops.so · imageops.dll · libimageops.dylib — OS 마다 이름 규칙이 다릅니다 5 System.loadLibrary("imageops") java.library.path 에서 찾아 열고, 심볼 이름으로 native 메서드에 연결합니다 이름이 한 글자라도 어긋나면 UnsatisfiedLinkError — 에러의 90%가 2·4·5단계의 이름 문제입니다
JNI 는 이름 규칙으로 연결되는 시스템입니다 — 그래서 헤더를 손으로 쓰지 않습니다.

① 자바 쪽 — 이게 전부입니다

// src/main/java/com/example/native_/MathOps.java
package com.example.native_;

public final class MathOps {

    static {                                  // 클래스가 처음 쓰일 때 한 번
        System.loadLibrary("mathops");        // libmathops.so / mathops.dll
    }

    // 본문이 없습니다 — 구현은 C++ 쪽에 있습니다
    public static native int add(int a, int b);
    public static native long sumRange(long from, long to);
    public static native String upper(String s);

    private MathOps() {}
}
static { } 블록에 넣는 이유 — 클래스가 처음 로드될 때 딱 한 번 실행되기 때문입니다. 두 번 부르면 UnsatisfiedLinkError: Native Library ... already loaded in another classloader 가 날 수 있고, 늦게 부르면 첫 호출에서 터집니다. native 메서드를 가진 클래스 안의 static 블록이 정석입니다.

② 헤더 생성 — 손으로 쓰지 마세요

# javac 가 컴파일하면서 JNI 헤더까지 뽑아 줍니다 (JDK 8+)
javac -h native/include \
      -d build/classes \
      src/main/java/com/example/native_/MathOps.java

# 생성된 파일: native/include/com_example_native__MathOps.h
#   · 패키지의 . 은 _ 로
#   · 이름 안의 _ 는 _1 로 (native_ → native_1)
#   · 오버로드가 있으면 뒤에 __타입시그니처 가 붙습니다

# 옛날 자료에 나오는 javah 는 JDK 10 에서 삭제됐습니다 — 쓰지 마세요
/* 생성된 헤더 (일부) — 이 시그니처를 그대로 구현합니다 */
JNIEXPORT jint JNICALL
Java_com_example_native_1_MathOps_add
  (JNIEnv *, jclass, jint, jint);

JNIEXPORT jstring JNICALL
Java_com_example_native_1_MathOps_upper
  (JNIEnv *, jclass, jstring);
이름 규칙을 외울 필요는 없습니다 — 대신 헤더를 절대 손으로 고치지 마세요. 자바 쪽 시그니처를 바꾸면 javac -h 를 다시 돌려 헤더를 새로 뽑고, C++ 구현을 거기에 맞춥니다. 이 순서를 지키는 것만으로 링크 에러의 대부분이 사라집니다.

③ C++ 구현 — JNIEnv* 가 모든 것의 손잡이

// native/src/mathops.cpp
#include <jni.h>
#include <string>
#include <algorithm>
#include "com_example_native__MathOps.h"

extern "C" {                        // ★ 필수 — C++ 맹글링을 끕니다

JNIEXPORT jint JNICALL
Java_com_example_native_1_MathOps_add(JNIEnv*, jclass, jint a, jint b) {
    return a + b;                   // jint 는 그냥 int32_t 입니다
}

JNIEXPORT jlong JNICALL
Java_com_example_native_1_MathOps_sumRange(JNIEnv*, jclass, jlong from, jlong to) {
    long long s = 0;
    for (long long i = from; i <= to; ++i) s += i;
    return static_cast<jlong>(s);
}

JNIEXPORT jstring JNICALL
Java_com_example_native_1_MathOps_upper(JNIEnv* env, jclass, jstring in) {
    const char* c = env->GetStringUTFChars(in, nullptr);   // ① 빌리고
    if (!c) return nullptr;                                // OOM 이면 예외가 이미 걸려 있음
    std::string s(c);
    env->ReleaseStringUTFChars(in, c);                     // ② 반드시 돌려주고
    std::transform(s.begin(), s.end(), s.begin(), ::toupper);
    return env->NewStringUTF(s.c_str());                   // ③ 새 자바 문자열로
}

}   // extern "C"
세 가지만 기억하면 됩니다 — ① 파일 전체를 extern "C" 로 감싼다, ② 첫 인자 JNIEnv* env 로 모든 JNI 기능을 부른다, ③ 빌린 것은 반드시 돌려준다(Get… 에는 짝이 되는 Release… 가 있습니다). env->X() 는 C++ 문법이고, C 로 쓸 때는 (*env)->X(env, …) 입니다.

④ 빌드 — 세 OS 의 한 줄씩

# 리눅스 — -fPIC 와 -shared 가 핵심, 결과는 lib*.so
g++ -std=c++20 -O2 -fPIC -shared \
    -I"$JAVA_HOME/include" -I"$JAVA_HOME/include/linux" \
    native/src/mathops.cpp -o build/native/libmathops.so

# macOS — 확장자는 .dylib, 헤더 폴더는 darwin
clang++ -std=c++20 -O2 -fPIC -shared \
    -I"$JAVA_HOME/include" -I"$JAVA_HOME/include/darwin" \
    native/src/mathops.cpp -o build/native/libmathops.dylib

# 윈도우 (Developer Command Prompt) — lib 접두사 없이 .dll
cl /std:c++20 /O2 /LD ^
   /I"%JAVA_HOME%\include" /I"%JAVA_HOME%\include\win32" ^
   native\src\mathops.cpp /Fe:build\native\mathops.dll

# 실행 — 라이브러리가 있는 폴더를 알려 줍니다
java -cp build/classes -Djava.library.path=build/native \
     --enable-native-access=ALL-UNNAMED com.example.Main
OS파일 이름추가 include
리눅스libmathops.soinclude/linux
macOSlibmathops.dylibinclude/darwin
윈도우mathops.dllinclude/win32
loadLibrary("mathops") 하나가 세 이름을 다 덮습니다 — JVM 이 OS 규칙대로 lib 접두사와 확장자를 붙여 줍니다. 그래서 자바 코드에는 확장자를 쓰지 않습니다. 절대 경로로 직접 지정할 때만 System.load("/abs/path/libmathops.so") 를 씁니다.

실무에선 이렇게 — CMake 가 JNI 를 직접 찾아 줍니다 🟡 중급

CMakeLists.txt — 20줄이면 세 OS 공통

cmake_minimum_required(VERSION 3.24)
project(mathops LANGUAGES CXX)

set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_POSITION_INDEPENDENT_CODE ON)   # -fPIC 을 자동으로

# ★ JAVA_HOME 을 보고 헤더 폴더 두 개를 찾아 줍니다
find_package(JNI REQUIRED)

add_library(mathops SHARED src/mathops.cpp)

# CMake 3.24+ 는 임포트 타깃을 줍니다 — 이게 가장 깔끔합니다
target_link_libraries(mathops PRIVATE JNI::JNI)

# (구버전 호환) target_include_directories(mathops PRIVATE ${JNI_INCLUDE_DIRS})

# 접두사·확장자를 OS 기본값 그대로 두면 loadLibrary 가 알아서 찾습니다
set_target_properties(mathops PROPERTIES
    OUTPUT_NAME "mathops"
    CXX_VISIBILITY_PRESET hidden      # JNIEXPORT 붙은 것만 노출
    VISIBILITY_INLINES_HIDDEN ON)
cmake -B build -S native -DCMAKE_BUILD_TYPE=Release
cmake --build build -j
# build/libmathops.so (리눅스) · build/Release/mathops.dll (윈도우)

첫 실행에서 만나는 에러 네 가지

메시지진짜 원인
no mathops in java.library.path파일을 못 찾음 — 경로 · 파일명 확인
UnsatisfiedLinkError: MathOps.add(II)I파일은 열렸는데 심볼 이름이 다름
Can't load IA 32-bit .dll on AMD 64-bit비트수 불일치 — JVM 과 컴파일러
libstdc++.so.6: version not found배포 서버의 런타임이 더 낡음
# 심볼이 진짜 들어 있는지 눈으로 확인 — 이 한 줄이 시간을 아낍니다
nm -D --defined-only build/libmathops.so | grep Java_
#   00000000000011a9 T Java_com_example_native_1_MathOps_add   ★ 있어야 정상

# 윈도우
dumpbin /exports build\Release\mathops.dll | findstr Java_

# 어떤 파일을 실제로 열었는지
java -verbose:jni -cp ... com.example.Main 2>&1 | head
가장 흔한 실수 하나 — extern "C" 를 빠뜨리면 심볼이 _Z27Java_com_example… 처럼 맹글링된 이름으로 나갑니다. 파일은 잘 열리는데 메서드만 못 찾는 상태가 되죠. nm -D | grep Java_ 결과가 비어 있거나 _Z 로 시작하면 이 문제입니다.
정리 — native 선언 → javac -h → C++ 구현 → 공유 라이브러리 → loadLibrary, 이 다섯 단계가 JNI 의 전부입니다. 헤더는 항상 생성하고, 빌드는 find_package(JNI) 로 맡기고, 막히면 nm -D | grep Java_ 로 심볼부터 확인합니다.
JVM 03

타입 매핑과 문자열 — Modified UTF-8 이라는 함정

숫자는 그냥 통과합니다. 문제는 문자열입니다. JNI 가 주는 “UTF-8” 은 진짜 UTF-8 이 아닙니다. 이 사실을 모르면 한글은 잘 되는데 이모지에서만 깨지는 버그를 며칠 동안 쫓게 됩니다.

jstring 을 C++ 에서 읽는 세 가지 길 — 어느 것이 진짜 UTF-8 인가 jstring 자바 힙의 UTF-16 문자열 GC 가 옮길 수 있습니다 GetStringUTFChars — Modified UTF-8 널 문자는 0xC0 0x80, 이모지는 서로게이트 쌍 6바이트 — 표준 UTF-8 이 아닙니다 GetStringChars — UTF-16 원본 그대로 가장 정확하지만 UTF-16 → UTF-8 변환을 내가 씁니다 (또는 ICU · std 변환) 자바에서 getBytes(UTF_8) 로 byte[] 을 넘기기 ★ 진짜 UTF-8 · C++ 은 바이트만 받으면 됩니다 — 실무에서 가장 안전한 길 언제 무엇을 쓰나 ASCII · 한글만 다룬다 → 첫째로 충분합니다 (짧고 빠릅니다) 이모지 · 사용자 입력 · 파일 경로가 섞인다 → 셋째. 이미 C++ 가 UTF-16 을 쓴다면 둘째
“UTF-8 이라 쓰여 있으니 UTF-8 이겠지”가 JNI 문자열 버그의 출발점입니다.

기본 타입 매핑 — 외울 것은 두 줄뿐

자바JNI 타입C++ 실체시그니처
booleanjbooleanuint8_t (0/1)Z
bytejbyteint8_tB
charjcharuint16_t ★C
shortjshortint16_tS
intjintint32_tI
longjlongint64_tJ ★
float / doublejfloat/jdoublefloat/doubleF/D
void—voidV
Stringjstring불투명 포인터Ljava/lang/String;
int[]jintArray불투명 포인터[I
함정 두 개 — ① jchar 는 C 의 char 가 아니라 UTF-16 코드 단위(2바이트)입니다. char* 로 캐스팅하면 글자가 반토막 납니다. ② 시그니처에서 long 은 J 입니다(L 은 객체용). (J)V 를 (L)V 로 쓰면 NoSuchMethodError 가 납니다.

시그니처 읽는 법 — 표 하나면 끝

형식:  (인자들)반환값        — 인자 사이에 구분자가 없습니다

int add(int a, int b)                    →  (II)I
void run()                               →  ()V
long sum(long from, long to)             →  (JJ)J
String upper(String s)                   →  (Ljava/lang/String;)Ljava/lang/String;
void onData(byte[] buf, int len)         →  ([BI)V
boolean check(int[] a, String tag)       →  ([ILjava/lang/String;)Z
double[] transform(double[] in)          →  ([D)[D

읽는 규칙
  · 객체는  L패키지/클래스;   ← 점이 아니라 슬래시, 끝에 세미콜론 ★
  · 배열은  앞에 [           ← [[I 는 int[][]
  · 기본형은 한 글자          ← Z B C S I J F D V

# 확신이 없으면 물어보면 됩니다 — 이게 가장 확실합니다
javap -s -p build/classes/com/example/native_/MathOps.class
javap -s 를 습관으로 — 시그니처를 손으로 짐작해서 쓰는 순간 GetMethodID 가 조용히 nullptr 을 돌려주고 다음 호출에서 JVM 이 통째로 죽습니다. 클래스 파일에게 직접 물어보면 1초에 끝납니다.

문자열을 제대로 다루기 — 빌리면 반드시 돌려줍니다 🟡 중급

RAII 로 감싸면 실수가 사라집니다

// jni_util.hpp — 프로젝트마다 하나씩 두게 되는 도우미
#include <jni.h>
#include <string>
#include <string_view>

class JStringUtf {                 // Modified UTF-8 을 잠깐 빌리는 래퍼
public:
    JStringUtf(JNIEnv* env, jstring s) : env_(env), s_(s) {
        if (s_) c_ = env_->GetStringUTFChars(s_, nullptr);
    }
    ~JStringUtf() {                             // ★ 어떤 경로로 나가도 반환됩니다
        if (c_) env_->ReleaseStringUTFChars(s_, c_);
    }
    JStringUtf(const JStringUtf&) = delete;     // 두 번 Release 를 원천 차단
    JStringUtf& operator=(const JStringUtf&) = delete;

    bool ok() const { return c_ != nullptr; }
    std::string_view view() const { return c_ ? std::string_view(c_) : std::string_view{}; }
    std::string str() const { return c_ ? std::string(c_) : std::string{}; }

private:
    JNIEnv* env_;
    jstring s_;
    const char* c_ = nullptr;
};

// 쓰는 쪽 — 예외를 던지든 일찍 return 하든 새지 않습니다
JNIEXPORT jint JNICALL
Java_com_example_native_1_MathOps_countChars(JNIEnv* env, jclass, jstring in) {
    JStringUtf s(env, in);
    if (!s.ok()) return -1;              // OOM — 예외가 이미 걸려 있습니다
    return static_cast<jint>(s.view().size());
}
왜 RAII 인가 — JNI 함수는 중간에 예외 상태를 만들 수 있고, 그때 return 으로 빠져나가면서 Release 를 빼먹기 딱 좋습니다. 소멸자에 넣는 순간 “잊어버릴 수가 없는” 코드가 됩니다. jbyteArray · jintArray 도 같은 패턴으로 하나씩 만들어 두세요(v04).

진짜 UTF-8 이 필요할 때 ★

// ① 가장 간단하고 가장 안전한 길 — 자바가 변환해서 byte[] 로 넘깁니다
public final class Text {
    static { System.loadLibrary("textops"); }

    private static native int nProcess(byte[] utf8, int len);

    public static int process(String s) {
        byte[] b = s.getBytes(java.nio.charset.StandardCharsets.UTF_8); // 진짜 UTF-8
        return nProcess(b, b.length);
    }
}
// C++ 쪽 — 문자열이 아니라 그냥 바이트를 받습니다
JNIEXPORT jint JNICALL
Java_com_example_Text_nProcess(JNIEnv* env, jclass, jbyteArray arr, jint len) {
    // 짧으면 스택 버퍼로 복사받는 GetByteArrayRegion 이 가장 안전합니다
    std::string s(static_cast<size_t>(len), '\0');
    env->GetByteArrayRegion(arr, 0, len,
                            reinterpret_cast<jbyte*>(s.data()));
    // s 는 이제 표준 UTF-8 입니다 — 그대로 fopen · sqlite · curl 에 넘겨도 안전
    return static_cast<jint>(s.size());
}

// ② C++ 가 이미 UTF-16 을 쓴다면 (윈도우 API · ICU) 원본 그대로가 낫습니다
const jchar* u = env->GetStringChars(in, nullptr);
jsize n = env->GetStringLength(in);                 // UTF-16 코드 단위 개수
std::u16string s16(reinterpret_cast<const char16_t*>(u), n);
env->ReleaseStringChars(in, u);
Modified UTF-8 이 구체적으로 무엇을 깨뜨리나 — ① '\0' 이 0xC0 0x80 두 바이트로 나와 길이가 어긋나고, ② U+10000 이상(이모지 · 일부 한자) 이 표준 4바이트가 아니라 서로게이트 쌍 6바이트로 나옵니다. 이 바이트열을 curl · sqlite3 · std::filesystem 에 그대로 넘기면 “?”나 깨진 글자가 됩니다.

참조의 세 종류 — 지역 · 전역 · 약한 전역 🔴 실무

지역 참조는 “이 호출 동안만” 유효합니다

// ✗ 절대 하면 안 되는 것 — 지역 참조를 전역에 저장
static jclass g_cls;                      // 위험!
JNIEXPORT void JNICALL Java_..._init(JNIEnv* env, jclass) {
    g_cls = env->FindClass("com/example/Result");   // 지역 참조입니다
}   // ← 함수가 끝나는 순간 무효. 다음에 쓰면 JVM 이 죽습니다

// ✓ 전역 참조로 승격해서 보관합니다
static jclass g_resultCls = nullptr;
JNIEXPORT void JNICALL Java_..._init(JNIEnv* env, jclass) {
    jclass local = env->FindClass("com/example/Result");
    if (!local) return;                            // 예외가 걸려 있음
    g_resultCls = static_cast<jclass>(env->NewGlobalRef(local));
    env->DeleteLocalRef(local);                    // 지역 쪽은 정리
}
// 라이브러리가 내려갈 때 (JNI_OnUnload) 반드시 해제
//   env->DeleteGlobalRef(g_resultCls);

// ✓ 루프 안에서는 지역 참조를 직접 지웁니다 — 안 그러면 표가 넘칩니다
for (jsize i = 0; i < n; ++i) {
    jobject e = env->GetObjectArrayElement(arr, i);
    use(env, e);
    env->DeleteLocalRef(e);          // ★ 이 한 줄이 없으면 수만 번째에서 터집니다
}

// ✓ 개수가 많고 예측이 어려우면 프레임으로 통째 정리
if (env->PushLocalFrame(64) == 0) {
    /* ... 여기서 만든 지역 참조 전부 ... */
    env->PopLocalFrame(nullptr);     // 한 번에 해제
}

세 종류를 한 표로

수명GC 를 막나해제
지역(local)네이티브 함수 반환까지막음자동 · DeleteLocalRef
전역(global)내가 지울 때까지막음DeleteGlobalRef 필수
약한 전역객체가 살아 있는 동안안 막음DeleteWeakGlobalRef
// 약한 전역 — 캐시처럼 "있으면 쓰고 없으면 말고"
static jweak g_listener = nullptr;

void notify(JNIEnv* env) {
    jobject l = env->NewLocalRef(g_listener);   // 살아 있으면 강한 지역 참조
    if (env->IsSameObject(l, nullptr)) return;  // ★ 이미 수거됨 — 그냥 넘어갑니다
    env->CallVoidMethod(l, g_onEvent);
    env->DeleteLocalRef(l);
}
메모리 누수의 두 얼굴 — 전역 참조를 안 지우면 그 자바 객체가 영원히 GC 되지 않습니다(힙 누수). 지역 참조를 루프에서 안 지우면 참조 표가 터집니다 (local reference table overflow (max=512)). 전자는 서서히 죽고 후자는 갑자기 죽습니다 — 둘 다 -Xcheck:jni 가 잡아 줍니다(v06).
정리 — 숫자는 그대로 통과하지만 jchar 는 2바이트이고 시그니처의 long 은 J 입니다. 문자열은 이모지가 섞일 수 있으면 getBytes(UTF_8) 로 byte[] 을 넘기는 길이 가장 안전하고, Get… 은 항상 Release… 와 짝이며 RAII 로 묶으면 실수가 사라집니다.
JVM 04

배열과 제로카피 — 복사를 없애야 이득이 남습니다

8MB 이미지를 C++ 에 넘겨 0.4ms 만에 처리했는데 전체는 3ms 가 걸렸다면, 2.6ms 는 복사입니다. JNI 에서 진짜 성능은 알고리즘이 아니라 경계에서 몇 번 복사되느냐에서 갈립니다.

같은 8MB 를 넘기는 두 가지 방법 — 복사가 어디서 몇 번 일어나나 ① byte[] — 경계에서 복사됩니다 ② DirectByteBuffer — 주소만 건넵니다 자바 힙의 byte[8MB] GC 가 관리 · 언제든 옮길 수 있습니다 GetByteArrayElements 대개 8MB 통째 복사 (JVM 구현에 달림) C++ 는 복사본을 읽고 씁니다 Release(mode 0) 때 결과가 또 한 번 복사 ByteBuffer.allocateDirect(8MB) JVM 힙 밖 · 주소가 고정됩니다 GetDirectBufferAddress 주소 하나를 돌려줍니다 — 복사 0 C++ 와 자바가 같은 메모리를 봅니다 쓴 결과를 자바가 그 자리에서 읽습니다 8MB 이미지 왕복 실측 — 막대 길이가 곧 걸린 시간 byte[] 3.1ms DirectByteBuffer 0.9ms — 3.4배 0 1 2 3 왕복(ms) 사라진 2.2ms 는 전부 복사입니다. 버퍼가 클수록 격차가 벌어집니다 (측정 환경마다 다릅니다)
경계를 넘는 데이터가 크면 DirectByteBuffer 가 사실상 유일한 정답입니다.

배열을 받는 세 가지 방법

// ① Get…ArrayElements — 가장 흔하고, 대개 복사가 일어납니다
jbyte* p = env->GetByteArrayElements(arr, nullptr /*isCopy*/);
// ... 사용 ...
env->ReleaseByteArrayElements(arr, p, 0);
//   mode 0          : 자바로 되돌려 쓰고 버퍼 해제  (읽고 쓰기)
//   JNI_COMMIT      : 되돌려 쓰되 버퍼는 유지        (중간 반영)
//   JNI_ABORT       : 되돌려 쓰지 않고 버림 ★        (읽기 전용일 때 — 복사 절반 절약)

// ② Get…ArrayRegion — 내 버퍼로 필요한 만큼만 복사받습니다
std::vector<jbyte> buf(len);
env->GetByteArrayRegion(arr, off, len, buf.data());
// ... 계산 ...
env->SetByteArrayRegion(out, 0, len, buf.data());   // 결과만 돌려주기
//   → 크기가 작거나(수 KB) 일부만 필요할 때 가장 안전하고 예측 가능합니다

// ③ GetPrimitiveArrayCritical — 복사를 “안 할 수도” 있습니다
void* raw = env->GetPrimitiveArrayCritical(arr, nullptr);
if (raw) {
    auto* d = static_cast<uint8_t*>(raw);
    for (jsize i = 0; i < n; ++i) d[i] = lut[d[i]];   // 아주 짧게!
    env->ReleasePrimitiveArrayCritical(arr, raw, 0);
}
Critical 구간의 규칙 — 어기면 교착이나 크래시입니다 — 그 사이에는 다른 JNI 함수를 부르지 말고, 블로킹하지 말고(락 · I/O · sleep), 자바 코드를 부르지 마세요. 구현에 따라 GC 가 멈춰 있을 수 있어서, 길게 잡고 있으면 애플리케이션 전체가 정지합니다. “수십 µs 안에 끝나는 순수 계산”에만 씁니다.

언제 무엇을 쓰나 ★

상황선택이유
수 KB 이하Region예측 가능 · 안전
읽기만 하는 큰 배열Elements + ABORT되쓰기 복사 절약
아주 짧은 인플레이스Critical복사 0 가능성
MB 급 · 반복 호출DirectBuffer항상 복사 0
C++ 가 메모리 소유NewDirectByteBuffer자바가 들여다봄
실측 감각 (8MB · 같은 머신 · 참고용)
  Region 복사만          약 1.0 ms
  Elements(복사) 왕복    약 2.2 ms
  Critical(복사 없음)    약 0.05 ms   ← 구현이 복사를 안 했을 때
  DirectBuffer           약 0.00 ms   ← 주소 하나만 전달

  ※ Critical 이 복사를 하느냐 마느냐는 JVM · GC 종류에 달렸습니다.
    "안 할 수도 있다"에 성능을 걸지 마세요 — 큰 데이터는 DirectBuffer 로.
한 줄 결론 — 작으면 Region, 크면 DirectBuffer. Critical 은 “이미 DirectBuffer 로 못 바꾸는 API 를 상대할 때”의 최후 수단입니다.

DirectByteBuffer — JVM 힙 밖의 공유 메모리 🔴 실무

자바가 잡고 C++ 가 쓰는 길

import java.nio.ByteBuffer;
import java.nio.ByteOrder;

public final class Image {
    static { System.loadLibrary("imageops"); }

    // ByteBuffer 는 반드시 direct 여야 합니다 — heap buffer 면 주소가 없습니다
    private static native void nGrayscale(ByteBuffer src, ByteBuffer dst, int w, int h);

    public static ByteBuffer grayscale(ByteBuffer rgba, int w, int h) {
        if (!rgba.isDirect()) throw new IllegalArgumentException("direct buffer 여야 합니다");
        ByteBuffer out = ByteBuffer.allocateDirect(w * h)
                                   .order(ByteOrder.nativeOrder());   // ★ 엔디안 맞추기
        nGrayscale(rgba, out, w, h);
        return out;
    }
}
JNIEXPORT void JNICALL
Java_com_example_Image_nGrayscale(JNIEnv* env, jclass,
                                  jobject src, jobject dst, jint w, jint h) {
    auto* s = static_cast<const uint8_t*>(env->GetDirectBufferAddress(src));
    auto* d = static_cast<uint8_t*>(env->GetDirectBufferAddress(dst));
    if (!s || !d) {                          // direct 가 아니면 nullptr 입니다
        env->ThrowNew(env->FindClass("java/lang/IllegalArgumentException"),
                      "direct ByteBuffer required");
        return;
    }
    // 용량 검사는 반드시 — 여기서 안 하면 힙을 넘어 씁니다
    if (env->GetDirectBufferCapacity(src) < (jlong)w * h * 4) { /* throw */ return; }

    const size_t n = (size_t)w * h;
    for (size_t i = 0; i < n; ++i) {         // 복사 없이 그 자리에서
        d[i] = (uint8_t)((s[i*4] * 77 + s[i*4+1] * 150 + s[i*4+2] * 29) >> 8);
    }
}
반드시 용량을 검사하세요 — GetDirectBufferAddress 는 생짜 포인터입니다. 자바 쪽에서 작은 버퍼를 넘겨도 C++ 는 모릅니다. 경계 검사 없는 JNI 함수는 그대로 원격 메모리 손상 취약점이 됩니다. GetDirectBufferCapacity 로 매번 확인하는 것이 규칙입니다.

C++ 가 잡고 자바가 들여다보는 길

// C++ 가 소유한 메모리를 자바에게 "창"으로 보여 줍니다
static uint8_t* g_frame = nullptr;         // 예: 카메라 · 디코더가 채우는 버퍼
static size_t   g_size  = 0;

JNIEXPORT jobject JNICALL
Java_com_example_Decoder_nFrameBuffer(JNIEnv* env, jclass) {
    if (!g_frame) return nullptr;
    // 소유권은 넘어가지 않습니다 — 자바는 읽기만 하는 창을 얻습니다
    return env->NewDirectByteBuffer(g_frame, (jlong)g_size);
}
ByteBuffer frame = Decoder.frameBuffer();      // 복사 0
frame.order(ByteOrder.nativeOrder());
int first = frame.getInt(0);                   // 네이티브 메모리를 직접 읽습니다

// ★ 수명 규칙을 문서에 반드시 적어 두세요
//   "이 버퍼는 다음 decode() 호출 전까지만 유효합니다"
//   그 뒤에 읽으면 해제된 메모리를 읽는 것 — JVM 이 조용히 이상해집니다
운영에서 반드시 챙길 것

① 다이렉트 메모리는 -Xmx 에 안 들어갑니다
   java -XX:MaxDirectMemorySize=2g ...
   초과하면 OutOfMemoryError: Direct buffer memory

② 해제 시점이 GC 에 달려 있습니다 (Cleaner 가 처리)
   → 큰 버퍼는 풀(pool)로 재사용하는 것이 정석입니다
   → 정 급하면 java.lang.ref.Cleaner 로 명시적 해제 훅을 겁니다

③ 모니터링
   jcmd <pid> VM.native_memory summary     (-XX:NativeMemoryTracking=summary)
   BufferPoolMXBean 의 "direct" 항목을 지표로 뽑아 두면 좋습니다
실무 패턴 — 요청마다 allocateDirect 하지 말고 고정 개수의 버퍼를 미리 잡아 큐로 돌려 쓰기가 정답입니다. 다이렉트 버퍼 할당 자체가 수십 µs 로 비싸고, 해제가 GC 에 묶여 있어서 순간적으로 수 GB 가 쌓이는 사고가 흔합니다.
정리 — 경계를 넘는 데이터가 수 KB 이하면 …Region, MB 급이면 DirectByteBuffer 입니다. Critical 은 짧은 계산 전용이고, 다이렉트 버퍼는 용량 검사 · 엔디안 · 풀 재사용 세 가지만 지키면 복사 0으로 자바와 C++ 가 같은 메모리를 나눠 씁니다.
JVM 05

객체 · 콜백 · 예외 — C++ 가 자바를 부를 때

여기까지는 자바가 C++ 를 불렀습니다. 이제 반대 방향입니다 — 진행률 보고 · 이벤트 통지 · 정렬 비교 함수처럼 C++ 가 자바를 되부르는 순간 클래스 캐싱 · 예외 확인 · 스레드 붙이기라는 세 가지 규칙이 한꺼번에 필요해집니다.

콜백 한 번의 왕복 — 다섯 걸음 자바 (JVM 스레드) C++ (네이티브 코드) ① 자바가 native 메서드 호출 — listener 객체를 인자로 ② 계산 도중 CallVoidMethod(listener, onProgress, 30) ③ 자바 onProgress(30) 이 실행되고 즉시 반환 ④ ExceptionCheck() — 콜백이 던졌으면 즉시 중단해야 합니다 ⑤ 계산 결과를 반환 — 여기서 지역 참조가 전부 정리됩니다 캐시하지 않으면 매 콜백마다 FindClass · GetMethodID 가 다시 돕니다 이 두 함수가 콜백 비용의 대부분입니다 — JNI_OnLoad 에서 한 번 찾아 전역 참조로 보관하세요
콜백은 클래스 캐싱 · 예외 확인 두 가지만 지키면 어렵지 않습니다.

JNI_OnLoad — 캐시는 여기서 한 번만

#include <jni.h>

static JavaVM*   g_vm       = nullptr;   // 스레드 붙일 때 필요합니다
static jclass    g_listener = nullptr;   // 전역 참조
static jmethodID g_onProgress = nullptr; // ★ methodID 는 참조가 아니라 그냥 값
static jmethodID g_onDone     = nullptr;

// 라이브러리가 로드될 때 JVM 이 자동으로 불러 줍니다
JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM* vm, void*) {
    g_vm = vm;
    JNIEnv* env = nullptr;
    if (vm->GetEnv(reinterpret_cast<void**>(&env), JNI_VERSION_1_8) != JNI_OK)
        return JNI_ERR;

    // ★ 여기서 찾아야 하는 이유 — 이 순간에는 "정상적인 클래스로더"가 붙어 있습니다
    jclass local = env->FindClass("com/example/ProgressListener");
    if (!local) return JNI_ERR;
    g_listener = static_cast<jclass>(env->NewGlobalRef(local));
    env->DeleteLocalRef(local);

    g_onProgress = env->GetMethodID(g_listener, "onProgress", "(I)V");
    g_onDone     = env->GetMethodID(g_listener, "onDone",     "(Ljava/lang/String;)V");
    if (!g_onProgress || !g_onDone) return JNI_ERR;

    return JNI_VERSION_1_8;      // ★ 반드시 버전을 돌려줘야 로드가 성공합니다
}

JNIEXPORT void JNICALL JNI_OnUnload(JavaVM* vm, void*) {
    JNIEnv* env = nullptr;
    if (vm->GetEnv(reinterpret_cast<void**>(&env), JNI_VERSION_1_8) == JNI_OK)
        if (g_listener) env->DeleteGlobalRef(g_listener);
}
왜 하필 JNI_OnLoad 인가 — FindClass 는 현재 스레드의 클래스로더를 기준으로 클래스를 찾습니다. C++ 가 직접 만든 스레드에는 애플리케이션 클래스로더가 없어서 ClassNotFoundException 이 납니다 — 자바에서는 멀쩡히 보이는 클래스인데도요. JNI_OnLoad 는 loadLibrary 를 부른 스레드에서 실행되므로 안전합니다.

C++ 에서 자바 메서드 부르기

JNIEXPORT jint JNICALL
Java_com_example_Job_nRun(JNIEnv* env, jclass, jobject listener, jint total) {
    for (jint i = 0; i < total; ++i) {
        heavy_step(i);

        if (i % 1000 == 0) {                       // ★ 매번 부르지 않습니다
            env->CallVoidMethod(listener, g_onProgress, (jint)(i * 100 / total));

            // ★★ 콜백 직후에는 반드시 예외 확인 — 자바 쪽이 던졌을 수 있습니다
            if (env->ExceptionCheck()) return -1;   // 예외를 그대로 자바로 전파
        }
    }

    jstring msg = env->NewStringUTF("done");
    env->CallVoidMethod(listener, g_onDone, msg);
    env->DeleteLocalRef(msg);                       // 루프 밖이라도 습관적으로
    if (env->ExceptionCheck()) return -1;
    return 0;
}

// 반환 타입에 따라 함수 이름이 다릅니다
//   CallVoidMethod / CallIntMethod / CallLongMethod / CallObjectMethod ...
//   정적 메서드는 CallStatic...Method(클래스, mid, ...)
//   상위 클래스 구현을 강제로 부르려면 CallNonvirtual...Method
콜백 빈도가 곧 성능입니다 — 진행률을 루프마다 보고하면 경계를 100만 번 넘습니다. 1000번에 한 번 또는 50ms 에 한 번으로 묶으면 사용자 경험은 그대로인데 오버헤드는 1000분의 1이 됩니다. “콜백은 비싸다”가 아니라 “콜백 횟수가 비싸다”입니다.

핸들 패턴 — C++ 객체를 자바 객체에 매달기 🟡 중급

long 필드 하나에 포인터를 담습니다

public final class Encoder implements AutoCloseable {
    static { System.loadLibrary("codec"); }

    private long handle;                   // ★ C++ 포인터가 여기에 들어갑니다

    public Encoder(int w, int h) { handle = nCreate(w, h); }

    public int encode(java.nio.ByteBuffer frame) {
        if (handle == 0) throw new IllegalStateException("closed");
        return nEncode(handle, frame);
    }

    @Override public void close() {        // ★ try-with-resources 로 반드시 해제
        if (handle != 0) { nDestroy(handle); handle = 0; }
    }

    private static native long nCreate(int w, int h);
    private static native int  nEncode(long h, java.nio.ByteBuffer b);
    private static native void nDestroy(long h);
}
struct EncoderImpl { int w, h; /* ... */ };

JNIEXPORT jlong JNICALL
Java_com_example_Encoder_nCreate(JNIEnv*, jclass, jint w, jint h) {
    return reinterpret_cast<jlong>(new EncoderImpl{w, h});
}

JNIEXPORT void JNICALL
Java_com_example_Encoder_nDestroy(JNIEnv*, jclass, jlong h) {
    delete reinterpret_cast<EncoderImpl*>(h);
}

JNIEXPORT jint JNICALL
Java_com_example_Encoder_nEncode(JNIEnv* env, jclass, jlong h, jobject buf) {
    auto* impl = reinterpret_cast<EncoderImpl*>(h);
    if (!impl) return -1;
    auto* p = static_cast<uint8_t*>(env->GetDirectBufferAddress(buf));
    return impl->encode(p);
}
반드시 long 이어야 합니다 — int 에 담으면 64비트 주소가 잘려 32비트 환경에서만 도는 코드가 됩니다. 그리고 finalize() 는 쓰지 마세요(폐기 예정입니다). AutoCloseable + try-with-resources, 보험이 필요하면 java.lang.ref.Cleaner 를 씁니다.

예외 — 경계를 그냥 넘지 못합니다

// ★ C++ 예외가 JNI 경계를 넘으면 정의되지 않은 동작입니다 (대개 즉사)
JNIEXPORT jint JNICALL
Java_com_example_Codec_nDecode(JNIEnv* env, jclass, jbyteArray in) try {
    return do_decode(env, in);                   // 여기서 throw 할 수 있음
}
catch (const std::bad_alloc&) {
    env->ThrowNew(env->FindClass("java/lang/OutOfMemoryError"), "decode");
    return -1;
}
catch (const std::exception& e) {
    env->ThrowNew(env->FindClass("java/lang/RuntimeException"), e.what());
    return -1;
}
catch (...) {
    env->ThrowNew(env->FindClass("java/lang/Error"), "unknown native error");
    return -1;
}

// ★ ThrowNew 는 "예외를 걸어 둘 뿐" 함수를 끝내지 않습니다 — return 을 잊지 마세요

// 반대로, 자바가 던진 예외를 C++ 가 확인·처리하는 방법
if (env->ExceptionCheck()) {
    jthrowable t = env->ExceptionOccurred();
    env->ExceptionDescribe();         // 스택트레이스를 stderr 로 (디버깅용)
    env->ExceptionClear();            // ★ 지워야 다른 JNI 함수를 부를 수 있습니다
    /* 정리 작업 ... */
    env->Throw(t);                    // 필요하면 그대로 다시 던지기
}
예외가 걸린 상태에서 JNI 함수를 부르면 안 됩니다 — 허용되는 것은 ExceptionOccurred · ExceptionDescribe · ExceptionClear · DeleteLocalRef 같은 소수뿐입니다. 그 밖의 함수를 부르면 동작이 정의되어 있지 않고, -Xcheck:jni 를 켜면 그 자리에서 “JNI call made with exception pending” 이라고 알려 줍니다.

C++ 스레드에서 자바 부르기 — attach 와 detach 🔴 실무

JNIEnv* 는 스레드마다 다릅니다

// ★ 절대 금지 — JNIEnv* 를 다른 스레드로 넘기기
//   JNIEnv 는 스레드 지역입니다. JavaVM* 만 스레드 사이에서 공유할 수 있습니다

// RAII 붙이기 도우미 — 이 패턴 하나만 있으면 됩니다
class JniAttach {
public:
    explicit JniAttach(JavaVM* vm) : vm_(vm) {
        jint r = vm_->GetEnv(reinterpret_cast<void**>(&env_), JNI_VERSION_1_8);
        if (r == JNI_EDETACHED) {
            JavaVMAttachArgs a{JNI_VERSION_1_8, const_cast<char*>("native-worker"), nullptr};
            // AsDaemon: 이 스레드가 살아 있어도 JVM 종료를 막지 않습니다 ★
            if (vm_->AttachCurrentThreadAsDaemon(
                    reinterpret_cast<void**>(&env_), &a) == JNI_OK)
                attached_ = true;
        }
    }
    ~JniAttach() { if (attached_) vm_->DetachCurrentThread(); }
    JNIEnv* env() const { return env_; }
private:
    JavaVM* vm_; JNIEnv* env_ = nullptr; bool attached_ = false;
};

// C++ 워커 스레드에서 자바 콜백 부르기
void worker(jobject globalListener) {          // ★ 전역 참조여야 합니다
    JniAttach a(g_vm);
    if (!a.env()) return;
    a.env()->CallVoidMethod(globalListener, g_onProgress, 100);
    if (a.env()->ExceptionCheck()) a.env()->ExceptionClear();
}   // ← 소멸자가 detach

스레드 규칙 다섯 줄

공유해도 되나답
JavaVM*예 — 전 프로세스 하나
JNIEnv*아니오 — 스레드 전용
지역 참조 jobject아니오
전역 참조 jobject예
jmethodID/jfieldID예(클래스가 살아 있는 동안)
자주 나는 사고와 원인

"attempting to use JNIEnv from the wrong thread"
  → JNIEnv* 를 멤버 변수에 저장해 두고 다른 스레드에서 썼습니다

"JNI DetachCurrentThread called on thread not attached"
  → attach 없이 detach. RAII 로 묶으면 생기지 않습니다

프로세스가 종료 시점에 멈춤(hang)
  → AttachCurrentThread 로 붙인 스레드를 detach 안 했습니다.
    JVM 이 그 스레드를 기다립니다 → AsDaemon 을 쓰세요

콜백을 부르는 순간 크래시
  → 리스너를 지역 참조로 저장했습니다. NewGlobalRef 필수

스레드마다 attach/detach 를 반복해서 느림
  → 붙이는 데 수 µs 가 듭니다. 워커 스레드는 시작할 때 한 번 붙이고
    끝날 때 떼는 것이 정석입니다 (스레드 지역 RAII)
가장 안전한 설계 — C++ 가 자바를 되부르지 않게 만드는 것입니다. 결과를 큐에 쌓아 두고 자바 쪽 스레드가 주기적으로 꺼내 가게 하면 attach · 전역 참조 · 예외 전파 문제가 전부 사라집니다. 콜백은 “꼭 필요할 때만” 쓰는 도구로 두세요.
정리 — 콜백의 규칙은 셋입니다. 클래스와 메서드 ID 는 JNI_OnLoad 에서 한 번만 찾고, 콜백 뒤에는 항상 ExceptionCheck 하고, C++ 스레드에서는 attach 를 RAII 로 묶습니다. C++ 객체는 long 핸들 + AutoCloseable 로 자바 객체에 매답니다.
JVM 06

성능과 안정성 — 호출을 줄이고 크래시를 읽습니다

JNI 코드가 느린 이유는 대개 C++ 가 느려서가 아니라 경계를 너무 자주 넘어서입니다. 그리고 JNI 코드가 죽으면 예외가 아니라 JVM 프로세스 전체가 사라집니다 — 그때 남는 hs_err_pid 파일을 읽을 줄 알아야 합니다.

JNI 호출 한 번의 비용 — 무엇이 붙느냐에 따라 열 배가 넘습니다 빈 native 호출 약 8ns jobject 인자 + 필드 약 15ns String 인자 하나 약 60ns GetStringUTFChars 왕복 약 130ns 같은 일을 100만 번 — 호출 설계 하나로 갈립니다 루프에서 100만 번 호출 경계 비용 8ns × 100만 = 8ms 가 순수 낭비 배열 하나로 1번 호출 경계 비용 8ns — 계산 시간만 남습니다 설계 원칙 — 경계를 자주 넘지 말고, 한 번에 크게 넘기세요 (수치는 환경마다 다릅니다)
JNI 최적화의 90%는 “호출 횟수 줄이기” 한 가지입니다.

배치 설계 — 나쁜 API 와 좋은 API

경계를 100만 번 넘는 설계
  • native double sample(int i) 를 루프에서 호출
  • 픽셀 하나마다 native int pixel(x, y)
  • 진행률을 반복마다 콜백
  • 결과를 한 건씩 String 으로 반환
한 번에 크게 넘기는 설계
  • native void sampleAll(ByteBuffer out, int n)
  • 이미지 전체를 DirectBuffer 로 한 번
  • 진행률은 50ms 간격으로만
  • 결과를 구조체 배열(DirectBuffer)로 한 번에
// ✗ 이 API 는 아무리 C++ 를 잘 짜도 느립니다
for (int i = 0; i < 1_000_000; i++) sum += Native.sample(i);

// ✓ 같은 계산, 경계는 한 번
ByteBuffer out = ByteBuffer.allocateDirect(1_000_000 * 8)
                           .order(ByteOrder.nativeOrder());
Native.sampleAll(out, 1_000_000);
DoubleBuffer d = out.asDoubleBuffer();      // 자바 쪽에서 그대로 읽습니다
API 를 설계할 때의 질문 — “이 함수는 한 번 호출에 몇 µs 어치 일을 하는가?” 1µs 미만이면 그 함수는 애초에 경계에 두면 안 되는 함수입니다. 위로 한 겹 올려서 덩어리로 묶은 함수를 대신 내보내세요.

RegisterNatives — 이름 규칙에서 벗어나기

// 함수 이름을 마음대로 짓고, JNI_OnLoad 에서 직접 연결합니다
static jint nAdd(JNIEnv*, jclass, jint a, jint b) { return a + b; }
static void nRun(JNIEnv*, jclass, jobject buf)    { /* ... */ }

static const JNINativeMethod kMethods[] = {
    {"add", "(II)I",                    reinterpret_cast<void*>(nAdd)},
    {"run", "(Ljava/nio/ByteBuffer;)V", reinterpret_cast<void*>(nRun)},
};

JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM* vm, void*) {
    JNIEnv* env;
    if (vm->GetEnv((void**)&env, JNI_VERSION_1_8) != JNI_OK) return JNI_ERR;
    jclass c = env->FindClass("com/example/Fast");
    if (!c) return JNI_ERR;
    if (env->RegisterNatives(c, kMethods,
            sizeof(kMethods)/sizeof(kMethods[0])) != JNI_OK) return JNI_ERR;
    return JNI_VERSION_1_8;
}
이름 규칙 방식RegisterNatives
연결 시점첫 호출 때 탐색로드할 때 한 번
심볼 노출전부 공개해야전부 숨겨도 됨
이름 길이매우 김자유
패키지 변경C++ 도 수정문자열만 수정
안드로이드에서는 거의 표준입니다 — 심볼을 전부 -fvisibility=hidden 으로 숨길 수 있어 .so 크기가 줄고, 동적 심볼 탐색이 없어 첫 호출 지연이 사라집니다. 단점은 시그니처 문자열이 틀려도 컴파일러가 안 잡아 준다는 것 — javap -s 로 복사해 붙이세요.

크래시를 읽는 법 — hs_err_pid 파일 🔴 실무

-Xcheck:jni 를 개발 내내 켜 두세요

# 개발·CI 에서는 항상 켭니다 (운영에서는 끕니다 — 느려집니다)
java -Xcheck:jni -Xlog:jni+resolve=debug \
     --enable-native-access=ALL-UNNAMED -cp ... com.example.Main

# 잡아 주는 것들
#   FATAL ERROR in native method: Bad global or local ref passed to JNI
#   WARNING: JNI local refs: 545, exceeds capacity: 512      ★ 누수 신호
#   FATAL ERROR: JNI call made with exception pending
#   WARNING in native method: JNI call made without checking exceptions
hs_err_pid12345.log 에서 볼 곳은 딱 세 군데

① 무엇이 터졌나
   SIGSEGV (0xb) at pc=0x00007f2a1c0034ab
   #  Problematic frame:
   #  C  [libimageops.so+0x34ab]  grayscale+0x2b    ★ 우리 코드입니다

② 어느 자바 코드에서 내려왔나
   Java frames: (J=compiled, j=interpreted)
   j  com.example.Image.nGrayscale(...)+0
   j  com.example.Image.grayscale(...)+42          ★ 호출 지점

③ 어떤 상태였나
   siginfo: si_addr: 0x0000000000000000            ← 널 포인터
   siginfo: si_addr: 0x00007f2a2c000000            ← 범위를 넘어간 접근

# Problematic frame 이 libjvm.so 이면?
#   → 대개 우리가 JNI 규약을 어겨 JVM 내부 상태를 망가뜨린 것입니다
#     (잘못된 jobject · 예외 미확인 · Critical 구간에서 JNI 호출)

네이티브 버그를 잡는 도구

# ① 주소 소독기 — 경계 넘어 쓰기·use-after-free 를 즉시 잡습니다
g++ -std=c++20 -O1 -g -fsanitize=address -fno-omit-frame-pointer \
    -fPIC -shared src/*.cpp -o libimageops.so

LD_PRELOAD=$(g++ -print-file-name=libasan.so) \
ASAN_OPTIONS=handle_segv=0:detect_leaks=0 \
java -cp ... com.example.Main
#   handle_segv=0 이 중요합니다 — JVM 은 SIGSEGV 를 정상 동작에 씁니다 ★

# ② 코어 덤프로 사후 분석
ulimit -c unlimited
java -XX:+CreateCoredumpOnCrash -cp ... com.example.Main
gdb $(which java) core.12345
  (gdb) bt                    # 네이티브 스택
  (gdb) info registers

# ③ 크래시 파일 위치를 고정해 두기 (컨테이너에서 특히 유용)
java -XX:ErrorFile=/var/log/app/hs_err_%p.log \
     -XX:HeapDumpPath=/var/log/app -cp ... com.example.Main

# ④ 네이티브 메모리 추적
java -XX:NativeMemoryTracking=summary -cp ... com.example.Main
jcmd <pid> VM.native_memory summary
운영에 나가기 전 체크 두 개 — ① -Xcheck:jni 로 경고 0인지, ② ASan 빌드로 전체 테스트를 한 번 돌렸는지. JNI 버그는 “평소엔 잘 되다가 부하가 걸리면 죽는다”는 형태로 나타나서, 운영에서 처음 만나면 재현하는 데만 며칠이 걸립니다.

놓치기 쉬운 성능 요인 넷 🟡 중급

JIT 가 못 넘어가는 벽

① native 메서드는 인라인되지 않습니다
   자바 메서드끼리는 JIT 가 합쳐서 오버헤드를 0 으로 만들지만,
   native 경계에서는 그 최적화가 끊깁니다.
   → 작은 native 함수를 루프에서 부르는 설계가 특히 손해입니다

② 안전 지점(safepoint)과의 관계
   네이티브 코드 실행 중에는 그 스레드가 safepoint 에 들어가지 않습니다.
   → 아주 긴 네이티브 호출은 GC 를 기다리게 만들 수 있습니다
   → 수십 ms 이상 걸리는 작업은 중간에 끊어 돌려주는 설계가 낫습니다

③ 첫 호출은 느립니다
   심볼 탐색 · 클래스 로딩 · 페이지 폴트가 한꺼번에 일어납니다.
   → 기동 직후 워밍업 호출을 한 번 넣으면 첫 요청 지연이 사라집니다
   → JMH 의 @Warmup 이 존재하는 이유이기도 합니다

④ 네이티브 스레드는 JVM 이 모릅니다
   C++ 안에서 만든 스레드는 -XX:ActiveProcessorCount 계산 밖입니다.
   → 컨테이너 CPU 한도를 넘겨 스로틀링이 걸리는 사고가 흔합니다
③번은 실제 장애로 자주 이어집니다 — 배포 직후 첫 요청만 수백 ms 가 걸려 헬스체크가 실패하고 롤링 배포가 멈추는 형태입니다. 기동 시 워밍업 호출 한 줄이면 끝납니다 — 네이티브 라이브러리를 쓰는 서비스의 기본 장비로 두세요.

측정 없이 고치지 않기

# ① 자바와 네이티브를 한 장에 — async-profiler 가 표준입니다
java -agentpath:/opt/async-profiler/lib/libasyncProfiler.so=start,event=cpu,file=cpu.html \
     --enable-native-access=ALL-UNNAMED -cp app.jar com.example.Main
#   자바 프레임과 C++ 프레임이 하나의 플레임 그래프에 같이 나옵니다 ★
#   JFR 이나 jstack 은 네이티브 안쪽을 "한 줄"로만 보여 줍니다

# ② 어느 쪽이 시간을 먹는지 큰 그림부터
perf top -p $(pgrep -f com.example.Main)
#   libjvm.so 가 위 → 자바 문제 · libimageops.so 가 위 → 네이티브 문제

# ③ 경계 비용만 따로 재기 — 빈 native 함수를 하나 두고 JMH 로 (v12)
증상먼저 볼 곳
전체가 느림호출 횟수 — 배치로 묶이나
가끔만 느림GC · 다이렉트 버퍼 할당
첫 요청만 느림워밍업 · 페이지 폴트
스레드 늘려도 안 빨라짐네이티브 안의 전역 락
메모리가 계속 늘어남전역 참조 · 네이티브 할당 누수
“C++ 로 바꿨는데 안 빨라졌다”의 진짜 원인 1위 — 여전히 복사가 남아 있는 것입니다(v04). 2위는 호출이 잘게 쪼개져 있는 것이고, 3위는 애초에 병목이 거기가 아니었던 것입니다. 셋 다 프로파일러 한 번이면 구분됩니다.
정리 — 성능은 호출 횟수가 결정하고, 안정성은 규약 준수가 결정합니다. 덩어리로 넘기는 API를 설계하고, 연결은 RegisterNatives 로 깔끔하게 하고, 개발 내내 -Xcheck:jni 를 켜 두면 JNI 는 생각만큼 위험하지 않습니다.
JVM 07

Panama FFM — C++ 파일을 한 줄도 안 쓰고 부릅니다

JNI 의 번거로움은 전부 “C++ 쪽에도 정해진 이름의 함수를 만들어야 한다”에서 나왔습니다. Java 22 에서 정식이 된 FFM(Foreign Function & Memory) API 는 그 요구를 없앱니다 — 이미 있는 extern "C" 함수를 자바 코드만으로 부릅니다.

FFM 의 구성 요소 — 이 다섯 이름만 알면 됩니다 ① 이름에서 주소로 ② 시그니처 서술 ③ 호출 만들기 SymbolLookup libraryLookup(경로, arena) find("mylib_add") 함수 주소를 MemorySegment 로 FunctionDescriptor of(반환, 인자, 인자…) ValueLayout JAVA_INT · JAVA_DOUBLE · ADDRESS Linker.nativeLinker() 플랫폼 호출 규약을 압니다 downcallHandle() MethodHandle 을 돌려줍니다 int r = (int) mh.invokeExact(3, 4); 이 한 줄이 진짜 C 함수 호출입니다 — 중간에 자동 생성 코드도, .so 도 따로 없습니다 Arena — 네이티브 메모리의 수명을 묶어 관리합니다 ofConfined() 한 스레드 전용 · ofShared() 여러 스레드 · ofAuto() GC 에 맡김 · global() 영구 try-with-resources 로 닫는 순간 그 Arena 가 잡은 메모리가 전부 해제됩니다 — free 를 잊을 수 없습니다
JNI 의 malloc/free 자리를 Arena 가 구조적으로 대신합니다.

C++ 쪽 — 이것뿐입니다

// native/src/mathffm.cpp — JNI 헤더도, 정해진 함수 이름도 없습니다
#include <cstdint>
#include <cstring>
#include <cmath>

extern "C" {
#if defined(_WIN32)
  #define API __declspec(dllexport)
#else
  #define API __attribute__((visibility("default")))
#endif

API int32_t mathffm_add(int32_t a, int32_t b) { return a + b; }

API double mathffm_norm(const double* v, int32_t n) {   // 배열은 포인터 + 길이
    double s = 0;
    for (int32_t i = 0; i < n; ++i) s += v[i] * v[i];
    return std::sqrt(s);
}

API int32_t mathffm_greet(const char* name, char* out, int32_t cap) {
    int n = std::snprintf(out, (size_t)cap, "Hello, %s!", name);
    return n;                                            // 쓴 길이
}
}   // extern "C"
# 빌드 — JNI 헤더가 필요 없으니 -I 옵션도 없습니다
g++ -std=c++20 -O2 -fPIC -shared src/mathffm.cpp -o libmathffm.so
여기가 FFM 의 핵심 장점입니다 — 이 .so 는 자바를 전혀 모르는 평범한 C 라이브러리입니다. 같은 파일을 파이썬 ctypes 도, Node ffi 도, C# P/Invoke 도 씁니다. JNI 라이브러리는 자바 전용이라 그럴 수 없습니다.

자바 쪽 — Java 25 전체 코드

import java.lang.foreign.*;
import java.lang.invoke.MethodHandle;
import java.nio.file.Path;

public final class MathFfm implements AutoCloseable {

    private final Arena arena = Arena.ofShared();     // 라이브러리 수명 = 앱 수명
    private final MethodHandle add, norm, greet;

    public MathFfm(Path lib) {
        Linker linker = Linker.nativeLinker();
        SymbolLookup lookup = SymbolLookup.libraryLookup(lib, arena);

        add = linker.downcallHandle(
            lookup.find("mathffm_add").orElseThrow(),
            FunctionDescriptor.of(ValueLayout.JAVA_INT,      // 반환
                                  ValueLayout.JAVA_INT,      // a
                                  ValueLayout.JAVA_INT));    // b

        norm = linker.downcallHandle(
            lookup.find("mathffm_norm").orElseThrow(),
            FunctionDescriptor.of(ValueLayout.JAVA_DOUBLE,
                                  ValueLayout.ADDRESS,       // const double*
                                  ValueLayout.JAVA_INT));

        greet = linker.downcallHandle(
            lookup.find("mathffm_greet").orElseThrow(),
            FunctionDescriptor.of(ValueLayout.JAVA_INT,
                                  ValueLayout.ADDRESS, ValueLayout.ADDRESS,
                                  ValueLayout.JAVA_INT));
    }

    public int add(int a, int b) {
        try { return (int) add.invokeExact(a, b); }        // ★ invokeExact
        catch (Throwable t) { throw new RuntimeException(t); }
    }

    public double norm(double[] v) {
        try (Arena tmp = Arena.ofConfined()) {              // ★ 호출 동안만 사는 메모리
            MemorySegment seg = tmp.allocateFrom(ValueLayout.JAVA_DOUBLE, v);
            return (double) norm.invokeExact(seg, v.length);
        } catch (Throwable t) { throw new RuntimeException(t); }
    }

    public String greet(String name) {
        try (Arena tmp = Arena.ofConfined()) {
            MemorySegment in  = tmp.allocateFrom(name);     // 자동으로 널 종단 UTF-8
            MemorySegment out = tmp.allocate(256);
            int n = (int) greet.invokeExact(in, out, 256);
            if (n < 0) throw new IllegalStateException("greet failed");
            return out.getString(0);                        // 널 종단 문자열 읽기
        } catch (Throwable t) { throw new RuntimeException(t); }
    }

    @Override public void close() { arena.close(); }        // 라이브러리도 여기서 정리
}
invokeExact 는 타입이 정확히 맞아야 합니다 — (int) mh.invokeExact(3, 4) 에서 캐스트를 빼거나 long 을 넘기면 실행 시점에 WrongMethodTypeException 이 납니다. 느슨하게 가려면 invoke 를 쓸 수 있지만 변환 비용이 붙습니다 — 뜨거운 경로에는 invokeExact + static final MethodHandle 이 정석입니다.

라이브러리를 찾는 세 가지 방법 · 실행 플래그 🟡 중급

SymbolLookup 세 가지

// ① 파일 경로로 직접 열기 — 가장 명시적, 배포 때 경로를 내가 정합니다
SymbolLookup s1 = SymbolLookup.libraryLookup(Path.of("/opt/app/libmathffm.so"), arena);

// ② System.loadLibrary 로 먼저 올리고, 클래스로더가 올린 것에서 찾기
System.loadLibrary("mathffm");                 // java.library.path 규칙을 따릅니다
SymbolLookup s2 = SymbolLookup.loaderLookup();

// ③ C 표준 라이브러리 등 기본 심볼 — malloc · strlen · getpid
SymbolLookup s3 = Linker.nativeLinker().defaultLookup();
MethodHandle strlen = Linker.nativeLinker().downcallHandle(
    s3.find("strlen").orElseThrow(),
    FunctionDescriptor.of(ValueLayout.JAVA_LONG, ValueLayout.ADDRESS));

// 이름은 있으나 없으나 — Optional 이라 실패를 강제로 다루게 됩니다
lookup.find("없는함수").ifPresentOrElse(
    addr -> { /* ... */ },
    ()   -> { throw new IllegalStateException("심볼 없음 — 버전 불일치?"); });
# 실행 — Java 24+ 에서는 허가 플래그가 필요합니다 (없으면 경고)
java --enable-native-access=ALL-UNNAMED -cp app.jar com.example.Main

# 컴파일은 그냥 됩니다 (Java 22 부터 preview 아님)
javac --release 25 -d out src/**/*.java
실무에서 가장 편한 조합 — ②번입니다. System.loadLibrary 가 jar 안에서 꺼낸 .so 를 올리는 NativeLoader 패턴(v10)을 그대로 재사용할 수 있기 때문입니다. FFM 으로 갈아타도 패키징 코드는 하나도 안 바뀝니다.

Arena 네 가지 — 수명을 고르는 일

수명스레드쓰는 곳
ofConfined()close 까지만든 스레드만호출 한 번
ofShared()close 까지전부풀 · 장기 버퍼
ofAuto()GC 가 판단전부수명이 모호할 때
global()영원히전부상수 · 라이브러리
// ★ 닫힌 Arena 의 세그먼트를 만지면 예외가 납니다 — 크래시가 아니라 "예외"입니다
MemorySegment leaked;
try (Arena a = Arena.ofConfined()) {
    leaked = a.allocate(16);
}
leaked.get(ValueLayout.JAVA_INT, 0);
//  → IllegalStateException: Already closed
//  JNI 였다면 여기서 use-after-free 로 JVM 이 통째로 죽었을 자리입니다 ★

// ofConfined 는 다른 스레드에서 만지면 바로 잡아 줍니다
//  → WrongThreadException: Attempted access outside owning thread

// 큰 버퍼는 재사용 — 매번 allocate 하면 그게 병목이 됩니다
private static final Arena POOL = Arena.ofShared();
private static final MemorySegment SCRATCH = POOL.allocate(8 * 1024 * 1024);
이것이 FFM 이 JNI 보다 “안전하다”는 말의 실체입니다 — 틀린 접근이 세그폴트가 아니라 자바 예외가 됩니다. 경계 검사(세그먼트 크기)와 수명 검사(Arena 상태)를 런타임이 대신 해 주기 때문이고, 그 대가로 약간의 검사 비용이 붙습니다.
정리 — FFM 은 SymbolLookup(주소) · FunctionDescriptor(시그니처) · Linker(호출) · Arena(메모리) 네 조각의 조립입니다. C++ 쪽은 extern "C" 함수만 있으면 되고, 잘못된 접근은 크래시가 아니라 예외로 잡히며, 실행할 때 --enable-native-access 만 잊지 않으면 됩니다.
JVM 08

jextract · 구조체 · 콜백 — 그리고 FFM 이 아직 못 하는 것

함수가 다섯 개면 손으로 써도 됩니다. 헤더가 3천 줄이면 이야기가 다릅니다. jextract 는 C 헤더를 읽어 자바 바인딩을 통째로 생성합니다. 여기에 구조체 레이아웃과 upcall 콜백까지 더하면 FFM 으로 못 할 일이 거의 없습니다 — “거의”가 어디까지인지도 정확히 짚습니다.

jextract 흐름 · 구조체 레이아웃 · upcall 콜백 mylib.h 평범한 C 헤더 jextract JDK 와 짝을 맞춰 따로 받습니다 생성된 .java 함수 · 구조체 · 상수 바로 호출 import 후 구조체 레이아웃 — 패딩까지 자바가 그대로 적습니다 int x — 4B int y — 4B 패딩 4B double len — 8B 패딩을 빼먹으면 len 을 4바이트 앞에서 읽습니다 — 값이 조용히 이상해집니다 콜백 — 자바 메서드를 C 함수 포인터로 자바 static 메서드 int cmp(MemorySegment a, b) linker.upcallStub(...) 함수 포인터가 된 MemorySegment C 의 qsort 가 호출 arena 닫히면 무효 jextract 는 헤더를 자바로 옮겨 적어 주는 도구입니다 — C++ 클래스는 여전히 다루지 못합니다
헤더가 크면 손으로 쓰지 않습니다 — 그리고 그 한계도 헤더에서 옵니다.

jextract 설치와 사용

# jextract 는 JDK 에 들어 있지 않습니다 — jdk.java.net/jextract 에서 따로 받습니다
#   ★ JDK 버전과 짝이 맞아야 합니다 (JDK 22 계열 이후 배포본)
export PATH="$HOME/jextract/bin:$PATH"
jextract --version

# 헤더 하나에서 바인딩 생성
jextract \
  --output src/gen \
  --target-package com.example.gen \
  --library mathffm \
  -I native/include \
  native/include/mathffm.h

# 무엇이 생성됐는지 미리 보기 (파일을 만들지 않고 목록만)
jextract --dump-includes includes.txt native/include/mathffm.h

# 헤더가 거대할 때 — 필요한 것만 뽑습니다 (생성 파일 수가 수천 개가 되는 것을 방지)
jextract --output src/gen -t com.example.gen \
  --include-function mathffm_add \
  --include-function mathffm_norm \
  --include-struct  Point \
  native/include/mathffm.h
// 생성된 코드를 쓰는 쪽 — MethodHandle 조립이 전부 사라집니다
import static com.example.gen.mathffm_h.*;      // ★ static import 가 관례
import java.lang.foreign.*;

int r = mathffm_add(3, 4);                      // 그냥 자바 메서드처럼

try (Arena a = Arena.ofConfined()) {
    MemorySegment v = a.allocateFrom(ValueLayout.JAVA_DOUBLE, 3.0, 4.0);
    double n = mathffm_norm(v, 2);              // 5.0
}
생성 코드를 저장소에 넣을까, 빌드마다 만들까 — 커밋해 두는 쪽을 권합니다. jextract 버전이 바뀌면 생성 결과가 달라지고, 개발자마다 jextract 를 깔아야 하는 부담이 생깁니다. 헤더가 바뀔 때만 다시 돌려 diff 를 눈으로 확인하는 흐름이 가장 사고가 적습니다.

구조체 — 레이아웃을 직접 그리기

/* C 쪽 — 흔한 구조체 하나 */
typedef struct {
    int32_t x;
    int32_t y;
    double  len;      /* 8바이트 정렬 → 앞에 패딩 4바이트가 들어갑니다 */
} Point;

API void point_scale(Point* p, double k);
import java.lang.foreign.*;
import java.lang.invoke.VarHandle;

// ★ C 컴파일러가 넣는 패딩을 자바에서도 그대로 적습니다
static final StructLayout POINT = MemoryLayout.structLayout(
    ValueLayout.JAVA_INT.withName("x"),
    ValueLayout.JAVA_INT.withName("y"),
    MemoryLayout.paddingLayout(4),                 // ← 빼먹으면 값이 어긋납니다
    ValueLayout.JAVA_DOUBLE.withName("len")
);   // 전체 크기 16바이트

static final VarHandle X   = POINT.varHandle(MemoryLayout.PathElement.groupElement("x"));
static final VarHandle Y   = POINT.varHandle(MemoryLayout.PathElement.groupElement("y"));
static final VarHandle LEN = POINT.varHandle(MemoryLayout.PathElement.groupElement("len"));

try (Arena a = Arena.ofConfined()) {
    MemorySegment p = a.allocate(POINT);
    X.set(p, 0L, 3);                 // (세그먼트, 오프셋, 값)
    Y.set(p, 0L, 4);
    scale.invokeExact(p, 2.0);       // C 함수에 포인터로 그대로 전달
    double len = (double) LEN.get(p, 0L);

    // 구조체 배열도 자연스럽게 — 복사 0 으로 100만 개를 넘길 수 있습니다
    SequenceLayout ARR = MemoryLayout.sequenceLayout(1_000_000, POINT);
    MemorySegment all = a.allocate(ARR);
    MemorySegment first = all.asSlice(0, POINT.byteSize());
}
패딩을 확인하는 가장 빠른 방법 — C 쪽에서 printf("%zu %zu\n", sizeof(Point), offsetof(Point, len)); 를 찍어 자바의 POINT.byteSize() · POINT.byteOffset(...) 과 맞춰 보는 것입니다. 숫자 두 개만 같으면 레이아웃은 맞습니다. jextract 를 쓰면 이 계산을 대신해 줍니다.

upcall — C 가 자바를 되부르게 🔴 실무

자바 메서드를 함수 포인터로 만들기

import java.lang.foreign.*;
import java.lang.invoke.*;

public final class Sorter {

    // ① C 가 부를 자바 메서드 — 인자는 전부 MemorySegment(포인터)
    static int compare(MemorySegment a, MemorySegment b) {
        int x = a.get(ValueLayout.JAVA_INT, 0);
        int y = b.get(ValueLayout.JAVA_INT, 0);
        return Integer.compare(x, y);
    }

    public static void sort(int[] data) throws Throwable {
        Linker linker = Linker.nativeLinker();

        try (Arena arena = Arena.ofConfined()) {
            // ② 자바 메서드 → MethodHandle
            MethodHandle cmp = MethodHandles.lookup().findStatic(
                Sorter.class, "compare",
                MethodType.methodType(int.class, MemorySegment.class, MemorySegment.class));

            // ③ MethodHandle → C 함수 포인터 (upcall stub)
            FunctionDescriptor cmpDesc = FunctionDescriptor.of(
                ValueLayout.JAVA_INT, ValueLayout.ADDRESS, ValueLayout.ADDRESS);
            MemorySegment cmpPtr = linker.upcallStub(cmp, cmpDesc, arena);
            //   ★ arena 가 닫히면 이 포인터는 무효가 됩니다

            // ④ C 표준 qsort 에 그대로 넘깁니다
            MethodHandle qsort = linker.downcallHandle(
                linker.defaultLookup().find("qsort").orElseThrow(),
                FunctionDescriptor.ofVoid(ValueLayout.ADDRESS, ValueLayout.JAVA_LONG,
                                          ValueLayout.JAVA_LONG, ValueLayout.ADDRESS));

            MemorySegment buf = arena.allocateFrom(ValueLayout.JAVA_INT, data);
            qsort.invokeExact(buf, (long) data.length, 4L, cmpPtr);
            MemorySegment.copy(buf, ValueLayout.JAVA_INT, 0, data, 0, data.length);
        }
    }
}
upcall 의 두 가지 함정 — ① stub 의 수명은 arena 에 묶입니다. C 쪽이 포인터를 계속 보관한다면 Arena.ofShared()·global() 로 만들어야 합니다. ② 자바 예외가 C 스택을 통과하면 안 됩니다 — upcall 메서드 안에서 try/catch 로 전부 잡아 에러 코드로 바꿔 돌려주세요.

FFM 이 아직 못 하는 것 ★

한계실제 의미
C++ 클래스 직접 불가맹글링 · vtable · 예외를 모릅니다 — C 래퍼 필수
안드로이드 미지원ART 에 java.lang.foreign 이 없습니다 — JNI 로
Java 21 이하preview 라 --enable-preview 필요 · API 가 다름
템플릿 · 오버로드헤더에 실체화된 함수가 있어야 뽑힙니다
가변 인자(varargs)가능하지만 Linker.Option.firstVariadicArg 필요
성능(검사 비용)경계·수명 검사가 붙습니다 — critical 로 줄입니다
// 뜨거운 경로에서 검사 비용을 줄이는 옵션 (Java 22+)
MethodHandle fast = Linker.nativeLinker().downcallHandle(
    addr,
    FunctionDescriptor.of(ValueLayout.JAVA_INT, ValueLayout.ADDRESS, ValueLayout.JAVA_INT),
    Linker.Option.critical(true));   // allowHeapAccess=true — 힙 배열도 그대로 전달
//   ★ 대신 그 호출 동안 안전장치가 줄어듭니다:
//     · 오래 걸리는 함수에 쓰면 GC 를 방해할 수 있습니다
//     · 아주 짧고(수 µs) 블로킹하지 않는 함수에만 쓰세요
//     · JNI 의 GetPrimitiveArrayCritical 과 같은 성격의 도구입니다

// 가변 인자 — printf 처럼 뒤가 열린 함수
FunctionDescriptor printfDesc = FunctionDescriptor.of(
        ValueLayout.JAVA_INT, ValueLayout.ADDRESS, ValueLayout.JAVA_INT);
MethodHandle printf = Linker.nativeLinker().downcallHandle(
        Linker.nativeLinker().defaultLookup().find("printf").orElseThrow(),
        printfDesc, Linker.Option.firstVariadicArg(1));
C++ 프로젝트라면 결국 같은 결론 — FFM 이든 JNI 든 extern "C" 래퍼 한 겹이 필요합니다. 차이는 그 위에서 자바 코드만 쓰느냐(FFM), C++ 코드도 쓰느냐(JNI)입니다. 🔗 C ABI 탭에서 만든 래퍼가 그대로 재활용됩니다.
정리 — 헤더가 크면 jextract, 구조체는 패딩까지 명시한 StructLayout, 콜백은 upcallStub + arena 수명입니다. FFM 은 C++ 클래스와 안드로이드 두 곳에서 멈추고, 그 자리에는 JNI 가 남습니다.
JVM 09

JNA · JNR — 인터페이스 하나로 끝내는 길

JNI 헤더도, FFM 조립도 없이 자바 인터페이스 하나만 선언하면 C 함수가 불립니다. 대신 호출마다 값을 조립하는 중간 계층이 끼기 때문에 느립니다. 이 거래가 이득인 자리가 분명히 있고, 손해인 자리는 더 분명합니다.

같은 C 함수에 닿기까지 통과하는 계층 — 층수가 곧 비용입니다 JNA JNR-FFI FFM · JNI 자바 인터페이스 호출 동적 프록시(Proxy) libffi 로 인자 조립 C 함수 자바 인터페이스 호출 런타임 생성 스텁 짧아진 호출 경로 C 함수 MethodHandle · native JIT 가 인라인 중간 계층 없음 C 함수 호출당 수백 ns ~ µs 호출당 50~200ns 호출당 5~30ns 쓰기 편함과 호출 비용은 정확히 반비례합니다 초당 수천 번 이하로 부르는 관리 도구라면 JNA 로 하루 만에 끝내는 편이 이깁니다
계층이 하나 늘 때마다 수십~수백 ns 가 붙습니다 — 호출 횟수가 판단 기준입니다.

JNA — 코드 0줄 바인딩

// build.gradle.kts:  implementation("net.java.dev.jna:jna:5.x")
import com.sun.jna.*;

public interface MyLib extends Library {
    // libmylib.so / mylib.dll 을 알아서 찾아 올립니다
    MyLib I = Native.load("mylib", MyLib.class);

    int    mylib_add(int a, int b);
    double mylib_norm(double[] v, int n);      // 배열이 그냥 통합니다
    String mylib_version();                    // char* → String 자동 변환
    Pointer mylib_create(int w, int h);        // 불투명 포인터
    void   mylib_destroy(Pointer p);
}

// 쓰는 쪽 — 이게 전부입니다
int r = MyLib.I.mylib_add(3, 4);
double n = MyLib.I.mylib_norm(new double[]{3, 4}, 2);
// 구조체 — 필드 순서를 명시해야 합니다
public class Point extends Structure {
    public int x;
    public int y;
    public double len;

    @Override protected java.util.List<String> getFieldOrder() {
        return java.util.List.of("x", "y", "len");   // ★ 빠뜨리면 예외
    }
    public static class ByReference extends Point implements Structure.ByReference {}
}

// 콜백
public interface ProgressCb extends Callback { void invoke(int pct); }
MyLib.I.mylib_run(pct -> System.out.println(pct + "%"));
//  ★ 콜백 객체를 지역 변수로만 두면 GC 되어 크래시납니다 — 필드로 붙잡아 두세요
JNA 의 진짜 장점은 “배포”입니다 — 라이브러리를 jar 안에 넣어 두면 JNA 가 알아서 꺼내서 로드합니다(Native.extractFromResourcePath). jna-platform 을 쓰면 Win32 API · POSIX 함수가 이미 다 선언돼 있어서, “레지스트리 값 하나 읽기” 같은 일은 10분에 끝납니다.

JNR-FFI — 같은 모양, 더 빠른 구현

// build.gradle.kts:  implementation("com.github.jnr:jnr-ffi:2.x")
import jnr.ffi.*;
import jnr.ffi.annotations.*;
import jnr.ffi.types.*;

public interface MyLib {
    int    mylib_add(int a, int b);
    double mylib_norm(@In double[] v, int n);      // ★ 방향 표시로 복사를 줄입니다
    Pointer mylib_create(int w, int h);
    void   mylib_destroy(Pointer p);
    @size_t long mylib_size();                     // C 의 size_t 를 정확히
}

MyLib lib = LibraryLoader.create(MyLib.class)
        .search("/opt/app/lib")                    // 찾을 폴더 추가
        .load("mylib");

int r = lib.mylib_add(3, 4);

// 네이티브 메모리 직접 다루기
Runtime rt = Runtime.getRuntime(lib);
Pointer buf = Memory.allocateDirect(rt, 1024);
buf.putInt(0, 42);
JNAJNR-FFI
호출 비용수백 ns~µs50~200ns
배포 편의jar 자동 추출직접 처리
생태계jna-platformjnr-posix 등
안드로이드가능어려움
쓰는 곳도구 · 스크립트JRuby · 미들웨어
JNR 은 “JNA 보다 빠른 JNA” 정도로 생각하면 맞습니다. 런타임에 바이트코드 스텁을 생성해 프록시 한 겹을 없앤 구현이라, 인터페이스 선언 방식은 거의 같은데 수 배 빠릅니다. 다만 문서와 사용자 수가 JNA 보다 적어서 막혔을 때 검색이 잘 안 됩니다.

언제 쓰고 언제 피하나 🟡 중급

이 거래가 이득인 자리

JNA 를 쓰면 안 되는 곳
  • 초당 수십만 번 이상 호출되는 경로
  • 오디오 · 렌더링 등 지연 예산이 µs 단위인 곳
  • 큰 배열을 매 호출마다 넘기는 API
  • 서비스의 핵심 처리 루프
JNA 가 정답인 곳
  • OS API 한두 개 — 레지스트리 · 프로세스 · 창 제어
  • 사내 운영 도구 · CLI — 하루에 몇 번 도는 것
  • 프로토타입 — 이득이 있는지 먼저 재 보기
  • 초기화 때 한 번만 부르는 설정 함수
// 실무에서 아주 흔한 절충 — 무거운 것만 FFM/JNI, 나머지는 JNA
//   · lib_init() · lib_config() · lib_version()   → JNA 로 편하게
//   · lib_process(버퍼, 길이) 초당 10만 번         → FFM 으로 따로

// 프로토타입 단계의 표준 절차
//   ① JNA 로 30분 만에 붙여서 "이득이 나오는지" 측정
//   ② 이득이 2배 미만이면 → 네이티브를 포기하고 자바로 최적화
//   ③ 이득이 크면 → 그때 FFM 이나 JNI 로 옮깁니다 (API 는 이미 확정됨)
이 절차의 가치 — JNI 를 먼저 쓰면 “붙이는 데만” 이틀이 갑니다. 그러고 나서 이득이 없다는 걸 알면 그 이틀이 통째로 날아가죠. JNA 로 30분 만에 재고, 그다음에 결정하는 순서가 항상 이깁니다.

네 가지를 다시 한 번 · 마이그레이션 경로

지금 상태다음 걸음드는 시간
아무것도 없음JNA 로 측정30분
이득 확인됨 · Java 22+FFM 으로 이전반나절
이득 확인됨 · Java 17/21JNI 또는 JNR1~2일
안드로이드로 확장JNI 로 통일1~2일
C++ 클래스가 많음C ABI 래퍼 먼저규모에 비례
이전이 쉬운 이유 — 자바 쪽 "겉면"을 미리 분리해 두면 됩니다

  public interface ImageOps {                 ← 애플리케이션은 이것만 봅니다
      ByteBuffer grayscale(ByteBuffer src, int w, int h);
  }
    ├─ JnaImageOps   (프로토타입)
    ├─ FfmImageOps   (서버 · Java 22+)
    └─ JniImageOps   (안드로이드)

  구현을 갈아 끼워도 호출하는 코드는 한 줄도 안 바뀝니다.
  성능 비교(JMH)도 같은 인터페이스로 나란히 돌릴 수 있습니다 (v12)
JNA 의 숨은 비용 하나 — 인터페이스 방식은 편하지만 문자열 · 구조체 · 배열이 매 호출마다 마샬링됩니다. “함수 호출 자체는 200ns 인데 실제로는 2µs 걸린다”는 대부분 여기서 옵니다. 측정할 때는 실제로 넘길 데이터 크기 그대로 재세요.
정리 — JNA · JNR 은 “속도를 팔아 시간을 사는” 도구입니다. 측정용 프로토타입과 관리 도구에서는 최고의 선택이고, 뜨거운 루프에 들어가는 순간 최악의 선택이 됩니다. 자바 쪽 인터페이스를 미리 분리해 두면 나중에 FFM·JNI 로 갈아 끼우기가 쉽습니다.
JVM 10

빌드와 패키징 — jar 하나로 배포하기

개발자 PC 에서는 -Djava.library.path 로 넘어갑니다. 하지만 “의존성 한 줄 추가했더니 그냥 되더라” 가 되려면 OS 별 네이티브 파일을 jar 안에 넣고 실행 시점에 꺼내 로드해야 합니다. 실무 라이브러리(JNA · SQLite JDBC · Netty)가 전부 쓰는 그 패턴을 그대로 만듭니다.

jar 안에 넣고 실행 시점에 꺼내 로드하기 — NativeLoader 패턴 app.jar 안의 배치 native/linux-x86-64/libimageops.so native/linux-aarch64/libimageops.so native/windows-x86-64/imageops.dll native/darwin-aarch64/libimageops.dylib native/darwin-x86-64/libimageops.dylib CI 가 OS 마다 빌드해서 채워 넣습니다 ① os.name · os.arch 로 경로 결정 ② 리소스를 임시 파일로 꺼내기 ③ System.load(절대 경로) 쓰는 사람은 의존성 한 줄만 추가하면 됩니다 java.library.path 설정도, OS 별 설치 안내도 필요 없습니다 — SQLite JDBC · Netty 가 쓰는 방식입니다
이 패턴 하나가 “설치 안내 문서”를 통째로 없앱니다.

Gradle 이 CMake 를 부르게 하기

// build.gradle.kts
plugins { java }

val nativeDir = layout.buildDirectory.dir("native")

val cmakeConfigure by tasks.registering(Exec::class) {
    commandLine("cmake", "-B", "build/cmake", "-S", "native",
                "-DCMAKE_BUILD_TYPE=Release")
}

val cmakeBuild by tasks.registering(Exec::class) {
    dependsOn(cmakeConfigure)
    commandLine("cmake", "--build", "build/cmake", "--config", "Release", "-j")
}

// 빌드 결과를 리소스 폴더로 복사 — 여기서 플랫폼 폴더 이름이 정해집니다
val copyNative by tasks.registering(Copy::class) {
    dependsOn(cmakeBuild)
    from("build/cmake") {
        include("**/*.so", "**/*.dll", "**/*.dylib")
    }
    eachFile { path = name }               // 폴더 구조를 평평하게
    into(layout.buildDirectory.dir("nativeResources/native/${platformDir()}"))
    includeEmptyDirs = false
}

sourceSets["main"].resources.srcDir(layout.buildDirectory.dir("nativeResources"))
tasks.named("processResources") { dependsOn(copyNative) }

fun platformDir(): String {
    val os = System.getProperty("os.name").lowercase()
    val arch = System.getProperty("os.arch").lowercase()
    val o = when {
        os.contains("win")               -> "windows"
        os.contains("mac") || os.contains("darwin") -> "darwin"
        else                             -> "linux"
    }
    val a = when (arch) {
        "amd64", "x86_64" -> "x86-64"
        "aarch64", "arm64" -> "aarch64"
        else -> arch
    }
    return "$o-$a"
}
왜 Gradle 네이티브 플러그인(cpp-library)을 안 쓰나 — 쓸 수는 있지만 실무 C++ 프로젝트는 이미 CMake 로 되어 있고, vcpkg · Conan · 서드파티 SDK 가 전부 CMake 를 전제로 합니다. Gradle 은 CMake 를 부르는 얇은 껍데기로 두는 편이 유지보수가 쉽습니다.

NativeLoader — 60줄이면 충분합니다

package com.example.native_;

import java.io.*;
import java.nio.file.*;

public final class NativeLoader {
    private static boolean loaded = false;

    public static synchronized void load(String lib) {
        if (loaded) return;
        try {
            System.loadLibrary(lib);          // ① 시스템에 이미 있으면 그걸 씁니다
        } catch (UnsatisfiedLinkError ignored) {
            extractAndLoad(lib);              // ② 없으면 jar 에서 꺼냅니다
        }
        loaded = true;
    }

    private static void extractAndLoad(String lib) {
        String res = "/native/" + platform() + "/" + mapName(lib);
        try (InputStream in = NativeLoader.class.getResourceAsStream(res)) {
            if (in == null) throw new UnsatisfiedLinkError("no native for " + platform());

            Path dir = Files.createTempDirectory("nat-");
            dir.toFile().deleteOnExit();
            Path out = dir.resolve(mapName(lib));
            Files.copy(in, out, StandardCopyOption.REPLACING_EXISTING);
            out.toFile().deleteOnExit();

            System.load(out.toAbsolutePath().toString());   // ★ 절대 경로여야 합니다
        } catch (IOException e) {
            throw new UnsatisfiedLinkError("extract failed: " + e);
        }
    }

    private static String mapName(String lib) { return System.mapLibraryName(lib); }

    static String platform() {
        String os = System.getProperty("os.name").toLowerCase();
        String ar = System.getProperty("os.arch").toLowerCase();
        String o = os.contains("win") ? "windows"
                 : (os.contains("mac") || os.contains("darwin")) ? "darwin" : "linux";
        String a = switch (ar) {
            case "amd64", "x86_64" -> "x86-64";
            case "aarch64", "arm64" -> "aarch64";
            default -> ar;
        };
        return o + "-" + a;
    }
    private NativeLoader() {}
}
놓치기 쉬운 세 가지 — ① System.mapLibraryName("foo") 이 OS 규칙대로 이름을 만들어 줍니다(직접 문자열을 붙이지 마세요). ② 윈도우는 로드된 DLL 을 지울 수 없어 deleteOnExit 가 실패합니다 — 정상입니다. ③ 컨테이너의 /tmp 가 noexec 로 마운트되면 로드가 실패합니다 — -Djava.io.tmpdir 로 바꿀 수 있게 열어 두세요.

다중 플랫폼 CI · Spring Boot 에 얹기 🔴 실무

GitHub Actions — 매트릭스로 다섯 개를 한 번에

name: native
on: [push]

jobs:
  build-native:
    strategy:
      matrix:
        include:
          - { os: ubuntu-22.04,  target: linux-x86-64 }
          - { os: ubuntu-22.04-arm, target: linux-aarch64 }
          - { os: windows-2022,  target: windows-x86-64 }
          - { os: macos-14,      target: darwin-aarch64 }
          - { os: macos-13,      target: darwin-x86-64 }
    runs-on: ${{ matrix.os }}
    steps:
      - uses: actions/checkout@v4
      - name: build
        run: |
          cmake -B build -S native -DCMAKE_BUILD_TYPE=Release
          cmake --build build --config Release -j
      - uses: actions/upload-artifact@v4
        with:
          name: native-${{ matrix.target }}
          path: |
            build/**/*.so
            build/**/*.dll
            build/**/*.dylib

  package:
    needs: build-native
    runs-on: ubuntu-22.04
    steps:
      - uses: actions/checkout@v4
      - uses: actions/download-artifact@v4
        with: { path: natives }
      # natives/native-linux-x86-64/... 를 src/main/resources/native/linux-x86-64/ 로 옮기고
      - run: ./gradlew jar        # 다섯 플랫폼이 모두 들어간 fat jar 하나가 나옵니다
리눅스는 ubuntu-22.04 처럼 낡은 이미지에서 빌드하세요 — glibc 는 아래로 호환되지 않습니다. 24.04 에서 빌드한 .so 는 22.04 서버에서 GLIBC_2.38 not found 로 죽습니다. 가장 낮은 지원 환경에서 빌드하는 것이 원칙이고, 그래서 많은 프로젝트가 manylinux 컨테이너에서 빌드합니다.

Spring Boot 앱에서 쓰기

@Configuration
public class NativeConfig {

    // ① 애플리케이션 시작 때 한 번 로드 — 첫 요청이 아니라
    @Bean(destroyMethod = "close")
    ImageOps imageOps() {
        NativeLoader.load("imageops");
        return new JniImageOps();
    }

    // ② FFM 을 쓴다면 Arena 를 빈으로 두고 종료 때 닫습니다
    @Bean(destroyMethod = "close")
    Arena nativeArena() { return Arena.ofShared(); }

    // ③ 다이렉트 버퍼 풀 — 요청마다 allocateDirect 하지 않기 위해
    @Bean
    BufferPool bufferPool() { return new BufferPool(32, 8 * 1024 * 1024); }
}

@RestController
class ImageController {
    private final ImageOps ops;
    private final BufferPool pool;

    ImageController(ImageOps ops, BufferPool pool) { this.ops = ops; this.pool = pool; }

    @PostMapping(value = "/grayscale", produces = MediaType.IMAGE_PNG_VALUE)
    byte[] grayscale(@RequestParam int w, @RequestParam int h,
                     @RequestBody byte[] body) {
        ByteBuffer in = pool.borrow();
        try {
            in.clear(); in.put(body).flip();
            ByteBuffer out = ops.grayscale(in, w, h);
            byte[] r = new byte[out.remaining()];
            out.get(r);
            return r;
        } finally { pool.giveBack(in); }
    }
}
운영 체크리스트

□ 컨테이너 베이스 이미지에 libstdc++ 가 있나
    eclipse-temurin:25-jre  → 대개 있음
    distroless / alpine     → alpine 은 musl 이라 별도 빌드 필요 ★
□ 네이티브 스레드가 GC 스레드와 코어를 다투지 않나 (-XX:ActiveProcessorCount)
□ -XX:MaxDirectMemorySize 를 컨테이너 메모리에 맞춰 잡았나
□ hs_err 파일이 볼륨에 남게 -XX:ErrorFile 을 지정했나
□ 헬스체크가 네이티브 호출을 한 번 하는가 (로드 실패를 기동 때 잡기)
□ --enable-native-access 를 JAVA_OPTS 에 넣었나 (Java 24+)
Alpine(musl) 은 별개의 플랫폼입니다 — linux-x86-64 폴더의 glibc .so 는 Alpine 에서 안 돕니다. 지원하려면 linux-x86-64-musl 폴더를 따로 만들고 Alpine 컨테이너에서 따로 빌드해야 합니다. 지원 안 할 거면 README 에 명시하세요 — 이 사고가 정말 자주 납니다.
정리 — Gradle 이 CMake 를 부르고, CI 매트릭스가 OS 별 .so/.dll/.dylib 를 만들어 리소스로 넣고, NativeLoader 가 실행 시점에 꺼내 로드합니다. 이 세 조각이면 사용자는 의존성 한 줄만 추가하면 됩니다.
JVM 11

안드로이드 NDK · 코틀린 — 여기서는 JNI 가 유일한 길

안드로이드 런타임(ART)에는 java.lang.foreign 이 없습니다. FFM 도 JNA 도 사실상 선택지가 아니고, NDK + JNI 가 표준입니다. 다행히 Gradle 이 CMake 를 직접 불러 주기 때문에 빌드 설정은 서버보다 오히려 간단합니다.

안드로이드 네이티브 빌드 흐름 — Gradle 이 CMake 를 직접 부릅니다 build.gradle.kts externalNativeBuild CMakeLists.txt add_library(SHARED) NDK clang libc++_shared ABI 마다 따로 컴파일 arm64-v8a 실기기 대부분 armeabi-v7a 구형 32비트 x86_64 에뮬레이터 x86 거의 안 씁니다 APK · AAB 안의 lib/<ABI>/libimageops.so 기기는 자기 ABI 폴더만 설치합니다 — abiFilters 로 개수를 줄이면 앱 용량이 줄어듭니다 Kotlin — object 의 init 블록에서 System.loadLibrary("imageops") external fun 으로 선언하면 자바의 native 메서드와 똑같이 동작합니다 Android 15+ — 16KB 페이지 정렬이 필수입니다. 안 맞으면 앱이 아예 실행되지 않습니다
서버와 다른 점은 ABI 마다 따로 빌드된다는 것 하나입니다.

app/build.gradle.kts — 이 블록이 전부

android {
    namespace = "com.example.app"
    compileSdk = 35
    ndkVersion = "27.2.12479018"          // ★ 팀 전체가 같은 버전을 쓰도록 고정

    defaultConfig {
        minSdk = 24

        externalNativeBuild {
            cmake {
                cppFlags += listOf("-std=c++20", "-fvisibility=hidden")
                arguments += listOf(
                    "-DANDROID_STL=c++_shared",       // 예외·RTTI 를 쓰면 shared
                    "-DCMAKE_BUILD_TYPE=Release"
                )
            }
        }

        ndk {
            // ★ 필요한 ABI 만 — 4개 전부 넣으면 앱 크기가 4배가 됩니다
            abiFilters += listOf("arm64-v8a", "x86_64")
        }
    }

    externalNativeBuild {
        cmake {
            path = file("src/main/cpp/CMakeLists.txt")
            version = "3.22.1"
        }
    }

    // AAB 로 올리면 구글 플레이가 ABI 별로 쪼개 배포합니다 (권장)
    bundle { abi { enableSplit = true } }

    packaging {
        jniLibs {
            useLegacyPackaging = false     // 압축 없이 담아 설치 후 추출을 피합니다
        }
    }
}
# app/src/main/cpp/CMakeLists.txt
cmake_minimum_required(VERSION 3.22.1)
project(imageops LANGUAGES CXX)

add_library(imageops SHARED imageops.cpp)

# 안드로이드 로그 함수를 쓰려면 log 라이브러리를 연결합니다
find_library(log-lib log)
target_link_libraries(imageops ${log-lib})

target_compile_options(imageops PRIVATE -O3 -ffast-math)

# ★ Android 15+ (targetSdk 35) — 16KB 페이지 정렬
#   NDK r27 이상 + AGP 최신이면 기본이지만, 명시해 두면 안전합니다
target_link_options(imageops PRIVATE "-Wl,-z,max-page-size=16384")
16KB 페이지 문제는 “나중에”가 없습니다 — 최신 안드로이드 기기는 16KB 페이지로 동작하고, 4KB 로 정렬된 .so 는 로드 자체가 실패합니다. NDK r27 이상으로 올리고, 서드파티 .so(코덱 SDK 등)도 readelf -lW libX.so 로 LOAD 세그먼트 정렬이 0x4000 인지 확인하세요.

코틀린 쪽 — external fun

package com.example.app

import java.nio.ByteBuffer

object ImageOps {
    init {
        System.loadLibrary("imageops")        // object 초기화 때 한 번
    }

    // 자바의 native 메서드와 완전히 같습니다
    external fun version(): String

    external fun grayscale(src: ByteBuffer, dst: ByteBuffer, w: Int, h: Int)

    external fun createEncoder(w: Int, h: Int): Long      // 핸들 패턴
    external fun destroyEncoder(handle: Long)
}
// app/src/main/cpp/imageops.cpp
#include <jni.h>
#include <android/log.h>
#include <cstdint>

#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, "imageops", __VA_ARGS__)

extern "C" {

JNIEXPORT jstring JNICALL
Java_com_example_app_ImageOps_version(JNIEnv* env, jobject /* this */) {
    return env->NewStringUTF("1.0.0");
}

JNIEXPORT void JNICALL
Java_com_example_app_ImageOps_grayscale(JNIEnv* env, jobject,
                                        jobject src, jobject dst, jint w, jint h) {
    auto* s = static_cast<const uint8_t*>(env->GetDirectBufferAddress(src));
    auto* d = static_cast<uint8_t*>(env->GetDirectBufferAddress(dst));
    if (!s || !d) { LOGI("direct buffer required"); return; }
    const size_t n = (size_t)w * h;
    for (size_t i = 0; i < n; ++i)
        d[i] = (uint8_t)((s[i*4]*77 + s[i*4+1]*150 + s[i*4+2]*29) >> 8);
}

}   // extern "C"
코틀린 object 안에 두는 이유 — 자바의 static { } 자리를 object 의 init 블록이 대신합니다. 싱글턴이므로 정확히 한 번 실행되고, 그래서 클래스가 아니라 jobject 가 두 번째 인자로 옵니다(object 의 메서드는 인스턴스 메서드입니다). @JvmStatic 을 붙이면 jclass 가 됩니다.

코루틴 · KMP — 네이티브 호출을 어디서 부르나 🔴 실무

코루틴에서 안전하게 부르기

import kotlinx.coroutines.*
import java.nio.ByteBuffer

class ImageRepository(private val pool: BufferPool) {

    // ★ 네이티브 호출은 "블로킹"입니다 — 반드시 적절한 디스패처로 옮깁니다
    suspend fun grayscale(src: ByteBuffer, w: Int, h: Int): ByteBuffer =
        withContext(Dispatchers.Default) {          // CPU 작업 → Default
            val dst = pool.borrow(w * h)
            ImageOps.grayscale(src, dst, w, h)
            dst
        }

    // 파일·네트워크가 섞이면 IO
    suspend fun decodeFile(path: String): ByteBuffer =
        withContext(Dispatchers.IO) { ImageOps.decode(path) }
}

// ✗ 절대 하면 안 되는 것 — 메인 스레드에서 무거운 네이티브 호출
fun onClick() { ImageOps.grayscale(...) }     // ANR 로 앱이 죽습니다

// ★ 취소는 자동으로 전달되지 않습니다
//   코루틴을 cancel 해도 이미 시작된 C++ 루프는 끝까지 돕니다.
//   중간에 멈춰야 한다면 C++ 쪽에 취소 플래그를 두고 주기적으로 확인하게 합니다:
//     external fun requestCancel(handle: Long)
//     → C++ 루프가 std::atomic<bool> 을 매 프레임 확인
코루틴이 “가벼워서” 생기는 착각 — Dispatchers.Default 의 스레드 수는 코어 수로 제한됩니다. 네이티브 호출이 1초씩 블로킹하면 그 스레드는 그동안 아무것도 못 합니다. 긴 네이티브 작업이 여럿이면 전용 디스패처 (Executors.newFixedThreadPool(2).asCoroutineDispatcher())를 따로 두세요.

Kotlin/Native cinterop — KMP 로 넓히기

# src/nativeInterop/cinterop/imageops.def
headers = imageops.h
headerFilter = imageops.h
staticLibraries = libimageops.a
libraryPaths = native/build/ios-arm64
package = com.example.native.imageops
compilerOpts = -Inative/include
// build.gradle.kts — KMP 쪽
kotlin {
    androidTarget()                     // ← 여기는 JNI
    iosArm64 {                          // ← 여기는 cinterop
        compilations["main"].cinterops {
            val imageops by creating {
                defFile("src/nativeInterop/cinterop/imageops.def")
            }
        }
    }

    sourceSets {
        val commonMain by getting { }
        val androidMain by getting { }
        val iosMain by getting { }
    }
}

// commonMain — 플랫폼과 무관한 얼굴
expect object Native { fun grayscale(src: ByteArray, w: Int, h: Int): ByteArray }

// androidMain — JNI 구현
actual object Native {
    init { System.loadLibrary("imageops") }
    actual external fun grayscale(src: ByteArray, w: Int, h: Int): ByteArray
}

// iosMain — cinterop 으로 생성된 함수를 그대로
actual object Native {
    actual fun grayscale(src: ByteArray, w: Int, h: Int): ByteArray =
        memScoped { /* imageops_grayscale(...) 직접 호출 */ ByteArray(0) }
}
cinterop 은 JNI 가 아닙니다 — Kotlin/Native 는 JVM 없이 기계어로 컴파일되므로 C 함수를 그냥 부릅니다(FFM 보다도 직접적입니다). 같은 C++ 코어를 안드로이드는 JNI 로, iOS 는 cinterop 으로 쓰는 것이 KMP 프로젝트의 표준 구성입니다 — C ABI 래퍼가 여기서 또 한 번 값을 합니다.
정리 — 안드로이드는 FFM 이 없어서 JNI 입니다. externalNativeBuild + CMake 로 빌드하고, abiFilters 로 용량을 줄이고, 16KB 페이지 정렬을 반드시 확인합니다. 코틀린에서는 external fun 이 전부이고, 호출은 withContext(Dispatchers.Default) 로 옮깁니다.
JVM 12

실전 — 하나의 C++ 코어를 JNI 와 FFM 으로 두 번 감쌉니다

마지막은 처음부터 끝까지 이어 봅니다. 이미지 리사이즈 C++ 코어 하나를 만들고, JNI + DirectByteBuffer 와 FFM 두 가지로 감싼 다음, JMH 로 순수 자바 구현까지 셋을 나란히 재고, Spring Boot 엔드포인트에 붙여 배포합니다.

구조 — 인터페이스 하나 아래 두 구현, 그 아래 C++ 코어 하나 POST /resize Spring Boot 컨트롤러 interface ImageOps 구현을 갈아 끼웁니다 JniImageOps DirectByteBuffer 로 제로카피 · 안드로이드에서도 그대로 FfmImageOps Arena + MemorySegment · 자바 코드만으로 libimageops — extern "C" resize_rgba() 하나 두 구현이 완전히 같은 C++ 코어를 씁니다. C++ 코드는 자바를 전혀 모릅니다 JMH 결과 — 1920×1080 RGBA 를 절반 크기로 1회 리사이즈 순수 자바 12.4 ms JNI + DirectBuffer 2.8 ms FFM 2.9 ms 0 5 10 1회 소요(ms) JNI 와 FFM 의 차이는 오차 범위입니다 — 진짜 차이는 자바 대비 약 4배입니다
고를 기준은 속도가 아니라 “어디에 배포하는가 · 어떤 Java 를 쓰는가”입니다.

① C++ 코어 — 자바를 전혀 모릅니다

// native/src/imageops.cpp
#include <cstdint>
#include <cstddef>

extern "C" {
#if defined(_WIN32)
  #define API __declspec(dllexport)
#else
  #define API __attribute__((visibility("default")))
#endif

// RGBA8888 이미지를 이중선형 보간으로 리사이즈합니다.
// 반환: 0 성공, 음수 실패. 메모리는 호출자가 준비합니다 (소유권 이동 없음)
API int32_t resize_rgba(const uint8_t* src, int32_t sw, int32_t sh,
                        uint8_t* dst, int32_t dw, int32_t dh) {
    if (!src || !dst || sw <= 0 || sh <= 0 || dw <= 0 || dh <= 0) return -1;

    const float fx = static_cast<float>(sw) / dw;
    const float fy = static_cast<float>(sh) / dh;

    for (int32_t y = 0; y < dh; ++y) {
        const float sy = (y + 0.5f) * fy - 0.5f;
        int32_t y0 = (int32_t)(sy < 0 ? 0 : sy);
        int32_t y1 = (y0 + 1 < sh) ? y0 + 1 : y0;
        const float wy = sy - y0;

        for (int32_t x = 0; x < dw; ++x) {
            const float sx = (x + 0.5f) * fx - 0.5f;
            int32_t x0 = (int32_t)(sx < 0 ? 0 : sx);
            int32_t x1 = (x0 + 1 < sw) ? x0 + 1 : x0;
            const float wx = sx - x0;

            for (int c = 0; c < 4; ++c) {
                const float a = src[(y0*(size_t)sw + x0)*4 + c] * (1-wx)
                              + src[(y0*(size_t)sw + x1)*4 + c] * wx;
                const float b = src[(y1*(size_t)sw + x0)*4 + c] * (1-wx)
                              + src[(y1*(size_t)sw + x1)*4 + c] * wx;
                dst[(y*(size_t)dw + x)*4 + c] = (uint8_t)(a*(1-wy) + b*wy + 0.5f);
            }
        }
    }
    return 0;
}

API const char* imageops_version(void) { return "1.0.0"; }
}   // extern "C"
이 함수의 설계가 전부를 결정합니다 — 포인터 + 크기만 받고 메모리를 소유하지 않으며 에러를 코드로 돌려줍니다. 이 세 규칙 덕분에 JNI · FFM · 파이썬 · C# 어디에도 똑같이 붙습니다. 여기에 jni.h 를 한 줄이라도 넣는 순간 자바 전용 코드가 됩니다.

② 자바 인터페이스와 두 구현

public interface ImageOps extends AutoCloseable {
    /** src 는 direct ByteBuffer(RGBA), 결과도 direct ByteBuffer */
    ByteBuffer resize(ByteBuffer src, int sw, int sh, int dw, int dh);
    String version();
    @Override default void close() {}
}
// (a) JNI 구현 — DirectByteBuffer 로 복사 0
public final class JniImageOps implements ImageOps {
    static { NativeLoader.load("imageops_jni"); }

    private static native int  nResize(ByteBuffer src, int sw, int sh,
                                       ByteBuffer dst, int dw, int dh);
    private static native String nVersion();

    @Override public ByteBuffer resize(ByteBuffer src, int sw, int sh, int dw, int dh) {
        ByteBuffer dst = ByteBuffer.allocateDirect(dw * dh * 4)
                                   .order(ByteOrder.nativeOrder());
        int r = nResize(src, sw, sh, dst, dw, dh);
        if (r != 0) throw new IllegalStateException("resize failed: " + r);
        return dst;
    }
    @Override public String version() { return nVersion(); }
}
// JNI 껍데기 — 코어를 부르기만 합니다 (별도 .so 로 빌드)
#include <jni.h>
extern "C" int32_t resize_rgba(const uint8_t*, int32_t, int32_t,
                               uint8_t*, int32_t, int32_t);

extern "C" JNIEXPORT jint JNICALL
Java_com_example_JniImageOps_nResize(JNIEnv* env, jclass,
        jobject src, jint sw, jint sh, jobject dst, jint dw, jint dh) {
    auto* s = static_cast<const uint8_t*>(env->GetDirectBufferAddress(src));
    auto* d = static_cast<uint8_t*>(env->GetDirectBufferAddress(dst));
    if (!s || !d) return -2;
    if (env->GetDirectBufferCapacity(src) < (jlong)sw*sh*4) return -3;   // ★ 경계 검사
    if (env->GetDirectBufferCapacity(dst) < (jlong)dw*dh*4) return -4;
    return resize_rgba(s, sw, sh, d, dw, dh);
}
// (b) FFM 구현 — C++ 껍데기가 아예 없습니다
public final class FfmImageOps implements ImageOps {
    private final Arena arena = Arena.ofShared();
    private final MethodHandle resize;

    public FfmImageOps(Path lib) {
        Linker linker = Linker.nativeLinker();
        SymbolLookup look = SymbolLookup.libraryLookup(lib, arena);
        resize = linker.downcallHandle(
            look.find("resize_rgba").orElseThrow(),
            FunctionDescriptor.of(ValueLayout.JAVA_INT,
                ValueLayout.ADDRESS, ValueLayout.JAVA_INT, ValueLayout.JAVA_INT,
                ValueLayout.ADDRESS, ValueLayout.JAVA_INT, ValueLayout.JAVA_INT));
    }

    @Override public ByteBuffer resize(ByteBuffer src, int sw, int sh, int dw, int dh) {
        ByteBuffer dst = ByteBuffer.allocateDirect(dw * dh * 4)
                                   .order(ByteOrder.nativeOrder());
        // ★ direct ByteBuffer 는 MemorySegment 로 그대로 감쌉니다 — 복사 0
        MemorySegment s = MemorySegment.ofBuffer(src);
        MemorySegment d = MemorySegment.ofBuffer(dst);
        try {
            int r = (int) resize.invokeExact(s, sw, sh, d, dw, dh);
            if (r != 0) throw new IllegalStateException("resize failed: " + r);
            return dst;
        } catch (Throwable t) { throw new RuntimeException(t); }
    }
    @Override public String version() { return "1.0.0"; }
    @Override public void close() { arena.close(); }
}
차이를 눈으로 보세요 — (a)는 C++ 파일이 하나 더 필요하고 따로 빌드해 배포해야 합니다. (b)는 자바 파일 하나가 전부이고 libimageops 는 손대지 않은 순수 C 라이브러리 그대로입니다. 성능은 사실상 같습니다 — 그래서 Java 22+ 서버라면 (b)가 이깁니다.

③ JMH 로 재기 — 숫자 없이는 주장이 아닙니다 🔴 실무

벤치마크 코드

import org.openjdk.jmh.annotations.*;
import org.openjdk.jmh.infra.Blackhole;
import java.util.concurrent.TimeUnit;

@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.MILLISECONDS)
@State(Scope.Benchmark)
@Fork(value = 2, jvmArgsAppend = {"--enable-native-access=ALL-UNNAMED",
                                  "-XX:MaxDirectMemorySize=1g"})
@Warmup(iterations = 5, time = 1)
@Measurement(iterations = 10, time = 1)
public class ResizeBench {

    static final int SW = 1920, SH = 1080, DW = 960, DH = 540;

    ByteBuffer src;
    ImageOps jni, ffm;
    PureJavaImageOps java_;

    @Setup public void setup() {
        src = ByteBuffer.allocateDirect(SW * SH * 4).order(ByteOrder.nativeOrder());
        for (int i = 0; i < src.capacity(); i++) src.put(i, (byte) (i * 31));
        jni   = new JniImageOps();
        ffm   = new FfmImageOps(Path.of("build/libimageops.so"));
        java_ = new PureJavaImageOps();
    }

    @TearDown public void down() { jni.close(); ffm.close(); }

    @Benchmark public void pureJava(Blackhole bh) { bh.consume(java_.resize(src, SW, SH, DW, DH)); }
    @Benchmark public void viaJni(Blackhole bh)   { bh.consume(jni.resize(src, SW, SH, DW, DH)); }
    @Benchmark public void viaFfm(Blackhole bh)   { bh.consume(ffm.resize(src, SW, SH, DW, DH)); }

    // 경계 비용만 따로 재기 — 이 숫자가 설계 판단의 근거가 됩니다
    @Benchmark public int emptyJniCall() { return JniImageOps.nVersionLen(); }
    @Benchmark public int emptyFfmCall() throws Throwable { return ffm.versionLen(); }
}
# 실행 (gradle jmh 플러그인 또는 shadow jar)
./gradlew jmh
java -jar build/libs/benchmarks.jar ResizeBench -rf json -rff result.json

결과를 읽는 법

Benchmark                    Mode  Cnt   Score    Error  Units
ResizeBench.pureJava         avgt   20  12.412 ±  0.284  ms/op
ResizeBench.viaJni           avgt   20   2.803 ±  0.061  ms/op
ResizeBench.viaFfm           avgt   20   2.891 ±  0.074  ms/op
ResizeBench.emptyJniCall     avgt   20   0.000018        ms/op   (≈ 18ns)
ResizeBench.emptyFfmCall     avgt   20   0.000022        ms/op   (≈ 22ns)

읽는 순서
 ① 이득 배수      12.4 / 2.8 = 약 4.4배     ← 이게 없으면 복잡도를 감수할 이유가 없습니다
 ② 두 방식의 차이  2.803 vs 2.891 → Error 안 ← 사실상 동일. 속도로 고르지 마세요
 ③ 경계 비용      18ns · 22ns              ← 2.8ms 에 비해 0.001% — 무시해도 됩니다
 ④ Error 가 Score 의 5% 를 넘으면 측정을 의심하세요 (다른 프로세스 · 터보 부스트)

되돌아봐야 하는 결과
 · 이득이 1.5배 미만  → 자바 쪽 최적화(Vector API · 배열 재사용)를 먼저 해 보세요
 · JNI 만 갑자기 느림 → 복사가 남아 있습니다. byte[] 를 쓰고 있지 않은지 확인
 · 편차가 큼          → GC 나 다이렉트 버퍼 할당이 측정에 섞였습니다
이 표를 저장소에 커밋해 두세요 — “왜 이 부분이 C++ 인가”는 6개월 뒤에 반드시 다시 나오는 질문입니다. 측정치 한 장이 있으면 토론이 5분에 끝나고, 없으면 “다시 자바로 되돌리자”는 논의가 며칠을 잡아먹습니다.

④ Spring Boot 엔드포인트

@Configuration
class ImageConfig {
    @Bean(destroyMethod = "close")
    ImageOps imageOps(@Value("${app.native.impl:ffm}") String impl,
                      @Value("${app.native.lib:/opt/app/libimageops.so}") String lib) {
        return switch (impl) {
            case "jni" -> new JniImageOps();
            case "ffm" -> new FfmImageOps(Path.of(lib));
            default    -> new PureJavaImageOps();       // 폴백 ★
        };
    }
}

@RestController
class ResizeController {
    private final ImageOps ops;
    private final BufferPool pool;
    ResizeController(ImageOps ops, BufferPool pool) { this.ops = ops; this.pool = pool; }

    @PostMapping(value = "/resize", consumes = MediaType.APPLICATION_OCTET_STREAM_VALUE)
    ResponseEntity<byte[]> resize(@RequestParam int sw, @RequestParam int sh,
                                  @RequestParam int dw, @RequestParam int dh,
                                  @RequestBody byte[] body) {
        if ((long) sw * sh * 4 != body.length)
            return ResponseEntity.badRequest().build();

        ByteBuffer in = pool.borrow(body.length);
        try {
            in.clear(); in.put(body).flip();
            ByteBuffer out = ops.resize(in, sw, sh, dw, dh);
            byte[] r = new byte[out.remaining()];
            out.get(r);
            return ResponseEntity.ok(r);
        } finally { pool.giveBack(in); }
    }

    @GetMapping("/health/native")
    String health() { return ops.version(); }   // 기동 직후 로드 실패를 잡습니다
}
설정 한 줄로 구현을 바꿀 수 있게 두세요 — app.native.impl=java 로 되돌릴 수 있으면 운영에서 네이티브가 말썽일 때 재배포 없이 즉시 회피할 수 있습니다. “빠르지만 되돌릴 수 없는 것”보다 “조금 느려도 되돌릴 수 있는 것”이 운영에서는 이깁니다.

⑤ 배포 · 마무리 점검

저장소 구조 (최종)

  native/
    include/imageops.h        ← C ABI 헤더 (모든 언어의 계약서)
    src/imageops.cpp          ← 코어 — 자바를 모릅니다
    src/jni_shim.cpp          ← JNI 껍데기 (JNI 구현용)
    CMakeLists.txt
  src/main/java/com/example/
    ImageOps.java             ← 인터페이스
    JniImageOps.java  FfmImageOps.java  PureJavaImageOps.java
    NativeLoader.java         ← v10 의 그 클래스
  src/main/resources/native/
    linux-x86-64/ · darwin-aarch64/ · windows-x86-64/
  src/jmh/java/                ← 벤치마크
  .github/workflows/native.yml ← 매트릭스 빌드
배포 전 체크리스트

□ 폴백 구현(PureJava)이 있고 설정으로 전환되나
□ 모든 네이티브 함수에 경계 검사가 있나 (버퍼 크기)
□ -Xcheck:jni 경고가 0 인가 (JNI 구현)
□ ASan 빌드로 전체 테스트를 한 번 돌렸나
□ 지원 OS/아키텍처 목록이 README 에 명시돼 있나 (musl 포함 여부)
□ --enable-native-access 가 실행 스크립트에 있나
□ MaxDirectMemorySize 가 컨테이너 메모리에 맞게 잡혀 있나
□ hs_err 파일이 보존되는 경로로 -XX:ErrorFile 이 지정돼 있나
□ JMH 결과가 저장소에 커밋돼 있나
□ 헬스체크가 네이티브 호출을 한 번 하나
이 탭을 한 문장으로 — C++ 코어는 자바를 모르게 두고, 얇은 껍데기를 배포 대상에 맞춰 고릅니다. 서버는 FFM, 안드로이드는 JNI, 프로토타입은 JNA — C ABI 함수 하나가 그 셋을 전부 떠받칩니다.
정리 — C++ 코어 하나 · C ABI 하나 · 자바 인터페이스 하나로 JNI 와 FFM 두 구현을 나란히 둘 수 있습니다. JMH 로 재 보면 둘의 차이는 오차 범위, 자바 대비 이득은 4배였습니다 — 그러니 고를 기준은 속도가 아니라 배포 대상과 Java 버전입니다.
std::stack · std::queue · std::deque · std::priority_queue · 원형 버퍼 · 블로킹 큐 · C++23

스택은 vector,
큐는 deque,
어댑터는 얇은 껍데기

C++ 에는 std::stack·std::queue 가 있지만 이것들은 컨테이너가 아니라 어댑터입니다. 안에 든 진짜 저장소(vector·deque)를 고르는 것이 성능을 정하고, pop() 이 값을 돌려주지 않는 이유, 빈 상태에서 top() 이 미정의 동작인 이유를 알아야 사고가 안 납니다. 이 탭은 2026년 현업 기준으로 무엇을 어떤 타입으로 쓰는지, std::deque 가 메모리에 어떻게 놓이는지, 표준에 없는 스레드 안전 큐를 어떻게 만들고 무엇을 가져다 쓰는지까지 다룹니다. 끝나면 BFS·단조 덱·레이트 리미터·undo/redo 를 C++ 관용구로 바로 짤 수 있습니다.

12SECTIONS
C++23기준
14그림
0표준 동시성 큐
SQ 01

한 장 지도 — C++ 에서 무엇으로 쓰나

자바는 Stack 을 버리고 ArrayDeque 로 갔고, 파이썬은 list 와 deque 를 씁니다. C++ 의 답은 조금 다릅니다 — 스택은 대개 std::vector 그 자체, 큐는 std::queue(안은 deque), 그리고 스레드 사이 큐는 표준에 아예 없습니다. 먼저 지도를 펴고 시작합니다.

무엇을 쓸까 — 질문 세 개면 정해집니다 꺼내는 순서가 우선순위인가? 예 std::priority_queue 기본은 최대 힙 (sq05) 요소를 고쳐야 하면 → vector + 힙 함수 직접 아니오 여러 스레드가 동시에 넣고 빼나? 예 BlockingQueue (sq06) mutex + cv · 또는 라이브러리 1:1 저지연이면 → SPSC 링 (🚀 h03) 아니오 넣은 순서대로 꺼내나? (FIFO) 예 std::queue<T> 크기 고정이면 원형 버퍼 양 끝을 다 쓰면 → std::deque 직접 아니오 (LIFO) std::vector<T> push_back · back · pop_back API 를 막고 싶을 때만 → std::stack<T, std::vector<T>>
C++ 의 기본값은 스택 = vector, 큐 = std::queue(deque). 동시성 큐는 직접 만들거나 가져옵니다.

2026 권장 vs 쓰지 말 것

용도권장피할 것
스택std::vector<T> · 필요하면 std::stack<T, std::vector<T>>std::list 스택 · 손으로 짠 int st[100]; int sp;
큐std::queue<T> (= deque)vector 앞 지우기 erase(begin()) — O(n)
덱std::deque<T>앞뒤 삽입용 std::list
고정 크기 링직접 RingBuffer<T,N> · boost::circular_buffer크기 넘으면 pop_front 하는 deque(할당 반복)
우선순위std::priority_queue · ranges::push_heap정렬 상태 유지하려고 매번 sort
스레드 간mutex + condition_variable_any 큐 · moodycamel · oneTBB락 없이 std::queue 공유
비동기Asio experimental::channel · 직접 작업 큐코루틴 안에서 블로킹 pop()
자바의 Stack 같은 “버려진 클래스”는 없습니다 — 대신 C++ 에서 낡은 것은 습관입니다. C 시절의 고정 배열 + 인덱스 스택, 교과서식 std::list 큐, new 로 노드를 잇는 손수 만든 연결 리스트(🅲 c14)가 2026년 코드리뷰에서 걸리는 것들입니다.

다섯 타입을 한 번에 — 복붙해서 돌려 보기

// g++ -std=c++23 -O2 map.cpp   (clang++ · MSVC /std:c++latest 도 동일)
#include <deque>
#include <functional>
#include <print>
#include <queue>
#include <vector>

int main() {
    // ① 스택 — vector 의 뒤쪽만 씁니다
    std::vector<int> st;
    for (int x : {1, 2, 3}) st.push_back(x);
    std::println("stack top={} size={}", st.back(), st.size());
    st.pop_back();
    std::println("after pop {}", st);

    // ② 큐 — std::queue (안은 deque)
    std::queue<int> q;
    for (int x : {1, 2, 3}) q.push(x);
    std::println("queue front={} back={}", q.front(), q.back());
    q.pop();
    std::println("after pop front={}", q.front());

    // ③ 덱 — 양쪽 끝
    std::deque<int> d{2, 3};
    d.push_front(1);
    d.push_back(4);
    std::println("deque {}", d);

    // ④ 우선순위 큐 — 기본은 '최대' 힙
    std::priority_queue<int> maxq;
    std::priority_queue<int, std::vector<int>, std::greater<>> minq;
    for (int x : {5, 1, 8, 3}) { maxq.push(x); minq.push(x); }
    std::println("max top={} min top={}", maxq.top(), minq.top());
}
// 출력
// stack top=3 size=3
// after pop [1, 2]
// queue front=1 back=3
// after pop front=2
// deque [1, 2, 3, 4]
// max top=8 min top=1
std::println("{}", vector) 처럼 범위를 바로 찍는 것은 C++23 범위 포매팅입니다. 컴파일러가 조금 오래됐다면 for 로 하나씩 찍으면 됩니다.
C++ 의 스택 · 큐는 표준마다 조금씩 두꺼워졌습니다 C++98 C++11 C++17 C++20 C++23 C++26 어댑터 3종 · deque make_heap · push_heap pmr 할당자 emplace 가 참조 반환 from_range · push_range 반복자 쌍 생성자 · print emplace · 이동 의미론 mutex · condition_variable jthread · stop_token 코루틴 · ranges 힙 함수 inplace_vector std::execution 이 탭의 기준은 C++23 — C++26 항목은 컴파일러 지원을 확인하고 쓰세요
어댑터의 뼈대는 1998년 그대로이고, 바뀐 것은 넣는 방법(이동·emplace·범위)과 스레드 도구입니다.

한 단계 더 — 어댑터란 무엇인가 🟡 중급

std::stack 의 진짜 정의 — 거의 이것뿐입니다

// 표준 라이브러리 구현을 줄여 옮긴 것 (libstdc++ · libc++ · MSVC 모두 이 모양)
template <class T, class Container = std::deque<T>>
class stack {
protected:
    Container c;                 // ★ 진짜 저장소. protected 라 상속하면 보입니다
public:
    bool empty() const           { return c.empty(); }
    size_t size() const          { return c.size(); }
    T&   top()                   { return c.back(); }      // 빈 상태면 UB
    void push(const T& v)        { c.push_back(v); }
    void push(T&& v)             { c.push_back(std::move(v)); }
    template <class... A>
    decltype(auto) emplace(A&&... a) { return c.emplace_back(std::forward<A>(a)...); }
    void pop()                   { c.pop_back(); }        // ★ 반환값 없음
};
// 반복자도, clear() 도, operator[] 도 없습니다 — 일부러 '막아 둔' 것입니다
어댑터 = 기능을 빼는 래퍼 — std::stack 은 성능을 더해 주지 않습니다. “이 변수는 뒤에서만 넣고 뺀다”는 약속을 타입으로 강제하는 것이 존재 이유입니다. 팀 코드에서 의도를 드러내고 싶으면 쓰고, 순회·디버그 출력이 필요하면 vector 를 그대로 씁니다.

세 어댑터의 기본 하부 컨테이너

어댑터기본값바꿀 수 있는 것
std::stackdequevector ★ · list
std::queuedequelist (vector ✗)
std::priority_queuevectordeque (list ✗)
// 하부 컨테이너는 두 번째 템플릿 인자
std::stack<int, std::vector<int>>  s1;   // ★ 스택이면 이쪽이 대개 빠름
std::queue<int, std::list<int>>     q1;   // 가능하지만 이득 거의 없음
std::priority_queue<int, std::deque<int>> p1;   // 가능 — 쓸 일 드묾

// 이미 채운 컨테이너를 넘겨 시작 (이동)
std::vector<int> init{1, 2, 3};
std::stack<int, std::vector<int>> s2(std::move(init));   // top()==3
stack 의 기본이 왜 deque 인가 — 1998년 설계 당시 “재할당 때 전체 복사가 없다”는 장점을 골랐기 때문입니다. 지금은 이동 의미론 덕에 재할당이 싸졌고 연속 메모리의 캐시 이점이 더 큽니다. 특히 MSVC 의 deque 는 블록이 아주 작아(sq04) 스택이면 vector 를 끼우는 것이 정석입니다.
정리 — 스택은 std::vector, 큐는 std::queue(= deque), 우선순위는 std::priority_queue, 스레드 사이는 직접 만든 블로킹 큐나 검증된 라이브러리입니다. std::stack·std::queue 는 저장소가 아니라 API 를 좁히는 껍데기라는 것만 기억하면 나머지는 따라옵니다.
SQ 02

스택 — pop() 은 왜 값을 안 돌려주나

자바·파이썬에서 온 사람이 C++ 스택에서 처음 막히는 곳이 auto x = s.pop(); 입니다. 컴파일 에러가 납니다. 이것은 실수가 아니라 예외 안전성 때문에 일부러 고른 설계입니다. 그 이유를 알면 C++ 컨테이너 API 전체가 읽힙니다.

같은 일을 두 단계로 나눈 이유 — 실패했을 때 무엇이 남는가 가상의 T pop() — 값을 돌려준다면 실제 표준 — top() 읽고 pop() 지우기 ① 맨 위 요소를 임시 변수로 옮김 ① const T& x = s.top() — 읽기만 ② 컨테이너에서 제거 (size − 1) ② T v = x; — 복사/이동, 여기서 던져도 ③ 반환하며 복사 → 예외 발생! ③ 원본은 아직 스택에 그대로 요소는 이미 사라짐 — 되찾을 길 없음 ④ 성공한 뒤에만 s.pop() "꺼내기"와 "지우기"를 한 함수에 넣으면 강한 예외 보장을 줄 수 없습니다 — 그래서 STL 은 둘을 나눴고, 이동이 싸진 지금도 그대로입니다
읽기(top)와 지우기(pop)를 나눈 것이 요점입니다 — 실패해도 데이터가 사라지지 않습니다.

std::stack 전체 API — 실행해서 확인

#include <print>
#include <stack>
#include <string>
#include <vector>

int main() {
    std::stack<std::string, std::vector<std::string>> s;   // 하부를 vector 로 ★

    s.push("a");                    // 복사(여기선 임시 → 이동)
    s.emplace(3, 'b');              // string(3, 'b') == "bbb" 를 제자리 생성
    auto& t = s.emplace("c");       // C++17 부터 emplace 는 참조를 돌려줍니다
    t += "!";                       // 그 참조로 바로 수정

    std::println("size={} top={}", s.size(), s.top());

    while (!s.empty()) {            // ★ 비었는지 먼저 — 빈 top() 은 UB
        std::string v = std::move(s.top());   // ① 꺼내 오고
        s.pop();                              // ② 지운다
        std::print("{} ", v);
    }
    std::println("| empty={}", s.empty());
}
// 출력
// size=3 top=c!
// c! bbb a | empty=true
std::move(s.top()) 다음 pop() — 이것이 C++ 의 “pop 하면서 값 받기” 관용구입니다. 옮긴 뒤 껍데기만 남은 원소를 바로 지우므로 복사 비용이 0이고 안전합니다.

vector 로 쓰는 스택 — 현업에서 제일 흔한 모양

#include <optional>
#include <print>
#include <ranges>
#include <vector>

// "값을 돌려주는 pop" 이 꼭 필요하면 optional 로 직접 — 빈 경우도 표현됩니다
template <class T>
std::optional<T> pop_value(std::vector<T>& st) {
    if (st.empty()) return std::nullopt;
    std::optional<T> v{std::move(st.back())};   // 여기서 던지면 원본은 남음
    st.pop_back();                               // 성공한 뒤에만 제거
    return v;
}

int main() {
    std::vector<int> st;
    st.reserve(64);                     // 깊이를 알면 미리 — 재할당 0회
    for (int i = 1; i <= 4; ++i) st.push_back(i * 10);

    // 순회 순서: vector 는 바닥 → 꼭대기
    std::println("bottom→top {}", st);
    std::println("top→bottom {}", st | std::views::reverse);

    while (auto v = pop_value(st)) std::print("{} ", *v);
    std::println("| left={}", st.size());
    std::println("{}", pop_value(st).has_value());
}
// 출력
// bottom→top [10, 20, 30, 40]
// top→bottom [40, 30, 20, 10]
// 40 30 20 10 | left=0
// false
자바에서 온 사람에게 — 자바 Stack 은 순회하면 바닥부터, ArrayDeque 는 꼭대기부터 나와서 헷갈렸습니다. C++ 은 단순합니다 — std::stack 은 순회 자체가 안 되고, vector 는 늘 바닥 → 꼭대기입니다. 꼭대기부터 보려면 std::views::reverse.

한 단계 더 — 빈 스택 · 다른 언어와의 대응 🟡 중급

빈 스택은 예외도 null 도 아닌 UB

std::stack<int> s;
int x = s.top();   // ❌ 미정의 동작 — 예외 안 남, 쓰레기 값 또는 크래시
s.pop();           // ❌ 미정의 동작 — 내부 상태가 망가질 수 있음

// 자바: EmptyStackException / NoSuchElementException
// 파이썬: IndexError
// C++ : 아무 일도 보장하지 않음 → "확인은 호출하는 쪽 책임" ★

// 개발 중에는 표준 라이브러리 하드닝으로 잡습니다
//   libstdc++ :  -D_GLIBCXX_ASSERTIONS
//   libc++    :  -D_LIBCPP_HARDENING_MODE=_LIBCPP_HARDENING_MODE_FAST
//   MSVC      :  디버그 빌드(/MDd) 의 반복자 디버깅
// → 빈 vector 의 back() · 빈 deque 의 front() 를 즉시 abort 로 알려 줍니다
“한 번도 비지 않는다”고 확신하는 코드가 제일 위험합니다 — 입력이 이상하면 반드시 빕니다(괄호 검사에서 ) 로 시작하는 문자열). 빈 확인을 루프 조건에 묶어 두는 습관(while (!s.empty()))이 가장 싼 방어입니다.

연산 대응표

의미Java ArrayDequePython listC++ vectorC++ stack
넣기pushappendpush_back · emplace_backpush · emplace
보기peek[-1]back()top()
꺼내기pop → 값pop() → 값pop_back() → voidpop() → void
빈 확인isEmptynot stempty()empty()
비었을 때예외예외UBUB
비우기clearclearclear()s = {}
// std::stack 에는 clear() 가 없습니다 — 빈 객체를 대입
s = {};                       // 또는 std::stack<int>{}.swap(s);

// 손으로 짠 C 스타일 스택 — 2026년엔 리뷰에서 걸립니다
int st[100]; int sp = 0;      // 101번째 push = 버퍼 오버플로
st[sp++] = x;                 // 경계 검사 없음, 크기 고정
// → std::vector + reserve, 크기 상한이 계약이면 RingBuffer/inplace_vector

실무에선 이렇게 — 재귀를 명시적 스택으로 🔴 실무

깊은 트리 순회 — 스택 오버플로를 피합니다

#include <memory>
#include <print>
#include <vector>

struct Node {
    int val;
    std::vector<std::unique_ptr<Node>> kids;
    explicit Node(int v) : val(v) {}

    // ★ 소멸도 명시적 스택으로 — 기본 소멸자는 자식을 '재귀로' 지웁니다
    ~Node() {
        std::vector<std::unique_ptr<Node>> st;
        for (auto& k : kids) if (k) st.push_back(std::move(k));
        while (!st.empty()) {
            std::unique_ptr<Node> n = std::move(st.back());
            st.pop_back();
            for (auto& k : n->kids) if (k) st.push_back(std::move(k));
        }   // n 은 자식을 다 떼인 채(null 만 남음) 소멸 → 재귀 깊이 1
    }
};

// 재귀 버전은 깊이 10만이면 기본 스레드 스택(1~8MB)을 넘길 수 있습니다
long long sum_iterative(const Node& root) {
    long long total = 0;                       // long 은 Windows 에서 32비트 — 넘칩니다
    std::vector<const Node*> st{&root};      // 힙에 있는 '내 스택'
    while (!st.empty()) {
        const Node* n = st.back();
        st.pop_back();
        total += n->val;
        for (const auto& k : n->kids) st.push_back(k.get());
    }
    return total;
}

int main() {
    Node root(1);
    Node* cur = &root;
    for (int i = 2; i <= 100'000; ++i) {          // 깊이 10만짜리 일자 트리
        cur->kids.push_back(std::make_unique<Node>(i));
        cur = cur->kids.back().get();
    }
    std::println("{}", sum_iterative(root));
}   // root 소멸 — ~Node 가 반복으로 풀어서 스택 오버플로 없음
// 출력
// 5000050000
순회만 고치면 반쪽입니다 — ~Node 를 직접 쓰지 않으면 root 가 사라질 때 unique_ptr 소멸자가 재귀로 10만 단계를 내려가 Windows(1MB 스택)에서 바로 죽습니다. 깊은 연결 구조(긴 리스트·일자 트리)는 소멸도 명시적 스택으로 풉니다.

스택 깊이 · 크기 — 숫자로 감 잡기

항목대표값 (64비트)
메인 스레드 스택Linux 8MB · Windows 1MB
std::thread 기본 스택Linux 8MB · Windows 1MB
재귀 한 단계수십~수백 바이트(지역 변수에 따라)
sizeof(std::vector<T>)24바이트 (포인터 3개)
vector<const Node*> 원소8바이트
“재귀 → vector 스택” 변환은 기계적입니다 — 함수 인자를 원소로, 재귀 호출을 push_back 으로, 함수 시작을 back()+pop_back() 으로 바꿉니다. 순서가 중요하면(전위·후위) 자식을 역순으로 넣거나 “방문 표시” 플래그를 함께 넣습니다.
정리 — C++ 스택은 vector 의 뒤쪽이고, pop() 은 void 입니다. 값은 std::move(top()) 으로 받고 그다음 pop(), 비었는지는 항상 먼저 확인합니다 — 빈 스택은 예외가 아니라 UB 입니다.
SQ 03

큐(FIFO) — std::queue 와 하부 컨테이너

std::queue 는 뒤에 넣고 앞에서 뺍니다. 그래서 하부 컨테이너는 pop_front 가 있어야 하고, vector 는 끼울 수 없습니다. 자바의 offer/poll, 파이썬의 append/popleft 에 대응하는 것과, C++ 에는 “실패하면 예외” 버전이 없다는 점을 정리합니다.

어떤 어댑터에 어떤 컨테이너를 끼울 수 있나 — 진할수록 적합 vector deque list std::stack back · push_back · pop_back ✓ 권장 (연속 메모리) ✓ 기본값 ✓ 노드마다 할당 std::queue front · push_back · pop_front ✗ pop_front 없음 ✓ 기본값 · 권장 ✓ 가능 (이득 없음) std::priority_queue 임의 접근 · push_back · pop_back ✓ 기본값 · 권장 ✓ 가능 ✗ 임의 접근 없음 조건은 '멤버 함수가 있느냐' 뿐 — 없는 함수는 그 멤버를 부를 때 컴파일 에러가 납니다 그래서 queue<int, vector<int>> 는 선언까지는 되고 pop() 을 쓰는 순간 깨집니다
하부 컨테이너 규칙은 “필요한 멤버 함수가 있는가” 하나입니다 — 큐에 vector 는 못 끼웁니다.

std::queue API — 실행해서 확인

#include <print>
#include <queue>
#include <string>
#include <vector>

struct Job {
    int id;
    std::string name;
};

int main() {
    std::queue<Job> q;
    q.push({1, "resize"});
    q.emplace(2, "upload");           // Job{2,"upload"} 제자리 생성 (C++20 집합체 () 초기화)
    q.push(Job{3, "notify"});

    std::println("size={} front={} back={}", q.size(), q.front().id, q.back().id);

    while (!q.empty()) {
        Job j = std::move(q.front());  // ① 앞에서 꺼내 오고
        q.pop();                       // ② 지운다
        std::println("run #{} {}", j.id, j.name);
    }

    // C++23: 범위에서 바로 만들기 · 한꺼번에 넣기
    std::vector<int> src{7, 8, 9};
    std::queue<int> q2(std::from_range, src);
    q2.push_range(std::vector{10, 11});
    std::println("q2 front={} back={} size={}", q2.front(), q2.back(), q2.size());
}
// 출력
// size=3 front=1 back=3
// run #1 resize
// run #2 upload
// run #3 notify
// q2 front=7 back=11 size=5
std::from_range 생성자와 push_range 는 C++23 입니다(최신 GCC·Clang·MSVC 지원). q.emplace(2, "upload") 처럼 생성자 없는 집합체를 괄호로 만드는 것은 C++20 부터 됩니다. 그 전 컴파일러에서는 q.push({2, "upload"}) 로 쓰세요.

“예외로 받고 싶다” — C++ 에는 그 버전이 없습니다

동작Java QueuePython dequeC++ queue
넣기add(예외) · offer(false)appendpush · emplace
꺼내기remove(예외) · poll(null)popleft(예외)front() + pop() — 비면 UB
보기element(예외) · peek(null)[0](예외)front() — 비면 UB
// ① "특수값 반환" 이 필요하면 — optional 로 감쌉니다
template <class Q>
std::optional<typename Q::value_type> poll(Q& q) {
    if (q.empty()) return std::nullopt;
    std::optional<typename Q::value_type> v{std::move(q.front())};
    q.pop();
    return v;
}

// ② "예외" 가 필요하면 — deque 를 직접 쓰고 at() 을 씁니다
std::deque<int> d;
try {
    int x = d.at(0);            // 범위 검사 — 비었으면 std::out_of_range
} catch (const std::out_of_range& e) { /* ... */ }
queue 에는 at() 이 없습니다 — 어댑터가 막아 두었기 때문입니다. 검사된 접근이 필요하면 std::deque 를 그대로 쓰는 편이 정직합니다.

한 단계 더 — 큐로 쓰면 안 되는 것 · 써도 되는 것 🟡 중급

❌ vector 의 앞을 지우는 큐 — 꺼낼 때마다 전체를 한 칸씩 당김
std::vector<Task> q;
q.push_back(t);
Task x = std::move(q.front());
q.erase(q.begin());        // ❌ O(n) — 남은 원소 전부 이동
// n=10만이면 꺼내기 10만 번 = 원소 이동 약 50억 번

// 파이썬 list.pop(0) · JS Array.shift() 와 똑같은 실수입니다

std::queue<int, std::vector<int>> bad;
bad.push(1);               // 여기까지는 컴파일 됨
bad.pop();                 // ❌ 컴파일 에러:
// error: 'class std::vector<int>' has no member named 'pop_front'
✅ 한 번 쓰고 버리는 큐라면 — vector + 머리 인덱스
// BFS 처럼 "넣은 건 다 꺼내고 끝" 인 큐는 지울 필요가 없습니다
std::vector<int> q;
q.reserve(n);              // 최대 n 개면 할당 1번
q.push_back(start);
for (size_t head = 0; head < q.size(); ++head) {
    int u = q[head];       // ★ 꺼내기 = 인덱스 증가, O(1)
    for (int v : adj[u])
        if (!seen[v]) { seen[v] = true; q.push_back(v); }
}
// 장점: 연속 메모리 · 할당 1회 · 끝나면 q 에 방문 순서가 그대로 남음
// 단점: 꺼낸 칸의 메모리가 끝까지 남음 → 오래 사는 큐에는 쓰지 말 것

std::list 를 큐로 — 왜 이득이 없나

std::queue<int, std::list<int>> lq;   // 가능은 합니다

// push 한 번 = 노드 하나 malloc (int 4바이트에 노드 24바이트 + 할당 헤더)
// pop  한 번 = free 한 번
// 순회 = 노드마다 포인터 따라가기 → 캐시 미스

// list 가 이기는 유일한 경우: 원소를 '옮기지 않고' 다른 리스트로 넘길 때
std::list<Conn> idle, busy;
busy.splice(busy.end(), idle, idle.begin());   // O(1), 할당 없음 · 주소 유지
“주소가 절대 안 바뀌어야 하는 원소”라는 요구가 있으면 list 대신 std::deque(양 끝 삽입 시 참조 유지)나 vector<unique_ptr<T>>를 먼저 검토하세요(sq04 · sq09).

C++23 — 반복자 쌍 · 범위로 어댑터 만들기

#include <queue>
#include <ranges>
#include <stack>
#include <vector>

std::vector<int> v{1, 2, 3};

// C++23 전: 하부 컨테이너를 먼저 만들어 넘겨야 했습니다
std::queue<int> a(std::deque<int>(v.begin(), v.end()));

// C++23: 반복자 쌍을 바로 받습니다
std::queue<int> b(v.begin(), v.end());
std::stack<int> c(v.begin(), v.end());           // top()==3

// C++23: from_range · push_range · ranges::to
std::stack<int> d(std::from_range, v | std::views::reverse);   // top()==1
auto e = v | std::ranges::to<std::queue<int>>();                // front()==1
C++23 표기 기능은 GCC 15 · Clang 20 · MSVC 17.14 근처에서 대부분 들어와 있습니다. 빌드가 안 되면 -std=c++23(MSVC /std:c++latest) 을 먼저 확인하세요.
정리 — 큐는 std::queue<T>(안은 deque) 하나면 됩니다. vector 앞 지우기는 O(n), list 는 할당 비용만 늘고, 한 번 쓰고 버리는 BFS 큐는 vector + 머리 인덱스가 가장 빠릅니다. C++ 큐에는 “예외로 알려 주는” 꺼내기가 없으니 empty() 확인이나 optional 래퍼로 감쌉니다.
SQ 04

덱 — std::deque 는 원형 배열이 아닙니다

자바 ArrayDeque·러스트 VecDeque 는 원형 배열, 파이썬 deque 는 블록의 이중 연결 리스트입니다. C++ std::deque 는 다릅니다 — 고정 크기 블록 여러 개 + 그 블록을 가리키는 포인터 맵입니다. 이 구조 때문에 양 끝 삽입에서 원소 주소가 절대 안 바뀌고, 대신 블록 크기가 구현마다 극단적으로 다릅니다.

std::deque — 블록 포인터 맵 + 고정 크기 블록 (그림은 블록당 4칸) 맵 (T* 배열) · P0 P1 P2 · · A B C D E F G H start.cur → A (블록 0) 앞쪽 빈칸 2개 = push_front 자리 가운데 블록은 꽉 참 — 연속 메모리 finish.cur → H 다음 칸 뒤쪽 빈칸 2개 = push_back 자리 맵이 꽉 차면 포인터 배열만 다시 할당 — 원소는 한 개도 옮기지 않습니다 블록 크기 — 구현마다 크게 다릅니다 (64비트) libstdc++ (GCC) 512 B 블록 → int 128개 · 512 B 이상 요소는 블록당 1개 libc++ (Clang) 4096 B 블록 → int 1024개 · 256 B 이상 요소는 16개 MSVC STL 16 B 블록 → int 4개 · 8 B 넘는 요소는 블록당 1개 MSVC 의 deque<std::string> 은 사실상 '원소마다 할당' — 연결 리스트에 가깝습니다
deque 는 “짧은 배열들의 배열”입니다 — 양 끝이 O(1) 인 대가로 인덱스 접근이 간접 2번입니다.

주소가 안 바뀐다 — deque 만의 성질을 실행해서 확인

#include <deque>
#include <print>
#include <vector>

int main() {
    std::deque<int> d{10, 20, 30};
    int* p = &d[1];                       // 20 의 주소
    auto it = d.begin() + 1;              // 20 을 가리키는 반복자

    for (int i = 0; i < 10'000; ++i) {    // 양 끝에만 2만 번 삽입
        d.push_front(-i);
        d.push_back(i);
    }
    std::println("*p={} size={}", *p, d.size());
    // ✅ p 는 여전히 유효 — 양 끝 삽입은 참조·포인터를 무효화하지 않습니다
    // ❌ it 는 무효 — 양 끝 삽입이라도 '반복자'는 전부 무효 (*it 는 UB)

    std::vector<int> v{10, 20, 30};
    int* q = &v[1];
    v.push_back(40);                      // 용량 3 → 재할당 → q 는 매달린 포인터
    std::println("cap={}", v.capacity()); // q 는 절대 읽지 마세요
}
// 출력
// *p=20 size=20003
// cap=6        (libstdc++ · libc++ 는 2배 / MSVC 는 1.5배라 cap=4)
참조는 살고 반복자는 죽는 이유 — 반복자는 “지금 몇 번 블록, 맵의 어디” 를 들고 있는데 맵이 재할당되면 그 정보가 낡습니다. 원소 자체는 블록에서 움직이지 않으니 포인터·참조는 그대로입니다. 중간 insert/erase 는 둘 다 전부 무효로 만듭니다(sq10 표).

연산별 복잡도 — deque vs vector

연산dequevector
operator[]O(1) · 간접 2번O(1) · 간접 1번
push_backO(1) · 원소 이동 없음분할 상환 O(1)
push_frontO(1)— (insert(begin) O(n))
pop_frontO(1)— (erase(begin) O(n))
중간 삽입·삭제O(min(i, n−i))O(n−i)
reserve없음있음
메모리 연속성블록 안에서만전체 연속
sizeof (64비트)libstdc++ 80 · libc++ 48 · MSVC 4024
deque 의 push_back 은 “분할 상환”이 아니라 그냥 O(1) 로 규정됩니다 — 표준이 세는 것은 원소 생성 횟수이고, 맵 재할당 때 옮기는 것은 원소가 아니라 블록 포인터이기 때문입니다. libstdc++ 는 빈 deque 를 만들기만 해도 맵과 블록 하나를 할당합니다 — 빈 std::queue 를 수만 개 만드는 코드라면 기억해 두세요.

한 단계 더 — 고정 크기 원형 버퍼를 직접 🟡 중급

RingBuffer<T, 8> — 끝을 넘으면 처음으로 감깁니다 c 0 d 1 2 3 4 5 a 6 b 7 시계 방향으로 쓰고 읽습니다 head 6 읽는 쪽 tail 10 → 칸 2 쓰는 쪽 계산은 이것뿐입니다 N = 8 (2의 거듭제곱) 칸 번호 = 카운터 & (N − 1) 10 & 7 = 2 → 다음 쓰기는 칸 2 size = tail − head = 4 full: size == N empty: head == tail 카운터는 계속 증가 — 넘쳐도 부호 없는 뺄셈이라 size 는 맞음 가득 차면 두 갈래 — 거절(try_push) 또는 가장 오래된 것 덮어쓰기(push_overwrite)
원형 버퍼는 카운터 두 개와 비트 AND 하나입니다 — 할당이 없어 임베디드·게임 루프에 맞습니다.

RingBuffer<T, N> — std::array 기반 전체 구현

#include <array>
#include <cstddef>
#include <optional>
#include <print>
#include <utility>

template <class T, std::size_t N>
class RingBuffer {
    static_assert(N > 0 && (N & (N - 1)) == 0, "N 은 2의 거듭제곱");
    std::array<T, N> buf_{};               // 힙 할당 0 — 객체 안에 통째로
    std::size_t head_ = 0, tail_ = 0;     // 계속 증가하는 카운터
    static constexpr std::size_t slot(std::size_t i) { return i & (N - 1); }
public:
    bool empty() const noexcept { return head_ == tail_; }
    bool full()  const noexcept { return tail_ - head_ == N; }
    std::size_t size() const noexcept { return tail_ - head_; }

    bool try_push(T v) {                   // 가득 차면 거절
        if (full()) return false;
        buf_[slot(tail_++)] = std::move(v);
        return true;
    }
    void push_overwrite(T v) {             // 가득 차면 가장 오래된 것을 밀어냄
        if (full()) ++head_;
        buf_[slot(tail_++)] = std::move(v);
    }
    std::optional<T> try_pop() {
        if (empty()) return std::nullopt;
        return std::move(buf_[slot(head_++)]);
    }
    T& operator[](std::size_t i) { return buf_[slot(head_ + i)]; }  // 0 = 가장 오래된 것
};

int main() {
    RingBuffer<int, 4> r;
    for (int i = 1; i <= 6; ++i) r.push_overwrite(i);   // 1, 2 는 밀려남
    for (std::size_t i = 0; i < r.size(); ++i) std::print("{} ", r[i]);
    std::println("| full={}", r.full());
    std::println("try_push(7)={}", r.try_push(7));
    auto v = r.try_pop();
    std::println("pop={} size={}", *v, r.size());
}
// 출력
// 3 4 5 6 | full=true
// try_push(7)=false
// pop=3 size=3

이 구현의 선택과 한계

선택이유 · 대가
N 은 2의 거듭제곱% 대신 & — 나눗셈 없음. 카운터가 넘쳐도 맞음
카운터를 줄이지 않음“가득 vs 빔” 구분에 빈칸 하나를 버릴 필요가 없음
std::array<T,N>할당 0 · 대신 T 는 기본 생성 가능해야 하고 빈칸도 객체가 살아 있음
스레드 안전 아님스레드 간에는 🚀 h03 의 SPSC 링(원자 카운터 + 캐시라인 분리)
// 검증된 라이브러리 — Boost.CircularBuffer
#include <boost/circular_buffer.hpp>
boost::circular_buffer<int> cb(3);      // 용량 3 (실행 중 정함 · 힙 할당 1회)
cb.push_back(1); cb.push_back(2); cb.push_back(3);
cb.push_back(4);       // 가득 → 반대쪽 끝(1)이 밀려남 → [2, 3, 4]
cb.push_front(0);      // 가득 → 반대쪽 끝(4)이 밀려남 → [0, 2, 3]
cb.set_capacity(8);    // 크기 바꾸기도 됩니다

// C++26 — 고정 용량 '스택' 이면 표준이 생깁니다
// std::inplace_vector<int, 64> st;   // 객체 안에 64칸, 할당 0
// st.push_back(x);                     // 넘치면 std::bad_alloc
// st.try_push_back(x);                 // 넘치면 nullptr 반환
언제 직접, 언제 라이브러리? — 크기가 컴파일 시점 상수이고 할당이 금지(임베디드·오디오 콜백·게임 프레임)면 위 템플릿, 크기가 실행 중 정해지면 boost::circular_buffer, 고정 용량 스택이면 C++26 inplace_vector 입니다.
정리 — std::deque 는 블록 + 포인터 맵이라 양 끝 O(1) 이고 양 끝 삽입에서 참조가 유지됩니다. 대신 블록 크기가 구현마다 달라(MSVC 16 B) 성능이 들쭉날쭉합니다. 크기가 고정이면 2의 거듭제곱 원형 버퍼가 가장 싸고 예측 가능합니다.
SQ 05

우선순위 큐 — less 를 넣었는데 최대 힙인 이유

std::priority_queue 는 기본이 최대 힙입니다. 자바·파이썬·C# 이 전부 최소 힙이라 옮겨 올 때 가장 많이 틀립니다. 비교자를 읽는 법, 여러 키로 정렬하기, 요소를 고칠 수 없다는 제약과 지연 삭제, 그리고 std::ranges::push_heap 으로 vector 를 직접 힙으로 다루는 법까지 갑니다.

최대 힙 — 트리는 머릿속 그림, 실제로는 vector 한 줄 9 [0] 7 [1] 8 [2] 3 5 6 top() = 칸 0 = 가장 큰 값 push: 끝에 넣고 위로 pop: 끝을 루트로, 아래로 둘 다 O(log n) 실제 메모리 9 7 8 3 5 6 [0] [1] [2] [3] [4] [5] 자식 2i+1 · 2i+2 부모 (i − 1) / 2 형제끼리는 순서가 없습니다 — 같은 우선순위의 꺼내는 순서도 보장되지 않습니다
힙은 “부모가 자식보다 크다”만 지킵니다 — 그래서 정렬도 아니고 FIFO 도 아닙니다.

여러 키 + 같은 우선순위는 먼저 넣은 것부터

#include <print>
#include <queue>
#include <string>
#include <tuple>
#include <vector>

struct Task {
    std::string name;
    int priority;      // 클수록 먼저
    int deadline;      // 작을수록 먼저
    unsigned seq;      // 넣은 순서 — 동점이면 먼저 넣은 것 먼저 ★
};

// 비교자는 "a 가 b 보다 '뒤에' 나와야 하면 true"
struct Later {
    bool operator()(const Task& a, const Task& b) const {
        return std::tuple(-a.priority, a.deadline, a.seq)
             > std::tuple(-b.priority, b.deadline, b.seq);
    }
};

int main() {
    std::priority_queue<Task, std::vector<Task>, Later> pq;
    unsigned seq = 0;
    pq.push({"backup", 1, 50, seq++});
    pq.push({"alert",  3, 10, seq++});
    pq.push({"email",  2, 30, seq++});
    pq.push({"report", 2, 20, seq++});
    pq.push({"ping",   3, 10, seq++});   // alert 와 완전 동점 → seq 로 가름

    while (!pq.empty()) {
        const Task& t = pq.top();         // 참조는 pop 전까지만 유효
        std::print("{}({}/{}) ", t.name, t.priority, t.deadline);
        pq.pop();
    }
    std::println("");
}
// 출력
// alert(3/10) ping(3/10) report(2/20) email(2/30) backup(1/50)
seq 를 빼면 alert 와 ping 의 순서는 정해져 있지 않습니다 — 힙은 안정 정렬이 아닙니다. “같은 우선순위면 들어온 순서대로”가 요구사항이면 단조 증가 번호를 마지막 키로 넣는 것이 표준 해법입니다.

비교자 방향 — 한 표로 끝내기

원하는 것비교자
큰 값 먼저 (최대 힙)std::less<T> — 기본값
작은 값 먼저 (최소 힙)std::greater<>
점수 높은 것 먼저a.score < b.score
마감 이른 것 먼저a.deadline > b.deadline
여러 키tuple(...) > tuple(...) 로 “뒤에 나올 쪽” 판단
// 람다 비교자 — 타입은 decltype 으로
auto later = [](const Task& a, const Task& b) { return a.priority < b.priority; };

std::priority_queue<Task, std::vector<Task>, decltype(later)> p1(later);  // C++17 까지
std::priority_queue<Task, std::vector<Task>, decltype(later)> p2;         // C++20 ★
//  C++20 부터 캡처 없는 람다는 기본 생성 가능 → 인자 생략 가능

std::priority_queue p3(later, std::vector<Task>{});   // CTAD 로 타입 추론
외우는 법 — 비교자는 sort 와 같은 “작다” 관계이고, 힙은 그 순서의 마지막(가장 ‘큰’ 것)을 꼭대기에 둡니다. 그래서 less → 큰 것이 위, greater → 작은 것이 위입니다.

실무에선 이렇게 — 요소를 못 고친다 → 지연 삭제 🔴 실무

다익스트라 — decrease-key 대신 하나 더 넣기

#include <functional>
#include <limits>
#include <print>
#include <queue>
#include <utility>
#include <vector>

int main() {
    using P = std::pair<int, int>;              // (거리, 정점) — 거리 먼저 비교
    std::vector<std::vector<P>> adj(5);         // adj[u] = {(v, w), ...}
    auto edge = [&](int u, int v, int w) { adj[u].push_back({v, w}); };
    edge(0, 1, 4); edge(0, 2, 1); edge(2, 1, 2);
    edge(1, 3, 1); edge(2, 3, 5); edge(3, 4, 3);

    const int INF = std::numeric_limits<int>::max();
    std::vector<int> dist(5, INF);
    std::priority_queue<P, std::vector<P>, std::greater<>> pq;   // 최소 힙
    dist[0] = 0;
    pq.emplace(0, 0);
    int stale = 0;

    while (!pq.empty()) {
        auto [d, u] = pq.top();                 // 값으로 복사한 뒤
        pq.pop();                               // 지운다
        if (d > dist[u]) { ++stale; continue; } // ★ 낡은 항목 — 건너뜀
        for (auto [v, w] : adj[u])
            if (d + w < dist[v]) {
                dist[v] = d + w;
                pq.emplace(dist[v], v);         // 고치지 않고 새로 넣음
            }
    }
    std::println("dist={} stale={}", dist, stale);
}
// 출력
// dist=[0, 3, 1, 4, 7] stale=2
왜 고칠 수 없나 — top() 은 const& 이고 중간 원소에는 접근 자체가 안 됩니다. 값을 바꾸면 힙 조건이 깨지기 때문입니다. 낡은 항목을 남겨 두고 꺼낼 때 버리는 것(지연 삭제)이 다익스트라·타이머·작업 취소의 정석이고, 큐 크기는 최대 간선 수까지 늘 수 있습니다.

vector 를 직접 힙으로 — ranges 힙 함수로 top-k

#include <algorithm>
#include <functional>
#include <print>
#include <string>
#include <vector>

struct Player { std::string name; int score; };

int main() {
    std::vector<Player> all{{"ann", 50}, {"bob", 10}, {"cid", 90}, {"dan", 30},
                            {"eve", 70}, {"fay", 80}, {"gus", 20}};
    const std::size_t k = 3;
    std::vector<Player> heap;            // 크기 k 의 '최소' 힙 — 꼭대기가 현재 k 등
    heap.reserve(k + 1);

    for (const auto& p : all) {
        heap.push_back(p);
        std::ranges::push_heap(heap, std::greater{}, &Player::score);   // 투영 ★
        if (heap.size() > k) {
            std::ranges::pop_heap(heap, std::greater{}, &Player::score); // 최솟값을 끝으로
            heap.pop_back();                                           // 버림
        }
    }
    std::ranges::sort_heap(heap, std::greater{}, &Player::score);      // 점수 내림차순
    for (const auto& p : heap) std::print("{}:{} ", p.name, p.score);
    std::println("");
}
// 출력
// cid:90 fay:80 eve:70
ranges 힙 함수는 C++20 이고 투영(&Player::score)을 받아 비교자를 짧게 씁니다. n 개에서 상위 k 개는 O(n log k) — 전부 정렬(O(n log n))보다 싸고, 메모리도 k 개뿐입니다.

한 단계 더 — 어댑터의 벽을 넘는 법 🧬 전문가

reserve · clear · 순회 — 보호 멤버 c 를 엽니다

// ① 미리 잡은 vector 를 넘기면 용량이 그대로 이어집니다
std::vector<int> buf;
buf.reserve(1 << 16);
std::priority_queue<int> pq(std::less<int>{}, std::move(buf));

// ② 상속으로 protected 멤버 c 에 접근 — 표준이 일부러 열어 둔 문입니다
template <class T, class C = std::vector<T>,
          class Cmp = std::less<typename C::value_type>>
struct OpenPQ : std::priority_queue<T, C, Cmp> {
    using std::priority_queue<T, C, Cmp>::priority_queue;
    void reserve(std::size_t n) { this->c.reserve(n); }
    void clear() noexcept       { this->c.clear(); }
    const C& raw() const        { return this->c; }   // 힙 순서 — 정렬 아님!
};

// ③ C++23 — 한꺼번에 넣기 (하부 끝에 붙인 뒤 make_heap 으로 재구성)
pq.push_range(std::vector{5, 9, 1});

priority_queue vs 직접 힙 vs set

필요고를 것
넣고 최상위만 꺼냄priority_queue
순회 · 한꺼번에 재구성vector + ranges::make_heap (O(n))
임의 원소 삭제·수정std::multiset · 인덱스 힙(직접)
최소·최대 둘 다std::multiset (begin · prev(end))
큰 객체인덱스나 unique_ptr 를 넣기(sq09)
std::set 을 우선순위 큐로 쓰면 중복이 사라집니다 — 같은 값 두 개를 넣으면 하나만 남습니다. 꼭 써야 하면 multiset, 또는 (우선순위, 고유 id) 쌍을 키로 씁니다. 트리라서 노드마다 할당이 있고, 힙보다 보통 몇 배 느립니다.
정리 — std::priority_queue 는 기본이 최대 힙, 최소 힙은 std::greater<> 입니다. 비교자는 “a 가 뒤에 나오면 true”, 동점 순서가 필요하면 순번을 마지막 키로, 원소를 고쳐야 하면 지연 삭제입니다. 순회·재구성이 필요하면 어댑터 대신 vector + ranges 힙 함수를 직접 씁니다.
SQ 06

스레드 안전 큐 — 표준에 없으니 제대로 만듭니다

자바에는 BlockingQueue, 파이썬에는 queue.Queue, C# 에는 Channel 이 있습니다. C++ 표준 라이브러리에는 동시성 큐가 하나도 없습니다(표준화 제안은 오래 논의 중입니다). 그래서 mutex + condition_variable 블로킹 큐를 정확하게 짜는 법 — 가짜 깨어남, 배압, jthread 로 종료 — 을 익히고, 성능이 필요할 때 무엇을 가져다 쓰는지 정리합니다.

세로가 시간 — 잠든 쪽은 락을 쥐고 있지 않습니다 생산자 (jthread) BlockingQueue (cap 4) 소비자 × 2 ① pop() — 비어서 wait (락 풀고 잠듦) ② push(1) — 넣고 notify_one ③ 깨움 → 조건 다시 확인 → 꺼냄 ④ push — 4개 가득 → not_full 대기 ⑤ 소비자가 꺼냄 → 생산자 깨움 ⑥ close() — notify_all ⑦ 남은 것 비운 뒤 nullopt → 종료 실선은 호출 · 점선은 깨움(notify) — ④ 의 기다림이 곧 배압입니다
블로킹 큐의 전부는 조건 두 개(not_empty · not_full)와 닫힘 플래그입니다.

BlockingQueue<T> — 유한 용량 · 닫기 · stop_token

#include <condition_variable>
#include <cstddef>
#include <deque>
#include <mutex>
#include <optional>
#include <stop_token>

template <class T>
class BlockingQueue {
public:
    explicit BlockingQueue(std::size_t cap) : cap_(cap) {}

    // 가득이면 자리가 날 때까지 기다림(배압). 닫혔거나 중지 요청이면 false
    bool push(T v, std::stop_token st = {}) {
        std::unique_lock lk(m_);
        not_full_.wait(lk, st, [&] { return closed_ || q_.size() < cap_; });
        if (closed_ || q_.size() >= cap_) return false;   // 닫힘 또는 중지
        q_.push_back(std::move(v));
        lk.unlock();                 // 풀고 깨우면 깬 쪽이 바로 락을 잡습니다
        not_empty_.notify_one();
        return true;
    }

    // 비었으면 기다림. 닫히고 다 비었거나 중지 요청이면 nullopt
    std::optional<T> pop(std::stop_token st = {}) {
        std::unique_lock lk(m_);
        not_empty_.wait(lk, st, [&] { return closed_ || !q_.empty(); });
        if (q_.empty()) return std::nullopt;
        T v = std::move(q_.front());
        q_.pop_front();
        lk.unlock();
        not_full_.notify_one();
        return v;
    }

    bool try_push(T v) {             // 기다리지 않는 버전
        { std::lock_guard lk(m_);
          if (closed_ || q_.size() >= cap_) return false;
          q_.push_back(std::move(v)); }
        not_empty_.notify_one();
        return true;
    }

    void close() {                   // 더는 안 넣음 — 소비자는 남은 걸 비우고 끝
        { std::lock_guard lk(m_); closed_ = true; }
        not_empty_.notify_all();
        not_full_.notify_all();
    }

private:
    std::mutex m_;
    std::condition_variable_any not_empty_, not_full_;   // stop_token 을 받는 쪽
    std::deque<T> q_;
    std::size_t cap_;
    bool closed_ = false;
};

생산자 1 · 소비자 2 — 실행해서 확인

#include <atomic>
#include <print>
#include <thread>
#include <vector>
// + 위의 BlockingQueue

int main() {
    BlockingQueue<int> q(4);                 // 최대 4개 — 넘치면 생산자가 기다림
    std::atomic<long long> sum{0};
    std::atomic<int> count{0};

    std::vector<std::jthread> consumers;
    for (int i = 0; i < 2; ++i)
        consumers.emplace_back([&](std::stop_token st) {   // jthread 가 토큰을 넘겨 줌
            while (auto v = q.pop(st)) { sum += *v; ++count; }
        });

    std::jthread producer([&] {
        for (int i = 1; i <= 100; ++i) q.push(i);
        q.close();                           // 다 넣었으면 닫기 — 소비자 종료 신호
    });

    producer.join();                         // 넣기 + 닫기 끝
    consumers.clear();                       // 각 jthread: request_stop() → join()
    std::println("count={} sum={}", count.load(), sum.load());
}
// 출력 (어느 소비자가 무엇을 꺼냈는지는 매번 다르지만 합계는 같습니다)
// count=100 sum=5050
request_stop() 이 와도 남은 원소는 비워집니다 — 술어를 받는 wait(lk, st, pred) 는 중지 요청이 있어도 마지막에 술어를 한 번 더 보고 돌려주므로, 큐에 원소가 있으면 꺼냅니다. “즉시 버리고 끝”이 필요하면 pop 에서 st.stop_requested() 를 먼저 보세요.
왜 condition_variable_any 인가 — stop_token 을 받는 wait 오버로드는 _any 에만 있습니다 C++20. 토큰이 필요 없으면 조금 더 가벼운 std::condition_variable 로 바꿔도 됩니다.

실무에선 이렇게 — 틀리기 쉬운 세 가지 🔴 실무

가짜 깨어남 · 확인 후 행동 · 락 안에서 오래 일하기

// ❌ ① if 로 기다리기 — 가짜 깨어남(spurious wakeup)에 뚫림
std::unique_lock lk(m);
if (q.empty()) cv.wait(lk);     // 아무도 안 깨웠는데 깨어날 수 있음
auto v = q.front();             // 💥 빈 큐 접근 (UB)
// ✅ 술어 버전은 내부가 while 루프 — 항상 이것
cv.wait(lk, [&] { return !q.empty(); });

// ❌ ② 확인과 행동을 다른 락 구간에서 — 그 사이에 남이 가져감
if (!safe.empty()) {            // 락 잡고 확인 → 풀림
    auto v = safe.front();      // 락 잡고 접근 — 이미 비었을 수 있음
}
// ✅ "확인 + 꺼내기" 를 한 함수(try_pop / pop) 안에서 한 락으로

// ❌ ③ 락을 쥔 채 무거운 일
std::lock_guard g(m);
auto job = std::move(q.front()); q.pop_front();
job.run();                      // 이 동안 다른 스레드 전부 대기
// ✅ 꺼내는 것만 락 안에서, 실행은 락 밖에서 (위 pop 이 그 모양)

무한 큐 vs 유한 큐 · 종료 방식

선택장점위험
무한 큐생산자가 안 막힘소비가 느리면 메모리 폭주
유한 큐배압 — 생산자가 자연히 느려짐교착 주의(서로 기다리기)
close()소비자 수와 무관 · 남은 것 처리닫은 뒤 push 는 실패 처리 필요
독약(poison pill)큐 구현 그대로소비자 수만큼 넣어야 함
stop_token기다리는 중에도 즉시 깨어남남은 원소 정책을 정해야 함
// 독약 방식 — T 를 optional 로 감싸 '빈 값' 을 종료 신호로
BlockingQueue<std::optional<Job>> jq(256);
for (int i = 0; i < n_workers; ++i) jq.push(std::nullopt);   // 일꾼 수만큼 ★
// 일꾼:  while (auto item = jq.pop()) { if (!*item) break; run(**item); }

한 단계 더 — 가져다 쓰는 동시성 큐 🧬 전문가

라이브러리 성격 비교 (내부 구현은 🚀 h03)

라이브러리성격
moodycamel::ConcurrentQueue락프리 MPMC · 무한 · 헤더만 · 생산자별 순서만 보장
moodycamel::BlockingConcurrentQueue위 + wait_dequeue(타임아웃) 블로킹
boost::lockfree::queueMPMC · 고정 용량 가능 · T 는 trivial 대입·소멸자
boost::lockfree::spsc_queue1:1 전용 링 · 가장 단순·빠름
tbb::concurrent_queue무한 · try_pop 만 (기다리지 않음)
tbb::concurrent_bounded_queue용량 · 블로킹 pop · abort()
tbb::concurrent_priority_queue스레드 안전 우선순위 큐

moodycamel 사용 모양 · 고르는 기준

#include "blockingconcurrentqueue.h"   // vcpkg: concurrentqueue

moodycamel::BlockingConcurrentQueue<int> q;
q.enqueue(42);                                   // 생산자 (여럿 가능)

int v;
if (q.wait_dequeue_timed(v, std::chrono::milliseconds(100)))   // 소비자
    handle(v);

// 생산자가 하나뿐이면 토큰으로 더 빠르게
moodycamel::ProducerToken tok(q);
q.enqueue(tok, 7);
“락프리 = 무조건 빠름”이 아닙니다 — 초당 수십만 건 이하라면 위 BlockingQueue 로 충분하고 디버깅·종료 처리가 훨씬 쉽습니다. 여러 생산자 사이의 전역 FIFO 가 필요하면 moodycamel 은 맞지 않습니다(생산자별 순서만 지킴). 먼저 뮤텍스 버전으로 재 보고, 경합이 병목으로 측정될 때 바꾸세요.
정리 — 표준엔 동시성 큐가 없으니 mutex + condition_variable_any + deque 로 만듭니다. 술어 있는 wait(가짜 깨어남), 유한 용량(배압), close() 나 stop_token(종료) 세 가지가 핵심이고, 측정해서 부족할 때만 moodycamel·oneTBB·Boost.Lockfree 로 갑니다.
SQ 07

비동기 · 채널 — 표준 채널은 아직 없습니다

코틀린 Channel, C# System.Threading.Channels, 파이썬 asyncio.Queue, 러스트 tokio::sync::mpsc 에 해당하는 것이 C++ 표준에는 없습니다. C++20 이 준 것은 코루틴 문법뿐이고 스케줄러·채널은 라이브러리 몫입니다. 현업에서 쓰는 Asio 채널과, 의존성 없이 만드는 단일 스레드 작업 큐를 봅니다.

Asio 채널 (버퍼 2) — 스레드 하나가 두 코루틴을 번갈아 돌립니다 (순서는 예시) 생산자 코루틴 send 1 · send 2 중단 — 버퍼 가득 send 3 · close 소비자 코루틴 아직 차례 아님 recv 1 · recv 2 중단 — 빔 recv 3 · 닫힘 → 끝 OS 스레드 1개 io_context.run() — 중단된 코루틴은 스레드를 붙잡지 않습니다 시간 → co_await 에서 멈춘 쪽은 '대기 목록'에 올라갈 뿐 — 스레드가 잠들지 않습니다 그래서 코루틴 안에서 BlockingQueue::pop() 을 부르면 스레드 전체가 멈춥니다
채널은 “기다림을 스레드 대신 코루틴이 하게 만든 큐”입니다 — 스레드 하나로 수천 개가 기다릴 수 있습니다.

Boost.Asio 채널 — 생산자 · 소비자 코루틴

// 최근 Boost · -std=c++20 이상
#include <boost/asio.hpp>
#include <boost/asio/experimental/channel.hpp>
#include <print>

namespace asio = boost::asio;
using Chan = asio::experimental::channel<void(boost::system::error_code, int)>;

asio::awaitable<void> producer(Chan& ch) {
    for (int i = 1; i <= 3; ++i)          // 버퍼가 차면 여기서 '중단' (스레드는 안 멈춤)
        co_await ch.async_send(boost::system::error_code{}, i, asio::use_awaitable);
    ch.close();                           // 받는 쪽에 '끝' 을 알림
}

asio::awaitable<void> consumer(Chan& ch) {
    for (;;) {
        auto [ec, v] = co_await ch.async_receive(asio::as_tuple(asio::use_awaitable));
        if (ec) { std::println("closed"); co_return; }   // 닫히고 비었음
        std::println("got {}", v);
    }
}

int main() {
    asio::io_context io;                  // 이벤트 루프 — 스레드 하나
    Chan ch(io, 2);                       // 버퍼 2칸 (0 이면 랑데부)
    asio::co_spawn(io, producer(ch), asio::detached);
    asio::co_spawn(io, consumer(ch), asio::detached);
    io.run();
}
// 출력
// got 1
// got 2
// got 3
// closed
이름에 experimental 이 붙어 있습니다 — 오랫동안 안정적으로 쓰였지만 Boost 버전에 따라 헤더·이름이 바뀔 수 있습니다. 여러 스레드가 같은 채널을 쓰면 concurrent_channel 을 씁니다.

의존성 없이 — 단일 스레드 작업 큐(이벤트 루프)

#include <deque>
#include <functional>
#include <memory>
#include <print>

class EventLoop {
    std::deque<std::move_only_function<void()>> jobs_;   // C++23
public:
    void post(std::move_only_function<void()> f) { jobs_.push_back(std::move(f)); }
    void run() {
        while (!jobs_.empty()) {
            auto job = std::move(jobs_.front());   // ① 꺼내 오고
            jobs_.pop_front();                     // ② 지운 뒤
            job();                                 // ③ 실행 — 안에서 post 해도 안전
        }
    }
};

int main() {
    EventLoop loop;
    auto data = std::make_unique<int>(42);
    loop.post([&loop, p = std::move(data)] {      // unique_ptr 캡처 → std::function 불가
        std::println("A {}", *p);
        loop.post([] { std::println("C (A 가 넣음)"); });
    });
    loop.post([] { std::println("B"); });
    loop.run();
}
// 출력
// A 42
// B
// C (A 가 넣음)
실행 전에 꺼내 두는 것이 요점입니다 — jobs_.front()() 로 바로 부르면, 작업이 실행 도중 큐를 비우거나(clear) run() 을 다시 부르는 순간 실행 중인 자기 자신이 소멸합니다. 꺼내 둔 지역 변수는 큐가 어떻게 바뀌든 끝까지 살아 있습니다. std::move_only_function C++23 이 없는 환경이면 std::function 으로 바꾸고 캡처를 복사 가능하게 합니다.

한 단계 더 — 언어별 비동기 큐 대응과 함정 🟡 중급

비동기 큐 — 어디에 무엇이 있나

언어표준 · 사실상 표준
KotlinChannel (kotlinx.coroutines)
C#System.Threading.Channels (표준)
Pythonasyncio.Queue (표준)
Rusttokio::sync::mpsc (생태계 표준)
Gochan (언어 내장)
C++표준 없음 — Asio experimental::channel · Boost.Cobalt channel · 직접 구현
C++26 의 std::execution(senders/receivers) 은 비동기 작업을 조합하는 틀이지 채널이 아닙니다. 표준 채널이 생기기 전까지는 이미 쓰는 이벤트 루프(Asio · Qt · 게임 엔진)의 큐를 따르는 것이 가장 덜 위험합니다.

코루틴 안에서 블로킹 큐를 부르면

❌ 이벤트 루프 스레드를 통째로 재움
asio::awaitable<void> handler(BlockingQueue<Job>& q) {
    auto job = q.pop();     // 스레드가 cv.wait 에서 잠듦
    // → 같은 io_context 의 다른 코루틴·소켓 전부 멈춤
}
✅ 기다림은 co_await 로 — 스레드는 계속 돈다
asio::awaitable<void> handler(Chan& ch) {
    auto [ec, job] = co_await ch.async_receive(
        asio::as_tuple(asio::use_awaitable));
}
// 스레드 풀 결과를 코루틴으로 넘길 때는
// asio::post(io, ...) 로 '루프에 일 넣기'
정리 — C++ 표준에는 비동기 채널이 없습니다. 코루틴이면 Asio experimental::channel(멀티 스레드는 concurrent_channel), 단일 스레드 이벤트 루프면 deque<move_only_function> 작업 큐로 충분합니다. 코루틴 안에서 블로킹 pop() 은 금지 — 기다림은 항상 co_await 로 합니다.
SQ 08

알고리즘 패턴 — C++ 관용구로 다섯 개

괄호 검사 · 단조 스택 · BFS · 슬라이딩 윈도우 최댓값 · 0-1 BFS 는 코딩 테스트와 실무 양쪽에서 반복해 나옵니다. 이론(CS 가이드 s03·s09)은 넘어가고, 여기서는 C++ 로 쓸 때의 타입 선택과 관용구 — vector 스택, 인덱스를 넣는 덱, 구조적 바인딩, 넣을 때 방문 표시 — 에 집중합니다. 전부 복붙해서 돕니다.

a = [5, 3, 4, 1, 2, 6] · k = 3 — 덱의 앞은 언제나 창 안의 최댓값 i x 덱 (앞 → 뒤) 이번에 버린 것 최댓값 0 5 5 — 창이 덜 참 1 3 5 3 — (3 은 5 보다 작지만 5 가 먼저 나갈 수 있어 보관) 창이 덜 참 2 4 5 4 뒤에서 3 — 4 가 더 크고 더 늦게 나감 5 3 1 4 1 앞에서 5 — 인덱스 0 이 창 [1, 3] 밖 4 4 2 4 2 뒤에서 1 4 5 6 6 뒤에서 2, 4 — 6 이 전부 이김 6 [5, 4, 4, 6] 각 원소는 덱에 한 번 들어가고 한 번 나옵니다 — 전체 O(n) 뒤에서 버리는 것은 '나보다 작고 먼저 나갈' 값 · 앞에서 버리는 것은 창 밖으로 나간 값
단조 덱 — 뒤에서는 쓸모없어진 값을, 앞에서는 만료된 값을 버리면 앞이 곧 답입니다.

① 괄호 검사 — vector<char> 스택

#include <print>
#include <string_view>
#include <vector>

bool balanced(std::string_view s) {
    std::vector<char> st;
    for (char c : s) {
        switch (c) {
            case '(': st.push_back(')'); break;   // ★ 닫는 짝을 미리 넣어 두면
            case '[': st.push_back(']'); break;   //   비교가 한 줄로 끝남
            case '{': st.push_back('}'); break;
            case ')': case ']': case '}':
                if (st.empty() || st.back() != c) return false;  // 빈 확인 먼저
                st.pop_back();
                break;
            default: break;                       // 다른 글자는 무시
        }
    }
    return st.empty();                            // 안 닫힌 게 남으면 실패
}

int main() {
    for (std::string_view s : {"([]{})", "([)]", "((", "a[b(c)]d", ")("})
        std::println("{:10} {}", s, balanced(s));
}
// 출력
// ([]{})     true
// ([)]       false
// ((         false
// a[b(c)]d   true
// )(         false

② 단조 스택 — 다음 큰 수

#include <print>
#include <vector>

// res[i] = i 오른쪽에서 처음 나오는 a[i] 보다 큰 값 (없으면 -1)
std::vector<int> next_greater(const std::vector<int>& a) {
    std::vector<int> res(a.size(), -1);
    std::vector<std::size_t> st;          // 답을 아직 못 찾은 '인덱스' — 값은 내림차순
    st.reserve(a.size());
    for (std::size_t i = 0; i < a.size(); ++i) {
        while (!st.empty() && a[st.back()] < a[i]) {
            res[st.back()] = a[i];        // a[i] 가 그들의 '다음 큰 수'
            st.pop_back();
        }
        st.push_back(i);
    }
    return res;                           // 남은 인덱스는 -1 그대로
}

int main() {
    std::println("{}", next_greater({2, 1, 2, 4, 3}));
}
// 출력
// [4, 2, 4, -1, -1]
스택에 값이 아니라 인덱스를 넣는 것이 C++ 관용구입니다 — 결과를 그 자리에 써야 하고, 값은 a[i] 로 언제든 볼 수 있습니다. 주식 스팬·히스토그램 최대 직사각형·빗물 받기가 전부 이 틀입니다.

③ 격자 BFS — std::queue + 넣을 때 방문 표시

#include <array>
#include <print>
#include <queue>
#include <string>
#include <utility>
#include <vector>

int main() {
    const std::vector<std::string> g{"S..#", "##.#", "....", "#.#G"};
    const int R = 4, C = 4;
    std::vector dist(R, std::vector<int>(C, -1));        // -1 = 아직 안 감
    constexpr std::array<std::pair<int, int>, 4> dirs{{{1, 0}, {-1, 0}, {0, 1}, {0, -1}}};

    std::queue<std::pair<int, int>> q;
    dist[0][0] = 0;
    q.push({0, 0});
    while (!q.empty()) {
        auto [r, c] = q.front();                         // 복사해 두고
        q.pop();                                         // 지운다
        for (auto [dr, dc] : dirs) {
            int nr = r + dr, nc = c + dc;
            if (nr < 0 || nr >= R || nc < 0 || nc >= C) continue;
            if (g[nr][nc] == '#' || dist[nr][nc] != -1) continue;
            dist[nr][nc] = dist[r][c] + 1;               // ★ 넣을 때 표시 → 중복 삽입 없음
            q.push({nr, nc});
        }
    }
    std::println("S→G = {}", dist[3][3]);
    for (const auto& row : dist) std::println("{}", row);
}
// 출력
// S→G = 6
// [0, 1, 2, -1]
// [-1, -1, 3, -1]
// [6, 5, 4, 5]
// [-1, 6, -1, 6]

④ 슬라이딩 윈도우 최댓값 — 인덱스를 넣는 deque

#include <deque>
#include <print>
#include <vector>

std::vector<int> window_max(const std::vector<int>& a, std::size_t k) {
    std::deque<std::size_t> dq;           // 인덱스 — 값은 앞 → 뒤 내림차순 유지
    std::vector<int> out;
    for (std::size_t i = 0; i < a.size(); ++i) {
        while (!dq.empty() && a[dq.back()] <= a[i])
            dq.pop_back();                // 나보다 작으면 영영 최대가 못 됨
        dq.push_back(i);
        if (dq.front() + k <= i)          // 창 밖 (i - k 는 부호 없는 뺄셈이라 피함)
            dq.pop_front();
        if (i + 1 >= k) out.push_back(a[dq.front()]);
    }
    return out;
}

int main() {
    std::println("{}", window_max({5, 3, 4, 1, 2, 6}, 3));
    std::println("{}", window_max({1, 3, -1, -3, 5, 3, 6, 7}, 3));
}
// 출력
// [5, 4, 4, 6]
// [3, 3, 5, 5, 6, 7]
dq.front() <= i - k 로 쓰면 안 됩니다 — i < k 일 때 size_t 뺄셈이 거대한 수로 감겨 조건이 참이 되고, 아직 창 안인 원소를 버립니다. 뺄셈을 덧셈으로 옮기는 습관이 부호 없는 인덱스의 기본기입니다.

실무에선 이렇게 — 0-1 BFS · 덱의 양 끝을 다 쓰는 알고리즘 🔴 실무

⑤ 벽 부수기 최소 횟수 — 비용 0 은 앞에, 1 은 뒤에

#include <array>
#include <deque>
#include <print>
#include <string>
#include <utility>
#include <vector>

int main() {
    const std::vector<std::string> g{"S.#.", "###.", "..##", ".#.G"};
    const int R = 4, C = 4, INF = 1'000'000;
    std::vector dist(R, std::vector<int>(C, INF));
    constexpr std::array<std::pair<int, int>, 4> dirs{{{1, 0}, {-1, 0}, {0, 1}, {0, -1}}};

    std::deque<std::pair<int, int>> dq;
    dist[0][0] = 0;
    dq.push_back({0, 0});
    while (!dq.empty()) {
        auto [r, c] = dq.front();
        dq.pop_front();
        for (auto [dr, dc] : dirs) {
            int nr = r + dr, nc = c + dc;
            if (nr < 0 || nr >= R || nc < 0 || nc >= C) continue;
            int w = (g[nr][nc] == '#') ? 1 : 0;           // 벽을 부수면 1
            if (dist[r][c] + w < dist[nr][nc]) {
                dist[nr][nc] = dist[r][c] + w;
                if (w == 0) dq.push_front({nr, nc});      // ★ 비용 0 → 앞
                else        dq.push_back({nr, nc});       // ★ 비용 1 → 뒤
            }
        }
    }
    std::println("부순 벽 최소 = {}", dist[3][3]);
}
// 출력
// 부순 벽 최소 = 2
간선 가중치가 0 과 1 뿐이면 다익스트라의 힙이 필요 없습니다 — 덱의 앞은 “현재 거리”, 뒤는 “현재 + 1” 이 되어 덱이 저절로 정렬 상태를 유지합니다. O(V + E) 로, priority_queue 판(O(E log V))보다 빠릅니다.

패턴 → 타입 대응표

패턴C++ 타입핵심
괄호 · 수식 파싱vector<char>닫는 짝을 넣기
다음 큰/작은 수vector<size_t>인덱스를 넣기
DFS (재귀 대체)vector<Node*>sq02 참고
BFSqueue<pair> · vector+머리넣을 때 방문 표시
윈도우 최댓값deque<size_t>뒤로 버리고 앞으로 만료
0-1 BFSdeque<pair>0 은 앞 · 1 은 뒤
다익스트라priority_queue + greater지연 삭제 (sq05)
위상 정렬queue<int>진입 차수 0 부터
BFS 에서 “꺼낼 때” 방문 표시를 하면 같은 칸이 큐에 여러 번 들어가 최악에 큐가 간선 수만큼 커집니다. 결과는 맞아도 메모리·시간이 몇 배가 되므로, 항상 넣는 순간에 표시합니다.
정리 — 스택 패턴은 vector 에 인덱스, BFS 는 std::queue + 넣을 때 표시, 윈도우 최댓값과 0-1 BFS 는 std::deque 의 양 끝을 씁니다. 부호 없는 인덱스 뺄셈은 덧셈으로 옮겨 감김을 피합니다.
SQ 09

성능과 메모리 — 복잡도가 같아도 열 배 차이

vector·deque·list 는 모두 “끝에 넣고 빼기 O(1)” 입니다. 그런데 실제 시간은 열 배 넘게 벌어집니다. 차이를 만드는 것은 할당 횟수와 캐시 지역성입니다. 여기에 이동 의미론·emplace·큰 객체 저장법·pmr 할당자까지 묶어 “같은 코드를 싸게 만드는 법”을 정리합니다.

int 를 스택처럼 1천만 번 push/pop — vector = 1 로 놓은 상대 시간 (예시값) 0 2× 4× 6× 8× 10× RingBuffer (std::array) ≈ 0.9× · 할당 0 std::vector 1× · 처음 몇 번만 재할당 deque (libstdc++ · libc++) ≈ 1.8× · 블록 경계마다 할당·해제 deque (MSVC) ≈ 4× · int 4개마다 블록 하나 std::list ≈ 12× · 원소마다 malloc · free 수치는 예시 — 컴파일러·할당기·CPU 마다 다르니 아래 코드로 직접 재세요
복잡도는 같아도 할당 횟수가 순위를 정합니다 — 연결 리스트는 거의 언제나 꼴찌입니다.

직접 재 보기 — 의존성 없는 측정 코드

// g++ -std=c++23 -O2 bench.cpp && ./a.out
#include <chrono>
#include <deque>
#include <list>
#include <print>
#include <vector>

template <class C>
double bench_stack(int n) {
    auto t0 = std::chrono::steady_clock::now();
    C c;
    long long sum = 0;
    for (int round = 0; round < 10; ++round) {      // 10 × n 번 push/pop
        for (int i = 0; i < n; ++i) c.push_back(i);
        while (!c.empty()) { sum += c.back(); c.pop_back(); }
    }
    auto t1 = std::chrono::steady_clock::now();
    if (sum == 42) std::println("?");               // 최적화로 통째 삭제되는 것 방지
    return std::chrono::duration<double, std::milli>(t1 - t0).count();
}

int main() {
    const int n = 1'000'000;
    std::println("vector {:7.1f} ms", bench_stack<std::vector<int>>(n));
    std::println("deque  {:7.1f} ms", bench_stack<std::deque<int>>(n));
    std::println("list   {:7.1f} ms", bench_stack<std::list<int>>(n));
}
// 출력 예 (x86-64 · -O2 · libstdc++ — 예시값, 기계마다 다릅니다)
// vector     9.8 ms
// deque     17.6 ms
// list     118.4 ms
측정의 함정 — -O0(디버그)로 재면 순위가 뒤집히기도 합니다. 결과를 쓰지 않으면 컴파일러가 루프를 지웁니다(위의 sum). 제대로 하려면 Google Benchmark(⚡ e01)로 반복·분산까지 봅니다.

원소 하나가 실제로 차지하는 메모리 (int, 64비트)

컨테이너원소당덤
vector4 B남는 용량 (최대 약 1배)
deque4 B양 끝 블록의 빈칸 + 맵
list노드 24 B할당기 단위로 보통 32 B — 8배
RingBuffer<int,N>4 B × N 고정없음 · 객체 안에 통째로
priority_queue4 B (vector)vector 와 같음
캐시 한 줄(64 B)에 int 16개가 들어갑니다. vector 는 한 번 메모리를 읽으면 다음 15개가 공짜이고, list 는 노드마다 다른 곳에 있어 원소마다 캐시 미스가 날 수 있습니다. 자바의 ArrayDeque<Integer> 가 박싱 때문에 원소마다 객체를 갖는 것과 비교하면 C++ 은 값이 컨테이너 안에 그대로 놓인다는 것이 가장 큰 이점입니다.

한 단계 더 — 이동 · emplace · 큰 객체 🟡 중급

복사 · 이동 · 제자리 생성

std::queue<std::string> q;
std::string s(1000, 'x');

q.push(s);               // 복사 — 1000 바이트 새로 할당
q.push(std::move(s));    // 이동 — 포인터 몇 개만 옮김. s 는 '유효하지만 미지정'
q.emplace(1000, 'y');    // 제자리 생성 — 임시 객체조차 없음

// 꺼낼 때도 이동
std::string got = std::move(q.front());
q.pop();

// 이동 생성자가 noexcept 가 아니면 vector 가 재할당 때 '복사'로 후퇴 (🧱 m01)
struct Msg {
    std::vector<std::byte> body;
    std::string topic;
};                        // ★ 아무것도 안 쓰면 이동이 자동으로 noexcept
static_assert(std::is_nothrow_move_constructible_v<Msg>);

큰 객체를 힙에 — 무엇을 넣을까

struct Big { std::array<std::byte, 4096> payload; int pri; };

// ❌ priority_queue<Big, ..> — push/pop 마다 log n 번 × 4 KB 이동
// ✅ ① (우선순위, 인덱스) 만 힙에 — 16 바이트만 움직임
std::vector<Big> store;
std::priority_queue<std::pair<int, std::size_t>> pq;
store.push_back(make_big());
pq.emplace(store.back().pri, store.size() - 1);

// ✅ ② unique_ptr 를 넣기 — 8 바이트 이동, 대신 할당 1회 + 간접 참조
struct ByPri {
    bool operator()(const std::unique_ptr<Big>& a,
                    const std::unique_ptr<Big>& b) const { return a->pri < b->pri; }
};
std::priority_queue<std::unique_ptr<Big>,
                    std::vector<std::unique_ptr<Big>>, ByPri> pq2;
// top() 이 const& 라 unique_ptr 를 옮겨 꺼낼 수 없음 → 꺼낼 때는 const_cast 대신
// ①의 인덱스 방식이나 vector + ranges::pop_heap 을 쓰는 편이 깔끔합니다
priority_queue<unique_ptr> 의 함정 — top() 이 const& 를 돌려주므로 std::move(pq.top()) 는 복사 시도 → 컴파일 에러입니다. 이동해서 꺼내야 하는 원소라면 vector + ranges::pop_heap 후 std::move(v.back()) 가 정석입니다.

실무에선 이렇게 — pmr 로 할당을 없애기 🔴 실무

프레임마다 쓰고 버리는 BFS 큐 — 할당 0회

#include <array>
#include <cstddef>
#include <deque>
#include <memory_resource>
#include <queue>
#include <vector>

int reachable(const std::vector<std::vector<int>>& adj, int start) {
    std::array<std::byte, 64 * 1024> arena;               // 스택 위 64 KB
    std::pmr::monotonic_buffer_resource pool{arena.data(), arena.size()};

    std::queue<int, std::pmr::deque<int>> q{std::pmr::deque<int>{&pool}};
    std::pmr::vector<char> seen(adj.size(), 0, &pool);

    int count = 0;
    seen[start] = 1;
    q.push(start);
    while (!q.empty()) {
        int u = q.front();
        q.pop();
        ++count;
        for (int v : adj[u])
            if (!seen[v]) { seen[v] = 1; q.push(v); }
    }
    return count;
}   // pool 소멸 = 한꺼번에 반환. 64 KB 를 넘을 때만 upstream(new) 호출
monotonic_buffer_resource 는 해제를 무시합니다 — pop_front 가 블록을 돌려줘도 재사용하지 않으므로 오래 사는 큐에 쓰면 계속 자랍니다. 함수·프레임 단위로 쓰고 버릴 때만 쓰고, 오래 사는 큐는 unsynchronized_pool_resource(🚀 h04)를 씁니다.

비용을 줄이는 순서

#할 일효과
1스택은 vector · reserve재할당 0 · 연속 메모리
2push(std::move(x)) · emplace복사 0
3큰 원소는 인덱스 · unique_ptr힙 재정렬 비용 ↓
4크기 고정이면 RingBuffer할당 0 · 예측 가능
5수명이 짧으면 pmr::monotonic할당이 포인터 증가 한 번
6MSVC 에서 deque 가 병목이면 교체작은 블록 문제 회피
“list 가 중간 삽입에 강하다”는 이 탭의 용도(양 끝)에는 해당이 없습니다. 스택·큐·덱에서 list 를 고를 이유는 splice 와 주소 고정 두 가지뿐이고, 그것도 대개 deque 나 vector<unique_ptr> 로 해결됩니다(🧱 m05 · ⚡ e02).
정리 — 같은 O(1) 이라도 할당이 없는 쪽이 이깁니다 — RingBuffer ≈ vector < deque ≪ list. 원소는 이동·emplace 로 넣고, 큰 객체는 인덱스나 포인터를 힙에 넣고, 짧게 쓰고 버리는 큐는 pmr::monotonic_buffer_resource 로 할당을 없앱니다. 수치는 반드시 직접 재서 확인합니다.
SQ 10

흔한 실수 모음 — 컴파일은 되는데 터지는 것들

C++ 스택·큐의 사고는 대부분 컴파일러가 잡아 주지 않는 곳에서 납니다 — 빈 컨테이너 접근, pop 뒤에 남은 참조, 반복자 무효화, 비교자 방향, 이미 사라진 객체를 가리키는 원소. 실제 코드리뷰에서 자주 나오는 순서대로 나쁜 예와 고친 예를 나란히 둡니다.

이 연산을 하면 들고 있던 반복자 · 참조가 어떻게 되나 — 진할수록 위험 push_back 앞에 넣기 앞에서 지우기 중간 삽입 std::vector 재할당이면 전부아니면 end() 만 insert(begin)뒤쪽 전부 · O(n) erase(begin)지운 곳부터 뒤 전부 그 뒤 전부재할당이면 전부 std::deque 반복자 전부참조는 유지 반복자 전부참조는 유지 지운 것만pop_front 전부반복자 · 참조 모두 std::list 없음노드 고정 없음노드 고정 지운 것만pop_front 없음대신 할당 1회 deque 는 '참조는 살고 반복자는 죽는' 유일한 칸이 있습니다 — 그래서 가장 많이 헷갈립니다 무효화된 것을 쓰는 것은 예외가 아니라 UB — 테스트에서 우연히 통과하기 쉽습니다
무효화 규칙은 “원소가 움직이는가”로 외웁니다 — vector 는 움직이고, list 는 안 움직이고, deque 는 원소는 안 움직이지만 지도(맵)가 움직입니다.
❌ ① pop 한 뒤에 front() 의 참조를 씀
while (!q.empty()) {
    const Job& j = q.front();   // 참조
    q.pop();                    // 원소 소멸 → j 는 매달림
    run(j);                     // 💥 UB — 가끔은 '잘 돌아서' 더 위험
}
✅ 옮겨 받은 뒤에 지운다
while (!q.empty()) {
    Job j = std::move(q.front());   // 값으로 받기
    q.pop();
    run(j);                         // ✅ j 는 내 것
}
❌ ② vector 스택의 back() 참조를 쥔 채 push
std::vector<Frame> st;
st.push_back(root);
Frame& cur = st.back();
st.push_back(child);          // 재할당되면 cur 는 옛 메모리
cur.visited = true;           // 💥 해제된 메모리에 쓰기
✅ 인덱스로 들고 있거나, push 뒤에 다시 얻는다
std::size_t cur = st.size() - 1;   // 인덱스는 재할당과 무관
st.push_back(child);
st[cur].visited = true;           // ✅
// 또는: 처리할 것을 먼저 다 하고 마지막에 push
❌ ③ 최소 힙을 원했는데 비교자를 거꾸로
// "마감이 이른 것부터" 라고 생각하며
auto cmp = [](const Task& a, const Task& b) {
    return a.deadline < b.deadline;    // sort 처럼 썼음
};
std::priority_queue<Task, std::vector<Task>, decltype(cmp)> pq;
// → 마감이 '가장 늦은' 것이 top() 에 옴
✅ “a 가 뒤에 나와야 하면 true”
auto later = [](const Task& a, const Task& b) {
    return a.deadline > b.deadline;    // a 가 늦으면 a 는 뒤로
};
std::priority_queue<Task, std::vector<Task>, decltype(later)> pq;
// → 마감이 가장 이른 것이 top() ✅  (이름을 'later' 로 지으면 덜 헷갈림)
❌ ④ 큐에 '빌린 것'을 넣음 — 원본이 먼저 죽는다
std::queue<std::string_view> names;
void add(int id) {
    std::string s = "user-" + std::to_string(id);
    names.push(s);              // s 는 함수 끝에서 소멸
}                               // → 큐 안의 view 는 전부 매달림

std::deque<std::function<void()>> jobs;
void schedule(Request& req) {
    jobs.push_back([&] { handle(req); });   // req 수명 < 작업 수명이면 💥
}
✅ 큐에는 소유하는 값 — 또는 수명을 같이 넘긴다
std::queue<std::string> names;           // 값으로 소유
names.push("user-" + std::to_string(id));

void schedule(std::shared_ptr<Request> req) {
    jobs.push_back([req] { handle(*req); });  // 작업이 수명을 함께 쥠
}
// unique_ptr 를 넘기려면 move_only_function (sq07)

한 단계 더 — 순회 중 수정 · 힙 원소 고치기 · 그 밖 🟡 중급

범위 for 안에서 같은 deque 에 넣기

std::deque<int> work{1, 2, 3};

// ❌ 범위 for 는 반복자를 씁니다 — push_back 이 그 반복자를 무효화
for (int x : work)
    if (x < 3) work.push_back(x * 10);     // 💥 UB

// ✅ 인덱스로 돌면 deque 의 operator[] 는 언제나 유효
for (std::size_t i = 0; i < work.size(); ++i)
    if (work[i] < 3) work.push_back(work[i] * 10);
// work == [1, 2, 3, 10, 20]

// ✅ 더 흔한 정답 — 그건 사실 '큐' 입니다
std::queue<int> q(std::from_range, std::vector{1, 2, 3});   // C++23
work[i] 를 읽은 값을 먼저 계산해서 push_back 에 넘기므로 안전합니다. work[i] 의 참조를 쥔 채 push_back 하는 것도 deque 에서는 괜찮지만(참조 유지), vector 였다면 ② 와 같은 사고입니다.

실수 체크리스트

실수증상 · 고치기
빈 상태에서 top()·front()·pop()UB · empty() 를 루프 조건에
auto x = s.pop();컴파일 에러 · top() 후 pop()
pop 뒤 참조 사용UB · 값으로 옮겨 받기
const_cast 로 top() 수정힙 순서 깨짐 · 지연 삭제(sq05)
비교자 방향순서 반대 · “뒤에 나올 쪽이 true”
queue<T, vector<T>>pop() 에서 컴파일 에러
스레드 간 락 없이 공유데이터 경쟁 · sq06 의 큐
확인과 꺼내기를 따로 잠금경쟁 · 한 함수 한 락
BFS 에서 꺼낼 때 방문 표시중복 삽입 · 넣을 때 표시
size_t 뺄셈 비교감김 · 덧셈으로 옮기기
정리 — 사고의 대부분은 “이미 사라졌거나 움직인 원소를 가리키는 것”입니다. 꺼낼 때는 값으로 옮겨 받고 나서 pop, 무효화 규칙은 원소가 움직이는가로 판단하고, 큐에는 소유하는 값만 넣습니다. 비교자는 “a 가 뒤에 나오면 true”.
SQ 11

다른 언어와 비교 — C++ 만 다른 두 가지

같은 스택·큐라도 언어마다 기본 타입과 관례가 다릅니다. C++ 에서 다른 언어로, 또는 그 반대로 옮길 때 실제로 틀리는 것은 딱 두 가지입니다 — 우선순위 큐의 기본 방향(C++ 은 최대 힙)과 표준에 동시성 큐가 없다는 점. 나머지는 한 장의 표로 대응됩니다.

가로는 우선순위 큐 기본 방향, 세로는 표준 동시성 큐 유무 최소 힙이 기본 최대 힙이 기본 큐 있음 큐 없음 Java Python C# Rust BinaryHeap · mpsc C++ priority_queue · 큐 없음 C++ 혼자 이 칸 — 옮길 때 두 번 놀랍니다 JS 는 표준 우선순위 큐도 스레드 큐도 없어 그리지 않았습니다 (워커는 postMessage)
자바·파이썬·C# 에서 온 사람은 힙 방향을, 러스트에서 온 사람은 채널이 없음을 먼저 확인하세요.
용도Java 21+Python 3.13JS (Node 24)C# (.NET 10)C++23RustGo
스택ArrayDequelistArrayStack<T>vector · stackVecslice
큐ArrayDequedeque직접(링·두 스택)Queue<T>queue(=deque)VecDequeslice · 링
덱 내부원형 배열블록 이중 연결—원형 배열블록 + 맵원형 배열—
우선순위PriorityQueue 최소heapq 최소없음PriorityQueue<E,P> 최소priority_queue 최대BinaryHeap 최대container/heap
스레드 큐ArrayBlockingQueuequeue.Queue—Channel · BlockingCollection없음 (sq06)mpscchan
비동기— (가상 스레드 + 블로킹 큐)asyncio.Queue이벤트 루프Channel없음 · Asiotokio::mpscchan
pop 반환값값값 · undefined값voidOption—
빈 상태예외 · null예외undefined예외 · TryPopUBNonepanic

같은 일 — “스택에 넣고 하나 꺼내기”

Java    Deque<Integer> st = new ArrayDeque<>();  st.push(1);  int x = st.pop();
Python  st = [];  st.append(1);  x = st.pop()
JS      const st = [];  st.push(1);  const x = st.pop();
C#      var st = new Stack<int>();  st.Push(1);  int x = st.Pop();
Rust    let mut st = Vec::new();  st.push(1);  let x = st.pop();   // Option<i32>
Go      st := []int{};  st = append(st, 1);  x := st[len(st)-1];  st = st[:len(st)-1]
C++     std::vector<int> st;  st.push_back(1);  int x = st.back();  st.pop_back();
러스트는 왜 pop 이 값을 돌려줄 수 있나 — 러스트의 이동은 바이트 복사라 실패하지 않습니다. C++ 은 이동·복사 생성자가 임의 코드라 던질 수 있어서 sq02 의 문제가 생깁니다. 같은 자료구조라도 언어의 이동 규칙이 API 모양을 정합니다.

최소 힙 — 다섯 언어

Java    new PriorityQueue<Integer>()                         // 기본 최소
Python  heapq.heappush(h, x)                                     // 기본 최소
C#      new PriorityQueue<string, int>()                         // 기본 최소 (우선순위 따로)
Rust    BinaryHeap::new() + Reverse(x)                           // 기본 최대 → Reverse
C++     std::priority_queue<int, std::vector<int>, std::greater<>> // 기본 최대 → greater
포팅할 때 가장 흔한 버그 — 자바·파이썬 다익스트라를 C++ 로 옮기며 std::greater<> 를 빠뜨리면 컴파일도 되고 결과도 그럴듯하게 나오지만 틀립니다. 테스트에 최단 경로가 둘 이상인 그래프를 꼭 넣으세요.

한 단계 더 — 설계 철학의 차이 🟡 중급

왜 C++ 만 이렇게 생겼나

특징이유
어댑터 + 하부 컨테이너“쓰지 않는 것에 비용을 내지 않는다” — 저장소는 사용자가 고름
pop() 이 void던질 수 있는 복사·이동 앞에서 강한 예외 보장
빈 접근이 UB확인 비용을 안 내는 쪽이 기본 — 확인은 호출자가
값이 컨테이너 안에박싱 없음 — int 는 4 B 그대로
동시성 큐 없음“하나의 정답”이 없는 영역(락·락프리·용량 정책)이라 라이브러리에 맡김

자바에서 온 사람의 첫 주 체크리스트

// 1) Stack/ArrayDeque.pop() 습관 → top() + pop()
// 2) PriorityQueue 의 최소 힙 습관 → std::greater<>
// 3) poll() 의 null 습관 → empty() 먼저 / optional 래퍼
// 4) BlockingQueue 습관 → sq06 의 BlockingQueue 를 프로젝트에 하나 두기
// 5) Iterator 의 ConcurrentModificationException →
//    C++ 은 알려 주지 않고 UB — 무효화 규칙(sq10) 을 외우기
// 6) Integer 박싱 걱정 → 필요 없음. 대신 '복사' 걱정 (move · emplace)
자바의 ConcurrentModificationException 은 친절한 것이었습니다 — C++ 은 같은 실수를 해도 조용히 계속 돕니다. 개발 빌드에 표준 라이브러리 하드닝 + AddressSanitizer(🛠️ b10)를 켜 두는 것이 그 친절을 되찾는 방법입니다.
정리 — 대부분은 이름만 다를 뿐 1:1 로 대응합니다. C++ 만 다른 것은 우선순위 큐가 최대 힙이라는 것과 표준 동시성 큐·채널이 없다는 것, 그리고 pop() 이 값을 돌려주지 않고 빈 접근이 UB 라는 API 모양입니다.
SQ 12

실무 사례 — 이벤트 큐 · undo · 레이트 리미터 · 재시도

지금까지의 타입을 실제 서비스·게임 코드의 모양으로 엮습니다. 각 사례마다 왜 그 타입인지가 핵심입니다 — 프레임 이벤트는 vector 두 개, undo/redo 는 스택 두 개, 최근 N 개는 상한 있는 deque 나 링, 레이트 리미터는 시각을 담은 deque, 재시도는 시각 기준 최소 힙입니다.

1초에 3건 — 위는 허용(덱에 시각을 넣음), 아래는 거절 0 1000 2000 ms 0 200 400 1000 0 만료 1250 200 만료 2000 덱 [1250, 2000] 600 900 1300 1300 의 창 (300, 1300] — 400 · 1000 · 1250 세 건 → 거절 덱에는 허용된 시각만 · 앞에서 만료시키고 개수가 한도 미만이면 뒤에 넣습니다
슬라이딩 윈도우 = 시각을 담은 deque — 앞에서 만료, 뒤에서 추가, 크기가 곧 최근 1초의 건수입니다.

① 레이트 리미터 — deque<time_point>

#include <chrono>
#include <cstddef>
#include <deque>
#include <print>

class SlidingWindowLimiter {
    using Clock = std::chrono::steady_clock;
    std::deque<Clock::time_point> hits_;   // 허용한 요청의 시각
    std::size_t limit_;
    Clock::duration window_;
public:
    SlidingWindowLimiter(std::size_t limit, Clock::duration window)
        : limit_(limit), window_(window) {}

    bool allow(Clock::time_point now) {
        while (!hits_.empty() && now - hits_.front() >= window_)
            hits_.pop_front();                 // 창 밖 — 만료
        if (hits_.size() >= limit_) return false;
        hits_.push_back(now);
        return true;
    }
};

int main() {
    using namespace std::chrono_literals;
    SlidingWindowLimiter lim(3, 1000ms);
    const std::chrono::steady_clock::time_point t0{};   // 시험용 기준 시각
    for (int ms : {0, 200, 400, 600, 900, 1000, 1250, 1300, 2000})
        std::println("{:>4} ms {}", ms,
                     lim.allow(t0 + std::chrono::milliseconds(ms)) ? "허용" : "거절");
}
// 출력
//    0 ms 허용
//  200 ms 허용
//  400 ms 허용
//  600 ms 거절
//  900 ms 거절
// 1000 ms 허용
// 1250 ms 허용
// 1300 ms 거절
// 2000 ms 허용
시각을 인자로 받게 만든 것이 실무 요령입니다 — 테스트에서 가짜 시각을 넣을 수 있습니다. 여러 스레드가 부르면 allow 를 std::mutex 로 감싸고, 사용자별이면 unordered_map<Key, 리미터> 입니다. 한도가 커서 메모리가 부담이면 덱 대신 토큰 버킷(카운터 두 개)으로 바꿉니다.

② 재시도 큐 — 다음 시도 시각 기준 최소 힙

#include <print>
#include <queue>
#include <string>
#include <utility>
#include <vector>

struct Retry {
    long long due_ms;     // 다음 시도 시각
    unsigned seq;         // 같은 시각이면 먼저 넣은 것부터
    std::string name;
    int attempt;          // 몇 번째 시도 (1부터)
};
struct LaterDue {
    bool operator()(const Retry& a, const Retry& b) const {
        return std::pair(a.due_ms, a.seq) > std::pair(b.due_ms, b.seq);
    }
};

bool try_send(const std::string& name, int attempt) {   // 가짜 외부 호출
    if (name == "A") return attempt >= 3;               // 세 번째에 성공
    if (name == "B") return true;                       // 바로 성공
    return false;                                       // C 는 계속 실패
}

int main() {
    std::priority_queue<Retry, std::vector<Retry>, LaterDue> pq;
    unsigned seq = 0;
    for (const char* n : {"A", "B", "C"}) pq.push({0, seq++, n, 1});
    const int max_attempts = 3;

    while (!pq.empty()) {
        Retry r = pq.top();             // 실제 서비스라면 due 까지 타이머 대기
        pq.pop();
        if (try_send(r.name, r.attempt)) {
            std::println("t={:4} {} #{} ok", r.due_ms, r.name, r.attempt);
        } else if (r.attempt >= max_attempts) {
            std::println("t={:4} {} #{} give up → 데드레터", r.due_ms, r.name, r.attempt);
        } else {
            long long delay = 100LL << (r.attempt - 1);      // 100 · 200 · 400 ...
            std::println("t={:4} {} #{} fail → retry at {}",
                         r.due_ms, r.name, r.attempt, r.due_ms + delay);
            pq.push({r.due_ms + delay, seq++, r.name, r.attempt + 1});
        }
    }
}
// 출력
// t=   0 A #1 fail → retry at 100
// t=   0 B #1 ok
// t=   0 C #1 fail → retry at 100
// t= 100 A #2 fail → retry at 300
// t= 100 C #2 fail → retry at 300
// t= 300 A #3 ok
// t= 300 C #3 give up → 데드레터

게임 · 에디터 — 이벤트 큐와 undo/redo 🟡 중급

③ 프레임 이벤트 큐 — vector 두 개를 교환

#include <print>
#include <string>
#include <utility>
#include <vector>

struct Event { std::string name; };

class EventBus {
    std::vector<Event> pending_, processing_;
public:
    void post(Event e) { pending_.push_back(std::move(e)); }

    template <class F>
    void dispatch(F&& handle) {
        std::swap(pending_, processing_);      // 이번 프레임 몫을 확정 (포인터 교환)
        for (auto& e : processing_) handle(e); // 처리 중 post → pending_ 로 (다음 프레임)
        processing_.clear();                   // 용량은 유지 → 프레임마다 할당 0
    }
};

int main() {
    EventBus bus;
    bus.post({"hit"});
    bus.post({"jump"});
    for (int frame = 1; frame <= 2; ++frame) {
        bus.dispatch([&](const Event& e) {
            std::println("frame {}: {}", frame, e.name);
            if (e.name == "hit") bus.post({"sound"});   // 다음 프레임에 처리
        });
    }
}
// 출력
// frame 1: hit
// frame 1: jump
// frame 2: sound
왜 std::queue 가 아닌가 — 한 프레임 안에서 처리하던 큐에 새 이벤트가 계속 붙으면 프레임이 끝나지 않을 수 있습니다(이벤트가 이벤트를 낳는 연쇄). 두 버퍼를 교환하면 “이번 프레임 몫”이 고정되고, vector 라서 순회가 빠르고 할당도 사라집니다. 스레드에서 들어오는 입력은 sq06 큐로 받은 뒤 프레임 시작에 이 버스로 옮깁니다.

④ 명령 패턴 undo/redo — 스택 두 개

#include <memory>
#include <print>
#include <string>
#include <vector>

struct Command {
    virtual ~Command() = default;
    virtual void apply(std::string& doc) = 0;
    virtual void revert(std::string& doc) = 0;
};
struct Append : Command {
    std::string text;
    explicit Append(std::string t) : text(std::move(t)) {}
    void apply(std::string& doc) override  { doc += text; }
    void revert(std::string& doc) override { doc.resize(doc.size() - text.size()); }
};

class Editor {
    std::string doc_;
    std::vector<std::unique_ptr<Command>> undo_, redo_;   // 스택 두 개
    static bool move_top(std::vector<std::unique_ptr<Command>>& from,
                         std::vector<std::unique_ptr<Command>>& to) {
        if (from.empty()) return false;
        to.push_back(std::move(from.back()));
        from.pop_back();
        return true;
    }
public:
    void exec(std::unique_ptr<Command> c) {
        c->apply(doc_);
        undo_.push_back(std::move(c));
        redo_.clear();                         // ★ 새 작업을 하면 redo 기록은 버림
    }
    bool undo() { if (!move_top(undo_, redo_)) return false; redo_.back()->revert(doc_); return true; }
    bool redo() { if (!move_top(redo_, undo_)) return false; undo_.back()->apply(doc_);  return true; }
    const std::string& doc() const { return doc_; }
};

int main() {
    Editor e;
    e.exec(std::make_unique<Append>("Hello"));
    e.exec(std::make_unique<Append>(" World"));
    std::println("[{}]", e.doc());
    e.undo(); std::println("[{}]", e.doc());
    e.undo(); std::println("[{}]", e.doc());
    e.redo(); std::println("[{}]", e.doc());
    e.exec(std::make_unique<Append>("!"));
    bool r = e.redo();                         // redo 기록이 비워졌으므로 false
    std::println("[{}] redo={}", e.doc(), r);
}
// 출력
// [Hello World]
// [Hello]
// []
// [Hello]
// [Hello!] redo=false

운영 — 최근 N 개 보관과 사례별 타입 정리 🔴 실무

⑤ 최근 N 개 로그 — 장애 때 덤프하는 “블랙박스”

#include <cstddef>
#include <deque>
#include <mutex>
#include <print>
#include <string>

class RecentLog {
    mutable std::mutex m_;
    std::deque<std::string> lines_;
    std::size_t cap_;
public:
    explicit RecentLog(std::size_t cap) : cap_(cap) {}
    void add(std::string s) {
        std::lock_guard g(m_);
        if (lines_.size() == cap_) lines_.pop_front();   // 가장 오래된 것 버림
        lines_.push_back(std::move(s));
    }
    void dump() const {                                  // 크래시 핸들러·/debug 엔드포인트에서
        std::lock_guard g(m_);
        for (const auto& l : lines_) std::println("  {}", l);
    }
};

int main() {
    RecentLog log(3);
    for (const char* s : {"boot", "connect", "query", "timeout", "retry"}) log.add(s);
    log.dump();
}
// 출력
//   query
//   timeout
//   retry
N 이 컴파일 시점 상수이고 원소가 작으면 sq04 의 RingBuffer<T, N>::push_overwrite 가 할당 없이 같은 일을 합니다. 로그 문자열처럼 원소 자체가 할당을 하면 deque 냐 링이냐의 차이는 작으니 읽기 쉬운 쪽을 고르세요.

사례 → 타입 한 장 요약

사례타입이유
작업 큐 (스레드 풀)sq06 BlockingQueue배압 · 종료 처리
프레임 이벤트vector 두 개 교환연쇄 차단 · 할당 0
undo / redovector<unique_ptr> 두 개LIFO · 다형 명령
undo 상한 (100단계)deque<unique_ptr>오래된 것을 앞에서 버림
최근 N 개 로그deque · RingBuffer상한 · 덮어쓰기
레이트 리미터deque<time_point>앞 만료 · 뒤 추가
재시도 · 타이머priority_queue (시각 최소)가장 이른 것 먼저
오디오 · ISR → 메인SPSC 링 (🚀 h03)락 금지 구간
코루틴 간 전달Asio 채널 (sq07)스레드를 막지 않음
타이머가 수만 개면 힙도 무겁습니다 — 대부분 취소되는 타임아웃(네트워크 연결마다 하나)은 타이밍 휠(시간 칸마다 리스트를 두는 원형 배열)이 O(1) 등록·취소로 더 맞습니다. 리눅스 커널과 Netty 가 그렇게 합니다.
정리 — 실무의 큐는 요구사항 한 줄이 타입을 정합니다 — “이번 프레임 몫만”이면 vector 교환, “되돌리기”면 스택 두 개, “최근 N 개”면 상한 있는 deque·링, “최근 1초”면 시각 deque, “가장 이른 것 먼저”면 시각 최소 힙. 스레드가 끼면 sq06, 코루틴이 끼면 sq07 로 돌아가세요.
PITCHFORK 레이아웃 · 모던 CMAKE 타깃 · PIMPL · C++20 모듈 · VCPKG · ABI · QT 6 · 엔진 구조 · 2026 기준

헤더 한 줄이
빌드 시간을
정합니다

자바·파이썬은 폴더가 곧 패키지라 구조를 잘못 잡아도 import 한 줄이 조금 길어질 뿐입니다. C++ 은 다릅니다 — 어느 헤더가 어디서 보이느냐가 빌드 시간 · 재컴파일 범위 · 바이너리 호환까지 정합니다. 이 탭은 디렉터리 트리 · CMake 타깃 · 헤더 경계를 한 묶음으로 보고, 라이브러리 · Qt 앱 · 게임/서버가 규모가 커질 때 어디서 어떻게 나누는지를 정리합니다. CMake 문법 자체는 🛠️ 빌드 탭 b02 · b03 에 있으니 여기서는 "어디에 무엇을 두나"만 다룹니다.

10SECTIONS
10시각화
5프로젝트 유형
1장짜리 요약표
STR 01

C++ 프로젝트의 공통 바탕 — Pitchfork 레이아웃과 규모별 진화

C++ 에는 Maven 같은 공식 표준 레이아웃이 없습니다. 대신 CMake 생태계에서 사실상 표준으로 굳은 것이 Pitchfork 레이아웃(PFL) — include/ · src/ · tests/ · external/ · docs/ 로 나누는 규칙입니다. fmt · spdlog · Catch2 같은 유명 라이브러리 대부분이 이 모양이라, 처음 보는 저장소에서도 길을 잃지 않습니다.

라이브러리 + 실행 파일 하나 — 출발점

shop/                      # 저장소 이름 = 프로젝트 이름
├─ CMakeLists.txt          # 최상위 — project() · 옵션 · add_subdirectory
├─ CMakePresets.json       # dev · release · asan 프리셋 (커밋)
├─ include/
│  └─ shop/                # ★ 공개 헤더 — 쓰는 쪽이 <shop/…> 로 포함
│     ├─ order.hpp
│     └─ money.hpp
├─ src/                    # 구현 .cpp + 비공개 헤더 (밖에서 안 보임)
│  ├─ CMakeLists.txt
│  ├─ order.cpp
│  ├─ money.cpp
│  └─ detail/db_row.hpp    # 내부 전용 헤더
├─ tests/                  # 테스트 — 별도 실행 파일 타깃
├─ external/               # 소스째 들고 온 서드파티 (되도록 비움)
├─ docs/
├─ tools/                  # 스크립트 · 코드 생성기
└─ build/                  # 산출물 — .gitignore

공개 헤더를 왜 include/shop/ 로 한 번 더 감싸나

// ❌ include/order.hpp 에 바로 두면
#include "order.hpp"        // 어느 라이브러리의 order.hpp ?
                            // 의존성 둘이 같은 이름을 쓰면 먼저 찾은 쪽이 이김

// ✅ include/shop/order.hpp
#include <shop/order.hpp>   // 이름 공간이 경로에 박혀 충돌이 없음
                            // 빌드 트리에서도, 설치 후
                            // (/usr/include/shop/order.hpp)에도 같은 한 줄 ★

// 같은 라이브러리 안의 비공개 헤더는 따옴표 + 상대 경로
#include "detail/db_row.hpp"
세 가지가 한 번에 풀립니다 — ① 헤더 이름 충돌, ② 소스 트리와 설치 트리의 #include 줄이 같아짐, ③ "include/ 아래 = 공개 API, src/ 아래 = 구현"이라는 경계가 폴더로 보임. ③ 이 STR 06 의 ABI 관리와 STR 10 의 규칙 강제의 출발점입니다.
폴더 (PFL)무엇을 두나흔한 실수
include/<proj>/설치되어 남에게 나가는 헤더만구현용 헬퍼 헤더까지 여기 둠
src/.cpp 와 비공개 헤더. PFL 은 실행 파일 진입점도 여기공개 헤더를 src 에서 직접 include 경로로 노출
libs/저장소 안의 하위 라이브러리 여럿 (각자 include/src 를 가짐)처음부터 만들기 — 하나일 땐 불필요
tests/ · examples/테스트 타깃 · 사용 예제테스트를 라이브러리 타깃에 섞음
external/패키지 관리자로 못 받는 서드파티 원본 (STR 05)고쳐 쓰고 출처를 안 남김
tools/ · docs/ · data/스크립트 · 문서 · 테스트 데이터빌드 산출물을 data 에 커밋
합친 배치도 PFL 이 허용합니다 — 헤더와 .cpp 를 src/shop/order.hpp · src/shop/order.cpp 처럼 한 폴더에 두는 "merged" 방식. 설치할 일이 없는 앱이나 헤더 전용 라이브러리에 편합니다. 분리냐 합침이냐는 공개 API 를 남에게 주느냐로 고르고, 한 저장소 안에서 섞지만 마세요. C 프로젝트의 같은 이야기는 🅲 C 언어 탭 c25 에 있습니다.

규모별 진화 — 언제 다음 단계로 넘어가나 🟢 기초

위는 구조, 아래는 그 구조로 넘어가는 신호 — 신호가 없으면 넘어가지 않습니다 1단계 2단계 3단계 4단계 5단계 main.cpp 하나 CMakeLists 1개 include · src · tests 라이브러리 + 앱 타깃 libs/ 아래 타깃 여럿 폴더마다 CMakeLists 패키지로 내보냄 install · Config.cmake 규칙 자동 검사 타깃 의존 · tidy · CI 실험 · 과제 혼자 · 파일 몇 개 테스트가 필요해짐 실행 파일이 둘 파일 수십 개 · 2~3명 증분 빌드가 느림 다른 저장소가 가져다 씀 팀 여럿 리뷰로 못 막음 내부 앱 대부분은 여기 2단계는 생각보다 일찍 옵니다 — 테스트를 쓰려면 라이브러리 타깃이 있어야 하기 때문입니다
구조는 신호가 올 때 한 칸씩 키웁니다. 단, C++ 은 라이브러리 + 앱 분리(2단계)만큼은 테스트 때문에 거의 처음부터 필요합니다.
신호 (경험치)무엇이 아픈가다음 단계
테스트를 쓰고 싶다main() 이 들어 있는 실행 파일은 테스트가 링크할 수 없음로직을 라이브러리 타깃으로 (STR 02)
헤더 하나 고치면 수백 개 재컴파일공개 헤더에 무거운 include 가 몰림전방 선언 · pimpl (STR 03)
파일 수십 개 · 사람 2~3명"이 기능은 어디 소속?" · 타깃 하나가 다 끌어옴libs/ 로 타깃 분리 (STR 08)
다른 저장소가 가져다 씀복사·붙여넣기로 퍼진 코드가 버전마다 다름설치 규칙 · 패키지 (STR 06)
팀이 여럿엔진이 게임 코드를 include 하는 식의 역방향 의존규칙 자동 검사 (STR 10)
정리 — 바탕은 include/<proj>/ · src/ · tests/ 세 폴더와 최상위 CMakeLists 하나입니다. 공개 헤더는 항상 프로젝트 이름 폴더로 감싸 <proj/x.hpp> 로 포함하고, 나머지는 위 표의 신호가 올 때만 키웁니다.
STR 02

모던 CMake 타깃 구조 — 폴더마다 CMakeLists, 의존은 타깃으로

C++ 프로젝트의 "모듈 경계"는 폴더가 아니라 CMake 타깃입니다. 폴더를 나눠도 전역 include_directories() 하나면 모두가 모두를 봅니다. 반대로 타깃을 나누고 PUBLIC/PRIVATE 를 정확히 붙이면 빌드가 의존 방향을 검사해 줍니다. 키워드의 뜻과 프리셋 문법은 🛠️ 빌드 탭 b02 에 있으니, 여기서는 파일을 어떻게 나눠 배치하나만 봅니다.

트리 — CMakeLists 가 폴더마다 하나

shop/
├─ CMakeLists.txt          # project · find_package · add_subdirectory 만
├─ CMakePresets.json       # 팀 공용 (커밋)
├─ CMakeUserPresets.json   # 개인용 — .gitignore
├─ cmake/                  # 직접 만든 .cmake 모듈 · Config.cmake.in
├─ include/shop/…          # 공개 헤더
├─ src/
│  └─ CMakeLists.txt       # shop::core 라이브러리 타깃
├─ apps/
│  └─ shop-cli/
│     ├─ CMakeLists.txt    # 실행 파일 타깃 — 얇게
│     └─ main.cpp
└─ tests/
   └─ CMakeLists.txt       # 테스트 실행 파일 (STR 09)
PFL 은 실행 파일도 src/ 에 두지만, 실행 파일이 둘 이상이면 apps/ 로 빼는 관례가 흔합니다(규칙이 아니라 관례). 핵심은 main() 이 있는 파일을 라이브러리 타깃에 넣지 않는 것입니다.

최상위 CMakeLists.txt — 조립만 합니다

cmake_minimum_required(VERSION 3.25)
project(shop VERSION 1.4.0 LANGUAGES CXX)

find_package(fmt CONFIG REQUIRED)
find_package(SQLite3 REQUIRED)        # SQLite::SQLite3 타깃 제공

if(PROJECT_IS_TOP_LEVEL)              # (3.21+) 남이 add_subdirectory 하면 건너뜀
    set(CMAKE_CXX_EXTENSIONS OFF)
    include(CTest)                    # BUILD_TESTING 옵션 (기본 ON)
endif()

add_subdirectory(src)
add_subdirectory(apps/shop-cli)
if(PROJECT_IS_TOP_LEVEL AND BUILD_TESTING)
    add_subdirectory(tests)
endif()

src/CMakeLists.txt — 라이브러리가 자기 사정을 다 압니다

add_library(shop_core)
add_library(shop::core ALIAS shop_core)  # 쓰는 쪽은 언제나 shop::core

target_sources(shop_core
    PRIVATE order.cpp money.cpp
    PUBLIC FILE_SET HEADERS              # (3.23+) 공개 헤더 목록
        BASE_DIRS ${PROJECT_SOURCE_DIR}/include
        FILES ${PROJECT_SOURCE_DIR}/include/shop/order.hpp
              ${PROJECT_SOURCE_DIR}/include/shop/money.hpp)

target_include_directories(shop_core
    PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})  # "detail/db_row.hpp" 용 — 나만 봄
target_compile_features(shop_core PUBLIC cxx_std_23)

target_link_libraries(shop_core
    PUBLIC  fmt::fmt            # order.hpp 가 fmt 타입을 드러냄
    PRIVATE SQLite::SQLite3)    # .cpp 안에서만 씀

apps/shop-cli/CMakeLists.txt — 두 줄

add_executable(shop-cli main.cpp)
target_link_libraries(shop-cli PRIVATE shop::core)
# include/ 경로 · fmt 헤더 · C++23 설정이 자동으로 따라옵니다
# SQLite 는 링크만 되고 헤더 경로는 안 옵니다 (PRIVATE)
ALIAS 를 왜 만드나 — 설치된 패키지를 find_package 로 받으면 이름이 shop::core 입니다(STR 06). 저장소 안에서도 같은 이름을 쓰면 나중에 소스 포함 ↔ 패키지 사용을 바꿔도 링크 줄이 그대로이고, :: 가 들어간 이름은 오타가 나면 CMake 가 "그런 타깃 없음"으로 바로 멈춥니다.
PUBLIC 은 위로 번지고, PRIVATE 은 거기서 멈춥니다 shop-cli shop_tests shop::core fmt SQLite PRIVATE PUBLIC PRIVATE fmt 헤더 경로가 앱까지 전파 앱에서 sqlite3.h 를 쓰면 컴파일 오류 오류가 나는 것이 정상입니다 — 빌드가 의존 방향을 검사해 주는 것입니다
타깃 그래프 — 공개 헤더가 드러내는 의존만 PUBLIC. 그러면 앱은 core 의 내부 사정을 알 방법이 없습니다.
❌ 폴더는 나눴지만 경계가 없음

최상위에 include_directories(include src libs) · link_libraries(fmt sqlite3) 를 둡니다. 모든 타깃이 모든 헤더를 보므로 앱이 src/detail/ 을 include 해도 아무도 모르고, 나중에 라이브러리를 떼어 내려 하면 그제야 수백 곳이 깨집니다.

✅ 타깃마다 필요한 것만

각 폴더의 CMakeLists 가 자기 타깃의 소스 · 헤더 · 의존만 선언합니다. 최상위는 add_subdirectory 로 조립만 합니다. 전역 명령(include_directories · add_definitions · CMAKE_CXX_FLAGS 덧붙이기)은 쓰지 않습니다.

파일여기에 둔다여기에 두지 않는다
최상위 CMakeListsproject() · find_package · 옵션 · add_subdirectory소스 목록 · 타깃별 플래그
폴더별 CMakeLists그 폴더의 타깃 하나(또는 몇 개)와 그 의존다른 폴더의 소스
cmake/*.cmake경고 묶음 함수 · sanitizer 함수 · Config.cmake.in타깃 정의
CMakePresets.json제너레이터 · 빌드 폴더 · 툴체인 · 캐시 변수개인 경로 (→ CMakeUserPresets)
정리 — C++ 의 모듈 경계는 CMake 타깃입니다. 폴더마다 CMakeLists 하나, 최상위는 조립만, 라이브러리는 proj::name ALIAS 로 부르고, 공개 헤더가 드러내는 의존만 PUBLIC 으로 둡니다.
STR 03

헤더/소스 분리와 컴파일 시간 — 공개 헤더를 가볍게

#include 는 파일을 그 자리에 붙여넣는 것이라, 공개 헤더가 끌어온 모든 것은 그 헤더를 쓰는 모든 .cpp 가 다시 읽습니다. 그래서 C++ 의 구조 설계는 결국 "공개 헤더에 무엇을 남기나"입니다. 분리의 기초는 🔵 C++ 기초 탭 x09, 템플릿 인스턴스화 비용은 🔬 전문가 탭 d08, PCH · 유니티 빌드 측정은 🚀 고성능 탭 h12 에 있으니, 여기서는 배치 규칙만 봅니다.

무엇을 어디에 — 헤더 무게로 나눈 트리

include/shop/
├─ fwd.hpp            # 전방 선언만 — class Order; class Money;
├─ order.hpp          # 공개 클래스 — 표준 헤더 몇 개만 include
├─ logger.hpp         # pimpl — 무거운 의존이 하나도 안 보임
├─ algo.hpp           # 템플릿 공개 API (헤더에 둘 수밖에 없음)
└─ detail/
   └─ algo_impl.hpp   # 템플릿 내부 — namespace shop::detail
src/
├─ order.cpp          # 첫 줄은 #include <shop/order.hpp> ★
├─ logger.cpp         # <fstream> · <mutex> 는 여기서만
├─ algo_inst.cpp      # 자주 쓰는 타입의 명시적 인스턴스화
└─ detail/
   └─ db_row.hpp      # 비공개 헤더 — 같은 타깃의 .cpp 만 봄
.cpp 첫 줄에 자기 헤더 — 헤더가 다른 include 에 얹혀 가면 첫 줄에서 바로 컴파일 오류가 나므로, 모든 공개 헤더가 혼자서도 컴파일되는지 자동으로 검사됩니다.

pimpl — 구현을 .cpp 로 숨기기

// include/shop/logger.hpp — 쓰는 쪽이 보는 전부
#pragma once
#include <memory>
#include <string>
#include <string_view>

namespace shop {
class Logger {
public:
    explicit Logger(const std::string& path);
    ~Logger();                          // ★ 선언만 — 정의는 .cpp
    Logger(Logger&&) noexcept;
    Logger& operator=(Logger&&) noexcept;
    void write(std::string_view line);
private:
    struct Impl;                        // 전방 선언
    std::unique_ptr<Impl> impl_;
};
}  // namespace shop
// src/logger.cpp — 무거운 헤더는 여기에만
#include <shop/logger.hpp>
#include <fstream>
#include <mutex>

namespace shop {
struct Logger::Impl {
    std::ofstream out;
    std::mutex m;
    explicit Impl(const std::string& p) : out(p, std::ios::app) {}
};
Logger::Logger(const std::string& p) : impl_(std::make_unique<Impl>(p)) {}
Logger::~Logger() = default;            // 여기서는 Impl 이 완전한 타입
Logger::Logger(Logger&&) noexcept = default;
Logger& Logger::operator=(Logger&&) noexcept = default;

void Logger::write(std::string_view line) {
    std::lock_guard lk(impl_->m);       // 이동된 뒤의 객체에는 부르지 않는 전제
    impl_->out << line << '\n';
}
}  // namespace shop
소멸자를 헤더에서 = default 로 두면 컴파일 오류입니다 — unique_ptr<Impl> 의 삭제자가 불완전한 타입 Impl 을 지우려 하기 때문입니다. 소멸자 · 이동 연산은 선언은 헤더, 정의는 .cpp 가 공식입니다.
헤더 하나를 고치면 몇 개가 다시 컴파일되나 (.cpp 400개 가상 예시) 0 100 200 300 400 공용 config.hpp 수정 400 order.hpp 멤버 추가 · pimpl 전 120 — order.hpp 를 포함한 곳 전부 같은 변경 · pimpl 후 1 — order.cpp 하나 src/detail 헤더 수정 6 — 같은 폴더의 .cpp 만 숫자는 설명용 가상값 — 내 프로젝트는 ninja -d explain 이나 -ftime-trace 로 잽니다 공개 헤더가 가벼울수록, 비공개 헤더가 많을수록 막대가 짧아집니다
재컴파일 범위 = 그 헤더를 include 하는 .cpp 수. pimpl 과 비공개 헤더는 이 숫자를 줄이는 구조 장치입니다.
기법무엇을 줄이나대가 · 주의
전방 선언 (fwd.hpp)포인터 · 참조 · 함수 선언만 쓰는 곳의 include값 멤버 · 상속 · 인라인 호출에는 못 씀
pimplprivate 멤버의 의존이 공개 헤더에 드러나는 것힙 할당 1회 + 간접 호출 — 핫패스 값 타입엔 쓰지 않음
템플릿 + detail/사용자가 내부 이름을 쓰는 것 (컴파일 시간은 그대로)타입이 정해져 있으면 extern template + 명시적 인스턴스화
PCH표준 · 서드파티 헤더 반복 파싱PCH 에 넣은 헤더를 고치면 전부 재컴파일 — 내 헤더는 넣지 않음
유니티 빌드CI 의 전체 빌드 시간익명 namespace 이름 충돌 · 증분 빌드는 오히려 느림

PCH · 유니티 빌드는 타깃 단위 스위치로

# src/CMakeLists.txt — 표준 · 서드파티 헤더만
target_precompile_headers(shop_core PRIVATE
    <vector> <string> <memory> <fmt/format.h>)

# 유니티 빌드는 코드가 아니라 프리셋에서 켭니다
#   "ci" 프리셋: "CMAKE_UNITY_BUILD": "ON"
#   "dev" 프리셋: 끈 채로 (증분 빌드가 빠름)

include 그래프를 재는 법

# 이 .cpp 가 실제로 끌어오는 헤더 트리 (GCC · Clang)
g++ -H -fsyntax-only -Iinclude src/order.cpp 2>&1 | head -40

# 어느 헤더 파싱에 시간이 드는지 (Clang) → ClangBuildAnalyzer 로 합산
clang++ -ftime-trace -c src/order.cpp -Iinclude

# 왜 이 파일이 다시 빌드됐나 (Ninja)
ninja -C build -d explain 2>&1 | head
정리 — 공개 헤더에는 쓰는 쪽이 알아야 하는 것만 남기고, 무거운 의존은 pimpl 이나 src/ 의 비공개 헤더로 내립니다. 템플릿은 detail/ 로 감추고, PCH · 유니티 빌드는 구조가 아니라 프리셋 스위치로 다룹니다.
STR 04

C++20 모듈 — 파일 배치와 헤더와의 공존

모듈은 "헤더를 붙여넣지 않고 컴파일된 인터페이스를 가져오는" 방식입니다. 구조 관점에서 바뀌는 점은 두 가지 — 공개 범위가 폴더가 아니라 export 키워드로 정해지고, 빌드 시스템이 파일 사이 의존 순서를 알아야 한다는 것입니다. 문법 소개와 컴파일러별 지원 표는 🚀 고성능 탭 h12 에 있습니다.

모듈 하나 = 폴더 하나 = 타깃 하나

src/money/
├─ CMakeLists.txt
├─ money.cppm          # 기본 인터페이스 단위  export module shop.money;
├─ money-format.cppm   # 인터페이스 파티션     export module shop.money:format;
└─ money.cpp           # 구현 단위             module shop.money;
include/shop/
└─ legacy.hpp          # 아직 헤더로 남은 부분 — 함께 써도 됩니다
파일 종류밖에서 보이나CMake 에서
기본 인터페이스export 한 것만FILE_SET CXX_MODULES
인터페이스 파티션기본 인터페이스가 다시 export 할 때FILE_SET CXX_MODULES
구현 단위안 보임 (.cpp 와 같음)일반 소스 (PRIVATE)
확장자는 관례입니다 — Clang 쪽은 .cppm, MSVC 기본은 .ixx. CMake 는 FILE_SET 으로 종류를 알려 주므로 어느 쪽이든 됩니다. 모듈 이름(shop.money)과 폴더 이름을 맞추는 것도 컴파일러가 강제하지 않는 팀 규칙입니다.

세 파일의 실제 모양

// money.cppm — 기본 인터페이스
module;                          // 전역 모듈 조각: 기존 헤더는 여기서 include
#include <cstdint>
export module shop.money;
export import :format;           // 파티션을 바깥에도 보이게

export namespace shop {
class Money {
public:
    explicit Money(std::int64_t cents) : cents_(cents) {}
    std::int64_t cents() const { return cents_; }
    Money operator+(Money o) const;          // 정의는 money.cpp
private:
    std::int64_t cents_;
};
}
// money-format.cppm — 파티션
module;
#include <cstdint>
#include <string>
export module shop.money:format;
export namespace shop { std::string format_cents(std::int64_t cents); }

// money.cpp — 구현 단위 (인터페이스를 자동으로 가져옴)
module;
#include <cstdint>
#include <format>
#include <string>
module shop.money;
namespace shop {
Money Money::operator+(Money o) const { return Money{cents_ + o.cents_}; }
std::string format_cents(std::int64_t c) {   // 0 이상만 가정 (음수 처리 생략)
    return std::format("{}.{:02}", c / 100, c % 100);
}
}
인터페이스가 먼저 컴파일되어 BMI 를 만들고, 나머지는 그것을 읽습니다 money-format.cppm 파티션 :format money.cppm export module shop.money import BMI 파일 컴파일된 인터페이스 money.cpp module shop.money (구현) main.cpp import shop.money; 둘 다 BMI 를 읽음 money.cpp 를 고쳐도 BMI 는 그대로 — main.cpp 는 다시 컴파일되지 않습니다 이 순서를 알아야 하므로 CMake 3.28+ 가 소스를 미리 스캔합니다
모듈의 구현 단위는 pimpl 의 .cpp 와 같은 역할 — 인터페이스만 BMI 로 나가고, 구현 변경은 쓰는 쪽을 건드리지 않습니다.

CMake — 모듈 파일은 FILE_SET 으로

# src/money/CMakeLists.txt
#   최상위는 cmake_minimum_required(VERSION 3.28) 이상 — 모듈 스캔이 기본으로 켜짐
add_library(shop_money)
add_library(shop::money ALIAS shop_money)
target_sources(shop_money
    PUBLIC  FILE_SET CXX_MODULES FILES money.cppm money-format.cppm
    PRIVATE money.cpp)                  # 구현 단위는 일반 소스
target_compile_features(shop_money PUBLIC cxx_std_20)

# 쓰는 쪽은 헤더 라이브러리와 똑같이
target_link_libraries(shop-cli PRIVATE shop::money)
# 제너레이터: Ninja(1.11+) 또는 Visual Studio — 그 밖은 문서로 지원 여부 확인

헤더와 공존 — 기존 헤더를 모듈로 감싸기

// src/order/order.cppm — 헤더는 그대로 두고 모듈 입구만 추가
module;
#include <shop/order.hpp>          // 기존 공개 헤더
export module shop.order;
export namespace shop {
    using shop::Order;              // 이름만 다시 내보냄
    using shop::place_order;
}
// 새 코드는 import shop.order;  옛 코드는 #include 그대로
2026년 현재 지원은 툴체인마다 고르지 않습니다 — 모듈 자체는 세 컴파일러에서 쓸 만해졌지만, import std; · 헤더 단위(import <vector>;) · IDE 인덱싱 · 패키지로 모듈 배포는 조합에 따라 아직 걸립니다. 남에게 나가는 라이브러리는 헤더를 기본으로 두고 모듈 입구를 덧붙이는 쪽이 안전합니다.
❌ 저장소 전체를 한 번에 모듈로

모든 헤더를 .cppm 으로 바꾸고 매크로 의존을 걷어 내다 보면, 툴체인 하나가 따라오지 못할 때 되돌릴 길이 없습니다. 서드파티 헤더가 섞인 곳은 전역 모듈 조각이 길어지고 이득도 작습니다.

✅ 잎 모듈부터, 타깃 단위로

다른 것에 의존하지 않는 잎(money · util)부터 타깃 하나씩 옮기고, 옮긴 타깃은 헤더 입구를 한동안 같이 둡니다. CI 에 지원해야 하는 컴파일러를 모두 걸어 두고, 하나라도 깨지면 그 타깃은 헤더로 남깁니다.

정리 — 모듈은 폴더 하나 · 타깃 하나 · 모듈 하나로 맞추고, 인터페이스는 FILE_SET CXX_MODULES, 구현 단위는 일반 소스로 둡니다. 툴체인 지원이 고르지 않으니 헤더와 공존시키며 잎 타깃부터 옮깁니다.
STR 05

의존성 관리 구조 — 매니페스트 한 곳, 서드파티는 격리

C++ 에는 공식 패키지 관리자가 없어서 의존성이 vcpkg · Conan · FetchContent · 서브모듈 · 복사본으로 흩어지기 쉽습니다. 규모가 커질 때 문제는 도구 선택보다 "서드파티가 저장소 어디에, 몇 군데에 들어와 있나"입니다. 각 도구의 설치와 문법은 🧰 설치 탭 s07 과 🛠️ 빌드 탭 b03 에 있습니다.

의존성이 사는 자리

shop/
├─ vcpkg.json                # ★ 의존 목록 (매니페스트 모드)
├─ vcpkg-configuration.json  # 기본 레지스트리 · 내부 레지스트리
├─ CMakePresets.json         # vcpkg 툴체인을 여기서 연결
├─ cmake/
│  └─ deps.cmake             # FetchContent 선언 모음 (쓸 때만)
├─ external/
│  └─ tinyfoo/               # 패키지가 없는 것만, 원본 그대로
│     └─ UPSTREAM.txt        # 출처 URL · 버전 · 적용한 패치
└─ src/infra/
   └─ json_codec.cpp         # nlohmann_json 을 include 하는 유일한 곳
목록은 한 파일, 사용은 한 폴더 — 버전은 매니페스트 한 곳에만 적고, 특정 서드파티의 헤더는 src/infra/ 같은 어댑터 한 곳에서만 include 합니다. 라이브러리를 갈아탈 때 고칠 범위가 그 폴더로 줄어듭니다.

vcpkg 매니페스트 + 프리셋

{
  "name": "shop",
  "version": "1.4.0",
  "dependencies": [ "fmt", "sqlite3", "nlohmann-json" ],
  "features": {
    "tests": { "description": "단위 테스트", "dependencies": [ "gtest" ] }
  },
  "builtin-baseline": "<vcpkg 저장소의 커밋 SHA>"
}
{
  "version": 6,
  "configurePresets": [{
    "name": "dev",
    "generator": "Ninja",
    "binaryDir": "build/dev",
    "toolchainFile": "$env{VCPKG_ROOT}/scripts/buildsystems/vcpkg.cmake",
    "cacheVariables": { "VCPKG_MANIFEST_FEATURES": "tests" }
  }]
}
builtin-baseline 이 모든 패키지 버전을 한 시점으로 고정합니다. 이 커밋만 올리면 의존성이 한꺼번에 갱신되고, 리뷰도 한 줄입니다.
이 라이브러리를 어떻게 들여오나 — 위에서 멈출수록 관리가 쉽습니다 vcpkg · Conan 에 패키지가 있나? 예 매니페스트에 한 줄 vcpkg.json · conanfile 아니오 CMake 로 빌드되는 소스인가? 예 FetchContent 태그 · 해시 고정 · SYSTEM 아니오 작고 거의 안 바뀌나? 예 external/ 사본 출처 · 패치 기록 아니오 내부 포트 vcpkg 포트 · 레시피 git 서브모듈은 FetchContent 와 같은 자리 — 하나만
대부분은 첫 질문에서 끝납니다. 아래로 내려갈수록 버전 갱신과 보안 패치를 사람이 직접 챙겨야 합니다.

FetchContent — 설치된 게 있으면 그것부터

# cmake/deps.cmake  (최상위에서 include)
include(FetchContent)
FetchContent_Declare(Catch2
    GIT_REPOSITORY https://github.com/catchorg/Catch2.git
    GIT_TAG        v3.7.1
    SYSTEM                      # (3.25+) 서드파티 경고를 내 빌드에서 끔
    FIND_PACKAGE_ARGS 3)        # (3.24+) find_package(Catch2 3) 를 먼저 시도
FetchContent_MakeAvailable(Catch2)
# vcpkg 로 이미 받았다면 그것을 쓰고, 없을 때만 내려받습니다

Conan 2 를 쓰는 팀이라면

# conanfile.txt — 저장소 루트
[requires]
fmt/11.0.2

[test_requires]
gtest/1.15.0

[generators]
CMakeDeps
CMakeToolchain

[layout]
cmake_layout
conan install . --build=missing
cmake --preset conan-release      # CMakeToolchain 이 만든 프리셋
cmake --build --preset conan-release
비교vcpkg 매니페스트Conan 2FetchContent · 서브모듈
목록 파일vcpkg.jsonconanfile.txt/.pyCMake 코드 · .gitmodules
버전 고정baseline + overrideslockfile태그 · 해시
바이너리 캐시있음있음 (서버 운영 가능)없음 — 매번 빌드
잘 맞는 곳CMake 단일 · Windows 비중 큼여러 빌드 시스템 · 사내 아티팩트 서버작은 의존 몇 개 · 헤더 전용
❌ 서드파티가 사방에

nlohmann/json.hpp 를 수십 개 파일이 직접 include 하고, 같은 라이브러리가 vcpkg 와 external/ 에 두 벌 들어와 있습니다. 두 버전이 한 바이너리에 섞이면 ODR 위반으로 조용히 깨질 수 있습니다(🛠️ 빌드 탭 b03 의 다이아몬드 의존).

✅ 들여오는 길 하나, 쓰는 곳 하나

라이브러리마다 들여오는 방법은 한 가지, 쓰는 쪽은 언제나 find_package 가 주는 타깃(fmt::fmt)만 링크합니다. external/ 안은 고치지 않고, 고쳐야 하면 패치 파일과 UPSTREAM.txt 로 남깁니다.

정리 — 의존 목록은 매니페스트 한 파일, 버전은 baseline · lockfile 로 고정, 서드파티 헤더는 어댑터 폴더 한 곳에서만 씁니다. 패키지 관리자에 없을 때만 FetchContent → external/ 순서로 내려갑니다.
STR 06

라이브러리 · ABI — 공개 헤더가 곧 계약서

남이 가져다 쓰는 라이브러리는 구조가 곧 계약입니다. include/<proj>/ 에 있는 모든 것이 공개 API이고, 공유 라이브러리라면 그 헤더의 클래스 레이아웃까지 ABI로 굳습니다. ABI 가 깨지는 원리는 🔬 전문가 탭 d07, 설치 명령 전체는 🛠️ 빌드 탭 b02 에 있으니, 여기서는 소스 트리와 설치 트리를 어떻게 맞추나를 봅니다.

왼쪽 소스 트리 → 오른쪽 설치 트리 — src/ 는 어디로도 가지 않습니다 소스 · 빌드 트리 설치 트리 (<prefix>) include/shop/*.hpp build/…/shop/export.hpp shop_core 타깃 cmake/shopConfig.cmake.in include/shop/ 공개 헤더 + 생성된 export.hpp #include <shop/x.hpp> 가 그대로 통함 lib/libshop.so.1.4.0 + libshop.so.1 (soname) · libshop.so lib/cmake/shop/ shopConfig · shopTargets.cmake src/*.cpp · src/detail/*.hpp 는 설치되지 않습니다 — 그래서 마음대로 바꿔도 됩니다
PFL 의 include/shop/ 가 설치 트리와 같은 모양이라서, 빌드 중에도 설치 후에도 #include 줄이 같습니다.

export 매크로와 가시성 — 기본은 숨김

include(GenerateExportHeader)
generate_export_header(shop_core
    BASE_NAME shop                     # → SHOP_EXPORT 매크로
    EXPORT_FILE_NAME ${CMAKE_CURRENT_BINARY_DIR}/include/shop/export.hpp)

target_sources(shop_core PUBLIC FILE_SET HEADERS
    BASE_DIRS ${CMAKE_CURRENT_BINARY_DIR}/include
    FILES ${CMAKE_CURRENT_BINARY_DIR}/include/shop/export.hpp)

set_target_properties(shop_core PROPERTIES
    CXX_VISIBILITY_PRESET     hidden   # SHOP_EXPORT 붙은 것만 밖으로
    VISIBILITY_INLINES_HIDDEN ON
    OUTPUT_NAME shop                   # libshop_core 가 아니라 libshop
    VERSION   ${PROJECT_VERSION}       # 1.4.0 → 파일 이름
    SOVERSION ${PROJECT_VERSION_MAJOR} # 1     → soname (ABI 세대)
    EXPORT_NAME core)                  # 설치 후 이름 shop::core
// include/shop/logger.hpp
#include <shop/export.hpp>
namespace shop {
class SHOP_EXPORT Logger { /* STR 03 의 pimpl 그대로 */ };
}

설치 규칙과 Config 파일

include(GNUInstallDirs)
install(TARGETS shop_core EXPORT shopTargets
    FILE_SET HEADERS)                  # 공개 헤더도 함께 (3.23+)
install(EXPORT shopTargets
    NAMESPACE shop::
    DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/shop)
# shopConfig.cmake · ConfigVersion 생성은 빌드 탭 b02 그대로
# cmake/shopConfig.cmake.in
@PACKAGE_INIT@
include(CMakeFindDependencyMacro)
find_dependency(fmt)        # PUBLIC 의존 — 쓰는 쪽도 찾아야 함
find_dependency(SQLite3)    # 정적 라이브러리면 PRIVATE 의존도 링크에 필요
include("${CMAKE_CURRENT_LIST_DIR}/shopTargets.cmake")
check_required_components(shop)
ABI 를 지키는 구조 규칙 — ① 공개 클래스는 pimpl 로 레이아웃을 숨기고, ② 공개 헤더에서 std:: 타입을 주고받는 것은 같은 컴파일러 · 표준 라이브러리끼리만 안전하며, ③ 다른 언어 · 다른 컴파일러 · 플러그인이 상대라면 extern "C" + 불투명 핸들로 경계를 긋습니다(🔗 C ABI 탭).
바꾼 것버전 (VERSION)SOVERSION쓰는 쪽
.cpp 안 구현만1.4.0 → 1.4.11 그대로재컴파일 불필요
공개 함수 추가1.4.1 → 1.5.01 그대로새 기능만 쓰려면 재컴파일
공개 클래스 멤버 · 가상 함수 변경1.5.0 → 2.0.02 로 올림반드시 재컴파일 (옛 앱은 .so.1 유지)
정리 — 공개 헤더는 include/<proj>/ 에만, 기본 가시성은 hidden, 밖으로 낼 것만 PROJ_EXPORT. 설치 트리가 소스 트리와 같은 모양이 되도록 FILE_SET HEADERS 로 설치하고, ABI 를 깨면 SOVERSION 을 올립니다.
STR 07

Qt 데스크톱 앱 — core · 뷰모델 · QML 을 폴더와 타깃으로 가르기

Qt 앱이 커질 때 무너지는 곳은 늘 같습니다 — QML 파일 안에 로직이 쌓이고, C++ 클래스가 위젯과 파일 입출력을 함께 쥐는 것입니다. 층을 나누는 이유와 뷰모델 코드는 🖥️ 데스크톱 앱 탭 g11, Qt CMake 첫걸음은 g02 · g04 에 있으니, 여기서는 Qt 6 CMake API 로 그 층을 디렉터리와 타깃에 어떻게 옮기나만 봅니다.

QML 앱의 출발 트리

notes/
├─ CMakeLists.txt         # find_package(Qt6) · qt_standard_project_setup
├─ src/
│  └─ core/               # ★ 순수 로직 — Qt Quick 을 include 하지 않음
│     ├─ CMakeLists.txt   # notes_core (STATIC)
│     ├─ note.hpp · note.cpp
│     └─ note_store.hpp · note_store.cpp
├─ app/                   # 실행 파일 = QML 모듈 'Notes'
│  ├─ CMakeLists.txt      # qt_add_executable + qt_add_qml_module
│  ├─ main.cpp            # 엔진 띄우기만
│  ├─ viewmodels/         # QObject + QML_ELEMENT — 화면 상태와 명령
│  ├─ qml/                # Main.qml · NoteList.qml · Editor.qml (한 폴더)
│  └─ resources/
│     ├─ icons/*.svg
│     └─ i18n/notes_ko.ts # qt_add_translations 로 연결
└─ tests/
   ├─ core/               # 창 없이 — GoogleTest 또는 Qt Test
   └─ qml/                # Qt Quick Test (tst_*.qml) — 필요할 때만

app/CMakeLists.txt — 실행 파일이 QML 모듈을 품음

# 최상위: find_package(Qt6 6.5 REQUIRED COMPONENTS Core Quick)
#         qt_standard_project_setup(REQUIRES 6.5)
qt_add_executable(notes main.cpp)

qt_add_qml_module(notes
    URI      Notes
    VERSION  1.0
    QML_FILES
        qml/Main.qml
        qml/NoteList.qml
        qml/Editor.qml
    SOURCES                          # QML_ELEMENT 가 붙은 C++ 도 여기
        viewmodels/note_list_vm.hpp
        viewmodels/note_list_vm.cpp
    RESOURCES
        resources/icons/add.svg)

target_link_libraries(notes PRIVATE notes_core Qt6::Quick)
# src/core/CMakeLists.txt — Quick · Widgets 를 링크하지 않음
add_library(notes_core STATIC note.cpp note_store.cpp)
target_include_directories(notes_core PUBLIC ${PROJECT_SOURCE_DIR}/src)
target_link_libraries(notes_core PUBLIC Qt6::Core)  # QString 정도 — 빼도 됨
#   → #include <core/note_store.hpp> 로 씁니다
위층은 아래층만 압니다 — core 는 화면이 무엇인지 모릅니다 QML 화면 app/qml/*.qml 모양 · 바인딩 · 애니메이션 C++ 로직을 두지 않음 뷰모델 (QObject) app/viewmodels/ Q_PROPERTY · Q_INVOKABLE · QML_ELEMENT 화면 상태와 명령 — 위젯·QML 타입은 모름 core (순수 C++) src/core/ → notes_core 파일 · 파싱 · 규칙 — 테스트의 중심 CLI · 서버로 그대로 가져갈 수 있음 Qt Core · 표준 라이브러리 의존 방향 tests/core → notes_core 만 링크 — 창 없이 수백 개가 1초 안에 tests/qml → 화면 동작이 복잡해질 때만 Qt Quick Test 를 더함 core 를 바꿀 일이 화면 수정보다 훨씬 적으면 층이 잘 나뉜 것입니다
층 = 타깃. core 를 별도 라이브러리 타깃으로 빼 두면 "core 가 Quick 을 include 했다"는 실수가 링크 오류로 드러납니다.

뷰모델은 core 를 감싸기만

// app/viewmodels/note_list_vm.hpp
#pragma once
#include <QObject>
#include <QString>
#include <QtQml/qqmlregistration.h>
#include <core/note_store.hpp>

class NoteListViewModel : public QObject {
    Q_OBJECT
    QML_ELEMENT                       // QML 에서 NoteListViewModel { } 로 생성
    Q_PROPERTY(int count READ count NOTIFY countChanged)
public:
    explicit NoteListViewModel(QObject* parent = nullptr) : QObject(parent) {}
    int count() const { return static_cast<int>(store_.size()); }
    Q_INVOKABLE void add(const QString& title);   // .cpp: store_.add(...) 후 emit
signals:
    void countChanged();
private:
    core::NoteStore store_;           // 규칙은 전부 core 에
};

main.cpp — 조립만

#include <QGuiApplication>
#include <QQmlApplicationEngine>

int main(int argc, char* argv[]) {
    QGuiApplication app(argc, argv);
    QQmlApplicationEngine engine;
    QObject::connect(&engine, &QQmlApplicationEngine::objectCreationFailed,
                     &app, [] { QCoreApplication::exit(-1); },
                     Qt::QueuedConnection);
    engine.loadFromModule("Notes", "Main");   // Qt 6.5+ — URI · 타입 이름
    return app.exec();
}
Widgets 앱이라면 qml/ 자리에 ui/(.ui 파일 · 위젯 클래스)가 오고 나머지는 같습니다. 실전 예는 g12 로그 뷰어의 lv_core 분리를 보세요.
신호구조CMake 에서
화면 3~5개실행 파일 = QML 모듈 하나 · core 라이브러리 분리위 그대로
공용 컴포넌트가 화면 여럿에서 재사용qml/ 를 QML 모듈 여러 개로 (Notes.Components 등)qt_add_library + qt_add_qml_module. 정적이면 실행 파일이 <타깃>plugin 을 링크
CLI · 서버와 로직 공유core 에서 Qt 를 완전히 빼거나 별도 패키지로 (STR 06)notes_core 의 Qt6::Core 링크 제거
정리 — core(순수 C++) · 뷰모델(QObject) · 화면(QML/위젯) 세 층을 폴더로 나누고, core 만은 반드시 별도 라이브러리 타깃으로 둡니다. QML 파일과 뷰모델은 qt_add_qml_module 하나에 모으고, 화면이 많아지면 그때 QML 모듈을 쪼갭니다.
STR 08

게임 · 서버 대형 코드베이스 — 엔진과 게임을 가르고 의존은 안쪽으로

코드가 수십만 줄이 되면 디렉터리 이름보다 "누가 누구를 include 해도 되나"가 구조의 전부입니다. 서버를 몇 개의 프로세스로 쪼갤지는 🎮 게임 서버 탭 n01 에 있고, 이 섹션은 한 저장소 안의 코드 모듈을 다룹니다 — 엔진 · 게임플레이 · 실행 파일 · 플러그인을 어떻게 나누고 어느 방향으로만 의존하게 하는지입니다.

엔진 / 게임 분리 트리

game/
├─ engine/                # 게임 규칙을 모르는 재사용 층
│  ├─ core/               # 로그 · 메모리 · 수학 · 컨테이너 — 의존 0
│  ├─ platform/           # 창 · 입력 · 파일 · 스레드 — OS 차이 흡수
│  ├─ render/             # → core · platform
│  ├─ audio/
│  ├─ net/                # 소켓 · 직렬화 · 세션
│  └─ physics/
├─ game/
│  ├─ gameplay/           # 규칙 · 엔티티 · AI — render 를 모름 ★
│  ├─ client/             # 실행 파일: gameplay + render + audio
│  └─ server/             # 실행 파일: gameplay + net (GPU 없음)
├─ shared/proto/          # 클라·서버 공용 패킷 정의 → 코드 생성
├─ plugins/               # MODULE 라이브러리 — 모드 · 에디터 확장
├─ tools/                 # 에셋 쿠커 · 에디터 (engine 을 링크)
└─ third_party/
각 모듈 폴더 안은 STR 01 의 모양을 작게 반복합니다 — engine/render/include/engine/render/*.hpp + src/. Unreal Engine 이 모듈마다 Public/ · Private/ 폴더와 Build.cs 의 PublicDependencyModuleNames · PrivateDependencyModuleNames 를 두는 것도 같은 생각입니다.

의존 방향을 링크 줄로 적기

# engine/render/CMakeLists.txt
add_library(engine_render)
add_library(engine::render ALIAS engine_render)
target_link_libraries(engine_render
    PUBLIC  engine::core          # 공개 헤더가 core 타입을 씀
    PRIVATE engine::platform)     # 구현에서만

# game/gameplay/CMakeLists.txt
target_link_libraries(game_gameplay
    PUBLIC engine::core)          # render · audio 없음 ★

# game/server/CMakeLists.txt — 헤드리스 서버
add_executable(game_server main.cpp)
target_link_libraries(game_server PRIVATE game::gameplay engine::net)

# game/client/CMakeLists.txt
add_executable(game_client main.cpp)
target_link_libraries(game_client PRIVATE
    game::gameplay engine::render engine::audio engine::net)
gameplay 가 render 를 링크하는 순간 서버도 GPU 드라이버 · 창 라이브러리를 끌고 와야 합니다. gameplay 는 "무엇이 보여야 하는지"를 컴포넌트 · 이벤트로 남기고, 클라이언트의 render 시스템이 그것을 읽게 합니다.
의존은 바깥에서 안쪽으로만 — 안쪽일수록 바꾸기 비쌉니다 core platform render 등 gameplay 실행 파일 · 플러그인 client · server · editor · 플러그인 조립만 — 아무도 이들에 의존하지 않음 gameplay — 규칙 · 엔티티 · AI render 를 모름 → 서버에서도 씀 render · audio · net · physics core · platform 만 의존 platform — 창 · 입력 · 파일 · 스레드 OS 차이를 여기서 흡수 core — 로그 · 메모리 · 수학 고치면 전부 다시 빌드 · 다시 검증 안쪽 원이 바깥 원의 헤더를 include 하면 구조가 깨진 것입니다 플러그인은 가장 바깥 — 엔진이 플러그인을 아는 게 아니라 불러 올 뿐입니다
동심원 = 의존 방향. 바깥이 안쪽을 쓰고, 안쪽은 바깥을 모릅니다 — 그래서 서버는 render 없이 gameplay 를 가져갑니다.

플러그인 — 엔진이 모르는 코드를 나중에 불러오기 🔴 고급

계약 헤더 — engine/core 에 둠

// engine/core/include/engine/plugin.hpp
#pragma once

namespace engine {
class Registry;                          // 엔진이 넘겨주는 등록 창구

class IPlugin {
public:
    virtual ~IPlugin() = default;
    virtual const char* name() const = 0;
    virtual void on_load(Registry& reg) = 0;
    virtual void on_unload() = 0;
};
}  // namespace engine

inline constexpr int kEnginePluginApi = 3;   // 계약을 바꾸면 올림

// 플러그인이 내보내는 세 함수의 모양 — 이름이 맹글링되지 않게 C 링크
extern "C" {
using EnginePluginApiFn     = int (*)();
using EnginePluginCreateFn  = engine::IPlugin* (*)();
using EnginePluginDestroyFn = void (*)(engine::IPlugin*);
}

플러그인 쪽 — plugins/fog/

// plugins/fog/fog_plugin.cpp
#include <engine/plugin.hpp>
#include "fog_export.h"                  // generate_export_header 가 만듦

namespace {
class FogPlugin final : public engine::IPlugin {
public:
    const char* name() const override { return "fog"; }
    void on_load(engine::Registry& /*reg*/) override { /* 시스템 등록 */ }
    void on_unload() override {}
};
}  // namespace

extern "C" FOG_EXPORT int engine_plugin_api() { return kEnginePluginApi; }
extern "C" FOG_EXPORT engine::IPlugin* engine_plugin_create() { return new FogPlugin; }
extern "C" FOG_EXPORT void engine_plugin_destroy(engine::IPlugin* p) { delete p; }
add_library(fog_plugin MODULE fog_plugin.cpp)   # dlopen 전용 — 링크 대상 아님
target_link_libraries(fog_plugin PRIVATE engine::core)
set_target_properties(fog_plugin PROPERTIES CXX_VISIBILITY_PRESET hidden)
include(GenerateExportHeader)
generate_export_header(fog_plugin BASE_NAME fog)   # → fog_export.h · FOG_EXPORT
target_include_directories(fog_plugin PRIVATE ${CMAKE_CURRENT_BINARY_DIR})
지운 쪽이 delete 해야 합니다 — 그래서 destroy 를 플러그인이 내보냅니다(힙이 DLL 마다 다를 수 있음). 가상 함수 표를 경계 너머로 쓰는 이 방식은 엔진과 같은 컴파일러 · 같은 설정으로 빌드한 플러그인에만 안전합니다. 서드파티 모드까지 받으려면 C 함수 표로 바꿉니다(🔗 C ABI 탭).
신호다음 구조
헤드리스 서버가 필요gameplay 에서 render · audio 의존 제거 → 서버 실행 파일이 따로 링크
엔진을 두 번째 게임이 씀engine/ 을 버전 붙은 패키지로 내거나(STR 06) 모노레포에서 엔진 소유 팀을 정함(STR 10)
에디터 · 툴이 생김tools/ 실행 파일이 엔진을 링크 — 게임 실행 파일에 에디터 코드를 넣지 않음
모드 · 사내 확장plugins/ + 계약 헤더 + API 버전 숫자
정리 — engine / game / 실행 파일 / plugins 로 나누고, 의존은 바깥 → 안쪽 한 방향만 둡니다. gameplay 가 render 를 모르면 서버를 공짜로 얻고, 플러그인은 계약 헤더 + extern "C" 생성 함수 + API 버전으로 붙입니다.
STR 09

테스트 · 도구 설정 위치 — tests/ 는 거울, 설정 파일은 루트에

테스트와 도구 설정은 "어디 두든 돌기만 하면" 될 것 같지만, 규모가 커지면 찾을 수 있느냐 · 서드파티까지 검사하느냐가 갈립니다. clang-format · clang-tidy · sanitizer 설치와 켜는 법은 🧰 설치 탭 s09, CI 연결은 🛠️ 빌드 탭 b07, sanitizer 원리는 b10 에 있습니다. 여기서는 파일 자리와 타깃 구성만 봅니다.

설정은 루트, 테스트는 src 의 거울

shop/
├─ .clang-format          # 포맷 — 루트 하나, 하위 폴더가 그대로 상속
├─ .clang-tidy            # 검사 규칙 — 루트 하나
├─ .clangd                # 편집기에 build/dev 의 compile_commands 위치
├─ .editorconfig
├─ CMakePresets.json      # dev · asan · tsan · tidy · release
├─ cmake/
│  └─ ProjectDefaults.cmake  # shop_target_defaults(): 경고 · tidy · sanitizer
├─ src/
│  ├─ order.cpp
│  └─ pricing/discount.cpp
├─ tests/
│  ├─ CMakeLists.txt
│  ├─ unit/order_test.cpp            # src/order.cpp 의 거울
│  ├─ unit/pricing/discount_test.cpp # 폴더 깊이까지 같게
│  ├─ integration/db_test.cpp        # 실제 SQLite 파일을 씀
│  └─ data/                          # 테스트 입력 파일
├─ benchmarks/            # Google Benchmark — 테스트와 따로 (Release 로 돌림)
└─ external/              # 서드파티 — tidy · 경고 대상 아님
clang-tidy 는 소스 파일에서 위로 올라가며 가장 가까운 .clang-tidy 를 씁니다. 하위 폴더에 두면 그 폴더만 규칙이 바뀌고, 부모 규칙에 덧붙이려면 InheritParentConfig: true 를 넣습니다. STR 10 에서 이것으로 폴더별 금지 규칙을 겁니다.

tests/CMakeLists.txt — GoogleTest

find_package(GTest CONFIG REQUIRED)     # vcpkg "gtest"
include(GoogleTest)

add_executable(shop_tests
    unit/order_test.cpp
    unit/pricing/discount_test.cpp)
target_link_libraries(shop_tests PRIVATE shop::core GTest::gtest_main)
shop_target_defaults(shop_tests)
gtest_discover_tests(shop_tests)        # TEST() 하나하나가 ctest 항목

add_executable(shop_itests integration/db_test.cpp)
target_link_libraries(shop_itests PRIVATE shop::core GTest::gtest_main)
shop_target_defaults(shop_itests)
target_compile_definitions(shop_itests PRIVATE
    SHOP_TEST_DATA="${CMAKE_CURRENT_SOURCE_DIR}/data")
gtest_discover_tests(shop_itests PROPERTIES LABELS integration)
테스트도 공개 API 로 — shop::core 만 링크하면 테스트는 include/shop/ 만 봅니다. src/detail/ 을 꼭 시험해야 하면 그 테스트 타깃에만 target_include_directories(... PRIVATE ${PROJECT_SOURCE_DIR}/src) 를 붙여 예외임을 드러냅니다. Catch2 를 쓰면 Catch2::Catch2WithMain + catch_discover_tests 로 같은 모양 — 저장소 하나에 프레임워크는 하나만.

cmake/ProjectDefaults.cmake — 내 타깃에만

option(SHOP_CLANG_TIDY "빌드할 때 clang-tidy 를 함께 돌림" OFF)
set(SHOP_SANITIZE "" CACHE STRING "예: address,undefined 또는 thread")

function(shop_target_defaults tgt)
    if(MSVC)
        target_compile_options(${tgt} PRIVATE /W4 /permissive-)
    else()
        target_compile_options(${tgt} PRIVATE -Wall -Wextra -Wpedantic)
    endif()
    if(SHOP_SANITIZE AND NOT MSVC)    # MSVC 는 /fsanitize=address 만 (b10)
        target_compile_options(${tgt} PRIVATE
            -fsanitize=${SHOP_SANITIZE} -fno-omit-frame-pointer)
        target_link_options(${tgt} PRIVATE -fsanitize=${SHOP_SANITIZE})
    endif()
    if(SHOP_CLANG_TIDY)
        set_target_properties(${tgt} PROPERTIES CXX_CLANG_TIDY clang-tidy)
    endif()
endfunction()
# 라이브러리 · 실행 파일 · 테스트 모두에 한 줄씩 호출합니다
프리셋의 CMAKE_CXX_CLANG_TIDY 로 전역에 켜면 FetchContent 로 들어온 서드파티 타깃까지 검사해 경고가 쏟아집니다. 함수로 내 타깃에만 붙이면 external/ 은 자연히 빠집니다.

CMakePresets.json — 상속으로 짧게

{
  "version": 6,
  "configurePresets": [
    { "name": "base", "hidden": true, "generator": "Ninja",
      "binaryDir": "build/${presetName}",
      "cacheVariables": { "CMAKE_EXPORT_COMPILE_COMMANDS": "ON" } },
    { "name": "dev",  "inherits": "base",
      "cacheVariables": { "CMAKE_BUILD_TYPE": "Debug" } },
    { "name": "asan", "inherits": "dev",
      "cacheVariables": { "SHOP_SANITIZE": "address,undefined" } },
    { "name": "tsan", "inherits": "dev",
      "cacheVariables": { "SHOP_SANITIZE": "thread" } },
    { "name": "tidy", "inherits": "dev",
      "cacheVariables": { "SHOP_CLANG_TIDY": "ON" } },
    { "name": "release", "inherits": "base",
      "cacheVariables": { "CMAKE_BUILD_TYPE": "Release" } }
  ]
}
# .clangd — 프리셋 빌드 폴더를 편집기에 알려 줌
CompileFlags:
  CompilationDatabase: build/dev
프리셋 하나 = 검사 조합 하나 (칸이 진하면 켜짐) 디버그 정보 ASan+UBSan TSan clang-tidy 최적화 언제 dev 켜짐 — — — — 매일 로컬 asan 켜짐 켜짐 — — — PR 마다 CI tsan 켜짐 — 켜짐 — — 야간 CI tidy 켜짐 — — 켜짐 — PR 마다 CI release — — — — -O2 태그 · 배포 ASan 과 TSan 은 한 빌드에 같이 켤 수 없습니다 — 그래서 프리셋이 따로입니다 빌드 폴더도 build/<프리셋> 으로 갈라 서로의 캐시를 덮지 않습니다
검사 도구는 코드가 아니라 프리셋 이름으로 고릅니다 — CI 는 cmake --preset asan 한 줄로 같은 조합을 재현합니다.
정리 — .clang-format · .clang-tidy · 프리셋은 루트에 하나, 테스트는 tests/ 아래에 src 와 같은 경로로 둡니다. 경고 · tidy · sanitizer 는 shop_target_defaults() 로 내 타깃에만 붙이고, 어떤 조합으로 돌릴지는 프리셋 이름이 정합니다.
STR 10

한 장 요약 — 유형 · 규모별 권장 구조와 규칙을 도구로 강제하기

구조 규칙을 문서로만 두면 리뷰어가 매번 "엔진에서 게임 헤더를 include 하면 안 돼요"라고 말해야 합니다. 마지막 섹션은 앞의 아홉 섹션을 표 한 장으로 줄이고, C++ 에서 그 규칙을 빌드 · 정적 분석이 지키게 하는 방법을 확신 있는 만큼만 정리합니다.

유형 · 규모권장 구조경계를 지키는 것다음 단계 신호
학습 · 도구 · PoCmain.cpp + CMakeLists 하나 · 합친(merged) 배치없음테스트가 필요해짐
사내 앱 · 서비스 1~3명PFL: include/<proj>/ · src/ · tests/ · 라이브러리 + 앱 타깃 (STR 01 · 02)타깃 PUBLIC/PRIVATE헤더 하나에 수백 개 재컴파일
중대형 앱 · 서버libs/ 아래 타깃 여럿 · apps/ 실행 파일 · pimpl 로 공개 헤더 다이어트 (STR 03)타깃 의존 + 링크 검사 함수팀이 둘 이상
배포 라이브러리PFL 분리 배치 · export 헤더 · hidden 가시성 · install/Config · SOVERSION (STR 06)ABI 검사 도구 (d07)다른 언어 · 컴파일러가 씀 → C ABI
Qt 데스크톱core 라이브러리 · 뷰모델 · QML 모듈 (STR 07)core 가 Quick 을 링크하지 않음화면 10개+ → QML 모듈 분할
게임 · 엔진engine / game / 실행 파일 / plugins 동심원 (STR 08)링크 검사 · 폴더별 tidy엔진을 다른 게임이 씀
새 코드 · 단일 컴파일러잎 타깃부터 C++20 모듈 (STR 04)export 키워드지원 컴파일러가 늘어남
두 질문으로 시작 구조가 정해집니다 — 얼마나 큰가, 남이 링크하는가 규모 (코드 · 사람) → 밖으로 배포 내부만 작은 배포 라이브러리 PFL + install · export 헤더 · SOVERSION 엔진 · SDK 모듈 계층 + C ABI 경계 + ABI 검사 앱 · 도구 · 작은 서비스 PFL 기본 — include · src · tests 대형 앱 · 서버 · 게임 libs/ · apps/ 타깃 분리 + 규칙 강제 오픈소스 라이브러리 게임 엔진 SDK CLI 도구 Qt 앱 사내 서버 · 게임 서버 대부분은 왼쪽 아래에서 시작해 오른쪽으로 갑니다 — 위로 갈 때 ABI 가 생깁니다
가로로 가면 타깃이 늘고, 위로 가면 ABI 와 설치 규칙이 생깁니다. 두 방향의 비용은 따로 냅니다.

규칙을 도구로 — 빌드가 먼저 막고, 정적 분석이 나머지를 🟡 중급

① 타깃 의존 = 1차 방어선 · ② 금지 링크 검사

# cmake/LayerCheck.cmake — 낮은 층이 높은 층을 링크하면 구성 단계에서 실패
function(forbid_link tgt)
    get_target_property(libs ${tgt} LINK_LIBRARIES)
    foreach(bad IN LISTS ARGN)
        if(bad IN_LIST libs)
            message(FATAL_ERROR "구조 위반: ${tgt} 는 ${bad} 에 의존하면 안 됩니다")
        endif()
    endforeach()
endfunction()

# 최상위 CMakeLists 맨 끝 — 모든 타깃이 정의된 뒤
include(cmake/LayerCheck.cmake)
forbid_link(engine_core     engine::render game::gameplay)
forbid_link(game_gameplay   engine::render engine::audio)
한계를 알고 쓰세요 — LINK_LIBRARIES 는 직접 적은 이름 그대로입니다. 팀이 언제나 ALIAS 이름(engine::render)으로 링크한다는 전제가 필요하고, 다른 타깃의 PUBLIC 으로 건너온 헤더 경로는 못 잡습니다. 그래서 STR 02 의 "공개 헤더가 드러내는 것만 PUBLIC" 규칙이 먼저입니다. 전체 그래프는 cmake --graphviz=deps.dot 로 눈으로 확인합니다.

③ 폴더별 clang-tidy · ④ include 정리

# engine/core/.clang-tidy — core 에서는 OS 헤더 금지
InheritParentConfig: true
Checks: 'portability-restrict-system-includes'
CheckOptions:
  - key:   portability-restrict-system-includes.Includes
    value: '*,-windows.h'      # 허용 목록 glob — windows.h 만 제외
# OS 헤더는 platform/ 에서만 → core 가 리눅스 서버에서도 빌드됨
# 내 타깃에만 include 검사 (shop_target_defaults 안에 추가)
if(SHOP_IWYU)
    set_target_properties(${tgt} PROPERTIES
        CXX_INCLUDE_WHAT_YOU_USE include-what-you-use)
endif()
#   또는 clang-tidy 17+ 의 misc-include-cleaner 로 같은 일을
도구막는 것
CMake 타깃링크 안 한 모듈의 헤더 사용 — 컴파일 오류
forbid_link층을 거스르는 링크 줄
폴더별 .clang-tidy폴더마다 다른 금지 헤더 · 규칙
IWYU · include-cleaner남이 끌어온 헤더에 얹혀 가는 include
CODEOWNERSengine/core/ 변경에 소유 팀 리뷰 필수

모노레포 vs 멀티레포 — C++ 에서 고르는 기준 🔴 고급

기준모노레포 (엔진 + 게임 + 툴 한 저장소)멀티레포 (저장소마다 패키지)
공개 헤더 변경쓰는 곳까지 한 커밋에 고침 — ABI 를 함께 바꿔도 됨새 버전을 내고 각 저장소가 따라감 — SOVERSION · 변경 기록 필수
빌드타깃 그래프 하나 · ccache/sccache · 원격 캐시가 사실상 필수저장소마다 작고 빠름 · 의존은 vcpkg 포트/Conan 패키지로
경계CMake 타깃 + forbid_link + CODEOWNERS패키지 경계 자체 — 공개 헤더 밖은 원천적으로 안 보임
잘 맞는 곳한 제품군 · 한 회사 · 같은 컴파일러외부 공개 라이브러리 · 릴리스 주기가 다른 팀
❌ 규칙이 위키에만

"core 는 render 를 모른다"가 문서에만 있고 최상위에 include_directories(engine game) 가 있습니다. 반년 뒤 누군가 급하게 #include <engine/render/mesh.hpp> 를 core 에 넣고, 서버 빌드가 GPU 라이브러리를 요구하기 시작합니다.

✅ 규칙이 빌드에

타깃마다 필요한 것만 링크하므로 그 include 는 컴파일 오류이고, 링크 줄로 우회하면 forbid_link 가 구성 단계에서 멈춥니다. OS 헤더는 폴더별 tidy 가, 남은 판단은 CODEOWNERS 리뷰가 맡습니다.

정리 — 시작은 PFL + 라이브러리/앱 타깃, 커지면 libs/ · apps/, 남에게 주면 install · ABI, 엔진이면 동심원 의존. 그리고 규칙은 문서가 아니라 CMake 타깃 → 링크 검사 → 폴더별 clang-tidy → CODEOWNERS 순서로 도구에 맡깁니다.