Race Condition 해결 전략

gm-15·2026년 3월 24일

CS

목록 보기
3/3
post-thumbnail

[병렬프로그래밍]에서 멀티스레드를 다루던 도중 레이스 컨디션 상황에 대한 언급이 나왔다.
마침 [냉장GOAT]에서 레이스 컨디션을 메인으로 다루었고, [ParkingMate]에서도 이중 예약 방지를 위해 동일한 문제를 다룬 적이 있어 이번 기회에 자세히 정리하고자 한다.


Race Condition이란?

두 개 이상의 스레드가 같은 자원에 동시에 접근할 때 실행 순서에 따라 결과가 달라지는 현상이다.

각 스레드의 코드는 틀리지 않았다. 다만 실행 순서가 보장되지 않아서 생기는 문제다.

고전적인 예시가 있다.

// 재고가 1개 남은 상황
if (stock > 0) {       // 스레드 A: 통과
                       // 스레드 B: 통과 (동시에!)
    stock--;           // 스레드 A: stock = 0
    stock--;           // 스레드 B: stock = -1 💥
}

두 스레드 모두 stock > 0을 확인하고 통과한다.
결과는 재고 -1. 현실에서는 결제는 됐는데 상품이 없는 상황이다.


어디서 터지나?

레이스 컨디션은 (확인 → 행동) 사이에 틈이 생길 때 발생한다.

냉장GOAT에서는 여러 직원이 동시에 재고를 차감하는 상황이었고,
ParkingMate에서는 같은 주차 공간에 두 명이 동시에 예약을 시도하는 상황이었다.

둘 다 본질적으로 공유 자원에 대한 비원자적 연산에 관한 문제이다.


해결 전략 4계층

레이스 컨디션 해결법은 적용 범위에 따라 4가지 계층으로 나눌 수 있다.

1. 애플리케이션 레벨 (JVM 내부)

단일 서버, 스레드 간 경합을 제어한다.

synchronized

public synchronized void decreaseStock(Long id, int quantity) {
    // 한 번에 하나의 스레드만 진입
}

메서드나 블록에 synchronized 키워드를 붙여 한 번에 하나의 스레드만 접근하게 한다.

메서드 단위로 걸면 간단하지만, JVM 하나 안에서만 유효하다.
서버가 2대가 되는 순간 무용지물이다.


Atomic 클래스 (CAS)

AtomicInteger stock = new AtomicInteger(10);
stock.decrementAndGet(); // CPU 명령어 수준의 원자적 연산

AtomicInteger 등은 하드웨어 수준의 Compare-And-Swap 알고리즘을 사용하여 락(Lock) 없이 원자성을 보장한다. 성능이 좋다.

단, 주의할 점이 있다. decrementAndGet() 자체는 원자적이다. 문제는 아래처럼 확인과 행동을 분리할 때다.

if (stock.get() > 0) {        // CAS 1번째
    stock.decrementAndGet();   // CAS 2번째 ← 사이에 다른 스레드가 끼어들 수 있음
}

각 연산은 원자적이지만 CAS와 CAS 사이는 원자적이지 않다.
"재고 확인 후 차감 + 주문 생성"처럼 복합 연산의 원자성은 보장하지 못한다.


2. DB 레벨 (RDB)

가장 확실하게 정합성을 보장할 수 있는 계층이다.

낙관적 락 (Optimistic Lock)

충돌이 드물다는 가정 하에 동작한다.

@Version
private Long version;

수정 시점에 내가 읽었던 버전과 현재 버전을 비교한다.
버전이 다르면 OptimisticLockException 발생 → 애플리케이션에서 재시도.

DB 락을 잡지 않아서 가볍다.
단, 충돌이 잦아질수록 재시도가 폭발적으로 늘어나 오히려 DB 부하가 커진다.


비관적 락 (Pessimistic Lock)

충돌이 잦다는 가정 하에 동작한다.

@Lock(LockModeType.PESSIMISTIC_WRITE)
Optional<Stock> findByProductId(Long productId);

SELECT FOR UPDATE로 읽는 순간부터 해당 row를 선점한다.
정합성은 강하게 보장되지만, 락 점유 시간 = DB 커넥션 점유 시간이다.
트래픽이 몰리면 HikariCP 커넥션 풀 고갈로 이어진다.

냉장GOAT에서 @TransactionalprocessOrder에서 제거한 이유가 바로 이것이다.
트랜잭션이 길어질수록 커넥션을 오래 붙잡게 되고, 결국 전체가 막히게 된다.

ParkingMate 적용 사례
ParkingMate에서는 같은 문제를 다른 방향으로 풀었다.
락 범위를 줄이는 대신, 트랜잭션 안에 묶여 있던 알림 처리를 Transactional Outbox로 통째로 분리했다.
예약 트랜잭션(T1)은 booking INSERT + outbox_event INSERT만 처리하고 커밋한다.
알림은 별도 스케줄러(T2)가 나중에 처리하도록 분리했다.

실측 결과 평균 응답시간은 37ms → 36ms로 큰 변동이 없었지만, Hikari 커넥션 점유 표준편차가 5.1ms → 2.1ms로 줄었다.
절대값보다 분산 안정성이 핵심이었던 사례이다.


3. 분산 락 레벨 (다중 서버)

서버가 여러 대로 늘어나면 JVM 레벨 락은 의미가 없다.
각 서버의 synchronized는 서로를 모른다.

Redis (Redisson)

RLock lock = redissonClient.getLock("stock:lock:" + productId);
boolean acquired = lock.tryLock(5, 10, TimeUnit.SECONDS);
if (!acquired) throw new IllegalStateException("락 획득 실패");
try {
    // 임계 구역
} finally {
    lock.unlock();
}

Redisson의 RLock은 내부적으로 두 가지를 함께 사용한다.

Lua 스크립트로 "키 확인 → 세팅 → TTL 설정" 세 단계를 원자적으로 묶는다. Redis는 Lua 스크립트를 실행하는 동안 다른 명령을 일절 받지 않기 때문에 중간에 끼어드는 것이 불가능하다.

락을 못 얻은 스레드는 Pub/Sub 채널을 구독하고 대기한다. 락이 해제되면 Redis가 해당 채널에 알림을 보내고, 대기 중이던 스레드가 그때 재시도한다. 계속 폴링하지 않으니 Redis 부하가 낮다.

냉장GOAT에서 직접 적용한 방식이기도 하다.


주의할 점은 Redis 자체가 단일 장애점(SPOF)이 된다는 것.
이를 보완하기 위한 방법으로 Redlock 알고리즘이 있다. Redis 노드를 홀수 개(보통 5개)로 독립적으로 운영하고, 과반수(3개 이상)에서 락 획득에 성공하면 유효한 락으로 인정하는 방식이다. 노드 하나가 죽어도 나머지로 과반수를 충족할 수 있다.

다만 Redlock의 안전성에 대한 논쟁이 있다. Kleppmann은 GC pause 중 TTL이 만료되면 두 클라이언트가 동시에 락을 가질 수 있다고 지적했고, Redis 창시자 antirez는 이는 분산 시스템 전반의 문제이지 Redlock만의 문제가 아니라고 반박했다. 완벽한 분산 락은 없다는 것이 현재까지의 결론이다.

냉장GOAT에서는 Resilience4j 서킷 브레이커로 Redis 장애 시 fallback을 처리했다.


MySQL Named Lock

SELECT GET_LOCK('order:lock:1', 3);
-- 임계 구역
SELECT RELEASE_LOCK('order:lock:1');

별도 인프라 없이 DB만으로 분산 락을 구현할 수 있다.
단, Named Lock은 커넥션 단위로 락을 관리하기 때문에 트랜잭션용 커넥션과 락용 커넥션을 반드시 분리해야 한다. 결국 커넥션 풀을 두 배로 운영해야 하는 복잡도가 생긴다. Redis는 DB 커넥션과 완전히 독립된 시스템이라 이 문제가 없다. 프로덕션에서 Named Lock보다 Redis를 선택하는 이유다.


4. 아키텍처 레벨 (Queue)

락 자체를 없애버리는 방식이다.

요청 → Kafka → 단일 Consumer → 순차 처리

모든 요청을 큐에 쌓고, 소비자 하나가 순서대로 처리하면 동시성 문제가 원천 차단된다.
대규모 트래픽에서 처리량과 정합성을 동시에 잡는 가장 강력한 방법이다.

단, Consumer가 1개면 처리량이 병목이 된다.
실전에서는 파티셔닝 + 파티션당 1 Consumer 구조로 처리량도 확보한다.


정리

상황선택
단일 서버 + 저빈도 충돌Optimistic Lock
단일 서버 + 고빈도 충돌Pessimistic Lock
다중 서버 + 실시간 정합성Redis 분산 락
다중 서버 + 대용량 트래픽Kafka 순차 처리

레이스 컨디션 해결의 핵심은 "어떤 락을 쓸지"가 아니라 "지금 어떤 상황인지를 먼저 정의하는 것"이다.

충돌 빈도, 서버 수, 트래픽 규모.
이 세 가지에 따라 적절한 답이 달라진다.

2편에서는 낙관적/비관적 락의 트레이드오프를 더 깊게 파고, 냉장GOAT에 실제로 적용한 구조를 함께 다룰 예정이다.


참고 : CAS(https://steady-coding.tistory.com/568#google_vignette)

위에서 언급한 ParkingMate 실측 과정

측정 방법 :
BookingConnectionTimingTest.java에서 직접 측정.
@SpringBootTest + @ActiveProfiles("mysqltest")로 실제 MySQL(Docker, localhost:3307)에 연결하고, TransactionTemplate으로 트랜잭션을 직접 제어하면서 System.currentTimeMillis()로 커넥션 획득부터 반납까지의 시간을 10회 반복 측정.

Before(알림을 T1에 포함): [32, 37, 51, 37, 38, 34, 38, 36, 35, 39] → 평균 37ms, 표준편차 5.1ms
After(Outbox 분리): [36, 40, 34, 38, 38, 38, 34, 37, 36, 33] → 평균 36ms, 표준편차 2.1ms

0개의 댓글