C 언어 시리즈 #05 — 포인터 연산·엄격 별칭(strict aliasing)·유효성
이 글의 핵심
포인터 덧셈이 의미하는 바이트·요소 단위, one-past-the-end, float*와 int*가 같은 메모리를 가리킬 때 왜 UB가 되는지, restrict가 최적화에 주는 힌트를 정리합니다.
시리즈 안내
#05 | 📋 전체 목차 | 이전: #04 함수·ABI · 다음: #06 배열·문자열
이번 글은 포인터를 “그냥 주소 숫자”로만 보지 말고, 타입과 수명, 별칭(aliasing) 계약까지 함께 묶어서 이해하자는 취지입니다. 다만 순서는 일반적인 교재와 크게 다르게 구성했습니다. 먼저 무엇이 잘못될 수 있는지부터 살펴보고, 그다음에 차분하게 기초로 내려가겠습니다.
포인터를 잘못 써서 서버 전체가 흔들렸던 날
포인터를 잘못 써서 잠깐이지만 서버 전체가 거의 마비될 뻔한 적이 있습니다. 전형적으로 프로덕션에서만 터지는 유형이었습니다. 개발 환경에서는 멀쩡하다가, 트래픽이 늘고 최적화 빌드가 배포된 뒤부터 간헐적으로 터지는 문제였습니다. 로그는 애매했고, 코어 덤프는 운이 좋을 때만 잡혔으며, 팀 채널에서는 “재현되나요?”라는 질문만 수십 번 오갔습니다.
원인은 결국 이미 free한 메모리를 가리키는 포인터를 한 스레드가 건드린 것이었는데, 타이밍이 맞을 때만 레이스가 발생해서 며칠 동안 “환경 문제 아니냐”는 말이 나왔습니다. 포인터 하나가 느슨하면 시스템 전체까지는 아니더라도 서비스 한 덩어리는 충분히 멈출 수 있다는 것을 그때 실감했습니다. 추상화가 아무리 우아해도 밑바닥에는 주소와 수명이 있습니다.
Valgrind와 사흘을 보낸 이야기
그 전에 다른 프로젝트에서는 Valgrind memcheck만 붙잡고 꼬박 사흘을 보낸 적이 있습니다. 프로덕션에서 한 달에 한 번 정도만 터지는 까다로운 버그였는데, 재현 스크립트를 짜고 로그를 잔뜩 남겨도 “Invalid read” 한 줄이 찍힐까 말까였습니다. 결국 리눅스 박스를 하나 띄워 valgrind —leak-check=full —show-leak-kinds=all로 바이너리를 돌렸는데, 첫날에는 실행이 너무 느려서 CI에서 쓰던 데이터로는 타이밍이 맞지 않았습니다. 둘째 날에는 부하를 조금씩 올려 가며 결국 use-after-free 스택을 잡았고, 셋째 날에는 그 경로가 우리 코드가 아니라 플러그인 쪽 콜백과 얽혀 있다는 것을 추적해 고쳤습니다. 그때 얻은 교훈은 단순합니다. 포인터 버그는 재현이 매우 어렵고, 도구 없이는 시간만 흘러간다는 것입니다. GDB로 bt만 찍고 있으면 끝이 보이지 않을 때가 많아서, 힙 문제라면 Valgrind나 ASan 계열을 먼저 떠올리게 되었습니다.
먼저 볼 것: 포인터가 왜 이렇게 위험한지
널·미초기화·범위 밖
NULL을 역참조하면 보통 UB이고, 일부 환경에서는 바로 SIGSEGV로 종료됩니다. 더 악질인 것은 초기화되지 않은 지역 포인터입니다. 쓰레기 값이 우연히 “그럴듯한 주소”처럼 보이면 당장은 죽지 않고 며칠 뒤에 터질 수 있습니다. 범위 밖 접근의 경우 one-past-the-end 위치를 역참조하는 순간 OOB가 되는데, 이는 AddressSanitizer가 상당히 잘 잡아 줍니다.
댕글링 포인터
수명이 끝난 메모리를 가리키는 포인터입니다. free 이후에 같은 p를 쓰거나, 스택 변수의 주소를 함수 밖으로 가지고 나가는 것이 대표적입니다. 겉으로는 잘 돌아가는 것처럼 보이다가 Valgrind에서만 “Invalid read”로 드러나는 패턴입니다.
int *bad(void) {
int x = 0;
return &x; /* x의 수명은 함수 종료와 함께 끝(일반) — UB */
}
Double free / Use-after-free
UAF는 free 이후에 읽기·쓰기를 하는 것이고, 더블 프리는 같은 블록을 두 번 free하는 것입니다. 보안 이슈와도 직결되므로, free 직후 p = NULL로 막아 두는 것은 완벽하지는 않아도 실수 한 번은 줄여 줍니다.
메모리 누수
realloc 경로나 에러 시 early return, 플러그인 생명주기 처리를 빠뜨리면 RSS가 천천히 올라갑니다. 장기 실행 프로세스라면 모니터링과 도구를 함께 활용해야 합니다.
-O0에서는 되는데 -O2에서만 터지는 문제 (strict aliasing)
C에서는 타입이 맞지 않는 포인터들이 같은 저장 공간을 동시에 건드리면 최적화 가정이 깨질 수 있어 UB가 됩니다. 컴파일러는 “별칭이 없다”고 가정하고 값을 레지스터에 캐시해 두는데, int*로 쓰고 float*로 읽는 식으로 겹치면 그 모델이 무너지기 때문입니다. char/unsigned char를 통한 바이트 접근은 예외로 따로 다룹니다.
void bad_alias(int *i, float *f) {
*i = 1;
*f = 2.0f; /* *같은 주소*를 가리키면 UB 가능(일반) */
}
restrict
restrict는 “이 경로로만 이 영역에 접근하며, 겹치는 부분이 없다”는 약속입니다. 거짓이면 UB입니다. 멀티스레드 환경에서 버퍼가 겹치면 대표적으로 문제가 생깁니다.
void add_vec(int * restrict a,
const int * restrict b,
const int * restrict c, size_t n) {
for (size_t i = 0; i < n; i++) a[i] = b[i] + c[i];
}
-fno-strict-aliasing은 타입 규칙을 느슨하게 만들어 문제를 숨길 수는 있지만 성능이 나빠질 수 있습니다. 저는 장기적으로 memcpy나 올바른 union 패턴으로 고치는 편이 낫다고 생각합니다.
Effective type과 memcpy
malloc으로 받은 메모리는 처음으로 의미 있는 값을 쓸 때 effective type이 정해지므로, 바이트만 fread로 채운 뒤 T로 통째로 읽는 것은 위험할 수 있습니다. 네트워크 패킷을 int32_t로 읽고 싶다면 memcpy로 int32_t 변수에 복사하거나, 비트 연산으로 명시적으로 디코딩하는 편이 안전합니다.
#include <stdint.h>
#include <string.h>
int32_t read_i32(const unsigned char b[4]) {
int32_t v;
memcpy(&v, b, sizeof v);
return v;
}
C23에서는 포인터 provenance를 표준 문맥에서 더 명시하려는 흐름이 있으며, 포인터를 단순히 정수와 같다고 볼 수 없는 경우가 있다는 점만 기억해 두십시오.
uint32_t be32_load(const unsigned char p[4]) {
return ((uint32_t)p[0] << 24) | ((uint32_t)p[1] << 16) |
((uint32_t)p[2] << 8) | (uint32_t)p[3];
}
도구: Valgrind, ASan, GDB (간단히)
Valgrind는 리눅스에서 느리지만 memcheck가 누수와 invalid read/write를 잘 찾아 줍니다. 위의 경험처럼 며칠이 걸리더라도 결국 원인을 짚어 주는 경우가 많습니다.
AddressSanitizer는 -g -O1 정도에 -fsanitize=address를 넣으면 OOB와 상황에 따라 UAF까지 런타임에 잡아 주기 때문에 CI에도 자주 붙입니다. UBSan(-fsanitize=undefined)은 strict aliasing 위반까지 완벽히 잡지는 못하지만 개발 중에는 좋은 신호가 됩니다.
GDB는 크래시가 났을 때 bt로 스택을 보는 것이 기본이며, ASan이 붙은 바이너리는 첫 위반에서 멈추면서 할당 경로를 잘 보여 주는 경우가 많습니다. 코어 덤프는 재현성이 낮은 버그에 특히 도움이 되는데, PIE·ASLR 환경이라면 심볼과 빌드 ID를 맞추는 일을 팀 정책으로 정해 두어야 합니다.
이제 기초: 포인터란 무엇인가
포인터는 어떤 객체의 주소를 담는 객체입니다. T *p는 “T를 가리키는 포인터”이고, p 안에 들어 있는 것은 그 객체의 주소 값입니다. 주소의 비트 너비는 sizeof(void *)로 확인할 수 있습니다.
선언 읽는 법
int *a, b는 함정입니다. *는 선언자에 붙으므로 a만 int 포인터이고 b는 그냥 int입니다. 둘 다 포인터로 선언하려면 int *a, *b;처럼 써야 합니다. 팀에 따라 int* a처럼 붙여 쓰기도 하지만, 한 줄에 여러 변수를 선언하는 것 자체를 금지하는 스타일도 많습니다.
#include <stdio.h>
int main(void) {
int x = 42;
int *p = &x;
printf("x = %d\n", x);
printf("p가 가리키는 곳: %d\n", *p);
return 0;
}
&x는 주소이고, *p는 역참조 결과로 lvalue가 되므로 *p = 0; 같은 대입도 가능합니다.
널과 초기화
가리킬 대상이 없으면 널 포인터 NULL로 두는 것이 일반적이며, 지역 변수 T *p;를 선언만 한 채로 *p를 하면 거의 바로 UB입니다. C23부터는 nullptr 키워드도 생겼습니다.
int *p = NULL;
if (p == NULL) {
/* 아직 가리킬 대상 없음 */
}
const·volatile
const T *p는 가리키는 대상을 읽기만 하겠다는 의도이고, T * const p는 포인터 자체가 불변이라 다른 객체를 가리키도록 바꿀 수 없습니다. volatile T *p는 MMIO 같은 곳에서 컴파일러가 접근을 생략하지 못하게 하는 힌트이지만, 멀티스레딩 동기화의 기본 해법은 아닙니다. 동시성은 stdatomic을 쓰는 편이 안전합니다.
메모리 주소와 역참조 (바이트 관점)
포인터 값은 주소에 타입(그리고 strict aliasing 문맥에서의 접근 의미)이 붙은 것입니다. 객체는 연속된 바이트이고, unsigned char로 보는 것은 객체 표현을 다루는 표준적인 방법 중 하나입니다.
int x = 0x12345678;이 있을 때 바이트 순서(엔디언)는 플랫폼마다 다르고, 메모리는 연속된 바이트 슬롯으로 그려 보면 이해하기 쉽습니다. int *p = &x;라면 p는 그 int 객체의 시작 주소를 담는다고 보면 됩니다.
*p는 p가 가리키는 int 객체이고, p[0]은 배열이 아니더라도 포인터 연산 의미상 *(p+0)과 같습니다(아래 포인터 연산과 연결됩니다).
스택의 int *p가 지역 변수 x를 가리킬 때, 함수가 살아 있는 동안 *p로 접근하는 것은 대개 안전하지만, return 이후에 그 주소를 쓰는 것은 수명 위반입니다.
uintptr_t로 주소를 정수로 옮겨 로그나 해시에 쓸 수는 있지만, 그 정수에 임의로 산술 연산을 한 뒤 다시 포인터로 만들면 정렬·수명·provenance 문제로 UB가 될 수 있으므로 경로 전체를 살펴봐야 합니다. printf("%p", (void *)p);나 PRIxPTR로 출력하는 팀도 많은데, 외부에 공개되는 로그라면 ASLR 우회 위험도 함께 고려해야 합니다.
포인터 연산: 스케일링과 배열
T *p에 대해 p + n은 T 객체 n개만큼 이동합니다. n 바이트가 아니라 n * sizeof(T) 바이트만큼 이동한다고 이해하면 됩니다.
배열 T a[N]에서 a + N은 one-past-the-end로, 비교에는 쓸 수 있지만 그 주소를 역참조하면 UB입니다. 루프에서 end에 도달하는 것과 end를 읽는 것은 다르다는 점이 핵심입니다.
#include <stddef.h>
void walk(const int *a, size_t n) {
const int *end = a + n;
for (const int *p = a; p != end; p++) {
(void)*p;
}
}
p - q는 같은 배열(또는 one-past 위치) 안에서의 거리이며 타입은 ptrdiff_t입니다. 서로 관계없는 주소끼리 빼는 것은 허용되지 않습니다.
char buf[16]을 무작정 T *로 캐스팅해서 쓰면 정렬 위반과 effective type 문제로 UB가 될 수 있으므로, 네트워크 버퍼는 memcpy나 alignas를 고려하십시오.
p[i]와 i[p]가 같다는 이야기는 교육용으로 가끔 나오지만, 실무에서는 p[i]로 통일하는 것이 좋습니다.
const int *const first = a;
const int *const last = a + n;
for (const int *p = first; p < last; ++p) { /* *p */ }
다중 포인터: **와 main의 argv
T**는 T*를 가리키는 포인터이며, main(int argc, char **argv)가 대표적인 예입니다. 호출자 쪽의 포인터 자체를 바꾸려면 T **가 필요할 때가 있습니다.
#include <stdlib.h>
void reseat(int **pp, int *newp) {
*pp = newp;
}
int main(void) {
int a = 1, b = 2;
int *p = &a;
reseat(&p, &b);
return p == &b ? 0 : 1;
}
다중 포인터는 소유권과 수명이 한꺼번에 꼬이기 쉬우므로, typedef로 단계를 드러내는 것도 한 방법입니다.
typedef int *IntPtr;
typedef IntPtr *IntPtrPtr;
배열이냐 포인터냐: T (*p)[N] vs T *a[N]
T (*p)[N]은 길이 N인 T 배열 전체를 가리키는 포인터이고, T *a[N]은 포인터 N개를 담은 배열입니다. char *argv[]가 후자에 해당합니다.
void print_row3(int (*row)[3]) {
for (int i = 0; i < 3; i++) {
printf("%d ", (*row)[i]);
}
printf("\n");
}
int main(void) {
int m[2][3] = {{1,2,3},{4,5,6}};
print_row3(&m[1]);
return 0;
}
int a = 1, b = 2, c = 3;
int *arr[3] = {&a, &b, &c};
int a[10];에서 대부분의 문맥에서 a가 int *로 디케이한다는 이야기는 시리즈 #06에서 더 자세히 다룹니다. &a[0]과 a는 값이 같아 보이는 경우가 많지만, &a의 타입은 int (*)[10]으로 달라서 +1이 건너뛰는 크기가 달라집니다.
함수 포인터
함수 포인터는 함수 식별자가 포인터 문맥에서 사용될 때의 형태입니다. f와 &f가 문맥에 따라 비슷하게 취급되는 경우가 흔합니다.
#include <stdio.h>
int add(int a, int b) { return a + b; }
int sub(int a, int b) { return a - b; }
int main(void) {
int (*op)(int, int) = add;
printf("%d\n", op(2, 3));
op = sub;
printf("%d\n", op(2, 3));
return 0;
}
typedef int (*binop_t)(int, int);
binop_t pick(char op) { return op == '+' ? add : sub; }
qsort 비교 함수가 const void*를 받는 이유는, 비교 함수 안에서 T*로 캐스팅해서 읽도록 설계되었기 때문입니다.
#include <stdlib.h>
int icmp(const void *a, const void *b) {
int x = *(const int *)a, y = *(const int *)b;
return (x > y) - (x < y);
}
int main(void) {
int a[] = {3,1,2};
qsort(a, 3, sizeof a[0], icmp);
return 0;
}
동적 메모리: malloc, calloc, realloc, free
힙은 malloc 계열로 할당받고 free와 짝을 맞춰야 합니다. 이중 free나 엉뚱한 포인터에 대한 free는 UB입니다.
malloc은 초기화되지 않은 바이트를 주고, calloc은 0으로 초기화된 메모리를 줍니다. malloc(0) 같은 경계 조건은 구현마다 다르므로 방어적으로 처리하는 것이 좋습니다.
#include <stdio.h>
#include <stdlib.h>
int main(void) {
int *p = (int *)malloc(3 * sizeof *p);
if (!p) { return 1; }
p[0] = 1; p[1] = 2; p[2] = 3;
free(p);
return 0;
}
realloc은 블록을 다른 위치로 옮길 수 있으므로, 반환값을 확인하기 전까지 이전 포인터를 건드리지 말고 새 포인터를 쓰는 것이 안전합니다.
p = realloc(p, new_size);
if (!p && new_size != 0) {
return 1;
}
malloc(a * b)의 곱이 size_t 범위를 넘치면 조용히 잘못된 작은 할당이 될 수 있어서, 실서비스에서는 안전한 곱셈 유틸리티와 상한 검사를 함께 두는 경우가 많습니다.
calloc과 realloc으로 가변 버퍼를 만들 때도, 실패 시 기존 블록이 살아 있는지 구현 문서를 확인하면서 처리해야 합니다.
구조체와 ->, .
s.field는 값 struct S s에 쓰고, p->field는 struct S *p에 씁니다. p->field는 (*p).field와 같습니다.
#include <stdio.h>
typedef struct {
int x;
int y;
} Point;
int main(void) {
Point p = {1, 2};
Point *pp = &p;
printf("%d\n", pp->x);
return 0;
}
offsetof로 필드 위치를 구하는 코드는 패딩이나 ABI가 바뀌면 결과가 달라질 수 있으므로 테스트·문서와 함께 관리하십시오.
#include <stddef.h>
#include <stdio.h>
typedef struct {
char tag;
int value;
} Msg;
int main(void) {
printf("offsetof(Msg, value)=%zu\n", offsetof(Msg, value));
return 0;
}
void*
void *는 임의의 객체를 가리킬 수 있도록 설계되었으며, C에서는 T*와 일정한 제약 아래 상호 변환되지만 직접 역참조할 수는 없습니다. 반드시 T*로 변환한 다음 *나 []를 사용하십시오.
int x = 42;
void *vp = &x;
int *ip = (int *)vp;
*ip = 0;
제네릭 핸들은 void*에 길이를 size_t로 짝지어 넘기는 패턴이 흔하며, 직렬화나 네트워크 처리에는 앞서 말한 memcpy와 명시적 디코딩을 사용하십시오.
실전 예: 리스트와 문자열
단일 연결 리스트 (교육용)
#include <stdio.h>
#include <stdlib.h>
typedef struct Node {
int value;
struct Node *next;
} Node;
static Node *node_new(int v) {
Node *n = (Node *)malloc(sizeof *n);
if (!n) return NULL;
n->value = v; n->next = NULL;
return n;
}
static void list_free(Node *head) {
while (head) {
Node *nxt = head->next;
free(head);
head = nxt;
}
}
int main(void) {
Node *h = node_new(1);
if (!h) return 1;
h->next = node_new(2);
if (!h->next) { list_free(h); return 1; }
for (Node *p = h; p; p = p->next) {
printf("%d\n", p->value);
}
list_free(h);
return 0;
}
힙에 Node a -> Node b -> NULL처럼 연결되어 있을 때, 순회는 for (Node *p = head; p; p = p->next)처럼 포인터 루프와 자연스럽게 맞물립니다. 누가 next를 갱신하느냐가 버그와 누수를 가르는 지점입니다.
list_prepend처럼 head를 바꾸려면 Node **가 필요하다는 점은 앞의 reseat 예제와 같은 이야기입니다.
void list_prepend(Node **head, Node *n) {
n->next = *head;
*head = n;
}
문자열
#include <string.h>
#include <stdio.h>
int main(void) {
const char *s = "a=1&b=2";
const char *eq = strchr(s, '=');
if (eq) {
/* eq+1 이후 파싱은 범위 체크랑 같이 */
}
return 0;
}
strtok는 내부에 static 상태를 두기 때문에 멀티스레드나 재진입 상황에서 문제가 될 수 있으므로, strtok_r 계열을 쓸 수 있다면 그쪽을 쓰는 편이 낫습니다.
길이를 알 수 없는 입력에 strcpy를 쓰면 OOB 위험이 있으므로 snprintf나 팀 래퍼로 막으십시오.
void copy_name(char *dst, size_t dsz, const char *src) {
if (dsz == 0) return;
(void)snprintf(dst, dsz, "%s", src);
}
strstr로 :를 찾은 뒤 p+1로 넘어가는 패턴은 char가 1바이트 요소이므로 포인터 +1이 “다음 문자”로 이동하는 전형적인 예입니다. 그래도 전체 범위 검사는 여전히 필요합니다.
최적화와 “값이냐 포인터냐”
몇 워드 크기의 작은 struct는 값으로 넘겨도 복사 비용이 싸고, 큰 구조체는 const T*로 넘기는 방식이 흔합니다. 포인터가 항상 더 빠른 것은 아니며, 캐시 라인·별칭·ABI에 따라 결과가 달라집니다.
typedef struct { double x, y, z, w; } V4;
void by_ptr(const V4 *v) { (void)v->x; }
V4 by_value(V4 v) { return v; }
-O2에 -fstrict-aliasing이 켜진 상태에서는 타입이 맞지 않는 캐스팅이 간헐적인 버그로 나타날 수 있습니다. “디버그 빌드에서는 되는데 릴리스에서만 터진다”면 UB와 별칭 문제를 의심해 보십시오. Sanitizer와 코드 리뷰를 함께 활용하십시오.
전역 T*
static int *g;가 파일 스코프에 있더라도, 가리키는 대상이 free되면 UAF가 됩니다. 전역이나 TLS 핸들은 락과 소유권 규칙이 문서화되어 있지 않으면 수명을 관리하기 어렵습니다.
static int *g;
void set_bad(int *p) { g = p; }
sizeof(T*)는 포인터 객체의 크기일 뿐 가리키는 배열 전체의 길이가 아니므로, 동적 길이는 size_t로 따로 넘겨야 합니다. 힙 블록의 정렬은 malloc이 표준·구현 차원에서 보장하는 범위에 기대지만, 민감한 경우라면 플랫폼 API까지 확인하십시오.
점검할 것들
캐스팅으로 타입을 우회하기보다 memcpy, 명시적 디코딩, 검증된 union 패턴을 쓰십시오. size_t 오프셋과 곱셈은 오버플로가 날 수 있으니 경로마다 상한을 두십시오. OOB나 UB 시나리오는 Sanitizer로 미리 잡는 것이 좋습니다. CI에서는 -Wall -Wextra 수준으로 경고를 올리고, 가능하면 개발·스테이징 환경에 ASan+UBSan을 붙이는 팀이 많습니다. -Wformat 계열 경고로 printf와 포인터 관련 실수도 함께 잡을 수 있습니다.
한 줄로 정리하자면
T *p에 대해 p+n은 요소 단위 이동이며, a+N one-past 위치는 역참조 금지라는 것이 핵심입니다. strict aliasing은 서로 다른 타입으로 겹쳐 읽고 쓰는 경로를 제약하며, restrict는 약속이 거짓이면 UB입니다. malloc·memcpy·Sanitizer·ABI는 모두 이 교차점에서 자주 등장합니다.
자주 묻는 질문 (FAQ)
Q. 이 내용은 실무에서 언제 쓰이나요?
A. 힙·스택·정적 영역에 걸친 소유권 관리, 네트워크·디스크에서 읽은 바이트를 구조체로 해석하는 작업, 대용량 memcpy와 SIMD까지 포인터는 모든 계층의 밑바탕입니다. strict aliasing·restrict·malloc·Sanitizer·ABI는 그 교차점에서 자주 문제가 되는 주제입니다.
Q. 먼저 읽으면 좋은 글은 무엇인가요?
A. C 언어 시리즈 목차에서 #02 타입, #04 함수·ABI, 이어서 #06 배열과 #07 구조체를 함께 보면 흐름이 이어집니다. relatedPosts에도 관련 글을 연결해 두었습니다.
Q. 더 깊이 공부하려면 어떻게 해야 하나요?
A. C 표준과 cppreference C 항목을 읽고, Compiler Explorer로 -O2 결과를 비교(별칭·벡터화)하며, ASan/Valgrind로 직접 측정해 보는 것이 가장 체감이 큽니다.
같이 보면 좋은 글
- C 언어 시리즈 #06 — 배열·디케이(decay)·문자열 리터럴·VLA
- C 언어 시리즈 #07 — 구조체·공용체·비트필드·패딩과 ABI
- C 언어 시리즈 #02 — 타입·승격(usual arithmetic)·정수 표현과 패딩