go
2026년 3월 19일· 8분 읽기

Golang Concurrency (2) - Channel 완전 정복

#golang
#go
#concurrency
#channel
#buffered
#unbuffered
#producer-consumer
#고랭
#동시성
#채널
📚시리즈: Golang Concurrency
  1. 1.Golang Concurrency (1) - 개요와 Goroutine 기초
  2. 2.Golang Concurrency (2) - 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)

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<br/>send - blocking] -- "핸드셰이크<br/>(동시에 진행)" --> GB[Goroutine B<br/>receive - blocking]
    end
graph LR
    subgraph "Buffered Channel (크기 3)"
        GA2[Goroutine A<br/>send x3 가능<br/>4번째 send BLOCK!] --> BUF[Buffer<br/>크기: 3] --> GB2[Goroutine B<br/>receive]
    end

4. Unbuffered vs Buffered Channel

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)
}

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)
}

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))
}

언제 어떤 것을 쓸까?

상황선택
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

close의 의미

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

닫힌 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)      // 닫힘 표시
}

Close 규칙

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

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된다.

신호용 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)
}

여러 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. 정리

개념핵심
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 심화 패턴을 다룬다.

FAQ

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바이트 차지하므로, 데이터 없이 신호만 전달할 때 가장 효율적인 선택이다.

참고

관련 글