Published on

Go 채널 데드락·goroutine 누수 재현과 해결

Authors

서버나 배치에서 Go 동시성을 쓰다 보면, 어느 날 갑자기 fatal error: all goroutines are asleep - deadlock!가 터지거나, 프로세스는 살아 있는데 메모리/스레드가 서서히 늘어나는 현상을 만납니다. 전자는 채널/락의 교착 상태(데드락), 후자는 대부분 goroutine 누수입니다. 둘은 별개의 문제처럼 보이지만, 실제로는 같은 뿌리(종료 조건 부재, 블로킹 I/O, 소비자/생산자 불균형)에서 함께 발생하는 경우가 많습니다.

이 글에서는 (1) 데드락·누수를 의도적으로 재현하고, (2) 왜 그런지 실행 흐름으로 설명하고, (3) 현업에서 통하는 해결 패턴(컨텍스트, 채널 종료, fan-in/fan-out, 에러 전파, 워커풀)을 코드로 정리합니다.

참고로 Go 네트워크/서비스에서 타임아웃과 취소는 거의 필수입니다. gRPC 컨텍스트/데드라인 튜닝은 아래 글도 함께 보면 좋습니다.

데드락과 goroutine 누수의 차이

  • 데드락: 모든 실행 흐름이 어떤 자원(채널 송수신, 뮤텍스, WaitGroup.Wait)에서 서로를 기다리며 멈춘 상태. Go 런타임은 “모든 goroutine이 잠들었다”고 판단하면 패닉을 냅니다.
  • goroutine 누수: 특정 goroutine이 종료되지 못하고 영원히 대기(채널 수신, 송신, select 대기, 블로킹 I/O)하면서 누적되는 상태. 런타임이 즉시 패닉을 내지 않을 수 있어 더 위험합니다.

실무에서는 “요청이 끝났는데도 goroutine이 남아 있다”가 누수의 전형적인 징후입니다. 그리고 누수는 결국 데드락/리소스 고갈로 이어지기도 합니다.

재현 1: 수신자가 없는 채널 송신 데드락

가장 단순한 데드락은 수신자가 없는 unbuffered 채널에 송신하는 경우입니다.

package main

func main() {
	ch := make(chan int) // unbuffered
	ch <- 1              // 수신자가 없어 영원히 블로킹
}

이 코드는 곧바로 데드락 패닉이 납니다. 원인은 명확합니다.

  • unbuffered 채널의 송신은 수신자가 “동시에” 있어야 완료됩니다.
  • main goroutine이 송신에서 멈추면, 프로그램 전체가 멈춥니다.

해결

  • 수신 goroutine을 만들거나
  • 버퍼 채널로 바꾸거나
  • 송신을 select로 감싸 타임아웃/취소를 넣습니다.
package main

import (
	"context"
	"time"
)

func main() {
	ch := make(chan int)
	ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
	defer cancel()

	select {
	case ch <- 1:
		// sent
	case <-ctx.Done():
		// give up
	}
}

select의 취소 케이스는 데드락을 “없애는” 게 아니라, 영원히 기다리지 않게 만들어 장애 전파/복구가 가능해집니다.

재현 2: 닫힌 채널이 아닌데 range로 기다리다 멈추는 누수

for v := range ch는 채널이 close될 때까지 계속 기다립니다. 생산자가 종료하면서 채널을 닫지 않으면 소비자는 끝나지 않습니다.

package main

import (
	"fmt"
	"time"
)

func main() {
	ch := make(chan int)

	go func() {
		for i := 0; i < 3; i++ {
			ch <- i
		}
		// close(ch) 를 안 하면 소비자는 영원히 대기
	}()

	go func() {
		for v := range ch {
			fmt.Println("recv:", v)
		}
		fmt.Println("consumer done")
	}()

	time.Sleep(500 * time.Millisecond)
	fmt.Println("main done")
}

실행하면 main done은 찍히지만 consumer done은 찍히지 않습니다. 소비 goroutine이 range에서 대기하며 누수가 발생합니다.

해결: 생산자가 “종료 신호”를 책임지고 close

채널 종료는 “더 이상 값이 오지 않는다”는 프로토콜입니다. 일반적으로 송신자(생산자)가 닫는 것이 규칙입니다.

package main

import (
	"fmt"
	"time"
)

func main() {
	ch := make(chan int)

	go func() {
		defer close(ch)
		for i := 0; i < 3; i++ {
			ch <- i
		}
	}()

	for v := range ch {
		fmt.Println("recv:", v)
	}

	time.Sleep(50 * time.Millisecond)
	fmt.Println("main done")
}

핵심은 “누가 close할 책임이 있는가”를 코드 구조로 강제하는 것입니다. 생산자가 여러 명이면 더 설계가 필요합니다(아래 fan-in에서 다룹니다).

재현 3: WaitGroup 데드락(카운트 불일치)

WaitGroup.Add(1)을 했는데 어떤 경로에서 Done()이 호출되지 않으면 Wait()는 영원히 기다립니다.

package main

import (
	"sync"
)

func main() {
	var wg sync.WaitGroup
	wg.Add(1)

	go func() {
		// 실수: return 경로에서 Done 누락
		return
		// wg.Done()
	}()

	wg.Wait() // 영원히 대기
}

해결: goroutine 시작 직후 defer wg.Done()

package main

import "sync"

func main() {
	var wg sync.WaitGroup
	wg.Add(1)

	go func() {
		defer wg.Done()
		// 어떤 return 경로든 Done 보장
	}()

	wg.Wait()
}

실무에서는 Add를 goroutine 안에서 하는 실수도 많습니다. Add는 보통 goroutine을 띄우기 “직전”에 하고, 띄운 뒤에는 Done만 책임지게 두는 편이 안전합니다.

재현 4: fan-in에서 결과 채널 close를 잘못 처리해 발생하는 누수

여러 워커가 결과를 하나의 채널로 모으는 fan-in에서 흔한 실수는 다음 중 하나입니다.

  • 결과 채널을 아무도 닫지 않아 소비자가 range에서 멈춤
  • 반대로, 여러 송신자가 결과 채널을 각각 닫아 panic: close of closed channel

잘못된 예(채널을 닫지 않음)

package main

import (
	"fmt"
	"sync"
)

func main() {
	jobs := make(chan int)
	results := make(chan int)

	var wg sync.WaitGroup
	for i := 0; i < 3; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			for j := range jobs {
				results <- j * 2
			}
		}()
	}

	go func() {
		for j := 0; j < 5; j++ {
			jobs <- j
		}
		close(jobs)
		wg.Wait()
		// 실수: results를 close 안 함
	}()

	for r := range results {
		fmt.Println(r)
	}
}

해결: “마지막 종료 지점”에서 단 한 번 close(results)

results는 워커들이 송신하지만, 닫는 책임은 워커가 아니라 **조정자(coordinator)**가 가져가는 게 일반적입니다. 즉, 워커 종료를 WaitGroup으로 확인한 뒤 한 번만 닫습니다.

package main

import (
	"fmt"
	"sync"
)

func main() {
	jobs := make(chan int)
	results := make(chan int)

	var wg sync.WaitGroup
	for i := 0; i < 3; i++ {
		wg.Add(1)
		go func(workerID int) {
			defer wg.Done()
			for j := range jobs {
				results <- j * 2
			}
		}(i)
	}

	go func() {
		for j := 0; j < 5; j++ {
			jobs <- j
		}
		close(jobs)

		wg.Wait()
		close(results) // 오직 여기서 한 번
	}()

	for r := range results {
		fmt.Println(r)
	}
}

이 패턴은 fan-in에서 가장 많이 쓰이며, 누수/패닉을 동시에 방지합니다.

누수의 본질: “취소가 전파되지 않는다”

goroutine 누수의 대부분은 다음 형태입니다.

  • 어떤 goroutine이 select 없이 채널 수신을 기다림
  • 상위 요청은 이미 실패/타임아웃/클라이언트 종료
  • 하지만 하위 goroutine에게 “이제 그만”이 전달되지 않아 계속 대기

그래서 실무에서는 채널 설계만큼 컨텍스트 취소가 중요합니다. 특히 HTTP 핸들러, gRPC 핸들러, 큐 컨슈머처럼 “요청/작업 단위”가 명확한 곳에서는 context.Context를 루트로 두고 하위 goroutine까지 전달하는 것이 정석입니다.

해결 패턴 1: selectctx.Done()을 넣어 블로킹을 끊기

채널 수신/송신 모두 취소 가능하게 만들면 누수가 급감합니다.

package main

import (
	"context"
	"time"
)

func worker(ctx context.Context, jobs <-chan int, results chan<- int) {
	for {
		select {
		case <-ctx.Done():
			return
		case j, ok := <-jobs:
			if !ok {
				return
			}
			// 결과 송신도 취소 가능하게
			select {
			case <-ctx.Done():
				return
			case results <- j * 2:
			}
		}
	}
}

func main() {
	ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
	defer cancel()

	_ = ctx
}

포인트는 “수신만 취소”가 아니라, 송신도 취소해야 한다는 점입니다. 결과 채널이 꽉 차거나 소비자가 죽으면 송신에서 누수가 납니다.

해결 패턴 2: 버퍼 채널은 만능이 아니다(하지만 완충재는 된다)

버퍼 채널은 생산자/소비자의 순간적인 속도 차를 흡수해 데드락 가능성을 줄입니다. 하지만 다음을 해결하지는 못합니다.

  • 소비자가 영원히 죽어 있으면 결국 버퍼는 가득 차고 송신은 블로킹
  • 종료 조건/close/취소가 없으면 누수는 그대로

버퍼는 “스파이크 완충” 정도로 보고, 종료 프로토콜과 함께 쓰는 게 안전합니다.

해결 패턴 3: 에러 그룹으로 조기 종료와 누수 방지

여러 goroutine 중 하나라도 실패하면 나머지를 취소해야 합니다. 표준 라이브러리만으로도 가능하지만, errgroup(Go 팀이 제공하는 golang.org/x/sync/errgroup)을 쓰면 패턴이 깔끔해집니다.

package main

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

func main() {
	ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
	defer cancel()

	g, ctx := errgroup.WithContext(ctx)

	g.Go(func() error {
		select {
		case <-time.After(50 * time.Millisecond):
			return fmt.Errorf("worker failed")
		case <-ctx.Done():
			return ctx.Err()
		}
	})

	g.Go(func() error {
		// 첫 번째 goroutine이 실패하면 ctx가 취소되어 여기서 빠져나옴
		<-ctx.Done()
		return ctx.Err()
	})

	_ = g.Wait()
}

이 방식은 “실패 시 다른 goroutine이 빠져나올 출구”를 보장하므로 누수 방지에 강력합니다.

운영에서의 관측/디버깅: 누수는 숫자로 드러난다

1) goroutine 수를 메트릭으로 보기

가장 단순하지만 효과가 큽니다.

  • runtime.NumGoroutine()을 주기적으로 로깅/메트릭화
  • 요청량과 무관하게 우상향이면 누수 의심
package main

import (
	"log"
	"runtime"
	"time"
)

func main() {
	go func() {
		for range time.NewTicker(2 * time.Second).C {
			log.Println("goroutines:", runtime.NumGoroutine())
		}
	}()

	select {}
}

2) pprof로 스택 트레이스 보기

HTTP 서버에서 net/http/pprof를 열어두면, 누수 지점이 “어디에서 블로킹 중인지” 스택으로 보입니다.

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

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

그 다음 curlhttp://127.0.0.1:6060/debug/pprof/goroutine?debug=2를 보면, chan receivechan send에서 멈춘 지점이 그대로 나옵니다.

체크리스트: 채널/고루틴 설계에서 지켜야 할 규칙

  1. range ch를 쓰면 close(ch) 책임자를 반드시 정한다.
  2. 송신/수신이 블로킹될 수 있는 모든 지점에 selectctx.Done() 또는 타임아웃을 고려한다.
  3. 워커풀에서는 결과 채널을 워커가 닫지 말고, 조정자가 WaitGroup 이후 한 번만 닫는다.
  4. WaitGroup은 goroutine 시작 직후 defer Done()을 기본으로 한다.
  5. 버퍼 채널은 완충재일 뿐, 종료/취소 프로토콜을 대체하지 못한다.
  6. 운영에서는 runtime.NumGoroutine()와 pprof를 기본 장착한다.

마무리

Go의 채널은 강력하지만 “종료”를 자동으로 처리해주지 않습니다. 데드락은 즉시 폭발하지만, goroutine 누수는 조용히 진행되며 결국 장애를 크게 만듭니다. 해결의 핵심은 하나로 요약됩니다.

  • 종료 조건을 설계에 포함하고(누가 close하는가)
  • 취소를 전파하며(ctx.Done)
  • 조정자(coordinator)가 생명주기를 관리한다(WaitGroup, errgroup)

이 3가지만 일관되게 지키면, 채널 기반 파이프라인과 워커풀은 훨씬 예측 가능해지고 운영 안정성도 크게 올라갑니다.