Published on

Go 채널 닫기 panic·goroutine leak 5분 진단

Authors

서버가 가끔 panic: close of closed channel 로 터지거나, 배포 후 시간이 지날수록 메모리와 goroutine 수가 서서히 증가한다면 거의 항상 채널 종료 규약이 깨졌거나, 종료 신호가 전달되지 않는 고루틴이 남아있는 상태입니다.

이 글은 "원인 분류 → 재현 포인트 → 안전한 패턴으로 교체 → 검증"을 5분 진단 루틴으로 정리합니다. (데드락 자체가 의심되면 먼저 Go 고루틴 채널 데드락 5분 재현·해결 도 같이 보세요.)


0. 5분 진단 체크리스트

1분: panic 메시지로 분류

  • panic: close of closed channel
    • 같은 채널을 두 군데 이상에서 close 하고 있음
    • 또는 "소유자"가 불명확해 경쟁적으로 닫힘
  • panic: send on closed channel
    • 생산자가 ch <- v 를 시도하는데 누군가 먼저 close(ch)
    • 보통 "수신자가 닫는" 안티패턴에서 발생

2분: goroutine leak 징후 확인

  • runtime.NumGoroutine() 가 지속 증가
  • pprof 에서 chan receive 또는 select 에서 오래 멈춘 스택이 다수
  • 요청 취소나 종료 시점에도 worker가 안 죽음

2분: 코드에서 흔한 냄새 찾기

  • close(ch) 가 여러 함수/고루틴에 흩어져 있음
  • 수신자(consumer)가 채널을 닫음
  • for v := range ch 루프가 있는데, 생산자가 종료 시 close 하지 않음
  • select { case v := <-ch: ... } 인데 ctx.Done() 케이스가 없음
  • 버퍼 채널에 send 하다가 영원히 막힘 (소비자가 죽었는데 생산자가 모름)

1. 채널 종료 규약: "닫는 쪽은 생산자" 하나로 통일

Go에서 채널을 닫는 목적은 "더 이상 값이 오지 않는다"를 수신자에게 알리는 것입니다. 따라서 원칙은 단순합니다.

  • 채널을 send 하는 쪽(생산자)이 close 를 소유한다
  • 수신자는 close 하지 않는다
  • close 는 보통 "단 한 곳"에서만 실행되게 만든다

이 원칙이 깨지면 close of closed channel, send on closed channel 이 거의 확정적으로 발생합니다.


2. panic: close of closed channel 3대 원인과 처방

원인 A: 여러 생산자가 각자 닫는다

아래는 흔한 실수입니다. worker가 여러 개인데 각자 defer close(out) 를 해버립니다.

package main

import (
	"sync"
)

func fanOut(in <-chan int, out chan<- int, workers int) {
	var wg sync.WaitGroup
	wg.Add(workers)

	for i := 0; i < workers; i++ {
		go func() {
			defer wg.Done()
			for v := range in {
				out <- v
			}
			// 잘못된 패턴: 여러 고루틴이 out을 닫으려 함
			close(out)
		}()
	}

	wg.Wait()
}

처방: WaitGroup 뒤에서 "단 한 번" 닫기

func fanOut(in <-chan int, out chan<- int, workers int) {
	var wg sync.WaitGroup
	wg.Add(workers)

	for i := 0; i < workers; i++ {
		go func() {
			defer wg.Done()
			for v := range in {
				out <- v
			}
		}()
	}

	go func() {
		wg.Wait()
		close(out) // 닫기는 오직 여기 한 곳
	}()
}

핵심은 "생산 종료 시점"을 wg.Wait() 로 합쳐서 단일 지점에서 close 하는 것입니다.

원인 B: 에러/타임아웃 경로에서 중복 close

정상 경로에서 닫고, 에러 경로에서도 닫는 식으로 분기마다 close 를 넣으면 중복이 쉽게 생깁니다.

처방: defer close(ch) 를 "소유자" 함수 최상단에 하나만

func produce(out chan<- int) {
	defer close(out) // 소유자가 단일 종료 책임
	// ... 중간에 return이 있어도 안전
}

원인 C: close 를 "취소 신호"로 사용

종료 신호를 보내려다 close(dataCh) 같은 식으로 데이터 채널을 닫아버리면, 다른 경로에서 또 닫거나, 아직 send 하는 생산자가 남아있을 수 있습니다.

처방: 데이터 채널과 종료 신호를 분리

  • 데이터는 dataCh
  • 종료는 ctx.Done() 또는 doneCh (단방향 브로드캐스트)

3. panic: send on closed channel 의 핵심 원인

이 panic은 "누군가 채널을 닫았는데, 다른 누군가가 계속 보내고 있다"는 뜻입니다.

가장 흔한 구조는 아래입니다.

  • consumer가 "이제 그만"을 표현하려고 close(ch) 를 호출
  • producer는 그 사실을 모른 채 계속 ch <- v

처방 1: consumer는 닫지 말고 cancel 을 요청

context 를 써서 producer에게 취소를 전파합니다.

package main

import (
	"context"
	"time"
)

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

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

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

	// consumer
	for v := range ch {
		_ = v
		cancel() // "이제 그만"은 cancel로 표현
		break
	}

	time.Sleep(10 * time.Millisecond)
}

처방 2: 닫힘을 감지하기 전에 보내지 않기

"닫힘 감지"는 수신에서만 가능합니다. 송신자는 send 시점에 상대 상태를 알 수 없으므로, 취소 신호(ctx.Done() 등)를 반드시 같이 둬야 합니다.


4. goroutine leak 5분 진단: pprof 로 "안 죽는 고루틴" 찾기

goroutine leak은 대체로 아래 중 하나입니다.

  • 채널 수신 대기: v := <-ch 에서 영원히 블록
  • 채널 송신 대기: ch <- v 에서 영원히 블록
  • select 에서 종료 케이스가 없어 영원히 대기

4.1 즉석 계측: goroutine 수를 로그로 확인

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

func logGoroutines() {
	for range time.Tick(5 * time.Second) {
		log.Printf("goroutines=%d", runtime.NumGoroutine())
	}
}

배포 후 시간이 지나며 계속 증가하면 leak 의심이 강합니다.

4.2 net/http/pprof 로 스택 덤프

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

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

그 다음 아래처럼 확인합니다.

  • 브라우저에서 http://127.0.0.1:6060/debug/pprof/goroutine?debug=2
  • 또는 CLI에서 curl 로 받아서 chan receive 가 많은지 확인

스택에 chan receive 가 반복적으로 보이면, "채널이 닫히지 않거나" "취소가 전달되지 않는다"로 좁혀집니다.


5. leak 을 만드는 대표 패턴과 안전한 교체

패턴 A: for range ch 인데 생산자가 절대 닫지 않음

func consumer(ch <-chan int) {
	for v := range ch {
		_ = v
	}
	// 여기에 절대 도달하지 않음
}

교체: 생산자가 종료 시 close(ch) 를 보장

func producer(out chan<- int) {
	defer close(out)
	// 생산 후 return
}

또는 "영구 스트림"이라면 context 를 받아서 종료를 보장합니다.

패턴 B: selectctx.Done() 가 없다

func worker(ch <-chan int) {
	for {
		select {
		case v := <-ch:
			_ = v
		}
	}
}

채널이 영원히 안 오면 worker는 영원히 살아있습니다.

교체: 취소 케이스 추가

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

여기서 v, ok := <-ch 는 "채널 닫힘"을 정상 종료 조건으로 만들기 위한 표준 패턴입니다.

패턴 C: fan-in 에서 입력 채널 종료를 합치지 못함

여러 입력을 하나로 합치는 fan-in은 leak이 자주 납니다. 입력 중 하나가 끝났는데도 다른 고루틴이 out <- v 에서 막히거나, out을 닫는 타이밍이 꼬입니다.

안전한 fan-in 템플릿

package main

import "sync"

func merge[T any](ins ...<-chan T) <-chan T {
	out := make(chan T)
	var wg sync.WaitGroup
	wg.Add(len(ins))

	for _, ch := range ins {
		ch := ch
		go func() {
			defer wg.Done()
			for v := range ch {
				out <- v
			}
		}()
	}

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

	return out
}

주의: 본문에 제네릭 표기 merge[T any] 는 MDX에서 부등호 오인 위험이 있으니 코드 블록 안에서만 사용해야 합니다. (지금처럼 fenced code block 내부는 안전합니다.)


6. close 를 안전하게 "한 번만" 호출하고 싶을 때

"여러 경로에서 종료가 발생할 수 있지만, close는 한 번만" 같은 요구가 있습니다. 이때 sync.Once 가 실전에서 가장 깔끔합니다.

package main

import "sync"

type Closer struct {
	once sync.Once
	ch   chan struct{}
}

func NewCloser() *Closer {
	return &Closer{ch: make(chan struct{})}
}

func (c *Closer) Done() <-chan struct{} { return c.ch }

func (c *Closer) Close() {
	c.once.Do(func() { close(c.ch) })
}

이 패턴은 "종료 신호 채널"에 특히 좋습니다. 데이터 채널에 남용하면 설계가 흐려지니, 종료 신호와 데이터는 분리하는 쪽을 권합니다.


7. 재현 기반으로 고치기: 최소 실패 예제 만들기

panic과 leak은 "재현"이 되면 80%는 끝납니다. 다음 순서로 최소 예제를 만드세요.

  1. 문제 의심 지점에서 go test -run TestName -count=100 처럼 반복 실행
  2. 타이밍 이슈면 작은 time.Sleep 을 넣어 경쟁을 증폭
  3. race가 의심되면 go test -race

예를 들어, 중복 close는 아래처럼 쉽게 흔들어 재현할 수 있습니다.

func TestCloseTwice(t *testing.T) {
	ch := make(chan int)
	go func() { close(ch) }()
	go func() { close(ch) }()
	// 타이밍에 따라 close of closed channel
	// 실제 서비스에서는 훨씬 복잡한 경로에서 이런 일이 발생
}

8. 운영 관점 팁: "증상"을 지표로 만들기

panic은 바로 티가 나지만, leak은 조용히 비용을 올립니다. 다음 지표를 권합니다.

  • runtime.NumGoroutine()
  • 프로세스 RSS 메모리
  • 요청 처리량 대비 goroutine 증가율

이건 systemd 무한 재시작처럼 "증상은 보이는데 원인은 다른 곳"일 때 특히 도움이 됩니다. 비슷한 진단 접근은 systemd 서비스 자동 재시작 무한루프 진단 가이드 도 참고할 만합니다.


9. 결론: 5분 안에 이렇게 정리하면 된다

  • close of closed channel 이면 "누가 닫는가" 소유권부터 정리
    • close 는 단 한 곳에서만
    • fan-out, fan-in 은 WaitGroup 뒤에서 한 번 닫기
  • send on closed channel 이면 "수신자가 닫는" 구조를 의심
    • consumer는 close 하지 말고 cancel 로 종료 요청
  • goroutine leak 이면 pprofchan receive 또는 chan send 스택을 보고
    • ctx.Done() 케이스 추가
    • 생산자가 종료 시 채널을 닫아 range 루프가 끝나게 만들기

이 규칙만 지켜도 채널 관련 장애의 상당수를 예방할 수 있고, 이미 발생한 장애도 빠르게 원인 분류가 가능합니다.