← 문제로

16. 동시성 버그 재현과 검출: 데이터 레이스 디텍터(ThreadSanitizer/Helgrind)와 결정적 재현 기법

난이도 중
내 답안
모범답안

해설 — 동시성 버그 재현과 검출: 데이터 레이스 디텍터(ThreadSanitizer/Helgrind)와 결정적 재현 기법

난이도: 중

1. 재현이 드문 이유 — 스케줄링 비결정성

데이터 레이스는 "두 스레드가 동기화 없이 같은 메모리를 동시에 접근하고(적어도 하나는 쓰기)" 발생한다. 하지만 레이스가 존재한다는 것과 그것이 관측 가능한 오류로 드러난다는 것은 다르다. 실제 실행에서 두 스레드의 명령이 인터리빙되는 순서는 다음 요인들에 의해 매 실행마다 달라진다.

  • OS 스케줄러: 타임슬라이스 길이, 어느 코어에 배정되는지, 다른 프로세스와의 경합에 따라 스레드가 선점되는 시점이 실행마다 다르다.
  • 컴파일러/CPU 재배열: 컴파일러 최적화와 CPU의 비순차 실행(out-of-order execution)이 메모리 접근 순서를 소스 코드 순서와 다르게 만들 수 있다(메모리 모델·배리어 문제와 연결 — concurrency/problem2, problem9).
  • 캐시 상태와 락 경합 타이밍: 락을 잡는 시점의 캐시라인 상태, 락 경합 여부(스핀 vs 블록)에 따라 임계구역 진입 타이밍이 미세하게 흔들린다.

레이스가 있는 코드도 대부분의 인터리빙에서는 "우연히" 안전한 순서로 실행되어 문제없는 결과가 나온다. 문제가 되는 좁은 타이밍 윈도우(예: 두 스레드가 정확히 같은 나노초 근방에서 같은 캐시라인을 갱신)에 걸릴 확률은 낮으므로, 수백만 번의 정상 실행 사이에 드물게 한 번씩만 증상이 나타난다. 트래픽이 몰리는 운영 환경(코어 수 많음, 컨텍스트 스위칭 빈번, GC 일시정지 등)은 이 타이밍 윈도우에 걸릴 확률을 높이는 조건이라, "로컬에서는 재현 안 되는데 운영에서만 터진다"는 현상이 흔하다.

하이젠버그(Heisenbug): 디버거를 붙이거나 로깅을 추가하면 그 자체가 실행 시간·메모리 배치·스케줄링을 바꾼다. 디버거는 브레이크포인트마다 실행을 멈추고, 로깅은 I/O 대기와 락(로그 버퍼 보호)을 추가한다. 이 타이밍 변화가 우연히 레이스를 회피하는 인터리빙을 유도해버리면, "관찰하려는 순간 사라지는 버그"가 된다. 이는 레이스가 근본적으로 타이밍 의존적이라는 사실의 직접적 귀결이다.

2. 동적 분석 도구의 원리

shadow memory

TSan·Helgrind는 프로그램이 실제로 실행되는 동안 메모리 접근을 가로채서(계측, instrumentation) 감시한다. 이를 위해 감시 대상 메모리의 각 워드(또는 바이트)마다 별도의 그림자(shadow) 영역에 "최근에 어떤 스레드가, 어떤 클록 상태에서, 읽기/쓰기 중 무엇을 했는가"를 기록한다. 새로운 접근이 들어올 때마다 그림자 상태와 비교해 이전 접근과 happens-before 관계가 없으면 레이스로 보고한다.

이 방식은 모든 메모리 접근에 부가 연산(그림자 갱신·비교)을 추가하고, 원본 메모리 크기에 비례하는 그림자 메모리를 별도로 유지해야 하므로 오버헤드가 크다. 통상 TSan은 실행 시간 5~15배, 메모리 사용량 5~10배 정도 증가한다(정확한 배수는 워크로드에 따라 다름). 이 비용 때문에 상시 프로덕션 배포에는 쓰지 않고, 테스트·CI·스테이징 환경에서 주로 사용한다.

happens-before 관계와 벡터 클록

"happens-before"는 한 이벤트가 다른 이벤트보다 먼저 일어났다고 동기화로 보장되는 부분순서다. 대표적으로:

  • 뮤텍스 해제(A) → 같은 뮤텍스의 획득(B): A happens-before B.
  • 스레드 생성(fork) → 새 스레드의 첫 명령.
  • 스레드의 마지막 명령join 완료.

TSan은 각 스레드·각 락마다 벡터 클록(스레드별 논리 시각의 벡터)을 유지하고, 동기화 이벤트(락 해제/획득, 조인 등)가 일어날 때마다 벡터 클록을 병합해 happens-before 관계를 전파한다. 두 메모리 접근(적어도 하나는 쓰기)이 서로 다른 스레드에서 발생했는데, 두 접근 시점의 벡터 클록을 비교했을 때 어느 쪽도 다른 쪽보다 "먼저"라고 할 수 없다면(즉 happens-before 관계가 없다면) 데이터 레이스로 보고한다.

Helgrind(락셋) vs TSan(happens-before)

  • Helgrind는 주로 락셋(lockset) 알고리즘을 사용한다 — 각 공유 변수에 대해 "이 변수를 접근할 때 항상 잡혀 있던 락들의 교집합"을 추적하고, 그 교집합이 비면(즉 모든 접근을 보호하는 공통 락이 없으면) 잠재적 레이스로 경고한다. 락 기반 동기화만 가정하므로 락 없이 배리어·원자 연산 등으로 안전하게 동기화된 코드를 오탐(false positive)하기 쉽다.
  • TSan은 happens-before 기반으로, 락뿐 아니라 원자 연산·조인 등 더 넓은 동기화 패턴을 인식해 오탐률이 낮고, 실제 게임업계에서도 더 널리 쓰인다. 다만 순수 happens-before 판정은 "그 실행에서 지나간 경로"에 한정된다는 한계는 동일하다.

3. 정적 분석과의 차이

정적 분석은 프로그램을 실행하지 않고 소스 코드나 컴파일러 중간 표현(IR)을 분석해 잠재적 레이스 패턴(예: 락 없이 공유 변수에 접근하는 경로가 존재하는가)을 찾는다.

  • 장점: 코드에 존재하는 모든 경로(실행되지 않은 예외 경로·드문 분기 포함)를 이론적으로 검사할 수 있다. 테스트 커버리지에 의존하지 않는다.
  • 한계: 실제 동기화 의미(어떤 락이 어떤 변수를 "논리적으로" 보호하는지, 락 상속·조건부 동기화, 프로그램 불변식)를 정확히 추론하기 어려워 오탐(false positive)이 많다. 복잡한 동기화 패턴(락-프리 알고리즘, 커스텀 배리어)을 오판하기 쉽다.

동적 분석은 실제로 프로그램을 실행하며 실제로 지나간 메모리 접근과 동기화 이벤트만 관찰한다.

  • 장점: 실제 실행 기반이므로 "이론적으로 위험해 보이지만 실제로는 안전한" 패턴을 오탐하는 비율이 낮다(정적 분석보다 신뢰도 높은 보고).
  • 한계: 그 실행에서 지나간 코드 경로·인터리빙만 검사 가능하다 — 테스트가 특정 분기나 특정 타이밍 윈도우를 지나가지 않으면 그 레이스는 절대 검출되지 않는다(false negative). "레이스가 없다고 나왔다"가 "레이스가 없다"를 보장하지 않는다.

요약하면 정적 분석은 "보지 못한 것도 이론적으로 잡을 수 있지만 오탐이 많고", 동적 분석은 "실제로 지나간 것만 정확히 잡지만 놓칠 수 있다" — 상호 보완적으로 쓰는 것이 이상적이다.

4. CI 통합의 트레이드오프

게임서버 CI에 TSan 빌드를 상시 통합할 때 고려할 점:

  • 실행 시간 증가: 5~15배 느려지므로 전체 테스트 스위트를 매 커밋마다 TSan으로 돌리면 CI 시간이 크게 늘어난다. 보통 전용 잡(nightly, 또는 병렬 러너)으로 분리하거나, 타임아웃 값을 별도로 늘려 설정해야 한다.
  • 리소스 비용: 메모리 사용량 증가로 빌드 에이전트에 더 많은 메모리가 필요하다. 다수의 병렬 CI 잡을 도는 환경에서는 에이전트 스펙·비용에 영향을 준다.
  • false negative 한계: TSan은 "그 테스트 실행 동안 지나간 경로"만 검사하므로, 테스트 커버리지가 낮거나 동시성 스트레스 시나리오(다수 클라이언트 동시 접속, 특정 타이밍의 disconnect 등)를 테스트가 재현하지 못하면 레이스가 존재해도 검출되지 않는다. "TSan 통과 = 레이스 없음"이 아니라 "이 테스트가 실행한 경로에서는 레이스 못 찾음"으로 해석해야 한다.
  • 그럼에도의 가치: 회귀(regression)를 조기에, 사람이 우연히 운영에서 마주치기 전에 잡아낼 수 있다는 점에서 상시 통합의 ROI는 높다. 특히 동시성 테스트(다중 스레드 스트레스 테스트)를 TSan과 결합하면 커버리지 한계를 어느 정도 보완할 수 있다.

5. 결정적 재현을 돕는 기법

  • 스케줄 강제(timing perturbation): 임계구역 앞뒤에 의도적으로 sleep/yield/스핀 지연을 삽입해 특정 인터리빙(레이스가 발생하는 좁은 윈도우)이 걸릴 확률을 인위적으로 높인다. 디버깅 시 임시로 넣고, 재현되면 원인을 찾은 뒤 제거한다.
  • 스트레스 테스트: 스레드 수를 늘리고(예: 코어 수보다 훨씬 많은 스레드), 반복 횟수를 늘려(수만~수백만 회 루프) 확률적으로 낮은 타이밍 윈도우가 통계적으로 걸리도록 유도한다. TSan/Helgrind와 결합하면 낮은 확률의 레이스도 몇 초~몇 분 안에 검출 가능해진다.
  • 레코드&리플레이: 한 번의 실행에서 스케줄링 결정(어느 스레드가 언제 실행됐는지)을 기록해두고, 동일한 스케줄을 강제로 재생함으로써 버그를 결정적으로 재현한다. rr(Mozilla) 같은 도구가 대표적이며, "한 번 재현되면 그 실행을 계속 되돌려 디버깅"할 수 있다는 게 핵심 가치다.
  • 랜덤화된 스케줄러 + 고정 시드: 스케줄러 자체를 랜덤하게 흔들되(스케줄 퍼징), 그 랜덤성의 시드를 고정하면 같은 시드로 같은(비정상적이지만 결정적인) 인터리빙을 재현할 수 있다. CI에서 "실패한 시드"를 로그에 남겨두면 나중에 같은 시드로 재실행해 재현·회귀 확인이 가능하다.
  • 로깅 설계: 레이스 진단에는 스레드 ID, (논리/물리) 타임스탬프, 접근한 변수명, 그 시점의 락 상태가 유용하다. 다만 동기 로깅(파일 쓰기, 콘솔 출력)은 그 자체로 타이밍을 바꿔 하이젠버그를 유발할 수 있으므로, 저오버헤드 링버퍼(메모리에만 기록하고 크래시/트리거 시에만 플러시)를 쓰는 것이 일반적 절충안이다.

실무 정리 (게임서버 관점)

  • 유닛/통합 테스트 중 동시성 관련 스위트는 TSan 빌드로 별도 CI 잡을 만들어 nightly 또는 PR 게이트로 돈다(전체 스위트를 매번 돌리기엔 비용이 크다).
  • 세션·틱 루프·매치메이킹처럼 락 경합이 잦은 핵심 경로는 스트레스 테스트 + TSan 조합으로 정기 검증한다.
  • 운영에서 간헐적으로 재현되는 크래시/이상 동작은 우선 로그(스레드ID·타임스탬프)로 힌트를 얻고, 의심 지점에 인위적 지연을 넣어 로컬 재현을 시도하며, 가능하면 레코드&리플레이 도구로 결정적 재현 환경을 만든다.
  • 정적 분석 린트(예: 스레드 안전성 애노테이션)는 코드 리뷰 단계에서 보조 수단으로 병행하되, TSan 같은 동적 도구의 실제 검증을 대체하지 않는다.