제로에서 깊이까지 Go — 파트 3: 스택 vs 힙 & 이스케이프 분석이 실제로 작동하는 방식

발행: (2025년 12월 12일 오후 07:28 GMT+9)
10 min read
원문: Dev.to

Source: Dev.to

Go 프로그램을 프로파일링하면서 간단한 함수가 메모리를 할당하는 이유나 작은 구조체가 갑자기 힙에 배치되는 이유가 궁금했던 적이 있다면, 바로 escape analysis의 영향을 본 것입니다. 초보자들은 스택과 힙을 고정된 규칙처럼 배우곤 합니다 — 작은 것은 스택에, 큰 것은 힙에 — 하지만 Go는 전혀 그렇게 동작하지 않습니다. 크기는 무관합니다. 중요한 것은 수명(lifetime) 입니다.

값은 컴파일러가 해당 값이 생성된 함수보다 오래 살아남지 않을 것임을 증명할 수 있을 때만 스택에 남습니다. 수명이 모호해지는 순간, 그 값은 “escape”하고 Go는 힙에 배치합니다. 이는 휴리스틱이나 추측이 아니라 엄격한 안전 규칙입니다.

이 규칙을 이해하면 Go 프로그램을 X‑ray 시야로 들여다볼 수 있습니다. 할당이 일어나기 전에 예측하고, 코드의 작은 변화가 메모리 동작을 어떻게 바꾸는지 확인하며, 컴파일러가 기대하는 방식으로 Go를 작성하게 됩니다 — 결과적으로 더 빠르고, 깔끔하며, 예측 가능한 프로그램이 됩니다.

스택: 빠르고, 로컬이며, 일시적

스택 프레임은 함수가 실행되는 동안에만 존재합니다. 함수가 반환되면 프레임은 사라집니다. 값이 그 프레임 안에 머무를 수 있다고 증명되면 스택에 할당됩니다.

func sum(a, b int) int {
    c := a + b
    return c
}

컴파일러에게 escape analysis를 보여달라고 요청해 보세요:

go build -gcflags="-m"

아무 것도 escape되지 않습니다. 모든 것이 스택에 있습니다. 컴파일러는 함수를 인라인화하여 변수를 레지스터나 상수로 바꿀 수도 있습니다. 이것이 이상적인 경로: 순수 스택 동작, GC 압력 없음, 힙 작업 없음.

힙: 수명이 연장된 값들을 위한 공간

현재 스택 프레임 외부에서 해당 값을 참조해야 한다면 힙에 살아야 합니다. 포인터를 반환하는 것이 가장 흔한 예입니다.

func makePtr() *int {
    x := 42
    return &x
}

컴파일러 출력:

./main.go:4:9: &x escapes to heap

x의 크기는 무관합니다; 컴파일러는 호출자가 함수 반환 후에도 x에 대한 참조가 필요함을 보고 스택 프레임에 더 이상 보관할 수 없다고 판단합니다. 포인터 자체는 저렴하지만, 수명 연장이 escape를 강제합니다.

클로저: 변수가 조용히 escape하는 경우

클로저는 초보자들이 실수로 힙 할당을 만들기 쉬운 고전적인 경우입니다.

func counter() func() int {
    x := 0
    return func() int {
        x++
        return x
    }
}

컴파일러 출력:

./main.go:6:13: func literal escapes to heap
./main.go:5:5: moved to heap: x

counter는 종료되지만 반환된 클로저는 여전히 x에 접근해야 합니다. 따라서 x는 스택 프레임에 얽매이지 않는 힙으로 이동해야 합니다. 많은 개발자가 클로저 기반 코드를 작성하면서 매 호출마다 메모리를 할당한다는 사실을 깨닫지 못합니다.

두 개의 생성자, 두 개의 수명, 두 개의 할당 패턴

값을 반환

type User struct {
    Name string
}

func newUser(name string) User {
    return User{Name: name}
}

포인터를 반환

func newUserPtr(name string) *User {
    return &User{Name: name}
}

첫 번째 버전에서는 컴파일러가 User를 호출자의 스택 프레임에 직접 배치하는 경우가 많습니다. 데이터와 필드, 크기는 동일하지만 포인터 버전은 힙 할당을 강제합니다. 그래서 경험 많은 Go 개발자들은 공유 mutable 상태가 필요하지 않다면 값 반환을 선호하라고 말합니다.

Escape Analysis는 명확한 소유권을 좋아한다

작은 리팩터링만으로도 힙 escape를 방지할 수 있습니다:

func sumSlice(nums []int) *int {
    total := 0
    for _, v := range nums {
        total += v
    }
    return &total
}

컴파일러 출력:

./main.go:7:12: &total escapes to heap

포인터 대신 값을 반환하도록 고쳐 보세요:

func sumSlice(nums []int) int {
    total := 0
    for _, v := range nums {
        total += v
    }
    return total
}

이제 컴파일러는 escape 메시지를 보고하지 않습니다. 로직은 동일하지만 수명 의미만 달라진 것입니다. Escape analysis를 이해하면 직관을 컴파일러의 결정과 맞출 수 있습니다.

놀라운 경우: 포인터 없이도 힙 할당이 발생한다

포인터를 반환하지 않아도 힙 escape가 일어날 수 있습니다. 루프 안에서 고루틴을 실행하는 경우를 보세요:

func run() {
    for i := 0; i < 3; i++ {
        go func() {
            fmt.Println(i)
        }()
    }
}

컴파일러 출력:

./main.go:5:10: func literal escapes to heap
./main.go:4:6: moved to heap: i

루프 변수 i는 각 반복이 끝난 뒤에도 살아 있어야 합니다. 고루틴이 나중에 실행될 수 있기 때문이죠. 스택에 머물 수 없으므로 힙으로 이동합니다. 이는 동시성이 수명을 어떻게 바꾸는지 보여주는 예입니다.

Escape Analysis가 실제로 하는 일

Escape analysis는 최적화 단계가 아니라 보수적인 안전 알고리즘입니다:

  • 컴파일러가 값이 로컬임을 증명할 수 있으면 → 스택.
  • 비‑escape를 증명하려면 결정 불가능한 문제를 풀어야 한다면, 컴파일러는 해당 값이 escape한다고 가정합니다.

Go는 안전을 우선시하므로 프로그램이 올바르게 동작합니다.

코드에서 Escape Analysis 확인하기

컴파일러가 내린 모든 결정을 관찰할 수 있습니다:

go build -gcflags="-m=2"

더 자세히 보려면:

go build -gcflags="-m -m"

이 메시지를 스캔하면 중독됩니다 — 컴파일러가 출력하기 전에 escape를 예측하게 되고, Go 메모리 모델에 대한 깊은 통찰을 얻게 됩니다.

초보자에게 왜 중요한가

Escape analysis를 이해하는 것은 조기 최적화가 아니라, 수명을 파악함으로써 다음을 할 수 있게 해줍니다:

  • 값 반환과 포인터 반환을 의도적으로 선택한다.
  • 컴파일러 기대에 맞는 코드를 작성해 불필요한 힙 할당과 GC 압력을 줄인다.

이 단계가 바로 초보자가 초보기를 벗어나는 순간입니다.

다음 편: Part 4 — Go에서 포인터, 두려움 없이

다음 장에서는 포인터를 탐구합니다 — “저수준” 트릭이 아니라 Go에서 소유권과 수명을 형성하는 메커니즘으로서의 포인터를 다룹니다. 포인터가 왜 오해받는지, C 포인터와 어떻게 다른지, 그리고 값/포인터 구분이 Go 설계의 근본이 되는지를 설명합니다.

Memory model → escape analysis → pointers → concurrency → scheduler

이 시리즈는 Go를 이해하는 데 초점을 맞추며, 단순히 사용하는 것이 아니라 깊이 파고듭니다.

Back to Blog

관련 글

더 보기 »

Go 프로파일링(pprof 사용)

pprof란 무엇인가요? pprof는 Go의 내장 프로파일링 도구로, 애플리케이션의 런타임 데이터(예: CPU 사용량, 메모리 할당 등)를 수집하고 분석할 수 있게 해줍니다.

Go 서버에서 고성능 SQLite 읽기

워크로드 가정 이 권장 사항은 다음을 전제로 합니다: - 읽기가 쓰기보다 우세하고 쓰기는 드물거나 오프라인 - 단일 서버 프로세스가 데이터베이스를 소유함 - 다중 goroutine…