- Published on
Go 고루틴 데이터 레이스 5분 재현·해결
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버나 배치에서 Go 고루틴을 쓰다 보면, 테스트에서는 멀쩡한데 운영에서만 값이 튀거나 간헐적으로 크래시가 나는 문제가 종종 나옵니다. 상당수가 데이터 레이스입니다.
데이터 레이스는 “여러 고루틴이 같은 메모리를 동시에 읽거나 쓰는데, 그 접근을 동기화하지 않은 상태”를 말합니다. Go는 메모리 모델상 이런 접근이 발생하면 결과가 비결정적이며, 단순히 값이 틀리는 수준을 넘어 논리 자체가 무너질 수 있습니다.
이 글에서는 5분 안에 재현 가능한 최소 예제로 레이스를 만들고, -race로 잡아낸 다음, 상황별로 가장 실용적인 해결책을 정리합니다. 동시성 문제의 진단 관점은 Spring Boot 3 가상스레드 도입 후 Deadlock·TPS 저하 진단에서도 비슷한 방식으로 접근할 수 있습니다.
1) 5분 재현: 공유 카운터 레이스 만들기
가장 흔한 레이스는 “공유 변수 증가”입니다. 아래 코드는 counter++가 원자적이지 않기 때문에 레이스가 발생합니다.
package main
import (
"fmt"
"sync"
)
func main() {
var (
counter int
wg sync.WaitGroup
)
for i := 0; i < 100; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for j := 0; j < 1000; j++ {
counter++
}
}()
}
wg.Wait()
fmt.Println("counter:", counter)
}
예상 값은 100 * 1000 = 100000이지만, 실제로는 종종 더 작은 값이 출력됩니다. 이건 단순히 “고루틴이 덜 돌았다”가 아니라, 증가 연산이 다음처럼 쪼개져 수행되기 때문입니다.
- 메모리에서
counter읽기 - 레지스터에서
+1 - 메모리에 다시 쓰기
여러 고루틴이 이 과정을 섞어 실행하면 일부 증가가 유실됩니다.
2) 레이스 디텍터로 잡기: go test -race / go run -race
Go의 강력한 도구가 race detector입니다.
- 실행:
go run -race main.go - 테스트:
go test -race ./...
실행하면 대개 다음과 같은 경고가 나옵니다(출력은 환경마다 다를 수 있음).
WARNING: DATA RACE- 어떤 고루틴이 어떤 주소를 읽고 썼는지
- 스택 트레이스
여기서 중요한 포인트는 2가지입니다.
- 레이스는 “발생하면” 잡히지만, “항상” 잡히는 건 아닙니다. 스케줄링 타이밍에 따라 레이스가 드러나지 않을 수도 있습니다.
- 그래도
-race는 CI에서 돌릴 가치가 매우 큽니다. 특히 공유 상태가 있는 서비스라면 거의 필수입니다.
3) 해결책 A: sync.Mutex로 공유 상태 보호
공유 변수를 계속 공유 변수로 두고 싶다면, 가장 직관적인 해결은 sync.Mutex입니다.
package main
import (
"fmt"
"sync"
)
func main() {
var (
counter int
mu sync.Mutex
wg sync.WaitGroup
)
for i := 0; i < 100; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for j := 0; j < 1000; j++ {
mu.Lock()
counter++
mu.Unlock()
}
}()
}
wg.Wait()
fmt.Println("counter:", counter)
}
Mutex를 쓸 때 체크리스트
- 락 구간은 짧게 유지합니다. (락 안에서 I/O, 네트워크 호출 금지)
- 락 순서가 여러 개면 데드락 가능성이 생깁니다. 락 순서를 정하거나 락 개수를 줄입니다.
- 읽기만 많은 경우
sync.RWMutex도 고려합니다.
4) 해결책 B: 채널로 소유권 이전(share memory by communicating)
Go가 권장하는 스타일은 “공유 메모리를 잠그기”보다 “메시지로 소유권을 옮기기”입니다. 카운터를 한 고루틴이 전담하고, 나머지는 증가 요청만 보내는 구조입니다.
package main
import (
"fmt"
"sync"
)
func main() {
inc := make(chan int)
done := make(chan int)
// 카운터 소유 고루틴
go func() {
counter := 0
for v := range inc {
counter += v
}
done <- counter
}()
var wg sync.WaitGroup
for i := 0; i < 100; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for j := 0; j < 1000; j++ {
inc <- 1
}
}()
}
wg.Wait()
close(inc)
counter := <-done
fmt.Println("counter:", counter)
}
채널 방식이 특히 좋은 경우
- 상태 변경 로직이 단순 증가가 아니라, 여러 필드를 함께 업데이트해야 하는 경우
- 업데이트 순서가 중요하고, 일관성을 강하게 보장해야 하는 경우
- 락 경쟁이 심해서 병목이 되는 경우(단, 채널도 병목이 될 수 있으니 측정 필요)
5) 해결책 C: sync/atomic으로 단일 값만 원자적으로
단순 카운터 같은 “단일 정수”는 atomic이 가장 가볍습니다.
package main
import (
"fmt"
"sync"
"sync/atomic"
)
func main() {
var counter int64
var wg sync.WaitGroup
for i := 0; i < 100; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for j := 0; j < 1000; j++ {
atomic.AddInt64(&counter, 1)
}
}()
}
wg.Wait()
fmt.Println("counter:", counter)
}
atomic 사용 시 주의
- 원자적으로 안전한 건 “그 변수 하나”에 한정됩니다.
- 여러 변수를 함께 일관되게 갱신해야 하면 atomic만으로는 부족합니다(락 또는 채널이 필요).
atomic.Value는 읽기 최적화에 좋지만, 저장 타입 일관성 등 제약이 있습니다.
6) 레이스가 자주 숨어있는 패턴 3가지
(1) 루프 변수 캡처
고루틴을 루프에서 만들 때 루프 변수를 그대로 캡처하면 의도치 않은 값이 들어갈 수 있습니다. 최신 Go에서는 일부 패턴이 개선되었지만, 여전히 “명시적으로 지역 변수에 복사”하는 습관이 안전합니다.
for i := 0; i < 10; i++ {
i := i
go func() {
fmt.Println(i)
}()
}
이 문제는 레이스라기보다 “클로저 캡처 버그”에 가깝지만, 공유 상태가 섞이면 레이스로 확대됩니다.
(2) 맵 동시 접근
Go의 기본 map은 동시 쓰기, 혹은 읽기와 쓰기가 동시에 발생하면 위험합니다(런타임 패닉이 나기도 함). 해결은 다음 중 하나입니다.
sync.Mutex로 감싸기sync.Map사용(읽기 많고 키가 안정적일 때 유리)- 샤딩 맵(키 해시로 여러 락으로 분산)
(3) 슬라이스 append 공유
슬라이스는 헤더(포인터, 길이, 용량)가 있고 append 시 재할당이 일어날 수 있습니다. 여러 고루틴이 같은 슬라이스에 append하면 매우 쉽게 레이스가 납니다.
해결은 보통 다음입니다.
- 고루틴별 로컬 슬라이스에 모은 뒤 마지막에 합치기
- 채널로 수집 고루틴에 전달
- 락으로 보호(하지만 append 빈도가 높으면 병목)
7) 실전 워크플로: 재현·진단·수정·회귀 방지
- 재현 최소화: 공유 변수를 가진 최소 예제를 먼저 만듭니다.
-race상시 사용: 로컬에서go test -race를 먼저 돌리고, CI에도 넣습니다.- 해결책 선택
- 단일 값 카운터:
atomic - 복합 상태 일관성:
Mutex또는 “상태 소유 고루틴 + channel” - 읽기 압도적:
RWMutex또는atomic.Value
- 단일 값 카운터:
- 성능 검증: 락/채널은 병목이 될 수 있으니 벤치마크로 확인합니다.
성능과 안정성의 균형을 잡는 과정은 프레임워크가 달라도 유사합니다. 예를 들어 병목이나 교착을 의심할 때 접근법은 Spring Boot DB 커넥션 고갈 - HikariCP 튜닝 가이드처럼 “관측 지표를 세우고 병목을 좁혀가는 방식”이 그대로 적용됩니다.
8) 결론: 레이스는 운이 아니라 도구로 잡는다
Go 고루틴 데이터 레이스는 “가끔만 터져서” 더 위험합니다. 하지만 Go는 언어 차원에서 -race라는 강력한 무기를 제공합니다.
- 재현: 공유 변수 증가 같은 최소 예제로 빠르게 확인
- 진단:
go test -race로 스택 트레이스 확보 - 해결:
Mutex(일관성),channel(소유권),atomic(단일 값)
운영에서만 터지는 동시성 버그를 줄이고 싶다면, 가장 먼저 CI에 go test -race ./...를 넣는 것부터 시작하는 게 효과가 큽니다.