본문으로 건너뛰기
Previous
Next
C 언어 시리즈 #09 — 동적 메모리·단편화·할당기·Sanitizer·프로덕션 패턴

C 언어 시리즈 #09 — 동적 메모리·단편화·할당기·Sanitizer·프로덕션 패턴

C 언어 시리즈 #09 — 동적 메모리·단편화·할당기·Sanitizer·프로덕션 패턴

이 글의 핵심

힙이 왜 단편화되는지, realloc이 왜 포인터를 바꿀 수 있는지, 이중 해제·UAF가 왜 UB인지, 서버·임베디드에서 각각 어떤 할당 전략을 쓰는지 설명합니다.

시리즈 안내

#09 | 📋 전체 목차 | 이전: #08 전처리기 · 다음: #10 컴파일·링크

C에서 메모리는 “주소 한 개”로 끝나지 않습니다. 프로세스 가상 주소 공간의 어디에 무엇이 올라가는지, 스택 프레임이 어떻게 쌓이는지, 힙 할당기가 내부적으로 어떤 메타데이터를 붙이는지까지 엮어 이해해야 실제 버그(누수, 힙 손상, 미정의 동작)를 재현·수정할 수 있습니다. 이 글은 시리즈 #02 타입·정렬, #06 배열·문자열, #07 구조체·ABI에서 쌓은 전제를 바탕으로, 동적 메모리와 그 주변을 실전 관점에서 정리합니다. 다만 순서는 “교과서 머리”가 아니라, 현장에서 제일 아프게 맞는 것부터 씁니다.


1. “메모리 릭으로 서버가 점점 느려지던…”

옛날 옛날이 아닙니다. 메모리 릭으로 서버가 점점 느려지던 주말 on-call이 떠오르시죠. 처음엔 p95가 살짝 밀리고, 캐시 hit이 좋아서 그런가 보다 하다가, 며칠 뒤엔 OOM 킬이나 스왑 지옥을 밟는 그림입니다. top이나 htop에서 RSS만 보고 “여유 있네” 하다가, 실제로는 누수·캐시 비한도 성장이 쌓인 걸 뒤늦게 깨닫는 경우가 많아요(반말로 말하자면, 그때는 이미 늦을 때가 있습니다).

힙 누수는 스택 오버플로보다 덜 극적이어서 잡기가 더 껄끄럽습니다. 어쩌다 한 번 죽는 스택/세그폴트는 로그에 바로 흔적이 남는데, 누수는 “천천히” 자산을 빼앗기니까, 원인이 할당인지 캐시 정책인지 외부 큐인지 감이 잘 안 옵니다.

여기서 제가 팀에 자주 쓰는 한 문장이 있습니다. malloc을 소스에 직접 쓰지 말고, 래퍼(팀 API)로 모으시라는 뜻입니다. 이유는 단순합니다. 누수·이중 free·realloc 실패, 전부 “한 줄”이 아니라 패턴으로 퍼지거든요. 래퍼에만 로깅·널 체크·성공/실패 통계·디버그 시 칸막이를 박으면, 나중에 “누가 malloc을 이렇게 많이 썼지?”를 찍어보는 게 가능해집니다. 직접 malloc은 금지가 과한 팀도 있겠죠. 그렇다면 “라이브러리 경계·서비스 핵심 루프”에서만이라도 묶으세요. 그게 프로덕션 감이에요.


2. 누수가 아픈 이유, 그리고 대표 원인

제일 아픈 건, 릭이 성능을 먹는다는 점입니다. OS가 힙을 키우고, 페이지 폴트가 늘고, GC는 없는데 해제만 안 하니 RSS가 램프를 탑니다. “조용한 장애”죠.

대표 원인을 이야기로 풀어 볼게요. 어떤 동료는 malloc만 하고 free성공 경로에만 썼습니다. 에러 경로에선 반환해버리니 블록이 남죠. 또 어떤 팀은 reallocp = realloc(p, n)에 넣다가, 실패 시 p를 잃어 한 번에 두 배로 아픕니다(누수 + null). 자료구조에 포인터를 넣어 두고 누가 주인인지 문서가 없으면, 리뷰에서 “여기 free?”가 영원히 반복됩니다. longjmp나 깊은 중첩 return만 있다면, cleanup 한 줄이 빠지는 게 누수 한 건으로 남죠.

탐지는 ASan 누수 옵션, Valgrind, 정적 분석… 여전히 조합이 답이에요. “한 번 Valgrind”만으로 끝낸 팀이 있었는데, 그날은 부하가 약해서 을 못 잡은 적도 있었습니다(그래서 CI에 넣죠).

방지소유권이 핵심입니다. create/destroy 쌍, 단일 owner, goto cleanup이든 매크로든 팀 말투에 맞춰 한 스타일로 고정하십시오. free 대입, 그건 safe_free 래퍼에 넣으면 됩니다(직접 free 흩뿌리지 말고요).

static void buffer_free(char **p) {
    if (p && *p) {
        free(*p);
        *p = NULL;
    }
}

3. 댕글링·이중 free — 누수 다음으로 난제

누수가 “자산”을 지우는 병이면, 댕글링·이중 free“데이터”를 지우는 랜드마인이에요. free 뒤에 같은 포인터를 읽으면 UAF, free를 두 번은 double free — UB이고, 나중에 터집니다.

free 직후 p = NULL단일 별칭일 때만 믿을 수 있어요(다른 곳이 같은 블록을 가리키면 소용없죠). “이 함수가 free한다 / 안 한다”는 API 문서에 박는 게 래퍼 문화랑 잘 맞습니다.

void use_after_free_example(void) {
    int *p = (int *)malloc(sizeof *p);
    if (!p) {
        return;
    }
    *p = 42;
    free(p);
    p = NULL;
    /* *p 읽기 금지 — UB */
}

이중 free는 힙 메타데이터를 써서 훨씬 뒤에 괴물을 만듭니다. safe_free 같은 걸 쓰면 적어도 이중을 줄일 수는 있죠.

void safe_free(void **pp) {
    if (pp && *pp) {
        free(*pp);
        *pp = NULL;
    }
}

4. 이제 기초로: 프로세스 메모리(스택·힙·데이터·코드)

누수·UAF의 배경이 되는 을 잠깐 짓자고요. 운영체제·런타임이 보여 주는 그림은 플랫폼마다 다르지만, 전형적인 사용자 모드에서는 코드·초기화/비초기화 데이터·힙·스택이 같이 살아요(반말: 주소 위아래는 아키마다 뒤집히니, 방향에만 끌려가지 마세요).

4.1 개념 다이어그램

높은 주소
+--------------------------+
|        스택 (Stack)       |  <- 지역 변수, 함수 인자, 반환 주소
|    (자동 저장, LIFO)        |
+--------------------------+
|            ↓ 성장          |
|         (미사용 가드)        |
|            ↑ 성장          |
+--------------------------+
|        힙 (Heap)           |  <- malloc 계열, mmap 기반 큰 블록 등
|    (수동 수명 관리)         |
+--------------------------+
| BSS (비초기화 전역/정적)    |  <- 0으로 암시 초기화되는 데이터
+--------------------------+
| Data (초기화 전역/정적)     |  <- 상수가 아닌 초기값이 있는 전역
+--------------------------+
| RO Data (문자열 리터럴 등)  |  <- 읽기 전용 데이터(플랫폼에 따라 분리)
+--------------------------+
| Text (코드, 읽기 실행)      |  <- 기계어, 상수 풀 일부
+--------------------------+
낮은 주소

Mermaid로 정리:

flowchart TB
  subgraph addr["가상 주소 공간(개념)"]
    S[스택: 지역·프레임·반환 주소]
    H[힙: malloc/free·수명은 프로그래머 책임]
    B[BSS: 비초기화 static/global]
    D[Data: 초기화 static/global]
    R[RO: const·리터럴·읽기 전용]
    T[Text: 실행 코드]
  end
  S --> H
  H --> B
  B --> D
  D --> R
  R --> T

코드(Text) 는 보통 읽기·실행, BSS/데이터 는 전역·정적, malloc 계와 메타데이터, 스택 은 프레임마다 쌓이는 지역·인자. “큰 버퍼는 힙으로” 같은 팀 룰은 여기서 옵니다. 서버 쪽 말씀드리면, “스택 오버플로는 그 스레드 한도”라는 점, 멀티스레드 장애 때 유용하세요.


5. 스택: 지역·프레임·스택 오버플로

지역은 프레임 안, 수명은 블록 끝까지. 스택은 유한하니, 깊은 재귀·입력에 묶인 VLA(가변 길이 배열) 는 위험합니다. 서비스 코드는 상한 두고, 넘기면 에러 아니면 쪽으로 빼죠.

void risky(const char *path) {
    /* VLA는 스택을 크게 먹을 수 있음 — 입력 의존 크기면 특히 위험 */
    size_t n = read_size_from_untrusted(path); /* 가정: 외부 입력 */
    char buf[n]; /* VLA: 플랫폼/컴파일러에 따라 한도 초과 시 UB */
    (void)buf;
}

한 줄로: 스택은 자동·저비용·한도, 힙은 자유·실패·단편화·동기화·버그가 늘어남. 이건 기본기예요, 선생님 말씀처럼 외우시면 됩니다.


6. 힙: malloc·calloc·realloc (래퍼 안에서만 보자)

malloc(size) 는 연속 공간, max_align_t 정렬, 내용은 미초기화— 이전 흔적이 남을 수 있어요, 0이라 가정 하면 망입니다.

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

int main(void) {
    int *p = (int *)malloc(sizeof *p * 4);
    if (!p) {
        perror("malloc");
        return 1;
    }
    /* 반드시 초기화 */
    for (int i = 0; i < 4; ++i) {
        p[i] = i;
    }
    free(p);
    return 0;
}

calloc(nmemb, size) 는 곱 오버플로를 API 차원에서 줄이려는 설계(의미는 “0으로 채운 배열”). 민감 데이터 “보안”을 대체하진 않는다… FAQ에도 썼듯 착각 금지이십니다.

realloc제자리 확장 시도, 실패하면 이동·옛 ptr 무효입니다. p = realloc(p, n)실패 시 누수 클래식— 임시 포인터 패턴 쓰세요(래퍼로 박으면 팀이 안 헷갈림).

/* 위험: 실패 시 기존 블록 누수 + ptr 손실 */
/* p = realloc(p, newsize); */

void *tmp = realloc(p, newsize);
if (!tmp) {
    perror("realloc");
    return -1;
}
p = tmp;

할당 실패 는 서버/CLI/임베디드 모두 현실입니다. 널 체크, 상위에 에러, 부분 할당 롤백… 아래 IntVec 처럼 free(v) 를 잊지 마세요(여기도 래퍼 xmalloc + 로깅을 얹는 팀이 많아요, 저는 그렇게 씁니다).

typedef struct {
    int *data;
    size_t len;
} IntVec;

IntVec *intvec_create(size_t n) {
    IntVec *v = (IntVec *)malloc(sizeof *v);
    if (!v) {
        return NULL;
    }
    v->data = (int *)malloc(sizeof *v->data * n);
    if (!v->data) {
        free(v); /* 부분 할당 정리 */
        return NULL;
    }
    v->len = n;
    return v;
}

void intvec_destroy(IntVec *v) {
    if (!v) {
        return;
    }
    free(v->data);
    free(v);
}

aligned_alloc특수 정렬이 필요하면 표준/플랫폼 API를, 잘못 가정하면 SIMD·DMA가 때릴 수 있습니다.

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

int main(void) {
    void *p = aligned_alloc(64, 1024);
    if (p == NULL) {
        perror("aligned_alloc");
        return 1;
    }
    free(p);
    return 0;
}

7. 단편화·메타데이터(힙의 현실)

할당기는 자유 블록을 리스트·버디·TLSF 등으로 관리합니다. 잦은 작은 malloc/free 는 내부/외부 단편화를 키우고, “빈 건 있는데 malloc 이 실패”가 나올 수도 있죠. 완화는 아레나(한 덩이 버림), 배치, — GC 없는 C에선 아키텍처에 가깝습니다. (그래서 앞서 말한 할당 래퍼에 “이 경로는 아레나만”을 박는 팀도 있어요.)


8. ASan·Valgrind·malloc 통계

AddressSanitizer: shadow로 OOB·UAF·이중 free 를 잡습니다, CI에 -fsanitize=address 넣죠.

clang -fsanitize=address -g -O1 main.c -o main
./main

Valgrind Memcheck 은 느리지만 기준선— 미초기화 읽기, 잘못된 free, 누수. 야간 배치에도 어울립니다.

valgrind --leak-check=full --track-origins=yes ./a.out

malloc_stats / mallopt 등은 glibc 쪽 이식이 약해도, “힙이 왜 이 크기냐” 서버 점검 한 번은 도움 될 때가 있어요. 문서와 실제 출력을 꼭 대조하세요.


9. 풀·아레나·스레드 로컬/대체 할당기

같은 크기를 아주 많이 쓰면, 큰 덩이 + 자유 리스트(슬랩) 이 단편화·속도 둘 다 나을 수 있습니다(스레드 안전·정렬·ASan과의 관계는 설계 몫). 요청 한 틱 안에만 쓰는 객체면, malloc수십 번 대신 아레나에서 bump 하고 끝에 통째로 버리는 HTTP/게임/파서 패턴도 흔해요.

typedef struct {
    char *base;
    size_t cap;
    size_t off;
} Arena;

void *arena_alloc(Arena *a, size_t n, size_t align) {
    (void)align;
    if (!a || a->off + n > a->cap) {
        return NULL;
    }
    void *p = a->base + a->off;
    a->off += n;
    return p;
}

(프로덕션에선 정렬·오버플로·스레드 정책을 완성해야 합니다, 이건 압축 샘플이에요.) tcmalloc / jemalloc 같은 건 경합·프로파일 뷰가 바뀌니, 지표를 다시 맞출 각오.


10. 실전: 동적 vector·문자열·Person 생성

realloccapacity 키우기— 곱셈·오버플로는 항상 의심하세요.

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

typedef struct {
    int *data;
    size_t size;
    size_t cap;
} IntVec;

static int intvec_grow(IntVec *v, size_t need) {
    if (need <= v->cap) {
        return 0;
    }
    size_t ncap = v->cap ? v->cap : 8;
    while (ncap < need) {
        if (ncap > (size_t)-1 / 2) {
            return ENOMEM;
        }
        ncap *= 2;
    }
    void *p = realloc(v->data, ncap * sizeof *v->data);
    if (!p) {
        return ENOMEM;
    }
    v->data = (int *)p;
    v->cap = ncap;
    return 0;
}

int intvec_push(IntVec *v, int x) {
    if (intvec_grow(v, v->size + 1) != 0) {
        return -1;
    }
    v->data[v->size++] = x;
    return 0;
}

void intvec_free(IntVec *v) {
    free(v->data);
    v->data = NULL;
    v->size = v->cap = 0;
}

realloc 실패 시 v->data그대로— 상위가 트랜잭션처럼 원상복구를 선택할 수 있어요(이거 래퍼에 쓰면 실무자가 덜 울죠).

asprintf 있으면 경계를 아는 쪽 우선.

#define _POSIX_C_SOURCE 200809L
#include <string.h>
#include <stdlib.h>
#include <stdio.h>

int main(void) {
    const char *a = "Hello, ";
    const char *b = "world!";

    char *c = NULL;
    if (asprintf(&c, "%s%s", a, b) < 0) {
        perror("asprintf");
        return 1;
    }
    puts(c);
    free(c);
    return 0;
}

Person 부분 실패strdup 실패 시 free(p) 잊지 마세요.

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

typedef struct {
    int id;
    char *name;
} Person;

Person *person_create(int id, const char *name) {
    Person *p = (Person *)malloc(sizeof *p);
    if (!p) {
        return NULL;
    }
    p->id = id;
    p->name = strdup(name);
    if (!p->name) {
        free(p);
        return NULL;
    }
    return p;
}

void person_destroy(Person *p) {
    if (!p) {
        return;
    }
    free(p->name);
    p->name = NULL;
    free(p);
}

11. RAII 흉내·AutoFree — “래퍼”가 여기에도

C++ RAII 는 없어도, 정리를 한곳에 모을 수는 있죠. cleanup 속성 쓰는 팀도 있고, goto cleanup 도 현실 해결책입니다.

#include <stdlib.h>

typedef struct {
    void *p;
} AutoFree;

static void auto_free(AutoFree *a) {
    free(a->p);
    a->p = NULL;
}

int main(void) {
    AutoFree buf = { malloc(1024) };
    if (!buf.p) {
        return 1;
    }
    auto_free(&buf);
    return 0;
}

12. 프로파일링, 멀티스레드, 임베디드

massif 등으로 누가 많이 쓰는지, 안 풀리는지. 서버는 힙 스냅샷 곡선이 누수 감을 줍니다.

malloc스레드 안전해야 하지만 경합은 있으니, 누가 만든 걸 누가 쓰는지 정하지 않으면 레이스·UAF·이중 free한꺼번에 늘어요. 임베디드는 malloc 금지인 곳도 있고, 정적 풀·슬랩이 루트가 되곤 합니다.


13. “증상”을 스토리로: 트러블슈팅 (표 없음)

옛날, free(): invalid pointer 를 처음 본 밤이 있었습니다. 포인터가 힙이 아니라 스택을 가리키고 있었죠. ASan으로 최초 손상 지점을 잡고, safe_free + 소유권 문서로 정리했습니다.

세그폴트가 “가끔”이면 UAF·OOB·정렬·경합을 의심하죠. ASan 먼저, 경합이면 ThreadSanitizer 쪽 시선. “메모리는 줄지 않는데 서버는 무거워”— 캐시 누수, 풀 무한 성장, 핸들 누수— 힙 프로파일, 트랜잭션 경계, malloc_stats(환경에 따라)로 어느 경로가 잡아먹는지 쫓습니다.

malloc만 부하에 실패하면 단편화·한도·32비트 주소— 아레나, 64비트, 할당기 교체, 요청 경량화. 데이터가 이상하면 realloc 이동 뒤 포인터, 구조체 self 포인터까지 봐야 해요(아픕니다, 진짜).

SIMD 깨지면 malloc 정렬 가정을 다시— aligned_alloc / posix_memalign. VLA 는 힙으로 빼고 상한을— 스택 터뜨리기 전에.


14. “처리 파이프라인” — 기억용 한 장

할당·초기화·이동·공유·해제사슬입니다. 한 줄에서 안 끝나요.

flowchart LR
  A[필요 크기·수명·공유 정책 정하기] --> B[할당 API 선택: malloc/calloc/realloc/typed pool]
  B --> C[실패·오버플로·정렬 처리]
  C --> D[초기화·불변식 설정]
  D --> E[이동/별칭/스레딩·동기화]
  E --> F[해제: 단일 지점, 이중 free 방지]

15. CI·프로덕션에 묻는 질문 (표 대신)

PR에서 ASan/Valgrind가 빨간불을 켤 수 있느냐— 이거 없으면 저는 찜찜해요, 솔직히. realloc임시 포인터가 팀 규칙인가요? calloc의 곱·경계, asprintf 같은 API 쓰는데 이식 fallback이 있죠? 고부하 경로엔 아레나/풀/할당기 실측있죠? 아니면 “나중에”죠— 그 “나중에”가 느린 서버로 돌아올 때가 있습니다. 책상에 앉은 질문이 아니라, on-call 에 다시 떠오를 질문이에요.


16. 요약

동적 메모리는 “주소 하나”가 아니라 할당기 상태수명·별칭·스레딩이 얽힌 문제입니다. 누수는 느리게 죽이고, UAF/이중 free늦게 터뜨립니다. malloc을 소스에 박는 대신래퍼에 모으시고, AddressSanitizer·Valgrind로 가설 증명하세요. 끝.

다음: #10 전처리·컴파일·어셈블·링크


자주 묻는 질문 (FAQ)

Q. callocmalloc+memset보다 안전한가요?

A. 0 초기화가 요구사항이라면 calloc이 의도를 분명히 드러내고, 구현이 곱셈에서 실패를 조기에 잡는 이점이 있을 수 있습니다. 그러나 기밀 데이터 보호를 대체하거나, 경쟁적 공격 지점을 단독으로 막는다고 보기는 어렵습니다.

Q. 커스텀 malloc 풀은 언제 쓰나요?

A. 소량 크기·고빈도 할당, 실시간성, 단편화 완화가 요구될 때입니다. 대신 스레드 안전정렬관측 도구 호환을 설계에 포함해야 합니다.

Q. 이 글의 예제를 실무에 바로 써도 되나요?

A. 교육용 최소 예제이므로(에러 정책, 정렬, OS별 이식) 그대로 복붙하기보다 팀의 코딩 가이드에 맞게 보강하십시오.


같이 보면 좋은 글 (내부 링크)

이 주제와 연결되는 다른 글입니다.


이 글에서 다루는 키워드 (관련 검색어)

C, malloc, 메모리 관리, AddressSanitizer, 아레나 할당 등으로 검색하시면 이 글이 도움이 됩니다.