우아한테크코스 테코톡

벨로의 Race Condition 이해하기

https://youtu.be/At4Gyezw8WA?si=6RD-BBpJk9cBBcYH

벨로의 Race Condition 이해하기


Race Condition이란 무엇인가

Race Condition은 공유 자원에 여러 스레드나 프로세스가 동시에 접근할 때, 실행 순서에 따라 결과가 달라지는 현상이다. 웹 서버처럼 동시에 많은 요청을 처리하는 환경에서는 데이터 중복, 누락, 잘못된 재고 차감, 포인트 중복 지급 같은 문제로 이어질 수 있다.

핵심 원인은 단순하다.

공유 자원에 대한 비원자적 병행 접근

즉, 하나로 처리되어야 하는 작업이 여러 단계로 나뉘어 있고, 그 사이에 다른 스레드가 끼어들 수 있을 때 Race Condition이 발생한다.


Race Condition이라는 이름은 어디서 왔을까

Race Condition이라는 용어는 원래 전기 공학에서 시작되었다. 두 신호가 거의 동시에 도착할 때, 어느 신호가 먼저 도착하느냐에 따라 회로의 출력이 달라지는 현상을 설명하기 위해 사용되었다.

이를 소프트웨어로 바꾸면 다음과 같다.

두 신호 → 두 스레드 또는 프로세스
회로 출력 → 공유 데이터의 최종 상태

즉, 여러 스레드가 같은 데이터를 동시에 다룰 때 실행 순서에 따라 결과가 달라지는 상황이 Race Condition이다.


Race Condition의 특징

Race Condition은 다음 특징을 가진다.

첫째, 같은 코드를 실행해도 매번 결과가 달라질 수 있다.

둘째, 여러 스레드나 프로세스가 같은 변수를 동시에 읽고 쓸 때 발생한다.

셋째, 작업의 실행 순서에 따라 결과가 달라진다.

이 특징 때문에 Race Condition은 발견하기 어렵다. 평소에는 잘 동작하다가 특정 타이밍에서만 문제가 발생하기 때문이다.


커피 예시로 이해하는 Race Condition

두 사람이 집에서 커피를 마신다고 가정해보자.

커피가 떨어지면 먼저 본 사람이 커피를 사오기로 했다.

대부분의 경우에는 문제가 없다. 하지만 어느 날 A가 커피가 없는 것을 보고 사러 나갔다. 그런데 거의 동시에 B도 커피가 없는 것을 보고 사러 나갔다.

결과적으로 둘 다 커피를 사오게 되고, 커피가 2개가 된다.

이 상황을 개발 관점으로 보면 다음과 같다.

커피 재고 = 공유 자원
A와 B = 스레드
커피 사오기 = 공유 자원 변경 작업

실제 서비스에서는 이런 문제가 다음처럼 나타날 수 있다.

쿠폰 중복 발급
포인트 중복 적립
재고 초과 차감
주문 중복 생성

메모를 붙여도 왜 해결되지 않을까

문제를 해결하기 위해 “커피 사러 감”이라는 메모를 붙인다고 해보자.

A가 커피가 없는 것을 보고 메모를 붙인다. 그러면 B는 메모를 보고 기다리면 된다.

겉보기에는 해결된 것 같다.

하지만 A와 B가 거의 동시에 커피가 없는 것을 확인하면 어떻게 될까?

A도 메모를 붙이러 가고, B도 메모를 붙이러 간다. 결국 둘 다 메모를 붙이고 둘 다 커피를 사러 갈 수 있다.

즉, 메모 자체도 공유 자원이 되어버린다.

문제는 여전히 남아 있다.

확인한다
메모를 쓴다
메모를 붙인다
커피를 산다

이 과정이 하나의 원자적 작업이 아니기 때문이다.


Race Condition의 근본 원인: 비원자적 연산

Race Condition의 본질은 원자적으로 처리되어야 할 작업이 여러 단계로 나뉘어 있다는 것이다.

컴퓨터 내부에서 단순한 증가 연산도 실제로는 여러 단계로 나뉜다.

Read   : 값을 읽는다
Modify : 값을 변경한다
Write  : 변경한 값을 다시 저장한다

예를 들어 count++ 같은 코드도 한 번에 실행되는 것처럼 보이지만 내부적으로는 다음 흐름이다.

현재 count 값을 읽는다
count 값을 1 증가시킨다
증가된 count 값을 다시 저장한다

여기서 두 스레드가 동시에 접근하면 문제가 생긴다.

Thread A: count = 0 읽음
Thread B: count = 0 읽음
Thread A: count + 1 저장
Thread B: count + 1 저장

결과는 2가 되어야 하지만 실제로는 1이 될 수 있다.


왜 단순한 코드에서도 Race Condition이 생길까

자바에서 단순한 후위 증가 연산자도 바이트코드 수준에서는 값을 읽고, 증가시키고, 다시 저장하는 과정으로 나뉜다. 발표에서도 Counter 클래스 예시를 통해 단순 증가 연산도 여러 단계로 분리되기 때문에 여러 스레드가 동시에 접근하면 Race Condition이 발생할 수 있다고 설명한다.

즉 문제는 코드가 복잡해서 생기는 것이 아니다.

나뉘면 안 되는 작업이
중간에 끊길 수 있기 때문에 발생한다

이것이 핵심이다.


웹 서버에서 Race Condition이 위험한 이유

웹 서버는 기본적으로 동시에 많은 요청을 처리한다.

예를 들어 사용자가 동시에 같은 상품을 주문한다고 해보자.

재고 확인
재고 차감
주문 생성

이 과정이 원자적으로 처리되지 않으면 다음 문제가 생길 수 있다.

재고는 1개인데 주문은 2개 생성됨
포인트는 한 번만 써야 하는데 두 번 사용됨
쿠폰은 1회만 발급되어야 하는데 여러 번 발급됨

이런 문제는 단순 버그가 아니라 데이터 무결성 문제다.


Race Condition이 디버깅하기 어려운 이유

Race Condition은 항상 발생하지 않는다.

특정 타이밍에만 발생한다.

동시에 접근했는가
어떤 스레드가 먼저 실행되었는가
중간에 컨텍스트 스위칭이 발생했는가

이 조건이 맞아야 드러난다.

그래서 로컬 테스트에서는 잘 통과하고, 운영 환경에서만 가끔 발생할 수 있다. 또한 로그를 찍으면 타이밍이 바뀌어 문제가 사라지는 경우도 있다.

이런 특성 때문에 Race Condition은 재현이 어렵고 디버깅이 까다롭다.


Race Condition 해결 원리

해결 원리는 명확하다.

공유 자원을 확인하고 변경하는 동안
다른 스레드가 끼어들 수 없게 만든다

즉, Read-Modify-Write 전체를 하나의 원자적 작업처럼 만들어야 한다.


대표적인 해결 방식

Mutex와 Semaphore

뮤텍스와 세마포어는 잠깐 멈추고 기다리는 방식이다.

한 스레드가 공유 자원을 사용 중이면 다른 스레드는 기다린다.

Thread A가 락 획득
Thread B는 대기
Thread A 작업 완료 후 락 해제
Thread B 작업 시작

장점은 이해하기 쉽고 안정적이라는 점이다. 단점은 대기 시간이 생기고, 잘못 사용하면 데드락이 발생할 수 있다는 점이다.


CAS와 Lock-Free

CAS는 Compare-And-Swap의 약자다.

기존 값이 내가 예상한 값과 같을 때만 새 값으로 바꾼다.

현재 값이 내가 읽었던 값과 같으면 변경
다르면 실패 후 재시도

이 방식은 락을 걸고 기다리는 대신, 충돌이 발생하면 다시 시도하는 방식이다.

고성능 동시성 제어에서 자주 사용된다.


어떤 방식을 선택해야 할까

정답은 상황마다 다르다.

정확성이 최우선인가
성능이 중요한가
충돌 빈도가 높은가
DB 트랜잭션으로 해결 가능한가
애플리케이션 락이 필요한가
분산 환경인가

이 기준에 따라 선택해야 한다.

예를 들어 단일 JVM 내부의 카운터라면 AtomicInteger 같은 원자적 타입을 사용할 수 있다. DB 재고 차감이라면 트랜잭션, 락, 조건부 업데이트를 고려해야 한다. 분산 서버 환경이라면 Redis Lock, DB Lock, 메시지 큐 기반 직렬화 같은 방식도 검토해야 한다.


실무에서 의심해야 하는 코드

다음 패턴은 Race Condition이 생기기 쉽다.

조회 후 저장
존재 여부 확인 후 생성
잔액 확인 후 차감
재고 확인 후 주문
쿠폰 발급 여부 확인 후 발급

예를 들면 이런 흐름이다.

if (coupon not exists) {
    issueCoupon();
}

단일 요청에서는 문제가 없어 보인다. 하지만 동시에 두 요청이 들어오면 둘 다 “쿠폰 없음”을 보고 둘 다 발급할 수 있다.

이런 코드는 반드시 동시성 관점에서 점검해야 한다.


마무리

Race Condition의 핵심 원인은 공유 자원에 대한 비원자적 병행 접근이다. 같은 자원을 여러 스레드가 동시에 읽고 쓰는데, 그 작업이 하나의 원자적 단위로 보호되지 않으면 실행 순서에 따라 결과가 달라질 수 있다.

정리하면 다음과 같다.

공유 자원이 있다
여러 스레드가 동시에 접근한다
작업이 Read-Modify-Write로 나뉜다
중간에 다른 스레드가 끼어든다
결과가 실행 순서에 따라 달라진다

Race Condition은 재현이 어렵고, 운영 환경에서 데이터 무결성 문제로 이어질 수 있다. 그래서 동시성 문제는 단순히 “가끔 발생하는 버그”가 아니라, 서버 애플리케이션의 안정성을 결정하는 핵심 주제다.

결국 좋은 백엔드 코드는 단순히 기능이 동작하는 코드가 아니라, 동시에 실행되어도 안전한 코드다.


© 2020. All rights reserved.

SIKSIK