Go 1.19의 메모리 모델 변경과 동시성 버그 수정기
문제 상황
사용자 세션 캐시를 구현한 서비스에서 간헐적으로 nil pointer dereference가 발생했다. 로컬에서는 재현되지 않았고, 트래픽이 많은 시간대에만 나타났다.
기존 코드는 map과 sync.RWMutex를 조합해 사용하고 있었다.
type SessionCache struct {
mu sync.RWMutex
cache map[string]*Session
}
func (sc *SessionCache) Get(key string) *Session {
sc.mu.RLock()
defer sc.mu.RUnlock()
return sc.cache[key]
}
문제는 Session 객체 내부 필드를 업데이트할 때 락을 걸지 않은 부분이었다. 여러 고루틴이 동시에 Session의 LastAccessTime을 수정하면서 race condition이 발생했다.
해결 과정
Go 1.19부터 메모리 모델이 명확해지면서 happens-before 관계가 더 엄격해졌다는 것을 알게 되었다. 두 가지 방향으로 개선했다.
첫째, sync.Map으로 전환했다. 읽기 작업이 많은 경우 RWMutex보다 성능이 좋았다.
type SessionCache struct {
cache sync.Map
}
func (sc *SessionCache) Get(key string) *Session {
val, ok := sc.cache.Load(key)
if !ok {
return nil
}
return val.(*Session)
}
둘째, Session 내부의 LastAccessTime을 atomic으로 변경했다.
type Session struct {
UserID string
lastAccessTime atomic.Int64
}
func (s *Session) UpdateAccessTime() {
s.lastAccessTime.Store(time.Now().Unix())
}
결과
벤치마크 결과 기존 대비 약 40% 성능 향상을 확인했다. race detector로도 더 이상 경고가 발생하지 않았다.
# 기존
BenchmarkGet-8 5000000 312 ns/op
# 개선 후
BenchmarkGet-8 8000000 187 ns/op
프로덕션 배포 후 2주간 모니터링 결과 nil pointer 에러가 완전히 사라졌다. Go의 동시성 프리미티브를 올바르게 사용하는 것의 중요성을 다시 한번 깨달았다.