Go 가비지 컬렉터의 동작과 비용, GOGC와 메모리 제한, 지연 시간, 최적화 방법을 이해하기 위한 고급 안내서.
URL: https://go.dev/doc/gc-guide
Title: A Guide to the Go Garbage Collector - The Go Programming Language
| 목차(Table of Contents) |
|---|
| 소개Go 값이 존재하는 위치추적 기반 가비지 컬렉션GC 사이클비용 이해하기GOGC메모리 제한지연 시간파이널라이저, 클린업, 약한 포인터추가 자료 |
이 가이드는 Go 가비지 컬렉터에 대한 통찰을 제공하여, 고급 Go 사용자가 애플리케이션의 비용을 더 잘 이해하도록 돕기 위한 것입니다. 또한 이러한 통찰을 활용해 Go 사용자가 애플리케이션의 자원 사용 효율을 개선할 수 있도록 안내합니다. 이 문서는 가비지 컬렉션에 대한 사전 지식을 요구하지 않지만, Go 프로그래밍 언어에 대한 친숙함은 전제합니다.
Go 언어는 Go 값의 저장을 배치하는 책임을 집니다. 대부분의 경우 Go 개발자는 값이 어디에 저장되는지, 또는 (설령 그렇다 하더라도) 왜 거기에 저장되는지에 대해 신경 쓸 필요가 없습니다. 하지만 실제로는 이러한 값들이 흔히 컴퓨터의 물리 메모리(physical memory) 에 저장되어야 하며, 물리 메모리는 유한한 자원입니다. 유한하기 때문에, Go 프로그램을 실행하는 동안 메모리가 고갈되지 않도록 메모리를 신중하게 관리하고 재활용해야 합니다. 필요에 따라 메모리를 할당하고 재활용하는 것은 Go 구현체의 역할입니다.
메모리를 자동으로 재활용하는 또 다른 용어가 가비지 컬렉션(garbage collection) 입니다. 높은 수준에서 가비지 컬렉터(collector, 줄여서 GC)는 애플리케이션을 대신해 더 이상 필요하지 않은 메모리 영역을 식별하여 재활용하는 시스템입니다. Go 표준 툴체인은 모든 애플리케이션과 함께 배포되는 런타임 라이브러리를 제공하며, 이 런타임 라이브러리에는 가비지 컬렉터가 포함되어 있습니다.
이 가이드에서 설명하는 형태의 가비지 컬렉터가 존재한다는 것은 Go 명세에 의해 보장되지 않는다는 점에 유의하세요. 명세가 보장하는 것은 Go 값의 기반 저장소가 언어 자체에 의해 관리된다는 사실뿐입니다. 이러한 생략은 의도적인 것으로, 근본적으로 다른 메모리 관리 기법을 사용할 수 있게 해 줍니다.
따라서 이 가이드는 Go 프로그래밍 언어의 특정 구현에 대한 것이며 다른 구현에는 적용되지 않을 수 있습니다. 구체적으로, 이 가이드는 표준 툴체인(gc Go 컴파일러 및 도구)에 적용됩니다. Gccgo와 Gollvm도 매우 유사한 GC 구현을 사용하므로 많은 개념이 적용되지만, 세부 사항은 달라질 수 있습니다.
또한 이 문서는 살아있는(living) 문서로, Go 최신 릴리스를 가장 잘 반영하기 위해 시간이 지남에 따라 변경될 것입니다. 현재 이 문서는 Go 1.19 시점의 가비지 컬렉터를 설명합니다.
GC로 들어가기 전에, 먼저 GC가 관리할 필요가 없는 메모리에 대해 이야기해 보겠습니다.
예를 들어, 지역 변수에 저장된 비포인터(non-pointer) Go 값은 Go GC에 의해 전혀 관리되지 않을 가능성이 큽니다. 대신 Go는 값이 생성된 어휘적 스코프(lexical scope)에 결합된 메모리를 할당하도록 배치합니다. 일반적으로 이는 GC에 의존하는 것보다 효율적입니다. Go 컴파일러가 그 메모리를 언제 해제할 수 있는지 미리 결정할 수 있고, 정리(clean up)를 수행하는 기계어 명령을 생성할 수 있기 때문입니다. 보통 이런 방식으로 Go 값에 메모리를 할당하는 것을 (공간이 고루틴 스택에 저장되므로) “스택 할당(stack allocation)”이라고 부릅니다.
Go 컴파일러가 값의 수명을 결정할 수 없어 이런 방식으로 할당할 수 없는 Go 값은 힙으로 이스케이프(escape to the heap) 한다고 말합니다. “힙(the heap)”은 Go 값이 어딘가 에 놓여야 할 때를 위한, 메모리 할당의 범용 수용소(catch-all)로 볼 수 있습니다. 힙에 메모리를 할당하는 행위는 보통 “동적 메모리 할당(dynamic memory allocation)”이라고 부르는데, 이는 컴파일러와 런타임이 해당 메모리가 어떻게 사용되고 언제 정리될지에 대해 거의 가정할 수 없기 때문입니다. 여기서 GC가 등장합니다. GC는 동적 메모리 할당을 식별하고 정리하는 시스템입니다.
Go 값이 힙으로 이스케이프해야 하는 이유는 다양합니다. 예를 들어 크기가 동적으로 결정되는 경우가 있습니다. 상수(constant)가 아니라 변수(variable)로 초기 크기가 정해지는 슬라이스의 backing array를 생각해 보세요. 또한 힙으로의 이스케이프는 전이적(transitive)이어야 합니다. 즉, 이미 이스케이프한다고 결정된 다른 Go 값 안에 어떤 Go 값에 대한 참조가 기록되면, 그 값 역시 이스케이프해야 합니다.
어떤 Go 값이 이스케이프하는지 여부는 그 값이 사용되는 컨텍스트와 Go 컴파일러의 escape analysis 알고리즘의 결과입니다. 값이 언제 이스케이프하는지를 정확히 열거하려고 하는 것은 취약하고 어렵습니다. 알고리즘 자체가 꽤 정교하고 Go 릴리스마다 바뀌기 때문입니다. 어떤 값이 이스케이프하고 어떤 값이 그렇지 않은지 식별하는 방법에 대한 자세한 내용은 힙 할당 제거 섹션을 참고하세요.
가비지 컬렉션은 메모리를 자동으로 재활용하는 여러 방법을 가리킬 수 있습니다. 예를 들어 참조 카운팅(reference counting)이 있습니다. 이 문서의 맥락에서 가비지 컬렉션은 포인터를 전이적으로 따라가며 사용 중인(so-called live) 객체를 식별하는 추적(tracing) 기반 가비지 컬렉션을 의미합니다.
용어를 더 엄밀히 정의해 봅시다.
객체(Object) — 객체는 하나 이상의 Go 값을 포함하는, 동적으로 할당된 메모리 조각입니다.
포인터(Pointer) — 객체 안의 어떤 값이든 참조하는 메모리 주소입니다. 이는 자연스럽게 *T 형태의 Go 값을 포함하지만, 내장(built-in) Go 값의 일부도 포함합니다. 문자열, 슬라이스, 채널, 맵, 인터페이스 값 모두 GC가 추적해야 하는 메모리 주소를 포함합니다.
객체와 다른 객체를 가리키는 포인터가 함께 객체 그래프(object graph) 를 형성합니다. 살아있는(live) 메모리를 식별하기 위해 GC는 프로그램의 루트(roots) 에서 시작해 객체 그래프를 순회합니다. 루트는 프로그램이 확실히 사용 중인 객체를 가리키는 포인터입니다. 루트의 두 예로 지역 변수와 전역 변수가 있습니다. 객체 그래프를 순회하는 과정을 스캐닝(scanning) 이라고 합니다. Go 문서에서 종종 객체가 도달 가능(reachable) 한지 여부를 보게 될 텐데, 이는 스캐닝 과정으로 그 객체를 발견할 수 있다는 뜻입니다. 또한, 한 가지 예외를 제외하면, 메모리가 도달 불가능(unreachable)해지면 계속 도달 불가능 상태로 남습니다.
이 기본 알고리즘은 모든 추적 기반 GC에서 공통입니다. 추적 기반 GC가 달라지는 지점은, 메모리가 live임을 발견한 뒤 무엇을 하느냐입니다. Go의 GC는 마크-스윕(mark-sweep) 기법을 사용합니다. 즉 진행 상황을 추적하기 위해, GC는 만나는 값을 live로 마킹(marking) 합니다. 추적이 끝나면, GC는 힙의 모든 메모리를 순회하면서 마킹되지 않은(즉 live가 아닌) 메모리를 할당 가능하도록 만듭니다. 이 과정을 스윕(sweeping) 이라고 합니다.
여러분이 익숙할 수 있는 대안 기법으로는 객체를 메모리의 새로운 위치로 실제로 이동 시키고, 이후 애플리케이션의 모든 포인터를 갱신하는 데 사용되는 포워딩 포인터(forwarding pointer)를 남겨두는 방식이 있습니다. 이런 방식으로 객체를 이동시키는 GC를 이동(moving) GC라고 부릅니다. Go는 비이동(non-moving) GC입니다.
Go GC는 마크-스윕 GC이기 때문에, 크게 두 단계로 동작합니다: 마크 단계(mark phase)와 스윕 단계(sweep phase). 이 말은 당연해 보일 수 있지만 중요한 통찰이 담겨 있습니다. 모든 메모리가 추적되기 전에는 메모리를 다시 할당 가능한 상태로 되돌릴 수 없습니다. 아직 스캔되지 않은 포인터가 어떤 객체를 살아 있게 만들 수 있기 때문입니다. 결과적으로 스윕 동작은 마킹 동작과 완전히 분리되어야 합니다. 더 나아가, GC 관련 작업이 없을 때는 GC가 전혀 활성화되지 않을 수도 있습니다. GC는 GC 사이클(GC cycle) 로 알려진 과정에서 스윕, 오프(off), 마크의 세 단계(phase)를 지속적으로 순환합니다. 이 문서에서는 GC 사이클을 스윝부터 시작해 오프로 전환한 뒤 마크로 가는 것으로 생각하겠습니다.
다음 몇 섹션에서는 사용자가 GC 파라미터를 자신에게 유리하게 조정(tweak)하는 데 도움이 되도록, GC 비용에 대한 직관을 쌓는 데 집중합니다.
GC는 본질적으로 복잡한 소프트웨어이며, 더 복잡한 시스템 위에 구축되어 있습니다. GC를 이해하고 동작을 조정하려 하면 세부 사항에 빠져들기 쉽습니다. 이 섹션은 Go GC 비용과 튜닝 파라미터에 대해 사고할 수 있는 프레임워크를 제공하는 것이 목적입니다.
먼저, 세 가지 단순한 공리(axiom)에 기반한 GC 비용 모델을 생각해 봅시다.
GC가 관여하는 자원은 두 가지뿐입니다: 물리 메모리와 CPU 시간.
GC의 메모리 비용은 이전 사이클에서 살아있는 힙 메모리(live heap memory), 마크 단계 전까지 새로 할당된 힙 메모리(new heap memory), 그리고 메타데이터 공간(앞선 비용에 비례하더라도 상대적으로 작음)으로 구성됩니다.
GC 메모리 비용(사이클 N) = 사이클 N-1의 live heap + new heap
Live heap 메모리는 이전 GC 사이클에서 live로 판단된 메모리이며, new heap 메모리는 현재 사이클에서 애플리케이션이 할당한 모든 메모리로, 사이클 끝에 live일 수도 dead일 수도 있습니다. 어떤 시점에 얼마만큼의 메모리가 live인지 여부는 프로그램의 성질이며, GC가 직접 제어할 수 있는 것이 아닙니다.
GC CPU 시간(사이클 N) = 사이클당 고정 CPU 시간 비용 + 바이트당 평균 CPU 시간 비용 * 사이클 N에서 발견된 live heap 메모리
사이클당 고정 CPU 비용에는 다음 사이클을 위한 데이터 구조 초기화처럼, 매 사이클 일정 횟수로 발생하는 작업이 포함됩니다. 이 비용은 일반적으로 작으며, 완전성을 위해 포함합니다.
GC의 CPU 비용 대부분은 마킹과 스캐닝이며, 이는 한계 비용에 반영됩니다. 마킹과 스캐닝의 평균 비용은 GC 구현뿐 아니라 프로그램의 동작에도 좌우됩니다. 예를 들어 포인터가 많을수록 GC 작업이 늘어나는데, 최소한 GC는 프로그램의 모든 포인터를 방문해야 하기 때문입니다. 연결 리스트나 트리 같은 구조는 GC가 병렬로 순회하기 더 어려워 바이트당 평균 비용을 증가시킵니다.
이 모델은 스윕 비용을 무시합니다. 스윝 비용은 총 힙 메모리(죽은 메모리 포함; 할당 가능하도록 만들어야 하므로)에 비례합니다. Go의 현재 GC 구현에서는 스윝이 마킹/스캐닝보다 훨씬 빠르기 때문에, 비교하면 비용이 무시할 만합니다.
이 모델은 단순하지만 효과적입니다. GC의 지배적인(dominant) 비용을 정확히 분류합니다. 또한 가비지 컬렉터의 총 CPU 비용 이 일정 시간 동안 수행된 GC 사이클 수에 의존한다는 사실도 알려줍니다. 마지막으로, 이 모델에는 GC의 근본적인 시간/공간(time/space) 트레이드오프가 내재되어 있습니다.
이를 보기 위해, 제한적이지만 유용한 시나리오인 정상 상태(steady state) 를 살펴봅시다. GC 관점에서 애플리케이션의 정상 상태는 다음 속성으로 정의됩니다.
즉 GC 관점에서 애플리케이션의 워크로드가 시간에 따라 대략 동일해 보입니다. 예를 들어 웹 서비스라면, 요청률이 일정하고 평균적으로 같은 종류의 요청이 들어오며, 각 요청의 평균 수명이 대략 일정한 경우입니다.
즉 객체 그래프의 통계(객체 크기 분포, 포인터 수, 자료구조 평균 깊이 등)가 사이클마다 동일합니다.
예시를 따라가 봅시다. 어떤 애플리케이션이 정상 상태에서 초당 10 MiB를 할당하고, GC가 100 MiB/cpu-second의 속도로 메모리를 스캔할 수 있다고 가정합시다(가정 값). 정상 상태는 live 힙 크기에 대해 가정하지 않지만, 단순화를 위해 이 애플리케이션의 live 힙이 항상 10 MiB라고 합시다. 또한 단순화를 위해 고정 GC 비용을 0이라고 합시다. GC 사이클 주기를 바꿔가며 생각해 보겠습니다.
각 GC 사이클이 정확히 1 cpu-second 뒤에 발생한다고 합시다. 그러면 각 GC 사이클이 끝날 때까지 애플리케이션은 10 MiB의 추가 메모리를 할당하게 되고, 총 힙 크기는 20 MiB가 됩니다. 그리고 매 GC 사이클마다 GC는 10 MiB의 live 힙을 스캔하는 데 0.1 cpu-second를 사용하게 되어 CPU 오버헤드는 10%가 됩니다. GC는 전체 힙이 아니라 live 힙만 순회하면 된다는 점을 기억하세요. (참고: live 힙이 일정하다고 해서 새로 할당된 메모리가 모두 dead라는 뜻은 아닙니다. GC가 실행된 후에, 기존과 신규 힙 메모리가 섞인 어떤 조합 이 죽고, 결과적으로 매 사이클 10 MiB가 live로 남는다는 뜻입니다.)
이제 각 GC 사이클이 더 드물게, 2 cpu-seconds마다 한 번 발생한다고 합시다. 그러면 정상 상태에서 이 애플리케이션은 그 시간 동안 20 MiB를 할당하므로, 각 GC 사이클 시점의 총 힙 크기는 30 MiB가 됩니다. 하지만 매 GC 사이클마다 GC가 live 메모리 10 MiB를 스캔하는 데 필요한 시간은 여전히 0.1 cpu-second 뿐입니다. live 힙 크기가 할당량과 무관하게 동일하다고 가정했기 때문입니다. 따라서 GC 오버헤드는 10%에서 5%로 감소하고, 그 대가로 메모리 사용량이 50% 증가합니다.
오버헤드의 이 변화가 앞서 언급한 근본적인 시간/공간 트레이드오프입니다. 그리고 이 트레이드오프의 중심에는 GC 빈도(GC frequency) 가 있습니다. GC를 더 자주 실행하면 메모리 사용이 줄고, 반대로 덜 자주 실행하면 메모리를 더 씁니다. 그렇다면 GC는 실제로 얼마나 자주 실행될까요? Go에서는 GC가 언제 시작해야 하는지 결정하는 것이 사용자가 제어할 수 있는 주요 파라미터입니다.
높은 수준에서, GOGC는 GC CPU와 메모리 간 트레이드오프를 결정합니다.
GOGC는 각 GC 사이클 이후의 목표 힙 크기(target heap size), 즉 다음 사이클에서의 총 힙 크기 목표값을 결정함으로써 동작합니다. GC의 목표는 총 힙 크기가 목표 힙 크기를 초과하기 전에 컬렉션 사이클을 끝내는 것입니다. 총 힙 크기는 이전 사이클 끝의 live 힙 크기와, 이전 사이클 이후 애플리케이션이 새로 할당한 힙 메모리의 합으로 정의됩니다. 한편 목표 힙 메모리는 다음과 같이 정의됩니다.
목표 힙 메모리 = Live heap + (Live heap + GC roots) * GOGC / 100
예를 들어 live 힙 크기가 8 MiB이고, 고루틴 스택이 1 MiB, 전역 변수 포인터가 1 MiB인 Go 프로그램을 생각해 봅시다. GOGC 값이 100이면 다음 GC가 실행되기 전까지 새로 할당되는 메모리 양은 10 MiB(즉 10 MiB 작업의 100%)가 되어, 총 힙 풋프린트는 18 MiB가 됩니다. GOGC가 50이면 50%인 5 MiB가 됩니다. GOGC가 200이면 200%인 20 MiB가 됩니다.
참고: GOGC가 루트 집합(root set)을 포함하는 것은 Go 1.18부터입니다. 이전에는 live 힙만 계산했습니다. 대개 고루틴 스택의 메모리 양은 매우 작고 live 힙 크기가 다른 GC 작업의 주요 원천이지만, 수십만 개의 고루틴이 있는 프로그램에서는 GC가 잘못 판단하는 문제가 있었습니다.
힙 목표는 GC 빈도를 제어합니다. 목표가 클수록 GC는 다음 마크 단계를 시작하기까지 더 오래 기다릴 수 있고, 그 반대도 마찬가지입니다. 정확한 수식은 추정에 유용하지만, GOGC의 핵심 목적 관점에서 이해하는 것이 가장 좋습니다. 즉 GC CPU와 메모리 트레이드오프에서 한 지점을 선택하는 파라미터입니다. 핵심 요점은 GOGC를 두 배로 늘리면 힙 메모리 오버헤드는 두 배가 되고, GC CPU 비용은 대략 절반이 된다 는 것입니다(반대도 성립). (왜 그런지에 대한 전체 설명은 부록을 참고하세요.)
참고: 목표 힙 크기는 말 그대로 목표일 뿐이며, GC 사이클이 정확히 그 지점에서 끝나지 않을 수 있는 이유가 여럿 있습니다. 예를 들어 충분히 큰 힙 할당은 단번에 목표를 초과할 수 있습니다. 또한 이 가이드가 지금까지 사용한 GC 모델을 넘어서는 GC 구현상의 이유도 있습니다. 더 자세한 내용은 지연 시간 섹션을 참고하되, 완전한 세부 사항은 추가 자료에서 확인할 수 있습니다.
GOGC는 모든 Go 프로그램이 인식하는 GOGC 환경 변수 또는 runtime/debug 패키지의 SetGCPercent API를 통해 설정할 수 있습니다.
또한 GOGC=off로 설정하거나 SetGCPercent(-1)을 호출하여(단, 메모리 제한이 적용되지 않는 경우) GC를 완전히 끌 수도 있습니다. 개념적으로 이 설정은 GOGC를 무한대 값으로 설정한 것과 동일하며, GC 트리거 전까지의 새 메모리 할당량이 무제한이 됩니다.
지금까지 논의한 내용을 더 잘 이해하려면, 아래의 GC 비용 모델에 기반한 인터랙티브 시각화를 사용해 보세요. 이 시각화는 GC 외 작업이 10초의 CPU 시간을 필요로 하는 어떤 프로그램 실행을 묘사합니다. 첫 1초 동안은 초기화 단계(라이브 힙 성장)를 수행한 뒤 정상 상태에 들어갑니다. 애플리케이션은 총 200 MiB를 할당하며, 한 번에 20 MiB가 live입니다. 또한 관련 GC 작업은 live 힙에서만 발생하며, (비현실적으로) 애플리케이션이 추가 메모리를 사용하지 않는다고 가정합니다.
슬라이더로 GOGC 값을 조정해, 총 실행 시간과 GC 오버헤드가 어떻게 바뀌는지 확인하세요. 각 GC 사이클은 new heap이 0으로 떨어질 때 끝납니다. new heap이 0으로 떨어지는 동안의 시간은 사이클 N의 마크 단계와 사이클 N+1의 스윕 단계를 합친 시간입니다. 이 시각화(그리고 이 가이드의 모든 시각화)는 단순화를 위해 GC가 실행되는 동안 애플리케이션이 일시 정지된다고 가정하므로, GC CPU 비용은 new heap 메모리가 0이 되기까지의 시간으로 완전히 표현됩니다. 이는 시각화를 단순하게 하기 위한 것일 뿐이며, 동일한 직관이 그대로 적용됩니다. X축은 프로그램의 전체 CPU 시간 지속 시간을 항상 보여주도록 이동합니다. GC가 추가로 사용한 CPU 시간이 전체 지속 시간을 증가시키는 것을 확인하세요.
GOGC
100
GC는 항상 일정한 CPU 및 피크 메모리 오버헤드를 유발합니다. GOGC가 증가하면 CPU 오버헤드는 감소하지만, 피크 메모리는 live 힙 크기에 비례해 증가합니다. GOGC가 감소하면 피크 메모리 요구량은 감소하지만, 추가 CPU 오버헤드를 치르게 됩니다.
참고: 그래프는 프로그램 완료까지의 벽시계 시간(wall-clock time)이 아니라 CPU 시간을 표시합니다. 프로그램이 1 CPU에서 실행되고 자원을 100% 사용한다면 둘은 동일합니다. 실제 프로그램은 멀티코어 시스템에서 실행되고 CPU를 항상 100% 사용하지 않을 수 있습니다. 이런 경우 GC의 벽시계 시간 영향은 더 낮습니다.
참고: Go GC는 최소 총 힙 크기 4 MiB를 가지므로, GOGC로 설정된 목표가 4 MiB 아래이면 올림 처리됩니다. 시각화는 이 세부 사항을 반영합니다.
좀 더 동적이고 현실적인 예도 있습니다. 역시 GC가 없으면 애플리케이션은 10 CPU-seconds에 완료되지만, 정상 상태의 할당률이 중간 지점에서 크게 증가하고, 첫 단계에서는 live 힙 크기도 조금 변합니다. 이 예는 live 힙 크기가 실제로 변하는 경우 정상 상태가 어떻게 보일 수 있는지, 그리고 할당률이 높을수록 GC 사이클이 더 자주 발생하는지를 보여줍니다.
GOGC
100
Go 1.19 이전까지는 GOGC가 GC 동작을 수정할 수 있는 유일한 파라미터였습니다. GOGC는 트레이드오프를 설정하는 방식으로는 훌륭하지만, 사용 가능한 메모리가 유한하다는 점을 고려하지 않습니다. live 힙 크기가 일시적으로 급증(transient spike)하면, GC가 그 live 힙 크기에 비례한 총 힙 크기를 선택하기 때문에, 보통의 경우에는 더 높은 GOGC 값이 더 나은 트레이드오프를 제공하더라도 GOGC는 피크 live 힙 크기에 맞춰 구성되어야 합니다.
아래 시각화는 이러한 일시적 힙 스파이크 상황을 보여줍니다.
GOGC
100
예시 워크로드가 약 60 MiB 조금 넘는 메모리를 사용할 수 있는 컨테이너에서 실행된다면, 다른 GC 사이클에서는 추가 메모리를 활용할 수 있음에도 GOGC는 100을 넘겨 올릴 수 없습니다. 더 나아가, 어떤 애플리케이션에서는 이런 일시적 피크가 드물고 예측하기 어려워, 가끔 피할 수 없고 잠재적으로 큰 비용을 유발하는 OOM(out-of-memory) 상황이 발생할 수 있습니다.
그래서 Go는 1.19 릴리스에서 런타임 메모리 제한(runtime memory limit) 설정을 지원하기 시작했습니다. 메모리 제한은 모든 Go 프로그램이 인식하는 GOMEMLIMIT 환경 변수 또는 runtime/debug 패키지에 있는 SetMemoryLimit 함수로 설정할 수 있습니다.
이 메모리 제한은 Go 런타임이 사용할 수 있는 총 메모리 양 에 상한을 설정합니다. 포함되는 메모리의 구체적 범위는 runtime.MemStats 기준으로 다음 식으로 정의됩니다.
Sys``-``HeapReleased
또는 runtime/metrics 패키지 기준으로는,
/memory/classes/total:bytes``-``/memory/classes/heap/released:bytes
Go GC는 힙 메모리 사용량을 명시적으로 제어할 수 있기 때문에, 이 메모리 제한과 Go 런타임이 사용하는 다른 메모리의 양에 기반해 총 힙 크기를 설정합니다.
아래 시각화는 GOGC 섹션의 동일한 단일 단계 정상 상태 워크로드를 보여주되, 이번에는 Go 런타임에서 발생하는 10 MiB의 추가 오버헤드와 조정 가능한 메모리 제한을 포함합니다. GOGC와 메모리 제한을 모두 바꿔보며 어떤 일이 생기는지 확인하세요.
GOGC
100
Memory Limit
100.0 MiB
GOGC로 결정되는 피크 메모리(예: GOGC=100이면 42 MiB)보다 메모리 제한을 더 낮게 설정하면, GC가 제한 내에서 피크 메모리를 유지하기 위해 더 자주 실행되는 것을 볼 수 있습니다.
앞서 일시적 힙 스파이크 예시로 돌아가면, 메모리 제한을 설정하고 GOGC를 올려 두 가지를 모두 얻을 수 있습니다: 메모리 제한 초과 없이, 더 나은 자원 경제성(resource economy). 아래 인터랙티브 시각화를 사용해 보세요.
GOGC
100
Memory Limit
100.0 MiB
일부 GOGC와 메모리 제한 값 조합에서는 피크 메모리 사용이 메모리 제한 값에서 멈추지만, 프로그램 실행의 나머지 부분은 여전히 GOGC가 정한 총 힙 크기 규칙을 따른다는 점을 확인할 수 있습니다.
이 관찰은 또 하나의 흥미로운 세부 사항으로 이어집니다. GOGC를 off로 설정해도 메모리 제한은 여전히 준수됩니다! 사실 이 설정은 자원 경제성의 최대화 를 나타내는데, 어떤 메모리 제한을 유지하기 위한 최소 GC 빈도를 설정하기 때문입니다. 이 경우 프로그램 실행 전 구간에서 힙 크기가 메모리 제한까지 상승합니다.
하지만 메모리 제한은 분명 강력한 도구이지만, 메모리 제한 사용에는 비용이 따르며, GOGC의 유용성을 무효화하지는 않습니다.
live 힙이 커져 총 메모리 사용이 메모리 제한에 가까워지는 상황을 생각해 보세요. 위 정상 상태 시각화에서 GOGC를 off로 설정한 뒤 메모리 제한을 점점 더 낮춰보면, 불가능한 메모리 제한을 유지하기 위해 GC가 계속 실행되면서 애플리케이션 총 시간이 무한정 늘어나기 시작하는 것을 볼 수 있습니다.
이처럼 프로그램이 GC 사이클의 연속 실행 때문에 합리적인 진행을 하지 못하는 상황을 스래싱(thrashing) 이라고 합니다. 이는 사실상 프로그램을 정지시키기 때문에 특히 위험합니다. 더 나쁜 점은, GOGC로 피하려 했던 동일한 상황—충분히 큰 일시적 힙 스파이크—이 프로그램을 무기한으로 멈추게 할 수 있다는 것입니다! 일시적 힙 스파이크 시각화에서 메모리 제한을(약 30 MiB 이하로) 줄여보고, 최악의 동작이 특히 힙 스파이크로부터 시작되는 것을 확인해 보세요.
많은 경우, 무기한 정지는 OOM보다 더 나쁠 수 있습니다. OOM은 보통 훨씬 빠른 실패로 이어지기 때문입니다.
이 때문에 메모리 제한은 소프트(soft) 로 정의됩니다. Go 런타임은 모든 상황에서 이 메모리 제한을 유지하겠다고 보장하지 않습니다. 단지 합리적인 정도의 노력을 약속할 뿐입니다. 이 완화는 스래싱을 피하는 데 중요합니다. GC가 빠져나올 길을 제공하기 때문입니다: GC에 너무 많은 시간을 쓰지 않기 위해 메모리 사용이 제한을 초과하도록 허용합니다.
내부적으로는 GC가 어떤 시간 창(time window) 동안 사용할 수 있는 CPU 시간의 상한을 설정합니다(매우 짧은 일시적 CPU 사용 급증에 대한 히스테리시스 포함). 이 상한은 현재 대략 50%로, 2 * GOMAXPROCS CPU-second 창입니다. GC CPU 시간을 제한하면 GC 작업이 지연되고, 그 사이 Go 프로그램은 메모리 제한을 초과하더라도 새 힙 메모리를 계속 할당할 수 있습니다.
GC CPU 50% 제한의 직관은, 충분한 메모리가 있는 프로그램에 대한 최악의 영향에 근거합니다. 메모리 제한을 너무 낮게 잘못 설정한 경우, GC가 CPU 시간의 50% 이상을 가져갈 수 없으므로 프로그램은 최대 2배까지만 느려집니다.
참고: 이 페이지의 시각화는 GC CPU 제한을 시뮬레이션하지 않습니다.
메모리 제한은 강력한 도구이고 Go 런타임은 오용으로 인한 최악의 동작을 완화하려고 하지만, 여전히 신중하게 사용하는 것이 중요합니다. 아래는 메모리 제한이 가장 유용한 곳과 그렇지 않은 곳에 대한 조언 모음입니다.
좋은 예는 고정된 사용 가능 메모리를 가진 컨테이너에 웹 서비스를 배포하는 것입니다.
이 경우, Go 런타임이 인지하지 못하는 메모리 원천을 고려해 5–10% 정도의 여유(headroom)를 남기는 것이 경험칙입니다.
좋은 예는 C 라이브러리가 일시적으로 상당히 더 많은 메모리를 필요로 하는 cgo 프로그램입니다.
함께 사는(co-tenant) 프로그램을 위해 메모리를 “예약”하고 싶을 수 있지만, 프로그램들이 완전히 동기화되어 있지 않다면(예: Go 프로그램이 어떤 서브프로세스를 호출하고, 피호출자가 실행되는 동안 블로킹되는 경우) 결국 둘 다 더 많은 메모리가 필요해져 결과가 덜 신뢰할 수 있습니다. Go 프로그램이 필요하지 않을 때 메모리를 덜 쓰도록 하면 전반적으로 더 신뢰할 수 있는 결과를 만듭니다. 이 조언은 한 머신에서 컨테이너 메모리 제한 합이 실제 물리 메모리를 초과할 수 있는 overcommit 상황에도 적용됩니다.
좋은 예는 CLI 도구나 데스크톱 애플리케이션입니다. 어떤 입력이 들어올지, 시스템에 얼마나 메모리가 있을지 불명확한 상태에서 프로그램에 메모리 제한을 내장하면, 혼란스러운 크래시와 나쁜 성능으로 이어질 수 있습니다. 게다가 고급 사용자는 원하면 직접 메모리 제한을 설정할 수 있습니다.
이는 OOM 위험을 심각한 애플리케이션 성능 저하 위험으로 바꾸는 것이며, Go가 스래싱을 완화하기 위해 노력하더라도 종종 바람직한 트레이드오프가 아닙니다. 이런 경우에는 환경의 메모리 제한을 늘리거나(그 후에 필요하면 메모리 제한 설정) GOGC를 낮추는 것이 훨씬 효과적입니다(GOGC는 스래싱 완화보다 훨씬 깔끔한 트레이드오프를 제공합니다).
이 문서의 시각화는 GC가 실행되는 동안 애플리케이션이 멈춘다고 모델링했습니다. 실제로 이런 방식으로 동작하는 GC 구현도 존재하며, 이를 “stop-the-world” GC라고 부릅니다.
하지만 Go GC는 완전한 stop-the-world가 아니며, 대부분의 작업을 애플리케이션과 동시(concurrently)에 수행합니다. 이는 주로 애플리케이션 지연 시간(latencies) 을 줄이기 위함입니다. 구체적으로 단일 계산 단위(예: 웹 요청)의 엔드투엔드(end-to-end) 지속 시간입니다. 지금까지 이 문서는 주로 애플리케이션 처리량(throughput) (예: 초당 처리하는 웹 요청 수)에 대해 다뤘습니다. GC 사이클 섹션의 각 예시가 실행 중인 프로그램의 총 CPU 지속 시간에 초점을 맞췄다는 점을 떠올려보세요. 하지만 웹 서비스에서는 이런 지속 시간은 훨씬 덜 의미가 있습니다. 처리량이 여전히 중요하지만(QPS 등), 종종 개별 요청의 지연 시간이 더 중요합니다.
지연 시간 관점에서 stop-the-world GC는 마크와 스윕 단계를 실행하는 데 상당한 시간이 필요할 수 있으며, 그 동안 애플리케이션(웹 서비스라면 진행 중인 요청)은 더 이상 진행할 수 없습니다. 반면 Go GC는 전역 애플리케이션 정지(global pause) 길이가 힙 크기에 비례하지 않도록 하고, 핵심 추적 알고리즘을 애플리케이션이 실행 중인 상태에서 수행합니다. (알고리즘적으로는 정지 시간이 GOMAXPROCS에 더 강하게 비례하지만, 가장 흔히는 실행 중인 고루틴을 멈추는 데 걸리는 시간이 지배적입니다.) 동시 컬렉션은 비용이 없지 않아서, 실제로는 동등한 stop-the-world GC보다 처리량이 낮은 설계로 이어지는 경우가 많습니다. 그러나 낮은 지연 시간이 본질적으로 낮은 처리량을 의미하지는 않으며, Go GC 성능은 지연 시간과 처리량 모두에서 시간이 지남에 따라 꾸준히 개선되었습니다.
현재 Go GC의 동시성(concurrent) 특성은 이 문서에서 지금까지 논의한 어떤 내용도 무효화하지 않습니다. 어떤 주장도 이 설계 선택에 의존하지 않았습니다. 처리량에 관해 GC 빈도는 여전히 CPU 시간과 메모리 간 트레이드오프를 결정하는 주요 수단이며, 사실 지연 시간에서도 동일한 역할을 합니다. 이는 GC 비용 대부분이 마크 단계가 활성일 때 발생하기 때문입니다.
따라서 핵심 요점은 GC 빈도를 줄이면 지연 시간도 개선될 수 있다 는 것입니다. 이는 GOGC 및/또는 메모리 제한을 증가시키는 튜닝 파라미터 변경으로 GC 빈도를 줄이는 경우뿐 아니라, 최적화 가이드에 설명된 최적화에도 적용됩니다.
하지만 지연 시간은 처리량보다 이해하기 복잡한 경우가 많습니다. 지연 시간은 비용의 단순 합산이 아니라 프로그램의 순간순간 실행의 결과이기 때문입니다. 그 결과 지연 시간과 GC 빈도 사이의 연결은 덜 직접적입니다. 더 깊이 파고들고 싶은 분을 위해 지연 시간의 가능한 원천을 아래에 나열합니다.
이러한 지연 시간 원천은 포인터 쓰기가 추가 작업을 요구하는 경우를 제외하면 실행 트레이스(execution traces)에서 확인할 수 있습니다.
가비지 컬렉션은 유한한 메모리만으로 무한 메모리의 환상(illusion)을 제공합니다. 메모리는 할당되지만 명시적으로 해제되지 않으며, 이는 최소 수준의 수동 메모리 관리보다 더 단순한 API와 동시 알고리즘을 가능하게 합니다. (수동 메모리 관리 언어 중 일부는 객체가 해제되도록 “스마트 포인터”나 컴파일 타임 소유권 추적 같은 대안 접근을 사용하지만, 이러한 기능은 해당 언어의 API 설계 관례에 깊게 내장되어 있습니다.)
살아있는 객체—전역 변수 또는 어떤 고루틴의 계산에서 도달 가능한 객체—만이 프로그램의 동작에 영향을 줄 수 있습니다. 객체가 도달 불가능(“dead”)해진 이후 어느 시점이든 GC는 이를 안전하게 재활용할 수 있습니다. 이는 오늘날 Go가 사용하는 추적 기반 설계 같은 다양한 GC 설계를 가능하게 합니다. 객체의 죽음(death)은 언어 수준에서 관찰 가능한 사건이 아닙니다.
하지만 Go 런타임 라이브러리는 그 환상을 깨는 세 가지 기능을 제공합니다: 클린업(cleanups), 약한 포인터(weak pointers), 파이널라이저(finalizers). 각 기능은 객체의 죽음을 관찰하고 반응하는 방법을 제공하며, 파이널라이저의 경우 심지어 이를 되돌릴 수도 있습니다. 이는 물론 Go 프로그램을 더 복잡하게 만들고 GC 구현에 추가 부담을 줍니다. 그럼에도 이러한 기능은 다양한 상황에서 유용하기 때문에 존재하며, Go 프로그램은 이를 자주 사용하고 그 혜택을 누립니다.
각 기능의 세부 사항은 해당 패키지 문서(runtime.AddCleanup, weak.Pointer, runtime.SetFinalizer)를 참고하세요. 아래에는 이 기능들을 사용하는 일반적인 조언, 각 기능에서 흔히 겪는 문제의 개요, 그리고 테스트 조언이 있습니다.
클린업, 약한 포인터, 파이널라이저의 정확한 타이밍은 예측하기 어려울 수 있으며, 여러 번 연속 실행한 뒤에도 모든 것이 동작한다고 스스로를 설득하기 쉽습니다. 하지만 미묘한 실수를 하기도 쉽습니다. 이들을 위한 테스트 작성은 까다로울 수 있지만, 사용이 매우 미묘하기 때문에 테스트는 평소보다 더 중요합니다.
이는 미묘한 제약과 동작을 가진 저수준 기능입니다. 예를 들어 클린업이나 파이널라이저가 프로그램 종료 시에 실행된다는 보장이 없고, 심지어 아예 실행된다는 보장도 없습니다. API 문서에 있는 긴 주석은 경고로 받아들여야 합니다. 대다수 Go 코드는 이러한 기능을 직접 사용해서 얻을 이점이 없고, 간접적으로만 이득을 봅니다.
가능한 경우, 이런 메커니즘이 패키지의 공개 API로 새어나오지 않게 하세요. 사용자가 오용하기 어렵거나 불가능하게 만드는 인터페이스를 제공하세요. 예를 들어 사용자가 C로 할당한 메모리에 클린업을 붙여 해제하라고 요구하는 대신, 래퍼 패키지를 작성해 그 세부 사항을 내부로 숨기세요.
이는 이전 항목과 관련되지만, 오류 가능성을 줄이는 매우 강력한 패턴이므로 명시적으로 강조할 가치가 있습니다. 예를 들어 unique 패키지는 내부적으로 약한 포인터를 사용하지만, 약하게 참조되는 객체를 완전히 캡슐화합니다. 이러한 값은 애플리케이션의 나머지 부분에서 절대 변경될 수 없고, 오직 Value 메서드를 통해 복사될 수 있어, 패키지 사용자에게 무한 메모리의 환상을 보존합니다.
클린업과 파이널라이저는 C에서 할당한 메모리나 mmap 매핑 참조 같은 외부 메모리 자원에 적합합니다. C의 malloc으로 할당한 메모리는 결국 C의 free로 해제되어야 합니다. C 메모리를 감싸는 래퍼 객체에 free를 호출하는 파이널라이저를 붙이는 것은, 가비지 컬렉션의 결과로 C 메모리가 결국 회수되게 하는 합리적인 방법입니다.
하지만 파일 디스크립터 같은 비메모리 자원은 일반적으로 Go 런타임이 알지 못하는 시스템 한도의 영향을 받습니다. 또한 특정 Go 프로그램에서 GC 타이밍은 패키지 작성자가 거의 제어할 수 없는 경우가 많습니다(예: GC 실행 빈도는 GOGC가 제어하며, 운영자가 다양한 값으로 설정할 수 있습니다). 이 두 사실 때문에, 클린업과 파이널라이저를 비메모리 자원을 해제하는 유일한 수단으로 쓰는 것은 좋지 않습니다.
비메모리 자원을 감싸는 API를 제공하는 패키지 작성자라면, 클린업/파이널라이저로 GC에 의존하기보다는 Close 메서드 같은 명시적인 해제 API를 제공하는 것을 고려하세요. 대신 클린업/파이널라이저는 프로그래머의 실수를 최선 노력(best-effort)으로 처리하는 핸들러로 사용하세요. 예를 들어 os.File처럼 어쨌든 자원을 정리하거나, 결정적 정리 실패를 사용자에게 보고할 수 있습니다.
역사적으로 파이널라이저는 Go 코드와 C 코드 사이의 인터페이스를 단순화하고 비메모리 자원을 정리하기 위해 추가되었습니다. 의도된 용도는 C 메모리나 다른 비메모리 자원을 소유하는 래퍼 객체에 적용하여, Go 코드가 그 자원 사용을 끝내면 해제될 수 있게 하는 것이었습니다. 이러한 이유는 파이널라이저가 좁은 범위를 갖고, 객체당 하나의 파이널라이저만 가능하며, 그 파이널라이저가 객체의 첫 바이트에만 붙어야 하는 이유를(부분적으로) 설명해 줍니다. 이 제한은 이미 일부 용도를 억제합니다. 예를 들어 어떤 패키지가 내부적으로 전달받은 객체에 대한 정보를 캐시하고 싶다면, 객체가 사라졌을 때 그 정보를 정리할 수 없습니다.
더 나쁜 점은, 파이널라이저는 붙어 있는 객체를 파이널라이저 함수에 전달하기 위해 객체를 부활(resurrect)시키므로 비효율적이고 오류가 발생하기 쉽다는 것입니다(그리고 그 이후에도 계속 살아 있을 수 있습니다). 이 사실 하나만으로도, 객체가 참조 사이클의 일부라면 결코 해제될 수 없고, 객체를 뒷받침하는 메모리는 최소 다음 가비지 컬렉션 사이클까지 재사용될 수 없게 됩니다.
다만 객체를 부활시키기 때문에 파이널라이저는 클린업보다 실행 순서가 더 잘 정의됩니다. 이런 이유로, 파괴(destruction) 순서가 복잡하게 요구되는 구조를 정리하는 데 파이널라이저가 (드물게) 유용할 수 있습니다.
하지만 Go 1.24 및 그 이후의 대부분의 사용 사례에서는, 더 유연하고 오류가 적으며 더 효율적인 클린업을 사용할 것을 권장합니다.
gof := new(myFile) f.fd = syscall.Open(...) runtime.AddCleanup(f, func(fd int) { syscall.Close(f.fd) // 실수: f를 참조하므로 이 클린업은 실행되지 않습니다! }, f.fd)
gof := new(myFile) f.fd = syscall.Open(...) runtime.AddCleanup(f, func(f *myFile) { syscall.Close(f.fd) }, f) // 실수: f를 참조하므로 이 클린업은 실행되지 않습니다. 이 경우는 또한 panic이 납니다.
파이널라이저는 실행 순서가 잘 정의되어 있지만, 클린업은 그렇지 않습니다. 또한 클린업은 서로 동시에 실행될 수도 있습니다.
오래 걸리는 클린업은 다른 클린업 실행을 막지 않도록 고루틴을 생성해야 합니다.
runtime.GC는 도달 불가능 객체의 클린업이 실행될 때까지 기다리지 않고, 큐에 들어갈 때까지만 기다립니다.
약한 포인터의 Value 메서드는 예상치 못한 시점부터 nil을 반환하기 시작할 수 있습니다. Value 호출은 항상 nil 체크로 보호하고, 백업 계획을 마련하세요.
약한 포인터를 맵 키로 사용할 때, 맵 값의 도달 가능성에는 영향을 주지 않습니다. 따라서 약한 포인터 키가 가리키는 객체가 맵 값에서도 도달 가능하다면, 그 객체는 여전히 도달 가능으로 간주됩니다.
gof := new(myCycle) f.self = f // 실수: f가 f로부터 도달 가능하므로 이 파이널라이저는 실행되지 않습니다. runtime.SetFinalizer(f, func(f *myCycle) { ... })
gof := new(myFile) f.fd = syscall.Open(...) runtime.SetFinalizer(f, func(_ *myFile) { syscall.Close(f.fd) // 실수: 바깥 f를 참조하므로 이 클린업은 실행되지 않습니다! })
go// 실수: 이 연결 리스트를 회수하려면 최소 10번의 GC 사이클이 필요합니다. node := new(linkedListNode) for range 10 { tmp := new(linkedListNode) tmp.next = node node = tmp runtime.SetFinalizer(node, func(node *linkedListNode) { ... }) }
패키지 경계를 넘어 반환되는 객체에 파이널라이저를 붙이는 것을 피하세요. 그러면 패키지 사용자가 runtime.SetFinalizer를 호출해 여러분이 반환한 객체의 파이널라이저를 변경할 수 있어, 사용자에게 예상치 못한 동작이 되고, 심지어 그 동작에 의존할 수도 있습니다.
오래 걸리는 파이널라이저는 다른 파이널라이저 실행을 막지 않도록 새 고루틴을 생성해야 합니다.
runtime.GC는 도달 불가능 객체의 파이널라이저가 실행될 때까지 기다리지 않고, 큐에 들어갈 때까지만 기다립니다.
이 기능들을 사용할 때, 이를 사용하는 코드에 대한 테스트 작성이 까다로울 수 있습니다. 아래는 견고한 테스트를 위한 팁입니다.
runtime.GC로 베이스라인을 만드세요. runtime.GC를 사용해 약한 포인터를 nil로 만들고, 클린업/파이널라이저를 실행 큐에 넣으세요.runtime.GC는 클린업과 파이널라이저 실행을 기다리지 않고, 오직 큐잉만 합니다.가장 견고한 테스트를 작성하려면, 테스트에서 클린업 또는 파이널라이저가 끝날 때까지 블록(block)할 수 있는 방법을 주입(inject)하세요(예: 테스트에서 선택적으로 채널을 클린업/파이널라이저에 전달하고 실행이 끝났을 때 채널에 쓰도록). 이것이 너무 어렵거나 불가능하다면, 대안으로 특정 post-cleanup 상태가 참이 될 때까지 스핀(spin)하는 방법이 있습니다. 예를 들어 os 테스트는 파일이 닫혔는지 확인하는 루프에서 runtime.Gosched를 호출합니다(파일이 도달 불가능해진 후).
파이널라이저를 사용하는 테스트에서 파이널라이저가 붙은 객체 사슬이 있다면, 모든 파이널라이저가 실행되도록 하려면 최소한 테스트가 만들 수 있는 가장 긴 사슬 길이만큼 runtime.GC를 호출해야 합니다.
레이스 모드로 테스트해, 동시에 실행되는 클린업 간의 레이스와 클린업/파이널라이저 코드와 코드베이스 나머지 사이의 레이스를 발견하세요.
위 정보는 정확하지만, Go GC 설계의 비용과 트레이드오프를 완전히 이해하기에는 세부 사항이 부족합니다. 더 많은 정보를 원한다면 아래 자료를 참고하세요.
이 가이드는 주로 GC의 물리 메모리 사용에 초점을 맞췄지만, 자주 등장하는 질문 중 하나는 그것이 정확히 무엇을 의미하며 가상 메모리(보통 top 같은 프로그램에서 “VSS”로 표시)와 어떻게 비교되는지입니다.
물리 메모리는 대부분의 컴퓨터에서 실제 RAM 칩에 있는 메모리입니다. 가상 메모리(virtual memory)는 운영체제가 제공하는 물리 메모리의 추상화로, 프로그램을 서로 격리합니다. 또한 물리 주소에 전혀 매핑되지 않는 가상 주소 공간을 프로그램이 예약(reserve)하는 것도 보통 허용됩니다.
가상 메모리는 운영체제가 유지하는 매핑(mapping)일 뿐이므로, 물리 메모리에 매핑되지 않는 큰 가상 메모리 예약은 일반적으로 매우 저렴합니다.
Go 런타임은 다음 몇 가지 방식으로 이러한 가상 메모리 비용 관점을 활용합니다.
이 기법은 메모리 제한을 관리하고, Go 런타임이 더 이상 필요로 하지 않는 메모리를 OS에 반환하는 데 명시적으로 사용됩니다. Go 런타임은 또한 더 이상 필요 없는 메모리를 백그라운드에서 지속적으로 해제합니다. 자세한 내용은 추가 자료를 참고하세요.
32비트 플랫폼에서 Go 런타임은 힙 단편화 문제를 줄이기 위해 힙을 위해 128 MiB에서 512 MiB 사이의 주소 공간을 미리 예약합니다.
Go 런타임은 여러 내부 데이터 구조 구현에서 큰 가상 메모리 주소 공간 예약을 사용합니다. 64비트 플랫폼에서는 보통 최소 가상 메모리 풋프린트가 약 700 MiB입니다. 32비트 플랫폼에서는 풋프린트가 무시할 정도로 작습니다.
그 결과 top의 “VSS” 같은 가상 메모리 지표는 Go 프로그램의 메모리 풋프린트를 이해하는 데 보통 유용하지 않습니다. 대신 물리 메모리 사용을 더 직접적으로 반영하는 “RSS” 같은 측정값에 집중하세요.
Go 애플리케이션이 GC와 상호작용하는 방식을 최적화하기 전에, 먼저 GC가 실제로 주요 비용인지부터 확인하는 것이 중요합니다.
Go 생태계는 비용을 식별하고 Go 애플리케이션을 최적화하기 위한 여러 도구를 제공합니다. 이러한 도구의 간단한 개요는 진단 가이드를 참고하세요. 여기서는 이 도구들 중 일부와, GC 영향 및 동작을 이해하기 위해 적용하기에 합리적인 순서를 중심으로 설명합니다.
시작하기 좋은 지점은 CPU 프로파일링입니다. CPU 프로파일링은 CPU 시간이 어디에 쓰이는지 개요를 제공하지만, 익숙하지 않다면 GC가 특정 애플리케이션에서 얼마나 큰 역할을 하는지 파악하기 어려울 수 있습니다. 다행히 GC를 이해하는 것은 runtime 패키지의 여러 함수가 무엇을 의미하는지 아는 것으로 대부분 귀결됩니다. 아래는 CPU 프로파일 해석에 유용한 함수들의 일부입니다.
아래 함수들은 리프(leaf) 함수가 아니므로, pprof 도구의 기본 top 명령에서는 보이지 않을 수 있습니다. 대신 top -cum 명령을 사용하거나 해당 함수를 list 명령으로 직접 보고 누적(cumulative) 퍼센트 열에 집중하세요.
* **`runtime.gcBgMarkWorker`**: 백그라운드 마크 워커 고루틴의 진입점입니다. 여기서 소비되는 시간은 GC 빈도와 객체 그래프의 복잡도/크기에 따라 스케일링됩니다. 애플리케이션이 마킹과 스캐닝에 얼마나 많은 시간을 쓰는지에 대한 기준선을 나타냅니다.
이 고루틴 내부에는 워커 타입을 나타내는 `runtime.gcDrainMarkWorkerDedicated`, `runtime.gcDrainMarkWorkerFractional`, `runtime.gcDrainMarkWorkerIdle` 호출이 보일 것입니다. 대체로 유휴 상태인 Go 애플리케이션에서는, Go GC가 작업을 더 빨리 끝내기 위해 추가(유휴) CPU 자원을 사용하려고 하며 이는 `runtime.gcDrainMarkWorkerIdle` 심볼로 나타납니다. 그 결과 여기에 소비되는 시간이 CPU 샘플의 큰 비율을 차지할 수 있는데, Go GC는 이를 “공짜”라고 판단합니다. 애플리케이션이 더 활동적이 되면 유휴 워커의 CPU 시간은 감소합니다. 이런 일이 생기는 흔한 이유 중 하나는 애플리케이션이 사실상 하나의 고루틴에서만 실행되지만 `GOMAXPROCS`가 1보다 큰 경우입니다.
* **`runtime.mallocgc`**: 힙 메모리를 위한 메모리 할당기의 진입점입니다. 여기서의 누적 시간이 많이(>15%) 나타나면 보통 매우 많은 메모리가 할당되고 있음을 의미합니다.
* **`runtime.gcAssistAlloc`**: 고루틴이 자신의 시간을 일부 양보하여 GC의 스캐닝/마킹을 돕기 위해 들어가는 함수입니다. 여기서 누적 시간이 많이(>5%) 나타나면, 애플리케이션의 할당 속도가 GC 처리 속도를 앞지르고 있을 가능성이 큽니다. 이는 GC의 영향도가 특히 크다는 신호이며, 애플리케이션이 마킹과 스캐닝에 소비하는 시간을 나타내기도 합니다. 또한 이는 `runtime.mallocgc` 호출 트리에 포함되므로, 그 수치도 함께 부풀릴 수 있습니다.
2. 실행 트레이스
CPU 프로파일은 집계된 관점에서 시간이 어디에 쓰이는지 식별하는 데 훌륭하지만, 더 미묘하거나 드물거나, 지연 시간과 관련된 성능 비용을 보여주는 데는 덜 유용합니다. 반면 실행 트레이스는 Go 프로그램 실행의 짧은 구간에 대한 풍부하고 깊은 뷰를 제공합니다. 다양한 Go GC 관련 이벤트를 포함하며, 특정 실행 경로를 직접 관찰할 수 있고 애플리케이션이 Go GC와 상호작용하는 방식도 볼 수 있습니다. 추적되는 GC 이벤트는 트레이스 뷰어에서 편리하게 GC로 라벨링됩니다.
실행 트레이스를 시작하는 방법은 runtime/trace 패키지 문서를 참고하세요.
다른 방법으로는 부족할 때, Go GC는 GC 동작에 대한 훨씬 깊은 통찰을 제공하는 몇 가지 특수 트레이스를 제공합니다. 이 트레이스는 항상 STDERR에 GC 사이클당 한 줄씩 출력되며, 모든 Go 프로그램이 인식하는 GODEBUG 환경 변수로 설정합니다. GC 구현의 세부 사항에 대한 친숙함이 필요하므로 주로 Go GC 자체 디버깅에 유용하지만, 경우에 따라 GC 동작을 더 잘 이해하는 데 도움이 될 수 있습니다.
핵심 GC 트레이스는 GODEBUG=gctrace=1로 활성화합니다. 출력 형식은 runtime 패키지 문서의 환경 변수 섹션에 문서화되어 있습니다.
“페이서 트레이스(pacer trace)”라고 불리는 보조 GC 트레이스는 더 깊은 통찰을 제공하며, GODEBUG=gcpacertrace=1로 활성화합니다. 이 출력 해석에는 GC의 “페이서(pacer)”에 대한 이해가 필요합니다( 추가 자료 참고). 이는 이 가이드 범위를 벗어납니다.
GC 비용을 줄이는 한 가지 방법은, 애초에 GC가 관리해야 하는 값의 수를 줄이는 것입니다. 아래에 설명하는 기법은 가장 큰 성능 개선을 만들 수 있는데, GOGC 섹션에서 보았듯이 Go 프로그램의 할당률(allocation rate)은 GC 빈도의 주요 요인이기 때문입니다. 이 가이드에서 핵심 비용 지표는 GC 빈도입니다.
GC가 큰 비용 원천임을 식별한 뒤, 힙 할당 제거의 다음 단계는 대부분의 할당이 어디서 오는지 찾는 것입니다. 이 목적에는 메모리 프로파일(정확히는 힙 메모리 프로파일)이 매우 유용합니다. 시작 방법은 문서를 참고하세요.
메모리 프로파일은 힙 할당이 프로그램 어디에서 오는지 설명하며, 할당 시점의 스택 트레이스로 식별합니다. 각 메모리 프로파일은 힙 메모리를 네 가지 관점으로 분해할 수 있습니다.
inuse_objects — 살아있는 객체 수를 분해.inuse_space — 살아있는 객체가 사용하는 바이트 수로 분해.alloc_objects — Go 프로그램 시작 이후 할당된 객체 수를 분해.alloc_space — Go 프로그램 시작 이후 할당된 총 바이트 수를 분해.이 뷰들은 pprof 도구의 -sample_index 플래그나, 인터랙티브 모드의 sample_index 옵션으로 전환할 수 있습니다.
참고: 메모리 프로파일은 기본적으로 힙 객체의 일부만 샘플링하므로 모든 힙 할당에 대한 정보를 포함하지는 않습니다. 하지만 핫스팟을 찾기에는 충분합니다. 샘플링 비율을 바꾸려면 runtime.MemProfileRate를 참고하세요.
GC 비용을 줄이는 목적에서는 alloc_space가 보통 가장 유용한데, 이는 할당률과 직접 대응합니다. 이 뷰는 가장 큰 이득을 줄 할당 핫스팟을 보여줍니다.
힙 프로파일의 도움으로 후보 힙 할당 지점을 찾았다면, 이를 어떻게 제거할 수 있을까요? 핵심은 Go 컴파일러의 이스케이프 분석(escape analysis)을 활용하여, 예를 들어 고루틴 스택 같은 다른(더 효율적인) 저장소로 메모리를 옮기게 하는 것입니다. 다행히 Go 컴파일러는 특정 Go 값을 힙으로 이스케이프시키기로 결정한 이유를 설명할 수 있습니다. 그 정보를 알면, 분석 결과를 바꾸도록 소스 코드를 재구성하는 문제(종종 가장 어렵지만, 이 가이드 범위를 벗어남)가 됩니다.
Go 컴파일러의 이스케이프 분석 정보를 얻는 가장 쉬운 방법은, 어떤 패키지에 적용했거나 적용하지 못한 최적화를 텍스트로 설명하는 디버그 플래그를 사용하는 것입니다. 여기에는 값이 이스케이프하는지 여부도 포함됩니다. 아래 명령을 실행해 보세요. [package]는 Go 패키지 경로입니다.
bash$ go build -gcflags=-m=3 [package]
이 정보는 LSP를 지원하는 에디터에서 오버레이로 시각화할 수도 있습니다. 코드 액션으로 노출됩니다. 예를 들어 VS Code에서는 “Source Action... > Show compiler optimization details” 명령을 실행해 현재 패키지의 진단을 활성화할 수 있습니다. (또는 “Go: Toggle compiler optimization details” 명령을 실행해도 됩니다.) 표시할 주석(annotation)은 다음 설정으로 제어할 수 있습니다.
ui.diagnostic.annotations에 escape를 포함하도록 설정하세요.마지막으로 Go 컴파일러는 추가 커스텀 툴링을 만들 수 있도록, 이 정보를 기계 판독 가능(JSON) 형식으로도 제공합니다. 자세한 내용은 Go 소스 코드의 문서를 참고하세요.
Go GC는 살아있는 메모리의 ‘인구통계(demographics)’에 민감합니다. 객체/포인터 그래프가 복잡하면 병렬성이 제한되고 GC 작업이 늘어나기 때문입니다. 그 결과 GC에는 특정 흔한 구조에 대한 몇 가지 최적화가 포함되어 있습니다. 성능 최적화에 가장 직접적으로 유용한 것들을 아래에 나열합니다.
참고: 아래 최적화는 코드의 의도를 흐려 가독성을 떨어뜨릴 수 있으며, Go 릴리스가 바뀌면 효과가 유지되지 않을 수 있습니다. 비용 식별 섹션에 나열된 도구로 중요 지점을 찾은 뒤, 정말 필요한 곳에만 적용하는 것을 권장합니다.
그 결과, 꼭 필요하지 않은 데이터 구조에서 포인터를 제거하는 것이 유리할 수 있습니다. 이는 GC가 프로그램에 가하는 캐시 압박(cache pressure)을 줄입니다. 포인터 대신 인덱스를 사용하는 데이터 구조는 타입 안정성이 떨어질 수 있지만 더 좋은 성능을 낼 수 있습니다. 이는 객체 그래프가 복잡하고 GC가 마킹/스캐닝에 많은 시간을 쓰는 것이 명확할 때만 가치가 있습니다.
그 결과, 구조체 타입 값에서는 포인터 필드를 구조체 앞부분에 모으는 것이 유리할 수 있습니다. 이는 애플리케이션이 마킹/스캐닝에 많은 시간을 쓰는 것이 명확할 때만 가치가 있습니다. (이론적으로 컴파일러가 자동으로 할 수 있지만 아직 구현되지 않았고, 구조체 필드는 소스 코드에 작성된 순서대로 배치됩니다.)
또한 GC는 거의 모든 포인터와 상호작용해야 하므로, 예를 들어 포인터 대신 슬라이스 인덱스를 사용하는 것이 GC 비용을 줄이는 데 도움이 될 수 있습니다.
프로그램이 메모리에 접근할 때 CPU는 프로그램이 사용하는 가상 메모리 주소를, 실제 데이터가 있는 물리 메모리 주소로 변환해야 합니다. 이를 위해 CPU는 운영체제가 관리하는 “페이지 테이블(page table)”을 참조합니다. 페이지 테이블은 가상 메모리에서 물리 메모리로의 매핑을 나타내는 데이터 구조입니다. 페이지 테이블의 각 엔트리는 페이지(page)라 불리는, 분할 불가능한 물리 메모리 블록을 나타내므로 이런 이름이 붙었습니다.
투명 거대 페이지(Transparent Huge Pages, THP)는 Linux 기능으로, 연속적인 가상 메모리 영역을 뒷받침하는 물리 메모리 페이지들을 더 큰 메모리 블록인 huge page로 투명하게 대체합니다. 더 큰 블록을 사용하면 동일한 메모리 영역을 표현하는 데 필요한 페이지 테이블 엔트리가 줄어 페이지 테이블 조회 시간이 개선됩니다. 그러나 더 큰 블록은 시스템이 huge page의 일부만 사용하면 낭비가 커진다는 뜻이기도 합니다.
Go 프로그램을 프로덕션에서 실행할 때, Linux에서 THP를 활성화하면 추가 메모리 사용을 대가로 처리량과 지연 시간이 개선될 수 있습니다. 힙이 작은 애플리케이션은 THP의 이득을 얻지 못할 수 있으며, 상당한 추가 메모리(최대 50%)를 사용할 수도 있습니다. 반면 힙이 큰 애플리케이션(1 GiB 이상)은 추가 메모리 오버헤드(1–2% 이하) 없이도 꽤 큰 이득(처리량 최대 10%)을 얻는 경향이 있습니다. 어느 경우든 THP 설정을 인지하는 것은 도움이 되며, 실험을 권장합니다.
Linux 환경에서 THP를 활성화/비활성화하려면 /sys/kernel/mm/transparent_hugepage/enabled를 수정하면 됩니다. 자세한 내용은 공식 Linux 관리자 가이드를 참고하세요. 프로덕션 환경에서 THP를 활성화하기로 한다면, Go 프로그램에 대해 아래 추가 설정을 권장합니다.
/sys/kernel/mm/transparent_hugepage/defrag를 defer 또는 defer+madvise로 설정하세요.이 설정은 Linux 커널이 일반 페이지를 huge page로 합치는(coalesce) 공격성을 제어합니다. defer는 커널이 huge page 합치기를 게으르게(lazily) 백그라운드에서 수행하도록 합니다. 더 공격적인 설정은 메모리가 제한된 시스템에서 정지(stall)를 유발할 수 있고, 애플리케이션 지연 시간을 악화시킬 수 있습니다. defer+madvise는 defer와 비슷하지만, 명시적으로 huge page를 요청하고 성능을 위해 필요한 다른 애플리케이션에 더 친화적입니다.
/sys/kernel/mm/transparent_hugepage/khugepaged/max_ptes_none을 0으로 설정하세요.이 설정은 Linux 커널 데몬이 huge page를 할당하려 할 때 추가로 할당할 수 있는 페이지 수를 제어합니다. 기본값은 최대한 공격적이며, 종종 Go 런타임이 OS에 메모리를 반환하려고 하는 작업을 되돌릴 수 있습니다. Go 1.21 이전에는 Go 런타임이 기본값의 부정적 영향을 완화하려 했지만 CPU 비용이 있었습니다. Go 1.21+와 Linux 6.2+에서는 Go 런타임이 더 이상 huge page 상태를 변경하지 않습니다.
Go 1.21.1 이상으로 업그레이드한 뒤 메모리 사용이 증가했다면, 이 설정을 적용해 보세요. 문제를 해결할 가능성이 큽니다. 추가 워크어라운드로, 프로세스 수준에서 huge page를 비활성화하려면 Prctl 함수를 PR_SET_THP_DISABLE과 함께 호출하거나, 힙 메모리에 대해 huge page를 비활성화하려면 GODEBUG=disablethp=1(Go 1.21.6 및 Go 1.22에 추가 예정)을 설정할 수 있습니다. 다만 GODEBUG 설정은 향후 릴리스에서 제거될 수 있습니다.
GOGC 섹션에서는 GOGC를 두 배로 늘리면 힙 메모리 오버헤드가 두 배가 되고 GC CPU 비용이 절반이 된다고 주장했습니다. 왜 그런지 수학적으로 풀어봅시다.
먼저, 힙 목표는 총 힙 크기에 대한 목표를 설정합니다. 하지만 이 목표는 주로 new 힙 메모리에 영향을 주는데, live 힙은 애플리케이션의 근본적인 속성이기 때문입니다.
목표 힙 메모리 = Live heap + (Live heap + GC roots) * GOGC / 100
총 힙 메모리 = Live heap + New heap memory
⇒
New heap memory = (Live heap + GC roots) * GOGC / 100
여기서 GOGC를 두 배로 늘리면 애플리케이션이 각 사이클마다 할당하는 new 힙 메모리 양도 두 배가 되는 것을 볼 수 있습니다. 이것이 힙 메모리 오버헤드를 포착합니다. 참고로 Live heap + GC roots 는 GC가 스캔해야 하는 메모리 양의 근사입니다.
이제 GC CPU 비용을 봅시다. 어떤 시간 구간 T 동안의 총 비용은 사이클당 비용에 GC 빈도를 곱한 것으로 나눌 수 있습니다.
총 GC CPU 비용 = (사이클당 GC CPU 비용) * (GC 빈도) * T
사이클당 GC CPU 비용은 GC 모델에서 유도할 수 있습니다.
사이클당 GC CPU 비용 = (Live heap + GC roots) * (바이트당 비용) + 고정 비용
여기서는 마크/스캔 비용이 지배적이므로 스윕 비용은 무시합니다.
정상 상태는 할당률과 바이트당 비용이 일정하므로, 이 new 힙 메모리에서 GC 빈도를 유도할 수 있습니다.
GC 빈도 = (할당률) / (New heap memory) = (할당률) / ((Live heap + GC roots) * GOGC / 100)
이를 모두 합치면 총 비용에 대한 전체 식을 얻습니다.
총 GC CPU 비용 = (할당률) / ((Live heap + GC roots) * GOGC / 100) * ((Live heap + GC roots) * (바이트당 비용) + 고정 비용) * T
충분히 큰 힙(대부분의 경우)을 가정하면, 사이클의 한계 비용이 고정 비용보다 지배적입니다. 그러면 총 GC CPU 비용 식을 크게 단순화할 수 있습니다.
총 GC CPU 비용 = (할당률) / (GOGC / 100) * (바이트당 비용) * T
이 단순화된 식에서, GOGC를 두 배로 늘리면 총 GC CPU 비용이 절반이 됨을 알 수 있습니다. (이 가이드의 시각화는 고정 비용도 시뮬레이션하므로, 시각화가 보고하는 GC CPU 오버헤드는 GOGC를 두 배로 늘릴 때 정확히 절반이 되지는 않습니다.) 또한 GC CPU 비용은 할당률과 메모리 스캔의 바이트당 비용에 의해 크게 결정됩니다. 이러한 비용을 줄이는 방법에 대한 자세한 내용은 최적화 가이드를 참고하세요.
참고: live 힙 크기와, GC가 실제로 스캔해야 하는 메모리 양 사이에는 불일치가 존재합니다. 같은 크기의 live 힙이라도 구조가 다르면 CPU 비용은 달라지지만 메모리 비용은 같아, 트레이드오프가 달라집니다. 이 때문에 정상 상태 정의에 힙 구조가 포함됩니다. 힙 목표는 GC가 스캔해야 하는 메모리 양에 대한 더 가까운 근사로서 스캔 가능한(scannable) live 힙만 포함해야 한다고 주장할 수도 있지만, 스캔 가능한 live 힙은 매우 작고 live 힙 자체는 큰 경우 퇴행적(degenerate) 동작을 유발합니다.