1. 왜 채널이 필요한가
프로그램을 하나의 스레드에서 작성할 때는 모든 것이 단순합니다. 함수가 값을 계산하고 return으로 돌려주면 됩니다. 하지만 작업을 고루틴에서 실행하는 순간, 일상적인 문제가 생깁니다. 고루틴은 자기만의 시간 속에서 움직이고, 작업 결과는 어떻게든 전달되어야 합니다.
“결과를 공용 변수에 넣자”라고 생각할 수도 있지만, 그러면 data race를 떠올리게 됩니다. 뮤텍스로 변수를 보호할 수도 있지만, 그건 또 다른 기법입니다. 또는 Go답게 할 수도 있습니다: 메모리를 공유하지 말고, 통신하라 — 채널로 값을 보내는 것입니다.
Go에서는 매우 전형적인 스타일입니다: “share memory by communicating”.
타입으로서의 채널: chan T
Go의 채널은 chan T라는 특수한 타입의 값이며, 고루틴 사이에서 T 타입의 값을 전달할 수 있습니다. 여기서 분명히 짚고 넘어가야 합니다. 채널은 “모든 것을 위한 범용 파이프”가 아닙니다. chan int를 만들면 그 채널로는 int만 오갑니다. chan string을 만들었다면, 오직 string만 다룹니다.
이 제한은 사실 꽤 좋습니다. 컴파일러가 실수로 섞어 쓰는 것을 막아 주기 때문입니다. 채널은 동적 언어처럼 타입을 “친절하게” 바꿔 주지 않습니다. Go는 솔직하게 말합니다. “약속은 int에 대한 것이었으니, int를 보내자.”
간단한 참고용 표입니다:
| 채널 | 전달할 수 있는 것 |
|---|---|
|
정수 (int) |
|
문자열 (string) |
|
불리언 값 (bool) |
|
Task 타입의 값(예: 구조체) |
이 점은 통신 프로토콜을 머릿속에 유지하는 데 큰 도움이 됩니다. “이 채널로는 결과가 흐르고, 저 채널로는 이벤트가 흐른다”처럼 말이죠.
2. 채널 생성: make(chan T)와 nil
채널에는 중요한 “초기화 의식”이 있습니다. var로 채널 변수를 선언했다고 해서, 아직 실제 채널이 만들어진 것은 아닙니다. 단지 그 타입의 영값, 즉 nil이 들어 있는 변수를 만든 것뿐입니다.
실제로 채널을 만들고 그 채널로 값을 보내거나 받으려면 make를 사용해야 합니다.
두 가지 경우를 비교해 봅시다. 정말 중요합니다:
package main
import "fmt"
func main() {
var ch1 chan int // nil 채널 (채널이 생성되지 않음)
ch2 := make(chan int) // 채널 생성됨
fmt.Println(ch1 == nil) // true
fmt.Println(ch2 == nil) // false
}
출력은 다음과 같습니다:
// true
// false
nil 채널이 왜 위험할까요? nil 채널에서의 전송이나 수신은 영원히 블록되기 때문입니다. “오류를 돌려준다”도 아니고, “패닉을 일으킨다”도 아니고, 그냥 끝없이 기다립니다.
규칙은 간단합니다. 채널을 사용할 계획이라면, make로 생성하세요.
4. Send/receive: 두 가지 기본 연산
채널을 처음 배울 때는 정말로 두 가지 연산만 익히면 됩니다:
- send (전송): ch <- v
- receive (수신): <-ch
여기서 “형식”을 헷갈리면 안 됩니다. 전송은 문장이고, 수신은 식입니다. 수신은 변수를 대입하거나 바로 사용할 수 있습니다.
전송: ch <- v
전송은 다음처럼 생겼습니다:
ch <- 42
이는 “값 42를 채널 ch로 보내라”는 뜻으로 읽습니다.
수신: v := <-ch
수신은 다음처럼 생겼습니다:
v := <-ch
이는 “채널 ch에서 값을 받아서 v에 넣어라”라고 읽습니다.
이렇게도 쓸 수 있습니다. 바로 사용하고 싶을 때입니다:
fmt.Println(<-ch)
5. 고루틴과 함께하는 최소 예제
“Hello, channels!”를 만들어 봅시다. 아이디어는 간단합니다. 고루틴이 채널에 숫자를 보내고, main이 그것을 기다렸다가 출력합니다.
package main
import "fmt"
func main() {
ch := make(chan int)
go func() {
ch <- 42
}()
v := <-ch
fmt.Println(v) // 42
}
두 가지를 주목하세요.
첫째: ch := make(chan int) — 채널이 생성되었고, nil이 아닙니다.
둘째: v := <-ch는 기다릴 수 있습니다. 그리고 그것은 정상입니다. 여기서 기다림은 메커니즘의 일부입니다. 고루틴이 값을 보낼 때까지 main이 “수신 위치에 서 있는” 셈입니다.
6. 블로킹은 “악수”와 같다
버퍼가 없는 채널(make(chan T)이 정확히 그런 채널을 만듭니다)에서는 통신이 악수와 비슷합니다. 보내는 쪽과 받는 쪽이 서로 “만나야” 합니다.
이 모델에서는:
- 전송 ch <- v는 누군가가 ch에서 받기 시작할 때까지 블록됩니다;
- 수신 <-ch는 누군가가 ch로 보내기 시작할 때까지 블록됩니다.
간단한 도식:
sequenceDiagram
participant M as main (receiver)
participant G as goroutine (sender)
M->>M: v := <-ch (waiting...)
G->>G: ch <- 42
G-->>M: 42 "moves" through the channel
M->>M: fmt.Println(v)
이 때문에 채널은 데이터뿐 아니라 동기화에도 자주 사용됩니다. “값이 올 때까지는 다음으로 가지 않는다”는 식입니다. 고루틴 완료를 기다리는 가장 단순한 방법으로서의 “done 채널” 패턴은 가장 널리 쓰이는 패턴 중 하나입니다.
7. Deadlock: 보내거나 받을 상대가 없을 때
이제 초보자들이 아주 자주 작성하는 예제를 보겠습니다. 처음에는 이렇게 생각하는 것이 자연스럽습니다. 뇌가 우주의 경계를 시험하는 것이니까요.
받는 쪽 없이 보내기
package main
func main() {
ch := make(chan int)
ch <- 1 // deadlock: 받을 사람이 없음
}
무슨 일이 일어날까요? main이 1을 보내려고 하지만, 채널에는 버퍼가 없으므로 전송에는 “지금 당장” 받을 쪽이 필요합니다. 그런데 받는 쪽이 없습니다. 결과적으로 프로그램은 멈추고, Go 런타임은 보통 다음과 같은 메시지로 종료합니다:
// fatal error: all goroutines are asleep - deadlock!
(이 문장은 마치 모든 고루틴이 잠들어 버리고 일을 거부한 것처럼 들립니다. 어떤 의미에서는 맞습니다.)
보내는 쪽 없이 받기
거울처럼 반대 상황도 있습니다:
package main
func main() {
ch := make(chan int)
_ = <-ch // deadlock: 보낼 사람이 없음
}
nil 채널에 대해 따로
또 다른 전형적인 “왜 그냥 멈춰 있지?” 상황입니다. 채널이 nil일 수 있기 때문입니다.
package main
func main() {
var ch chan int // nil
// ch <- 1 // 영원히 멈춤
// _ = <-ch // 영원히 멈춤
_ = ch
}
다음 문장을 기억해 두면 좋습니다: nil 채널은 “존재하지 않음”이며, 그 채널과의 모든 통신 시도는 영원한 대기로 바뀝니다.
8. 미니 예제: 채널로 Task 반환하기
예제가 너무 “진공 속의 말 한 마리” 같지 않도록, 작은 학습용 작업 애플리케이션 조각을 확장해 봅시다. 작업 생성이 즉시 끝나지 않을 수 있다고 가정해 보겠습니다(예를 들어 저장소를 흉내 낼 수 있습니다). 생성 작업을 고루틴에서 실행하고, 완성된 작업을 채널로 다시 돌려받고 싶습니다.
간단한 구조체를 만들어 보겠습니다:
package main
import "fmt"
type Task struct {
ID int
Title string
}
func main() {
created := make(chan Task)
go func() {
t := Task{ID: 1, Title: "Read about chan"}
created <- t
}()
task := <-created
fmt.Println(task.Title) // Read about chan
}
여기에는 아직 데이터베이스도, 네트워크도, 진짜 복잡함도 없습니다. 그리고 그게 좋습니다. 지금 우리의 목표는 “실제 프로덕션”이 아니라 올바른 메커니즘입니다. 고루틴이 결과를 만들고, 그것을 보내고, main이 받는 것입니다.
조금 더 “실제처럼” 보이게 하려면 생성을 함수로 분리할 수도 있습니다. 이때 채널은 인자로 전달됩니다. 그것은 정상입니다. 채널은 전달하기 쉬우며, 마치 “통신 선로에 대한 참조”처럼 동작합니다.
package main
import "fmt"
type Task struct {
ID int
Title string
}
func createTaskAsync(title string, out chan Task) {
out <- Task{ID: 1, Title: title}
}
func main() {
ch := make(chan Task)
go createTaskAsync("Buy coffee (for the brain)", ch)
t := <-ch
fmt.Println(t.ID, t.Title) // 1 Buy coffee (for the brain)
}
주의하세요. 여기서 createTaskAsync 함수는 Task를 return으로 돌려주지 않습니다. 채널 out을 통해 돌려줍니다. 이것이 바로 채널의 기본 아이디어입니다. 작업 결과는 호출 스택을 통해서가 아니라 메시지 흐름을 통해 전달될 수 있습니다.
9. 흔한 실수
실수 1: make를 잊고 “영원한 대기”를 만나는 경우.
가장 흔한 이야기입니다. 프로그래머가 정직하게 var ch chan int를 선언한 뒤, 어딘가에서 ch <- 10을 수행하면 프로그램이 그냥 “멈춰” 버립니다. 오류도, 힌트도 없이 그저 고요합니다. 이유는 ch가 nil이기 때문이며, nil 채널에 대한 연산은 영원히 블록됩니다. 해결책은 습관입니다. 채널이 동작해야 한다면, 필요한 곳에 최대한 가깝게 make(chan T)로 생성하세요.
실수 2: 같은 고루틴에서 먼저 보내고 나중에 받으려는 시도.
때로는 코드가 논리적으로 보입니다. “먼저 보내고, 그다음 받자.” 하지만 버퍼가 없는 채널에서는 그것이 “먼저-나중”이 아니라 “만나서 교환”입니다. main에서 ch <- 1을 쓰는데 받는 쪽이 아직 없다면, 전송이 블록되고 수신까지 도달하지 못합니다. 머릿속에서는 줄 서기 같지만, 실제로는 악수입니다.
실수 3: 수신을 “문장”이 아니라 “식”이라는 점을 잘못 이해하는 경우.
초보자들은 결과를 사용하지 않고 “값을 받기”만 따로 한 줄에 쓰려다가, 코드 스타일이 이상하다고 느끼기도 합니다. Go에서 수신은 <-ch라는 식이며, 보통은 변수에 대입하거나(v := <-ch) 바로 사용합니다(fmt.Println(<-ch)). “수신은 값을 만든다”라고 생각하는 습관은 코드를 금방 더 깔끔하게 만듭니다.
실수 4: 채널 타입이 맞지 않는 경우(chan int vs chan int64).
채널은 엄격하게 타입이 정해집니다. 어딘가에서 chan int를 쓰기로 했는데, 나중에 int64를 보내거나 chan int를 chan int64를 기대하는 함수에 전달하려고 하면 컴파일러가 허용하지 않습니다. 이것은 까다롭게 구는 것이 아니라 실제 보호 장치입니다. “거의 비슷한” 숫자 타입을 경쟁 코드에서 섞기 시작하면 금방 혼란이 생깁니다.
실수 5: 아무도 보내거나 받지 않는데도 “채널이 뭔가 해 줄 것”이라고 기대하는 경우.
채널은 능동적인 객체도 아니고, 별도의 스레드를 가진 큐도 아닙니다. 채널은 스스로 값을 “전달”하지 않습니다. send/receive를 수행하는 고루틴이 있어야 합니다. 통신을 설계할 때는 프로토콜을 소리 내어 말해 보는 것이 좋습니다. 누가 보내는지, 누가 받는지, 그리고 어느 시점에 그것이 일어나는지 말입니다. 이 질문들 중 하나라도 답이 없다면, 스스로 deadlock을 만들 가능성이 높습니다.
GO TO FULL VERSION