한 번 재현하기 위해 천 번을 돌려야 하는 버그
멀티스레드 환경에서 발생하는 버그 중에는 100번에 한 번 또는 1,000번에 한 번꼴로만 재현되는 종류가 있다. 개발 환경에서 한 시간 동안 돌려도 보이지 않다가 운영 환경에서 하루에 한 번씩 사용자 데이터를 손상시키는 식이다. 이 부류의 버그를 통칭해 경쟁 상태(Race condition)라고 부른다.
이 버그가 까다로운 이유는 비결정적이라는 데 있다. 같은 입력을 주고 같은 코드를 실행해도 결과가 매번 다르다. 디버거를 붙이면 타이밍이 바뀌어 버그가 사라진다. 로그를 추가하면 같은 이유로 재현되지 않는다. 코드를 들여다보면 분명히 문제가 없어 보인다. 그러나 운영에서는 분명히 데이터가 깨진다.
두 연산 사이의 빈틈에서 발생하는 사고
경쟁 상태의 핵심은 두 개 이상의 스레드가 공유 자원에 접근할 때, 그 접근이 원자적으로 이루어지지 않는다는 데 있다. 이 문제를 컴퓨터과학에서 가장 먼저 체계적으로 다룬 사람은 네덜란드의 에츠허르 데이크스트라(Edsger Dijkstra)다. 1965년에 발표한 “Cooperating Sequential Processes” 논문에서 그는 세마포어라는 동기화 원시 연산을 도입했고, 이후 모든 동시성 제어 메커니즘이 이 개념 위에서 확장되었다.
단순한 카운터 변수를 두 스레드가 동시에 증가시키는 경우를 생각해 보자. 코드상으로는 counter += 1 한 줄이지만 실제 CPU는 이 한 줄을 세 단계로 나눠 실행한다. 변수 값을 메모리에서 읽고, 1을 더하고, 그 결과를 다시 메모리에 쓴다. 두 스레드가 동시에 이 세 단계를 시작하면, 둘 다 같은 시작 값을 읽어서 같은 결과를 쓰게 된다. 카운터를 두 번 증가시켰다고 생각하지만 실제로는 한 번만 증가한 셈이 된다. 100만 번 중에 단 몇 번만 이 정확한 타이밍이 맞아떨어지므로 평소에는 잘 작동하는 것처럼 보인다.

원자성을 보장하는 단위
이 문제를 해결하는 방법은 그 세 단계를 하나의 원자적 연산으로 묶는 것이다. 대표적인 방법이 뮤텍스(mutex), 세마포어, 원자 연산(atomic operation)이다. 어떤 방법을 쓰든 핵심은 한 스레드가 자원에 접근하는 동안 다른 스레드를 막는 데 있다. 가장 간단해 보이는 카운터 증가조차 멀티스레드 환경에서는 이 보호 없이 안전하지 않다.
락이 만드는 다른 종류의 사고
그러나 락을 도입하면 새로운 문제가 생긴다. 두 스레드가 서로 상대방이 점유한 락을 기다리며 영원히 멈추는 데드락(Deadlock), 락 경쟁으로 인해 성능이 급격히 떨어지는 컨텐션(Contention), 우선순위가 높은 작업이 낮은 작업에 의해 지연되는 우선순위 역전(Priority inversion) 같은 부작용이 따라온다.
1997년 화성 탐사 로버 패스파인더(Pathfinder)가 화성에서 주기적으로 리셋되는 문제가 발생했다. 원인 분석 결과 우선순위 역전이었다. 우선순위가 낮은 기상 측정 스레드가 잡은 락을 우선순위가 높은 통신 스레드가 기다리는 사이, 우선순위 중간의 데이터 처리 스레드가 CPU를 독점하면서 통신 스레드의 데드라인이 지나가 시스템 워치독이 리셋을 발동시켰다. JPL 엔지니어들은 이 문제를 우선순위 상속 프로토콜로 해결했다. 화성에서 발견되어 화성에서 패치된 사례로 임베디드 시스템 교과서에 자주 등장한다.
메모리 모델이 만드는 또 다른 비결정성
락만 잘 걸면 끝나는 문제가 아니라는 사실은 현대 CPU의 메모리 모델 때문이다. CPU와 컴파일러는 성능을 위해 명령어 순서를 재배치한다. 단일 스레드에서는 보이는 결과가 달라지지 않도록 신중하게 재배치되지만, 다른 스레드의 관점에서는 명령어가 의도한 순서대로 실행되지 않은 것처럼 보일 수 있다.
이 문제를 다루기 위해 각 언어와 하드웨어 아키텍처는 메모리 일관성 모델을 정의한다. 자바 언어 명세 17장은 이 메모리 모델을 형식적으로 정의한 가장 잘 알려진 사례 중 하나다. happens-before 관계, volatile의 의미, synchronized 블록이 제공하는 보장이 어떤 종류의 명령어 재배치를 막는지 단계별로 명시한다. C++과 러스트도 자체 메모리 모델을 가지고 있고, 이걸 모르고 멀티스레드 코드를 작성하면 락을 걸어도 여전히 깨지는 코드를 만들 수 있다.
왜 테스트로는 잡히지 않는가
경쟁 상태 버그가 일반 테스트로 잡히지 않는 이유는 명확하다. 테스트는 같은 입력으로 같은 결과를 검증하지만 경쟁 상태는 같은 입력으로도 다른 결과를 낼 수 있다는 점에서 발생한다. 통계적으로 보면 이 문제는 작은 표본으로 결론을 내는 위험과 비슷한 구조다. 100번 돌려서 한 번도 실패하지 않았다고 해서 그 코드가 정확하다고 결론 내릴 수 없다.
이 부류의 문제를 잡기 위한 도구로 Thread Sanitizer, Helgrind, Java의 jcstress 같은 동시성 테스트 프레임워크가 발전해 왔다. 이 도구들은 코드를 실행하면서 메모리 접근 패턴을 추적하고, 동기화되지 않은 공유 자원 접근을 자동으로 탐지한다. 100번 중 한 번 발생하는 문제를 잡기 위해 100번 실행하는 게 아니라, 코드 한 번 실행에서 이론적으로 가능한 모든 경쟁 상태를 탐지하는 방식이다. 동시성 코드의 정확성을 보장하는 것은 단순한 디버깅이 아니라 별도의 공학 분야로 다루어진다.