Go 언어 고루틴 동작 원리와 동시성 제어 완전 정복

Go 언어 고루틴: 동작 원리, 동시성 제어, 실무 패턴 완전 정복

Go 언어를 처음 접한 개발자들이 가장 인상 깊어하는 기능을 꼽으라면 단연 고루틴(Goroutine)입니다. go 키워드 하나로 함수를 비동기로 실행할 수 있는 단순함, 그리고 수십만 개를 동시에 띄워도 끄떡없는 가벼움은 Go 언어를 서버 개발의 강자로 만들어 준 핵심 요인입니다.

이 글에서는 고루틴이 왜 OS 스레드보다 가벼운지, Go 런타임의 M:N 스케줄러가 어떻게 동작하는지, 그리고 공유 자원 문제를 해결하는 WaitGroup, Mutex, atomic 패턴까지 실무 관점에서 심층적으로 다룹니다.

🛠️ 실무에서 고루틴이 빛나는 순간

필자가 사내 메신저 서버를 Go로 구축했을 때, 수천 개의 클라이언트 연결을 고루틴으로 처리했습니다. Java 스레드 풀 방식으로 동일한 서버를 구현했을 때와 비교하면 메모리 사용량이 약 10분의 1 수준이었습니다. go 키워드 하나가 가져다주는 이 차이는, Go를 서버 개발 언어로 선택한 핵심 이유 중 하나입니다.


1. 스레드 vs 고루틴: 근본적 차이

고루틴을 이해하려면 먼저 OS 스레드(Thread)가 무엇인지, 그리고 왜 문제가 되는지 알아야 합니다.

1-1. OS 스레드의 비용

운영체제 스레드는 프로세스 안에서 독립적으로 실행되는 작업 흐름입니다. 각 스레드는 OS 커널이 직접 관리하며, 생성과 전환에 상당한 비용이 따릅니다.

항목OS 스레드고루틴
초기 스택 크기1~8 MB (고정)2~8 KB (동적 확장)
생성 비용마이크로초 단위나노초 단위
컨텍스트 스위칭커널 모드 전환 필요유저 공간에서 처리
동시 실행 한계수천 개 수준수십만~수백만 개 가능

OS 스레드는 생성 시 1~8 MB의 스택을 미리 할당합니다. 동시 연결이 10만 개라면 스택만 수백 GB가 필요해집니다. 이는 현실적이지 않습니다.

1-2. 고루틴의 경량성 비결

고루틴은 처음 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만 개 대비 훨씬 적음
}

2. M:N 스케줄링: Go 런타임의 비밀

Go 런타임은 M:N 스케줄링을 구현합니다. M개의 고루틴을 N개의 OS 스레드 위에서 실행하는 방식입니다.

2-1. G-M-P 모델

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) 방식으로 부하를 분산합니다.

2-2. 블로킹과 스케줄러의 대처

고루틴이 시스템 콜(파일 I/O, 네트워크 등)로 블로킹되면, Go 런타임은 해당 M을 잠시 떼어내고 새로운 M을 P에 붙여 다른 고루틴을 계속 실행합니다.

// 이 고루틴이 파일 I/O로 블로킹되어도
go func() {
    data, _ := os.ReadFile("large.txt") // 블로킹 발생
    process(data)
}()

// 다른 고루틴은 멈추지 않고 계속 실행됨
go func() {
    doOtherWork() // 계속 실행
}()

이 메커니즘 덕분에 수천 개의 네트워크 연결을 처리해도 전체 프로그램이 블로킹되지 않습니다.

2-3. GOMAXPROCS: 병렬 실행 수 제어

import "runtime"

func main() {
    // 기본값: CPU 코어 수
    fmt.Println(runtime.GOMAXPROCS(0)) // 현재 값 조회

    // 4코어 CPU에서 병렬 실행 수를 2로 제한
    runtime.GOMAXPROCS(2)
}

GOMAXPROCS는 동시에 실행되는 P의 수를 결정합니다. Go 1.5 이후 기본값은 CPU 코어 수입니다.


3. 고루틴 기본 사용법

3-1. 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 키워드를 붙인 함수 호출은 즉시 반환되고, 함수는 새로운 고루틴에서 비동기로 실행됩니다.

3-2. 익명 함수 고루틴

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)
}

4. WaitGroup: 고루틴 완료 대기

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("모든 작업 완료")
}

4-1. WaitGroup 사용 규칙

// ✅ 올바른 패턴: 고루틴 시작 전에 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이 먼저 통과해버릴 수 있습니다.


5. Race Condition: 동시성 버그의 원인

여러 고루틴이 동일한 변수를 동시에 읽고 쓰면 경쟁 상태(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단계 연산입니다: 읽기 → 증가 → 쓰기. 두 고루틴이 동시에 “읽기” 단계에 있으면 둘 다 같은 값을 읽어 증가시키므로 하나의 증가분이 사라집니다.

5-1. Go Race Detector로 탐지

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배)가 있어 사용하지 않지만, 테스트 환경에서는 필수입니다.


6. Mutex: 상호 배제로 안전한 접근

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
}

6-1. defer로 Unlock 보장

func (c *SafeCounter) Increment() {
    c.mu.Lock()
    defer c.mu.Unlock() // panic이 발생해도 Unlock 보장
    c.count++
}

defer c.mu.Unlock()은 함수가 어떻게 종료되든 Unlock을 보장합니다. Lock 직후 defer를 쓰는 것이 관용적 패턴입니다.

6-2. RWMutex: 읽기 성능 최적화

읽기가 쓰기보다 훨씬 많은 경우 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불가하나의 고루틴만 가능

7. Deadlock: 교착 상태 예방

데드락(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) // 프로그램이 영원히 멈춤
}

7-1. 데드락 예방 원칙

// ✅ 해결: 항상 동일한 순서로 락 획득
func acquireLocks(mu1, mu2 *sync.Mutex) {
    mu1.Lock() // 항상 mu1 먼저
    mu2.Lock() // 그 다음 mu2
}

func releaseLocks(mu1, mu2 *sync.Mutex) {
    mu2.Unlock() // 역순으로 해제
    mu1.Unlock()
}

🛠️ 데드락 예방 3원칙

  1. 락 획득 순서를 전역적으로 통일한다. 모든 코드에서 mu1 → mu2 순서를 지키면 데드락이 발생하지 않는다.
  2. 락 보유 시간을 최소화한다. 락 안에서 외부 호출, I/O, 다른 락 획득을 피한다.
  3. 복잡한 락 중첩을 피한다. 락이 2개 이상 중첩되는 구조는 채널로 대체를 고려한다.

8. sync/atomic: 락 없는 원자적 연산

단순한 카운터 증가처럼 원자적으로 처리 가능한 연산은 뮤텍스보다 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
}

8-1. atomic vs Mutex: 선택 기준

// ✅ 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 또는 채널

9. sync.Once: 한 번만 실행 보장

초기화 코드를 여러 고루틴에서 동시에 호출해도 딱 한 번만 실행되도록 보장합니다.

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 연결 풀, 설정 파일 로딩 등에 필수적으로 사용됩니다.


10. 실무 패턴: Worker Pool

무한정 고루틴을 생성하면 메모리 고갈 위험이 있습니다. 워커 풀(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)
}

이 패턴의 핵심은 채널을 작업 큐로 사용한다는 점입니다. 채널과 고루틴이 자연스럽게 결합되어 워커 풀을 구성합니다. 채널에 대한 자세한 내용은 다음 글에서 다룹니다.


11. 고루틴 누수(Goroutine Leak) 탐지

고루틴은 가볍지만, 종료되지 않고 쌓이면 고루틴 누수가 발생합니다.

// ❌ 고루틴 누수: 채널 수신자가 없어 고루틴이 영원히 블로킹
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 언어의 설계 철학인 “동시성을 일급 시민으로”를 구현하는 핵심 메커니즘입니다. 가벼운 실행 단위, 스마트한 스케줄러, 그리고 채널과의 완벽한 조화가 Go를 현대 서버 개발의 강자로 만들어 줍니다.

오늘 배운 핵심을 요약하면:

  1. 고루틴은 OS 스레드와 달리 2~8 KB의 작은 스택으로 시작하며, 수십만 개를 동시에 실행할 수 있다.
  2. G-M-P 모델과 Work Stealing으로 고루틴이 효율적으로 스케줄링된다.
  3. WaitGroup으로 고루틴 완료를 기다리며, wg.Add(1)은 고루틴 시작 전에 호출해야 한다.
  4. Race Condition-race 플래그로 탐지하고, Mutex 또는 atomic으로 해결한다.
  5. 데드락은 락 획득 순서를 통일하는 것으로 예방할 수 있다.
  6. 고루틴 누수를 방지하려면 context 취소 패턴을 활용한다.

다음 글에서는 고루틴 간 통신의 핵심인 채널(Channel)을 심층적으로 다루며, 버퍼 채널, 방향성 채널, select 문, 그리고 파이프라인 패턴 등 실무에서 채널을 활용하는 모든 방법을 알아보겠습니다.