실무 C 코드 관례와 UB 함정 정리
NOTE
C 실무 코드에서 “규칙”이 아니라 “관례”로만 존재하는 것들을 등급별로 정리하고, 기본이라 아무도 안 알려주지만 언젠가 발목 잡는 언어 자체의 함정들을 모았다.
0. 이 노트의 목적
C는 컴파일러가 지켜주는 안전장치(예외, GC, 배열 경계 체크, 소멸자)가 없다. 그래서 “규칙”이 아니라 “관례”로만 존재하는 것들이 유독 많고, 이걸 모르면 컴파일은 되는데 특정 상황에서만 터지는 버그를 만든다. 이 노트는 두 종류로 나눈다.
- [A] 꼭 지켜야 하는 관례 — 안 지키면 버그·리소스 누수·UB(정의되지 않은 동작)로 직결되는, 사실상 규칙에 가까운 것들. 15개가 다 같은 무게는 아니라서 [1급 UB / 2급 설계 / 3급 컨벤션] 등급으로 나눴다.
- [B] 기본이라서 아무도 안 알려주는 것 — 컴파일은 되고 당장은 안 터지지만, C 언어 자체의 특성 때문에 모르면 언젠가 반드시 발목 잡는 함정들. 대학/입문서가 잘 안 다루고, 실무에서는 “당연히 아는 것”으로 취급돼서 아무도 짚어주지 않는다. 다만 이 중 상당수는 외우는 게 아니라 도구로 잡는 것이다.
A. 꼭 지켜야 하는 관례
A0-1. 15개를 같은 무게로 보지 말 것 — 등급 구분
“꼭 지켜야 한다”고 다 같은 게 아니다. 어기면 즉시 버그가 되는 것과 어기면 리뷰에서 지적받는 것을 같은 무게로 취급하면, 정작 중요한 걸 놓친다. 셋으로 나눈다.
| 등급 | 어기면 무슨 일이 | 해당 항목 | 판단 기준 |
|---|---|---|---|
| 1급 — 버그·UB 직결 | 리소스 누수, 메모리 깨짐, 실패가 조용히 묻힘, 멀쩡한 연결이 끊김. 재현 안 되는 버그의 주 원인 | A1(생성/소멸 쌍) A2(반환값·errno) A3(EAGAIN) A4(시그널 플래그 타입) A5(헤더 가드) A6(안전한 문자열 함수) A14(역순 정리·단일 출구) | 협상 대상이 아니다. 시간이 없어도 이건 지킨다 |
| 2급 — 설계 원칙 | 당장은 잘 돈다. 확장·동시성·유지보수 상황에서 비용이 터진다 | A7(전역 최소화·static) A8(sleep 대신 poll) A9(요청 스코프 vs 전역) A10(워커풀) A11(main 슬림화) A12(기능별 스레드 분리) | 신규 설계에선 반드시 고민하되, 기존 코드를 이것 때문에 통째로 갈아엎진 않는다 |
| 3급 — 팀 컨벤션 | 기능은 정상. 리뷰에서 지적받고, 코드베이스 일관성이 깨진다 | A13(모듈 접두어) A15(Hungarian 프리픽스) | 옳고 그름의 문제가 아니라 “이 팀은 이렇게 쓴다”의 문제. 주변 코드를 따른다 |
이 등급의 용도: 시간이 없을 때 뭘 먼저 볼지, 그리고 리뷰에서 뭘 강하게 말하고 뭘 제안 수준으로 말할지를 가른다. 1급 위반은 “고쳐야 한다”, 3급 위반은 “이 파일 주변은 이렇게 쓰던데요” 정도가 적절하다.
A0-2. 레거시 관행과 C 표준이 충돌할 때 — 판단 기준
아래 A4·A5·A11·A15에서 같은 딜레마가 네 번 반복된다: 표준/일반 원칙은 X인데 기존 레거시 코드는 Y로 쓴다. 매번 헷갈리지 않도록 기준을 먼저 세운다.
| 상황 | 따를 것 | 이유 |
|---|---|---|
| 기존 파일을 수정하는데 그 파일이 레거시 관행(Y)을 쓰고 있다 | Y (주변 코드) | 한 파일 안에 두 스타일이 섞이는 게 더 나쁘다. 일관성이 개별 최적보다 우선 |
| 새 파일·새 모듈을 만든다 | X (표준) — 단 3급 컨벤션은 예외로 Y | 새로 짜는 코드까지 낡은 관행을 복제할 이유가 없다. 다만 접두어·Hungarian(3급)은 코드베이스 통일성이 목적이므로 기존 코드를 따른다 |
1급 위반이 걸려 있다 (A4 volatile 누락, A5 예약 식별자, A6 strcpy) | X (표준) — 수정하는 김에 고친다 | ”기존 코드가 그러니까”가 UB를 정당화하진 못한다. 다만 관련 없는 파일까지 일괄 수정하진 않는다 — 손대는 범위 안에서만 |
| 판단이 안 선다 | 물어본다 | 특히 A11(main/init 분리)처럼 파일 구조를 바꾸는 건 팀 합의 영역이다 |
핵심은 **“기존 코드를 존중하되, UB까지 복제하진 않는다”**다. 3급(스타일)은 기존 코드를 따르고, 1급(UB)은 표준을 따른다 — 이 한 줄이 네 항목의 답을 전부 커버한다.
A1. 생성/소멸은 반드시 쌍으로 — _init/_destroy, malloc/free
C엔 소멸자가 없다. 만든 쪽이 끝까지 책임지고 짝이 되는 해제 함수를 호출해야 한다. xxx_init()↔xxx_destroy()처럼 전부 쌍으로 짜고, 중간에 실패해도 그때까지 성공한 것만 역순으로 정리한다.
/* 워커 스레드 생성 중 실패하면 앞서 만든 것들만 역순 정리 */
if (workers[i] == NULL) {
for (j = 0; j < i; j++) {
worker_destroy(workers[j]);
}
...
}체크리스트: _init을 만들면 반드시 _destroy를 만든다. _init이 중간에 실패할 수 있다면, 실패 지점까지 만든 리소스만 정리하는 경로를 반드시 같이 짠다(전부 만들거나 전부 안 만들거나, 둘 중 하나 상태만 존재해야 한다).
_destroy 쌍이 caller의 직접 free() 호출보다 왜 더 안전한지(deep free, 캡슐화, DLL 힙 경계 크래시)는 동적 할당 소유권 — caller free vs callee create·destroy 쌍에서 더 다룬다.
A2. 반환값은 반드시 체크한다 — “0=성공, 음수=실패”
C는 예외가 없다. 함수가 실패했다는 걸 알리는 유일한 방법은 리턴값이고, 호출부가 이걸 확인 안 하면 실패가 그냥 조용히 묻힌다. POSIX 계열 관례(open(), read())를 그대로 따라 0 이상=성공, 음수=실패로 통일하는 게 일반적이다.
errno를 쓰는 시스템콜은 실패 시(-1 등) 반드시 errno를 그 즉시 확인해야 한다 — 성공한 호출도 errno를 바꿀 수 있고 다음 호출이 덮어쓰기 때문에, 한 줄만 뒤로 밀려도 엉뚱한 값을 읽는다.
if ((ret = select(fd + 1, &rfds, 0, 0, &timeout)) < 0)
LOG_ERR("select fail(errno(%d,%s))\n", errno, strerror(errno));A3. NONBLOCKING I/O에서 EAGAIN/EWOULDBLOCK은 에러가 아니라 정상 케이스다
소켓을 논블로킹으로 걸면(O_NONBLOCK, MSG_DONTWAIT), “아직 준비 안 됨”이 에러 코드로 돌아온다. 이걸 진짜 에러로 처리해서 연결을 끊거나 재시도를 포기하면 멀쩡한 연결이 죽는다 — 반드시 EAGAIN/EWOULDBLOCK만 따로 분기해서 “나중에 다시 시도”로 처리해야 한다. libevent나 Redis의 이벤트 루프도 같은 구조다 — “커널이 소켓을 관리하게 하고, 애플리케이션 스레드는 절대 커널 큐 안에서 잠들지 않는다”는 게 핵심.
A4. 시그널 핸들러는 플래그만 건드리고, 그 플래그는 volatile sig_atomic_t여야 한다
핸들러 안에서 로그를 찍거나 malloc/free를 부르면 위험하다(async-signal-safe 함수만 허용). 표준 패턴은 핸들러는 플래그 하나만 바꾸고, 메인 루프가 그걸 주기적으로 확인하는 것이다.
static volatile sig_atomic_t g_svcflag = 1;
static void default_signal_handler(int signo)
{
g_svcflag = 0; /* 핸들러는 플래그 하나만 건드린다 */
}
...
while (g_svcflag) { ... sleep_msec(1000); }표준(및 POSIX)은 시그널 핸들러가 건드리는 변수를 volatile sig_atomic_t로 선언하라고 명시한다. volatile이 없으면 컴파일러가 “이 값은 루프 안에서 안 바뀐다”고 착각해 재검사를 최적화로 생략할 수 있고(레지스터 캐싱), sig_atomic_t가 아니면 값을 쓰는 도중 시그널이 끼어들어 쪼개진 값을 읽을 이론적 위험이 있다. 오래된 레거시 코드에서는 이 타입 한정자가 빠진 채 그냥 int로 선언된 경우가 흔한데, 최적화 레벨이 낮거나 컴파일러가 공격적으로 최적화하지 않아 지금까지 안 드러났을 뿐이다. 1급 위반이므로 표준을 따른다 — 신규 코드는 volatile sig_atomic_t, 기존 코드도 손대는 김에 고친다.
A5. 헤더 가드는 빠짐없이 — 하나라도 빠지면 재정의 컴파일 에러
#ifndef MY_HEADER_H
#define MY_HEADER_H
...
#endif#ifndef 방식(구식이지만 표준 호환성 최고)과 #pragma once(비표준, 더 짧음) 중 어느 쪽이든 새 헤더를 만들 때 가드를 빼먹지 않는 게 관례이고, 매크로 이름은 그 헤더에서만 쓰는 유일한 이름이어야 한다.
가드 이름은 밑줄로 시작하면 안 된다
__MY_HEADER_H__처럼 밑줄 2개로 시작하는 이름은 C 표준이 컴파일러/표준 라이브러리용으로 예약해둔 것이라 사용자 코드가 쓰면 안 된다(자세한 건 B11). 오래된 코드베이스일수록 이 위반이 흔하지만, 새로 짤 땐MY_HEADER_H처럼 밑줄 없이 시작하는 이름을 쓸 것. 기존 헤더를 일괄 개명하는 건 별개 문제이니 팀에 먼저 물어볼 것.
A6. strcpy/strcat/sprintf/gets는 쓰지 않는다 — 길이 제한 있는 함수로 대체
버퍼 크기를 모르고 쓰는 문자열 함수는 오버플로우의 고전적 원인이다. strcpy→strlcpy/snprintf, strcat→strncat, sprintf→snprintf, gets→fgets로 항상 바꿔 쓴다. (자세한 비교: snprintf와 sscanf)
주의: strncpy도 완벽하진 않다 — 소스가 n바이트보다 길면 널 종단을 안 붙여준다. strlcpy(항상 널 종단 보장)나 snprintf(buf, sizeof(buf), "%s", src)가 더 안전하다.
이 관례는 모듈마다 편차가 크다 — 새로 짜거나 최근 많이 손본 코드는 안전한 함수로 대체돼 있는 반면, 오래된 주변 모듈엔 레거시 위험 함수가 그대로 남아있는 경우가 흔하다. 기존 코드를 수정할 때 위험 함수를 만나면 그 자리에서 바꾸는 게 맞다.
A7. 전역 상태는 최소화하고, 모듈 밖에서 안 쓸 심볼은 무조건 static
전역 변수는 어디서든 바뀔 수 있어서 디버깅 난이도를 올린다. 파일 안에서만 쓰는 함수/변수는 static을 붙여 링커 심볼 테이블에서 숨긴다 — 이름 충돌도 막고, “이건 이 파일 밖에서 절대 안 건드린다”는 의도도 명확해진다. (자세한 내용: extern과 static)
A8. sleep() 대신 poll()/select() — 대기 중에도 반응해야 한다
sleep(1)은 그 1초 동안 종료 신호가 와도 못 알아챈다. select()/poll()은 타임아웃까지만 기다리되 fd가 준비되면 즉시 깨어난다. 요청 처리 경로에는 raw sleep()을 넣지 않는 게 원칙이다. (자세한 내용: select(), epoll)
A9. “요청 스코프 상태”와 “프로세스 전역 상태”를 설계 단계에서 구분한다
이름에 context가 들어간다고 다 같은 게 아니다. 요청 1건이 시작해서 끝날 때까지만 사는 상태(id로 매핑, 응답 시 조회 후 삭제)와, 프로세스가 켜져 있는 동안 계속 사는 전역 상태는 수명과 스코프가 완전히 다른 개념이다. 이 구분을 안 하면 “이름은 context인데 실제로는 전역 싱글턴”이 되는 게 실제로 흔한 실수다.
typedef struct {
int request_id; /* 이 값으로 해시테이블에 매핑 */
...
} request_ctx_t;요청 전송 시 malloc해서 request_id를 키로 해시테이블에 넣고, 응답/타임아웃 시 꺼내 쓰고 free()한다 — 요청을 보낸 순간 생겨서 응답을 처리하는 순간 죽는다. libuv의 uv_req_t(공식 문서: “preserve context between the initiation and the callback of individual actions”)도 정확히 같은 개념이다.
| 구분 | 생성 시점 | 소멸 시점 | 스코프 |
|---|---|---|---|
| 요청 스코프 context | 요청 전송 시 | 응답/타임아웃 처리 시 | 요청 1건 |
| 프로세스 전역 상태 | main() 시작 시 1회 | 프로세스 종료 시 | 데몬 전체 |
A10. 워커 스레드 풀 — 스레드 개수는 설정값으로 뺀다
스레드 하나가 요청을 순서대로 처리하면 처리량이 스레드 하나 성능에 묶인다. 동일한 작업 함수를 실행하는 스레드를 N개 띄워두고, N을 설정 파일 값으로 빼서 배포 환경 부하에 맞게 조절한다.
스레드 개수는 설정 파일 항목으로 빼되, 기본값·최솟값·최댓값을 매크로로 같이 정의하는 게 좋은 관례다. 설정 파일은 사람이 손으로 고치는 것이라 잘못된 값이 들어올 수 있으니, 읽은 값을 그대로 믿지 않고 범위를 검증하는 것까지가 한 세트다. 스레드 생성 자체는 raw pthread_create()를 직접 흩뿌리기보다, 생성/이름/종료 플래그 확인 방식을 통일한 공통 래퍼를 통해서만 만드는 게 유지보수에 유리하다.
A11. main()은 얇게, 초기화는 init.c로 — 원칙은 이렇지만 현실은 타협돼 있는 경우가 많다
원칙: main()은 init() 호출과 시그널/종료 처리만 담당하고, 실제 초기화 로직은 별도 파일에서 구현한다. 이러면 main()만 열어봐도 “뜰 때 뭘 준비하는지” 한눈에 보이고, 초기화 로직을 독립적으로 테스트하기 쉽다.
현실에서는 <모듈>_main.c 하나에 main()이 있고 그 안에서 서브시스템 초기화 함수들을 순서대로 직접 호출하다가 실패하면 그 시점까지 성공한 것들을 역순으로 정리하는 긴 코드가 인라인으로 들어가는 경우가 흔하다. 새 코드는 원칙(얇은 main + init.c 분리)을 따르되, 기존 _main.c 스타일을 볼 때 “왜 다르지”라고 헷갈리지 않으면 된다. 2급(설계 원칙)이라 UB가 아니므로 기존 파일을 이것 때문에 쪼개지 않는다 — 파일 구조 변경은 “물어본다” 칸에 해당한다.
A12. 기능별로 스레드(또는 프로세스)를 분리한다
프로세스 레벨(기능 단위로 별도 데몬을 두는 구성)과 스레드 레벨(비즈니스 워커 풀, DB 전용 스레드, 송/수신 전용 스레드 쌍, 백그라운드 작업 스레드) 두 층에서 모두 기능별로 나누는 설계가 흔하다. 한쪽이 느려지거나 멈춰도(예: DB 지연) 다른 기능(예: 통신 송수신)은 계속 돌 수 있다는 게 실질적 이득이다.
A13. 모듈 접두어로 네임스페이스를 흉내 낸다
C++의 namespace나 Go 패키지 같은 이름 충돌 방지 장치가 C엔 없다. 그래서 모든 전역 심볼(함수·타입·매크로)에 모듈 접두어를 붙이는 게 사실상 필수 관례다. SQLite의 sqlite3_(버전 2와의 충돌을 피하려 sqlite_에서 바꾼 이력도 있음), nginx의 ngx_, libuv의 uv_와 정확히 같은 목적이다.
A14. 생성 순서의 역순으로 정리(cleanup)하고, goto로 출구를 하나로 모은다
리소스를 A→B→C 순서로 만들었다면, 실패하거나 종료할 때는 반드시 C→B→A, 만든 순서의 역순으로 정리한다. 순서를 안 지키면 아직 의존하고 있는 리소스를 먼저 해제해버리는 use-after-free/댕글링 문제가 생긴다.
실무 C 코드의 주력 패턴은 goto 기반 단일 출구점이다. 여러 리소스를 할당하거나 락을 잡는 함수에서, 중간에 어디서 실패하든 함수 끝의 정리 코드 한 곳으로 점프시킨다.
unlock(&cache_lock[idx]);
ret = SQL_FAIL;
goto err_return; /* 실패 지점이 몇 개든 전부 여기로 */
...
ret = SQL_SUCC; /* 성공 경로도 같은 출구를 지난다 */
err_return:
if (out_data)
free(out_data);
rdlock(&cache_lock[cur_idx]);
return ret;
}SEI CERT C가 권장하고 curl·Linux 커널·SQLite가 쓰는 관례다. 핵심은 **“goto를 쓰냐 마냐”가 아니라 “성공이든 실패든 리소스 해제 코드를 한 곳에만 둔다”**는 것이다. 정리 코드를 return 지점마다 복사해두면 나중에 리소스가 하나 추가될 때 한 군데를 빼먹어서 누수가 난다. C에서 goto가 허용되는 거의 유일하고 확실한 용도가 이것이다(반대로 루프 대용이나 임의 점프로 쓰는 goto는 여전히 금기).
A15. Hungarian 프리픽스 — 팀에 따라 타입/스코프를 이름에 새기기도 한다
변수·필드 이름 앞에 타입/스코프를 나타내는 접두어(sz_문자열, p포인터, n_/i_int, g전역 등)를 붙이는 관례다. 정확히 MS Windows API에서 유명해진 Hungarian Notation이다. 최신 오픈소스(Linux 커널, Redis, nginx)는 대체로 지양하는 추세지만(IDE 타입 추론이 좋아져서), 여러 팀이 오래 유지보수하는 통신/임베디드 코드베이스에선 여전히 표준으로 쓰이는 곳이 있다 — 접두어만 봐도 코드를 안 열고 변수 정체를 짐작할 수 있다는 실무적 이점이 있다.
3급(팀 컨벤션)이라 위 판단 기준에서 유일하게 “새 파일에서도 기존 코드를 따르는” 항목이다. 바깥 트렌드가 지양한다고 해서 혼자 다른 스타일로 쓰면, 옳은 쪽이어도 코드베이스 일관성을 깨는 쪽이 된다.
B. 기본이라서 아무도 안 알려주는 것
B0. 먼저 — 이 중 절반은 “조심하는” 게 아니라 “도구가 잡게” 하는 것이다
아래 항목들을 읽고 “앞으로 조심해야지”로 끝내면 실패한다. 사람의 주의력은 새벽 장애 대응 중에 가장 먼저 무너지는 자원이다. 다행히 B1~B5·B10·B12는 대부분 도구가 자동으로 잡아준다 — 아래 표를 먼저 세팅하고, 그다음에 개별 항목을 읽는 게 순서다.
| 도구 | 켜는 법 | 잡아주는 항목 | 비고 |
|---|---|---|---|
| 컴파일러 경고 | -Wall -Wextra | B3(지역 주소 리턴), B5(signed/unsigned), B6(매크로), B9(sizeof decay), B10 | 비용 0. 지금 당장 켤 수 있는 유일한 것이자 회수율 최고 |
| AddressSanitizer | -fsanitize=address -g | B1(배열 경계), B2(use-after-free/double-free), B3(스택 밖 참조) | 런타임 2배 느려지는 대신 터지는 즉시 스택트레이스를 준다. 디버그 빌드에 상시 켜둘 것 |
| Valgrind memcheck | valgrind --leak-check=full | B4(미초기화 값 읽기), A1(_init/_destroy 누수 검증) | ASan보다 느리지만 누수 리포트가 상세. A1이 실제로 지켜졌는지 증명하는 수단 |
| ThreadSanitizer | -fsanitize=thread | 데이터 레이스(락 없는 전역 카운터 등) | ASan과 동시에 못 켠다. 따로 돌린다 |
static_assert | _Static_assert(sizeof(x)==N, "...") | B12(구조체 크기·패딩) | 컴파일 타임에 직렬화 구조체 크기를 못박는 용도 |
남는 것 — B7(인자 평가 순서), B8(선언/정의), B11(예약 식별자)은 도구가 잘 못 잡는다. 이 셋만 진짜로 머리에 넣어두면 된다. 나머지는 빌드 설정으로 해결된다.
실행 순서
①
-Wall -Wextra로 경고 0개 만들기 → ② 디버그 빌드에 ASan 켜고 테스트 한 바퀴 → ③ 종료 경로에 Valgrind로 누수 확인 → ④ 워커풀 붙일 때 TSan.
B1. 배열은 경계 체크가 없다
arr[10]을 선언해놓고 arr[10](11번째, 범위 밖)에 접근해도 컴파일러도 런타임도 막아주지 않는다. 운 좋으면 세그폴트로 바로 터지고, 운 나쁘면 옆 메모리를 조용히 덮어써서 한참 뒤 완전히 다른 곳에서 버그가 터진다. 루프 조건 <=를 <로 써야 할 자리에 실수하는 off-by-one이 가장 흔한 원인 — 배열/버퍼를 다룰 때는 항상 크기를 명시적으로 들고 다니고(sizeof나 별도 length 변수), 인덱스 연산마다 경계를 의심한다.
B2. free한 포인터는 그 즉시 NULL로 만든다
free(p)는 메모리를 해제할 뿐 p를 NULL로 바꿔주지 않는다. p는 여전히 예전 주소를 가리키는 댕글링 포인터로 남고, 이후 실수로 p를 또 쓰면(use-after-free) 또는 free(p)를 두 번 부르면(double-free) 힙이 깨진다. 관례: free(p); p = NULL;을 습관으로 묶는다 — NULL 포인터에 대한 free()는 표준상 안전(no-op)하므로 이후 실수로 또 free(p)를 불러도 최소한 크래시는 안 난다.
B3. 지역 변수의 주소를 함수 밖으로 리턴하면 안 된다
int *foo(void) {
int local = 42;
return &local; // local은 foo()가 끝나는 순간 스택에서 사라진다
}컴파일은 되고(경고는 뜬다), 리턴된 포인터는 이미 무효화된 스택 프레임을 가리킨다. 값을 함수 밖으로 내보내야 한다면 malloc으로 힙에 만들거나(호출자가 free 책임), 호출자가 미리 준비한 버퍼의 주소를 인자로 받아서 채워주는 방식을 쓴다.
B4. malloc은 메모리를 초기화해주지 않는다
malloc(n)이 돌려주는 메모리엔 이전에 그 자리에 있던 쓰레기값이 그대로 남아있다. 초기화된 메모리가 필요하면 calloc()(0으로 채워줌)을 쓰거나, malloc 직후 memset(p, 0, size)를 명시적으로 부른다. 구조체 필드 몇 개만 세팅하고 나머지는 “당연히 0이겠지”라고 가정하는 게 흔한 실수 원인 — 특히 구조체에 패딩 바이트가 끼면 그 부분은 memcmp로 두 구조체를 통째로 비교할 때도 쓰레기값 차이로 실패한다.
B5. signed/unsigned 비교는 조용히 틀린다
int n = -1;
unsigned int u = 1;
if (n < u) { ... } // 거짓! n이 암묵적으로 아주 큰 unsigned 값으로 변환됨C는 int와 unsigned int를 비교할 때 int를 unsigned로 승격시킨다. -1은 unsigned로 변환되면 UINT_MAX가 되어버려서, 직관과 반대로 n < u가 거짓이 된다. size_t(항상 unsigned)를 다루는 루프에서 for (int i = len - 1; i >= 0; i--)처럼 len이 unsigned면 len - 1도 unsigned라 len == 0일 때 언더플로우로 무한루프에 빠지는 게 실무에서 실제로 자주 나는 버그다.
B6. 매크로는 괄호로 감싸고, 부작용 있는 인자를 넣지 않는다
#define SQUARE(x) x * x
SQUARE(1 + 2) // 1 + 2 * 1 + 2 = 5 (기대: 9) — 괄호 누락
#define MAX(a, b) ((a) > (b) ? (a) : (b))
MAX(i++, j) // i가 조건에 따라 두 번 증가할 수도 있음 — 부작용 중복 평가매크로는 텍스트 치환이라 함수처럼 “인자를 한 번만 평가”하지 않는다. 매크로를 쓸 땐 전체와 각 인자를 전부 괄호로 감싸고, ++/--/함수 호출처럼 부작용이 있는 표현식은 매크로 인자로 넘기지 않는다.
B7. 함수 인자의 평가 순서는 정해져 있지 않다
printf("%d %d\n", i++, i++); // 출력이 컴파일러마다 다를 수 있다 — UBC 표준은 함수 호출 시 인자들이 어떤 순서로 평가되는지 규정하지 않는다. 같은 변수를 두 번 이상 부작용을 주며 인자로 넘기면(i++를 두 곳에), 결과가 컴파일러/최적화 레벨마다 달라질 수 있는 정의되지 않은 동작이 된다. 인자 하나당 부작용은 한 번만, 애매하면 임시 변수로 순서를 강제한다.
B8. 선언과 정의는 다르다 — extern은 “어딘가에 있다”는 약속일 뿐
// a.h
extern int g_count; // 선언: "이 변수는 어딘가에 정의돼 있다"
// a.c
int g_count = 0; // 정의: 실제로 메모리를 만든다헤더에 extern으로 선언만 해두고 어느 .c 파일에서도 실제 정의를 안 하면 링크 에러(undefined reference)가 난다. 반대로 헤더에 extern 없이 변수를 그냥 선언하면(구식 컴파일러 기준) 여러 .c가 그 헤더를 include할 때마다 각각 정의를 만들어 다중 정의 링크 에러가 날 수 있다. (자세한 내용: extern과 static)
B9. 배열은 함수 인자로 넘어가는 순간 포인터로 바뀐다(decay) — sizeof가 달라진다
void foo(char arr[100]) {
printf("%zu\n", sizeof(arr)); // 100이 아니라 8(포인터 크기)이 나온다
}
char buf[100];
printf("%zu\n", sizeof(buf)); // 여기선 100이 맞게 나온다
foo(buf);배열은 함수에 인자로 넘어가는 순간 첫 원소를 가리키는 포인터로 decay한다. 그래서 함수 안에서 sizeof(arr)을 쓰면 배열 전체 크기가 아니라 포인터 크기(8바이트, 64비트 기준)가 나온다 — 버퍼 크기를 함수 안에서 알아야 한다면 크기를 별도 인자로 반드시 같이 넘겨야 한다(snprintf(buf, sizeof(buf), ...) 패턴은 호출부에서 sizeof를 쓰기 때문에 안전한 것 — 함수 내부에서 받은 포인터에 sizeof를 쓰면 틀린다).
B10. 컴파일러 경고는 전부 원인을 파악하고 고친다
-Wall -Wextra 없이 컴파일하면 위 B5(signed/unsigned 비교), B7(초기화 안 된 변수 사용), switch문 break 누락 같은 흔한 버그를 컴파일러가 미리 잡아줄 기회를 스스로 버리는 것이다. “경고는 나는데 일단 돌아가니까 넘어간다”가 나중에 재현 안 되는 버그로 되돌아오는 경우가 실무에서 제일 흔하다 — 경고 0개를 기본값으로 두고, 억제해야 한다면 라인 단위로 이유를 남기고 억제한다.
B11. 밑줄로 시작하는 이름은 “내 것”이 아니다
C 표준(§7.1.3)은 특정 이름 패턴을 컴파일러와 표준 라이브러리 몫으로 예약해뒀다. 사용자 코드가 이걸 쓰면 표준상 정의되지 않은 동작이다 — 당장은 잘 돌지만, 컴파일러나 libc를 업그레이드했을 때 내부 매크로와 이름이 겹쳐 터질 수 있다.
| 패턴 | 예약 범위 | 예시 |
|---|---|---|
__로 시작 | 모든 스코프에서 예약 | __MY_HEADER__, __mycount |
_ + 대문자 | 모든 스코프에서 예약 | _Buffer, _MY_H_ |
_ + 소문자 | 파일 스코프(전역)에서 예약 | 전역 _count(지역 변수면 괜찮음) |
실무에서 워낙 흔한 관행이라 리뷰에서도 잘 안 짚이지만, 표준 기준으로는 명백한 위반이다. 안전한 이름은 밑줄 없이 시작하는 형태다. 같은 이유로 자기 코드에 _init처럼 밑줄로 시작하는 전역 함수명을 쓰는 것도 피해야 한다(다만 static이라 파일 밖으로 안 나가면 실질 위험은 낮다).
B12. 구조체엔 보이지 않는 구멍(패딩)이 있다
구조체를 그대로 IPC로 주고받거나 파일/네트워크로 직렬화하는 코드에서는 구조체의 메모리 레이아웃 자체가 통신 규격이 된다.
struct A {
char c; /* 1바이트 */
/* ← 여기에 패딩 3바이트가 조용히 들어간다 */
int n; /* 4바이트 — 4의 배수 주소에 놓여야 하므로 */
char c2; /* 1바이트 */
/* ← 끝에도 3바이트 패딩 (구조체 크기를 4의 배수로) */
}; /* sizeof = 4가 아니라 12 */CPU는 int를 아무 주소에서나 읽는 걸 싫어해서(정렬 요구), 컴파일러가 멤버 사이에 말 안 하고 빈 바이트를 끼워 넣는다. 여기서 세 가지가 터진다.
| 문제 | 왜 생기나 | 대응 |
|---|---|---|
memcmp로 구조체 비교가 실패 | 패딩 바이트엔 쓰레기값이 남아있어서, 논리적으로 같은 두 구조체도 다르다고 나온다 | 필드별로 비교하거나, calloc/memset으로 패딩까지 0으로 밀고 시작한다(B4와 같은 뿌리) |
| 양쪽 빌드 환경이 다르면 레이아웃이 어긋남 | 32/64비트, 컴파일러, 정렬 옵션이 다르면 같은 선언이라도 sizeof와 오프셋이 달라진다 | 송수신 양쪽을 같은 헤더·같은 컴파일 옵션으로 빌드한다 |
| 네트워크로 나가면 바이트 순서까지 문제 | 정수의 바이트 배열 순서(엔디안)가 장비마다 다르다 | 네트워크 경계에선 htonl/htons(보낼 때), ntohl/ntohs(받을 때) |
패킹 지시자(#pragma pack, __attribute__((packed)))를 안 쓰는 코드베이스는 패딩을 없애는 대신 “양쪽 끝이 같은 헤더로, 같은 환경에서 빌드된다”는 전제에 기대고 있는 것이다. 통제된 사내망에서는 성립하는 방식이지만, 이게 전제라는 사실 자체를 모르면 위험하다 — 빌드 환경이 갈리거나 이기종과 붙는 순간 조용히 깨지고, 증상은 “특정 필드만 쓰레기값”으로 나타나서 원인을 찾기가 매우 어렵다.
실무 대응: 직렬화 대상 구조체엔 크기를 컴파일 타임에 못박아두면 사고를 미연에 잡는다.
_Static_assert(sizeof(my_msg_t) == 64, "IPC 구조체 크기 변경 — 양쪽 다 다시 빌드할 것");멤버를 큰 타입부터 작은 타입 순으로 선언하면 패딩 자체도 줄어든다(long → int → short → char).
관련 문서
- extern과 static
- select()
- epoll
- snprintf와 sscanf
- 전처리기 조건부 컴파일 — if 0으로 코드 끄기와 빌드 옵션 분기
- 락 없는 카운터 — GCC 원자적 연산 빌트인과 C11 atomic
- 불투명 포인터(Opaque Pointer) — 헤더는 선언만, 구현은 숨기기
- 매크로를 do-while(0)으로 감싸는 이유 — 여러 문장을 안전한 하나로 묶기
- 정수를 void 포인터 인자에 실어 보내기 — intptr_t 캐스팅 관용구
- 개수 필드 + 포인터의 포인터 — 동적 문자열 배열 만들고 해제하기
- Hungarian 표기법 접두어 해독표 — 변수 이름만 보고 타입·스코프 짐작하기
- 가변 길이 구조체(Struct Hack)와 필러 필드 — 헤더 뒤에 매달리는 가변 payload
- 시프트로 여러 필드를 하나의 정수에 조합하기 — 필드 패킹
- 엔디안(바이트 순서)과 인터넷 체크섬 — LSB·MSB와 htons를 직접 만들어보기