~Go 프로파일링 적용 시리즈 - 배치 서버 ver.~
[1] 배치 서버 OOM 분석을 위한 메트릭 설계와 프로파일링 전략
[2] Prometheus 라이브러리를 활용한 Go 메트릭 구현
[3] Prometheus 대시보드를 활용한 메트릭 분석 가이드
🟨 이 시리즈를 연재하게 된 계기
회사에서 운영 중인 배치성 서버에서 간헐적인 OOM(Out of Memory) Killed 현상이 발생하기 시작했다. 이 서버는 NFT 컬렉션 정보 수집, 메타데이터 수집, 토큰 가격/거래량 추적, 게임 라이브 데이터 수집 등 다양한 배치 작업을 주기적으로 수행하는 핵심 인프라였기 때문에, OOM으로 인한 서비스 중단은 전체 데이터 파이프라인에 치명적인 영향을 미칠 수 있는 상황이었다. 문제의 원인을 신속하게 파악하고 해결할 필요가 있었다.

초기 관찰 결과, 이 OOM 현상은 특이한 패턴을 보였다. 평소에는 안정적인 메모리 사용량을 유지하다가, 특정 시간대나 패턴 없이 불규칙적으로 갑작스럽게 OOM이 발생했다. 즉, 메모리 사용량 그래프에서 OOM을 예측할 만한 뚜렷한 메모리 급증이 관찰되지 않는다는 것이었다. 이는 매우 짧은 순간에 발생하는 대규모 메모리 할당 때문일 가능성을 시사했다.
상황을 더욱 복잡하게 만든 것은 해당 배치성 서버가 여러 작업을 동시에 실행하는 구조라는 점이었다. 각각의 작업이 서로 다른 데이터 소스와 처리 로직을 가지고 있어, 기존의 기본적인 시스템 모니터링만으로는 구체적으로 어떤 작업이 메모리 사용량 급증을 유발하는지 파악하기 어려웠다.
이러한 문제를 해결하기 위해서는 기존의 시스템 레벨 모니터링만으로는 부족했다. 어떤 배치 작업이, 어떤 시점에, 얼마나 많은 데이터를 처리하면서 메모리를 사용하는지를 세밀하게 추적할 수 있는 체계가 필요했다. 이에 배치 서버에 체계적인 모니터링 시스템을 구축하기로 결정했다.
서치 결과, Go 생태계에서 널리 사용되는 두 가지 접근 방식을 결합하기로 했다:
- pprof 기반 프로파일링 환경 구축: Go의 기본 프로파일링 도구인 pprof를 활성화하여 힙 메모리 사용량, 고루틴 수, GC 동작 등을 실시간으로 분석할 수 있는 기반을 마련
- Prometheus 메트릭 추가: 각 배치 작업별로 상세한 메트릭을 수집하도록 코드를 개선. 작업 실행 시간, 처리된 레코드 수, 레코드당 처리 시간, 작업 성공/실패 여부 등을 추적하여 어떤 작업이 비정상적인 동작을 보이는지 파악.
어떻게 결정은 내렸지만, 실행은 쉽지 않았다. 서로 다른 배치 작업에 일관된 메트릭을 추가하고, 코드의 안정성을 해치지 않으면서 프로파일링 환경을 구축하며, 수집된 방대한 데이터 속에서 의미 있는 신호를 찾아내는 과정은 시행착오의 연속이었다.
이 시리즈는 그 과정의 기록이다. 배치 서버의 OOM 원인 추적 과정과 그 결과로 구축된 모니터링 체계, 그리고 문제 해결 과정에서 얻은 인사이트를 담았다. 모니터링 설계부터 메트릭 수집 구현, 데이터 분석을 통한 원인 규명, 그리고 최종 해결까지의 전 과정을 다룰 예정이다.

Go 애플리케이션의 메모리 문제를 진단할 때는 단일 지표만으로는 정확한 원인을 파악하기 어렵다.
따라서 이 글에서는 상황에 따라 선택적으로 조합하여 사용할 수 있도록 메트릭을 소개한다.
🟨 Golang 프로파일링 라이브러리 간단 비교
prometheus: “언제/어디서” 문제가 생기는지 감지하는데 효과적
pprof: “왜/어떻게” 문제가 생기는지 분석하는데 효과적
|
구분
|
pprof
|
Prometheus
|
|
목적
|
문제 원인 분석/디버깅, 순간적인 심층 분석 | 지속적 모니터링 |
|
데이터
|
특정 순간의 프로그램 상태를 담은 스냅샷 데이터 ex) 함수별 메모리 할당, 고루틴 스택 등 |
시계열 메트릭 (연속적, 숫자로 표현 가능한 모든 것) ex) 메모리 사용량 추이 |
|
사용 시점
|
문제 발생 후 분석 | 24/7 실시간 감시 |
|
상세도
|
코드 상세 정보
|
비즈니스/시스템 메트릭
|
|
추적 단위
|
함수, 고루틴, 메모리 블록 등 | 작업, 서비스, 엔드포인트 |
▫️ 시너지 효과
- (감시) Prometheus 로 문제 현상 포착
- Grafana 대시보드에서 go_memstats_heap_alloc_bytes 같은 메트릭 주시
- 메모리가 평소와 다른 패턴으로 급증하거나, GC 이후에도 메모리가 반환되지 않고 계속 쌓이는 문제 발생 시점을 정확히 인지
- (진단) pprof로 원인 분석
- Prometheus를 통해 문제가 발생하고 있는 바로 그 시점에, 문제가 되는 Pod의 debug/pprof/heap 엔드포인트에 접속하여 힙 프로파일을 수집
- go tool pprof 명령어로 이 프로파일을 열어 어떤 함수가, 코드 몇 번째 줄에서 비정상적으로 많은 메모리를 할당하고 있는지 원인을 특정
▫️ 사전 준비: Go 런타임 메트릭 수집 설정
// ... existing code ...
// MemoryUsage 메모리 관련
MemoryUsage = promauto.NewGaugeVec(prometheus.GaugeOpts{
Name: "batch_memory_usage_bytes",
Help: "Current memory usage in bytes",
}, []string{"type"})
func StartCollectingSystemMetrics() {
go func() {
// ... existing code ...
var memStats runtime.MemStats
runtime.ReadMemStats(&memStats)
MemoryUsage.WithLabelValues("heap_alloc").Set(float64(memStats.HeapAlloc))
MemoryUsage.WithLabelValues("heap_sys").Set(float64(memStats.HeapSys))
MemoryUsage.WithLabelValues("stack").Set(float64(memStats.StackInuse))
}()
}
func updateMemoryMetrics() {
var m runtime.MemStats
runtime.ReadMemStats(&m)
memoryUsage.WithLabelValues("heap_alloc").Set(float64(m.Alloc))
// 메모리 누수 감지
if m.Alloc > 800*1024*1024 { // 800MB 초과
log.Warn("High memory usage detected")
}
}
회사 프로메테우스는 Pull 방식으로 주시적인 수집을 하고 있다. 즉, 프로메테우스가 특정 URL을 타겟으로 지정하여 수집을 하기 때문에 프로메테우스 엔드포인트를 별도로 설정했다. (위의 코드의 경우, /metrics)
만약 다른 방식으로 동작한다면 그에 맞춰서 커스텀 필요하다는 점을 명시해 둔다.
📌 Prometheus 애플리케이션 메트릭
시간에 따른 변화 추이를 보며 문제의 패턴을 파악하는데 사용
▫️ 1. 시스템 레벨 메트릭
이 메트릭들은 애플리케이션의 전반적인 메모리 사용량과 가비지컬렉션(GC) 동작을 파악하기 위해 사용.
예시 코드처럼 goroutine를 사용해 주기적으로 수집 추천.
📍MemoryUsage
- 코드
// ... existing code ...
// MemoryUsage 메모리 관련
MemoryUsage = promauto.NewGaugeVec(prometheus.GaugeOpts{
Name: "batch_memory_usage_bytes",
Help: "Current memory usage in bytes",
}, []string{"type"})
func StartCollectingSystemMetrics() {
go func() {
// ... existing code ...
var memStats runtime.MemStats
runtime.ReadMemStats(&memStats)
MemoryUsage.WithLabelValues("heap_alloc").Set(float64(memStats.HeapAlloc))
MemoryUsage.WithLabelValues("heap_sys").Set(float64(memStats.HeapSys))
MemoryUsage.WithLabelValues("stack").Set(float64(memStats.StackInuse))
}()
}
func updateMemoryMetrics() {
var m runtime.MemStats
runtime.ReadMemStats(&m)
memoryUsage.WithLabelValues("heap_alloc").Set(float64(m.Alloc))
// 메모리 누수 감지
if m.Alloc > 800*1024*1024 { // 800MB 초과
log.Warn("High memory usage detected")
}
}
- 측정 데이터
- type: 레이블에 따라 현재 애플리케이션의 메모리 사용량을 바이트 단위로 측정
- heap_alloc: Go 런타임에 의해 관리되는 힙 메모리에 할당된 객체들의 총 크기. 실제 사용 중인 메모리 양을 나타냄. (=활성 객체가 사용 중인 힙 메모리)
- heap_sys: OS로부터 할당받은 전체 힙 메모리 크기. heap_alloc보다 크거나 같으며, Go 런타임이 미리 확보해 둔 메모리까지 포함
- stack: 현재 실행 중인 모든 고루틴이 사용하는 스택 메모리의 총 크기
- type: 레이블에 따라 현재 애플리케이션의 메모리 사용량을 바이트 단위로 측정
- 측정 이유
- 애플리케이션의 전반적인 메모리 사용 현황을 파악하고, 메모리 누수나 비정상적인 메모리 증가를 감지하기 위함
- heap_alloc과 heap_sys의 차이를 통해 메모리 파편화나 Go 런타임의 메모리 관리 효율성을 간접적으로 확인
- 분석 예시
시나리오: 배치 작업 후 메모리가 안 줄어든다
→ heap_alloc은 줄었는데 heap_sys는 그대로
→ Go가 OS에 메모리를 안 돌려주고 있음
→ GOGC, GOMEMLIMIT 튜닝 필요
📍GCPauseTime & GCCount
- 코드
GCPauseTime = promauto.NewHistogram(prometheus.HistogramOpts{
Name: "batch_gc_pause_duration_seconds",
Help: "GC pause duratino",
Buckets: prometheus.ExponentialBuckets(0.00001, 2, 15),
})
GCCount = proamuto.NewCounter(prometheus.CounterOpts{
Name: "batch_gc_total",
Help: "Total number of GC cycles",
})
- 측정 데이터
- GCPauseTime: 가비지 컬렉션으로 인해 애플리케이션이 멈추는 시간의 분포를 측정
- GCCount: 가비지 컬렉션이 실행된 총 횟수를 측정
- 측정 이유
- 잦은 GC 발생(GCCount 증가)이나 긴 GC 유휴시간(GCPauseTime 증가)은 애플리케이션의 성능저하(느려짐, 응답 지연)의 직접적인 원인
- 이 메트릭들을 통해 메모리 할당 패턴에 문제가 있는지, GC 튜닝이 필요한지 등을 판단 가능
- 분석 예시
배치 서버 특성상:
- 대량 데이터 처리 → 많은 객체 생성
- GC 빈번 → 성능 저하
- GC 시간이 길면 → 처리 지연
예: GC가 100ms씩 걸리고, 분당 10번 실행
→ 분당 1초(1.6%)는 GC만 하는 중!
📍ActiveGoroutines
- 코드
ActiveGoroutines = promauto.NewGauge(prometheus.GaugeOpts{
Name: "batch_goroutines_active",
Help: "Number of active goroutines",
})
ActiveGoroutines.Set(flaot64(runtime.NumGoroutine()))
- 측정 데이터
- 현재 실행 중인 고루틴의 개수
- 측정 이유
- 고루틴은 적은 양의 메모리를 사용하지만, 수천~수만 개로 늘어나면 상당한 메모리 소비
- 고루틴이 정상적으로 종료되지 않고 계속 쌓이는 ‘고루틴 누수’는 심각한 메모리 누수 문제로 이어질 수 있음
▫️ 2. 배치 작업 특화 메트릭
특정 로직을 주기적으로 실행하는 배치 작업의 상태를 모니터링하기 위한 메트릭
📍BatchJobDuration
- 코드
BatchJobDuation = promauto.NewHistogramVec(prometheus.HistogramOpts{
Name: "batch_job_duration_seconds",
Help: "Duration of batch job execution",
Buckets: prometheus.ExponentialBuckets(1, 2, 10),
}, []string{"job_name", "status"})) // status: success, failed, timeout
- 측정 데이터
- 각 배치 작업(job_name)이 실행되는 데 걸리는 시간을 status(성공/실패) 별로 측정
- 측정 이유
- 시간이 오래 걸리는 작업을 찾아내 성능을 개선하거나, 작업 실패 시 평균 실행 시간과 비교하여 원인 유추에 도움
📍BatchJobsTotal & BatchJobsActive
- 코드
BatchJobsTotal = promauto.NewCounterVec(prometheus.CounterOpts{
Name: "batch_jobs_total",
Help: "Total number of batch jobs executed",
}, []string{"job_name", "status"})
BatchJobActive = promauto.NewGaugeVec(prometheus.GaugeOpts{
Name: "batch_jobs_active",
Help: "Number of currently running batch jobs",
}, []string{"job_name"})
- 측정 데이터
- BatchJobsTotal: 작업별, 상태별 누적 실행 횟수
- BatchJobsActive: 현재 동시에 실행 중인 작업의 수
- 측정 이유
- 작업의 실패율을 계산하고, 특정 작업이 비정상적으로 오랫동안 활성화되어 멈춰있는지 감지하는데 사용
- 분석 예시
문제 상황:
월요일: daily_report 작업 10분
화요일: daily_report 작업 15분
수요일: daily_report 작업 30분 ← 뭔가 이상!
→ 메모리 누수로 인한 성능 저하 감지
→ 특정 작업이 문제인지 파악
📍RecordsProcessed & BatchRecordProcessingTime
- 코드
RecordsProcessed = promauto.NewCounterVec(prometheus.CounterOpts{
Name: "batch_records_processed_total",
Help: "Total number of records processed",
}, []string{"job_name", "table"})
BatchRecordProcessingTime = promauto.NewHistogramVec(prometheus.HistogramOpts{
Name: "batch_record_processing_time_seconds",
Help: "Time taken to process a single record.",
Buckets: prometheus.ExponentialBuckets(0.001, 2, 15), // 1ms ~ 16s
}, []string{"job_name"})
func monitorDBPool(db *sql.DB) {
ticker := time.NewTicker(10 * time.Second)
for range ticker.C {
stats := db.Stats()
dbConnections.WithLabelValues("idle").Set(float64(stats.Idle))
dbConnections.WithLabelValues("in_use").Set(float64(stats.InUse))
dbConnections.WithLabelValues("total").Set(float64(stats.OpenConnections))
// 경고: 90% 이상 사용 중
if stats.InUse > int(float64(stats.MaxOpenConnections) * 0.9) {
log.Warn("DB connection pool nearly exhausted")
}
}
}
- 측정 데이터
- RecordsProcessed: 각 작업이 처리한 데이터 레코드의 총 개수
- BatchRecordProcessingTime: 레코드 1개를 처리하는 데 걸리는 시간
- 측정 이유
- 작업의 처리량을 파악하고, 전체 작업 시간이 오래 걸릴 때 그것이 처리할 데이터 양 때문인지, 아니면 개별 레코드 처리 로직 성능 문제인지 분리해서 분석할 수 있게 함
- 분석 예시
메모리 사용량 / 처리 레코드 = 레코드당 메모리 사용
예시:
- 1000개 처리 → 100MB 사용 (정상)
- 1000개 처리 → 1GB 사용 (비정상!)
→ 메모리 효율성 저하 감지
▫️ 3. DB 관련 메트릭
DB는 많은 어플리케이션의 성능 병목 지점이므로, 관련 지표는 상세히 추적하는 것이 중요
📍DBConnections
- 코드
DBConnections = promauto.NewGaugeVec(prometheus.GaugeOpts{
Name: "batch_db_connections",
Help: "Database connection pool stats",
}, []string{"state"}) // state: idle, in_use, total
- 측정 데이터
- DB connection pool의 상태를 state(사용 중, 유휴 상태, 전체)별로 측정
- 측정 이유
- 커넥션 풀이 고갈되거나 유휴 커넥션이 너무 많으면 성능에 영향을 줌. 풀의 크기가 적절한지 판단하고 커넥션 누수를 감지하는데 사용
- 분석 예시
커넥션 누수 시나리오:
1. 배치 작업이 커넥션을 안 돌려줌
2. in_use가 계속 증가
3. 새 커넥션 못 받음
4. 작업 실패
패턴:
- 정상: in_use가 올라갔다 내려감
- 누수: in_use가 계속 증가만 함
📍QueryDuration & QueryRows
- 코드
QueryDuration = promauto.NewHistogramVec(prometheus.HistogramOpts{
Name: "batch_db_query_duration_seconds",
Help: "Database query duration",
Buckets: prometheus.ExponentialBuckets(0.001, 2, 12), // 1ms ~ 2s
}, []string{"query_type", "table"}) // query_type: select, insert, update, delete
QueryRows = promauto.NewHistogramVec(prometheus.HistogramOpts{
Name: "batch_db_query_rows",
Help: "Number of rows affected by query",
Buckets: prometheus.ExponentialBuckets(1, 10, 8),
}, []string{"query_type", "table"})
- 측정 데이터
- QueryDuration: 쿼리 유형(select, insert 등)과 테이블 별로 쿼리 실행 시간을 측정
- QueryRows: 쿼리에 의해 영향받은(조회되거나 변경된) 로우(row)의 수
- 측정 이유
- 애플리케이션을 느리게 만드는 ‘슬로우 쿼리’를 찾아낼 수 있음. 특정 테이블에 대한 특정 유형의 쿼리가 느린지, 혹은 너무 많은 데이터를 한 번에 조회해서 느린 것인지 등을 분석할 수 있음.
- 분석 예시
배치 서버는 대량 쿼리 실행
→ 느린 쿼리 = 메모리 점유 시간 증가
→ 메모리 누수처럼 보일 수 있음
예: SELECT * FROM huge_table
→ 10분 동안 결과 받는 중
→ 그동안 메모리 계속 증가
▫️ 4. 메모리 누수 탐지용 메트릭
시스템 레벨의 메모리 사용량 증가가 감지 되었을 때 원인을 상세히 분석하기 위한 메트릭
📍ObjectsAllocated & ObjectsFreed
- 코드
ObjectsAllocated = promauto.NewCounterVec(prometheus.CounterOpts{
Name: "batch_objects_allocated_total",
Help: "Total objects allocated",
}, []string{"type"}) // type: cache_entry, db_connection, buffer
ObjectsFreed = promauto.NewCounterVec(prometheus.CounterOpts{
Name: "batch_objects_freed_total",
Help: "Total objects freed",
}, []string{"type"})
// 누수 감지 공식
누수율 = (allocated - freed) / allocated
// 실제 코드
type ConnectionPool struct {
// ...
}
func (p *ConnectionPool) Get() *Connection {
conn := p.createConnection()
objectsAllocated.WithLabelValues("connection").Inc()
return conn
}
func (p *ConnectionPool) Release(conn *Connection) {
objectsFreed.WithLabelValues("connection").Inc()
// ...
}
// Grafana에서:
// allocated{type="connection"} - freed{type="connection"} > 100
// → 100개 이상 커넥션이 해제 안 됨!
- 측정 데이터
- 애플리케이션의 특정 로직(ex. 캐시 버퍼, DB 커넥션 등)에서 생성되고 해제되는 객체의 수를 type 별로 카운트
- 측정 이유
- ObjectsAllocated와 ObjectsFreed의 카운트를 비교하여 특정 타입의 객체가 해제되지 않고 계속 쌓이고 있는지 확인할 수 있음
- 두 값의 차이가 시간이 지남에 따라 계속 증가한다면, 해당 type의 객체에서 메모리 누수가 발생하고 있음을 시사
📍CacheSize, CacheEntries, CacheEvictions
- 코드
CacheSize = promauto.NewGaugeVec(prometheus.GaugeOpts{
Name: "batch_cache_size_bytes",
Help: "Current cache size in bytes",
}, []string{"cache_name"})
CacheEntries = promauto.NewGaugeVec(prometheus.GaugeOpts{
Name: "batch_cache_entries",
Help: "Number of cache entries",
}, []string{"cache_name"})
CacheEvictions = promauto.NewCounterVec(prometheus.CounterOpts{
Name: "batch_cache_evictions_total",
Help: "Total cache evictions",
}, []string{"cache_name", "reason"}) // reason: size_limit, ttl_expired, manual
- 측정 데이터
- cache_name 별로 캐시의 상태를 추적
- CacheSize: 캐시가 사용 중인 메모리 크기(bytes)
- CacheEntries: 캐시에 저장된 항목(entry) 수
- CacheEvictions: 캐시에서 항목이 제거된 횟수. 제거 원인(reason) 별로 집계 (크기 제한, TTL 만료 등)
- 측정 이유
- 캐시는 메모리를 많이 사용하는 대표적인 기능 중 하나로, 잘못 관리될 경우 메모리 문제를 일으키기 쉬움
- 캐시 크기와 항목 수가 예상보다 계속 커진다면 캐시 정책(마료 시간, 크기 제한 등)이 제대로 동작하지 않거나, 캐시 데이터가 누수되고 있을 가능성이 있음
- CacheEvictions 는 캐시 제거 정책이 예상대로 작동하는지 확인하는 데 도움을 줌
- 분석 예시
배치 서버의 흔한 누수 원인 = 캐시
시나리오:
1. 매일 새로운 데이터 처리
2. 캐시에 계속 추가만 함
3. 오래된 데이터 안 지움
4. 메모리 폭발
모니터링:
- cacheSize 계속 증가 → 누수
- cacheEntries 계속 증가 → 누수
- evictions 없음 → TTL 설정 필요
📌 pprof 프로파일
그 순간의 스냅샷을 찍어 '어떤 코드'가 원인인지 알려줌
▫️ 1. heap 프로파일
- 측정 데이터
- 현재 힙 메모리에 있는 모든 객체와 그 객체를 어떤 함수가 할당했는지에 대한 상세 정보(콜스택)
- 측정 이유
- pprof 도구로 이 프로파일을 분석하면 "A 함수가 B 함수를 호출했고, 거기서 생성된 MyDataObject 타입의 객체 5만 개가 메모리의 80%를 차지하고 있다" 와 같이 어떤 객체가, 어떤 코드 라인에서 생성되어 해제되지 않고 있는지를 정확하게 알려줌
- 정확한 프로파일 수집 방법
// GC 실행 후 수집 (노이즈 제거)
func collectAccurateHeapProfile() {
runtime.GC()
runtime.GC() // 두 번 실행으로 확실히 정리
f, _ := os.Create("heap.prof")
pprof.WriteHeapProfile(f)
f.Close()
}
- 분석 방법
# 프로파일 분석 시작
go tool pprof heap.prof
# 핵심 명령어들
(pprof) top 20 # 상위 20개 메모리 사용 함수
(pprof) list main.funcName # 특정 함수의 라인별 메모리 사용
(pprof) peek # 호출 관계 트리 확인
(pprof) tree # 전체 호출 트리 구조
# inuse vs alloc 차이점
go tool pprof -inuse_space heap.prof # 현재 사용 중인 메모리 (중요!)
go tool pprof -alloc_space heap.prof # 프로그램 시작 후 누적 할당량
# 웹 UI로 시각화
go tool pprof -http=:8080 heap.prof
▫️ 2. goroutine 프로파일
- 측정 데이터
- 현재 실행 중인 모든 고루틴의 목록과, 각 고루틴이 현재 어떤 코드 라인에서 멈춰(대기)있는지를 보여주는 스냅샷
- 측정 이유
- 고루틴 누수의 원인을 찾기 위해 사용
- Prometheus에서 go_goroutines 수치가 계속 증가하는 것을 확인했다면, 이 프로파일을 통해 수만 개의 고루틴이 모두 동일한 채널(channel)에서 영원히 값을 기다리고 있거나, 특정 뮤텍스(mutex) 락을 기다리는 등의 교착 상태(deadlock)나 잘못된 로직을 발견할 수 있습니다.
- 분석 방법
# 고루틴 프로파일 수집
curl http://localhost:6060/debug/pprof/goroutine?debug=2 > goroutine.txt
# 분석 결과 해석
goroutine profile: total 10547
10234 @ 0x438396 0x4061a5 0x405f3b 0x701234
# 0x701234 main.worker+0x48 /app/worker.go:45
# 위 의미:
# - 10,234개의 고루틴이 동일한 위치에서 대기 중
# - worker.go 파일의 45번 라인
# - 채널 수신 또는 뮤텍스 대기일 가능성 높음
🟨 실전 워크플로우
▫️ Step 1: Prometheus ‘전체 시스템 메트릭’으로 이상 징후 감지
가장 먼저 봐야 할 것은 애플리케이션 전반의 상태입니다.
- batch_memory_usage_bytes{type="heap_alloc"}
- 이 값이 지속적으로, 계단식으로 우상향하며 내려오지 않는다면 메모리 누수가 발생하고 있다는 가장 강력한 증거
- batch_goroutines_active
- 이 값이 계속해서 증가한다면, 특정 작업이 끝나지 않고 계속 쌓이는 고루틴 누수를 의심. 고루틴 누수는 메모리 누수의 직접적인 원인이 됨.
▫️ Step 2: Prometheus ‘배치 작업 메트릭’으로 용의선상 좁히기
시스템 메트릭(예: 메모리 사용량)이 급증하는 시점과 특정 배치 작업의 실행 시점을 비교하여 상관관계를 찾습니다.
- batch_job_duration_seconds
- 특정 organizer의 작업 시간이 비정상적으로 길어지거나 메모리가 급증한 시점에 유난히 오래 실행된 작업이 있는지 확인
- batch_jobs_active
- 특정 organizer의 active 수치가 1 이상으로 올라간 뒤 내려오지 않고 있는지 확인
- 이는 해당 작업이 어딘가에 멈춰서 리소스를 계속 점유하고 있다는 뜻
- batch_jobs_total{status="failed"}
- 특정 organizer가 계속 실패하고 있는지 확인
- 에러 처리나 자원 해제 로직이 제대로 동작하지 않아 메모리 누수가 발생할 수 있음
▫️ Step 3 : 문제 시점에 pprof 수집
2단계에서 유력한 용의 작업을 특정했다면, 마지막으로 pprof를 사용해 코드 레벨의 원인을 찾아냅니다.
- heap profiling
- 메모리 사용량이 비정상적으로 높을 때, go tool pprof http://<pod-ip>:6060/debug/pprof/heap 명령을 실행하여 힙 프로파일을 캡쳐. 이 프로파일은 "어떤 함수에서 할당한 메모리가 해제되지 않고 쌓여있는지"를 정확하게 보여줌
- Grafana Phlare, Pyroscope 등 Continuous Profiling 솔루션이 있다면 모니터링에 도움이 됨
- 메모리 사용량이 비정상적으로 높을 때, go tool pprof http://<pod-ip>:6060/debug/pprof/heap 명령을 실행하여 힙 프로파일을 캡쳐. 이 프로파일은 "어떤 함수에서 할당한 메모리가 해제되지 않고 쌓여있는지"를 정확하게 보여줌
- goroutine profiling
- 고루틴 수가 계속 증가한다면, go tool pprof http://<pod-ip>:6060/debug/pprof/goroutine 명령으로 "어떤 고루틴이 종료되지 않고 계속 대기하고 있는지"를 확인 가능.
▫️ Step 4: 원인 수정
위의 메트릭으로 밝혀낸 원인을 수정합니다.
🟨 추가: 인프라 메트릭 vs 애플리케이션 메트릭
추가로 한 가지 짚고 넘어가고 싶은 점이 있다. 우리가 추가하려는 Prometheus 메트릭은 인프라팀에서 이미 수집하고 있는 쿠버네티스 메트릭과는 다른 관점에서 문제를 바라본다.
| 구분 | 인프라 메트릭(쿠버네티스 등) | 애플리케이션 메트릭(Go 코드 내 추가) |
| 관점 | Black Box - 밖에서 본 컨테이너 | White Box - 애플리케이션 내부 |
| 측정 대상 | 컨테이너가 실제 사용 중인 물리적 리소스 (cgroup 기준) | Go 런타임이 관리하는 메모리와 고루틴 상태 |
| 주요 메트릭 | container_memory_usage_bytes | go_memstats_heap_alloc_bytes go_goroutines |
| 역할 | 리소스 할당 및 스케줄링, 현상 파악 | 성능 병목, 메모리 누수 등 원인 분석 |
'Language > Go' 카테고리의 다른 글
| [Go] Prometheus 대시보드를 활용한 메트릭 분석 가이드 (0) | 2025.09.16 |
|---|---|
| [Go] Prometheus 라이브러리로 Go 애플리케이션 프로파일링 하기 (0) | 2025.08.28 |
| [Go] Context에 관한 고찰 - 3 : Graceful Shutdown의 미학 (0) | 2025.07.28 |
| [Go] Context에 관한 고찰 - 2 : Context의 중요성과 메모리 누수 (0) | 2025.07.24 |
| [Go] WaitGroup vs ErrGroup 비교 (0) | 2025.07.23 |
