고루틴(Goroutine) — Go 동시성의 핵심
Go 언어가 다른 언어들과 구별되는 가장 강력한 특징은 동시성(Concurrency) 지원입니다. Java나 Python에서 스레드를 생성하는 것은 비용이 크고 복잡한 작업이지만, Go에서는 go 키워드 하나만으로 경량 동시 실행 단위를 만들 수 있습니다. 이 경량 동시 실행 단위를 고루틴(Goroutine) 이라 부릅니다.
고루틴이란?
고루틴은 Go 런타임이 관리하는 경량 스레드(lightweight thread) 입니다. OS 스레드와 달리 훨씬 적은 메모리(초기 2~8KB 스택)로 시작하며, 필요에 따라 스택이 자동으로 늘어납니다. 수천, 수만 개의 고루틴을 동시에 실행해도 프로그램이 정상 동작합니다.
OS 스레드: ~1MB 스택 메모리, OS 스케줄러가 직접 관리
고루틴: ~2KB 스택 메모리, Go 런타임 스케줄러가 관리
go 키워드로 고루틴 시작하기
함수 호출 앞에 go 키워드를 붙이면 새 고루틴에서 해당 함수가 실행됩니다.
package main
import (
"fmt"
"time"
)
func sayHello(name string) {
fmt.Printf("Hello, %s!\n", name)
}
func main() {
// 고루틴으로 실행 — main 함수와 병렬 실행
go sayHello("Alice")
go sayHello("Bob")
go sayHello("Charlie")
// main이 먼저 끝나면 고루틴도 강제 종료됨
// time.Sleep으로 잠시 대기 (실제 코드에서는 WaitGroup 사용)
time.Sleep(100 * time.Millisecond)
fmt.Println("main 함수 종료")
}
중요: main 함수가 종료되면 실행 중인 고루틴도 모두 강제 종료됩니다. 위 예제에서 time.Sleep 없이는 고루틴이 실행될 기회조차 없을 수 있습니다.
M:N 스케줄러 — GOMAXPROCS
Go 런타임은 M:N 스케줄러 를 사용합니다. M개의 고루틴을 N개의 OS 스레드(보통 CPU 코어 수)에 매핑합니다.
package main
import (
"fmt"
"runtime"
)
func main() {
// 사용 가능한 논리 CPU 수 출력
fmt.Println("CPU 코어 수:", runtime.NumCPU())
fmt.Println("현재 GOMAXPROCS:", runtime.GOMAXPROCS(0))
// 실행 중인 고루틴 수 확인
fmt.Println("고루틴 수:", runtime.NumGoroutine())
// GOMAXPROCS 설정 (기본값은 NumCPU)
// runtime.GOMAXPROCS(4)
}
GOMAXPROCS는 기본적으로 CPU 코어 수로 설정되어 있어, 멀티코어를 자동으로 활용합니다.
sync.WaitGroup — 고루틴 완료 대기
time.Sleep으로 대기하는 것은 좋지 않습니다. sync.WaitGroup을 사용하면 모든 고루틴이 완료될 때까지 정확히 대기할 수 있습니다.
package main
import (
"fmt"
"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("모든 워커 완료")
}
WaitGroup 사용 규칙
wg.Add(n)은 고루틴 시작 전에 호출wg.Done()은 고루틴 내에서defer로 호출 (패닉 발생 시에도 확실히 실행)- WaitGroup은 포인터(
*sync.WaitGroup)로 전달
스택 자동 확장
Go 1.4부터 고루틴의 초기 스택 크기는 2KB 로 매우 작습니다. 스택이 부족하면 Go 런타임이 자동으로 더 큰 스택으로 복사합니다. 이 덕분에 수천 개의 고루틴을 메모리 걱정 없이 실행할 수 있습니다.
package main
import (
"fmt"
"sync"
)
// 10,000개의 고루틴 동시 실행 예제
func main() {
var wg sync.WaitGroup
results := make([]int, 10000)
for i := 0; i < 10000; i++ {
wg.Add(1)
go func(idx int) {
defer wg.Done()
results[idx] = idx * idx
}(i)
}
wg.Wait()
fmt.Printf("첫 5개 결과: %v\n", results[:5])
fmt.Printf("마지막 5개 결과: %v\n", results[9995:])
}
클로저와 고루틴 — 흔한 함정
루프에서 고루틴을 시작할 때 클로저가 변수를 캡처하는 방식 때문에 예상치 못한 결과가 나올 수 있습니다.
package main
import (
"fmt"
"sync"
)
func main() {
var wg sync.WaitGroup
// ❌ 잘못된 패턴 — 모든 고루틴이 같은 i를 참조
for i := 0; i < 5; i++ {
wg.Add(1)
go func() {
defer wg.Done()
fmt.Println(i) // 대부분 5 출력 (루프가 끝난 후의 i 값)
}()
}
wg.Wait()
fmt.Println("---")
// ✅ 올바른 패턴 1 — 인자로 값 전달
for i := 0; i < 5; i++ {
wg.Add(1)
go func(n int) {
defer wg.Done()
fmt.Println(n) // 0, 1, 2, 3, 4 (순서는 불규칙)
}(i)
}
wg.Wait()
fmt.Println("---")
// ✅ 올바른 패턴 2 — 루프 변수 복사 (Go 1.22+에서는 자동 해결)
for i := 0; i < 5; i++ {
i := i // 새 변수로 섀도잉
wg.Add(1)
go func() {
defer wg.Done()
fmt.Println(i)
}()
}
wg.Wait()
}
Go 1.22 이후부터는 for 루프 변수가 반복마다 새로 생성되어 이 문제가 자동으로 해결됩니다.
익명 함수로 고루틴 시작하기
별도의 함수를 선언하지 않고 인라인으로 고루틴을 실행할 수 있습니다.
package main
import (
"fmt"
"sync"
)
func main() {
var wg sync.WaitGroup
data := []string{"apple", "banana", "cherry", "date", "elderberry"}
for _, fruit := range data {
wg.Add(1)
fruit := fruit // 루프 변수 캡처 방지
go func() {
defer wg.Done()
// 병렬로 각 과일 처리
processed := fmt.Sprintf("[처리됨] %s", fruit)
fmt.Println(processed)
}()
}
wg.Wait()
fmt.Println("모든 처리 완료")
}
실전 예제 — 병렬 HTTP 요청
고루틴의 가장 실용적인 활용 중 하나는 여러 HTTP 요청을 동시에 보내는 것입니다.
package main
import (
"fmt"
"net/http"
"sync"
"time"
)
type Result struct {
URL string
Status int
Error error
}
func checkURL(url string, wg *sync.WaitGroup, results chan<- Result) {
defer wg.Done()
client := &http.Client{Timeout: 5 * time.Second}
resp, err := client.Get(url)
if err != nil {
results <- Result{URL: url, Error: err}
return
}
defer resp.Body.Close()
results <- Result{URL: url, Status: resp.StatusCode}
}
func main() {
urls := []string{
"https://golang.org",
"https://github.com",
"https://google.com",
}
var wg sync.WaitGroup
results := make(chan Result, len(urls))
start := time.Now()
for _, url := range urls {
wg.Add(1)
go checkURL(url, &wg, results)
}
// 모든 고루틴이 끝나면 채널 닫기
go func() {
wg.Wait()
close(results)
}()
// 결과 수집
for r := range results {
if r.Error != nil {
fmt.Printf("❌ %s: %v\n", r.URL, r.Error)
} else {
fmt.Printf("✅ %s: %d\n", r.URL, r.Status)
}
}
fmt.Printf("\n소요 시간: %v (순차 실행보다 빠름)\n", time.Since(start))
}
고루틴 누수 (Goroutine Leak)
고루틴이 종료되지 않고 계속 실행되는 것을 고루틴 누수 라 합니다. 메모리 누수와 비슷한 문제입니다.
package main
import (
"fmt"
"runtime"
"time"
)
// ❌ 누수 발생 — ch에서 읽는 사람이 없으면 고루틴이 영원히 블록
func leakyFunc(ch chan<- int) {
// ch가 아무도 읽지 않으면 이 고루틴은 영원히 대기
ch <- 42
}
// ✅ context나 done 채널로 종료 신호 전달
func safeFunc(ch chan<- int, done <-chan struct{}) {
select {
case ch <- 42:
case <-done:
fmt.Println("고루틴 정상 종료")
}
}
func main() {
fmt.Println("시작 고루틴 수:", runtime.NumGoroutine())
// 누수 예시
for i := 0; i < 10; i++ {
ch := make(chan int) // 아무도 읽지 않는 채널
go leakyFunc(ch)
}
time.Sleep(10 * time.Millisecond)
fmt.Println("누수 후 고루틴 수:", runtime.NumGoroutine()) // 10개 이상
// 정상 종료 예시
done := make(chan struct{})
ch := make(chan int, 1)
go safeFunc(ch, done)
close(done) // 종료 신호
time.Sleep(10 * time.Millisecond)
fmt.Println("정상 종료 후:", runtime.NumGoroutine())
}
고루틴 vs 스레드 비교
| 항목 | OS 스레드 | 고루틴 |
|---|---|---|
| 스택 크기 | ~1MB (고정) | 2KB ~ (동적) |
| 생성 비용 | 높음 (μs~ms) | 낮음 (ns) |
| 스케줄러 | OS 커널 | Go 런타임 |
| 컨텍스트 스위칭 | 느림 | 빠름 |
| 통신 방식 | 공유 메모리 | 채널 권장 |
| 개수 | 수백~수천 | 수십만 가능 |
핵심 정리
go 함수()— 새 고루틴에서 함수를 비동기 실행sync.WaitGroup— 여러 고루틴의 완료를 기다릴 때 사용- 루프 변수 캡처 주의— 인자로 전달하거나 새 변수로 섀도잉
- main 종료 시 모든 고루틴 종료— WaitGroup 또는 채널로 동기화 필수
- 고루틴 누수 방지— context 패키지로 취소 신호 전파