Published on

Go 고루틴 leak·채널 데드락 찾는 10가지 패턴

Authors

서버가 느려지고 CPU는 낮은데 메모리만 계속 오르거나, 특정 요청에서만 영원히 응답이 안 오는 증상은 Go에서 흔히 goroutine leak 또는 채널 기반 동기화의 deadlock으로 이어집니다. 특히 고루틴은 생성 비용이 낮아 "일단 고루틴으로"가 습관이 되기 쉬운데, 종료 신호와 수신자/송신자 균형이 깨지는 순간부터 누수는 눈에 잘 띄지 않게 쌓입니다.

이 글은 "어떤 코드가 위험한지"를 빠르게 스캔할 수 있도록, 현장에서 반복되는 10가지 패턴을 재현 코드와 함께 정리합니다. 마지막에는 pprof/트레이스/런타임 덤프로 실제 누수를 확인하는 방법까지 연결합니다. 네트워크 타임아웃 설계는 Go gRPC DEADLINE_EXCEEDED 9가지 원인과 처방도 함께 보면 좋습니다.

먼저: 누수와 데드락을 구분하는 기준

  • goroutine leak: 고루틴이 종료되지 않고 블로킹 상태로 남아 수가 계속 증가합니다. 증상은 runtime.NumGoroutine() 증가, 메모리 증가, GC 부담 증가, 특정 채널/락 대기 스택이 반복됩니다.
  • deadlock: 전체 진행이 멈춥니다. 대표적으로 "all goroutines are asleep - deadlock!" 패닉이거나, 메인 루틴/핵심 워커가 서로를 기다리며 정지합니다.

실무에서는 둘이 섞입니다. 예를 들어 워커 풀의 수신자가 멈추면 송신자 고루틴이 누수처럼 쌓이고, 결국 시스템이 사실상 데드락 상태가 됩니다.

패턴 1) 수신자가 없는 채널 송신 (unbuffered)

가장 전형적인 누수 형태입니다. 수신자가 없으면 송신은 영원히 블로킹됩니다.

package main

import "time"

func main() {
	ch := make(chan int) // unbuffered
	go func() {
		ch <- 1 // receiver가 없으면 영원히 대기
	}()

	time.Sleep(2 * time.Second)
}

처방

  • 송신/수신의 생명주기를 맞추고, 종료 시그널을 context 또는 done 채널로 강제합니다.
  • 정말 "한 번만" 전달하면 되는 값이면 버퍼 채널을 고려합니다.
ch := make(chan int, 1)
ch <- 1 // receiver 없어도 일단 통과

단, 버퍼로 가리는 것은 근본 해결이 아닙니다. 소비자가 영원히 없으면 결국 데이터가 쌓이거나 로직이 깨집니다.

패턴 2) 버퍼 채널이 꽉 찼는데 계속 송신

버퍼 채널은 안전장치가 아니라 "지연된 데드락"이 될 수 있습니다.

package main

import (
	"time"
)

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

	go func() {
		for i := 0; i < 10; i++ {
			ch <- i // 3번째부터 블로킹
		}
	}()

	time.Sleep(2 * time.Second)
}

처방

  • 송신 측에 select와 타임아웃/취소를 붙입니다.
select {
case ch <- i:
	// ok
case <-time.After(200 * time.Millisecond):
	// backpressure 처리: drop, retry, metric
}
  • 더 좋은 방식은 context 기반으로 "이 작업은 언제까지"를 명확히 하는 것입니다.

패턴 3) range ch가 끝나지 않음 (채널 close 누락)

수신 측이 for v := range ch로 돌면 채널이 닫힐 때까지 끝나지 않습니다. 생산자가 close를 안 하면 소비자는 영원히 대기합니다.

package main

func main() {
	ch := make(chan int)
	go func() {
		ch <- 1
		// close(ch) 누락
	}()

	for v := range ch {
		_ = v
	}
}

처방

  • 생산자 쪽에서 "더 이상 보낼 값이 없다"를 보장할 수 있을 때만 close합니다.
  • 여러 생산자가 있으면 단일 고루틴이 fan-in 후 한 번만 닫도록 구조를 바꿉니다.
// 여러 producer가 있을 때는 producer가 직접 close하지 말고
// 모든 producer 종료를 WaitGroup으로 모은 뒤 한 곳에서 close

패턴 4) close를 여러 번 호출하거나, 닫힌 채널에 송신

이건 누수보다는 즉시 패닉으로 터지지만, 운영에서는 "가끔" 터져 원인 추적이 어렵습니다.

close(ch)
close(ch)      // panic
ch <- 1        // panic

처방

  • 채널의 소유권을 명확히 합니다. "누가 닫는가"는 단 한 곳이어야 합니다.
  • 종료 신호용 채널은 close(done) 패턴을 쓰되, sync.Once로 보호합니다.
var once sync.Once

func stop() {
	once.Do(func() { close(done) })
}

패턴 5) select에서 default로 바쁜 루프 (busy loop)

default는 "블로킹하지 않겠다"는 의미라서, 이벤트가 없으면 계속 루프를 돕니다. 이 자체가 누수는 아니지만 CPU를 태우며 다른 고루틴을 굶기고, 결과적으로 데드락처럼 보이게 합니다.

for {
	select {
	case v := <-ch:
		_ = v
	default:
		// 아무것도 안 하고 계속 돈다
	}
}

처방

  • default가 필요하면 time.Sleep 또는 ticker로 주기를 제한합니다.
  • 대부분은 default 없이 블로킹 수신이 맞습니다.
for {
	select {
	case v := <-ch:
		_ = v
	case <-ctx.Done():
		return
	}
}

패턴 6) context 취소를 무시한 채널/락 대기

요청 스코프 고루틴이 ctx.Done()을 보지 않으면, 클라이언트가 끊겨도 서버 내부 고루틴이 계속 남습니다. 특히 gRPC/HTTP 핸들러에서 흔합니다.

func handler(ctx context.Context, ch <-chan int) {
	// ctx 취소가 와도 아래는 끝나지 않을 수 있음
	v := <-ch
	_ = v
}

처방

  • 채널 수신/송신은 selectctx.Done()과 함께 둡니다.
select {
case v := <-ch:
	_ = v
case <-ctx.Done():
	return
}

패턴 7) WaitGroup 카운트 불일치 (Add/Done 누락)

wg.Wait()가 영원히 끝나지 않는 대표 원인입니다.

var wg sync.WaitGroup

wg.Add(1)
go func() {
	// wg.Done() 누락
}()

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

처방

  • 고루틴 시작 직후 defer wg.Done()을 습관화합니다.
  • Add는 고루틴 시작 전에 호출합니다. 고루틴 내부에서 Add하면 레이스로 카운트가 꼬일 수 있습니다.
wg.Add(1)
go func() {
	defer wg.Done()
	// work
}()

패턴 8) Mutex/RWMutex 잠금 순서 역전 (lock order inversion)

채널만의 문제는 아닙니다. 두 락을 서로 다른 순서로 잡으면 데드락이 발생합니다.

var muA, muB sync.Mutex

func f1() {
	muA.Lock()
	defer muA.Unlock()
	muB.Lock()
	defer muB.Unlock()
}

func f2() {
	muB.Lock()
	defer muB.Unlock()
	muA.Lock()
	defer muA.Unlock()
}

처방

  • 락 획득 순서를 전역 규칙으로 고정합니다. 예: 항상 AB.
  • 가능하면 하나의 락으로 합치거나, 메시지 패싱(채널)으로 소유권을 단일 고루틴에 몰아줍니다.

패턴 9) nil 채널로 select가 영원히 막힘

nil 채널은 송신/수신이 영원히 블로킹됩니다. select에서 nil 채널 케이스는 "존재하지 않는 것"처럼 동작해 디버깅을 어렵게 만듭니다.

var ch chan int // nil

select {
case v := <-ch:
	_ = v
case <-time.After(1 * time.Second):
	// 이 케이스가 없으면 영원히 대기
}

처방

  • 채널은 생성 시점을 명확히 하고, 옵션 기능 토글이 필요하면 nil 대신 별도 플래그를 둡니다.
  • select에서 특정 케이스를 비활성화하려고 nil 채널을 쓰는 패턴은 "의도"가 문서화돼야 합니다.

패턴 10) ワーカー 풀에서 결과 채널 미수신 (fan-out 후 fan-in 누락)

여러 고루틴이 결과를 보내는데, 수신자가 중간에 리턴하면 송신자들이 줄줄이 막혀 누수됩니다.

func doWork(ctx context.Context, jobs []int) error {
	results := make(chan int)
	for _, j := range jobs {
		j := j
		go func() {
			// 작업 후 결과 전송
			results <- j * 2
		}()
	}

	// 에러가 나서 조기 리턴하면 results를 더 이상 읽지 않음
	return context.Canceled
}

처방

  • 결과 채널에 버퍼를 두거나(작업 수만큼은 위험), 더 일반적으로는 ctx 취소 시 송신자가 빠져나오게 만듭니다.
  • 수신자 고루틴을 따로 두고, 조기 리턴 시에도 drain 하거나 취소를 전파합니다.
func doWork(ctx context.Context, jobs []int) error {
	ctx, cancel := context.WithCancel(ctx)
	defer cancel()

	results := make(chan int)
	var wg sync.WaitGroup

	for _, j := range jobs {
		wg.Add(1)
		j := j
		go func() {
			defer wg.Done()
			select {
			case results <- j * 2:
			case <-ctx.Done():
				return
			}
		}()
	}

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

	for r := range results {
		_ = r
		// 조건에 따라 조기 종료하고 싶다면 cancel 호출
		// cancel()
	}
	return nil
}

누수/데드락을 "찾는" 실전 진단 루틴

코드를 고쳐도 "진짜로" 고쳐졌는지 확인해야 합니다. 다음 순서가 실무에서 효율적입니다.

1) 고루틴 수를 지표로 박기

import (
	"runtime"
	"time"
)

func logGoroutines() {
	for range time.Tick(10 * time.Second) {
		println("goroutines:", runtime.NumGoroutine())
	}
}
  • 배포 전후, 트래픽 변화에 따라 NumGoroutine이 기준선으로 돌아오는지 확인합니다.

2) pprof로 goroutine 덤프 보기

import _ "net/http/pprof"
import "net/http"

func main() {
	go http.ListenAndServe(":6060", nil)
	// ...
}

운영/스테이징에서:

  • curl http://localhost:6060/debug/pprof/goroutine?debug=2
  • 같은 스택이 수백 개 반복되면 "어디서 막혔는지"가 바로 보입니다.

3) go test에서 레이스와 타임아웃으로 잡기

  • go test -race는 채널/락 문제가 항상 잡히진 않지만, 공유 상태 경쟁으로 인해 데드락이 유발되는 경우 단서가 됩니다.
  • 테스트에 타임아웃을 걸어 "무한 대기"를 실패로 바꿉니다.
func TestSomething(t *testing.T) {
	ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
	defer cancel()

	done := make(chan struct{})
	go func() {
		defer close(done)
		// test target
	}()

	select {
	case <-done:
	case <-ctx.Done():
		t.Fatal("timeout: possible deadlock")
	}
}

4) "종료 가능한" 구조가 기본값인지 점검

다음 체크리스트로 설계를 재검토하면 누수 재발을 크게 줄일 수 있습니다.

  • 모든 고루틴은 return 경로가 명확한가
  • 블로킹 포인트(채널 send/recv, 락, IO)에 ctx.Done() 또는 타임아웃이 붙어 있는가
  • 채널을 닫는 주체가 단 하나로 고정돼 있는가
  • 조기 리턴이 가능한 함수에서, 하위 고루틴 취소 전파가 되는가

동시성 문제의 진단 접근은 언어가 달라도 유사합니다. 스레드/락 기반 진단 관점은 Spring Boot 3·Java 21 가상스레드 데드락/지연 진단도 참고하면 "대기 지점"을 찾는 감각을 확장하는 데 도움이 됩니다.

마무리: 패턴을 "금지"가 아니라 "규칙"으로 만들기

Go의 채널과 고루틴은 강력하지만, 종료와 소유권 규칙이 없으면 작은 편의가 큰 장애로 바뀝니다. 위 10가지 패턴은 대부분 코드 리뷰에서 정적 스캔으로 발견할 수 있으니, 팀 규칙으로 다음을 고정하는 것이 좋습니다.

  • 고루틴을 만들면 항상 ctx 또는 done을 함께 설계한다
  • 채널은 소유권(누가 닫는지)과 생명주기를 주석/인터페이스로 고정한다
  • select에는 취소 경로가 기본으로 들어간다

이 원칙만 지켜도 "고루틴이 계속 늘어난다" 류의 장애는 체감상 대부분 사라집니다.