티스토리 뷰
Stop-the-World와 메모리 관리 - Java GC의 이해와 실전 튜닝 1편: 메모리를 누가 치울 것인가 - GC의 탄생과 프로그래밍 언어의 설계 철학
ebson 2026. 4. 9. 23:06들어가며
Java로 애플리케이션을 개발하면서 메모리 할당과 해제를 직접 신경 쓰는 경우는 많지 않습니다. 객체를 new로 생성하면 JVM이 힙에 메모리를 할당하고, 더 이상 참조되지 않는 객체는 가비지 컬렉터(Garbage Collector, GC)가 알아서 회수합니다. 이 과정이 너무 자연스러워서, GC가 없는 환경에서 메모리를 관리한다는 것이 어떤 의미인지 체감하기 어려운 경우도 있습니다.
그런데 GC가 처음부터 당연한 것은 아니었습니다. C와 C++로 대표되는 시스템 프로그래밍 언어들은 수십 년간 개발자가 직접 메모리를 관리하는 방식을 고수해 왔고, 이 방식에는 그만한 이유가 있었습니다. GC를 채택한 언어와 채택하지 않은 언어 사이에는 "런타임이 얼마나 개입해야 하는가"에 대한 근본적인 철학 차이가 존재합니다.
이번 글에서는 수동 메모리 관리가 왜 구조적으로 문제를 일으키는지를 살펴보고, 이 문제를 해결하기 위해 GC가 어떻게 탄생했는지, 그리고 이후 프로그래밍 언어들이 메모리 관리에 대해 어떤 서로 다른 답을 내렸는지를 정리해 보겠습니다.
수동 메모리 관리 - 강력하지만 위험한 자유
C 언어에서 동적 메모리를 사용하려면 malloc으로 할당하고, 사용이 끝나면 free로 해제해야 합니다. C에서는 new와 delete가 같은 역할을 합니다. 개발자가 메모리의 생명주기를 완전히 제어할 수 있으므로, 필요한 만큼만 메모리를 점유하고 즉시 반환할 수 있다는 점에서 최대한의 효율을 끌어낼 수 있는 모델입니다. 운영체제 커널이나 임베디드 시스템처럼 메모리와 성능에 극도로 민감한 영역에서 C/C가 여전히 사용되는 이유이기도 합니다.
그러나 이 자유에는 대가가 따릅니다. 개발자가 할당과 해제의 짝을 정확히 맞추지 못하면 여러 종류의 결함이 발생하며, 이 결함들은 공통적으로 한 가지 특성을 갖습니다. 컴파일 타임에 검출되지 않고, 런타임에도 즉시 드러나지 않는다는 것입니다.
가장 흔한 문제는 메모리 누수(Memory Leak)입니다. malloc으로 할당한 메모리에 대해 free를 호출하지 않으면 해당 메모리는 프로세스가 종료될 때까지 회수되지 않습니다. 단발성 프로그램에서는 큰 문제가 되지 않을 수 있지만, 수개월간 중단 없이 실행되는 서버 프로세스에서는 누적된 메모리 누수가 결국 시스템을 정지시킬 수 있습니다. 더 까다로운 점은 누수가 발생하는 지점과 증상이 나타나는 시점 사이에 긴 시간 차이가 있어, 원인을 추적하기 어렵다는 것입니다.
댕글링 포인터(Dangling Pointer)는 이미 해제된 메모리를 가리키는 포인터를 통해 접근하는 문제입니다. 해제된 영역에 다른 데이터가 할당된 상태에서 이전 포인터를 통해 읽기를 수행하면 엉뚱한 값을 받게 되고, 쓰기를 수행하면 다른 데이터를 오염시킵니다. C/C++ 표준에서는 이를 정의되지 않은 동작(Undefined Behavior)으로 분류하며, 결과를 예측할 수 없습니다. 보안 관점에서도 Use-After-Free는 대표적인 공격 벡터 중 하나로 알려져 있습니다.
이중 해제(Double Free)는 같은 메모리 블록에 대해 free를 두 번 호출하는 경우입니다. 이 경우 힙 관리 메타데이터가 손상되어 이후의 malloc 호출에서 예측 불가능한 동작이 발생할 수 있습니다. 버퍼 오버플로(Buffer Overflow)는 할당된 메모리 경계를 넘어 읽거나 쓰는 문제로, 스택 오버플로와 힙 오버플로 모두 보안 취약점의 대표적 원인으로 분류됩니다.
이런 문제들을 완화하기 위한 도구는 존재합니다. Valgrind나 AddressSanitizer 같은 메모리 검사 도구들은 개발 단계에서 메모리 접근 오류를 탐지하는 데 효과적입니다. 하지만 이 도구들은 문제를 사후에 발견해 주는 것이지, 원천적으로 발생할 수 없도록 만들어 주는 것은 아닙니다. 수동 메모리 관리 모델에서는 개발자의 실수가 곧 버그이며, 코드베이스가 커질수록 실수의 가능성은 구조적으로 증가합니다.
GC의 탄생 - 1959년, 메모리 관리를 기계에 맡기다
자동 메모리 관리라는 개념이 처음 등장한 것은 1959년입니다. John McCarthy가 Lisp를 설계하면서 가비지 컬렉션을 최초로 구현했으며, 이 내용은 1960년에 발표된 논문 "Recursive Functions of Symbolic Expressions and Their Computation by Machine, Part I"(Communications of the ACM)에 기술되어 있습니다.
Lisp는 심볼릭 연산(Symbolic Computation)을 위한 언어였습니다. 리스트 구조를 기반으로 데이터를 표현하며, 함수 호출 과정에서 리스트의 생성과 폐기가 빈번하게 발생합니다. 이런 특성의 언어에서 개발자에게 매번 free를 호출하도록 요구하는 것은 현실적이지 않았습니다. McCarthy는 프로그램이 더 이상 접근할 수 없는 메모리를 자동으로 식별하고 회수하는 메커니즘을 언어 런타임에 내장하는 접근을 선택했고, 이것이 GC의 시작이었습니다.
최초의 GC 알고리즘은 Mark-and-Sweep이었습니다. 동작 원리는 직관적입니다. 프로그램의 루트(Root) — 전역 변수, 스택 변수 등 현재 실행 중인 코드가 직접 참조하는 대상 — 에서 출발하여 참조 체인을 따라 도달할 수 있는 모든 객체를 마킹(Mark)합니다. 마킹이 끝난 뒤, 마킹되지 않은 객체는 더 이상 접근할 수 없으므로 안전하게 해제(Sweep)합니다. 단순하지만 수동 메모리 관리의 핵심 문제 — 언제 해제할 것인가 — 에 대한 근본적인 해결책이었습니다.
이후 수십 년에 걸쳐 GC 알고리즘은 꾸준히 발전해 왔습니다. 1960년대에 등장한 참조 카운팅(Reference Counting)은 각 객체가 자신을 참조하는 횟수를 기록하고, 참조 수가 0이 되면 즉시 회수하는 방식입니다. 객체가 불필요해지는 즉시 메모리를 회수할 수 있다는 장점이 있지만, 두 객체가 서로를 참조하는 순환 참조(Circular Reference)를 처리하지 못한다는 근본적인 한계가 있었습니다.
1970년에 C.J. Cheney가 제안한 복사 수집(Copying Collection)은 살아있는 객체만을 새로운 메모리 공간으로 복사하는 방식입니다. 복사 후 원래 공간을 통째로 비울 수 있으므로 메모리 단편화가 발생하지 않습니다. 이 방식은 단편화 해소와 할당 속도 측면에서 장점이 있었고, 이후 세대별 GC의 핵심 기반이 됩니다.
그리고 1984년, David Ungar가 ACM 학회에서 발표한 세대별 수집(Generation Scavenging)이 등장합니다. Ungar가 관찰한 핵심 사실은 "대부분의 객체는 할당 직후 곧 사용되지 않게 된다(die young)"는 것이었습니다. 이 관찰에 기반하여 메모리를 세대(Generation)로 나누고, 새로 생성된 객체가 위치하는 영역(Young Generation)을 자주, 빠르게 수집하는 전략이 수립되었습니다. 오래 생존한 객체가 위치하는 영역(Old Generation)은 상대적으로 드물게 수집합니다. 이 비대칭 전략은 현대 GC 알고리즘의 이론적 토대가 되었으며, Java의 HotSpot VM을 포함한 대부분의 현대 GC가 이 세대별 가설(Generational Hypothesis)에 기반하고 있습니다.
GC를 채택한 언어들 — 안전성과 생산성의 선택
GC라는 아이디어가 Lisp에서 출발한 이후, 1990년대와 2000년대를 거치며 GC를 런타임에 내장하는 언어들이 다수 등장했습니다. 이 언어들은 각각 다른 목표를 가지고 있었지만, "메모리 관리를 런타임에 맡기겠다"는 결정에 있어서는 공통된 판단을 내렸습니다.
Java는 1995년에 Sun Microsystems에서 출시되었습니다. "Write Once, Run Anywhere"를 내세운 Java의 핵심 가치는 플랫폼 독립성이었고, 이를 위해 JVM(Java Virtual Machine)이라는 중간 계층을 도입했습니다. JVM이 메모리를 완전히 관리하지 않으면 플랫폼별 메모리 레이아웃과 포인터 크기의 차이가 이식성을 깨뜨릴 수 있었기 때문에, Java에서 GC는 선택이 아닌 구조적 필수였습니다. 개발자에게는 포인터 산술(Pointer Arithmetic)을 제거하고, 모든 객체 접근을 JVM이 관리하는 참조(Reference)로 대체함으로써 메모리 안전성을 보장했습니다. 이에 대한 상세한 내용은 다음 편에서 다루겠습니다.
C#은 2000년에 Microsoft가 발표하여 2002년 .NET Framework 1.0과 함께 정식 출시된 언어입니다. CLR(Common Language Runtime) 위에서 동작하는 관리형(Managed) 런타임 모델을 채택하여 Java와 유사한 접근을 취했습니다. 다만 C#은 unsafe 키워드를 통해 관리되지 않는 포인터 접근을 허용하는 탈출구를 제공한다는 점에서, GC 기반이면서도 시스템 프로그래밍의 가능성을 열어 둔 설계라 할 수 있습니다.
Go는 2009년에 Google에서 발표한 언어입니다. 시스템 프로그래밍 영역을 대상으로 하면서도 GC를 채택한 것은 다소 독특한 선택이었습니다. Go가 언어 차원에서 지원하는 goroutine 기반 동시성 모델에서는 수많은 경량 스레드가 동적으로 메모리를 할당하고 해제합니다. 이 환경에서 수동 메모리 관리를 요구하면 동시성 모델의 편의성이 크게 훼손되기 때문에, GC를 통해 복잡성을 제거하는 것이 합리적인 판단이었습니다. Go의 GC는 낮은 지연 시간(Low Latency)에 특히 집중하는 방향으로 발전해 왔습니다.
Python은 1991년에 등장한 언어로, 참조 카운팅(Reference Counting)을 기본 메모리 관리 방식으로 사용합니다. 앞서 언급한 순환 참조 문제를 해결하기 위해 별도의 순환 참조 탐지기(Cycle Detector)를 추가로 운용하는 하이브리드 구조입니다. 스크립팅 언어로서 개발자가 메모리에 대해 거의 신경 쓰지 않아도 되는 환경을 제공하는 것이 설계의 우선순위였습니다.
이 언어들의 공통점은 "개발자가 비즈니스 로직에 집중할 수 있도록, 메모리 관리의 부담을 런타임이 대신 진다"는 판단입니다. 그 대가로 GC가 실행되는 동안 발생하는 지연(Stop-the-World)과 메모리 오버헤드를 수용하는 트레이드오프를 받아들인 것입니다.
GC 없이 메모리를 관리하는 언어들 - 제어권과 성능의 선택
GC를 채택하지 않은 언어들이 구식이거나 열등한 것은 아닙니다. 이 언어들은 GC의 비결정적 지연(non-deterministic pause)을 허용할 수 없는 영역을 대상으로 하거나, GC 없이도 메모리 안전성을 확보할 수 있는 대안적 메커니즘을 제공합니다.
C는 1972년에 Dennis Ritchie가 설계한 언어로, 하드웨어에 가장 가까운 추상화를 제공합니다. 운영체제 커널, 임베디드 시스템, 디바이스 드라이버 등 GC의 예측 불가능한 일시 정지를 허용할 수 없는 영역이 주요 대상입니다. 리눅스 커널, Windows 커널이 C로 작성되어 있다는 사실은, 이 영역에서 수동 메모리 관리의 제어권이 여전히 필수적임을 보여줍니다. 메모리 관련 버그의 위험은 존재하지만, 이를 감수하더라도 GC의 런타임 오버헤드를 받아들일 수 없는 환경이 있다는 것입니다.
C은 1985년에 Bjarne Stroustrup이 설계한 언어입니다. C의 핵심 설계 원칙 중 하나는 "사용하지 않는 것에 대해 비용을 지불하지 않는다(Zero-overhead principle)"입니다. GC를 런타임에 내장하면 GC를 필요로 하지 않는 코드까지 그 오버헤드를 부담하게 되므로, C++은 GC 대신 개발자가 자원 관리를 체계적으로 할 수 있는 언어적 장치를 발전시켜 왔습니다.
대표적인 것이 RAII(Resource Acquisition Is Initialization) 패턴입니다. 객체의 생성자에서 자원을 획득하고 소멸자에서 자원을 해제하는 이 관례를 통해, 스코프를 벗어나면 자원이 자동으로 반환되는 구조를 만들 수 있습니다. C++11 이후 도입된 스마트 포인터(std::unique_ptr, std::shared_ptr)는 이 패턴을 표준 라이브러리 수준에서 지원하여, 수동 delete 호출의 필요성을 크게 줄였습니다. GC가 아닌 방식으로 메모리 안전성에 접근한 것입니다.
Rust는 2015년에 정식 릴리스된 언어로, 메모리 관리에 대해 C/C++이나 GC 채택 언어들과는 다른 제3의 접근을 취했습니다. Rust의 소유권(Ownership) 시스템은 각 값에 대해 하나의 소유자만 존재하도록 강제하고, 소유자가 스코프를 벗어나면 값이 자동으로 해제됩니다. 빌림 검사기(Borrow Checker)는 컴파일 타임에 참조의 유효성을 검증하여, 댕글링 포인터나 데이터 레이스가 발생할 수 없도록 합니다.
Rust가 흥미로운 이유는 GC의 런타임 오버헤드 없이도 C/C++에서 발생하는 메모리 버그를 원천적으로 차단했다는 점입니다. 다만 이 안전성은 공짜가 아닙니다. 컴파일러가 소유권 규칙을 엄격하게 검사하기 때문에 학습 곡선이 가파르고, 소유권 규칙에 맞도록 코드 구조를 설계하는 데 추가적인 노력이 필요합니다. GC가 런타임에 지불하는 비용을 Rust는 개발 시점에 지불하는 셈입니다.
세 가지 길, 하나의 질문
지금까지 살펴본 내용을 정리하면, 프로그래밍 언어의 메모리 관리 전략은 크게 세 갈래로 나뉩니다.
첫 번째는 C와 C++ 초기 스타일에 해당하는 수동 관리입니다. 개발자가 모든 메모리의 생명주기를 직접 제어합니다. 최대한의 성능과 제어권을 확보할 수 있지만, 메모리 관련 버그의 위험을 구조적으로 안고 갑니다.
두 번째는 Java, Go, C#, Python 등이 채택한 GC 기반 자동 관리입니다. 런타임이 더 이상 도달할 수 없는 객체를 자동으로 식별하고 회수합니다. 개발자의 메모리 관리 부담이 크게 줄어들지만, GC 실행에 따른 런타임 오버헤드와 비결정적 지연이 수반됩니다.
세 번째는 Rust가 제시한 소유권 기반 컴파일 타임 관리입니다. C++의 RAII를 언어의 핵심 규칙으로 격상시켜, GC 없이도 컴파일러가 메모리 안전성을 보장합니다. 런타임 오버헤드가 없는 대신 컴파일 타임의 제약과 학습 비용이 존재합니다.
이 세 가지 접근은 결국 같은 질문에 대한 서로 다른 대답입니다. "메모리 관리의 책임을 누구에게 둘 것인가." 개발자에게 둘 것인지, 런타임에 맡길 것인지, 컴파일러에게 위임할 것인지. 어떤 접근이 더 우월하다고 단정하기는 어렵습니다. 언어가 대상으로 하는 문제 영역, 허용할 수 있는 성능 오버헤드, 개발자 생산성에 대한 기대치에 따라 합리적인 선택이 달라지기 때문입니다.
마치며
이번 글에서는 GC가 등장하게 된 배경을 수동 메모리 관리의 구조적 문제에서 출발하여 정리해 보았습니다. 1959년 John McCarthy가 Lisp에서 Mark-and-Sweep을 구현한 이후, GC 알고리즘은 참조 카운팅, 복사 수집, 세대별 수집으로 발전해 왔고, 이 아이디어들은 오늘날 Java를 비롯한 많은 언어의 런타임에 녹아 있습니다.
글을 정리하면서 다시 확인하게 된 점이 있습니다. GC의 채택 여부는 단순히 "편리하냐 아니냐"의 문제가 아니라, 언어가 스스로에게 부여한 역할과 책임 범위에 대한 선언이라는 것입니다. Java가 GC를 선택한 것은 JVM이라는 중간 계층 위에서 플랫폼 독립성과 개발 생산성을 확보하기 위한 구조적 결정이었고, 그 결정이 가져온 트레이드오프 — 특히 Stop-the-World — 를 개선하기 위해 Java의 GC는 25년이 넘는 시간 동안 진화해 왔습니다.
다음 글에서는 Java가 왜 GC를 필수적으로 채택해야 했는지를 JVM 아키텍처의 관점에서 좀 더 구체적으로 살펴보고, JVM 메모리 구조에서 GC가 관리하는 영역과 그렇지 않은 영역을 구분하여 정리해 보겠습니다.
참고 출처
- McCarthy, J. (1960). "Recursive Functions of Symbolic Expressions and Their Computation by Machine, Part I." Communications of the ACM, 3(4), 184-195.
- Cheney, C.J. (1970). "A Nonrecursive List Compacting Algorithm." Communications of the ACM, 13(11), 677-678.
- Ungar, D. (1984). "Generation Scavenging: A Non-disruptive High Performance Storage Reclamation Algorithm." ACM SIGSOFT/SIGPLAN Software Engineering Symposium on Practical Software Development Environments.
- ISO/IEC 9899:2018 (C18 Standard)
- ISO/IEC 14882:2020 (C++20 Standard)
- The Rust Reference — Ownership (doc.rust-lang.org/reference/ownership.html)
- Oracle. "The Java Language Environment: A White Paper." (1996)
'STUDY' 카테고리의 다른 글
- Total
- Today
- Yesterday
- DB 인덱스 성능
- Spring Batch
- 백엔드 성능
- Redis vs DB
- 트랜잭션 관리
- Cache Avalanche
- 백엔드 성능 설계
- InterruptedException
- Double-Checked Locking
- Redis 성능 개선
- 동시성처리
- Cache Aside
- Eager Initialization
- 캐시 장애
- 백엔드 성능 튜닝
- 백엔드 아키텍처
- 캐시 성능 비교
- Redis 캐시 전략
- 캐시와 인덱스
- 스레드 생명주기
- Hot Key 문제
- Initialization-on-Demand Holder Idiom
- Cache Penetration
- TTL 설계
- Java Performance
- 트래픽 처리
- DB 트랜잭션
- mybatis
- Enum 기반 싱글톤
- spring batch 5
| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | 5 | 6 | |
| 7 | 8 | 9 | 10 | 11 | 12 | 13 |
| 14 | 15 | 16 | 17 | 18 | 19 | 20 |
| 21 | 22 | 23 | 24 | 25 | 26 | 27 |
| 28 | 29 | 30 |
