고 고급 #6 프로파일링 — pprof와 benchmark

4 분 소요

#5 unsafe와 cgo 다음, 이번엔 정반대 결의 도구. 측정.

“Don’t guess; measure.”

성능 문제는 거의 항상 — 우리가 의심한 곳이 아닌 다른 곳에서 발생합니다. Go는 표준 도구가 강력해서 — 짐작 대신 빠르게 측정으로 갈 수 있습니다.

benchmark — 표준 도구 #

간단 benchmark
// adder_test.go
package adder

import "testing"

func BenchmarkAdd(b *testing.B) {
	for i := 0; i < b.N; i++ {
		Add(1, 2)
	}
}
실행
go test -bench=. -benchmem
결과
BenchmarkAdd-8    1000000000    0.30 ns/op    0 B/op    0 allocs/op
  • b.N — 측정 시간이 안정적으로 나올 만큼 자동 조정
  • -benchmem — 할당 정보 추가
  • ns/op — 한 번 실행에 걸리는 시간
  • B/op / allocs/op — 메모리 할당량과 횟수

benchmark 작성의 기본기 #

reset 패턴
func BenchmarkParse(b *testing.B) {
	data := loadBigInput()    // 무거운 준비

	b.ResetTimer()             // 여기서부터 측정
	for i := 0; i < b.N; i++ {
		Parse(data)
	}
}

준비 비용은 — ResetTimer로 분리. b.StopTimer / b.StartTimer로 루프 안에서도 일시 정지 가능.

컴파일러 최적화 회피 #

자주 만나는 함정
func BenchmarkSum(b *testing.B) {
	for i := 0; i < b.N; i++ {
		sum(1, 2)    // ✗ 결과 안 쓰면 컴파일러가 통째로 제거할 수 있음
	}
}

해결 — 결과를 패키지 레벨 변수에 대입(컴파일러가 제거 못 하게).

결과 보존
var benchResult int

func BenchmarkSum(b *testing.B) {
	var r int
	for i := 0; i < b.N; i++ {
		r = sum(1, 2)
	}
	benchResult = r
}

benchstat — 두 결과 비교 #

개선 전후 비교
go test -bench=. -count=10 > before.txt
# 코드 수정
go test -bench=. -count=10 > after.txt

go install golang.org/x/perf/cmd/benchstat@latest
benchstat before.txt after.txt
              │   before   │            after            │
              │   sec/op   │   sec/op    vs base         │
Parse-8       │ 1.23µ ± 2% │ 0.85µ ± 1%  -30.89% (p=0.000 n=10)

p 값으로 유의미한 차이인지 판단. 한 번만 돌리면 노이즈에 속습니다 — -count=10 권장.

CPU 프로파일 #

benchmark에서 CPU 프로파일
go test -bench=. -cpuprofile=cpu.out
go tool pprof cpu.out
pprof 인터랙티브
(pprof) top
Showing nodes accounting for 1.23s, 87.86% of 1.4s total
      flat  flat%   sum%        cum   cum%
     0.43s 30.71% 30.71%      0.65s 46.43%  parse
     0.31s 22.14% 52.86%      0.31s 22.14%  hash
     ...

(pprof) list parse
(pprof) web      ← 브라우저에서 콜그래프

top으로 가장 시간을 많이 쓰는 함수 보고, list <fn>으로 라인별 시간 확인.

메모리 프로파일 #

go test -bench=. -memprofile=mem.out
go tool pprof -alloc_space mem.out

두 가지 시점:

  • -alloc_space — 누적 할당량 (이게 GC 부하를 가장 잘 보여줌)
  • -inuse_space — 현재 살아 있는 메모리

핫 패스에서 할당이 많으면 — GC가 자주 돌아 throughput이 낮아집니다. 보통 escape analysis와 함께 분석.

Escape analysis #

go build -gcflags='-m' main.go
결과 예
./main.go:5:9: &User{...} escapes to heap

스택에 남아도 됐을 객체가 — 어떤 이유로 힙 할당으로 빠졌는지 알려 줍니다. 메모리 할당을 줄이는 작업의 출발점으로 삼기 좋습니다.

프로덕션 프로파일 — net/http/pprof #

pprof endpoint
import (
	"net/http"
	_ "net/http/pprof"    // /debug/pprof/* endpoint 자동 등록
)

func main() {
	go http.ListenAndServe(":6060", nil)
	// 본 서버는 별도로...
}
실시간 프로파일
# 30초간 CPU 프로파일
go tool pprof http://localhost:6060/debug/pprof/profile?seconds=30

# 현재 메모리
go tool pprof http://localhost:6060/debug/pprof/heap

# 현재 고루틴
go tool pprof http://localhost:6060/debug/pprof/goroutine

프로덕션에서 — 서비스를 멈추지 않고 프로파일을 뜰 수 있습니다. 단, 6060 포트는 외부에 노출되지 않게 주의해야 합니다(internal only).

고루틴 누수 찾기 #

curl http://localhost:6060/debug/pprof/goroutine?debug=1

현재 살아 있는 모든 고루틴의 스택. 수가 점점 늘면 누수. 중급 #3에서 본 누수 패턴이 의심될 때 첫 번째 도구.

Trace — 시간축 분석 #

CPU/메모리 프로파일이 “어디” 라면, trace는 “언제”.

trace 수집
go test -bench=. -trace=trace.out
go tool trace trace.out

브라우저가 열리고 — 고루틴 스케줄링, GC 시점, 시스템 콜 같은 게 시간축으로 보입니다. GC가 자주 도는지, 고루틴이 starvation 되는지 같은 시간 축 문제에 어울립니다.

Race detector #

#2에서 잠깐 봤습니다.

go test -race ./...
go run -race main.go

테스트와 로컬 실행에서는 항상 켜두는 게 좋습니다. CI에서도 race build를 별도로 돌리는 게 좋습니다. 오버헤드가 5~10배 정도 커서 — 프로덕션에서는 안 켭니다.

측정 워크플로우 #

전형적인 흐름:

  1. 사용자/메트릭이 느리다고 알려 줌
  2. CPU 프로파일 → 어느 함수에 시간이 쓰이는지
  3. (메모리 의심이면) 메모리 프로파일 → 어디서 많이 할당
  4. 의심 부분에 벤치마크 작성 — 재현 가능한 측정 환경
  5. 수정 + benchstat으로 비교 — 정말 빨라졌는지
  6. 본 환경에 다시 배포 — 메트릭으로 재확인

핵심은 — 매 단계마다 측정으로 검증. 추측만으로 최적화하면 — 실제로 더 느려지는 경우가 흔합니다.

자주 만나는 경우 #

문자열 연결 #

for 루프에서 +
var s string
for _, p := range parts {
	s += p    // ✗ 매번 새 string 할당
}

해결 — strings.Builder.

var b strings.Builder
for _, p := range parts {
	b.WriteString(p)
}
s := b.String()

slice의 capacity 미지정 #

capacity 미리 잡기
result := make([]int, 0, len(items))    // ✓ capacity 미리
for _, x := range items {
	result = append(result, transform(x))
}

make([]int, 0)으로 시작하면 — 여러 번 재할당. 미리 capacity를 주면 1 회 할당으로 끝.

map의 capacity #

m := make(map[string]int, expectedSize)

map도 — 예상 크기를 주면 reallocation 줄일 수 있습니다.

인터페이스 박싱 #

인터페이스 변환의 비용
var any interface{} = 42    // int → interface{} 박싱 (heap 할당 가능)

핫 루프에서 interface{} (또는 any)로 변환하는 일이 잦으면 할당이 누적. 가능하면 — 구체 타입으로.

마무리 #

이번 글에서 정리한 내용:

  • benchmarkb.N, -benchmem, ResetTimer, 결과 보존으로 최적화 회피
  • benchstat — 두 측정의 통계적 비교
  • CPU 프로파일top, list, web
  • 메모리 프로파일-alloc_space가 보통 더 유용
  • escape analysis-gcflags='-m'로 힙 할당 원인
  • net/http/pprof — 프로덕션 실시간 측정
  • trace — 시간축, GC, 스케줄링
  • race detector — 항상 테스트에서 켜기
  • 워크플로우: 메트릭 → 프로파일 → 벤치 → 수정 → benchstat → 재확인

다음 글(#7 코드 생성)에서는 — Go가 자주 권장하는 또 다른 길. reflect의 비용을 피하면서 자동화하는 방법, go generate와 stringer 같은 표준 도구를 정리합니다.

X