Skip to content
ioob.dev
Go back

Go 입문 10편 — 동시성 패턴

· 5분 읽기

Table of contents

기초에서 실전으로

9편에서 고루틴과 채널의 기본을 다뤘다. 이번 편은 그 도구들을 가지고 실전에서 자주 쓰이는 패턴을 만들어볼 차례다. 동시성 코드는 잘 만들면 성능이 비약적으로 좋아지지만, 대충 만들면 디버깅 지옥에 빠진다. 패턴을 알고 쓰느냐 모르고 쓰느냐의 차이가 크다.

Worker Pool

가장 흔한 동시성 패턴 중 하나다. 처리할 작업이 많을 때 고정된 수의 워커 고루틴을 띄워놓고, 작업을 큐에 넣어 분배한다. 고루틴을 작업마다 하나씩 무한히 띄우는 것보다 리소스를 통제할 수 있다.

전체 흐름을 그림으로 먼저 파악하고 코드를 보면 이해가 빠르다.

flowchart LR
    Producer["Producer\n(작업 생성)"]
    Jobs["jobs 채널"]
    W1["Worker 1"]
    W2["Worker 2"]
    W3["Worker 3"]
    Results["results 채널"]
    Consumer["Consumer\n(결과 수집)"]

    Producer -->|"작업 투입"| Jobs
    Jobs -->|"경쟁적으로\n꺼내감"| W1
    Jobs --> W2
    Jobs --> W3
    W1 -->|"처리 결과"| Results
    W2 --> Results
    W3 --> Results
    Results -->|"range로\n순회"| Consumer
package main

import (
    "fmt"
    "sync"
    "time"
)

func worker(id int, jobs <-chan int, results chan<- string, wg *sync.WaitGroup) {
    defer wg.Done()
    for job := range jobs {
        // 작업 처리 (시뮬레이션)
        time.Sleep(50 * time.Millisecond)
        results <- fmt.Sprintf("워커%d → 작업%d 완료", id, job)
    }
}

func main() {
    const numWorkers = 3
    const numJobs = 10

    jobs := make(chan int, numJobs)
    results := make(chan string, numJobs)

    var wg sync.WaitGroup

    // 워커 시작
    for w := 1; w <= numWorkers; w++ {
        wg.Add(1)
        go worker(w, jobs, results, &wg)
    }

    // 작업 투입
    for j := 1; j <= numJobs; j++ {
        jobs <- j
    }
    close(jobs) // 더 이상 작업 없음

    // 모든 워커 완료 후 결과 채널 닫기
    go func() {
        wg.Wait()
        close(results)
    }()

    // 결과 수집
    for result := range results {
        fmt.Println(result)
    }
}

jobs 채널로 작업을 보내고, 3개의 워커가 경쟁적으로 꺼내간다. 작업 10개를 3명이 나눠서 처리하니, 순차 실행보다 약 3배 빠르게 끝난다. 워커 수를 조절하면 CPU나 네트워크 자원을 과도하게 쓰는 걸 방지할 수 있다.

핵심 구조를 정리하면 이렇다.

  1. 작업 채널과 결과 채널을 만든다
  2. 고정된 수의 워커를 띄운다
  3. 작업을 채널에 넣고, 다 넣으면 닫는다
  4. 워커가 전부 끝나면 결과 채널도 닫는다

Fan-Out / Fan-In

Worker Pool과 비슷하지만 관점이 다르다. Fan-Out은 하나의 입력을 여러 고루틴으로 분산하는 것이고, Fan-In은 여러 고루틴의 출력을 하나로 합치는 것이다.

package main

import (
    "fmt"
    "sync"
)

// Fan-Out: 하나의 소스에서 여러 워커로 분산
func generate(nums ...int) <-chan int {
    out := make(chan int)
    go func() {
        for _, n := range nums {
            out <- n
        }
        close(out)
    }()
    return out
}

func square(in <-chan int) <-chan int {
    out := make(chan int)
    go func() {
        for n := range in {
            out <- n * n
        }
        close(out)
    }()
    return out
}

// Fan-In: 여러 채널을 하나로 합침
func merge(channels ...<-chan int) <-chan int {
    out := make(chan int)
    var wg sync.WaitGroup

    for _, ch := range channels {
        wg.Add(1)
        go func(c <-chan int) {
            defer wg.Done()
            for n := range c {
                out <- n
            }
        }(ch)
    }

    go func() {
        wg.Wait()
        close(out)
    }()

    return out
}

func main() {
    // 데이터 생성
    nums := generate(1, 2, 3, 4, 5, 6, 7, 8)

    // Fan-Out: 같은 입력 채널을 3개의 워커가 공유
    sq1 := square(nums)
    sq2 := square(nums)
    sq3 := square(nums)

    // Fan-In: 3개의 결과를 하나로
    for result := range merge(sq1, sq2, sq3) {
        fmt.Println(result)
    }
}

generate가 숫자를 생산하면, 3개의 square 고루틴이 동시에 꺼내가서 제곱한다(Fan-Out). 각자의 결과 채널을 merge가 하나로 합친다(Fan-In). 파이프라인처럼 데이터가 흘러가는 이 구조가 Go의 동시성 프로그래밍에서 매우 자주 등장하는 패턴이다.

context.Context — 취소와 타임아웃

실전에서 동시성 코드를 짜면 반드시 마주치는 문제가 있다. “이 작업을 언제 멈출 것인가?” 사용자가 요청을 취소했거나, 시간이 너무 오래 걸리거나, 상위 작업이 실패했을 때 하위 고루틴도 정리해야 한다. context 패키지가 이 역할을 한다.

package main

import (
    "context"
    "fmt"
    "time"
)

func slowOperation(ctx context.Context) (string, error) {
    select {
    case <-time.After(5 * time.Second):
        return "작업 완료", nil
    case <-ctx.Done():
        return "", ctx.Err()
    }
}

func main() {
    // 2초 타임아웃
    ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
    defer cancel() // 리소스 정리를 위해 반드시 호출

    result, err := slowOperation(ctx)
    if err != nil {
        fmt.Println("실패:", err) // context deadline exceeded
        return
    }
    fmt.Println(result)
}

context.WithTimeout은 지정한 시간이 지나면 자동으로 취소 신호를 보내는 context를 만든다. ctx.Done()은 취소되면 닫히는 채널을 반환하고, select에서 이 채널과 작업 완료를 동시에 기다리면 타임아웃을 깔끔하게 처리할 수 있다.

수동 취소도 가능하다.

package main

import (
    "context"
    "fmt"
    "time"
)

func longTask(ctx context.Context, name string) {
    for {
        select {
        case <-ctx.Done():
            fmt.Printf("%s: 취소됨 (%v)\n", name, ctx.Err())
            return
        case <-time.After(500 * time.Millisecond):
            fmt.Printf("%s: 작업 중...\n", name)
        }
    }
}

func main() {
    ctx, cancel := context.WithCancel(context.Background())

    go longTask(ctx, "워커1")
    go longTask(ctx, "워커2")

    time.Sleep(2 * time.Second)
    fmt.Println("--- 취소 신호 전송 ---")
    cancel() // 모든 하위 고루틴에 취소 전파

    time.Sleep(100 * time.Millisecond) // 정리 시간
}

cancel()을 호출하면 이 context에서 파생된 모든 고루틴에 취소 신호가 전파된다. 부모 context가 취소되면 자식도 자동으로 취소되는 계층 구조 덕분에, 복잡한 작업 트리도 한 번의 호출로 깔끔하게 정리할 수 있다.

defer cancel()은 습관처럼 넣어야 한다. context를 만들고 cancel을 호출하지 않으면 내부 타이머나 고루틴이 누수된다.

경쟁 조건

동시성에서 가장 위험한 버그가 경쟁 조건(race condition)이다. 여러 고루틴이 같은 데이터를 동시에 읽고 쓸 때 발생한다.

package main

import (
    "fmt"
    "sync"
)

func main() {
    counter := 0
    var wg sync.WaitGroup

    for i := 0; i < 1000; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            counter++ // 경쟁 조건! 여러 고루틴이 동시에 수정
        }()
    }

    wg.Wait()
    fmt.Println("카운터:", counter) // 1000이 아닐 수 있다
}

1000개의 고루틴이 각각 counter를 1씩 올리니 결과가 1000이어야 할 것 같지만, 실제로는 그보다 작은 값이 나올 수 있다. counter++는 “읽기 → 증가 → 쓰기”라는 세 단계로 이루어지는데, 두 고루틴이 동시에 같은 값을 읽으면 하나의 증가가 씹히기 때문이다.

Go에는 경쟁 조건을 탐지하는 도구가 내장되어 있다. go run -race로 실행하면 런타임에 경쟁 조건을 잡아준다.

sync.Mutex — 상호 배제

채널로 해결하기 어려운 공유 상태 문제에는 Mutex를 쓴다.

package main

import (
    "fmt"
    "sync"
)

type SafeCounter struct {
    mu    sync.Mutex
    value int
}

func (c *SafeCounter) Increment() {
    c.mu.Lock()
    defer c.mu.Unlock()
    c.value++
}

func (c *SafeCounter) Value() int {
    c.mu.Lock()
    defer c.mu.Unlock()
    return c.value
}

func main() {
    counter := &SafeCounter{}
    var wg sync.WaitGroup

    for i := 0; i < 1000; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            counter.Increment()
        }()
    }

    wg.Wait()
    fmt.Println("카운터:", counter.Value()) // 항상 1000
}

Lock()Unlock() 사이에서는 한 번에 하나의 고루틴만 실행된다. defer c.mu.Unlock()으로 쓰면 함수가 어떻게 끝나든 락이 반드시 해제되니 안전하다.

읽기가 많고 쓰기가 적은 경우에는 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()
    val, ok := c.data[key]
    return val, ok
}

func (c *Cache) Set(key, value string) {
    c.mu.Lock()          // 쓰기 락 — 하나만 쓸 수 있음
    defer c.mu.Unlock()
    c.data[key] = value
}

RLock은 읽기 락으로, 여러 고루틴이 동시에 잡을 수 있다. 쓰기 락(Lock)이 잡히면 읽기도 쓰기도 전부 대기해야 한다. 캐시처럼 읽기 빈도가 높은 자료구조에 적합하다.

채널 vs Mutex — 뭘 써야 할까

둘 다 동시성 문제를 해결하지만, 쓰임새가 다르다.

상황권장
고루틴 간 데이터 전달채널
작업 파이프라인, 이벤트 스트림채널
공유 상태의 동기화 (카운터, 캐시)Mutex
간단한 완료 신호채널 또는 WaitGroup

Go 공식 위키에서는 “채널로 소유권을 전달하라”라고 권장한다. 데이터가 한 고루틴에서 다른 고루틴으로 넘어가는 흐름이라면 채널이 자연스럽고, 여러 고루틴이 같은 메모리에 접근해야 하는 상황이라면 Mutex가 맞다. 둘 중 뭘 쓸지 고민될 때는 “소유권이 이전되는가?”를 기준으로 판단하면 된다.


동시성 패턴은 결국 두 가지 질문으로 귀결된다. “작업을 어떻게 분배할 것인가?”와 “언제 멈출 것인가?” Worker Pool과 Fan-Out/Fan-In이 첫 번째 질문에, context가 두 번째 질문에 답한다. 경쟁 조건은 항상 경계해야 하며, Mutex는 채널로 풀기 어려운 공유 상태 문제의 마지막 보루다.

다음 편에서는 패키지와 모듈 시스템을 다룬다. 코드를 어떻게 구조화하고, 외부 의존성을 어떻게 관리하는지 살펴보자.

-> 11편: 패키지와 모듈


Share this post on:

Comments

Loading comments...


Previous Post
Go 입문 11편 — 패키지와 모듈
Next Post
Go 입문 9편 — 고루틴과 채널