Published on

Go 채널 데드락·goroutine leak 7가지 패턴

Authors

동시성 버그는 재현이 어렵고, 장애로 나타날 땐 보통 CPU 0%인데 응답 없음, 메모리 서서히 증가, 요청이 특정 비율로만 타임아웃 같은 형태로 보입니다. 특히 Go의 채널은 강력한 동기화 도구지만, 잘못 쓰면 데드락과 goroutine leak을 동시에 만들기 쉽습니다.

이 글은 채널 데드락과 goroutine leak을 유발하는 대표 패턴 7가지를 재현 코드로 보여주고, 안전한 수정 패턴까지 함께 제공합니다. goroutine 누수 자체를 실전에서 추적하는 방법은 Go goroutine 누수 잡기 - pprof+context 실전도 같이 참고하면 좋습니다.


빠른 체크리스트

아래 중 하나라도 해당되면 채널/고루틴 이슈를 의심하세요.

  • selectdefault를 넣었더니 CPU가 튄다
  • range ch가 끝나지 않는다
  • 에러가 나도 워커들이 계속 남아있다
  • 수신자가 없는데 ch <- x를 한다
  • defer close(ch)를 습관처럼 쓴다
  • time.After를 루프에서 매번 만든다

1) 수신자 없는 send: 가장 흔한 즉시 데드락

증상

  • 특정 코드 경로에서만 요청이 영원히 멈춤
  • 스택 트레이스에 chan send에서 멈춰 있음

재현 코드

package main

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

왜 문제인가

버퍼 없는 채널은 send와 receive가 만나야만 진행됩니다. 수신자가 없으면 send는 영원히 대기합니다.

안전한 수정 패턴

  • 수신 goroutine을 먼저 띄우거나
  • 버퍼 채널로 바꾸되, 버퍼가 꽉 차는 경우도 고려하거나
  • 취소 가능하게 selectcontext.Done()을 포함합니다.
package main

import (
	"context"
	"fmt"
	"time"
)

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

	ch := make(chan int)

	go func() {
		fmt.Println(<-ch)
	}()

	select {
	case ch <- 1:
		// ok
	case <-ctx.Done():
		// 타임아웃/취소 시 빠져나감
	}
}

2) 닫지 않은 채널을 range로 소비: 조용한 goroutine leak

증상

  • 워커가 for v := range ch에서 끝나지 않음
  • 처리량이 떨어지고 goroutine 수가 서서히 증가

재현 코드

package main

func worker(ch <-chan int) {
	for v := range ch { // close 되지 않으면 영원히 대기
		_ = v
	}
}

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

	ch <- 1
	// close(ch) 를 안 하면 worker는 계속 살아 있음
}

안전한 수정 패턴

  • 생산자가 채널을 닫는 책임을 갖게 하세요.
  • 생산자가 여러 개인 경우엔 sync.WaitGroup으로 생산자 종료를 모은 뒤 한 곳에서만 close합니다.
package main

import "sync"

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

	var producers sync.WaitGroup
	producers.Add(2)

	go func() {
		defer producers.Done()
		ch <- 1
	}()
	go func() {
		defer producers.Done()
		ch <- 2
	}()

	go func() {
		producers.Wait()
		close(ch) // close는 오직 여기서만
	}()

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

3) 여러 송신자가 close 경쟁: close of closed channel 또는 패닉 회피로 인한 누수

증상

  • 간헐적으로 panic: close of closed channel
  • 패닉을 피하려고 recover를 넣었더니 더 큰 누수/정합성 문제가 발생

재현 코드

package main

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

	go func() {
		defer close(ch)
		ch <- 1
	}()
	go func() {
		defer close(ch) // 두 번째 close 시 패닉
		ch <- 2
	}()

	for range ch {
	}
}

왜 문제인가

채널 close는 정확히 한 번만 호출되어야 합니다. 생산자가 여러 개라면 close 책임을 분리해야 합니다.

안전한 수정 패턴

  • close는 “생산자 그룹을 대표하는 단일 코디네이터”가 담당
  • 또는 errgroup을 써서 생산자 수명 관리를 명확히
package main

import (
	"sync"
)

func main() {
	ch := make(chan int)
	var wg sync.WaitGroup

	wg.Add(2)
	go func() {
		defer wg.Done()
		ch <- 1
	}()
	go func() {
		defer wg.Done()
		ch <- 2
	}()

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

	for range ch {
	}
}

4) 버퍼 채널을 “무한 큐”로 착각: 꽉 차는 순간 전체 정지

증상

  • 평소엔 잘 되다가 트래픽 피크에 갑자기 멈춤
  • 스택에 chan send가 대량으로 누적

재현 코드

package main

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

	ch <- 1
	ch <- 2 // 버퍼가 꽉 차서 블록 (수신자가 없으면 데드락)
}

안전한 수정 패턴

  • “버퍼는 완충재”일 뿐, 백프레셔 설계가 핵심입니다.
  • 드롭/타임아웃/우선순위 큐 등 정책을 명시합니다.
package main

import (
	"context"
	"time"
)

func trySend(ctx context.Context, ch chan<- int, v int) bool {
	select {
	case ch <- v:
		return true
	case <-ctx.Done():
		return false
	case <-time.After(10 * time.Millisecond):
		return false // 드롭 또는 카운팅
	}
}

func main() {
	ch := make(chan int, 100)
	ctx := context.Background()
	_ = trySend(ctx, ch, 1)
}

5) selectdefault로 바쁜 루프 생성: CPU 폭증 + 다른 goroutine 굶김

증상

  • CPU가 비정상적으로 상승
  • 실제로는 대기해야 하는데 폴링을 계속함

재현 코드

package main

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

	for {
		select {
		case <-ch:
			// 처리
		default:
			// 아무 것도 없으면 계속 돈다 (busy loop)
		}
	}
}

안전한 수정 패턴

  • 정말로 논블로킹이 필요하면 default 대신 제한된 대기(틱/슬립)를 넣거나
  • 이벤트 기반으로 설계를 바꿉니다.
package main

import "time"

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

	for {
		select {
		case <-ch:
			// 처리
		case <-time.After(50 * time.Millisecond):
			// 주기적으로만 깨어남
		}
	}
}

주의: 위처럼 time.After를 루프에서 계속 만들면 또 다른 누수 패턴(7번)으로 이어질 수 있습니다. 아래에서 더 안전한 형태를 소개합니다.


6) 소비자가 중간에 리턴하는데 생산자는 계속 send: 생산자 goroutine leak

증상

  • 에러/타임아웃 이후에도 백그라운드 작업이 계속 남음
  • 요청 취소가 적용되지 않음

재현 코드

package main

import "errors"

func producer(out chan<- int) {
	for i := 0; i < 10; i++ {
		out <- i // 소비자가 사라지면 여기서 영원히 블록
	}
}

func consumer(in <-chan int) error {
	_ = <-in
	return errors.New("fail early") // 중간 종료
}

func main() {
	ch := make(chan int)
	go producer(ch)
	_ = consumer(ch)
	// producer는 ch send에서 멈춰 leak
}

안전한 수정 패턴: context로 취소 전파

package main

import (
	"context"
	"errors"
)

func producer(ctx context.Context, out chan<- int) {
	defer close(out)
	for i := 0; i < 10; i++ {
		select {
		case out <- i:
		case <-ctx.Done():
			return
		}
	}
}

func consumer(ctx context.Context, in <-chan int) error {
	select {
	case <-in:
		return errors.New("fail early")
	case <-ctx.Done():
		return ctx.Err()
	}
}

func main() {
	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()

	ch := make(chan int)
	go producer(ctx, ch)

	if err := consumer(ctx, ch); err != nil {
		cancel() // 실패 시 생산자까지 종료
	}
}

이 패턴은 실무에서 특히 중요합니다. HTTP 요청 핸들러에서 r.Context()를 그대로 넘기면, 클라이언트가 끊겼을 때 자동으로 취소가 전파되어 누수를 크게 줄일 수 있습니다.


7) time.After를 루프에서 남발: 타이머 누적과 메모리 압박

증상

  • 트래픽이 높을수록 메모리가 서서히 증가
  • 프로파일링에서 타이머/런타임 관련 객체가 많이 보임

문제 코드

package main

import "time"

func main() {
	ch := make(chan int)
	for {
		select {
		case <-ch:
			// 처리
		case <-time.After(100 * time.Millisecond):
			// 루프마다 새로운 타이머 생성
		}
	}
}

왜 문제인가

time.After는 내부적으로 타이머를 만들고, 만료 시점까지 참조가 유지됩니다. 루프가 빠르게 돌면 타이머가 대량으로 생성되어 GC 압박이 커질 수 있습니다.

안전한 수정 패턴: time.Ticker 또는 재사용 타이머

package main

import "time"

func main() {
	ch := make(chan int)
	ticker := time.NewTicker(100 * time.Millisecond)
	defer ticker.Stop()

	for {
		select {
		case <-ch:
			// 처리
		case <-ticker.C:
			// 주기 이벤트
		}
	}
}

단발성 타임아웃을 반복적으로 걸어야 한다면 time.NewTimer를 만들고 Reset하는 방식도 고려할 수 있습니다. 단, Stop과 채널 드레인 처리를 정확히 해야 하므로 팀 컨벤션으로 정해두는 편이 안전합니다.


디버깅 팁: “데드락”과 “누수”를 구분해서 보자

  • 데드락: 보통 즉시 멈추고 fatal error: all goroutines are asleep - deadlock!로 터지거나, 특정 goroutine이 chan send 또는 chan receive에서 영원히 멈춰 있습니다.
  • goroutine leak: 프로세스는 살아있지만 서서히 악화됩니다. goroutine 수 증가, 메모리 증가, tail latency 증가로 나타납니다.

실전에서는 다음 순서가 효율적입니다.

  1. runtime.NumGoroutine()를 주기적으로 로그/메트릭으로 수집
  2. pprof로 goroutine 덤프를 떠서 chan send, chan receive, select에서 오래 멈춘 스택을 찾기
  3. 취소 전파가 없는 경로를 context로 연결

누수 추적의 구체적인 절차와 pprof 해석은 Go goroutine 누수 잡기 - pprof+context 실전에 더 자세히 정리해 두었습니다.


마무리: 채널은 “소유권”과 “종료 신호”가 전부다

채널 기반 동시성에서 대부분의 사고는 아래 두 가지가 불명확해서 발생합니다.

  • 누가 채널을 닫는가(소유권)
  • 언제 종료해야 하는가(취소/타임아웃/에러 전파)

위 7가지 패턴을 팀 코드리뷰 체크리스트로 만들면, 데드락과 goroutine leak을 장애 전에 상당수 차단할 수 있습니다. 운영 중인 서비스가 응답 없음 상태로 보인다면, 애플리케이션 내부 문제뿐 아니라 컨테이너/노드 레벨의 재시작 루프도 함께 의심해야 하니 systemd 서비스가 계속 재시작될 때 원인 추적법 같은 관점도 같이 가져가면 진단 속도가 빨라집니다.