Skip to content

8a.0 경쟁 상태와 동기화 — 가끔만 실패하는 코드

동시성 버그는 코드의 한 줄이 아니라 가능한 실행들의 집합에 산다. 만 번에 한 번 실패하고, 로그를 넣으면 사라지고, 프로덕션에서만 나타나는 버그는 우연이 아니라 실행 공간의 확률적 표본이다. 이 인트로는 동시성 API를 미리 외우는 대신, 순차 실행 직관이 왜 배신하는지 세우고 챕터 8a의 학습 경로를 안내한다.

학습 목표

  • 간헐적으로만 실패하는 동시성 버그가 왜 비결정적인지 실행 공간의 관점으로 설명한다.
  • "테스트를 통과했다"가 동시성 코드에서 보장하는 것과 보장하지 않는 것을 구분한다.
  • race 진단·수정 문제를 만났을 때 챕터 8a의 각 문서에서 답해야 할 질문을 세운다.

출발점: 만 번에 한 번, 꼭 프로덕션에서만

재고 차감 로직이 있다. 남은 수량을 확인하고, 충분하면 차감한다. 단위 테스트도, 통합 테스트도, 부하 테스트 첫 몇 번도 전부 통과했다. 배포하고 몇 주 뒤, 프로모션으로 트래픽이 몰린 날 재고가 음수가 됐다. 조사가 시작되고 흔한 대응이 이어진다.

대응결과
같은 입력으로 로컬에서 재실행재현되지 않는다
문제 구간에 로그 추가 후 재배포증상이 사라진다
테스트를 만 번 반복 실행전부 통과한다
"일단 retry를 넣자"빈도만 줄고 이달 말 다시 발생한다

이 패턴은 동시성 버그의 시그니처다. 버그가 코드 안의 고정된 결함이라면 같은 입력에서 같은 실패가 나와야 한다. 그런데 이 버그는 입력이 아니라 타이밍의 함수다. 두 요청이 특정 순서로 겹칠 때만 불변식("재고는 0 이상")이 깨지고, 그 순서는 스케줄러·부하·캐시 상태가 정한다. 로그 추가가 증상을 없앤 것은 수정이 아니라 타이밍 변경이다 — 관찰이 현상을 바꾸는 이 성질 때문에 이런 버그를 heisenbug라 부른다.

코드는 하나, 실행은 여럿이다

순차 프로그램에서 코드는 실행을 결정한다. 같은 입력이면 같은 경로, 같은 결과다. 디버깅이란 그 하나의 실행을 재구성하는 일이다.

동시 실행에서 이 등식이 깨진다. 스레드가 둘이면 각 스레드의 연산이 어떤 순서로 섞이는지(interleaving)에 따라 서로 다른 실행이 만들어지고, 코드 하나가 실행들의 집합을 정의하게 된다. 그 집합의 크기는 연산 수에 따라 조합적으로 폭발한다. 테스트 한 번은 그중 하나의 표본을 뽑는 행위이고, 만 번의 통과는 만 개의 표본이 무사했다는 뜻일 뿐 집합 전체의 안전을 말하지 않는다.

여기서 이 챕터의 관점이 나온다. 동시성 버그를 다룬다는 것은 실패한 실행 하나를 쫓는 것이 아니라, 가능한 실행의 집합을 모델로 다루는 것이다. 집합을 열거할 수 있어야 무엇이 잘못될 수 있는지 알고, 집합을 제한할 수 있어야 잘못될 수 없게 만들며, 제한의 비용을 알아야 성능과 교환할 수 있다.

익숙한 표현 아래의 계약

동시성에서도 애플리케이션의 표현과 실제 보장 사이에 간극이 있다. 챕터 8의 계약 관점을 공유 상태에 적용하면 이렇게 된다.

애플리케이션에서 보이는 표현아래에서 확인해야 할 계약
"한 줄짜리 연산이니 원자적이다"소스 한 줄이 몇 개의 읽기·쓰기로 갈라지는지, 그 사이에 다른 스레드가 끼어들 수 있는지 확인해야 한다.
"검사했으니 안전하다"검사와 행동 사이의 시간 동안 검사 결과가 유효하다는 보장이 있는지 확인해야 한다.
"이 스레드가 쓴 값을 저 스레드가 읽는다"동기화 없이는 언제, 심지어 영원히 보이지 않을 수도 있다는 언어 메모리 모델의 계약을 확인해야 한다.
"락을 잡았으니 끝났다"락이 보호하는 범위가 지켜야 할 불변식 전체를 덮는지, 락들 사이의 순서가 교착을 만들지 않는지 확인해야 한다.
"싱글 스레드라 동시성 문제가 없다"data race는 없지만, await 경계마다 다른 작업이 상태를 바꿀 수 있다는 점은 그대로 남는다.

이 간극은 평소에는 드러나지 않는다. race window가 마이크로초 단위라면 낮은 부하에서 충돌 확률은 0에 가깝다. 부하가 늘고 코어가 늘어 겹침이 잦아질 때 — 즉 프로덕션의 가장 바쁜 순간에 — 간극이 장애로 나타난다.

이 지식이 필요한 세 가지 순간

1. 간헐 장애를 진단할 때

재현되지 않는 버그 앞에서 모델 없는 대응은 "로그를 더 넣자", "일단 재시도하자"로 흐른다. interleaving 모델이 있으면 반대로 움직일 수 있다 — 어떤 순서가 불변식을 깨는지 가설을 세우고, race window를 의도적으로 넓혀 재현 확률을 끌어올리고, 도구(ThreadSanitizer)로 동기화 누락을 기계적으로 찾는다. 재현 확률이 낮다는 것과 버그가 없다는 것을 구분하는 근거가 생긴다.

2. 공유 상태와 동시 요청을 설계할 때

같은 사용자의 중복 클릭, 같은 자원을 갱신하는 두 API 요청, 캐시를 채우는 동시 미스는 모두 공유 상태 위의 경쟁이다. 무엇을 임계 구역으로 묶을지, mutex·atomic·불변 데이터·큐 중 무엇으로 보호할지는 취향이 아니라 지켜야 할 불변식과 지불할 비용의 문제다. 비경합 락은 나노초 단위로 싸고, 경합하는 락은 컨텍스트 스위치라는 비용을 치른다 — 이 비용 구조를 알아야 "락은 느리니 피하자"라는 통념 대신 측정에 기반한 선택을 할 수 있다.

3. 테스트와 리뷰가 보장하는 것의 경계를 판단할 때

순차 코드에서 테스트 통과는 강한 증거다. 동시성 코드에서는 실행된 interleaving에 대한 증거일 뿐이다. 이 경계를 알아야 "테스트가 다 통과하는데 왜 코드 리뷰에서 락 순서를 따지는가"에 답할 수 있고, 어떤 코드에 도구 검증(TSan)과 설계 규율(락 순서, 소유권)을 요구할지 정할 수 있다.

이 챕터에서 세울 두 가지 모델

8a.1 race condition과 interleaving

어떤 실행들이 가능하고, 무엇이 잘못되는가?

interleaving 모델로 실행 공간을 세우고, 소스 한 줄이 원자적이지 않다는 것을 실측으로 확인한다. data race와 race condition을 구분하고 — 하나는 언어 계약 위반, 하나는 논리 오류다 — 가끔만 실패하는 버그를 스트레스 증폭과 ThreadSanitizer로 재현·탐지 가능한 대상으로 바꾼다.

8a.2 동기화와 메모리 모델

가능한 실행을 어떻게 제한하고, 그 비용은 얼마인가?

mutex·조건 변수·atomic이 실행 공간을 제한하는 각각의 방식과 happens-before라는 언어의 약속을 세운다. 동기화 없는 플래그 신호가 컴파일러 최적화만으로 깨지는 것을 관찰하고, 비경합·경합 상황의 동기화 비용을 측정해 락 범위와 granularity를 판단하는 근거를 만든다.

두 문서는 이 커리큘럼의 다른 층과 연결된다. 아래로는 챕터 7의 캐시 일관성이 하드웨어가 보장하는 바닥을, 챕터 8의 스레드·스케줄링이 경합의 커널 비용을 제공한다. 위로는 챕터 11의 트랜잭션 동시성 제어가 같은 문제의식 — 동시에 실행해도 불변식을 지킨다 — 을 데이터베이스의 계약으로 일반화한다.

이 챕터를 읽는 방법

  1. 실행을 하나로 상상하지 않는다. 공유 상태를 만나면 "이 코드가 실행된다"가 아니라 "이 연산들이 어떻게 섞일 수 있는가"를 묻는다.
  2. 재현 확률과 버그 존재를 구분한다. 재현이 안 된다는 것은 표본을 못 뽑았다는 뜻이지 집합이 비었다는 뜻이 아니다.
  3. 보호 대상을 코드가 아니라 불변식으로 말한다. "이 함수에 락을 건다"가 아니라 "이 불변식을 지키는 모든 접근을 한 계약 아래 둔다"가 정확한 문장이다.
  4. 동기화의 비용을 측정으로 확인한다. 비경합과 경합의 비용 차이는 수 배에서 수십 배다. 어느 쪽 상황인지 모르고 내리는 최적화 판단은 추측이다.

정리

  • 동시성 버그는 입력이 아니라 타이밍의 함수이며, 코드 하나가 정의하는 가능한 실행들의 집합 속에 존재한다.
  • 테스트 통과는 실행된 interleaving에 대한 증거일 뿐, 실행 공간 전체의 안전을 보장하지 않는다.
  • 로그 추가로 사라지는 버그는 고쳐진 것이 아니라 타이밍이 바뀐 것이다.
  • 챕터 8a는 실행 공간을 세우는 모델(8a.1)과 그것을 제한하는 계약·비용(8a.2)으로 나뉘며, 하드웨어(챕터 7)·커널(챕터 8)·데이터베이스(챕터 11)의 동시성 층과 연결된다.

확인 문제

1. 동료가 "이 버그는 스테이징에서 한 번도 재현되지 않았고 테스트도 전부 통과하니, 프로덕션 장애의 원인은 코드가 아니라 인프라일 것"이라고 주장한다. 이 추론의 빈틈을 실행 공간의 관점에서 지적하라.

정답과 해설

동시성 버그의 발생 조건은 입력이 아니라 연산이 겹치는 타이밍이다. 스테이징과 테스트는 프로덕션보다 부하·코어 수·지연 분포가 달라 문제의 interleaving이 표본으로 뽑힐 확률 자체가 낮다. 재현 실패는 "그 실행이 표본에 없었다"는 뜻이지 "가능한 실행 집합에 없다"는 뜻이 아니므로, 코드를 용의선상에서 지울 근거가 되지 않는다. 공유 상태를 갱신하는 경로가 있다면 검사와 행동 사이의 race window를 먼저 조사해야 한다.

2. 간헐적으로 값이 틀어지는 버그에 로그를 추가했더니 더 이상 재현되지 않는다. 이 관찰이 버그에 대해 말해 주는 것과 말해 주지 않는 것을 구분하라.

정답과 해설

말해 주는 것: 증상이 타이밍에 민감하다는 것, 즉 동시성 버그일 가능성이 높다는 진단 단서다. 로그 I/O가 스레드의 상대 속도를 바꿔 문제의 interleaving이 뽑힐 확률을 낮췄을 뿐이다. 말해 주지 않는 것: 버그가 사라졌다는 것. 가능한 실행 집합은 그대로이므로 환경이 바뀌면(부하 증가, 하드웨어 변경, 로그 레벨 조정) 다시 나타난다. 올바른 다음 단계는 로그를 유지한 채 배포하는 것이 아니라, race window를 넓혀 재현을 만들거나 도구로 동기화 누락을 찾는 것이다(8a.1).

참고 자료