Table of contents
- 기초에서 실전으로
- Worker Pool
- Fan-Out / Fan-In
- context.Context — 취소와 타임아웃
- 경쟁 조건
- sync.Mutex — 상호 배제
- 채널 vs Mutex — 뭘 써야 할까
기초에서 실전으로
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나 네트워크 자원을 과도하게 쓰는 걸 방지할 수 있다.
핵심 구조를 정리하면 이렇다.
- 작업 채널과 결과 채널을 만든다
- 고정된 수의 워커를 띄운다
- 작업을 채널에 넣고, 다 넣으면 닫는다
- 워커가 전부 끝나면 결과 채널도 닫는다
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편: 패키지와 모듈
Loading comments...