Go에서의 동시성 제어: 뮤텍스와 RWMutex로 임계 구역 마스터하기
James Reed
Infrastructure Engineer · Leapcell

Go에서의 동시성 제어: 뮤텍스와 RWMutex로 임계 구역 마스터하기
고루틴과 채널을 중심으로 하는 Go의 내장 동시성 모델은 강력하고 우아합니다. 하지만 여러 고루틴이 공유 리소스에 액세스해야 할 때, 데이터 경쟁(data race)은 심각한 문제가 됩니다. 데이터 경쟁은 두 개 이상의 고루틴이 동일한 메모리 위치에 액세스하고, 그중 적어도 하나는 쓰기 작업이며, 동기화 메커니즘을 사용하지 않을 때 발생합니다. Go의 sync
패키지는 동시성 프로그래밍을 위한 기본적인 빌딩 블록을 제공하며, 이 패키지의 가장 중요한 구성 요소 중 하나는 임계 구역(critical section) — 즉, 공유 리소스에 접근하고 원자적으로 실행되어야 하는 코드 블록 — 을 보호하기 위해 특별히 설계된 sync.Mutex
와 sync.RWMutex
입니다.
문제: 데이터 경쟁과 임계 구역
여러 고루틴이 증가시키는 간단한 카운터를 생각해 봅시다.
package main import ( "fmt" "sync" ) var counter int func increment() { for i := 0; i < 100000; i++ { counter++ // 이것은 임계 구역입니다 } } func main() { counter = 0 numGoroutines := 100 var wg sync.WaitGroup wg.Add(numGoroutines) for i := 0; i < numGoroutines; i++ { go func() { defer wg.Done() increment() }() } wg.Wait() fmt.Printf("Final counter value: %d\n", counter) }
이 코드를 실행하면 Final counter value
가 10,000,000
(100 고루틴 * 100,000 증가)이 아닌 것을 발견할 가능성이 높습니다. 이는 counter++
연산이 원자적이지 않기 때문입니다. 일반적으로 세 단계를 포함합니다.
counter
의 현재 값을 읽습니다.- 값을 증가시킵니다.
- 새로운 값을
counter
에 다시 씁니다.
두 고루틴이 동시에 counter
를 증가시키려고 하면, 둘 다 같은 값을 읽고, 증가시킨 다음, 하나가 다른 하나의 결과를 덮어쓸 수 있어 업데이트가 누락될 수 있습니다. 이것은 전형적인 데이터 경쟁입니다.
sync.Mutex
: 쓰기를 위한 배타적 액세스
sync.Mutex
( "상호 배제"의 약자)는 공유 리소스에 대한 배타적 액세스를 부여하는 동기화 원시 타입입니다. 한 번에 하나의 고루틴만 잠금(lock)을 보유할 수 있습니다. 고루틴이 잠긴 뮤텍스를 획득하려고 하면, 뮤텍스가 잠금 해제될 때까지 차단됩니다.
sync.Mutex
는 두 가지 주요 메서드를 가집니다:
Lock()
: 잠금을 획득합니다. 잠금이 이미 보유 중이면, 호출하는 고루틴은 해제될 때까지 차단됩니다.Unlock()
: 잠금을 해제합니다. 호출하는 고루틴이 잠금을 보유하지 않은 경우, 동작은 정의되지 않으며 패닉이 발생할 수 있습니다.
sync.Mutex
를 사용하여 카운터 예제를 수정해 봅시다:
package main import ( "fmt" "sync" ) var counter int var mu sync.Mutex // 뮤텍스 선언 func incrementWithMutex() { for i := 0; i < 100000; i++ { mu.Lock() // 임계 구역에 들어가기 전에 잠금 획득 counter++ mu.Unlock() // 임계 구역에서 나온 후 잠금 해제 } } func main() { counter = 0 numGoroutines := 100 var wg sync.WaitGroup wg.Add(numGoroutines) for i := 0; i < numGoroutines; i++ { go func() { defer wg.Done() incrementWithMutex() }() } wg.Wait() fmt.Printf("Final counter value (with Mutex): %d\n", counter) }
이제 이 코드를 실행하면 Final counter value
는 일관되게 10,000,000
이 될 것입니다. mu.Lock()
및 mu.Unlock()
호출은 한 번에 하나의 고루틴만 counter
를 수정할 수 있도록 보장하여 데이터 경쟁을 방지합니다.
defer
에 대한 중요 참고: mu.Lock()
직후에 defer mu.Unlock()
을 사용하는 것은 공통적이고 좋은 관행입니다. 이렇게 하면 임계 구역이 패닉을 일으키거나 조기에 반환되더라도 잠금이 항상 해제됩니다.
func incrementWithMutexDeferred() { for i := 0; i < 100000; i++ { mu.Lock() defer mu.Unlock() // 패닉 발생 또는 함수 반환 시에도 잠금 해제를 보장합니다 counter++ } }
defer
는 강력하지만, 그 범위를 염두에 두어야 합니다. 루프 안에서 defer
를 사용하면 많은 지연된 호출이 큐에 쌓여 매우 엄격한 루프의 성능이나 메모리에 영향을 줄 수 있습니다. 카운터 예제의 경우, 루프 안에 defer mu.Unlock()
을 넣는 것이 올바르지만, 더 복잡한 작업의 경우, 임계 구역이 반복문에서 짧고 분리된 부분이라면 루프 외부에서 잠금 해제가 가능한지 고려해야 합니다.
sync.RWMutex
: 읽기-쓰기 배타적 액세스
sync.Mutex
는 효과적이지만, 너무 제한적일 수 있습니다. 공유 리소스가 자주 읽히지만 가끔만 쓰이는 경우, sync.Mutex
는 모든 액세스 — 읽기 및 쓰기 — 를 직렬화합니다. 이는 읽기가 다른 읽기를 차단한다는 것을 의미하며, 이는 여러 고루틴이 데이터 경쟁을 유발하지 않고 동일한 데이터를 동시에 안전하게 읽을 수 있기 때문에 종종 불필요합니다.
이것이 sync.RWMutex
가 유용한 곳입니다. 이것은 "읽기-쓰기 뮤텍스"이며 두 가지 별도의 잠금 메커니즘을 제공합니다:
- 읽기기(Readers): "읽기 잠금"(공유 잠금)을 획득할 수 있습니다. 여러 고루틴이 동시에 읽기 잠금을 보유할 수 있습니다.
- 쓰기기(Writers): "쓰기 잠금"(배타적 잠금)을 획득할 수 있습니다. 한 번에 하나의 고루틴만 쓰기 잠금을 보유할 수 있으며, 쓰기 잠금이 보유 중일 때는 다른 읽기 잠금이나 쓰기 잠금을 획득할 수 없습니다.
sync.RWMutex
는 다음과 같은 메서드를 가집니다:
RLock()
: 읽기 잠금을 획득합니다. 쓰기 잠금이 보유 중이거나 쓰기기가 쓰기 잠금을 획득하기 위해 기다리는 경우 차단됩니다 (쓰기기 기아 현상 방지).RUnlock()
: 읽기 잠금을 해제합니다.Lock()
: 쓰기 잠금을 획득합니다. 현재 읽기 잠금 또는 쓰기 잠금이 보유 중인 경우 차단됩니다.Unlock()
: 쓰기 잠금을 해제합니다.
읽기가 빈번하고 쓰기가 드문 간단한 캐시 또는 설정 저장소를 사용하여 sync.RWMutex
를 설명해 봅시다.
package main import ( "fmt" "sync" "time" ) type Config struct { data map[string]string mu sync.RWMutex // 읽기 및 배타적 쓰기를 위한 RWMutex } func NewConfig() *Config { return &Config{ data: make(map[string]string), } } // Get은 설정 값을 반환합니다 (읽기기). func (c *Config) Get(key string) string { c.mu.RLock() // 읽기 잠금 획득 defer c.mu.RUnlock() // 읽기 잠금 해제 time.Sleep(50 * time.Millisecond) // 약간의 작업 시뮬레이션 return c.data[key] } // Set은 설정 값을 업데이트합니다 (쓰기기). func (c *Config) Set(key, value string) { c.mu.Lock() // 쓰기 잠금 획득 defer c.mu.Unlock() // 쓰기 잠금 해제 time.Sleep(100 * time.Millisecond) // 약간의 작업 시뮬레이션 c.data[key] = value } func main() { cfg := NewConfig() cfg.Set("name", "Alice") cfg.Set("env", "production") var wg sync.WaitGroup numReaders := 5 numWriters := 2 // 읽기기 시작 for i := 0; i < numReaders; i++ { wg.Add(1) go func(readerID int) { defer wg.Done() for j := 0; j < 3; j++ { fmt.Printf("Reader %d: Getting name = %s\n", readerID, cfg.Get("name")) fmt.Printf("Reader %d: Getting env = %s\n", readerID, cfg.Get("env")) time.Sleep(50 * time.Millisecond) } }(i) } // 쓰기기 시작 (일부 읽기 후 또는 동시에) wg.Add(numWriters) go func() { defer wg.Done() time.Sleep(200 * time.Millisecond) // 일부 읽기가 먼저 수행되도록 함 fmt.Println("Writer 1: Setting name to Bob") cfg.Set("name", "Bob") }() go func() { defer wg.Done() time.Sleep(400 * time.Millisecond) fmt.Println("Writer 2: Setting env to development") cfg.Set("env", "development") }() wg.Wait() fmt.Println("--- Final State ---") fmt.Printf("Final name: %s\n", cfg.Get("name")) fmt.Printf("Final env: %s\n", cfg.Get("env")) }
출력에서 여러 "Reader" 고루틴이 동시에 값을 Get
하는 것을 관찰할 수 있습니다. 그러나 "Writer" 고루틴이 Set
을 호출하면 배타적 Lock()
을 획득하여 Unlock()
이 호출될 때까지 다른 읽기 또는 쓰기를 차단하는 것을 볼 수 있습니다. 이것은 RWMutex
가 읽기 집약적인 작업 부하에 대한 동시성을 어떻게 개선할 수 있는지 보여줍니다.
무엇을 선택해야 할까요?
sync.Mutex
와 sync.RWMutex
중에서 선택하는 것은 공유 리소스의 액세스 패턴에 따라 달라집니다:
-
sync.Mutex
(간단함, 모든 배타적):- 사용 시점: 공유 리소스에 대한 모든 액세스 (읽기 및 쓰기)는 엄격하게 직렬화되어야 합니다.
- 쓰기가 빈번하거나 읽기와 비슷할 때.
- 임계 구역이 작고 읽기/쓰기 잠금 관리에 대한 오버헤드가 정당화되지 않을 때.
- 단순성이 선호될 때.
Mutex
는 이해하기 쉽고 읽기/쓰기 잠금 상호 작용과 관련된 미묘한 버그가 적습니다. - 예시: 간단한 카운터, 큐 또는 스택으로, 작업이 상태를 수정합니다.
-
sync.RWMutex
(읽기 최적화, 쓰기 배타적):- 사용 시점: 공유 리소스는 쓰기보다 훨씬 더 자주 읽힙니다.
- 읽기 작업에 대한 동시성을 개선하려면. 여러 읽기기가 병렬로 진행될 수 있습니다.
- 쓰기 잠금 획득 및 해제 비용 (간단한 뮤텍스보다 복잡함)이 동시 읽기의 이점보다 클 때.
- 예시: 캐시, 설정 저장소, 거의 업데이트되지 않지만 자주 쿼리되는 전역 상태 객체.
sync.RWMutex
에 대한 고려 사항:
- 오버헤드:
RWMutex
는 더 복잡한 내부 상태 관리로 인해Mutex
보다 약간 더 높은 오버헤드를 가집니다. 읽기가 매우 짧거나 경합되지 않는 경우, 성능 향상은 무시할 수 있거나 오히려 부정적일 수 있습니다. - 쓰기기 기아 현상: 극도로 읽기 집약적인 시나리오에서는 지속적인 새 읽기기가 읽기 잠금을 획득하면 쓰기기가 기아 상태에 빠질 수 있습니다. Go의
sync.RWMutex
는 쓰기기가 대기 중일 때 쓰기기에 우선순위를 부여하여 이를 방지하도록 설계되었습니다. 쓰기기가 쓰기 잠금을 획득하기 위해 기다리는 경우 새 읽기기는 차단됩니다.
모범 사례 및 일반적인 함정
-
항상
defer Unlock()
/RUnlock()
: 이렇게 하면 잠금이 해제되어 임계 구역이 패닉을 일으키거나 조기에 반환되더라도 교착 상태를 방지합니다. -
잠금 세분성:
- 너무 거칠게 (너무 많이 잠금): 동시성을 감소시킵니다.
struct
전체를 잠그지만 단 하나의 필드만 보호가 필요하다면, 다른 필드들이 불필요하게 차단됩니다. - 너무 미세하게 (너무 적게 잠금): 모든 공유 상태가 보호되지 않으면 데이터 경쟁이 발생할 수 있습니다.
- 적절한 균형을 찾으십시오. 종종 뮤텍스를 보호하는
struct
내부에 배치하고 해당struct
의 메서드를 사용하여 필드에 액세스하는 것 (메서드 내에서 잠금 획득/해제)은 좋은 패턴입니다.
- 너무 거칠게 (너무 많이 잠금): 동시성을 감소시킵니다.
-
뮤텍스 복사 금지:
sync.Mutex
및sync.RWMutex
는 상태를 가집니다. 값으로 복사하지 마십시오. 뮤텍스를 포함하는 구조체에 대한 포인터를 전달하거나, 포인터로 전달되는 구조체 내에서 필드로 선언하십시오.go vet
린터는 종종sync.Mutex
복사를 경고합니다.type MyData struct { mu sync.Mutex value int } // 올바름: 뮤텍스 복사를 피하기 위해 포인터로 전달 func (d *MyData) Increment() { d.mu.Lock() defer d.mu.Unlock() d.value++ } // 잘못됨: MyData를 값으로 전달하면 뮤텍스가 복사되며, // 각 복사본은 자체 독립적인 잠금 상태를 유지하여 제어되지 않는 액세스로 이어집니다. // func Update(data MyData) { data.mu.Lock() ... } // 위험!
-
중첩 잠금 방지: 다른 고루틴에 걸쳐 일관되지 않은 순서로 여러 잠금을 획득하는 것은 교착 상태의 일반적인 원인입니다. 여러 잠금을 획득해야 하는 경우, 잠금 획득에 대한 엄격한 전역 순서를 설정하십시오.
-
채널을 통신에 우선:
Mutex
및RWMutex
는 공유 메모리 보호에 필수적이지만, Go의 동시성에 대한 관용적인 접근 방식은 종종 "메모리 공유를 통해 통신하지 말고, 통신을 통해 메모리를 공유하라"를 강조합니다. 채널은 특히 복잡한 조정이 필요할 때 동시 데이터 액세스를 처리하는 훨씬 더 안전하고 표현적인 방법이 될 수 있습니다. 하지만 공유 데이터 구조에 대한 간단한 액세스의 경우, 뮤텍스는 여전히 기본적인 도구입니다.
결론
sync.Mutex
와 sync.RWMutex
는 Go 개발자의 동시성 도구 키트에서 필수적인 도구입니다. 그 목적, 올바른 사용법, 그리고 각 도구를 적용해야 할 시점을 이해함으로써, 데이터 경쟁으로부터 임계 구역을 효과적으로 보호하고, 강력한 동시성 애플리케이션을 구축하며, 읽기 집약적인 작업 부하에 대한 성능을 최적화할 수 있습니다. Go가 오케스트레이션 및 통신을 위해 채널을 옹호하지만, 뮤텍스와 같은 명령적 동기화 원시 타입은 공유 상태를 직접 관리하는 데 여전히 중요합니다. 이를 마스터하는 것이 고성능, 정확하고 안정적인 Go 프로그램을 작성하는 열쇠입니다.