- Published on
Go 고루틴 - 채널 close 패닉·누수 7가지 패턴
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버나 배치에서 Go 고루틴을 쓰다 보면, 장애의 형태는 대개 두 가지로 수렴합니다. 하나는 panic: close of closed channel 또는 panic: send on closed channel 같은 즉발성 패닉이고, 다른 하나는 트래픽이 줄어도 RSS/고루틴 수가 내려오지 않는 “조용한 누수”입니다. 둘 다 원인은 비슷합니다. 채널의 소유권(누가 닫는가), 종료 신호의 전파(누가 누구를 멈추게 하는가), 백프레셔(버퍼/수신자 부재) 를 코드에 명시하지 않았기 때문입니다.
이 글에서는 현업에서 반복적으로 마주치는 7가지 패턴을 “나쁜 예”와 “고치는 방법”으로 정리합니다. (관점은 Go context 기반의 취소 전파와, 채널을 스트림으로 다루는 관용구에 가깝습니다.)
관련해서 장애를 “증상 기반으로 빠르게 쪼개서” 진단하는 접근은 다른 글에서도 유사하게 다룬 적이 있습니다. 예를 들어 K8s CrashLoopBackOff - OOMKilled·Probe 실패 진단 처럼, 원인을 유형화하면 재현이 어려운 문제도 해결 속도가 빨라집니다.
기본 원칙 3가지 (패턴 들어가기 전)
1) close 는 “송신자”만 한다
채널을 닫는 행위는 “더 이상 값이 오지 않는다”는 선언입니다. 이 선언을 할 수 있는 건 값을 보내는 쪽(생산자) 뿐입니다. 수신자가 닫으면, 아직 살아 있는 송신자가 send 하다가 패닉이 납니다.
2) close 는 “브로드캐스트 신호”로 쓸 수 있지만, 데이터 채널과 분리하라
종료 신호를 데이터 채널에 섞으면 소유권이 흐려지고, close 타이밍이 꼬입니다. 종료 전파는 보통 context 또는 done 채널로 분리합니다.
3) 고루틴은 “반드시” 빠져나갈 길이 있어야 한다
for range ch 는 채널이 닫히지 않으면 영원히 끝나지 않습니다. select 에 ctx.Done() 또는 별도의 종료 케이스를 넣어 “탈출구”를 만들어야 누수가 안 납니다.
패턴 1) 수신자가 채널을 close 해서 생기는 패닉
나쁜 예
수신자가 임의로 닫아버리면, 송신자는 다음 send 에서 panic 이 납니다.
package main
import (
"fmt"
"time"
)
func main() {
ch := make(chan int)
go func() {
for i := 0; i < 3; i++ {
ch <- i
time.Sleep(10 * time.Millisecond)
}
}()
fmt.Println(<-ch)
close(ch) // 수신자가 닫음: 송신자가 살아 있으면 위험
fmt.Println("closed")
}
해결
채널을 닫는 책임을 생산자에 둡니다. 생산자가 단일 고루틴이면 단순합니다.
go func() {
defer close(ch)
for i := 0; i < 3; i++ {
ch <- i
}
}()
for v := range ch {
fmt.Println(v)
}
생산자가 여러 개면 패턴 3에서 다룹니다.
패턴 2) 여러 곳에서 close 를 호출해 close of closed channel
나쁜 예
에러 처리, 타임아웃 처리, 정상 종료 처리 등 여러 경로에서 close 를 호출하면 “중복 close” 가 발생합니다.
func stop(ch chan struct{}) {
close(ch)
}
func main() {
done := make(chan struct{})
go stop(done)
go stop(done) // 둘 중 하나가 먼저 닫고, 다른 하나는 패닉
select {
case <-done:
}
}
해결 1: sync.Once 로 close 단일화
type Closer struct {
once sync.Once
ch chan struct{}
}
func NewCloser() *Closer {
return &Closer{ch: make(chan struct{})}
}
func (c *Closer) Close() {
c.once.Do(func() { close(c.ch) })
}
func (c *Closer) Done() <-chan struct{} { return c.ch }
해결 2: 더 단순하게는 context 사용
취소 전파 자체가 목적이면 close 를 직접 관리하기보다 context.WithCancel 을 쓰는 편이 안전합니다.
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
select {
case <-ctx.Done():
}
패턴 3) 다중 생산자 채널에서 “누가 닫을지” 합의가 없어 누수 또는 패닉
나쁜 예
생산자가 여러 명인데 한 생산자가 close 하면, 나머지 생산자는 send on closed channel 로 터집니다.
for i := 0; i < 3; i++ {
go func(id int) {
ch <- id
if id == 0 {
close(ch) // 한 생산자가 멋대로 닫음
}
}(i)
}
해결: WaitGroup 으로 “모든 생산자 종료 후” 단일 close
var wg sync.WaitGroup
ch := make(chan int)
for i := 0; i < 3; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
ch <- id
}(i)
}
go func() {
wg.Wait()
close(ch) // 닫는 주체는 별도 고루틴 1개
}()
for v := range ch {
fmt.Println(v)
}
이 패턴은 “생산자 집합을 모아서 하나의 스트림으로 만든다”는 점에서 가장 자주 쓰입니다.
패턴 4) for range ch 가 끝나지 않아 고루틴이 영원히 대기 (누수)
나쁜 예
소비자는 range 로 읽는데, 생산자는 에러로 조용히 종료하거나 close 를 빼먹습니다. 그러면 소비자는 영원히 블록됩니다.
func consumer(ch <-chan int) {
for v := range ch { // ch가 닫히지 않으면 끝나지 않음
_ = v
}
}
해결: context 를 탈출구로 추가
func consumer(ctx context.Context, ch <-chan int) {
for {
select {
case <-ctx.Done():
return
case v, ok := <-ch:
if !ok {
return
}
_ = v
}
}
}
ok 체크는 “정상적인 close” 를 처리하고, ctx.Done() 은 “비정상/외부 취소” 를 처리합니다.
패턴 5) 버퍼 없는 채널에서 수신자가 사라져 송신자가 블록 (누수)
나쁜 예
요청 처리 중 클라이언트가 끊기거나, 상위 로직이 조기 반환하면 수신자가 사라집니다. 그 순간부터 송신 고루틴은 ch <- x 에서 영원히 블록될 수 있습니다.
func worker(ch chan<- int) {
ch <- 1 // 수신자가 없으면 여기서 멈춤
}
func main() {
ch := make(chan int)
go worker(ch)
return // 수신 없이 종료: worker는 블록된 채로 남을 수 있음
}
해결 1: 송신도 select 로 취소 가능하게
func worker(ctx context.Context, ch chan<- int) {
select {
case <-ctx.Done():
return
case ch <- 1:
return
}
}
해결 2: 버퍼는 “완화”일 뿐, 설계가 우선
버퍼를 키우면 일시적으로 덜 막히지만, 소비가 멈추면 결국 가득 차고 동일한 문제가 재현됩니다. 버퍼는 백프레셔를 늦출 뿐 제거하지 않습니다.
패턴 6) time.After 를 루프에서 남발해 타이머 누수·GC 압박
time.After(d) 는 내부적으로 타이머를 생성합니다. 루프에서 계속 만들면 타이머 객체가 빠르게 쌓이고, 특히 취소가 잦은 코드에서 GC 부담이 커집니다. “고루틴 누수”와 함께 자주 보이는 성능 퇴행 포인트입니다.
나쁜 예
for {
select {
case <-time.After(200 * time.Millisecond):
// do something
case <-ctx.Done():
return
}
}
해결: time.NewTimer 재사용
t := time.NewTimer(200 * time.Millisecond)
defer t.Stop()
for {
// 타이머 재설정 전에 drain 필요할 수 있음
if !t.Stop() {
select {
case <-t.C:
default:
}
}
t.Reset(200 * time.Millisecond)
select {
case <-t.C:
// do something
case <-ctx.Done():
return
}
}
이 주제는 “작은 오용이 누적되어 렌더링/처리량이 폭증” 하는 유형과 결이 비슷합니다. 프레임워크는 다르지만, 증상-원인 매칭 방식은 Next.js App Router 렌더링 폭증 진단 - RSC 캐시·useMemo 오용 같은 글에서 다룬 접근과 통합니다.
패턴 7) “에러 채널”을 닫아버리거나, 수신을 안 해서 데드락
에러를 채널로 모으는 패턴은 흔하지만, 설계가 애매하면 쉽게 막힙니다.
나쁜 예 A: 에러 채널을 여러 고루틴이 닫음
errCh := make(chan error)
for i := 0; i < 2; i++ {
go func() {
defer close(errCh) // 여러 번 close 위험
errCh <- fmt.Errorf("fail")
}()
}
나쁜 예 B: 에러를 보내는데 아무도 안 읽음
버퍼가 없거나 작으면, 에러 전송 자체가 블로킹 포인트가 됩니다.
errCh := make(chan error) // unbuffered
go func() {
errCh <- fmt.Errorf("fail") // 수신자 없으면 블록
}()
해결: 에러는 “단일 수집자”가 읽고, 생산자는 닫지 않는다
- 생산자 고루틴은
errCh를 닫지 않습니다. - 수집자는
WaitGroup이후close(errCh)를 단일하게 수행합니다. - 에러는 보통 “첫 에러만 필요”한 경우가 많으므로,
errCh를1버퍼로 두고select로 드롭 정책을 명시하는 것도 실무적으로 유용합니다.
errCh := make(chan error, 1)
var wg sync.WaitGroup
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
worker := func() {
defer wg.Done()
if err := doWork(ctx); err != nil {
select {
case errCh <- err:
cancel() // 첫 에러가 오면 전체 취소
default:
// 이미 에러가 들어갔으면 드롭
}
}
}
wg.Add(3)
for i := 0; i < 3; i++ {
go worker()
}
go func() {
wg.Wait()
close(errCh)
}()
if err, ok := <-errCh; ok {
// 첫 에러 처리
_ = err
}
여기서 중요한 건 “드롭을 하든, 모두 모으든” 정책을 코드로 박아 넣는 것입니다. 정책이 없으면 언젠가 블로킹이 됩니다.
안전한 종료를 위한 체크리스트
- 채널마다 “소유자(닫는 주체)”를 한 문장으로 설명할 수 있는가
- 다중 생산자면
WaitGroup으로 close 를 단일화했는가 - 모든 고루틴 루프에
ctx.Done()또는 종료 조건이 있는가 - 송신도 블로킹 포인트이므로
select로 취소 가능하게 했는가 time.After를 루프에서 남발하지 않는가- 에러 채널은 생산자가 닫지 않고, 수집자가 닫는가
- 테스트에서
go test -race와 함께 고루틴 수(runtime.NumGoroutine) 변화를 관찰해봤는가
마무리: close 를 “자원 해제”가 아니라 “프로토콜”로 보자
Go에서 채널 close 는 메모리를 해제하는 API가 아니라, 스트림 프로토콜의 일부입니다. “이 스트림은 끝났다”는 신호를 누가, 언제, 어떤 조건에서 보내는지 합의하지 않으면 패닉 또는 누수로 돌아옵니다.
실무에서는 context 로 취소를 통일하고, 데이터 채널은 생산자가 책임지고 닫으며, 다중 생산자는 WaitGroup 으로 수렴시키는 3원칙만 지켜도 사고율이 크게 줄어듭니다.