2025-12-27
고루틴이 Go의 동시성 실행 단위라면, 채널(Channel)은 그 고루틴들을 연결하는 통신 수단입니다. Go 언어의 유명한 설계 철학 중 하나가 바로 이것입니다.
“공유 메모리로 통신하지 말고, 통신으로 메모리를 공유하라.” (Do not communicate by sharing memory; instead, share memory by communicating.)
이 글에서는 채널이 내부적으로 어떻게 동작하는지, 비버퍼 채널과 버퍼 채널의 차이, 방향성 채널로 API를 안전하게 설계하는 방법, select 문의 다양한 활용, 그리고 실무에서 자주 쓰이는 파이프라인, 팬아웃, 팬인 패턴까지 심층적으로 다룹니다.
🛠️ 채널을 쓰는 이유
앞선 고루틴 글에서 Mutex로 공유 자원을 보호하는 방법을 배웠습니다. 채널은 다른 접근법입니다. 데이터를 고루틴 간에 “전달”함으로써, 어느 시점에든 하나의 고루틴만 그 데이터를 소유하게 만듭니다. 소유권 이전이 곧 동기화입니다. 필자는 “상태를 공유하면 Mutex, 데이터를 전달하면 채널”이라는 기준으로 선택합니다.
채널은 make(chan T) 또는 make(chan T, n)으로 생성합니다. 내부적으로는 세 가지 요소로 구성됩니다.
채널 내부 구조:
┌─────────────────────────────────────┐
│ hchan struct │
│ ┌──────┐ ┌──────┐ ┌──────────┐ │
│ │ buf │ │ sendq│ │ recvq │ │
│ │(링버퍼)│ │(대기큐)│ │ (대기큐) │ │
│ └──────┘ └──────┘ └──────────┘ │
│ qcount: 현재 버퍼의 데이터 수 │
│ dataqsiz: 버퍼 용량 │
│ closed: 채널 닫힘 여부 │
└─────────────────────────────────────┘
비버퍼 채널에서 수신자가 없는 상태에서 송신하면, 송신 고루틴이 sendq에 들어가 블로킹됩니다. 반대로 송신자가 없는데 수신하려 하면, 수신 고루틴이 recvq에서 블로킹됩니다. 이 블로킹/언블로킹 메커니즘이 채널의 자연스러운 동기화를 만들어냅니다.
비버퍼 채널(Unbuffered Channel)은 송신자와 수신자가 반드시 동시에 준비되어야 데이터가 전달됩니다. 이를 랑데부(Rendezvous)라고 부릅니다.
package main
import "fmt"
func main() {
ch := make(chan string) // 비버퍼 채널
go func() {
ch <- "고루틴에서 보낸 메시지" // 수신자가 준비될 때까지 블로킹
fmt.Println("송신 완료")
}()
msg := <-ch // 고루틴의 송신이 완료될 때까지 블로킹
fmt.Println("수신:", msg)
// 출력:
// 수신: 고루틴에서 보낸 메시지
// 송신 완료
}
func process(done chan struct{}) {
// 작업 수행
fmt.Println("작업 완료")
done <- struct{}{} // 완료 신호 전송
}
func main() {
done := make(chan struct{})
go process(done)
<-done // process가 완료될 때까지 대기
fmt.Println("메인 계속 실행")
// "작업 완료"는 반드시 "메인 계속 실행" 이전에 출력됨
}
비버퍼 채널의 송신은 수신이 완료된 후에야 반환됩니다. 이는 강력한 happens-before 보장을 제공합니다.
버퍼 채널(Buffered Channel)은 지정한 크기만큼 데이터를 담아둘 수 있습니다. 버퍼가 가득 차지 않으면 송신자는 수신자를 기다리지 않아도 됩니다.
ch := make(chan int, 3) // 버퍼 크기 3
ch <- 1 // 즉시 반환 (버퍼에 저장)
ch <- 2 // 즉시 반환
ch <- 3 // 즉시 반환
// ch <- 4 // 블로킹: 버퍼가 가득 참
fmt.Println(<-ch) // 1
fmt.Println(<-ch) // 2
fmt.Println(<-ch) // 3
| 상황 | 권장 채널 |
|---|---|
| 두 고루틴 간 명확한 동기화 필요 | 비버퍼 채널 |
| 완료 신호, 세마포어 | 비버퍼 채널 |
| 생산자가 소비자보다 빠른 경우 | 버퍼 채널 |
| 작업 큐, 이벤트 큐 | 버퍼 채널 |
| 버스트 트래픽 흡수 | 버퍼 채널 |
🛠️ 버퍼 크기 결정 전략
버퍼 크기를 너무 크게 잡으면 메모리를 낭비하고, 너무 작으면 자주 블로킹이 발생합니다. 실무에서는 워커 수와 동일하게 잡거나 (
make(chan Job, numWorkers)), 벤치마크를 통해 적절한 크기를 찾습니다. 버퍼가 자주 가득 찬다면 워커 수를 늘리는 것이 근본적인 해결책입니다.
Go는 채널에 방향을 지정할 수 있습니다. 함수 매개변수에 방향을 명시하면 컴파일 타임에 잘못된 사용을 방지할 수 있습니다.
// 송신 전용 채널: chan<- T
func producer(ch chan<- int) {
for i := 0; i < 5; i++ {
ch <- i
}
close(ch)
// <-ch // ❌ 컴파일 에러: 송신 전용 채널에서 수신 불가
}
// 수신 전용 채널: <-chan T
func consumer(ch <-chan int) {
for v := range ch {
fmt.Println("수신:", v)
}
// ch <- 1 // ❌ 컴파일 에러: 수신 전용 채널에 송신 불가
}
func main() {
ch := make(chan int, 5)
go producer(ch) // chan int → chan<- int로 자동 변환
consumer(ch) // chan int → <-chan int로 자동 변환
}
// ✅ 방향성을 명시한 함수 시그니처
func StartWorker(jobs <-chan Job, results chan<- Result) {
for job := range jobs {
results <- process(job)
}
}
이 함수 시그니처를 보는 것만으로도 “입력은 jobs, 출력은 results”라는 데이터 흐름이 명확해집니다. 방향성 채널은 자기 문서화(Self-Documenting) 코드를 가능하게 합니다.
송신자가 더 이상 보낼 데이터가 없으면 close(ch)로 채널을 닫습니다.
func generate(ch chan<- int, count int) {
for i := 0; i < count; i++ {
ch <- i
}
close(ch) // 채널 닫기: 수신자에게 완료 신호
}
func main() {
ch := make(chan int, 10)
go generate(ch, 5)
// range는 채널이 닫히면 자동 종료
for v := range ch {
fmt.Println(v) // 0 1 2 3 4
}
fmt.Println("완료")
}
for {
v, ok := <-ch
if !ok {
// 채널이 닫혔음
break
}
fmt.Println(v)
}
ok가 false이면 채널이 닫혔고, v는 해당 타입의 Zero Value입니다.
ch := make(chan int, 1)
close(ch)
ch <- 1 // ❌ panic: send on closed channel
채널은 송신자만 닫아야 합니다. 수신자가 닫으면 송신자가 패닉을 일으킬 수 있습니다. 여러 송신자가 있을 때는 sync.Once나 별도의 done 채널을 활용합니다.
select는 여러 채널 연산 중 준비된 것을 실행합니다. Go의 switch처럼 생겼지만, 채널 전용입니다.
func main() {
ch1 := make(chan string)
ch2 := make(chan string)
go func() {
time.Sleep(1 * time.Second)
ch1 <- "one"
}()
go func() {
time.Sleep(2 * time.Second)
ch2 <- "two"
}()
for i := 0; i < 2; i++ {
select {
case msg1 := <-ch1:
fmt.Println("ch1:", msg1)
case msg2 := <-ch2:
fmt.Println("ch2:", msg2)
}
}
// 출력 (약 1초 후): ch1: one
// 출력 (약 2초 후): ch2: two
}
select {
case v := <-ch:
fmt.Println("수신:", v)
default:
fmt.Println("채널이 비어 있음 — 즉시 반환")
}
default가 있으면 준비된 채널이 없어도 블로킹하지 않습니다. 폴링(polling) 패턴에 유용합니다.
func fetchWithTimeout(url string) (string, error) {
resultCh := make(chan string, 1)
go func() {
// 실제 HTTP 요청이라고 가정
time.Sleep(3 * time.Second)
resultCh <- "response data"
}()
select {
case result := <-resultCh:
return result, nil
case <-time.After(2 * time.Second): // 2초 타임아웃
return "", fmt.Errorf("요청 타임아웃")
}
}
time.After는 지정한 시간 후에 값을 전송하는 채널을 반환합니다. select와 조합하면 타임아웃 로직을 단 3줄로 구현할 수 있습니다.
func worker(done <-chan struct{}, jobs <-chan int) {
for {
select {
case <-done:
fmt.Println("워커 종료")
return
case job := <-jobs:
fmt.Println("처리:", job)
}
}
}
func main() {
done := make(chan struct{})
jobs := make(chan int, 10)
go worker(done, jobs)
jobs <- 1
jobs <- 2
time.Sleep(time.Millisecond * 100)
close(done) // 모든 워커에게 종료 신호 (브로드캐스트)
time.Sleep(time.Millisecond * 100)
}
close(done)은 done 채널을 수신하는 모든 고루틴에게 동시에 신호를 보냅니다. 채널 닫기를 이용한 이 브로드캐스트 패턴은 컨텍스트(context) 패키지의 기반 아이디어이기도 합니다.
채널로 단계별 처리를 연결하는 파이프라인(Pipeline) 패턴은 Go 동시성 프로그래밍의 핵심 관용구입니다.
// 1단계: 숫자 생성기
func generate(nums ...int) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for _, n := range nums {
out <- n
}
}()
return out
}
// 2단계: 제곱 계산
func square(in <-chan int) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for n := range in {
out <- n * n
}
}()
return out
}
// 3단계: 필터 (짝수만)
func filterEven(in <-chan int) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for n := range in {
if n%2 == 0 {
out <- n
}
}
}()
return out
}
func main() {
// 파이프라인 연결: 생성 → 제곱 → 필터
nums := generate(1, 2, 3, 4, 5, 6, 7, 8, 9, 10)
squares := square(nums)
evens := filterEven(squares)
for v := range evens {
fmt.Println(v) // 4, 16, 36, 64, 100
}
}
각 단계는 독립적인 고루틴에서 실행됩니다. 이전 단계가 값을 생산하는 즉시 다음 단계가 처리를 시작하여 진정한 병렬 파이프라인이 구성됩니다.
하나의 채널에서 여러 고루틴으로 작업을 분산하는 팬아웃(Fan-out) 패턴입니다.
func fanOut(in <-chan int, numWorkers int) []<-chan int {
channels := make([]<-chan int, numWorkers)
for i := 0; i < numWorkers; i++ {
channels[i] = square(in) // 같은 입력 채널을 여러 워커에 연결
}
return channels
}
실제로는 단일 입력을 여러 워커가 경쟁적으로 처리하는 방식이 더 일반적입니다.
func main() {
jobs := make(chan int, 20)
results := make(chan int, 20)
// 5개의 워커 고루틴 (팬아웃)
var wg sync.WaitGroup
for w := 0; w < 5; w++ {
wg.Add(1)
go func() {
defer wg.Done()
for job := range jobs {
results <- job * job // 제곱 계산
}
}()
}
// 작업 전송
for i := 1; i <= 20; i++ {
jobs <- i
}
close(jobs)
// 결과 수집 완료 후 채널 닫기
go func() {
wg.Wait()
close(results)
}()
for r := range results {
fmt.Print(r, " ")
}
}
여러 채널의 결과를 하나로 합치는 팬인(Fan-in) 패턴입니다.
func fanIn(channels ...<-chan int) <-chan int {
merged := make(chan int)
var wg sync.WaitGroup
// 각 채널에서 값을 읽어 merged에 전달
forward := func(ch <-chan int) {
defer wg.Done()
for v := range ch {
merged <- v
}
}
wg.Add(len(channels))
for _, ch := range channels {
go forward(ch)
}
// 모든 채널이 닫히면 merged도 닫기
go func() {
wg.Wait()
close(merged)
}()
return merged
}
func main() {
// 세 개의 독립적인 작업 채널
ch1 := generate(1, 2, 3)
ch2 := generate(10, 20, 30)
ch3 := generate(100, 200, 300)
// 팬인: 세 채널을 하나로 합침
for v := range fanIn(ch1, ch2, ch3) {
fmt.Print(v, " ")
}
// 출력 순서 비결정적: 1 10 100 2 20 200 3 30 300 (순서 섞임)
}
팬아웃 + 팬인을 조합하면 완전한 병렬 처리 파이프라인이 완성됩니다.
실제 서버 코드에서는 context.Context와 채널을 함께 사용하여 타임아웃과 취소를 우아하게 처리합니다.
import "context"
func processWithContext(ctx context.Context, jobs <-chan int) <-chan int {
results := make(chan int)
go func() {
defer close(results)
for {
select {
case <-ctx.Done(): // 타임아웃 또는 취소
fmt.Println("컨텍스트 취소:", ctx.Err())
return
case job, ok := <-jobs:
if !ok {
return // jobs 채널이 닫힘
}
select {
case results <- job * 2:
case <-ctx.Done():
return
}
}
}
}()
return results
}
func main() {
// 2초 타임아웃
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
jobs := make(chan int, 10)
go func() {
for i := 0; i < 10; i++ {
time.Sleep(500 * time.Millisecond) // 작업당 0.5초
jobs <- i
}
close(jobs)
}()
for r := range processWithContext(ctx, jobs) {
fmt.Println("결과:", r)
}
// 2초 후 타임아웃 → 4개 정도만 처리됨
}
context는 다음 글에서 자세히 다루지만, 채널과의 조합이 얼마나 자연스러운지 미리 확인할 수 있습니다.
var ch chan int // nil 채널
ch <- 1 // ❌ 영원히 블로킹
<-ch // ❌ 영원히 블로킹
nil 채널에 송수신하면 영원히 블로킹됩니다. 단, select 문에서 nil 채널 case는 무시됩니다. 이 특성을 활용해 특정 case를 동적으로 비활성화할 수 있습니다.
// nil 채널로 case 비활성화 트릭
var timerCh <-chan time.Time
if needTimer {
timerCh = time.After(5 * time.Second)
}
select {
case <-timerCh: // needTimer가 false이면 이 case는 영원히 선택되지 않음
fmt.Println("타임아웃")
case v := <-ch:
fmt.Println(v)
}
// ❌ 수신자가 없어 고루틴이 영원히 블로킹
func badFunction() {
ch := make(chan int)
go func() {
ch <- heavyCompute() // 수신자가 없으면 영원히 대기
}()
// ch를 읽지 않고 반환 → 고루틴 누수
}
// ✅ 버퍼 채널 또는 수신 보장
func goodFunction() {
ch := make(chan int, 1) // 버퍼 1 → 수신자 없어도 즉시 반환
go func() {
ch <- heavyCompute()
}()
// 나중에 필요할 때 <-ch로 읽거나, 함수가 끝나면 GC가 처리
}
채널은 단순한 동기화 도구가 아닙니다. 고루틴 간 소유권 이전을 통해 공유 상태 없이 안전한 동시성 프로그래밍을 가능하게 하는 Go 언어의 철학적 핵심입니다.
오늘 배운 핵심을 요약하면:
chan<-, <-chan)로 함수 시그니처를 자기 문서화할 수 있다.다음 글에서는 Go 언어의 컨텍스트(context)를 심층적으로 다루며, context.WithCancel, context.WithTimeout, context.WithValue의 동작 원리와 HTTP 서버, DB 쿼리, 고루틴 취소에서의 실무 활용 패턴을 알아보겠습니다.