LLM WikiAccess-protected knowledge portal
← 스터디 홈
2편 · 약 22분

고루틴(Goroutine)과 채널(Channel)

왜 Go의 동시성인가

서버가 요청을 수천 개 동시에 처리해야 할 때, OS 스레드를 수천 개 만드는 방식은 현실적이지 않다. 스레드 하나가 수 MB의 스택을 점유하고, 컨텍스트 스위칭 비용도 크다. Go는 이 문제를 고루틴(Goroutine) 이라는 경량 실행 단위로 해결한다.

고루틴은 OS 스레드가 아니다. Go 런타임이 직접 관리하는 사용자 공간의 실행 단위로, 초기 스택 크기가 약 2~8KB에 불과하다(Go 버전과 워크로드에 따라 다르며, 필요 시 자동으로 확장된다). 수십만 개의 고루틴을 하나의 서버에서 실행하는 것이 실제로 가능하다.

OS 스레드 스택: 1–8 MB 생성 비용: 높음 컨텍스트 스위치: μs 커널이 관리
Go 고루틴 스택: 2–8 KB (동적 확장) 생성 비용: 매우 낮음 컨텍스트 스위치: ns Go 런타임이 관리
고루틴 vs OS 스레드 비교

GMP 스케줄러: 런타임의 심장

Go 런타임은 GMP 모델을 사용해 고루틴을 OS 스레드 위에서 멀티플렉싱한다.

  • G (Goroutine): 실행할 코드와 스택을 담은 경량 실행 단위
  • M (Machine): OS 스레드. 실제로 CPU에서 코드를 실행한다
  • P (Processor): 논리적 실행 컨텍스트. 로컬 런 큐(run queue)와 메모리 캐시를 가진다

GOMAXPROCS 환경 변수(또는 runtime.GOMAXPROCS())가 P의 개수를 결정하며, 기본값은 CPU 코어 수다.

P0 (Processor)
M0 (OS Thread) Local Run Queue
G1 G2 G3
P1 (Processor)
M1 (OS Thread) Local Run Queue
G4 G5
Global Run Queue (공유) — P가 로컬 큐 소진 시 참조, 없으면 다른 P에서 Work Steal
GMP 스케줄러 구조

P의 로컬 큐가 비면, 스케줄러는 먼저 글로벌 큐를 확인하고, 그래도 없으면 다른 P의 로컬 큐에서 고루틴을 훔쳐(work-steal) 온다. 이를 통해 모든 CPU 코어가 균등하게 일하도록 보장한다.

고루틴 시작과 기본 패턴

고루틴은 함수 앞에 go 키워드를 붙이면 즉시 시작된다.

package main

import (
    "fmt"
    "time"
)

func printCount(label string, n int) {
    for i := 1; i <= n; i++ {
        fmt.Printf("[%s] %d\n", label, i)
        time.Sleep(50 * time.Millisecond)
    }
}

func main() {
    go printCount("A", 5) // 고루틴으로 실행
    go printCount("B", 5)
    time.Sleep(500 * time.Millisecond) // 고루틴이 끝날 때까지 대기 (임시 방법)
}

time.Sleep으로 기다리는 방식은 실제 코드에서 쓰지 않는다. 고루틴 완료를 제대로 기다리는 방법은 sync.WaitGroup이다.

sync.WaitGroup: 고루틴 완료 대기

import "sync"

func worker(id int, wg *sync.WaitGroup) {
    defer wg.Done() // 함수 종료 시 카운터 감소
    fmt.Printf("Worker %d 시작\n", id)
    // ... 작업 ...
    fmt.Printf("Worker %d 완료\n", id)
}

func main() {
    var wg sync.WaitGroup

    for i := 1; i <= 5; i++ {
        wg.Add(1)          // 고루틴 시작 전에 카운터 증가
        go worker(i, &wg)
    }

    wg.Wait() // 카운터가 0이 될 때까지 블록
    fmt.Println("모든 Worker 완료")
}

wg.Add(n)은 반드시 go 키워드 이전에 호출해야 한다. 고루틴 안에서 호출하면 경쟁 조건(race condition)이 생긴다.

채널(Channel): 통신으로 메모리를 공유한다

채널은 고루틴 간 값을 안전하게 전달하는 타입이 있는 파이프다.

// 생성
ch := make(chan int)        // 언버퍼드(unbuffered) 채널
bch := make(chan string, 5) // 버퍼드(buffered) 채널, 용량 5

// 전송
ch <- 42

// 수신
v := <-ch

// 채널 닫기 (송신자가 더 이상 값을 보내지 않을 때)
close(ch)

언버퍼드 vs 버퍼드

언버퍼드 채널은 송신자와 수신자가 만나는(rendezvous) 순간에만 전송이 완료된다. 수신자가 없으면 송신자는 블록, 송신자가 없으면 수신자는 블록한다. 강력한 동기화 보장이다.

버퍼드 채널은 버퍼가 가득 찰 때까지 송신자가 블록되지 않는다. 생산·소비 속도가 다를 때 완충재 역할을 한다.

언버퍼드 채널
송신자 G1 ←만남→ 수신자 G2
수신자 없으면 G1 블록
송신자 없으면 G2 블록
버퍼드 채널 (cap=3)
송신자
v1 v2 _
수신자
버퍼 가득 찰 때만 블록
비동기 생산·소비 가능
언버퍼드 vs 버퍼드 채널 동작

range로 채널 수신

채널을 닫으면 range가 자동으로 종료된다.

func generate(ch chan<- int, n int) { // chan<- 송신 전용
    for i := 0; i < n; i++ {
        ch <- i
    }
    close(ch) // 필수: 없으면 수신 쪽이 영원히 블록
}

func main() {
    ch := make(chan int, 10)
    go generate(ch, 5)

    for v := range ch { // close(ch)까지 수신
        fmt.Println(v)
    }
}

select: 여러 채널 동시에 처리

selectswitch와 비슷하지만, 채널 연산을 다룬다. 준비된 케이스가 여러 개이면 무작위로 하나를 고른다.

func merge(ch1, ch2 <-chan int) <-chan int { // <-chan 수신 전용
    out := make(chan int)
    go func() {
        defer close(out)
        for {
            select {
            case v, ok := <-ch1:
                if !ok { ch1 = nil } else { out <- v }
            case v, ok := <-ch2:
                if !ok { ch2 = nil } else { out <- v }
            }
            if ch1 == nil && ch2 == nil { return }
        }
    }()
    return out
}

default 케이스를 추가하면 모든 채널이 블록 상태일 때 블록하지 않고 즉시 실행된다 — 비블로킹 송수신 패턴에 쓴다.

context.Context: 취소와 타임아웃

고루틴에게 "그만해"를 전달하는 표준 방법이 context.Context다.

import (
    "context"
    "fmt"
    "time"
)

func doWork(ctx context.Context, id int) {
    for {
        select {
        case <-ctx.Done():
            fmt.Printf("Worker %d 취소됨: %v\n", id, ctx.Err())
            return
        default:
            fmt.Printf("Worker %d 작업 중...\n", id)
            time.Sleep(100 * time.Millisecond)
        }
    }
}

func main() {
    // 2초 타임아웃
    ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
    defer cancel() // 누수 방지 — 항상 호출

    go doWork(ctx, 1)
    go doWork(ctx, 2)

    <-ctx.Done()
    fmt.Println("완료:", ctx.Err())
}

context.WithCancel, context.WithTimeout, context.WithDeadline 셋이 가장 자주 쓰인다. cancel()은 반드시 호출해야 한다 — defer cancel()이 관용구다.

sync.Mutex: 공유 상태 보호

채널로 해결할 수 없는 경우, 여러 고루틴이 같은 변수를 읽고 쓸 때는 sync.Mutex로 보호한다.

import "sync"

type SafeCounter struct {
    mu sync.Mutex
    v  map[string]int
}

func (c *SafeCounter) Inc(key string) {
    c.mu.Lock()
    defer c.mu.Unlock()
    c.v[key]++
}

func (c *SafeCounter) Value(key string) int {
    c.mu.Lock()
    defer c.mu.Unlock()
    return c.v[key]
}

읽기가 훨씬 많고 쓰기가 드문 경우 sync.RWMutex를 쓴다. RLock()/RUnlock()은 여러 고루틴이 동시에 읽을 수 있게 허용한다.

파이프라인과 팬아웃/팬인 패턴

실제 시스템에서는 고루틴을 파이프라인 으로 연결해 데이터를 단계별로 처리한다.

Source
(생성)
팬아웃 (Fan-out) Worker 1 Worker 2 Worker 3
팬인
(Fan-in)
merge()
Sink
(집계)
각 단계는 채널로 연결. 업스트림이 done되면 close()로 다운스트림에 신호.
파이프라인과 팬아웃/팬인
// 팬아웃: 하나의 입력 채널을 여러 워커가 읽는다
func fanOut(in <-chan int, workers int) []<-chan int {
    outs := make([]<-chan int, workers)
    for i := 0; i < workers; i++ {
        out := make(chan int)
        outs[i] = out
        go func(out chan<- int) {
            defer close(out)
            for v := range in {
                out <- v * v // 예: 제곱 처리
            }
        }(out)
    }
    return outs
}

고루틴 누수: 가장 흔한 함정

고루틴이 채널을 영원히 기다리다가 종료되지 않으면 고루틴 누수(goroutine leak) 다. 프로그램이 오래 실행되면 메모리가 계속 늘어난다.

// 나쁜 예: 수신자 없는 채널에 송신 → 고루틴이 영원히 블록
func leak() {
    ch := make(chan int)
    go func() {
        ch <- 42 // 아무도 읽지 않으면 영원히 대기
    }()
    // ch를 사용하지 않고 반환 → 고루틴 누수
}

// 좋은 예: context로 취소 신호 전달
func noLeak(ctx context.Context) {
    ch := make(chan int, 1) // 버퍼드로 블록 방지
    go func() {
        select {
        case ch <- 42:
        case <-ctx.Done(): // 취소 시 안전하게 종료
        }
    }()
}

누수 탐지에는 runtime.NumGoroutine() 또는 goleak 라이브러리(Uber가 오픈소스로 공개)를 테스트에서 활용한다.

race detector: 경쟁 조건 탐지

Go 빌드 도구에는 경쟁 조건(race condition)을 런타임에 탐지하는 기능이 내장되어 있다.

go test -race ./...
go run -race main.go

-race 플래그를 켜면 런타임이 모든 메모리 접근을 추적해, 두 고루틴이 동기화 없이 같은 메모리를 읽고 쓰면 즉시 보고한다. CI에서 반드시 실행하는 것이 좋다.

References