Race Condition: 공유 자원 접근 순서에 따라 결과가 달라지는 동시성 결함
Race Condition(경쟁 조건)은 둘 이상의 실행 흐름이 공유 자원에 동시에 접근할 때 실행 순서에 따라 결과가 달라지는 현상이다. 원인은 원자성이 보장되지 않는 연산을 여러 스레드나 프로세스가 동시에 수행하기 때문이며, 락(lock), 뮤텍스(mutex), 원자적 연산(atomic operation) 등으로 해결한다. 웹 백엔드의 재고 차감, 잔액
1. 개념
Race Condition(경쟁 조건)은 두 개 이상의 스레드(thread) 또는 프로세스(process)가 공유 자원(shared resource)에 동시에 접근하고, 그중 최소 하나가 쓰기(write) 작업을 수행할 때, 실행 순서(interleaving)에 따라 최종 결과가 달라지는 상황을 말한다.
핵심은 "동시에 실행된다"는 사실 자체가 아니라 "실행 순서가 보장되지 않는데 결과가 순서에 의존한다"는 점이다. 순서가 어떻게 되든 결과가 같다면 Race Condition이 아니다.
2. 왜 사용하는가
이 섹션은 역설적으로 "왜 발생하는가"로 바꿔서 다뤄야 한다. Race Condition은 의도적으로 사용하는 기법이 아니라 동시성 프로그래밍에서 피해야 할 결함이다.
발생하는 이유는 명확하다. 현대 시스템은 성능을 위해 멀티코어 병렬 처리, 멀티스레딩, 비동기 I/O를 사용한다. 여러 실행 흐름이 동시에 진행되는 것 자체는 처리량(throughput)을 높이기 위한 필수 설계지만, 이 과정에서 공유 상태(shared state)를 다루는 코드가 원자적(atomic)이지 않으면 경쟁 조건이 발생한다.
3. 동작 원리
가장 흔한 예시는 count += 1 같은 단순 증가 연산이다. 이 한 줄은 CPU 수준에서 세 단계로 나뉜다.
- 메모리에서 count 값을 레지스터로 읽기(read)
- 레지스터 값에 1 더하기(modify)
- 결과를 다시 메모리에 쓰기(write)
이를 읽기-수정-쓰기(Read-Modify-Write) 연산이라 부르며, 이 세 단계가 원자적으로 실행되지 않으면 문제가 생긴다.
초기값: count = 0
Thread A Thread B
read count (0)
read count (0)
modify (0 + 1 = 1)
modify (0 + 1 = 1)
write count = 1
write count = 1
결과: count = 1 (기대값 2)두 스레드가 각각 1씩 증가시켰지만 중간에 컨텍스트 스위칭(context switching)이 끼어들면서 한 번의 증가가 소실된다. 이런 코드 구간을 임계 구역(critical section)이라 부르며, 임계 구역에 대한 동시 접근을 막는 것을 상호 배제(mutual exclusion)라 한다.
4. 코드 예제
문제가 있는 코드 (Python, threading)
import threading
counter = 0
def increment():
global counter
for _ in range(100000):
counter += 1 # 원자적이지 않은 연산
threads = [threading.Thread(target=increment) for _ in range(2)]
for t in threads:
t.start()
for t in threads:
t.join()
print(f"결과: {counter}") # 기대값 200000, 실제로는 그보다 작게 나옴counter += 1은 바이트코드 수준에서 LOAD, ADD, STORE 세 단계로 분리되어 실행되며, CPython의 GIL(Global Interpreter Lock)이 바이트코드 단위 전환을 허용하기 때문에 두 스레드가 이 세 단계 사이에서 교차 실행될 수 있다. 실행할 때마다 결과가 달라진다.
Lock으로 해결
import threading
counter = 0
lock = threading.Lock()
def increment():
global counter
for _ in range(100000):
with lock: # 임계 구역 진입: 상호 배제 보장
counter += 1
threads = [threading.Thread(target=increment) for _ in range(2)]
for t in threads:
t.start()
for t in threads:
t.join()
print(f"결과: {counter}") # 항상 200000lock.acquire()와 lock.release() 사이의 코드는 한 번에 하나의 스레드만 실행할 수 있다. with lock: 블록은 이 두 호출을 자동으로 감싸준다.
원자적 연산으로 해결 (Python, multiprocessing)
from multiprocessing import Process, Value
import multiprocessing
def increment(counter, lock):
for _ in range(100000):
with lock:
counter.value += 1
if __name__ == "__main__":
counter = Value('i', 0) # 프로세스 간 공유 메모리
lock = multiprocessing.Lock()
processes = [Process(target=increment, args=(counter, lock)) for _ in range(2)]
for p in processes:
p.start()
for p in processes:
p.join()
print(f"결과: {counter.value}") # 항상 200000프로세스 간에는 GIL이 공유되지 않으므로 multiprocessing.Value와 Lock을 사용해 명시적으로 동기화해야 한다.
웹 백엔드에서의 예시 (TypeScript, Node.js 비동기 환경)
// 문제가 있는 코드: 재고 차감 로직
let stock = 10;
async function purchase(userId: string): Promise<boolean> {
const currentStock = await checkStockFromDB(); // 비동기 조회 (다른 요청이 끼어들 수 있는 지점)
if (currentStock > 0) {
await new Promise((resolve) => setTimeout(resolve, 10)); // I/O 대기 흉내
stock = currentStock - 1; // 이 시점에 다른 요청이 이미 같은 값을 읽었을 수 있음
return true;
}
return false;
}
async function checkStockFromDB(): Promise<number> {
return stock;
}
// 동시에 여러 요청이 들어오면 재고가 음수가 되거나 초과 판매될 수 있다
Promise.all([purchase("A"), purchase("B"), purchase("C")]).then(console.log);자바스크립트는 싱글 스레드지만 await 지점마다 다른 태스크가 이벤트 루프(event loop)에서 실행될 기회를 얻는다. 따라서 await 이전에 읽은 값이 await 이후 시점에는 이미 낡은 값(stale value)일 수 있다. 이것도 Race Condition의 일종이다.
-- 해결: DB 수준의 원자적 연산 사용
UPDATE products
SET stock = stock - 1
WHERE id = 'product-123' AND stock > 0
RETURNING stock;
-- 애플리케이션 코드에서 read-modify-write를 하지 않고
-- DB 엔진의 단일 UPDATE 문으로 원자성을 보장한다5. 시간 복잡도 또는 성능 특성
Race Condition 자체는 알고리즘이 아니므로 시간 복잡도 개념이 직접 적용되지 않는다. 다만 해결 수단인 락의 성능 특성은 실무에서 중요하다.
- 뮤텍스(mutex) 획득/해제: 경쟁이 없을 때는 수십 나노초(ns) 수준이지만, 경쟁이 발생하면 운영체제 커널 개입(스레드 대기 큐 진입, 컨텍스트 스위칭)으로 마이크로초(μs)~밀리초(ms) 단위까지 늘어날 수 있다.
- 스핀락(spinlock): 짧은 임계 구역에서는 컨텍스트 스위칭 비용을 피할 수 있어 유리하지만, 대기 시간이 길어지면 CPU를 낭비한다.
- 락 경합(lock contention)이 심해지면 병렬성으로 얻은 성능 이득이 사라지고 오히려 순차 실행보다 느려질 수 있다.
6. 실무 사용 사례
- 전자상거래 재고 차감: 동시 주문 시 재고가 음수가 되는 오버셀링(overselling) 버그
- 은행 계좌 잔액 갱신: 동시 이체 요청으로 잔액이 잘못 계산되는 문제
- 캐시 갱신: 여러 요청이 동시에 캐시 미스(cache miss)를 감지하고 동시에 DB를 조회해 캐시를 덮어쓰는 캐시 스탬피드(cache stampede)
- 파일 시스템: 여러 프로세스가 동시에 같은 파일에 쓰기 작업을 수행할 때 발생하는 데이터 손상
- 분산 시스템: 여러 서버 인스턴스가 동시에 같은 작업(예: 스케줄된 배치 작업)을 중복 실행하는 문제, 분산 락(distributed lock)으로 해결
7. 주의할 점
- 락을 남용하면 데드락(deadlock)이 발생할 수 있다. 여러 락을 획득하는 순서가 스레드마다 다르면 서로 상대방의 락을 기다리며 무한 대기하는 상황이 생긴다. 락 획득 순서를 항상 동일하게 유지해야 한다.
- 임계 구역은 최대한 짧게 유지해야 한다. 락 안에서 네트워크 호출이나 파일 I/O처럼 느린 작업을 수행하면 락 경합이 심해져 전체 시스템 처리량이 급격히 떨어진다.
- 테스트로 잡기 어렵다. Race Condition은 특정 타이밍에서만 재현되므로 로컬 환경이나 낮은 부하에서는 통과하고 운영 환경의 높은 동시성 상황에서만 드러나는 경우가 많다.
- 자바스크립트 싱글 스레드 환경에서도
await지점이 있으면 Race Condition이 발생할 수 있다는 점을 간과하기 쉽다. "싱글 스레드니까 안전하다"는 가정은 비동기 코드에서는 성립하지 않는다. - DB 트랜잭션의 격리 수준(isolation level)에 따라 동시성 이상 현상(dirty read, non-repeatable read, phantom read)이 다르게 나타난다.
SELECT ... FOR UPDATE나 낙관적 락(optimistic lock, 버전 컬럼 방식)을 상황에 맞게 선택해야 한다.
8. 핵심 정리
Race Condition은 공유 자원에 대한 비원자적 연산을 여러 실행 흐름이 동시에 수행할 때 실행 순서에 따라 결과가 달라지는 결함이다. 원인은 read-modify-write 같은 연산이 여러 단계로 쪼개지는 데 있고, 해결책은 락, 원자적 연산, DB의 단일 UPDATE 문처럼 임계 구역에 대한 상호 배제를 보장하는 것이다. 비동기 코드에서도 await 지점을 기준으로 동일한 문제가 발생할 수 있으므로, 싱글 스레드 환경이라는 이유만으로 안전하다고 가정해서는 안 된다.