select
이 챕터에서 다루는 것
7-2의 <-ch는 하나의 채널만 기다린다. 현실에서는
"결과 채널이거나, 취소 신호이거나, 타임아웃이거나" 중 먼저 오는 것을 처리해야 한다.
select가 그것이다.
select는 7-1에서 미뤄 둔 고루틴 누수의 일반 해법이기도
하다. 빠져나갈 길을 케이스로 하나 더 붙이면 된다.
문제 — 하나만 기다릴 수 있다
result := <-resultCh // 여기서 영원히 멈출 수 있다
resultCh에 값이 안 오면 이 고루틴은 끝나지 않는다. done 채널을 만들어 놨어도
소용없다. 이미 resultCh에서 잠들었기 때문에 done을 확인할 기회가 없다.
다른 언어들은 이 문제를 다르게 푼다. Java는 Future.get(timeout)을, Python은
asyncio.wait(..., return_when=FIRST_COMPLETED)를 준다. 둘 다 라이브러리 함수다.
Go는 이것을 문법으로 만들었다.
기본형
select {
case v := <-ch1:
// ch1에서 받았을 때
case ch2 <- x:
// ch2로 보낼 수 있었을 때
case <-done:
// done이 준비됐을 때
}
switch와 모양은 비슷하지만 의미는 전혀 다르다. switch는 값을 위에서부터
비교하고, select는 모든 케이스의 채널 연산이 준비되기를 동시에 기다린다.
케이스의 순서에는 아무 의미가 없다.
동작 규칙은 세 줄이다.
- 준비된 케이스가 하나면 그것을 실행한다.
- 여럿이면 무작위로 하나를 고른다.
- 하나도 없으면 —
default가 있으면 그것을, 없으면 준비될 때까지 블록한다.
package main
import (
"fmt"
"time"
)
func main() {
// 1. 여러 채널을 동시에 기다린다. 먼저 준비된 케이스가 실행된다.
//
// 두 채널 모두 버퍼 1이다. select가 고르지 않은 쪽의 송신 고루틴도
// 막히지 않고 끝난다 — 버퍼가 없으면 그 고루틴이 누수된다.
fast := make(chan string, 1)
slow := make(chan string, 1)
go func() {
time.Sleep(10 * time.Millisecond)
fast <- "빠른 쪽"
}()
go func() {
time.Sleep(200 * time.Millisecond)
slow <- "느린 쪽"
}()
select {
case v := <-fast:
fmt.Println("먼저 도착:", v)
case v := <-slow:
fmt.Println("먼저 도착:", v)
}
// 2. default가 있으면 select는 절대 블록하지 않는다.
ch := make(chan int, 1)
select {
case v := <-ch:
fmt.Println("수신:", v)
default:
fmt.Println("비어 있음 — 기다리지 않는다")
}
ch <- 42
select {
case v := <-ch:
fmt.Println("수신:", v)
default:
fmt.Println("비어 있음")
}
// 3. 논블로킹 송신. 버퍼가 꽉 차 있으면 값을 버린다.
// 로그·메트릭처럼 "밀리느니 버리는 편이 나은" 곳에서 쓴다.
ch <- 1 // 버퍼를 채운다
for _, v := range []int{2, 3} {
select {
case ch <- v:
fmt.Println("송신 성공:", v)
default:
fmt.Println("버퍼 가득 — 버림:", v)
}
}
// 4. 케이스가 없는 select는 영원히 블록된다. 데드락 감지에 걸린다.
// select {}
}
cd examples/07-concurrency
go run ./04-select-basics
먼저 도착: 빠른 쪽
비어 있음 — 기다리지 않는다
수신: 42
버퍼 가득 — 버림: 2
버퍼 가득 — 버림: 3
첫 줄은 타이밍에 의존한다. 10ms와 200ms의 차이가 충분히 커서 실질적으로 항상 "빠른 쪽"이 나오지만, 이것은 보장이 아니다. 나머지 네 줄은 채널 상태만으로 결정되므로 완전히 결정적이다.
:::warning 선택되지 않은 케이스의 반대편은 어떻게 되는가
위 코드에서 fast와 slow에 버퍼 1을 준 이유가 여기 있다. select가 fast를
고르면 slow에 보내려던 고루틴은 아무도 안 받는 채널에 송신하게 된다. 버퍼가 없으면
그 고루틴은 영원히 블록된다. 200ms 뒤에 조용히 누수되는 것이다.
select로 여러 소스를 경쟁시킬 때는 지는 쪽의 고루틴이 어떻게 끝나는지를 항상
같이 설계해야 한다. 답은 보통 셋 중 하나다 — 버퍼 1을 주거나, context로 취소하거나
(7-7), 결과 채널 하나를 공유하고 첫 값만 쓰거나.
:::
준비된 케이스가 여럿이면 무작위다
이것은 구현 세부가 아니라 명세다. 언어 명세가 "uniform pseudo-random selection"을 요구한다. 이유는 기아(starvation) 방지다. 위에서부터 고른다면 첫 케이스가 항상 바쁠 때 아래 케이스는 영원히 실행되지 않는다.
package main
import "fmt"
const rounds = 100000
func main() {
// 두 채널을 항상 "둘 다 준비된" 상태로 만들어 두고 select를 반복한다.
a := make(chan int, 1)
b := make(chan int, 1)
counts := map[string]int{}
for range rounds {
a <- 1
b <- 1
select {
case <-a:
counts["a"]++
<-b // 안 고른 쪽을 비운다
case <-b:
counts["b"]++
<-a
}
}
// 정확한 숫자는 실행마다 다르다. 대략 반반이면 정상이다.
fmt.Printf("전체 %d회\n", rounds)
fmt.Printf("a 선택: %.1f%%\n", 100*float64(counts["a"])/rounds)
fmt.Printf("b 선택: %.1f%%\n", 100*float64(counts["b"])/rounds)
fmt.Println("둘 다 한 번 이상 선택됨:", counts["a"] > 0 && counts["b"] > 0)
}
go run ./04-select-random
전체 100000회
a 선택: 50.0%
b 선택: 49.9%
둘 다 한 번 이상 선택됨: true
퍼센트는 실행마다 달라진다. 세 번 더 돌려서 관찰한 값은
50.1/49.9, 49.9/50.1, 49.9/50.1이었다. 마지막 줄만 사실상 보장된다.
여기서 나오는 실무 규칙 하나. 우선순위가 필요하면 select 하나로는 안 된다.
// "취소 신호가 있으면 무조건 먼저 처리"를 원한다면
select {
case <-done:
return
default:
}
select {
case <-done:
return
case v := <-work:
handle(v)
}
앞의 select가 취소를 먼저 확인하고, 뒤의 select가 실제 대기를 한다. 하나로
합치면 work가 준비돼 있을 때 50% 확률로 취소를 무시한다.
default — 논블로킹
default가 있으면 select는 절대 블록하지 않는다. 준비된 케이스가 없으면 즉시
default로 간다.
두 가지 용도가 있다.
1. 논블로킹 수신 — "값이 있으면 처리하고, 없으면 다른 일을 한다".
2. 논블로킹 송신(값 버리기) — 위 예제의 3번. 메트릭 수집이나 디버그 로그처럼 밀리느니 버리는 편이 나은 데이터에 쓴다. 이 패턴은 7-3의 "버퍼는 지연을 숨기지 처리량을 만들지 않는다"에 대한 현실적인 대응이다 — 버퍼가 차면 백프레셔로 시스템 전체를 세우는 대신 낮은 우선순위 데이터를 떨군다.
:::danger default를 루프 안에 넣으면 바쁜 대기다
for {
select {
case v := <-ch:
handle(v)
default:
// 아무것도 안 함 → CPU 100%
}
}
이것은 채널을 폴링하는 것이고, 코어 하나를 전부 태운다. default는 "한 번
찔러 보는" 용도이지 루프의 일부가 아니다. 기다려야 한다면 default를 빼면 된다.
:::
타임아웃
select의 가장 흔한 실전 용도다.
// work는 d만큼 걸리는 작업을 흉내 낸다. 결과 채널에 버퍼를 주는 것이 중요하다.
// 호출자가 타임아웃으로 떠나 버려도 이 고루틴은 값을 넣고 정상 종료한다.
func work(d time.Duration) <-chan string {
out := make(chan string, 1)
go func() {
time.Sleep(d)
out <- fmt.Sprintf("%v 걸린 작업 완료", d)
}()
return out
}
// withTimeAfter는 time.After를 쓴다. 짧고 읽기 쉽다.
func withTimeAfter(d, limit time.Duration) (string, error) {
select {
case v := <-work(d):
return v, nil
case <-time.After(limit):
return "", errTimeout
}
}
// withTimer는 Timer를 직접 만들고 Stop한다.
// 루프 안에서 반복해 호출하는 경로라면 이쪽이 쓰레기를 덜 만든다.
func withTimer(d, limit time.Duration) (string, error) {
t := time.NewTimer(limit)
defer t.Stop()
select {
case v := <-work(d):
return v, nil
case <-t.C:
return "", errTimeout
}
}
go run ./04-timeout
time.After 작업10ms: 10ms 걸린 작업 완료
time.Timer 작업10ms: 10ms 걸린 작업 완료
time.After 작업300ms: 타임아웃
time.Timer 작업300ms: 타임아웃
한도가 50ms이고 작업은 10ms 또는 300ms이므로 이 출력은 안정적이다. 시간 자체는 기계마다 다르므로 실제 소요 시간을 출력하지 않았다 — 동시성 예제에서 벽시계 시간을 찍으면 재현할 수 없는 출력이 된다.
time.After의 비용
time.After(d)는 타이머를 하나 만들고 채널을 반환한다. 타이머는 d가 지나야
발화하고, 그전에는 회수되지 않는다.
한 번 쓰고 마는 자리에서는 아무 문제가 없다. 문제가 되는 것은 루프다.
for {
select {
case v := <-ch:
handle(v)
case <-time.After(time.Minute): // 반복마다 1분짜리 타이머가 하나씩 쌓인다
return
}
}
ch에 초당 1000건이 오면 1분 동안 60,000개의 타이머가 살아 있다. 실제로 겪는
메모리 증가 패턴이다.
:::tip 규칙
- 한 번 쓰고 끝나는 자리:
time.After가 짧고 명확하다. - 루프 안:
time.NewTimer를 밖에서 만들고Reset으로 재사용하거나, 아예context.WithTimeout(7-7)으로 대체한다.
실무에서는 세 번째가 정답인 경우가 대부분이다. 타임아웃은 보통 호출 체인 전체에
전파돼야 하는 것이고, 그것이 context의 일이다.
:::
nil 채널로 케이스를 끄기
7-2의 동작표에서 "nil 채널의 송수신은 영원히 블록"이라고 했다.
버그처럼 보이는 이 규칙이 select와 만나면 도구가 된다. 영원히 준비되지 않는
케이스는 없는 케이스와 같다.
문제 상황부터 보자. 두 채널을 select로 합치는데, 한쪽이 먼저 닫히면?
for {
select {
case v := <-a: // a가 닫히면 이 케이스가 "항상 준비" 상태가 된다
fmt.Println(v) // 0이 무한히 찍힌다
case v := <-b:
fmt.Println(v)
}
}
닫힌 채널의 수신은 즉시 성공한다. 그래서 a가 닫히는 순간 select는 절반의
확률로 계속 a를 고르고 제로값을 뱉는다. 바쁜 대기다.
해법은 a를 nil로 바꾸는 것이다.
package main
import "fmt"
// gen은 값을 순서대로 내보내고 채널을 닫는다.
func gen(vals ...int) <-chan int {
out := make(chan int, len(vals))
for _, v := range vals {
out <- v
}
close(out)
return out
}
func main() {
a := gen(1, 2, 3)
b := gen(10, 20)
sum := 0
got := 0
// 두 채널 중 하나가 닫히면 그 케이스는 계속 준비 상태가 되어
// select가 제로값을 무한히 꺼내게 된다.
// 닫힌 채널의 변수를 nil로 바꾸면 그 케이스는 영원히 블록되고,
// 결과적으로 select에서 "꺼진다".
for a != nil || b != nil {
select {
case v, ok := <-a:
if !ok {
a = nil // 이 케이스를 끈다
continue
}
sum += v
got++
case v, ok := <-b:
if !ok {
b = nil
continue
}
sum += v
got++
}
}
// 도착 순서는 비결정적이지만 합과 개수는 항상 같다.
fmt.Println("받은 개수:", got)
fmt.Println("합계:", sum)
}
go run ./04-nil-disable
받은 개수: 5
합계: 36
값이 도착하는 순서는 매번 다르지만 합과 개수는 항상 같다. 이것이 이 파트에서 반복해 쓰는 결정성 확보 기법의 세 번째 형태다 — 순서에 의존하지 않는 집계 (합, 개수, 집합)만 출력한다.
for a != nil || b != nil 조건도 이 기법의 일부다. 둘 다 꺼지면 루프가 끝난다.
끄기와 종료 판정이 같은 변수로 표현된다.
같은 기법을 송신 쪽에도 쓴다. "보낼 값이 있을 때만 송신 케이스를 켜는" 패턴이다.
var out chan<- int // nil로 시작 — 송신 케이스가 꺼져 있다
var pending int
for {
select {
case v := <-in:
pending = v
out = realOut // 보낼 게 생겼으니 켠다
case out <- pending:
out = nil // 보냈으니 다시 끈다
}
}
이 구조가 없으면 "보낼 값이 없을 때 뭘 보내야 하나" 문제에 부딪힌다. nil로 끄면 그 케이스가 존재하지 않는 것과 같아진다.
흔히 하는 실수
1. select가 위에서부터 고른다고 생각한다
무작위다. 우선순위가 필요하면 select를 두 번 쓴다.
2. for 없이 select를 쓰고 반복을 기대한다
select는 한 번만 실행된다. 계속 처리하려면 for { select { ... } }다.
3. for-select에서 break가 루프를 벗어난다고 생각한다
for {
select {
case <-done:
break // select를 벗어날 뿐, for는 계속 돈다
}
}
break는 가장 안쪽 for/switch/select를 벗어난다. select 안의 break는
select만 끝낸다. 루프를 벗어나려면 return을 쓰거나 레이블을 붙인다.
loop:
for {
select {
case <-done:
break loop
}
}
실무에서는 레이블보다 함수로 뽑아 return하는 편을 선호한다.
4. 케이스 안에서 오래 걸리는 일을 한다
select가 케이스를 고른 순간 그 고루틴은 케이스 본문을 끝낼 때까지 다른 채널을
보지 않는다. 취소 신호에 반응이 느려진다. 본문은 짧게, 무거운 일은 다른 고루틴으로.
5. 타임아웃만 있고 취소가 없다
select {
case v := <-ch:
case <-time.After(30 * time.Second):
}
호출자가 이미 포기했는데도 30초를 채운다. 그리고 ch에 보내려던 고루틴은 그동안
계속 살아 있다. 타임아웃과 취소는 다른 문제이고, 7-7이 둘을
한 값으로 묶는다.
6. 빈 select {}를 실수로 쓴다
케이스가 하나도 없는 select {}는 영원히 블록된다. 서버를 계속 돌리려는 의도로
쓰기도 하지만, 그럴 때도 시그널을 기다리는 편이 낫다. 다른 고루틴이 전부 잠들면
fatal error: all goroutines are asleep - deadlock!이 뜬다.
정리
select는 여러 채널 연산을 동시에 기다린다. 준비된 것 하나를 실행한다.- 여럿이 준비되면 무작위 선택이고, 이것은 명세다. 기아를 막기 위한 것이다.
우선순위가 필요하면
select를 두 단계로 나눈다. default는 논블로킹으로 만든다. 루프 안에 넣으면 바쁜 대기이므로 금지. 논블로킹 송신은 "밀리느니 버린다"를 표현하는 방법이다.time.After는 타이머를 만든다. 루프 안에서 쓰면 쌓인다.NewTimer+Reset이나context.WithTimeout으로 바꾼다.- nil 채널은 케이스를 끄는 스위치다. 닫힌 채널을
nil로 바꿔 바쁜 대기를 막고, 종료 판정까지 같은 변수로 표현한다. select안의break는select만 벗어난다. 레이블이나return을 쓴다.- 경쟁에서 진 쪽의 고루틴이 어떻게 끝나는지 항상 같이 설계한다.
연습문제
-
04-select-basics에서fast와slow의 버퍼를 없애고(make(chan string)) 프로그램 끝에runtime.NumGoroutine()을 찍어 보자. 무엇이 남아 있는가? 200ms를 기다려도 사라지지 않는 이유를 설명해 보자. -
04-nil-disable에서a = nil두 줄을 지우고got가 20이 되면 강제로 루프를 빠져나가도록 바꾼 뒤sum을 출력해 보자. 어떤 값이 나오는가? 왜 그런지 7-2의 동작표로 설명해 보자. -
"우선순위 있는
select"를 직접 만들어 보자. 고우선 채널과 저우선 채널이 있고, 고우선에 값이 있으면 항상 그것을 먼저 처리해야 한다. 두 채널 모두 계속 값이 들어오는 상황에서 저우선이 굶지 않으려면 어떤 장치가 더 필요한가? -
04-timeout의work에서 결과 채널의 버퍼를 없애고 300ms 케이스를 실행해 보자. 프로그램은 정상 종료하는가?runtime.NumGoroutine()으로 확인하고, 왜 버퍼 1이 "타임아웃 패턴의 필수 요소"인지 정리해 보자.