Go 컴파일러가 슬라이스 백킹 저장소를 힙 대신 스택에 할당해 성능과 메모리 효율을 개선하는 최근 최적화를 설명합니다.
왜 Go인가 arrow_drop_down Enter를 눌러 드롭다운 활성화/비활성화
배우기 Enter를 눌러 드롭다운 활성화/비활성화
문서 arrow_drop_down Enter를 눌러 드롭다운 활성화/비활성화
Go 명세 Go 언어의 공식 명세
Go 사용자 매뉴얼 Go로 소프트웨어를 만드는 방법에 대한 완전한 입문서
표준 라이브러리 Go 표준 라이브러리에 대한 참조 문서
릴리스 노트 각 Go 릴리스의 새로운 점 알아보기
Effective Go 명확하고 성능이 좋으며 Go다운 코드를 작성하기 위한 팁
패키지 Enter를 눌러 드롭다운 활성화/비활성화
커뮤니티 arrow_drop_down Enter를 눌러 드롭다운 활성화/비활성화
녹화된 발표 이전 이벤트의 영상
밋업 open_in_new 지역의 다른 Go 개발자 만나기
컨퍼런스 open_in_new 전 세계 Go 개발자들과 배우고 교류하기
Go 블로그 Go 프로젝트의 공식 블로그
Go 프로젝트 Go로부터 도움을 받고 최신 정보를 얻기
navigate_before 왜 Go인가 * 사례 연구 * 사용 사례 * 보안
navigate_before 문서 * Go 명세 * Go 사용자 매뉴얼 * 표준 라이브러리 * 릴리스 노트 * Effective Go
navigate_before 커뮤니티
* 녹화된 발표
* 밋업 open_in_new
* 컨퍼런스 open_in_new
* Go 블로그
* Go 프로젝트
* 연결하기
Keith Randall
2026년 2월 27일
우리는 항상 Go 프로그램을 더 빠르게 만드는 방법을 찾고 있습니다. 지난 2번의 릴리스에서 우리는 느려짐의 특정 원인인 힙 할당을 완화하는 데 집중해 왔습니다. Go 프로그램이 힙에서 메모리를 할당할 때마다, 그 할당을 만족시키기 위해 꽤 큰 코드 조각이 실행되어야 합니다. 여기에 더해 힙 할당은 가비지 컬렉터에 추가 부하를 줍니다. Green Tea 같은 최근의 개선이 있더라도, 가비지 컬렉터는 여전히 상당한 오버헤드를 발생시킵니다.
그래서 우리는 힙 대신 스택에서 더 많은 할당을 수행하는 방법을 연구해 왔습니다. 스택 할당은 수행 비용이 훨씬 더 저렴합니다(때로는 완전히 공짜이기도 합니다). 게다가 스택 할당은 스택 프레임 자체와 함께 자동으로 수거될 수 있으므로 가비지 컬렉터에 아무런 부하도 주지 않습니다. 스택 할당은 또한 즉각적인 재사용을 가능하게 하며, 이는 캐시 친화적입니다.
처리할 작업의 슬라이스를 구성하는 작업을 생각해 봅시다:
func process(c chan task) {
var tasks []task
for t := range c {
tasks = append(tasks, t)
}
processAll(tasks)
}
채널 c에서 작업을 꺼내 슬라이스 tasks에 추가할 때 런타임에 어떤 일이 일어나는지 살펴봅시다.
첫 번째 루프 반복에서는 tasks를 위한 백킹 저장소가 없으므로 append가 하나를 할당해야 합니다. 슬라이스가 최종적으로 얼마나 커질지 알 수 없기 때문에 너무 공격적으로 할당할 수는 없습니다. 현재는 크기 1의 백킹 저장소를 할당합니다.
두 번째 루프 반복에서는 이제 백킹 저장소가 존재하지만 가득 찼습니다. append는 다시 새로운 백킹 저장소를 할당해야 하며, 이번에는 크기 2입니다. 이전의 크기 1 백킹 저장소는 이제 가비지가 됩니다.
세 번째 루프 반복에서는 크기 2의 백킹 저장소가 가득 찼습니다. append는 다시 새로운 백킹 저장소를 할당해야 하며, 이번에는 크기 4입니다. 이전의 크기 2 백킹 저장소는 이제 가비지가 됩니다.
네 번째 루프 반복에서는 크기 4의 백킹 저장소에 항목이 3개만 들어 있습니다. append는 기존 백킹 저장소에 항목을 그냥 넣고 슬라이스 길이만 증가시키면 됩니다. 좋습니다! 이번 반복에서는 할당자 호출이 없습니다.
다섯 번째 루프 반복에서는 크기 4의 백킹 저장소가 가득 찼고, append는 다시 새로운 백킹 저장소를 할당해야 하며 이번에는 크기 8입니다.
이런 식으로 계속됩니다. 일반적으로는 할당이 가득 찰 때마다 크기를 두 배로 늘리므로, 결국 대부분의 새 작업을 할당 없이 슬라이스에 추가할 수 있습니다. 하지만 슬라이스가 작을 때의 “시작” 단계에는 꽤 많은 오버헤드가 있습니다. 이 시작 단계 동안 우리는 할당자에서 많은 시간을 보내고, 상당한 양의 가비지를 만들어 내는데, 이는 꽤 낭비처럼 보입니다. 그리고 여러분의 프로그램에서는 슬라이스가 실제로는 크게 커지지 않을 수도 있습니다. 여러분이 마주치는 것은 이 시작 단계뿐일 수도 있습니다.
이 코드가 여러분 프로그램에서 정말로 뜨거운 부분이라면, 이 모든 할당을 피하기 위해 슬라이스를 더 큰 크기로 시작하고 싶을 수 있습니다.
func process2(c chan task) {
tasks := make([]task, 0, 10) // 아마도 최대 10개의 작업
for t := range c {
tasks = append(tasks, t)
}
processAll(tasks)
}
이것은 수행하기에 타당한 최적화입니다. 절대 틀리지 않습니다. 프로그램은 여전히 올바르게 실행됩니다. 추정이 너무 작으면 이전과 같이 append에서 할당이 발생합니다. 추정이 너무 크면 메모리를 조금 낭비합니다.
작업 수에 대한 추정이 적절했다면, 이 프로그램에는 할당 지점이 하나뿐입니다. make 호출이 올바른 크기의 슬라이스 백킹 저장소를 할당하고, append는 재할당을 전혀 할 필요가 없습니다.
놀라운 점은 채널에 원소가 10개 있을 때 이 코드를 벤치마크해 보면, 할당 수가 1로 줄어든 것이 아니라 0으로 줄어든다는 것입니다!
이유는 컴파일러가 백킹 저장소를 스택에 할당하기로 결정했기 때문입니다. 필요한 크기(task 크기의 10배)를 알고 있으므로, 힙이 아니라 process2의 스택 프레임에 저장 공간을 할당할 수 있습니다1. 이는 백킹 저장소가 processAll 내부에서 힙으로 이스케이프하지 않는다는 사실에 의존한다는 점에 유의하세요.
하지만 물론 크기 추정을 하드코딩하는 것은 다소 경직되어 있습니다. 아마도 추정 길이를 전달할 수 있을까요?
func process3(c chan task, lengthGuess int) {
tasks := make([]task, 0, lengthGuess)
for t := range c {
tasks = append(tasks, t)
}
processAll(tasks)
}
이렇게 하면 호출자가 tasks 슬라이스에 적절한 크기를 선택할 수 있고, 이는 이 코드가 어디에서 호출되는지에 따라 달라질 수 있습니다.
안타깝게도 Go 1.24에서는 백킹 저장소 크기가 상수가 아니기 때문에 컴파일러가 더 이상 백킹 저장소를 스택에 할당할 수 없습니다. 결국 힙에 놓이게 되어, 우리의 0-할당 코드는 1-할당 코드로 바뀝니다. 그래도 append가 중간 할당을 모두 수행하는 것보다는 낫지만, 아쉬운 일입니다.
하지만 걱정하지 마세요, Go 1.25가 왔습니다!
추정이 작을 때만 스택 할당을 얻기 위해 다음과 같이 한다고 상상해 봅시다:
func process4(c chan task, lengthGuess int) {
var tasks []task
if lengthGuess <= 10 {
tasks = make([]task, 0, 10)
} else {
tasks = make([]task, 0, lengthGuess)
}
for t := range c {
tasks = append(tasks, t)
}
processAll(tasks)
}
좀 못생겼지만, 작동은 할 것입니다. 추정이 작을 때는 상수 크기 make를 사용하므로 스택에 할당된 백킹 저장소를 사용하고, 추정이 더 크면 가변 크기 make를 사용하여 힙에서 백킹 저장소를 할당합니다.
하지만 Go 1.25에서는 이런 못생긴 길로 갈 필요가 없습니다. Go 1.25 컴파일러가 이 변환을 여러분 대신 해 줍니다! 특정 슬라이스 할당 위치에서 컴파일러는 자동으로 작은(현재는 32바이트) 슬라이스 백킹 저장소를 할당하고, 요청된 크기가 충분히 작으면 make의 결과에 그 백킹 저장소를 사용합니다. 그렇지 않으면 평소처럼 힙 할당을 사용합니다.
Go 1.25에서는 lengthGuess가 충분히 작아서 그 길이의 슬라이스가 32바이트 안에 들어가면 process3는 힙 할당을 전혀 수행하지 않습니다. (물론 lengthGuess가 c 안의 항목 수를 올바르게 추정한 경우입니다.)
우리는 항상 Go의 성능을 개선하고 있으니, 최신 Go 릴리스로 업그레이드하고 여러분의 프로그램이 얼마나 더 빨라지고 메모리 효율이 좋아지는지 놀라워해 보세요!
좋습니다. 하지만 여전히 이 이상한 길이 추정을 추가하려고 API를 바꾸고 싶지는 않을 것입니다. 다른 방법이 있을까요?
Go 1.26으로 업그레이드하세요!
func process(c chan task) {
var tasks []task
for t := range c {
tasks = append(tasks, t)
}
processAll(tasks)
}
Go 1.26에서는 같은 종류의 작고 추측적인 백킹 저장소를 스택에 할당하지만, 이제 이를 append 지점에서 직접 사용할 수 있습니다.
첫 번째 루프 반복에서는 tasks를 위한 백킹 저장소가 없으므로 append가 첫 번째 할당으로 작은 스택 할당 백킹 저장소를 사용합니다. 예를 들어 그 백킹 저장소에 task 4개를 넣을 수 있다면, 첫 번째 append는 길이 4의 백킹 저장소를 스택에서 할당합니다.
그다음 3번의 루프 반복은 스택 백킹 저장소에 직접 추가되며, 할당이 필요 없습니다.
4번째 반복에서는 마침내 스택 백킹 저장소가 가득 차고 더 많은 백킹 저장소를 위해 힙으로 가야 합니다. 하지만 이 글 앞부분에서 설명한 시작 오버헤드의 거의 전부를 피했습니다. 크기 1, 2, 4의 힙 할당도 없고, 그것들이 결국 만들어 내는 가비지도 없습니다. 슬라이스가 작다면 힙 할당이 전혀 없을 수도 있습니다.
좋습니다. tasks 슬라이스가 이스케이프하지 않을 때는 모두 좋습니다. 하지만 슬라이스를 반환하는 경우는 어떨까요? 그러면 스택에 할당할 수 없겠죠?
맞습니다! 아래 extract가 반환하는 슬라이스의 백킹 저장소는 스택에 할당될 수 없습니다. extract의 스택 프레임은 extract가 반환될 때 사라지기 때문입니다.
func extract(c chan task) []task {
var tasks []task
for t := range c {
tasks = append(tasks, t)
}
return tasks
}
하지만 이렇게 생각할 수도 있습니다. 반환되는 슬라이스는 스택에 할당할 수 없습니다. 그렇다면 그저 가비지가 되어 버리는 모든 중간 슬라이스는 어떨까요? 그것들은 스택에 할당할 수 있지 않을까요?
func extract2(c chan task) []task {
var tasks []task
for t := range c {
tasks = append(tasks, t)
}
tasks2 := make([]task, len(tasks))
copy(tasks2, tasks)
return tasks2
}
그러면 tasks 슬라이스는 extract2 밖으로 이스케이프하지 않습니다. 위에서 설명한 모든 최적화의 이점을 얻을 수 있습니다. 그런 다음 extract2의 맨 마지막에서, 슬라이스의 최종 크기를 알게 되었을 때 필요한 크기의 힙 할당을 한 번 수행하고, task들을 그 안으로 복사한 뒤, 그 복사본을 반환합니다.
하지만 정말로 그 모든 추가 코드를 직접 쓰고 싶으신가요? 오류가 발생하기 쉬워 보입니다. 아마 컴파일러가 이 변환을 대신해 줄 수 있을까요?
Go 1.26에서는 가능합니다!
이스케이프하는 슬라이스에 대해 컴파일러는 원래의 extract 코드를 다음과 비슷하게 변환합니다:
func extract3(c chan task) []task {
var tasks []task
for t := range c {
tasks = append(tasks, t)
}
tasks = runtime.move2heap(tasks)
return tasks
}
runtime.move2heap는 힙에 이미 할당된 슬라이스에 대해서는 항등 함수인 특별한 컴파일러+런타임 함수입니다. 스택에 있는 슬라이스에 대해서는 힙에 새 슬라이스를 할당하고, 스택 할당된 슬라이스를 힙 복사본으로 복사한 다음, 그 힙 복사본을 반환합니다.
이렇게 하면 원래의 extract 코드에서, 항목 수가 우리의 작은 스택 할당 버퍼에 들어맞는다면 정확히 올바른 크기의 할당을 정확히 1번 수행하게 됩니다. 항목 수가 작은 스택 할당 버퍼의 용량을 초과하면, 스택 할당 버퍼가 넘친 뒤에는 평소의 두 배씩 늘리는 할당을 수행합니다.
Go 1.26이 수행하는 최적화는 실제로 손으로 한 최적화보다 더 낫습니다. 손으로 한 최적화가 항상 마지막에 수행하는 추가 할당+복사를 요구하지 않기 때문입니다. 반환 지점까지 오직 스택 기반 슬라이스만 사용해 온 경우에만 할당+복사가 필요합니다.
복사 비용을 치르기는 하지만, 그 비용은 이제 더 이상 하지 않아도 되는 시작 단계의 복사들에 의해 거의 완전히 상쇄됩니다. (사실 새 방식은 최악의 경우에도 이전 방식보다 원소를 하나 더 복사하면 됩니다.)
수동 최적화는 여전히 유익할 수 있으며, 특히 슬라이스 크기를 미리 잘 추정할 수 있다면 더욱 그렇습니다. 하지만 이제 컴파일러가 많은 단순한 경우를 잡아 주고, 여러분이 정말 중요한 나머지 경우에 집중할 수 있게 되기를 바랍니다.
컴파일러가 이 모든 최적화를 올바르게 수행하려면 보장해야 할 세부 사항이 많습니다. 이 최적화들 중 하나가 여러분에게 정확성 또는 성능(악화) 문제를 일으킨다고 생각한다면, -gcflags=all=-d=variablemakehash=n으로 이를 끌 수 있습니다. 이 최적화들을 끄는 것이 도움이 된다면, 우리가 조사할 수 있도록 이슈를 등록해 주세요.
1 Go 스택에는 동적으로 크기가 정해지는 스택 프레임을 위한 alloca 스타일 메커니즘이 없습니다. 모든 Go 스택 프레임은 상수 크기입니다.
다음 글://go:fix inline과 소스 수준 인라이너
시작하기PlaygroundTourStack Overflow도움말
소개다운로드블로그이슈 추적기릴리스 노트브랜드 가이드라인행동 강령
연결BlueskyMastodonTwitterGitHubSlackr/golangMeetupGolang Weekly
새 창에서 열립니다.
go.dev는 서비스를 제공하고 품질을 향상시키며 트래픽을 분석하기 위해 Google의 쿠키를 사용합니다. 자세히 알아보기.
확인