Published on

Go 채널 select 데드락 5분 디버깅 가이드

Authors
Binance registration banner

서버나 워커를 Go로 짜다 보면 select를 넣었으니 “이제 블로킹은 없겠지”라고 생각하기 쉽습니다. 그런데 운영에서 갑자기 처리량이 0이 되고, CPU도 안 쓰고, 로그도 더 이상 안 찍히는 상태가 옵니다. 고루틴 덤프를 떠보면 여러 고루틴이 select 안에서 멈춰 있습니다.

이 글은 select 기반 코드에서 발생하는 데드락을 5분 안에 분류하고 고치는 흐름으로 정리합니다. 더 넓은 패턴(고루틴 leak 포함)은 아래 글도 함께 참고하면 좋습니다.

0분: 증상 정의부터 (진짜 데드락인가?)

Go에서 흔히 “데드락”이라고 부르는 증상은 크게 3가지로 나뉩니다.

  1. 런타임 패닉 데드락: 모든 고루틴이 블로킹이면 fatal error: all goroutines are asleep - deadlock!
  2. 논리적 데드락: 일부 고루틴은 살아있지만, 중요한 경로가 영원히 기다려서 서비스가 멈춘 것처럼 보임
  3. 무한 대기/기아(starvation): select가 특정 케이스만 계속 타서 다른 작업이 진행되지 않음

이번 글의 핵심은 2번과 3번을 빠르게 분류하는 것입니다.

1분: 고루틴 덤프를 가장 빨리 확보하는 방법

운영/스테이징에서 멈췄을 때 가장 먼저 필요한 건 “지금 어디서 기다리는지”입니다.

방법 A: pprof HTTP 엔드포인트

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

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

덤프 확인:

  • curl http://localhost:6060/debug/pprof/goroutine?debug=2

방법 B: SIGQUIT로 즉시 스택 덤프

리눅스에서 프로세스에 SIGQUIT를 보내면 Go 런타임이 고루틴 스택을 표준에러로 출력합니다.

  • kill -QUIT pid``

여기서 pid처럼 꺾쇠를 쓰는 표기는 MDX에서 빌드 에러가 날 수 있으니 문서/런북에는 항상 백틱 처리하는 습관이 안전합니다.

2분: 덤프에서 select 데드락을 분류하는 5가지 체크포인트

덤프를 보면 대개 이런 형태가 반복됩니다.

  • select에서 chan receive
  • select에서 chan send
  • semacquire(뮤텍스/WaitGroup)
  • IO wait(네트워크/파일)

select가 보인다면 아래 5가지를 우선순위로 봅니다.

체크 1) nil 채널이 섞였는가

Go에서 nil 채널에 대한 송수신은 영원히 블로킹합니다. selectnil 채널 케이스가 들어가면, 그 케이스는 “절대 선택되지 않는” 상태가 됩니다. 의도적으로 케이스를 비활성화할 때 쓰기도 하지만, 실수로 nil이 들어오면 논리적 데드락의 단골 원인입니다.

문제 코드 예:

var stopCh chan struct{} // nil

func worker(jobs <-chan int) {
  for {
    select {
    case j := <-jobs:
      _ = j
    case <-stopCh: // 영원히 대기
      return
    }
  }
}

수정 방향:

  • stopCh는 반드시 make로 초기화하거나
  • context.Context로 종료를 표준화하거나
  • 케이스를 껐다 켤 거면 nil이 될 수 있음을 명시적으로 관리

예:

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

체크 2) 채널 close 규약이 깨졌는가 (특히 fan-in/fan-out)

select 기반 파이프라인에서 자주 생기는 문제는 “누가 채널을 닫는가”가 불명확해져서, 소비자는 영원히 기다리고 생산자는 종료해버리는 상황입니다.

안티패턴:

  • 여러 producer가 같은 채널을 닫으려 함(패닉 위험)
  • 아무도 채널을 닫지 않음(consumer가 종료 조건을 못 맞춤)

안전한 규약:

  • 채널을 만드는 쪽이 닫는다
  • producer가 여러 개면, 별도의 WaitGroup으로 producer 종료를 모아 단 한 곳에서 close

예:

func fanIn(ctx context.Context, ins ...<-chan int) <-chan int {
  out := make(chan int)
  var wg sync.WaitGroup

  forward := func(ch <-chan int) {
    defer wg.Done()
    for v := range ch {
      select {
      case <-ctx.Done():
        return
      case out <- v:
      }
    }
  }

  wg.Add(len(ins))
  for _, ch := range ins {
    go forward(ch)
  }

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

  return out
}

체크 3) default가 있어서 “진행”하는데 실제로는 멈췄는가

selectdefault가 있으면 블로킹이 사라져서 “데드락이 아닌 것처럼” 보입니다. 하지만 이 경우는 바쁜 루프(busy loop) 또는 기아로 이어져 시스템이 멈춘 것처럼 보일 수 있습니다.

문제 코드 예:

for {
  select {
  case msg := <-ch:
    handle(msg)
  default:
    // 아무 것도 없으면 그냥 계속 돈다
  }
}

이 코드는 메시지가 없을 때 CPU를 태우며, 다른 고루틴이 스케줄링을 덜 받아 처리 지연이 커질 수 있습니다.

수정 방향:

  • 정말 폴링이 필요하면 time.Ticker로 간격을 둔다
  • 아니면 default를 제거하고 블로킹을 허용한다
ticker := time.NewTicker(50 * time.Millisecond)
defer ticker.Stop()

for {
  select {
  case msg := <-ch:
    handle(msg)
  case <-ticker.C:
    // 주기 작업
  case <-ctx.Done():
    return
  }
}

체크 4) unbuffered 채널에서 송신자가 수신자를 영원히 못 만나는가

unbuffered 채널은 송신과 수신이 “동시에 만나야” 진행됩니다. select로 감싸도 본질은 바뀌지 않습니다.

대표 케이스:

  • 수신 고루틴이 종료됐는데 송신이 계속됨
  • 송신이 특정 조건에서만 발생하는데 수신은 그 조건을 기다림(서로 조건 의존)

빠른 진단:

  • 덤프에서 chan send가 많이 쌓였는지
  • 해당 채널의 consumer 고루틴이 살아있는지

해결 방향:

  • 이벤트 성격이면 버퍼를 주고 드롭/백프레셔 정책을 명확히
  • 종료 시에는 송신 루프도 ctx.Done()을 보게 만들기

예(백프레셔 + 종료):

func publish(ctx context.Context, out chan<- Event, ev Event) error {
  select {
  case <-ctx.Done():
    return ctx.Err()
  case out <- ev:
    return nil
  }
}

체크 5) 타임아웃이 “타임아웃처럼” 동작하지 않는가

time.After를 루프에서 매번 만들면 타이머 객체가 계속 생성됩니다. GC가 처리하긴 하지만, 고부하에서 지연/메모리 압박으로 이어져 “멈춘 것처럼” 보일 수 있습니다. 또한 타임아웃을 걸었는데도 종료 경로가 다른 락/채널에서 막히면 체감상 데드락이 됩니다.

권장:

  • 반복 루프에서는 time.NewTimer 재사용
  • 타임아웃은 “관측/로그”가 아니라 “중단/정리”까지 이어져야 의미가 있음

예:

t := time.NewTimer(0)
if !t.Stop() {
  <-t.C
}

defer t.Stop()

for {
  t.Reset(2 * time.Second)

  select {
  case <-ctx.Done():
    return
  case v := <-ch:
    _ = v
    if !t.Stop() {
      select {
      case <-t.C:
      default:
      }
    }
  case <-t.C:
    // 타임아웃 시: 로그만 찍지 말고 상태 전환/취소를 수행
    // 예: cancel(), 재연결, 워커 재시작 등
  }
}

3분: 재현 가능한 최소 코드로 축소하는 요령

5분 디버깅의 핵심은 “운영 증상”을 “로컬에서 재현되는 최소 사례”로 줄이는 겁니다.

  • 문제 채널 1개만 남기기
  • producer/consumer 고루틴 수를 1로 줄이기
  • 종료 시나리오(취소, close, 에러)를 강제로 발생시키기

예를 들어 “종료 시 멈춘다”면 아래처럼 테스트에 종료를 강제합니다.

func TestShutdownDeadlock(t *testing.T) {
  ctx, cancel := context.WithCancel(context.Background())
  defer cancel()

  jobs := make(chan int)

  done := make(chan struct{})
  go func() {
    defer close(done)
    worker(ctx, jobs)
  }()

  cancel()      // 종료를 먼저 걸고
  close(jobs)   // 채널도 닫아보고

  select {
  case <-done:
  case <-time.After(1 * time.Second):
    t.Fatal("shutdown stuck")
  }
}

이런 식으로 “종료 순서”를 바꿔가며 막히는 조합을 찾으면, 원인이 select 자체가 아니라 종료 규약에 있음을 빠르게 확인할 수 있습니다.

4분: 덤프에서 자주 보이는 패턴별 처방전

패턴 A) for range ch가 끝나지 않는다

  • 원인: 채널이 닫히지 않음
  • 처방: 채널 close 책임자를 단일화, 또는 ctx.Done() 케이스 추가
for {
  select {
  case <-ctx.Done():
    return
  case v, ok := <-ch:
    if !ok {
      return
    }
    _ = v
  }
}

패턴 B) select에 종료 케이스는 있는데 실제로는 못 탄다

  • 원인: 종료 채널이 nil이거나, cancel이 호출되지 않거나, cancel은 됐는데 다른 곳에서 이미 막힘
  • 처방: 종료 신호를 context로 통일하고, 모든 블로킹 지점이 ctx.Done()을 보게 만들기

패턴 C) select로 여러 채널을 받는데 한쪽이 영원히 굶는다

  • 원인: 특정 채널이 계속 ready라서 다른 케이스가 밀림(특히 버퍼 채널)
  • 처방: 우선순위가 필요하면 2단 select로 설계하거나, 공정성에 의존하지 않기

우선순위 예:

select {
case hi := <-highPrio:
  handleHi(hi)
  continue
default:
}

select {
case hi := <-highPrio:
  handleHi(hi)
case lo := <-lowPrio:
  handleLo(lo)
case <-ctx.Done():
  return
}

5분: 운영에서 다시 안 터지게 만드는 가드레일

1) 고루틴 수, 채널 길이, 처리율을 메트릭으로 고정

  • runtime.NumGoroutine()를 주기적으로 기록
  • 버퍼 채널이면 len(ch)를 관측(단, 경쟁 상태라 절대값은 참고용)
  • 처리율이 0으로 떨어질 때 알람

2) “종료는 컨트롤 플레인”으로 표준화

  • 종료는 context.Context 하나로 통일
  • 채널 close는 데이터 플레인 규약으로만 사용
  • 워커는 ctx.Done()을 모든 블로킹 경로에서 확인

3) 덤프/프로파일링 엔드포인트를 기본 포함

Kubernetes 환경이라면 장애 시 진단이 네트워크 이슈로도 섞여 보일 수 있습니다. 애플리케이션이 멈춘 것인지, 외부 의존성 타임아웃인지 같이 봐야 합니다. 네트워크 기반 “멈춤” 진단 흐름은 아래 글도 참고가 됩니다.

실전 체크리스트(요약)

  • 덤프 확보: pprof 또는 kill -QUIT pid``
  • select 데드락 1순위: nil 채널, close 규약, unbuffered 송수신 불일치
  • default는 데드락을 숨기고 기아/바쁜 루프를 만든다
  • 타임아웃은 타이머 재사용 + 타임아웃 후 상태 전환까지 포함
  • 종료는 context로 통일하고, 모든 블로킹 지점에 ctx.Done()을 연결

select는 만능이 아니라 “여러 블로킹 이벤트 중 하나를 고르는 도구”입니다. 데드락의 본질은 대개 select 바깥에 있는 종료·소유권·백프레셔 규약이 무너졌을 때 생깁니다. 위 5분 루틴대로 덤프를 보고 원인을 분류하면, 대부분은 코드 몇 줄로 재발을 막을 수 있습니다.