경쟁 상태 (Race Condition)
한눈에 보기
여러 실행 흐름이 공유 자원에 동시 접근할 때 타이밍에 따라 결과가 달라지는 경쟁 상태를 다룬다. counter++가 깨지는 과정, 임계 구역 문제의 세 조건, 뮤텍스와 원자적 연산, 데이터베이스 락 전략까지 정리한다.
최종 업데이트 2026.09.17
경쟁 상태(Race Condition)는 둘 이상의 실행 흐름이 공유 자원에 동시에 접근할 때, 접근 순서나 타이밍에 따라 결과가 달라지는 상황을 말한다. 실행할 때마다 결과가 다르고 재현이 어려워 동시성 버그 가운데 가장 잡기 어려운 부류에 속한다. 문제의 근원은 공유 데이터를 건드리는 코드 구간, 즉 임계 구역(critical section)이 원자적으로 실행되지 않는 데 있다.
왜 counter++ 한 줄이 위험한가
고수준 언어의 한 줄이 기계 수준에서는 여러 단계로 쪼개진다는 것이 경쟁 상태의 출발점이다. counter++는 실제로 읽기, 더하기, 쓰기 세 단계다.
// 두 스레드가 counter++ 를 동시에 실행 (초기값 0)
스레드 A: load counter (0)
스레드 B: load counter (0) // A가 쓰기 전에 끼어듦
스레드 A: add 1 -> 1
스레드 B: add 1 -> 1
스레드 A: store counter = 1
스레드 B: store counter = 1 // 최종값 1, 기대값은 2
두 번 증가했는데 결과는 한 번만 반영되었다. 이런 끼어듦은 단일 코어에서는 문맥 교환 타이밍에 따라, 멀티코어에서는 물리적으로 동시에 일어난다. 갱신 손실(lost update) 외에도, 검사와 사용 사이에 상태가 바뀌는 검사 시점과 사용 시점 문제(TOCTOU, Time Of Check To Time Of Use)도 대표적인 경쟁 상태다. 파일 존재를 확인한 뒤 여는 사이에 파일이 바뀌는 보안 취약점이 전형적 예다.
임계 구역 문제와 해법의 세 가지 요건
경쟁 상태를 막으려면 임계 구역에 한 번에 하나의 흐름만 들어가게 해야 한다. 임계 구역 문제의 올바른 해법은 세 조건을 만족해야 한다.
- 상호 배제(Mutual Exclusion): 한 스레드가 임계 구역에 있으면 다른 스레드는 들어올 수 없다.
- 진행(Progress): 임계 구역이 비어 있다면 들어가려는 스레드의 진입을 불필요하게 미루지 않는다.
- 유한 대기(Bounded Waiting): 진입을 요청한 스레드는 유한한 횟수의 양보 후에는 반드시 들어갈 수 있어야 한다.
상호 배제만 지키고 진행이나 유한 대기를 어기면 각각 불필요한 지연과 기아(starvation)가 생기므로 셋 다 필요하다.
해결 도구들
| 도구 | 방식 | 적합한 상황 |
|---|---|---|
| 뮤텍스(Mutex) | 락을 잡은 스레드만 임계 구역 진입, 나머지는 잠들어 대기 | 일반적인 공유 데이터 보호 |
| 스핀락(Spinlock) | 락이 풀릴 때까지 반복 확인하며 대기 | 임계 구역이 극히 짧은 커널이나 멀티코어 저수준 코드 |
| 세마포어(Semaphore) | 카운터로 동시 진입 수를 제한 | 자원 개수 관리, 스레드 간 순서 신호 |
| 원자적 연산(Atomic Operation) | 하드웨어의 CAS(Compare-And-Swap) 등으로 락 없이 갱신 | 카운터 증가 같은 단순 갱신, 락프리 자료구조 |
| 불변과 격리 설계 | 공유 자체를 없애거나 데이터를 읽기 전용으로 유지 | 메시지 전달 모델, 함수형 스타일, 스레드별 데이터 |
가장 좋은 해법은 공유 가변 상태 자체를 줄이는 것이다. 경쟁 상태는 공유, 가변, 동시 접근 세 조건이 모두 성립할 때만 생기므로 하나만 제거해도 사라진다. 고(Go)의 채널로 데이터를 소유권째 넘기는 방식, 스레드마다 독립 복사본을 두는 스레드 로컬(thread local) 저장소가 공유 자체를 없애는 대표적 설계다.
데이터베이스와 실무에서의 경쟁 상태
경쟁 상태는 스레드 프로그래밍만의 문제가 아니다. 웹 서비스에서 재고 확인 후 차감하는 두 요청이 겹치면 재고가 음수가 되는 문제는 전형적인 갱신 손실이며, 해결책도 같은 원리다. 데이터베이스에서는 select ... for update 같은 비관적 락(pessimistic lock), 버전 컬럼으로 충돌을 감지하는 낙관적 락(optimistic lock), 또는 update stock set qty = qty - 1 where qty > 0 처럼 검사와 갱신을 한 문장의 원자적 연산으로 합치는 방법을 쓴다. 분산 환경에서는 레디스(Redis)나 주키퍼(ZooKeeper) 기반의 분산 락으로 범위를 넓힌다. 결국 어느 층위든 검사와 변경을 원자적 단위로 묶는다는 동일한 사고가 적용된다.
탐지와 예방의 습관
경쟁 상태는 테스트를 통과하고도 운영에서 터지는 일이 많아 도구와 습관이 중요하다. 코드 리뷰에서는 공유 변수 접근마다 어떤 락이 보호하는지 물어야 하고, 락 없이 접근하는 경로가 하나라도 있으면 보호는 무너진다. 동적 분석 도구로는 스레드새니타이저(ThreadSanitizer)가 실행 중의 데이터 경쟁(data race)을 탐지해 주고, 자바(Java)는 java.util.concurrent의 검증된 자료구조를, 러스트(Rust)는 소유권 시스템으로 데이터 경쟁을 컴파일 시점에 차단하는 접근을 제공한다. 데이터 경쟁은 동기화 없이 한쪽이 쓰는 동시 접근이라는 좁은 개념이고, 경쟁 상태는 타이밍에 따라 결과가 달라지는 논리 오류 전반을 가리키는 넓은 개념이라는 구분도 알아 두면 좋다.
면접에서 이렇게 물어본다
- counter++ 연산에서 경쟁 상태가 발생하는 과정을 기계 수준 단계로 설명해 보라.
- 임계 구역 문제 해법이 만족해야 할 세 가지 조건을 말해 보라.
- 재고 차감 API에서 동시 요청으로 재고가 음수가 되는 문제를 어떻게 해결하겠는가.
- 낙관적 락과 비관적 락의 차이와 각각 유리한 상황을 설명해 보라.
- 데이터 경쟁과 경쟁 상태는 어떻게 다른가.
운영체제에서 같이 읽기
- CPU 스케줄링 (CPU Scheduling)운영체제가 준비 상태의 여러 프로세스 중 다음에 CPU를 쓸 프로세스를 고르는 CPU 스케줄링을 다룬다. 선점과 비선점, FCFS부터 라운드 로빈과 MLFQ까지 알고리즘 비교, 리눅스 CFS와 EEVDF까지 정리한다.
- 교착 상태 (Deadlock)둘 이상의 프로세스가 서로가 점유한 자원을 기다리며 영원히 멈추는 교착 상태를 다룬다. 네 가지 필요조건, 예방과 회피와 탐지 회복 전략, 기아와 라이브락의 구분, 데이터베이스 데드락 실무 사례까지 정리한다.
- 파일 시스템 (File System)저장 장치 위에 파일과 디렉터리라는 추상화를 만드는 파일 시스템을 다룬다. 슈퍼블록과 아이노드 구조, 연속과 연결과 색인 할당 비교, 저널링과 쓰기 시 복사, VFS 계층까지 정리한다.
- 인터럽트 (Interrupt)CPU가 하던 일을 멈추고 긴급한 사건을 먼저 처리하게 하는 인터럽트를 다룬다. 폴링과의 비교, 하드웨어 인터럽트와 예외와 트랩의 구분, 처리 절차와 상반부 하반부, 타이머 인터럽트와 멀티태스킹의 관계를 정리한다.
이 개념, 내 코드에서는 어떻게 물어볼까요
데브핏(DevFit)에 채용공고 URL을 붙여 넣고 레포지토리를 연결하면, 내가 쓴 커밋에서 예상 질문과 어필 포인트를 만듭니다. 하루 10회까지 무료입니다. 합불 판정이나 지원자 랭킹은 만들지 않고, 최종 판단은 사람이 합니다.
공고 넣고 시작하기