sayu.day

Go Observability와 성능 튜닝: zap, Prometheus, pprof, GC

프로덕션 Go 서비스의 장애 대응은 세 단계로 나뉩니다. 먼저 로그·메트릭·트레이스로 "지금 무슨 일이 일어나는가"를 관측하고, 이상 징후가 잡히면 pprof로 "정확히 어디가 문제인가"를 진단하고, 마지막으로 할당 패턴과 GC 파라미터를 고쳐 실제로 개선합니다. 이 글은 이 관측 → 진단 → 개선 흐름을 하나의 파이프라인으로 정리합니다.

관측: 로그, 메트릭, 트레이스

Observability는 로그(무슨 일이 일어났나), 메트릭(얼마나 일어났나), 트레이스(어디서 일어났나) 세 축으로 구성됩니다. 로그는 디버깅과 감사에, 메트릭은 모니터링과 알림에, 트레이스는 병목 분석과 의존성 파악에 씁니다.

로그: uber/zap으로 구조화하기

구조화된 로깅은 텍스트 로그보다 검색과 집계가 쉽습니다. 로그 레벨은 용도를 명확히 구분해서 씁니다.

레벨 사용 시점 프로덕션
DEBUG 개발/디버깅 OFF
INFO 정상 흐름 기록 ON
WARN 복구 가능한 문제 ON
ERROR 실패, 조치 필요 ON + Alert

로거 초기화는 두 가지 방식이 있습니다.

// ReplaceGlobals: 빠른 프로토타이핑에 적합
logger, _ := zap.NewProduction()
zap.ReplaceGlobals(logger)
// 이후 어디서든 zap.L().Info(...)
// DI 주입(권장): 정식 프로젝트에서는 Wire로 주입
func InitializeServer(logger *zap.Logger) *Server { ... }

ReplaceGlobals는 간편하지만 테스트에서 Mock 로거로 바꾸기 어렵습니다. 정식 서비스라면 DI로 주입해 의존성을 명시적으로 드러내는 편이 낫습니다.

Request ID 전파도 중요합니다. 미들웨어에서 requestID를 생성해 컨텍스트에 담고, 핸들러와 유스케이스를 거쳐 logger.With(ctx)로 넘기면 한 요청의 모든 로그에 requestID가 자동으로 붙어 장애 시 로그를 한 번에 추적할 수 있습니다.

메트릭: RED/USE와 Grafana Alert

수집할 메트릭은 요청 기준(RED)과 리소스 기준(USE) 두 축으로 나눕니다.

RED 설명 Prometheus 타입
Rate 초당 요청 수 Counter
Errors 에러율 Counter
Duration 응답 시간 분포 Histogram
USE 설명 예시
Utilization 리소스 사용률 CPU, Memory
Saturation 포화도, 대기열 Goroutine 수
Errors 리소스 에러 Connection 실패

Grafana 대시보드는 Overview → HTTP → Database → External API 순으로 레이어를 나누고, Overview에는 요청률·에러율·P99 레이턴시 패널을 우선 배치합니다. Alert는 조건과 채널을 명확히 정의해 두어야 합니다.

Alert 이름 조건 심각도 채널
HighErrorRate 에러율 > 5% (5분) Critical Slack + PagerDuty
HighLatency P99 > 1s (5분) Warning Slack
PodRestart 재시작 > 3회 (1시간) Critical Slack

트레이스: 분산 요청 추적

여러 서비스를 거치는 요청은 같은 Trace ID를 공유해야 병목 구간을 찾을 수 있습니다. 예를 들어 Gateway → UserService → OrderService → PaymentService로 이어지는 호출에서 모두 동일한 traceID를 전달하면, 어느 구간에서 지연이 발생했는지 한눈에 볼 수 있습니다. Go에서는 OpenTelemetry SDK와 OTLP Exporter로 이를 구현하고, tracer.Start(ctx, name)으로 스팬을 시작합니다.

로그(zap → Loki), 메트릭(Prometheus → Grafana), 트레이스(OpenTelemetry → Tempo)를 하나의 Grafana 대시보드로 모으고, Alert Manager가 Slack·PagerDuty로 알림을 보내는 구조가 표준적인 통합 아키텍처입니다.

진단: pprof로 CPU/메모리/락 병목 찾기

관측으로 이상을 감지했다면, 다음은 추측이 아니라 측정으로 원인을 좁히는 단계입니다. pprof는 "어디서 느린지"를 함수 단위로 보여주는 근거 도구입니다.

최소 설정

import (
	_ "net/http/pprof"
	"net/http"
)

go func() {
	_ = http.ListenAndServe("127.0.0.1:6060", nil)
}()

pprof 엔드포인트는 반드시 내부망이나 VPN, 인증 뒤에서만 열어야 합니다. 외부에 노출하면 애플리케이션 내부 구조와 메모리 상태가 그대로 유출됩니다.

CPU 프로파일

go tool pprof "http://127.0.0.1:6060/debug/pprof/profile?seconds=30"
(pprof) top
(pprof) top -cum
(pprof) list <func>
(pprof) web

flat이 높으면 해당 함수 자체가 병목이고, cum이 높으면 하위 호출 구조 전체가 병목입니다.

메모리 프로파일

go tool pprof http://127.0.0.1:6060/debug/pprof/heap
go tool pprof -alloc_space http://127.0.0.1:6060/debug/pprof/heap

alloc_space가 높다면 메모리 누수보다는 "할당 폭탄"일 가능성이 큽니다. 즉 메모리를 계속 붙잡고 있는 게 아니라, 짧은 시간에 너무 많이 할당하고 있다는 뜻입니다.

락/블로킹 분석

runtime.SetBlockProfileRate(1)
runtime.SetMutexProfileFraction(10)
go tool pprof http://127.0.0.1:6060/debug/pprof/block
go tool pprof http://127.0.0.1:6060/debug/pprof/mutex

실무 워크플로우

  1. 재현 가능한 부하 시나리오 준비
  2. Before 프로파일 수집
  3. 가설 1개만 반영
  4. After 프로파일 수집
  5. 개선 수치 확인 후 다음 가설 진행

이 순서를 지키면 "최적화했는데 오히려 느려졌다"는 회귀를 줄일 수 있습니다. 프로파일 1회 결과만으로 결론 내리거나, 핫패스 검증 없이 sync.Pool을 남발하거나, pprof를 외부에 공개하는 것은 흔한 실수입니다.

개선: 할당 패턴과 GOMEMLIMIT/GOGC 튜닝

진단으로 할당이 문제라는 게 확인되면, 개선 우선순위는 명확합니다.

  1. 할당량 자체 줄이기
  2. GOMEMLIMIT 설정
  3. 마지막에 GOGC 미세 조정

GC 문제는 대부분 파라미터 문제가 아니라 할당 패턴 문제이기 때문에, 파라미터부터 만지는 것은 순서가 틀린 접근입니다.

관측 없이 튜닝하지 않기

GODEBUG=gctrace=1 ./myapp

go_gc_duration_seconds, go_memstats_heap_alloc_bytes, go_memstats_next_gc_bytes를 함께 보면서 "GC가 자주 도는 이유"를 먼저 찾습니다.

효과가 컸던 개선 4가지

1) 불필요한 할당 제거: append 대상 slice를 preallocate하고, 핫패스의 string <-> []byte 변환을 최소화하고, 중간 객체 생성을 줄입니다.

2) 객체 재사용: sync.Pool은 짧은 생명주기 버퍼에만 제한적으로 씁니다. 영구 캐시 용도로 쓰면 오히려 디버깅 비용이 커집니다.

3) 메모리 한계 명시

GOMEMLIMIT=2GiB ./myapp

컨테이너 환경에서는 거의 필수입니다. OOM 직전의 메모리 폭주 패턴을 완화해 줍니다.

4) GOGC 조정

GOGC=100 # 기본
GOGC=50  # 메모리↓ CPU↑
GOGC=200 # 메모리↑ CPU↓

경험상 50~150 범위에서 트래픽 특성에 맞추는 경우가 많습니다.

요청 경로에서 runtime.GC()를 직접 호출하거나, 근거 없이 GOGC=10, GOGC=1000 같은 극단값을 설정하거나, 프로파일 없이 파라미터만 반복 변경하는 것은 피해야 합니다.

실제 장애 패턴

관측 → 진단 → 개선 파이프라인이 실전에서 반복적으로 마주친 장애 패턴은 다음 두 가지입니다.

  • P99 튐 + GC 사이클 급증: 임시 객체가 폭증하면서 GC가 자주 돌고, 그 여파로 P99 레이턴시가 튀는 패턴입니다. 원인은 대개 핫패스의 불필요한 할당입니다.
  • 배치 구간 OOM: batch size가 과대하고 중간 결과를 계속 적재하면서 메모리가 쌓이다 OOM으로 이어지는 패턴입니다.

두 경우 모두 해결책은 공통적으로 "할당 구조 변경 + 한계값 설정"이었습니다. 메트릭 관점에서는 의존성별 에러율이 50%를 넘거나, DB 커넥션 풀 사용률이 90%를 넘거나, Goroutine 수가 급격히 늘어나는 것을 단일 장애 지점(SPoF)의 조기 신호로 봅니다.

정리

단계 도구 용도
관측 zap → Loki, Prometheus → Grafana, OpenTelemetry → Tempo 무슨 일이, 얼마나, 어디서 일어났는지 파악
진단 pprof (CPU/Heap/Block/Mutex) 병목 함수와 할당 지점 특정
개선 할당 패턴 변경 → GOMEMLIMIT → GOGC 근본 원인 제거와 안전장치 설정

Go 성능 이슈는 숫자 놀이가 아니라 메모리 설계 작업입니다. 관측으로 이상을 잡고, pprof로 정확한 지점을 찾고, 할당 구조를 먼저 고친 뒤 GOMEMLIMIT과 GOGC로 마무리하는 순서를 지키면 "측정 → 변경 → 재측정"이 팀의 표준 대응 루틴으로 자리 잡습니다.