CS

Race condition이란? 동시성 버그와 임계 구역 이해하기

Enchantée 2026. 6. 23. 22:01
728x90
반응형

멀티스레드와 운영체제를 공부하다 보면 race condition이라는 표현을 자주 만나게 됩니다.

한국어로는 보통 경쟁 상태라고 부릅니다.

말 그대로 여러 실행 흐름이 같은 데이터나 자원을 두고 경쟁하면서, 실행 순서에 따라 결과가 달라지는 문제입니다.

프로세스와 스레드, 데드락을 이해했다면 그다음으로 반드시 짚고 넘어가야 하는 동시성 개념입니다.

 

Race condition은 여러 작업이 공유 자원에 동시에 접근하고, 그 접근 순서가 결과를 바꿀 때 발생하는 동시성 버그입니다.

 

이번 글에서는 race condition이 무엇인지, 왜 재현하기 어려운지, C++ 코드와 DB 예시에서는 어떻게 나타나는지, 실무에서는 어떤 기준으로 예방해야 하는지 정리해보겠습니다.

 


1. Race condition은 무엇인가?

Race condition은 둘 이상의 실행 흐름이 같은 자원에 접근할 때 발생할 수 있습니다.

여기서 실행 흐름은 스레드, 프로세스, 비동기 작업, DB 트랜잭션, 분산 시스템의 여러 서버 인스턴스가 될 수 있습니다.

공유 자원은 메모리 변수, 파일, DB row, 캐시 값, 재고 수량, 계좌 잔액, 작업 큐 상태처럼 여러 작업이 함께 읽거나 수정하는 대상입니다.

핵심은 실행 순서가 달라지면 결과도 달라진다는 점입니다.

요소 설명
공유 자원 여러 작업이 함께 접근하는 데이터 또는 외부 자원
동시 접근 둘 이상의 작업이 비슷한 시점에 같은 자원에 접근
순서 의존성 작업이 실행되는 순서에 따라 결과가 달라짐
동기화 누락 락, 트랜잭션, 원자 연산 같은 보호 장치가 부족함

예를 들어 두 스레드가 동시에 count 값을 1씩 증가시킨다고 생각해보겠습니다.

겉으로 보면 두 번 증가하므로 최종 값이 2가 되어야 할 것 같습니다.

하지만 두 스레드가 같은 값을 읽고 각각 1을 더한 뒤 다시 저장하면 최종 값이 1이 될 수 있습니다.

이것이 race condition의 전형적인 예시입니다.

 


2. 왜 중요한가?

Race condition은 단순한 이론 문제가 아닙니다.

실무에서는 데이터 손상, 중복 결제, 재고 수량 오류, 사용자 권한 오류, 로그 누락, 캐시 불일치 같은 문제로 이어질 수 있습니다.

더 까다로운 점은 항상 같은 방식으로 재현되지 않는다는 것입니다.

스레드 스케줄링, CPU 코어 수, 네트워크 지연, DB 락 타이밍, 요청 순서에 따라 문제가 나타나기도 하고 사라지기도 합니다.

  1. 로컬에서는 잘 동작하지만 운영 환경에서만 발생할 수 있다.
  2. 로그를 추가하면 타이밍이 바뀌어 문제가 사라진 것처럼 보일 수 있다.
  3. 트래픽이 적을 때는 드러나지 않다가 피크 시간에 발생할 수 있다.
  4. 테스트를 한 번 통과했다고 안전하다고 볼 수 없다.

그래서 race condition은 증상만 보고 디버깅하기보다, 공유 자원과 동기화 경계를 기준으로 분석해야 합니다.

면접에서도 단순히 “여러 스레드가 동시에 접근하는 문제”라고 끝내기보다, 읽기-수정-쓰기 흐름과 임계 구역을 함께 설명하는 것이 좋습니다.

 


3. 핵심 개념: read-modify-write

Race condition을 이해할 때 가장 중요한 흐름은 read-modify-write입니다.

예를 들어 count++이라는 코드는 한 줄처럼 보이지만, 실제 의미는 보통 다음 세 단계로 나눌 수 있습니다.

  1. 현재 count 값을 읽는다.
  2. 읽은 값에 1을 더한다.
  3. 계산한 값을 다시 count에 저장한다.

이 세 단계가 하나의 끊기지 않는 작업처럼 보장되지 않으면 문제가 생길 수 있습니다.

두 스레드가 동시에 같은 값을 읽으면, 각각 증가 연산을 수행하더라도 하나의 증가가 사라질 수 있습니다.

단계 Thread A Thread B count
1 count 읽기 0
2 count 읽기 0
3 0 + 1 저장 1
4 0 + 1 저장 1

두 스레드가 각각 증가시켰지만 최종 결과는 2가 아니라 1입니다.

이런 누락을 lost update라고도 부릅니다.

실무에서는 카운터뿐 아니라 재고 차감, 포인트 사용, 쿠폰 발급, 잔액 변경에서도 같은 패턴이 나타날 수 있습니다.

 


4. 그림으로 이해하기

Race condition의 흐름을 단순화하면 다음처럼 볼 수 있습니다.

- Race condition timeline

초기 count = 0

Thread A                 Thread B
   |                        |
   | read count = 0         |
   |                        | read count = 0
   | count + 1 = 1          |
   |                        | count + 1 = 1
   | write count = 1        |
   |                        | write count = 1
   v                        v

최종 count = 1
기대한 값 = 2

 

문제는 두 스레드가 모두 오래된 값을 기준으로 계산한다는 점입니다.

각 스레드 입장에서는 정상적으로 1을 더했지만, 전체 시스템 관점에서는 한 번의 증가가 사라졌습니다.

그래서 공유 데이터를 수정하는 코드는 “한 줄로 보이는가”보다 “원자적으로 보호되는가”를 봐야 합니다.

 


5. C++ 코드로 보는 race condition

다음 코드는 두 스레드가 같은 counter를 동시에 증가시키는 예시입니다.

의도는 200000이지만, counter++이 동기화 없이 실행되므로 안전하지 않습니다.

#include <iostream>
#include <thread>

int counter = 0;

void increase() {
    for (int i = 0; i < 100000; ++i) {
        ++counter;
    }
}

int main() {
    std::thread t1(increase);
    std::thread t2(increase);

    t1.join();
    t2.join();

    std::cout << counter << '\n';
}

 

예상 결과

199811 // 1번째 실행
197113 // 2번째 실행
.
.
.
196715 // n번째 실행

C++에서는 여러 스레드가 같은 일반 변수에 동시에 접근하고, 그중 하나 이상이 쓰기 작업이면 data race가 발생할 수 있습니다.

Data race는 C++ 표준 관점에서 정의되지 않은 동작입니다.

따라서 이 코드는 단순히 “가끔 값이 틀리는 코드”가 아니라, 언어 차원에서 안전하지 않은 코드입니다.

 


6. mutex로 임계 구역 보호하기

공유 데이터를 수정하는 구간을 임계 구역이라고 부릅니다.

임계 구역은 한 번에 하나의 스레드만 들어가도록 보호해야 합니다.

C++에서는 std::mutex와 std::lock_guard를 사용할 수 있습니다.

#include <iostream>
#include <mutex>
#include <thread>

int counter = 0;
std::mutex counterMutex;

void increase() {
    for (int i = 0; i < 100000; ++i) {
        std::lock_guard<std::mutex> lock(counterMutex);
        ++counter;
    }
}

int main() {
    std::thread t1(increase);
    std::thread t2(increase);

    t1.join();
    t2.join();

    std::cout << counter << '\n';
}

 

실행 결과

200000

std::lock_guard는 생성될 때 mutex를 잠그고, 스코프를 벗어나면 자동으로 해제합니다.

이 방식은 RAII를 이용하므로 예외가 발생하거나 중간에 return이 생겨도 락 해제 누락을 줄일 수 있습니다.

다만 mutex는 보호 범위가 너무 넓으면 성능 저하와 대기 시간이 커질 수 있습니다.

그래서 실무에서는 어떤 데이터를 어떤 락이 보호하는지 명확히 정하는 것이 중요합니다.

 


7. atomic으로 해결할 수 있는 경우

단순 카운터처럼 하나의 값에 대한 원자 연산이면 std::atomic을 사용할 수 있습니다.

atomic은 특정 연산이 중간에 끼어들 수 없는 단위로 수행되도록 도와줍니다.

#include <atomic>
#include <iostream>
#include <thread>

std::atomic<int> counter{0};

void increase() {
    for (int i = 0; i < 100000; ++i) {
        ++counter;
    }
}

int main() {
    std::thread t1(increase);
    std::thread t2(increase);

    t1.join();
    t2.join();

    std::cout << counter << '\n';
}

 

실행 결과

200000

이 예제에서는 counter 증가가 원자적으로 처리되므로 lost update가 발생하지 않습니다.

하지만 atomic이 모든 동시성 문제를 해결하는 것은 아닙니다.

여러 변수 사이의 불변식을 함께 보호해야 하거나, 복잡한 상태 전이가 필요하다면 mutex나 더 높은 수준의 동기화 구조가 필요할 수 있습니다.

예를 들어 재고 수량과 주문 상태를 함께 바꿔야 한다면 단일 atomic 변수만으로는 충분하지 않을 수 있습니다.

 


8. DB에서도 race condition이 발생한다

Race condition은 메모리 안의 변수에서만 생기지 않습니다.

DB 트랜잭션에서도 같은 문제가 발생할 수 있습니다.

예를 들어 재고가 1개 남은 상품에 대해 두 요청이 동시에 주문을 생성한다고 생각해보겠습니다.

- 잘못된 재고 차감 흐름

-- Request A
SELECT stock FROM product WHERE id = 1; -- 1
UPDATE product SET stock = stock - 1 WHERE id = 1;

-- Request B
SELECT stock FROM product WHERE id = 1; -- 1
UPDATE product SET stock = stock - 1 WHERE id = 1;

 

두 요청이 모두 stock이 1이라고 읽으면 둘 다 주문 가능하다고 판단할 수 있습니다.

그 결과 재고가 음수가 되거나, 실제 재고보다 많은 주문이 생성될 수 있습니다.

이런 문제를 막으려면 DB 트랜잭션, row lock, 조건부 update, unique constraint, 낙관적 락 같은 방법을 상황에 맞게 사용해야 합니다.

UPDATE product
SET stock = stock - 1
WHERE id = 1 AND stock > 0;

 

이 쿼리는 stock이 0보다 클 때만 차감합니다.

실무에서는 이 update가 실제로 몇 row를 변경했는지 확인하고, 0 row라면 재고 부족으로 처리하는 식으로 설계합니다.

즉, 애플리케이션에서 먼저 읽고 판단하는 방식보다 DB가 원자적으로 조건을 검사하고 변경하게 만드는 편이 안전할 수 있습니다.

 


9. Race condition과 deadlock은 다르다

Race condition과 deadlock은 모두 동시성 문제지만 성격이 다릅니다.

Race condition은 작업들이 동시에 진행되면서 실행 순서에 따라 잘못된 결과가 나오는 문제입니다.

Deadlock은 작업들이 서로를 기다리다가 아무도 진행하지 못하는 문제입니다.

구분 Race condition Deadlock
핵심 문제 실행 순서에 따라 결과가 달라짐 서로 기다려 진행이 멈춤
대표 증상 잘못된 값, 중복 처리, 누락 응답 없음, 무한 대기, 트랜잭션 실패
주요 원인 공유 자원 보호 부족 락 획득 순서와 자원 대기 구조 문제
대응 방식 임계 구역 보호, 원자 연산, 트랜잭션 락 순서 통일, 타임아웃, 데드락 감지

흥미로운 점은 race condition을 막기 위해 락을 추가하다가, 락 설계가 잘못되면 deadlock이 생길 수 있다는 것입니다.

그래서 동시성 코드는 단순히 락을 많이 거는 것이 아니라, 공유 자원의 범위와 락 획득 순서를 함께 설계해야 합니다.

 


10. 실무에서는 어떻게 볼까?

실무에서 race condition을 줄이려면 먼저 공유 상태를 줄이는 것이 좋습니다.

공유하지 않아도 되는 데이터라면 스레드별 로컬 데이터로 만들고, 요청별 상태는 가능하면 독립적으로 유지합니다.

공유가 필요하다면 다음 질문을 기준으로 설계를 점검할 수 있습니다.

  1. 이 데이터는 여러 스레드나 요청에서 동시에 접근되는가?
  2. 읽기만 하는가, 수정도 하는가?
  3. 읽기-수정-쓰기 작업이 하나의 원자적 단위로 보호되는가?
  4. 락이 보호하는 데이터 범위가 코드에 명확하게 드러나는가?
  5. DB에서는 조건 검사와 변경이 같은 트랜잭션 또는 단일 쿼리 안에서 처리되는가?

또한 테스트에서는 단순 단위 테스트만으로 충분하지 않을 수 있습니다.

동시 요청 테스트, 반복 실행 테스트, 트래픽이 높은 상황의 부하 테스트, DB 격리 수준 확인이 필요할 수 있습니다.

로그를 볼 때도 “어떤 요청이 먼저 왔는가”만 보지 말고, 실제로 어떤 순서로 읽고 썼는지를 추적해야 합니다.

 


11. 면접 질문 예시

11-1. Race condition이 무엇인가요?

여러 실행 흐름이 같은 공유 자원에 접근할 때, 실행 순서에 따라 결과가 달라지는 문제입니다.

대표적으로 여러 스레드가 같은 변수를 동기화 없이 증가시키면 일부 증가가 누락될 수 있습니다.

 

11-2. Race condition과 data race는 같은 말인가요?

완전히 같은 말은 아닙니다.

Race condition은 실행 순서에 따라 잘못된 결과가 나오는 넓은 개념입니다.

Data race는 C++ 같은 언어에서 동기화 없이 같은 메모리에 동시에 접근하고, 그중 하나 이상이 쓰기인 경우처럼 더 구체적인 메모리 접근 문제를 말합니다.

 

11-3. Race condition을 어떻게 예방할 수 있나요?

공유 상태를 줄이고, 공유 자원을 수정하는 임계 구역을 mutex, atomic, 트랜잭션, 조건부 update 같은 방법으로 보호해야 합니다.

또한 락을 사용할 때는 보호 범위와 획득 순서를 명확히 해서 deadlock 같은 다른 동시성 문제도 함께 피해야 합니다.

 


12. 정리

Race condition은 동시성 코드를 이해할 때 반드시 알아야 하는 핵심 개념입니다.

  1. Race condition은 공유 자원 접근 순서에 따라 결과가 달라지는 문제다.
  2. count++ 같은 한 줄 코드도 read-modify-write 단계로 나뉠 수 있다.
  3. C++ data race는 정의되지 않은 동작이므로 반드시 동기화가 필요하다.
  4. mutex, atomic, DB 트랜잭션, 조건부 update는 상황에 따라 다른 해결책이다.
  5. Race condition을 막으려면 공유 상태, 임계 구역, 원자성 경계를 명확히 설계해야 한다.

결국 race condition을 잘 다룬다는 것은 락 문법을 외우는 것이 아니라, 어떤 상태가 누구에게 공유되고 어떤 단위로 보호되어야 하는지 판단하는 일입니다.

 


728x90
반응형