Golang 메모리 사용량이 계속 높다면, 핵심 원인은 heap 증가, 오래 살아 있는 buffer, retained goroutine, cache, workload 패턴 변화 중 하나일 수 있습니다.
그래서 “메모리가 높다”는 말만으로는 아직 진단이 아닙니다. 어떤 서비스는 일시적인 heap pressure 뒤에 정상적으로 회복되고, 어떤 서비스는 reference가 계속 살아 있어서 메모리가 올라간 뒤 거의 내려오지 않습니다.
이 글은 실전 순서에 집중합니다.
- 정상적인 heap pressure와 수상한 retention을 어떻게 구분할지
- 부하가 빠진 뒤에도 메모리가 안 내려올 때 무엇을 먼저 볼지
- goroutine, buffer, cache, workload shape가 메모리를 어떻게 붙잡는지
짧게 말하면 메모리 그래프 모양을 트래픽 모양과 먼저 비교하고, 부하 후 회복 여부를 본 뒤, 어떤 reference나 goroutine, cache가 데이터를 reachable 상태로 남기고 있는지 추적해야 합니다.
더 넓은 Go 분기부터 다시 보고 싶다면 Golang 트러블슈팅 가이드로 가세요.
먼저 메모리 모양부터 보기
가장 먼저 봐야 할 질문은 메모리가 트래픽과 함께 올라갔다가 나중에 내려오는지, 아니면 의미 있게 거의 내려오지 않는지입니다.
이 구분은 보통 아래 중 어디에 가까운지를 보여줍니다.
- 실제 작업량 때문에 생기는 정상 pressure
- 장수 reference 때문에 생기는 retention
- background 작업이 데이터를 예상보다 오래 붙잡는 상황
- workload와 맞지 않게 커진 cache
이 첫 분기 없이 보면, 정상 pressure를 leak처럼 오해하거나, 수상한 retention을 “Go GC 특성” 정도로 넘기기 쉽습니다.
정상 pressure와 수상한 retention의 차이
Go 서비스는 아래 같은 이유로 legitimately 메모리를 더 쓸 수 있습니다.
- payload가 커짐
- batch 크기가 커짐
- 동시성이 늘어남
- cache가 더 잘 맞아서 보유량도 커짐
이것은 내려가야 할 메모리가 계속 reachable 상태로 남아 있는 것과는 다릅니다.
수상한 신호는 보통 이렇게 보입니다.
- 트래픽 파동이 한 번 지나갈 때마다 메모리 baseline이 계속 올라감
- burst가 끝난 뒤에도 heap이 높게 남아 있음
- goroutine 수도 같이 증가하는 추세임
- 하나의 cache, queue, buffer pool이 제한 없이 커짐
결국 더 중요한 질문은 “메모리가 높은가?”보다 “현재 메모리 모양이 실제 workload 모양과 여전히 맞는가?”입니다.
자주 나오는 원인
1. 오래 살아 있는 buffer와 slice
큰 byte slice나 buffer가 예상보다 오래 살아 있을 수 있습니다.
자주 보이는 패턴은 아래와 같습니다.
- 큰 slice가 장수 구조체에 계속 참조됨
- buffer pool이 unusually 큰 객체를 계속 보관함
- request-scoped 데이터가 실수로 전역 구조에 저장됨
큰 buffer가 몇 개만 오래 남아 있어도 메모리 그래프는 트래픽 대비 과하게 커 보일 수 있습니다.
2. goroutine이 reference를 붙잡고 있음
blocked 또는 장수 goroutine이 간접적으로 데이터를 메모리에 오래 남길 수 있습니다.
goroutine 자체가 커 보이지 않아도, request payload, decoded object, 장수 channel을 참조하는 closure나 stack을 통해 heap이 reachable 상태로 남을 수 있습니다.
그래서 메모리 장애와 goroutine leak 장애가 같이 보이는 경우가 많습니다.
3. cache 또는 queue 증가
엄밀한 의미의 leak은 아니지만, 서비스가 감당하기 어려울 만큼 오래 많이 들고 있는 경우도 많습니다.
아래를 보세요.
- 상한이 없는 cache
- backpressure 때문에 커지는 queue
- request-specific key를 쓰는 map
- 실제 트래픽에 비해 너무 오래 잡고 있는 in-memory aggregation
이 경우 해결책은 GC 튜닝이 아니라, 자료구조에 상한과 lifecycle을 넣는 것일 때가 많습니다.
4. workload shape가 바뀜
payload와 workload 패턴이 기존 가정보다 커졌을 수도 있습니다.
예를 들면:
- 더 큰 JSON body
- dependency가 반환하는 결과 집합 증가
- 더 높은 동시 요청 수
- 이전보다 더 큰 batch job
이 경우 메모리는 코드가 새로 망가졌다는 뜻보다, 운영 가정이 더 이상 맞지 않는다는 신호일 수 있습니다.
실전 점검 순서
메모리가 너무 높아 보일 때는 아래 순서가 빠릅니다.
- 메모리 증가를 트래픽과 동시성 변화와 비교
- 부하가 빠진 뒤 메모리가 내려오는지 확인
- 장수 buffer, slice, cache 점검
- goroutine 증가와 background worker 상태 비교
- workload pressure인지 suspicious retention인지 판단
이 순서가 중요한 이유는 두 가지 흔한 실수를 막아주기 때문입니다.
- 높은 메모리 그래프를 전부 leak으로 보는 실수
- 무엇이 reachable 상태인지 보기 전에 GC 탓부터 하는 실수
retained goroutine이 의심된다면 Goroutine leak 찾는 법과 같이 보세요.
retention이 어떤 식으로 생기는지 보는 간단한 예시
의도적으로 단순한 예시지만 패턴은 분명합니다.
var chunks [][]byte
for {
chunks = append(chunks, make([]byte, 1<<20))
}
slice, map, cache가 계속 reachable 상태로 남아 있으면 트래픽이 평범해 보여도 heap은 계속 커질 수 있습니다.
중요한 것은 문법이 아니라 reachability 모델입니다. 프로그램 안에서 그 객체들로 이어지는 경로가 남아 있으면 GC는 회수할 수 없습니다.
”Go가 메모리를 안 돌려준다”는 말의 함정
엔지니어가 자주 “Go가 메모리를 안 돌려준다”고 말하지만, 이 문장은 서로 다른 두 가지 상황을 섞습니다.
- runtime이 이미 collectible한 객체 이후에도 메모리를 예약 상태로 유지하는 경우
- 애플리케이션이 여전히 reference를 들고 있어서 객체가 collectible하지 않은 경우
이 둘은 같은 문제가 아닙니다.
runtime 동작을 의심하기 전에, 정말 그 객체가 더 이상 reachable하지 않은지 먼저 확인해야 합니다. cache, goroutine, 전역 구조를 통해 여전히 잡고 있다면 해결책은 GC 튜닝이 아니라 애플리케이션 lifecycle 쪽에 있습니다.
큰 객체 경로마다 물어볼 질문
크거나 계속 증가하는 allocation 경로를 볼 때마다 아래를 물어보면 도움이 됩니다.
- 이 객체의 owner는 누구인가
- 얼마나 오래 살아야 하는가
- 어떤 이벤트가 발생하면 collectible해져야 하는가
- 그 이벤트가 실제로 일어나고 있는가
메모리 문제는 결국 lifecycle 문제로 드러나는 경우가 많습니다.
FAQ
Q. 메모리가 높으면 항상 leak인가요?
아닙니다. 더 큰 payload, 더 높은 동시성, 더 공격적인 caching 때문에 정상 pressure가 커진 것일 수도 있습니다.
Q. retention 가능성이 더 큰 신호는 무엇인가요?
부하가 빠진 뒤에도 메모리가 계속 높게 남고, goroutine, queue, cache도 함께 증가한다면 suspicious retention 쪽 가능성이 더 큽니다.
Q. 운영에서 무엇부터 비교해야 하나요?
메모리 증가와 트래픽 모양을 먼저 비교하고, burst 이후 이전 baseline 근처로 돌아오는지부터 보세요.
Read Next
- Go 전체 분기부터 다시 보고 싶다면 Golang 트러블슈팅 가이드로 가세요.
- retained goroutine이 의심된다면 Goroutine leak 찾는 법을 보세요.
- shutdown이나 background worker가 메모리를 오래 잡는 것처럼 보이면 Golang HTTP server shutdown이 안 끝날 때도 같이 보세요.
Related Posts
Sources:
심사 대기 중에는 광고 대신 관련 가이드를 먼저 보여줍니다.
먼저 읽어볼 가이드
검색 유입이 많은 핵심 글부터 이어서 보세요.
- 미들웨어 트러블슈팅 가이드: Redis vs RabbitMQ vs Kafka 개발자를 위한 미들웨어 트러블슈팅 허브 글입니다. Redis, RabbitMQ, Kafka 중 어떤 증상부터 먼저 봐야 하는지와 어떤 문제 패턴이 각 시스템에 가까운지 정리합니다.
- Kubernetes CrashLoopBackOff: 먼저 볼 것들 startup failure, probe, config, resource limit 관점에서 CrashLoopBackOff를 어떻게 나눠서 봐야 하는지 정리한 가이드입니다.
- Kafka consumer lag가 계속 늘 때: 트러블슈팅 가이드 Kafka consumer lag가 계속 늘어날 때 무엇부터 봐야 하는지 정리합니다. poll 주기, 처리 속도, rebalance, consumer 설정까지 실전 기준으로 다룹니다.
- Kafka Rebalancing Too Often 가이드 Kafka consumer group에서 rebalance가 너무 자주 일어날 때 membership flapping, poll timing, protocol, assignment churn을 어떤 순서로 봐야 하는지 설명하는 실전 가이드입니다.
- Docker container가 계속 재시작될 때: 먼저 확인할 것들 exit code, command failure, environment mistake, health check 관점에서 Docker restart loop를 푸는 실전 가이드입니다.
심사 대기 중에는 광고 대신 관련 가이드를 먼저 보여줍니다.