고루틴과 채널: Go의 동시성 기초
1. 개요
Go는 callback 지옥이나 복잡한 스레드 풀 없이도 동시성 프로그램을 간단하게 작성하도록 설계되었습니다. 두 가지 핵심 요소는 고루틴(가벼운 실행 단위)과 채널(고루틴 간의 통신 수단)입니다. 이 글을 다 읽은 후에는 고루틴을 생성하는 방법, sync.WaitGroup으로 동기화하는 방법, 채널을 통한 안전한 통신, 여러 채널을 동시에 처리하는 select 사용법, 그리고 고루틴 누수나 데드락 같은 흔한 실수를 피하는 방법을 알게 될 것입니다.
모든 예제는 Go 1.22 이상을 사용하며 외부 라이브러리가 필요 없습니다 — go run main.go만 실행하면 됩니다.
2. 고루틴이란
고루틴은 운영 체제가 아닌 Go 런타임에서 관리하는 독립적인 함수 실행입니다. 고루틴을 생성하는 비용은 매우 저렴합니다(스택 크기가 몇 KB이고 자동으로 확장됨). 따라서 운영 체제 스레드처럼 리소스를 낭비하지 않으면서 수십만 개의 고루틴을 생성할 수 있습니다.
package main
import (
"fmt"
"time"
)
func sayHello(name string) {
fmt.Println("안녕하세요,", name)
}
func main() {
go sayHello("Order Service")
go sayHello("Payment Service")
time.Sleep(100 * time.Millisecond) // 고루틴이 완료될 때까지 대기
fmt.Println("메인 종료")
}
go 키워드는 함수 호출 앞에 붙여서 메인을 블로킹하지 않고 새로운 고루틴에서 함수를 실행합니다. main이 두 고루틴이 출력을 마치기 전에 종료될 수 있으므로 time.Sleep으로 잠시 대기해야 합니다 — 이는 신뢰할 수 없는 방법이며 설명 목적일 뿐입니다. 결과는 다음과 같을 수 있습니다:
안녕하세요, Payment Service
안녕하세요, Order Service
메인 종료
출력 순서는 정해지지 않습니다. 스케줄러가 어떤 고루틴을 먼저 실행할지 결정하기 때문입니다. 이것이 동시성의 핵심입니다: 여러 작업이 “동시에” 진행되지만 순서는 보장되지 않습니다.
주의: 실제 코드에서 고루틴을 동기화하려고
time.Sleep을 사용하는 것은 안티패턴입니다. 섹션 4에서 이를 대체할sync.WaitGroup을 소개합니다.
3. 채널: 고루틴 간의 통신 수단
채널은 고루틴이 데이터를 송수신하기 위한 타입 안전 파이프입니다. Go의 철학은 “메모리를 공유하여 통신하지 마십시오; 통신으로 메모리를 공유하세요”입니다.
package main
import "fmt"
func calculateTotal(amount float64, result chan<- float64) {
tax := amount * 0.1
result <- amount + tax
}
func main() {
result := make(chan float64)
go calculateTotal(1_000_000, result)
total := <-result
fmt.Printf("주문 총액: %.0f\n", total)
}
make(chan float64)는 타입이float64인 언버퍼드 채널을 생성합니다.- 함수 파라미터의
chan<- float64는 채널이 송신 전용임을 의미하여, 실수로 읽으려 하면 컴파일러가 오류를 잡아냅니다. result <- amount + tax는 채널에 값을 보냅니다.main의<-result는 데이터가 도착할 때까지 블로킹되어 자연스럽게 동기화 역할을 합니다 —Sleep이 필요 없습니다.
주문 총액: 1100000
3.1. 언버퍼드 vs 버퍼드 채널
언버퍼드 채널(make(chan T))은 송신자와 수신자가 “만나야” 합니다 — 수신자가 준비될 때까지 송신이 블로킹됩니다. 버퍼드 채널(make(chan T, n))은 수신자를 기다리지 않고 최대 n개의 값을 보낼 수 있습니다.
package main
import "fmt"
func main() {
orders := make(chan string, 3)
orders <- "Order-1"
orders <- "Order-2"
orders <- "Order-3"
close(orders)
for order := range orders {
fmt.Println("처리:", order)
}
}
버퍼가 3개이므로 세 개의 송신 명령이 블로킹되지 않습니다. close(orders)는 더 이상의 데이터가 없음을 신호하여, range 루프가 다음 값을 무한정 기다리는 대신 자동으로 종료되도록 합니다.
처리: Order-1
처리: Order-2
처리: Order-3
주의: 송신자만 채널을
close할 수 있습니다. 닫힌 채널로 송신하면send on closed channel패닉이 발생합니다. 닫힌 채널에서 수신하는 것은 유효하며, 0 값과ok == false를 반환합니다.
4. WaitGroup으로 여러 고루틴 동기화하기
여러 고루틴이 완료될 때까지 기다려야 하지만 반환 값에는 관심이 없을 때, sync.WaitGroup이 채널보다 더 깔끔한 선택입니다.
package main
import (
"fmt"
"sync"
)
func processOrder(id int, wg *sync.WaitGroup) {
defer wg.Done()
fmt.Printf("Order-%d 처리됨\n", id)
}
func main() {
var wg sync.WaitGroup
for i := 1; i <= 3; i++ {
wg.Add(1)
go processOrder(i, &wg)
}
wg.Wait()
fmt.Println("모든 order 처리 완료")
}
wg.Add(1)은 고루틴이 실행되기 전에 카운터를 증가시킵니다 —go앞에서 호출해야 하고, 고루틴 내부가 아니어야 합니다. 그렇지 않으면 레이스 컨디션이 발생할 수 있습니다.defer wg.Done()은 고루틴이 종료될 때 카운터를 감소시킵니다.wg.Wait()은 카운터가 0이 될 때까지 메인을 블로킹합니다.
Order-2 처리됨
Order-1 처리됨
Order-3 처리됨
모든 order 처리 완료
처음 세 줄의 순서는 정해지지 않지만 마지막 줄은 항상 마지막에 출력됩니다 — 이것이 WaitGroup이 보장하는 것입니다.
4.1. 동시 동작 테스트하기
모든 고루틴이 완료되었는지 확인하는 테스트를 작성할 수 있습니다. 출력 순서에 의존하는 대신 카운터 채널을 사용하세요:
package orderpkg
import (
"sync"
"sync/atomic"
"testing"
"github.com/stretchr/testify/assert"
)
func TestProcessOrders_AllCompleted(t *testing.T) {
var wg sync.WaitGroup
var processed int32
for i := 1; i <= 5; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
atomic.AddInt32(&processed, 1)
}(i)
}
wg.Wait()
assert.Equal(t, int32(5), processed)
}
atomic.AddInt32는 여러 고루틴이 카운터를 동시에 증가시킬 때 레이스 컨디션을 방지합니다 — 일반적인 processed++을 사용하면 go test -race가 데이터 레이스를 보고합니다.
테스트 의존성 (go.mod에 추가):
go get github.com/stretchr/testify
5. Select: 여러 채널 동시에 처리하기
select는 채널용 switch처럼 작동합니다 — 먼저 준비된 case를 선택해서 실행합니다. 여러 데이터 소스를 기다리거나 타임아웃을 설정할 때 매우 유용합니다.
package main
import (
"fmt"
"time"
)
func fetchFromCache(ch chan<- string) {
time.Sleep(50 * time.Millisecond)
ch <- "캐시 데이터"
}
func fetchFromDB(ch chan<- string) {
time.Sleep(200 * time.Millisecond)
ch <- "데이터베이스 데이터"
}
func main() {
cacheCh := make(chan string)
dbCh := make(chan string)
go fetchFromCache(cacheCh)
go fetchFromDB(dbCh)
select {
case res := <-cacheCh:
fmt.Println("받음:", res)
case res := <-dbCh:
fmt.Println("받음:", res)
case <-time.After(100 * time.Millisecond):
fmt.Println("타임아웃, 기본 데이터 사용")
}
}
select는 한 가지 case가 준비될 때까지 블로킹됩니다. 캐시는 50ms 후에 반환되고 DB는 200ms 후이므로 cacheCh case가 먼저 실행됩니다:
받음: 캐시 데이터
time.After는 지정된 시간 후에 값을 받는 채널을 생성하여 select의 타임아웃을 구현합니다.
6. Context로 고루틴 올바르게 취소하기
일반적인 실수는 고루틴을 시작하지만 중단할 방법이 없다는 것인데, 이는 고루틴 누수로 이어집니다. context 패키지는 호출 체인 전체에 취소 신호를 전달하여 이 문제를 해결합니다.
package main
import (
"context"
"fmt"
"time"
)
func monitorPayment(ctx context.Context, orderID string) {
for {
select {
case <-ctx.Done():
fmt.Println("주문 모니터링 중지:", orderID, "-", ctx.Err())
return
case <-time.After(500 * time.Millisecond):
fmt.Println("주문 상태 확인:", orderID)
}
}
}
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 1200*time.Millisecond)
defer cancel()
go monitorPayment(ctx, "Order-99")
<-ctx.Done()
time.Sleep(50 * time.Millisecond) // 고루틴이 마지막 줄을 출력할 때까지 대기
}
context.WithTimeout은 1200ms 후에 자동으로Done()되는 컨텍스트를 생성합니다.monitorPayment에서select는ctx.Done()과 주기적인 틱을 모두 감시합니다. 컨텍스트가 만료되면 고루틴이 무한히 실행되는 대신 깔끔하게 종료됩니다.defer cancel()은 함수가 일찍 종료되더라도 컨텍스트 리소스를 반드시 해제합니다.
주문 상태 확인: Order-99
주문 상태 확인: Order-99
주문 모니터링 중지: Order-99 - context deadline exceeded
주의:
WithTimeout이든WithCancel이든 항상cancel()을 호출하세요 (defer를 통해). 이 단계를 놓치면 즉시 오류가 발생하지 않지만 장시간 루프에서 컨텍스트가 누수됩니다.
7. 흔한 실수들
수신자가 없는 채널로 인한 데드락. 어떤 고루틴도 읽지 않는 언버퍼드 채널로 송신하면 프로그램이 멈추고 Go 런타임이 자동으로 감지합니다:
func main() {
ch := make(chan int)
ch <- 1 // 아무도 읽지 않음 → 데드락
}
fatal error: all goroutines are asleep - deadlock!
고루틴 누수. 고루틴이 채널을 읽을 때까지 무한정 기다립니다. 아무도 송신하지 않고 취소 메커니즘도 없으면 메모리가 프로그램이 종료될 때까지 유지됩니다. 장시간 실행되는 고루틴을 시작할 때는 항상 context 또는 “done” 채널을 포함하세요.
잘못된 루프 변수 캡처 (Go 1.22 이전). Go < 1.22를 사용하면 for 루프의 루프 변수 i가 반복 간에 공유되어 모든 고루틴이 같은 최종 값을 볼 수 있습니다. Go 1.22부터는 각 반복이 새로운 i 변수를 생성하므로 이 문제가 사라졌습니다 — 하지만 더 오래된 버전을 사용하는 경우 섹션 4.1의 예제처럼 변수를 파라미터로 전달하세요.
변수 공유 시 레이스 컨디션. 두 고루틴이 채널이나 sync/atomic 없이 같은 변수를 읽고 쓰는 것은 전형적인 레이스 컨디션입니다. 개발 단계에서 go test -race 또는 go run -race를 항상 실행하여 조기에 감지하세요.
언제 고루틴을 사용하지 말아야 할까: 짧은 작업의 경우 고루틴 생성 및 동기화의 오버헤드가 이점보다 클 수 있습니다. 작업이 단 몇 마이크로초만 걸리면 순차 실행이 보통 더 빠르고 읽기 쉽습니다.
8. 결론
고루틴은 극도로 낮은 비용으로 가벼운 실행 스레드를 만드는 방법을 제공하고, 채널은 메모리를 직접 공유하는 대신 안전한 통신 메커니즘을 제공합니다. sync.WaitGroup으로 동기화하고, select로 다중 소스를 처리하며, context로 올바르게 취소하는 것을 결합하면 Go의 일반적인 동시성 패턴을 구축할 수 있는 충분한 도구를 갖추게 됩니다 — 워커 풀, 파이프라인, fan-in/fan-out에서부터 시작하세요. 다음 단계는 go test -race로 연습하여 레이스 컨디션을 조기에 감지하는 습관을 들이는 것입니다.