다양한 메모리 관리 방식의 이해

Chaeyun·2025년 4월 27일
0

CS

목록 보기
6/6

개요

다양한 언어가 어떻게 메모리를 관리하는지 알아봅시다.

얼마전까지 메모리 관리는 C처럼 직접 메모리를 할당/해제해주거나 JAVA처럼 가비지 컬렉터로 관리하는 것만 있는 줄 알고 있었습니다.

그런데 Reference Counting이라는 방식이 있다는 것을 새로 알게 되어 이번 기회에 언어별 다양한 메모리 관리 방식에 대해 정리해보고자 합니다.

메모리 관리 방식의 종류

메모리 관리 방식은 크게 개발자가 수동으로 메모리를 관리해야 하는 것과 자동으로 관리되는 것으로 나눌 수 있습니다.
그리고 자동 관리 방식은 다시 Reference Counting 방식Tracing 방식 두 가지로 분류될 수 있습니다.

1. 수동 메모리 관리 방식 (Manual Memory Management)

개발자가 메모리 할당과 해제를 직접 관리하는 방식입니다.
이는 메모리 사용을 최적화할 수 있지만, 개발자의 실수로 메모리 누수나 잘못된 포인터 접근 같은 문제를 발생시킬 위험이 있습니다.

주요 언어

  • C, C++

기법

  • 메모리 할당: malloc, calloc (C), new (C++)
  • 메모리 해제: free (C), delete (C++)

장점

  • 성능 최적화: 메모리 관리에 대한 세밀한 제어가 가능하므로, 성능을 최적화할 수 있습니다.
  • 예측 가능한 성능: 자동 관리 방식에서 나타나는 Stop-the-World나 성능 저하가 없습니다.
  • 저지연성 : 애플리케이션의 실행 흐름을 개발자가 완전히 제어할 수 있어, 메모리 관리에 따른 지연 시간을 피할 수 있습니다.
  • 실시간 시스템에 적함 : 위의 특성들로 인해 실시간 시스템에 적합합니다.

Stop-the-World
stop-the-world란 GC을 실행하기 위해 JVM이 애플리케이션 실행을 멈추는 것입니다. stop-the-world가 발생하면 GC를 실행하는 쓰레드를 제외한 나머지 쓰레드는 모두 작업을 멈추고, GC 작업을 완료한 이후에야 중단했던 작업을 다시 시작합니다.

단점

  • 복잡성: 메모리 관리를 직접 하므로 코드가 복잡하고 오류를 피하기 어렵습니다.
  • 높은 오류 발생 가능성 : 메모리 관리가 수동으로 이루어지기 때문에, 개발자가 메모리 해제를 잊거나 중복 해제하는 등 실수하기 쉽습니다. 이는 시스템의 안정성에 심각한 영향을 미칠 수 있습니다.

2. 참조 카운팅 방식 (Reference Counting)

참조 카운팅은 각 객체가 몇 번 참조되고 있는지를 카운트하여, 참조가 0이 되면 해당 객체가 더 이상 사용되지 않는 것으로 판단하고 메모리를 해제하는 방식입니다.

주요 언어

  • Python (가비지 컬렉터와 함께 사용), Objective-C, Swift (ARC)

기법

  • 객체가 생성될 때마다 참조 카운트를 증가시키고, 객체가 참조를 잃을 때마다 카운트를 감소시킵니다.
  • 참조 카운트가 0이 되면 객체는 더 이상 사용되지 않으므로, 그 메모리를 해제합니다.

장점

  • 자동 메모리 관리: 개발자가 명시적으로 메모리를 해제할 필요 없이, 참조 카운트가 0이 될 때 자동으로 메모리를 해제합니다.
  • 단순성: 구현이 비교적 간단하고, 객체의 생명 주기를 명확하게 추적할 수 있습니다.
  • 비교적 예측 가능한 성능: 가비지 컬렉터에서 나타나는 시스템 중단이 없습니다.

단점

  • 순환 참조 문제: 두 객체가 서로를 참조하는 순환 구조가 발생할 수 있습니다. 이 경우 참조 카운트가 0이 되지 않아서 메모리 누수가 발생합니다.
  • space 오버헤드: 레퍼런스 카운팅 방식은 각 객체마다 카운트를 저장할 공간이 필요합니다. 이것은 객체의 메모리 영역에서 가까운 곳에 저장될 것인데, 객체마다 32에서 64비트의 공간이 추가적으로 필요로 하게 됩니다.
  • speed 오버헤드: 참조 카운트를 매번 갱신하는 작업이 성능에 영향을 미칠 수 있습니다. 특히 멀티 스레드 환경에서는 동기화 비용이 커질 수 있습니다.

멀티 스레드 환경에서 동기화 비용의 의미
참조 카운팅은 "참조가 추가되거나 제거될 때마다" 카운트 값을 수정해야 함.

그런데 멀티스레드 환경에서는 여러 스레드가 동시에 같은 객체를 참조하거나 해제할 수 있음.
→ 따라서 카운트 값 변경이 원자적(atomic) 으로 일어나야 함
(동시에 두 스레드가 카운트 수정을 하면, 값이 꼬일 수 있기 때문!)

참조 카운트 연산을 스레드 안전하게 만들려면
락(lock) 을 걸거나 원자적 증가/감소(atomic increment/decrement) 같은 CPU 수준 명령어를 써야 함.
→ 연산 병목이 되어 성능 저하 가능성 有

#include <atomic>

std::atomic<int> counter(0);  // atomic counter 변수 선언

void incrementCounter() {
  counter++;  // atomic하게 counter 변수를 증가시킵니다.
}

void decrementCounter() {
  counter--;  // atomic하게 counter 변수를 감소시킵니다.
}

3. 추적 방식 (Tracing 방식)

현재 흔히 Garbage Collection라고 부르는 것으로, 프로그램 실행 중 특정 타이밍에 현재 할당된 모든 메모리를 조사하여 그것이 현재 접근 가능한지 불가능한지 분류한 뒤 접근이 불가능한 메모리를 해제하는 방식입니다. 개발자가 메모리 해제를 직접 신경 쓸 필요 없이, GC가 백그라운드에서 주기적으로 실행되어 사용되지 않는 객체를 정리합니다.

주요 언어

  • Java, C#, Go, JavaScript, Python (참조 카운팅 방식과 함께 사용)

기법

  • 마크 앤 스윕 (Mark & Sweep):
    1. Mark 단계: 모든 객체를 "마크"하여 현재 참조되고 있는지 여부를 확인합니다. Marking을 위해서는 여러 가지 방법이 있는데 주로 Object Header에 Flag나 별도의 Bitmap Table 등을 사용합니다.
    2. Sweep 단계: 마크되지 않은 객체(즉, 더 이상 참조되지 않는 객체)를 메모리에서 회수합니다. Sweep이 완료된 후에는 모든 Object의 Marking 정보를 초기화합니다.
  • Generational Garbage Collection (세대별 GC):
    • 객체를 "신생 객체"와 "오래된 객체"로 분류하여, 신생 객체는 자주 회수하고, 오래된 객체는 비교적 덜 자주 회수하는 방식입니다. 이 방식은 성능 최적화의 일환으로 사용됩니다.
  • 컴팩션 (Compaction):
    • 메모리에서 할당된 객체들 간의 빈 공간을 줄여서 메모리를 압축하는 작업을 수행합니다. 이로 인해 메모리 단편화가 감소합니다. 크게 Sliding 방식과 Copying 방식이 있습니다.

장점

  • 자동화된 메모리 관리: 개발자가 메모리 해제를 신경 쓰지 않아도 되어 코드가 간결하고 안전합니다.
  • 순환 참조 처리: 순환 참조 문제를 해결할 수 있습니다. 가비지 컬렉터는 순환 구조를 탐지하고 이를 처리할 수 있습니다.
  • 멀티 스레드 친화적: 메모리 관리가 중앙집중식으로 처리되어 멀티 스레드 환경에서의 관리 비용이 적습니다.
    • 수동 관리 방식은 개발자가 직접 충돌을 관리해야 합니다.
    • 참조 카운팅 방식은 잦은 락이나 원자적 연산으로 인한 오버헤드가 있습니다.

단점

  • 성능 오버헤드: 가비지 컬렉션이 작동할 때 Stop-the-World 현상으로 인해 시스템에 일시적인 정지가 발생할 수 있습니다. GC는 일정 주기로 실행되므로, 애플리케이션의 성능에 영향을 미칠 수 있습니다.
  • 예측 불가능한 성능: 가비지 컬렉터가 언제 실행될지 예측하기 어려우므로, 실시간 처리나 고성능이 중요한 애플리케이션에서는 단점이 될 수 있습니다.
  • 메모리 사용량 증가: GC는 메모리 할당과 해제를 여러 번 반복하므로 일부 경우 메모리 소비가 더 많을 수 있습니다.

비교 요약

기법특징장점단점
수동 메모리 관리개발자가 메모리 할당과 해제를 직접 처리성능 최적화, 세밀한 제어 가능메모리 누수, 댕글링 포인터 위험
참조 카운팅객체가 참조될 때마다 카운트를 증가, 해제 시 감소자동 메모리 관리, 단순성순환 참조 문제, 성능 오버헤드
가비지 컬렉터사용되지 않는 객체를 자동으로 회수자동화된 메모리 관리, 순환 참조 처리 가능성능 오버헤드, 예측 불가능한 정지 시간


Appendix(부록)

1. 수동 메모리 관리 방식에서 발생 가능한 오류

수동 관리 방식에서는 메모리를 잘못 해제하거나 중복 해제하면 치명적인 오류가 발생할 수 있습니다.

여기서 중요한 개념들은 메모리 누수, 댕글링 포인터(Dangling Pointer), 이중 해제(Double Free) 오류입니다.

1. 메모리 누수 (Memory Leak)

메모리 누수는 할당한 메모리를 해제하지 않아서 더 이상 사용하지 않는 메모리가 시스템에 남아 있는 상태입니다.
이런 메모리는 시스템의 메모리 리소스를 점차적으로 고갈시키며, 프로그램이 오래 실행되면 메모리 부족 문제를 일으킬 수 있습니다.

발생 원인

  • 메모리 할당 후 free() 또는 delete 호출을 잊어버리는 경우
  • 할당된 메모리의 포인터를 잃어버리는 경우 (예: 포인터 변수에 다른 주소를 할당하거나 범위가 벗어나는 경우)
  • 예외 처리를 제대로 하지 않으면, 할당된 메모리가 해제되지 않고 프로그램이 종료되거나 중단될 수 있습니다.

예시 코드

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

void allocateMemory() {
    int *arr = (int *)malloc(10 * sizeof(int));  // 메모리 할당
    // 메모리를 해제하지 않음 -> 메모리 누수 발생
}

int main() {
    allocateMemory();
    return 0;
}

해결 방법

메모리를 할당한 후에는 반드시 free() (C) 또는 delete (C++)를 사용하여 해제해 주어야 합니다. 또한 예외가 발생할 수 있는 경우, 메모리 해제를 보장하는 코드를 작성해야 합니다.

2. 댕글링 포인터 (Dangling Pointer)

댕글링 포인터는 이미 해제된 메모리를 가리키는 포인터입니다. 해당 메모리가 해제되었는데, 포인터가 여전히 그 주소를 가리키고 있는 상태를 말합니다.
댕글링 포인터를 통해 메모리에 접근하려 하면 프로그램 크래시, 데이터 손상 등 예기치 않은 동작이 발생할 수 있습니다.

발생 원인

  • free() 또는 delete 후, 그 메모리를 가리키던 포인터를 계속 사용하려 할 때
  • 객체나 배열을 해제한 후, 여전히 그 포인터를 사용하는 경우

예시 코드

#include <iostream>

void danglingPointerExample() {
    int *ptr = new int(10);  // 메모리 할당
    delete ptr;              // 메모리 해제

    // 포인터는 여전히 해제된 메모리를 가리킴
    std::cout << *ptr << std::endl;  // 댕글링 포인터 사용 (예기치 않은 결과)
}

int main() {
    danglingPointerExample();
    return 0;
}

해결 방법

  • 메모리를 해제한 후, 포인터를 nullptr로 설정하거나 NULL로 초기화하여 더 이상 해당 메모리를 참조하지 않도록 해야 합니다.
  • 포인터 초기화는 매우 중요합니다. delete 또는 free() 후에는 포인터를 안전하게 nullptr 또는 NULL로 설정하세요.
delete ptr;  // 메모리 해제
ptr = nullptr;  // 댕글링 포인터 방지

3. 이중 해제 (Double Free)

이중 해제는 같은 메모리 블록을 두 번 이상 해제하려 할 때 발생하는 오류입니다. 이중 해제를 시도하면 undefined behavior가 발생하고, 프로그램이 예기치 않게 동작하거나 크래시할 수 있습니다.

발생 원인

  • 같은 포인터에 대해 두 번 이상 free() 또는 delete를 호출하는 경우
  • 두 개 이상의 포인터가 동일한 메모리 영역을 가리킬 때, 하나의 포인터를 delete한 후 다른 포인터로 다시 delete를 호출하는 경우

예시 코드

#include <iostream>

void doubleFreeExample() {
    int *ptr = new int(10);  // 메모리 할당

    delete ptr;  // 첫 번째 해제
    delete ptr;  // 두 번째 해제 (이중 해제 시도) -> 정의되지 않은 동작
}

int main() {
    doubleFreeExample();
    return 0;
}

해결 방법

  • deletefree()를 호출한 후에는 해당 포인터를 nullptr로 설정하여, 다시 해제하지 않도록 해야 합니다.
  • 또한, 메모리를 여러 포인터에서 공유하지 않도록 주의하고, 메모리를 해제한 후에는 다른 포인터가 그 메모리를 참조하지 않도록 해야 합니다.
delete ptr;
ptr = nullptr;  // 이중 해제 방지

결론

C와 C++에서 메모리를 잘못 해제하거나 중복 해제하는 오류는 프로그램의 안정성을 심각하게 위협할 수 있습니다. 이를 방지하려면:

  1. 메모리를 할당한 후 반드시 해제하는 습관을 기르고,
  2. 해제 후 포인터를 nullptr로 설정하여 댕글링 포인터를 방지하고,
  3. 이중 해제를 피하기 위해 한 번 해제한 후 포인터를 다시 사용하지 않도록 하는 것이 중요합니다.

메모리 관리를 철저히 하지 않으면 프로그램에서 예기치 않은 동작이나 크래시가 발생할 수 있습니다. 메모리 관련 오류를 추적하기 위한 도구(Valgrind, AddressSanitizer 등)를 사용하는 것도 좋은 방법입니다.


2. 참조 카운팅 방식 최적화 기법

참조가 생성되고 제거될때마다 reference count를 증가,감소 시킨다면 오버헤드가 큽니다. 이를 감소시키기 위한 다양한 방법이 있습니다.

1. Thread-Local Reference Counting (Thread-Local 참조 카운팅)

각 스레드가 자기만의 로컬 참조 카운트를 따로 가지고 있다가,
필요할 때만(ex. 객체를 해제할 때) 글로벌 카운트로 합쳐(sync)주는 방식

스레드 A: 로컬 카운트 +1
스레드 B: 로컬 카운트 +1
→ 필요할 때 전체 합쳐서 글로벌 카운트 = 로컬 A + 로컬 B

장점

  • 평소에는 락(lock)이나 atomic 연산 없이, 빠르게 로컬 카운트만 수정함.
  • 스레드 간 경쟁이 거의 없음.

단점

  • 최종적으로 합칠 때는 여전히 동기화가 필요하고,
  • 참조 수명이 복잡하면(sync 포인트가 많으면) 구현이 어려움.

2. Deferred Reference Counting (지연 참조 카운팅)

즉시 참조 카운트를 수정하지 않고 참조 변경 이벤트를 큐(queue)에 넣어두었다가,
나중에 한꺼번에 처리하는 방식

(참조 변경 발생) → (이벤트 큐에 기록) → (나중에 배치로 카운트 정리)

장점

  • 참조 수정이 빈번한 코드 경로에서 오버헤드를 줄일 수 있음.
  • atomic 연산 횟수를 줄일 수 있음.

단점

  • 메모리 해제가 늦어질 수 있고,
  • 큐를 관리하는 추가 오버헤드가 생김.

3. Coalescing Updates (업데이트 병합)

같은 객체에 대해 여러 번 참조 추가/해제를 빠르게 연속해서 할 경우,
변경사항을 합쳐서 한 번에 처리하는 방식

+1, +1, -1, +1, -1, -1 → 결과적으로 +0 → 아무것도 하지 않아도 됨

장점

  • 오버헤드를 아주 크게 줄일 수 있음.
  • 네트워크 전송할 때도 유용(레퍼런스 변화를 한번에 보내기).

단점

  • 구현이 조금 복잡하고, 모든 상황에서 병합이 가능한 건 아님.

요약

최적화 기법핵심 아이디어장점단점
Thread-Local Counting스레드마다 로컬 카운트 유지빠른 참조 연산합칠 때 복잡
Deferred Counting나중에 일괄 카운트 반영참조 조작 최소화해제 지연 가능성
Coalescing Updates참조 변경을 병합 처리오버헤드 극소화상황에 따라 불가능
Epoch Reclamation에포크 기반으로 안전 해제락 없는 고성능 가능구현 매우 복잡

참고로 현대 언어들에서도 이런 기법들이 실제로 쓰임

  • Swift → Thread-local 참조 카운팅 + 지연 참조 카운팅 일부 사용.
  • Rust -> crossbeam 라이브러리에서 Thread-local 참조 카운팅 + 에포크 기반 메모리 정리 기법 제공
  • C++std::shared_ptr은 기본적으로 atomic 연산 기반인데, 최적화된 구현체는 로컬 캐시를 활용하기도 함.

3. 주요 언어별 방식

주요 언어별 메모리 관리 방식은 다음과 같습니다.


✅ C / C++

  • 메모리 관리 방식: 수동(Manual)
  • 기법:
    • malloc, calloc, free (C)
    • new, delete (C++)
  • 특징:
    • 개발자가 직접 메모리 할당 및 해제를 관리해야 함
    • 장점: 높은 성능과 세밀한 제어 가능
    • 단점: 메모리 누수, 댕글링 포인터 등의 오류 발생 위험

✅ Java

  • 메모리 관리 방식: 자동(Automatic)
  • 기법: 가비지 컬렉션(Garbage Collection)
  • 특징:
    • JVM이 객체 사용 여부를 추적하고, 사용하지 않으면 자동으로 메모리 회수
    • Mark & Sweep, Generational GC, G1 GC 등 다양한 버전이 있음
    • 장점: 메모리 해제를 신경 쓸 필요 없음
    • 단점: GC 동작 시 일시적인 성능 저하 가능

✅ Python

  • 메모리 관리 방식: 자동
  • 기법:
    • 참조 카운트(Reference Counting)
    • 순환 참조 해결을 위한 가비지 컬렉터
  • 특징:
    • 객체의 참조 수가 0이 되면 자동으로 메모리 해제
    • 순환 참조 문제를 위해 gc 모듈로 수동 제어 가능

✅ JavaScript

  • 메모리 관리 방식: 자동
  • 기법: 가비지 컬렉션
  • 특징:
    • 현대 브라우저(예: V8 엔진)는 객체의 reachability(접근 가능성)을 기준으로 메모리 회수
    • 개발자는 클로저, 이벤트 리스너 등으로 인한 메모리 누수에 주의해야 함

✅ Rust

  • 메모리 관리 방식: 수동 + 안전성 보장 (컴파일 타임)
  • 기법: Ownership 시스템
  • 특징:
    • 컴파일 타임에 메모리 사용을 검사하여 런타임 오버헤드 없이 안전성 확보
    • borrow checker가 데이터의 소유권 및 수명을 검사
    • 장점: 안전성과 성능의 균형
    • 단점: 초기 러닝 커브 존재

✅ Go (Golang)

  • 메모리 관리 방식: 자동
  • 기법: 가비지 컬렉션
  • 특징:
    • Java와 유사한 GC 메커니즘
    • 성능을 고려한 GC 최적화 (병행 가비지 컬렉터 등)
    • 장점: 간결한 코드와 메모리 안전성
    • 단점: GC로 인한 예측 불가능한 일시적인 지연

정리하며

가비지 컬렉터도 여러 버전(G1 GC, ZGC, Shenandoah GC 등)이 있는데 다음 글에서는 각각에 대한 특징과 차이점을 정리해보고자 한다.
또한 Rust는 Ownership 기반의 수동 방식 메모리 관리 시스템을 사용한다고 하는데,
이에 대해 자세히 조사해보고자 한다.

References

0개의 댓글