- Published on
Go 채널 닫기 panic·goroutine leak 5분 진단
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버가 가끔 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: select 에 ctx.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%는 끝납니다. 다음 순서로 최소 예제를 만드세요.
- 문제 의심 지점에서
go test -run TestName -count=100처럼 반복 실행 - 타이밍 이슈면 작은
time.Sleep을 넣어 경쟁을 증폭 - 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로 종료 요청
- consumer는
- goroutine leak 이면
pprof로chan receive또는chan send스택을 보고ctx.Done()케이스 추가- 생산자가 종료 시 채널을 닫아
range루프가 끝나게 만들기
이 규칙만 지켜도 채널 관련 장애의 상당수를 예방할 수 있고, 이미 발생한 장애도 빠르게 원인 분류가 가능합니다.