Published on

Go 채널 데드락 재현·해결 - select 타임아웃

Authors

서버나 워커를 Go로 짜다 보면 “가끔 멈춘다”는 제보가 가장 곤란합니다. CPU도 안 쓰고, 로그도 안 찍히고, 프로세스는 살아 있는데 요청이 끝나지 않는 상태. 상당수는 채널 송수신이 서로를 기다리며 영원히 블로킹되는 데드락에서 시작합니다.

Go 런타임은 모든 고루틴이 잠들면 fatal error: all goroutines are asleep - deadlock! 같은 메시지로 죽이기도 하지만, 실무에서 더 흔한 형태는 일부 고루틴만 멈춰서 전체 기능이 정지되는 “부분 데드락”입니다. 이 글에서는 데드락을 의도적으로 재현한 뒤, select 타임아웃으로 고립된 대기 상태를 끊고 복구 가능한 실패로 바꾸는 패턴을 다룹니다.

또한 운영 환경에서 데드락이 종종 “리소스 고갈”과 함께 나타난다는 점에서, 파일 디스크립터 고갈 이슈도 함께 점검하는 습관이 도움이 됩니다. 관련해서는 Linux EMFILE(Too many open files) 원인과 해결도 함께 참고해두면 좋습니다.

채널 데드락이 생기는 전형적인 조건

채널 송수신은 기본적으로 동기화 지점입니다.

  • unbuffered 채널: 송신은 수신자가 준비될 때까지 블로킹
  • buffered 채널: 버퍼가 가득 차면 송신이 블로킹, 버퍼가 비면 수신이 블로킹

데드락은 보통 다음 중 하나로 유발됩니다.

  1. 송신/수신이 서로를 기다리는데, 그 반대편이 더 이상 진행되지 않음(고루틴 누수, 조기 리턴, 에러 처리 누락)
  2. range ch로 읽는데 채널을 닫지 않음(종료 신호 부재)
  3. 단일 고루틴에서 같은 채널에 대해 “받고 나서 보내야 하는데, 보내고 나서 받아야 하는” 식의 순환 대기
  4. fan-in/fan-out에서 일부 워커가 멈춰서 결과 채널이 영원히 채워지지 않음

이제 가장 작은 재현 코드를 보겠습니다.

데드락 재현 1: unbuffered 채널에서 수신자가 사라진 경우

아래 코드는 송신자가 ch로 값을 보내려 하지만, 수신 고루틴이 조기 종료되어 아무도 받지 않습니다. 이 경우 송신은 영원히 멈춥니다.

package main

import (
	"fmt"
	"time"
)

func main() {
	ch := make(chan int) // unbuffered

	go func() {
		// 어떤 조건에서 조기 리턴했다고 가정
		time.Sleep(50 * time.Millisecond)
		return
	}()

	fmt.Println("sending...")
	ch <- 1 // 여기서 영원히 블로킹될 수 있음
	fmt.Println("sent")
}

이 코드는 환경에 따라 런타임 데드락으로 종료되기도 하고, 다른 고루틴이 살아 있으면 “그냥 멈춘 것처럼” 보이기도 합니다. 핵심은 송신이 완료될 조건이 사라졌다는 점입니다.

해결 1: select 타임아웃으로 블로킹을 실패로 전환

송신을 “무한 대기”가 아니라 “기한 내 실패”로 바꾸면, 시스템은 멈추는 대신 에러를 반환하고 상위에서 복구할 수 있습니다.

package main

import (
	"errors"
	"fmt"
	"time"
)

func sendWithTimeout(ch chan<- int, v int, d time.Duration) error {
	select {
	case ch <- v:
		return nil
	case <-time.After(d):
		return errors.New("send timeout")
	}
}

func main() {
	ch := make(chan int)

	err := sendWithTimeout(ch, 1, 100*time.Millisecond)
	fmt.Println("result:", err)
}

이 패턴의 장점은 명확합니다.

  • 데드락이 “프로세스 정지”가 아니라 “타임아웃 에러”로 관측됨
  • 호출자가 재시도/서킷브레이커/폴백을 적용할 수 있음

하지만 time.After를 남발하면 타이머 객체가 많이 생길 수 있습니다. 고빈도 경로에서는 time.NewTimer를 재사용하거나, 상위에서 context로 통일하는 편이 좋습니다.

데드락 재현 2: range로 읽는데 채널을 닫지 않는 경우

아래 코드는 수신자가 range ch로 종료 신호(채널 close)를 기다리는데, 송신 측이 채널을 닫지 않아 영원히 대기합니다.

package main

import (
	"fmt"
	"time"
)

func main() {
	ch := make(chan int, 1)

	go func() {
		for v := range ch {
			fmt.Println("recv:", v)
		}
		fmt.Println("receiver done")
	}()

	ch <- 1
	// close(ch) 를 호출하지 않으면 receiver는 끝나지 않음

	time.Sleep(200 * time.Millisecond)
	fmt.Println("main done")
}

이건 런타임이 즉시 알려주지 않는 “조용한 정지”로 이어지기 쉽습니다. 특히 워커 풀에서 결과 수집 고루틴이 이런 식으로 멈추면, 상위 로직이 결과를 기다리며 같이 멈춥니다.

해결 2: 종료 신호를 명시하고, 수신에도 타임아웃을 둔다

채널을 닫는 것이 가장 정석입니다. 동시에, 수신 측도 영원히 기다리지 않도록 타임아웃을 둘 수 있습니다.

package main

import (
	"fmt"
	"time"
)

func recvWithTimeout(ch <-chan int, d time.Duration) (int, bool) {
	select {
	case v, ok := <-ch:
		return v, ok
	case <-time.After(d):
		return 0, false
	}
}

func main() {
	ch := make(chan int, 1)
	ch <- 1
	close(ch)

	if v, ok := recvWithTimeout(ch, 100*time.Millisecond); ok {
		fmt.Println("got:", v)
	}

	// 채널이 닫힌 뒤에는 ok=false로 빠르게 빠져나올 수 있음
	if _, ok := recvWithTimeout(ch, 100*time.Millisecond); !ok {
		fmt.Println("closed or timeout")
	}
}

여기서 ok의 의미가 중요합니다.

  • ok=true: 값 수신 성공
  • ok=false: 채널이 닫혔거나(즉시), 타임아웃이 발생했거나(위 예제에서는 구분 불가)

타임아웃과 close를 구분하고 싶다면 반환 타입을 enum 형태로 분리하거나, 에러를 함께 반환하세요.

실무형 패턴: context + select로 타임아웃/취소 통일

서비스 코드에서는 time.After보다 context.WithTimeout이 더 잘 맞습니다.

  • 요청 단위로 타임아웃 정책을 통일
  • 상위 취소가 하위 고루틴까지 전파
  • 로깅/트레이싱에서 데드라인을 함께 남길 수 있음
package main

import (
	"context"
	"errors"
	"fmt"
	"time"
)

func sendCtx(ctx context.Context, ch chan<- int, v int) error {
	select {
	case ch <- v:
		return nil
	case <-ctx.Done():
		return ctx.Err()
	}
}

func main() {
	ch := make(chan int)
	ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
	defer cancel()

	err := sendCtx(ctx, ch, 1)
	if err != nil {
		if errors.Is(err, context.DeadlineExceeded) {
			fmt.Println("deadline exceeded")
		}
		fmt.Println("send failed:", err)
	}
}

이 방식은 “고루틴이 어디선가 멈춘다”를 “요청이 데드라인을 넘겼다”로 바꿉니다. 즉, 장애가 나더라도 관측 가능하고 제어 가능한 실패가 됩니다.

select 타임아웃을 쓸 때 흔한 함정 4가지

1) 타임아웃이 해결이 아니라 증상 숨김이 되는 경우

타임아웃은 데드락을 “영원한 정지”에서 “에러”로 바꾸지만, 원인을 제거하진 않습니다. 다음을 반드시 함께 점검하세요.

  • 상대 고루틴이 정상적으로 실행되는지
  • 채널 close가 보장되는지(defer close(ch)가 항상 실행되는지)
  • 에러 경로에서 조기 리턴하며 수신자를 고립시키지 않는지

2) 버퍼 크기로 데드락을 ‘우연히’ 가리는 문제

버퍼를 키우면 당장의 블로킹은 줄지만, 소비가 멈추면 결국 다시 막힙니다. 버퍼는 “완충”일 뿐 “진행 보장”이 아닙니다.

3) 타임아웃 후 고루틴 누수

타임아웃으로 빠져나왔는데, 반대편 고루틴이 여전히 채널에 보내려 하거나 받으려 하면 고루틴이 남습니다. 이때는 아래 중 하나가 필요합니다.

  • ctx.Done()을 모든 워커 루프에서 감시
  • 종료 채널(done)을 별도로 두고 브로드캐스트
  • 송신 측이 selectdone을 함께 보게 만들기

예시:

func worker(ctx context.Context, out chan<- int) {
	defer close(out)
	for i := 0; i < 10; i++ {
		select {
		case out <- i:
		case <-ctx.Done():
			return
		}
	}
}

4) 타임아웃 값을 “대충” 잡는 문제

너무 짧으면 정상 요청도 실패시키고, 너무 길면 장애 감지가 늦습니다. 다음 기준으로 정하세요.

  • 외부 호출이 있다면 해당 SLA와 리트라이 정책에 맞춤
  • 내부 채널이라면 “큐 적체”를 감지할 수 있을 정도로 짧게
  • 타임아웃 발생 시 로그에 큐 길이(버퍼), 고루틴 수, 처리량을 같이 남김

운영에서 간헐 장애를 추적할 때는 “타임아웃 로그”가 강력한 단서가 됩니다. 비슷한 맥락으로 간헐 오류를 추적하는 관점은 Spring Boot 3 간헐적 500? Netty 메모리릭 추적 글의 접근법도 참고할 만합니다.

디버깅 팁: 데드락/정지 상태를 빨리 잡는 방법

  1. 스택 트레이스 덤프: SIGQUIT로 고루틴 스택을 떠서 “누가 어디서 블로킹인지” 확인
  2. pprof goroutine 프로파일: 고루틴이 특정 채널 receive/send에서 대기 중인지 확인
  3. 채널 소유권 명확화: “누가 close 하는가”를 코드 규칙으로 고정
  4. fan-in 구조에서 WaitGroup과 close 결합: 생산자 종료 후 결과 채널 close 보장

fan-in 예시(결과 채널 close 보장):

package main

import (
	"context"
	"fmt"
	"sync"
	"time"
)

func main() {
	ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond)
	defer cancel()

	results := make(chan int)
	var wg sync.WaitGroup

	workers := 3
	wg.Add(workers)
	for w := 0; w < workers; w++ {
		go func(id int) {
			defer wg.Done()
			for i := 0; i < 10; i++ {
				select {
				case results <- id*100 + i:
				case <-ctx.Done():
					return
				}
			}
		}(w)
	}

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

	for {
		select {
		case v, ok := <-results:
			if !ok {
				fmt.Println("all done")
				return
			}
			fmt.Println("got", v)
		case <-ctx.Done():
			fmt.Println("collector timeout:", ctx.Err())
			return
		}
	}
}

이 코드는 다음을 동시에 만족합니다.

  • 워커는 ctx.Done()을 감시해서 취소 시 즉시 탈출
  • 수집기는 채널 close로 정상 종료
  • 수집기 또한 select로 타임아웃/취소에 안전

정리: select 타임아웃은 “멈춤”을 “제어 가능한 실패”로 바꾼다

  • 채널 데드락은 대부분 “상대가 사라졌는데 기다리는” 구조에서 발생합니다.
  • select + 타임아웃(또는 context)은 무한 블로킹을 끊어 장애를 관측 가능한 형태로 바꿉니다.
  • 다만 타임아웃만으로는 근본 원인이 해결되지 않습니다. 반드시 close 소유권, ctx.Done() 전파, 고루틴 누수 방지까지 같이 설계해야 합니다.

운영에서 멈춤을 다루는 핵심은 “절대 기다리지 않게 만들기”가 아니라, “기다림을 기한과 신호로 통제하기”입니다. Go 채널은 강력하지만, 그만큼 종료 조건과 취소 경로를 코드로 명시해야 안전해집니다.