월요일 아침, 주문 서버를 맡고 있는 지민 씨는 난감한 요청을 받았습니다. "상품 100개의 재고를 외부 API로 조회하는 데 지금 50초가 걸리는데, 5초 안으로 줄여 주세요." 코드를 열어 보니 상품을 하나씩, 순서대로, 응답을 기다리며 호출하고 있었습니다. 이런 상황에서 Go가 빛을 발합니다. 오늘은 Go의 고루틴과 채널을 처음 접하는 분도 따라올 수 있게 풀어 보겠습니다.
기다리는 시간을 겹치게 하는 것이 동시성
요청 하나가 0.5초 걸리고 그런 요청이 100개라면 순서대로 처리할 때 50초가 걸립니다. 하지만 대부분의 시간은 CPU가 일하는 게 아니라 네트워크 응답을 기다리는 시간입니다. 기다리는 동안 다른 요청을 보내 둘 수 있다면 전체 시간은 가장 오래 걸린 요청 하나에 가까워집니다.
동시성은 일을 빠르게 하는 마법이 아니라, 기다리는 시간을 서로 겹치게 만드는 기술입니다.
Go는 이 일을 언어 차원에서 지원합니다. 스레드를 직접 만들고 관리하는 대신, 아주 가벼운 실행 단위인 고루틴(goroutine)을 수천 개씩 띄울 수 있습니다.
고루틴: go 키워드 하나로 시작
함수 호출 앞에 go만 붙이면 그 함수는 별도의 고루틴에서 실행됩니다.
package main
import (
"fmt"
"time"
)
func say(msg string) {
time.Sleep(100 * time.Millisecond)
fmt.Println(msg)
}
func main() {
go say("고루틴에서 실행")
fmt.Println("main 먼저 출력")
time.Sleep(300 * time.Millisecond) // 임시 대기
}여기서 중요한 함정이 있습니다. main 함수가 끝나면 다른 고루틴이 끝났든 말든 프로그램 전체가 종료됩니다. 위 예제의 Sleep은 설명용 임시방편일 뿐, 실제 코드에서는 쓰면 안 됩니다. 끝나기를 제대로 기다리는 방법이 필요합니다.
WaitGroup: 모두 끝날 때까지 기다리기
sync.WaitGroup은 "할 일이 몇 개 남았는지" 세는 카운터입니다.
var wg sync.WaitGroup
for i := 1; i <= 3; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
fmt.Println("작업", id, "완료")
}(i)
}
wg.Wait() // 카운터가 0이 될 때까지 대기Add로 늘리고, 각 고루틴이 끝나면 Done으로 줄이고, Wait가 0이 될 때까지 막아 줍니다. 반복문 변수를 인자로 넘긴 이유는 고루틴이 변수를 나중에 읽기 때문인데, 이 부분이 초보자가 가장 자주 밟는 지뢰입니다. (Go 1.22부터는 반복 변수가 매번 새로 만들어져 많이 완화되었지만, 이전 버전 코드베이스에서는 여전히 주의해야 합니다.)
채널: 고루틴끼리 값을 주고받는 통로
재고 조회 결과를 모아야 하니, 고루틴이 계산한 값을 밖으로 전달할 방법이 필요합니다. 이때 쓰는 것이 채널(channel)입니다.
func fetchStock(id int, out chan<- string) {
time.Sleep(500 * time.Millisecond) // 외부 API 호출 흉내
out <- fmt.Sprintf("상품 %d: 재고 있음", id)
}
func main() {
results := make(chan string, 100) // 버퍼 크기 100
for i := 1; i <= 100; i++ {
go fetchStock(i, results)
}
for i := 1; i <= 100; i++ {
fmt.Println(<-results)
}
}100개를 동시에 띄웠으니 전체는 약 0.5초 안팎에 끝납니다. 50초가 거의 100분의 1로 줄어드는 셈입니다. 채널에 값을 보내는 out <- v, 받는 <-out 두 가지만 기억하면 됩니다. 받을 값이 없으면 받는 쪽이 기다리므로 별도의 잠금 없이도 순서가 자연스럽게 맞춰집니다.
현실에서는 동시에 너무 많이 띄우면 안 된다
100개는 괜찮지만 10만 개를 한꺼번에 외부 API로 쏘면 상대 서버가 차단하거나 내 쪽 연결 수가 바닥납니다. 그래서 흔히 워커 풀(worker pool) 패턴으로 동시 실행 수를 제한합니다.
jobs := make(chan int, 100)
results := make(chan string, 100)
for w := 0; w < 5; w++ { // 일꾼 5명
go func() {
for id := range jobs {
results <- fmt.Sprintf("상품 %d 처리", id)
}
}()
}
for i := 1; i <= 100; i++ { jobs <- i }
close(jobs)
for i := 1; i <= 100; i++ { <-results }일꾼 5명이 일감 채널에서 하나씩 꺼내 처리하므로 동시에 5건만 나갑니다. close(jobs)로 "더 이상 일감이 없다"고 알려 주면 range 루프가 자연스럽게 끝납니다.
초보자가 자주 만나는 문제 세 가지
- 데드락: 아무도 받지 않는 채널에 값을 보내면 영원히 멈추고, Go가
all goroutines are asleep오류를 띄웁니다. - 데이터 레이스: 여러 고루틴이 같은 변수를 동시에 고치면 결과가 뒤죽박죽입니다.
go run -race main.go로 검사하고, 공유가 필요하면sync.Mutex나 채널을 쓰세요. - 고루틴 누수: 끝나지 않는 고루틴이 쌓이면 메모리가 새듯 늘어납니다.
context로 취소 신호를 전달하는 습관을 들이면 좋습니다.
정리하며
핵심은 네 가지입니다. go로 고루틴을 띄우고, WaitGroup으로 끝을 기다리며, 채널로 결과를 주고받고, 워커 풀로 동시 실행 수를 조절합니다. 지민 씨는 이 구조로 50초짜리 조회를 3초대까지 줄였다고 합니다(예시 상황입니다).
처음에는 데드락 오류가 낯설겠지만, 작은 예제를 직접 돌려 보며 -race 옵션을 켜 두는 것만으로도 절반은 해결됩니다. 오늘 저녁, 20줄짜리 연습 코드부터 천천히 시작해 보세요.


