Table of Contents
“메모리가 새고 있는데… 힙 덤프는 깨끗해요”
Go 언어의 가장 큰 장점은 Goroutine입니다. go 키워드 하나로 동시성을 처리할 수 있고, 초기 스택이 2KB밖에 안 되니 수십만 개를 띄워도 부담이 없죠.
하지만 이 “가벼움”이 독이 될 때가 있습니다. 너무 가볍다 보니 개발자들이 **“끝내는 것”**을 잊어버리곤 합니다.
배포 직후에는 멀쩡합니다. 그런데 3일, 일주일이 지나면 메모리 사용량이 야금야금 올라가더니 결국 **OOM(Out of Memory)**으로 죽습니다. 힙 프로파일(Heap Profile)을 떠봐도 딱히 큰 객체는 안 보입니다. 이럴 때 99% 확률로 범인은 Goroutine Leak입니다.
끝나지 않고 영원히 대기하는 좀비 Goroutine들이 스택 메모리를 잡아먹고, 힙에 있는 객체들을 붙들고 있어서 GC(Garbage Collection)가 수거해가지 못하게 만드는 것이죠.
흔한 누수 패턴 3가지
Goroutine Leak은 대부분 “누군가를 기다리다가(Blocking)” 발생합니다.
1. 아무도 읽어주지 않는 채널 (Unbuffered Channel)
가장 흔한 실수입니다.
func process(w http.ResponseWriter, r *http.Request) {
ch := make(chan string) // 버퍼 없음!
go func() {
// 복잡한 로직...
result := doHeavyWork()
ch <- result // (A) 여기서 블로킹됨
}()
select {
case res := <-ch:
w.Write([]byte(res))
case <-time.After(100 * time.Millisecond):
// 타임아웃! 함수 종료
return
}
}
타임아웃이 발생해서 process 함수는 끝났습니다. 하지만 go func() 안의 익명 함수는 아직 살아있습니다. ch <- result를 하려고 기다리는데, 받아줄 놈(Receiver)이 사라졌으니까요.
이 고루틴은 영원히 메모리에 남습니다.
해결책: 버퍼 채널(make(chan string, 1))을 쓰거나 select 안에서 default를 처리해줘야 합니다.
2. wg.Done()을 깜빡했을 때
sync.WaitGroup을 쓸 때 중간에 return을 해버리면 wg.Done()이 호출되지 않습니다.
func worker(wg *sync.WaitGroup) {
defer wg.Done() // defer로 무조건 실행되게 해야 함!
if err := doSomething(); err != nil {
return // 그냥 리턴하면 wg.Wait() 하고 있는 메인 고루틴은 영원히 대기
}
}
3. Context 취소를 안 챙길 때
부모 컨텍스트가 취소되었는데, 자식 고루틴이 그걸 모르고 계속 도는 경우입니다.
func longRunningLoop(ctx context.Context) {
go func() {
for {
doWork()
time.Sleep(1 * time.Second)
// ctx.Done() 체크 안 함
}
}()
}
ctx.Done()이 닫혔는지 항상 체크해야 합니다.
탐정 놀이: pprof로 범인 찾기
“어딘가에서 새고 있다”는 심증만으로는 부족합니다. 물증을 잡아야죠. Go에는 강력한 프로파일링 도구인 pprof가 있습니다.
1. 라이브러리 추가
import _ "net/http/pprof"
func main() {
// 6060 포트로 pprof 서버 실행
go func() {
http.ListenAndServe("localhost:6060", nil)
}()
// ...
}
2. 고루틴 개수 확인
브라우저에서 http://localhost:6060/debug/pprof/에 접속해보세요. goroutine 항목의 숫자가 비정상적으로 높다면(예: 수천, 수만 개) 누수입니다.
3. 스택 트레이스 분석
터미널에서 다음 명령어를 실행하면, 현재 살아있는 모든 고루틴의 스택 트레이스를 볼 수 있습니다.
go tool pprof -http=:8080 http://localhost:6060/debug/pprof/goroutine
웹 UI가 뜨면 [Source] 뷰를 보세요. 특정 함수 라인에 수천 개의 고루틴이 멈춰있는 게 보일 겁니다. 거기가 바로 범행 현장입니다.
예방이 최선입니다: goleak
Uber에서 만든 goleak 라이브러리는 테스트 코드가 끝났을 때 고루틴이 남아있는지 검사해줍니다.
func TestMain(m *testing.M) {
goleak.VerifyTestMain(m)
}
이거 한 줄만 넣어두면, 테스트 돌릴 때마다 고루틴 정리가 안 된 코드를 귀신같이 잡아내서 테스트를 실패시킵니다. CI 파이프라인에서 미리 막을 수 있는 가장 확실한 방법이죠.
마치며
Goroutine Leak은 **“조용히 쌓이는 기술 부채”**와 같습니다. 기능은 잘 돌아가는 것처럼 보이지만, 서서히 서버의 목을 조여오니까요.
“고루틴은 공짜가 아니다”라는 사실을 기억하세요. 생성하는 코드(go func)를 짤 때는 반드시 **“이 고루틴은 언제, 어떻게 종료되는가?”**를 먼저 고민해야 합니다. 그 질문에 답할 수 없다면, 그 코드는 잠재적인 시한폭탄입니다.