Go에서 Concurrency 파트 2: Channel

발행: (2026년 5월 1일 AM 10:31 GMT+9)
5 분 소요
원문: Dev.to

Source: Dev.to

Channel

“메모리를 공유해서 통신하지 말고, 통신을 통해 메모리를 공유하라.”
Go에서는 goroutine이 보통 채널을 통해 데이터를 교환하며, 공유 변수의 값을 직접 변경하지 않는다.

  • 값 보내기: c <- v
  • 값 받기: v := <-c

c는 타입이 지정된 채널이며, 요소 타입은 채널을 만들 때 고정된다.

func multiply(v1, v2 int, c chan int) {
    c <- v1 * v2
}

func main() {
    c := make(chan int)
    go multiply(1, 2, c)
    go multiply(3, 4, c)

    a := <-c
    b := <-c
    fmt.Println(a * b) // prints 24
}

채널의 내장 블로킹 동작 덕분에 명시적인 WaitGroup이 필요하지 않다: 송신은 수신자가 준비될 때까지 블록되고, 수신은 값이 들어올 때까지 블록된다.

간단한 동기화에 채널 사용

채널은 WaitGroup과 비슷하게 한 번만 사용되는 신호 역할을 할 수 있다.

ready := make(chan bool)

go func() {
    // do some work...
    ready <- true // signal completion
}()

<-ready // wait until the goroutine signals

받은 값은 보통 무시한다; 프로그램은 단지 송신이 일어날 때까지 기다린다.

for range 로 스트림 받기

생산자가 채널을 닫으면, 수신자는 채널이 닫힐 때까지 반복할 수 있다.

func producer(c chan int) {
    for i := 1; i <= 5; i++ {
        fmt.Println("Sending", i)
        c <- i
        time.Sleep(500 * time.Millisecond)
    }
    close(c)
}

func main() {
    c := make(chan int)
    go producer(c)

    for v := range c {
        fmt.Print("Receiving ", v)
    }
    fmt.Println("\nDone")
}

Important: 더 이상 값이 전송되지 않을 경우 송신자가 채널을 닫아야 한다. 수신 측에서 채널을 닫으면 송신자가 아직 동작 중일 때 패닉이 발생할 수 있다.

Buffered vs Unbuffered Channels

기본적으로 채널은 용량 0(버퍼 없음)이며, 각 송신을 대응되는 수신과 동기화한다.

버퍼가 있는 채널은 송신자를 블록하지 않고 제한된 수의 값을 큐에 저장할 수 있다:

c := make(chan int, 3) // capacity 3
  • 처음 세 번의 송신은 즉시 성공한다.
  • 네 번째 송신은 수신자가 값을 읽을 때까지 블록된다.
  • 수신은 버퍼가 비어 있을 때만 블록된다.

버퍼 채널은 생산자와 소비자의 속도가 다를 때, 일시적인 불일치를 완화하는 데 유용하다.

Directional Channels

채널을 함수 매개변수로 전달할 때 방향을 제한할 수 있다:

  • 송신 전용: func producer(c chan<- int)
  • 수신 전용: func consumer(c <-chan int)

이렇게 하면 의도된 사용을 명시적으로 드러내고, 컴파일러가 오용을 잡아준다.

Select Statement

select는 채널 연산을 위한 switch와 같다. 케이스 중 하나가 진행 가능해질 때까지 블록한다.

기본 사용법

func main() {
    c1 := make(chan string)
    c2 := make(chan string)

    go func() {
        time.Sleep(1 * time.Second)
        c1 <- "A"
    }()
    go func() {
        time.Sleep(2 * time.Second)
        c2 <- "B"
    }()

    select {
    case res := <-c1:
        fmt.Println("Received from c1:", res)
    case res := <-c2:
        fmt.Println("Received from c2:", res)
    }
}

프로그램은 먼저 준비된 채널(c1이 예시에서는 먼저 준비됨)에서 값을 받아 출력한다.

송신과 수신을 동시에 선택하기

select {
case a := <-inChan:
    fmt.Println("Received:", a)
case outChan <- b:
    fmt.Println("Sent:", b)
}

흔히 쓰이는 패턴

타임아웃

select {
case res := <-c1:
    fmt.Println("Received:", res)
case <-time.After(5 * time.Second):
    fmt.Println("Timeout")
}

논블로킹 기본값

select {
case res := <-c1:
    fmt.Println("Received:", res)
default:
    fmt.Println("No message")
}

중단 신호

select {
case res := <-c1:
    fmt.Println("Received:", res)
case <-abortChan:
    fmt.Println("Abort")
    return
}
0 조회
Back to Blog

관련 글

더 보기 »

Go를 제로부터 시작하기: var vs :=

Go에는 변수를 선언하는 두 가지 방법이 있습니다: var와 :=. 이들은 서로 다른 이유로 존재하며 규칙도 다릅니다. 언제 각각을 사용해야 하는지 알면 사소한 실수를 피할 수 있습니다.

개발자로서 당신의 Fear Score는?

두려움은 우리에게 모든 것을 앗아갑니다. 나는 한 번 “You miss 100% of the shots you don’t take”라는 말을 들었고, 그것이 내 경력 전반에 울려 퍼졌습니다. 돌아보면, 나는 man…

TanStack Start와 Bun을 Railway에 배포

문제 Railway의 Nixpacks 자동 빌드가 Bun 프로젝트를 감지하지만, 이 사이트가 필요로 하는 순서를 처리할 수 없습니다: - 빌드 시 prisma generate - Vite + TanStack ...