GO · 심층 가이드
Go 성능 완전 정리
pprof로 병목을 찾고 이스케이프 분석과 사전 할당으로 힙 할당을 줄이는 과정을, 추측이 아니라 벤치마크 수치를 근거로 판단하도록 19개 주제로 묶었습니다.
Go에서 성능 튜닝의 무게중심은 알고리즘보다 할당 횟수에 있는 경우가 많습니다. 컴파일러가 이스케이프 분석으로 값을 스택에 둘지 힙에 올릴지 결정하고, 힙에 올라간 만큼 GC가 일합니다. 그래서 go test -bench . -benchmem이 찍어 주는 allocs/op가 사실상 첫 번째 지표입니다. 인터페이스에 값을 담거나 클로저가 지역 변수를 붙잡는 순간 조용히 힙으로 넘어가는데, go build -gcflags=-m으로 그 판정을 직접 확인할 수 있습니다.
순서는 측정부터입니다. pprof CPU 프로파일링으로 시간이 쏠린 함수를 먼저 찾고, pprof 메모리 프로파일링으로 할당 주범을 좁힙니다. 여기서 나온 대상에 이스케이프 분석을 걸어 왜 힙에 올라가는지 확인한 다음, 슬라이스 사전 할당과 문자열 빌더 최적화처럼 값싼 수정부터 적용합니다. CPU 그래프로 설명되지 않는 지연(스케줄러 대기, GC STW, 시스템 콜 블로킹)이 의심되면 트레이싱 (execution trace)이 유일하게 답을 주는 도구입니다.
sync.Pool은 만능이 아닙니다. 풀에 든 객체는 GC 사이클마다 정리되므로 재사용률이 낮은 워크로드에서는 이득이 거의 없고, 크기가 제각각인 버퍼를 그대로 넣으면 큰 버퍼가 풀에 눌러앉아 메모리만 늘어납니다. 넣기 전에 길이를 자르고 지나치게 큰 것은 버리는 편이 낫습니다. 컨테이너 환경도 주의할 지점입니다. 오랫동안 Go 런타임은 cgroup의 CPU 제한을 자동으로 반영하지 않아 GOMAXPROCS가 호스트 코어 수로 잡혔고, 이 때문에 명시적으로 값을 설정하는 관행이 자리 잡았습니다.
01sync.Pool & 메모리 최적화
임시 객체를 재사용하는 sync.Pool로 GC 압력을 줄입니다.
Go code
package main
import (
"bytes"
"fmt"
"sync"
)
// sync.Pool — 임시 객체 재사용 (GC 시 해제될 수 있음)
var bufPool = sync.Pool{
New: func() interface{} {
return new(bytes.Buffer)
},
}
func processRequest(data string) string {
// 풀에서 버퍼 가져오기
buf := bufPool.Get().(*bytes.Buffer)
defer func() {
buf.Reset()
bufPool.Put(buf) // 풀에 반환
}()
buf.WriteString("처리: ")
buf.WriteString(data)
return buf.String()
}
// 슬라이스 미리 할당
func efficientAppend(n int) []int {
// Bad: 매번 재할당
// result := []int{}
// for i := 0; i < n; i++ { result = append(result, i) }
// Good: 미리 용량 할당
result := make([]int, 0, n)
for i := 0; i < n; i++ {
result = append(result, i)
}
return result
}
// 문자열 변환 최적화
func buildString(parts []string) string {
// Bad: + 연산자 (매번 새 문자열)
// s := ""
// for _, p := range parts { s += p }
// Good: strings.Builder
var sb strings.Builder
sb.Grow(len(parts) * 10) // 예상 크기 예약
for _, p := range parts { sb.WriteString(p) }
return sb.String()
}
func main() {
results := make([]string, 5)
for i := 0; i < 5; i++ {
results[i] = processRequest(fmt.Sprintf("req-%d", i))
}
for _, r := range results { fmt.Println(r) }
nums := efficientAppend(100)
fmt.Println("첫:", nums[0], "마지막:", nums[99])
}sync.Pool은 GC 사이클에 의해 비워질 수 있습니다. 영구 캐시로 사용하지 마세요. 할당 비용이 큰 임시 객체(버퍼, 파서 등)에 적합합니다.
sync.Pool에서 꺼낸 객체는 반드시 Reset()하거나 초기화하세요. 이전 사용자의 데이터가 남아 있습니다.
02pprof & 성능 프로파일링
Go 내장 프로파일러로 CPU와 메모리 병목을 찾습니다.
Go code
// main.go — HTTP 서버에 pprof 연결
package main
import (
"log"
"net/http"
_ "net/http/pprof" // 이 import만으로 /debug/pprof 활성화
"runtime/pprof"
"os"
)
func main() {
// CPU 프로파일링 (파일로 저장)
f, err := os.Create("cpu.prof")
if err != nil { log.Fatal(err) }
defer f.Close()
pprof.StartCPUProfile(f)
defer pprof.StopCPUProfile()
// 메모리 프로파일링
defer func() {
mf, _ := os.Create("mem.prof")
defer mf.Close()
pprof.WriteHeapProfile(mf)
}()
// 실제 애플리케이션 코드
heavyWork()
// HTTP 서버에서 실시간 프로파일링
go func() {
log.Println(http.ListenAndServe(":6060", nil))
}()
// curl http://localhost:6060/debug/pprof/
}
func heavyWork() {
s := make([]int, 0)
for i := 0; i < 1_000_000; i++ {
s = append(s, i*i)
}
}
// 분석 명령어:
// go test -cpuprofile=cpu.prof -memprofile=mem.prof -bench=.
// go tool pprof cpu.prof
// go tool pprof -http=:8080 cpu.prof // 웹 UI
// (pprof) top10
// (pprof) list functionName
// (pprof) webgo test -bench=. -benchmem으로 벤치마크 실행 시 메모리 할당 횟수와 크기도 확인할 수 있습니다.
_ "net/http/pprof" import는 프로덕션에서 활성화하면 보안 위험입니다. 빌드 태그나 환경 변수로 개발/프로덕션을 구분하세요.
03프로파일 가이드 최적화 (PGO)
go build -pgo=auto로 CPU 사용 기반 최적화
Go code
<span class="cm">// 프로파일 가이드 최적화 (PGO) 예제
// data/prompts.js의 생성 프롬프트로 상세 코드 생성 가능</span>
fun main() { println("프로파일 가이드 최적화 (PGO)") }GO 공식 문서를 함께 참고하세요.
자주 발생하는 실수에 주의하세요.
04빌드 태그 & 크로스 컴파일
Go의 빌드 태그(//go:build)로 플랫폼별 코드를 분리하고, 환경 변수로 손쉽게 크로스 컴파일합니다. CGO 없이 정적 바이너리를 생성하여 Docker scratch 이미지나 다른 OS용 배포가 가능합니다.
Go code
package main
import (
"fmt"
"runtime"
)
// === 빌드 태그 예제 ===
// 파일: config_prod.go
// //go:build prod
// package main
// var AppMode = "production"
// 파일: config_dev.go
// //go:build !prod
// package main
// var AppMode = "development"
// 파일: platform_linux.go
// //go:build linux
// func platformInfo() string { return "Linux 최적화 경로 사용" }
// 파일: platform_windows.go
// //go:build windows
// func platformInfo() string { return "Windows API 사용" }
// 파일: platform_default.go
// //go:build !linux && !windows
// func platformInfo() string { return "범용 구현 사용" }
func main() {
// 현재 빌드 정보
fmt.Printf("OS: %s\n", runtime.GOOS)
fmt.Printf("Arch: %s\n", runtime.GOARCH)
fmt.Printf("CPUs: %d\n", runtime.NumCPU())
fmt.Printf("Go: %s\n", runtime.Version())
fmt.Println("\n=== 크로스 컴파일 명령어 ===")
targets := []struct{ os, arch, desc string }{
{"linux", "amd64", "Linux x86_64 서버"},
{"linux", "arm64", "Linux ARM (AWS Graviton)"},
{"darwin", "arm64", "macOS Apple Silicon"},
{"windows", "amd64", "Windows x86_64"},
{"linux", "amd64", "정적 바이너리 (Alpine/scratch)"},
}
for _, t := range targets {
cmd := fmt.Sprintf("GOOS=%s GOARCH=%s go build -o app-%s-%s",
t.os, t.arch, t.os, t.arch)
if t.desc == "정적 바이너리 (Alpine/scratch)" {
cmd = "CGO_ENABLED=0 " + cmd + "-static"
}
fmt.Printf(" %-50s # %s\n", cmd, t.desc)
}
fmt.Println("\n=== 빌드 태그 사용 ===")
fmt.Println(" go build -tags prod # 프로덕션 모드")
fmt.Println(" go build -tags 'prod metrics' # 여러 태그")
fmt.Println(" go build -ldflags '-s -w' # 디버그 정보 제거 (바이너리 축소)")
fmt.Println("\n=== Dockerfile 예제 ===")
dockerfile := "# Multi-stage 빌드\n" +
"FROM golang:1.22-alpine AS builder\n" +
"WORKDIR /app\n" +
"COPY go.* ./\n" +
"RUN go mod download\n" +
"COPY . .\n" +
"RUN CGO_ENABLED=0 go build -ldflags='-s -w' -o /server\n" +
"FROM scratch\n" +
"COPY --from=builder /server /server\n" +
"ENTRYPOINT [\"/server\"]"
fmt.Println(dockerfile)
}Go 1.17+에서는 //go:build 형식을 사용하세요(기존 // +build는 deprecated). -ldflags="-s -w"로 바이너리 크기를 20~30% 줄일 수 있습니다.
CGO_ENABLED=0을 설정하지 않으면 C 라이브러리에 동적 링크되어 Alpine이나 scratch 이미지에서 실행되지 않습니다. 또한 net 패키지가 기본적으로 cgo를 사용하므로 주의하세요.
05pprof CPU 프로파일링
runtime/pprof과 net/http/pprof로 CPU 사용을 분석합니다. 핫스팟을 찾아 최적화합니다.
Go code
package main
import (
"fmt"
"os"
"runtime/pprof"
)
func heavyWork() int {
sum := 0
for i := 0; i < 10_000_000; i++ {
sum += i * i
}
return sum
}
func main() {
// CPU 프로파일 생성
f, _ := os.Create("cpu.prof")
defer f.Close()
pprof.StartCPUProfile(f)
defer pprof.StopCPUProfile()
// 프로파일할 코드
result := heavyWork()
fmt.Println("결과:", result)
fmt.Println("\n프로파일 분석:")
fmt.Println(" go tool pprof cpu.prof")
fmt.Println(" (pprof) top 10 # 상위 10개 함수")
fmt.Println(" (pprof) list heavyWork # 함수별 상세")
fmt.Println(" (pprof) web # 웹 시각화")
fmt.Println()
fmt.Println("HTTP 서버에서:")
fmt.Println(" import _ "net/http/pprof"")
fmt.Println(" go tool pprof http://localhost:6060/debug/pprof/profile?seconds=30")
os.Remove("cpu.prof")
}net/http/pprof를 import하면 /debug/pprof/ 엔드포인트가 자동 등록됩니다. 프로덕션에서는 별도 포트로 분리하세요.
pprof 엔드포인트를 공개 포트에 노출하면 보안 위험입니다. 내부 네트워크에서만 접근 가능하도록 설정하세요.
06pprof 메모리 프로파일링
힙 메모리 할당을 분석하여 메모리 사용량을 최적화합니다.
Go code
package main
import (
"fmt"
"os"
"runtime"
"runtime/pprof"
)
func allocateSlices() {
var data [][]byte
for i := 0; i < 1000; i++ {
buf := make([]byte, 10240) // 10KB
data = append(data, buf)
}
_ = data
}
func main() {
allocateSlices()
// 힙 프로파일 생성
f, _ := os.Create("mem.prof")
defer f.Close()
runtime.GC() // GC 실행 후 프로파일링
pprof.WriteHeapProfile(f)
// 메모리 통계
var m runtime.MemStats
runtime.ReadMemStats(&m)
fmt.Printf("힙 할당: %d MB\n", m.HeapAlloc/1024/1024)
fmt.Printf("시스템 메모리: %d MB\n", m.Sys/1024/1024)
fmt.Printf("GC 횟수: %d\n", m.NumGC)
fmt.Println("\n분석 명령어:")
fmt.Println(" go tool pprof mem.prof")
fmt.Println(" (pprof) top --alloc_space # 총 할당량 기준")
fmt.Println(" (pprof) top --inuse_space # 현재 사용 기준")
fmt.Println(" go tool pprof -http=:8080 mem.prof # 웹 UI")
os.Remove("mem.prof")
}--alloc_space는 총 할당량, --inuse_space는 현재 사용 중인 메모리를 보여줍니다. 목적에 따라 선택하세요.
프로파일 전 runtime.GC()를 호출하지 않으면 아직 GC되지 않은 객체가 포함되어 결과가 부정확합니다.
07트레이싱 (execution trace)
runtime/trace로 고루틴 스케줄링, GC, 시스템 호출을 시각화합니다.
Go code
package main
import (
"fmt"
"os"
"runtime/trace"
"sync"
)
func main() {
f, _ := os.Create("trace.out")
defer f.Close()
trace.Start(f)
defer trace.Stop()
// 트레이싱할 코드
var wg sync.WaitGroup
for i := 0; i < 10; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
sum := 0
for j := 0; j < 1000000; j++ {
sum += j
}
_ = sum
}(i)
}
wg.Wait()
fmt.Println("트레이스 분석:")
fmt.Println(" go tool trace trace.out")
fmt.Println()
fmt.Println("확인 항목:")
fmt.Println(" - 고루틴 스케줄링 (Goroutine analysis)")
fmt.Println(" - GC 일시 정지 시간")
fmt.Println(" - 네트워크/시스템 호출 블로킹")
fmt.Println(" - 고루틴 간 동기화 대기 시간")
os.Remove("trace.out")
}go tool trace는 브라우저에서 타임라인을 시각화합니다. 고루틴 스케줄링 병목을 찾는 데 pprof보다 효과적입니다.
트레이싱은 오버헤드가 큽니다. 프로덕션에서는 짧은 시간(<30초)만 활성화하세요.
08벤치마크 분석
benchstat으로 벤치마크 결과를 통계적으로 비교합니다. 최적화 전후 성능을 객관적으로 평가합니다.
Go code
package main
import "fmt"
func main() {
fmt.Println("=== 벤치마크 분석 워크플로우 ===")
fmt.Println()
fmt.Println("1. 벤치마크 실행 (최소 5회):")
fmt.Println(" go test -bench=. -count=5 > old.txt")
fmt.Println()
fmt.Println("2. 코드 최적화 후 재실행:")
fmt.Println(" go test -bench=. -count=5 > new.txt")
fmt.Println()
fmt.Println("3. 통계 비교:")
fmt.Println(" benchstat old.txt new.txt")
fmt.Println()
fmt.Println("출력 예시:")
fmt.Println(" name old time/op new time/op delta")
fmt.Println(" Process-8 1.50ms ± 2% 0.85ms ± 1% -43.33%")
fmt.Println()
fmt.Println("4. 메모리 포함 분석:")
fmt.Println(" go test -bench=. -benchmem -count=5 > result.txt")
fmt.Println()
fmt.Println("설치:")
fmt.Println(" go install golang.org/x/perf/cmd/benchstat@latest")
}-count=5 이상으로 여러 번 실행해야 통계적으로 유의미한 비교가 가능합니다. benchstat이 p-value를 계산합니다.
벤치마크를 1회만 실행하면 노이즈에 의한 오차가 클 수 있습니다. 최소 5회 실행하여 분산을 확인하세요.
09메모리 할당 최적화
불필요한 힙 할당을 줄여 GC 압력을 낮춥니다. 슬라이스, 맵, 문자열 빌더의 사전 할당을 다룹니다.
Go code
package main
import (
"fmt"
"strings"
)
func main() {
n := 10000
// 나쁜 예: 반복적 재할당
s1 := make([]int, 0)
for i := 0; i < n; i++ {
s1 = append(s1, i) // 용량 초과 시 매번 재할당
}
// 좋은 예: 사전 할당
s2 := make([]int, 0, n)
for i := 0; i < n; i++ {
s2 = append(s2, i) // 재할당 없음
}
// 맵 사전 할당
m := make(map[string]int, n) // 힌트 제공
for i := 0; i < n; i++ {
m[fmt.Sprintf("key%d", i)] = i
}
// 문자열 빌더 사전 할당
var sb strings.Builder
sb.Grow(n * 10) // 예상 크기만큼 미리 할당
for i := 0; i < n; i++ {
fmt.Fprintf(&sb, "item%d,", i)
}
fmt.Printf("슬라이스: len=%d, cap=%d\n", len(s2), cap(s2))
fmt.Printf("맵: len=%d\n", len(m))
fmt.Printf("문자열: len=%d\n", sb.Len())
}make([]T, 0, n)으로 사전 할당하면 append 시 재할당이 발생하지 않아 GC 압력이 줄어듭니다.
make([]T, n)은 길이 n으로 초기화합니다(제로 값 채움). make([]T, 0, n)은 빈 슬라이스에 용량만 확보합니다.
10문자열 빌더 최적화
strings.Builder와 bytes.Buffer로 효율적인 문자열 연결을 수행합니다. + 연산자 대비 수십 배 빠릅니다.
Go code
package main
import (
"bytes"
"fmt"
"strings"
)
func concatPlus(n int) string {
s := ""
for i := 0; i < n; i++ {
s += "x" // 매번 새 문자열 생성 (O(n²))
}
return s
}
func concatBuilder(n int) string {
var sb strings.Builder
sb.Grow(n) // 사전 할당
for i := 0; i < n; i++ {
sb.WriteByte('x')
}
return sb.String()
}
func concatBuffer(n int) string {
var buf bytes.Buffer
buf.Grow(n)
for i := 0; i < n; i++ {
buf.WriteByte('x')
}
return buf.String()
}
func main() {
n := 10000
fmt.Println("+ 연산자:", len(concatPlus(n)))
fmt.Println("Builder:", len(concatBuilder(n)))
fmt.Println("Buffer:", len(concatBuffer(n)))
// strings.Join도 효율적
parts := make([]string, n)
for i := range parts { parts[i] = "x" }
result := strings.Join(parts, "")
fmt.Println("Join:", len(result))
}strings.Builder는 문자열 생성에, bytes.Buffer는 바이트 처리에 최적입니다. 둘 다 io.Writer를 구현합니다.
strings.Builder를 복사하면 패닉이 발생합니다. 값이 아닌 포인터로 전달하세요.
11슬라이스 사전 할당
make([]T, 0, cap)으로 슬라이스 용량을 미리 확보하여 재할당을 방지합니다.
Go code
package main
import (
"fmt"
"testing"
)
func withoutPrealloc(n int) []int {
var s []int // cap=0
for i := 0; i < n; i++ {
s = append(s, i) // 용량 초과 시 매번 재할당
}
return s
}
func withPrealloc(n int) []int {
s := make([]int, 0, n) // cap=n
for i := 0; i < n; i++ {
s = append(s, i) // 재할당 없음
}
return s
}
func BenchmarkWithout(b *testing.B) {
for i := 0; i < b.N; i++ {
withoutPrealloc(10000)
}
}
func BenchmarkWith(b *testing.B) {
for i := 0; i < b.N; i++ {
withPrealloc(10000)
}
}
func main() {
s1 := withoutPrealloc(8)
s2 := withPrealloc(8)
fmt.Printf("미할당: len=%d, cap=%d\n", len(s1), cap(s1))
fmt.Printf("사전할당: len=%d, cap=%d\n", len(s2), cap(s2))
// 변환 시 사전 할당
src := []int{1, 2, 3, 4, 5}
dst := make([]string, 0, len(src))
for _, n := range src {
dst = append(dst, fmt.Sprint(n))
}
fmt.Println(dst)
}입력 크기를 알 때는 항상 make([]T, 0, len)으로 사전 할당하세요. prealloc 린터가 이를 자동 감지합니다.
make([]int, n)은 길이 n짜리 슬라이스를 만듭니다(모두 0). append하면 뒤에 추가되어 크기가 2n이 됩니다.
12맵 사전 할당
make(map[K]V, hint)로 맵의 초기 크기를 지정하여 해시 테이블 리사이징을 줄입니다.
Go code
package main
import (
"fmt"
"testing"
)
func mapWithoutHint(n int) map[int]int {
m := make(map[int]int) // 기본 크기
for i := 0; i < n; i++ {
m[i] = i * i
}
return m
}
func mapWithHint(n int) map[int]int {
m := make(map[int]int, n) // 크기 힌트
for i := 0; i < n; i++ {
m[i] = i * i
}
return m
}
func BenchmarkMapWithout(b *testing.B) {
for i := 0; i < b.N; i++ {
mapWithoutHint(10000)
}
}
func BenchmarkMapWith(b *testing.B) {
for i := 0; i < b.N; i++ {
mapWithHint(10000)
}
}
func main() {
m1 := mapWithoutHint(100)
m2 := mapWithHint(100)
fmt.Println("크기:", len(m1), len(m2))
// maps.Clone (Go 1.21+)
// import "maps"
// m3 := maps.Clone(m1)
fmt.Println("맵 사전 할당으로 리사이징 횟수 감소")
}맵 크기 힌트는 정확하지 않아도 됩니다. 대략적인 크기만 제공해도 리사이징이 크게 줄어듭니다.
맵의 크기 힌트는 용량이 아닌 버킷 수에 영향을 미칩니다. len(m)은 실제 요소 수를 반환하며 용량과 무관합니다.
13고루틴 풀링
고루틴 생성/소멸 비용을 줄이는 풀링 패턴. 워커 풀로 고루틴 수를 제한합니다.
Go code
package main
import (
"fmt"
"sync"
)
type Pool struct {
tasks chan func()
wg sync.WaitGroup
}
func NewPool(workers int) *Pool {
p := &Pool{tasks: make(chan func(), workers*2)}
for i := 0; i < workers; i++ {
p.wg.Add(1)
go func() {
defer p.wg.Done()
for task := range p.tasks {
task()
}
}()
}
return p
}
func (p *Pool) Submit(task func()) {
p.tasks <- task
}
func (p *Pool) Close() {
close(p.tasks)
p.wg.Wait()
}
func main() {
pool := NewPool(4) // 4개 워커
var mu sync.Mutex
results := make([]int, 0, 100)
for i := 0; i < 100; i++ {
n := i
pool.Submit(func() {
result := n * n
mu.Lock()
results = append(results, result)
mu.Unlock()
})
}
pool.Close()
fmt.Printf("처리 완료: %d건\n", len(results))
}Go의 고루틴은 매우 가볍지만(2-8KB 스택), 수십만 개가 동시에 실행되면 스케줄링 오버헤드가 커집니다. 풀링으로 제한하세요.
풀을 닫기 전에 모든 작업이 제출되었는지 확인하세요. Close 후 Submit하면 패닉이 발생합니다.
14GC 튜닝
GOGC와 GOMEMLIMIT 환경 변수로 가비지 컬렉터 동작을 조정합니다.
Go code
package main
import (
"fmt"
"runtime"
"runtime/debug"
)
func main() {
// 현재 GC 설정 확인
fmt.Println("GOGC:", debug.SetGCPercent(-1))
debug.SetGCPercent(100) // 기본값 복원
// GC 통계
var stats debug.GCStats
debug.ReadGCStats(&stats)
fmt.Printf("GC 횟수: %d\n", stats.NumGC)
if len(stats.Pause) > 0 {
fmt.Printf("마지막 GC 일시정지: %v\n", stats.Pause[0])
}
// GOMEMLIMIT 설정 (Go 1.19+)
// 컨테이너 환경에서 메모리 제한
debug.SetMemoryLimit(512 * 1024 * 1024) // 512MB
// 메모리 통계
var m runtime.MemStats
runtime.ReadMemStats(&m)
fmt.Printf("힙 사용: %d MB\n", m.HeapAlloc/1024/1024)
fmt.Printf("힙 시스템: %d MB\n", m.HeapSys/1024/1024)
fmt.Printf("GC CPU 비율: %.2f%%\n", m.GCCPUFraction*100)
fmt.Println("\n환경 변수:")
fmt.Println(" GOGC=200 # GC 빈도 감소 (메모리 ↑, CPU ↓)")
fmt.Println(" GOGC=50 # GC 빈도 증가 (메모리 ↓, CPU ↑)")
fmt.Println(" GOMEMLIMIT=512MiB # 메모리 제한 (Go 1.19+)")
fmt.Println(" GOGC=off GOMEMLIMIT=1GiB # 소프트 메모리 제한만 사용")
}GOMEMLIMIT과 GOGC=off를 함께 사용하면 메모리 한도 내에서 GC를 최소화하여 처리량을 극대화합니다.
GOGC=off만 설정하면 메모리가 무한히 증가합니다. 반드시 GOMEMLIMIT과 함께 사용하세요.
15이스케이프 분석
Go 컴파일러의 이스케이프 분석을 이해하여 힙 할당을 최소화합니다.
Go code
package main
import "fmt"
// 스택에 할당됨 (이스케이프 안 함)
func stackAlloc() int {
x := 42 // 스택
return x
}
// 힙에 할당됨 (이스케이프)
func heapAlloc() *int {
x := 42 // 힙으로 이스케이프 (포인터가 반환됨)
return &x
}
// 인터페이스로 이스케이프
func interfaceEscape() {
x := 42
fmt.Println(x) // x가 any로 박싱 → 힙 할당
}
// 이스케이프 방지 패턴
type Result struct {
Value int
Err error
}
func compute() Result {
return Result{Value: 42} // 값 반환 → 스택 유지
}
func main() {
fmt.Println(stackAlloc())
fmt.Println(*heapAlloc())
fmt.Println(compute())
fmt.Println("\n이스케이프 분석 확인:")
fmt.Println(" go build -gcflags='-m' main.go")
fmt.Println(" go build -gcflags='-m -m' main.go # 더 상세")
}go build -gcflags='-m'로 이스케이프 분석 결과를 확인할 수 있습니다. escapes to heap이 핫스팟입니다.
포인터를 반환하면 변수가 힙으로 이스케이프합니다. 성능이 중요한 경우 값 타입을 반환하는 것을 고려하세요.
16인라인 최적화
Go 컴파일러의 함수 인라이닝을 이해하고 활용합니다. 작은 함수는 자동으로 인라인됩니다.
Go code
package main
import "fmt"
// 인라인 가능 (작은 함수)
func add(a, b int) int {
return a + b
}
// 인라인 가능
func max(a, b int) int {
if a > b { return a }
return b
}
// 인라인 불가 (복잡한 함수)
func complexFunc(n int) int {
sum := 0
for i := 0; i < n; i++ {
switch {
case i%15 == 0: sum += 15
case i%3 == 0: sum += 3
case i%5 == 0: sum += 5
default: sum += i
}
}
return sum
}
//go:noinline // 인라인 비활성화 (벤치마크용)
func addNoInline(a, b int) int {
return a + b
}
func main() {
fmt.Println(add(1, 2))
fmt.Println(max(3, 5))
fmt.Println("\n인라인 확인:")
fmt.Println(" go build -gcflags='-m' main.go")
fmt.Println(" 'can inline add' → 인라인 가능")
fmt.Println(" 'inlining call to add' → 실제 인라인됨")
fmt.Println()
fmt.Println("Go 1.22+ mid-stack inlining 향상:")
fmt.Println(" 더 큰 함수도 인라인 가능")
}인라인되는 함수는 함수 호출 오버헤드가 없어집니다. -gcflags='-m'로 인라인 여부를 확인하세요.
//go:noinline을 무분별하게 사용하면 최적화 기회를 잃습니다. 벤치마크 외에는 컴파일러에 맡기세요.
17어셈블리 출력 분석
Go 코드의 어셈블리 출력을 확인하여 컴파일러 최적화를 이해합니다.
Go code
package main
import "fmt"
func sum(nums []int) int {
total := 0
for _, n := range nums {
total += n
}
return total
}
// 바운드 체크 제거 힌트
func sumOptimized(nums []int) int {
total := 0
for i := 0; i < len(nums); i++ {
total += nums[i]
}
return total
}
func main() {
nums := []int{1, 2, 3, 4, 5}
fmt.Println(sum(nums))
fmt.Println(sumOptimized(nums))
fmt.Println("\n어셈블리 출력:")
fmt.Println(" go build -gcflags='-S' main.go # 전체 어셈블리")
fmt.Println(" go tool compile -S main.go # 파일별")
fmt.Println(" go tool objdump -s 'main.sum' ./app # 바이너리에서")
fmt.Println()
fmt.Println("Godbolt (https://godbolt.org)에서 온라인 확인 가능")
fmt.Println()
fmt.Println("바운드 체크 확인:")
fmt.Println(" go build -gcflags='-d=ssa/check_bce/debug=1' main.go")
}go build -gcflags='-d=ssa/check_bce/debug=1'로 바운드 체크가 제거된 곳을 확인할 수 있습니다.
어셈블리 수준 최적화는 마지막 수단입니다. 먼저 알고리즘과 데이터 구조를 최적화하세요.
18캐싱 전략
인메모리 캐시로 반복 연산을 줄입니다. TTL, LRU, 동시성 안전한 캐시를 구현합니다.
Go code
package main
import (
"fmt"
"sync"
"time"
)
type CacheItem[V any] struct {
Value V
ExpiresAt time.Time
}
type Cache[K comparable, V any] struct {
mu sync.RWMutex
items map[K]CacheItem[V]
ttl time.Duration
}
func NewCache[K comparable, V any](ttl time.Duration) *Cache[K, V] {
return &Cache[K, V]{
items: make(map[K]CacheItem[V]),
ttl: ttl,
}
}
func (c *Cache[K, V]) Get(key K) (V, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
item, ok := c.items[key]
if !ok || time.Now().After(item.ExpiresAt) {
var zero V
return zero, false
}
return item.Value, true
}
func (c *Cache[K, V]) Set(key K, value V) {
c.mu.Lock()
defer c.mu.Unlock()
c.items[key] = CacheItem[V]{
Value: value,
ExpiresAt: time.Now().Add(c.ttl),
}
}
func main() {
cache := NewCache[string, int](5 * time.Second)
cache.Set("count", 42)
if v, ok := cache.Get("count"); ok {
fmt.Println("캐시 히트:", v)
}
if _, ok := cache.Get("missing"); !ok {
fmt.Println("캐시 미스")
}
}캐시 TTL은 데이터 신선도와 성능 사이의 균형입니다. 변경이 드문 데이터는 긴 TTL, 자주 변하는 데이터는 짧은 TTL을 사용하세요.
캐시 만료를 확인하지 않으면 오래된 데이터를 반환합니다. 항상 TTL을 설정하고 만료된 항목을 정리하세요.
19프로파일링 도구 종합
Go의 성능 분석 도구 체인 총정리. pprof, trace, benchstat을 통합적으로 활용합니다.
Go code
package main
import "fmt"
func main() {
fmt.Println("=== Go 프로파일링 도구 체인 ===")
fmt.Println()
fmt.Println("1. CPU 프로파일링:")
fmt.Println(" go test -cpuprofile=cpu.prof -bench=.")
fmt.Println(" go tool pprof -http=:8080 cpu.prof")
fmt.Println()
fmt.Println("2. 메모리 프로파일링:")
fmt.Println(" go test -memprofile=mem.prof -bench=.")
fmt.Println(" go tool pprof -http=:8080 mem.prof")
fmt.Println()
fmt.Println("3. 실행 트레이스:")
fmt.Println(" go test -trace=trace.out -bench=.")
fmt.Println(" go tool trace trace.out")
fmt.Println()
fmt.Println("4. 벤치마크 비교:")
fmt.Println(" go test -bench=. -count=5 -benchmem | tee old.txt")
fmt.Println(" benchstat old.txt new.txt")
fmt.Println()
fmt.Println("5. 컴파일러 분석:")
fmt.Println(" go build -gcflags='-m' # 이스케이프 분석")
fmt.Println(" go build -gcflags='-m -l=4' # 인라인 결정")
fmt.Println(" go build -gcflags='-S' # 어셈블리")
fmt.Println()
fmt.Println("6. 런타임 HTTP 프로파일링:")
fmt.Println(" import _ "net/http/pprof"")
fmt.Println(" go tool pprof http://localhost:6060/debug/pprof/heap")
fmt.Println(" go tool pprof http://localhost:6060/debug/pprof/goroutine")
fmt.Println()
fmt.Println("최적화 순서: 측정 → 분석 → 개선 → 재측정")
}최적화 순서: 1) 알고리즘/자료구조, 2) 할당 줄이기, 3) 동시성 개선, 4) 컴파일러 힌트. 항상 측정 먼저!
측정 없이 최적화하면 효과 없는 곳에 시간을 낭비합니다. "추측하지 말고 측정하라(Don't guess, measure)"를 명심하세요.
정리하며
- 추측 대신 -benchmem의 allocs/op를 기준으로 잡고, 수정 전후를 같은 벤치마크로 비교합니다
- -gcflags=-m으로 어떤 값이 왜 힙으로 이스케이프하는지 컴파일러 판정을 직접 확인합니다
- sync.Pool은 재사용률이 높고 크기가 균일한 버퍼에만 쓰고, 반납 전에 길이를 잘라 냅니다
- CPU 프로파일로 설명 안 되는 지연은 execution trace로 스케줄러·GC 대기를 확인합니다
더 깊이 들어가고 싶다면 Go 학습 라이브러리에서 다른 주제 가이드를 이어서 보거나, 언어 비교에서 같은 개념이 다른 언어에서 어떻게 표현되는지 확인해 보세요.