[Spring] 동시성 이슈 해결 : Redis, RabbitMQ 활용 (JMeter를 통한 Throughput 비교)

이지연·2026년 2월 4일

동시성 이슈가 발생한 이유(Repeatable Read 환경에서의 Lost Update/경합)와, 이를 해결하기 위해 시도한 4가지 접근(격리수준/직렬화/비관적 락/Redis 단일스레드) 및 RabbitMQ 기반 RDB 동기화까지, 지금 적어둔 내용을 누락 없이 한 흐름으로 정리하고, Redis 재고 조회+차감의 원자성 깨짐을 해결하는 Lua 스크립트(및 Spring 적용 코드)까지 같이 정리한다.

0. 동시성 발생 상황(기본 Repeatable Read)

  • 일반적인 REPEATABLE READ 격리수준 상황에서 동시 주문이 들어오면, “재고는 일부만 차감됐는데 주문 insert는 더 많이 된” 형태의 불일치가 발생할 수 있다
    • (예: 재고 100개에서 23개만 차감, 주문은 47개 insert).
  • 이런 케이스는 “조회 → 검증 → 차감”이 여러 트랜잭션/스레드에서 섞이면서 생기는 전형적인 경합 패턴이고, 특히 락 없는 조회(plain SELECT) 와 업데이트가 섞이면 일관성이 더 깨지기 쉽다.

1) DB 격리수준 + Write Conflict Detection(충돌 시 실패) 활용

  • DB가 동시 업데이트 충돌을 감지하면 한쪽을 실패 처리하는 매커니즘(충돌 감지/실패)을 활용해 동시성 문제를 “정합성 측면에서” 막을 수는 있다.
  • 다만 충돌이 잦은 환경에서는 실패/재시도가 빈번해져 에러가 많이 발생한다
    • (예: 재고 100에서 32만 차감되고 주문도 32만 들어갔지만, 재고가 남아도 에러가 터져 주문이 더 못 들어가는 상황).

2) @Transactional(isolation = Isolation.SERIALIZABLE)로 격리수준 올리기

  • SERIALIZABLE은 “논리적으로 직렬 실행되도록 강제”하는 격리수준이고, 동시성 문제를 근본적으로 차단하려는 접근이다.
  • synchronized를 스프링 메서드에 거는 건 단일 JVM 내부에서만 의미가 있고, 멀티서버 환경에서는 무효가 되며, 애초에 “DB 쿼리 실행 순서”까지 보장해주지 못한다는 한계가 있다.
  • 그래서 DB 차원에서 동기화를 강제하는 의미로 @Transactional(isolation = Isolation.SERIALIZABLE)을 쓰는 것이다.
  • 단점은 성능 저하, 데드락/에러 증가이며
    • (예: 재고 100에서 25만 차감/주문 25만 insert, 재고 남아도 에러), 처리량도 낮아질 수 있다(측정 예: Throughput 48.1/sec).

3) SELECT ... FOR UPDATE / JPA PESSIMISTIC_WRITE로 배타락(비관적 락)

  • 특정 row를 “조회 시점부터” 배타락으로 잡아버려서, 충돌 범위를 row 단위로 제한하는 방식이다(2번처럼 시스템 전체를 직렬화하는 게 아니라 필요한 row만 잠근다).
  • 이 방식에서는 주문 수와 재고 차감 수가 맞게 떨어지고, “재고가 남았는데도 주문이 실패”하는 케이스도 줄어드는 방향으로 동작한다
  • 주의점: findById 같은 공용 조회 메서드에 락을 걸면 “모든 조회”가 락을 타서 성능이 깨질 수 있으니, 락 전용 메서드를 분리하는 설계가 필요하다.

예시:

@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select p from Product p where p.id = :id")
Optional<Product> findByIdForUpdate(@Param("id") Long id);
  • (측정 예: Throughput 52.2/sec).

4) 싱글 스레드 Redis로 재고 관리 + RDB 동기화

  • 재고 “조회/차감”을 Redis에서 처리하면 동시성 문제와 성능 문제를 동시에 해결할 수 있다(단 Redis는 단일 스레드로 명령을 순차 처리).
  • 대신 Redis 재고와 RDB 재고를 맞추기 위한 동기화 작업이 별도로 필요하다(실시간이든 주기적이든).

재고 처리 절차(정리한 내용):

  • 상품 등록 시: RDB에도 등록, Redis에도 재고 등록.
  • 주문 처리 시: Redis에서 재고 확인 후 Redis에서 재고 감소 처리.
  • RDB 동기화:
    • 스케줄러로 주기적 반영(실시간 아님).
    • 실시간 반영은 Kafka/RabbitMQ 같은 큐로 이벤트 기반 처리.
  • (측정 예: Throughput 70.3/sec).

RabbitMQ로 Redis ↔ RDB 재고 동기화(현재 코드 구조)

  • Redis에서 재고가 감소하면 재고 감소 이벤트를 큐(RabbitMQ)에 발행하고, 스프링이 큐를 listen해서 RDB 재고를 감소시키는 구조다(주문 트랜잭션과 RDB 재고 감소 트랜잭션을 분리).
  • RabbitMQ 설정: Queue("stockQueue", true) 생성, RabbitTemplateJackson2JsonMessageConverter 사용, @RabbitListener(queues="stockQueue")로 구독 처리.
  • 구독 측 로직에서 Product 조회 후 product.decreaseStockQuantity()로 RDB 반영한다(이 업데이트는 메시지 단위로 들어와 동시성 충돌이 줄어드는 방향).

Redis 재고 조회+차감 원자성 문제(현재 코드의 핵심 문제)

현재 코드는 아래처럼 get으로 조회하고, 조건 검사 후 decrement를 별도 호출한다.

String remainValue = redisTemplate.opsForValue().get(key);
int remainQuantity = Integer.parseInt(remainValue);

if (remainQuantity < qty) throw ...
else redisTemplate.opsForValue().decrement(key, qty);
  • Redis가 단일 스레드여도 “명령 단위”로만 원자적이다.
  • GETDECRBY 사이에 다른 요청이 끼면(다른 스레드/서버에서), 검증 시점의 값과 차감 시점의 값이 달라져 oversell/음수/불필요한 실패 같은 문제가 생긴다.
  • 해결은 “조회 + 비교 + 차감”을 Redis 서버에서 한 번에 실행되도록 묶는 것이고, 대표적으로 Lua 스크립트를 쓴다.

해결 Lua 스크립트(원자적 check-and-decrement)

아래 스크립트는 “재고 키 1개”에 대해, (1) 값 조회 (2) 부족하면 실패 코드 반환 (3) 충분하면 차감 후 남은 재고 반환을 단일 실행으로 보장한다.

1) Lua 파일 예시: decrement_if_enough.lua

  • 반환 규칙:
    • 재고 부족/키 없음/잘못된 값이면 -1
    • 성공 시 “차감 후 남은 재고”를 반환
-- KEYS [docs.spring](https://docs.spring.io/spring-data/redis/reference/redis/scripting.html) = stock key (ex: "123" or "stock:123")
-- ARGV [docs.spring](https://docs.spring.io/spring-data/redis/reference/redis/scripting.html) = decrement quantity

local key = KEYS [docs.spring](https://docs.spring.io/spring-data/redis/reference/redis/scripting.html)
local qty = tonumber(ARGV [docs.spring](https://docs.spring.io/spring-data/redis/reference/redis/scripting.html))

if qty == nil or qty <= 0 then
  return -1
end

local current = tonumber(redis.call('GET', key))
if current == nil then
  return -1
end

if current < qty then
  return -1
end

-- Atomic decrement
local remain = redis.call('DECRBY', key, qty)
return remain

2) Spring Data Redis 적용(권장 형태)

Spring Data Redis는 RedisTemplate.execute(script, keys, args...)로 스크립트를 실행하고, 내부적으로 EVALSHA/EVAL을 관리해준다.

@Bean
public DefaultRedisScript<Long> decreaseStockScript() {
    DefaultRedisScript<Long> script = new DefaultRedisScript<>();
    script.setLocation(new ClassPathResource("scripts/decrement_if_enough.lua"));
    script.setResultType(Long.class);
    return script;
}

호출부(원래 깨지던 구간 대체):

String key = String.valueOf(itemDto.getProductId());
Long remain = redisTemplate.execute(
        decreaseStockScript,
        List.of(key),
        String.valueOf(qty)
);

if (remain == null) {
    throw new IllegalStateException("Redis Lua result is null");
}
if (remain == -1L) {
    throw new IllegalArgumentException("재고 부족");
}

// 성공: remain이 차감 후 남은 재고
  • 이렇게 바꾸면 “조회→검증→차감”이 Redis 서버에서 한 번에 수행돼 원자성이 보장된다.
  • 참고로 스크립트를 Bean으로 싱글턴 구성해 SHA1 계산/캐시 활용을 기대하는 패턴이 일반적이다.
profile
Eazy하게

0개의 댓글