Golang Concurrency · 편 2 / 11

Channel
완전 정복

메모리를 공유해 통신하지 말고, 통신해서 공유하라
— 파이프 하나가 어떻게 동기화까지 대신하는가

Unbuffered Buffered 방향 제한 close · range
channel 한 살이 make → send → close
make(chan T, n) 타입 있는 파이프를 연다 ├─ n = 0 unbuffered · 핸드셰이크 └─ n > 0 buffered · 큐 ch <- v 보내는 쪽 v, ok := <-ch 받는 쪽 close(ch) 더 안 보낸다는 선언 └─ range 는 이 신호를 보고 멈춘다
보내는 쪽이 만들고 · 보내고 · 닫는다 받는 쪽은 받기만 한다
인사 + 시리즈 11편 중 2편. 1편에서 goroutine을 "띄우는 것"까지 했으니, 오늘은 그들 사이에 값을 흘려보내는 이야기라고 연결한다. 오른쪽 트리가 오늘의 목차이자 결론 — 이 여섯 줄만 몸에 붙으면 채널 코드의 90%는 읽힌다고 말해둔다.
문제why channel

goroutine을 띄웠는데, 결과를 받아올 길이 없다

  • 1편에서 배운 건 실행 단위까지였다 — 값을 주고받는 방법은 아직 없다
  • 공유 변수 + 뮤텍스로도 되지만, 잠그는 순서를 사람이 기억해야 한다
  • Go의 답은 CSP — 값을 건네주면 소유권도 함께 넘어간다
  • channel은 통신과 동기화를 한 번에 해결한다. 잠글 것이 없다
// 공유 메모리 — 잠금 순서를 사람이 지킨다
mu.Lock()
result = 42
mu.Unlock()

// channel — 건네주면 소유권도 넘어간다
ch := make(chan int)
go func() { ch <- 42 }()
v := <-ch
Go Proverb Don't communicate by sharing memory; share memory by communicating.
"goroutine 세 개 띄워서 결과 모아본 적 있으세요?"로 시작하면 좋다. 뮤텍스가 나쁘다는 얘기가 아니라(4편에서 sync를 제대로 다룬다), 값을 넘기는 문제에는 channel이 더 맞는 도구라는 정도로만 말한다. 여기서 오래 끌지 말 것 — 1분.
01

Channel 기본

만드는 법과 값을 넣고 빼는 법. 문법은 십 분이면 끝나지만, 여기서 정확히 읽는 훈련을 해두지 않으면 뒤의 blocking·close가 전부 헷갈린다.

목표는 "코드를 보고 방향을 바로 읽는 것". 문법 자체는 짧으니 3~4분에 끊고 blocking으로 넘어간다.
01 · 기본make

Channel은 타입이 정해진 파이프

  • make(chan T) — T 타입만 흐른다. 다른 타입은 컴파일 에러
  • 두 번째 인자가 버퍼 크기다. 생략하면 0
  • 이 숫자 하나가 동기화 성질을 바꾼다 — 0이면 핸드셰이크, 1 이상이면 큐
  • zero value는 nil이다. nil channel은 send·receive 모두 영원히 멈춘다
ch := make(chan int)        // unbuffered
ch := make(chan string, 5) // buffered (5칸)

ch := make(chan Result)     // 구조체도 된다
ch := make(chan chan int)   // channel의 channel도

var ch chan int             // nil — 절대 진행되지 않는다
숫자 하나의 무게 make(chan int)make(chan int, 1)타입이 같다. 그런데 동기화 성질은 완전히 다르다.
nil channel 얘기는 여기서는 "그런 게 있다" 정도로만. 3편 select에서 "케이스를 끄는 스위치"로 쓰이면서 다시 나온다고 예고하면 좋다.
01 · 기본<-

화살표의 위치 하나로 send와 receive가 갈린다

  • 화살표가 channel 오른쪽이면 send — ch <- v
  • 화살표가 channel 왼쪽이면 receive — v := <-ch
  • 값을 안 받아도 된다. <-ch만 쓰면 "올 때까지 기다린다"는 뜻
  • 두 값 형태 v, ok := <-chok는 channel이 살아 있는지를 알려준다
func TestChannelSendReceive(t *testing.T) {
    ch := make(chan int)

    go func() {
        ch <- 42 // send
    }()

    value := <-ch // send될 때까지 blocking
    assert.Equal(t, 42, value)
}
읽는 요령 화살표는 언제나 값이 흐르는 방향을 가리킨다. channel로 들어가면 send, 밖으로 나오면 receive.
여기서 한 번 멈추고 청중에게 코드를 소리 내어 읽게 해도 좋다. value := <-ch 줄이 "42가 올 때까지 여기서 멈춘다"는 뜻이라는 걸 짚는 게 이 장의 전부다.
01 · 기본any type

구조체를 보내면 결과와 에러가 함께 온다

func TestChannelStructType(t *testing.T) {
    type Result struct {
        Value int
        Err   error
    }
    ch := make(chan Result)

    go func() {
        ch <- Result{Value: 100, Err: nil}
    }()

    result := <-ch
    assert.Equal(t, 100, result.Value)
    assert.NoError(t, result.Err)
}
왜 이렇게 쓰나 에러용 channel을 따로 두면 둘의 짝을 맞추는 일이 새로 생긴다. 값과 에러를 한 구조체로 묶어 보내면 그 문제가 사라진다.
실무에서 가장 자주 쓰는 형태다. "채널 두 개(result, err)로 나눠본 분?" 하고 물어보면 반응이 온다 — 그 경우 select에서 둘 중 뭐가 먼저 올지 순서 문제가 생긴다는 얘기까지만 하고 3편으로 넘긴다.
02

Blocking과 버퍼

channel의 본질은 "값을 옮긴다"가 아니라 "기다린다"에 있다. 언제 멈추는지를 알면 unbuffered와 buffered의 차이는 저절로 따라온다.

오늘 발표의 중심. 시간이 부족하면 다른 장을 줄이고 여기에 쓴다. 8~12분.
02 · Blockingby design

Blocking은 부작용이 아니라 동기화 장치

Unbuffered — 만나야 진행

send와 receive가 동시에 준비되어야 값이 건너간다.

먼저 도착한 쪽이 상대를 기다린다. 만나는 그 순간이 두 goroutine의 동기화 지점이 된다.

Buffered — 자리가 있으면 통과

버퍼에 빈칸이 있으면 send는 즉시 끝난다.

가득 차면 그때부터 sender가 기다리고, 비면 receiver가 기다린다. 큐가 하나 끼어든 셈이다.

한 줄 요약 기다림 자체가 순서를 만든다. channel을 쓰면 lock 없이도 "A 다음에 B"가 보장되는 이유다.
"blocking = 나쁜 것"이라는 선입견을 여기서 깬다. 뒤의 두 장(09·10)이 이 카드 두 개를 그림으로 푼 것이라고 미리 말해두면 따라오기 쉽다.
02 · Blockingunbuffered

Unbuffered — 양쪽이 만나야 값이 건너간다

Goroutine Ach <- 42 — 여기서 대기
핸드셰이크둘이 동시에 진행
Goroutine Bv := <-ch — 여기서 대기

가운데가 만나는 지점이다. 어느 쪽이 먼저 도착하든 상대가 올 때까지 멈춰 서 있는다.

그래서 쓸모가 있다 값이 건너간 시점에 양쪽 모두 그 사실을 안다. 이 성질이 그대로 "작업 완료 확인"이 된다.
"악수"라는 비유가 잘 먹힌다 — 한 사람만 손을 내밀면 악수가 아니다. buffered와 대비시키려고 일부러 같은 그림 틀을 다음 장에서 반복한다고 말해둔다.
02 · Blockingbuffered

Buffered — 버퍼가 속도 차를 흡수한다

Producersend ×3 즉시 통과
Buffer (크기 3)4번째 send부터 blocking
Consumer비어 있으면 receive가 대기

선은 값이 흐르는 방향이다. 버퍼는 FIFO — 넣은 순서 그대로 나온다.

착각하기 쉬운 것 버퍼는 순간적인 뾰족함만 흡수한다. 평균 생산 속도가 소비 속도보다 빠르면 크기를 얼마로 잡든 결국 막힌다.
"버퍼를 키우면 성능이 좋아지나요?" 질문이 자주 나온다. 답은 콜아웃에 있다 — 평균 속도가 뒤집히지 않는 한 버퍼는 문제를 미룰 뿐이다.
02 · Blocking코드로 확인

Unbuffered — sender는 receiver를 기다리며 서 있다

func TestUnbufferedChannel(t *testing.T) {
    ch := make(chan int) // 버퍼 크기 0

    go func() {
        ch <- 42 // receiver가 준비될 때까지 여기서 blocking
    }()

    time.Sleep(100 * time.Millisecond) // sender는 이미 대기 중
    value := <-ch                      // 받는 순간 sender도 풀린다
    assert.Equal(t, 42, value)
}
여기서 go를 지우면? main goroutine이 자기 자신을 기다리게 된다 — fatal error: all goroutines are asleep - deadlock!
Sleep은 설명용이지 실무 코드가 아니라는 걸 반드시 짚는다. 콜아웃의 deadlock 메시지는 다들 한 번쯤 본 것이라 여기서 웃음이 나온다 — 왜 그 메시지가 뜨는지 이제 설명할 수 있게 됐다고 마무리.
02 · Blocking코드로 확인

Buffered — receiver가 없어도 버퍼만큼은 통과한다

func TestBufferedChannel(t *testing.T) {
    ch := make(chan int, 3)

    // receiver가 없어도 3개까지는 send가 즉시 끝난다
    ch <- 1
    ch <- 2
    ch <- 3
    // ch <- 4 → 여기서 blocking (버퍼 가득 참)

    assert.Equal(t, 1, <-ch)
    assert.Equal(t, 2, <-ch)
    assert.Equal(t, 3, <-ch)
}
달라진 것 goroutine이 하나도 없다. unbuffered였다면 첫 줄 ch <- 1에서 바로 deadlock이다.
앞 장과 나란히 두고 "goroutine이 사라졌다"는 점을 짚는 게 이 장의 핵심. 주석 처리된 4번째 send를 살리면 어떻게 되는지 물어보고 답을 받아본다 — 퀴즈 Q2와 연결된다.
02 · Blockingcap · len

cap은 그릇의 크기, len은 지금 담긴 양

  • cap(ch) — 만들 때 정한 버퍼 용량. 끝까지 변하지 않는다
  • len(ch) — 지금 버퍼에 쌓여 있는 값의 개수
  • unbuffered channel은 언제나 cap 0, len 0
  • 모니터링·로그에는 쓸 만하다. 하지만 흐름 제어에 쓰면 안 된다
ch := make(chan string, 5)

assert.Equal(t, 5, cap(ch)) // 용량
assert.Equal(t, 0, len(ch)) // 대기 중인 값

ch <- "a"
ch <- "b"

assert.Equal(t, 5, cap(ch))
assert.Equal(t, 2, len(ch))
len으로 분기하지 말 것 if len(ch) < cap(ch) { ch <- v } — 확인과 send 사이에 다른 goroutine이 채워 넣을 수 있다. 그 자리는 selectdefault가 맡는다(편 3).
읽는 순간 이미 옛날 값이라는 점 — 전형적인 TOCTOU다. 여기서 3편 예고를 한 번 더 깔아둔다.
02 · Blocking고르는 법

언제 어떤 것을 고를까

상황선택이유
goroutine 간 핸드셰이크Unbuffered만나는 순간이 곧 동기화 지점
완료 · 종료 신호 (done, quit)Unbuffered신호는 쌓일 이유가 없다
생산 · 소비 속도 차 완충Buffered짧게 몰리는 구간을 흡수
Producer / ConsumerBuffered서로를 매번 기다리지 않는다
대량 데이터 전달Buffered컨텍스트 전환 횟수가 줄어든다
고민되면 unbuffered 버퍼는 문제를 숨기는 쪽으로 작동한다. 병목이 실제로 보일 때 크기를 정해 붙이는 편이 낫다.
표를 읽어 내리지 말고 콜아웃 한 줄만 말한다. "버퍼 크기는 몇으로?" 질문에는 "측정 없이 정하지 마세요"가 정답 — 대개 1이나 코어 수 같은 근거 없는 숫자가 박혀 있다.
03

방향 제한

타입에 방향을 새겨 넣으면, 잘못된 사용이 컴파일 단계에서 걸린다. 런타임 비용은 0이고 문법은 화살표 하나 옮기는 게 전부다.

짧게 4분. 문법이 간단해서 빨리 지나갈 수 있지만 "시그니처가 문서다"라는 메시지는 꼭 남긴다.
03 · 방향chan<- · <-chan

화살표를 chan 뒤에 두면 넣기, 앞에 두면 꺼내기

  • chan<- intsend 전용. 받으려 하면 컴파일 에러
  • <-chan intreceive 전용. 보내거나 닫으면 컴파일 에러
  • 런타임 비용은 0이다. 순수하게 컴파일 타임 검사
  • 주로 함수 파라미터와 반환 타입에 쓴다. 변수 선언에 쓸 일은 드물다
chan int     // 양방향
chan<- int   // send-only — 넣기만
<-chan int   // receive-only — 꺼내기만

func produce(ch chan<- int) { ch <- 1 } // OK
func consume(ch <-chan int) { <-ch }   // OK

func bad(ch <-chan int) { close(ch) } // 컴파일 에러
타입이 곧 문서다 시그니처만 보고도 이 함수가 넣는 쪽인지 꺼내는 쪽인지 알 수 있다. 주석보다 강한 계약이다.
외우는 요령: 화살표가 chan 쪽을 향하면 들어가는 것, chan에서 빠져나오면 나오는 것. 헷갈리면 ch <- v·<-ch 두 표현을 떠올리면 된다고 알려준다.
03 · 방향계약

시그니처가 곧 역할 선언이다

// producer — send-only. 여기서는 받을 수 없다
func produce(ch chan<- int, values []int) {
    for _, v := range values {
        ch <- v
    }
    close(ch) // 보낸 쪽이 닫는다
}
// consumer — receive-only. 여기서는 닫을 수 없다
func consume(ch <-chan int) []int {
    var out []int
    for v := range ch {
        out = append(out, v)
    }
    return out
}
이 한 쌍이 기본형 보내는 함수가 닫고, 받는 함수는 range로 끝까지 읽는다. 오늘 나머지 예제는 전부 이 모양의 변주다.
호출부에서는 양방향 ch := make(chan int, 5)를 그냥 넘기면 된다는 점을 말해준다 — 변환이 자동이라 호출하는 쪽 코드는 하나도 안 바뀐다. 그 얘기가 다음 장이다.
03 · 방향한 방향뿐

권한은 좁히는 쪽으로만 흐른다

되는 방향 — 암묵적 변환

chan intchan<- int

chan int<-chan int

대입도, 함수 인자 전달도 그냥 된다. 캐스팅을 따로 쓸 필요가 없다.

안 되는 방향 — 컴파일 에러

chan<- intchan int

<-chan intchan int

한 번 좁힌 권한은 되돌릴 수 없다. 넓히려면 원래의 양방향 channel을 직접 들고 있어야 한다.

설계에 쓰는 법 양방향 channel은 만든 곳에만 두고, 밖으로는 좁힌 타입만 내보낸다. 그러면 규칙을 어길 방법 자체가 없어진다.
"private 필드를 getter로만 노출하는 것과 같은 발상"이라고 비유하면 객체지향 배경이 있는 청중에게 잘 전달된다. 23번 generator 패턴이 정확히 이 얘기의 코드판이다.
04

Close

close는 채널을 없애는 게 아니라 "더 이상 보내지 않겠다"고 알리는 일이다. 규칙 셋을 어기면 컴파일이 아니라 런타임 panic으로 터진다.

여기가 실무 사고가 가장 많이 나는 지점. 규칙 표(22)와 generator 패턴(23)에 시간을 쓴다. 6~8분.
04 · Close선언

close는 파괴가 아니라 선언이다

  • 채널을 없애지 않는다. 버퍼에 남은 값은 그대로 다 꺼낼 수 있다
  • "값이 다 갔다"가 아니라 "더 안 보낸다"는 뜻이다
  • 그래서 보내는 쪽만 닫을 수 있다 — 받는 쪽은 끝을 알 방법이 없다
  • 항상 닫아야 하는 건 아니다. range로 받는 쪽이 있을 때 필요하다
ch := make(chan int, 2)
ch <- 1
ch <- 2
close(ch) // 더 보내지 않겠다

fmt.Println(<-ch) // 1 — 남은 값은 그대로
fmt.Println(<-ch) // 2
fmt.Println(<-ch) // 0 — 다 비었다
메모리와는 무관하다 close하지 않아도 참조가 사라지면 GC가 회수한다. close는 정리가 아니라 신호다.
"파일 닫듯이 반드시 닫아야 하나요?" 질문이 꼭 나온다. 답은 콜아웃 — 리소스 해제가 아니라 프로토콜이다. 닫는 이유는 오직 받는 쪽에게 끝을 알리기 위해서다.
04 · Closev, ok

닫힌 channel은 zero value + false를 즉시 돌려준다

func TestReceiveFromClosedChannel(t *testing.T) {
    ch := make(chan int, 1)
    ch <- 42
    close(ch)

    // 버퍼에 값이 남아 있으면 정상 반환
    val, ok := <-ch
    assert.Equal(t, 42, val)
    assert.True(t, ok) // 유효한 값

    // 버퍼가 비고 닫혔으면 → zero value + false
    val, ok = <-ch
    assert.Equal(t, 0, val) // int의 zero value
    assert.False(t, ok)     // 닫힘 표시
}
ok 없이는 구분할 수 없다 닫혀서 나온 0인지 sender가 진짜 보낸 0인지는 오직 ok 안다. 그리고 blocking도 하지 않는다 — 즉시 돌아온다.
"닫힌 채널에서 계속 받으면 무한 루프 돌지 않나요?" — 그렇다, blocking 없이 zero value가 무한히 나온다. 그래서 ok 검사나 range가 필요하다. 다음 장으로 자연스럽게 연결된다.
04 · Close규칙 넷

셋은 panic, 하나는 안전

규칙어기면
sender가 close한다receiver가 닫으면 sender가 닫힌 channel에 send → panic
두 번 close하지 않는다panic: close of closed channel
닫힌 channel에 send하지 않는다panic: send on closed channel
닫힌 channel에서 receive는 안전하다허용 — zero value + false를 즉시 반환
컴파일러는 못 잡는다 셋 다 런타임에 터진다. 그래서 "누가 언제 닫는가"를 코드가 아니라 설계로 못 박아야 한다.
여기서 실제 장애 경험 하나를 얹으면 좋다 — 재시도 로직이 close를 두 번 부르는 형태가 흔하다. 해법은 sync.Once나 "닫는 주체를 하나로 모으기"인데, 후자가 다음 장이다.
04 · Closegenerator

만들고 · 보내고 · 닫는 일을 한 곳에 모은다

func generator() <-chan int {
    ch := make(chan int)
    go func() {
        defer close(ch) // 보낸 쪽이 닫는다
        for i := range 5 {
            ch <- i
        }
    }()
    return ch // receive-only — 밖에서는 닫을 수 없다
}

for v := range generator() {
    fmt.Println(v) // 0 1 2 3 4
}
규칙을 어길 방법이 없다 반환 타입을 <-chan으로 좁혔으니 호출자는 close도 send도 못 한다. 22번 규칙 셋이 전부 컴파일 에러로 바뀐 셈이다.
오늘 발표에서 딱 한 장만 사진 찍으라면 이 장이라고 말해도 좋다. defer close(ch)가 goroutine 안 첫 줄에 있다는 점 — panic이 나도 닫힌다. 파이프라인(3편)의 기본 벽돌이기도 하다.
05

Range와 신호

받는 쪽의 관용구인 range, 그리고 값을 담지 않는 채널 chan struct{}. 둘 다 앞에서 배운 close 위에 얹혀 있다.

close 규칙을 방금 했으니 자연스럽게 이어진다. 5~6분.
05 · Rangefor range ch

range의 종료 조건은 오직 close 하나

  • for v := range ch — 값이 올 때까지 기다렸다가 하나씩 꺼낸다
  • 끝나는 경우는 딱 하나, channel이 닫히는 것. 개수를 세지 않는다
  • close를 잊으면 영원히 blocking된다 — goroutine leak의 단골 원인
  • v, ok := <-ch를 직접 쓰는 것과 같지만 ok 검사를 대신해준다
ch := make(chan int, 5)
go func() {
    for i := 1; i <= 5; i++ {
        ch <- i
    }
    close(ch) // 없으면 range가 끝나지 않는다
}()

for v := range ch { // 1 2 3 4 5
    out = append(out, v)
}
짝을 맞춘다 range로 읽는 코드를 쓰는 순간, 그 channel을 누가 언제 닫는지도 함께 정해야 한다. 둘은 한 세트다.
"range는 몇 번 도나요?"라는 질문에 "닫힐 때까지"라고 답할 수 있으면 이 장은 끝. 무한 대기는 3편의 select + ctx.Done()으로 끊는다고 예고.
05 · 신호chan struct{}

데이터가 아니라 "끝났다"만 보내고 싶을 때

  • struct{}는 필드가 없어 0바이트다 — 값을 담지 않겠다는 의도가 타입에 드러난다
  • chan bool을 쓰면 "true는 뭐고 false는 뭐지?"라는 질문이 따라온다
  • 신호는 쌓일 이유가 없으니 보통 unbuffered로 만든다
  • 이름은 관례적으로 done, quit, ready
done := make(chan struct{})

go func() {
    // 작업 수행...
    close(done) // 완료 신호
}()

<-done // 완료될 때까지 대기
비용 unsafe.Sizeof(struct{}{})0이다 — 오가는 값에 드는 비용이 없다. (channel 자체의 비용은 별개다)
"왜 굳이 struct{}냐, bool 쓰면 안 되냐"는 질문이 반드시 나온다. 답은 성능이 아니라 의도 표현이라고 정리한다 — 값에 의미가 없다는 걸 타입으로 못 박는 것.
05 · 신호브로드캐스트

close는 전원에게, send는 한 명에게

done <- struct{}{}

기다리는 receiver 한 명만 깨운다.

셋이 기다리면 세 번 보내야 한다. 즉 sender가 수신자 수를 알고 있어야 한다.

close(done)

기다리는 전원이 동시에 깨어난다.

몇 명이든 상관없고, 닫힌 뒤에 도착한 receiver도 즉시 통과한다. 신호를 놓칠 수 없다.

그래서 취소·종료에는 close 수신자 수를 몰라도 되고 늦게 온 쪽도 놓치지 않는다. context.Done()이 정확히 이 방식이다 — 편 5에서 다시 만난다.
"신호를 놓칠 수 없다"가 핵심 — send 방식은 receiver가 늦게 도착하면 이미 지나간 신호를 못 받는다. context가 왜 채널을 닫는 방식으로 설계됐는지 여기서 납득시킨다.
05 · 신호FAQ

struct{}는 설계도, struct{}{}는 실물

표현의미비유
intint 타입설계도
42int 값실물
struct{}빈 구조체 타입설계도 — 필드가 없는
struct{}{}빈 구조체 실물 — 내용이 없는
끊어 읽는 법 struct{} + {} — 앞이 타입, 뒤가 빈 리터럴이다. Person{}Person + {}인 것과 똑같다. 그래서 make(chan struct{})에는 타입이, done <- struct{}{}에는 값이 온다.
중괄호 네 개가 붙어 있는 게 이상해 보일 뿐, 규칙은 평범하다는 걸 보여주는 장. 30초면 충분하니 빨리 넘어간다.
06

실습 — Producer / Consumer

지금까지 배운 것을 한 코드에 모은다. producer가 여럿이 되는 순간 "누가 닫는가"라는 질문이 실제 설계 문제로 바뀐다.

여기부터는 앞의 조각들을 조립하는 시간. 새 개념은 WaitGroup 하나뿐이고 그건 4편 주제라고 미리 선을 긋는다.
06 · 실습1 : 1

Producer 하나, Consumer 하나 — 기본형

func TestProducerConsumer(t *testing.T) {
    ch := make(chan int, 10)
    var results []int

    go func() { // Producer — 제곱수를 만든다
        for i := 1; i <= 10; i++ {
            ch <- i * i
        }
        close(ch) // 다 보냈다는 선언
    }()
    for val := range ch { // Consumer — 닫힐 때까지 모은다
        results = append(results, val)
    }
    assert.Len(t, results, 10)
}
버퍼 10의 역할 producer가 consumer를 매번 기다리지 않아도 된다. 0으로 바꿔도 결과는 같지만, 그때는 한 값마다 손을 맞잡는다.
앞의 모든 조각이 들어 있다 — make, send, close, range, 그리고 buffered의 이유까지. 코드를 위에서 아래로 한 번 짚어주면 복습이 된다.
06 · 실습N : 1

Producer가 여럿이면 아무도 혼자 닫을 수 없다

ch := make(chan int, 20)
var wg sync.WaitGroup

for p := range 3 { // Producer 3개
    wg.Add(1)
    go func() {
        defer wg.Done()
        for i := range 5 {
            ch <- p*100 + i
        }
    }()
}

go func() { // 감시자 — 전원이 끝나야 닫는다
    wg.Wait()
    close(ch)
}()
닫는 역할을 따로 세운다 producer 셋 중 누구도 "내가 마지막"인지 모른다. WaitGroup을 기다리는 네 번째 goroutine이 그 역할을 맡는다. 받는 쪽은 앞 장과 같다.
"producer 안에서 close하면 안 되나요?" — 두 번 close로 panic. 여기가 22번 규칙이 현실에서 부딪히는 지점이다. WaitGroup 자체는 4편에서 제대로 다룬다고 선을 긋는다.
06 · 실습step by step

닫히기까지 무슨 일이 순서대로 일어나는가

1
wg.Add(1) — goroutine을 띄우기 전에
goroutine 안에서 Add하면 Wait이 먼저 지나가 버릴 수 있다
2
defer wg.Done() — 각 producer가 자기 몫의 종료를 알린다
defer로 걸어야 panic이 나도 카운터가 줄어든다
3
wg.Wait() → close(ch) — 전원이 끝난 뒤에만 닫힌다
이 일을 별도 goroutine에 맡겨야 아래 range가 먼저 돌 수 있다
4
for range ch — close 신호를 보고 루프가 스스로 끝난다
consumer는 producer가 몇 개인지 끝까지 알 필요가 없다
왜 감시자를 따로 두나 wg.Wait()을 main에서 부르면, 버퍼가 차는 순간 producer가 멈추고 main은 Wait에서 멈춘다 — 서로를 기다리다 죽는다.
4번이 이 패턴의 진짜 값어치다 — consumer가 producer 개수와 완전히 분리된다. fan-in의 원형이고, 3편에서 이 모양을 그대로 확장한다.
마무리recap

오늘 가져갈 것

  • Channel은 goroutine 사이의 타입 안전한 파이프다 — 통신과 동기화를 한 번에 한다
  • Unbuffered는 핸드셰이크, Buffered는 큐 — make의 숫자 하나가 성질을 바꾼다
  • chan<- · <-chan으로 방향을 좁히면 컴파일러가 잘못된 사용을 막는다
  • close는 "더 안 보낸다"는 선언이고, 반드시 보내는 쪽이 한다 — 두 번 닫으면 panic
  • 닫힌 channel은 zero value + false를 즉시 돌려준다. range는 그 신호를 보고 끝난다
  • 신호에는 chan struct{} — 전원에게 알려야 하면 보내지 말고 닫는다
여섯 줄을 소리 내어 읽는 것만으로 정리가 된다. 여기서부터 질문을 받아도 좋다.
확인 ①클릭하면 답이 열립니다

스스로 답해보기 — 버퍼와 방향

sender가 receiver를 만날 때까지 계속 blocking된다. 프로그램 안의 모든 goroutine이 그 상태면 런타임이 all goroutines are asleep - deadlock!로 죽인다. → 슬라이드 09 · 11
앞의 3개는 즉시 통과하고 4번째에서 blocking된다. 버퍼는 순간적인 속도 차만 흡수할 뿐, 무한 큐가 아니다. → 슬라이드 10 · 12
확인과 send 사이에 다른 goroutine이 버퍼를 채울 수 있다. 읽은 순간 이미 옛날 값이다. 논블로킹 send는 select + default로 해야 한다. → 슬라이드 13
send와 close 둘 다. 받기만 가능하다. 런타임이 아니라 컴파일 타임에 막히므로 테스트 없이도 걸린다. → 슬라이드 16 · 17
없다. 변환은 좁히는 방향으로만 자동으로 일어난다. 넓히려면 원래의 양방향 channel을 직접 들고 있어야 한다. → 슬라이드 18
먼저 답하게 한 뒤 클릭해서 연다. 시간이 없으면 Q2와 Q3만 골라도 충분하다.
확인 ②클릭하면 답이 열립니다

스스로 답해보기 — close와 신호

sender가 아직 보내는 중이면 panic: send on closed channel이 난다. 끝을 아는 쪽은 sender뿐이라 close도 sender의 책임이다. → 슬라이드 22 · 23
v, ok := <-chok로만 구분된다. 닫힌 channel은 blocking 없이 zero value와 false를 즉시 돌려준다. → 슬라이드 21
close(ch)다. range의 종료 조건은 close 하나뿐이고, 값을 몇 개 보냈는지는 아무 상관이 없다. → 슬라이드 25
close는 기다리는 전원을 깨우고 늦게 온 receiver도 통과시킨다. send는 한 명만 깨우므로 수신자 수를 sender가 알아야 한다. → 슬라이드 26 · 27
producer 중 누구도 자기가 마지막인지 모른다. wg.Wait()을 기다리는 별도 goroutine이 전원 종료 후에 닫는다. → 슬라이드 31 · 32
Q1과 Q5가 실무에서 제일 자주 터지는 두 가지다. 여기서 시간이 남으면 각자 코드베이스에서 close를 누가 부르는지 떠올려 보게 한다.
다음part 3

다음 편 — select와 channel 심화

오늘은 channel 하나를 다뤘다. 그런데 실무에서는 값 채널과 에러 채널, 거기에 타임아웃과 취소까지 여러 개를 동시에 기다려야 한다. 다음 편의 주제가 그것이다.

오늘 미뤄둔 것들 논블로킹 send(default), 무한 대기를 끊는 타임아웃, nil channel로 케이스 끄기 — 오늘 스쳐 간 것들이 전부 select 한 문법으로 풀린다. fan-in / fan-out도 함께 다룬다.

참고 자료

  • 전체 코드 — github.com/kenshin579/tutorials-go /golang/concurrency
  • Go Tour — Channels go.dev/tour/concurrency/2
  • Effective Go — Channels go.dev/doc/effective_go#channels
  • Go Blog — Pipelines and cancellation go.dev/blog/pipelines
감사합니다 질문 환영합니다 — advenoh.pe.kr
GitHub 저장소 주소를 띄워둔 채로 Q&A를 받는다.
발표자 노트
← 글 보기 Channel 완전 정복 · Golang Concurrency 2
01 / 36

단축키

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