- Published on
Go 채널 데드락·goroutine leak 7가지 패턴
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
동시성 버그는 재현이 어렵고, 장애로 나타날 땐 보통 CPU 0%인데 응답 없음, 메모리 서서히 증가, 요청이 특정 비율로만 타임아웃 같은 형태로 보입니다. 특히 Go의 채널은 강력한 동기화 도구지만, 잘못 쓰면 데드락과 goroutine leak을 동시에 만들기 쉽습니다.
이 글은 채널 데드락과 goroutine leak을 유발하는 대표 패턴 7가지를 재현 코드로 보여주고, 안전한 수정 패턴까지 함께 제공합니다. goroutine 누수 자체를 실전에서 추적하는 방법은 Go goroutine 누수 잡기 - pprof+context 실전도 같이 참고하면 좋습니다.
빠른 체크리스트
아래 중 하나라도 해당되면 채널/고루틴 이슈를 의심하세요.
select에default를 넣었더니 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을 먼저 띄우거나
- 버퍼 채널로 바꾸되, 버퍼가 꽉 차는 경우도 고려하거나
- 취소 가능하게
select에context.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) select의 default로 바쁜 루프 생성: 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 증가로 나타납니다.
실전에서는 다음 순서가 효율적입니다.
runtime.NumGoroutine()를 주기적으로 로그/메트릭으로 수집pprof로 goroutine 덤프를 떠서chan send,chan receive,select에서 오래 멈춘 스택을 찾기- 취소 전파가 없는 경로를
context로 연결
누수 추적의 구체적인 절차와 pprof 해석은 Go goroutine 누수 잡기 - pprof+context 실전에 더 자세히 정리해 두었습니다.
마무리: 채널은 “소유권”과 “종료 신호”가 전부다
채널 기반 동시성에서 대부분의 사고는 아래 두 가지가 불명확해서 발생합니다.
- 누가 채널을 닫는가(소유권)
- 언제 종료해야 하는가(취소/타임아웃/에러 전파)
위 7가지 패턴을 팀 코드리뷰 체크리스트로 만들면, 데드락과 goroutine leak을 장애 전에 상당수 차단할 수 있습니다. 운영 중인 서비스가 응답 없음 상태로 보인다면, 애플리케이션 내부 문제뿐 아니라 컨테이너/노드 레벨의 재시작 루프도 함께 의심해야 하니 systemd 서비스가 계속 재시작될 때 원인 추적법 같은 관점도 같이 가져가면 진단 속도가 빨라집니다.