Published on

Go 고루틴 데이터 레이스 5분 재현·해결

Authors

서버나 배치에서 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가지입니다.

  1. 레이스는 “발생하면” 잡히지만, “항상” 잡히는 건 아닙니다. 스케줄링 타이밍에 따라 레이스가 드러나지 않을 수도 있습니다.
  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) 실전 워크플로: 재현·진단·수정·회귀 방지

  1. 재현 최소화: 공유 변수를 가진 최소 예제를 먼저 만듭니다.
  2. -race 상시 사용: 로컬에서 go test -race를 먼저 돌리고, CI에도 넣습니다.
  3. 해결책 선택
    • 단일 값 카운터: atomic
    • 복합 상태 일관성: Mutex 또는 “상태 소유 고루틴 + channel”
    • 읽기 압도적: RWMutex 또는 atomic.Value
  4. 성능 검증: 락/채널은 병목이 될 수 있으니 벤치마크로 확인합니다.

성능과 안정성의 균형을 잡는 과정은 프레임워크가 달라도 유사합니다. 예를 들어 병목이나 교착을 의심할 때 접근법은 Spring Boot DB 커넥션 고갈 - HikariCP 튜닝 가이드처럼 “관측 지표를 세우고 병목을 좁혀가는 방식”이 그대로 적용됩니다.

8) 결론: 레이스는 운이 아니라 도구로 잡는다

Go 고루틴 데이터 레이스는 “가끔만 터져서” 더 위험합니다. 하지만 Go는 언어 차원에서 -race라는 강력한 무기를 제공합니다.

  • 재현: 공유 변수 증가 같은 최소 예제로 빠르게 확인
  • 진단: go test -race로 스택 트레이스 확보
  • 해결: Mutex(일관성), channel(소유권), atomic(단일 값)

운영에서만 터지는 동시성 버그를 줄이고 싶다면, 가장 먼저 CI에 go test -race ./...를 넣는 것부터 시작하는 게 효과가 큽니다.