Published on

Go 고루틴 leak 막기 - context 취소·채널 close

Authors

서버가 “정상 동작”하는데도 메모리와 고루틴 수가 서서히 증가하는 케이스는 꽤 흔합니다. 특히 Go에서는 고루틴 생성 비용이 낮아 보이기 때문에 누수가 더 늦게 발견되고, 트래픽이 커진 뒤에야 장애로 표면화되곤 합니다.

이 글은 고루틴 leak의 전형적인 원인(취소 신호 미전파, 채널 close/드레인 누락, 블로킹 I/O)과 함께 context 취소와 채널 종료를 엮어 “반드시 끝나는” 구조로 만드는 실전 패턴을 다룹니다.

운영에서의 증상은 종종 다른 형태의 “무한 반복/재시작”으로도 나타납니다. 예를 들어 프로세스가 점점 느려져 헬스체크에 실패하고 systemd가 재시작을 반복한다면, 고루틴 leak이 원인일 수도 있습니다. 관련해서는 systemd 서비스 무한 재시작 10분 진단 체크리스트도 함께 참고하면 좋습니다.

고루틴 leak의 정의와 흔한 징후

고루틴 leak은 “고루틴이 논리적으로 끝났어야 하는데, 종료 경로가 없어 영원히 살아남는 상태”입니다. Go는 GC가 메모리를 회수해도, 고루틴이 블로킹 상태로 남아 있으면 스택/런타임 구조체가 유지되고, 간접적으로 메모리·FD·락·큐 적체를 유발합니다.

운영에서 자주 보이는 징후는 다음과 같습니다.

  • runtime.NumGoroutine()가 지속적으로 증가
  • pprof의 goroutine 프로파일에서 동일한 스택 트레이스가 대량으로 누적
  • 요청이 줄어도 리소스가 회복되지 않음
  • 큐/채널 송수신이 멈춘 듯한 정체(Backpressure 붕괴)

leak이 발생하는 3가지 대표 패턴

1) 취소되지 않는 무한 대기 select

가장 흔한 형태는 “언젠가 올 이벤트”를 기다리는 고루틴이 실제로는 영원히 오지 않는 이벤트를 기다리는 경우입니다.

func leakyWorker(ch <-chan int) {
	for {
		v := <-ch // ch에 값이 오지 않으면 영원히 블로킹
		_ = v
	}
}

이 코드는 ch가 닫히지 않고, 값이 더 이상 오지 않으면 종료되지 않습니다. 종료 신호(ctx.Done()) 또는 ch close를 설계에 포함해야 합니다.

2) send가 영원히 블로킹되는 생산자

소비자가 죽었는데 생산자는 계속 send를 시도하면 생산자는 무한 대기합니다.

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

버퍼 채널도 결국 꽉 차면 동일합니다. 생산자 역시 취소를 알아야 하고, selectctx.Done()을 함께 봐야 합니다.

3) 채널 close 규약이 없는 파이프라인

파이프라인에서 “누가 언제 close 하는가”가 불명확하면 downstream이 range에서 영원히 기다립니다.

func stage(in <-chan int) <-chan int {
	out := make(chan int)
	go func() {
		for v := range in {
			out <- v * 2
		}
		// out close 누락: downstream은 range out에서 영원히 대기
	}()
	return out
}

원칙: 취소는 context, 종료는 close, 대기는 WaitGroup

고루틴 leak 방지의 핵심은 “모든 고루틴이 반드시 종료 경로를 가진다”로 귀결됩니다. 실전에서 가장 안전한 조합은 다음 3가지입니다.

  • 취소 전파: context.Context
  • 데이터 스트림 종료: 채널 close
  • 종료 동기화: sync.WaitGroup

여기서 중요한 규칙이 있습니다.

  1. 채널은 송신자(생산자)가 close 한다. 수신자가 close 하지 않는다.
  2. 고루틴은 블로킹 연산(채널 recv/send, 타이머, I/O) 주변에 ctx.Done()을 함께 둔다.
  3. 종료 시에는 cancel() 호출 후 wg.Wait()“정리 완료”를 보장한다.

실전 패턴 1: ctx.Done()을 포함한 안전한 recv 루프

func worker(ctx context.Context, in <-chan int) {
	for {
		select {
		case <-ctx.Done():
			return
		case v, ok := <-in:
			if !ok {
				return
			}
			_ = v
		}
	}
}

포인트는 ok 체크입니다. upstream이 채널을 닫으면 즉시 종료합니다. 그리고 upstream이 닫지 않더라도 ctx 취소로 빠져나갈 수 있습니다.

실전 패턴 2: 안전한 send 루프(소비자 부재 방지)

func producer(ctx context.Context, out chan<- int) {
	defer close(out) // 생산자가 close

	for i := 0; ; i++ {
		select {
		case <-ctx.Done():
			return
		case out <- i:
		}
	}
}

out <- i가 막히는 상황에서도 ctx.Done()이 열리면 즉시 종료합니다. 이 패턴은 “소비자가 죽었는데 생산자가 살아남는” 누수를 크게 줄여줍니다.

실전 패턴 3: fan-out, fan-in에서 누수 없이 합치기

fan-out은 여러 워커가 하나의 입력을 나눠 처리하고, fan-in은 결과를 하나로 합칩니다. 이때 결과 채널을 닫는 책임과 워커 종료 동기화가 중요합니다.

아래 예시는 다음을 보장합니다.

  • ctx가 취소되면 모든 워커가 종료
  • 모든 워커가 종료되면 results가 close
  • 소비자는 range results로 안전하게 종료
func fanOutFanIn(ctx context.Context, in <-chan int, workerN int) <-chan int {
	results := make(chan int)
	var wg sync.WaitGroup
	wg.Add(workerN)

	worker := func() {
		defer wg.Done()
		for {
			select {
			case <-ctx.Done():
				return
			case v, ok := <-in:
				if !ok {
					return
				}
				// 결과 전송도 ctx를 함께 본다
				select {
				case <-ctx.Done():
					return
				case results <- v * 2:
				}
			}
		}
	}

	for i := 0; i < workerN; i++ {
		go worker()
	}

	go func() {
		wg.Wait()
		close(results)
	}()

	return results
}

여기서 흔히 빠뜨리는 지점은 “처리 후 결과를 보내는 send도 블로킹될 수 있다”는 점입니다. 그래서 결과 send에도 ctx.Done()을 넣었습니다.

실전 패턴 4: errgroup.WithContext로 실패 시 전체 취소

여러 고루틴 중 하나라도 실패하면 전체를 멈추고 싶을 때는 errgroup이 매우 유용합니다.

import "golang.org/x/sync/errgroup"

func run(ctx context.Context) error {
	g, ctx := errgroup.WithContext(ctx)

	g.Go(func() error {
		return doA(ctx)
	})
	g.Go(func() error {
		return doB(ctx)
	})

	// 하나라도 에러를 반환하면 ctx가 취소되고, g.Wait가 에러를 리턴
	return g.Wait()
}

단, doA, doB 내부가 반드시 ctx.Done()을 관찰하도록 작성되어 있어야 “정말로” 멈춥니다. errgroup은 취소 신호를 만들어줄 뿐, 블로킹을 강제로 깨주지는 않습니다.

채널 close 설계: 누가 닫고, 누가 드레인하는가

채널 close는 강력하지만 규약이 없으면 오히려 패닉을 유발합니다.

  • 닫힌 채널에 send하면 패닉
  • 여러 곳에서 close하면 패닉

따라서 다음 규칙을 팀 규약으로 고정하는 것이 좋습니다.

  • close는 “생산자 단일 지점”에서만 수행
  • 생산자 다수인 경우에는 WaitGroup으로 생산자 종료를 모은 뒤 “조정자”가 close

예시:

func merge(ctx context.Context, ins ...<-chan int) <-chan int {
	out := make(chan int)
	var wg sync.WaitGroup
	wg.Add(len(ins))

	forward := func(ch <-chan int) {
		defer wg.Done()
		for {
			select {
			case <-ctx.Done():
				return
			case v, ok := <-ch:
				if !ok {
					return
				}
				select {
				case <-ctx.Done():
					return
				case out <- v:
				}
			}
		}
	}

	for _, ch := range ins {
		go forward(ch)
	}

	go func() {
		wg.Wait()
		close(out)
	}()

	return out
}

out을 닫는 주체는 “조정자 고루틴” 하나뿐입니다.

타임아웃/타이머로 인한 leak: time.After 남발 주의

time.After(d)는 내부 타이머를 생성합니다. 루프에서 무심코 쓰면 타이머 객체가 대량 생성되고, GC/타이머 힙 부담이 커질 수 있습니다. 특히 select에서 자주 쓰는 패턴은 다음처럼 바꾸는 게 좋습니다.

나쁜 예(루프마다 타이머 생성):

for {
	select {
	case <-ctx.Done():
		return
	case <-time.After(500 * time.Millisecond):
		// periodic
	}
}

개선(티커 사용, 종료 시 Stop):

ticker := time.NewTicker(500 * time.Millisecond)
defer ticker.Stop()

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

“취소해도 안 끝나는” 경우: 블로킹 I/O와 라이브러리 호출

ctx는 신호일 뿐이라서, 아래처럼 컨텍스트를 무시하는 블로킹 호출이 있으면 고루틴이 끝나지 않습니다.

  • net.Conn.Read가 영원히 블로킹
  • 외부 라이브러리 함수가 ctx를 받지 않음
  • DB/HTTP 호출에 컨텍스트를 넘기지 않음

대응 방법:

  • 가능한 모든 I/O에 ctx를 전달 (http.NewRequestWithContext, db.QueryContext 등)
  • 커넥션/파일에 deadline 설정 (SetReadDeadline)
  • 종료 시 리소스를 명시적으로 Close 해서 블로킹을 깨기

예: TCP read에 deadline을 걸어 취소를 반영

func readLoop(ctx context.Context, c net.Conn) error {
	defer c.Close()

	buf := make([]byte, 4096)
	for {
		// 주기적으로 deadline을 갱신해 ctx 취소를 확인할 기회를 만든다
		_ = c.SetReadDeadline(time.Now().Add(1 * time.Second))

		n, err := c.Read(buf)
		if ne, ok := err.(net.Error); ok && ne.Timeout() {
			select {
			case <-ctx.Done():
				return ctx.Err()
			default:
				continue
			}
		}
		if err != nil {
			return err
		}
		_ = n
	}
}

디버깅 체크리스트: pprof로 “어디서” 새는지 찾기

운영에서 고루틴 leak을 잡는 가장 빠른 방법은 goroutine 프로파일을 보는 것입니다.

  1. net/http/pprof를 붙여서 goroutine 덤프 확인
  2. 동일 스택이 수백, 수천 개 쌓이는 지점 찾기
  3. 그 지점이 chan receive, chan send, select 대기인지 확인
  4. 해당 루프에 ctx.Done() 혹은 채널 close 경로가 있는지 점검

pprof 엔드포인트 예:

import _ "net/http/pprof"

go func() {
	_ = http.ListenAndServe("127.0.0.1:6060", nil)
}()

CLI에서 goroutine 확인:

  • go tool pprof -http=:0 http://127.0.0.1:6060/debug/pprof/goroutine

운영 관점에서의 예방: “끝나는 파이프라인”을 표준화

고루틴 leak은 코드 한 줄의 실수라기보다 “종료 프로토콜 부재”에서 오는 경우가 많습니다. 다음을 팀 표준으로 만들어두면 재발률이 크게 줄어듭니다.

  • 모든 고루틴 시작 함수는 ctx를 받는다
  • 채널을 반환하는 함수는 close 책임을 문서화한다(혹은 코드로 보장한다)
  • select에는 가능한 한 ctx.Done()을 항상 포함한다
  • fan-in/out은 WaitGroup 또는 errgroup 기반 템플릿을 사용한다

이런 “무한히 쌓이는 작업”은 CI/CD나 인프라에서도 비슷한 형태로 나타납니다. 예를 들어 파이프라인이 조건을 잘못 걸어 무한 재빌드를 반복하면 리소스가 소모됩니다. 비슷한 관점의 트러블슈팅으로 GitHub Actions Docker CI/CD 무한 재빌드 루프 끊기도 참고할 만합니다.

마무리: leak을 막는 최소 조건

정리하면, 고루틴 leak을 막는 최소 조건은 아래 3가지입니다.

  1. 모든 고루틴은 ctx.Done() 또는 입력 채널 close로 빠져나갈 수 있어야 한다
  2. 채널 close는 단일 생산자(또는 조정자)만 수행한다
  3. 종료는 cancel 호출과 Wait로 “정말 끝났는지” 확인한다

이 3가지만 지켜도 “트래픽이 늘수록 고루틴이 누적되는” 클래스의 장애는 대부분 예방할 수 있습니다.