Published on

Go 채널 데드락 - select+nil 채널로 5분 해결

Authors

서버나 워커를 Go로 운영하다 보면, 어느 날 갑자기 프로세스가 멈춘 듯 보이면서 로그도 더 이상 안 찍히는 상황을 만납니다. CPU는 낮고, 메모리는 안정적인데 요청은 타임아웃이 나고, goroutine 수는 계속 늘거나 특정 지점에서 정지합니다. 이런 증상의 상당수는 채널 데드락(정확히는 goroutine들이 서로를 기다리며 진행 불가)이 원인입니다.

이 글은 데드락을 “완벽히 근절”하는 거창한 이야기보다, 실무에서 가장 자주 터지는 패턴을 select + nil 채널로 5분 안에 정리하는 방법에 집중합니다. 특히 다음 상황에 즉효가 있습니다.

  • 어떤 채널은 상황에 따라 “받을 수도/안 받을 수도” 있어야 하는데 select가 계속 그 채널을 기다리는 경우
  • producer가 닫히거나 멈췄는데 consumer가 영원히 range ch 또는 case v := <-ch에 갇히는 경우
  • 여러 입력 중 일부만 활성화하고 싶지만 select case를 동적으로 켜고 끄기 어려운 경우

운영 관점에서 “원인이 데드락인지”를 먼저 확인하는 방법도 짧게 다룹니다. (프로세스가 멈춘 듯 보일 때는 앱 내부 문제뿐 아니라 런타임/환경 문제도 섞이기 때문에, 유사한 진단 글로는 systemd 서비스 재시작 루프 10분 진단 가이드도 같이 참고하면 좋습니다.)

1) 데드락의 전형: select가 영원히 깨어나지 않는다

Go의 select는 준비된 case가 없으면 블록됩니다. “그렇지, 당연하지”라고 생각하지만, 실무에서는 준비될 수 없는 채널을 무심코 넣어두는 순간 문제가 됩니다.

예를 들어, 아래 코드는 얼핏 그럴듯하지만 특정 조건에서 영원히 멈출 수 있습니다.

package main

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

func main() {
	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()

	jobs := make(chan int)
	results := make(chan int)

	go func() {
		// 실제 코드에서는 조건에 따라 jobs를 더 이상 보내지 않을 수 있음
		// close(jobs)를 깜빡하면 consumer는 영원히 기다릴 수 있다.
		for i := 0; i < 3; i++ {
			jobs <- i
		}
		// close(jobs) // 이게 없으면 아래 워커가 끝나지 않을 수 있음
	}()

	go worker(ctx, jobs, results)

	// results를 읽는 쪽도 종료 조건이 없으면 멈춘다.
	for i := 0; i < 3; i++ {
		fmt.Println(<-results)
	}

	fmt.Println("done")
}

func worker(ctx context.Context, jobs <-chan int, results chan<- int) {
	for {
		select {
		case <-ctx.Done():
			return
		case j := <-jobs:
			results <- j * 2
		}
	}
}

문제점은 두 가지입니다.

  1. jobs가 더 이상 오지 않는데 close(jobs)도 안 하면 j := <-jobs영원히 대기할 수 있습니다.
  2. jobs가 닫혀도 위 코드는 j := <-jobs에서 ok를 확인하지 않아, 닫힌 채널에서 0을 계속 읽는 논리 버그로 이어질 수 있습니다.

이 글의 핵심은 “닫아라/ok 체크해라”를 넘어, 상황에 따라 case를 아예 비활성화하는 패턴입니다.

2) 핵심 원리: nil 채널은 영원히 블록된다

Go에서 nil 채널에 대해 send/recv를 시도하면 해당 연산은 영원히 블록됩니다. 이 특성은 단독으로 쓰면 위험하지만, select 안에서는 강력한 도구가 됩니다.

  • select는 준비 가능한 case 중 하나를 고릅니다.
  • 어떤 case의 채널을 nil로 만들면, 그 case는 절대 선택되지 않습니다.

즉, select의 case를 동적으로 켜고 끄는 “스위치”로 nil 채널을 사용할 수 있습니다.

3) 5분 해결 패턴 1: 입력 채널을 조건부로 비활성화하기

가장 흔한 요구는 이겁니다.

  • 평소에는 jobs를 받는다.
  • 특정 조건이 되면 더 이상 jobs를 받지 않고, 다른 이벤트(예: 종료 신호, 타임아웃, flush)만 기다린다.

이때 selectcase j := <-jobs:를 넣어두면, jobs가 더 이상 오지 않는 순간 select는 다른 case가 준비되지 않는 한 계속 블록될 수 있습니다.

해결은 간단합니다. “받고 싶지 않을 때” jobsnil로 바꿉니다.

package main

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

func main() {
	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()

	jobs := make(chan int)
	go func() {
		for i := 0; i < 5; i++ {
			jobs <- i
		}
		close(jobs)
	}()

	process(ctx, jobs)
}

func process(ctx context.Context, jobs <-chan int) {
	var in <-chan int = jobs
	idleTimer := time.NewTimer(500 * time.Millisecond)
	defer idleTimer.Stop()

	for {
		select {
		case <-ctx.Done():
			fmt.Println("stop: ctx")
			return

		case j, ok := <-in:
			if !ok {
				// 채널이 닫히면 더 이상 이 case가 선택되지 않게 비활성화
				in = nil
				fmt.Println("jobs closed; disable input")
				continue
			}
			fmt.Println("job", j)

			// 활동이 있었으니 idle 타이머 리셋
			if !idleTimer.Stop() {
				select {
				case <-idleTimer.C:
				default:
				}
			}
			idleTimer.Reset(500 * time.Millisecond)

		case <-idleTimer.C:
			fmt.Println("idle tick")
			// 입력이 이미 닫혔다면, idle만 기다릴 이유가 없으니 종료
			if in == nil {
				fmt.Println("stop: no input")
				return
			}
		}
	}
}

포인트는 var in <-chan int = jobs로 별도 변수를 두고, 닫힘을 감지하면 in = nil로 바꿔 해당 case를 영구적으로 비활성화하는 것입니다.

이 패턴은 단순하지만 효과가 큽니다.

  • 닫힌 채널에서 0값을 무한히 읽는 버그 방지
  • 더 이상 오지 않는 입력 때문에 다른 종료 조건이 묻히는 상황 방지
  • 선택 로직이 명시적으로 바뀌어 디버깅이 쉬움

4) 5분 해결 패턴 2: “optional channel”을 nil로 표현하기

실무에서는 설정에 따라 특정 기능이 꺼져 있을 수 있습니다.

  • rate limit을 켜면 tick 채널을 받는다.
  • 끄면 tick 채널을 받지 않는다.

이때 흔히 if enabled { select { case ... } } else { select { ... } }처럼 select 자체를 중복 작성하게 됩니다. 더 나쁜 경우, 비활성 기능의 채널을 만들어두고 아무도 보내지 않아 데드락을 유발합니다.

nil 채널로 깔끔하게 해결할 수 있습니다.

package main

import (
	"fmt"
	"time"
)

func main() {
	enabled := false

	var tick <-chan time.Time
	if enabled {
		ticker := time.NewTicker(200 * time.Millisecond)
		defer ticker.Stop()
		tick = ticker.C
	} else {
		// 비활성화: nil 채널로 case 자체를 제거
		tick = nil
	}

	deadline := time.After(650 * time.Millisecond)

	for {
		select {
		case <-tick:
			fmt.Println("tick")
		case <-deadline:
			fmt.Println("deadline")
			return
		}
	}
}

enabledfalseticknil이므로 case <-tick:은 절대 선택되지 않습니다. 결과적으로 deadline만 기다리는 루프가 됩니다.

5) 5분 해결 패턴 3: fan-in에서 특정 입력이 멎을 때

여러 입력을 하나로 합치는 fan-in은 흔합니다. 그런데 입력 중 하나가 “일시적으로 멎음”이 아니라 “영구적으로 멎음” 상태가 되면, 잘못 짠 select는 전체를 멈춰 세우기도 합니다.

아래는 두 입력을 합치는 예시입니다. 각 입력이 닫히면 nil로 비활성화하고, 둘 다 닫히면 종료합니다.

package main

import "fmt"

func fanIn(a, b <-chan int) <-chan int {
	out := make(chan int)
	go func() {
		defer close(out)

		var ca <-chan int = a
		var cb <-chan int = b

		for ca != nil || cb != nil {
			select {
			case v, ok := <-ca:
				if !ok {
					ca = nil
					continue
				}
				out <- v
			case v, ok := <-cb:
				if !ok {
					cb = nil
					continue
				}
				out <- v
			}
		}
	}()
	return out
}

func main() {
	a := make(chan int)
	b := make(chan int)

	go func() {
		defer close(a)
		for i := 0; i < 3; i++ {
			a <- i
		}
	}()
	go func() {
		defer close(b)
		for i := 100; i < 102; i++ {
			b <- i
		}
	}()

	for v := range fanIn(a, b) {
		fmt.Println(v)
	}
}

이 패턴을 쓰면, 한쪽 입력이 먼저 닫혀도 나머지 입력은 계속 흘러가며, 둘 다 끝났을 때만 정상 종료합니다.

6) 왜 이게 데드락 “치료”로 먹히는가

운영에서 만나는 데드락은 보통 아래 둘 중 하나입니다.

  • 기다릴 필요가 없는 채널을 계속 기다림: 이미 닫혔거나, 더 이상 오지 않거나, 기능이 비활성인데도 select에 남아 있음
  • 종료 조건이 있지만 도달하지 못함: 어떤 case가 영원히 블록되어 다른 case가 실행될 기회를 못 얻는 구조(예: 단일 goroutine이 모든 것을 처리하는데 입력이 영원히 안 옴)

nil 채널은 “이 case는 이제 게임에서 제외”를 코드로 표현합니다. 그래서 로직이 단순해지고, 종료 조건이 살아납니다.

7) 실전 디버깅 팁: goroutine 덤프로 막힌 지점 확인

프로세스가 멈춘 듯할 때는 먼저 goroutine 덤프를 떠서 어디서 블록되는지 확인하는 게 가장 빠릅니다.

  • HTTP 서버를 쓰면 net/http/pprof를 붙여 goroutine 프로파일을 봅니다.
  • 또는 SIGQUIT로 스택 덤프를 출력합니다.

예를 들어 pprof를 붙이는 최소 코드는 다음과 같습니다.

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

go func() {
	// 운영에서는 반드시 인증/네트워크 제한을 걸 것
	_ = http.ListenAndServe("127.0.0.1:6060", nil)
}()

덤프에서 chan receive 또는 select로 오래 멈춘 goroutine이 보이면, “그 채널이 영원히 준비되지 않는가?”를 의심하고, 이 글의 nil 비활성화 패턴을 적용해볼 수 있습니다.

운영 장애가 “프로세스가 죽었다/재시작된다” 형태로 보일 때는 데드락과 반대로 크래시나 OOM일 수도 있으니, 환경 레벨 진단은 K8s CrashLoopBackOff 원인별 로그·Probe 해결 가이드도 함께 보면 좋습니다.

8) 주의사항: nil 채널은 만능이 아니다

select + nil 채널은 강력하지만, 남용하면 가독성이 떨어질 수 있습니다. 다음을 지키면 안전합니다.

  • 반드시 별도 변수로 받기: 원본 채널 변수 자체를 nil로 만들면 다른 코드가 영향을 받을 수 있습니다. var in <-chan T = ch처럼 로컬에서 스위칭하세요.
  • 닫힘은 ok로 처리: case v, ok := <-ch: 형태로 닫힘을 감지하고 ch = nil로 비활성화하세요.
  • 종료 조건을 명시: for in != nil || other != nil처럼 “언제 끝나는지”를 루프 조건에 드러내면 디버깅이 쉬워집니다.
  • 타이머 리셋은 안전하게: time.TimerStop과 drain 처리를 제대로 하지 않으면 의도치 않은 즉시 tick이 발생할 수 있습니다. 위 예제처럼 drain 패턴을 지키세요.

9) 체크리스트: 데드락이 보이면 5분 안에 보는 것들

  • select에 들어간 채널 중, 실제로는 “영원히 준비되지 않을 수 있는” 채널이 있는가
  • 닫힌 채널을 ok 체크 없이 계속 읽고 있지는 않은가
  • 입력이 끝났을 때 해당 case를 nil로 바꿔 비활성화할 수 있는가
  • fan-in/fan-out에서 한쪽 종료가 전체 정지를 유발하지 않는가
  • 종료 신호(ctx.Done)가 있는데도 루프가 빠져나오지 못하는가

추가로, 장애가 “응답 지연/타임아웃”으로 관측될 때는 애플리케이션 데드락뿐 아니라 인프라 타임아웃도 함께 의심해야 합니다. 예를 들어 게이트웨이/런타임 제한이 섞이면 증상이 비슷해질 수 있는데, 그런 경우는 Cloud Run 504 Timeout 원인·해결 9가지 같은 타임아웃 진단 글도 같이 보면 원인 분리가 빨라집니다.

마무리

Go 채널 데드락은 대부분 “채널을 닫아라”로 끝나지 않습니다. 문제는 보통 select가 더 이상 의미 없는 채널을 계속 case로 들고 있는 구조에서 시작합니다. nil 채널을 이용해 case를 동적으로 비활성화하면, 로직이 단순해지고 종료 조건이 살아나며, fan-in 같은 동시성 파이프라인도 안정적으로 동작합니다.

다음에 select에서 멈춰 있는 goroutine을 발견하면, 그 채널을 nil로 만들 수 있는지부터 먼저 떠올려 보세요. 5분 안에 증상이 사라지는 경우가 생각보다 많습니다.