본문으로 건너뛰기

채널(Channel) — 고루틴 간 통신

Go의 설계 철학 중 유명한 격언이 있습니다:

"메모리를 공유해서 통신하지 말고, 통신해서 메모리를 공유하라." (Do not communicate by sharing memory; instead, share memory by communicating.)

채널(Channel)은 이 철학을 구현하는 핵심 도구입니다. 고루틴 간에 값을 안전하게 전달하는 타입이 있는 파이프(typed pipe) 입니다.

채널 기초 — 생성과 기본 사용

package main

import "fmt"

func main() {
// 채널 생성 — make(chan 타입)
ch := make(chan int)

// 고루틴에서 값 전송
go func() {
ch <- 42 // 채널에 값 전송 (send)
}()

// 메인에서 값 수신
value := <-ch // 채널에서 값 수신 (receive)
fmt.Println("받은 값:", value) // 42
}

채널 연산:

  • 전송: ch <- value — 수신자가 있을 때까지 블록
  • 수신: value := <-ch — 송신자가 있을 때까지 블록

언버퍼드 채널 vs 버퍼드 채널

언버퍼드 채널 (Unbuffered Channel)

make(chan T) — 송신과 수신이 동시에 이루어져야 합니다. 일종의 랑데부(rendezvous) 방식입니다.

package main

import (
"fmt"
"time"
)

func main() {
ch := make(chan string) // 언버퍼드 채널

go func() {
time.Sleep(1 * time.Second)
fmt.Println("데이터 전송 전")
ch <- "안녕하세요" // 수신자가 준비될 때까지 블록
fmt.Println("데이터 전송 완료")
}()

fmt.Println("수신 대기 중...")
msg := <-ch // 송신자가 준비될 때까지 블록
fmt.Println("수신:", msg)
}
// 출력:
// 수신 대기 중...
// 데이터 전송 전
// 데이터 전송 완료
// 수신: 안녕하세요

버퍼드 채널 (Buffered Channel)

make(chan T, 용량) — 버퍼가 가득 찰 때까지는 블록 없이 전송 가능합니다.

package main

import "fmt"

func main() {
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

// 채널 길이와 용량
ch2 := make(chan int, 5)
ch2 <- 10
ch2 <- 20
fmt.Printf("길이: %d, 용량: %d\n", len(ch2), cap(ch2)) // 길이: 2, 용량: 5
}
구분언버퍼드버퍼드
동기화송수신 동시비동기 (버퍼 이내)
블록 조건상대방이 없을 때버퍼 꽉 참 / 버퍼 비어 있음
용도동기화 신호속도 차이 완충

방향성 채널 (Directional Channel)

채널을 함수 인자로 전달할 때 방향을 제한하면 실수를 방지할 수 있습니다.

package main

import "fmt"

// chan<- int : 전송 전용 채널 (send-only)
func producer(ch chan<- int) {
for i := 0; i < 5; i++ {
ch <- i
}
close(ch)
}

// <-chan int : 수신 전용 채널 (receive-only)
func consumer(ch <-chan int) {
for v := range ch {
fmt.Println("수신:", v)
}
}

func main() {
ch := make(chan int, 5) // 양방향 채널 생성
go producer(ch) // 전송 전용으로 자동 변환
consumer(ch) // 수신 전용으로 자동 변환
}

방향성 채널 타입:

  • chan T — 양방향 (읽기·쓰기 모두 가능)
  • chan<- T — 전송 전용 (쓰기만 가능)
  • <-chan T — 수신 전용 (읽기만 가능)

채널 닫기 (close)

close(ch)로 채널을 닫으면 더 이상 값을 전송할 수 없습니다. 수신 측에서는 채널이 닫혔음을 감지할 수 있습니다.

package main

import "fmt"

func main() {
ch := make(chan int, 3)
ch <- 1
ch <- 2
ch <- 3
close(ch) // 채널 닫기

// 방법 1: ok 패턴으로 채널 상태 확인
for {
value, ok := <-ch
if !ok {
fmt.Println("채널이 닫혔습니다")
break
}
fmt.Println("값:", value)
}

// 방법 2: range로 채널 순회 (채널 닫히면 자동 종료)
ch2 := make(chan string, 3)
ch2 <- "a"
ch2 <- "b"
ch2 <- "c"
close(ch2)

for s := range ch2 {
fmt.Println(s)
}
}

채널 닫기 규칙:

  • 닫힌 채널에 전송 → 패닉 발생
  • 닫힌 채널 수신 → 제로값과 false 반환
  • 이미 닫힌 채널 닫기 → 패닉 발생
  • 송신자가 채널을 닫아야 한다(수신자가 닫으면 송신자 패닉 위험)

range-over-channel

채널에서 range를 사용하면 채널이 닫힐 때까지 자동으로 수신합니다.

package main

import (
"fmt"
"sync"
)

func generateNumbers(start, end int) <-chan int {
ch := make(chan int)
go func() {
defer close(ch) // 함수 종료 시 채널 닫기
for i := start; i <= end; i++ {
ch <- i
}
}()
return ch
}

func main() {
// 파이프라인 패턴
numbers := generateNumbers(1, 10)

var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
for n := range numbers { // 채널이 닫힐 때까지 수신
fmt.Printf("%d ", n)
}
fmt.Println()
}()
wg.Wait()
}

파이프라인 패턴

채널을 연결해서 데이터 처리 파이프라인을 구성할 수 있습니다.

package main

import "fmt"

// 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() {
// 파이프라인 구성: generate → square → filterEven
nums := generate(1, 2, 3, 4, 5, 6, 7, 8, 9, 10)
squares := square(nums)
evens := filterEven(squares)

for v := range evens {
fmt.Printf("%d ", v) // 4 16 36 64 100
}
fmt.Println()
}

Fan-out, Fan-in 패턴

여러 고루틴에 작업을 분배(fan-out)하고 결과를 모으는(fan-in) 패턴입니다.

package main

import (
"fmt"
"sync"
)

// Fan-out: 하나의 채널을 여러 워커에 분배
func fanOut(input <-chan int, workers int) []<-chan int {
outputs := make([]<-chan int, workers)
for i := 0; i < workers; i++ {
out := make(chan int)
outputs[i] = out
go func(out chan<- int) {
defer close(out)
for n := range input {
out <- n * n // 제곱 계산
}
}(out)
}
return outputs
}

// Fan-in: 여러 채널의 결과를 하나로 합치기
func fanIn(inputs ...<-chan int) <-chan int {
var wg sync.WaitGroup
merged := make(chan int)

output := func(c <-chan int) {
defer wg.Done()
for n := range c {
merged <- n
}
}

wg.Add(len(inputs))
for _, c := range inputs {
go output(c)
}

go func() {
wg.Wait()
close(merged)
}()

return merged
}

func main() {
// 입력 채널 생성
input := make(chan int, 10)
for i := 1; i <= 10; i++ {
input <- i
}
close(input)

// Fan-out: 3개 워커에 분배
workers := fanOut(input, 3)

// Fan-in: 결과 합치기
results := fanIn(workers...)

sum := 0
for r := range results {
sum += r
}
fmt.Println("합계:", sum) // 385 (1²+2²+...+10²)
}

핵심 정리

  • 언버퍼드 채널: 동기 통신, 송수신이 동시에 이루어짐
  • 버퍼드 채널: 비동기 통신, 버퍼 크기 이내로 블록 없이 전송
  • 방향성 채널: 함수 인자에 chan<-, <-chan으로 의도 명확화
  • 채널 닫기: 송신자가 닫고, rangeok 패턴으로 감지
  • 파이프라인: 채널 연결로 데이터 흐름 구성
  • Fan-out/Fan-in: 병렬 처리와 결과 수집 패턴