select 문으로 여러 채널을 동시에 다루는 법
switch와 닮았지만 완전히 다르게 동작한다
select는 문법이 switch를 닮아서 처음 보면 "채널 버전 switch구나" 하고 넘어가기
쉽다. 그런데 동작 방식은 전혀 다르다. switch는 위에서부터 순서대로 조건을 검사해서
처음 맞는 case를 실행한다. select는 모든 case를 동시에 검사해서, 그중 준비된
(즉시 진행 가능한) case가 여러 개면 무작위로 하나를 고른다. 이 무작위성은 버그가
아니라 명세다 — 특정 채널만 계속 우선시되는 걸 막기 위한 설계다.
select {
case v := <-ch1:
fmt.Println("ch1:", v)
case v := <-ch2:
fmt.Println("ch2:", v)
}ch1과 ch2에 동시에 값이 준비돼 있다면, 매번 실행할 때마다 둘 중 하나가 무작위로
선택된다. "코드에서 ch1이 먼저 적혀 있으니 우선권이 있겠지"라고 가정하면 안 된다 —
그 가정이 맞는 것처럼 보이는 건 우연이고, 트래픽 패턴이 바뀌는 순간 깨진다.
준비된 case가 하나도 없으면 어떻게 되는가
모든 case의 채널이 아직 준비 안 됐으면 select는 그중 하나가 준비될 때까지
블로킹한다. 여기에 default case를 추가하면 이야기가 달라진다 — 준비된 case가
없으면 블로킹하는 대신 즉시 default를 실행한다. 이게 논블로킹 채널 연산을
만드는 유일한 표준 방법이다.
select {
case v := <-ch:
fmt.Println("받음:", v)
default:
fmt.Println("아직 값 없음, 계속 진행")
}이 패턴은 폴링 루프에서 주로 쓴다. 다만 default가 있는 select를 루프 안에 넣고
막 돌리면 CPU를 100% 태우는 바쁜 대기(busy-wait)가 되기 쉽다. 뭔가를 기다려야 하는
상황이면 대부분 default 없는 블로킹 select나, time.Sleep을 곁들인 폴링이 더
맞다. "논블로킹이 더 빠르겠지"라는 직관으로 아무 데나 default를 붙이면 오히려
느려진다.
타임아웃 패턴 — Go에서 가장 자주 보는 select 관용구
실무에서 select를 가장 많이 쓰는 곳은 타임아웃 처리다. time.After는 지정한
시간이 지나면 값을 하나 보내는 채널을 리턴한다. 이걸 다른 case와 나란히 두면
"둘 중 뭐가 먼저 오든" 처리하는 코드가 된다.
select {
case result := <-resultCh:
fmt.Println("결과 도착:", result)
case <-time.After(3 * time.Second):
fmt.Println("3초 안에 응답 없음, 포기")
}주의할 점 하나. time.After를 반복문 안에서 매번 새로 호출하면 그때마다 새 타이머가
생성되고, 이전 타이머는 GC가 회수할 때까지 메모리에 남는다. 짧은 주기로 반복 실행되는
코드라면 time.NewTimer를 만들어 두고 Reset으로 재사용하는 편이 낫다. 다만 이건
"핫 루프에서 수천 번 반복될 때"의 이야기고, 한두 번 쓰고 마는 코드에서는 time.After가
훨씬 읽기 쉽고 문제도 없다. 미리 최적화하지 말 것.
여러 소스를 하나로 합치기 (fan-in)
select의 또 다른 용도는 서로 다른 여러 채널에서 오는 값을 하나의 흐름으로 합치는
것이다. 이걸 fan-in이라고 부른다.
func merge(a, b <-chan int) <-chan int {
out := make(chan int)
var wg sync.WaitGroup
wg.Add(2)
forward := func(ch <-chan int) {
defer wg.Done()
for v := range ch {
out <- v
}
}
go forward(a)
go forward(b)
go func() {
wg.Wait()
close(out) // 양쪽 다 끝나야 out을 닫는다
}()
return out
}여기서 눈여겨볼 부분은 close(out)을 별도의 고루틴에서, wg.Wait() 뒤에 한다는
것이다. a와 b 두 채널 중 어느 게 먼저 닫힐지 알 수 없으므로, 양쪽 모두 소진된 걸
확인한 뒤에만 out을 닫아야 한다. 앞 챕터에서 "채널은 송신자만 닫는다"고 했는데,
이 경우 out의 송신자는 forward 두 개다 — 그래서 각자가 닫는 대신 WaitGroup으로
조율해서 마지막에 딱 한 번만 닫는다.
flowchart LR A["channel a"] --> M["merge"] B["channel b"] --> M M --> O["channel out"]
이 패턴은 6장 이후 실전 파이프라인에서 다시 쓴다. 지금은 "select는 조건 분기가 아니라 여러 채널 이벤트를 동시에 감시하는 도구"라는 감각만 잡고 넘어가면 충분하다.