Go의 sync 프리미티브를 사용한 동시성 프로그래밍 이해
Ethan Miller
Product Engineer · Leapcell

소개
Go 프로그래밍의 활기찬 세계에서 동시성은 최우선 고려 사항입니다. Go의 고루틴과 채널은 동시성 코드를 작성하기 위한 강력한 메커니즘을 제공하여 여러 작업을 동시에 관리하기 쉽게 만듭니다. 그러나 동시성에는 공유 리소스를 관리하는 불가피한 과제가 따릅니다. 여러 고루틴이 동일한 데이터에 동시에 접근하고 수정할 때, 데이터 경쟁(data race) 및 일관되지 않은 상태가 발생하여 예측 불가능하고 디버깅하기 어려운 동작을 초래할 수 있습니다. 동기화 프리미티브가 필수적인 이유가 바로 이것입니다. Go의 sync
패키지는 개발자가 고루틴 간의 상호 작용을 조정하여 안전하고 효율적인 동시성 애플리케이션을 작성할 수 있도록 하는 기본 도구 세트 — Mutex
, RWMutex
, WaitGroup
, Cond
— 를 제공합니다. 이러한 프리미티브와 적절한 사용법을 이해하는 것은 견고하고 성능이 뛰어난 Go 프로그램을 구축하는 데 매우 중요합니다. 이 글에서는 이러한 필수 동기화 도구의 원리, 구현 및 실제 적용 사례를 탐구합니다.
핵심 동기화 개념
각 sync
프리미티브의 세부 사항을 살펴보기 전에 동시성 프로그래밍의 몇 가지 핵심 개념에 대한 공통된 이해를 구축해 보겠습니다.
- 동시성 vs. 병렬성: 동시성은 한 번에 여러 가지를 다루는 것이고, 병렬성은 한 번에 여러 가지를 수행하는 것입니다. Go는 동시성에서 뛰어나며, 다중 코어 프로세서에서 종종 병렬성을 달성합니다.
- 경쟁 상태 (Race Condition): 경쟁 상태는 여러 고루틴이 동시에 공유 데이터에 접근하고 수정하려고 할 때 발생하며, 최종 결과는 작업 순서의 비결정성에 따라 달라집니다.
- 임계 구역 (Critical Section): 임계 구역은 공유 리소스에 접근하는 코드 부분입니다. 경쟁 상태를 방지하기 위해 한 번에 하나의 고루틴만 임계 구역 내의 코드를 실행하도록 허용해야 합니다.
- 교착 상태 (Deadlock): 교착 상태는 두 개 이상의 고루틴이 필요한 리소스를 서로 해제하기 위해 무기한 차단된 상태입니다.
- 기아 상태 (Livelock): 교착 상태와 유사하지만, 고루틴은 차단되지 않습니다. 대신, 서로에 대한 응답으로 상태를 계속 변경하여 유용한 작업을 수행하지 못하는 결과를 초래합니다.
Go의 sync
프리미티브 이해
Go의 sync
패키지는 동시 접근 및 조정을 관리하기 위한 몇 가지 핵심 프리미티브를 제공합니다.
Mutex: 상호 배제 잠금
A sync.Mutex
는 상호 배제 잠금이며, 여러 고루틴이 공유 리소스에 동시에 접근하는 것을 방지하도록 설계되었습니다. 단 한 번에 하나의 고루틴만이 잠금을 획득하여 임계 구역에 진입할 수 있도록 보장합니다.
원리 및 구현:
A Mutex
에는 Lock()
과 Unlock()
이라는 두 가지 주요 메서드가 있습니다.
Lock()
: 잠금을 획득합니다. 잠금이 이미 다른 고루틴에 의해 보유되고 있다면, 호출하는 고루틴은 잠금을 획득할 수 있을 때까지 차단됩니다.Unlock()
: 잠금을 해제합니다. 임계 구역을 벗어날 때Unlock()
을 호출하는 것이 중요하며, 패닉이 발생하더라도 항상 해제되도록 일반적으로defer
를 사용합니다.
내부적으로 Mutex
는 잠겨 있는지 여부와 어떤 고루틴이 대기 중인지 추적하는 내부 상태를 사용하여 구현됩니다. 목적을 달성하기 위해 원자 연산과 가능한 시스템 호출을 활용합니다.
응용 시나리오: 여러 고루틴이 공유된 카운터를 업데이트해야 하는 시나리오를 생각해 봅시다.
package main import ( "fmt" "sync" time ) func main() { var counter int var mu sync.Mutex var wg sync.WaitGroup for i := 0; i < 1000; i++ { wg.Add(1) go func() { defer wg.Done() mu.Lock() // 잠금 획득 counter++ mu.Unlock() // 잠금 해제 }() } wg.Wait() fmt.Printf("Final counter value: %d\n", counter) // 1000이어야 함 }
Mutex
없이는 counter
가 경쟁 상태로 인해 1000보다 작은 값을 가질 가능성이 높습니다. Mutex
는 counter
에 대한 각 증분 연산이 원자적이고 안전하도록 보장합니다.
RWMutex: 읽기/쓰기 상호 배제 잠금
A sync.RWMutex
는 읽기/쓰기 상호 배제 잠금입니다. 표준 Mutex
보다 더 세분화된 제어를 제공하며, 쓰기 시에는 배타적인 접근을 보장하면서 읽기 작업은 동시에 여러 읽기기가 리소스에 접근하도록 허용합니다. 이는 읽기 작업이 쓰기 작업보다 훨씬 빈번할 때 특히 유용합니다.
원리 및 구현:
RWMutex
는 읽기 및 쓰기 잠금 모두에 대한 메서드를 제공합니다.
- 쓰기 잠금:
Lock()
및Unlock()
. 표준Mutex
처럼 작동합니다. 단 한 번에 하나의 고루틴만이 쓰기 잠금을 보유할 수 있으며, 보유하는 동안에는 다른 읽기기나 쓰기기도 해당 잠금을 획득할 수 없습니다. - 읽기 잠금:
RLock()
및RUnlock()
. 여러 고루틴이 읽기 잠금을 동시에 보유할 수 있습니다. 그러나 쓰기기가 쓰기 잠금을 보유하고 있거나 획득하려고 기다리는 경우, 새로운 읽기기는 차단됩니다.
내부적으로 RWMutex
는 활성 읽기기의 수를 추적하고 쓰기기를 위한 Mutex
를 유지하여 이러한 상태를 기반으로 접근을 조정합니다.
응용 시나리오: 데이터를 자주 읽지만 드물게 업데이트하는 캐시를 상상해 보세요.
package main import ( "fmt" "sync" time ) type Cache struct { data map[string]string mu sync.RWMutex } func NewCache() *Cache { return &Cache{ data: make(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 } func main() { cache := NewCache() var wg sync.WaitGroup // 쓰기기 for i := 0; i < 2; i++ { wg.Add(1) go func(id int) { defer wg.Done() cache.Set(fmt.Sprintf("key%d", id), fmt.Sprintf("value%d", id)) fmt.Printf("Writer %d set key%d\n", id, id) time.Sleep(100 * time.Millisecond) // 작업 시뮬레이션 }(i) } // 읽기기 for i := 0; i < 5; i++ { wg.Add(1) go func(id int) { defer wg.Done() time.Sleep(50 * time.Millisecond) // 쓰기기에 우선권 부여 val, ok := cache.Get("key0") if ok { fmt.Printf("Reader %d got key0: %s\n", id, val) } else { fmt.Printf("Reader %d could not get key0 yet\n", id) } }(i) } wg.Wait() }
이 예제에서 여러 고루틴이 RLock()
을 사용하여 캐시를 동시에 읽을 수 있지만, Set()
작업(쓰기 잠금 획득)은 모든 읽기기와 다른 쓰기기를 차단하여 업데이트 중의 데이터 일관성을 보장합니다.
WaitGroup: 고루틴 완료 대기
A sync.WaitGroup
은 일련의 고루틴이 완료되기를 기다리는 데 사용됩니다. 메인 고루틴은 WaitGroup
의 모든 고루틴이 완료될 때까지 차단됩니다.
원리 및 구현:
WaitGroup
에는 세 가지 주요 메서드가 있습니다.
Add(delta int)
:WaitGroup
카운터를delta
만큼 증가시킵니다. 대기할 고루틴 수를 나타내는 데 일반적으로 사용됩니다. 여러 번 호출될 수 있습니다.Done()
:WaitGroup
카운터를 1만큼 감소시킵니다. 각 고루틴은 작업을 마칠 때마다 호출해야 하며, 일반적으로defer
를 사용합니다.Wait()
:WaitGroup
카운터가 0이 될 때까지 호출하는 고루틴을 차단합니다.
WaitGroup
은 내부 카운터를 저장합니다. Add
는 카운터를 증가시키고, Done
은 감소시키며, Wait
는 0이 될 때까지 차단합니다.
응용 시나리오:
여러 개의 독립적인 고루틴을 시작하고 모든 고루틴이 완료될 때까지 기다려야 하는 경우에 자주 사용됩니다. 이는 이미 Mutex
및 RWMutex
예제에서 시연되었습니다. 독립적인 예제는 다음과 같습니다.
package main import ( "fmt" "sync" time ) func worker(id int, wg *sync.WaitGroup) { defer wg.Done() // 완료 시 카운터 감소 fmt.Printf("Worker %d starting\n", id) time.Sleep(time.Duration(id) * 100 * time.Millisecond) // 작업 시뮬레이션 fmt.Printf("Worker %d finished\n", id) } func main() { var wg sync.WaitGroup for i := 1; i <= 3; i++ { wg.Add(1) // 각 작업자를 위해 카운터 증가 go worker(i, &wg) } wg.Wait() // 모든 작업자가 Done()을 호출할 때까지 차단 fmt.Println("All workers have completed.") }
이 코드는 worker 1
, worker 2
, worker 3
이 작업을 마친 후에만 "All workers have completed."가 출력되도록 보장합니다.
Cond: 조건 변수
A sync.Cond
(조건 변수)는 고루틴이 특정 조건이 충족될 때까지 기다릴 수 있도록 합니다. 항상 sync.Locker
(일반적으로 sync.Mutex
)와 연관되어 있으며, 이는 조건 자체를 보호합니다.
원리 및 구현:
Cond
에는 세 가지 주요 메서드가 있습니다.
Wait()
: 연관된 잠금을 원자적으로 해제하고, 호출하는 고루틴이 "신호(signaled)" 또는 "브로드캐스트(broadcasted)"될 때까지 차단하고, 반환 전에 잠금을 다시 획득합니다. 잠금을 보유한 상태에서 호출해야 합니다.Signal()
:Cond
에서 대기 중인 고루틴 중 최대 하나를 깨웁니다. 대기 중인 고루틴이 없으면 아무 작업도 하지 않습니다.Broadcast()
:Cond
에서 대기 중인 모든 고루틴을 깨웁니다.
Cond
는 단순히 잠금 해제가 아닌 특정 상태 변화를 기다려야 하는 고루틴에 자주 사용됩니다. 잠금은 조건 확인이 원자적임을 보장하고 조건을 정의하는 공유 데이터를 보호합니다.
응용 시나리오: 버퍼에 제한된 용량이 있는 생산자-소비자 문제를 고려해 보세요. 생산자는 버퍼가 가득 차 있으면 기다려야 하고, 소비자는 버퍼가 비어 있으면 기다려야 합니다.
package main import ( "fmt" "sync" time ) const ( bufferCapacity = 5 ) type Buffer struct { items []int mu sync.Mutex notFull *sync.Cond // 항목이 추가될 때 신호됨 notEmpty *sync.Cond // 항목이 제거될 때 신호됨 } func NewBuffer() *Buffer { b := &Buffer{ items: make([]int, 0, bufferCapacity), } b.notFull = sync.NewCond(&b.mu) b.notEmpty = sync.NewCond(&b.mu) return b } func (b *Buffer) Produce(item int) { b.mu.Lock() defer b.mu.Unlock() for len(b.items) == bufferCapacity { // 버퍼가 가득 차면 대기 // Wait는 원자적으로 잠금을 해제하고, 차단하고, 다시 잠금 해제합니다. fmt.Println("Buffer full, producer waiting...") b.notFull.Wait() } b.items = append(b.items, item) fmt.Printf("Produced: %d, Buffer: %v\n", item, b.items) b.notEmpty.Signal() // 소비자가 버퍼가 비어 있지 않음을 신호 } func (b *Buffer) Consume() int { b.mu.Lock() defer b.mu.Unlock() for len(b.items) == 0 { // 버퍼가 비어 있으면 대기 // Wait는 원자적으로 잠금을 해제하고, 차단하고, 다시 잠금 해제합니다. fmt.Println("Buffer empty, consumer waiting...") b.notEmpty.Wait() } item := b.items[0] b.items = b.items[1:] fmt.Printf("Consumed: %d, Buffer: %v\n", item, b.items) b.notFull.Signal() // 생산자가 버퍼가 가득 차지 않았음을 신호 return item } func main() { buf := NewBuffer() var wg sync.WaitGroup // 생산자 for i := 0; i < 3; i++ { wg.Add(1) go func(id int) { defer wg.Done() for j := 0; j < 5; j++ { time.Sleep(time.Duration(id*50+j*10) * time.Millisecond) // 작업 시뮬레이션 buf.Produce(id*100 + j) } }(i) } // 소비자 for i := 0; i < 2; i++ { wg.Add(1) go func(id int) { defer wg.Done() for j := 0; j < 7; j++ { // 생산된 것보다 더 많이 소비하여 대기 표시 time.Sleep(time.Duration(id*70+j*15) * time.Millisecond) // 작업 시뮬레이션 buf.Consume() } }(i) } wg.Wait() fmt.Println("All production and consumption complete.") }
이 예에서 notFull
과 notEmpty
는 Cond
변수입니다. 생산자는 버퍼가 가득 차면 notFull
에서 대기하고, 소비자는 버퍼가 비어 있으면 notEmpty
에서 대기합니다. 항목이 추가되면 notEmpty.Signal()
이 대기 중인 소비자를 깨웁니다. 항목이 제거되면 notFull.Signal()
이 대기 중인 생산자를 깨웁니다. Wait()
주변의 for
루프는 고루틴이 무작위로 깨어날 수 있거나 잠금을 다시 획득할 때 조건이 변경될 수 있기 때문에 중요합니다.
결론
sync
패키지는 Go에서 동시성을 관리하기 위한 필수 도구를 제공합니다. Mutex
는 공유 리소스에 대한 배타적 접근을 보장하여 임계 구역에서 데이터 경쟁을 방지합니다. RWMutex
는 동시 읽기기를 허용함으로써 읽기 작업이 많은 워크로드에 대해 더 최적화된 접근 방식을 제공합니다. WaitGroup
은 일련의 고루틴이 실행을 완료하기를 기다리는 작업을 단순화합니다. 마지막으로 Cond
를 사용하면 고루틴이 복잡한 조건이 충족되기를 기다릴 수 있어 정교한 고루틴 간 조정을 용이하게 합니다. 이러한 프리미티브를 숙달하는 것은 데이터 무결성 및 예측 가능한 프로그램 동작을 보장하면서 Go에서 견고하고 효율적이며 안정적인 동시성 애플리케이션을 작성하는 데 근본적입니다.