-race 플래그와 pprof로 동시성 버그 잡기
-race는 마법이 아니라 관찰이다
go test -race, go run -race처럼 -race 플래그를 붙이면 Go 런타임이 모든 메모리
접근에 타임스탬프와 고루틴 ID를 붙여 추적한다. 두 고루틴이 동기화 없이 같은 메모리
주소에 접근했는데 그중 하나가 쓰기였다면 즉시 리포트를 찍는다. 앞 챕터의 counter++
예제를 -race로 돌리면 이렇게 나온다.
go run -race main.go
==================
WARNING: DATA RACE
Write at 0x00c0000140a0 by goroutine 8:
main.main.func1()
/main.go:12 +0x3c
Previous write at 0x00c0000140a0 by goroutine 7:
main.main.func1()
/main.go:12 +0x3c
==================여기서 중요한 사실 하나 — race detector는 실제로 그 실행 경로에서 레이스가
발생했을 때만 잡는다. 코드에 레이스 가능성이 있어도, 이번 실행에서 두 고루틴이
우연히 겹치지 않았다면 아무것도 안 찍힌다. 그래서 CI에서 -race로 테스트를 한 번
통과했다고 "이 코드에 레이스가 없다"고 확신하면 안 된다. 반복 실행(go test -race -count=100 같은)이나 부하가 실린 통합 테스트로 겹칠 확률을 높여야 실전에 가까운
신뢰도를 얻는다.
대가는 있다 — 프로덕션에 상시로 켜두지 않는 이유
-race는 메모리 사용량을 510배, 실행 속도를 220배 늦춘다(공식 문서 기준). 그래서
프로덕션 바이너리에 상시로 켜두는 경우는 거의 없다. 일반적인 실무 패턴은 이렇다.
- CI의 단위/통합 테스트는 항상
-race로 돌린다 (go test -race ./...) - 카나리/스테이징 환경에 한시적으로
-race빌드를 띄워 실제 트래픽으로 검증하기도 한다 - 프로덕션 상시 운영 바이너리는
-race없이 빌드한다
"테스트에서 안 잡히니 괜찮겠지"라는 생각으로 넘어가지 말고, 동시성 코드를 건드릴 때는 관련 테스트가 실제로 여러 고루틴을 경합시키는지부터 확인하는 습관이 필요하다. 고루틴 하나만 도는 테스트는 애초에 레이스를 재현할 기회조차 없다.
goroutine이 몇 개 떠 있는지 확인하기
고루틴 누수를 의심할 때 가장 먼저 할 일은 "지금 몇 개나 떠 있는가"를 세는 것이다.
runtime.NumGoroutine()으로 코드 안에서 직접 찍어볼 수도 있고, net/http/pprof를
붙이면 실행 중인 서버에서 실시간으로 확인할 수 있다.
import _ "net/http/pprof"
func main() {
go func() {
log.Println(http.ListenAndServe("localhost:6060", nil))
}()
// ... 나머지 애플리케이션 로직
}이 한 줄을 추가하면 http://localhost:6060/debug/pprof/goroutine?debug=2에서 현재
살아있는 모든 고루틴의 스택 트레이스를 텍스트로 볼 수 있다. 정상 상태에서 이 숫자를
한 번 재두고, 부하 테스트나 장시간 운영 후에 다시 재서 계속 늘어나기만 한다면
누수를 의심할 근거가 된다.
# 고루틴 개수만 빠르게 확인
curl -s http://localhost:6060/debug/pprof/goroutine?debug=1 | head -5데드락 스택 읽는 법
7장 마지막에 언급한 "런타임이 전체 데드락은 잡아준다"는 문장을 실제로 확인해 보자. 전체 고루틴이 블로킹되면 Go 런타임은 다음과 같은 리포트를 찍고 종료한다.
fatal error: all goroutines are asleep - deadlock!
goroutine 1 [chan send]:
main.main()
/main.go:10 +0x25[chan send], [chan receive], [select] 같은 대괄호 안 문구가 그 고루틴이 정확히
어떤 연산에서 멈춰 있는지 알려준다. 여러 고루틴이 리포트에 나온다면, 그것들이 서로
어떤 채널을 통해 얽혀 있는지 스택의 파일:줄번호를 따라가며 그려보면 순환 대기 구조가
드러난다. 부분적 데드락(전체가 아니라 일부 고루틴만 멈춘 경우)은 이 자동 감지에
걸리지 않으므로, 앞서 말한 /debug/pprof/goroutine?debug=2로 수동으로 스택을 훑어
[chan receive] 상태로 오래 멈춰 있는 고루틴이 있는지 찾아야 한다.
체크리스트 삼아 쓰는 순서
동시성 버그를 의심하는 상황에서 실무적으로 따르는 순서를 정리하면 이렇다.
- 재현되는 테스트를 먼저 만든다 — 없으면
-race도, pprof도 소용없다 go test -race -count=10이상으로 반복 실행해 본다- 메모리/고루틴 수가 계속 느는지 pprof로 확인한다
- 멈춰 있다면 goroutine 덤프에서
[chan receive]/[chan send]상태로 오래 있는 고루틴을 찾는다 - 그 고루틴을 누가 왜 안 깨우는지, "채널을 닫아야 할 쪽이 안 닫았는지" 또는 "받을 쪽이 사라졌는지"를 역추적한다
이 순서는 마법 같은 도구 하나로 끝나지 않는다는 걸 보여주기 위한 것이다. -race는
레이스만 잡고, pprof는 상태를 보여줄 뿐 원인을 설명해 주지 않는다. 결국 3장과 6장에서
다룬 "채널은 누가 닫는가", "context는 누가 감시하는가"에 대한 이해가 있어야 이 도구들이
보여주는 정보를 해석할 수 있다.