레이블이 Go인 게시물을 표시합니다. 모든 게시물 표시
레이블이 Go인 게시물을 표시합니다. 모든 게시물 표시

수요일

Go goroutine leak 완벽 해결 — 고루틴이 계속 늘어나는 원인과 pprof 진단법

🔍 검색 키워드: Go goroutine leak, 고루틴 누수 해결, Go pprof goroutine 분석, Go context cancel 누수, Go 메모리 계속 증가, goroutine 개수 증가 원인

증상: 서버 메모리와 goroutine 수가 계속 늘어난다

Go 서비스를 배포한 뒤 시간이 지날수록 메모리 사용량이 우상향하고, 재시작하면 잠시 정상으로 돌아오는 패턴이 나타납니다. 이때 가장 먼저 의심해야 할 것이 goroutine leak(고루틴 누수)입니다. pprof 엔드포인트로 확인하면 아래와 같이 고루틴 수가 비정상적으로 큽니다.

$ curl http://localhost:6060/debug/pprof/goroutine?debug=1
goroutine profile: total 10432
10380 @ 0x43a5f6 0x4066bc 0x4062f5 0x6f1a2b 0x46f8c1
#	0x6f1a2a	main.fetchAll.func1+0x4a	/app/main.go:42

정상 서비스라면 요청이 끝난 뒤 고루틴 수가 원래 수준으로 돌아와야 하는데, 요청 수에 비례해 계속 쌓인다면 어딘가에서 고루틴이 영원히 블로킹되어 있다는 뜻입니다.

원인: 아무도 받지 않는 채널, 끝나지 않는 대기

고루틴은 스스로 종료되지 않으면 GC가 회수하지 못합니다. 대표적인 누수 원인은 다음과 같습니다.

  • 수신자 없는 채널 전송: 언버퍼드 채널에 값을 보내는데 받는 쪽이 이미 타임아웃으로 빠져나간 경우
  • context 미전파: HTTP 요청이 취소되어도 하위 고루틴에 ctx를 넘기지 않아 계속 실행되는 경우
  • time.Ticker 미정지: Stop()을 호출하지 않아 고루틴과 타이머가 남는 경우
  • 무한 for-select 루프: 종료 조건(done 채널, ctx.Done())이 없는 워커

아래는 가장 흔한 누수 코드입니다. 타임아웃이 발생하면 fetch 고루틴은 ch에 값을 보내려고 영원히 대기합니다.

func fetchWithTimeout(url string) (string, error) {
    ch := make(chan string) // 언버퍼드 채널
    go func() {
        ch <- slowRequest(url) // 받는 쪽이 없으면 영원히 블로킹
    }()
    select {
    case res := <-ch:
        return res, nil
    case <-time.After(1 * time.Second):
        return "", errors.New("timeout") // 여기서 반환하면 위 고루틴은 누수
    }
}

해결방법 1: 버퍼 채널로 전송 블로킹 제거

채널 버퍼를 1로 두면 받는 쪽이 사라져도 고루틴은 값을 넣고 정상 종료됩니다. 결과가 하나뿐인 경우 가장 간단한 해결책입니다.

ch := make(chan string, 1) // 버퍼 1: 수신자가 없어도 전송이 완료됨

해결방법 2: context로 취소 전파

요청 단위 작업은 반드시 context를 받아 하위 호출까지 전달하고, 블로킹 지점마다 ctx.Done()을 함께 select 합니다.

func fetchWithTimeout(ctx context.Context, url string) (string, error) {
    ctx, cancel := context.WithTimeout(ctx, time.Second)
    defer cancel() // 반드시 호출해서 타이머 리소스 해제
    ch := make(chan string, 1)
    go func() { ch <- slowRequestCtx(ctx, url) }()
    select {
    case res := <-ch:
        return res, nil
    case <-ctx.Done():
        return "", ctx.Err()
    }
}

해결방법 3: 워커 종료 조건과 Ticker 정리

func worker(ctx context.Context) {
    t := time.NewTicker(time.Minute)
    defer t.Stop() // Ticker는 반드시 Stop
    for {
        select {
        case <-t.C:
            doWork()
        case <-ctx.Done():
            return // 종료 조건 필수
        }
    }
}

누수 진단 방법: pprof와 테스트

운영 중에는 net/http/pprof를 열어 goroutine 프로파일을 두 번 떠서 비교하면 어느 코드 위치에서 고루틴이 쌓이는지 바로 보입니다. 개발 단계에서는 go.uber.org/goleak 라이브러리로 테스트 종료 시점에 남은 고루틴을 자동 검출할 수 있습니다.

go tool pprof http://localhost:6060/debug/pprof/goroutine
(pprof) top
(pprof) list main.fetchAll

정리표

누수 원인증상해결
수신자 없는 채널 전송chan send 상태로 고루틴 누적버퍼 채널(1) 또는 select+ctx
context 미전파요청 취소 후에도 작업 지속ctx 전달, WithTimeout+defer cancel
Ticker 미정지타이머와 고루틴 잔존defer t.Stop()
종료 조건 없는 루프워커가 영구 실행ctx.Done() 케이스 추가

핵심은 모든 고루틴은 종료 경로를 가져야 한다는 원칙입니다. 고루틴을 시작하는 코드를 쓸 때 “이 고루틴은 언제 끝나는가?”를 먼저 답할 수 없다면 누수 후보입니다.