본문 바로가기
1. 개발

회고[C/C++]: C 개발 이야기(메모리 누수)

by su8y 2025. 6. 5.

C언어로 개발하다 보면, 다른 언어들과는 차원이 다른 메모리 관리의 중요성을 뼈저리게 느끼게 됩니다. 파이썬이나 자바처럼 가비지 컬렉터(Garbage Collector)가 알아서 메모리를 정리해주는 편리함에 익숙해져 있다면, C언어는 마치 날것 그대로의 하드웨어와 씨름하는 느낌을 줍니다. 이게 때로는 엄청난 자유와 성능 최적화의 기회를 주지만, 동시에 끝없는 버그와의 전쟁을 의미하기도 합니다.

vs Java

저는 오랫동안 Java로 개발을 해왔습니다. Java는 JVM(Java Virtual Machine) 위에서 동작하며, 개발자가 메모리를 직접 제어할 일이 거의 없습니다. new로 객체를 생성하기만 하면, 더 이상 쓰이지 않는 객체는 GC가 알아서 회수해 가기 때문입니다.

따라서 Java에는 C언어의 malloc/free나 C++의 new/delete 같은 명시적인 해제 과정이 없습니다.

그렇다면 Java는 메모리 누수에서 완전히 자유로울까요?

결론부터 말하면 "아니요"입니다. GC가 강력하긴 하지만 만능은 아닙니다.

  • Static Collection: 정적 필드(static)에 객체를 담아두고 비우지 않는 경우
  • ThreadLocal: 사용 후 remove()를 호출하지 않아 스레드 풀 환경에서 객체가 계속 살아있는 경우

제가 리뷰했던 코드에서도, 특정 로직에서 객체 참조를 제대로 끊지 않아 힙 메모리가 가득 차고 결국 OutOfMemoryError로 서버가 뻗어버린 케이스가 있었습니다. 하지만 이는 '참조 관리'의 문제이지, '메모리 주소'를 직접 다루는 문제는 아니었습니다.


메모리 누수

반면 C/C++은 다릅니다. 여기서는 개발자가 곧 조물주이자 청소부입니다. 프로그램이 필요로 하는 메모리를 직접 할당(allocate)하고, 다 쓴 메모리는 반드시 직접 해제(deallocate)해야 합니다.

리눅스 시스템 개발을 하면서 "아, 메모리 관리가 이렇게 살벌한 거구나"라고 수없이 되뇌었습니다.

C/C++에서 free 호출을 하나라도 누락하면 그것이 곧 누수(Leak)가 됩니다.

  1. 점진적 성능 저하: 누수된 메모리가 쌓이면 물리 메모리가 부족해집니다.
  2. Swap & I/O 증가: OS는 부족한 메모리를 메우기 위해 스왑(Swap) 공간을 씁니다. 이는 디스크 I/O를 폭증시키고, 시스템 전체 속도를 급격히 떨어뜨립니다.
  3. Crash: 결국 OOM(Out Of Memory) 킬러에 의해 프로세스가 강제 종료됩니다.

서버 애플리케이션을 몇 시간 돌려놨는데 top 명령어로 확인한 메모리 사용량이 우상향 그래프를 그리고 있다면? 십중팔구 어딘가에서 free를 까먹은 것입니다.

 

안티 패턴 ? 클린 코드 ?

Java에서는 예외 처리(try-catch-finally)나 'Try-with-resources' 구문을 통해 자원을 정리합니다. 하지만 C언어에는 예외 처리 구문이 없습니다.

함수 중간에 에러가 발생해서 return을 해야 할 때, 그전에 할당했던 메모리들을 일일이 해제해 주지 않으면 바로 누수가 발생합니다. 이때 아이러니하게도, 평소 "절대 쓰지 말라"고 배우는 goto 문이 유용한 해결책이 됩니다.

이를 goto cleanup 패턴이라고 합니다.

goto 문은 일반적으로 "악마의 문(evil goto)"이라 불리며, 프로그램의 흐름을 복잡하게 만들고 가독성을 해쳐 스파게티 코드(Spaghetti Code)를 유발한다고 알려져 있습니다. 실제로 대부분의 고수준 언어에서는 goto 사용을 지양하며, C++이나 Java 같은 언어에서는 아예 존재하지 않거나 사용이 권장되지 않습니다.

C 언어에서 동적으로 할당된 메모리를 관리할 때, 특히 오류 처리 상황에서 메모리 누수(Memory Leak)를 방지하기 위해 goto 문과 cleanup 레이블을 사용하는 패턴은 오랫동안 논의되어 온 주제입니다.

goto cleanup 패턴은 함수 내에서 여러 단계의 자원 할당이 이루어지고, 어느 단계에서든 오류가 발생했을 때 이전에 할당된 모든 자원을 안전하게 해제하기 위해 사용됩니다. 일반적인 구조는 다음과 같습니다.

int process_data(int size) {
    char* buffer1 = NULL;
    char* buffer2 = NULL;
    int result = -1; 

    buffer1 = (char*)malloc(size);
    if (buffer1 == NULL) {
        perror("Failed to allocate buffer1");
        goto cleanup; 
    }
    printf("Buffer1 allocated.\n");
    if (size % 2 != 0) { 
        printf("Simulating error after buffer1 allocation.\n");
        goto cleanup;
    }


    buffer2 = (char*)malloc(size * 2);
    if (buffer2 == NULL) {
        perror("Failed to allocate buffer2");
        goto cleanup;
    }
    result = 0;

cleanup:
    printf("Executing cleanup.\n");
    if (buffer2 != NULL) {
        free(buffer2);
    }
    if (buffer1 != NULL) {
        free(buffer1);
    }
    return result;
}

이 패턴은 여러 자원을 순차적으로 할당해야 하는 복잡한 함수에서 각 할당 실패 시마다 중첩된 if-else 문을 사용하여 이전 자원을 해제하는 복잡성을 피하고, 하나의 출구(single exit point)를 통해 자원을 해제하도록 강제함으로써 메모리 누수를 효과적으로 방지합니다.

C 언어에서 goto cleanup 패턴은 명확한 목적(오류 발생 시 자원 해제)을 가지고 제한적인 범위 내에서 사용될 때는 오히려 클린 코드 원칙(특히 정확성, 신뢰성, DRY 원칙)에 부합하는 효과적인 해결책이 될 수 있습니다.
 

마무리

Java 개발자로서 GC의 소중함을 다시 한번 느끼는 계기였지만, 동시에 C/C++을 통해 메모리가 실제로 어떻게 관리되는지 이해할 수 있었습니다.

  • Java: 생산성과 안전성에 초점을 맞춤 (개발자 편의 > 하드웨어 제어)
  • C/C++: 성능과 정밀한 제어에 초점을 맞춤 (하드웨어 제어 > 개발자 편의)

결국 메모리 관리는 단순히 버그를 잡는 행위를 넘어, 내 코드가 시스템 리소스를 어떻게 점유하고 해제하는지, 그 생명주기를 완벽하게 장악하는 과정이었습니다.

자동화된 편안함에 안주하지 않고, 날것의 메모리와 씨름하며 얻은 경험은 앞으로 어떤 언어를 사용하든 더 견고하고 효율적인 코드를 작성하는 밑거름이 될 것이라 확신합니다.