Devin.KR

C · 심화

포인터·메모리 안전·시스템 프로그래밍

자원 소유권과 정리 패턴 - 누가 free 하는가

소유권 규칙을 주석·이름으로 드러내기, 생성/파괴 함수 짝, goto cleanup 단일 탈출, 부분 실패 시 롤백, 이중 해제 방지

개발자KR · 원고 갱신

이 장에서 배우는 것

  • 함수가 여러 개의 동적 자원을 순차적으로 할당할 때 실패 경로에서 발생하는 메모리 누수(Memory Leak)를 근본적으로 막는 방법
  • goto 문을 구조적으로 활용하여 자원 해제 구문을 한 곳으로 모으는 단일 탈출구(Single Exit Point) 정리 패턴
  • 자원의 소유권(Ownership)을 명확히 정의하고, 객체의 생성(Create)과 파괴(Destroy) 함수를 일관되게 짝지어 설계하는 소프트웨어 공학적 규칙
  • 이중 해제(Double Free)와 잘못된 순서의 메모리 해제로 인해 발생하는 세그멘테이션 결함(Segmentation Fault) 및 치명적 오류를 예방하는 안전한 파괴 기법

문제 상황

C 프로그래밍을 하다 보면 함수 하나가 여러 개의 독립적인 자원을 동시에 요구하는 상황을 흔히 마주한다. 앞 장에서 문자열과 길이를 안전하게 다루며 연습했던 도서관 대출 관리 프로그램을 더 큰 규모의 시스템으로 확장해 보자. 이 시스템은 단순한 실습용 프로그램이 아니라 데이터베이스 서버처럼 24시간 내내 실행되며, 사용자 요청에 따라 수십만 권의 책 정보를 동적 메모리에 올렸다가 내리기를 끊임없이 반복해야 한다. 한 권의 책 객체를 메모리에 온전히 등록하여 활성화하려면 뼈대가 되는 구조체 자체의 메모리뿐만 아니라, 다양한 길이를 가질 수 있는 제목 문자열과 저자 이름 문자열을 담기 위한 힙 메모리를 각각 따로 할당받아야 한다.

이처럼 여러 단계에 걸쳐 연속적으로 메모리를 할당할 때 우리가 흔히 간과하는 점은, 메모리 할당 함수인 malloc이 항상 성공을 보장하지 않는다는 사실이다. 시스템의 여유 메모리가 부족해지거나, 메모리 단편화(Fragmentation)가 심해져 요청한 크기의 연속된 빈 공간을 찾을 수 없다면 malloc은 즉시 NULL 포인터를 반환한다. 만약 책 구조체(첫 번째 자원)와 제목 문자열(두 번째 자원)까지는 성공적으로 메모리를 확보했는데, 마지막으로 저자 이름을 할당하는 순간(세 번째 자원) 실패했다면 우리는 어떻게 대처해야 할까?

가장 흔히 떠올리는 직관적인 해결책은 오류를 확인한 그 즉시 함수 실행을 중단하고 NULL이나 오류 코드를 반환하는 조기 반환(Early Return) 방식이다. 하지만 아무런 후속 조치 없이 함수를 종료해 버리면 끔찍한 결과가 초래된다. 함수가 반환되면서 함수 내부에 선언되어 있던 지역 포인터 변수들은 스택 영역에서 소멸해 버린다. 그러나 이 포인터들이 가리키고 있던 힙 영역의 구조체와 제목 문자열 메모리는 운영체제에 반환되지 않은 채 쓰레기처럼 남게 된다. 이제 그 누구도 해당 메모리 공간의 주소를 알지 못하므로 프로그램이 종료될 때까지 영구히 회수할 수 없는 '메모리 누수(Memory Leak)' 상태가 되는 것이다. 단 한 번의 실패 경로에서 발생한 100바이트의 누수라도, 이것이 서버처럼 오래 실행되는 프로그램에서 반복적으로 누적되면 결국 시스템의 전체 메모리를 고갈시키고 운영체제의 강제 종료(OOM Killer)를 유발하는 치명적인 장애로 이어진다.

조기 반환 시 발생하는 메모리 누수 메커니즘을 보여주는 그림.

이러한 메모리 누수를 완벽하게 방지하려면, 함수가 반환되기 전에 앞서 할당에 성공했던 모든 자원을 꼼꼼하게 추적하여 역순으로 직접 해제해 주어야 한다. 그러나 하나의 함수에서 할당해야 할 자원의 개수가 3개, 4개로 늘어날수록 문제는 심각해진다. 모든 실패 분기점마다 알맞은 개수의 free 함수 호출을 빠짐없이 적어 넣어야 하기 때문이다. 개발자의 작은 실수로 free 호출을 하나라도 누락하면 누수가 발생하며, 반대로 이미 해제한 메모리를 중복해서 해제하면 프로그램이 그 즉시 비정상 종료된다. 실무 수준의 견고한 시스템 프로그래밍에서는 이런 복잡한 예외 상황들을 과연 어떻게 우아하고 안전하게 묶어서 처리하는지 깊이 있게 살펴보자.

자원 소유권과 명시적 짝짓기 규칙

동적 메모리를 비롯하여 파일 디스크립터, 네트워크 소켓 같은 한정된 시스템 자원을 안전하게 다루려면 프로그래머는 항상 근본적인 질문을 던져야 한다. "이 자원은 언제 어디서 만들어지며, 최종적으로 누구에게 지울 책임이 있는가?" 이를 시스템 프로그래밍에서는 자원 소유권(Resource Ownership) 규칙이라고 부른다. 가비지 컬렉터(Garbage Collector)가 존재하는 현대의 고수준 언어들은 런타임 환경이 소유권을 묵시적으로 관리해 주지만, C 언어에서는 컴파일러나 실행 환경이 소유권을 추적하거나 검증해 주지 않는다. 따라서 오직 개발자 스스로가 함수 이름 짓기, 주석, 그리고 일관된 코드 구조를 통해 자원의 소유권을 코드 위에 명백하게 선언하고 드러내야만 한다.

복잡한 시스템에서 객체의 생명주기를 관리하는 가장 확실하고 널리 쓰이는 규칙은, '객체를 새롭게 만들어내는 함수(Create)'와 '객체를 철저히 파괴하는 함수(Destroy)'를 반드시 한 쌍으로 짝지어 설계하고 제공하는 것이다. 예를 들어 book_create라는 함수가 힙 메모리에 새로운 도서 구조체를 정성껏 조립하여 그 포인터를 반환한다고 하자. 이때 반환된 포인터를 건네받은 호출자(Caller)는 그 순간부터 해당 객체의 유일한 '소유자(Owner)'가 된다. 주인이 된 호출자는 객체를 마음껏 사용한 뒤, 객체의 수명이 완전히 다한 시점에 반드시 자신이 짝이 맞는 book_destroy 함수를 직접 호출하여 자원을 반납해야 할 엄중한 의무를 진다.

이러한 소유권 명시 규칙을 철저히 지키면 프로그램의 설계가 놀라울 정도로 깔끔해진다. 도서 구조체 내부에 문자열 포인터가 몇 개나 들어있는지, 동적 배열이 얼마나 복잡하게 얽혀서 할당되었는지와 같은 지루한 내부 구현 사정은 객체를 사용하는 호출자가 전혀 알 필요가 없다. 호출자는 오직 book_create로 생성하고 book_destroy로 소멸시킨다는 단순명료한 인터페이스만 준수하면 되므로, 코드의 결합도가 낮아지고 대규모 협업에서도 메모리 관련 버그가 획기적으로 줄어들게 된다.

함수 매개변수와 반환값 규약에 따른 메모리 소유권의 위치
API 설계 규약 (명명/주석) 실질적 소유권 위치 호출자가 반드시 지켜야 할 행동 지침
새 객체 포인터를 반환 (create / alloc) 함수 호출자 (Caller) 객체 사용이 완전히 종료된 후, 반드시 짝을 이루는 파괴(destroy / free) 함수를 명시적으로 호출해야 한다.
내부 포인터를 임시로 반환 (get / peek) 객체 관리자 (피호출자) 반환받은 포인터를 읽기 용도로만 사용하며, 절대로 직접 free를 호출해 메모리를 해제해서는 안 된다.
포인터를 매개변수로 넘겨 저장 (attach / set) 객체 관리자 (피호출자) 함수 호출과 동시에 소유권이 내부로 완전히 이전되었으므로, 호출자는 이후에 해당 포인터를 해제할 권한을 잃는다.
동적 메모리를 설계하고 다룰 때 숙련된 프로그래머는 '성공했을 때 무엇을 할까'보다 '실패했을 때 어떻게 원래 상태로 흠집 없이 되돌릴까'를 먼저 고민한다.

화살표 안티 패턴과 중첩 조건문의 한계

자원 소유권의 경계를 명확히 긋더라도, 함수 내부에서 여러 자원을 차례차례 할당하다가 중간에 실패하는 이른바 '부분 실패(Partial Failure)' 상황은 함수가 스스로 온전히 정리(Rollback)하고 깨끗한 상태로 반환해야 한다. 앞서 조기 반환의 위험성을 살펴보았으니, 이번에는 철저한 검사를 위해 모든 자원 할당 코드를 if 문으로 겹겹이 감싸는 방식을 생각해 볼 수 있다. 첫 번째 자원을 할당하고 성공하면 중괄호를 열어 두 번째 자원을 할당하고, 성공하면 다시 중괄호를 열어 세 번째 자원을 할당하는 식이다. 실패 시에는 else 블록을 이용해 순차적으로 메모리를 해제한다.

이러한 중첩 조건문 방식은 논리적으로는 완벽하게 누수를 막을 수 있지만, 치명적인 단점이 있다. 코드가 끝없이 오른쪽으로 파고드는 형태가 되어 이른바 '화살표 안티 패턴(Arrow Anti-Pattern)'을 만들어낸다. 들여쓰기가 과도하게 깊어지면 한 화면에 코드의 핵심 로직이 들어오지 않으며, 유지보수 과정에서 새로운 자원 할당이 단 하나만 추가되어도 전체 블록의 들여쓰기를 수정하고 복잡한 else 구조를 재배치해야 하는 고통을 수반한다. 가독성과 확장성이 극도로 훼손되는 것이다. 논리는 맞을지언정 인간이 읽고 유지보수하기에는 최악의 형태라 할 수 있다.

오류 발생 시 부분 롤백 처리 패턴 비교
오류 처리 패턴 주요 장점 치명적 단점 및 한계
다중 중첩 if 문 운영체제의 흐름 제어를 우회하지 않아 논리적 구조가 직관적이고 명시적이다. 할당 단계가 늘어날수록 들여쓰기가 깊어져 이른바 '화살표 패턴'이 발생하며 가독성이 파괴된다.
단순 조기 반환 (Early Return) 오류 조건을 확인한 후 즉시 반환하므로 불필요한 들여쓰기를 방지할 수 있다. 반환하기 전의 모든 분기마다 앞서 성공한 자원들을 일일이 해제해야 하므로 실수가 잦고 중복 코드가 양산된다.
단일 탈출구 (goto cleanup) 복잡한 에러 처리와 자원 정리 코드가 함수 맨 밑의 단 한 곳으로 깔끔하게 모인다. 과거의 유물로 취급되는 goto 문을 사용하므로, 무분별하게 남용할 경우 코드의 실행 흐름을 꼬이게 할 위험이 존재한다.

단일 탈출구와 goto cleanup 패턴

조기 반환의 메모리 누수 위험과 중첩 조건문의 가독성 파괴 문제를 동시에 우아하게 해결하기 위해, 리눅스 커널 운영체제나 고성능 웹 서버 프로젝트와 같은 대규모 시스템 소프트웨어에서 십수 년간 표준처럼 굳어진 기법이 바로 goto cleanup 패턴이다. 이 패턴의 철학은 매우 단순하다. 함수가 성공적으로 임무를 마치든, 중간에 치명적인 오류를 만나 실패하든 함수를 빠져나가는 최종 출구(Exit Point)를 단 하나로 강제하여 통일하는 것이다.

구현 방법은 간단하다. 함수의 맨 마지막 부분, 즉 정상적인 return 구문 바로 아래에 자원을 정리하기 위한 cleanup: (또는 error:) 레이블을 작성하고 그곳에 모든 자원의 해제 코드를 차례대로 모아 둔다. 함수 진행 중 어느 단계에서든 메모리 할당에 실패하거나 파일을 여는 데 실패하면, 그 즉시 goto cleanup; 명령을 호출하여 실행 흐름을 함수의 맨 끝에 마련해 둔 레이블로 단숨에 점프시킨다. 이 방식을 쓰면 부분 롤백 로직이 함수 하단부 한 곳에 응집되므로, 추후에 새로운 자원 할당 로직이 추가되거나 삭제되더라도 각 분기점의 로직을 일일이 건드릴 필요 없이 정리 구문만 수정하면 되어 실수할 확률이 극적으로 줄어든다.

과거 구조적 프로그래밍의 선구자들은 스파게티 코드를 유발한다는 이유로 goto 문의 사용을 강력히 금지했다. 하지만 자원을 확실하게 정리해야 하거나 깊이 중첩된 다중 반복문을 단번에 빠져나올 때 사용하는 이처럼 제한적이고 규칙적인 goto 문은, 오히려 현대 시스템 프로그래밍에서 코드의 복잡성을 크게 낮추고 가독성과 안정성을 극대화하는 가장 현실적이고 훌륭한 도구로 인정받고 있다.

단계별 자원 할당 흐름과 실패 시 goto cleanup 레이블로 이동하여 일괄 처리되는 과정도.

NULL 초기화의 마법과 롤백의 완성

goto cleanup 패턴이 완벽하게 톱니바퀴처럼 맞물려 돌아가려면, 함수 하단에 위치한 정리 구문(cleanup block)이 함수 실행 중 어느 시점에 불려오더라도 프로그램이 강제 종료되지 않고 묵묵히 제 할 일을 하도록 방어적으로 설계되어야 한다. 만약 첫 번째 자원 할당 자체에서 실패하여 곧바로 정리 구문으로 점프했다면 어떨까? 아직 두 번째와 세 번째 자원은 메모리 할당 시도조차 하지 않았기 때문에 포인터 변수 안에는 스택의 쓰레기값(Garbage Value)이 들어 있을 수 있다. 이 쓰레기값을 그대로 free 함수에 넘겨 메모리를 해제하려 시도하면 프로그램은 즉시 세그멘테이션 결함으로 붕괴하고 만다.

이 문제를 해결하는 열쇠는 바로 C 표준 라이브러리가 보장하는 free 함수의 특별한 성질과 사전 초기화에 있다. C 언어 표준 규격에 따르면, free 함수는 인자로 NULL 포인터가 들어오면 어떠한 오류도 발생시키지 않고 아무런 일도 하지 않은 채 조용히 반환되도록 엄격하게 약속되어 있다. 이 성질을 십분 활용하면 롤백 코드를 믿을 수 없을 만큼 깔끔하게 짤 수 있다. 함수가 시작되는 도입부에 모든 포인터 변수를 선언함과 동시에 NULL로 꼼꼼히 초기화해 두는 것이다.

이렇게 방어선을 구축해 두면, 중간에 어떤 자원의 할당이 실패해서 갑작스럽게 cleanup으로 뛰어넘어가더라도 걱정할 필요가 없다. 할당에 성공했던 포인터들은 유효한 힙 주소를 가지고 있으니 정상적으로 메모리가 해제될 것이고, 아직 할당 단계에 진입하지 못했던 포인터들은 초기화 상태인 NULL을 그대로 간직하고 있으므로 free 함수가 이를 부드럽게 무시하고 지나가게 된다. 덕분에 정리 구문 안에 복잡한 if (ptr != NULL) 같은 조건 확인 코드를 덕지덕지 붙일 필요 없이, 그저 해제해야 할 자원들을 역순으로 나란히 나열하는 것만으로 완벽한 부분 롤백 로직이 완성된다.

완성 코드

지금까지 학습한 자원 소유권의 개념, 명시적인 객체 생성 및 파괴 규칙, 단일 탈출구(goto cleanup) 패턴, 그리고 방어적인 NULL 초기화 기법을 모두 집대성하여 작은 도서관의 책 객체를 동적 메모리에 등록하고 안전하게 정리하는 전체 프로그램을 작성해 보자. 메모리 할당 실패 상황을 눈으로 확인하기 위해, 저자 이름에 "FAIL"이라는 특수한 문자열을 입력하면 의도적으로 할당을 취소하고 goto cleanup 경로를 타도록 시뮬레이션 분기를 추가했다.

library_alloc.c

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

struct Book {
    int id;
    char *title;
    char *author;
};

/* 소유권 선언: 반환된 Book 포인터의 소유권은 호출자에게 완전히 이전된다. */
/* 해제 책임: 호출자는 사용을 마친 후 반드시 book_destroy를 명시적으로 호출해야 한다. */
struct Book *book_create(int id, const char *title, const char *author) {
    /* 도입부: 모든 포인터를 선언 즉시 NULL로 덮어씌워 부분 롤백 시 방어선을 구축한다. */
    struct Book *b = NULL;
    char *dup_title = NULL;
    char *dup_author = NULL;

    /* 1. 객체의 뼈대가 될 구조체 자체의 메모리를 힙 영역에 할당한다. */
    b = malloc(sizeof(struct Book));
    if (b == NULL) {
        goto cleanup;
    }

    /* 2. 제목 문자열을 담을 공간 확보 (앞 장의 안전한 길이 계산 원칙 적용: 널 종료 문자 포함) */
    size_t title_len = strlen(title) + 1;
    dup_title = malloc(title_len);
    if (dup_title == NULL) {
        goto cleanup;
    }
    /* 메모리가 확실히 확보되었으므로 안심하고 문자열을 복제한다. */
    strcpy(dup_title, title);

    /* 3. 저자 문자열을 담을 공간 확보 */
    /* 의도적인 메모리 할당 실패 상황을 만들기 위한 특수 시뮬레이션 방어 코드 */
    if (strcmp(author, "FAIL") == 0) {
        goto cleanup;
    }

    size_t author_len = strlen(author) + 1;
    dup_author = malloc(author_len);
    if (dup_author == NULL) {
        goto cleanup;
    }
    strcpy(dup_author, author);

    /* 4. 성공 시 조립: 모든 자원 할당과 복제에 성공하면 비로소 구조체 멤버에 포인터를 정식으로 연결한다. */
    b->id = id;
    b->title = dup_title;
    b->author = dup_author;

    /* 완성된 객체의 소유권을 호출자에게 반환하며 정상 종료한다. */
    return b;

cleanup:
    /* 실패 시 단일 탈출구 (Single Exit Point) */
    /* 할당되지 않아 NULL을 품고 있는 변수는 free 함수가 조건문 없이 안전하게 무시한다. */
    /* 자원은 의존성의 역순으로 해제하는 것이 기본 원칙이다. */
    free(dup_author);
    free(dup_title);
    free(b);
    return NULL;
}

/* 객체 파괴 함수: 외부의 호출자가 임의로 내부 자원을 해제하게 두어선 안 되며 이 함수로만 통제한다. */
void book_destroy(struct Book *b) {
    /* 사용자가 이미 NULL이 된 포인터를 실수로 넘길 것에 대비한 방어 코드 */
    if (b == NULL) {
        return;
    }
    /* 해제 순서는 생명의 역순이다: 책 뼈대에 종속된 내부 문자열 자원부터 먼저 해제한다. */
    free(b->author);
    free(b->title);
    /* 내부를 모두 비워냈다면 마지막으로 뼈대인 책 구조체 자체를 파괴한다. */
    free(b);
}

int main(void) {
    printf("도서 1 정상 등록 프로세스 시작...\n");
    /* book1 포인터에 소유권이 안착된다. */
    struct Book *book1 = book_create(1, "시스템 프로그래밍의 정석", "저자A");
    if (book1 != NULL) {
        printf("등록 성공: [%d] %s (지은이: %s)\n", book1->id, book1->title, book1->author);
    }

    printf("\n도서 2 비정상 등록 시뮬레이션 시작...\n");
    /* 저자 이름에 FAIL을 전달하여 내부에서 할당 실패 흐름을 강제로 발생시킨다. */
    struct Book *book2 = book_create(2, "안전한 코드 작성법", "FAIL");
    if (book2 == NULL) {
        printf("등록 실패: 함수 내부에서 누수 없이 자원이 안전하게 롤백되었습니다.\n");
    }

    /* 호출자가 소유권을 잠시 쥐고 있던 자원들을 일괄적으로 운영체제에 반납한다. */
    book_destroy(book1);
    /* book2는 실패하여 NULL이지만, 파괴 함수 내부의 방어 코드 덕에 안전하게 무시된다. */
    book_destroy(book2); 

    printf("모든 자원 반납 완료. 프로그램을 종료합니다.\n");
    return 0;
}

줄별 해설

  • 15~17줄: b, dup_title, dup_author 포인터 변수들을 선언과 동시에 모두 NULL로 초기화한다. 이것은 훗날 어느 시점에서 실패가 발생하여 정리 구문으로 뛰어넘더라도 free(NULL)이 어떠한 치명적인 동작 없이 부드럽게 무시되도록 만드는 핵심 보험 장치다.
  • 27~29줄: 제목 문자열을 위한 힙 메모리 할당에 실패하면 그 즉시 goto cleanup;을 호출하여 하단으로 뛰어넘는다. 이 시점의 상태를 보면 b는 20줄에서 성공적으로 메모리가 할당된 유효 포인터 상태이고, dup_title과 dup_author는 아직 시도조차 하지 않아 NULL을 품고 있는 상태다.
  • 36~38줄: 시스템 메모리 고갈 상황을 재현하기 위해 저자명 매개변수에 "FAIL"이라는 값이 들어오면 강제로 오류를 유발하고 정리 구문으로 점프시킨다. 이는 실무에서 malloc이 연속된 빈 공간을 찾지 못해 NULL을 반환할 때 벌어지는 아찔한 상황과 메커니즘 면에서 완전히 동일하다.
  • 55~60줄: 함수가 성공할 뻔하다가 중간에 실패하여 곤두박질치듯 떨어진 cleanup 레이블 구역이다. 할당을 시도했던 순서의 정반대(역순)인 저자, 제목, 구조체 순으로 차분하게 free를 호출해 메모리 점유를 원상 복구하고 NULL을 반환한다. 만약 성공적인 실행 흐름이었다면 이 구역에 도달하기 전인 53줄에서 완성된 객체와 함께 return b;로 자랑스럽게 함수를 빠져나갔을 것이다.
  • 66~76줄: book_create와 논리적으로 완벽한 대칭을 이루는 객체 파괴 함수다. 구조체 내부의 종속적인 가변 문자열(저자, 제목)을 최우선으로 해제하고, 이 모든 것을 담고 있던 가장 겉옷 격인 구조체 포인터 b를 맨 마지막 단계에서 해제한다. 껍데기를 먼저 버리면 그 안에 든 알맹이를 꺼내거나 해제할 주소를 영영 찾을 수 없게 된다는 단순한 진리를 명심해야 한다.
  • 92~94줄: book2는 생성 과정에서 실패하여 NULL 값을 가지고 있다. 그러나 이 값을 book_destroy에 무심코 전달하더라도, 파괴 함수 첫 줄에 튼튼하게 설계해 둔 널 체크 방어 코드 덕분에 아무런 오류 메세지 없이 매우 자연스럽고 부드럽게 프로그램이 다음 단계로 진행된다.

실행 결과

도서 1 정상 등록 프로세스 시작...
등록 성공: [1] 시스템 프로그래밍의 정석 (지은이: 저자A)

도서 2 비정상 등록 시뮬레이션 시작...
등록 실패: 함수 내부에서 누수 없이 자원이 안전하게 롤백되었습니다.
모든 자원 반납 완료. 프로그램을 종료합니다.

실무에서 자주 틀리는 것

조기 반환(Early Return) 남용으로 인한 은밀한 메모리 누수

여러 개의 자원을 연속적으로 할당하는 과정에서 중간에 malloc이 실패했다고 해서 놀란 마음에 즉시 return을 호출해 버리는 것은 시스템 프로그래밍 입문자가 가장 많이 저지르는 치명적 실수다. 함수가 즉각 종료되면 스택에 있던 지역 포인터 변수는 사라지고, 그 이전에 성공적으로 할당받아 둔 자원은 누구도 접근할 수 없는 힙 영역의 미아(Orphan)이자 쓰레기로 영원히 남게 되어 시스템 메모리를 갉아먹는다.

/* 틀린 코드: 두 번째 자원 할당 실패 시 첫 번째 자원이 영원히 누수된다. */
b = malloc(sizeof(struct Book));
dup_title = malloc(title_len);
if (dup_title == NULL) {
    return NULL; /* 이 문장이 실행되는 순간 앞서 성공한 b 메모리는 영영 해제할 길이 사라진다! */
}
/* 고친 코드: 실패 시 즉시 반환하지 말고 단일 탈출구(cleanup)로 이동해 할당된 것만 안전히 정리한다. */
b = malloc(sizeof(struct Book));
if (b == NULL) goto cleanup;
dup_title = malloc(title_len);
if (dup_title == NULL) goto cleanup;

자원 해제 순서의 역전 (Use After Free 오류)

객체를 파괴할 때는 항상 '가장 깊은 곳에 묻혀 있는 내부 종속 자원'부터 우선하여 해제해야 한다. 무심코 구조체의 뼈대 자체를 먼저 free로 운영체제에 반환해 버려놓고, 한 발 늦게 구조체 내부의 멤버 포인터에 화살표 연산자(->)로 접근하려 들면 이미 시스템에 반납되어 접근 권한을 상실한 메모리를 건드리게 된다. 이는 악명 높은 Use After Free 보안 취약점의 핵심 원인이며 즉각적인 세그멘테이션 결함과 프로그램 붕괴를 초래한다.

/* 틀린 코드: 바깥쪽 껍데기를 먼저 버리면 내부 알맹이의 주소를 읽을 수 없다. */
void book_destroy(struct Book *b) {
    free(b);
    free(b->title);  /* 심각한 오류: 이미 파괴된 b 영역을 참조하므로 시스템이 죽을 수 있다. */
    free(b->author);
}
/* 고친 코드: 내부의 문자열들을 모두 끄집어내어 비운 뒤, 마지막으로 전체 구조체의 숨통을 끊는다. */
void book_destroy(struct Book *b) {
    free(b->author);
    free(b->title);
    free(b);
}

소유권 경계 침해와 이중 해제 (Double Free)

명시적인 생성 함수를 통해 탄생한 객체는 그 내부 자원까지 모두 합쳐서 거대한 하나의 단위로 취급되어야만 한다. 하지만 캡슐화와 소유권 규칙을 무시하고 메인 함수 등에서 객체 내부의 포인터를 마음대로 끄집어내어 임의로 free를 호출해 버리는 경우가 있다. 이렇게 밖에서 몰래 자원을 빼돌려 해제하면, 나중에 전체 객체가 수명을 다해 공식 파괴 함수가 호출될 때 이미 지워져 없어진 메모리 주소에 대고 다시 해제를 시도하는 무서운 이중 해제(Double Free) 런타임 오류가 발생하여 프로그램이 비명횡사하게 된다.

/* 틀린 코드: 객체의 은밀한 내부 자원에 호출자가 직접 간섭하여 소유권 계약을 위반한다. */
struct Book *b = book_create(1, "시스템", "전문가");
free(b->title); /* 소유권이 없는 외부에서 마음대로 책의 제목 부분을 뜯어 버렸다. */
book_destroy(b); /* 파괴 함수 내부에서 b->title을 무심코 다시 free하며 프로그램이 강제 종료된다! */
/* 고친 코드: 내부 포인터에 함부로 손대지 않고, 객체를 통째로 파괴 함수에만 넘겨 안전을 도모한다. */
struct Book *b = book_create(1, "시스템", "전문가");
/* 객체를 온전히 사용한 후의 해제 의무 이행 */
book_destroy(b); /* 내부 자원의 구체적인 해제 절차와 순서는 전적으로 파괴 함수가 책임진다. */

한눈에 보기

자원 해제와 오류 처리 핵심 패턴 요약
개념 및 기법 실무 적용 시 핵심 원칙과 동작 원리
소유권 명시와 짝짓기 객체를 create로 할당해 소유권을 넘겼다면, 수명이 끝날 때 반드시 destroy를 호출하여 생성과 파괴의 대칭을 완벽히 맞춘다.
단일 탈출구 (goto cleanup) 여러 자원을 다루는 도중 오류가 발생하면 그 자리에서 성급히 반환하지 않고, 꼬리 쪽에 밀어둔 정리 전용 레이블로 점프하여 일괄 수습한다.
방어적 NULL 초기화 정리 블록에서 free(NULL)은 아무 동작 없이 부드럽게 무시되므로, 포인터들을 미리 NULL로 세팅해두면 널 체크 분기문 없이도 부분 실패를 안전하게 넘길 수 있다.
생명주기 역순 해제 원칙 메모리 파괴 및 환수 작업 시에는 내부에 종속적으로 할당된 가변 배열이나 문자열을 가장 먼저 치우고, 제일 바깥 껍데기인 메인 구조체를 제일 마지막에 지워야 주소 참조 오류가 없다.

연습 문제

  1. 메모리 할당 실패를 확인한 직후 함수 흐름을 곧바로 끊어버리고 return을 호출하는 조기 반환(Early Return) 방식이 다중 자원 할당 상황에서 왜 특히 위험한지 자원 유실과 스택 동작 관점에서 설명하라.
  2. 표준 C 라이브러리의 free 함수에 유효한 주소가 아닌 NULL 포인터를 강제로 전달하면 프로그램은 런타임에 어떻게 동작하는지 서술하고, 이 독특한 동작 특성이 goto cleanup 패턴을 구현하여 에러 처리를 할 때 코드 작성자에게 어떤 결정적 이점을 주는지 설명하라.
  3. 다음 제시된 user_destroy 함수에서 발생할 수 있는 가장 치명적인 메모리 접근 오류의 원인을 찾아 상세히 설명하고, 프로그램이 비정상 종료되지 않도록 올바른 순서로 코드를 고쳐 보라.
    void user_destroy(struct User *u) { free(u); free(u->name); }

정답과 해설

  1. 여러 개의 자원을 차례로 할당하는 도중에 실패하여 즉시 return을 호출해 버리면, 함수 흐름이 메인으로 되돌아가면서 그 이전에 성공적으로 할당받은 힙 메모리의 주소를 보관하고 있던 지역 포인터 변수들이 스택에서 일제히 팝(Pop)되어 소멸된다. 이렇게 되면 이미 힙에 굳건히 자리 잡고 있는 동적 메모리 조각들에 접근하거나 해제를 명령할 수 있는 유일한 주소 정보가 사라져 버리므로, 프로그램이 종료되기 전까지 절대 잉여 공간을 회수할 수 없는 '메모리 누수(Memory Leak)'가 필연적으로 발생하게 된다.
  2. free 함수에 NULL 포인터를 인자로 넘기면, C 표준 규격에 따라 아무런 작업도 수행하지 않고 안전하게 즉시 반환된다. 이 특성 덕분에 함수의 도입부에서 모든 포인터 변수를 초기값 NULL로 맞춰두면, 중간에 오류가 생겨 급하게 goto cleanup 블록으로 넘어오더라도 복잡한 널 체크 조건문(if ptr != NULL) 없이 모든 변수를 일렬로 나란히 free 함수에 던져버릴 수 있다. 할당에 실패했거나 시도조차 못한 포인터는 여전히 NULL인 채로 유지되므로 free가 조용히 무시하게 되어, 부분 롤백 로직을 놀랍도록 깔끔하고 견고하게 작성할 수 있는 장점을 제공한다.
  3. 가장 바깥에 뼈대를 형성하는 전체 구조체 u를 먼저 해제하고 나면, 해당 힙 메모리 영역의 소유권은 즉시 운영체제로 반납되어 언제든 다른 용도로 덮어써질 수 있는 무방비 상태가 된다. 그 이후에 하단 코드에서 u->name을 읽어 그 안에 든 주소를 꺼내려고 시도하는 행위는 이미 해제되어 권한이 상실된 메모리를 무단 참조하는 유효하지 않은 동작(Use After Free)이다. 이 오류를 올바르게 고치려면 반드시 구조체의 내부에 깊숙이 엮여 있는 종속 자원인 u->name을 가장 먼저 끄집어내어 해제하고, 내부가 텅 빈 껍데기가 된 구조체 포인터 u를 맨 마지막에 해제해야 한다. 즉, free(u->name); free(u);의 역순 구조로 재배치해야 안전하다.
오탈자·오류 제보 비공개로 접수되어 원고 수정에 반영됩니다

이메일 등 개인정보는 받지 않습니다. 답변이 필요한 질문은 아래 댓글을 이용해 주세요.

READER FEEDBACK

질문·의견

내용에 관한 질문이나 더 나은 설명을 위한 의견을 남겨 주세요. 오탈자는 위의 제보 양식이 더 빨리 반영됩니다. 이 댓글은 원래 게시글과 같은 자리에 쌓입니다.

댓글 0

아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.

댓글을 남기려면 로그인이 필요합니다.