DevInsight

개발의 판단과 맥락을 기록하는 곳

Backend
조회 1약 4분 읽기

메모리를 대신 관리해주는 쾌락과 고통의 세계

가비지 컬렉션은 현대 프로그래밍 언어가 거의 예외 없이 채택한 자동 메모리 관리 기술이지만, 그 내부는 대부분의 개발자에게 블랙박스다. 2판으로 돌아온 이 핸드북은 지난 60년간 축적된 GC 연구의 정수를 집대성한다. 단순한 mark-sweep에서 병렬·동시·실시간 컬렉터까지, GC가 어떻게 진화해왔고 오늘날 어떤 선택지를 제공하는지 한 권으로 조망한다.

DevInsight 편집팀 발행

AI 보조 초안과 편집 검수를 거쳐 발행했습니다.

#garbage collection#memory management#JVM#runtime internals#concurrency#performance optimization#systems programming#backend

프로덕션 서비스의 레이턴시 프로파일을 뜯다 보면 누구나 한 번쯤 조용히 이를 갈게 되는 순간이 있다. 아무 이유 없이 200ms짜리 응답 시간 스파이크가 주기적으로 나타나고, CPU 사용률은 그 순간만 싹 올라갔다가 내려간다. 원인은 거의 항상 같다. GC, 정확히는 Stop-The-World(STW) 일시 정지다. 개발자는 메모리를 직접 해제하지 않아도 된다는 편리함과, 그 대가로 치러야 하는 예측 불가능한 일시 정지 사이에서 영원히 줄타기를 한다. 바로 그 지점에서 The Garbage Collection Handbook 2판이 하는 말은 단순하다. "고통은 익숙해지는 것이 아니라 이해해야 사라진다."


자동 메모리 관리의 역사는 생각보다 오래되었다. 1959년 Lisp이 최초의 GC를 선보인 이후, 무려 60년 이상의 연구가 축적되어 있다. 대부분의 개발자는 GC를 JVM이나 V8 같은 런타임이 알아서 해주는 '블랙박스'로 받아들이지만, 그 내부에서는 수십 년간의 컬렉터 알고리즘 전쟁이 벌어져 왔다. 핸드북은 이 전쟁의 기록이자 지도다. 1판(2011년)이 기본 원리와 고전적 알고리즘을 정리했다면, 2판은 그 후 10여 년간 폭발적으로 발전한 병렬·동시·실시간 컬렉터를 본격적으로 편입했다.

초기 GC는 단순했다. Mark-Sweep은 루트에서부터 도달 가능한 객체를 마킹하고, 마킹되지 않은 객체를 통째로 지운다. 문제는 여기서 발생하는 단편화와, 살아남은 객체를 정리하는 비용이었다. Mark-Compact가 이 문제를 해결하기 위해 등장했지만, Compaction 단계에서 모든 객체를 한쪽으로 밀어 넣는 비용은 또 다른 부담이었다. 이 시절의 GC는 기본적으로 전체를 멈추고(full stop-the-world) 처리했다. 객체가 많아지면 많아질수록 멈추는 시간은 선형으로 증가했다.


실무에서 이 핸드북의 진가가 드러나는 부분은 Generational Collection(세대별 수거) 가설이다. 대부분의 객체는 생성된 지 극히 짧은 시간 안에 죽는다(infant mortality). 이 관찰 하나가 GC 설계의 판을 완전히 뒤집었다. Young Generation과 Old Generation을 분리하고, Young 영역만 자주 GC하면 대부분의 쓰레기를 싼 비용으로 치울 수 있다. HotSpot JVM의 모든 컬렉터가 이 가설 위에 세워져 있다.

그런데 이렇게 단순해 보이는 구조에서도 예외는 존재한다. 객체가 예상보다 오래 살아남아 Old Generation으로 승격(promotion)되는 비율이 높아지면, Full GC의 빈도가 급격히 늘어난다. 이 타이밍을 잘못 맞추면 Old 영역이 살아있는 객체와 쓰레기로 뒤섞인 채 방치되고, 결국 한 번의 Major GC가 수 초 단위의 STW를 유발한다. 이 지점에서 대부분의 운영 장애가 터진다. 핸드북은 이 문제를 GC 로그의 패턴 분석부터 힙 프로파일링까지 체계적으로 설명하는데, 실제로 튜닝이 필요한 프로덕션 상황에서 이 장들이 주는 통찰은 문서만으로는 얻기 어려운 깊이가 있다.


2판이 진정으로 주목할 만한 부분은 동시성(Concurrency)실시간(Real-Time) 컬렉터에 대한 확장이다. 2010년대 후반부터 JVM 생태계는 무거운 STW를 줄이는 방향으로 급격히 전환했다. G1(Garbage-First)이 기본 컬렉터로 자리잡았고, Shenandoah와 ZGC는 힙 크기에 관계없이 거의 일정한 수 밀리초의 일시 정지를 목표로 한다. 이 컬렉터들의 핵심은 삼색 마킹(tri-color marking) 알고리즘의 동기화 기법에 있다. 흰색(미도달), 회색(도달했지만 하위 탐색 전), 검은색(도달+탐색 완료). GC 스레드가 애플리케이션 스레드와 동시에 마킹을 수행하면서도 객체를 놓치지 않으려면, 이 색 전환의 원자성과 메모리 배리어(memory barrier)를 정확히 설계해야 한다.

Shenandoah가 GC 루트 스캔에서부터 Concurrent Mark, Concurrent Evacuation까지 모든 단계를 애플리케이션과 동시에 실행할 수 있었던 비결은 Brooks Pointer라는 간접 참조(indirection)를 도입한 데 있다. 객체가 이동 중이면 Brooks Pointer를 통해 새로운 주소로 리디렉션하고, 모든 참조는 이 포인터를 거쳐 접근된다. ZGC는 여기에 Colored Pointer(주소의 특정 비트를 GC 상태 마킹에 활용)라는 하드웨어 의존적 기법을 더해 메모리 배리어의 오버헤드를 대폭 줄였다. 이 차이 하나가 Shenandoah와 ZGC의 CPU 점유율과 일시 정지 시간 프로파일을 완전히 다르게 만든다. 핸드북은 이런 구현 수준의 차이를 컴파일러 최적화, 캐시 일관성 프로토콜, 심지어 CPU 아키텍처(MESI 프로토콜, Store Buffer, Load-Load 재배열) 수준에서 해석해준다.


자, 그렇다면 GC를 이해하는 것이 실제로 개발자의 코드 작성 방식을 바꿀까? 대답은 단호하게 '그렇다'다. 객체 할당 속도(Object Allocation Rate)가 GC 빈도를 결정하고, Survivor 영역으로의 이동 횟수가 Old 영역의 증가 속도를 결정한다는 사실을 알면, 무의미한 임시 객체 생성을 줄이는 리팩토링이 단순한 '코드 스타일' 문제가 아니라 '운영 비용' 문제로 다가온다. HashMap을 남용하거나 Stream API에서 불필요한 boxing을 반복하는 코드는 Short-lived 객체를 대량으로 만들어내고, 결국 Young GC의 빈도를 높여 애플리케이션 전체의 CPU 사용률을 끌어올린다.

컨테이너 환경에서 GC가 더 까다로워진 점도 놓칠 수 없다. cgroup의 메모리 제한 아래에서 JVM이 힙 크기를 올바르게 감지하지 못하는 고전적인 문제는 UseContainerSupport 플래그로 완화되었지만, 컨테이너의 메모리 limit이 GC의 MaxHeapSize와 정확히 일치하지 않을 때 발생하는 조기 OOM이나 빈번한 Full GC는 여전히 흔한 사고 패턴이다. 이 핸드북은 컨테이너 메모리 모델과 GC의 상호작용을 명시적으로 다루는 몇 안 되는 문헌 중 하나다.


GC 연구의 60년을 한 권으로 압축하는 이 책이 던지는 가장 큰 질문은 아마도 이것일 것이다. 왜 아직도 GC는 '그냥 알아서 처리되는' 수준에 도달하지 못했는가. 그 이유는 근본적으로 메모리 관리가 단순한 기술적 과제가 아니라, 트레이드오프의 삼각형이기 때문이다. Throughput(처리량), Latency(일시 정지 시간), Footprint(메모리 사용량). 이 세 축을 동시에 최적화할 수 있는 컬렉터는 존재하지 않는다. GC의 역사는 이 삼각형 위에서 어느 축을 희생할 것인지를 선택해온 기록이다.

Parallel GC는 Throughput에 집중했다. G1은 Latency와 Throughput 사이의 균형을 잡으려 했다. Shenandoah와 ZGC는 Latency를 극단적으로 낮추는 대신 CPU 오버헤드와 메모리 사용량을 감수한다. Epsilon(no-op GC)은 아예 GC 자체를 포기함으로써 극한의 Throughput과 최소의 Latency를 동시에 얻지만, 대신 메모리 부족을 개발자가 직접 관리해야 한다. 이 선택지들 각각이 왜 존재하는지, 어떤 상황에서 어떤 컬렉터를 선택해야 하는지를 체계적으로 이해할 수 있는 유일한 입문서가 이 핸드북이다.


마지막 장에서 이 책은 미래를 조망한다. 하드웨어 트랜잭셔널 메모리(HTM), 지속성 메모리(persistent memory), 그리고 비순차 메모리 할당(non-moving collectors)이 GC의 다음 10년을 어떻게 바꿀지에 대한 논의는, 아직 실무에 적용되기 전 단계지만 이미 방향성을 제시한다. 하드웨어의 발전이 소프트웨어의 복잡성을 일부 흡수하는 패턴은 GC 역사에서 반복되어 왔다. ZGC의 Colored Pointer가 ARM의 Top-Byte Ignore 기능을 활용하는 식으로 말이다.

결국 GC는 "메모리를 대신 관리해준다"는 기쁨과 "정확히 언제 어떻게 멈출지 모른다"는 고통이 공존하는 세계다. 이 고통을 단순히 감내하거나 운에 맡기는 대신, 그 내부의 메커니즘을 이해하고 예측할 수 있다면 이야기는 달라진다. GC 튜닝이 더 이상 '신비로운 흑마법'이 아니라 '측정 가능한 엔지니어링 결정'으로 바뀌는 순간, 핸드북은 그 전환점에 놓인 가장 실용적인 도구가 된다. 읽기 전에는 몰랐던 GC 로그 한 줄의 의미를, 읽고 나서는 직감으로 읽어낼 수 있다면, 그 책은 자기 역할을 다한 것이다.

댓글

댓글을 읽어오는 중입니다.

같이 읽으면 좋은 글

방금 읽은 주제와 이어지는 글을 골랐습니다.

Backend 전체 보기
Backend

DB 폴링과 작별할 시간을 아는 기술

DB 폴링만으로 버티던 서비스가 커넥션 풀 고갈과 작업 지연 누적이라는 벽에 부딪히는 순간이 온다. 이 글은 그 신호를 감지하는 구체적인 기준과 함께, Redis/BullMQ 등 메시지 큐 도입 시 실제로 발생하는 consumer 확장·재시도·데드레터 관리 같은 운영 부담을 생략 없이 다룬다. 큐 도입은 해결책이자 새 책임이며, '정말 필요한가'를 판단하는 실용적인 기준을 제시한다.

#메시지 큐#DB 폴링#Redis#비동기 처리
Backend

앱에선 안 되는데 SQL 에디터에선 되는 RLS의 반전

Supabase에서 RLS를 활성화하면 클라이언트 요청과 SQL 에디터 직접 조회의 결과가 달라지는 이유를 권한 체계부터 추적한다. anon 키와 service_role 키의 차이, auth.uid()가 기대한 사용자 ID를 반환하지 않는 조건, 그리고 정책이 충돌할 때 PostgreSQL이 선택하는 동작 방식을 실제 사례 중심으로 분석한다. 마지막으로 RLS 정책을 로컬과 프로덕션에서 안전하게 테스트하는 절차를 정리했다.

#Supabase#RLS#PostgreSQL#Row Level Security

이전 글

프론트엔드 개발자가 배포에서 벗어나는 순간

다음 글

로컬 AI가 당신 대신 HTML을 쓰는 시대, 마크다운은 초안으로 충분하다

DevInsight Digest

새 글이 쌓이면, 피드에서 바로 이어 읽으세요.

과장된 알림 대신 발행한 글 전체를 RSS로 제공합니다.

RSS 구독