본문 바로가기
Backend2026년 9월 16일12분 읽기

고루틴 누수 잡기 — 새는 패턴 6가지와 프로파일로 찾는 법

YS
김영삼
조회 107
고루틴 누수 잡기 — 새는 패턴 6가지와 프로파일로 찾는 법

고루틴 누수는 시작된 고루틴이 영원히 끝나지 않고 쌓이는 현상이다. 각 고루틴은 최소 수 KB의 스택과 참조하는 객체들을 붙들고 있으므로, 누수가 쌓이면 메모리가 늘고 GC 부담이 커지며 결국 서비스가 죽는다.

원인의 90%는 하나로 요약된다. 고루틴이 무언가를 기다리는데, 그 무언가가 영원히 오지 않는다. 채널 수신, 락 획득, 네트워크 응답 — 기다리는 대상은 달라도 구조는 같다.

내가 처음 고루틴 누수를 겪었을 때, 증상은 "하루에 한 번씩 메모리가 한계에 닿는다"였다. 힙 프로파일을 봐도 특정 객체가 비정상적으로 많지 않았다. 고루틴 수를 세어 보고서야 알았다. 시작할 때 200개였던 게 20만 개가 돼 있었다.

가장 먼저 확인할 것 — 고루틴 수

import (
    "net/http"
    _ "net/http/pprof"
    "runtime"
)

// 1) pprof 엔드포인트 노출 (운영에서는 내부망에만)
go func() { http.ListenAndServe("localhost:6060", nil) }()

// 2) 메트릭으로 상시 노출
func reportGoroutines() {
    // 프로메테우스 게이지 등으로 주기적 기록
    n := runtime.NumGoroutine()
    goroutineGauge.Set(float64(n))
}
# 현재 고루틴 스택 덤프 — 어디서 막혀 있는지 바로 보인다
curl -s http://localhost:6060/debug/pprof/goroutine?debug=2 > dump.txt

# 상태별 집계 (chan receive, select, semacquire 등)
grep -oE '^goroutine [0-9]+ \[[^]]+\]' dump.txt | sed 's/.*\[//' | sort | uniq -c | sort -rn

# 같은 스택이 수천 개 보이면 그게 범인이다
grep -A 5 'chan receive' dump.txt | head -50
덤프가 가장 빠른 길 고루틴 누수는 힙 프로파일보다 고루틴 스택 덤프로 훨씬 빨리 잡힌다. 같은 스택 트레이스가 수천 번 반복되면 그 함수가 원인이다. debug=2로 받으면 각 고루틴이 무엇을 기다리는지까지 나온다.

패턴 1 — 수신자 없는 채널 전송

// 새는 코드
func fetch(ctx context.Context, urls []string) string {
    ch := make(chan string)          // 버퍼 없음
    for _, u := range urls {
        go func(u string) {
            ch <- get(u)             // 첫 번째 결과만 받으면 나머지는 영원히 블록
        }(u)
    }
    return <-ch                      // 하나만 수신하고 함수 종료
}

// 고친 코드: 버퍼를 주거나 컨텍스트로 종료
func fetch(ctx context.Context, urls []string) string {
    ch := make(chan string, len(urls))   // 버퍼 — 전송이 블록되지 않는다
    for _, u := range urls {
        go func(u string) {
            select {
            case ch <- get(u):
            case <-ctx.Done():           // 취소되면 빠져나간다
            }
        }(u)
    }
    return <-ch
}

패턴 2 — 컨텍스트를 전파하지 않음

요청 처리 중에 시작한 고루틴이 요청 컨텍스트를 받지 않으면, 클라이언트가 연결을 끊어도 계속 돈다. 트래픽이 많을수록 빠르게 쌓인다.

// 새는 코드
func handler(w http.ResponseWriter, r *http.Request) {
    go processInBackground()         // r.Context() 와 무관하게 계속 돈다
    w.WriteHeader(202)
}

// 고친 코드: 백그라운드 작업은 별도 수명 관리
func handler(w http.ResponseWriter, r *http.Request) {
    // 요청 컨텍스트는 응답과 함께 취소된다. 백그라운드 작업에 그대로 쓰면 안 되고,
    // 그렇다고 무한정 돌게 두어서도 안 된다 → 상한이 있는 별도 컨텍스트
    ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
    go func() {
        defer cancel()
        processInBackground(ctx)
    }()
    w.WriteHeader(202)
}

더 나은 구조는 백그라운드 작업을 큐에 넣고 워커 풀이 처리하게 하는 것이다. 요청마다 고루틴을 띄우면 부하가 몰릴 때 통제 불가능해진다.

패턴 3 — 타임아웃 없는 대기

// 새는 코드: 응답이 안 오면 영원히 기다린다
resp, err := http.Get(url)           // 기본 클라이언트는 타임아웃이 없다

// 고친 코드
client := &http.Client{
    Timeout: 10 * time.Second,        // 전체 상한
    Transport: &http.Transport{
        DialContext: (&net.Dialer{ Timeout: 3 * time.Second }).DialContext,
        ResponseHeaderTimeout: 5 * time.Second,
        IdleConnTimeout:       90 * time.Second,
        MaxIdleConnsPerHost:   100,
    },
}
req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
resp, err := client.Do(req)

패턴 4 — time.After를 루프 안에서

// 새는 코드: 매 반복마다 타이머가 생기고 만료 전까지 회수되지 않는다
for {
    select {
    case msg := <-ch:
        handle(msg)
    case <-time.After(time.Minute):   // 반복마다 새 타이머
        cleanup()
    }
}

// 고친 코드: 타이머를 재사용
t := time.NewTimer(time.Minute)
defer t.Stop()
for {
    select {
    case msg := <-ch:
        handle(msg)
        if !t.Stop() { <-t.C }        // 드레인 후 리셋
        t.Reset(time.Minute)
    case <-t.C:
        cleanup()
        t.Reset(time.Minute)
    }
}

엄밀히는 고루틴 누수라기보다 타이머 누수지만, 증상(메모리 증가)과 원인 구조가 같아 함께 다룬다. 고빈도 루프에서 특히 문제가 된다.

패턴 5·6 — 닫히지 않는 채널과 워커 종료 누락

  • 생산자가 채널을 안 닫음for range ch로 소비하는 고루틴이 영원히 기다린다. 생산자 측에서 defer close(ch)를 확실히 한다.
  • 워커 풀 종료 신호 누락 — 종료 시 워커들에게 신호를 보내고 WaitGroup으로 실제 종료를 기다려야 한다. 신호만 보내고 기다리지 않으면 작업이 잘린다.
// 워커 풀 정상 종료
func run(ctx context.Context, jobs <-chan Job, n int) {
    var wg sync.WaitGroup
    for i := 0; i < n; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            for {
                select {
                case j, ok := <-jobs:
                    if !ok { return }        // 채널이 닫히면 종료
                    process(j)
                case <-ctx.Done():
                    return                    // 취소되면 종료
                }
            }
        }()
    }
    wg.Wait()                                  // 실제 종료를 기다린다
}

테스트로 누수 막기

누수는 프로덕션에서 발견하면 이미 늦다. 테스트 단계에서 잡는 게 훨씬 싸다. 테스트 전후 고루틴 수를 비교하는 간단한 방법으로도 상당수를 잡을 수 있다.

func TestNoLeak(t *testing.T) {
    before := runtime.NumGoroutine()

    doWork()                                   // 테스트 대상

    // 정리에 약간의 시간이 필요할 수 있다
    deadline := time.Now().Add(2 * time.Second)
    for time.Now().Before(deadline) {
        if runtime.NumGoroutine() <= before {
            return
        }
        time.Sleep(20 * time.Millisecond)
    }
    t.Fatalf("고루틴 누수 의심: %d -> %d", before, runtime.NumGoroutine())
}
운영 환경 상시 점검
고루틴 수를 메트릭으로 노출하고 증가 추세에 알림을 건다
배포 직후와 24시간 후 값을 비교 — 완만한 증가가 누수의 전형
pprof 엔드포인트를 내부망에 열어 두고 덤프를 즉시 받을 수 있게 한다
부하 테스트 후 고루틴 수가 기준선으로 복귀하는지 확인
외부 호출에는 예외 없이 타임아웃 — 코드 리뷰 체크 항목에 포함

자주 묻는 질문

고루틴 누수인지 어떻게 확인하나요?

runtime.NumGoroutine() 값을 메트릭으로 기록해 추세를 봅니다. 시간이 지나며 완만히 증가하고 부하가 없는 시간대에도 줄지 않으면 누수입니다. pprof의 goroutine 덤프를 debug=2로 받아 같은 스택이 반복되는지 확인하면 원인 함수를 바로 찾을 수 있습니다.

가장 흔한 원인은 무엇인가요?

수신자가 사라진 채널로 전송을 시도하며 블록되는 경우와, 요청 컨텍스트를 전파하지 않아 취소 신호가 닿지 않는 경우입니다. 여기에 타임아웃 없는 외부 호출이 더해지면 트래픽에 비례해 빠르게 쌓입니다.

채널에 버퍼를 주면 해결되나요?

전송 블로킹은 완화되지만 근본 해결은 아닙니다. 버퍼가 차면 다시 블록되므로, select에 컨텍스트 취소 분기를 함께 두어 빠져나갈 경로를 만들어야 합니다.

백그라운드 작업에 요청 컨텍스트를 쓰면 되나요?

요청 컨텍스트는 응답과 함께 취소되므로 백그라운드 작업이 중간에 끊깁니다. 상한이 있는 별도 컨텍스트를 만들어 쓰거나, 작업을 큐에 넣고 워커 풀이 처리하게 하는 구조가 안전합니다.

테스트에서 누수를 자동으로 잡을 수 있나요?

테스트 전후의 고루틴 수를 비교하는 방식으로 상당수를 검출할 수 있습니다. 정리에 시간이 걸릴 수 있으므로 짧은 대기 후 재확인하는 형태로 작성하고, 누수 검출 전용 라이브러리를 함께 사용하는 것도 좋습니다.

고루틴이 많은 것 자체가 문제인가요?

수만 개라도 실제로 일하고 있다면 정상입니다. 문제는 끝나지 않고 계속 쌓이는 경우입니다. 절대값보다 추세와 대기 상태(chan receive, select 등에서 멈춘 비율)를 보는 것이 정확합니다.

댓글 0

아직 댓글이 없습니다.
Ctrl+Enter로 등록