Go의 메모리 관리 이해
Daniel Hayes
Full-Stack Engineer · Leapcell

현대 애플리케이션을 위한 Go의 효율적인 메모리 관리
현대 소프트웨어 개발 영역에서 효율적인 리소스 관리는 매우 중요합니다. C 및 C++와 같은 언어는 메모리에 대한 세부적인 제어를 제공하지만, 개발자에게는 큰 부담을 주며 메모리 누수 및 사용 후 해제 오류와 같은 일반적인 함정으로 이어지는 경우가 많습니다. 반면에 Java 또는 Python과 같이 자동 메모리 관리를 제공하는 언어는 개발을 단순화하지만 가비지 컬렉션으로 인해 예측할 수 없는 일시 중지가 발생하여 애플리케이션 응답성에 영향을 줄 수 있습니다. 고성능, 동시 시스템 구축을 위해 설계된 Go 언어는 주목할 만한 균형을 이룹니다. 정교한 가비지 컬렉터를 통한 자동 메모리 관리를 제공하는 동시에 로우레벨 언어와 유사한 예측 가능한 성능 특성을 유지합니다. Go의 메모리 할당 및 가비지 컬렉션(GC) 메커니즘을 이해하는 것은 효율적이고 안정적이며 고성능의 Go 애플리케이션을 작성하는 데 중요합니다. 이 글에서는 Go가 내부적으로 메모리를 관리하는 방법을 명확히 설명하고 핵심 원리와 실질적인 시사점을 탐구합니다.
Go 메모리 및 GC의 내부 작동 방식
Go의 메모리 관리를 완전히 파악하려면 먼저 몇 가지 기본 개념과 전체 아키텍처를 이해하는 것이 필수적입니다.
주요 개념: 힙 vs. 스택 및 포인터
Go에서는 다른 많은 언어와 마찬가지로 메모리가 크게 두 영역으로 나뉩니다. 스택과 힙입니다.
- 스택: 스택은 지역 변수, 함수 인수 및 반환 주소를 저장하는 데 사용됩니다. LIFO(Last-In, First-Out) 원칙에 따라 작동합니다. 스택에서의 할당 및 해제는 단순히 포인터를 이동시키는 것이므로 매우 빠릅니다. 스택에 할당된 메모리는 함수가 종료될 때 자동으로 회수됩니다.
- 힙: 힙은 동적 메모리 할당에 사용됩니다. 즉, 컴파일 시점에 크기를 알 수 없거나 단일 함수 호출 범위를 넘어 지속되는 메모리입니다. 슬라이스, 맵, 채널 및 사용자 정의 구조체의 인스턴스(힙으로 벗어나는 경우)와 같은 데이터 구조는 일반적으로 힙에 할당됩니다. 힙에서의 할당은 일반적으로 스택에서의 할당보다 느리며, 힙 할당 메모리는 가비지 컬렉션이 필요합니다.
Go는 메모리 내의 값을 참조하기 위해 포인터를 사용합니다. 포인터는 변수의 메모리 주소를 보유합니다. Go는 명시적인 포인터 사용을 허용하지만, 컴파일러가 암시적으로 포인터 간접 참조를 처리하는 경우가 많기 때문에(예: 슬라이스 또는 맵을 전달할 때) 보다 관용적인 접근 방식을 장려합니다. 변수가 스택에 할당되는지 힙에 할당되는지에 대한 결정은 Go 컴파일러가 **탈출 분석(escape analysis)**이라는 최적화를 통해 이루어집니다. 변수의 수명이 선언된 함수를 넘어서까지 계속되거나, 전역적으로 액세스 가능한 변수 또는 다른 고루틴에 의해 역참조될 수 있는 포인터가 참조하는 경우 해당 변수는 힙으로 "탈출"합니다.
간단한 예제를 통해 탈출 분석을 설명해 보겠습니다.
package main type Person struct { Name string Age int } func createPersonOnStack() Person { // P는 이 함수의 수명에 국한되고 값으로 반환(복사)되기 때문에 스택에 할당될 가능성이 높습니다. p := Person{Name: "Alice", Age: 30} return p } func createPersonOnHeap() *Person { // P의 주소가 반환되므로 이 함수의 수명을 넘어서까지 지속되므로 힙에 할당될 가능성이 높습니다. p := &Person{Name: "Bob", Age: 25} return p } func main() { _ = createPersonOnStack() _ = createPersonOnHeap() }
go build -gcflags='-m'
을 사용하여 탈출 분석의 출력을 볼 수 있습니다.
$ go build -gcflags='-m' ./your_package_path/main.go # github.com/your_user/your_repo ./main.go:13:9: &Person{...} escapes to heap ./main.go:8:9: p (return value) moved to heap
createPersonOnStack
의 경우 출력은 직관적이지 않을 수 있습니다. 이러한 작은 구조체의 경우 많은 경우 컴파일러가 최적화하여 p
를 스택으로 이동시킬 수 있지만, 반환 값이 즉시 "사용되지" 않거나 구조체가 더 커지면 컴파일러가 값비싼 복사를 피하기 위해 힙으로 승격하기로 결정할 수 있습니다. 그러나 createPersonOnHeap
은 &Person{...}
이 힙으로 벗어남을 확실히 보여주며, 이는 포인터로 반환되는 값에 대한 핵심적인 내용입니다.
Go의 동시 삼색 마크-앤-스윕 GC
Go의 가비지 컬렉터는 동시 삼색 마크-앤-스윕 컬렉터입니다. 이것이 무엇을 의미하는지 자세히 살펴보겠습니다.
-
동시: GC는 애플리케이션의 고루틴과 동시에 실행됩니다. 이는 가비지 컬렉션을 위해 애플리케이션이 완전히 중단되는 기간인 "stop-the-world"(STW) 일시 중지를 최소화하는 중요한 설계 선택입니다. Go의 GC는 매우 낮은 지연 시간을 목표로 하며, 종종 마이크로초 범위의 일시 중지 시간을 달성합니다.
-
삼색: 이는 추적 가비지 컬렉터가 마킹 단계 동안 객체를 추적하는 데 사용하는 개념적 모델입니다.
- 흰색: GC가 아직 방문하지 않은 객체입니다. GC 사이클 시작 시 모든 객체가 흰색입니다. 마킹 단계가 끝날 때까지 객체가 흰색으로 남아 있으면 도달할 수 없는 것으로 간주되어 수집 대상이 됩니다.
- 회색: 방문했지만 해당 자식(참조하는 객체)은 아직 스캔되지 않은 객체입니다. 이러한 객체는 작업 큐에 배치됩니다.
- 검은색: 방문했으며 해당 자식도 모두 방문하여 마크된 객체(또는 이미 회색/흰색인 객체)입니다. 이러한 객체는 "살아있는" 것으로 간주됩니다.
GC는 "루트" 객체(예: 전역 변수, 활성 고루틴의 스택 변수) 집합에서 시작하여 작동합니다. 이러한 루트는 처음에 회색으로 표시됩니다. GC는 회색 객체를 선택하여 검은색으로 표시한 다음 해당 객체가 참조하는 모든 객체를 스캔하고 아직 흰색인 객체를 회색으로 표시합니다. 이 과정은 더 이상 회색 객체가 없을 때까지 계속됩니다.
-
마크-앤-스윕: 이는 GC 사이클의 두 가지 주요 단계를 설명합니다.
- 마크 단계: GC는 루트에서 시작하여 도달 가능한 모든 객체(살아있는 객체)를 식별합니다. 이 단계는 객체 그래프를 순회하고 객체를 검은색으로 표시하는 것을 포함합니다. 뮤테이터(Go 프로그램)가 실행되는 동안 일관성을 보장하는 쓰기 배리어가 있습니다. 프로그램이 포인터를 수정하면(예: 객체를 새 객체를 가리키도록 함), 쓰기 배리어는 새 객체가 흰색인 경우 오류로 수집되는 것을 방지하기 위해 즉시 회색으로 표시되도록 보장합니다.
- 스윕 단계: 마크 단계가 완료된 후 GC는 힙을 반복하여 표시되지 않은(흰색) 객체를 차지하는 메모리를 회수합니다. 이 단계도 애플리케이션과 동시에 실행됩니다.
GC 사이클 상세
일반적인 Go GC 사이클은 여러 단계를 포함합니다.
- GC 트리거: GC는 마지막 GC 사이클 이후 할당된 새 메모리 양이 특정 임계값에 도달하면 자동으로 트리거됩니다. 이 임계값은
GOGC
환경 변수(기본값: 100)에 의해 제어되며, 이는 다음 GC 사이클 전에 살아있는 힙 크기의 증가율을 백분율로 나타냅니다. 예를 들어,GOGC=100
이면 GC는 마지막 GC 사이클 이후 살아있는 힙이 두 배가 되면 실행됩니다. 일반적으로 정상적인 작동에는 권장되지 않지만runtime.GC()
를 사용하여 명시적으로 트리거할 수도 있습니다. - 마크 어시스트 (프로그램 실행 중 동시 수행): 애플리케이션 고루틴이 메모리를 할당하려고 할 때 GC가 현재 활성화되어 있고 고루틴의 할당 속도가 매우 높은 경우, GC를 "지원"하도록 요청받아 표시 작업을 수행할 수 있습니다. 이는 GC가 할당 속도를 따라잡도록 돕고 힙이 너무 커지는 것을 방지합니다.
- 마킹 (마이너 STW 일시 중지 중 동시 수행):
- 세상 시작 (STW-1): 매우 짧은 일시 중지(마이크로초)가 발생하여 쓰기 배리어를 활성화하고 스캔을 위해 루트를 준비합니다. 이 일시 중지는 마킹 시작 시 힙 스냅샷의 일관성을 보장하는 데 중요합니다.
- 동시 스캔: GC 고루틴은 객체 그래프를 순회하기 시작하여 도달 가능한 객체를 표시합니다. 애플리케이션 고루틴은 이 단계 동안 계속 실행됩니다. 쓰기 배리어는 프로그램이 GC가 마킹하는 동안 포인터를 수정하는 경쟁 조건을 방지합니다.
- 세상 끝 (STW-2): 또 다른 짧은 일시 중지(마이크로초)가 발생하여 동시 마킹 단계 동안 수정된 스택 및 전역 변수를 스캔하고 마킹을 완료합니다.
- 스윕 (동시 수행): 마킹이 완료되면 스윕 단계가 시작됩니다. GC는 힙을 반복하여 표시되지 않은 메모리 블록을 식별하고 회수합니다. 이 또한 애플리케이션과 동시에 실행됩니다. 회수된 메모리는 중앙 풀(mheap)로 반환된 다음 각 P(프로세서) 캐시로 이동하여 빠른 할당에 사용됩니다.
Go에서의 메모리 할당: Mallocs 및 Spans
Go의 메모리 할당자(runtime/malloc.go
)는 고루틴 성능 및 동시성을 위해 고도로 최적화되어 있습니다. 힙을 **스팬(spans)**이라는 고정 크기 청크로 분할하여 작동합니다. 스팬은 일반적으로 8KB 정렬된 메모리의 연속 영역입니다.
Go 프로그램이 메모리를 할당해야 할 때:
- 크기 클래스: Go 할당자는 할당을 일련의 크기 클래스로 그룹화합니다. 작은 객체(최대 32KB)의 경우 약 67개의 크기 클래스가 있습니다. 각 크기 클래스는 특정 블록 크기(예: 8바이트, 16바이트, 24바이트...)에 매핑됩니다.
- P별 캐시 (mcache): 각 논리 프로세서(P)에는 각 크기 클래스에 대한 무료 메모리 블록의 로컬 캐시(
mcache
)가 있습니다. 이 설계를 통해 작은 객체를 할당할 때 잠금을 사용할 필요가 없어 할당이 매우 빠릅니다. 특정 P의 고루틴이 특정 크기 클래스의 메모리가 필요한 경우 먼저mcache
에서 무료 블록을 가져오려고 시도합니다. - 스팬 할당 (mcentral):
mcache
에 무료 블록이 없는 경우 중앙 풀(mcentral
)에서 새 스팬을 요청합니다.mcentral
은 스팬 목록을 포함하며, 일부는 무료 객체(부분적으로 채워진 스팬)를 가지고 있고 일부는 완전히 비어 있습니다(빈 스팬).mcache
가 스팬을 요청하면mcentral
에서 하나를 가져와 필요한 크기 클래스의 블록으로 분할한 다음, 한 블록을 고루틴에 반환하고 나머지는mcache
에 유지합니다.mcentral
에 대한 액세스는 잠금으로 보호됩니다. - 힙 영역 (mheap):
mcentral
에 적합한 스팬이 없는 경우,mheap
에서 새 메모리를 요청합니다.mheap
은 전체 힙을 관리하며, 운영 체제에서 큰 메모리 청크를 가져오고(mmap
또는sbrk
사용) 이를 스팬으로 분할합니다. 큰 할당(32KB 초과)은 하나 이상의 연속 스팬을 할당하는mheap
에서 직접 처리됩니다.
P별 캐시와 중앙 mheap
을 포함하는 이 계층적 할당 시스템은 경합을 크게 줄이고 특히 동시성이 높은 애플리케이션에서 할당 성능을 향상시킵니다.
실질적인 의미 및 성능 튜닝
Go의 메모리 모델을 이해하면 성능을 진단하고 최적화하는 데 도움이 될 수 있습니다.
- 힙 할당 최소화: Go의 GC는 훌륭하지만, 객체 할당 및 해제에는 여전히 오버헤드가 있습니다. 불필요한 힙 할당을 줄이는 것(더 많은 변수가 스택으로 탈출하도록 함)은 GC 압력을 줄이고 성능을 개선하는 가장 효과적인 방법 중 하나입니다.
go tool pprof
및go build -gcflags='-m'
과 같은 도구는 힙 할당을 식별하는 데 매우 유용합니다. GOGC
이해:GOGC
환경 변수는 GC 트리거 임계값을 제어합니다. 낮은GOGC
값은 더 자주 발생하지만 더 짧은 GC 사이클을 의미합니다(메모리 사용량을 줄일 수 있지만 GC로 인한 CPU 오버헤드가 증가할 수 있음). 더 높은GOGC
값은 덜 자주 발생하지만 잠재적으로 더 긴 GC 사이클을 의미합니다(메모리 사용량이 증가할 수 있지만 CPU 오버헤드가 줄어들 수 있음). 기본값GOGC=100
은 종종 좋은 시작점이지만 특정 워크로드 특성에 맞게 조정할 수 있습니다.- 대형 객체에 대한 장기 실행 포인터 피하기: 매우 큰 데이터 구조(예: 거대한 슬라이스 또는 맵)가 있고 이를 가리키는 단일 포인터를 계속 유지하면 해당 포인터가 사라질 때까지 GC는 해당 메모리를 회수할 수 없습니다. 구조체 내 데이터의 대부분이 사용되지 않더라도 전체 구조는 계속 활성 상태로 유지됩니다. 이것이 문제가 되는 경우 데이터 구조를 재설계하는 것을 고려하십시오.
- 재사용 가능한 버퍼/객체 풀: 객체를 자주 할당하고 해제하는 매우 높은 처리량 시스템의 경우,
sync.Pool
을 사용하거나 사용자 정의 객체 풀을 구현하면 새 객체를 할당하는 대신 객체를 재사용하여 GC 압력을 효과적으로 줄일 수 있습니다.
package main import ( "fmt" "runtime" "sync" ) type MyObject struct { Data [1024]byte // 비교적 큰 객체 } var objectPool = sync.Pool{ New: func() interface{} { // 이 함수는 풀에 사용 가능한 객체가 없을 때 새 객체가 필요할 때 호출됩니다. return &MyObject{} }, } func allocateDirectly() { _ = &MyObject{} // 힙에 할당 } func allocateFromPool() { obj := objectPool.Get().(*MyObject) // 풀에서 객체 가져오기 // obj로 무언가를 하세요 objectPool.Put(obj) // 풀에 객체 반환 } func main() { // 할당 전후 메모리를 관찰해 봅시다. var m runtime.MemStats runtime.ReadMemStats(&m) fmt.Printf("Initial Alloc = %v Bytes\n", m.Alloc) // 직접 할당 시뮬레이션 for i := 0; i < 10000; i++ { allocateDirectly() } runtime.GC() // 회수된 메모리를 보기 위해 GC 강제 실행 runtime.ReadMemStats(&m) fmt.Printf("After Direct Allocations & GC: Alloc = %v Bytes\n", m.Alloc) // 풀에서의 할당 시뮬레이션 // 통계 재설정 (GC가 이전 직접 할당 중 일부를 정리할 수 있음) runtime.GC() runtime.ReadMemStats(&m) fmt.Printf("Before Pool Allocations: Alloc = %v Bytes\n", m.Alloc) for i := 0; i < 10000; i++ { allocateFromPool() } runtime.GC() // GC 강제 실행 runtime.ReadMemStats(&m) fmt.Printf("After Pool Allocations & GC: Alloc = %v Bytes\n", m.Alloc) // 동일한 "할당" 횟수에 대해 풀을 사용하는 경우 직접 할당에 비해 'Alloc' 메트릭이 훨씬 적거나 안정적으로 유지됨을 관찰할 수 있습니다. // 이는 풀링이 실제 힙 사용량을 줄이고 GC 압력을 완화하기 때문입니다. }
이 예제를 실행하면 sync.Pool
을 사용했을 때 Alloc
메트릭(Go가 아직 사용 중인 총 할당 메모리)이 직접 할당에 비해 상당히 낮거나 심지어 안정된 상태를 유지한다는 것을 알 수 있으며, 이는 풀링이 실제 힙 사용량을 줄이고 GC 압력을 완화한다는 것을 보여줍니다.
결론
Go의 메모리 할당 및 가비지 컬렉션 메커니즘은 성능 및 동시성 스토리의 초석입니다. 효율적이고 동시적인 삼색 마크-앤-스윕 컬렉터와 고도로 최적화된 계층적 메모리 할당자를 활용함으로써 Go는 개발자가 수동 메모리 관리의 복잡성 없이 예측 가능한 저지연 성능을 갖춘 애플리케이션을 구축할 수 있도록 지원합니다. Go가 대부분의 메모리 문제를 자동으로 처리하지만, 스택 대 힙 할당, 탈출 분석, GC 사이클의 미묘한 차이 등 기본 원리를 이해하는 것은 진정으로 최적화되고 강력한 Go 프로그램을 작성하는 데 매우 중요합니다. 궁극적으로 Go의 메모리 관리 시스템은 개발자가 메모리 세부 사항보다는 비즈니스 로직에 더 집중할 수 있도록 하여 개발자 생산성 및 고성능 애플리케이션 효율성 모두를 달성할 수 있도록 합니다.