여러 channel을 동시에 기다리는 단 하나의 구조
— timeout · fan-in/out · nil channel까지
<-ch1을 먼저 쓰면, ch2가 아무리 빨라도 ch1을 기다리느라 못 받는다// 하나면 이걸로 충분하다 msg := <-ch1 // 둘이 되는 순간 — a := <-ch1 // ch2가 먼저 와도 b := <-ch2 // 여기까지 못 온다 // select: 먼저 준비된 쪽을 고른다 select { case m := <-ch1: case m := <-ch2: }
select는 여러 channel 연산을 나란히 세워 두고, 먼저 가능해지는 하나를 고르는 문법이다. 오늘 다루는 패턴은 전부 여기서 파생된다.
| 편 | 제목 | 다루는 영역 |
|---|---|---|
| 편 1 | 개요와 Goroutine 기초 | 실행 단위 |
| 편 2 | Channel 완전 정복 | 통신 |
| 편 3 | Select와 Channel 심화 패턴 | 선택 · 조합 |
| 편 4 · 5 | sync 패키지 완벽 가이드 / Context 완벽 가이드 | 동기화 · 생명주기 |
| 편 6 · 7 | 동시성 패턴 실전 / 에러 처리 전략 | 설계 패턴 |
| 편 8 · 9 | Memory Model과 Atomic / Race Detector | 저수준 · 디버깅 |
| 편 10 · 11 | 실전 프로젝트 / go tool trace 시각화 | 실전 · 도구 |
1편이 실행 단위, 2편이 통신 수단이었다면 오늘은 그 둘을 엮는 편이다.
time.After · context.WithTimeout · 둘 중 무엇을 고를까생김새는 switch를 닮았지만 고르는 대상이 다르다. 무엇을 기준으로 case가 선택되는지, 그리고 여럿이 동시에 준비되면 어떻게 되는지까지 본다.
<-ch와 같다select { case msg := <-ch1: // ch1이 먼저 오면 fmt.Println("ch1:", msg) case msg := <-ch2: // ch2가 먼저 오면 fmt.Println("ch2:", msg) } // 둘 다 아직이면 여기서 멈춰 기다린다
false면 channel이 닫힌 것ok로 그 사실을 알아채는 게 5장 nil channel 트릭의 출발점이다.
ok를 미리 심어두는 게 목적이다. "닫힌 channel은 blocking하지 않는다"는 문장을 소리 내어 한 번 말하고 넘어간다 — 24번 슬라이드에서 이 한 문장이 다시 나온다.ch1Count, ch2Count := 0, 0 for range 1000 { ch1 <- 1 // 둘 다 준비 상태로 ch2 <- 2 select { case <-ch1: ch1Count++ case <-ch2: ch2Count++ } drain(ch1, ch2) // 남은 값 비우기 } // ch1: 516, ch2: 484 — 대략 50:50
default 한 줄이 select의 성격을 통째로 바꾼다.
기다리는 구조에서 지금 되는지만 확인하는 구조로.
select { case val := <-ch: fmt.Println("received:", val) default: // 비어 있으면 즉시 여기로 fmt.Println("no data") }
ch := make(chan int, 1) ch <- 1 // 버퍼 가득 select { case ch <- 2: fmt.Println("sent") default: fmt.Println("buffer full") }
| 형태 | 동작 | 쓰는 곳 |
|---|---|---|
case 만 | 하나가 준비될 때까지 blocking | 기본형 |
+ default | 준비된 게 없으면 즉시 반환 | polling · 드롭 허용 송신 |
+ time.After | 정해진 시간이 지나면 깨어남 | timeout |
case <-nilCh | 그 case는 영원히 선택 안 됨 | 동적 비활성화 |
select {} | 영원히 blocking | deadlock — 거의 실수다 |
default가 든 select를 for 루프에 넣으면 busy-wait다. 아무 일도 없는데 CPU 코어 하나가 100%로 돈다. 기다려야 하는 자리라면 default를 빼고 timeout case를 둔다.
Go에는 timeout 문법이 따로 없다. 시간이 지나면 값이 오는 channel을
case로 하나 더 세울 뿐이다. 그 채널을 어디서 얻느냐가 time.After와 context의 차이다.
ch := make(chan string) go func() { time.Sleep(200 * time.Millisecond) // 느린 작업 ch <- "result" }() select { case msg := <-ch: // 결과가 먼저 오면 정상 처리 t.Log("received:", msg) case <-time.After(50 * time.Millisecond): // 50ms 초과 t.Log("timeout!") } // → timeout! (작업 200ms > 제한 50ms)
time.After(d)는 d 뒤에 값 하나가 도착하는 channel을 돌려준다. select 입장에서는 그냥 평범한 receive case 하나가 늘어난 것뿐이다.
func simulateAPICall(ctx context.Context, d time.Duration) (string, error) { ch := make(chan string, 1) // 버퍼 1 — 이유는 아래 go func() { time.Sleep(d) ch <- "api response" }() select { case result := <-ch: // 응답이 먼저 오면 return result, nil case <-ctx.Done(): // 마감 초과 · 상위에서 취소 return "", ctx.Err() // context.DeadlineExceeded } }
ctx, cancel := context.WithTimeout(...) + defer cancel(). cancel을 빼먹으면 timeout 전에 끝나도 타이머가 남는다고 짚는다. 버퍼 1 이야기는 1편 leak 슬라이드와 이어진다 — "만들 때 종료 경로를 함께 만든다"의 실제 사례다.| 항목 | time.After | context.WithTimeout |
|---|---|---|
| 취소 전파 | 없음 — 나만 빠져나온다 | 하위 goroutine까지 전파된다 |
| 적용 범위 | select 한 곳 | 호출 체인 전체 |
| 뒷정리 | 필요 없음 | defer cancel() 필수 |
| 이유 구분 | 없음 | ctx.Err() — 초과인지 취소인지 |
| 쓰는 곳 | 테스트 · 단발성 대기 | API 호출 · 서버 핸들러 |
time.After, 내가 부른 사람들도 멈춰야 하면 context. 실무 코드는 대부분 후자다.
일을 여럿에게 나눠 주고(fan-out), 흩어진 결과를 다시 하나로 모은다(fan-in). 둘을 이어 붙이면 그게 곧 동시성 파이프라인이다.
화살표는 데이터가 흐르는 방향이다. worker 셋이 같은 jobs channel에서 경쟁적으로 꺼내 가며, 누가 몇 개를 가져갈지는 정해져 있지 않다 — 빨리 끝낸 worker가 더 많이 가져간다(자연스러운 부하 분산).
jobs, results := make(chan int, 10), make([]chan int, 3) for i := range results { results[i] = make(chan int, 10) } var wg sync.WaitGroup for i := range results { wg.Add(1) go func() { // 셋이 같은 jobs를 공유한다 defer wg.Done() for job := range jobs { // close될 때까지 계속 꺼낸다 results[i] <- job * job } close(results[i]) }() } for i := 1; i <= 9; i++ { jobs <- i } close(jobs) // 없으면 worker가 영원히 대기 wg.Wait()
close(jobs)가 없으면 range가 끝나지 않아 worker 셋이 그대로 남는다.
② 루프 변수 i를 goroutine이 그대로 캡처하는 건 Go 1.22 이상에서만 안전하다.
func(i int)를 넘기는 옛 방식도 한 번 언급한다.source마다 goroutine 하나가 붙어 merged로 값을 넘긴다. 소비자는 channel 하나만 신경 쓰면 된다. 대신 도착 순서는 보장되지 않는다 — 순서가 필요하면 fan-in이 아니라 다른 설계를 골라야 한다.
func fanIn(channels ...<-chan string) <-chan string { var wg sync.WaitGroup merged := make(chan string) for _, ch := range channels { wg.Add(1) go func() { // source 하나당 goroutine 하나 defer wg.Done() for v := range ch { merged <- v } // close되면 끝 }() } go func() { wg.Wait() // 전부 끝나기를 기다렸다가 close(merged) // 그때 merged를 닫는다 }() return merged }
fanIn이 반환되지 않는다. 호출자가 range merged를 돌아야 값이 빠지는데, 그 전에 함수가 막혀 버려 deadlock이 된다.
func Process(in <-chan Job) <-chan Result. 그러면 이 블록을 여러 개 이어 붙여 다단 파이프라인을 만들 수 있고, 각 단계의 worker 수를 따로 조절할 수 있다. 6편에서 더 다룬다고 예고.nil channel은 아무것도 못 한다 — 보내도 막히고 받아도 막힌다. 버그처럼 보이는 이 성질이 select 안에서는 case를 끄는 스위치가 된다.
nil을 넣는 것만으로 case를 끌 수 있다var ch chan int // nil <-ch // 영원히 blocking ch <- 1 // 영원히 blocking select { case <-ch: // 절대 선택되지 않는다 case <-other: // 이쪽만 살아 있다 }
var a, b = (<-chan int)(ch1), (<-chan int)(ch2) // receive 전용으로 잡아둔다 for a != nil || b != nil { // 둘 다 nil = 전부 소진 select { case v, ok := <-a: if !ok { a = nil // 닫혔다 → 이 case를 끈다 continue } results = append(results, v) case v, ok := <-b: if !ok { b = nil continue } results = append(results, v) } }
0만 쌓으며 CPU를 태운다. 끄는 것 말고는 방법이 없다.
default를 넣는 순간 non-blocking이 된다. 대신 루프에 넣으면 busy-waittime.After 또는 ctx.Done()context.WithTimeout, 결과 channel에는 버퍼 1WaitGroup이 정한다nil을 넣으면 그 case가 꺼진다 — 소진된 소스를 끄는 표준 관용구time.After case를 둔다. → 슬라이드 11ctx.Err()로 초과인지 취소인지 구분할 수 있다. time.After는 그 select 한 곳에만 걸린다. → 슬라이드 15ok == false일 때 변수에 nil을 넣어 case를 꺼야 한다. → 슬라이드 23 · 24오늘까지는 주고받는 이야기였다. 그런데 goroutine 여럿이
같은 메모리를 함께 건드려야 하는 순간도 온다. 그때는 channel보다 자물쇠가 낫다.
다음 편은 CSP 바깥의 도구 — sync 패키지다.
WaitGroup을 오늘 세 번이나 썼지만 설명은 하지 않았다.
Mutex · RWMutex · Once까지 다음 편에서 제대로 본다.
참고 자료
github.com/kenshin579/tutorials-go /golang/concurrencyadvenoh.pe.kr