Published on

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

Authors

서버나 워커를 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분 루틴대로 덤프를 보고 원인을 분류하면, 대부분은 코드 몇 줄로 재발을 막을 수 있습니다.