본문으로 건너뛰기

스택, 힙, 이스케이프 분석

이 챕터에서 다루는 것

Go에는 mallocfree도 없고, "이 값이 스택에 있는지 힙에 있는지"를 코드에 적는 문법도 없다. 컴파일러가 정한다. 그 판단 규칙이 이스케이프 분석(escape analysis) 이고, go build -gcflags=-m으로 결과를 읽을 수 있다. 2-6에서 예고한 내용을 여기서 마무리한다.

문제 — 이 코드는 왜 안전한가

func newConfig(name string) *Config {
c := Config{Name: name, Retries: 3}
return &c
}

C나 C++에서 왔다면 이건 명백한 버그다. c는 스택 프레임 위의 지역 변수이고, 함수가 반환되면 그 프레임은 사라진다. 반환된 포인터는 이미 죽은 메모리를 가리킨다 — 댕글링 포인터다. C 컴파일러는 경고를 낸다.

Go에서는 완전히 안전하고, 심지어 관용적이다. 생성자 함수는 거의 다 이 모양이다.

이유는 간단하다. 컴파일러가 c가 함수 밖으로 나간다는 것을 알고, 애초에 스택이 아니라 힙에 할당하기 때문이다. 프로그래머는 어디에 할당됐는지 알 필요가 없고, 알 방법도 문법에는 없다. GC가 마지막 참조가 사라질 때 회수한다.

GC 개요

Go의 GC에 대해 지금 알아야 할 것은 많지 않다. 성능 튜닝은 Part 12의 주제이고, 여기서는 세 줄이면 충분하다.

  • 추적형(tracing) GC다. 루트(전역 변수, 각 고루틴의 스택)에서 출발해 도달 가능한 객체를 표시하고, 표시되지 않은 것을 회수한다. 참조 카운팅이 아니라서 순환 참조도 문제없이 회수된다.
  • 동시(concurrent) GC다. 프로그램을 멈추지 않고 대부분의 작업을 진행한다. 멈추는 구간(STW)은 있지만 보통 수백 마이크로초 수준이다.
  • 비압축(non-moving) GC다. 객체가 이사하지 않으므로 포인터 값이 도중에 바뀌지 않는다. (스택은 자랄 때 이사하지만 런타임이 포인터를 함께 고쳐 준다.)

Go 1.26부터 Green Tea GC가 기본이다. 이전 대비 GC 오버헤드가 10~40% 줄었다. 켜고 끄는 스위치가 아니라 이미 켜져 있는 상태다.

GC가 있어도 할당은 공짜가 아니다. 할당 자체에 비용이 있고, 할당이 늘면 GC가 더 자주 돌고, GC가 돌면 CPU를 쓴다. 그래서 "얼마나 할당하는가"가 Go 성능의 주된 지표다.

스택과 힙

스택
할당 비용포인터 하나 옮기기 (사실상 공짜)할당기 호출
해제함수 반환 시 자동GC가 회수
GC 부담없음있음
크기고루틴당 2KB로 시작해 자람프로세스 전체

스택 할당이 압도적으로 싸다. 그래서 컴파일러는 가능하면 스택에 두려 하고, "밖으로 나갈 수 있다"고 판단될 때만 힙으로 보낸다. 이 판단이 이스케이프 분석이다.

핵심 규칙은 하나다. 함수가 반환된 뒤에도 값이 살아 있어야 한다면 힙으로 간다.

-gcflags=-m으로 읽기

examples/03-composite-types/06-escape/main.go
package main

import "fmt"

type Config struct {
Name string
Retries int
}

// newConfig는 지역 변수의 주소를 반환한다. C였다면 댕글링 포인터다.
func newConfig(name string) *Config {
c := Config{Name: name, Retries: 3}
return &c
}

// retries는 c를 밖으로 내보내지 않는다. 스택에 남을 수 있다.
func retries(name string) int {
c := Config{Name: name, Retries: 3}
return c.Retries
}

// sumLocal의 buf도 함수 밖으로 나가지 않는다.
func sumLocal(n int) int {
buf := make([]int, 8)
for i := range buf {
buf[i] = i * n
}
total := 0
for _, v := range buf {
total += v
}
return total
}

// sumEscaping은 같은 버퍼를 반환한다. 이쪽은 힙으로 간다.
func sumEscaping(n int) []int {
buf := make([]int, 8)
for i := range buf {
buf[i] = i * n
}
return buf
}

func main() {
c := newConfig("api")
fmt.Println(c.Name, c.Retries)
fmt.Println(retries("db"))
fmt.Println(sumLocal(2))
fmt.Println(sumEscaping(2))
}
go run ./06-escape
api 3
3
56
[0 2 4 6 8 10 12 14]

이제 컴파일러의 판단을 들여다보자. -m은 최적화 결정을 출력하게 하는 플래그다.

go build -gcflags=-m -o /tmp/esc ./06-escape
# example.com/composite-types/06-escape
06-escape/main.go:11:6: can inline newConfig
06-escape/main.go:17:6: can inline retries
06-escape/main.go:23:6: can inline sumLocal
06-escape/main.go:36:6: can inline sumEscaping
06-escape/main.go:45:16: inlining call to newConfig
06-escape/main.go:46:13: inlining call to fmt.Println
06-escape/main.go:47:21: inlining call to retries
06-escape/main.go:47:13: inlining call to fmt.Println
06-escape/main.go:48:22: inlining call to sumLocal
06-escape/main.go:48:13: inlining call to fmt.Println
06-escape/main.go:49:25: inlining call to sumEscaping
06-escape/main.go:49:13: inlining call to fmt.Println
06-escape/main.go:11:16: leaking param: name
06-escape/main.go:12:2: moved to heap: c
06-escape/main.go:17:14: name does not escape
06-escape/main.go:24:13: make([]int, 8) does not escape
06-escape/main.go:37:13: make([]int, 8) escapes to heap
06-escape/main.go:46:13: ... argument does not escape
06-escape/main.go:46:15: c.Name escapes to heap
06-escape/main.go:46:23: c.Retries escapes to heap
06-escape/main.go:47:13: ... argument does not escape
06-escape/main.go:47:21: ~r0 escapes to heap
06-escape/main.go:48:13: ... argument does not escape
06-escape/main.go:48:22: ~r0 escapes to heap
06-escape/main.go:48:22: make([]int, 8) does not escape
06-escape/main.go:49:13: ... argument does not escape
06-escape/main.go:49:25: ~r0 escapes to heap
06-escape/main.go:49:25: make([]int, 8) escapes to heap

(줄 번호는 이 파일 기준이다. -o /tmp/esc를 붙인 것은 go build가 디렉터리 이름과 같은 실행 파일을 만들려다 실패하는 것을 피하기 위해서다.)

출력이 길지만 읽는 법은 단순하다. escape 또는 moved to heap이 들어간 줄만 보면 된다.

메시지
moved to heap: c지역 변수 c가 힙으로 갔다
X escapes to heapX가 힙으로 간다
X does not escape스택에 남는다
leaking param: name매개변수가 함수 밖으로 흘러 나간다
can inline / inlining call to이스케이프와 무관한 인라인 결정

하나씩 읽기

12:2: moved to heap: cnewConfigc다. &c를 반환하니 함수가 끝난 뒤에도 살아 있어야 한다. 컴파일러가 힙에 할당했고, 그래서 반환된 포인터가 안전하다. 이것이 이 챕터 첫 질문의 답이다.

17:14: name does not escaperetriesname은 안에서 쓰이고 끝난다. c에 대한 언급이 아예 없는 것도 눈여겨보자. 아무 데도 새지 않는 값은 조용히 스택에 남는다.

24:13: make([]int, 8) does not escape vs 37:13: make([]int, 8) escapes to heap — 같은 코드인데 결과가 갈렸다. sumLocalbuf를 안에서 다 쓰고 int 하나를 반환한다. sumEscapingbuf 자체를 반환한다. 차이는 반환값 하나뿐이고, 그것으로 할당 위치가 결정됐다.

46:15: c.Name escapes to heapfmt.Println에 넘기는 값이다. Println...any를 받는다. 구체 타입을 any(인터페이스)에 담으면 컴파일러는 그 값이 어디로 갈지 알 수 없으므로 힙으로 보낸다. fmt.Println 한 줄이 할당을 만든다는 뜻이고, 뜨거운 경로에서 로그를 찍을 때 실제로 문제가 된다.

leaking param 은 "이 매개변수가 반환값이나 힙 객체에 저장돼 함수 밖으로 흘러 나간다"는 뜻이다. 매개변수 자체가 힙에 할당된다는 말이 아니라, 호출자 쪽 값의 이스케이프 여부에 영향을 준다는 정보다.

:::tip 더 자세히 보려면 -gcflags='-m -m'처럼 -m을 두 번 주면 판단 근거까지 나온다. 처음에는 정보가 너무 많으니 -m 하나로 시작해서 escape가 들어간 줄만 grep하는 편이 낫다.

go build -gcflags=-m -o /tmp/esc ./06-escape 2>&1 | grep escape

-gcflags=-m빌드하는 패키지에만 적용된다. 의존 패키지까지 보려면 -gcflags=all=-m을 쓴다. 출력이 폭발하니 각오하고 쓰자. :::

무엇이 이스케이프를 일으키는가

컴파일러의 정확한 규칙은 버전마다 바뀌지만, 큰 줄기는 안정적이다.

  1. 포인터를 반환한다. return &c. 가장 명확한 경우다.
  2. 인터페이스에 담는다. fmt.Println(x), var a any = x. 컴파일러가 이후 흐름을 추적할 수 없다.
  3. 크기를 컴파일 시점에 모른다. make([]int, n)에서 n이 변수이고 충분히 클 수 있으면 힙으로 간다. 상수 크기의 작은 슬라이스는 스택에 남을 수 있다.
  4. 너무 크다. 스택 프레임에 넣기엔 큰 값(대략 수십 KB 이상)은 힙으로 간다.
  5. 클로저가 붙잡는다. 2-6에서 본 내용이다. 클로저가 함수 밖으로 나가면 붙잡은 변수도 함께 나간다.
  6. 다른 힙 객체에 저장한다. 전역 변수, 힙에 있는 구조체의 필드, 슬라이스 요소 등.

반대로 포인터를 쓴다고 반드시 힙은 아니다. 함수 안에서만 쓰이고 밖으로 안 나가는 포인터는 스택에 남는다. "포인터 = 힙 할당"이라는 오해는 흔하지만 틀렸다.

:::warning 포인터로 바꾸면 오히려 느려질 수 있다 3-5에서 "작은 구조체는 값이 빠를 수 있다"고 했다. 이유가 여기 있다. 값으로 넘기면 스택 복사로 끝나지만, 포인터로 넘기면서 그 포인터가 이스케이프하면 힙 할당 + GC 추적이 생긴다. 16바이트 복사보다 훨씬 비싸다. :::

할당을 줄이는 감각

이스케이프 분석을 이해했다고 코드를 그것에 맞춰 비틀 필요는 없다. 실제로 효과가 크고 가독성을 해치지 않는 습관은 몇 개 안 된다.

examples/03-composite-types/06-prealloc/main.go
package main

import (
"fmt"
"runtime"
"strconv"
"strings"
)

const n = 100_000

// totalAllocMiB는 프로그램 시작 이후 누적 할당량을 MiB로 돌려준다.
func totalAllocMiB() float64 {
var m runtime.MemStats
runtime.ReadMemStats(&m)
return float64(m.TotalAlloc) / (1 << 20)
}

func naive() []int {
var s []int
for i := range n {
s = append(s, i)
}
return s
}

func prealloc() []int {
s := make([]int, 0, n)
for i := range n {
s = append(s, i)
}
return s
}

func concatNaive(words []string) string {
out := ""
for _, w := range words {
out += w
}
return out
}

func concatBuilder(words []string) string {
var sb strings.Builder
sb.Grow(len(words))
for _, w := range words {
sb.WriteString(w)
}
return sb.String()
}

func measure(label string, f func()) {
before := totalAllocMiB()
f()
runtime.GC()
fmt.Printf("%-22s 누적 할당 %7.2f MiB\n", label, totalAllocMiB()-before)
}

func main() {
measure("append (미리 안 잡음)", func() { runtime.KeepAlive(naive()) })
measure("append (cap 미리 잡음)", func() { runtime.KeepAlive(prealloc()) })

words := make([]string, 20_000)
for i := range words {
words[i] = strconv.Itoa(i % 10)
}
measure("문자열 += ", func() { runtime.KeepAlive(concatNaive(words)) })
measure("strings.Builder", func() { runtime.KeepAlive(concatBuilder(words)) })
}
go run ./06-prealloc
append (미리 안 잡음) 누적 할당 3.93 MiB
append (cap 미리 잡음) 누적 할당 0.77 MiB
문자열 += 누적 할당 201.03 MiB
strings.Builder 누적 할당 0.02 MiB

(세 번 실행해서 마지막 자리만 0.01 정도 흔들렸다. 자릿수는 안정적이다.)

1. cap을 미리 잡는다. 10만 개를 append하는 데 3.93MiB를 썼다. 최종 결과는 0.77MiB(= 100,000 × 8바이트)뿐인데 나머지 3.16MiB는 중간 단계의 배열들이다. make([]int, 0, n)으로 시작하면 그게 전부 사라진다. 3-2의 재할당 이야기가 숫자로 나타난 것이다.

2. 문자열 연결은 strings.Builder로. 201MiB 대 0.02MiB, 1만 배다. out += w는 반복마다 새 문자열을 할당하고 전체를 복사한다. n번 반복이면 O(n²)이다. 2-3에서 개념으로만 말했던 것이 여기서 실측된다. sb.Grow(n)으로 버퍼까지 미리 잡으면 재할당도 없다.

나머지 습관들.

3. 뜨거운 경로에서 인터페이스로 감싸지 않는다. fmt.Sprintf, fmt.Println, log.Printf는 인자를 any로 받아 전부 이스케이프시킨다. 초당 수만 번 도는 루프 안이라면 의미가 있고, 그 밖에서는 신경 쓸 가치가 없다.

4. 버퍼를 재사용한다. 매번 make하는 대신 슬라이스를 s = s[:0]으로 되감아 다시 쓴다. 요소에 포인터가 있으면 3-2에서 말한 대로 clear(s)를 먼저 한다. 더 나아가면 sync.Pool인데, 그건 Part 12의 영역이다.

5. 작은 값은 값으로 넘긴다. 앞의 경고 그대로다.

:::danger 순서를 지키자 이 다섯 가지 중 1번과 2번만 "항상 해도 되는" 것이다. 나머지는 측정한 뒤에 한다. Go에는 go test -bench-benchmem, pprof가 있고(Part 8, Part 12), 그것들이 알려 주기 전에 코드를 비트는 것은 가독성만 잃는 거래다.

runtime.ReadMemStats를 직접 부르는 이 예제 방식은 교재용이다. 실제 측정은 벤치마크로 한다. :::

흔히 하는 실수

1. "포인터를 쓰면 힙 할당"이라고 생각한다

아니다. 밖으로 안 나가는 포인터는 스택에 남는다. 반대로 포인터를 안 써도 인터페이스에 담기면 힙으로 간다.

2. 지역 변수의 주소를 반환하기를 두려워한다

C 습관이다. Go에서는 안전하고 관용적이다. newX() 생성자는 전부 이 모양이다.

3. 이스케이프 분석 결과에 맞춰 코드를 비튼다

-gcflags=-m을 처음 보면 힙 할당을 전부 없애고 싶어진다. 대부분은 무의미하고, 컴파일러 버전이 바뀌면 판단도 바뀐다. 벤치마크가 가리키는 곳만 손댄다.

4. make([]T, 0, n)을 쓰면서 n을 잘못 잡는다

너무 작으면 재할당이 다시 생기고, 너무 크면 메모리를 낭비한다. 정확한 값을 모르면 어림값이라도 낫지만, len(input)처럼 정확히 알 수 있는 경우가 의외로 많다.

5. GC를 수동으로 돌린다

runtime.GC()를 프로덕션 코드에서 부르는 것은 거의 항상 잘못이다. 이 챕터의 예제처럼 측정 목적으로만 쓴다.

6. 큰 배열을 스택에 두려 한다

var buf [10 << 20]byte처럼 큰 지역 배열은 어차피 힙으로 가거나 스택을 폭증시킨다. 큰 버퍼는 슬라이스로 만들고 재사용한다.

정리

  • 컴파일러가 스택/힙을 정한다. 문법으로 지정할 방법이 없다.
  • 규칙 하나: 함수 반환 후에도 살아 있어야 하면 힙으로 간다.
  • 그래서 지역 변수의 주소를 반환해도 안전하다. 댕글링 포인터가 생기지 않는다.
  • go build -gcflags=-m으로 판단을 읽는다. escape, moved to heap이 든 줄만 보면 된다.
  • 이스케이프 원인: 포인터 반환, 인터페이스에 담기, 크기 미상, 과대 크기, 클로저 캡처, 힙 객체에 저장.
  • 포인터 ≠ 힙. 밖으로 안 나가면 스택이다.
  • 항상 해도 되는 최적화는 둘: cap 미리 잡기, 문자열은 strings.Builder.
  • 나머지는 측정 후에. Go 1.26은 Green Tea GC가 기본이고, 대개는 그것으로 충분하다.

연습문제

  1. 06-escape/main.goretries*Config를 반환하도록 고치고 -gcflags=-m 출력이 어떻게 달라지는지 확인해 보자. c에 대한 메시지가 생기는가?

  2. make([]int, k)에서 k를 (a) 상수, (b) 함수 인자, (c) 아주 큰 상수로 각각 바꿔 가며 이스케이프 여부를 확인해 보자. 경계가 어디쯤인가? 힌트: 컴파일러는 스택 프레임에 넣을 수 있는 크기에 상한을 둔다.

  3. concatNaivestrings.Join으로도 써 보고 세 방식의 누적 할당을 비교해 보자. strings.Builder보다 나은가 못한가? 왜 그런가?