[C언어 50강] 32강. 메모리 구조: stack/heap/data/text, 생명주기(lifetime)

[C언어 50강] 32강. 메모리 구조: stack/heap/data/text, 생명주기(lifetime)

C언어를 어느 정도 배우면 문법보다 더 중요한 질문이 생깁니다. "이 변수는 메모리 어디에 올라가고, 언제까지 유효한가?" 오늘은 바로 그 질문에 답하는 시간입니다. stack, heap, data, text 영역을 단순 암기가 아니라 프로그램 생명주기 관점에서 연결해 이해해보겠습니다. 포인터 버그, 초기화 버그, 성능 이슈를 줄이려면 결국 이 지도가 머릿속에 있어야 합니다.


핵심 개념

  • C 프로그램의 메모리는 역할에 따라 text/data/bss/heap/stack 같은 영역으로 구분된다.
  • 변수의 "유효 기간(lifetime)"은 저장 위치와 저장 클래스에 의해 결정되며, 스코프(scope)와는 구분해서 봐야 한다.
  • 버그의 상당수는 값 자체의 문제보다 "이미 죽은 객체를 참조"하거나 "아직 준비되지 않은 영역을 신뢰"하면서 발생한다.

개념 먼저 이해하기

먼저 큰 그림부터 잡아보겠습니다. C 실행 파일은 디스크에 저장되어 있다가 실행되면 운영체제가 프로세스를 만들고, 코드와 데이터를 가상 메모리 공간에 배치합니다. 이때 기계어 명령은 주로 text(코드) 영역에, 초기화된 전역/정적 변수는 data 영역에, 0으로 초기화되는 전역/정적 변수는 bss 영역에 놓입니다. 그리고 런타임 중 동적으로 늘고 줄어드는 객체는 heap, 함수 호출 문맥은 stack을 중심으로 관리됩니다.

중요한 오해 하나: 많은 초급자가 "스코프가 끝나면 메모리도 즉시 사라진다"라고만 기억합니다. 절반만 맞는 말입니다. 블록 스코프를 가진 자동 변수(int x;)는 보통 stack 프레임과 함께 수명이 끝나는 게 맞습니다. 그러나 정적 저장 기간을 가진 변수(static int x;, 전역 변수)는 블록을 벗어나도 프로그램 종료까지 살아 있습니다. 즉, 스코프는 "이름을 접근할 수 있는 범위"이고, lifetime은 "객체가 실제로 존재하는 기간"입니다. 이름은 못 보지만 객체는 살아 있을 수 있고, 반대로 이름은 남아도 객체는 이미 죽어 있을 수 있습니다(대표적으로 댕글링 포인터).

stack을 함수 호출의 작업대라고 생각하면 이해가 쉽습니다. 함수가 호출될 때 매개변수, 반환 주소, 지역 변수 등이 쌓이고 함수가 끝나면 한 번에 정리됩니다. 빠르고 규칙적이라 CPU 친화적입니다. 대신 함수가 끝난 뒤 그 주소를 계속 쓰면 안 됩니다. return &local;이 위험한 이유가 여기 있습니다. 주소는 숫자로 남아 보이지만, 그 메모리는 이미 다른 호출이 재사용할 수 있는 상태입니다.

heap은 런타임에 개발자가 요청해서 받는 창고입니다. 필요한 크기를 동적으로 확보할 수 있고 함수 경계를 넘어 데이터 생존 기간을 설계할 수 있습니다. 대신 관리 책임이 개발자에게 있습니다. malloc으로 얻은 메모리는 누가 언제 free할지 계약이 필요합니다. 이 계약이 흐려지면 누수, use-after-free, double free가 연쇄적으로 발생합니다. 즉 heap의 핵심은 "자유"가 아니라 "책임"입니다.

data/bss 영역은 프로그램 전체 상태를 담는 장소입니다. 전역 변수, static 변수는 여기서 시작해서 종료까지 유지됩니다. 그래서 편하지만, 상태 공유가 쉬운 만큼 결합도가 높아지고 테스트가 어려워집니다. 멀티스레드 환경에서는 동기화 문제까지 따라옵니다. 실무에서 전역 상태를 최소화하려는 이유는 단순 취향이 아니라 유지보수 비용과 버그 확률을 낮추기 위해서입니다.

text 영역은 코드를 담는 읽기 전용 성격의 영역입니다(환경에 따라 보호 방식은 다름). 문자열 리터럴도 보통 읽기 전용 메모리에 놓입니다. 그래서 char *s = "abc"; s[0] = 'A'; 같은 코드는 정의되지 않은 동작을 유발할 수 있습니다. 반면 char s[] = "abc";는 배열 복사본이 만들어져 수정이 가능합니다. 같은 "문자열"처럼 보이지만 저장 위치와 수정 가능성이 다르다는 점이 핵심입니다.

결국 메모리 구조를 공부하는 목적은 지도 암기가 아닙니다. 어떤 객체가 어디서 태어나고, 누가 소유하고, 언제 죽는지를 일관되게 추론하는 능력을 만드는 것입니다. 이 추론이 되면 디버깅이 빨라지고, 함수 인터페이스 설계도 좋아집니다. "이 포인터를 반환해도 되는가?", "정적 변수로 캐시를 둬도 되는가?", "전역으로 두면 테스트가 깨지지 않는가?" 같은 질문에 근거 있게 답할 수 있게 됩니다.

기본 사용

예제 1) 저장 영역과 lifetime 비교

#include <stdio.h>

int g_init = 10;          // data 영역(초기화됨), 프로그램 종료까지 생존
int g_zero;               // bss 영역(0 초기화), 프로그램 종료까지 생존

void counter(void) {
    static int s_count = 0; // data/bss 계열, 함수 호출 간 값 유지
    int local = 0;          // stack, 호출마다 새로 생성/소멸

    s_count++;
    local++;

    printf("s_count=%d, local=%d\n", s_count, local);
}

int main(void) {
    counter();
    counter();
    counter();
    printf("g_init=%d, g_zero=%d\n", g_init, g_zero);
    return 0;
}

설명:

  • local은 매 호출마다 0에서 시작하므로 출력이 항상 1입니다.
  • s_count는 정적 저장 기간이므로 호출 간 상태를 기억합니다.
  • 같은 함수 내부 변수라도 저장 기간에 따라 동작이 완전히 달라집니다.

예제 2) stack 주소 반환 금지 vs heap 반환 허용

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

/* 잘못된 예: 지역 배열 주소 반환 (절대 금지) */
char *bad_make_name(void) {
    char buf[32] = "devlab";
    return buf; // 경고 대상: 함수 종료 후 무효 주소
}

/* 올바른 예: heap 할당 후 반환, 호출자가 free 책임 */
char *good_make_name(void) {
    char *buf = (char *)malloc(32);
    if (!buf) return NULL;
    strcpy(buf, "devlab");
    return buf;
}

int main(void) {
    char *name = good_make_name();
    if (!name) return 1;

    printf("name=%s\n", name);
    free(name);
    name = NULL;
    return 0;
}

설명:

  • bad_make_namebuf는 stack 객체라 함수 종료와 함께 lifetime이 끝납니다.
  • good_make_name은 heap 객체를 반환하므로 호출자가 생명주기를 이어받아 관리할 수 있습니다.
  • 함수 인터페이스 설계에서 "누가 해제하는가"를 문서화해야 실무 버그가 줄어듭니다.

예제 3) 문자열 리터럴과 배열의 차이

#include <stdio.h>

int main(void) {
    const char *p = "hello"; // 읽기 전용 영역을 가리킬 가능성 높음
    char a[] = "hello";      // stack의 수정 가능한 배열 복사본

    /* p[0] = 'H'; */         // 금지: UB 가능
    a[0] = 'H';

    printf("p=%s\n", p);
    printf("a=%s\n", a);
    return 0;
}

설명:

  • 리터럴은 "값"이지 "수정 가능한 버퍼"가 아닙니다.
  • 수정이 필요하면 배열 또는 동적 버퍼로 별도 저장소를 확보해야 합니다.
  • const를 적극 사용하면 의도하지 않은 쓰기를 컴파일 단계에서 차단할 수 있습니다.

예제 4) heap 소유권과 cleanup 패턴

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

int build_buffer(size_t n, int **out) {
    int *buf = NULL;
    size_t i;

    if (!out || n == 0) return -1;

    buf = (int *)malloc(n * sizeof(int));
    if (!buf) return -2;

    for (i = 0; i < n; i++) buf[i] = (int)i;

    *out = buf;  // 소유권 전달
    return 0;
}

int main(void) {
    int *arr = NULL;
    if (build_buffer(8, &arr) != 0) {
        fprintf(stderr, "build failed\n");
        return 1;
    }

    printf("arr[3]=%d\n", arr[3]);
    free(arr);
    arr = NULL;
    return 0;
}

설명:

  • out-parameter로 소유권 전달을 명확히 표현했습니다.
  • 실패 시 *out을 건드리지 않는 규약을 유지하면 호출부가 단순해집니다.
  • lifetime 설계를 함수 경계에서 분명히 하는 것이 메모리 안전성의 핵심입니다.

자주 하는 실수

실수 1) scope와 lifetime을 같은 개념으로 착각

  • 원인: "중괄호 벗어나면 끝"이라는 단순 규칙만 암기.
  • 해결: 변수마다 저장 기간(automatic/static/dynamic)을 먼저 적고 코드를 읽는다.

실수 2) 지역 변수 주소를 반환하거나 전역에 저장

  • 원인: 주소값은 숫자니까 계속 쓸 수 있다고 오해.
  • 해결: 함수 종료 후 지역 객체는 무효라는 규칙을 절대 예외 없이 적용.

실수 3) 문자열 리터럴을 수정 가능한 버퍼로 사용

  • 원인: char * 문법만 보고 쓰기 가능하다고 판단.
  • 해결: 리터럴 포인터는 const char *로 선언하고, 수정 시 배열/heap 복사본 사용.

실수 4) heap 메모리 소유자 불명확

  • 원인: "누가 free할지"를 코드/주석에 남기지 않음.
  • 해결: API 단위로 owner를 명시하고, 반환/전달 규약을 팀 규칙으로 고정.

실무 패턴

  • 수명 표기 습관: 코드 리뷰에서 포인터가 등장하면 owner와 lifetime 코멘트를 반드시 확인.
  • 전역 상태 최소화: 테스트/병렬 실행을 위해 전역 대신 컨텍스트 구조체 전달 선호.
  • const 기본값화: 읽기 전용 데이터는 가능한 한 const로 잠궈 쓰기 사고 예방.
  • 컴파일 경고 강화: -Wall -Wextra -Wpedantic -Werror로 lifetime 의심 신호를 조기 발견.
  • 도구 병행: ASan/UBSan으로 "죽은 메모리 접근"을 런타임에서 빠르게 적발.

오늘의 결론

한 줄 요약: 메모리 구조를 안다는 건 영역 이름을 외우는 것이 아니라, 객체의 탄생·소유·소멸을 추적해 코드의 생명주기를 설계하는 능력이다.

연습문제

  1. 전역 변수, static 지역 변수, 자동 지역 변수의 출력 변화를 각각 비교하는 프로그램을 작성하고 차이를 설명해보세요.
  2. 지역 배열 주소를 반환하는 잘못된 함수를 heap 기반으로 리팩터링하고, 호출자 해제 책임을 문서화해보세요.
  3. 문자열 리터럴 수정 시도 코드를 작성해 경고/동작을 관찰한 뒤, const char * + 복사본 방식으로 안전하게 고쳐보세요.

이전 강의 정답

  • 31강 연습문제 1(dup_text 구현):
    • malloc(strlen(s) + 1)로 널 문자를 포함한 크기를 확보하고 strcpy 또는 memcpy로 복사한 뒤 반환합니다. 핵심은 "반환된 포인터는 호출자가 free"라는 소유권 계약을 함수 주석에 명시하는 것입니다.
  • 31강 연습문제 2(중간 실패 cleanup 구조):
    • 자원 획득 순서대로 포인터를 NULL로 초기화하고, 실패 시 단일 cleanup: 레이블에서 역순 해제를 수행하면 누수와 더블 프리를 동시에 막을 수 있습니다.
  • 31강 연습문제 3(ASan으로 UAF 확인):
    • free(p)p[0] 접근 코드를 만든 뒤 -fsanitize=address로 실행하면 heap-use-after-free 리포트에서 접근 위치, 할당 위치, 해제 위치가 함께 출력됩니다. 이 3개 스택 트레이스를 연결해 원인을 좁히는 연습이 핵심입니다.

실습 환경/재현 정보

  • 컴파일러: Apple clang 17+ 또는 gcc 13+
  • 컴파일 옵션(기본): -std=c11 -Wall -Wextra -Wpedantic -O0 -g
  • Sanitizer 옵션(권장): -fsanitize=address,undefined -fno-omit-frame-pointer
  • 실행 환경: macOS(arm64) 터미널 또는 Linux 셸
  • 재현 체크:
    1. 경고 없이 빌드되는지 확인
    2. stack/heap/static 예제의 출력 차이가 의도대로 나오는지 확인
    3. 금지된 메모리 접근 주석을 풀어보며 sanitizer 리포트를 직접 확인
    4. 해제 후 NULL 대입 습관이 호출부 안전성에 도움 되는지 점검