Published on

Go 고루틴 누수 5패턴 - 채널·컨텍스트로 차단

Authors

서버가 "점점 느려진다"거나, 트래픽이 줄어도 메모리와 고루틴 수가 내려오지 않는다면 고루틴 누수를 의심해야 합니다. 고루틴은 가볍지만 공짜가 아닙니다. 블로킹된 고루틴은 스택, 타이머, 네트워크 커넥션, 파일 디스크립터 같은 리소스를 계속 잡을 수 있고, GC 압력을 키워 지연 시간을 악화시킵니다.

이 글은 Go에서 특히 자주 발생하는 고루틴 누수 5패턴을 "재현 코드"와 함께 보여주고, channelcontext 중심으로 차단하는 방법을 정리합니다.

운영 관점에서 네트워크/커널 리소스가 누수와 결합되면 증상이 더 빨리 커집니다. 커넥션이 쌓이며 커널 테이블이 포화되는 케이스는 EKS conntrack 테이블 포화로 연결 끊김 해결법 같은 글에서 다루는 현상과도 맞물릴 수 있습니다.

고루틴 누수의 전형적 징후와 빠른 확인법

징후

  • runtime.NumGoroutine() 값이 트래픽이 줄어도 계속 증가하거나 떨어지지 않음
  • pprof에서 goroutine 프로파일을 보면 특정 함수에서 대량으로 chan receive 또는 select 로 대기
  • 메모리는 천천히 증가, CPU는 낮은데 지연 시간은 악화(스케줄링/GC 영향)

빠른 확인

  • 로컬/스테이징에서 net/http/pprof 활성화
  • go tool pprofgoroutine 덤프 확인
package main

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

func main() {
	go func() {
		log.Println(http.ListenAndServe("localhost:6060", nil))
	}()

	for range time.Tick(2 * time.Second) {
		log.Printf("goroutines=%d\n", runtime.NumGoroutine())
	}
}

운영 환경에서는 pprof 노출 정책이 필요합니다. 내부망 바인딩, 인증 프록시, 또는 on-demand로만 활성화하는 방식이 안전합니다.

패턴 1: 채널 수신 대기에서 영원히 블로킹

문제

작업 결과를 받는 쪽이 사라졌거나, 생산자/소비자 수가 엇갈려 수신이 영원히 오지 않으면 고루틴이 chan receive 에서 영구 대기합니다.

func leakWaitRecv(ch <-chan int) {
	// 누군가 ch로 값을 보내지 않으면 영원히 블로킹
	x := <-ch
	_ = x
}

이 패턴은 "언젠가 오겠지"라는 가정이 깨질 때 폭발합니다. 예를 들어 요청이 취소되었는데도 백그라운드 고루틴이 결과를 기다리는 구조가 흔합니다.

차단: contextselect 로 취소 경로 추가

import "context"

func waitRecv(ctx context.Context, ch <-chan int) (int, error) {
	select {
	case v := <-ch:
		return v, nil
	case <-ctx.Done():
		return 0, ctx.Err()
	}
}

핵심은 "정상 경로" 외에 "탈출 경로"를 항상 보장하는 것입니다. 특히 요청 스코프에서 파생된 ctx 를 그대로 전달해, 클라이언트 취소나 타임아웃이 곧바로 고루틴 종료로 이어지게 만들어야 합니다.

패턴 2: 채널 송신이 막혀 생산자가 누수

문제

소비자가 느리거나 중단되면 생산자의 ch <- v 가 블로킹되어 고루틴이 쌓입니다. 버퍼가 없는 채널일수록 즉시 재현됩니다.

func producer(ch chan<- int) {
	for i := 0; ; i++ {
		ch <- i // 소비자가 없으면 영원히 블로킹
	}
}

이 패턴은 "결과를 꼭 전달해야 한다"는 강박에서 생깁니다. 하지만 요청이 취소되었거나 더 이상 의미 없는 결과를 억지로 보내려 하면 누수가 됩니다.

차단 1: select 로 취소를 우선 처리

func producer(ctx context.Context, ch chan<- int) {
	for i := 0; ; i++ {
		select {
		case ch <- i:
			// sent
		case <-ctx.Done():
			return
		}
	}
}

차단 2: "드롭 가능"한 이벤트는 논블로킹 송신

메트릭, 디버그 이벤트, 최신 상태만 의미 있는 업데이트는 드롭이 더 안전할 때가 많습니다.

func trySend(ch chan<- int, v int) {
	select {
	case ch <- v:
		// ok
	default:
		// drop
	}
}

드롭 전략은 반드시 의미론을 문서화해야 합니다. 예를 들어 "최신 값만 유지"가 목표라면 버퍼 크기 1 과 함께 이전 값을 비우는 방식도 고려합니다.

패턴 3: time.Tick 과 타이머를 멈추지 않아 누수

문제

time.Tick 은 내부적으로 Ticker 를 만들고, 반환된 채널을 더 이상 읽지 않아도 런타임이 계속 틱을 생성합니다. 오래 사는 프로세스에서 반복적으로 만들면 타이머 리소스가 누적됩니다.

func leakTick() {
	ch := time.Tick(100 * time.Millisecond)
	_ = ch
	// 여기서 리턴하면, 틱을 소비하지 않는 채로 백그라운드 타이머가 남을 수 있음
}

또한 time.After 를 루프에서 계속 생성하면 타이머 객체가 급증해 GC 압력을 높일 수 있습니다.

차단: time.NewTickerStop 을 명시

func runWithTicker(ctx context.Context) {
	t := time.NewTicker(100 * time.Millisecond)
	defer t.Stop()

	for {
		select {
		case <-t.C:
			// do periodic work
		case <-ctx.Done():
			return
		}
	}
}

time.After 대신 time.NewTimer 를 재사용하는 패턴도 효과적입니다.

func loopWithTimer(ctx context.Context) {
	t := time.NewTimer(1 * time.Second)
	defer t.Stop()

	for {
		select {
		case <-t.C:
			// work
			t.Reset(1 * time.Second)
		case <-ctx.Done():
			return
		}
	}
}

주의: Reset 전후로 타이머 채널 드레인 규칙이 필요할 수 있습니다. 복잡해지면 검증된 유틸로 감싸거나, 단순하게 Ticker 로 해결하는 편이 안전합니다.

패턴 4: fan-out 작업에서 결과 수집이 막혀 고루틴이 줄줄이 대기

문제

여러 고루틴을 띄워 병렬 처리(fan-out)한 뒤 결과를 한 채널로 모으는(fan-in) 구조에서, 수집자가 중간에 리턴하면 나머지 작업 고루틴들이 결과 송신에서 막혀 누수됩니다.

func fanOutLeak(ctx context.Context, jobs []int) int {
	results := make(chan int) // unbuffered

	for _, j := range jobs {
		j := j
		go func() {
			// expensive work
			results <- (j * 2) // 수집자가 사라지면 여기서 블로킹
		}()
	}

	// 첫 결과만 받고 리턴
	return <-results
}

차단 1: context.WithCancel 로 "첫 성공" 이후 나머지 취소

func firstResult(ctx context.Context, jobs []int) (int, error) {
	ctx, cancel := context.WithCancel(ctx)
	defer cancel()

	results := make(chan int, 1) // 최소 1 버퍼로 첫 결과는 안전하게 수집

	for _, j := range jobs {
		j := j
		go func() {
			v := j * 2
			select {
			case results <- v:
				// 첫 결과 경쟁에서 이기면 cancel로 나머지 종료 유도
				cancel()
			case <-ctx.Done():
				return
			}
		}()
	}

	select {
	case v := <-results:
		return v, nil
	case <-ctx.Done():
		return 0, ctx.Err()
	}
}

여기서 버퍼 1 은 "첫 결과"를 담기 위한 안전장치입니다. 하지만 이것만으로 충분하지 않습니다. 반드시 각 작업 고루틴이 ctx.Done() 을 보고 빠져나오도록 만들어야 합니다.

차단 2: errgroup.WithContext 로 구조화된 동시성 적용

표준 라이브러리는 아니지만, golang.org/x/sync/errgroup 는 누수 방지에 매우 유용합니다.

import (
	"context"
	"golang.org/x/sync/errgroup"
)

func runAll(ctx context.Context, jobs []int) error {
	g, ctx := errgroup.WithContext(ctx)

	for _, j := range jobs {
		j := j
		g.Go(func() error {
			select {
			case <-ctx.Done():
				return ctx.Err()
			default:
				// do work
				_ = j * 2
				return nil
			}
		})
	}

	return g.Wait()
}

구조화된 동시성의 목표는 "고루틴의 생명주기를 스코프 안에 가둔다"입니다. Wait 로 종료를 강제하면, 누수가 설계 단계에서 줄어듭니다.

패턴 5: http.Request 취소를 전파하지 않아 I/O 대기가 남음

문제

요청 처리 중 백그라운드 고루틴이 외부 I/O를 수행할 때, 클라이언트가 연결을 끊어도 작업이 계속 진행될 수 있습니다. 특히 context.Background() 를 무심코 쓰면 취소가 전혀 전파되지 않습니다.

func handler(w http.ResponseWriter, r *http.Request) {
	go func() {
		// BAD: 요청 취소와 무관하게 계속 실행
		doRemoteCall(context.Background())
	}()

	w.WriteHeader(http.StatusAccepted)
}

이 경우 고루틴 자체도 문제지만, 더 큰 문제는 네트워크 커넥션/타임아웃 미설정으로 인해 커널 리소스까지 잠식할 수 있다는 점입니다.

차단: r.Context() 를 전달하고, 클라이언트/서버 타임아웃을 명시

func handler(w http.ResponseWriter, r *http.Request) {
	ctx := r.Context()

	go func() {
		// GOOD: 클라이언트 취소, 서버 타임아웃이 전파됨
		_ = doRemoteCall(ctx)
	}()

	w.WriteHeader(http.StatusAccepted)
}

그리고 외부 호출 쪽에서 타임아웃을 강제하세요.

func doRemoteCall(ctx context.Context) error {
	ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
	defer cancel()

	req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://example.com", nil)
	if err != nil {
		return err
	}

	client := &http.Client{Timeout: 3 * time.Second}
	resp, err := client.Do(req)
	if err != nil {
		return err
	}
	defer resp.Body.Close()
	return nil
}

NewRequestWithContext 로 요청 취소를 전파하고, http.Client.Timeout 또는 Transport 레벨 타임아웃으로 "영원한 I/O"를 방지합니다. 둘 중 하나만 걸어도 낫지만, 실무에서는 방어선을 여러 겹 두는 편이 안전합니다.

누수 방지 체크리스트

  • 채널 send/recv 는 항상 selectctx.Done() 또는 타임아웃 경로를 함께 둔다
  • fan-out 을 만들면 fan-in 종료 조건을 명확히 하고, 조기 종료 시 cancel 로 나머지를 정리한다
  • 반복 작업은 time.Tick 보다 time.NewTicker 를 쓰고 Stop 을 보장한다
  • 요청 핸들러 내부 고루틴은 r.Context() 를 최상위로 전달한다
  • 고루틴을 띄웠다면 "누가 언제 종료시키는가"를 코드 리뷰 체크 항목으로 둔다

디버깅 팁: 고루틴 덤프에서 무엇을 볼 것인가

pprof의 goroutine 덤프에서 다음 패턴이 반복되면 누수 가능성이 큽니다.

  • chan receive 또는 chan send 로 고정
  • select 에서 case 가 절대 충족되지 않는 형태
  • net.(*pollDesc).wait 같은 네트워크 대기

이때 단순히 "버퍼를 늘리자"로 끝내면, 누수가 "지연된 폭탄"으로 바뀔 수 있습니다. 버퍼는 완화책일 뿐이고, 취소 전파와 종료 스코프가 본질입니다.

운영에서 문제가 커지면 원인이 애플리케이션 동시성 누수인지, 커널/네트워크 리소스 병목인지 함께 봐야 합니다. 예를 들어 커넥션이 쌓이며 conntrack 이슈로 증상이 증폭되는 경우는 EKS conntrack 테이블 포화로 연결 끊김 해결법 같은 네트워크 레이어 점검과 같이 진행하는 편이 좋습니다.

마무리

Go 고루틴 누수는 대부분 "블로킹 연산에 탈출구가 없다"는 공통 원인으로 귀결됩니다. 채널에는 select 와 취소 경로를, 병렬 처리에는 cancel 또는 errgroup 같은 구조화된 동시성을, 타이머에는 Stop 을 기본값으로 두면 누수의 상당수를 설계 단계에서 제거할 수 있습니다.

다음 단계로는 서비스에 pprof를 안전하게 붙이고, runtime.NumGoroutine() 과 함께 주요 경로에 context 타임아웃 정책을 표준화해 보세요. 이렇게 하면 "가끔 발생하는" 누수가 "재현 가능하고 고칠 수 있는" 버그로 바뀝니다.