goSeries · 1/62026년 3월 19일11 min read
Golang Concurrency

Golang Concurrency 2편 - Channel 완전 정복

Go Channel의 기본 동작부터 buffered/unbuffered 차이, 방향 제한, close 규칙, producer-consumer 패턴까지 다룹니다

FFrank
#golang#concurrency#channel

Channel은 goroutine 간 데이터를 주고받는 통신 수단이다. Go의 동시성 철학인 "메모리를 공유하지 말고, 통신으로 메모리를 공유하라"를 실현하는 핵심 메커니즘이다.

이번 편에서는 Channel의 기본 동작부터 buffered/unbuffered 차이, 방향 제한, close 규칙까지 완전히 다룬다.

1. Channel 개념과 생성

cover

Channel은 make 함수로 생성한다. 타입이 지정된 파이프라고 생각하면 된다.

ch := make(chan int)       // unbuffered channel (int 타입)
ch := make(chan string, 5) // buffered channel (string 타입, 버퍼 크기 5)
슬라이드 새 탭에서 열기 →

슬라이드를 클릭한 뒤 이동 · f 전체화면 · ? 도움말

2. Send / Receive 동작

Channel에 값을 보내려면 <- 연산자를 사용한다.

ch <- 42    // send: channel에 42를 보냄
val := <-ch // receive: channel에서 값을 받음
func TestChannelSendReceive(t *testing.T) {
    ch := make(chan int)

    go func() {
        ch <- 42 // send
    }()

    value := <-ch // receive (send될 때까지 blocking)
    assert.Equal(t, 42, value)
}

Channel은 다양한 타입을 지원한다. struct를 전달하면 결과와 에러를 함께 보낼 수 있다.

func TestChannelStructType(t *testing.T) {
    type Result struct {
        Value int
        Err   error
    }

    ch := make(chan Result)

    go func() {
        ch <- Result{Value: 100, Err: nil}
    }()

    result := <-ch
    assert.Equal(t, 100, result.Value)
    assert.NoError(t, result.Err)
}

3. Blocking 동작 이해

Channel의 가장 중요한 특성은 blocking이다.

  • Unbuffered channel: send와 receive가 동시에 준비되어야 진행된다
  • Buffered channel: 버퍼가 가득 차면 send가 blocking, 버퍼가 비면 receive가 blocking
graph LR
    subgraph "Unbuffered Channel"
        GA["Goroutine A (send - blocking)"] -- "핸드셰이크 (동시에 진행)" --> GB["Goroutine B (receive - blocking)"]
    end
graph LR
    subgraph "Buffered Channel (크기 3)"
        GA2["Goroutine A (send x3 가능, 4번째 send BLOCK!)"] --> BUF["Buffer (크기: 3)"] --> GB2["Goroutine B (receive)"]
    end

4. Unbuffered vs Buffered Channel

4.1 Unbuffered Channel

ch := make(chan int) // 버퍼 크기 0
  • send하면 receiver가 receive할 때까지 blocking
  • 동기적(synchronous) 통신: 양쪽이 모두 준비되어야 진행
func TestUnbufferedChannel(t *testing.T) {
    ch := make(chan int)

    go func() {
        ch <- 42 // receiver가 준비될 때까지 여기서 blocking
    }()

    time.Sleep(100 * time.Millisecond) // sender는 이미 blocking 중
    value := <-ch                       // receive하는 순간 sender도 진행
    assert.Equal(t, 42, value)
}

4.2 Buffered Channel

ch := make(chan int, 3) // 버퍼 크기 3
  • 버퍼에 빈 공간이 있으면 send가 즉시 완료
  • 버퍼가 가득 차면 send가 blocking
  • 비동기적(asynchronous) 통신
func TestBufferedChannel(t *testing.T) {
    ch := make(chan int, 3)

    // receiver 없이도 3개까지 send 가능
    ch <- 1
    ch <- 2
    ch <- 3
    // ch <- 4 → blocking (버퍼 가득 참)

    assert.Equal(t, 1, <-ch)
    assert.Equal(t, 2, <-ch)
    assert.Equal(t, 3, <-ch)
}

4.3 cap과 len

func TestBufferedChannelCapLen(t *testing.T) {
    ch := make(chan string, 5)

    assert.Equal(t, 5, cap(ch)) // 버퍼 용량
    assert.Equal(t, 0, len(ch)) // 현재 대기 중인 값 수

    ch <- "a"
    ch <- "b"

    assert.Equal(t, 5, cap(ch))
    assert.Equal(t, 2, len(ch))
}

4.4 언제 어떤 것을 쓸까?

상황선택
goroutine 간 동기화(핸드셰이크) 필요Unbuffered
신호 전달 (done, quit)Unbuffered
생산 속도와 소비 속도 차이 완충Buffered
Producer/Consumer 패턴Buffered
성능이 중요한 대량 데이터 전달Buffered

5. Channel 방향 제한

함수 파라미터에서 channel의 방향을 제한할 수 있다.

chan<- int  // send-only (보내기만 가능)
<-chan int  // receive-only (받기만 가능)

이를 통해 컴파일 타임에 channel의 잘못된 사용을 방지할 수 있다.

// producer: send-only channel
func produce(ch chan<- int, values []int) {
    for _, v := range values {
        ch <- v
    }
    close(ch)
}

// consumer: receive-only channel
func consume(ch <-chan int) []int {
    var results []int
    for v := range ch {
        results = append(results, v)
    }
    return results
}

func TestChannelDirection(t *testing.T) {
    ch := make(chan int, 5)
    go produce(ch, []int{1, 2, 3, 4, 5})
    results := consume(ch)
    assert.Equal(t, []int{1, 2, 3, 4, 5}, results)
}

양방향 channel은 send-only 또는 receive-only로 암묵적 변환된다. 반대 방향으로의 변환은 컴파일 에러가 발생한다.

var sendOnly chan<- int = ch  // OK: 양방향 → send-only
var recvOnly <-chan int = ch  // OK: 양방향 → receive-only

6. Channel Close

6.1 close의 의미

close(ch)는 "이 channel에 더 이상 값을 보내지 않겠다"는 선언이다.

6.2 닫힌 Channel에서 Receive

func TestReceiveFromClosedChannel(t *testing.T) {
    ch := make(chan int, 1)
    ch <- 42
    close(ch)

    // 버퍼에 값이 있으면 정상 반환
    val, ok := <-ch
    assert.Equal(t, 42, val)
    assert.True(t, ok)       // 유효한 값

    // 버퍼가 비고 닫힌 channel → zero value + false
    val, ok = <-ch
    assert.Equal(t, 0, val)  // int의 zero value
    assert.False(t, ok)      // 닫힘 표시
}

6.3 Close 규칙

규칙설명
Sender가 closereceiver가 아닌 sender가 close 책임
두 번 close 금지이미 닫힌 channel을 다시 닫으면 panic
닫힌 channel에 send 금지닫힌 channel에 값을 보내면 panic
닫힌 channel에서 receive 가능zero value + false 반환

6.4 Close 책임 패턴

// 패턴: sender가 channel을 생성하고, 보내고, close한다
func generator() <-chan int {
    ch := make(chan int)
    go func() {
        defer close(ch)   // sender가 close 책임
        for i := range 5 {
            ch <- i
        }
    }()
    return ch // receive-only로 반환
}

7. Range over Channel

range를 사용하면 channel이 닫힐 때까지 자동으로 값을 수신한다.

func TestRangeOverChannel(t *testing.T) {
    ch := make(chan int, 5)

    go func() {
        for i := 1; i <= 5; i++ {
            ch <- i
        }
        close(ch) // range가 종료되려면 close 필수!
    }()

    var results []int
    for v := range ch {
        results = append(results, v)
    }

    assert.Equal(t, []int{1, 2, 3, 4, 5}, results)
}

range ch가 종료되려면 반드시 close(ch)가 호출되어야 한다. close하지 않으면 range는 영원히 blocking된다.

7.1 신호용 Channel

데이터 전달이 아닌 완료 신호만 보내려면 chan struct{}를 사용한다. struct{}는 메모리를 차지하지 않는다.

func TestChannelSignaling(t *testing.T) {
    done := make(chan struct{})

    go func() {
        // 작업 수행...
        close(done) // 완료 신호 (모든 receiver에게 전달)
    }()

    <-done // 완료 대기
}

close(done)모든 receiver에게 동시에 신호를 보낸다. 이것이 단순히 done <- struct{}{}와의 차이점이다.

struct{}struct{}{}의 차이가 헷갈린다면 FAQ를 참고하자.

8. 실습: Producer / Consumer 패턴

func TestProducerConsumer(t *testing.T) {
    ch := make(chan int, 10)
    var results []int

    // Producer: 제곱수 생성
    go func() {
        for i := 1; i <= 10; i++ {
            ch <- i * i
        }
        close(ch)
    }()

    // Consumer: 결과 수집
    for val := range ch {
        results = append(results, val)
    }

    expected := []int{1, 4, 9, 16, 25, 36, 49, 64, 81, 100}
    assert.Equal(t, expected, results)
}

8.1 여러 Producer

func TestMultipleProducers(t *testing.T) {
    ch := make(chan int, 20)
    var wg sync.WaitGroup

    // 3개의 producer
    for p := range 3 {
        wg.Add(1)
        go func() {
            defer wg.Done()
            for i := range 5 {
                ch <- p*100 + i
            }
        }()
    }

    // producer가 모두 끝나면 channel close
    go func() {
        wg.Wait()
        close(ch)
    }()

    var results []int
    for val := range ch {
        results = append(results, val)
    }

    assert.Len(t, results, 15) // 3 x 5 = 15개
}

9. 퀴즈

여기까지 읽었으면 풀 수 있는 문제들이다. 답을 고르면 바로 해설이 나온다.

- type: mcq
  q: "Unbuffered channel과 Buffered channel의 차이를 바르게 설명한 것은?"
  choices: ["Buffered는 receiver 없이도 버퍼 크기만큼 send가 된다", "Unbuffered는 버퍼가 하나라도 있어야 send가 가능하다", "Buffered는 버퍼가 가득 차도 send가 blocking되지 않는다", "Unbuffered는 send한 뒤 receiver를 기다리지 않는다"]
  answer: 0
  explain: "Buffered channel은 버퍼에 빈 공간이 있으면 send가 즉시 완료되므로 receiver가 없어도 버퍼 크기만큼 보낼 수 있다. 버퍼가 가득 차면 그때부터 send가 blocking된다. 반면 unbuffered channel은 버퍼 크기가 0이라 receiver가 receive할 때까지 send가 blocking되는 동기적 통신이다. (4.1절, 4.2절)"

- type: code
  q: "이 코드를 그대로 실행하면 (1)에서 어떤 일이 벌어지나?"
  lang: go
  code: |
    ch := make(chan int)

    ch <- 42      // (1)
    value := <-ch // (2)
    _ = value
  choices: ["값이 내부 버퍼에 저장되고 다음 줄로 넘어간다", "unbuffered라 send 자체가 컴파일 에러가 된다", "위의 make가 버퍼를 1로 잡아 주어 통과한다", "receiver가 없어 blocking되어 더 진행되지 않는다"]
  answer: 3
  explain: "make(chan int)는 버퍼 크기가 0인 unbuffered channel이다. unbuffered channel은 send와 receive가 동시에 준비되어야 진행되는데, 여기서는 receive하는 (2)가 같은 goroutine의 다음 줄에 있어 실행될 기회가 없다. 그래서 (1)에서 멈춘다. (3장, 4.1절)"

- type: ox
  q: "close(done)은 대기 중인 모든 receiver에게 동시에 완료 신호를 전달한다."
  answer: true
  explain: "맞다. close(done)은 모든 receiver에게 동시에 신호를 보내며, 이것이 receiver 하나에게 값을 하나만 넘기는 일반 send와의 차이점이다. 그래서 완료 신호를 여러 곳에 뿌릴 때 close를 쓴다. (7.1절)"

- type: blank
  q: "버퍼 크기 5로 만든 channel에 값을 2개 넣어도 그대로 5를 돌려주는 내장 함수는 ___(ch)이다."
  answer: ["cap"]
  explain: "cap(ch)다. cap은 make에 지정한 버퍼 용량이라 값이 들어오고 나가도 변하지 않는다. 지금 버퍼에 대기 중인 값의 개수는 len(ch)로 따로 확인하며, 버퍼 크기 5인 channel에 값 2개를 넣으면 cap은 5, len은 2다. (4.3절)"

- type: mcq
  q: "Channel의 close 규칙으로 옳은 것은?"
  choices: ["close 책임은 sender가 아니라 receiver에게 있다", "이미 닫힌 channel은 다시 닫아도 아무 일이 없다", "닫힌 channel에 값을 보내면 panic이 발생한다", "닫힌 channel에서 값을 받으면 panic이 발생한다"]
  answer: 2
  explain: "닫힌 channel에 send하면 panic이 발생한다. close 책임은 값을 보내는 sender에게 있고, 이미 닫힌 channel을 다시 닫아도 panic이다. 반대로 닫힌 channel에서 receive하는 것은 허용되며 zero value와 false를 돌려준다. (6.3절)"

- type: code
  q: "이 코드에서 v2와 ok2에 담기는 값은?"
  lang: go
  code: |
    ch := make(chan int, 1)
    ch <- 42
    close(ch)

    v1, ok1 := <-ch
    v2, ok2 := <-ch
    _, _, _, _ = v1, ok1, v2, ok2
  choices: ["v2는 42이고 ok2는 true로 그대로 나온다", "v2는 0이고 ok2는 false로 나온다", "두 번째 receive에서 panic이 발생한다", "두 번째 receive가 그 자리에서 blocking된다"]
  answer: 1
  explain: "첫 번째 receive는 버퍼에 남아 있던 42와 true를 가져간다. 그 다음에는 버퍼가 비고 channel도 닫힌 상태이므로 두 번째 receive는 blocking되지 않고 곧바로 int의 zero value인 0과 닫힘을 뜻하는 false를 반환한다. (6.2절)"

- type: ox
  q: "sender가 보낼 값을 모두 보내고 나면 range ch는 close 없이도 자동으로 종료된다."
  answer: false
  explain: "아니다. range ch가 종료되려면 반드시 close(ch)가 호출되어야 한다. 보낼 값을 다 보냈더라도 close하지 않으면 range는 다음 값을 기다리며 영원히 blocking된다. (7장)"

- type: blank
  q: "데이터 없이 완료 신호만 주고받을 때는 메모리를 0바이트 차지하는 chan ___{} 타입을 쓴다."
  answer: ["struct", "struct{}"]
  explain: "chan struct{}이다. struct{}는 필드가 없는 빈 구조체 타입이라 메모리를 0바이트 차지하므로, 데이터 없이 신호만 전달할 때 가장 효율적인 선택이다. 값을 보낼 때는 인스턴스인 struct{}{}를 쓴다. (7.1절)"

- type: mcq
  q: "본문의 선택 기준에서 Unbuffered channel이 권장되는 상황은?"
  choices: ["goroutine 사이의 핸드셰이크 동기화가 필요할 때", "생산 속도와 소비 속도의 차이를 완충하고 싶을 때", "Producer/Consumer 패턴으로 처리량을 낼 때", "성능이 중요한 대량 데이터를 전달해야 할 때"]
  answer: 0
  explain: "goroutine 간 동기화(핸드셰이크)와 done, quit 같은 신호 전달에는 unbuffered를 쓴다. 생산·소비 속도 차이 완충, Producer/Consumer 패턴, 성능이 중요한 대량 데이터 전달은 모두 buffered 쪽이다. (4.4절)"

- type: code
  q: "이 코드에서 컴파일 에러가 나는 줄은?"
  lang: go
  code: |
    ch := make(chan int, 5)

    var sendOnly chan<- int = ch // (1)
    var recvOnly <-chan int = ch // (2)
    var both chan int = recvOnly // (3)
    _, _, _ = sendOnly, recvOnly, both
  choices: ["(1) — 양방향 channel은 send-only가 될 수 없다", "(2) — 양방향 channel은 receive-only가 못 된다", "(3)까지 포함해 세 줄 모두 정상 컴파일된다", "(3) — 방향이 제한된 channel은 양방향이 못 된다"]
  answer: 3
  explain: "양방향 channel은 send-only나 receive-only로 암묵적 변환되므로 (1)과 (2)는 정상이다. 하지만 반대 방향, 즉 방향이 제한된 channel을 다시 양방향으로 돌리는 (3)은 컴파일 에러다. (5장)"

10. 정리

개념핵심
Channelgoroutine 간 타입 안전한 통신 수단
Unbuffered동기적 핸드셰이크, send/receive 동시 준비 필요
Buffered비동기적 큐, 버퍼 크기만큼 send 가능
방향 제한chan<- (send-only), <-chan (receive-only)
Closesender가 책임, 닫힌 channel은 zero value 반환
Rangechannel 닫힐 때까지 자동 수신
chan struct{}신호 전달용 (메모리 0)

다음 편에서는 여러 channel을 동시에 처리하는 select문과 fan-in/fan-out 등 channel 심화 패턴을 다룬다.

11. FAQ

11.1 Q. struct{}struct{}{}의 차이는?

struct{}타입이고, struct{}{}는 **값(인스턴스)**이다.

일반 구조체로 비유하면 이해하기 쉽다:

type Person struct {
    Name string
    Age  int
}

p := Person{Name: "Frank", Age: 30}
//   ^^^^^^ 타입
//         ^^^^^^^^^^^^^^^^^^^^^^^^ 값

마찬가지로 빈 구조체도 동일한 구조다:

v := struct{}{}
//   ^^^^^^^^ 타입: struct{} (필드가 없는 구조체)
//           ^^ 값: {} (빈 리터럴)
표현의미비유
intint 타입설계도
42int 값실물
struct{}빈 구조체 타입설계도 (필드 없음)
struct{}{}빈 구조체 값실물 (내용 없음)

Channel에서 사용할 때:

// channel 생성 — 타입을 지정
done := make(chan struct{})  // struct{} 타입의 channel

// 값을 보냄 — struct{}{} 인스턴스를 send
done <- struct{}{}

// close — 값 대신 "닫힘" 신호를 모든 receiver에게 전달
close(done)

struct{}는 메모리를 0바이트 차지하므로, 데이터 없이 신호만 전달할 때 가장 효율적인 선택이다.

12. 참고

관련 글