채널 기초 — 메모리를 공유하지 말고 통신하라
채널은 타입이 있는 큐다, 다만 규칙이 다르다
채널을 처음 배울 때 흔히 "큐"라고 설명하는데, 절반만 맞다. 큐라는 비유가 맞는 부분은 FIFO 순서를 지킨다는 것뿐이다. 실제 동작은 큐보다 훨씬 엄격하다 — 기본 채널(버퍼 없는 채널)은 보내는 쪽과 받는 쪽이 동시에 준비되어야만 값이 오간다. 한쪽만 준비돼 있으면 그 고루틴은 그냥 멈춰서 상대를 기다린다.
ch := make(chan int) // 버퍼 없는 채널
go func() {
ch <- 42 // 누군가 받을 때까지 여기서 블로킹
}()
v := <-ch // 값이 올 때까지 여기서 블로킹
fmt.Println(v) // 42이 특성 때문에 버퍼 없는 채널은 값을 전달하는 동시에 "동기화 지점(rendezvous)" 역할을
한다. ch <- 42가 성공적으로 리턴했다는 건 상대방이 그 값을 받기 시작했다는 것을
보장한다. 이 보장이 실무에서 은근히 중요하다 — "이 작업이 다음 단계로 넘어갔다"는 걸
채널 송신 성공 여부로 확인할 수 있기 때문이다.
버퍼 있는 채널은 다른 물건이다
make(chan int, 3)처럼 버퍼 크기를 주면 이야기가 달라진다. 버퍼가 다 찰 때까지는
송신이 즉시 리턴한다 — 받는 쪽이 준비됐는지와 무관하게. 이건 성능 최적화가 아니라
동기화 보장을 포기하는 선택이다.
ch := make(chan int, 2)
ch <- 1 // 즉시 리턴 (버퍼에 여유 있음)
ch <- 2 // 즉시 리턴
ch <- 3 // 여기서 블로킹 — 버퍼가 가득 참버퍼 크기를 얼마로 잡아야 하는지에 대한 정답은 없다. 다만 "일단 여유 있게 100 정도로 잡자"는 습관은 위험하다. 버퍼가 크면 생산자가 소비자보다 훨씬 빨리 도는 상황에서 문제를 늦게 발견한다 — 버퍼가 다 차야 비로소 블로킹되므로, 소비자가 아예 죽어 있어도 한동안은 정상처럼 보인다. 필자는 버퍼 크기를 정할 때 "이 숫자가 무엇을 의미하는가"를 설명할 수 없으면 0(버퍼 없음)에서 시작한다. 워커 개수와 정확히 맞춘 버퍼(예: 워커 풀 크기만큼)처럼 근거가 있을 때만 늘린다.
닫힌 채널에서 값을 읽으면 무슨 일이 생기는가
채널은 close(ch)로 닫을 수 있다. 닫힌 채널은 세 가지 규칙을 따른다.
- 닫힌 채널에 보내면 패닉이 난다.
- 닫힌 채널을 다시 닫으면 패닉이 난다.
- 닫힌 채널에서 받으면 즉시 제로값이 반환된다 — 버퍼에 남은 값이 있으면 그것부터 순서대로 나오고, 다 소진되면 제로값이 나온다.
세 번째 규칙 때문에 "받은 값이 진짜 보낸 값인지, 채널이 닫혀서 나온 제로값인지" 구분이
안 되는 문제가 생긴다. 이걸 풀기 위해 ,ok 관용구를 쓴다.
v, ok := <-ch
if !ok {
// 채널이 닫혔고 버퍼도 비었다 — v는 의미 없는 제로값
return
}
// v는 실제로 보내진 값for range로 채널을 돌면 이 처리를 자동으로 해준다 — 채널이 닫히고 버퍼가 비면 루프가
자연스럽게 끝난다.
func producer(ch chan<- int) {
defer close(ch) // 다 보냈으면 반드시 닫는다
for i := 0; i < 5; i++ {
ch <- i
}
}
func main() {
ch := make(chan int)
go producer(ch)
for v := range ch { // close(ch) 되면 루프 종료
fmt.Println(v)
}
}누가 닫아야 하는가 — 가장 많이 틀리는 규칙
채널을 닫는 책임은 오직 송신자에게만 있다. 수신자가 채널을 닫으면 안 된다. 이유는
간단하다 — 수신자는 송신자가 앞으로 더 보낼지 안 보낼지 알 방법이 없다. 만약 수신자가
"이제 그만 받을래" 하고 채널을 닫아버리면, 그 뒤에 송신자가 값을 보내는 순간 패닉이
난다(위 규칙 1번). 여러 송신자가 같은 채널에 쓰는 경우는 더 골치 아프다 — 그중 아무나
닫으면 안 되고, 보통은 별도의 조율(예: sync.WaitGroup으로 모든 송신자가 끝난 걸 확인한
뒤 마지막에 한 번만 닫기)이 필요하다.
sequenceDiagram participant P as 송신자(Producer) participant C as channel participant Co as 수신자(Consumer) P->>C: send(1) C->>Co: receive() = 1 P->>C: send(2) C->>Co: receive() = 2 P->>C: close() Co->>C: receive() C-->>Co: ok=false (제로값)
방향성 채널로 실수를 컴파일 타임에 잡기
함수 시그니처에 chan<- int(송신 전용)나 <-chan int(수신 전용)를 명시하면, 그
함수 안에서 실수로 반대 방향 연산을 하면 컴파일 에러가 난다. 위 producer 예제의
ch chan<- int가 그 예다 — 저 함수 안에서 <-ch로 값을 받으려 하면 컴파일이 안 된다.
런타임 패닉보다 컴파일 에러가 훨씬 싸다. 채널을 인자로 받는 함수를 쓸 때는 습관적으로
방향을 명시하는 걸 권한다 — 양방향 chan int로 두면 그 함수가 채널을 어떻게 쓸 생각인지
시그니처만 봐서는 알 수 없다.