4. Goroutines and Channels
goroutine과 channel로 하는 동시성
- Haram Lee
- 2026-10-06
- studies / Topics / Go
Go가 서버 언어로 많이 쓰이는 가장 큰 이유가 동시성이다. 이 글은 goroutine이 스레드와 무엇이 다른지, channel과 select로 무엇을 하는지, 그리고 요청은 바로 받고 전송은 뒤에서 모아서 하는, 수집 서버에서 실제로 쓰는 패턴을 정리한다.
4.1 Goroutines
함수 호출 앞에 go를 붙이면 그 함수가 새 goroutine에서 동시에 실행된다.
go sendBatch(batch) // 기다리지 않고 바로 다음 줄로 넘어간다goroutine은 OS 스레드가 아니다. Go 런타임이 적은 수의 OS 스레드 위에 많은 goroutine을 올려 스케줄링한다. 시작할 때 스택이 수 KB로 작아서 수만 개를 띄워도 부담이 적다. Java 스레드 하나가 보통 MB 단위 스택을 잡는 것과 비교하면 차이가 크다.
net/http 서버는 요청마다 goroutine을 하나씩 띄운다. 그래서 핸들러 코드는 “요청 하나를 순서대로 처리하는 코드"처럼 동기적으로 쓰면 되고, 동시 처리는 런타임이 맡는다. Node.js의 콜백이나 Scala의 Future 조합 없이도 많은 요청을 동시에 처리할 수 있는 이유다.
대신 goroutine은 띄운 쪽이 끝을 책임져야 한다. 끝나지 않는 goroutine이 쌓이는 것(goroutine leak)이 Go 서버의 대표적인 메모리 누수다. 그래서 goroutine을 다룰 때는 다음에 볼 channel 닫기와 5편의 취소가 늘 함께 나온다.
4.2 Channels
channel은 타입이 있는 큐다. 한쪽 goroutine이 보내고(ch <- v), 다른 쪽이 받는다(v := <-ch).
events := make(chan Event, 1000) // 버퍼 1000칸짜리 channel
// 보내는 쪽 (HTTP 핸들러)
events <- e
// 받는 쪽 (백그라운드 전송 goroutine)
for e := range events { // channel이 닫힐 때까지 받는다
...
}- 버퍼 없는 channel(
make(chan Event))은 보내는 쪽과 받는 쪽이 만날 때까지 둘 다 기다린다. 동기화 장치로 쓴다. - 버퍼 있는 channel은 버퍼가 찰 때까지 보내는 쪽이 기다리지 않는다. 버퍼가 차면 보내는 쪽이 막힌다. 이것이 자연스러운 배압(backpressure)이 된다.
- 닫기: 보내는 쪽이
close(ch)하면 받는 쪽의range가 끝난다. 받는 쪽은 닫지 않는다. 닫힌 channel에 보내면 panic이 난다.
Go의 격언 “메모리를 공유해서 통신하지 말고, 통신해서 메모리를 공유하라"가 이 구조를 말한다. 여러 goroutine이 같은 슬라이스를 락으로 지키며 고치는 대신, 하나의 goroutine만 슬라이스를 갖고 나머지는 channel로 값을 보낸다.
4.3 Waiting on Many Channels with select
select는 여러 channel 연산 중 먼저 준비된 것 하나를 실행한다.
ticker := time.NewTicker(1 * time.Second)
defer ticker.Stop()
for {
select {
case e := <-events:
batch = append(batch, e)
if len(batch) >= 500 {
flush(batch)
batch = batch[:0]
}
case <-ticker.C: // 1초마다 모인 만큼 보낸다
if len(batch) > 0 {
flush(batch)
batch = batch[:0]
}
case <-ctx.Done(): // 서버 종료 신호
flush(batch)
return
}
}이 루프가 수집 서버의 핵심 패턴이다. “500개가 모이거나 1초가 지나면 보낸다"는 동작을 락 없이 한 goroutine 안에서 구현한다. Kafka 클라이언트 라이브러리들이 내부에서 하는 배치도 같은 원리다.
default 절을 넣으면 아무 channel도 준비되지 않았을 때 기다리지 않고 바로 넘어간다. 버퍼가 가득 찼을 때 요청을 막지 않고 바로 거절(HTTP 503)하는 데 쓸 수 있다.
select {
case events <- e:
w.WriteHeader(http.StatusAccepted)
default:
http.Error(w, "busy", http.StatusServiceUnavailable)
}4.4 Locks and Waiting: the sync Package
channel이 만능은 아니다. 공유 상태가 단순하면 락이 더 읽기 쉽다.
sync.Mutex,sync.RWMutex: 공유 변수를 지킨다.mu.Lock(); defer mu.Unlock()형태로 쓴다.sync.WaitGroup: 여러 goroutine이 모두 끝날 때까지 기다린다. 서버를 종료할 때 백그라운드 작업이 다 끝났는지 확인하는 데 쓴다.sync/atomic: 카운터처럼 단순한 값을 락 없이 고친다.golang.org/x/sync/errgroup: 여러 goroutine을 띄우고 하나라도 에러가 나면 나머지를 취소한다.context와 함께 쓴다.
여러 goroutine이 같은 변수를 동시에 읽고 쓰는 실수는 go test -race나 go run -race로 잡는다. 동시성 코드를 짜면 테스트를 항상 -race로 돌린다.
4.5 Compared with Spark and Flink
Spark와 Flink의 동시성은 “데이터를 파티션으로 나눠 여러 머신에서 같은 연산을 돌린다"는 데이터 병렬성이다. Go의 동시성은 한 프로세스 안에서 “서로 다른 일을 동시에 진행한다"는 작업 동시성이다. 수집 서버에서 HTTP 요청 받기, 검증, Kafka 전송, 메트릭 기록이 각각 다른 goroutine에서 동시에 진행되는 모습이 전형적이다. 두 모델은 서로 경쟁하지 않는다. Go 서버가 Kafka에 넣은 데이터를 Flink가 꺼내 처리하는 식으로 이어진다.