Go 언어 채널 내부 구조와 select, 파이프라인 패턴 완전 정복

Go 언어 채널: 내부 구조, select, 파이프라인 패턴 완전 정복

고루틴이 Go의 동시성 실행 단위라면, 채널(Channel)은 그 고루틴들을 연결하는 통신 수단입니다. Go 언어의 유명한 설계 철학 중 하나가 바로 이것입니다.

“공유 메모리로 통신하지 말고, 통신으로 메모리를 공유하라.” (Do not communicate by sharing memory; instead, share memory by communicating.)

이 글에서는 채널이 내부적으로 어떻게 동작하는지, 비버퍼 채널과 버퍼 채널의 차이, 방향성 채널로 API를 안전하게 설계하는 방법, select 문의 다양한 활용, 그리고 실무에서 자주 쓰이는 파이프라인, 팬아웃, 팬인 패턴까지 심층적으로 다룹니다.

🛠️ 채널을 쓰는 이유

앞선 고루틴 글에서 Mutex로 공유 자원을 보호하는 방법을 배웠습니다. 채널은 다른 접근법입니다. 데이터를 고루틴 간에 “전달”함으로써, 어느 시점에든 하나의 고루틴만 그 데이터를 소유하게 만듭니다. 소유권 이전이 곧 동기화입니다. 필자는 “상태를 공유하면 Mutex, 데이터를 전달하면 채널”이라는 기준으로 선택합니다.


1. 채널의 내부 구조

채널은 make(chan T) 또는 make(chan T, n)으로 생성합니다. 내부적으로는 세 가지 요소로 구성됩니다.

채널 내부 구조:
┌─────────────────────────────────────┐
│  hchan struct                       │
│  ┌──────┐  ┌──────┐  ┌──────────┐  │
│  │ buf  │  │ sendq│  │  recvq   │  │
│  │(링버퍼)│  │(대기큐)│  │ (대기큐)  │  │
│  └──────┘  └──────┘  └──────────┘  │
│  qcount: 현재 버퍼의 데이터 수       │
│  dataqsiz: 버퍼 용량                │
│  closed: 채널 닫힘 여부             │
└─────────────────────────────────────┘

비버퍼 채널에서 수신자가 없는 상태에서 송신하면, 송신 고루틴이 sendq에 들어가 블로킹됩니다. 반대로 송신자가 없는데 수신하려 하면, 수신 고루틴이 recvq에서 블로킹됩니다. 이 블로킹/언블로킹 메커니즘이 채널의 자연스러운 동기화를 만들어냅니다.


2. 비버퍼 채널: 랑데부 동기화

비버퍼 채널(Unbuffered Channel)은 송신자와 수신자가 반드시 동시에 준비되어야 데이터가 전달됩니다. 이를 랑데부(Rendezvous)라고 부릅니다.

package main

import "fmt"

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

    go func() {
        ch <- "고루틴에서 보낸 메시지" // 수신자가 준비될 때까지 블로킹
        fmt.Println("송신 완료")
    }()

    msg := <-ch // 고루틴의 송신이 완료될 때까지 블로킹
    fmt.Println("수신:", msg)
    // 출력:
    // 수신: 고루틴에서 보낸 메시지
    // 송신 완료
}

2-1. 비버퍼 채널이 보장하는 것

func process(done chan struct{}) {
    // 작업 수행
    fmt.Println("작업 완료")
    done <- struct{}{} // 완료 신호 전송
}

func main() {
    done := make(chan struct{})
    go process(done)
    <-done // process가 완료될 때까지 대기
    fmt.Println("메인 계속 실행")
    // "작업 완료"는 반드시 "메인 계속 실행" 이전에 출력됨
}

비버퍼 채널의 송신은 수신이 완료된 후에야 반환됩니다. 이는 강력한 happens-before 보장을 제공합니다.


3. 버퍼 채널: 비동기 통신

버퍼 채널(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

3-1. 비버퍼 vs 버퍼 채널 선택 기준

상황권장 채널
두 고루틴 간 명확한 동기화 필요비버퍼 채널
완료 신호, 세마포어비버퍼 채널
생산자가 소비자보다 빠른 경우버퍼 채널
작업 큐, 이벤트 큐버퍼 채널
버스트 트래픽 흡수버퍼 채널

🛠️ 버퍼 크기 결정 전략

버퍼 크기를 너무 크게 잡으면 메모리를 낭비하고, 너무 작으면 자주 블로킹이 발생합니다. 실무에서는 워커 수와 동일하게 잡거나 (make(chan Job, numWorkers)), 벤치마크를 통해 적절한 크기를 찾습니다. 버퍼가 자주 가득 찬다면 워커 수를 늘리는 것이 근본적인 해결책입니다.


4. 채널 방향성: 안전한 API 설계

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로 자동 변환
}

4-1. 방향성 채널의 실무 가치

// ✅ 방향성을 명시한 함수 시그니처
func StartWorker(jobs <-chan Job, results chan<- Result) {
    for job := range jobs {
        results <- process(job)
    }
}

이 함수 시그니처를 보는 것만으로도 “입력은 jobs, 출력은 results”라는 데이터 흐름이 명확해집니다. 방향성 채널은 자기 문서화(Self-Documenting) 코드를 가능하게 합니다.


5. 채널 닫기(close)와 range

송신자가 더 이상 보낼 데이터가 없으면 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("완료")
}

5-1. 수신 시 ok 패턴으로 닫힘 확인

for {
    v, ok := <-ch
    if !ok {
        // 채널이 닫혔음
        break
    }
    fmt.Println(v)
}

okfalse이면 채널이 닫혔고, v는 해당 타입의 Zero Value입니다.

5-2. 닫힌 채널에 송신하면 panic

ch := make(chan int, 1)
close(ch)
ch <- 1 // ❌ panic: send on closed channel

채널은 송신자만 닫아야 합니다. 수신자가 닫으면 송신자가 패닉을 일으킬 수 있습니다. 여러 송신자가 있을 때는 sync.Once나 별도의 done 채널을 활용합니다.


6. select 문: 여러 채널 동시 감시

select는 여러 채널 연산 중 준비된 것을 실행합니다. Go의 switch처럼 생겼지만, 채널 전용입니다.

6-1. 기본 select

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
}

6-2. default로 논블로킹 처리

select {
case v := <-ch:
    fmt.Println("수신:", v)
default:
    fmt.Println("채널이 비어 있음 — 즉시 반환")
}

default가 있으면 준비된 채널이 없어도 블로킹하지 않습니다. 폴링(polling) 패턴에 유용합니다.

6-3. 타임아웃 구현

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줄로 구현할 수 있습니다.

6-4. 취소(Cancellation) 패턴

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) 패키지의 기반 아이디어이기도 합니다.


7. 파이프라인 패턴

채널로 단계별 처리를 연결하는 파이프라인(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
    }
}

각 단계는 독립적인 고루틴에서 실행됩니다. 이전 단계가 값을 생산하는 즉시 다음 단계가 처리를 시작하여 진정한 병렬 파이프라인이 구성됩니다.


8. 팬아웃(Fan-out): 작업 분산

하나의 채널에서 여러 고루틴으로 작업을 분산하는 팬아웃(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, " ")
    }
}

9. 팬인(Fan-in): 결과 통합

여러 채널의 결과를 하나로 합치는 팬인(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 (순서 섞임)
}

팬아웃 + 팬인을 조합하면 완전한 병렬 처리 파이프라인이 완성됩니다.


10. 실무 패턴: context와 채널의 조합

실제 서버 코드에서는 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는 다음 글에서 자세히 다루지만, 채널과의 조합이 얼마나 자연스러운지 미리 확인할 수 있습니다.


11. 채널 관련 흔한 실수

11-1. nil 채널

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

11-2. 수신자 없는 채널로 인한 고루틴 누수

// ❌ 수신자가 없어 고루틴이 영원히 블로킹
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의 철학 그 자체

채널은 단순한 동기화 도구가 아닙니다. 고루틴 간 소유권 이전을 통해 공유 상태 없이 안전한 동시성 프로그래밍을 가능하게 하는 Go 언어의 철학적 핵심입니다.

오늘 배운 핵심을 요약하면:

  1. 비버퍼 채널은 랑데부 동기화를 보장하며, 두 고루틴 간 happens-before 관계를 만든다.
  2. 버퍼 채널은 생산자-소비자 속도 차이를 흡수하며, 작업 큐로 활용된다.
  3. 방향성 채널(chan<-, <-chan)로 함수 시그니처를 자기 문서화할 수 있다.
  4. close + range 조합으로 채널 완료 신호를 자연스럽게 전달한다.
  5. select로 여러 채널을 동시에 감시하며, 타임아웃과 취소 패턴을 구현한다.
  6. 파이프라인, 팬아웃, 팬인은 채널을 기반으로 한 강력한 동시성 패턴이다.

다음 글에서는 Go 언어의 컨텍스트(context)를 심층적으로 다루며, context.WithCancel, context.WithTimeout, context.WithValue의 동작 원리와 HTTP 서버, DB 쿼리, 고루틴 취소에서의 실무 활용 패턴을 알아보겠습니다.