sync.RWMutex를 사용한 Go에서의 고성능 동시성 캐시 구축
Ethan Miller
Product Engineer · Leapcell

소개
현대의 마이크로서비스 아키텍처 및 고처리량 애플리케이션에서 영구 스토리지(데이터베이스 또는 외부 API 등)로부터 데이터를 검색하는 것은 종종 성능 병목 현상을 일으킵니다. 동일한 데이터를 반복적으로 가져오는 것은 상당한 지연 시간을 초래하고 불필요한 리소스를 소비할 수 있습니다. 인메모리 캐시는 자주 액세스되는 데이터를 애플리케이션에 더 가깝게 저장하여 응답 시간을 크게 줄이고 백엔드 시스템의 부하를 덜어줌으로써 우아한 솔루션을 제공합니다. 그러나 동시성 Go 애플리케이션에서 이 공유 캐시에 안전하게 액세스하고 수정하는 것은 그 자체의 과제를 제시합니다. 제어되지 않은 동시 액세스는 데이터 경쟁 상태를 유발하여 캐시를 손상시키고 잘못된 결과를 초래할 수 있습니다. 이 문서는 Go의 sync.RWMutex를 효과적으로 활용하여 고성능의 동시성 안전 인메모리 캐시를 구축하고 데이터 무결성과 최적의 애플리케이션 성능을 모두 보장하는 방법을 탐구합니다.
핵심 개념 및 구현
캐시를 구축하기 전에 Go에서의 동시성 프로그래밍 및 캐싱의 핵심인 몇 가지 용어를 간략하게 정의해 보겠습니다.
- 동시성(Concurrency): 여러 작업을 동시에 처리할 수 있는 능력. Go에서는 고루틴을 통해 이를 달성합니다.
- 스레드 안전성/동시성 안전성(Thread-Safety/Concurrency-Safety): 여러 고루틴이 동시 액세스할 때 공유 데이터 구조가 일관되고 올바르게 유지되도록 보장합니다.
- 데이터 경쟁 상태(Data Race): 여러 고루틴이 적절한 동기화 없이 동시에 동일한 메모리 위치에 액세스하고, 그중 하나 이상이 쓰기 작업인 경우 발생하는 조건입니다. 이는 정의되지 않은 동작으로 이어집니다.
- 뮤텍스(Mutex - Mutual Exclusion): 한 번에 하나의 고루틴에게만 공유 리소스에 대한 독점적인 액세스 권한을 부여하는 동기화 기본 요소입니다. Go는 이 목적으로
sync.Mutex를 제공합니다. - RWMutex (Read-Write Mutex): 여러 판독기(reader)가 공유 리소스에 동시에 액세스할 수 있도록 허용하지만, 작성기(writer)에게는 독점적인 액세스가 필요한 보다 특화된 뮤텍스입니다. 이는 읽기 작업이 많은 시나리오에서 성능을 위해 중요합니다.
캐싱에 sync.RWMutex를 사용하는 이유?
일반적인 캐싱 시나리오에서는 읽기 작업이 쓰기 작업을 훨씬 능가합니다. 많은 고루틴이 동시에 캐시에서 데이터를 검색하려고 할 수 있습니다. 표준 sync.Mutex를 사용하면 이러한 모든 판독기가 서로 기다려야 하는데, 단순히 읽기 작업만 하는 경우에도 마찬가지입니다. 이는 성능 저하로 이어집니다. sync.RWMutex는 여러 판독기가 동시에 읽기 잠금(read lock)을 유지하도록 허용함으로써 이를 해결합니다. 쓰기 작업(예: 캐시에 항목 추가 또는 업데이트)이 필요한 경우, 작성기는 쓰기 잠금(write lock)을 획득하며, 이는 쓰기 작업이 완료될 때까지 모든 새로운 판독기와 작성기를 차단합니다. 이는 쓰기 중 데이터 일관성을 보장하면서 읽기 성능을 최적화합니다.
우리 캐시 구축하기
키-값 쌍을 저장하는 간단하고 범용적인 인메모리 캐시를 설계해 보겠습니다.
먼저 캐시 구조를 정의합니다:
package cache import ( "sync" "time" ) // CacheEntry는 캐시에 저장된 항목을 나타냅니다. type CacheEntry[V any] struct { Value V Expiration *time.Time // 선택 사항: 항목이 오래된 것으로 간주되는 시간 } // MyCache는 동시성 안전 인메모리 캐시를 정의합니다. type MyCache[K comparable, V any] struct { data map[K]CacheEntry[V] mutex sync.RWMutex } // NewCache는 MyCache의 새 인스턴스를 만들고 반환합니다. func NewCache[K comparable, V any]() *MyCache[K, V] { return &MyCache[K, V]{ data: make(map[K]CacheEntry[V]), } }
여기서 MyCache는 데이터를 저장하기 위한 map과 동시 액세스를 보호하기 위한 sync.RWMutex를 보유합니다. CacheEntry는 선택적으로 만료 시간을 포함할 수 있으며, 이는 나중에 캐시 제거 정책을 위해 다룰 것입니다.
이제 핵심 캐시 작업인 Set, Get, Delete를 구현해 보겠습니다.
값 설정하기
// Set은 캐시에 항목을 추가하거나 업데이트합니다. // 만료 시간이 nil이면 항목은 만료되지 않습니다. func (c *MyCache[K, V]) Set(key K, value V, expiration *time.Duration) { c.mutex.Lock() // 쓰기 잠금 획득 defer c.mutex.Unlock() // 잠금 해제를 보장합니다 var expTime *time.Time if expiration != nil { t := time.Now().Add(*expiration) expTime = &t } c.data[key] = CacheEntry[V]{ Value: value, Expiration: expTime, } }
Set 메서드는 c.mutex.Lock()을 사용하여 쓰기 잠금을 획득합니다. 이는 한 번에 하나의 고루틴만 data 맵을 수정할 수 있도록 보장하여 쓰기 중에 데이터 경쟁 상태를 방지합니다. defer c.mutex.Unlock() 문은 함수 내에서 오류가 발생하더라도 잠금이 해제되도록 보장합니다.
값 가져오기
// Get은 캐시에서 항목을 검색합니다. // 찾았고 만료되지 않은 경우 값과 true를 반환하고, 그렇지 않으면 제로 값과 false를 반환합니다. func (c *MyCache[K, V]) Get(key K) (V, bool) { c.mutex.RLock() // 읽기 잠금 획득 defer c.mutex.RUnlock() // 읽기 잠금 해제를 보장합니다 entry, found := c.data[key] if !found { var zeroValue V // V의 제로 값 초기화 return zeroValue, false } // 만료 확인 if entry.Expiration != nil && time.Now().After(*entry.Expiration) { // 항목이 만료되었습니다. 현재로서는 찾을 수 없는 것으로 처리합니다. // 백그라운드 고루틴이 실제 제거를 처리할 수 있습니다. var zeroValue V return zeroValue, false } return entry.Value, true }
Get 메서드는 c.mutex.RLock()을 사용하여 읽기 잠금을 획득합니다. 여러 고루틴이 동시에 읽기 잠금을 보유할 수 있으며, 이는 읽기 성능에 매우 좋습니다. defer c.mutex.RUnlock()은 읽기 잠금이 해제되도록 보장합니다. 또한 기본 만료 로직을 포함합니다.
값 삭제하기
// Delete는 캐시에서 항목을 제거합니다. func (c *MyCache[K, V]) Delete(key K) { c.mutex.Lock() // 쓰기 잠금 획득 defer c.mutex.Unlock() // 잠금 해제를 보장합니다 delete(c.data, key) }
Set과 유사하게 Delete는 기본 data 맵을 수정하기 때문에 쓰기 잠금이 필요합니다.
애플리케이션 예제
동시성 Go 프로그램에서 이 캐시를 사용하는 방법을 살펴보겠습니다.
package main import ( "fmt" "math/rand" "strconv" "sync" "time" "your_module_path/cache" // 캐시 패키지가 이 경로에 있다고 가정합니다. ) func main() { myCache := cache.NewCache[string, string]() var wg sync.WaitGroup // --- 작성기 --- (Writers) for i := 0; i < 5; i++ { wg.Add(1) go func(writerID int) { defer wg.Done() for j := 0; j < 10; j++ { key := fmt.Sprintf("key-%d", rand.Intn(20)) // 랜덤 키 value := fmt.Sprintf("value-from-writer-%d-%d", writerID, j) // 일부는 만료 설정, 일부는 미설정 var expiration *time.Duration if rand.Intn(2) == 0 { // 50% 확률로 만료 설정 exp := time.Duration(rand.Intn(5)+1) * time.Second // 1-5초 expiration = &exp fmt.Printf("[Writer %d] Setting key: %s, value: %s with expiration: %v\n", writerID, key, value, exp) } else { fmt.Printf("[Writer %d] Setting key: %s, value: %s (no expiration)\n", writerID, key, value) } myCache.Set(key, value, expiration) time.Sleep(time.Duration(rand.Intn(50)) * time.Millisecond) // 작업 시뮬레이션 } }(i) } // --- 판독기 --- (Readers) for i := 0; i < 10; i++ { wg.Add(1) go func(readerID int) { defer wg.Done() for j := 0; j < 20; j++ { key := fmt.Sprintf("key-%d", rand.Intn(20)) // 다양한 키를 읽도록 시도 val, found := myCache.Get(key) if found { fmt.Printf("[Reader %d] Found key: %s, value: %s\n", readerID, key, val) } else { fmt.Printf("[Reader %d] Key %s not found or expired.\n", readerID, key) } time.Sleep(time.Duration(rand.Intn(30)) * time.Millisecond) // 작업 시뮬레이션 } }(i) } // 일부 만료가 발생하도록 잠시 대기 time.Sleep(2 * time.Second) // --- 삭제 --- (간단함을 위해 하나의 고루틴으로) wg.Add(1) go func() { defer wg.Done() for i := 0; i < 5; i++ { key := fmt.Sprintf("key-%d", rand.Intn(20)) fmt.Printf("[Deleter] Attempting to delete key: %s\n", key) myCache.Delete(key) time.Sleep(time.Duration(rand.Intn(100)) * time.Millisecond) } }() wg.Wait() fmt.Println("\nAll operations completed.") // 최종 상태 확인 (시연 목적) fmt.Println("\nFinal cache state (snapshot):") cSlice := myCache.GetAll() // 검증을 위해 GetAll 메서드를 추가했다고 가정 if len(cSlice) == 0 { fmt.Println("Cache is empty.") } else { for key, entry := range cSlice { expStr := "never" if entry.Expiration != nil { expStr = entry.Expiration.Format(time.RFC3339) } fmt.Printf("Key: %v, Value: %v, Expires: %s\n", key, entry.Value, expStr) } } } // 검사를 위한 도우미 메서드 추가 (시연용, RLock도 필요) func (c *MyCache[K, V]) GetAll() map[K]CacheEntry[V] { c.mutex.RLock() defer c.mutex.RUnlock() // 외부 수정을 방지하기 위해 복사본 반환 snapshot := make(map[K]CacheEntry[V]) for k, v := range c.data { snapshot[k] = v } return snapshot }
이 예제는 여러 고루틴(Writers, Readers, Deleters)이 MyCache 인스턴스와 동시적으로 상호 작용하는 방법을 보여줍니다. 출력에는 메시지가 번갈아 나타나겠지만, sync.RWMutex 덕분에 data 맵에 대한 모든 캐시 작업은 데이터 경쟁 상태 없이 안전하게 수행됩니다. sync.WaitGroup이 main 고루틴이 모든 작업자 고루틴이 완료될 때까지 기다리도록 하는 데 사용된다는 점에 유의하십시오.
추가 개선 사항 및 고려 사항
- 제거 정책(Eviction Policies): 현재 캐시는
Get에서 만료된 항목만 무효화합니다. 더 강력한 캐시에는 만료된 항목을 주기적으로 검색하고 제거하는 백그라운드 고루틴이 있을 것입니다 (LRU, LFU 등). 이를 구현하려면 기본 캐시 작업과의 신중한 동기화가 필요합니다. - 캐시 크기 제한: 대규모 데이터 세트의 경우 캐시에는 종종 최대 크기가 있습니다. 한계에 도달하면 제거 정책이 새 항목을 위한 공간을 만들기 위해 어떤 항목을 제거할지 결정합니다.
- 제네릭(Generics): Go의 제네릭(여기서는
[K comparable, V any]와 같이 사용됨)은 타입 캐스팅이나 별도의 구현 없이도 다양한 키 및 값 유형에 대해 캐시를 재사용할 수 있도록 합니다. - 오류 처리: 애플리케이션에 따라 항목을 찾을 수 없거나 만료되었을 때 부울 대신 오류를 반환하도록
Get을 원할 수 있습니다. - 성능 벤치마킹: 중요한 애플리케이션의 경우 최적의 구성을 찾기 위해 다양한 동기화 메커니즘 및 제거 전략의 성능을 벤치마킹합니다.
결론
고성능의 동시성 안전 인메모리 캐시를 구축하는 것은 많은 Go 애플리케이션에서 일반적인 요구 사항입니다. sync.RWMutex를 신중하게 사용함으로써 쓰기 작업 중에 데이터 무결성을 보장하면서 여러 동시 읽기 작업을 효율적으로 처리하는 강력한 캐시를 만들 수 있습니다. 이 접근 방식은 성능과 안전성의 균형을 맞추어 sync.RWMutex를 Go에서 확장 가능하고 안정적인 동시성 시스템을 구축하기 위한 필수 도구로 만듭니다.
sync.RWMutex를 활용하는 것은 동시 데이터 액세스를 위한 기본 패턴을 제공하여 확장 가능한 Go 애플리케이션을 위한 빠르고 일관된 인메모리 캐싱 솔루션을 가능하게 합니다.

