Golang Concurrency · 편 3 / 11

Select와
Channel 심화 패턴

여러 channel을 동시에 기다리는 단 하나의 구조
— timeout · fan-in/out · nil channel까지

select timeout fan-in · fan-out nil channel
select의 다섯 얼굴 keyword × 1
select ├─ case 만 있음 준비될 때까지 대기 ├─ + default 기다리지 않는다 ├─ + time.After 시간이 다 되면 깨어남 ├─ case <-nilCh 그 case는 꺼진다 └─ select {} 영원히 blocking
case 하나면 <-ch 와 같다 여럿이면 무작위
인사 + 시리즈 11편 중 3편. 앞 두 편에서 goroutine과 channel을 각각 봤으니, 오늘은 그 둘을 조합하는 이야기라고 위치를 잡아준다. 오른쪽 패널이 오늘 다룰 전부다 — 키워드는 select 하나뿐이고, 붙이는 것에 따라 얼굴이 다섯 개가 된다고 예고한다.
문제why select

channel 하나는 쉽다. 둘부터가 문제다

  • 두 API 중 먼저 응답하는 쪽을 쓰고 싶다
  • 그런데 <-ch1을 먼저 쓰면, ch2가 아무리 빨라도 ch1을 기다리느라 못 받는다
  • ch1이 영영 안 오면? 그 줄에서 프로그램이 멈춘다
  • 순서를 정하는 순간 먼저 오는 쪽이라는 요구가 깨진다
// 하나면 이걸로 충분하다
msg := <-ch1

// 둘이 되는 순간 —
a := <-ch1   // ch2가 먼저 와도
b := <-ch2   // 여기까지 못 온다

// select: 먼저 준비된 쪽을 고른다
select {
case m := <-ch1:
case m := <-ch2:
}
Go의 답 select여러 channel 연산을 나란히 세워 두고, 먼저 가능해지는 하나를 고르는 문법이다. 오늘 다루는 패턴은 전부 여기서 파생된다.
"두 곳에 요청을 보내고 먼저 오는 걸 쓰는 코드, 어떻게 짜셨어요?"로 열면 반응이 좋다. 순차 receive의 문제는 느린 게 아니라 순서를 강제한다는 점임을 못 박는다. 여기서 timeout 얘기가 먼저 나오면 3장에서 다룬다고 미뤄둔다.
시리즈11 parts

전체 시리즈 안에서 오늘의 위치

제목다루는 영역
편 1개요와 Goroutine 기초실행 단위
편 2Channel 완전 정복통신
편 3Select와 Channel 심화 패턴선택 · 조합
편 4 · 5sync 패키지 완벽 가이드 / Context 완벽 가이드동기화 · 생명주기
편 6 · 7동시성 패턴 실전 / 에러 처리 전략설계 패턴
편 8 · 9Memory Model과 Atomic / Race Detector저수준 · 디버깅
편 10 · 11실전 프로젝트 / go tool trace 시각화실전 · 도구

1편이 실행 단위, 2편이 통신 수단이었다면 오늘은 그 둘을 엮는 편이다.

goroutine과 channel을 아는 상태를 전제로 간다. 모르는 사람이 있으면 1·2편 링크를 안내하고 그대로 진행한다. 여기서 시간 쓰지 말 것 — 30초.
목차agenda

오늘 다루는 것

01
Select 문 기본
switch와 무엇이 다른가 · case 한 줄 해부 · 준비된 case가 여럿일 때의 무작위 선택
02
Default와 Non-blocking
기다리지 않는 receive / send · select의 다섯 형태 · busy-wait 함정
03
Timeout 처리
time.After · context.WithTimeout · 둘 중 무엇을 고를까
04
Fan-out / Fan-in
하나를 여럿에게 분배 · 여럿을 하나로 병합 · 둘을 붙인 파이프라인
05
Nil Channel 트릭
영원히 blocking하는 성질을 스위치로 쓰기 · 소진된 소스 끄기
1·2장은 문법, 3장부터가 실무. 시간이 모자라면 4장의 fan-out 코드 한 장(18)을 건너뛰고 개념 그림만 보여준 뒤 5장을 살린다. 5장(nil channel)이 오늘의 하이라이트라고 미리 찜해둔다.
01

Select 문 기본

생김새는 switch를 닮았지만 고르는 대상이 다르다. 무엇을 기준으로 case가 선택되는지, 그리고 여럿이 동시에 준비되면 어떻게 되는지까지 본다.

문법 장이라 빠르게 넘긴다. 세 장(06·07·08)이고 이 중 08(무작위 선택)만 확실히 각인시키면 된다.
01 · 기본select vs switch

switch는 을 보고, select는 준비 상태를 본다

  • 각 case는 반드시 channel 연산 하나다 — receive 또는 send
  • 고르는 기준은 값이 아니라 그 연산이 지금 가능한가
  • 가능한 case가 없으면 그 자리에서 기다린다
  • 실행되는 case는 정확히 하나. fallthrough는 없다
  • case가 하나뿐인 select는 그냥 <-ch와 같다
select {
case msg := <-ch1: // ch1이 먼저 오면
    fmt.Println("ch1:", msg)
case msg := <-ch2: // ch2가 먼저 오면
    fmt.Println("ch2:", msg)
}
// 둘 다 아직이면 여기서 멈춰 기다린다
한 줄 요약 select는 먼저 준비되는 쪽을 고르는 문법이다. 어느 쪽이 먼저일지는 코드가 정하지 않는다.
switch와 나란히 놓고 보면 이해가 빠르다. "case에 값 비교를 쓸 수 있나요?" → 없다. 반드시 channel 연산이다. send case도 된다는 점을 여기서 흘려두면 10번 슬라이드가 자연스럽다.
01 · 기본anatomy

case 한 줄은 네 조각으로 되어 있다

case v, ok := <-ch:
case후보 하나. 여러 개 중 하나만 실행된다
vchannel에서 꺼낸 값. 안 쓰면 생략 가능
okfalse면 channel이 닫힌
<-ch이 연산의 가능 여부가 선택 기준
ok를 눈여겨볼 것 닫힌 channel은 막힘 없이 계속 zero value를 내놓는다 — 즉 그 case는 영원히 준비 상태다. ok로 그 사실을 알아채는 게 5장 nil channel 트릭의 출발점이다.
여기서 ok를 미리 심어두는 게 목적이다. "닫힌 channel은 blocking하지 않는다"는 문장을 소리 내어 한 번 말하고 넘어간다 — 24번 슬라이드에서 이 한 문장이 다시 나온다.
01 · 기본pseudo-random

둘 다 준비됐다면 — 위에 쓴 게 먼저가 아니다

  • 준비된 case가 여럿이면 runtime이 무작위로 하나 고른다
  • 위에서부터 훑는 게 아니다 — 작성 순서에 기대면 안 된다
  • 덕분에 특정 channel만 계속 처리되는 starvation이 생기지 않는다
  • 우선순위가 필요하다면 select 바깥에서 직접 만들어야 한다
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
직접 돌려보면 매번 숫자가 조금씩 달라진다는 점을 강조. "그럼 우선순위를 주려면요?" 질문이 자주 나온다 — 바깥 select로 우선 channel을 non-blocking 확인한 뒤 안쪽 select로 넘기는 2단 구조를 말로만 설명하고 넘어간다.
02

Default와 Non-blocking

default 한 줄이 select의 성격을 통째로 바꾼다. 기다리는 구조에서 지금 되는지만 확인하는 구조로.

짧은 장이다. 두 장(10·11)이고, 11번의 busy-wait 경고가 실무에서 제일 자주 밟는 지뢰다.
02 · defaultnon-blocking

default가 있으면 select는 절대 멈추지 않는다

non-blocking receive
select {
case val := <-ch:
    fmt.Println("received:", val)
default:
    // 비어 있으면 즉시 여기로
    fmt.Println("no data")
}
non-blocking send
ch := make(chan int, 1)
ch <- 1 // 버퍼 가득

select {
case ch <- 2:
    fmt.Println("sent")
default:
    fmt.Println("buffer full")
}
기억법 default = "지금 안 되면 안 하고 넘어감". 값을 꼭 받아야 하는 자리에서 쓰면, 조용히 값을 흘리는 코드가 된다.
send 쪽 예시가 더 실용적이다 — 가득 찬 큐에 밀어 넣다 멈추는 대신 드롭하는 패턴(metrics·로그 수집)이 흔하다. "드롭해도 되는 데이터인가?"를 먼저 물으라고 조언한다.
02 · default한 장 정리

붙이는 것에 따라 얼굴이 다섯

형태동작쓰는 곳
case하나가 준비될 때까지 blocking기본형
+ default준비된 게 없으면 즉시 반환polling · 드롭 허용 송신
+ time.After정해진 시간이 지나면 깨어남timeout
case <-nilCh그 case는 영원히 선택 안 됨동적 비활성화
select {}영원히 blockingdeadlock — 거의 실수다
가장 흔한 실수 default가 든 select를 for 루프에 넣으면 busy-wait다. 아무 일도 없는데 CPU 코어 하나가 100%로 돈다. 기다려야 하는 자리라면 default를 빼고 timeout case를 둔다.
이 표가 오늘 문법 파트의 요약이다. 표를 읽어 내려가지 말고 2행(default)과 4행(nil)만 짚는다 — 4행은 5장 예고이기도 하다. busy-wait은 실제로 겪은 사례가 있으면 짧게 곁들인다.
03

Timeout 처리

Go에는 timeout 문법이 따로 없다. 시간이 지나면 값이 오는 channel을 case로 하나 더 세울 뿐이다. 그 채널을 어디서 얻느냐가 time.Aftercontext의 차이다.

여기부터 실무 파트다. 세 장(13·14·15)이고, 15번 비교표가 결론이다. 시간이 부족해도 15번은 반드시 남긴다.
03 · timeouttime.After

timeout은 특별한 문법이 아니라 또 하나의 case

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 하나가 늘어난 것뿐이다.
"timeout 키워드가 따로 있을 줄 알았는데 channel이네"라는 감상이 나오면 성공이다. 주의점 하나 — Go 1.23 이전에는 timeout으로 빠져나가도 타이머가 만료될 때까지 회수되지 않았다. 루프 안에서 짧은 간격으로 쓰면 메모리가 쌓였다. 지금은 개선됐지만 알아두면 좋다.
03 · timeout실무 기본값

실무에서는 context.WithTimeout을 쓴다

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
    }
}
버퍼 1이 핵심이다 timeout으로 빠져나간 뒤에도 goroutine은 값을 넣고 정상 종료한다. unbuffered였다면 받을 사람이 없어 send에서 영원히 멈춘다 — goroutine leak이다.
호출부는 ctx, cancel := context.WithTimeout(...) + defer cancel(). cancel을 빼먹으면 timeout 전에 끝나도 타이머가 남는다고 짚는다. 버퍼 1 이야기는 1편 leak 슬라이드와 이어진다 — "만들 때 종료 경로를 함께 만든다"의 실제 사례다.
03 · timeoutA vs B

무엇을 언제 고를까

항목time.Aftercontext.WithTimeout
취소 전파없음 — 나만 빠져나온다하위 goroutine까지 전파된다
적용 범위select 한 곳호출 체인 전체
뒷정리필요 없음defer cancel() 필수
이유 구분없음ctx.Err() — 초과인지 취소인지
쓰는 곳테스트 · 단발성 대기API 호출 · 서버 핸들러
고르는 기준 나 혼자 그만 기다리면 되면 time.After, 내가 부른 사람들도 멈춰야 하면 context. 실무 코드는 대부분 후자다.
한 줄만 남긴다면 "혼자 빠져나오는가, 남에게 알리는가". context 자체는 5편에서 통째로 다룬다고 예고하고 깊이 들어가지 않는다.
04

Fan-out / Fan-in

일을 여럿에게 나눠 주고(fan-out), 흩어진 결과를 다시 하나로 모은다(fan-in). 둘을 이어 붙이면 그게 곧 동시성 파이프라인이다.

오늘 가장 실전적인 장. 다섯 장(17~21)이라 길다. 코드 두 장(18·20)은 화면에 띄워만 두고 주석 위주로 설명하면 시간이 맞는다.
04 · 패턴fan-out

Fan-out — 하나의 큐를 여럿이 나눠 가진다

jobschan int, 공유
Worker 1go func()
Worker 2go func()
Worker 3go func()
results[0]chan int
results[1]chan int
results[2]chan int

화살표는 데이터가 흐르는 방향이다. worker 셋이 같은 jobs channel에서 경쟁적으로 꺼내 가며, 누가 몇 개를 가져갈지는 정해져 있지 않다 — 빨리 끝낸 worker가 더 많이 가져간다(자연스러운 부하 분산).

"작업을 3등분해서 나눠 주는 것"이 아니라 "다 같이 하나의 큐를 빤다"는 점이 핵심. 전자는 느린 worker가 전체를 붙잡지만 후자는 그렇지 않다. worker 수는 보통 CPU 코어 수나 외부 시스템이 견디는 동시 요청 수로 정한다.
04 · 패턴fan-out 구현

worker를 멈추는 건 close(jobs) 하나다

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 이상에서만 안전하다.
코드를 한 줄씩 읽지 말고 세 덩어리로 짚는다 — 준비 / worker 띄우기 / 작업 밀어넣고 닫기. 1.22 루프 변수 이야기는 구버전 코드베이스를 쓰는 사람에게 특히 중요하다. 인자로 func(i int)를 넘기는 옛 방식도 한 번 언급한다.
04 · 패턴fan-in

Fan-in — 흩어진 결과를 한 줄로 되돌린다

source1chan string
source2chan string
source3chan string
mergedchan string
consumerfor v := range merged

source마다 goroutine 하나가 붙어 merged로 값을 넘긴다. 소비자는 channel 하나만 신경 쓰면 된다. 대신 도착 순서는 보장되지 않는다 — 순서가 필요하면 fan-in이 아니라 다른 설계를 골라야 한다.

소비자 코드가 단순해지는 게 fan-in의 진짜 이득이다. select로 세 channel을 직접 다루는 것과 비교해 보여주면 좋다 — 소스가 늘어날 때마다 select case를 고칠 필요가 없다.
04 · 패턴fan-in 구현

언제 close할지를 WaitGroup이 정한다

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
}
왜 wg.Wait()를 goroutine에 넣나 여기서 그냥 기다리면 fanIn반환되지 않는다. 호출자가 range merged를 돌아야 값이 빠지는데, 그 전에 함수가 막혀 버려 deadlock이 된다.
이 슬라이드의 질문은 하나다 — "close는 누가 하나?" 답: 보내는 쪽. 그런데 보내는 쪽이 여럿이라 아무도 혼자서는 못 닫는다. 그래서 WaitGroup이 "전부 끝났다"를 판정하는 심판 역할을 한다.
04 · 패턴pipeline

둘을 이어 붙이면 파이프라인이 된다

입력chan Job
Worker 1처리
Worker 2처리
Worker 3처리
결과chan Result
역할이 다르다 fan-out은 처리량을 늘리고, fan-in은 흩어진 결과를 다시 하나로 되돌린다. 붙이면 바깥에서 볼 때 channel 하나 넣고 channel 하나 받는 단순한 함수가 된다.
이 그림 자체를 함수 하나로 감싸는 게 실전이다 — func Process(in <-chan Job) <-chan Result. 그러면 이 블록을 여러 개 이어 붙여 다단 파이프라인을 만들 수 있고, 각 단계의 worker 수를 따로 조절할 수 있다. 6편에서 더 다룬다고 예고.
05

Nil Channel 트릭

nil channel은 아무것도 못 한다 — 보내도 막히고 받아도 막힌다. 버그처럼 보이는 이 성질이 select 안에서는 case를 끄는 스위치가 된다.

오늘의 하이라이트. 두 장(23·24)이지만 24번 코드에서 시간을 넉넉히 쓴다. 여기서 "아, 그래서 ok를 받았구나"가 연결되면 발표가 완성된다.
05 · nil channel성질

영원히 막히는 성질이 여기서는 쓸모가 된다

  • nil channel에 send하면 영원히 blocking
  • nil channel에서 receive해도 영원히 blocking
  • 그래서 select 안에서 그 case는 영원히 준비되지 않는다 — 사실상 무시된다
  • 즉, channel 변수에 nil을 넣는 것만으로 case를 끌 수 있다
var ch chan int // nil

<-ch      // 영원히 blocking
ch <- 1  // 영원히 blocking

select {
case <-ch:    // 절대 선택되지 않는다
case <-other: // 이쪽만 살아 있다
}
뒤집어 생각하기 select에서 case를 지울 수는 없다. 코드는 고정이니까. 대신 그 case가 보는 변수를 nil로 바꾸면 지운 것과 같아진다.
"nil channel에 보내면 panic 아닌가요?"가 단골 질문이다 — panic은 닫힌 channel에 보낼 때다. nil은 조용히 멈춘다. 이 둘을 여기서 확실히 갈라놓는다.
05 · nil channel동적 비활성화

소진된 소스는 nil로 바꿔 끈다

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)
    }
}
nil로 바꾸지 않으면 닫힌 channel은 즉시 zero value를 무한정 내놓는다. 그 case가 계속 선택되면서 루프가 0만 쌓으며 CPU를 태운다. 끄는 것 말고는 방법이 없다.
여기가 오늘의 클라이맥스다. 먼저 "nil 대입 두 줄을 지우면 어떻게 될까요?"를 물어본 뒤 콜아웃을 연다. 쓰는 자리는 두 가지 — ① 소스를 merge하다 완료된 것부터 끄기 ② 조건에 따라 특정 channel 처리를 on/off. 둘 다 select 구조는 그대로 두고 변수만 바꾼다는 게 요점.
마무리recap

오늘 가져갈 것

  • select는 준비된 case 하나를 고른다. 여럿이면 무작위 — 작성 순서에 기대지 말 것
  • default를 넣는 순간 non-blocking이 된다. 대신 루프에 넣으면 busy-wait
  • timeout은 문법이 아니라 또 하나의 case다 — time.After 또는 ctx.Done()
  • 취소를 전파해야 하면 context.WithTimeout, 결과 channel에는 버퍼 1
  • fan-out은 처리량, fan-in은 병합. close는 보내는 쪽이 하고 시점은 WaitGroup이 정한다
  • channel 변수에 nil을 넣으면 그 case가 꺼진다 — 소진된 소스를 끄는 표준 관용구
여섯 줄을 소리 내어 읽으면 그대로 정리가 된다. 하나만 남긴다면 마지막 줄 — nil channel 트릭은 다른 언어에 대응물이 없다. 여기서부터 질문을 받아도 좋다.
확인클릭하면 답이 열립니다

스스로 답해보기

아니다. runtime이 무작위로 고른다. 우선순위가 필요하면 select 바깥에서 직접 만들어야 한다. → 슬라이드 08
준비된 case가 없을 때마다 즉시 default로 빠져나와 루프가 쉬지 않고 돈다 — busy-wait. 기다려야 한다면 default를 빼고 time.After case를 둔다. → 슬라이드 11
context. 취소가 호출 체인 전체로 전파되고 ctx.Err()로 초과인지 취소인지 구분할 수 있다. time.After는 그 select 한 곳에만 걸린다. → 슬라이드 15
결과 channel이 unbuffered면 받을 사람이 없어 send에서 영원히 멈춘다 — goroutine leak이다. 버퍼 1을 주면 값을 넣고 정상 종료한다. → 슬라이드 14
닫힌 channel은 항상 즉시 zero value를 준다. 그 case가 계속 선택되며 루프가 헛돈다. ok == false일 때 변수에 nil을 넣어 case를 꺼야 한다. → 슬라이드 23 · 24
청중에게 먼저 답하게 한 뒤 클릭해서 연다. 시간이 없으면 Q4와 Q5만 골라도 충분하다 — 둘 다 실무에서 실제로 밟는 지뢰다.
다음part 4

다음 편 — sync 패키지

오늘까지는 주고받는 이야기였다. 그런데 goroutine 여럿이 같은 메모리를 함께 건드려야 하는 순간도 온다. 그때는 channel보다 자물쇠가 낫다. 다음 편은 CSP 바깥의 도구 — sync 패키지다.

오늘 미뤄둔 것들 WaitGroup을 오늘 세 번이나 썼지만 설명은 하지 않았다. Mutex · RWMutex · Once까지 다음 편에서 제대로 본다.

참고 자료

  • 전체 코드 — github.com/kenshin579/tutorials-go /golang/concurrency
  • Go Tour — Select go.dev/tour/concurrency/5
  • Go Blog — Pipelines and cancellation go.dev/blog/pipelines
  • 이전 편 — Channel 완전 정복 advenoh.pe.kr
감사합니다 질문 환영합니다 — advenoh.pe.kr
GitHub 저장소 주소를 띄워둔 채로 Q&A를 받는다. "channel과 mutex 중 뭘 쓰나요?" 질문이 나오면 다음 편의 첫 슬라이드가 정확히 그 얘기라고 답한다.
발표자 노트
← 글 보기 Select와 Channel 심화 · Golang Concurrency 3
01 / 27

단축키

→ · Space · PgDn
다음 슬라이드
← · PgUp
이전 슬라이드
Home · End
처음 · 마지막
O
전체 슬라이드 개요
N
발표자 노트
T
밝은 테마 / 어두운 테마
F
전체 화면
Esc
닫기