C 를 알아야
C++ 가
이해됩니다
C++ 를 배우다 막히는 지점은 대부분 C 에서 물려받은 부분입니다 — 포인터, 배열이 포인터로 변하는 규칙, 널로 끝나는 문자열, 헤더와 링커, 그리고 미정의 동작. 이 탭은 그 바닥을 따로 떼어 정면으로 다룹니다. C ABI 로 라이브러리를 내보내거나, 커널·임베디드·기존 C 코드베이스를 만질 때도 그대로 쓰입니다. 앞의 열두 절로 언어를 익히고 나면, 뒤의 열네 절에서 표준 라이브러리 · 자료구조 · 문자열 · POSIX · 소켓 · 스레드 · 프로세스 · C23 · 임베디드 · 라이브러리 배포 · 테스트 · 빌드 · 생태계까지 C 로 실제 물건을 만드는 데 필요한 전부를 이어서 봅니다.
C 는 왜 아직도 여기 있나
1972년에 나온 언어가 2020년대에도 리눅스 커널 · 파이썬 인터프리터 · SQLite · 모든 운영체제의 바닥을 차지하고 있습니다. 낡아서 남아 있는 게 아니라 대체할 자리가 없어서 남아 있습니다.
C 가 지금도 쓰이는 네 자리
| 자리 | 이유 |
|---|---|
| 운영체제 · 드라이버 | 하드웨어 주소를 있는 그대로 다뤄야 합니다 |
| 언어 런타임 | 파이썬 · 루비 · PHP 가 전부 C 로 구현돼 있습니다 |
| 임베디드 · 펌웨어 | 램이 수십 KB 인 칩에서는 C 밖에 안 돕니다 |
| 언어 사이의 다리 | C ABI 가 모든 언어의 공용어입니다 — FFI 탭의 주제 |
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
if (x = 1) 같은 오타가 조용히 컴파일됩니다.
경고 플래그는 선택이 아니라 필수입니다.
true/false 내장, nullptr, typeof 같은
편한 것들이 들어왔지만 컴파일러 지원이 아직 고르지 않습니다.
정수 — 크기가 정해져 있지 않습니다
파이썬 정수는 무한대까지 커지고, 자바 int 는 항상 32비트입니다.
C 의 int 는 "최소 16비트, 나머지는 구현에 따라"입니다.
이 한 문장에서 나오는 버그가 아주 많습니다.
표준이 보장하는 것은 최소 크기뿐
| 타입 | 표준 보장 | 보통(64비트 리눅스) |
|---|---|---|
char | 1바이트 | 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 에서 배열은 포인터다"라는 말은 절반만 맞습니다. 배열은 배열이고, 대부분의 자리에서 포인터로 변할 뿐입니다. 이 '변한다'가 어디서 일어나는지 아는 것이 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] 는 호환되지 않습니다.
서로 넘기면 컴파일은 경고만 내고 실행에서 죽습니다.
idx = row * cols + col 로
직접 계산하는 방식입니다. 연속성도 지키고, 함수 인자로 넘기기도 쉽고,
해제도 한 번이면 끝납니다. 수치 계산 코드가 대부분 이 방식을 씁니다.
문자열 — 널 하나로 끝을 표시합니다
C 문자열은 타입이 아니라 약속입니다 — "'\0' 을 만날 때까지가 문자열이다".
길이를 들고 다니지 않는 이 설계에서 지난 50년 보안 취약점의 상당수가 나왔습니다.
쓰면 안 되는 함수와 대안
| 쓰지 말 것 | 대신 |
|---|---|
gets | C11 에서 표준에서 삭제됨 — fgets |
strcpy | snprintf(dst, cap, "%s", src) |
strcat | 길이를 세어 snprintf 로 한 번에 |
sprintf | snprintf — 상한을 반드시 지정 |
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; } 같은
자체 문자열 구조체를 만들어 씁니다.
길이를 잃지 않는 것만으로 버그가 크게 줄어듭니다.
메모리 — 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 직후의 값을 그냥 씀 — 실행마다 결과가 달라집니다 |
-fsanitize=address 를 켜는 것이 이 절의 결론입니다.
소유권을 코드로 표현하는 방법
함수 이름과 주석으로 규약을 밝힌다
/* 반환값의 소유권은 호출자에게 넘어갑니다.
다 쓴 뒤 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;
}
finally 도 소멸자도 없습니다.
정리 지점을 한 곳으로 모으는 goto 가 커널을 포함한 대형 C 코드베이스의 표준 관용구입니다.
malloc 반환값 캐스팅
C 에서는 (int*)malloc(...) 처럼 캐스팅하지 않는 것이 권장됩니다.
캐스팅이 stdlib.h 를 빠뜨린 실수를 가려 버리기 때문입니다.
(C++ 에서는 반대로 캐스팅이 필수입니다.)
스택에 잡는 것이 더 낫다
크기가 작고 함수 안에서만 쓴다면 지역 배열이 훨씬 빠르고 안전합니다. 스택은 보통 1~8MB 이므로, 수 KB 까지는 고민하지 말고 스택에 두세요.
아레나 · 풀 할당
수명이 같은 객체가 많다면 큰 덩어리를 한 번 잡고 잘라 쓰다가 한 번에 버리는 방식이 빠르고 누수도 없습니다. 게임·컴파일러에서 널리 쓰는 기법입니다.
구조체 · 정렬과 패딩
구조체 크기는 필드 크기의 합이 아닙니다. CPU 가 좋아하는 위치에 맞추려고 컴파일러가 빈칸을 끼워 넣기 때문입니다. 파일 형식이나 네트워크 프로토콜을 구조체로 다루려면 이 규칙을 알아야 합니다.
확인하는 법
#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)
공용체와 비트필드
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;
/* ... */
}
비트필드 — 플래그를 촘촘히
struct Flags {
unsigned enabled : 1;
unsigned mode : 3; // 0~7
unsigned reserved : 4;
}; // 쓰는 비트는 8개 — 단 sizeof 는 보통 4 (unsigned 단위로 잡힘)
읽기 쉽지만 비트 배치 순서가 구현 정의입니다. 하드웨어 레지스터나 통신 프로토콜에는 직접 시프트와 마스크를 쓰는 편이 이식성이 높습니다.
함수 포인터 — 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; 로 쓰면 안 됩니다.
큰 값들에서 정수 오버플로가 나서 정렬이 조용히 틀어집니다.
위처럼 비교 결과의 차로 쓰세요.
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 의 에러 처리는 반환값과 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 매니페스트를 켜야 합니다 —
크로스 플랫폼 코드에서 자주 만나는 함정입니다.
전처리기 — 강력하고 위험합니다
#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_VER | MSVC — 값이 버전 |
__cplusplus | C++ 컴파일러로 컴파일 중 |
__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 */
#pragma once 는 표준이 아닙니다 — 다만 GCC · Clang · MSVC 가 전부 지원해서
실무에서는 널리 쓰입니다. 심볼릭 링크나 같은 파일이 두 경로로 보이는 환경에서
드물게 오작동하므로, 배포용 공개 헤더에는 전통적인 인클루드 가드를 권합니다.
여러 파일로 나누기 · 빌드
파일이 두 개가 되는 순간 선언과 정의, 내부와 외부를 구분해야 합니다. C 에는 모듈이 없어서 헤더와 링커의 규칙이 그 역할을 대신합니다.
전역 변수를 여러 파일에서 쓰기
/* 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 를 위험하게 만드는 것
UB 는 "이상한 값이 나온다"가 아니라 "표준이 아무 약속도 하지 않는다"입니다. 컴파일러는 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) | 사람이 생각 못 한 입력을 자동으로 |
-fno-strict-aliasing 을 켜서
표준이 아니라 컴파일러와 따로 약속하는 방식으로 이 문제를 우회합니다.
C 와 C++ — 실제로 다른 지점들
"C++ 는 C 의 상위 집합"이라는 설명은 실무에서는 틀립니다. C 코드를 C++ 로 컴파일하면 안 되는 것들이 있고, 반대로 C++ 습관을 C 에 가져오면 어색해집니다. 두 언어를 오갈 때 걸리는 지점을 모았습니다.
| 항목 | C | C++ |
|---|---|---|
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 이 나옵니다.
표준 라이브러리 지도 — 무엇이 어느 헤더에 있나
파이썬은 import 할 모듈 이름을 몰라도 검색하면 나옵니다. C 는 헤더가 30개도 안 되는 대신
이름이 줄임말이라 짐작이 안 됩니다 — atoi 가 왜 stdlib.h 에 있는지,
memcpy 가 왜 string.h 에 있는지는 외우는 수밖에 없습니다.
이 절은 그 지도를 한 장에 담고, 실제로 손이 자주 가는 함수만 골라 표로 정리합니다.
가장 많이 쓰는 함수 — stdio · stdlib
| 함수 | 하는 일 · 주의 |
|---|---|
printf/fprintf | 서식 출력. 형식 문자와 타입이 다르면 UB |
snprintf | 버퍼에 서식 출력 — sprintf 대신 항상 이것 |
fgets | 한 줄 읽기. 개행이 같이 들어옵니다 |
fopen/fclose | 실패하면 NULL. 반드시 검사 |
fread/fwrite | 바이너리 덩어리 입출력. 반환값 = 항목 수 |
malloc/calloc | calloc 은 0으로 채우고 곱셈 넘침도 검사 |
realloc | 실패하면 원본 유지 + NULL 반환 ★ |
strtol/strtod | 문자열 → 수. atoi 대신 이것(에러 확인 가능) |
qsort/bsearch | 비교 함수 포인터를 넘기는 정렬 · 이진 탐색 |
exit/atexit | 종료와 종료 훅. abort 는 정리 없이 죽음 |
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 을 쓰세요 |
strdup | POSIX(C23 부터 표준). free 는 내 책임 |
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_t | PRId64 | <inttypes.h> 매크로 |
double | %f %g %e | float 도 %f (자동 승격) |
char* | %s | NULL 을 넘기면 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"); 한 줄로 — 단 다른 호출보다 먼저 불러야 합니다
}
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) 이름으로 대체본을 줍니다.
멀티스레드 코드에서는 무조건 이쪽을 쓰세요.
<threads.h> 조차 선택 기능이라 MSVC 는 오랫동안 지원하지 않았습니다.
그래서 실무 C 는 POSIX(리눅스·맥) + Win32(윈도우) + 외부 라이브러리를 섞어 씁니다 —
c17 · c18 · c26 에서 그 지형을 차례로 봅니다.
stdio(입출력) · stdlib(메모리·변환) · string(메모리 복사) 셋이 8할이고,
atoi 대신 strtol, sprintf 대신 snprintf,
strcpy 대신 snprintf 이 세 가지 교체만 몸에 붙여도
C 에서 나는 사고의 절반이 사라집니다.동적 배열 · 연결 리스트를 직접 만듭니다
파이썬의 list, C++ 의 std::vector 에 해당하는 것이 C 에는 없습니다.
그래서 C 프로젝트는 거의 예외 없이 자기 동적 배열을 하나 가지고 있습니다.
직접 만들어 보면 “왜 vector 는 push 가 평균 O(1) 인가”, “왜 포인터를 보관하면 위험한가”가
한 번에 이해됩니다. 여기서 만든 코드는 그대로 복사해 쓸 수 있는 수준으로 씁니다.
동적 배열 — 복붙해서 바로 쓰는 전체 코드
// 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 방지
}
v->data = realloc(v->data, n) 처럼 같은 변수에 바로 대입하면,
실패해서 NULL 이 돌아왔을 때 원래 버퍼의 주소를 잃어버려 통째로 샙니다.
위 코드처럼 임시 포인터에 받고 검사한 뒤에 대입하세요. 이건 C 코드 리뷰에서 가장 자주 지적되는 항목입니다.
왜 2배인가 — 증가 전략이 성능을 정합니다 🟡 중급
1씩 늘리기 vs 2배로 늘리기
| 전략 | n번 push 총 복사량 | 평가 |
|---|---|---|
| +1 씩 | O(n²) — 1+2+…+n | 10만 개면 체감으로 멈춤 |
| +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);
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; // 매번 새로 계산
// 리스트가 필요한 진짜 이유가 바로
// '주소가 절대 안 변한다'는 성질입니다
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 일 수도 있는데,
고쳐야 할 대상은 언제나 그 칸이기 때문에 특수 케이스가 사라집니다.
리눅스 커널 코드에 이 관용구가 도처에 나옵니다.
vector 가 없으니 동적 배열 하나는 직접 갖고 시작합니다.
핵심은 2배 성장(평균 O(1))과 realloc 을 임시 포인터로 받기,
그리고 재할당하면 원소 주소가 바뀐다는 사실입니다.
연결 리스트는 “중간 삽입이 잦거나 주소가 고정돼야 할 때”만 꺼내면 되고,
그때는 이중 포인터 관용구가 코드를 절반으로 줄여 줍니다.해시 테이블과 정렬 · 탐색 — 딕셔너리도 직접 만듭니다
파이썬의 dict 도 C 에는 없습니다. 다행히 해시 테이블은 100줄이면 만들 수 있고,
정렬과 이진 탐색은 표준 qsort·bsearch 가 이미 줍니다.
이 절에서는 오픈 어드레싱 해시 테이블 전체 코드와, 비교 함수를 넘기는 C 특유의 정렬 방식을 봅니다.
문자열 → 값 해시 테이블 — 전체 코드
#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 : "없음");
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);
% 보다 빠름),
적재율 0.7 에서 rehash, 그리고 삭제는 묘비로.
정렬·탐색은 qsort/bsearch 로 충분하되
비교 함수에서 뺄셈 대신 (x>y)-(x<y) 를 쓰세요.
실전에서는 stb_ds.h 한 장을 넣는 것이 대개 가장 좋은 선택입니다.문자열 처리 실전 — 파싱 · 동적 버퍼 · UTF-8
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 이 아닐 수 있습니다
#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줄을 하나 만들어 두고 계속 씁니다.
한글은 바이트 열로 그대로 다루되, 자를 때만 글자 경계를 확인하면 됩니다.POSIX — 파일 · 디렉터리 · 시간을 시스템 수준에서
C 표준에는 디렉터리를 읽는 함수조차 없습니다. 파일 크기, 수정 시각, 폴더 순회, 메모리 매핑은
전부 POSIX(리눅스·맥·BSD)나 Win32(윈도우)의 영역입니다.
이 절은 실무에서 실제로 쓰는 것만 골라, 양쪽 모두에서 도는 형태로 정리합니다.
#ifdef _WIN32 분기가 어떻게 생기는지도 여기서 감을 잡게 됩니다.
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 를 '예약'만 합니다 — 실제로 건드린 페이지만 물리 메모리를 씁니다
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 대응표와 분기 관용구
| POSIX | Win32 |
|---|---|
open/read/write | CreateFile/ReadFile/WriteFile |
close | CloseHandle |
stat | GetFileAttributesEx · _stat64 |
opendir/readdir | FindFirstFile/FindNextFile |
mmap | CreateFileMapping+MapViewOfFile |
clock_gettime | QueryPerformanceCounter |
nanosleep | Sleep(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 가 번지지 않게
stat, 폴더는 opendir, 큰 파일은 mmap,
시간 측정은 CLOCK_MONOTONIC 이 정답입니다.
read·write 는 부분 전송과 EINTR 을 루프로 감싸는 것이 기본형이고,
윈도우 대응은 #ifdef 를 래퍼 함수 안에만 가두면 됩니다.소켓 프로그래밍 — TCP 서버와 클라이언트를 처음부터
네트워크 라이브러리를 한 겹만 걷어 내면 전부 이 여섯 개의 C 함수가 나옵니다 —
socket · bind · listen · accept · connect · close.
파이썬 socket 모듈도 이름까지 똑같습니다. 여기서는 복사해서 바로 도는 에코 서버와 클라이언트를
만들고, 접속이 늘어날 때 select · poll · epoll 로 어떻게 넘어가는지까지 봅니다.
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 프로토콜 방식)
// 방법 ③ 고정 길이 레코드 — 가장 단순하지만 확장이 어렵습니다
접속이 늘어나면 — 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 입니다 — 에러가 아닙니다
셋 중 무엇을 쓰나
| 방식 | 복잡도 | 언제 |
|---|---|---|
select | O(n) 매번 전체 스캔 | fd 1024 제한. 새 코드에는 쓰지 않습니다 |
poll | O(n) | 수백 접속까지. 이식성이 가장 좋음 |
epoll | O(준비된 것만) | 리눅스. 수만 접속 — 서버의 표준 |
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 이 날 때까지 전부 읽어야" 하는 규칙이 생깁니다
SIGPIPE 무시(signal(SIGPIPE, SIG_IGN))하지 않으면
끊긴 연결에 send 하는 순간 프로세스가 죽습니다. ② 읽기·쓰기 타임아웃(SO_RCVTIMEO) 설정.
③ 길이 필드는 반드시 상한 검증. ④ TLS 가 필요하면 직접 만들지 말고 mbedTLS·OpenSSL 를 얹습니다.
⑤ 작은 메시지를 자주 보낸다면 TCP_NODELAY(Nagle 끄기)를 검토합니다.
스레드와 동기화 — pthreads · C11 threads · atomics
파이썬에는 GIL 이 있어서 스레드를 써도 CPU 가 하나뿐이지만, C 는 진짜로 코어를 다 씁니다.
그 대가로 데이터 레이스라는, 재현도 안 되고 디버거로도 안 잡히는 종류의 버그가 생깁니다.
이 절은 왜 counter++ 하나가 깨지는지부터 시작해서 뮤텍스·조건 변수·원자 연산과
TSan 으로 실제로 잡아내는 법까지 갑니다.
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+ 부터 지원 |
| macOS | libc 에 없음 — 별도 구현 필요 |
| MSVC | 최근 버전에서 지원 시작(오래 없었음) |
| 임베디드 | 대부분 없음 |
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 과 동시 사용 불가) |
helgrind | valgrind 계열 레이스 검출. 더 느리지만 재빌드 불필요 |
gdb thread apply all bt | 데드락 났을 때 모든 스레드 스택 |
_Atomic,
기다려야 하면 → 조건 변수(while 로 재확인).
그리고 스레드를 쓰는 프로젝트라면 CI 에 -fsanitize=thread 빌드를 하나 넣어 두세요 —
사람 눈으로 찾을 수 없는 버그를 기계가 찾아 줍니다.프로세스 · 시그널 · 파이프 — 셸이 하는 일을 직접
ls | grep c 를 치면 셸이 무슨 일을 하는지 아십니까? fork 로 자기를 복제하고,
pipe 로 둘을 잇고, exec 로 다른 프로그램이 되고, wait 으로 기다립니다 — 전부 이 절의 함수입니다.
파이썬 subprocess, Node child_process 도 안에서 정확히 이 일을 합니다.
Ctrl+C 를 눌렀을 때 프로그램이 깨끗하게 끝나게 만드는 법도 여기서 배웁니다.
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);
시그널 — 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"
윈도우에서는 — 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;
}
대응표와 실무 선택
| POSIX | Windows |
|---|---|
fork + execvp | CreateProcess |
waitpid | WaitForSingleObject |
pipe | CreatePipe |
popen | _popen |
kill(pid, SIGTERM) | TerminateProcess(강제) |
| 시그널 핸들러 | SetConsoleCtrlHandler |
getpid | GetCurrentProcessId |
#ifdef 로 덮기 힘듭니다.
libuv 의 uv_spawn 이나 subprocess.h(단일 헤더 라이브러리)를 쓰면
파이프 연결·환경 변수·종료 코드까지 한 API 로 해결됩니다. 직접 만들 값어치가 별로 없는 영역입니다.
wait 을 안 하면
커널이 종료 코드를 담은 껍데기를 계속 붙들고 있습니다(ps 에 Z 로 보임).
오래 도는 서버라면 SIGCHLD 핸들러에서
while (waitpid(-1, NULL, WNOHANG) > 0) {} 를 돌려 회수하세요.
fork 는 반환값으로 부모·자식을 구분하고,
자식은 exec 으로 다른 프로그램이 됩니다. 부모는 반드시 wait 으로 회수해야
좀비가 안 남습니다. 파이프는 안 쓰는 쪽을 닫는 것이 전부이고,
시그널 핸들러에서는 플래그만 세우고 나옵니다.
윈도우에는 fork 가 없으니 “프로세스 실행” 한 기능으로 추상화하는 것이 정답입니다.C23 — 30년 만에 C 가 편해졌습니다
C 는 오랫동안 “변하지 않는 언어”였습니다. C11 이후 C17 은 버그 수정판이었고, 실무 기준은 늘 C99 였죠.
그런데 C23 은 다릅니다 — nullptr, constexpr, typeof,
#embed, auto, 2진 리터럴까지 C++ 에서 부러워하던 것들이 대거 들어왔습니다.
다만 컴파일러 지원이 고르지 않아, 무엇을 지금 써도 되는지를 아는 것이 이 절의 목표입니다.
바로 편해지는 것들
// ① 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 · Clang | MSVC | 판단 |
|---|---|---|---|
nullptr | 지원 | 부분 | 혼자 쓰는 코드면 ○ |
bool 키워드 | 지원 | 부분 | stdbool.h 를 계속 include 하면 양쪽 다 안전 |
| 2진 리터럴 | 지원(오래전부터 확장으로도) | 지원 | ◎ 사실상 안전 |
typeof | 지원(GNU 확장으로 30년) | 부분 | ○ |
constexpr | 지원 | 부분 | △ enum 이 여전히 무난 |
#embed | GCC 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)의 몫입니다.
nullptr·bool·2진 리터럴·typeof 는 지금 써도 거의 안전하고,
#embed·_BitInt 는 GCC/Clang 전용으로 생각하는 편이 마음 편합니다.
다만 메모리 안전·모듈·컨테이너는 여전히 없습니다 — C 를 쓰는 이유와 감수할 것은 그대로입니다.임베디드 · 시스템 C — 운영체제 없이 도는 코드
전 세계에서 매년 만들어지는 마이크로컨트롤러는 수백억 개이고, 그 위에서 도는 코드는 대부분 C 입니다. malloc 도 printf 도 운영체제도 없는 환경에서 C 가 어떻게 쓰이는지를 보면, 지금까지 배운 포인터·비트 연산·volatile 이 왜 언어에 있는지가 비로소 이해됩니다. Arduino·ESP32·STM32 중 어디서 시작하면 되는지도 정리합니다.
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) { } // ★ 뺄셈이라 오버플로에도 안전
}
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 같은 단일 헤더 구현을 넣습니다
링커 스크립트 맛보기
/* 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 (;;) { }
}
.bss 초기화 루프가
그 약속을 지키는 코드입니다. PC 에서는 운영체제와 C 런타임이 대신 해 주던 일이라
보이지 않았을 뿐입니다.
어디서 시작하나 — 보드 고르기
| 보드 | 가격대 · 특징 | 이럴 때 |
|---|---|---|
| Arduino Uno R4 | 가장 쉬움. IDE 한 번 설치로 끝 | 처음이라면 여기. C++ 처럼 보이지만 사실상 C |
| Raspberry Pi Pico 2 | 싸고 강력(RP2350). C SDK 공식 지원 | C 로 제대로 배우기 가장 좋은 균형 |
| ESP32-S3 | Wi-Fi·BLE 내장. ESP-IDF(C 기반) | 네트워크가 필요한 장치 |
| STM32 Nucleo | 산업 표준. HAL·CubeMX | 취업·산업용을 노린다면 |
| Raspberry Pi 5 | 리눅스가 도는 컴퓨터 | 임베디드가 아니라 작은 서버에 가깝습니다 |
volatile 은 여기서 진짜 역할을 하고, 비트 마스크와 시프트가 문법처럼 쓰입니다.
시작은 Raspberry Pi Pico 2 나 Arduino 로 LED 하나 깜빡이는 것부터면 충분합니다.라이브러리 만들기 — 남이 쓸 수 있는 형태로 내보내기
C 가 지금도 중심에 있는 진짜 이유는 C ABI 가 모든 언어의 공통 언어이기 때문입니다.
파이썬·자바·C#·Go·러스트·자바스크립트가 전부 C 로 만든 .so·.dll 을 부를 수 있습니다.
이 절에서는 정적·동적 라이브러리를 빌드하고, 심볼을 골라 내보내고, 헤더를 설계하고,
다른 언어에서 한 줄로 불러 보는 것까지 한 번에 해 봅니다.
만들어 봅니다 — 같은 소스로 .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 증가 |
-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 의 버그는 증상이 원인에서 멀리 떨어져 나타납니다 — 100줄 앞에서 배열을 하나 넘어 썼는데 전혀 다른 함수에서 죽죠. 그래서 C 에서는 “눈으로 코드를 본다”가 가장 비효율적인 방법입니다. 새니타이저·valgrind·gdb 를 증상별로 어떤 걸 꺼내야 하는지 정리하고, 테스트 프레임워크를 붙여 CI 에서 자동으로 돌게 만드는 데까지 갑니다.
새니타이저 — 가장 값싸고 강력한 도구
# ① 주소 새니타이저 — 메모리 오류 종합 검출 (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 ← 여기서 할당했습니다
gdb — 외워야 할 명령은 열 개뿐
| 명령 | 하는 일 |
|---|---|
run / r | 실행 (인자는 run a b) |
bt | 백트레이스 — 가장 많이 씁니다 |
frame N / f N | N번 스택 프레임으로 이동 |
break f.c:42 | 중단점. break foo if x==3 도 됨 |
n / s / c | 다음 줄 / 함수 안으로 / 계속 |
p expr | 값 출력. p *arr@10 은 배열 10개 |
watch x | x 가 바뀌는 순간 멈춤 — 값 오염 추적에 최고 |
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
최적화 빌드와 디버그 빌드의 차이 🔴 고급
-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
-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
long 크기, 구조체 패딩, 경로 구분자, 대소문자 구분 파일 시스템 —
전부 CI 가 대신 잡아 줍니다.
bt, 이상하면 ASan, 메모리가 늘면 valgrind,
스레드면 TSan. 그리고 무엇을 하든 먼저 -g -O1 로 다시 빌드합니다.
테스트는 Unity 파일 세 개면 시작할 수 있고,
새니타이저를 켠 CI 잡 하나가 코드 리뷰 열 번의 값어치를 합니다.실전 프로젝트 구조 · 빌드 — Makefile 과 CMake 완전판
파일이 세 개를 넘어가는 순간 gcc *.c 로는 감당이 안 됩니다.
무엇이 무엇에 의존하는지를 적어 두면 도구가 바뀐 것만 다시 빌드해 줍니다.
이 절은 복사해서 바로 쓰는 Makefile 완전판과 CMake 표준 구성,
그리고 디렉터리 배치 · .clang-format · GitHub Actions까지
“새 C 프로젝트를 시작하는 한 세트”를 통째로 만듭니다.
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) # ★ 헤더 의존성을 여기서 읽어 들입니다
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 ...
배포까지 — 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 파일 하나로 배포되는 것은
여전히 다른 언어가 못 따라오는 장점입니다.
src/·include/<프로젝트>/ 배치,
-MMD -MP 가 들어간 Makefile(또는 CMake),
.clang-format, compile_commands.json,
세 OS 를 도는 CI. 여기까지 갖추면 파일이 100개로 늘어도 빌드는 몇 초에 끝나고,
협업자가 cmake -B build 한 줄로 시작할 수 있습니다.C 생태계 — 지금 무엇을 가져다 쓰나
C 에는 pip 도 npm 도 없지만, 세상에서 가장 많이 실행되는 라이브러리들이 C 로 되어 있습니다.
직접 만들 것과 가져다 쓸 것을 구분하는 감각이 실력의 절반입니다.
이 절은 2026년 기준으로 실제로 많이 쓰이는 라이브러리 아홉 개를 용도별로 정리하고,
각각 최소 예제 한 조각씩과 함께 C 로 만들어진 유명 프로젝트들이 왜 C 를 골랐는지를 봅니다.
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
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 요청 | libcurl | TLS·프록시·재시도까지 전부. 대안 없음 |
| JSON | cJSON / yyjson | 쉬운 쪽 / 빠른 쪽(수 GB/s). jansson 도 좋음 |
| 압축 | zlib / zstd | 호환성 / 속도·압축률. miniz 는 헤더 한 장 |
| 비동기 서버 | libuv / libevent | epoll·kqueue·IOCP 를 하나로 덮어 줍니다 |
| 게임 · 그래픽 | raylib / SDL3 | 배우기 쉬움 / 상용 수준 이식성 |
| TLS · 암호 | mbedTLS / OpenSSL | 임베디드 · 소형 / 기능 완비. 직접 구현 금지 |
| GUI | GTK4 / 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/ 에 넣고 커밋
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만 줄 | 세상 모든 장치에 이식돼야 함 |
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 가 따라가는 중 |
| WebAssembly | Emscripten 으로 C 코드가 브라우저에서 돕니다 |
| 여전한 수요 | 임베디드 · 커널 · 런타임 · 라이브러리 경계는 바뀌지 않습니다 |
mylib.h 를 만들어 보세요. ② 그걸로 작은 도구(로그 파서·JSON 뷰어·TCP 채팅)를 하나 만듭니다.
③ 만든 것을 c23 처럼 .so 로 빌드해 파이썬에서 불러 봅니다.
이 세 단계를 마치면 C 를 “읽을 수 있는 언어”가 아니라 “쓸 수 있는 도구”로 갖게 됩니다.
third_party/ 에 넣습니다.
그리고 Redis 소스 읽기는 이 탭 전체의 가장 좋은 복습입니다.가장 빠른 코드가
필요한 자리에는
여전히 C++ 가 있습니다
C++ 의 강점은 하드웨어에 가장 가까우면서도 추상화를 공짜로 쓴다는 것입니다 — 실행 중 통역도, 가비지 컬렉터의 멈춤도 없습니다. 그래서 게임 엔진 · 브라우저 · DB 엔진 · 거래 시스템 · 영상 처리 · 임베디드가 전부 C++ 로 서 있습니다. 이 가이드는 그 C++ 를 기초 문법부터 백엔드 서버 · 게임 서버 · 고성능 시스템, 그리고 파이썬 · Node · 자바 · C# 에서 불러 쓰는 모듈까지 한 페이지로 잇습니다. 이 탭을 마치면 C++ 코드를 읽고 고칠 수 있게 됩니다.
C++ 를 실행하는 법 — 컴파일이 전부의 시작
파이썬은 파일을 그냥 실행합니다. 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) | 내가 설계 |
| 고치고 확인 | 즉시 | 빌드 기다림 |
이렇게도 쓸 수 있다 — 컴파일 옵션이 결과를 바꿉니다 🟡 중급
외우면 좋은 플래그 다섯 개
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 # 링크 (합치기)
두 종류의 에러를 구분하기 ★
① 컴파일 에러 — 문법·타입이 틀렸다
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
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
g++ -std=c++20 -O2 -Wall a.cpp -o a 이 한 줄만 외우면 시작할 수 있고,
실무에서는 이걸 CMake 가 대신 해 줍니다.
에러가 나면 먼저 컴파일 에러인지 링크 에러인지부터 구분하세요 — 고칠 곳이 다릅니다.변수 · 타입 · 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 은 안 적어도 빈 문자열
파이썬 타입과의 대응
| 파이썬 | C++ | 메모 |
|---|---|---|
| int | int · long long | 범위 제한 있음 — 넘치면 UB |
| float | double | 같은 IEEE 754 |
| bool | bool | true/false 소문자 |
| str | std::string | 바이트 배열입니다(뒤에서) |
| list | std::vector<T> | 한 가지 타입만 담습니다 |
| dict | std::unordered_map | 정렬이 필요하면 map |
| tuple | std::tuple · struct | 보통 struct 가 낫습니다 |
| None | std::optional<T> | 널 포인터와는 다릅니다 |
| bytes | std::vector<uint8_t> |
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 보다 이쪽
한 단계 더 — 메모리에 실제로 어떻게 놓이나 🧬 전문가
크기 · 정렬 · 패딩
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 (한쪽만 실수여도 실수 연산)
int·double·bool·std::string·std::vector
다섯 개면 시작할 수 있고, 변수는 항상 초기화하고
범위 for(for (const auto& x : v))를 쓰면 처음 흔히 밟는 함정 대부분을 피합니다.함수 — 오버로딩과 인자 전달
문법은 파이썬과 비슷합니다. 다른 건 두 가지 — 같은 이름의 함수를 여러 개 만들 수 있고, 인자를 어떻게 넘기느냐가 성능을 정합니다.
기본 형태
// 반환타입 이름(인자) { ... }
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); // ❌ 컴파일 에러
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);
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 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%입니다.조건 · 반복 · 범위 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 | 리해시되면 전부(원소 추가 시) · 참조는 유지 |
-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++ 는 그냥 죽거나 이상한 값이 됩니다.포인터와 참조 — 여기가 진짜 관문
파이썬 변수는 전부 포인터였습니다. 다만 그 사실이 감춰져 있었을 뿐입니다. C++ 는 값을 직접 다룰지, 주소를 다룰지 내가 고릅니다. 그게 속도의 대가로 치르는 값입니다.
값 · 포인터 · 참조 세 가지
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 → 정적 분석으로 미리
// ✅ 습관 — "이 참조가 가리키는 것이 나보다 오래 사는가?"를
// 포인터·참조를 저장할 때마다 자문하세요. 지역 변수면 값으로 복사하세요.
한 단계 더 — 널 역참조가 왜 '그냥 크래시'가 아닌가 🧬 전문가
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).
가장 중요한 질문 하나: “이게 가리키는 대상이 아직 살아 있나?”vector · string — 파이썬 list·str 의 자리
C++ 컨테이너는 수십 가지지만, 실무의 90%는 vector 입니다.
나머지는 “언제 예외인가”만 알면 됩니다.
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 가 쓰는 대략의 바이트
vector 가 파이썬 list,
string 이 str, unordered_map 이 dict 입니다.
거의 항상 vector 가 정답이고,
크기를 알면 reserve 를 부르고,
순회할 땐 const auto& 를 붙이세요 — 이 셋이면 대부분의 성능 문제가 예방됩니다.클래스 — 소멸자가 결정적 차이
문법은 파이썬 클래스와 비슷합니다. 다른 건 하나 — 객체가 사라질 때 자동으로 불리는 함수(소멸자)가 있습니다. 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 한다)
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 로 구조체를 그대로 넘길 수 있습니다(🔗 탭)
is_standard_layout 이 참이어야 바이트 단위로 그대로 주고받을 수 있습니다.
가상 함수가 하나라도 있으면 이 성질이 깨집니다(vtable 포인터가 앞에 붙습니다).
vector·string·unique_ptr 이 대신해 주니까요.상속과 가상 함수
파이썬은 항상 자식 메서드가 불렸습니다. 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 상속으로 이어집니다
실무에선 이렇게 — 테스트할 수 있는 구조로 🔴 실무
경계에만 인터페이스를 둡니다
// 바깥 세계(파일·네트워크·시각)와 닿는 곳만 인터페이스로 뽑습니다
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);
// 상태 없음 · 상속 없음 · 입력과 출력만 — 어느 언어에서든 감싸기 쉽습니다
한 단계 더 — 가상 호출은 실제로 얼마나 비싼가 🧬 전문가
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·템플릿·함수 객체로 해결되는 경우가 훨씬 많습니다.헤더와 소스 분리 — 파일이 여러 개가 될 때
파이썬은 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 이 완전한 이 자리에서 정의
실무에선 이렇게 — 폴더 구조와 포함 규칙 🔴 실무
공개 헤더와 내부 헤더를 나눕니다
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
nm 한 번으로
“이 심볼은 어디에도 없다”를 확인하고 원인으로 직행하는 사람의 차이가 여기서 납니다.
#pragma once, 함수 정의는 .cpp 에,
헤더에 정의를 두려면 inline.
multiple definition 이 나오면 헤더를 의심하고,
undefined reference 가 나오면 빌드에 그 파일을 넣었는지부터 보세요.네임스페이스와 전처리기
네임스페이스는 파이썬의 모듈과 같은 역할입니다.
전처리기(#)는 컴파일 전에 텍스트를 갈아 끼우는 단계로, 강력하지만 위험합니다.
네임스페이스
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,
함수 매크로 대신 함수를 쓰면 디버거와 자동완성이 정상 동작합니다.
매크로는 플랫폼 분기와 파일·줄 번호 정도에만 남기세요.예외와 에러 처리
문법은 파이썬과 거의 같습니다. 다른 건 문화 — 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 경계를 예외가 넘어가면 안 됩니다 — 반드시 에러 코드로 바꿔 넘기세요.치트시트 — 파이썬 · 자바에서 옮겨 오기
이미 아는 개념에 이름표만 새로 붙이면 됩니다. 이 표를 옆에 두고 남의 C++ 코드를 읽어 보세요.
이렇게도 쓸 수 있다 — 파이썬 ↔ C++ 대조표 🟡 중급
| 파이썬 | C++ | 메모 |
|---|---|---|
| ■ 값과 자료구조 | ||
x = 10 | int 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 · struct | struct 를 권장 |
None | std::optional<T> | nullptr 과 다릅니다 |
lst.append(x) | v.push_back(x) · emplace_back | |
len(lst) | v.size() | 부호 없는 타입 ★ |
x in lst | std::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<< | |
self | this (대개 생략) | 포인터입니다 |
with open(...) | RAII (소멸자) | 탭 2 m01 |
import m | #include "m.h" | 텍스트 삽입 |
try/except/raise | try/catch/throw | const& 로 받기 |
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 되는 순간의 감각이 다음 학습을 끌고 갑니다.
-fsanitize=address,undefined 를 켜세요 — 절반은 그 자리에서 답이 나옵니다.여기까지 해야
“C++ 를 쓴다”고
말할 수 있습니다
new·delete 를 손으로 쓰던 시절의 C++ 는 잊으세요.
현대 C++ 는 자원을 객체 수명에 묶고, 복사 대신 옮기고,
루프 대신 조합합니다. 이 탭의 앞 세 섹션만 익혀도 메모리 버그가 거의 사라집니다.
RAII — C++ 의 심장
“가진 것은 사라질 때 알아서 반납한다”는 한 문장이 전부입니다.
파이썬의 with 가 모든 객체에 항상 걸려 있는 셈입니다.
문제 — 손으로 정리하면 반드시 빠뜨립니다
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(); // ★ 여기까지 왔으면 롤백하지 않음
}
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; // ✅
-fsanitize=address 로 빌드하고 테스트를 돌리면
누수된 할당의 스택 트레이스까지 나옵니다(🛠️ b10).
delete·close·unlock 이 보이면 감쌀 타입을 찾으세요.스마트 포인터 — new·delete 를 지웁니다
이것만 써도 메모리 누수의 90%가 사라집니다. 핵심은 “누가 소유하는가”를 타입으로 표현하는 것입니다.
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) 가 나을 수 있음)
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& — 이 한 줄이 성능과 가독성을 둘 다 지켜 줍니다.이동 시맨틱 — 복사하지 않고 옮기기
100MB 짜리 벡터를 함수에서 돌려줄 때 복사가 일어나지 않는 이유입니다. 개념은 하나 — “어차피 사라질 것이라면 내용을 훔쳐 오면 된다.”
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 하세요.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 는 속도를 위한 것이 아니라 실수를 막고 의도를 드러내기 위한 것이고,
멀티스레드에서 “동시에 읽어도 되는가”의 판단 근거가 됩니다.STL 컨테이너 — 거의 항상 vector
컨테이너는 수십 가지지만 실무의 90%는 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).
복잡도 비교
| 연산 | vector | deque | map | u_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 가 없어 매우 빠릅니다
한 단계 더 — 컨테이너의 실제 메모리 🧬 전문가
노드 기반의 진짜 비용
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 로)
perf stat -e cache-misses 로 확인하면
왜 그런지까지 설명할 수 있게 됩니다.
vector 가 기본값이고,
키로 찾아야 하면 unordered_map, 순서가 필요하면 map 입니다.
크기를 알면 reserve, 참조를 보관하면 재할당 주의,
그리고 노드 기반 컨테이너는 메모리를 7배 쓴다는 것만 기억하세요.알고리즘과 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.islice | vw::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, ...);
}
rg::sort·rg::find_if·std::erase_if·rg::any_of
이 넷만 써도 코드가 눈에 띄게 읽기 좋아집니다.
views 파이프라인은 2~3단계까지, 그리고 view 를 반환하지 마세요.람다와 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); // ✅ 통째로
한 단계 더 — 람다는 무엇으로 컴파일되나 🧬 전문가
이름 없는 클래스입니다
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); }; // 캡처는 포인터 하나
std::function 으로 타입을 지울 때 생깁니다.
“람다가 느리다”가 아니라 “타입 소거가 느리다”가 정확한 표현입니다.
[&], 저장·비동기면 값 캡처 — 이 한 줄이 규칙의 전부입니다.
인터페이스에는 성능이 중요하면 템플릿, 구현을 숨기려면 std::function.템플릿 입문
템플릿은 타입을 나중에 채우는 틀입니다.
같은 코드를 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) 를 맨 아래에
이렇게도 쓸 수 있다 — 타입에 따라 다르게 🟡 중급
컴파일 시점 분기
#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,
제약을 걸 땐 컨셉(다음 섹션)을 쓰면 에러 메시지가 사람이 읽을 수 있게 됩니다.컨셉 — 템플릿 에러를 한 줄로
“이런 타입만 받는다”를 코드로 적습니다. 수백 줄짜리 템플릿 에러가 한 줄로 줄어드는 것이 가장 큰 이득입니다.
쓰는 법
#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> 처럼 표준 컨셉을 붙이기만 해도
수백 줄짜리 에러가 세 줄로 줄어듭니다.
직접 만들 때는 함수가 실제로 쓰는 연산만 요구하세요.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]] 를 꼭 붙이세요.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 가
가장 빠르고 안전합니다.동시성 — 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 관례를 따르는 것도 좋습니다)
-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
lock_guard(RAII)로 짧게 잡으세요.
스레드를 직접 만들기 전에 병렬 알고리즘과 async 를 먼저 보고,
TSan 으로 검증하세요.느린 함수 하나만
C++ 로 갈아 끼웁니다
파이썬을 버리는 게 아닙니다. 프로파일러가 계속 가리키는 그 함수 하나를 C++ 로 만들어
import 해서 쓰는 것이 목표입니다. 쓰는 사람은 C++ 가 있는지도 모릅니다.
파일 세 개면 첫 모듈이 돕니다.
왜 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++ 를 아는 사람이 없음 |
이렇게도 쓸 수 있다 — 선택지 비교 🟡 중급
네이티브로 가는 길은 여러 갈래입니다
| 방법 | 장점 | 단점 |
|---|---|---|
| pybind11 | C++ 답고 자료 많음 · numpy 연동 ★ | 빌드 필요 · 컴파일 느림 |
| nanobind | pybind11 보다 작고 빠름 | C++17+ · 자료가 적음 |
| ctypes | 빌드 불필요 · 표준 라이브러리 | C ABI 만 · 타입 안전성 없음 |
| cffi | ctypes 보다 안전 · 빠름 | 의존성 추가 |
| Cython | 파이썬 문법 그대로 | 별도 언어를 배워야 함 |
| Rust + PyO3 | 메모리 안전 · 빌드 편함 | Rust 학습 |
| C API 직접 | 의존성 0 | 참조 카운트를 손으로 — 권하지 않음 |
현실적인 기대 배수
같은 알고리즘 기준의 대략적인 감각 (직접 재 보셔야 합니다)
순수 파이썬 루프 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가 세그폴트로 인터프리터 전체가 죽는 형태로 바뀝니다 - 버스 팩터 — 그 모듈을 고칠 수 있는 사람이 팀에 몇 명인지
- 되돌릴 수 있게 — 순수 파이썬 구현을 같이 남겨 두고 환경변수로 전환 가능하게 해 두면 사고 시 즉시 되돌립니다 ★
인터페이스를 먼저 설계합니다
# ❌ 파이썬 객체를 그대로 넘기는 설계 — 경계를 수백만 번 넘습니다
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) # 결과를 미리 만든 버퍼에 씁니다
한 단계 더 — 경계 비용을 실제로 재 보기 🧬 전문가
빈 함수를 부르는 데 얼마나 드나
// 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배 설득력 있습니다
if n < 5000: return pure_python(a) 같은 분기는
과한 최적화가 아니라 측정에 근거한 설계입니다.
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 가 있어야 합니다
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) # 잘못된 인자는 예외로 ★
한 단계 더 — 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 # 더 자세한 분석
CMakeLists.txt + pyproject.toml,
그리고 pip install . — 이게 전부입니다.
PYBIND11_MODULE 의 이름과 모듈 파일 이름을 맞추는 것만 조심하면
첫 import 는 5분 안에 성공합니다.빌드 붙이기 — 쓰는 사람은 C++ 를 몰라도 되게
목표는 pip install fastmod 한 줄입니다.
컴파일러도, CMake 도, C++ 도 쓰는 쪽에서는 보이지 않아야 합니다.
빌드 백엔드 고르기
| 방법 | 언제 |
|---|---|
| scikit-build-core | ★ 기본 추천. CMake 를 그대로 쓰고 외부 라이브러리 연동이 자유롭습니다 |
setuptools + Pybind11Extension | 파일 몇 개짜리 아주 단순한 모듈 |
| meson-python | Meson 을 쓰는 프로젝트(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 는 이름이 겹치면 실패
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 등급
pyproject.toml + CMakeLists.txt 두 파일이면
pip install . 로 빌드됩니다. 배포는 cibuildwheel 이 3 OS × 여러 버전을 자동으로 만들어 주고,
abi3 를 쓰면 wheel 수를 5분의 1로 줄일 수 있습니다.
개발 중에는 pip install -e . --no-build-isolation + ccache.타입 변환 — 어디서 복사가 일어나는가
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); // 복사 없이 참조
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 버퍼로 바꾸세요.
반환할 때는 정책을 의식하고, 확신이 없으면 복사가 안전합니다.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>>()); }
));
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
누가 객체를 소유하나
// 기본 — 파이썬이 소유합니다 (참조가 사라지면 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()
한 단계 더 — 객체 하나에 드는 실제 비용 🧬 전문가
인스턴스 구조와 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__ 를 붙이면 파이썬답게 됩니다.
다만 클래스 계층을 그대로 옮기지 마세요 —
노출은 얇게, 파이썬다움은 파이썬 래퍼에서 만드는 편이 유지보수가 훨씬 쉽습니다.numpy 배열 — 복사 없이 주고받기
여기가 이 탭의 핵심입니다. 100만 개짜리 배열을 복사 없이 포인터로 받아 C++ 속도로 처리하고, 결과도 복사 없이 돌려줍니다.
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() 를 쓰세요");
...
}
// ★ 검사는 '경계에서 한 번', 내부 루프에서는 하지 않습니다
// → 안전성과 속도를 동시에 얻습니다
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%가 여기서 예방됩니다.GIL 해제 — 진짜 병렬로 도는 지점
파이썬은 GIL 때문에 스레드를 여러 개 써도 CPU 계산은 한 번에 하나뿐이었습니다. C++ 확장은 계산하는 동안 GIL 을 놓을 수 있습니다 — 파이썬의 한계를 넘는 자리입니다.
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++ 타입일 때만
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() 를 선언하면
조용한 데이터 손상으로 이어집니다. 선언은 '약속'입니다.
한 단계 더 — 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% 인지)
py::gil_scoped_release 한 줄이면
파이썬 스레드로 여러 코어를 실제로 쓸 수 있습니다 — 파이썬만으로는 불가능했던 지점입니다.
규칙은 하나 — GIL 을 놓은 구간에서는 파이썬 객체를 절대 만지지 않기.
그리고 계산이 짧으면 놓지 않는 편이 빠릅니다.예외를 파이썬 예외로
쓰는 사람은 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++ 에서 파이썬 함수 부르기
진행률 보고, 사용자 정의 규칙, 이벤트 통지 — 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))
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()
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 획득 비용만 줄이려면 여러 결과를 모았다가 한 번에 콜백
#include <pybind11/functional.h> 를 넣으면
파이썬 함수를 std::function 으로 받을 수 있습니다.
규칙은 둘 — 부를 때 GIL 을 잡을 것,
그리고 호출 횟수를 데이터 크기와 무관하게 유지할 것.
진행률은 1% 단위, 규칙은 배열째 넘기는 형태가 정답입니다.타입 힌트와 .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/
실무에선 이렇게 — 문서와 예제까지 🔴 실무
사용자가 처음 만나는 것들
- 설치 한 줄 —
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);
.pyi 스텁과 빈 py.typed 파일을 함께 넣어야
자동완성과 mypy 검사가 동작합니다.
pybind11-stubgen 으로 만들고 손으로 다듬은 뒤,
CI 에서 최신인지 검사하세요.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 배열도 같은 타입으로 받습니다
무엇이 좋아지나
| pybind11 | nanobind | |
|---|---|---|
| 바이너리 크기 | 기준 | 1/3 ~ 1/10 |
| 컴파일 시간 | 기준 | 3~5배 빠름 |
| 호출 오버헤드 | 기준 | 2~10배 빠름 |
| 필요 표준 | C++11 | C++17 |
| 파이썬 | 3.7+ | 3.8+ |
| 자료·예제 | 매우 많음 | 적음 |
| STL 자동 변환 | 폭넓음 | 일부는 명시 필요 |
| ML 프레임워크 배열 | numpy 위주 | torch·jax 통합 ★ |
이렇게도 쓸 수 있다 — 옮길 때 달라지는 것들 🟡 중급
주의할 차이점
// ① 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 에 익숙 |
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")
흔한 실패 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) |
| 수명이 끝난 뒤 접근 | 반환 정책이 reference | reference_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)
PYTHONFAULTHANDLER=1,
그리고 AddressSanitizer 와 py-spy — 이 셋이면 대부분 원인이 나옵니다.이벤트 루프를
막지 않고
C++ 를 부릅니다
Node 는 단일 스레드입니다. 그래서 C++ 를 붙일 때 가장 중요한 것은 속도가 아니라 이벤트 루프를 막지 않는 것입니다. N-API 로 만들면 Node 버전이 올라가도 다시 빌드할 필요가 없습니다.
애드온 · WASM · 별도 프로세스 — 무엇을 고를까
네이티브 애드온이 항상 답은 아닙니다. 배포와 유지보수 비용까지 보면 다른 선택이 나은 경우가 많습니다.
세 가지 방식 비교
| 네이티브 애드온 | WASM | 별도 프로세스 | |
|---|---|---|---|
| 속도 | 최고 | 0.7~0.9배 | IPC 비용 |
| 배포 | OS별 바이너리 | 파일 하나 ★ | 실행 파일 |
| 격리 | 없음(크래시=전체) | 샌드박스 | 완전 격리 ★ |
| 스레드 | 자유 | 제한적 | 자유 |
| 메모리 공유 | 제로카피 ★ | 선형 메모리 복사 | 불가(파이프) |
| 시스템 접근 | 전부 | WASI 범위만 | 전부 |
| 브라우저 | ❌ | ✅ | ❌ |
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
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`) // 훨씬 작아야 정상
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 로
실무에선 이렇게 — 패키지 구조 🔴 실무
저장소 배치
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 파일을 덮어쓸 수 없습니다.
테스트 프로세스를 확실히 종료한 뒤 빌드하세요.
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(); // 이 스레드 전용 버퍼 ★
...
}
// 소멸자는 환경이 정리될 때 자동으로 불립니다
worker_threads 로
CPU 작업을 분산하는 것은 흔한 패턴입니다.
전역 상태를 둔 애드온은 그 순간 조용히 깨집니다.
처음부터 SetInstanceData 를 쓰세요.
napi.h 를 include 하고
NODE_API_MODULE 로 등록하면 끝입니다.
C++ 쪽은 인자 검증과 예외 변환만 하고,
옵션·기본값·타입은 JS 래퍼와 .d.ts 에서 처리하세요.
전역 상태 대신 SetInstanceData 를 쓰면 worker_threads 에서도 안전합니다.빌드 — 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-gyp | cmake-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 을 안 쓰면
애드온의 내부 심볼이 전부 노출되어, 같은 프로세스의 다른 애드온과 충돌할 수 있습니다.
두 애드온이 같은 정적 라이브러리를 다른 버전으로 링크한 경우 특히 위험합니다.
-fno-exceptions 끄기),
맥 배포 타깃, 리눅스 glibc·musl 구분.
그리고 가시성은 hidden 으로 두세요.값 · 객체 · 배열 변환
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()
객체와 배열
// 객체 읽기
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 쪽에서 처리하는 편이 안전합니다
한 단계 더 — 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()
Persistent 참조를 Reset 하지 않았거나,
AdjustExternalMemory 로 알려 주지 않아 GC 가 압박을 못 느끼는 경우입니다.
process.memoryUsage().external 을 모니터링하세요.
Array 는 원소마다 변환되므로 큰 데이터에는 쓰지 마세요 —
TypedArray 로 한 번에 넘기는 것이 정답입니다(다음 섹션).
옵션은 객체로, 에러는 표준 에러 타입으로 던지고 즉시 반환하세요.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(); // 입력을 직접 바꿨습니다
}
// ★ 문서에 "입력 버퍼를 수정합니다" 를 반드시 명시하세요
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'))
한 단계 더 — 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 를 막아야 합니다.비동기 — AsyncWorker
무거운 계산을 백그라운드 스레드에서 돌려 이벤트 루프를 살려 둡니다. Node 애드온에서 가장 중요한 패턴입니다.
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();
}
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회 ★
AsyncWorker 로 만드세요.
규칙은 하나 — Execute() 안에서 JS 를 만지지 않기.
입력은 생성자에서 Persistent 로 잡아 두고,
결과는 OnOK() 에서 만듭니다. 그리고 동기·비동기 둘 다 제공하는 것이 표준입니다.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) })
// ⚠ 네이티브 세그폴트는 이것으로도 못 잡습니다 (신호 수준)
한 단계 더 — 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() 를 빼먹으면 프로세스가 끝나지 않습니다.
이벤트가 잦으면 배치로 묶어 전달하세요.클래스 — 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 든 무관
}
리소스 누수 테스트
// 반복 생성·해제 후 메모리 확인 ★
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 래퍼에서.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
--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 이 현재 플랫폼에 맞는 것만 설치합니다 ★
설치 실패를 부드럽게
// 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))
한 단계 더 — 바이너리 크기와 호환성 🧬 전문가
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+
npm ci 를 도는 팀에서
100MB 패키지는 실질적인 비용입니다.
20MB 를 넘으면 플랫폼별 optionalDependencies 로 분리하세요.
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)"
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`)
한 단계 더 — 호환성을 지키는 설계 🧬 전문가
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)
"
asarUnpack 을 잊지 마세요.
크래시가 앱을 죽이는 것이 문제라면 utilityProcess 로 격리하세요.한 번 만들어
모든 언어에서
부릅니다
바인딩 라이브러리 없이, 컴파일도 없이 파이썬에서 .dll·.so 를 바로 부르는 길입니다.
문법은 언어마다 달라도 C 함수를 부르는 법은 모든 언어가 압니다.
그래서 C ABI 가 언어 사이의 공용어입니다.
C ABI 가 공용어인 이유
C++ 의 클래스도, 템플릿도, 예외도 다른 언어는 이해하지 못합니다. 모든 언어가 공통으로 아는 것은 “C 함수를 부르는 방법” 하나뿐입니다.
extern "C" 로 감쌉니다 — 안은 마음껏 C++ 를 써도 됩니다.ABI 란 무엇인가
API : 소스 코드 수준의 약속 — "이 함수는 이런 인자를 받는다"
ABI : 기계어 수준의 약속 — 실제로 어떻게 부르는가
· 인자를 어느 레지스터·스택 위치에 놓는가
· 반환값은 어디로 받는가
· 스택 정리는 누가 하는가
· 구조체는 메모리에 어떻게 배치되는가
· 함수 이름이 심볼로 어떻게 기록되는가
C ABI 는 이 규칙이 OS·아키텍처마다 '문서로 고정'되어 있습니다.
그래서 어떤 언어로 만들었든 이 규칙만 지키면 서로 부를 수 있습니다.
★ C++ 는 표준 ABI 가 없습니다
같은 리눅스에서도 GCC 와 Clang 의 이름 맹글링·예외 구현이 다를 수 있고,
MSVC 는 아예 다릅니다. 그래서 C++ 클래스를 그대로 노출하면
'같은 컴파일러로 빌드한 경우에만' 동작합니다(🔬 d07).
두 가지 길 — 무엇을 고를까
| 바인딩(pybind11) | C ABI + FFI | |
|---|---|---|
| 대상 언어 | 파이썬 전용 | 거의 모든 언어 |
| 빌드 | 파이썬 버전마다 | 한 번만 ★ |
| 표현력 | 클래스·예외·numpy | 함수·숫자·포인터만 |
| 타입 안전성 | 컴파일러가 검사 | 없음 — 틀리면 크래시 |
| 호출 비용 | 낮음 | ctypes 는 조금 큼 |
| 설치 | wheel 빌드 필요 | 파일 하나 복사 |
.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; // 절대 밖으로 안 나가게
}
}
실무에선 이렇게 — 누가 이 방식을 쓰나 🔴 실무
우리가 매일 쓰는 것들이 이 구조입니다
- SQLite — C API 하나로 파이썬·자바·Go·러스트·모바일 전부가 씁니다
- OpenSSL · zlib · libcurl — 시스템 라이브러리의 표준 형태
- CUDA · OpenCL — GPU 드라이버 인터페이스
- ffmpeg(libav*) — 거의 모든 영상 처리 도구의 뿌리
- ONNX Runtime · llama.cpp — C++ 로 만들고 C API 로 노출한 최신 사례
- OS API 전부 — Win32 · POSIX · 시스템 콜
설계할 때 먼저 정할 것
/* ① 이름 규칙 — 접두어를 반드시 붙입니다 (전역 심볼이므로) */
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);
extern "C" 껍질 + 자유로운 C++ 내부.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 확인 |
윈도우 LNK2019 | dllimport/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')
"
PATH 에 있어도
DLL 을 자동으로 찾지 않습니다. os.add_dll_directory() 를
명시적으로 불러야 합니다. “리눅스에선 되는데 윈도우에서만 못 찾는” 문제의 단골 원인입니다.
extern "C" 를 붙이고,
__declspec(dllexport) / visibility("default") 로 내보내야 합니다.
문제가 생기면 nm -DC 로 실제 심볼 이름부터 확인하세요.헤더 설계 — 불투명 포인터
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* 로 하면 아무것도 못 잡습니다. ★ */
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);
ctypes 호출이
사실상 직렬화되지만, ctypes 는 호출 중 GIL 을 해제합니다.
즉 C 함수는 진짜로 동시에 실행될 수 있습니다.
“파이썬이니까 안전하겠지”는 FFI 에서는 성립하지 않습니다.
typedef struct mylib_engine mylib_engine; 처럼
이름만 두고, 실제 정의는 .cpp 안에.
create/destroy 쌍과 포인터+길이 쌍, 이 두 관용구가 C API 설계의 기본입니다.파이썬 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순위 원인입니다.
타입 대응표
| C | ctypes |
|---|---|
int32_t | c_int32 |
int64_t · size_t | c_int64 · c_size_t |
double · float | c_double · c_float |
uint8_t | c_uint8 |
const char* | c_char_p ★ |
void* · 핸들 | c_void_p |
double* | POINTER(c_double) |
bool | c_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(), \
"구조체 크기 불일치 — 헤더와 파이썬 정의가 어긋났습니다"
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) # 전역이나 인스턴스 멤버로 보관
한 단계 더 — 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 로 받아 파이썬 클래스로 감싸세요.
빌드도 설치도 없다는 것이 이 방식의 가장 큰 장점입니다.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_])
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, "필드 위치 변경");
실무에선 이렇게 — 세 방식 비교 🔴 실무
언제 무엇을
| ctypes | cffi ABI | cffi API | |
|---|---|---|---|
| 의존성 | 없음 | cffi | cffi + 컴파일러 |
| 선언 방식 | 손으로 | C 문법 복사 | 진짜 헤더 |
| 타입 검증 | 없음 | 실행 시 | 빌드 시 ★ |
| 속도 | 느림 | 비슷 | 빠름 |
| 정렬 안전 | 수동 | 수동 | 자동 |
| 배포 | 가장 쉬움 | 쉬움 | wheel 필요 |
공통으로 지킬 것
# ① 파이썬 쪽 래퍼로 감싸 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()
"
sizeof·offsetof 를 양쪽에서 대조하세요.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 })
이렇게도 쓸 수 있다 — 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();
--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 버전마다 재빌드 |
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": "." }]
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 으로 비교
}
Deno.dlopen,
Bun 은 bun:ffi 로 같은 .so 를 부릅니다.
핵심 규칙 둘 — 서버에서는 비동기로 부르고,
비동기 중에는 버퍼 참조를 유지하세요.문자열과 메모리 소유권 규약
FFI 버그의 절반이 여기서 납니다 — “이 메모리는 누가 해제하는가”를 정하지 않으면 반드시 새거나 두 번 해제됩니다.
네 가지 소유권 패턴
/* ① 호출자가 버퍼를 주고, 라이브러리가 채운다 ★ 가장 안전 */
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 주석 · 헤더 상단의 규약 설명 */
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);
실무에선 이렇게 — 규약을 문서와 코드로 못 박기 🔴 실무
헤더에 규약을 적습니다
/**
* 엔진의 이름을 가져옵니다.
*
* @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);
누수를 자동으로 잡기
# 파이썬 래퍼는 항상 컨텍스트 매니저로
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 로 돌립니다
· 파이썬 래퍼에 반복 호출 누수 테스트를 둡니다
콜백을 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
실무에선 이렇게 — 스레드를 넘나드는 콜백 🔴 실무
어느 스레드에서 불리는지가 전부입니다
/* 헤더에 반드시 명시하세요 */
/* ① 같은 스레드에서 불림 (동기) — 가장 안전 ★ */
/* "콜백은 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 간격이 적당합니다.
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) # 경계 비용 없이 호출됩니다
@cfunc 은 강력한 우회로입니다 — 파이썬 코드를
C ABI 함수 포인터로 컴파일해 주므로, 원소마다 불려도
GIL 도 인터프리터도 거치지 않습니다.
사용자 정의 규칙이 꼭 필요한 라이브러리에서 고려할 만합니다.
void* user 가 표준형입니다.
파이썬에서는 콜백 객체를 반드시 살려 두고,
예외를 콜백 밖으로 내보내지 마세요(에러 코드로 변환).
그리고 가능하면 콜백 대신 폴링이 훨씬 안전합니다.에러 코드와 버전 관리
예외를 쓸 수 없으니 반환값으로 실패를 알립니다. 그리고 나중에 기능을 추가해도 옛 사용자가 안 깨지게 설계해야 합니다.
에러 코드 설계
/* ① 값을 명시적으로 고정 — 나중에 순서가 바뀌면 안 됩니다 */
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 값 재배치 |
| 동작 개선(같은 결과) | 기본값·의미 변경 ★ |
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 에서 제거 예정" */
_ex 접미사로 함수를 추가하고 옛 것을 유지하는 방식이
세 OS 에서 모두 통하고 이해하기도 쉽습니다.
last_error 로 제공합니다.
파이썬 래퍼에서 표준 예외를 다중 상속한 예외로 변환하세요.
호환성은 구조체에 크기 필드를 두고 필드는 뒤에만 추가하면 지킬 수 있습니다.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
...
계약 테스트로 일관성 유지
# 모든 언어 바인딩이 같은 테스트 케이스를 통과해야 합니다
# 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).
.so 하나를
C#(LibraryImport) · 자바(FFM) · Go(cgo) · Rust · Ruby · Julia 에서
전부 부를 수 있습니다. 언어별 함정은 거의 동일합니다 —
GC 의 객체 이동 · 콜백 수명 · 예외 전파.
배열을 한 번에 넘기는 설계가 이 모두를 한꺼번에 해결합니다.내 PC 에서만
되는 코드는
없는 것과 같습니다
C++ 에서 가장 많은 시간을 잡아먹는 것은 문법이 아니라 빌드와 배포입니다. 세 OS 에서 도는 CMake 하나, 사용자가 컴파일하지 않는 배포, 버그를 자동으로 잡아 주는 Sanitizer — 이 셋이면 대부분 해결됩니다.
컴파일과 링크 — 실제로 무슨 일이
에러 메시지를 읽으려면 어느 단계에서 났는지부터 알아야 합니다. 네 단계만 이해하면 대부분의 빌드 문제가 풀립니다.
네 단계
# ① 전처리 — #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 # 실행할 때 필요한 공유 라이브러리
에러를 단계로 구분하기 ★
| 메시지 | 단계 · 원인 |
|---|---|
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
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
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 세 줄부터.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()
이렇게도 쓸 수 있다 — 자주 쓰는 조각들 🟡 중급
조건 분기와 옵션
# 플랫폼별
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}=.)
target_link_libraries·target_include_directories 에
PUBLIC/PRIVATE 만 정확히 붙이면 나머지는 자동입니다.
빌드 타입 지정과 compile_commands.json 생성을 잊지 마세요.의존성 — 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
# → 이 폴더를 통째로 옮기면 인터넷 없이 빌드됩니다
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).
디버그 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 를 돌리세요
한 단계 더 — 프로파일 기반 최적화 🧬 전문가
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 여부)
-DCMAKE_BUILD_TYPE=Release 를 빠뜨리면
최적화가 안 됩니다 — 성능 이야기의 출발점입니다.
운영 배포는 RelWithDebInfo + 심볼 분리가 좋고,
경고는 전부 켜서 에러로 만드세요.
Debug 에서만 되는 코드는 UB 를 의심하고 Sanitizer 를 돌리세요.윈도우 · 리눅스 · 맥 — 실제로 다른 것들
“크로스 플랫폼”이라고 해도 부딪히는 지점은 정해져 있습니다. 그 목록만 알면 대부분 피할 수 있습니다.
차이 목록
| Windows | Linux | macOS | |
|---|---|---|---|
| 컴파일러 | MSVC | GCC | Clang |
| 공유 라이브러리 | .dll+.lib | .so | .dylib |
| 기호 내보내기 | 명시 필요 ★ | 기본 전부 | 기본 전부 |
| 경로 구분 | \ (그러나 /도 됨) | / | / |
| 파일명 대소문자 | 구분 안 함 ★ | 구분 | 기본 구분 안 함 |
long 크기 | 4바이트 ★ | 8바이트 | 8바이트 |
| 라이브러리 경로 | 같은 폴더·PATH | RPATH·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. 파일이 커지지만 의존성이 없어집니다 ★
$<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 }}
자주 겪는 증상과 해결
| 증상 | 원인 · 해결 |
|---|---|
리눅스에서만 No such file | 파일명 대소문자 ★ |
MSVC 만 min/max 에러 | NOMINMAX 정의 |
| MSVC 만 한글 깨짐·C4819 | /utf-8 옵션 |
맥에서 library not loaded | @rpath·install_name_tool |
리눅스만 undefined reference to pthread | Threads::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)
# ⚠ 크로스 빌드는 '컴파일만' 검증합니다. 반드시 실행 테스트를 따로 하세요
long 크기, 라이브러리 경로.
코드에서는 std::filesystem·고정폭 정수·표준 스레드로 흡수하고,
나머지는 CMake 조건 분기와 파일 분리로 처리하세요.파이썬 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
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'}
한 단계 더 — 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+
→ 큰 의존성은 별도 패키지로 분리하거나
시스템 설치를 요구하는 편이 나을 수 있습니다
cibuildwheel 설정 몇 줄이면
세 OS × 여러 파이썬 버전 wheel 이 자동으로 만들어집니다.
auditwheel 이 의존 라이브러리를 wheel 안에 포장해 주므로
사용자는 아무것도 설치할 필요가 없습니다.
배포 전에 컴파일러 없는 도커 이미지에서 설치 테스트를 반드시 하세요.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
실무에선 이렇게 — 릴리스 자동화 🔴 실무
태그 하나로 전부
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분 (플랫폼 병렬)
한 단계 더 — 공급망 보안 🧬 전문가
빌드 출처 증명
# ① 신뢰 발행 — 토큰 없이 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__ · 절대 경로 · 타임스탬프를 제거하세요
동적 · 정적 링크와 배포 의존성
“내 PC 에선 되는데 배포하면 .so 를 못 찾습니다” —
C++ 배포에서 가장 흔한 문제이고, 원인은 몇 가지뿐입니다.
정적 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
# 멀티스테이지로 빌드 도구 없이 결과만
실무에선 이렇게 — 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)를 쓰는 이유가 같습니다.
한 단계 더 — 로딩 과정과 최적화 🧬 전문가
동적 링크의 실행 비용
# 프로그램 시작 시 동적 링커가 하는 일
# ① 필요한 .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 로.디버거로 붙기
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 가 빠릅니다 — 다음 섹션입니다.Sanitizer — 버그를 자동으로 잡습니다
플래그 하나면 메모리 오류·미정의 동작·데이터 경쟁을 정확한 줄 번호와 함께 잡아 줍니다. C++ 를 안전하게 쓰는 가장 실용적인 방법입니다.
세 가지 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
출력이 이렇게 나옵니다
==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 # 어떤 보호가 켜졌는지
-fsanitize=address,undefined 한 줄이면
메모리 버그의 대부분이 정확한 줄 번호와 함께 잡힙니다.
UBSan 은 비용이 거의 없으니 개발 빌드에 항상,
ASan·TSan 은 CI 별도 잡으로 돌리세요.
파서가 있다면 퍼징도 몇 분만 돌려 보세요 — 반드시 뭔가 나옵니다.C++ 가 빠른 게
아니라
내가 빠르게 씁니다
생각 없이 쓴 C++ 는 numpy 보다 느립니다. C++ 가 빠른 이유는 메모리 배치와 복사를 내가 정할 수 있기 때문이고, 그걸 안 하면 이점이 없습니다. 이 탭은 "왜 안 빨라지나"에 답합니다.
측정 먼저 — 벤치마크를 신뢰할 수 있게
“빨라진 것 같다”는 증거가 아닙니다. 제대로 재는 법부터 잡지 않으면 나머지 아홉 섹션은 의미가 없습니다.
최적화의 순서 ★
이 순서를 지키면 대부분 ①②에서 끝납니다
① 알고리즘 · 자료구조 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); });
이렇게도 쓸 수 있다 — 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
실무에선 이렇게 — 프로파일링으로 병목 찾기 🔴 실무
추측하지 말고 프로파일러를
# ★ 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)
cachegrind)처럼
결정적인 지표를 쓰세요.
perf stat 의 IPC 와 cache-miss 만 봐도
“계산이 문제인지 메모리가 문제인지”가 갈립니다.메모리 레이아웃과 캐시
같은 알고리즘도 데이터가 어떻게 놓여 있느냐에 따라 수 배가 갈립니다. C++ 를 쓰는 진짜 이유가 여기 있습니다.
메모리는 계층입니다
대략적인 지연 시간 (현대 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 는 거의 답이 아니다"라고 한 이유)
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 가 낫습니다
// → 접근 패턴을 보고 정하세요
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
할당 줄이기 — 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);
}
...
};
객체 풀
// 같은 타입을 자주 만들고 버린다면 — 재사용 풀
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;
reserve · 버퍼 재사용 · 아레나(PMR) 세 가지면 대부분 해결됩니다.
모듈을 만든다면 호출당 할당 횟수를 0에 가깝게 만드는 것이 목표입니다.복사 제거 — 값 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)
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 이 클수록 유리)
실무에선 이렇게 — 데이터가 흘러가는 경로 설계 🔴 실무
경계에서 몇 번 복사되는지 세기
파이썬 모듈의 전형적인 데이터 경로
❌ 나쁜 경로 (복사 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 에서 반환 방식 비교
void f(T& out))가
코드를 읽기 어렵게 만드는 대가만 치르는 경우가 많습니다
(버퍼 재사용이 목적일 때는 예외 — e03).
& 누락, 값 인자, map 순회 타입,
auto, 문자열 조립.
clang-tidy performance-* 가 전부 자동으로 잡아 줍니다.
그리고 값 반환은 이제 공짜이니 두려워하지 마세요.분기 예측과 브랜치리스
if 하나가 예측을 빗나가면 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
perf stat 의 branch-misses 가 5% 를 넘을 때만 손대세요.
최선의 해법은 브랜치리스 트릭이 아니라
데이터를 정렬하거나 타입별로 묶어 예측 가능하게 만드는 것입니다.SIMD — 한 번에 여러 개
CPU 는 double 4~8개를 한 명령으로 계산할 수 있습니다.
먼저 컴파일러가 자동으로 하게 유도하고, 그래도 안 되면 직접 씁니다.
__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];
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
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
__restrict 와 단순한 루프 구조.
-fopt-info-vec 로 실제로 벡터화됐는지 확인하고,
직접 쓸 때는 highway·std::simd 로 이식성을 지키세요.
그리고 이미 있는 라이브러리를 먼저 찾아보세요.병렬 — 여러 코어를 실제로
파이썬 혼자서는 불가능한 지점입니다. 다만 코어를 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² 개가 됩니다 ★
실무에선 이렇게 — 모듈에서의 병렬화 🔴 실무
스레드 수를 사용자가 정하게 ★
// ⚠ 라이브러리가 마음대로 모든 코어를 쓰면
// 이미 멀티프로세싱으로 도는 환경에서 경합이 일어나 오히려 느려집니다
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 을 놓아야 파이썬 스레드도 병렬로 돕니다
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
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 서버에서 성능 차이가 더 큽니다
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); // ✅
alignas(64) 패딩입니다.컴파일러가 만든 코드 읽기
“최적화가 됐겠지”를 확인으로 바꾸는 방법입니다. 어셈블리를 다 읽을 필요는 없고, 몇 가지 신호만 볼 줄 알면 됩니다.
보는 방법
# ① 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 · vmulpsxmm·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 는 색으로 연결해 줍니다
★ 목표는 '어셈블리를 쓰는 것'이 아니라
'내 가정이 맞는지 확인하는 것' 입니다.
한 단계 더 — 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 를 켜면 알아서 해 주지만 정확도를 포기합니다
// → 직접 쓰는 편이 통제 가능합니다
// 정수는 결합법칙이 성립하므로 컴파일러가 알아서 합니다
vaddpd·ymm 이 있으면 벡터화, call 이 있으면 인라인 실패,
memcpy·new 가 있으면 복사·할당입니다.
-Rpass-missed 리포트가 "왜 안 됐는지"를 문장으로 알려 줍니다.파이썬 대비 실제로 몇 배인가
과장 없이 정리합니다. 어떤 경우에 몇 배가 나오고, 어떤 경우에 안 나오는지 — 이걸 알아야 도입 판단을 할 수 있습니다.
경우별 기대 배수
| 작업 | 순수 파이썬 대비 |
|---|---|
| 원소마다 분기·상태가 있는 루프 | 30~100배 ★ |
| 문자열·바이트 파싱 | 20~100배 |
| 재귀·트리 탐색 | 20~50배 |
| 단순 배열 연산 | 50~200배 (단, numpy 대비 1~3배) |
| 이미 numpy 로 벡터화된 것 | 1~3배 ⚠ |
| pandas 그룹 연산 | 3~20배 (polars 와 비슷) |
| I/O 대기가 대부분 | 1배 — 의미 없음 ❌ |
| 멀티코어 활용 | × 코어 수 (GIL 해제 시) ★ |
왜 그렇게 차이가 나나
파이썬 루프 한 바퀴에 일어나는 일
· 바이트코드 디스패치
· 객체 참조 카운트 증감
· 정수·실수도 힙 객체 (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)
크기별로 재야 하는 이유
전형적인 결과 형태
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)
한 단계 더 — 한계와 대안 🧬 전문가
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++ 로 갈 이유가 없습니다
numba·polars 로 충분한 경우가 생각보다 많고,
그것을 먼저 시도해 보는 것이 프로다운 판단입니다(🐍 y01).
“컴파일러 버그”가
아니라
내 코드의 UB 입니다
여기부터는 “왜 그렇게 동작하는가”를 다룹니다. Release 에서만 터지는 버그, 라이브러리가 안 붙는 이유, 운영 중 크래시 — 원인을 추측이 아니라 구조로 설명할 수 있게 됩니다.
번역 단위 · ODR · 링커
진단 없이 조용히 메모리가 깨지는 유일한 규칙 위반이 ODR 입니다. 링크는 성공하는데 실행이 이상하다면 여기를 의심해야 합니다.
하나의 정의 규칙(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; }
// → 어느 것이 남을지 모릅니다
이렇게도 쓸 수 있다 — 링커가 하는 일 🟡 중급
심볼 해석 과정
① 각 오브젝트의 심볼 테이블을 읽습니다 (제공 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; }
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;
}
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 에 도달하는 경로 전체를 자유롭게 다룰 수 있습니다
자주 밟는 UB 목록 ★
| UB | 탐지 |
|---|---|
| 초기화 안 된 변수 읽기 | MSan · 경고 |
| 배열 범위 초과 | ASan ★ |
| 해제 후 사용 · 이중 해제 | ASan |
| 널 포인터 역참조 | UBSan |
| 부호 있는 정수 오버플로 | UBSan ★ |
0으로 나누기 · INT_MIN / -1 | UBSan |
| 시프트 폭이 비트 수 이상 | UBSan |
| strict aliasing 위반 | ⚠ 탐지 어려움 |
| 정렬되지 않은 접근 | UBSan |
| 수명이 끝난 객체 접근 | ASan |
| 데이터 경쟁 | TSan |
| 반환값 없는 non-void 함수 | 경고 · UBSan |
| 소멸자에서 예외 던지기 | — |
-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 를 직접 관찰하기 🧬 전문가
코드가 사라지는 것을 보기
// 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 와 정적 분석이 계속 중요합니다
-fsanitize=undefined 가 대개 그 자리를 짚어 줍니다.객체 수명과 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());
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)
__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 # ★ 값이 망가진 시점으로 거꾸로
한 단계 더 — 포인터 프로버넌스 🧬 전문가
포인터는 주소만이 아닙니다
// 표준은 포인터에 '어느 객체에서 유래했는가'(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) — 지금은 '보수적으로 쓰는 것'이 답입니다
memcpy 하세요 — 비용은 0 입니다.
__restrict 는 벡터화를 여는 열쇠이지만 약속을 어기면 UB 입니다.
수명 버그는 ASan 이 해제 위치까지 알려 줍니다.vtable 과 가상 호출 비용
가상 함수는 “느리다”가 아니라 “인라인을 막는다”가 정확한 표현입니다. 실제 비용을 수치로 확인하고 대안을 정리합니다.
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
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)
final 을 붙이고 호출 단위를 배열 전체로 키우면 대부분 사라집니다.
다만 프로파일러가 가리킬 때만 손대세요 —
전부 템플릿으로 바꾸면 빌드 시간과 가독성을 잃습니다.예외의 실제 구현
“예외는 느리다”는 절반만 맞습니다. 던지지 않으면 비용이 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
noexcept 는 성능에 직결되니 반드시 붙이고,
언어 경계에서는 예외를 통과시키지 마세요.메모리 모델과 memory_order
멀티스레드에서 다른 CPU 가 내 쓰기를 언제 보게 되는가 를 정하는 규칙입니다. 락 프리 코드를 쓰려면 반드시 알아야 합니다.
왜 순서가 뒤바뀌나
세 곳에서 재배치가 일어납니다
① 컴파일러 — 최적화로 명령 순서를 바꿉니다
② 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)
한 단계 더 — 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 에서는 차이가 있으니 핫 경로라면 검토할 가치가 있습니다 ★
release/acquire 쌍,
단순 카운터는 relaxed, 확신이 없으면 기본값을 쓰세요.
그리고 x86 에서만 테스트하지 마세요 — ARM 에서 드러납니다.ABI 호환성 — 배포의 벽
C++ 에는 표준 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
실무에선 이렇게 — 배포 전략 🔴 실무
상황별 선택
| 배포 형태 | 전략 |
|---|---|
| 소스 배포 (헤더 전용) | 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)
한 단계 더 — 왜 표준 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 불일치입니다. 라이브러리를 다시 빌드하거나
플래그를 맞춰야 합니다.
abidiff 로 CI 에서 감시하세요.템플릿 인스턴스화와 컴파일 시간
템플릿은 쓰는 곳마다 코드를 새로 생성합니다. 그래서 빌드가 느려지고 바이너리가 커집니다 — 그 구조와 대책입니다.
무슨 일이 일어나나
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
한 단계 더 — 템플릿 메타프로그래밍의 비용 🧬 전문가
재귀보다 폴드·팩 확장
// ❌ 재귀 템플릿 — 인스턴스가 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
-ftime-trace 로 상위 원인을 찾고,
ccache·전방 선언·명시적 인스턴스화로 줄이세요.
재귀 템플릿보다 constexpr 함수와 폴드 표현식이 훨씬 쌉니다.커스텀 할당자
메모리를 어떻게 나눠 줄지 직접 정합니다. 지연 시간이 일정해야 하는 게임·거래 시스템에서 씁니다.
언제 필요한가
기본 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_; }
};
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 로 범위를 한정하세요.
monotonic_buffer_resource)로 충분하고,
직접 만들 때는 정렬·수명·스레드 안전성을 반드시 지켜야 합니다.
그리고 p99 개선을 수치로 보여 도입을 정당화하세요.인트린식과 인라인 어셈블리
컴파일러가 뽑아 주지 못하는 명령을 직접 씁니다. 마지막 수단이고, 대가는 이식성과 유지보수입니다.
인트린식이 필요한 경우
// ① 컴파일러가 자동으로 못 내는 명령
#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배 미만이면 대개 유지보수 비용이 더 큽니다.
__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 감지로 이식성을 지키세요.크래시 덤프 분석
운영 중 죽은 프로그램의 덤프 파일 하나로 원인을 찾아냅니다. 재현할 수 없는 버그를 다루는 유일한 방법입니다.
덤프를 남기게 설정하기
# ① 코어 덤프 활성화
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 설정
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) |
SIGABRTfree() 근처 | 힙 손상 · 이중 해제 |
| 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 로 시작합니다.C++ 를 쓰지 말아야 할 때
마지막 섹션은 안 쓰는 판단입니다. 기술 선택은 팀이 6개월 뒤에도 고칠 수 있는가로 결정됩니다.
C++ 가 답이 아닌 경우
- I/O 가 병목 — 계산 언어를 바꿔도 그대로입니다(⚡ e01)
- 이미 numpy·polars 로 충분 — 1~3배를 위해 치를 비용이 아닙니다(⚡ e10)
- 요구가 자주 바뀌는 초기 코드 — 빌드·배포 부담이 개발 속도를 깎습니다
- 팀에 C++ 경험자가 없음 — 버스 팩터 1 은 위험합니다
- 크래시가 서비스를 죽이면 안 됨 — 세그폴트는 프로세스 전체를 죽입니다(🐍 y12)
- numba·Cython 으로 충분 — 빌드 파이프라인 없이 70~90% 성능(⚡ e10)
- 보안이 최우선 — 메모리 안전 언어가 구조적으로 유리합니다
대안 비교
| 선택지 | 고르는 이유 |
|---|---|
| numba | 파이썬 코드 그대로 · 빌드 불필요 ★ |
| Cython | 점진 전환 · 기존 코드 유지 |
| polars · DuckDB | 표 데이터는 직접 짜는 것보다 빠릅니다 |
| Rust | 메모리 안전 · 빌드 편함 · 배포 쉬움 (🦀) |
| Go | 서비스 · 동시성 · 배포 단순 |
| C++ | 기존 C/C++ 자산 · 최고 성능 · 세밀한 제어 |
| GPU | 대규모 병렬 · 대역폭 |
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 에 상시
한 단계 더 — 여기서 어디로 🧬 전문가
이 가이드에서 이어지는 길
- 🦀 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 가이드에서도 같은 원칙이 반복될 것입니다.
아무것도 없는 PC 에
C / C++ 개발 환경을
처음부터 세웁니다
C++ 를 포기하는 사람의 절반은 문법이 아니라 설치에서 포기합니다.
“g++ 은(는) 내부 또는 외부 명령이 아닙니다”, “cl.exe 를 찾을 수 없습니다” —
이 탭은 그 벽을 넘기 위한 것입니다.
내려받을 정확한 주소, 설치 마법사에서 체크할 항목, PATH 를 넣는 화면 경로,
설치가 됐는지 확인하는 명령까지 하나도 건너뛰지 않고 적었습니다.
이 탭을 마치면 빈 PC 를 받아도 30분 안에 빌드가 도는 상태로 만들 수 있습니다.
컴파일러 고르기 — MSVC · GCC · Clang 무엇을 언제
파이썬은 “파이썬을 깐다”가 끝입니다. C++ 는 컴파일러가 여러 종류이고, 어느 것을 깔았느냐에 따라 명령어도 에러 메시지도 만들어지는 파일도 달라집니다. 그래서 가장 먼저 결정할 것은 컴파일러입니다. 5분이면 정해집니다.
셋의 정체 — 이름부터 정리
| 이름 | 실행 파일 | 2026 기준 버전 |
|---|---|---|
| MSVC 마이크로소프트 | cl.exelink.exe | 툴셋 v14.4x VS 2022 · VS 2026 |
| GCC GNU | gcc (C)g++ (C++) | GCC 15 리눅스 기본 |
| Clang LLVM | clangclang++clang-cl | LLVM 20 이상 맥 기본 |
같은 코드, 다른 명령 — 첫 인상
# 같은 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
/ 로 시작하고 GCC·Clang 은 - 로 시작합니다.
인터넷에서 복사한 명령이 안 먹는다면 대부분 이 차이입니다.
그래서 실무에서는 손으로 치지 않고 CMake 에 맡깁니다(s06).
한 단계 더 — 진짜 차이는 “런타임”입니다 🟡 중급
왜 섞어 쓰면 안 되나 — ABI
컴파일러가 다르면 만들어 낸 .lib/.a 파일을 서로 못 씁니다.
std::string 이 메모리에 놓이는 모양, 예외를 던지는 방식, 이름을 붙이는 규칙(맹글링)이
컴파일러마다 다르기 때문입니다. 이것을 ABI(Application Binary Interface)라고 합니다.
| 표준 라이브러리 | C 런타임 | |
|---|---|---|
| MSVC | MS STL | UCRT (msvcp140.dll) |
| MinGW UCRT64 | libstdc++ | UCRT |
| MinGW MINGW64 | libstdc++ | msvcrt.dll (옛것) |
| 리눅스 GCC | libstdc++ | glibc |
| 맥 Clang | libc++ | libSystem |
.lib 를 g++ 로 링크하면
undefined reference 폭탄을 맞습니다. 꼭 섞어야 하면 경계를
C 함수(extern "C")로 만들어야 합니다.
파이썬·Node 확장을 만들 거라면 ★
여기에는 선택의 여지가 거의 없습니다. 확장 모듈은 그 파이썬(또는 Node)을 빌드한 컴파일러와 같은 계열로 빌드해야 합니다.
| 대상 | 써야 하는 컴파일러 |
|---|---|
| 윈도우 · python.org 파이썬 | MSVC (cl.exe) ★ |
| 윈도우 · Node LTS | MSVC (node-gyp) ★ |
| 리눅스 휠(manylinux) | GCC (도커 안) |
| 맥 휠 | Apple Clang |
| MSYS2 파이썬 | MSYS2 GCC (짝이 맞을 때만) |
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 는 이걸 매 커밋마다 돌립니다.
지금 뭐가 깔려 있는지부터 확인
# 윈도우 PowerShell — 셋 다 물어봅니다
g++ --version
clang++ --version
cl # 버전만 뱉고 끝나면 정상
# 어디에 있는 것을 쓰고 있나 (★ 이게 더 중요합니다 — s11)
where.exe g++
where.exe cl
# 맥 · 리눅스
g++ --version ; clang++ --version
which -a g++ clang++
# 아무 것도 안 나오면 → 아직 아무것도 안 깔린 상태입니다.
# s02(윈도우) 또는 s05(맥·리눅스)로 가세요.
윈도우 ① — Visual Studio Build Tools 설치
윈도우에서 C++ 를 한다면 여기서 시작합니다. 파이썬 확장도, Node 애드온도, 대부분의 윈도우용 라이브러리도 전부 이 컴파일러를 전제로 합니다. 화면에 뭐가 뜨는지, 어디를 체크해야 하는지 하나씩 따라가겠습니다.
① 어디서 받나 — 정확한 경로
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 와 똑같은 것이 들어갑니다
② 명령 한 줄로 설치 (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
설치 마법사 화면 — 어디를 체크하나 🟢 기초
워크로드 탭 — 이것만 체크
[워크로드] [개별 구성 요소] [언어 팩] [설치 위치]
▲ 이 탭
데스크톱 및 모바일
☑ 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:\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" 본체
BuildTools 자리가
Community 로 바뀝니다(...\2022\Community\VC\...).
Enterprise·Professional 도 마찬가지입니다.
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 가 충돌하지 않음 ← ★
cl.exe 하나가 C 와 C++ 를 모두 컴파일합니다.
확장자가 .c 면 C 로, .cpp 면 C++ 로 처리합니다.
강제하려면 /TC(C) · /TP(C++) 를 씁니다.
참고로 MSVC 의 C 지원은 C11 · C17 이며(/std:c11, /std:c17),
C23 지원은 아직 부분적입니다 — 최신 C 표준이 필요하면 s03 의 GCC 를 쓰세요.
cl 이 안 됩니다 —
x64 Native Tools Command Prompt 를 쓰거나 Enter-VsDevShell 로 환경을 켜세요.윈도우 ② — MSYS2 + GCC(UCRT64) 로 g++ 갖추기
인터넷의 C++ 예제 대부분은 g++ -std=c++20 a.cpp -o a 로 시작합니다.
윈도우에서 그 명령을 그대로 쓰려면 MSYS2 를 깔면 됩니다.
단, MSYS2 에는 셸이 네 종류 있어서 여기서 대부분 헤맵니다 — 그것부터 정리합니다.
① 내려받기와 설치
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 버전
툴체인 설치 — 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- |
| MINGW64 | mingw-w64-x86_64- |
| CLANG64 | mingw-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
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 넣기 (재부팅 불필요)
# 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 항목 제거가 전부입니다
# (레지스트리에 남기는 것이 거의 없습니다)
g++ 를 쳐서 안 된다고
MSYS2 설치를 의심하지 마세요. UCRT64 셸이나 PATH 를 등록한 PowerShell에서 확인하세요.
pacman -Syu 두 번 →
UCRT64 셸에서 pacman -S mingw-w64-ucrt-x86_64-toolchain →
PATH 에 C:\msys64\ucrt64\bin. 개발용 셸은 언제나 UCRT64 입니다.윈도우 ③ — 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 도구" 를 체크했을 때만 보입니다)
세 가지 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++ |
clang++ 가 어느 쪽인지 알 수 없게 됩니다.
where.exe clang++ 로 항상 확인하고,
헷갈리면 CMake 에 절대 경로를 주세요(s11).
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
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 로도 충분합니다 |
ext4.vhdx)는
한 번 커지면 파일을 지워도 자동으로 줄지 않습니다.
wsl --shutdown 후 diskpart 의 compact vdisk 로 줄이거나,
최신 WSL 의 %UserProfile%\.wslconfig 에
[experimental] / sparseVhd=true 를 넣어 두면 자동으로 관리됩니다.
wsl --install 한 줄이며,
리눅스에 배포할 코드와 valgrind·perf 가 필요할 때 씁니다. 소스는 리눅스 홈에 두세요.macOS 와 Linux — 명령 한두 줄이면 끝납니다
윈도우에 비하면 허무할 정도로 간단합니다. 컴파일러가 OS 에 이미 있거나 패키지 관리자 한 줄이면 들어옵니다. 대신 “기본 컴파일러가 너무 낡았다”는 다른 종류의 문제가 생기는데, 그 해결까지 같이 봅니다.
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 # 되돌리기
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
/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++ # 대화형으로 선택
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)
xcode-select --install + brew install cmake ninja,
우분투는 apt install build-essential cmake ninja-build gdb,
페도라는 dnf group install "Development Tools", 아치는 pacman -S base-devel.
설치보다 “버전이 충분히 최신인가”를 확인하는 것이 더 중요합니다.CMake 와 Ninja — 첫 프로젝트를 만들어 봅니다
컴파일러를 깔았으니 이제 손으로 g++ 를 치지 않는 법을 배웁니다.
CMake 는 “어떤 OS·어떤 컴파일러든 같은 두 줄로 빌드되게” 해 주는 도구이고,
C++ 세계의 사실상 표준입니다. vcpkg·IDE·CI 가 전부 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
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
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)
-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 위치 등)를 여기에 둡니다
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 -S . -B build -G Ninja 와 cmake --build build 두 줄.
안 될 때는 --fresh 를 떠올리세요.패키지 관리자 — vcpkg 와 Conan 2
파이썬에는 pip install, Node 에는 npm i 가 있습니다.
C++ 에는 오랫동안 그런 게 없어서 라이브러리 하나 쓰려고 하루를 쓰는 일이 흔했습니다.
지금은 vcpkg 와 Conan 이 그 자리를 채웁니다. fmt 하나를 끝까지 붙여 보겠습니다.
① 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 fmt | vcpkg.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
완전한 예제 — 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-dynamic | MSYS2/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
find_package(fmt CONFIG REQUIRED) + target_link_libraries.
패키지 관리자를 바꿔도 프로젝트 코드는 그대로라는 뜻입니다.
언제 어느 것을 쓰나 ★
| 상황 | 선택 |
|---|---|
| 윈도우 중심 · Visual Studio | vcpkg |
| CMake 만 쓰는 소규모 팀 | vcpkg ★ |
| 사내 라이브러리를 배포해야 함 | Conan (사설 저장소) |
| 같은 코드를 여러 ABI 로 | Conan (프로필) |
| CI 에서 바이너리 캐시가 절실 | 둘 다 가능 |
| Makefile · Bazel 등 비CMake | Conan |
| 일단 fmt 하나 써 보고 싶다 | vcpkg |
헤더 몇 개짜리 작은 라이브러리라면 패키지 관리자 없이도 됩니다:
FetchContent_Declare(fmt GIT_REPOSITORY ... GIT_TAG 11.0.2) +
FetchContent_MakeAvailable(fmt).
의존성이 서너 개 이하면 이게 가장 간단합니다.
다만 빌드할 때마다 같이 컴파일되므로 무거운 라이브러리에는 맞지 않습니다.
git clone + bootstrap 이 설치의 전부이고,
프로젝트에는 vcpkg.json 에 이름 한 줄,
CMake 에 find_package + target_link_libraries 두 줄,
그리고 툴체인 파일 지정이면 끝납니다.
파이썬·Node 확장을 만들 거라면 트리플릿은 -static-md.편집기와 IDE — VS Code · Visual Studio · CLion
컴파일러가 있어도 빨간 줄이 잔뜩 뜨고 자동완성이 안 되면 개발이 안 됩니다.
C++ 편집기 설정의 핵심은 딱 하나 — compile_commands.json 입니다.
이 파일이 무엇이고 왜 전부인지부터 보고, 실제 설정 파일을 통째로 붙여 두겠습니다.
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 | clangd | |
|---|---|---|
| 설정 난이도 | 쉬움 | 보통 |
| 큰 프로젝트 속도 | 느려짐 | 빠름 ★ |
| 진단 정확도 | 보통 | 컴파일러와 동일 |
| 리팩터링 | 제한적 | 이름 바꾸기 등 |
| MSVC 전용 문법 | 강함 | 일부 약함 |
| 디버깅 | 담당 | 안 함 |
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 함수 안으로
.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
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 · 프로파일러가 통합되어 있습니다
· 단점: 무겁고, 큰 프로젝트에서 인덱싱이 오래 걸립니다
compile_commands.json 을 만들고 편집기가 찾게 하는 것입니다.
CMake 에 CMAKE_EXPORT_COMPILE_COMMANDS=ON,
VS Code 에 cmake.copyCompileCommands.
자동완성 엔진은 clangd 와 cpptools 중 하나만 켜세요.코드 품질 도구 — format · tidy · sanitizer 를 켭니다
C++ 는 틀린 코드도 잘 컴파일됩니다. 배열 밖을 읽어도, 해제한 메모리를 써도
컴파일러는 아무 말 없이 .exe 를 만들어 줍니다.
그래서 도구를 켜는 것이 문법을 배우는 것만큼 중요합니다.
설치는 대부분 이미 끝났고, 여기서는 설정 파일을 두는 것이 전부입니다.
① 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;
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배 |
자동화 — 사람이 잊어도 되게 🔴 실무
커밋 전 자동 검사 (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 "✓ 전부 통과"
runs-on: ubuntu-latest 에서 bash tools/check.sh 한 줄입니다.
로컬과 CI 가 같은 스크립트를 쓰는 것이
“CI 에서만 깨진다”를 없애는 가장 확실한 방법입니다.
.clang-format 과 .clang-tidy 두 파일을 두고,
CMake 에 경고 옵션을 켜고, 테스트는 ASan·UBSan 빌드로 돌립니다.
도구가 잡아 주는 버그를 손으로 찾느라 시간을 쓰지 마세요.파이썬 · Node 연동을 위한 추가 설치
이 가이드의 최종 목적은 파이썬·Node 에서 불러 쓸 모듈을 만드는 것입니다.
그러려면 컴파일러 말고 몇 가지가 더 필요합니다 —
파이썬의 개발용 헤더, 바인딩 라이브러리, 그리고 Node 쪽의 node-gyp 요구사항.
전부 명령 몇 줄이지만, 빠뜨리면 원인을 알기 어려운 에러가 납니다.
① 파이썬 개발 파일 — 있는지 확인부터
# 지금 쓰는 파이썬이 어느 것인지 (★ 가장 먼저)
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 가 있어야 합니다
헤더가 없을 때 — 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-addon-api 는 안정된 ABI 를 쓰므로
한 번 빌드하면 여러 Node 버전에서 그대로 돕니다.
| 에러 | 원인 | 해결 |
|---|---|---|
Python.h: No such file | 개발 헤더 없음 | apt install python3-dev |
LNK1104: python313.lib | libs 폴더를 못 찾음 | -DPython_EXECUTABLE= 로 명시 |
ImportError: DLL load failed | 의존 DLL 누락 · 비트 불일치 | 같은 트리플릿으로 재빌드(s07) |
ModuleNotFoundError (빌드는 성공) | sys.path 에 없음 | 빌드 폴더를 경로에 추가 |
gyp ERR! find VS | MSVC 워크로드 없음 | s02 재설치 |
gyp ERR! find Python | 파이썬을 못 찾음 | npm config set python ... |
NODE_MODULE_VERSION 불일치 | 다른 Node 로 빌드 | N-API 로 전환 · 재빌드 |
python3-dev) 와 pip install pybind11,
Node 쪽은 파이썬 3.x + MSVC 워크로드가 전부입니다.
그리고 언제나 “어느 파이썬/노드로 빌드했는가”를 확인하세요 — 실패의 대부분이 여기서 옵니다.환경 변수 · PATH — 완전 정복
설치가 안 된 게 아니라 못 찾는 것인 경우가 압도적으로 많습니다.
“왜 g++ 가 안 되지”, “왜 다른 버전이 잡히지”, “왜 VS Code 에서만 안 되지” —
전부 PATH 이야기입니다. 이 섹션만 이해하면 설치 문제의 절반이 사라집니다.
사용자 변수 vs 시스템 변수
| 사용자 변수 | 시스템 변수 | |
|---|---|---|
| 적용 범위 | 내 계정만 | 모든 계정 |
| 권한 | 필요 없음 | 관리자 |
| 저장 위치 | HKCU\Environment | HKLM\...\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
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 # 셸을 새로 시작
순서 충돌 — 실제로 겪는 사고 🟡 중급
증상과 진단
[증상] 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).
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
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_PATH | find_package 검색 경로 ★ |
CMAKE_GENERATOR | 기본 생성기 |
VCPKG_ROOT | vcpkg 위치(s07) |
LD_LIBRARY_PATH (리눅스) | 실행 시 .so 검색 |
DYLD_LIBRARY_PATH (맥) | 같은 용도 (SIP 제한 있음) |
ASAN_OPTIONS | Sanitizer 동작(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')
where.exe / which -a, 추가는 setx 대신 PowerShell 스크립트로.설치 문제 사전 — 증상 → 원인 → 해결
여기까지 왔는데 안 되는 경우를 위한 섹션입니다. C++ 설치 문제는 종류가 의외로 적습니다 — 같은 20가지가 반복됩니다. 증상 문구로 찾아서 바로 해결할 수 있게 정리했습니다. 먼저 어느 단계에서 막혔는지 좁히는 것부터 시작합니다.
① 명령을 못 찾을 때 🟢 기초
| 증상 | 원인 | 해결 |
|---|---|---|
'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 found | MSYS2 가 아니라 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
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
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 는 남겨 두세요
그래도 안 될 때 — 상태 덤프 🔴 실무
환경 진단 한 방 스크립트 (윈도우)
# 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 을 다시 보세요.
컴파일러(MSVC / GCC / Clang) · 빌드 시스템(CMake + Ninja) · 패키지 관리자(vcpkg) · 편집기(VS Code + clangd) · 품질 도구(clang-format · clang-tidy · sanitizer) · 연동 도구(pybind11 · node-gyp). 이제 🅲 C 언어 나 🔵 C++ 기초 탭으로 넘어가 실제 코드를 쓰면 됩니다 — “환경이 안 돼서” 막히는 일은 이제 없습니다.
C++ 로
API 서버를
만듭니다
Spring Boot 나 FastAPI 로 서버를 만들어 본 사람을 기준으로 씁니다.
같은 GET /users/1 를 C++ 로 받으려면 무엇이 다른지, 어떤 프레임워크를 고르고
DB·JSON·인증·로그·배포를 어떻게 붙이는지 한 줄씩 따라갑니다.
10줄짜리 첫 서버에서 시작해 Drogon + PostgreSQL + Redis 로 만든 URL 단축 서비스까지 갑니다.
중요한 건 속도가 아니라 C++ 서버가 필요한 상황인지 먼저 가려내는 눈이라, 그 얘기부터 합니다.
C++ 서버는 언제 쓰나 — 프레임워크 지도
가장 값진 판단은 대체로 “C++ 로 안 만든다” 입니다. 그 선을 먼저 긋고 나서, 넘어온 경우에 어떤 프레임워크를 고를지 봅니다. C++ 웹 프레임워크는 종류는 많은데 실서비스에서 검증된 건 서너 개뿐입니다.
C++ 서버가 실제로 이기는 자리
| 이럴 때 유리 | 이럴 땐 별로 |
|---|---|
| 요청당 CPU 계산이 실제로 무거움 이미지·영상·행렬·매칭 엔진 | 요청 대부분이 DB·외부 API 대기 |
| 지연 시간의 꼬리가 계약 조건 p99 를 5ms 안에 — GC 정지가 없음 | 초당 수백 건 규모의 사내 서비스 |
| 이미 C++ 자산이 있음 엔진·SDK·모델 추론 코드 | 요구사항이 매주 바뀌는 초기 제품 |
| 연결 수가 수십만 게임·채팅·시세 스트리밍 | 팀에 C++ 를 읽는 사람이 없음 |
| 서버 대수·메모리 비용이 큼 같은 트래픽을 1/5 대수로 | 관리형 서비스(람다 등)에 얹어야 함 |
Spring Boot · FastAPI 를 아는 사람이 겪는 낙차
| Spring / FastAPI | C++ | |
|---|---|---|
| 패키지 | Maven · pip | vcpkg · Conan |
| 실행 | ./gradlew bootRun | 빌드 후 바이너리 |
| 라우팅 | 애너테이션·데코레이터 | 매크로 또는 .Get() |
| JSON 매핑 | 자동(Jackson·pydantic) | 직접 또는 glaze |
| ORM | JPA · SQLAlchemy | 얇음(Drogon ORM) |
| DI | 프레임워크가 해 줌 | 내가 조립 |
| 핫 리로드 | 있음 | 없음 — 빌드 대기 |
| 메모리 누수 | GC 가 처리 | ASan·수명 설계 |
한 단계 더 — 프레임워크 아홉 개를 한 표로 🟡 중급
비동기 모델 · 성숙도 · 쓰는 자리
| 이름 | 동시성 모델 | 성숙도·규모 | 이럴 때 씁니다 |
|---|---|---|---|
| cpp-httplib | 블로킹 + 스레드 풀 요청 1건 = 스레드 1개 | 헤더 파일 1개 매우 안정 · 의존성 0 | 배우기 · 사내 도구 · 테스트용 목 서버 · CLI 에 붙이는 관리 API |
| Crow | Asio 이벤트 루프 핸들러는 동기 | 헤더 온리 · 활발 | Flask 처럼 라우트를 죽 나열하고 싶을 때 |
| Drogon ★ | 이벤트 루프 N개 + C++20 코루틴 | 실서비스 다수 TechEmpower 상위 단골 | REST API 의 기본값. ORM·필터·세션·설정 파일이 다 들어 있음 |
| userver | 스택풀 코루틴 동기처럼 쓰고 비동기로 돎 | Yandex 실운영 문서는 러시아색 | 대규모 MSA — PostgreSQL·Mongo·Redis·gRPC 드라이버가 한 벌로 |
| uWebSockets | epoll 단일 루프 코어당 프로세스 | Bun 런타임이 채택 | 웹소켓 수만~수십만 연결 · 브로드캐스트 |
| Boost.Beast | Asio 를 직접 조립 | Boost 표준 라이브러리 | HTTP/WebSocket 을 프레임워크 없이 다뤄야 할 때 · 프록시·게이트웨이 |
| oat++ | 선택형(멀티스레드/코루틴) | 중간 · 커뮤니티 작음 | OpenAPI(스웨거) 문서를 코드에서 자동 생성하고 싶을 때 |
| Pistache | 스레드 + epoll | 개발 속도 느림 | 기존 코드 유지보수 외에는 새로 고르지 않습니다 |
| Seastar | 코어당 샤드 share-nothing · 자체 할당자 | ScyllaDB 가 사용 | 초당 100만 건 급 · 표준 라이브러리를 통째로 바꿔 쓸 각오가 있을 때 |
실무에선 이렇게 — 고르는 규칙 세 줄 🔴 실무
현실적인 결정 규칙
① 처음이거나, 서버가 부업이다 → cpp-httplib
· 헤더 하나 복사, CMake 세 줄, 10분이면 뜹니다
· 초당 수천 건까지는 이걸로 충분합니다
② 진짜 서비스를 C++ 로 만든다 → Drogon ★
· 라우팅·필터·세션·ORM·설정 파일이 다 들어 있습니다
· C++20 코루틴으로 DB 호출을 동기처럼 씁니다
· 한국어 자료는 적지만 공식 문서가 튼튼합니다
③ 팀이 마이크로서비스를 여러 개 굴린다 → userver
· 트레이싱·메트릭·설정 핫리로드가 기본 탑재
· 대신 학습 곡선과 빌드 시간이 만만치 않습니다
④ HTTP 가 아니라 소켓이 주인공이다 → uWebSockets / Beast
· 게임 · 채팅 · 시세 스트리밍
⑤ gRPC 만 필요하다 → grpc C++ 만
· HTTP 프레임워크 자체가 필요 없습니다 (w08)
첫 판단을 도와주는 계산 한 번
지금 서버가 감당해야 하는 것을 숫자로 적어 봅니다.
· 피크 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 코어 ← 여기서부터 언어가 돈입니다
첫 서버 — 10줄로 JSON 을 돌려줍니다
설명보다 먼저 뜨는 걸 봅니다. cpp-httplib 은 헤더 파일 하나라
vcpkg 없이도 되고, curl 로 확인까지 5분이면 끝납니다.
이 단계에서 C++ 서버가 요청을 받는 모양이 손에 붙습니다.
첫 서버 — 이게 전부입니다
// 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); // 여기서 블록됩니다
}
@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)
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");
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 호출도 같은 헤더에서 |
httplib::Server 에 람다를 등록하고 listen, 이게 C++ 서버의 최소 형태입니다.
빌드는 vcpkg 매니페스트 + 툴체인 파일 조합이 표준이고, 이 조합은 이 탭 내내 그대로 씁니다.
다만 블로킹 모델이라 동시 처리 수가 스레드 수에 묶인다는 점이 다음 절로 넘어가는 이유입니다.Drogon 완전 입문 — C++ 의 Spring Boot 자리
라우팅·필터·세션·ORM·설정 파일·로거가 한 상자에 들어 있는 유일한 C++ 프레임워크입니다. 이벤트 루프 N개 + C++20 코루틴 조합이라, DB 를 기다리는 동안에도 스레드가 놀지 않습니다. 이 절만 끝내면 실서비스 API 의 뼈대가 완성됩니다.
설치와 프로젝트 생성
# ① 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; }()));
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);
Spring Boot · FastAPI ↔ Drogon 대응표
| 하고 싶은 것 | Spring Boot | FastAPI | Drogon |
|---|---|---|---|
| 라우트 등록 | @GetMapping | @app.get | ADD_METHOD_TO(..., Get) |
| 경로 변수 | @PathVariable | 함수 인자 | {id} → 함수 인자 |
| 본문 파싱 | @RequestBody | pydantic 모델 | req->getJsonObject() |
| 응답 | ResponseEntity | dict 반환 | newHttpJsonResponse |
| 공통 전처리 | Filter · Interceptor | Middleware | Filter · Advice |
| 예외 → 응답 | @ControllerAdvice | exception_handler | setExceptionHandler |
| 비동기 DB | R2DBC · 가상 스레드 | await | co_await execSqlCoro |
| 설정 | application.yml | pydantic-settings | config.json + 환경 변수 |
| 로깅 | SLF4J | logging | LOG_INFO << ... (또는 spdlog) |
| 테스트 | MockMvc | TestClient | GoogleTest + HttpClient (w11) |
Boost.Asio 와 C++20 코루틴 — 그 밑에서 벌어지는 일
Drogon·Crow·Beast 는 전부 이 위에 얹혀 있습니다.
io_context·strand·co_await 세 단어만 잡으면
프레임워크가 왜 그렇게 생겼는지가 보이고, 필요할 때 직접 서버를 조립할 수도 있습니다.
코루틴 에코 서버 — 이게 전체 코드입니다
#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() 이 빠져나옵니다
공유 자료 — 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);
블로킹을 어디로 치우나 · 타임아웃
// ① 무거운 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);
std::enable_shared_from_this 로
세션을 살려 두는 것이고, 코루틴을 쓰면 지역 변수가 코루틴 프레임에 살아 있어
이 문제의 8할이 사라집니다. 코루틴을 권하는 가장 큰 이유입니다.
Asio 용어를 아는 언어로 옮기면
| Boost.Asio | 파이썬 asyncio | Node.js | 하는 일 |
|---|---|---|---|
io_context | 이벤트 루프 | libuv 루프 | 완료된 작업의 콜백을 담아 두는 큐 |
io.run() | run_forever() | (런타임이 자동) | 큐에서 꺼내 실행하는 일꾼 |
awaitable<T> | 코루틴 | Promise | 나중에 값을 주는 것 |
co_await | await | await | 여기서 양보하고 결과가 오면 재개 |
co_spawn | create_task | 즉시 실행 | 코루틴을 루프에 띄웁니다 |
strand | (루프가 하나라 불필요) | (스레드가 하나) | 실행 직렬화 — 락 대신 순서 |
thread_pool | run_in_executor | worker_threads | 블로킹 작업 격리 |
steady_timer | asyncio.sleep | setTimeout | 시간 대기 |
cancellation_signal | task.cancel() | AbortController | 진행 중인 작업 취소 |
strand 는 파이썬·Node 에 대응어가 없습니다.
저쪽은 루프가 하나뿐이라 애초에 동시 실행이 없기 때문입니다.
C++ 는 여러 스레드가 같은 루프를 돌리므로 이 장치가 필요합니다.
Boost.Beast — HTTP·WebSocket 을 직접 다룰 때
Beast 는 프레임워크가 아니라 부품입니다. 라우팅도 JSON 도 없습니다. 그래서 REST API 를 만들 때는 답이 아니지만, 프록시·게이트웨이·실시간 채널처럼 프레임워크가 오히려 방해되는 자리에서는 이만한 게 없습니다.
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, 완료 콜백에서 다음 것 */ }
};
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
WebSocket 을 운영에 올릴 때 확인할 것
| 항목 | 안 챙기면 | 처방 |
|---|---|---|
| 핑 · 퐁 | 죽은 연결이 계속 쌓입니다 | timeout::suggested 가 자동으로 보냅니다 |
| 프록시 타임아웃 | 60초마다 조용히 끊깁니다 | Nginx proxy_read_timeout 을 길게(300s) |
| 동시 쓰기 | 프레임이 섞여 프로토콜이 깨집니다 | 세션마다 송신 큐 하나 |
| 백프레셔 | 느린 클라이언트가 서버 메모리를 먹습니다 | 큐 길이 상한 → 넘으면 연결을 끊습니다 |
| 메시지 크기 | 큰 프레임 하나로 메모리 폭발 | read_message_max 를 설정 |
| 연결 수 | accept: too many open files | ulimit -n 65535 · 노드당 상한 계산 |
| 재연결 | 배포 때 전부 동시에 몰립니다 | 클라이언트에 지수 백오프 + 지터 |
| 인증 | 업그레이드 후에는 헤더를 못 봅니다 | 핸드셰이크 요청에서 토큰을 검사 |
Authorization 헤더를 다시 볼 기회가 없습니다.
브라우저는 WebSocket 요청에 커스텀 헤더를 못 붙이므로
쿠키나 쿼리 파라미터의 단기 토큰을 쓰는 게 보통입니다.
is_upgrade 로 갈라 같은 포트에서 둘을 처리하고, 브로드캐스트는 strand + 세션별 송신 큐가 정답입니다.
라우팅이 필요한 순간이 오면 그때가 Drogon 으로 옮길 때입니다.JSON — pydantic 이 없는 세계에서 살아남기
C++ 서버에서 체감 성능과 코드 분량을 가장 크게 좌우하는 게 JSON 입니다. 편한 것(nlohmann)과 빠른 것(simdjson·glaze)이 다르고, 구조체 자동 매핑이 되느냐가 코드 길이를 절반으로 줄입니다.
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); // 바이너리 — 내부 통신용
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 라 할당이 거의 없습니다
// · 대신 '앞으로만' 읽을 수 있고 수정은 못 합니다
padded_string
(또는 뒤에 SIMDJSON_PADDING 바이트 여유가 있는 버퍼)여야 합니다.
HTTP 본문을 그대로 넘기면 버퍼 밖을 읽습니다.
② 반환되는 string_view 는 원본 버퍼가 살아 있는 동안만 유효합니다 —
응답을 만들 때까지 원본을 붙잡아 두거나 복사하세요.
라이브러리 고르기 표
| 라이브러리 | 구조체 매핑 | 수정 가능 | 빌드 부담 | 이럴 때 |
|---|---|---|---|---|
| nlohmann/json | 매크로 한 줄 | ○ | 헤더 온리 컴파일이 느린 편 | 기본값. 팀원이 다 아는 API, 예제가 가장 많음 |
| glaze ★ | 자동(리플렉션) | ○ | 헤더 온리 · C++23 | 새 프로젝트 · 직렬화가 뜨거운 경로일 때 |
| reflect-cpp | 자동 + 검증 | ○ | 헤더 온리 · C++20 | pydantic 처럼 입력 검증까지 타입으로 하고 싶을 때 |
| simdjson | △ 수동 | × 읽기 전용 | 라이브러리 | 수십 MB 입력 · 로그·이벤트 수집기 |
| RapidJSON | × | ○ | 헤더 온리 | 기존 코드 유지 · SAX 스트리밍이 필요할 때 |
| Boost.JSON | △ | ○ | Boost 일부 | 이미 Boost 를 쓰고 의존성을 안 늘리고 싶을 때 |
| jsoncpp | × | ○ | 라이브러리 | 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);
}
DB 연결 — 커넥션 풀이 성능의 8할입니다
JPA·SQLAlchemy 같은 두꺼운 ORM 이 없으니 SQL 을 직접 씁니다. 나쁘지 않습니다 — 문제가 되는 건 문법이 아니라 커넥션을 어떻게 관리하고, 어느 스레드에서 부르느냐입니다. 여기서 잘못 잡으면 C++ 인데 파이썬보다 느린 서버가 나옵니다.
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;
}
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();
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();
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";
실무 — 풀 크기·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)
# 원칙 — 마이그레이션은 '앱 시작 시'가 아니라 '배포 단계'에서 한 번만 ★
저장소별 드라이버 고르기
| 저장소 | 비동기 (co_await 가능) | 동기 | 메모 |
|---|---|---|---|
| PostgreSQL | Drogon DbClient ★ | libpqxx | 둘 다 libpq 위에 있습니다. 기능 차이는 거의 없습니다 |
| MySQL | Drogon DbClient(mysql) | Connector/C++ 9.x | 비동기 쪽 성숙도는 PostgreSQL 이 더 낫습니다 |
| SQLite | Drogon(sqlite3) — 내부 스레드 풀 | SQLiteCpp | 파일 I/O 라 진짜 비동기는 아닙니다 |
| Redis | Drogon RedisClient ★ | redis-plus-plus | 동기 쪽이 API 는 훨씬 풍부합니다 |
| MongoDB | 없음 | mongocxx | 워커 스레드로 던져야 합니다 |
| ClickHouse | 없음 | clickhouse-cpp | 배치 삽입이 기본 — 요청 경로에 두지 마세요 |
| Kafka | 콜백 기반 | librdkafka | 프로듀서는 비동기, 전송 완료 콜백으로 확인 |
repo/ 안에서만 접근) 실수를 줄일 수 있습니다.
gRPC · Protobuf — 내부 통신의 표준
외부에는 REST, 내부에는 gRPC — 이게 요즘의 기본 구성입니다. .proto 파일 하나로 C++ 서버와 파이썬·자바 클라이언트가 같은 계약을 공유하고, HTTP/2 위에서 스트리밍까지 공짜로 얻습니다.
계약 파일과 빌드 연결
// 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 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();
}
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 경로 | 필드 번호 규칙 |
인증과 보안 — 스타터가 없는 자리를 직접 채우기
Spring Security 도, fastapi-users 도 없습니다. 대신 필요한 조각은 다 있습니다 —
jwt-cpp, Argon2, 그리고 Nginx. 중요한 건 라이브러리가 아니라
어디까지를 앱이 하고 어디부터를 앞단이 하느냐의 경계입니다.
비밀번호 — 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
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; // 이유를 클라이언트에 알리지 마세요 ★
}
}
앞단 — 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; # 유휴 연결이 끊기지 않게
}
}
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 + 앱에서 사용자 단위 |
| CORS | Allow-Origin: * + credentials | 허용 목록 화이트리스트 |
| 입력 크기 | 제한 없음 | 본문·헤더·업로드 전부 상한 |
| 에러 응답 | 예외 메시지를 그대로 | 고정 문구 + 서버 로그에만 상세 |
| 의존성 | 버전 고정 없음 | vcpkg baseline 고정 + trivy 스캔 |
| 메모리 안전 | 테스트만 | CI 에서 ASan·UBSan 빌드로도 테스트(w11) |
-fsanitize=address,undefined 로 테스트를 돌리는 것만으로도 상당수가 걸립니다.
로깅 · 설정 · 관측 — 액추에이터가 없는 서버 운영하기
배포하고 나면 무슨 일이 벌어지는지 보이는가가 전부입니다. C++ 에는 Spring Actuator 같은 게 없으니 spdlog · toml++ · prometheus-cpp · OpenTelemetry 네 조각을 직접 끼웁니다. 한 번 만들어 두면 다음 프로젝트에도 그대로 씁니다.
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)
설정 — 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; // 시작할 때 한 번에 검증 ★
}
메트릭과 트레이스 — 숫자로 보게 만들기 🔴 실무
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 네 개(요청 수·지연·에러율·연결 수) → ③ 트레이스.
③ 은 서비스가 여러 개로 갈라진 뒤에 값어치가 생깁니다.
단일 서비스라면 ①②만으로 충분합니다.
꼭 만들어 두는 엔드포인트 네 개
| 경로 | 응답 | 쓰임새 |
|---|---|---|
/healthz | 200 ok (의존성 확인 없이) | liveness — 프로세스가 살아 있는지. 실패하면 재시작됩니다 |
/readyz | DB·Redis 핑 확인 후 200 | readiness — 트래픽을 받아도 되는지. 부팅 중엔 503 |
/metrics | Prometheus 텍스트 | 내부 포트에만. 외부에 열면 정보 노출입니다 |
/version | 커밋 해시 · 빌드 시각 | “지금 뜬 게 어느 버전이지?” 를 3초에 끝냅니다 |
/healthz 에서 DB 를 확인하지 마세요 — DB 가 잠깐 느려지면
모든 파드가 동시에 재시작되어 장애가 커집니다.
liveness 는 프로세스 자체만, 의존성 확인은 readiness 에서 합니다.
이 구분 하나로 새벽에 깨는 횟수가 줄어듭니다.
테스트와 벤치마크 — 평균은 거짓말을 합니다
C++ 서버를 만드는 이유가 성능이라면, 성능을 재는 법이 곧 실력입니다.
핸들러 테스트를 GoogleTest 로 자동화하고, oha·wrk 로 부하를 걸고,
평균이 아니라 꼬리(p99) 를 보는 습관까지 이 절에서 만듭니다.
단위 테스트 — 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 가 개별 테스트를 인식합니다
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;
}
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
· 여기까지 안 하고 코드부터 고치면 대개 헛수고입니다
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
부하 도구 고르기
| 도구 | 강점 | 약점 · 쓰는 자리 |
|---|---|---|
| oha ★ | 설치 한 줄 · 실시간 TUI · HTTP/2 · 지연 보정 | 복잡한 시나리오는 못 씁니다. 일상적인 확인에 가장 편합니다 |
| wrk | 루아 스크립트 · 아주 가벼움 · 백분위수 출력 | HTTP/2 없음. 오래된 표준이라 자료가 많습니다 |
| bombardier | 단일 바이너리 · 간단 | 기능이 적습니다. CI 스모크 부하에 적당 |
| k6 | 시나리오 · 임계값(threshold) · 리포트 · CI 통합 | 자체 오버헤드가 큽니다. 회귀 방지에 씁니다 |
| ghz | gRPC 전용 · .proto 를 그대로 사용 | HTTP 는 못 잽니다(w08) |
| ab | 어디에나 깔려 있습니다 | 단일 스레드 · keep-alive 처리가 부실. 수치를 믿지 마세요 |
oha 로 지금 빨라졌나를 30초에 확인하고,
CI 에는 k6 로 p99 가 기준을 넘으면 실패하는 임계값을 걸어 둡니다.
후자가 있어야 성능이 조용히 나빠지는 것을 막습니다.
oha·wrk 로 걸되 p99·p99.9 와 소켓 에러를 함께 보고,
튜닝은 측정 환경 → 스레드 → keep-alive → 할당자 → 코드 순서를 지키세요.배포 — 2.5GB 빌드 이미지에서 40MB 로
C++ 의 배포 이점은 분명합니다 — 런타임도, 인터프리터도, JVM 도 필요 없습니다. 바이너리 하나면 끝나므로 이미지가 아주 작아지고 부팅이 즉시입니다. 대신 빌드 환경과 실행 환경을 분리하는 요령이 필요합니다.
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
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 은 신중하게
requests.memory 를 작게 잡을 수 있어 같은 노드에 더 많이 올라갑니다.
다만 CPU limit 은 조심하세요 — 스로틀링이 p99 를 크게 망칩니다.
이미지 줄이기 — 조치별 효과
| 조치 | 효과 | 비고 |
|---|---|---|
| 멀티스테이지 분리 | 약 2.4GB 감소 | 가장 큰 한 방. 이것만 해도 대부분 끝납니다 |
strip (또는 -s) | 20~60MB | 디버그 심볼 제거. 심볼 파일은 따로 보관하세요 |
| distroless/cc 로 교체 | 약 90MB | 셸·패키지 매니저가 없습니다 — 디버깅이 어려워집니다 |
-Os -Wl,--gc-sections | 5~15% | 성능이 중요한 경로가 있으면 -O2 를 유지하세요 |
| 완전 정적 + scratch | 약 28MB | musl 툴체인 필요. DNS·NSS 동작을 반드시 확인 |
.dockerignore | 빌드 시간 | 컨텍스트 전송이 줄어듭니다. build/, .git/ 는 필수 |
| COPY 순서 조정 | 빌드 시간 | vcpkg.json 을 먼저 복사해 의존성 레이어를 캐시 |
| UPX 압축 | 50~70% | 권하지 않습니다 — 시작 시간이 늘고 보안 도구가 오탐합니다 |
실전 — URL 단축 서비스를 끝까지 만듭니다
지금까지의 조각을 하나로 엮습니다. Drogon + PostgreSQL + Redis 로
POST /api/v1/links 와 GET /{code} 두 엔드포인트를 만들고,
Docker Compose 로 띄우고, 같은 기능의 FastAPI 버전과 나란히 재 봅니다.
스키마와 코드 생성
-- 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;
}
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;
});
});
띄우기 — 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 |
| 같은 경로 p99 | 3 ms | 42 ms |
| 캐시 미스(DB 조회) | 약 21k rps | 약 6k rps |
| 링크 생성(POST) | 약 14k rps | 약 5k rps |
| 메모리(RSS) | 약 48MB | 약 210MB(4워커 합) |
| 이미지 크기 | 약 41MB | 약 190MB |
| 콜드 스타트 | 약 30ms | 약 1.2s |
| 코드 줄 수 | 약 620줄 | 약 180줄 |
| 첫 동작까지 | 반나절 | 30분 |
마무리 — 여기서 더 갈 곳 🔴 실무
이 프로젝트에 이어 붙일 것들
① 인증 붙이기 (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 |
수천 명이
같은 세계에서
동시에 움직이게
게임 서버는 웹 서버와 목표가 다릅니다. 웹은 요청 하나에 응답 하나를 잘 돌려주면 되지만, 게임은 아무도 요청하지 않아도 1초에 30~60번 세계를 굴려야 하고, 그 결과를 접속한 전원에게 수십 밀리초 안에 뿌려야 합니다. 그래서 스레드 모델도, 네트워크 모델도, 데이터 저장 방식도 전부 달라집니다. 이 탭은 틱 루프 · 신뢰 UDP · 패킷 설계 · IOCP/epoll · 넷코드(예측·보간·지연 보상) 를 거쳐 마지막에 실제로 도는 2D 실시간 대전 서버 한 벌을 끝까지 만듭니다. 국내 게임사 서버 팀에서 실제로 쓰는 용어와 구조를 기준으로 씁니다.
게임 서버의 종류와 구조 — 무엇을 만들 것인지부터
"게임 서버를 만들자"는 말은 너무 넓습니다. 방 하나에 8명이 들어가는 대전 게임과 한 세계에 수천 명이 돌아다니는 MMO 는 부품이 겹치는 게 거의 없습니다. 먼저 어떤 종류를 만드는지 정하고, 그다음 서버를 몇 개로 쪼갤지 정합니다.
왜 클라이언트를 못 믿나 — 한 줄로 끝나는 이유
// ✗ 클라이언트가 보내 주는 값을 그대로 믿는 서버
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; // 지형 검사도 서버가
// 실패하면 그냥 안 움직입니다 — 클라에는 정정 패킷을 보냅니다
}
서버를 몇 조각으로 나누나
| 서버 | 병목 | 확장 방법 |
|---|---|---|
| 게이트웨이 | 동시 연결 수·소켓 | 수평(무상태) |
| 로비/매치 | Redis I/O | 수평 + 큐 샤딩 |
| 게임 룸 | CPU(틱 처리) | 룸 단위로 분산 |
| 존(MMO) | CPU + 관심 영역 | 지역 분할 |
| 채팅 | 팬아웃 | Pub/Sub |
| DB 프록시 | 디스크·커넥션 | 샤딩·읽기 복제 |
한 단계 더 — 실시간이냐 턴제냐가 모든 것을 정합니다 🟡 중급
장르별 틱 레이트와 요구 지연
| 장르 | 틱 | 허용 RTT | 전송 |
|---|---|---|---|
| FPS·대전 격투 | 60~128 | ~50ms | UDP |
| MOBA | 30~60 | ~80ms | UDP |
| 액션 MMO | 10~30 | ~150ms | UDP/TCP |
| 타겟팅 MMORPG | 10~20 | ~200ms | TCP |
| 턴제·카드·보드 | 이벤트 | ~500ms | TCP/WS |
| 방치형·소셜 | 없음 | 초 단위 | HTTP |
턴제라면 이 정도로 끝납니다
// 턴제는 '루프'가 없습니다 — 이벤트가 올 때만 깨어납니다
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(); });
}
};
실무에선 이렇게 — 직접 만들 것인가, 사서 쓸 것인가 🔴 실무
2026년 기준 선택지와 판단 기준
| 선택지 | 형태 | 맞는 경우 | 걸리는 지점 |
|---|---|---|---|
| 자체 C++ 서버 | Asio/IOCP 직접 | 대규모·장수 서비스, 비용 민감, 특수 시뮬레이션 | 사람과 시간(서버 1~3명 × 수개월) |
| UE5 Dedicated | 엔진 내장 | UE 로 만드는 액션·슈터, 물리 판정이 곧 게임 | 인스턴스당 메모리·CPU 가 큼 |
| Photon | SaaS/온프렘 | 소규모 방 게임, 빠른 출시 | CCU 과금, 로직 커스터마이즈 한계 |
| Nakama | 오픈소스(Go) | 로비·매치·소셜을 통째로 받고 싶을 때 | 실시간 시뮬은 결국 따로 짜야 함 |
| PlayFab / GameLift | 클라우드 백엔드 | 서버 인스턴스 관리·매치 대행 | 락인, 요금 예측이 어려움 |
| Agones + 자체 | K8s 오케스트레이션 | 자체 서버를 쓰되 배치만 맡길 때 | K8s 운영 인력 필요 |
틱 루프 — 아무도 부르지 않아도 돌아가는 심장
웹 서버는 요청이 없으면 아무 일도 하지 않습니다. 게임 서버는 반대입니다. 1초에 30~60번, 아무 입력이 없어도 세계를 한 칸씩 굴립니다. 이 루프의 정확도가 곧 게임의 체감 품질이 되므로, 처음부터 제대로 짜 두어야 합니다.
고정 틱 루프 — 이 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틱쯤으로 조용히 내려앉습니다.
“이상적인 시각”을 기준으로 더해야 장시간 돌려도 틱 수가 정확합니다.
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틱에 한 번만 전송
}
한 프로세스에 방이 여러 개일 때
// 방마다 스레드를 하나씩? → 방 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(); });
}
실무에선 이렇게 — 틱이 밀리는 진짜 원인들 🔴 실무
틱 안에서 절대 하면 안 되는 것
| 하지 말 것 | 대신 |
|---|---|
| 동기 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; // 버킷 누적으로 근사
};
next += DT 로 이상 시각을 누적하고,
따라잡기에 상한을 두고, 남는 시간은 자야 합니다.
시뮬 틱과 전송 틱을 분리하면 정확도는 지키면서 대역폭만 줄일 수 있습니다.
틱 안에서는 절대 블로킹하지 않습니다 — 이 원칙 하나가 이후 모든 장의 전제입니다.TCP 냐 UDP 냐 — 신뢰성을 직접 얹는 이유
TCP 는 “빠짐없이 순서대로” 를 보장합니다. 좋아 보이지만 실시간 게임에서는 그게 독이 됩니다. 패킷 하나가 늦으면 뒤에 도착한 최신 정보까지 전부 붙잡아 두기 때문입니다. 그래서 게임은 UDP 위에 필요한 만큼만 신뢰성을 얹습니다.
TCP 의 진짜 문제 — 머리 막힘(HOL blocking)
TCP 로 위치를 60Hz 로 보낸다고 합시다.
보낸 순서 : P100 P101 P102 P103 P104 P105
P101 이 유실 → 재전송에 RTT(예: 80ms) 가 걸립니다
그동안 P102~P105 는 이미 도착해 있지만
커널 수신 버퍼에 갇혀 앱으로 올라오지 못합니다 ★
"순서대로" 를 지켜야 하기 때문입니다
결과 : 80ms 뒤에 P101~P105 가 한꺼번에 올라옵니다
→ 화면에서는 '멈췄다가 순간이동' 으로 보입니다
UDP + 최신값 우선이라면 :
P101 은 그냥 버립니다. P102 가 도착한 순간 그걸 씁니다
→ 16ms 뒤 정상 진행. 한 프레임 튄 것으로 끝납니다
그럼 언제 TCP/WebSocket 이 정답인가
| 상황 | 선택 |
|---|---|
| 턴제·카드·보드 | TCP / WebSocket |
| 타겟팅 MMORPG | TCP 로도 충분 |
| 브라우저(WebGL) 클라 | WebSocket 또는 WebTransport |
| 로비·매치·상점·인증 | TCP/HTTPS |
| 사내망·모바일 3G 배려 | TCP 가 방화벽에 강함 |
| FPS·격투·레이싱 | UDP 필수 |
| MOBA·액션 MMO | UDP 권장 |
한 단계 더 — 신뢰 UDP 라이브러리 고르기 🟡 중급
2026년 기준 실사용 후보
| 라이브러리 | 특징 | 암호화 | 맞는 경우 |
|---|---|---|---|
| GameNetworkingSockets Valve | Steam 에서 실제로 도는 코드. 채널·혼잡 제어·릴레이 | 내장(AES-GCM) | PC 게임, Steam 연동, 가장 무난한 기본값 |
| ENet | 가볍고 API 가 단순. 20년 된 검증된 코드 | 없음(직접) | 학습·소규모·프로토타입 |
| KCP | ARQ 만 제공. 전송은 직접 붙임. 지연에 공격적 | 없음 | 중국계 게임·모바일 고손실 환경 |
| yojimbo | Glenn Fiedler. 암호화+연결 토큰 설계가 교과서적 | 내장(libsodium) | 보안 설계를 배우고 싶을 때 |
| QUIC msquic·quiche | 표준. 스트림별 독립 순서 보장, 0-RTT | 필수(TLS 1.3) | 모바일(회선 전환), 웹 연동 |
| 직접 구현 | seq·ack 비트필드·재전송을 손으로 | 직접 | 학습용. 상용에는 권장하지 않음 |
실무에선 이렇게 — 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; }
}
패킷 크기 — MTU 를 넘기면 조각납니다
이더넷 MTU 1500 바이트에서 실제로 쓸 수 있는 크기는
1500 - IP 헤더 20 - UDP 헤더 8 = 1472 바이트
VPN·PPPoE·모바일 터널을 감안하면 실무 안전선은 ★ 1200 바이트
이 선을 넘으면 IP 단편화가 일어납니다
· 조각 중 하나만 잃어도 패킷 전체가 무효가 됩니다
· 손실률 1% 짜리 회선에서 3조각 패킷의 손실률은 약 3%
· 일부 방화벽·NAT 는 조각난 UDP 를 아예 버립니다
그래서 게임 패킷은 항상 1200 바이트 안에 넣습니다
· 스냅샷이 크면 '중요한 것부터' 잘라 담고 나머지는 다음 틱에
· 그래도 커야 하는 것(맵 데이터·리소스)은 TCP/HTTP 로 따로 받습니다
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;
}
};
// 우선순위 — 예산이 모자랄 때 무엇부터 버리나
// ① 신뢰 채널(아이템·스킬) → 절대 버리지 않음
// ② 가까운 적의 위치 → 우선
// ③ 먼 적의 위치 · 이펙트 → 이번 틱은 건너뜀 ★
// ④ 통계·디버그 정보 → 여유 있을 때만
패킷 설계 — 바이트 흐름을 메시지로 되돌리기
TCP 에는 “메시지”라는 개념이 없습니다. send 를 세 번 했다고 recv 가 세 번 나오지 않습니다.
신입 서버 개발자가 가장 많이 만드는 버그가 여기서 나옵니다.
길이를 앞에 붙이고, 다 모일 때까지 기다리는 것 — 이 규칙 하나가 프로토콜의 절반입니다.
헤더와 파서 — 복붙해서 바로 쓰는 형태
#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? 🟡 중급
세 방식의 실제 차이
| 수동 직렬화 | Protobuf | FlatBuffers | |
|---|---|---|---|
| 읽는 비용 | 복사 1회 | 파싱+할당 | 0 (제로카피) |
| 크기 | 가장 작음 | 작음(varint) | 조금 큼(정렬 패딩) |
| 스키마 변경 | 손으로 버전 관리 | 호환 잘 됨 | 호환 잘 됨 |
| 클라 연동 | 직접 | 전 언어 지원 | C#·C++·TS 지원 |
| 디버깅 | 어렵다 | 텍스트 덤프 쉬움 | 보통 |
| 쓰기 좋은 곳 | 위치 갱신 같은 초고빈도 | 로비·상점·전적 | 게임 룸 스냅샷 |
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; }
실무에선 이렇게 — 패킷 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 표시만 (번호를 비워 둠)
세션과 I/O 모델 — IOCP · epoll · Asio
연결 5천 개에 스레드 5천 개를 만들 수는 없습니다. 그래서 소수의 스레드가 수천 개의 소켓을 돌아가며 처리합니다. 윈도우는 IOCP, 리눅스는 epoll / io_uring. 이 둘은 사고방식이 다릅니다. Boost.Asio 는 그 차이를 덮어 하나의 코드로 만들어 줍니다.
두 모델의 차이 — 한 표로
| IOCP (Windows) | epoll (Linux) | |
|---|---|---|
| 통지 시점 | 완료 후 | 준비 됨 |
| 모델 이름 | proactor | reactor |
| 버퍼 소유 | 커널이 들고 있음 | 앱이 그때 준비 |
| 스레드 제어 | 커널이 조절 | 앱이 직접 |
| 연결 10k | 매우 좋음 | 매우 좋음 |
| 대안 | RIO(초저지연) | io_uring |
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);
}
}
}
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 keepalive | OS 설정 | 기본 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초 재접속 유예
}
}
}
shared_from_this + strand 로 수명과 동시성을 동시에 해결하고,
no_delay 를 켜고 앱 하트비트로 끊김을 감지하며, 세션과 플레이어는 분리합니다.스레드 모델 — 락을 줄이는 가장 확실한 방법
멀티스레드 게임 로직은 “되긴 되는데 가끔 이상한” 버그의 온상입니다. 그래서 실무의 정답은 대개 스레드를 잘 쓰는 것이 아니라 공유를 없애서 락 자체를 지우는 것입니다.
잡 큐 — 액터의 실제 구현
#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 | 보내는 양이 확 줄어듦 | 자료구조 관리 비용 |
관심 영역(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*) | 좋음 | 요청 단위로 잡 큐에 던지면 됨 |
| 한 방 안의 이동·전투 판정 | 대체로 손해 | 서로 참조가 많아 락 비용 > 이득 |
| 인벤토리·거래 | 하지 말 것 | 정합성이 생명 — 단일 스레드가 안전 |
scoped_lock 과 계층 번호로 순서를 강제하고,
병렬화는 방 단위·스냅샷·경로 탐색처럼 독립적인 곳에만 씁니다.넷코드 — 예측 · 리컨실리에이션 · 보간 · 지연 보상
서버가 권위를 가지면 “버튼을 누르고 RTT 뒤에 캐릭터가 움직이는” 문제가 생깁니다. 100ms 를 기다린 뒤 움직이는 게임은 아무도 못 합니다. 그래서 네 가지 기법으로 지연을 숨깁니다. 이것이 넷코드이고, 게임 서버의 핵심 지식입니다.
클라이언트 예측 — 클라 쪽 코드
// 서버와 '완전히 같은' 이동 함수를 클라도 갖고 있어야 합니다 ★
// 공용 라이브러리로 빼서 서버·클라가 같은 .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; // 데이터가 끊기면 마지막 값
}
};
실무에선 이렇게 — 지연 보상 — 서버가 시간을 되감습니다 🔴 실무
서버 쪽 되감기 구현
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)); }
넷코드 네 기법 — 언제 무엇을
| 기법 | 어디서 돌까 | 무엇을 숨기나 | 부작용 |
|---|---|---|---|
| 클라 예측 | 클라 | 내 입력의 왕복 지연 | 틀리면 튐(rubber-banding) |
| 리컨실리에이션 | 클라 | 예측 오차 정정 | 재계산 비용, 코드 일치 필요 |
| 보간 | 클라 | 낮은 스냅샷 주기 | 남이 100ms 과거로 보임 |
| 지연 보상 | 서버 | 쏜 사람의 지연 | 맞은 쪽이 억울함 |
| 롤백 넷코드 | 양쪽 | 격투 게임의 프레임 단위 지연 | 구현 난도 최상 |
매치메이킹 · 로비 — Redis 로 대기열과 랭킹을
게임 룸이 잘 돌아도 거기까지 들어가는 길이 없으면 서비스가 아닙니다. 대기열 · 파티 · MMR 매칭 · 랭킹 · 세션 공유 — 이 영역은 CPU 가 아니라 상태 공유가 문제이고, 그래서 거의 항상 Redis 가 답이 됩니다.
대기열 — 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;
}
매칭 품질을 재는 지표
| 지표 | 목표 |
|---|---|
| 중앙 대기 시간 | < 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 을 원자 실행합니다.
ZREM 반환값 확인으로 중복 매칭을 막고,
로비 → 게임 서버 이동은 1회용 토큰(GETDEL) 으로 넘깁니다.DB 와 영속화 — 틱을 멈추지 않고 저장하기
게임 서버에서 DB 쿼리를 틱 안에서 동기로 부르는 순간 끝입니다. 5ms 짜리 쿼리 하나가 방 전체를 5ms 멈춥니다. 그렇다고 아무렇게나 미루면 아이템 복사 버그가 납니다. 이 둘 사이의 균형이 이 절의 주제입니다.
더티 플래그와 배치 저장
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 워커 풀 — 커넥션은 스레드마다 하나
// 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");
}
}
};
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 | “몇 개”가 아니라 “그 물건”을 옮깁니다 |
| 소유자 컬럼 하나 | 소유는 항상 한 곳에만 기록 |
| 조건부 UPDATE | WHERE owner_id = 이전주인 이 락 역할 |
| 멱등 키 | 재시도·중복 패킷에도 한 번만 |
| 거래는 지연 쓰기 금지 | 커밋 전에는 성공을 알리지 않습니다 |
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});
}
언리얼 엔진 전용 서버 — 직접 안 짜도 되는 경우
UE5 로 게임을 만든다면 서버를 처음부터 짤 이유가 거의 없습니다. 엔진 안에 이미 리플리케이션 · 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); // 바뀔 때만 표시
}
언제 UE 서버, 언제 직접 짜나
| 상황 | 선택 |
|---|---|
| UE 로 만드는 슈터·액션 대전 | UE 전용 서버 |
| 물리·애니가 곧 판정 | UE 전용 서버 |
| 방당 인원 ≤ 64, 세션 길이 짧음 | UE 전용 서버 |
| 동접 수천이 한 세계에 | 자체 C++ + UE 클라 |
| 인스턴스당 비용이 관건 | 자체 C++ |
| 로비·상점·랭킹 | 항상 별도 백엔드 |
Type = TargetType.Server 한 줄에서 시작합니다.
GameMode 는 서버에만 있고, 값은 DOREPLIFETIME 으로 복제, 요청은 Server RPC + WithValidation 입니다.
부하는 NetUpdateFrequency · NetCullDistance · 푸시 모델로 줄이고,
로비·랭킹·결제는 UE 밖의 백엔드에 둡니다.보안과 치트 방지 — 클라이언트는 적의 컴퓨터입니다
게임 클라이언트는 공격자의 손 안에서 도는 코드입니다. 디버거를 붙이고, 메모리를 고치고, 패킷을 뜯어보고, 아예 클라를 새로 만들어 붙일 수도 있습니다. 그래서 방어의 원칙은 하나입니다 — 서버에서 검증되지 않은 것은 존재하지 않는다.
게임 규칙 검증 — 실제로 이렇게 생겼습니다
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;
}
속도 제한과 재생 공격 방어
// ── 메시지 종류별 토큰 버킷 ──────────────────────────────
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;
}
한 단계 더 — 암호화는 어디에 거나 🟡 중급
경로별 선택
| 경로 | 선택 | 이유 |
|---|---|---|
| 로그인·결제·상점 | HTTPS(TLS 1.3) | 토큰과 개인정보가 오갑니다. 협상 불가 |
| 로비 TCP | TLS | 부담이 크지 않고 표준 라이브러리로 끝납니다 |
| 게임 룸 UDP | DTLS 또는 라이브러리 내장 | GNS·yojimbo 는 AES-GCM 이 이미 들어 있습니다 |
| 서버 ↔ 서버 | 사설망 + mTLS | 내부라고 평문으로 두면 침해 시 전면 노출 |
| Redis · DB | 사설망 + 암호 | 포트를 공개망에 여는 사고가 실제로 잦습니다 |
| 패킷 자체 난독화 | 보조 수단 | 시간을 벌 뿐 — 검증을 대체하지 못합니다 |
실무에선 이렇게 — 봇 탐지와 로그 감사 🔴 실무
통계로 잡는 것들
// 사람은 이런 값을 만들 수 없습니다 — 통계로 걸러 조사 큐에 올립니다
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년 |
부하 테스트와 운영 — 몇 명까지 되는지 숫자로 말하기
“우리 서버는 몇 명까지 버팁니까?” 이 질문에 숫자로 답하지 못하면 출시할 수 없습니다. 답을 만드는 방법은 하나뿐입니다 — 봇으로 진짜 부하를 걸어 보는 것. 그리고 운영 중에는 지표 · 덤프 · 무중단 배포 세 가지가 있어야 살아남습니다.
봇 클라이언트 — 진짜 클라와 같은 패킷을
// 봇은 '가짜' 여서는 안 됩니다. 실제 프로토콜을 그대로 써야 의미가 있습니다
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();
}
서버 지표 내보내기
// 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)에 열고 사설망에서만 접근시킵니다 ★
실무에선 이렇게 — 크래시 덤프 · 무중단 배포 · 오토스케일 🔴 실무
크래시가 나면 반드시 증거가 남아야 합니다
// ── 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 로 자동 수집하는 것이 표준입니다
무중단 배포 — 드레인 방식
// 게임 서버는 '즉시 재시작' 이 불가능합니다. 진행 중인 매치가 있으니까요
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);
}
실전 프로젝트 — 2D 탑다운 실시간 대전 서버 한 벌
앞의 열두 절을 하나로 합칩니다. Asio 세션 → 프레이밍 → FlatBuffers → 룸 잡 큐 → 60Hz 틱 → 델타 스냅샷 → Redis 매치 → Docker 배포 까지, 실제로 빌드해서 돌릴 수 있는 형태로 만듭니다. 코드는 짧게 유지하되 실무에서 빠지면 안 되는 것은 전부 넣었습니다.
폴더 구조
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
가장 중요한 테스트 — 시뮬 결정성
// 예측이 맞으려면 '같은 입력 → 같은 결과' 가 반드시 성립해야 합니다
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;
}
여기서 더 나아간다면 — 다음 단계 로드맵
| 단계 | 할 일 | 얻는 것 |
|---|---|---|
| ① UDP 로 전환 | TCP 세션을 GameNetworkingSockets 로 교체(n03) | 손실 환경에서 지연 안정화 |
| ② AoI 도입 | 그리드 + 시야 필터(n06) | 방 인원 상한이 몇 배로 |
| ③ 매치 서버 분리 | 매처를 별도 프로세스로, 티켓으로 연결(n08) | 게임 서버 수평 확장 |
| ④ 리플레이 | 입력 + 시드 저장, 재생 도구(n11) | 버그 재현·치트 조사 |
| ⑤ Agones 배포 | Fleet + 드레인 + 오토스케일(n12) | 운영 자동화 |
| ⑥ 관측 | OpenTelemetry 트레이스 + 로그 상관관계 | 장애 원인 추적 시간 단축 |
shared/ 의 시뮬 코드를 클라와 공유하고 결정성 테스트로 지킵니다.
Redis 티켓으로 로비와 잇고, Docker 로 묶고, 봇으로 정원을 측정해 그 숫자로 운영합니다.
여기까지 왔다면 게임 서버 개발자로서 필요한 지도는 완성된 것입니다.다른 언어로는
못 하는 일이
아직 남아 있습니다
앞의 ⚡ 성능 탭이 “내 함수를 빠르게”였다면, 이 탭은 “이 일은 C++ 아니면 안 된다”는 영역입니다. 마이크로초를 다투는 거래 시스템, 프레임을 놓치면 안 되는 오디오 콜백, 초당 수백만 건을 받는 I/O, GPU 커널, 컬럼 지향 데이터 엔진, LLM 로컬 추론까지 — 운영체제와 하드웨어를 직접 만지는 코드를 실제로 짜 봅니다. 각 절은 복붙해서 도는 최소 예제와 재 본 수치로 끝납니다. 이 탭을 마치면 “느리다”를 어느 계층이 얼마나 느린지로 말할 수 있게 됩니다.
2026년에도 C++ 가 필요한 일들 — 일곱 분야
웹 API 는 파이썬·Go·자바로 충분합니다. 그런데도 매년 C++ 로 새 코드가 수백만 줄씩 쓰입니다. 공통점은 하나 — 실행 시간의 상한이나 메모리의 상한이 계약으로 정해져 있는 일들입니다. 먼저 그 지도를 그리고, 이 탭에서 각각을 손으로 만져 봅니다.
“왜 C++ 인가”를 한 줄로
| 분야 | 대체 못 하는 이유 |
|---|---|
| 고빈도 거래 | GC 정지(수 ms)가 사업을 망칩니다 |
| 게임 엔진 | 수십만 객체를 배치까지 통제해야 함 |
| 브라우저 | 기존 3천만 줄 · OS API 직접 호출 |
| DB 엔진 | mmap · O_DIRECT · SIMD 집계 |
| 오디오 | 콜백 안에서 할당·락 금지 |
| ML 런타임 | CUDA · 인트린식 · 커널 융합 |
| 임베디드 | 런타임 없이 추상화만 쓰기 |
이 탭이 실제로 만지는 것
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 · 진단 순서
이렇게도 쓸 수 있다 — Rust · Go · Zig 가 있는데 왜 🟡 중급
2026년의 정직한 비교
| C++23 | Rust | Go | |
|---|---|---|---|
| 지연 예측성 | 최상 | 최상 | GC 정지 |
| 메모리 안전 | 수동 | 컴파일러 | GC |
| 기존 자산 | 압도적 | 성장 중 | 적음 |
| GPU · SIMD | 1급 | 가능 | 사실상 없음 |
| C 라이브러리 | 그냥 됨 | unsafe 래핑 | cgo(느림) |
| 빌드 시간 | 느림 | 느림 | 빠름 |
| 사람 구하기 | 많음 | 적음 | 많음 |
현실의 코드베이스는 섞여 있습니다
[ 실제로 흔한 2026년 구성 ]
파이썬 / TypeScript ← 오케스트레이션 · API · 화면
│ (pybind11 · N-API · gRPC)
C++ 코어 엔진 ← 이 탭의 영역
│ (CUDA · 인트린식 · mmap)
하드웨어 / 커널
Rust 는 보통 '새로 짜는 주변부'로 들어옵니다
· 새 네트워크 서비스, 새 CLI, 새 파서
· C++ 코어는 그대로 두고 FFI 로 붙임
· Chromium · Android · Windows 가 실제로 쓰는 방식
실무에선 이렇게 — 이 코드가 정말 네이티브여야 하는지 판정하기 🔴 실무
판정 질문 다섯 개
① 지연의 '상한'이 있나?
"평균 10ms" 는 상한이 아닙니다.
"p99.9 가 200µs 를 넘으면 안 된다" 가 상한입니다.
→ 상한이 있으면 GC 언어 탈락
② 한 번 호출이 아니라 '초당 몇 번' 인가?
초당 100회면 파이썬으로 충분합니다.
초당 100만 회면 호출 오버헤드가 본체보다 큽니다.
③ 데이터가 메모리에서 얼마나 움직이나?
GB 단위를 옮긴다면 배치(layout)가 전부입니다.
④ 하드웨어 기능을 직접 써야 하나?
AVX-512 · CUDA · io_uring · RDMA · GPIO
⑤ 붙일 라이브러리가 C/C++ 뿐인가?
FFmpeg · OpenCV · TensorRT · Qt · Vulkan
면접·현업에서 실제로 나오는 문장들
| 듣게 되는 말 | 실제 뜻 |
|---|---|
| “틱 투 트레이드 줄여 줘” | 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)$')
저지연 설계 — 핫패스에서 아무것도 하지 않기
고빈도 거래 코드의 원칙은 놀랍도록 단순합니다. 핫패스에서 할당하지 않고, 던지지 않고, 시스템콜을 부르지 않는다. 셋 다 “평균은 빠른데 가끔 100배 느린” 원인이기 때문입니다. 평균이 아니라 p99.9 를 지키는 코드가 어떻게 생겼는지 봅니다.
핫패스 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}); // 실제 기록은 다른 스레드가
}
};
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;
}
};
/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();
}
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));
// → 인터럽트·문맥 전환을 건너뛰어 수신 지연이 절반 이하로
커널 바이패스 — 세 가지 선택지
| 방식 | 특징 | 왕복 |
|---|---|---|
| 일반 소켓 | 아무 준비 불필요 | ~10µs |
| SO_BUSY_POLL | 한 줄 추가 | ~5µs |
| AF_XDP | 커널 5.4+ · 무료 | ~2µs |
| DPDK | NIC 드라이버 탈취 | ~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 를 통째로 뺏지 않아 운영이 쉽습니다
한 단계 더 — 캐시를 미리 덥히는 워밍 🧬 전문가
첫 주문이 제일 느린 문제
// 문제: 실제 신호가 왔을 때가 '그 코드 경로의 첫 실행'이라
// 명령어 캐시 · 데이터 캐시 · 분기 예측기가 전부 차갑습니다.
// 그래서 진짜 주문만 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 직전까지 — 직렬화 · 체크섬 · 버퍼 복사까지 —
// 똑같이 수행해야 의미가 있습니다.
핫패스에 뭐가 들어갔는지 감시하기
// ① 컴파일 시점에 막기 — 핫패스에서 예외를 금지
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
# 핫패스가 완성되면 이 숫자가 '거의 변하지 않아야' 합니다
락프리 자료구조 — SPSC 링 큐부터 손으로
뮤텍스는 잠기는 순간 커널로 들어가 다른 스레드를 재웁니다. 그 왕복이 수 µs 입니다. 생산자 하나 · 소비자 하나(SPSC)라면 락 없이, 원자 변수 두 개로 큐를 만들 수 있습니다. 전체 구현을 한 줄씩 보고, 언제 그냥 뮤텍스가 나은지까지 정리합니다.
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;
}
};
load 하면
매번 상대 코어의 캐시라인을 끌어옵니다.
사본을 두고 필요할 때만 갱신하면 처리량이 2~3배 올라갑니다.
(이 기법을 쓴 대표 구현이 Folly ProducerConsumerQueue 와 rigtorp/SPSCQueue 입니다.)
memory_order 를 실전 기준으로
| 쓰는 곳 | 무엇을 보장하나 |
|---|---|
relaxed | 원자성만. 순서 보장 없음 |
acquire(load) | 이 뒤의 읽기가 앞으로 못 옴 |
release(store) | 이 앞의 쓰기가 뒤로 못 감 |
acq_rel | RMW 연산용(둘 다) |
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)에서는 진짜 명령이 됩니다.
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;
}
};
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 가 쓰는 방식)
실무에선 이렇게 — 언제 그냥 뮤텍스가 낫나 🔴 실무
정직한 비교표
| 상황 | 선택 | 이유 |
|---|---|---|
| 경합 거의 없음 | mutex | 무경합 락은 ~20ns |
| 임계 구역이 김 | mutex | 스핀은 코어 낭비 |
| 1:1 파이프라인 | SPSC | ~15ns · 최적 |
| N:1 로그 수집 | MPSC | 소비자만 하나 |
| N:M 작업 큐 | MPMC / TBB | 직접 만들지 말 것 |
| 읽기 압도적 | shared_mutex | 또는 RCU |
| 실시간 스레드 | 락프리 필수 | 블로킹 자체가 금지 |
재 보고 고르기 — 세 구현 벤치마크
// 생산자 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 | ~95ns | 10M |
| SPSC (사본 없음) | ~38ns | 26M |
| SPSC (cached 사본) | ~13ns | 77M |
| boost spsc_queue | ~14ns | 71M |
한 단계 더 — 경쟁 조건을 자동으로 잡기 🧬 전문가
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
atomic 을 이해합니다 — 원자 변수로 제대로 동기화했으면 경고하지 않고,
relaxed 로 잘못 낮췄으면 잡아냅니다.
락프리 코드를 TSan 없이 쓰는 것은 안전벨트 없이 운전하는 것과 같습니다.
실무에서 쓰는 검증된 라이브러리
| 라이브러리 | 무엇이 좋은가 |
|---|---|
rigtorp/SPSCQueue | 헤더 하나 · 가장 빠른 SPSC |
folly::MPMCQueue | 블로킹/논블로킹 겸용 |
moodycamel::ConcurrentQueue | 범용 MPMC · 사용 쉬움 |
boost::lockfree | 표준적 · 문서 좋음 |
oneTBB concurrent_queue | TBB 스케줄러와 통합 |
libcds | hazard pointer · 학술적 |
seastar | 샤딩 · 코어당 하나씩 |
# vcpkg 로 한 줄 설치
vcpkg install concurrentqueue boost-lockfree tbb
# CMake
find_package(unofficial-concurrentqueue CONFIG REQUIRED)
target_link_libraries(app PRIVATE unofficial::concurrentqueue::concurrentqueue)
메모리 관리 고급 — 아레나 · PMR · huge page · NUMA
new 는 범용 할당자입니다. “아무 크기나, 아무 때나, 아무 스레드에서나”를 감당하느라
탐색 · 락 · 단편화를 안고 있습니다. 그런데 우리 프로그램은 대개 패턴이 뻔합니다.
그 패턴을 알면 할당을 덧셈 한 번으로 줄일 수 있습니다.
아레나 — 전체 구현 (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); // 실제로 만져야 물리 페이지가 붙습니다
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) ★
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; // 병렬로 초기화해야 흩어집니다
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);
BumpPtrAllocator 가 정확히 위의 40줄입니다).
std::pmr 이 답입니다.병렬 계산 — OpenMP · oneTBB · std::execution
코어가 16개인데 하나만 쓰고 있다면 15배를 버리는 중입니다. 그런데 스레드를 16개 만든다고 16배가 되지는 않습니다. 어디까지 빨라지는지 미리 계산하고, 실제로 그만큼 나오는지 재는 법까지 봅니다. C++ 에서 병렬을 쓰는 세 가지 길을 실제 코드로 비교합니다.
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 | 런타임에 맡김 |
-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_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() */ });
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();
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이 더 빠릅니다.
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 이 (실행시간 × 스레드수) 에 못 미치면 놀고 있는 것입니다
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
GPU — CUDA 첫 커널부터 실제 이득 판정까지
GPU 는 코어가 수천 개인 대신 하나하나가 느리고, 전부 같은 일을 해야 합니다. 그래서 “빠르다/느리다”가 아니라 맞는 문제 / 안 맞는 문제가 있습니다. 진짜 도는 커널을 하나 짜 보고, PCIe 복사 비용을 넘길 수 있는지 계산하는 법까지 갑니다.
첫 커널 — 벡터 덧셈 (진짜 도는 코드)
// 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 실행
NVIDIA 밖 — SYCL · HIP · Metal
| API | 대상 | 메모 |
|---|---|---|
| CUDA | NVIDIA | 생태계 압도적 |
| HIP / ROCm | AMD | CUDA 를 거의 그대로 |
| 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
실무에선 이렇게 — 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 에도 똑같이 적용하면 됩니다.
복사를 줄이는 세 가지 기법
// ① 고정 메모리(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 로 옮겨 두기
한 단계 더 — 커널을 프로파일링하기 🧬 전문가
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);
커널 최적화 체크리스트
| 증상 | 처방 |
|---|---|
| 점유율(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 를 씁니다.
영상 · 이미지 — OpenCV C++ 와 FFmpeg libav
파이썬 OpenCV 는 편합니다. 그런데 프레임마다 파이썬 루프를 도는 순간 초당 30장도 못 냅니다. C++ 로 내려오면 디코딩된 프레임 버퍼를 그대로 넘겨받아 복사 없이 처리할 수 있습니다. OpenCV 를 붙이는 법부터 FFmpeg 로 직접 디코딩하는 코드까지 봅니다.
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;
}
}
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초 |
이렇게도 쓸 수 있다 — 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 에서 한다면 내리지 말고 그대로 두는 것이 정답입니다
실무에선 이렇게 — 파이썬과 C++ 를 어떻게 나누나 🔴 실무
판단 기준
| 하는 일 | 어디서 |
|---|---|
| 실험 · 파라미터 탐색 | 파이썬 |
| cv2 함수만 이어 붙이기 | 파이썬 |
| 모델 학습 | 파이썬 |
| 픽셀 단위 커스텀 알고리즘 | C++ |
| 실시간 30fps 이상 다중 스트림 | C++ |
| 디코더 → 추론 → 인코더 무복사 | C++ |
| 임베디드 · 카메라 장비 탑재 | C++ |
| 서버 배포(파이썬 없는 환경) | C++ |
다중 스트림 파이프라인 뼈대
// 카메라 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(); }
}
}
};
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 — 화면에 그릴 때도 안 내려옵니다
인코딩과 스트리밍까지
// 처리한 프레임을 다시 내보내기 (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 | 파이프라인 조립 · 플러그인 |
| DeepStream | NVIDIA 다중 스트림 추론 |
| OpenCV VideoIO | 프로토타입 · 간단한 도구 |
| libvpx · SVT-AV1 | 코덱 직접 제어 |
오디오 실시간 — 콜백 안에서는 아무것도 하지 않습니다
오디오 드라이버는 몇 밀리초마다 정확히 콜백을 부르고, 그 안에서 다음 버퍼를 채워 달라고 합니다. 한 번이라도 늦으면 스피커에서 “틱” 소리가 납니다. 그래서 실시간 오디오 스레드에는 다른 어떤 분야보다 엄격한 규칙이 있습니다. 그 규칙과, UI 와 안전하게 교신하는 법을 봅니다.
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();
}
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 # 출력을 입력에 물려 왕복 지연 측정
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 처리가 바뀝니다.
한 단계 더 — 실시간 안전성을 자동으로 검사하기 🧬 전문가
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
이 규칙이 통하는 다른 분야
| 분야 | 같은 규칙이 적용되는 곳 |
|---|---|
| 오디오 | 드라이버 콜백 |
| 게임 | 프레임 루프 · 물리 스텝 |
| 저지연 거래 | 틱 핸들러(h02) |
| 로보틱스 | 제어 루프(1kHz) |
| 자동차 | ECU 태스크 · AUTOSAR |
| 커널 · 드라이버 | 인터럽트 핸들러 |
| 항공 · 의료 | DO-178C · IEC 62304 |
공통 규칙 다섯 줄 — 외워 두면 분야가 바뀌어도 그대로 씁니다
1. 최악 실행 시간(WCET)이 정해지지 않은 호출을 하지 않는다
2. 메모리는 시작할 때 전부 잡고, 만져 두고, 잠근다
3. 다른 스레드를 기다리지 않는다 (락 · join · 조건 변수 금지)
4. 실패하면 '기다리지 말고 버린다' — 늦은 결과는 없는 것만 못하다
5. 예산 대비 사용률을 항상 재서 내보낸다
고속 I/O — mmap · io_uring · O_DIRECT
read() 한 번은 시스템콜 한 번입니다. 초당 100만 건을 읽어야 하면
시스템콜만으로 CPU 한 코어가 날아갑니다. 리눅스는 이 문제를 위해
io_uring 이라는 완전히 새로운 인터페이스를 만들었습니다. 실제 코드와 실측 수치로 봅니다.
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);
}
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);
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_HUGEPAGE | 2MB 페이지로 |
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/O | io_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}))};
내구성 — 언제 정말 디스크에 있나
// 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 |
한 단계 더 — 네트워크도 커널을 건너뜁니다 🧬 전문가
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 클러스터에서 표준
그 전에 해 볼 무료 튜닝
# ① 수신 큐를 코어에 분산 (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 가 사라지는 서비스가
아직도 많습니다. 커널 바이패스를 검토하기 전에
위 다섯 가지를 전부 해 보는 것이 비용 대비 효과가 압도적입니다.
데이터 처리 엔진 — Arrow · Parquet · DuckDB
“판다스가 메모리를 다 먹고 죽었어요”의 원인은 대개 데이터 배치입니다. 같은 데이터라도 행으로 두느냐 열로 두느냐에 따라 집계 속도가 열 배 넘게 갈립니다. C++ 로 컬럼 지향 데이터를 다루는 표준 도구(Arrow · Parquet)와, SQL 엔진을 라이브러리로 임베딩하는 법을 봅니다.
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));
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 시간 |
|---|---|---|
| CSV | 9.4GB | 84초 |
| JSON | 21GB | 210초 |
| Parquet(snappy) | 1.1GB | 0.9초 |
| Parquet(zstd) | 0.8GB | 1.1초 |
| Arrow IPC(메모리) | 3.2GB | 0.11초 |
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(...)");
}
언제 직접 짜고 언제 엔진을 부르나
| 하는 일 | 선택 |
|---|---|
| 집계 · 조인 · 그룹바이 | DuckDB |
| 파일 형식 변환(CSV→Parquet) | DuckDB |
| 탐색적 분석 · 리포트 | DuckDB |
| 도메인 특화 수식 계산 | Arrow + 직접 |
| 스트리밍 실시간 집계 | 직접 |
| 초저지연(µs) 조회 | 직접(전용 인덱스) |
| 분산 · 수십 TB | ClickHouse · 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;
}
실무에선 이렇게 — 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 · 함수형 스타일
한 단계 더 — 판다스와 정면 비교 🧬 전문가
같은 질의를 네 가지로
질의: 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 로 표현이 안 되는 계산'입니다
(도메인 수식 · 시계열 상태 기계 · 커스텀 조인 조건)
파이썬과 무복사로 주고받기
// 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) # 결과도 뷰
ML 추론 런타임 — 파이썬 없이 배포하기
모델 학습은 파이썬이 압도적입니다. 그런데 배포는 사정이 다릅니다. 파이썬 인터프리터 · 수 GB 짜리 의존성 · GIL 없이 실행 파일 하나로 끝내고 싶을 때 C++ 추론 런타임을 씁니다. ONNX Runtime 으로 일반 모델을, llama.cpp 로 LLM 을 돌려 봅니다.
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) 가 결과입니다
}
더 빠르게 — 프로바이더와 배치
// ① 실행 프로바이더 — 하드웨어에 맞춰 골라 넣습니다
// 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 CPU | 19ms |
| ORT CUDA | 3.1ms |
| ORT TensorRT FP16 | 1.2ms |
| ORT TensorRT INT8 | 0.7ms |
이렇게도 쓸 수 있다 — 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); // 다음 입력은 방금 토큰
}
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 성능
실무에선 이렇게 — 토크나이저와 전처리를 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)에서 자주 어긋납니다
추론 서버 뼈대
// 요청을 모아 배치로 처리하는 구조 — 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;
}
}
};
한 단계 더 — 직접 짜 볼 때와 도구 지도 🧬 전문가
2026년 추론 도구 지도
| 도구 | 강점 | 자리 |
|---|---|---|
| ONNX Runtime | 이식성 · 전 하드웨어 | 범용 1순위 |
| TensorRT | NVIDIA 최속 | 고정 GPU |
| OpenVINO | 인텔 CPU · iGPU | 엣지 · 산업 |
| llama.cpp / GGML | 양자화 · CPU | 로컬 LLM |
| vLLM | 연속 배치 · PagedAttn | LLM 서버 |
| ExecuTorch | 모바일 · 임베디드 | 온디바이스 |
| TFLite | 안드로이드 · MCU | 초소형 |
| ggml 직접 | 완전 통제 | 연구 · 특수 |
직접 짜 보면 이해가 달라집니다
// 추론 엔진의 핵심은 결국 행렬 곱 하나입니다.
// 양자화된 가중치(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;
}
}
// 실제 커널은 '풀어서 저장'하지 않고 곱셈에 바로 넣습니다 —
// 메모리를 한 번만 읽는 것이 전부이기 때문입니다(위 대역폭 계산 참고).
컴파일 · 바이너리 최적화 — LTO · PGO · 빌드 시간
코드를 한 줄도 안 고치고 10~30% 빨라지는 방법이 있습니다. 컴파일러에게 더 넓게 보게 하고(LTO), 실제로 어떻게 도는지 알려 주는(PGO) 것입니다. 동시에 C++ 의 가장 큰 불편인 빌드 시간을 줄이는 실전 처방도 함께 봅니다.
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
-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
이렇게도 쓸 수 있다 — -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 디렉터리에 두면 로더가 알아서 고릅니다
-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 + mold | 15분 | 22초 |
| + 헤더 정리(IWYU) | 11분 | 16초 |
| + 유니티 빌드 | 4분 | 28초 |
| + ccache(두 번째) | 50초 | 16초 |
한 단계 더 — 모듈 현황과 바이너리 다이어트 🧬 전문가
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 | 기본은 동작 | 제한적 |
바이너리 크기와 코드 배치
# ① 안 쓰는 코드 잘라내기 — 임베디드·컨테이너에서 큰 효과
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% ★
프로파일링 실전 — 느린 것을 찾는 순서
성능 작업에서 가장 흔한 실패는 “빨라 보이는 곳”을 고치는 것입니다. 실제로는 아무도 예상 못 한 곳이 90%를 먹고 있습니다. 추측을 금지하고 순서대로 재는 방법을 정리하고, 마지막에 실제 사례 하나를 끝까지 따라갑니다.
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
# 가로 폭 = 그 함수에 머문 시간 · 위로 갈수록 깊은 호출
# '넓고 평평한 봉우리' 를 찾으면 그것이 범인입니다
숫자를 해석하는 법
| 지표 | 기준 | 낮/높을 때 |
|---|---|---|
| IPC | 1.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
이렇게도 쓸 수 있다 — 플랫폼별 도구 🟡 중급
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.*
윈도우 — 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 (윈도우도 지원)
perf sched 나 offcputime 이 비슷한 역할입니다.
실무에선 이렇게 — 체크리스트로 굳혀 두기 🔴 실무
성능 작업 순서 — 인쇄해서 붙여 두세요
0. 재현 가능한 벤치마크를 먼저 만든다
· 5회 이상 돌려 편차를 본다 (편차가 개선폭보다 크면 무의미)
· 터보 부스트 · 주파수 조절 · 다른 프로세스를 끈다
· 릴리스 빌드로 잰다 (디버그 빌드 측정은 전부 거짓말)
1. 목표 지표를 하나만 정한다 (p99 · 처리량 · 총 시간 중 하나)
2. CPU 사용률을 본다 → 낮으면 대기, 높으면 계산
3. 플레임그래프를 뜬다 → 넓은 봉우리 세 개를 적는다
4. perf stat 으로 IPC·미스율 → 성격(메모리 / 분기 / 계산)을 판정
5. 가장 큰 것 하나만 고친다 → 두 개 동시에 고치면 원인을 모른다
6. 다시 잰다 → 개선폭을 기록
7. 2번으로 돌아간다 → 목표에 닿거나, 남은 항목이 5% 미만일 때까지
★ 하지 말 것
· 프로파일 없이 '느려 보이는 곳' 고치기
· 마이크로 최적화부터 시작하기 (알고리즘 · I/O · 배치가 먼저)
· 한 번 재고 결론 내리기
· 개선 수치를 기록 안 하기
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 로 노출
# → 프로파일러 없이도 '어느 구간이 언제부터 느려졌는지' 추적됩니다
한 단계 더 — 실제 사례 하나를 끝까지 🧬 전문가
증상부터 원인까지
[ 상황 ]
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줄 미만, 알고리즘은 하나도 안 바꿨습니다
화면이 있는
프로그램을
C++ 로 만듭니다
파이썬에서 PySide6 로 창을 띄워 본 사람을 기준으로 씁니다.
같은 QPushButton 을 C++ 에서 만들면 무엇이 같고 무엇이 달라지는지,
설치부터 첫 창 · 스레드 · DB · 다국어 · 설치 파일 만들기까지 한 줄씩 따라갑니다.
툴킷은 하나가 아니라서 무엇을 만드느냐에 따라 Qt · Dear ImGui · SDL3 · wxWidgets 중 무엇을 고를지부터 정리하고,
마지막에는 수백만 줄짜리 로그를 여는 뷰어 앱을 Qt 로 배포 파일까지 끝냅니다.
이 탭을 마치면 “설치 파일을 건네줄 수 있는” 상태가 됩니다.
왜 C++ 로 앱을 만드나 — 툴킷 일곱 개 중에 고르기
2026년에 데스크톱 앱을 만드는 가장 쉬운 길은 사실 Electron 이나 Tauri 입니다. 그래도 C++ 를 고르는 이유가 분명히 있고, 그 이유가 어떤 툴킷을 고를지까지 결정합니다. 먼저 그 지도를 그린 다음에 설치로 넘어갑니다.
툴킷 일곱 개 한눈에 ★
| 툴킷 | 강점 | 약점 |
|---|---|---|
| Qt 6 | 위젯·QML·네트워크·DB·다국어가 한 상자. 문서·구인 시장 1위 | 덩치 큼(런타임 40~120MB), 라이선스 판단 필요 |
| Dear ImGui | 파일 몇 개 붙이면 끝. UI 코드가 로직 안에 들어감 | 일반 사용자용 룩이 아님, 접근성·IME 약함 |
| SDL3 | 창·입력·오디오·GPU 의 얇은 표준층. 게임의 바닥 | 버튼 하나도 직접 그림 |
| raylib | 30줄이면 게임 하나. 배우기 가장 쉬움 | 큰 프로젝트 구조는 직접 설계 |
| wxWidgets | 진짜 OS 네이티브 위젯을 씀. LGPL 보다 느슨한 라이선스 | API 가 예스러움, QML 같은 선언형 없음 |
| JUCE | 오디오 플러그인의 사실상 표준(VST3·AU·AAX) | 오디오 밖에서 쓸 이유가 없음 |
| Slint | 선언형 .slint, 임베디드 화면에 가벼움 | 생태계가 아직 얇음 |
PySide6 를 아는 사람이 겪는 낙차
| PySide6 | Qt C++ | |
|---|---|---|
| 설치 | pip install pyside6 | 온라인 설치기 · vcpkg |
| 실행 | python app.py | CMake 빌드 후 실행 |
| 수명 관리 | GC + 부모 자식 | 부모 자식이 전부 |
| 시그널 연결 | btn.clicked.connect(f) | connect(btn, &QPushButton::clicked, …) |
| UI 파일 | 런타임 로드 가능 | 빌드 때 코드 생성(uic) |
| 배포 | PyInstaller | windeployqt + 설치기 |
| 배포 크기 | 80~200MB | 15~60MB |
| 시작 속도 | 0.5~2초 | 0.05~0.2초 |
그래서 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 로 넘기는 것보다 같은 프로세스가 압도적으로 단순합니다
라이선스 — 넘어가면 나중에 아픕니다 ★
| 툴킷 | 라이선스 | 상용 배포 시 |
|---|---|---|
| Qt 6 | LGPLv3 또는 상용 | 동적 링크면 LGPL 로 가능 |
| Dear ImGui | MIT | 자유 |
| SDL3 | zlib | 자유 |
| raylib | zlib | 자유 |
| wxWidgets | wxWindows(LGPL+예외) | 정적 링크도 자유 |
| JUCE | GPL 또는 상용 | 매출 기준 유료 |
| Slint | GPL·상용·로열티프리 | 조건부 무료 |
실무에선 이렇게 — 섞어 씁니다 🟡 중급
한 앱 안에 두 툴킷
실제 제품에서 자주 보는 조합
① 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 탭 참고
→ 프로세스가 나뉘면 크래시 격리가 되는 장점도 있습니다
이 탭이 가는 길
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 실전 프로젝트 ★ 로그 뷰어를 배포까지
Qt 6 설치 — 설치기냐 vcpkg 냐, 그리고 첫 CMake
pip install pyside6 한 줄이던 것이 C++ 에서는 계정 만들기 · 컴포넌트 고르기 · 30GB 디스크가 됩니다.
여기서 막히는 사람이 정말 많아서, 무엇을 왜 고르는지를 화면 순서대로 적어 둡니다.
그리고 이 모든 걸 건너뛰는 vcpkg 경로도 같이 봅니다.
경로 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 를 권합니다
공백·한글이 든 경로는 나중에 반드시 문제를 냅니다
경로 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 가 전부 이 파일을 읽습니다 ★
.vscode/settings.json 이나 CMakeLists.txt.user 를 커밋해서
남의 경로가 들어가는 사고가 사라집니다. CMakePresets.json 은 커밋하고
CMakeUserPresets.json 은 .gitignore 하는 게 관례입니다.
Qt Creator 냐 VS Code 냐 — 둘 다 씁니다 🟡 중급
역할이 다릅니다
| Qt Creator | VS Code | |
|---|---|---|
| Qt 프로젝트 인식 | 기본 | CMake Tools 확장 |
| Qt Designer(.ui) | 내장, 더블클릭 | 외부 실행 |
| QML 편집·미리보기 | 가장 좋음 | 확장으로 부분 지원 |
| 디버거 Qt 타입 표시 | QString 이 글자로 보임 | natvis 설정 필요 |
| 일반 코드 편집 | 무난 | 확장·AI 도구 풍부 |
| Git·터미널 | 기본만 | 훨씬 좋음 |
| 메모리 사용 | 가벼움 | 무거움 |
.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"
}
compile_commands.json 을 못 찾은 겁니다.
CMake 에 -DCMAKE_EXPORT_COMPILE_COMMANDS=ON 을 주고
빌드 폴더에 그 파일이 생겼는지 확인하세요.
Qt 는 moc 이 만든 헤더(moc_*.cpp, ui_*.h)를 빌드 폴더에 만들기 때문에,
한 번 빌드한 뒤에야 자동완성이 온전해집니다.
Qt Widgets 첫 앱 — 이벤트 루프와 시그널/슬롯
Qt 앱의 main() 은 딱 세 줄입니다. 그 세 줄이 무슨 일을 하는지 알면
“왜 창이 바로 닫히지”, “왜 UI 가 멈추지” 같은 질문이 한꺼번에 풀립니다.
PySide6 코드와 나란히 놓고 보겠습니다.
첫 창 — 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()) # ④ 같습니다
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 할 때 부모를 주면 신경 끄고, 안 주면 내가 책임진다” 하나뿐입니다.
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); // 고해상도 · 다국어에서 전부 깨집니다
표를 띄웁니다 — 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)
QUiLoader 로 실행 중에 .ui 를 읽을 수 있지만,
C++ 은 빌드할 때 uic 가 C++ 코드로 바꿔 넣습니다.
그래서 더 빠르지만, .ui 를 고치면 다시 빌드해야 합니다.
.ui 냐 코드냐 — 팀 규칙을 정하세요
| .ui (Designer) | 코드로 직접 | |
|---|---|---|
| 만드는 속도 | 빠름 — 끌어다 놓기 | 느림 |
| Git 병합 | XML 충돌이 지옥 | 일반 코드처럼 |
| 조건부 UI | 어려움 | 자유로움 |
| 리뷰 | diff 를 못 읽음 | 읽힘 |
| 커스텀 위젯 | promote 과정 필요 | 그냥 new |
| 디자이너 협업 | 가능 | 불가 |
Qt Quick · QML — 선언형 UI와 C++ 백엔드 잇기
Widgets 가 “버튼을 만들어 레이아웃에 넣어라”라면, QML 은 “여기에 버튼이 있다”고 적는 방식입니다. React 나 SwiftUI 를 써 봤다면 익숙한 감각이고, 애니메이션·터치·커스텀 디자인에서 압도적으로 유리합니다. 대신 C++ 데이터를 QML 로 넘기는 다리를 놓는 법을 알아야 합니다.
최소 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();
}
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;
};
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 Widgets | Qt Quick(QML) | |
|---|---|---|
| 업무용 폼·표·메뉴 | 압도적으로 빠름 | 직접 만들어야 함 |
| 애니메이션·전환 | 고통 | 기본 문법 |
| 커스텀 디자인 | QSS 로 한계 있음 | 자유로움 |
| 도킹·MDI·툴바 | 기본 제공 | 없음(직접) |
| 터치·모바일 | 어색 | 이쪽 전제 |
| GPU 사용 | 거의 없음(CPU 래스터) | 씬 그래프 |
| 저사양·가상머신 | 잘 돎 | GPU 없으면 느림 |
| 고칠 때 재빌드 | 필요 | QML 만 고치면 즉시 |
| 디버깅 | 일반 C++ | QML+C++ 두 곳을 봄 |
| 메모리(빈 창) | 25~40MB | 60~110MB |
섞어 쓰기와 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 로 바인딩 재평가 횟수를 봅니다
Qt 실전 — 스레드 · HTTP · 설정 · DB · 다국어
창을 띄우는 것과 쓸 만한 앱 사이의 거리는 대부분 이 다섯 가지입니다. 특히 “UI 가 멈춘다”는 문제는 신입이든 경력이든 한 번은 겪는데, Qt 의 해법은 놀랄 만큼 단정합니다 — 워커는 시그널만 쏘고 화면은 건드리지 않습니다.
방법 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);
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 배포 — exe 하나를 건네도 안 도는 이유
빌드가 끝나면 app.exe 가 생기지만, 그걸 다른 컴퓨터에 복사하면 거의 확실히 안 돕니다.
Qt DLL 이 없기 때문입니다. 여기서는 필요한 파일을 자동으로 모으고 → 설치 파일로 묶고 → 서명하는
한 바퀴를 OS 별로 끝냅니다. 이 섹션을 마치면 남에게 건넬 수 있는 결과물이 나옵니다.
윈도우 — 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 한 줄로 끝납니다
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 포함)
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/.app | 3-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
정적 빌드와 크기 줄이기 — 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 없음)
Dear ImGui — 즉시 모드가 도구를 만드는 방식
Qt 로 디버그 창 하나를 만들려면 클래스 · 헤더 · 시그널 · 모델이 줄줄이 붙습니다.
ImGui 는 그 자리에서 if (ImGui::Button("리셋")) reset(); 한 줄이면 끝입니다.
게임 엔진과 그래픽스 도구가 전부 이걸 쓰는 이유, 그리고 왜 사용자용 앱에는 안 쓰는지를 봅니다.
세팅 — 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 의 큰 장점입니다
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 네이티브 룩이 요구될 때 |
| 혼자·소수가 쓰는 도구 | 복잡한 폼 · 문서 편집 |
함정 세 개와 대안 라이브러리
// 함정 ① 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 러스트 진영의 즉시 모드 (참고용)
SDL3 와 raylib — 창 · 입력 · 게임 루프를 직접
Qt 와 ImGui 는 “위젯”이라는 개념 위에 있습니다. SDL3 와 raylib 에는 그런 게 없습니다 — 창을 열고, 입력을 받고, 픽셀을 그리는 딱 그만큼만 줍니다. 게임뿐 아니라 영상 플레이어 · 시뮬레이터 · 시각화 도구처럼 화면 전체를 직접 그리는 프로그램의 바닥입니다.
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;
}
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;
}
DrawCircleV, IsKeyDown, CheckCollisionCircleRec).
초기화·컨텍스트·핸들 같은 게 안 보이고, 전역 상태로 다 숨겨져 있습니다.
그게 큰 프로젝트에서는 단점이 되지만, 배우거나 프로토타입을 만들 때는 압도적입니다.
SDL3 냐 raylib 이냐
| SDL3 | raylib | |
|---|---|---|
| 성격 | OS 추상화 층 | 배우기용 프레임워크 |
| 배우는 시간 | 며칠 | 한두 시간 |
| 제어 수준 | 세밀함 | 정해진 길 |
| 상용 게임 사용 | 많음 | 드묾(인디는 있음) |
| 3D | 직접(또는 SDL_gpu) | 기본 제공 |
| 오디오·폰트 | 기본만 | 편의 함수 풍부 |
| 웹(WASM) 이식 | 공식 지원 | 지원 |
| C++ 스타일 | C API | C API |
실전 선택 기준
게임을 처음 만들어 본다 → raylib
게임 잼 · 프로토타입 · 교육 → raylib
실제로 스팀에 낼 생각 → SDL3
에뮬레이터 · 미디어 플레이어 · 도구 → SDL3
이미 ImGui 를 쓰고 있다 → SDL3 (백엔드가 있음)
엔진을 직접 만든다 → SDL3 (+ SDL_gpu 또는 bgfx)
3D 게임을 제대로 → 엔진을 쓰세요 ★
Godot(C++ 확장 가능) · Unreal(C++ 이 본체) · O3DE
"SDL3 로 3D 엔진을 처음부터"는 몇 년짜리 프로젝트입니다
한 단계 더 — 텍스처 · 오디오 · 게임패드 🟡 중급
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
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초로 자르는 한 줄이 이걸 끊습니다 ★
wxWidgets — 진짜 OS 위젯을 쓰는 유일한 선택
Qt 는 버튼을 직접 그립니다. 아주 비슷하게 그리지만 OS 가 만든 버튼은 아닙니다. wxWidgets 는 OS 의 진짜 버튼을 그대로 부릅니다. 그래서 윈도우에서는 윈도우 앱처럼, 맥에서는 맥 앱처럼 보이고, 라이선스가 가장 자유롭습니다. 어떤 상황에서 이게 결정적인지 봅니다.
설치와 첫 프레임 ★
# 설치 — 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 를 아는 사람을 위한 대응표
| Qt | wxWidgets |
|---|---|
| QApplication | wxApp + wxIMPLEMENT_APP |
| QMainWindow | wxFrame |
| QWidget | wxWindow / wxPanel |
| QVBoxLayout | wxBoxSizer(wxVERTICAL) |
| QGridLayout | wxFlexGridSizer |
| connect(…) | Bind(wxEVT_…, …) |
| QString | wxString (문자열 변환 주의) |
| QTableView + 모델 | wxDataViewCtrl + wxDataViewModel |
| QThread | wxThread |
| QSettings | wxConfig |
| QFileDialog | wxFileDialog |
| Qt Designer | wxFormBuilder · wxCrafter |
| tr("…") | _("…") (gettext) |
이벤트 처리 — 옛 문법과 새 문법 🟡 중급
이벤트 테이블 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()) ← ✗ 현재 로캘로 해석해 깨질 수 있습니다
QWidget::winId() 로 비슷하게 되지만,
wx 쪽이 애초에 그런 결합을 전제로 설계돼 있어 훨씬 덜 아픕니다.
고를 이유와 피할 이유 — 정직하게 🔴 실무
wx 를 고르는 세 가지 상황
① 라이선스가 결정적일 때 ★
wxWindows Licence = LGPL + "바이너리 배포에 조건을 걸지 않는다"
→ 정적 링크해서 소스 공개 없이 상용 배포가 명시적으로 허용됩니다
→ Qt 로 같은 걸 하려면 상용 라이선스(연 수백만 원대)가 필요합니다
→ 실행 파일 하나로 배포해야 하는 상용 도구에 결정적입니다
② 진짜 네이티브 룩이 요구될 때
관공서·금융·산업 장비 소프트웨어의 심사 항목에
"OS 표준 UI 준수" 가 들어 있는 경우가 실제로 있습니다
Qt 는 "매우 비슷하게" 그리지만 같지는 않습니다
③ 기존 Win32/MFC 자산과 섞어야 할 때
창 핸들 수준 결합이 필요하면 wx 가 가장 덜 아픕니다
── 참고: wx 로 만들어진 실제 소프트웨어 ──
Audacity(오디오 편집) · Code::Blocks(IDE)
FileZilla(FTP) · Kicad(전자 CAD) · Amaya
전부 "오래 유지되는 도구"라는 공통점이 있습니다
피해야 할 상황과 현실
| 항목 | Qt | wxWidgets |
|---|---|---|
| 커뮤니티 크기 | 매우 큼 | 작음 |
| 한국어 자료 | 많음 | 거의 없음 |
| 구인 시장 | 존재함 | 거의 없음 |
| 선언형 UI | QML | 없음 |
| 모바일 | 안드로이드·iOS | 실질적으로 없음 |
| 커스텀 디자인 | QSS·QML | 매우 어려움 |
| 부가 모듈 | 네트워크·DB·차트… | 있지만 얇음 |
| 플랫폼 동작 차이 | 적음 | 있음 — 3-OS 테스트 필수 |
| 라이선스 자유도 | LGPL/상용 | 가장 자유 |
| 배포 크기(정적) | 제한적 | 10~20MB 단일 exe |
Win32 · WinUI 3 — 트레이 유틸리티와 윈도우 전용 기능
백그라운드에 조용히 떠 있다가 트레이 아이콘으로만 존재를 알리는 프로그램 — 이런 건 Qt 를 통째로 얹는 게 과합니다. Win32 만으로 200KB 짜리 실행 파일을 만들 수 있고, 이 절의 메시지 루프는 모든 윈도우 GUI 의 바닥이라 알아 두면 Qt 디버깅에도 쓰입니다.
최소 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
--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 예외가 날아옵니다
WinUI 3 — 고를 만한가
| WinUI 3 (C++) | Qt | |
|---|---|---|
| 윈도우 11 디자인 | 완벽 (Mica·Fluent) | 흉내만 |
| 다른 OS | 불가 | 3-OS |
| UI 정의 | XAML | QML 또는 코드 |
| 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 다운 디자인"이
제품 요구사항일 때만 감당할 값어치가 있습니다.
DwmSetWindowAttribute 로 Mica 배경 · 둥근 모서리 · 다크 타이틀바를
켤 수 있습니다. QWidget::winId() 로 HWND 를 얻어 호출하면 되고,
열 줄이면 “윈도우 11 앱처럼 보이는 Qt 앱”이 됩니다.
성능과 구조 — 수백만 행을 부드럽게 보여 주기
데스크톱 앱이 느려지는 자리는 거의 정해져 있습니다 — UI 스레드에서 뭔가를 오래 하거나, 화면 밖의 것까지 만들거나, 한 셀에 문자열을 매번 새로 만드는 것. 여기서는 그 셋을 구조로 막는 법과, 느린 곳을 찾는 순서를 정리합니다.
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/ 만 다시 씁니다.
③ 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 문이 거의 없다면 분리가 잘 된 것입니다
느린 곳 찾기 — 순서가 정해져 있습니다 🔴 실무
측정 도구와 쓰는 순서
① 먼저 "무엇이 느린가"를 한 문장으로 — 여기서 절반이 끝납니다
· 파일을 열 때 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 프레임별 시간
⑤ 그래도 모르겠으면 — 반씩 지워 보세요
기능을 절반 끄고 재보고, 느린 쪽을 다시 절반 끄고…
프로파일러보다 이게 빠를 때가 실제로 많습니다
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 하지 말 것
std::vector 접근마다 반복자 검사를 넣어 10~50배 느립니다.
“C++ 인데 파이썬보다 느린데요?” 의 8할이 이것입니다.
측정은 반드시 Release 또는 RelWithDebInfo 로 하세요.
실전 — 로그 뷰어를 배포 파일까지 만듭니다
앞의 열한 절을 하나로 묶습니다. 만들 것은 수 GB 짜리 로그 파일을 열어 즉시 검색하고 레벨로 거르는 뷰어입니다. 메모장으로는 못 열고, 파이썬으로는 느리고, 상용 도구는 비싼 — C++ 데스크톱 앱이 실제로 이기는 자리입니다.
① 프로젝트 뼈대 — 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;
}
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 를 따름
// ⑦ 오류를 대화상자로만 띄우지 말고 상태 표시줄에도 남기기
// ⑧ 큰 파일 인덱싱 중에는 [취소] 버튼을 실제로 동작하게
빌드부터 설치 파일까지 — 스크립트 한 개
#!/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
이 프로젝트에 들어간 것 — 한눈에
| 배운 것이 여기 쓰였습니다 | 절 |
|---|---|
| 툴킷으로 Qt Widgets 를 고른 근거 | g01 |
CMake + CMAKE_PREFIX_PATH · 프리셋 | g02 |
| QMainWindow · 레이아웃 · 모델/뷰 | g03 |
| QtConcurrent 로 UI 안 멈추게 · QSettings | g05 |
| windeployqt · Inno Setup · 서명 | g06 |
| mmap · 인덱스 배열 · 고정 행 높이 | g11 |
| core · viewmodel · ui 세 층 분리 | g11 |
마지막으로 — 정직한 얘기
이 로그 뷰어를 다른 방법으로 만들면
파이썬 + 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 이었고, 맞다면 여기까지가 한 바퀴입니다.
C++ 로 만든 힘을
C# 이 그대로
가져다 씁니다
C# 은 생산성이 아주 높은 언어지만, 픽셀·샘플·행렬을 수천만 개씩 훑는 루프에서는
JIT 가 아무리 잘해도 손으로 벡터화한 C++ 를 따라잡기 어렵습니다.
그래서 실무에서는 무거운 알맹이만 C++ 로 만들고 나머지는 C# 으로 씁니다.
이 탭은 그 연결을 가장 간편한 길부터 보여 줍니다 —
C ABI 함수 하나 + [LibraryImport] 한 줄이면 끝이고,
거기서 마샬링 · 구조체 · 핸들 · 콜백 · 예외 · Unity · NuGet 배포까지 넓혀 갑니다.
이 탭을 마치면 C++ 라이브러리를 C# 콘솔 · ASP.NET Core · Unity 어디에나 붙일 수 있게 됩니다.
다섯 갈래 중 어디로 갈 것인가 — 2026년의 기본값
C++ 를 C# 에서 부르는 방법은 다섯 가지입니다. 그런데 실제로 고민할 가치가 있는 건 두 개뿐이고, 나머지 셋은 “이런 조건일 때만” 이라는 꼬리표가 붙습니다. 먼저 그 지도를 그려 놓고 시작합니다.
다섯 갈래를 한 표로 ★
| 플랫폼 | C++ 클래스 | 난이도 | NativeAOT | |
|---|---|---|---|---|
| LibraryImport | 전부 | 핸들로 감싸서 | 낮음 | 완전 호환 |
| DllImport(구방식) | 전부 | 핸들로 감싸서 | 낮음 | 일부 제약 |
| C++/CLI | 윈도우만 | 직접 감쌈 | 중간 | 불가 |
| COM | 윈도우만 | 인터페이스로 | 높음 | 부분 |
| 자동 생성 | 전부 | 생성기가 처리 | 중간 | 생성 결과에 따라 |
가장 짧은 길 — 이 정도면 끝입니다
// 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
.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 파일입니다
실무에선 이렇게 — 고를 때 실제로 따지는 것 🔴 실무
이렇게 시작하면 나중에 아픕니다
// ✗ 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 래퍼 |
.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 인코딩 + 할당이 붙음
*/
extern "C" 함수 + [LibraryImport] 입니다.
C++/CLI 는 “윈도우 전용 + 기존 C++ 클래스가 산더미”일 때만, 자동 생성은 “헤더가 수천 줄”일 때만,
COM 은 “이미 있어서 어쩔 수 없을 때만” 꺼냅니다.첫 P/Invoke — 10분이면 호출됩니다
설명은 뒤로 미루고 실제로 도는 것을 먼저 만듭니다. C++ 파일 하나, C# 파일 하나, 그리고 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}");
extern "C" 누락, ② __declspec(dllexport) 누락,
③ 32비트 DLL 을 64비트 프로세스에서(또는 반대로) 여는 경우.
마지막 것은 윈도우에서 BadImageFormatException 으로 나기도 합니다.
extern "C" __declspec(dllexport) 로 내보내고,
C# 에서는 확장자 없는 이름으로 [LibraryImport] 하고,
csproj 로 DLL 을 출력 폴더에 복사합니다. 이 셋만 맞으면 첫 호출은 무조건 성공합니다.마샬링 규칙 — 무엇이 그냥 통과하고 무엇이 변환되나
P/Invoke 의 거의 모든 버그와 거의 모든 비용이 “타입이 경계에서 어떻게 변환되는가” 한 곳에 몰려 있습니다. 규칙은 의외로 짧습니다 — 메모리 모양이 같으면 그냥 통과하고, 다르면 변환기가 낍니다.
타입 대응표 — 붙여 놓고 쓰세요 ★
| C / C++ | C# | 비고 |
|---|---|---|
int8_t / char | sbyte | 부호 있음 |
uint8_t / unsigned char | byte | 바이트 버퍼 |
int16_t / short | short | |
int32_t / int | int | 가장 안전 |
int64_t / long long | long | |
long | 쓰지 마세요 | OS 마다 폭이 다름 |
size_t / ptrdiff_t | nuint / nint | 포인터 폭 |
float / double | float / double | IEEE754 동일 |
bool (C++) | [MarshalAs(U1)] bool | 1바이트로 못 박음 |
BOOL (Win32) | [MarshalAs(Bool)] bool | 4바이트 |
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]); // 여기서 한 번만 복사
}
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);
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}");
}
SetLastError 는 OS API 규약이지 일반 C 함수의 규약이 아닙니다.
내 라이브러리는 정수 에러 코드 반환 + mylib_last_error() 조합이 낫습니다(k07).
불필요하게 켜면 호출마다 약간의 오버헤드가 붙습니다.
한 단계 더 — 런타임 마샬링을 아예 끄기 🧬 전문가
DisableRuntimeMarshalling
// AssemblyInfo.cs — 어셈블리 전체에 한 번
using System.Runtime.CompilerServices;
[assembly: DisableRuntimeMarshalling]
// 이걸 켜면 어셈블리 안의 모든 P/Invoke 에서
// · 자동 문자열 변환 → 금지 (직접 byte* 로 다뤄야 함)
// · 자동 bool 변환 → 금지
// · 자동 구조체 변환 → 금지 (blittable 만 허용)
// · [MarshalAs] → 무시
// 대신
// · 호출이 '순수한 점프'가 되어 오버헤드가 최소가 됩니다
// · NativeAOT 와 완전히 일치하는 동작을 보장합니다
// · 무엇이 복사되는지 실수할 여지가 사라집니다
타입 크기를 실제로 확인하는 습관
// 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++ 로 넘어가는 이유가 “빠르게”라면, 경계에서 데이터를 복사하는 순간 그 이유가 절반 사라집니다. 구조체 배치를 C++ 와 똑같이 맞추고 배열을 핀 고정해서 넘기면, 8천만 바이트도 0바이트 복사로 건네집니다.
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); }
}
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 바이트로 확인합니다.핸들과 수명 — SafeHandle 로 누수를 구조적으로 막기
C++ 객체는 경계를 넘지 못합니다. 대신 불투명 포인터(핸들)를 건네고, C# 은 그것을 객체처럼 보이게 감쌉니다.
이때 IntPtr 로 들고 있으면 해제를 잊는 순간 그냥 누수이고, 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 이어도 안전합니다
}
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 를 두 번 호출하는 경쟁 조건에서 이중 해제 가능
| IntPtr | SafeHandle | |
|---|---|---|
| 해제 누락 | 그냥 누수 | 종료자가 보완 |
| 이중 해제 | 가능 (크래시) | 불가능 |
| 호출 중 해제 | 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);
}
“빌려 본” 포인터의 함정
// ✗ 위험 — 엔진이 죽은 뒤에도 문자열을 들고 있다고 착각
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 유지
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());
}
mylib_debug_* 접두사로 구분해 두면 깔끔합니다.
SafeHandle 파생 클래스 + IDisposable 겉껍질 두 겹으로 감쌉니다.
IntPtr 을 공개 API 에 절대 노출하지 마세요.콜백 — C++ 가 C# 을 부르게 만들기
진행률 보고, 이벤트 알림, 정렬 비교 함수 — 네이티브가 관리 코드를 되부르는 상황은 생각보다 자주 옵니다. 원리는 간단합니다. C# 메서드의 주소를 함수 포인터로 만들어 넘기는 것. 함정은 하나뿐입니다 — 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); // 확실히 하기 위해
}
두 방식 비교 — 무엇을 언제
| 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(() => ...); // 신중히
콜백에서 예외가 나면
// 관리 예외가 네이티브 프레임을 통과하려 하면
// → 대부분의 경우 프로세스가 그대로 종료됩니다
// → 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 를 쓰면 컨텍스트가 없어
// 재개가 스레드풀로 넘어갑니다. 콜백 안에서는 동기 코드만.
mylib_shutdown() 을 반드시 두고,
C# 쪽에서 등록을 해제한 뒤 스레드가 멎을 때까지 기다린 다음 종료하세요.
[UnmanagedCallersOnly] 정적 메서드 + delegate* unmanaged 가 기본형이고,
인스턴스는 void* user 에 실어 둔 GCHandle 로 되찾습니다.
콜백 안에서는 값만 저장하고 즉시 반환, 예외는 절대 밖으로 내보내지 않습니다.예외와 에러 — 경계는 넘지 못합니다
C++ 예외와 .NET 예외는 완전히 다른 메커니즘입니다. 서로를 알지 못하고, 서로를 잡지 못합니다. 경계를 넘으려 하는 순간 스택 트레이스도 로그도 없이 프로세스가 사라집니다. 해결은 단순합니다 — 경계에서 전부 잡아 에러 코드로 바꾸는 것.
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 · 범위 · 크기)
③ 프로세스를 분리한다 — 크래시가 서비스를 죽이지 않게
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() 은 언제나 호출 가능하며 실패하지 않는다.
규약을 테스트로 강제하기
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++/CLI — 클래스를 통째로 감싸는 길
P/Invoke 는 함수 단위라, C++ 클래스가 수백 개인 코드베이스를 감싸려면 손이 너무 많이 갑니다. C++/CLI 는 한 소스 파일 안에서 .NET 클래스와 C++ 클래스를 같이 쓸 수 있게 해 줍니다. 대가는 분명합니다 — 윈도우 전용.
감쌀 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()));
}
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 든 규칙은 같습니다 —
"경계는 굵고 드물게, 안쪽 루프는 한쪽 세계에서만" ★
ref class 하나로 Dispose · 프로퍼티 · 예외 변환이 전부 따라오지만,
리눅스 · NativeAOT · Unity 와는 함께 갈 수 없습니다.자동 바인딩 — 헤더에서 생성하고 손으로 다듬기
헤더가 수천 줄이면 손으로 옮기는 건 현실적이지 않습니다. CppSharp · SWIG · ClangSharp 가 헤더를 읽어 C# 선언을 뱉어 줍니다. 다만 절반만 자동입니다 — 소유권·수명·스레드 규약은 헤더에 적혀 있지 않기 때문에 생성기도 알 수 없습니다.
세 도구 비교 ★
| ClangSharp | SWIG | CppSharp | |
|---|---|---|---|
| 대상 | C 헤더 | C · C++ | C++ 클래스 |
| 중간 C++ 코드 | 없음 | 생성됨(.cxx) | 생성됨 |
| 결과 예측성 | 매우 높음 | 중간 | 낮음 |
| C++ 클래스 | 불가 | 가능 | 가능 |
| 설정 방식 | rsp 파일 | .i 파일 | C# 코드 |
| 다른 언어도 | C# 만 | 20여 개 | C# 중심 |
| 대표 사용처 | Vulkan · DirectX | 과학 라이브러리 | 게임 SDK |
.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
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());
실무에선 이렇게 — 생성 결과를 어떻게 관리하나 🔴 실무
생성물을 커밋할까, 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; }
생성물 품질을 올리는 힌트들
// 헤더에 힌트를 넣으면 생성 품질이 눈에 띄게 좋아집니다
// ① 고정폭 타입만 쓰기 — 생성기가 헷갈릴 여지가 없습니다
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 매개변수로 바꾸는
작은 후처리 스크립트를 한 번 써 두면 이후 모든 생성에 적용됩니다.
생성기를 바꾸는 것보다 헤더 규칙을 정하는 편이 훨씬 값쌉니다.
Unity 네이티브 플러그인 — 폴더 규약과 IL2CPP
Unity 도 결국 C# 이라 P/Invoke 가 그대로 통합니다. 다른 점은 셋입니다 — 파일을 어디에 두느냐, IL2CPP 로 빌드하면 콜백 규칙이 달라진다는 것, 그리고 에디터가 DLL 을 놓아 주지 않는다는 악명 높은 문제입니다.
가장 단순한 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()}");
}
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);
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);
}
[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
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
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
__Internal 이 뜻하는 것 —
“별도 파일이 아니라 실행 파일 자체 안에 있는 심볼을 찾아라”는 지시입니다.
그래서 extern "C" 와 심볼 노출이 특히 중요하고,
Xcode 가 쓰이지 않는다고 판단해 심볼을 떼어 내지 않도록
(필요하면 -force_load 로) 링크 설정을 확인해야 합니다.
[DllImport] + Plugins 폴더 규약,
콜백에는 [MonoPInvokeCallback] + static + 정적 필드 보관,
배열은 NativeArray. 그리고 개발 중에는 수동 로드/해제로 에디터 재시작을 피합니다.배포 — NuGet 규약 · NativeAOT · 크로스 플랫폼
내 컴퓨터에서 도는 것과 남의 컴퓨터에서 도는 것은 완전히 다른 문제입니다. 다행히 .NET 에는 네이티브 라이브러리를 담는 표준 규약이 있어서, 그 폴더 구조만 지키면 런타임이 알아서 맞는 파일을 골라 줍니다.
패키지를 만드는 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-arm64 | ARM 서버·라즈베리파이 | 권장 |
win-arm64 | ARM 윈도우 | 선택 |
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 이미지)
이렇게도 쓸 수 있다 — 탐색 경로를 직접 정하기 🟡 중급
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; // 기본 탐색으로 넘깁니다
}
}
[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"]
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++ 코어를 세 곳에서
앞의 열한 절을 한 프로젝트로 잇습니다. 이미지 리사이즈 C++ 모듈 하나를 만들고, 콘솔 CLI · ASP.NET Core Minimal API · Unity 세 곳에서 같은 NuGet 패키지로 씁니다. 마지막에는 BenchmarkDotNet 으로 경계 비용을 숫자로 확인합니다.
① 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})")
};
}
}
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;
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;
}
}
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++ 로 만든 알맹이를
자바와 코틀린이
그대로 불러 씁니다
JVM 은 서버에서 대단히 강한 런타임이지만, 픽셀 · 샘플 · 행렬을 수천만 개씩 훑는 루프나 이미 C++ 로만 존재하는 라이브러리(코덱 · 물리 엔진 · 벤더 SDK) 앞에서는 손이 묶입니다. 그래서 실무에서는 무거운 알맹이만 C++ 로 두고 나머지는 자바 · 코틀린으로 씁니다. 이 탭은 그 연결을 네 가지 길로 정리한 뒤, 지금도 가장 널리 쓰이는 JNI 를 끝까지 파고, Java 22 에서 정식이 된 Panama FFM 과 jextract, 손쉬운 JNA · JNR, 그리고 Gradle 패키징 · 안드로이드 NDK · 코틀린까지 이어 갑니다. 이 탭을 마치면 C++ 라이브러리를 Spring Boot 서버에도, 안드로이드 앱에도 붙일 수 있게 됩니다.
네 가지 길 — 어디로 갈 것인가를 먼저 정합니다
자바에서 C++ 를 부르는 방법은 크게 넷입니다. 넷 다 “된다”는 점에서는 같지만 호출 한 번의 비용 · 손으로 쓸 코드의 양 · 안드로이드 지원 여부가 전부 다릅니다. 코드를 한 줄 쓰기 전에 이 지도를 먼저 그려 놓아야 나중에 갈아엎지 않습니다.
네 가지 길을 한 표로 ★
| JNI | FFM | JNR | JNA | |
|---|---|---|---|---|
| 도입 시기 | JDK 1.1 | Java 22 | 외부 | 외부 |
| 손으로 쓸 C++ | 많음 | extern "C" 만 | 동일 | 동일 |
| 손으로 쓸 자바 | 적음 | 중간 | 거의 없음 | 거의 없음 |
| 호출 비용 | 10~30ns | 5~30ns | 50~200ns | 수백 ns~µs |
| 메모리 안전 | 낮음(JVM 크래시) | 중간(Arena) | 중간 | 중간 |
| C++ → 자바 콜백 | 가능 | upcall | 제한적 | Callback |
| 안드로이드 | 표준 | 불가 | 불가 | 가능(느림) |
| GraalVM native | 설정 필요 | 부분 지원 | 어려움 | 어려움 |
같은 함수 하나, 네 가지 선언
// 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");
한 단계 더 — 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
언제 C++ 로 내려갈 가치가 있나
내려가면 손해
- 호출 한 번에 하는 일이 수 µs 미만 — 경계 비용이 이득을 먹습니다
- JSON 파싱 · DB 접근 · HTTP — JVM 쪽 라이브러리가 이미 충분히 빠릅니다
- 단순 산술 루프 — JIT 와 Vector API 로 대개 따라잡힙니다
- 배포 대상 OS 가 여럿인데 CI 가 하나뿐일 때
내려갈 가치
- C++ 로만 존재하는 것 — 코덱 · 물리 엔진 · 벤더 SDK · CUDA
- 한 번 호출에 수 ms 이상 일하는 무거운 커널
- 수백 MB 버퍼를 복사 없이 훑어야 할 때(v04)
- GC 정지를 못 견디는 실시간 오디오 · 신호 처리
extern "C" 한 겹이 필요하고,
Java 24+ 에서는 --enable-native-access 가 필요합니다.JNI 첫 모듈 — 30분이면 자바가 C++ 를 부릅니다
JNI 는 “복잡하다”는 평판과 달리 첫 호출까지는 네 단계뿐입니다.
native 메서드를 선언하고, javac -h 로 헤더를 뽑고, 그 시그니처대로 C++ 를 구현하고,
공유 라이브러리로 만들어 System.loadLibrary 로 얹으면 끝입니다.
① 자바 쪽 — 이게 전부입니다
// 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.so | include/linux |
| macOS | libmathops.dylib | include/darwin |
| 윈도우 | mathops.dll | include/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 로 시작하면 이 문제입니다.
javac -h → C++ 구현 → 공유 라이브러리 → loadLibrary,
이 다섯 단계가 JNI 의 전부입니다. 헤더는 항상 생성하고, 빌드는 find_package(JNI) 로 맡기고,
막히면 nm -D | grep Java_ 로 심볼부터 확인합니다.타입 매핑과 문자열 — Modified UTF-8 이라는 함정
숫자는 그냥 통과합니다. 문제는 문자열입니다. JNI 가 주는 “UTF-8” 은 진짜 UTF-8 이 아닙니다. 이 사실을 모르면 한글은 잘 되는데 이모지에서만 깨지는 버그를 며칠 동안 쫓게 됩니다.
기본 타입 매핑 — 외울 것은 두 줄뿐
| 자바 | JNI 타입 | C++ 실체 | 시그니처 |
|---|---|---|---|
| boolean | jboolean | uint8_t (0/1) | Z |
| byte | jbyte | int8_t | B |
| char | jchar | uint16_t ★ | C |
| short | jshort | int16_t | S |
| int | jint | int32_t | I |
| long | jlong | int64_t | J ★ |
| float / double | jfloat/jdouble | float/double | F/D |
| void | — | void | V |
| String | jstring | 불투명 포인터 | 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());
}
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);
'\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);
}
local reference table overflow (max=512)).
전자는 서서히 죽고 후자는 갑자기 죽습니다 — 둘 다 -Xcheck:jni 가 잡아 줍니다(v06).
jchar 는 2바이트이고
시그니처의 long 은 J 입니다. 문자열은 이모지가 섞일 수 있으면
getBytes(UTF_8) 로 byte[] 을 넘기는 길이 가장 안전하고,
Get… 은 항상 Release… 와 짝이며 RAII 로 묶으면 실수가 사라집니다.배열과 제로카피 — 복사를 없애야 이득이 남습니다
8MB 이미지를 C++ 에 넘겨 0.4ms 만에 처리했는데 전체는 3ms 가 걸렸다면, 2.6ms 는 복사입니다. JNI 에서 진짜 성능은 알고리즘이 아니라 경계에서 몇 번 복사되느냐에서 갈립니다.
배열을 받는 세 가지 방법
// ① 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 로.
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 가 쌓이는 사고가 흔합니다.
…Region,
MB 급이면 DirectByteBuffer 입니다. Critical 은 짧은 계산 전용이고,
다이렉트 버퍼는 용량 검사 · 엔디안 · 풀 재사용 세 가지만 지키면
복사 0으로 자바와 C++ 가 같은 메모리를 나눠 씁니다.객체 · 콜백 · 예외 — C++ 가 자바를 부를 때
여기까지는 자바가 C++ 를 불렀습니다. 이제 반대 방향입니다 — 진행률 보고 · 이벤트 통지 · 정렬 비교 함수처럼 C++ 가 자바를 되부르는 순간 클래스 캐싱 · 예외 확인 · 스레드 붙이기라는 세 가지 규칙이 한꺼번에 필요해집니다.
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
핸들 패턴 — 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); // 필요하면 그대로 다시 던지기
}
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)
JNI_OnLoad 에서 한 번만 찾고,
콜백 뒤에는 항상 ExceptionCheck 하고,
C++ 스레드에서는 attach 를 RAII 로 묶습니다.
C++ 객체는 long 핸들 + AutoCloseable 로 자바 객체에 매답니다.성능과 안정성 — 호출을 줄이고 크래시를 읽습니다
JNI 코드가 느린 이유는 대개 C++ 가 느려서가 아니라 경계를 너무 자주 넘어서입니다.
그리고 JNI 코드가 죽으면 예외가 아니라 JVM 프로세스 전체가 사라집니다 —
그때 남는 hs_err_pid 파일을 읽을 줄 알아야 합니다.
배치 설계 — 나쁜 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(); // 자바 쪽에서 그대로 읽습니다
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 한도를 넘겨 스로틀링이 걸리는 사고가 흔합니다
측정 없이 고치지 않기
# ① 자바와 네이티브를 한 장에 — 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 · 다이렉트 버퍼 할당 |
| 첫 요청만 느림 | 워밍업 · 페이지 폴트 |
| 스레드 늘려도 안 빨라짐 | 네이티브 안의 전역 락 |
| 메모리가 계속 늘어남 | 전역 참조 · 네이티브 할당 누수 |
RegisterNatives 로 깔끔하게 하고,
개발 내내 -Xcheck:jni 를 켜 두면 JNI 는 생각만큼 위험하지 않습니다.Panama FFM — C++ 파일을 한 줄도 안 쓰고 부릅니다
JNI 의 번거로움은 전부 “C++ 쪽에도 정해진 이름의 함수를 만들어야 한다”에서 나왔습니다.
Java 22 에서 정식이 된 FFM(Foreign Function & Memory) API 는 그 요구를 없앱니다 —
이미 있는 extern "C" 함수를 자바 코드만으로 부릅니다.
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
.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);
extern "C" 함수만 있으면 되고,
잘못된 접근은 크래시가 아니라 예외로 잡히며, 실행할 때
--enable-native-access 만 잊지 않으면 됩니다.jextract · 구조체 · 콜백 — 그리고 FFM 이 아직 못 하는 것
함수가 다섯 개면 손으로 써도 됩니다. 헤더가 3천 줄이면 이야기가 다릅니다.
jextract 는 C 헤더를 읽어 자바 바인딩을 통째로 생성합니다.
여기에 구조체 레이아웃과 upcall 콜백까지 더하면 FFM 으로 못 할 일이 거의 없습니다 —
“거의”가 어디까지인지도 정확히 짚습니다.
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
}
구조체 — 레이아웃을 직접 그리기
/* 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());
}
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);
}
}
}
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));
extern "C" 래퍼 한 겹이 필요합니다.
차이는 그 위에서 자바 코드만 쓰느냐(FFM), C++ 코드도 쓰느냐(JNI)입니다.
🔗 C ABI 탭에서 만든 래퍼가 그대로 재활용됩니다.
upcallStub + arena 수명입니다.
FFM 은 C++ 클래스와 안드로이드 두 곳에서 멈추고, 그 자리에는 JNI 가 남습니다.JNA · JNR — 인터페이스 하나로 끝내는 길
JNI 헤더도, FFM 조립도 없이 자바 인터페이스 하나만 선언하면 C 함수가 불립니다. 대신 호출마다 값을 조립하는 중간 계층이 끼기 때문에 느립니다. 이 거래가 이득인 자리가 분명히 있고, 손해인 자리는 더 분명합니다.
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 되어 크래시납니다 — 필드로 붙잡아 두세요
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);
| JNA | JNR-FFI | |
|---|---|---|
| 호출 비용 | 수백 ns~µs | 50~200ns |
| 배포 편의 | jar 자동 추출 | 직접 처리 |
| 생태계 | jna-platform | jnr-posix 등 |
| 안드로이드 | 가능 | 어려움 |
| 쓰는 곳 | 도구 · 스크립트 | JRuby · 미들웨어 |
언제 쓰고 언제 피하나 🟡 중급
이 거래가 이득인 자리
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 는 이미 확정됨)
네 가지를 다시 한 번 · 마이그레이션 경로
| 지금 상태 | 다음 걸음 | 드는 시간 |
|---|---|---|
| 아무것도 없음 | JNA 로 측정 | 30분 |
| 이득 확인됨 · Java 22+ | FFM 으로 이전 | 반나절 |
| 이득 확인됨 · Java 17/21 | JNI 또는 JNR | 1~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)
빌드와 패키징 — jar 하나로 배포하기
개발자 PC 에서는 -Djava.library.path 로 넘어갑니다.
하지만 “의존성 한 줄 추가했더니 그냥 되더라” 가 되려면
OS 별 네이티브 파일을 jar 안에 넣고 실행 시점에 꺼내 로드해야 합니다.
실무 라이브러리(JNA · 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"
}
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+)
linux-x86-64 폴더의 glibc .so 는 Alpine 에서 안 돕니다.
지원하려면 linux-x86-64-musl 폴더를 따로 만들고
Alpine 컨테이너에서 따로 빌드해야 합니다.
지원 안 할 거면 README 에 명시하세요 — 이 사고가 정말 자주 납니다.
.so/.dll/.dylib 를 만들어 리소스로 넣고,
NativeLoader 가 실행 시점에 꺼내 로드합니다.
이 세 조각이면 사용자는 의존성 한 줄만 추가하면 됩니다.안드로이드 NDK · 코틀린 — 여기서는 JNI 가 유일한 길
안드로이드 런타임(ART)에는 java.lang.foreign 이 없습니다.
FFM 도 JNA 도 사실상 선택지가 아니고, NDK + JNI 가 표준입니다.
다행히 Gradle 이 CMake 를 직접 불러 주기 때문에 빌드 설정은 서버보다 오히려 간단합니다.
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")
.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) }
}
externalNativeBuild + CMake 로 빌드하고, abiFilters 로 용량을 줄이고,
16KB 페이지 정렬을 반드시 확인합니다. 코틀린에서는 external fun 이 전부이고,
호출은 withContext(Dispatchers.Default) 로 옮깁니다.실전 — 하나의 C++ 코어를 JNI 와 FFM 으로 두 번 감쌉니다
마지막은 처음부터 끝까지 이어 봅니다. 이미지 리사이즈 C++ 코어 하나를 만들고, JNI + DirectByteBuffer 와 FFM 두 가지로 감싼 다음, JMH 로 순수 자바 구현까지 셋을 나란히 재고, Spring Boot 엔드포인트에 붙여 배포합니다.
① 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.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(); }
}
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 나 다이렉트 버퍼 할당이 측정에 섞였습니다
④ 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 결과가 저장소에 커밋돼 있나
□ 헬스체크가 네이티브 호출을 한 번 하나
스택은 vector,
큐는 deque,
어댑터는 얇은 껍데기
C++ 에는 std::stack·std::queue 가 있지만 이것들은 컨테이너가 아니라 어댑터입니다.
안에 든 진짜 저장소(vector·deque)를 고르는 것이 성능을 정하고,
pop() 이 값을 돌려주지 않는 이유, 빈 상태에서 top() 이 미정의 동작인 이유를 알아야 사고가 안 납니다.
이 탭은 2026년 현업 기준으로 무엇을 어떤 타입으로 쓰는지, std::deque 가 메모리에 어떻게 놓이는지,
표준에 없는 스레드 안전 큐를 어떻게 만들고 무엇을 가져다 쓰는지까지 다룹니다.
끝나면 BFS·단조 덱·레이트 리미터·undo/redo 를 C++ 관용구로 바로 짤 수 있습니다.
한 장 지도 — C++ 에서 무엇으로 쓰나
자바는 Stack 을 버리고 ArrayDeque 로 갔고, 파이썬은 list 와 deque 를 씁니다.
C++ 의 답은 조금 다릅니다 — 스택은 대개 std::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 로 하나씩 찍으면 됩니다.
한 단계 더 — 어댑터란 무엇인가 🟡 중급
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::stack | deque | vector ★ · list |
std::queue | deque | list (vector ✗) |
std::priority_queue | vector | deque (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
deque 는 블록이 아주 작아(sq04) 스택이면 vector 를 끼우는 것이 정석입니다.
std::vector, 큐는 std::queue(= deque), 우선순위는 std::priority_queue,
스레드 사이는 직접 만든 블로킹 큐나 검증된 라이브러리입니다.
std::stack·std::queue 는 저장소가 아니라 API 를 좁히는 껍데기라는 것만 기억하면 나머지는 따라옵니다.스택 — pop() 은 왜 값을 안 돌려주나
자바·파이썬에서 온 사람이 C++ 스택에서 처음 막히는 곳이 auto x = s.pop(); 입니다. 컴파일 에러가 납니다.
이것은 실수가 아니라 예외 안전성 때문에 일부러 고른 설계입니다. 그 이유를 알면 C++ 컨테이너 API 전체가 읽힙니다.
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 ArrayDeque | Python list | C++ vector | C++ stack |
|---|---|---|---|---|
| 넣기 | push | append | push_back · emplace_back | push · emplace |
| 보기 | peek | [-1] | back() | top() |
| 꺼내기 | pop → 값 | pop() → 값 | pop_back() → void | pop() → void |
| 빈 확인 | isEmpty | not st | empty() | empty() |
| 비었을 때 | 예외 | 예외 | UB | UB |
| 비우기 | clear | clear | clear() | 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() 으로 바꿉니다.
순서가 중요하면(전위·후위) 자식을 역순으로 넣거나 “방문 표시” 플래그를 함께 넣습니다.
vector 의 뒤쪽이고, pop() 은 void 입니다.
값은 std::move(top()) 으로 받고 그다음 pop(), 비었는지는 항상 먼저 확인합니다 — 빈 스택은 예외가 아니라 UB 입니다.큐(FIFO) — std::queue 와 하부 컨테이너
std::queue 는 뒤에 넣고 앞에서 뺍니다. 그래서 하부 컨테이너는 pop_front 가 있어야 하고,
vector 는 끼울 수 없습니다. 자바의 offer/poll, 파이썬의 append/popleft 에 대응하는 것과,
C++ 에는 “실패하면 예외” 버전이 없다는 점을 정리합니다.
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 Queue | Python deque | C++ queue |
|---|---|---|---|
| 넣기 | add(예외) · offer(false) | append | push · 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) { /* ... */ }
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
-std=c++23(MSVC /std:c++latest) 을 먼저 확인하세요.
std::queue<T>(안은 deque) 하나면 됩니다.
vector 앞 지우기는 O(n), list 는 할당 비용만 늘고, 한 번 쓰고 버리는 BFS 큐는 vector + 머리 인덱스가 가장 빠릅니다.
C++ 큐에는 “예외로 알려 주는” 꺼내기가 없으니 empty() 확인이나 optional 래퍼로 감쌉니다.덱 — std::deque 는 원형 배열이 아닙니다
자바 ArrayDeque·러스트 VecDeque 는 원형 배열, 파이썬 deque 는 블록의 이중 연결 리스트입니다.
C++ std::deque 는 다릅니다 — 고정 크기 블록 여러 개 + 그 블록을 가리키는 포인터 맵입니다.
이 구조 때문에 양 끝 삽입에서 원소 주소가 절대 안 바뀌고, 대신 블록 크기가 구현마다 극단적으로 다릅니다.
주소가 안 바뀐다 — 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
| 연산 | deque | vector |
|---|---|---|
operator[] | O(1) · 간접 2번 | O(1) · 간접 1번 |
push_back | O(1) · 원소 이동 없음 | 분할 상환 O(1) |
push_front | O(1) | — (insert(begin) O(n)) |
pop_front | O(1) | — (erase(begin) O(n)) |
| 중간 삽입·삭제 | O(min(i, n−i)) | O(n−i) |
reserve | 없음 | 있음 |
| 메모리 연속성 | 블록 안에서만 | 전체 연속 |
sizeof (64비트) | libstdc++ 80 · libc++ 48 · MSVC 40 | 24 |
push_back 은 “분할 상환”이 아니라 그냥 O(1) 로 규정됩니다 — 표준이 세는 것은 원소 생성 횟수이고,
맵 재할당 때 옮기는 것은 원소가 아니라 블록 포인터이기 때문입니다.
libstdc++ 는 빈 deque 를 만들기만 해도 맵과 블록 하나를 할당합니다 — 빈 std::queue 를 수만 개 만드는 코드라면 기억해 두세요.
한 단계 더 — 고정 크기 원형 버퍼를 직접 🟡 중급
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의 거듭제곱 원형 버퍼가 가장 싸고 예측 가능합니다.우선순위 큐 — less 를 넣었는데 최대 힙인 이유
std::priority_queue 는 기본이 최대 힙입니다. 자바·파이썬·C# 이 전부 최소 힙이라 옮겨 올 때 가장 많이 틀립니다.
비교자를 읽는 법, 여러 키로 정렬하기, 요소를 고칠 수 없다는 제약과 지연 삭제, 그리고 std::ranges::push_heap 으로
vector 를 직접 힙으로 다루는 법까지 갑니다.
여러 키 + 같은 우선순위는 먼저 넣은 것부터
#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
&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 힙 함수를 직접 씁니다.스레드 안전 큐 — 표준에 없으니 제대로 만듭니다
자바에는 BlockingQueue, 파이썬에는 queue.Queue, C# 에는 Channel 이 있습니다.
C++ 표준 라이브러리에는 동시성 큐가 하나도 없습니다(표준화 제안은 오래 논의 중입니다).
그래서 mutex + condition_variable 블로킹 큐를 정확하게 짜는 법 — 가짜 깨어남, 배압, jthread 로 종료 — 을 익히고,
성능이 필요할 때 무엇을 가져다 쓰는지 정리합니다.
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::queue | MPMC · 고정 용량 가능 · T 는 trivial 대입·소멸자 |
boost::lockfree::spsc_queue | 1: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 은 맞지 않습니다(생산자별 순서만 지킴).
먼저 뮤텍스 버전으로 재 보고, 경합이 병목으로 측정될 때 바꾸세요.
condition_variable_any + deque 로 만듭니다.
술어 있는 wait(가짜 깨어남), 유한 용량(배압), close() 나 stop_token(종료) 세 가지가 핵심이고,
측정해서 부족할 때만 moodycamel·oneTBB·Boost.Lockfree 로 갑니다.비동기 · 채널 — 표준 채널은 아직 없습니다
코틀린 Channel, C# System.Threading.Channels, 파이썬 asyncio.Queue, 러스트 tokio::sync::mpsc 에 해당하는 것이
C++ 표준에는 없습니다. C++20 이 준 것은 코루틴 문법뿐이고 스케줄러·채널은 라이브러리 몫입니다.
현업에서 쓰는 Asio 채널과, 의존성 없이 만드는 단일 스레드 작업 큐를 봅니다.
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 으로 바꾸고 캡처를 복사 가능하게 합니다.
한 단계 더 — 언어별 비동기 큐 대응과 함정 🟡 중급
비동기 큐 — 어디에 무엇이 있나
| 언어 | 표준 · 사실상 표준 |
|---|---|
| Kotlin | Channel (kotlinx.coroutines) |
| C# | System.Threading.Channels (표준) |
| Python | asyncio.Queue (표준) |
| Rust | tokio::sync::mpsc (생태계 표준) |
| Go | chan (언어 내장) |
| C++ | 표준 없음 — Asio experimental::channel · Boost.Cobalt channel · 직접 구현 |
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, ...) 로 '루프에 일 넣기'
experimental::channel(멀티 스레드는 concurrent_channel),
단일 스레드 이벤트 루프면 deque<move_only_function> 작업 큐로 충분합니다.
코루틴 안에서 블로킹 pop() 은 금지 — 기다림은 항상 co_await 로 합니다.알고리즘 패턴 — C++ 관용구로 다섯 개
괄호 검사 · 단조 스택 · BFS · 슬라이딩 윈도우 최댓값 · 0-1 BFS 는 코딩 테스트와 실무 양쪽에서 반복해 나옵니다.
이론(CS 가이드 s03·s09)은 넘어가고, 여기서는 C++ 로 쓸 때의 타입 선택과 관용구 — vector 스택, 인덱스를 넣는 덱,
구조적 바인딩, 넣을 때 방문 표시 — 에 집중합니다. 전부 복붙해서 돕니다.
① 괄호 검사 — 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]
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
priority_queue 판(O(E log V))보다 빠릅니다.
패턴 → 타입 대응표
| 패턴 | C++ 타입 | 핵심 |
|---|---|---|
| 괄호 · 수식 파싱 | vector<char> | 닫는 짝을 넣기 |
| 다음 큰/작은 수 | vector<size_t> | 인덱스를 넣기 |
| DFS (재귀 대체) | vector<Node*> | sq02 참고 |
| BFS | queue<pair> · vector+머리 | 넣을 때 방문 표시 |
| 윈도우 최댓값 | deque<size_t> | 뒤로 버리고 앞으로 만료 |
| 0-1 BFS | deque<pair> | 0 은 앞 · 1 은 뒤 |
| 다익스트라 | priority_queue + greater | 지연 삭제 (sq05) |
| 위상 정렬 | queue<int> | 진입 차수 0 부터 |
vector 에 인덱스, BFS 는 std::queue + 넣을 때 표시,
윈도우 최댓값과 0-1 BFS 는 std::deque 의 양 끝을 씁니다.
부호 없는 인덱스 뺄셈은 덧셈으로 옮겨 감김을 피합니다.성능과 메모리 — 복잡도가 같아도 열 배 차이
vector·deque·list 는 모두 “끝에 넣고 빼기 O(1)” 입니다. 그런데 실제 시간은 열 배 넘게 벌어집니다.
차이를 만드는 것은 할당 횟수와 캐시 지역성입니다. 여기에 이동 의미론·emplace·큰 객체 저장법·pmr 할당자까지 묶어
“같은 코드를 싸게 만드는 법”을 정리합니다.
직접 재 보기 — 의존성 없는 측정 코드
// 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비트)
| 컨테이너 | 원소당 | 덤 |
|---|---|---|
vector | 4 B | 남는 용량 (최대 약 1배) |
deque | 4 B | 양 끝 블록의 빈칸 + 맵 |
list | 노드 24 B | 할당기 단위로 보통 32 B — 8배 |
RingBuffer<int,N> | 4 B × N 고정 | 없음 · 객체 안에 통째로 |
priority_queue | 4 B (vector) | vector 와 같음 |
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 · 연속 메모리 |
| 2 | push(std::move(x)) · emplace | 복사 0 |
| 3 | 큰 원소는 인덱스 · unique_ptr | 힙 재정렬 비용 ↓ |
| 4 | 크기 고정이면 RingBuffer | 할당 0 · 예측 가능 |
| 5 | 수명이 짧으면 pmr::monotonic | 할당이 포인터 증가 한 번 |
| 6 | MSVC 에서 deque 가 병목이면 교체 | 작은 블록 문제 회피 |
list 를 고를 이유는 splice 와 주소 고정 두 가지뿐이고,
그것도 대개 deque 나 vector<unique_ptr> 로 해결됩니다(🧱 m05 · ⚡ e02).
pmr::monotonic_buffer_resource 로 할당을 없앱니다. 수치는 반드시 직접 재서 확인합니다.흔한 실수 모음 — 컴파일은 되는데 터지는 것들
C++ 스택·큐의 사고는 대부분 컴파일러가 잡아 주지 않는 곳에서 납니다 — 빈 컨테이너 접근, pop 뒤에 남은 참조, 반복자 무효화, 비교자 방향, 이미 사라진 객체를 가리키는 원소. 실제 코드리뷰에서 자주 나오는 순서대로 나쁜 예와 고친 예를 나란히 둡니다.
❌ ① 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 뺄셈 비교 | 감김 · 덧셈으로 옮기기 |
다른 언어와 비교 — C++ 만 다른 두 가지
같은 스택·큐라도 언어마다 기본 타입과 관례가 다릅니다. C++ 에서 다른 언어로, 또는 그 반대로 옮길 때 실제로 틀리는 것은 딱 두 가지입니다 — 우선순위 큐의 기본 방향(C++ 은 최대 힙)과 표준에 동시성 큐가 없다는 점. 나머지는 한 장의 표로 대응됩니다.
| 용도 | Java 21+ | Python 3.13 | JS (Node 24) | C# (.NET 10) | C++23 | Rust | Go |
|---|---|---|---|---|---|---|---|
| 스택 | ArrayDeque | list | Array | Stack<T> | vector · stack | Vec | slice |
| 큐 | ArrayDeque | deque | 직접(링·두 스택) | Queue<T> | queue(=deque) | VecDeque | slice · 링 |
| 덱 내부 | 원형 배열 | 블록 이중 연결 | — | 원형 배열 | 블록 + 맵 | 원형 배열 | — |
| 우선순위 | PriorityQueue 최소 | heapq 최소 | 없음 | PriorityQueue<E,P> 최소 | priority_queue 최대 | BinaryHeap 최대 | container/heap |
| 스레드 큐 | ArrayBlockingQueue | queue.Queue | — | Channel · BlockingCollection | 없음 (sq06) | mpsc | chan |
| 비동기 | — (가상 스레드 + 블로킹 큐) | asyncio.Queue | 이벤트 루프 | Channel | 없음 · Asio | tokio::mpsc | chan |
| pop 반환 | 값 | 값 | 값 · undefined | 값 | void | Option | — |
| 빈 상태 | 예외 · null | 예외 | undefined | 예외 · TryPop | UB | None | panic |
같은 일 — “스택에 넣고 하나 꺼내기”
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
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)를 켜 두는 것이 그 친절을 되찾는 방법입니다.
pop() 이 값을 돌려주지 않고 빈 접근이 UB 라는 API 모양입니다.실무 사례 — 이벤트 큐 · undo · 레이트 리미터 · 재시도
지금까지의 타입을 실제 서비스·게임 코드의 모양으로 엮습니다. 각 사례마다 왜 그 타입인지가 핵심입니다 — 프레임 이벤트는 vector 두 개, undo/redo 는 스택 두 개, 최근 N 개는 상한 있는 deque 나 링, 레이트 리미터는 시각을 담은 deque, 재시도는 시각 기준 최소 힙입니다.
① 레이트 리미터 — 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
RingBuffer<T, N>::push_overwrite 가 할당 없이 같은 일을 합니다.
로그 문자열처럼 원소 자체가 할당을 하면 deque 냐 링이냐의 차이는 작으니 읽기 쉬운 쪽을 고르세요.
사례 → 타입 한 장 요약
| 사례 | 타입 | 이유 |
|---|---|---|
| 작업 큐 (스레드 풀) | sq06 BlockingQueue | 배압 · 종료 처리 |
| 프레임 이벤트 | vector 두 개 교환 | 연쇄 차단 · 할당 0 |
| undo / redo | vector<unique_ptr> 두 개 | LIFO · 다형 명령 |
| undo 상한 (100단계) | deque<unique_ptr> | 오래된 것을 앞에서 버림 |
| 최근 N 개 로그 | deque · RingBuffer | 상한 · 덮어쓰기 |
| 레이트 리미터 | deque<time_point> | 앞 만료 · 뒤 추가 |
| 재시도 · 타이머 | priority_queue (시각 최소) | 가장 이른 것 먼저 |
| 오디오 · ISR → 메인 | SPSC 링 (🚀 h03) | 락 금지 구간 |
| 코루틴 간 전달 | Asio 채널 (sq07) | 스레드를 막지 않음 |
헤더 한 줄이
빌드 시간을
정합니다
자바·파이썬은 폴더가 곧 패키지라 구조를 잘못 잡아도 import 한 줄이 조금 길어질 뿐입니다. C++ 은 다릅니다 — 어느 헤더가 어디서 보이느냐가 빌드 시간 · 재컴파일 범위 · 바이너리 호환까지 정합니다. 이 탭은 디렉터리 트리 · CMake 타깃 · 헤더 경계를 한 묶음으로 보고, 라이브러리 · Qt 앱 · 게임/서버가 규모가 커질 때 어디서 어떻게 나누는지를 정리합니다. CMake 문법 자체는 🛠️ 빌드 탭 b02 · b03 에 있으니 여기서는 "어디에 무엇을 두나"만 다룹니다.
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 에 커밋 |
src/shop/order.hpp · src/shop/order.cpp 처럼 한 폴더에 두는 "merged" 방식.
설치할 일이 없는 앱이나 헤더 전용 라이브러리에 편합니다. 분리냐 합침이냐는 공개 API 를 남에게 주느냐로 고르고, 한 저장소 안에서 섞지만 마세요.
C 프로젝트의 같은 이야기는 🅲 C 언어 탭 c25 에 있습니다.
규모별 진화 — 언제 다음 단계로 넘어가나 🟢 기초
| 신호 (경험치) | 무엇이 아픈가 | 다음 단계 |
|---|---|---|
| 테스트를 쓰고 싶다 | main() 이 들어 있는 실행 파일은 테스트가 링크할 수 없음 | 로직을 라이브러리 타깃으로 (STR 02) |
| 헤더 하나 고치면 수백 개 재컴파일 | 공개 헤더에 무거운 include 가 몰림 | 전방 선언 · pimpl (STR 03) |
| 파일 수십 개 · 사람 2~3명 | "이 기능은 어디 소속?" · 타깃 하나가 다 끌어옴 | libs/ 로 타깃 분리 (STR 08) |
| 다른 저장소가 가져다 씀 | 복사·붙여넣기로 퍼진 코드가 버전마다 다름 | 설치 규칙 · 패키지 (STR 06) |
| 팀이 여럿 | 엔진이 게임 코드를 include 하는 식의 역방향 의존 | 규칙 자동 검사 (STR 10) |
<proj/x.hpp> 로 포함하고, 나머지는 위 표의 신호가 올 때만 키웁니다.모던 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)
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 가 "그런 타깃 없음"으로 바로 멈춥니다.
❌ 폴더는 나눴지만 경계가 없음
최상위에 include_directories(include src libs) · link_libraries(fmt sqlite3) 를 둡니다.
모든 타깃이 모든 헤더를 보므로 앱이 src/detail/ 을 include 해도 아무도 모르고, 나중에 라이브러리를 떼어 내려 하면 그제야 수백 곳이 깨집니다.
✅ 타깃마다 필요한 것만
각 폴더의 CMakeLists 가 자기 타깃의 소스 · 헤더 · 의존만 선언합니다. 최상위는 add_subdirectory 로 조립만 합니다.
전역 명령(include_directories · add_definitions · CMAKE_CXX_FLAGS 덧붙이기)은 쓰지 않습니다.
| 파일 | 여기에 둔다 | 여기에 두지 않는다 |
|---|---|---|
| 최상위 CMakeLists | project() · find_package · 옵션 · add_subdirectory | 소스 목록 · 타깃별 플래그 |
| 폴더별 CMakeLists | 그 폴더의 타깃 하나(또는 몇 개)와 그 의존 | 다른 폴더의 소스 |
cmake/*.cmake | 경고 묶음 함수 · sanitizer 함수 · Config.cmake.in | 타깃 정의 |
CMakePresets.json | 제너레이터 · 빌드 폴더 · 툴체인 · 캐시 변수 | 개인 경로 (→ CMakeUserPresets) |
proj::name ALIAS 로 부르고, 공개 헤더가 드러내는 의존만 PUBLIC 으로 둡니다.헤더/소스 분리와 컴파일 시간 — 공개 헤더를 가볍게
#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 만 봄
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 가 공식입니다.
| 기법 | 무엇을 줄이나 | 대가 · 주의 |
|---|---|---|
전방 선언 (fwd.hpp) | 포인터 · 참조 · 함수 선언만 쓰는 곳의 include | 값 멤버 · 상속 · 인라인 호출에는 못 씀 |
| pimpl | private 멤버의 의존이 공개 헤더에 드러나는 것 | 힙 할당 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
src/ 의 비공개 헤더로 내립니다. 템플릿은 detail/ 로 감추고, PCH · 유니티 빌드는 구조가 아니라 프리셋 스위치로 다룹니다.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) |
.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);
}
}
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 그대로
import std; · 헤더 단위(import <vector>;) · IDE 인덱싱 · 패키지로 모듈 배포는 조합에 따라 아직 걸립니다.
남에게 나가는 라이브러리는 헤더를 기본으로 두고 모듈 입구를 덧붙이는 쪽이 안전합니다.
❌ 저장소 전체를 한 번에 모듈로
모든 헤더를 .cppm 으로 바꾸고 매크로 의존을 걷어 내다 보면, 툴체인 하나가 따라오지 못할 때 되돌릴 길이 없습니다.
서드파티 헤더가 섞인 곳은 전역 모듈 조각이 길어지고 이득도 작습니다.
✅ 잎 모듈부터, 타깃 단위로
다른 것에 의존하지 않는 잎(money · util)부터 타깃 하나씩 옮기고, 옮긴 타깃은 헤더 입구를 한동안 같이 둡니다.
CI 에 지원해야 하는 컴파일러를 모두 걸어 두고, 하나라도 깨지면 그 타깃은 헤더로 남깁니다.
FILE_SET CXX_MODULES, 구현 단위는 일반 소스로 둡니다. 툴체인 지원이 고르지 않으니 헤더와 공존시키며 잎 타깃부터 옮깁니다.의존성 관리 구조 — 매니페스트 한 곳, 서드파티는 격리
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 이 모든 패키지 버전을 한 시점으로 고정합니다. 이 커밋만 올리면 의존성이 한꺼번에 갱신되고, 리뷰도 한 줄입니다.
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 2 | FetchContent · 서브모듈 |
|---|---|---|---|
| 목록 파일 | vcpkg.json | conanfile.txt/.py | CMake 코드 · .gitmodules |
| 버전 고정 | baseline + overrides | lockfile | 태그 · 해시 |
| 바이너리 캐시 | 있음 | 있음 (서버 운영 가능) | 없음 — 매번 빌드 |
| 잘 맞는 곳 | CMake 단일 · Windows 비중 큼 | 여러 빌드 시스템 · 사내 아티팩트 서버 | 작은 의존 몇 개 · 헤더 전용 |
❌ 서드파티가 사방에
nlohmann/json.hpp 를 수십 개 파일이 직접 include 하고, 같은 라이브러리가 vcpkg 와 external/ 에 두 벌 들어와 있습니다.
두 버전이 한 바이너리에 섞이면 ODR 위반으로 조용히 깨질 수 있습니다(🛠️ 빌드 탭 b03 의 다이아몬드 의존).
✅ 들여오는 길 하나, 쓰는 곳 하나
라이브러리마다 들여오는 방법은 한 가지, 쓰는 쪽은 언제나 find_package 가 주는 타깃(fmt::fmt)만 링크합니다.
external/ 안은 고치지 않고, 고쳐야 하면 패치 파일과 UPSTREAM.txt 로 남깁니다.
라이브러리 · ABI — 공개 헤더가 곧 계약서
남이 가져다 쓰는 라이브러리는 구조가 곧 계약입니다. include/<proj>/ 에 있는 모든 것이 공개 API이고,
공유 라이브러리라면 그 헤더의 클래스 레이아웃까지 ABI로 굳습니다. ABI 가 깨지는 원리는 🔬 전문가 탭 d07,
설치 명령 전체는 🛠️ 빌드 탭 b02 에 있으니, 여기서는 소스 트리와 설치 트리를 어떻게 맞추나를 봅니다.
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)
std:: 타입을 주고받는 것은
같은 컴파일러 · 표준 라이브러리끼리만 안전하며, ③ 다른 언어 · 다른 컴파일러 · 플러그인이 상대라면 extern "C" + 불투명 핸들로 경계를 긋습니다(🔗 C ABI 탭).
| 바꾼 것 | 버전 (VERSION) | SOVERSION | 쓰는 쪽 |
|---|---|---|---|
| .cpp 안 구현만 | 1.4.0 → 1.4.1 | 1 그대로 | 재컴파일 불필요 |
| 공개 함수 추가 | 1.4.1 → 1.5.0 | 1 그대로 | 새 기능만 쓰려면 재컴파일 |
| 공개 클래스 멤버 · 가상 함수 변경 | 1.5.0 → 2.0.0 | 2 로 올림 | 반드시 재컴파일 (옛 앱은 .so.1 유지) |
include/<proj>/ 에만, 기본 가시성은 hidden, 밖으로 낼 것만 PROJ_EXPORT.
설치 트리가 소스 트리와 같은 모양이 되도록 FILE_SET HEADERS 로 설치하고, ABI 를 깨면 SOVERSION 을 올립니다.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 를 감싸기만
// 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();
}
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 링크 제거 |
qt_add_qml_module 하나에 모으고, 화면이 많아지면 그때 QML 모듈을 쪼갭니다.게임 · 서버 대형 코드베이스 — 엔진과 게임을 가르고 의존은 안쪽으로
코드가 수십만 줄이 되면 디렉터리 이름보다 "누가 누구를 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/
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)
플러그인 — 엔진이 모르는 코드를 나중에 불러오기 🔴 고급
계약 헤더 — 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})
destroy 를 플러그인이 내보냅니다(힙이 DLL 마다 다를 수 있음).
가상 함수 표를 경계 너머로 쓰는 이 방식은 엔진과 같은 컴파일러 · 같은 설정으로 빌드한 플러그인에만 안전합니다.
서드파티 모드까지 받으려면 C 함수 표로 바꿉니다(🔗 C ABI 탭).
| 신호 | 다음 구조 |
|---|---|
| 헤드리스 서버가 필요 | gameplay 에서 render · audio 의존 제거 → 서버 실행 파일이 따로 링크 |
| 엔진을 두 번째 게임이 씀 | engine/ 을 버전 붙은 패키지로 내거나(STR 06) 모노레포에서 엔진 소유 팀을 정함(STR 10) |
| 에디터 · 툴이 생김 | tools/ 실행 파일이 엔진을 링크 — 게임 실행 파일에 에디터 코드를 넣지 않음 |
| 모드 · 사내 확장 | plugins/ + 계약 헤더 + API 버전 숫자 |
extern "C" 생성 함수 + API 버전으로 붙입니다.테스트 · 도구 설정 위치 — 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 를 씁니다. 하위 폴더에 두면 그 폴더만 규칙이 바뀌고,
부모 규칙에 덧붙이려면 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)
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
cmake --preset asan 한 줄로 같은 조합을 재현합니다..clang-format · .clang-tidy · 프리셋은 루트에 하나, 테스트는 tests/ 아래에 src 와 같은 경로로 둡니다. 경고 · tidy · sanitizer 는 shop_target_defaults() 로 내 타깃에만 붙이고, 어떤 조합으로 돌릴지는 프리셋 이름이 정합니다.한 장 요약 — 유형 · 규모별 권장 구조와 규칙을 도구로 강제하기
구조 규칙을 문서로만 두면 리뷰어가 매번 "엔진에서 게임 헤더를 include 하면 안 돼요"라고 말해야 합니다. 마지막 섹션은 앞의 아홉 섹션을 표 한 장으로 줄이고, C++ 에서 그 규칙을 빌드 · 정적 분석이 지키게 하는 방법을 확신 있는 만큼만 정리합니다.
| 유형 · 규모 | 권장 구조 | 경계를 지키는 것 | 다음 단계 신호 |
|---|---|---|---|
| 학습 · 도구 · PoC | main.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 키워드 | 지원 컴파일러가 늘어남 |
규칙을 도구로 — 빌드가 먼저 막고, 정적 분석이 나머지를 🟡 중급
① 타깃 의존 = 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 |
| CODEOWNERS | engine/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 리뷰가 맡습니다.