· 9분 읽기
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
실무 워크플로우
- 재현 가능한 부하 시나리오 준비
- Before 프로파일 수집
- 가설 1개만 반영
- After 프로파일 수집
- 개선 수치 확인 후 다음 가설 진행
이 순서를 지키면 "최적화했는데 오히려 느려졌다"는 회귀를 줄일 수 있습니다. 프로파일 1회 결과만으로 결론 내리거나, 핫패스 검증 없이 sync.Pool을 남발하거나, pprof를 외부에 공개하는 것은 흔한 실수입니다.
개선: 할당 패턴과 GOMEMLIMIT/GOGC 튜닝
진단으로 할당이 문제라는 게 확인되면, 개선 우선순위는 명확합니다.
- 할당량 자체 줄이기
GOMEMLIMIT설정- 마지막에
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로 마무리하는 순서를 지키면 "측정 → 변경 → 재측정"이 팀의 표준 대응 루틴으로 자리 잡습니다.