
경쟁 상태(Race Condition): 타이밍에 따른 버그
코드는 완벽한데 가끔씩 돈이 사라집니다. 타이밍 이슈가 만드는 최악의 버그.

코드는 완벽한데 가끔씩 돈이 사라집니다. 타이밍 이슈가 만드는 최악의 버그.
내 서버는 왜 걸핏하면 뻗을까? OS가 한정된 메모리를 쪼개 쓰는 처절한 사투. 단편화(Fragmentation)와의 전쟁.

미로를 탈출하는 두 가지 방법. 넓게 퍼져나갈 것인가(BFS), 한 우물만 팔 것인가(DFS). 최단 경로는 누가 찾을까?

이름부터 빠릅니다. 피벗(Pivot)을 기준으로 나누고 또 나누는 분할 정복 알고리즘. 왜 최악엔 느린데도 가장 많이 쓰일까요?

매번 3-Way Handshake 하느라 지쳤나요? 한 번 맺은 인연(TCP 연결)을 소중히 유지하는 법. HTTP 최적화의 기본.

통장에 100만원이 있었다. ATM A에서 10만원 입금, ATM B에서도 10만원 입금을 동시에 했다. 상식적으로 120만원이 되어야 한다. 그런데 잔액을 확인했더니 110만원이다. 10만원이 증발했다.
은행 직원을 불렀다. "시스템 오류입니다", "다시 확인해보겠습니다", "절대 그럴 리 없는데요"라는 말만 돌아왔다. 코드를 열어봐도 문제가 없다. 로그를 봐도 두 입금 모두 정상 처리되었다. 그런데 돈은 사라졌다.
이런 버그는 재현이 안 된다는 게 최악이다. 테스트에서는 잘 되고, 100번 중 1번만 발생한다. 타이밍이 딱 맞아떨어질 때만 터지는 버그다. 이게 바로 경쟁 상태(Race Condition)다.
나는 이 문제를 처음 겪었을 때 완전히 멘붕이었다. 코드는 명백히 맞다. balance = balance + 10; 이게 뭐가 문제인가? 더하기가 왜 실패하는가?
답은 이 한 줄이 한 번에 실행되지 않는다는 데 있다. CPU 입장에서 이 코드는 실제로 3단계로 쪼개진다.
// 겉보기: 한 줄
balance = balance + 10;
// 실제 CPU 동작 (기계어 레벨)
// 1. READ: 메모리에서 balance 값을 읽어 레지스터에 저장 (100)
// 2. MODIFY: 레지스터 값에 10을 더함 (110)
// 3. WRITE: 레지스터 값을 메모리에 다시 쓴다 (110)
이 3단계를 Read-Modify-Write 패턴이라고 부른다. 문제는 두 스레드(또는 프로세스)가 동시에 이 작업을 하면, 실행 순서가 꼬일 수 있다는 것이다.
시간 스레드 A 스레드 B
1 READ 100 -
2 - READ 100 (A가 아직 WRITE 안 함!)
3 MODIFY 110 -
4 WRITE 110 -
5 - MODIFY 110 (100 + 10)
6 - WRITE 110 (A의 결과를 덮어씀!)
두 스레드 모두 balance = 100을 읽었다. 각자 10을 더해서 110을 만들었다. 그리고 110을 메모리에 썼다. 결과는 110이다. 20이 더해져야 하는데 10만 더해졌다. 나머지 10은 증발했다.
이게 은행 계좌였다면 돈이 사라진 것이고, 재고 관리 시스템이었다면 재고가 꼬인 것이다. 코드 자체는 완벽하지만, 실행 타이밍에 따라 결과가 달라진다. 이게 Race Condition의 본질이다.
이 개념이 확 이해된 건 누군가 화장실에 비유해줬을 때다.
화장실이 하나 있다. 문에 잠금장치가 없다. A와 B가 동시에 들어가려고 한다. 둘 다 문을 열어본다(READ). 둘 다 "비어있네?"라고 생각한다. 둘 다 들어간다(WRITE). 충돌한다. 재앙이다.
해결책은 간단하다. 잠금장치를 단다. A가 들어가서 문을 잠근다. B가 오면 잠겨있으니 기다린다. A가 나와서 잠금을 푼다. B가 들어간다. 이게 바로 뮤텍스(Mutex, Mutual Exclusion)다.
코드로 치면 이렇게 된다.
// 문제가 있는 코드
async function deposit(amount) {
const current = await getBalance(); // READ
const updated = current + amount; // MODIFY
await setBalance(updated); // WRITE
}
// 두 개의 deposit이 동시에 실행되면 Race Condition 발생
Read-Modify-Write가 일어나는 코드 구간을 임계 구역(Critical Section)이라고 한다. 이 구간은 한 번에 한 스레드만 들어갈 수 있어야 한다. 이를 보장하는 게 상호 배제(Mutual Exclusion)다.
JavaScript에서는 Lock을 직접 쓸 수 없지만, 개념을 보여주면 이렇다.
// Mutex를 사용한 보호
const lock = new Mutex();
async function deposit(amount) {
await lock.acquire(); // 잠금 획득 (다른 스레드는 여기서 대기)
// Critical Section 시작
const current = await getBalance();
const updated = current + amount;
await setBalance(updated);
// Critical Section 끝
lock.release(); // 잠금 해제
}
이렇게 하면 두 개의 deposit이 동시에 실행되어도 한 번에 하나만 Critical Section에 들어간다. 먼저 들어간 놈이 끝날 때까지 나머지는 기다린다.
또 다른 방법은 원자적 연산(Atomic Operation)을 쓰는 것이다. "원자적"이라는 말은 "더 이상 쪼갤 수 없다"는 뜻이다. 즉, Read-Modify-Write를 한 번에 처리해서 중간에 끼어들 수 없게 만드는 것이다.
// 데이터베이스에서의 원자적 업데이트
await db.query('UPDATE accounts SET balance = balance + ? WHERE id = ?', [amount, accountId]);
이 SQL은 데이터베이스가 원자적으로 처리한다. 중간에 다른 쿼리가 끼어들 수 없다. 또는 Compare-And-Swap(CAS) 같은 CPU 명령어를 쓰기도 한다.
// CAS 개념 (의사코드)
function compareAndSwap(addr, expected, newValue) {
// 이 전체가 원자적으로 실행됨
if (memory[addr] === expected) {
memory[addr] = newValue;
return true;
}
return false;
}
// 사용 예
while (true) {
const current = balance;
const updated = current + 10;
if (compareAndSwap(&balance, current, updated)) {
break; // 성공
}
// 실패하면 다시 시도 (다른 스레드가 먼저 변경한 것)
}
CAS는 "내가 읽은 값이 아직 그대로면 업데이트하고, 바뀌었으면 실패한다"는 연산이다. 이걸 루프로 돌려서 성공할 때까지 재시도한다. 이런 방식을 Lock-Free 프로그래밍이라고 한다.
Race Condition은 애플리케이션 코드에만 있는 게 아니다. 데이터베이스에도 있다.
Lost Update 문제: 두 트랜잭션이 같은 행을 읽고 각자 수정해서 쓰면, 하나의 수정이 사라진다. 위의 은행 계좌 예시와 똑같다.
Dirty Read 문제: 트랜잭션 A가 데이터를 수정했지만 커밋 안 했는데, 트랜잭션 B가 그걸 읽어버린다. A가 롤백하면 B는 존재하지 않는 데이터를 읽은 셈이 된다.
이런 문제를 막으려고 데이터베이스는 트랜잭션 격리 수준(Isolation Level)을 제공한다. SERIALIZABLE로 설정하면 트랜잭션들이 순차적으로 실행된 것처럼 동작한다. Race Condition이 완전히 막힌다. 대신 성능이 떨어진다.
또 다른 접근법은 낙관적 락(Optimistic Locking) vs 비관적 락(Pessimistic Locking)이다.
SELECT ... FOR UPDATE)-- 낙관적 락 예시
UPDATE accounts
SET balance = 110, version = version + 1
WHERE id = 123 AND version = 5;
-- 만약 version이 5가 아니면 (다른 트랜잭션이 먼저 바꿨으면) 0 rows affected
또 다른 Race Condition 변종으로 TOCTOU 버그가 있다. "체크하는 시점"과 "사용하는 시점" 사이에 상태가 바뀌는 버그다.
// TOCTOU 버그 예시
async function deleteFile(filePath) {
if (await fileExists(filePath)) { // Time Of Check
// ... 시간이 흐른다 ...
// 다른 프로세스가 파일을 지울 수도 있다!
await fs.unlink(filePath); // Time Of Use
}
}
체크할 때는 파일이 있었는데, 사용할 때는 없어져 있을 수 있다. 해결책은 체크와 사용을 원자적으로 만드는 것이다.
// 해결: 그냥 지우고 에러를 핸들링
try {
await fs.unlink(filePath);
} catch (err) {
if (err.code !== 'ENOENT') throw err; // 파일 없으면 무시
}
프론트엔드에서도 Race Condition이 있다. React의 useState에서 흔히 겪는다.
const [count, setCount] = useState(0);
// 버그가 있는 코드
function increment() {
setCount(count + 1); // 이전 값을 읽어서 +1
}
// 버튼을 빠르게 두 번 클릭하면?
// 두 increment가 거의 동시에 실행
// 둘 다 count = 0을 읽는다
// 둘 다 setCount(1)을 호출
// 결과: count = 1 (2가 아니라!)
해결책은 함수형 업데이트를 쓰는 것이다.
function increment() {
setCount(prev => prev + 1); // 이전 값을 함수로 받음
}
// React가 내부적으로 순서를 보장해줌
// 첫 번째 increment: prev = 0, 결과 = 1
// 두 번째 increment: prev = 1, 결과 = 2
Race Condition 버그는 정말 짜증난다. 재현이 안 되고, 로그에도 안 남고, 타이밍에 따라 가끔씩만 터진다. 내가 쓰는 방법들:
Thread Sanitizer: C/C++에서는 -fsanitize=thread 플래그를 켜면 컴파일러가 Race Condition을 자동으로 감지해준다.
로그에 타임스탬프와 스레드 ID 찍기: 어느 스레드가 언제 뭘 했는지 추적할 수 있다.
인위적으로 딜레이 넣기: 의심가는 부분에 sleep()을 넣어서 타이밍을 조작하면 버그가 재현되기도 한다.
불변 데이터 구조 사용: 데이터를 수정하지 않고 새로 만들면 Race Condition 자체가 발생하지 않는다. (함수형 프로그래밍의 핵심)
Race Condition은 코드 자체의 문제가 아니라 실행 환경의 문제다. 코드는 맞는데, 동시에 실행되면서 타이밍이 꼬이는 것이다. 이 개념을 이해하고 나면 멀티스레드, 비동기 프로그래밍, 분산 시스템에서 왜 락이 필요한지, 왜 트랜잭션이 필요한지, 왜 불변성이 중요한지가 모두 연결된다.
은행 계좌 예시처럼, 돈이 걸린 시스템에서는 이런 버그가 실제 손실로 이어진다. 그래서 임계 구역을 정확히 파악하고, 적절한 동기화 메커니즘(Mutex, Atomic, Transaction)을 써야 한다. 안 그러면 새벽 3시에 프로덕션 버그로 깨어나는 자신을 발견하게 될 것이다. 나처럼.