Go 고루틴과 채널: 실용적 동시성 가이드

Backend2026-09-30TryQuickToolBox

Go 코드를 작성하다 보면 잘 작동하다가도 갑자기 문제가 생기는 경우가 있습니다. 부하가 걸릴 때 데이터 레이스로 서비스가 중단되거나, 데드락으로 API가 멈출 수 있습니다. 동시성은 Go의 가장 큰 장점 중 하나지만, 동시에 많은 개발자가 어려움을 겪는 부분이기도 합니다. 고루틴과 채널은 기본 구성 요소이지만, 올바르게 사용하려면 동작 방식과 흔한 함정을 이해해야 합니다.

이 가이드에서는 고루틴과 채널을 사용하는 실용적인 패턴을 코드 예제와 함께 살펴보고, 바로 적용할 수 있는 조언을 제공합니다.

고루틴이란?

고루틴은 Go 런타임이 관리하는 경량 스레드입니다. 함수 호출 앞에 go를 붙여 생성합니다:

go doSomething()

고루틴은 작은 스택(몇 킬로바이트)으로 시작해 필요에 따라 커집니다. 따라서 수천, 심지어 수백만 개를 생성해도 비용이 저렴합니다. Go 스케줄러가 고루틴을 OS 스레드에 멀티플렉싱하므로 스레드를 직접 관리할 필요가 없습니다.

하지만 조정 없이 고루틴을 생성하면 혼란이 발생합니다. 고루틴을 기다리거나, 데이터를 전달하거나, 취소할 방법이 필요합니다. 여기서 채널이 등장합니다.

채널: 고루틴 간의 통신

채널은 값을 주고받기 위한 타입이 지정된 통로입니다. 채널은 동기화를 제공합니다. 송신은 수신자가 준비될 때까지 블록되고, 수신은 값이 준비될 때까지 블록됩니다. 따라서 채널은 고루틴을 조정하는 데 이상적입니다.

make(chan T)로 채널을 생성합니다:

ch := make(chan int)
go func() {
    ch <- 42 // send
}()
value := <-ch // receive

기본적으로 채널은 버퍼가 없어 송신과 수신이 동시에 일어납니다. 버퍼가 있는 채널은 수신자 없이도 제한된 수의 송신을 허용합니다:

ch := make(chan int, 3) // buffer size 3
ch <- 1
ch <- 2
ch <- 3
// ch <- 4 would block until someone receives

버퍼 채널은 버스트를 완화할 수 있지만 조정의 필요성을 없애지는 않습니다.

일반적인 동시성 패턴

1. 워커 풀

고정된 수의 워커에 작업을 분배하여 리소스 사용을 제한합니다:

func worker(id int, jobs <-chan int, results chan<- int) {
    for j := range jobs {
        results <- j * 2
    }
}

func main() {
    jobs := make(chan int, 100)
    results := make(chan int, 100)

    for w := 1; w <= 3; w++ {
        go worker(w, jobs, results)
    }

    for i := 1; i <= 5; i++ {
        jobs <- i
    }
    close(jobs)

    for r := 1; r <= 5; r++ {
        <-results
    }
}

이 패턴은 병렬성을 제어하려는 CPU 바운드 또는 IO 바운드 작업에 적합합니다.

2. 팬아웃, 팬인

팬아웃은 여러 고루틴이 같은 채널에서 읽는 것을 의미합니다. 팬인은 여러 채널의 결과를 하나로 병합하는 것입니다:

func fanIn(chs ...<-chan int) <-chan int {
    out := make(chan int)
    var wg sync.WaitGroup
    wg.Add(len(chs))
    for _, ch := range chs {
        go func(c <-chan int) {
            defer wg.Done()
            for v := range c {
                out <- v
            }
        }(ch)
    }
    go func() {
        wg.Wait()
        close(out)
    }()
    return out
}

여러 프로듀서가 있고 단일 결과 스트림을 원할 때 사용하세요.

3. 멀티플렉싱을 위한 select

select 문을 사용하면 고루틴이 여러 채널 작업을 기다릴 수 있습니다:

select {
case msg := <-ch1:
    fmt.Println("received from ch1:", msg)
case ch2 <- 42:
    fmt.Println("sent to ch2")
case <-time.After(1 * time.Second):
    fmt.Println("timeout")
}

비블로킹 작업에는 default 케이스와 함께 select를 사용하고, 영원히 기다리지 않으려면 타임아웃을 사용하세요.

함정과 회피 방법

모범 사례

비교: 채널 vs. 뮤텍스

측면채널뮤텍스
사용 사례데이터 전달, 조정공유 상태 보호
복잡성단순 작업에는 더 높음단순 작업에는 더 낮음
성능동기화 오버헤드짧은 임계 영역에서는 보통 더 빠름
위험데드락, 누수주의하지 않으면 데드락

FAQ

언제 버퍼 채널을 사용해야 하나요?

버스트를 처리하기 위해 송신자와 수신자를 분리하려거나, 송신 횟수를 정확히 알고 블로킹을 피하려면 버퍼 채널을 사용하세요. 그러나 버퍼 없는 채널은 더 강력한 동기화 보장을 제공하며 종종 추론하기 더 쉽습니다.

고루틴 누수를 어떻게 피하나요?

모든 고루틴이 명확한 종료 경로를 갖도록 하세요. context.Context를 사용해 취소를 신호하고, 더 이상 값을 보내지 않을 때 채널을 닫고, 타임아웃이나 default 케이스 없이 채널 작업에서 무기한 블로킹되는 것을 피하세요.

모든 것에 채널을 사용할 수 있나요?

아니요. 채널은 강력하지만 오버헤드와 복잡성을 추가합니다. 단순한 공유 상태에는 뮤텍스가 종종 더 간단하고 효율적입니다. Go 속담에 "메모리를 공유하여 통신하지 말고, 통신하여 메모리를 공유하라"는 말이 있지만, 채널이 항상 답이라는 의미는 아닙니다.

Go 서비스의 동시성 문제를 디버깅하거나 로그를 분석하는 경우, Nginx Log Analyzer가 패턴과 오류를 빠르게 찾는 데 도움이 될 수 있습니다.