본문으로 건너뛰기
Goroutine Leak 디버깅 화면

# Goroutine Leak: Go 프로덕션을 서서히 죽이는 메모리 누수의 주범

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)를 짤 때는 반드시 **“이 고루틴은 언제, 어떻게 종료되는가?”**를 먼저 고민해야 합니다. 그 질문에 답할 수 없다면, 그 코드는 잠재적인 시한폭탄입니다.

이 글 공유하기:
My avatar

글을 마치며

이 글이 도움이 되었기를 바랍니다. 궁금한 점이나 의견이 있다면 댓글로 남겨주세요.

더 많은 기술 인사이트와 개발 경험을 공유하고 있으니, 다른 포스트도 확인해보세요.

유럽살며 여행하며 코딩하는 노마드의 여정을 함께 나누며, 함께 성장하는 개발자 커뮤니티를 만들어가요! 🚀


관련 포스트