디버깅 실전 · 순서가 보장되지 않아 생기는 것 · 이론
숫자가 가끔 안 맞는 이유는 순서다
한 줄 요약
경쟁 조건은 "가끔 일어나는 일" 이 아니라 출발선을 맞추면 언제나 일어나는 일이고, 재현할 수 있게 되는 순간 고칠 수 있는 결함이 된다.
왜 이게 필요했나
"어제 정산이 3건 모자랐습니다." 로그에는 오류가 없다. 코드를 읽으면 멀쩡하다. 한 번 돌려 보면 맞는다. 이런 신고의 상당수가 경쟁 조건이고, 공통점이 하나 있다 — 혼자 돌리면 언제나 맞는다.
가장 흔한 모양은 잃어버린 갱신(lost update)이다. 프로세스가 값을 읽고, 그 값으로 무언가 하고, 하나 올려 쓴다. 그 사이에 다른 프로세스가 같은 값을 읽어 갔다면, 나중에 쓴 쪽이 앞의 결과를 덮는다. 두 건이 들어왔는데 하나만 남는다. 열 건이 완전히 겹쳐 들어오면 아홉 건이 사라진다.
여기서 조사가 막히는 이유는 대개 재현이다. 두 프로세스가 정확히 같은 순간에 그 구간에 들어가야 하는데, 손으로 두 터미널을 번갈아 치면 그런 일은 잘 일어나지 않는다. 그래서 출발선을 만든다. 프로세스를 먼저 다 띄워 놓고 전부 대기 상태가 된 뒤 출발 표식을 하나 만들면, 그 순간부터 경쟁은 확률이 아니라 재현 가능한 사실이 된다.
어떻게 동작하나
막는 방법은 문제의 모양에 따라 다르다. 세 가지를 구별하면 대부분이 정리된다.
1. 읽고 고쳐 쓰는 구간 — 잠금으로 묶는다. 읽기와 쓰기를 한 덩어리로 만들어야 한다. 파이썬에서는 [fcntl 모듈](https://docs.python.org/3/library/fcntl.html)의 flock 으로 파일에 배타 잠금을 건다. 이 잠금은 권고(advisory) 다 — 잠금을 잡지 않고 그냥 여는 프로그램은 아무 방해 없이 쓴다. 그래서 같은 파일을 만지는 모든 프로그램이 같은 약속을 지켜야 한다.
여기에 아주 자주 걸리는 함정이 있다. open(path, "w") 는 여는 순간 파일을 비운다. 그다음 줄에서 잠금을 잡아도 이미 늦었다. 읽고 쓸 자리는 "r+", 덧붙일 자리는 "a" 로 열고 잠금을 잡는다.
2. 확인하고 나서 하는 일 — 확인과 실행을 한 번에 한다. "파일이 없으면 만든다", "리스가 비어 있으면 잡는다" 는 사이에 틈이 있다. 이 틈을 없애는 방법은 만들기 자체를 판정으로 쓰는 것이다. [POSIX 의 open](https://pubs.opengroup.org/onlinepubs/9699919799/functions/open.html)은 O_CREAT 와 O_EXCL 을 함께 주면 파일이 이미 있을 때 실패하며, 그 확인과 생성이 원자적이라고 규정한다. 파이썬에서는 os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY) 이고, 실패는 FileExistsError 로 온다. 진 쪽이 예외를 받는 것이 정상 동작이다.
3. 통째로 다시 쓰는 파일 — 원자적으로 바꿔 끼운다. 설정 파일이나 상태 스냅숏을 쓰는 동안 다른 쪽이 읽으면 반쯤 쓰인 내용을 본다. JSON 이면 파싱 오류가 나고, 그 오류는 읽는 쪽 코드의 버그처럼 보인다. 해법은 같은 디렉터리의 임시 파일에 전부 쓴 뒤 이름을 바꾸는 것이다. [POSIX 의 rename](https://pubs.opengroup.org/onlinepubs/9699919799/functions/rename.html)은 새 이름이 이미 있어도 그 대체가 원자적이며, 그 사이 어떤 순간에도 이름이 사라지지 않는다고 정한다. 파이썬에서는 os.replace 다. 임시 파일이 같은 파일 시스템에 있어야 한다는 조건을 잊으면 조용히 복사가 되어 원자성이 깨진다.
증상 틈이 있는 자리 막는 방법합계가 모자란다 읽기와 쓰기 사이 배타 잠금으로 한 덩어리둘 다 자기가 주인이라 한다 확인과 생성 사이 O_CREAT 와 O_EXCL읽는 쪽이 파싱에 실패한다 쓰는 도중의 파일 임시 파일에 쓰고 rename파일이 비어 있다 잠금 전에 "w" 로 열기 "r+" 또는 "a" 로 열기현장에서 만나는 모습
첫째, 재현을 포기하고 코드를 노려본다. 눈으로 찾는 경쟁 조건은 놓치기 쉽고, 찾았다고 믿어도 그게 그 증상의 원인인지 알 수 없다. 출발선을 만들어 증상을 손에 쥔 뒤 고치면 고쳤다는 것도 같은 방법으로 증명된다.
둘째, 잠금을 걸었는데 그대로다. 흔한 원인 둘은 잠금의 범위와 대상이다. 읽기는 잠금 밖에서 하고 쓰기만 잠금 안에서 하면 아무것도 막지 못한다. 서로 다른 파일을 잠그거나, 프로세스마다 다른 임시 파일을 잠그는 경우도 있다.
셋째, 한쪽만 예의를 지킨다. 권고 잠금은 모두가 지켜야 뜻이 있다. 운영 스크립트 하나가 > 로 덮어쓰면 그날의 잠금은 전부 무의미해진다. 그래서 잠금 규약은 코드가 아니라 문서와 검토로 지켜진다.
넷째, 코어가 하나면 안 난다고 믿는다. 프로세스는 언제든 선점되므로 코어 수와 상관없이 읽기와 쓰기 사이에 다른 프로세스가 끼어들 수 있다. 실제로 2코어짜리 파드에서도 여덟 프로세스를 같은 순간에 출발시키면 갱신은 하나만 남는다.
실무에서 진짜 중요한 것
- 출발선을 만들어 재현한다. 재현되지 않는 경쟁은 고쳤는지도 알 수 없다.
- 잠금은 읽기부터 감싼다. 쓰기만 감싼 잠금은 장식이다.
- 확인 후 사용은 한 번의 호출로 바꾼다. O_EXCL 이 그 자리를 위해 있다.
- 통째로 쓰는 파일은 rename 으로 갈아 끼운다. 읽는 쪽은 옛 파일이나 새 파일만 봐야 한다.
다음 실습에서 할 것
출발선을 맞추는 하네스를 받아, 잠금 없는 갱신 여덟 건이 하나만 남는 것을 눈으로 확인한다. 같은 일을 배타 잠금 안에서 해 전부 남는 것을 보고, 확인 후 사용의 틈을 O_EXCL 로 닫아 승자가 언제나 하나가 되게 한다. 통째로 쓰는 문서를 원자적으로 갈아 끼워 읽는 쪽의 파싱 오류를 0 으로 만들고, 잠금 전에 파일을 비우는 실수가 자료를 어떻게 날리는지 확인한 뒤 증거를 한 장으로 보고한다.