2025-12-26
Go 언어를 처음 접한 개발자들이 가장 인상 깊어하는 기능을 꼽으라면 단연 고루틴(Goroutine)입니다. go 키워드 하나로 함수를 비동기로 실행할 수 있는 단순함, 그리고 수십만 개를 동시에 띄워도 끄떡없는 가벼움은 Go 언어를 서버 개발의 강자로 만들어 준 핵심 요인입니다.
이 글에서는 고루틴이 왜 OS 스레드보다 가벼운지, Go 런타임의 M:N 스케줄러가 어떻게 동작하는지, 그리고 공유 자원 문제를 해결하는 WaitGroup, Mutex, atomic 패턴까지 실무 관점에서 심층적으로 다룹니다.
🛠️ 실무에서 고루틴이 빛나는 순간
필자가 사내 메신저 서버를 Go로 구축했을 때, 수천 개의 클라이언트 연결을 고루틴으로 처리했습니다. Java 스레드 풀 방식으로 동일한 서버를 구현했을 때와 비교하면 메모리 사용량이 약 10분의 1 수준이었습니다.
go키워드 하나가 가져다주는 이 차이는, Go를 서버 개발 언어로 선택한 핵심 이유 중 하나입니다.
고루틴을 이해하려면 먼저 OS 스레드(Thread)가 무엇인지, 그리고 왜 문제가 되는지 알아야 합니다.
운영체제 스레드는 프로세스 안에서 독립적으로 실행되는 작업 흐름입니다. 각 스레드는 OS 커널이 직접 관리하며, 생성과 전환에 상당한 비용이 따릅니다.
| 항목 | OS 스레드 | 고루틴 |
|---|---|---|
| 초기 스택 크기 | 1~8 MB (고정) | 2~8 KB (동적 확장) |
| 생성 비용 | 마이크로초 단위 | 나노초 단위 |
| 컨텍스트 스위칭 | 커널 모드 전환 필요 | 유저 공간에서 처리 |
| 동시 실행 한계 | 수천 개 수준 | 수십만~수백만 개 가능 |
OS 스레드는 생성 시 1~8 MB의 스택을 미리 할당합니다. 동시 연결이 10만 개라면 스택만 수백 GB가 필요해집니다. 이는 현실적이지 않습니다.
고루틴은 처음 2~8 KB의 작은 스택으로 시작하고, 필요에 따라 동적으로 늘어납니다. 10만 개의 고루틴이 있어도 스택 총량이 수 GB 수준입니다.
package main
import (
"fmt"
"runtime"
"sync"
)
func main() {
var wg sync.WaitGroup
// 고루틴 10만 개 생성
for i := 0; i < 100_000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
// 간단한 작업
}()
}
wg.Wait()
var mem runtime.MemStats
runtime.ReadMemStats(&mem)
fmt.Printf("Heap 사용량: %d KB\n", mem.HeapAlloc/1024)
// 대략 수십 MB 수준 — OS 스레드 10만 개 대비 훨씬 적음
}
Go 런타임은 M:N 스케줄링을 구현합니다. M개의 고루틴을 N개의 OS 스레드 위에서 실행하는 방식입니다.
Go 스케줄러는 세 가지 핵심 개념으로 구성됩니다.
G (Goroutine): 실행할 고루틴
M (Machine): OS 스레드
P (Processor): 논리적 프로세서 (GOMAXPROCS 수만큼 존재)
[P1] → [M1] → [G1, G2, G3 ...] (로컬 큐)
[P2] → [M2] → [G4, G5, G6 ...]
↑
[Global Queue: G7, G8 ...]
각 P는 로컬 고루틴 큐를 가집니다. M은 P에 할당되어 큐에서 G를 꺼내 실행합니다. 한 P의 큐가 비면 다른 P의 큐에서 절반을 훔쳐오는(Work Stealing) 방식으로 부하를 분산합니다.
고루틴이 시스템 콜(파일 I/O, 네트워크 등)로 블로킹되면, Go 런타임은 해당 M을 잠시 떼어내고 새로운 M을 P에 붙여 다른 고루틴을 계속 실행합니다.
// 이 고루틴이 파일 I/O로 블로킹되어도
go func() {
data, _ := os.ReadFile("large.txt") // 블로킹 발생
process(data)
}()
// 다른 고루틴은 멈추지 않고 계속 실행됨
go func() {
doOtherWork() // 계속 실행
}()
이 메커니즘 덕분에 수천 개의 네트워크 연결을 처리해도 전체 프로그램이 블로킹되지 않습니다.
import "runtime"
func main() {
// 기본값: CPU 코어 수
fmt.Println(runtime.GOMAXPROCS(0)) // 현재 값 조회
// 4코어 CPU에서 병렬 실행 수를 2로 제한
runtime.GOMAXPROCS(2)
}
GOMAXPROCS는 동시에 실행되는 P의 수를 결정합니다. Go 1.5 이후 기본값은 CPU 코어 수입니다.
go 키워드로 시작package main
import (
"fmt"
"time"
)
func sayHello(name string) {
fmt.Printf("안녕하세요, %s!\n", name)
}
func main() {
go sayHello("Alice") // 고루틴으로 실행
go sayHello("Bob") // 또 다른 고루틴
sayHello("Main") // 메인 고루틴에서 직접 실행
time.Sleep(time.Millisecond * 100)
// 출력 순서는 비결정적: Bob, Alice, Main 순서가 매번 다를 수 있음
}
go 키워드를 붙인 함수 호출은 즉시 반환되고, 함수는 새로운 고루틴에서 비동기로 실행됩니다.
func main() {
for i := 0; i < 5; i++ {
i := i // 루프 변수 캡처 문제 방지 (새 변수 생성)
go func() {
fmt.Println(i)
}()
}
time.Sleep(time.Millisecond * 100)
}
🛠️ 루프 변수 캡처 함정
Go 1.22 이전에는
for루프의i를 고루틴 클로저에서 직접 참조하면 루프가 끝난 후의 최종값만 출력되는 버그가 발생했습니다.i := i로 루프 내부에 새 변수를 만들거나, 함수 매개변수로 전달하는 방식으로 해결합니다. Go 1.22부터는 이 문제가 언어 차원에서 수정되었습니다.
// 안전한 방법: 매개변수로 전달
for i := 0; i < 5; i++ {
go func(n int) {
fmt.Println(n) // 각자의 n을 가짐
}(i)
}
time.Sleep으로 기다리는 방식은 불안정합니다. sync.WaitGroup이 올바른 방법입니다.
import "sync"
func main() {
var wg sync.WaitGroup
for i := 1; i <= 5; i++ {
wg.Add(1) // 대기할 고루틴 수 +1
go func(id int) {
defer wg.Done() // 고루틴 완료 시 -1
fmt.Printf("Worker %d 완료\n", id)
}(i)
}
wg.Wait() // 모든 고루틴이 Done()을 호출할 때까지 대기
fmt.Println("모든 작업 완료")
}
// ✅ 올바른 패턴: 고루틴 시작 전에 Add
wg.Add(1)
go func() {
defer wg.Done()
// 작업
}()
// ❌ 잘못된 패턴: 고루틴 내부에서 Add
go func() {
wg.Add(1) // 레이스 컨디션 위험 — Wait()가 먼저 실행될 수 있음
defer wg.Done()
// 작업
}()
wg.Add(1)은 반드시 go 키워드 이전에 호출해야 합니다. 고루틴 시작 후 Add를 호출하면 Wait이 먼저 통과해버릴 수 있습니다.
여러 고루틴이 동일한 변수를 동시에 읽고 쓰면 경쟁 상태(Race Condition)가 발생합니다.
func main() {
var counter int
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
counter++ // ❌ 레이스 컨디션!
}()
}
wg.Wait()
fmt.Println("Counter:", counter)
// 매번 다른 값 출력: 998, 1000, 995, ... (비결정적)
}
counter++는 사실 3단계 연산입니다: 읽기 → 증가 → 쓰기. 두 고루틴이 동시에 “읽기” 단계에 있으면 둘 다 같은 값을 읽어 증가시키므로 하나의 증가분이 사라집니다.
Go는 내장 레이스 탐지기를 제공합니다.
go run -race main.go
go test -race ./...
실행하면 레이스 컨디션 발생 위치를 정확히 출력합니다.
==================
WARNING: DATA RACE
Write at 0x00c000018090 by goroutine 7:
main.main.func1()
/main.go:14 +0x44
Read at 0x00c000018090 by goroutine 8:
main.main.func1()
/main.go:14 +0x44
==================
🛠️ 개발 환경에서는 항상 -race 플래그를 사용하라
CI/CD 파이프라인에
go test -race를 추가하면 레이스 컨디션을 조기에 발견할 수 있습니다. 프로덕션에서는 성능 오버헤드(약 5~10배)가 있어 사용하지 않지만, 테스트 환경에서는 필수입니다.
sync.Mutex는 한 번에 하나의 고루틴만 임계 구역(Critical Section)에 접근하도록 보장합니다.
import "sync"
type SafeCounter struct {
mu sync.Mutex
count int
}
func (c *SafeCounter) Increment() {
c.mu.Lock()
defer c.mu.Unlock()
c.count++ // ✅ 한 번에 하나의 고루틴만 접근
}
func (c *SafeCounter) Value() int {
c.mu.Lock()
defer c.mu.Unlock()
return c.count
}
func main() {
var wg sync.WaitGroup
counter := &SafeCounter{}
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
counter.Increment()
}()
}
wg.Wait()
fmt.Println("Counter:", counter.Value()) // 항상 1000
}
func (c *SafeCounter) Increment() {
c.mu.Lock()
defer c.mu.Unlock() // panic이 발생해도 Unlock 보장
c.count++
}
defer c.mu.Unlock()은 함수가 어떻게 종료되든 Unlock을 보장합니다. Lock 직후 defer를 쓰는 것이 관용적 패턴입니다.
읽기가 쓰기보다 훨씬 많은 경우 sync.RWMutex를 사용하면 성능이 크게 향상됩니다.
type Cache struct {
mu sync.RWMutex
data map[string]string
}
func (c *Cache) Get(key string) (string, bool) {
c.mu.RLock() // 읽기 잠금 (여러 고루틴이 동시에 읽기 가능)
defer c.mu.RUnlock()
v, ok := c.data[key]
return v, ok
}
func (c *Cache) Set(key, value string) {
c.mu.Lock() // 쓰기 잠금 (배타적)
defer c.mu.Unlock()
c.data[key] = value
}
| 잠금 종류 | 읽기 허용 | 쓰기 허용 |
|---|---|---|
RLock | 여러 고루틴 동시 가능 | 불가 |
Lock | 불가 | 하나의 고루틴만 가능 |
데드락(Deadlock)은 두 고루틴이 서로 상대방이 해제할 락을 기다리며 영원히 블로킹되는 상태입니다.
// ❌ 데드락 발생 시나리오
func main() {
var mu1, mu2 sync.Mutex
// 고루틴 A: mu1 → mu2 순서로 획득
go func() {
mu1.Lock()
time.Sleep(time.Millisecond) // mu2를 얻으려 할 때 B가 이미 mu2를 가짐
mu2.Lock()
fmt.Println("A 완료")
mu2.Unlock()
mu1.Unlock()
}()
// 고루틴 B: mu2 → mu1 순서로 획득
go func() {
mu2.Lock()
time.Sleep(time.Millisecond) // mu1을 얻으려 할 때 A가 이미 mu1을 가짐
mu1.Lock()
fmt.Println("B 완료")
mu1.Unlock()
mu2.Unlock()
}()
time.Sleep(time.Second) // 프로그램이 영원히 멈춤
}
// ✅ 해결: 항상 동일한 순서로 락 획득
func acquireLocks(mu1, mu2 *sync.Mutex) {
mu1.Lock() // 항상 mu1 먼저
mu2.Lock() // 그 다음 mu2
}
func releaseLocks(mu1, mu2 *sync.Mutex) {
mu2.Unlock() // 역순으로 해제
mu1.Unlock()
}
🛠️ 데드락 예방 3원칙
- 락 획득 순서를 전역적으로 통일한다. 모든 코드에서
mu1 → mu2순서를 지키면 데드락이 발생하지 않는다.- 락 보유 시간을 최소화한다. 락 안에서 외부 호출, I/O, 다른 락 획득을 피한다.
- 복잡한 락 중첩을 피한다. 락이 2개 이상 중첩되는 구조는 채널로 대체를 고려한다.
단순한 카운터 증가처럼 원자적으로 처리 가능한 연산은 뮤텍스보다 sync/atomic이 더 효율적입니다.
import "sync/atomic"
func main() {
var counter int64
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
atomic.AddInt64(&counter, 1) // ✅ 원자적 증가
}()
}
wg.Wait()
fmt.Println("Counter:", atomic.LoadInt64(&counter)) // 항상 1000
}
// ✅ atomic 적합: 단순 숫자 연산
var hits int64
atomic.AddInt64(&hits, 1) // 카운터 증가
atomic.CompareAndSwapInt64(&v, old, new) // CAS 연산
// ✅ Mutex 적합: 복합 연산, 구조체 필드 여러 개
mu.Lock()
cache[key] = value // 맵 쓰기
count++ // 여러 필드 동시 업데이트
mu.Unlock()
| 상황 | 권장 |
|---|---|
| 단일 숫자 증감 | atomic |
| 플래그 변경 | atomic.StoreInt32 |
| 구조체 여러 필드 | Mutex |
| 슬라이스/맵 조작 | Mutex 또는 채널 |
초기화 코드를 여러 고루틴에서 동시에 호출해도 딱 한 번만 실행되도록 보장합니다.
type Database struct {
conn *sql.DB
}
var (
instance *Database
once sync.Once
)
func GetDatabase() *Database {
once.Do(func() {
// 이 블록은 프로그램 전체에서 단 한 번만 실행
db, err := sql.Open("postgres", "...")
if err != nil {
panic(err)
}
instance = &Database{conn: db}
fmt.Println("DB 연결 초기화 완료")
})
return instance
}
func main() {
var wg sync.WaitGroup
for i := 0; i < 10; i++ {
wg.Add(1)
go func() {
defer wg.Done()
db := GetDatabase() // 10개의 고루틴이 동시에 호출해도
_ = db // 초기화는 단 한 번만 발생
}()
}
wg.Wait()
// 출력: "DB 연결 초기화 완료" (딱 한 번)
}
싱글턴 패턴, DB 연결 풀, 설정 파일 로딩 등에 필수적으로 사용됩니다.
무한정 고루틴을 생성하면 메모리 고갈 위험이 있습니다. 워커 풀(Worker Pool)로 고루틴 수를 제한합니다.
func main() {
const numWorkers = 5
const numJobs = 20
jobs := make(chan int, numJobs)
results := make(chan int, numJobs)
// 워커 풀 생성: 고정된 수의 고루틴만 실행
var wg sync.WaitGroup
for w := 0; w < numWorkers; w++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
for job := range jobs { // 채널이 닫힐 때까지 반복
result := job * job
fmt.Printf("Worker %d: %d^2 = %d\n", id, job, result)
results <- result
}
}(w)
}
// 작업 전송
for j := 1; j <= numJobs; j++ {
jobs <- j
}
close(jobs) // 채널을 닫으면 워커들이 for range를 종료
// 모든 워커 완료 후 결과 채널 닫기
go func() {
wg.Wait()
close(results)
}()
// 결과 수집
var total int
for r := range results {
total += r
}
fmt.Println("합계:", total)
}
이 패턴의 핵심은 채널을 작업 큐로 사용한다는 점입니다. 채널과 고루틴이 자연스럽게 결합되어 워커 풀을 구성합니다. 채널에 대한 자세한 내용은 다음 글에서 다룹니다.
고루틴은 가볍지만, 종료되지 않고 쌓이면 고루틴 누수가 발생합니다.
// ❌ 고루틴 누수: 채널 수신자가 없어 고루틴이 영원히 블로킹
func leak() {
ch := make(chan int) // 버퍼 없는 채널
go func() {
ch <- 1 // 수신자가 없으면 영원히 대기
}()
// ch를 읽지 않고 함수 종료 → 고루틴이 메모리에 남아 있음
}
// ✅ 수정: context로 취소 신호 전달
func noLeak(ctx context.Context) {
ch := make(chan int)
go func() {
select {
case ch <- 1:
case <-ctx.Done(): // 취소 신호 수신 시 종료
return
}
}()
}
현재 실행 중인 고루틴 수를 확인하는 방법:
fmt.Println("고루틴 수:", runtime.NumGoroutine())
테스트나 모니터링에서 이 값이 계속 증가하면 누수를 의심해야 합니다.
고루틴은 단순한 문법 기능이 아닙니다. Go 언어의 설계 철학인 “동시성을 일급 시민으로”를 구현하는 핵심 메커니즘입니다. 가벼운 실행 단위, 스마트한 스케줄러, 그리고 채널과의 완벽한 조화가 Go를 현대 서버 개발의 강자로 만들어 줍니다.
오늘 배운 핵심을 요약하면:
wg.Add(1)은 고루틴 시작 전에 호출해야 한다.-race 플래그로 탐지하고, Mutex 또는 atomic으로 해결한다.다음 글에서는 고루틴 간 통신의 핵심인 채널(Channel)을 심층적으로 다루며, 버퍼 채널, 방향성 채널, select 문, 그리고 파이프라인 패턴 등 실무에서 채널을 활용하는 모든 방법을 알아보겠습니다.