Skip to content
Kang Log
Go back

6. Lua로 Redis 키가 있을 때만 재고 증가

Updated:

6. Lua로 Redis 키가 있을 때만 재고 증가

재고 증가 API를 연결한 뒤, Redis 키가 없는 상황을 어떻게 처리할지 검토했다.

처음에는 increment()를 사용했지만, 키가 유실됐을 때 잘못된 가용 재고를 만들 수 있어 키가 있을 때만 증가하도록 Lua 스크립트로 변경했다.

전체 처리 흐름

DB 재고 증가 + StockIncreased Outbox 저장
    ↓
DB 커밋·잠금 해제
    ↓
Redis에서 Lua 실행
    ├─ 키 있음 → 요청 수량만큼 증가 → 증가 후 수량 반환
    └─ 키 없음 → 변경하지 않음 → null 반환
    ↓
200 응답

1. 단순 increment()의 문제

기존 코드는 다음과 같았다.

return redisTemplate.opsForValue()
        .increment(key, quantity);

증가 연산 자체는 원자적이지만, 키가 없으면 0부터 시작해서 새 키를 만든다.

기존 DB 재고: 100
Redis 키: 유실
추가 수량: 20

DB: 120
Redis: 20

Redis에 키가 없다는 것은 실제 재고가 0이라는 뜻이 아니다. 기존 수량을 모르는 상태에서 이번 증가량만 저장하면, 잘못된 값을 정상 가용 재고처럼 반환하게 된다.

따라서 다음 정책을 선택했다.

키가 있고 값이 0 → 정상적으로 증가
키가 없음       → 증가하지 않고 null 반환

2. 키 확인과 증가를 Lua로 묶기

Java에서 키를 확인한 뒤 증가시키면 두 Redis 요청 사이에 다른 작업이 끼어들 수 있다.

A: 키 존재 확인 → 있음
B: 키 삭제
A: 증가 요청 → 0부터 새로 생성

이를 막기 위해 Redis 안에서 존재 확인과 증가를 하나의 작업으로 실행한다.

private static final RedisScript<Long> INCREASE_IF_PRESENT =
        RedisScript.of("""
                if redis.call('EXISTS', KEYS[1]) == 0 then
                    return nil
                end

                return redis.call('INCRBY', KEYS[1], ARGV[1])
                """, Long.class);

스크립트의 의미는 다음과 같다.

코드역할
EXISTS키 존재 여부 확인
return nil키가 없으면 종료하고 Java에 null 반환
INCRBY현재 값에 증가량을 더하고 결과 반환
Long.class실행 결과를 Java의 Long으로 받음

3. Repository에서 스크립트 호출

public Long increase(Long productId, int quantity) {
    String key = "stock:available:" + productId;

    return redisTemplate.execute(
            INCREASE_IF_PRESENT,
            List.of(key),
            String.valueOf(quantity)
    );
}

전달한 값은 Lua에서 다음과 같이 사용된다.

List.of(key)             → KEYS[1]
String.valueOf(quantity) → ARGV[1]

예를 들어 increase(1L, 100)을 호출하면:

KEYS[1] = "stock:available:1"
ARGV[1] = "100"

키는 목록으로 전달한다. Lua 스크립트 하나가 여러 키에 접근할 수도 있기 때문이다. 증가량은 StringRedisTemplate에 맞춰 문자열로 전달하며, Redis의 INCRBY가 정수로 해석한다.

4. Lua가 보장하는 범위

Redis의 명령 실행은 기본적으로 한 스레드에서 순서대로 이루어지지만, 별도로 보낸 명령 사이에는 다른 요청이 들어올 수 있다.

Lua를 사용하면 스크립트 전체가 끝날 때까지 다른 클라이언트 명령이 중간에 끼어들지 않는다.

Lua 시작
    키 확인
    수량 증가
Lua 종료
    ↓
다음 클라이언트 명령 실행

다만 이 원자성은 Redis 내부에 한정된다.

5. 기존 Service와 응답 처리 유지

Repository가 반환한 Long을 Service가 그대로 반환하므로, 키가 없는 경우에도 Controller 변경은 필요하지 않았다.

키 있음 → 증가 후 수량 반환
키 없음 → 정상 반환값이 null
접근 실패 → Service가 예외를 잡고 null 반환

키가 없는 것은 예외가 아니다. Lua가 정상적으로 null을 반환하는 경우다.

응답의 availableQuantity: null은 가용 재고를 제공하지 못했다는 의미이며, 재고가 0이라는 뜻은 아니다.

검증한 내용

별도 실제 Redis에서 Repository의 Lua를 실행했다.

테스트결과
기존 값 50에 100 증가150 저장·반환
기존 값 0에 100 증가100 저장·반환
키가 없는 상태에서 반복 증가 요청null 반환, 키 미생성

Lua 테스트 3개를 추가했고, 기존 테스트를 포함해 Stock 전체 31개가 통과했다.


7. 실제 서비스 연동으로 재고 증가 흐름 확인

자동 테스트 이후 최신 Stock 이미지를 빌드하여 실제 PostgreSQL·Redis·Kafka 환경에서 재고 증가를 확인했다.

1. 최신 코드를 실행 환경에 반영

현재 Docker 컨테이너는 이미지에 들어 있는 코드를 실행한다. 로컬 소스를 수정한 것만으로 실행 중인 컨테이너가 바뀌지는 않으므로, Stock 이미지를 다시 빌드하고 컨테이너를 교체했다.

./gradlew :stock:jibDockerBuild
docker compose up -d --no-deps --force-recreate stock

DB·Redis 데이터는 유지한 상태로 애플리케이션 코드만 갱신했다.

2. 정상 증가 확인

Postman으로 기존 상품에 증가 요청을 보냈다.

POST /api/stocks/1/increase
Content-Type: application/json
{
  "quantity": 100
}

DB 실재고와 Redis 가용 재고가 각각 기존보다 100 증가하는 것을 확인했다.

증가 요청
    ↓
DB 재고 증가 + Outbox 저장
    ↓
Redis 증가
    ↓
응답에서 두 수량 확인
    ↓
StockIncreased 발행 확인

3. Redis 키가 없는 경우 확인

테스트 상품의 Redis 키만 삭제한 뒤 다시 증가 요청을 보냈다.

docker compose exec redis valkey-cli DEL stock:available:1

DB 재고는 유지되어 있으므로 DB 증가와 Outbox 저장은 정상적으로 수행된다. Redis에서는 Lua가 키 부재를 확인하고 증가를 생략한다.

실제 응답은 다음과 같았다.

{
  "success": true,
  "message": "success",
  "data": {
    "productId": 1,
    "quantity": 500,
    "availableQuantity": null
  }
}

StockIncreased 이벤트도 발행됐다.

이벤트는 DB 재고가 증가했다는 사실을 나타내므로 Redis 키가 없어도 발행되는 것이 정상이다. 키 부재에 따른 Redis 미생성은 앞선 실제 Redis 자동 테스트에서도 확인했다.

최종 검증 결과

구분확인한 내용
상품 생성상품·Outbox 저장과 이벤트 발행
재고 초기화DB·Redis 초기 수량 0 및 등록 이벤트
주문 스냅샷상품 정보 저장 및 등록 이벤트
재고 증가DB·Redis 증가 및 증가 이벤트
Redis 키 부재DB 증가 유지, 가용 재고 null 응답
Notification상품 생성·재고 등록·스냅샷 등록·재고 증가 이벤트 확인

자동 테스트는 모듈별 작업 완료 시 다음 결과를 확인했다.

모듈통과 수
Product47
Stock31
Order13

DB 자동 테스트는 H2를 사용했고, Redis 증가 API의 실패 처리는 테스트 대역과 예외 주입으로 검증했다. Lua와 일부 초기화 테스트는 별도 실제 Redis를 사용했다. 실제 Kafka 전달은 서비스 연동 테스트로 확인했다.

여기까지 #16 상품 생성 흐름과 #17 재고 증가 흐름을 완료했다.


8. 구현 과정에서 검토한 정합성과 후속 과제

Redis 실패를 허용하는 구조를 구현하면서, DB·Redis가 달라졌을 때 무엇을 기준으로 복구할지와 판매에 어떤 영향을 주는지 검토했다.

1. DB를 실재고의 기준으로 사용

현재는 DB의 quantity를 실재고의 기준으로 삼고, Redis에는 가용 재고를 반영한다.

SoT(Source of Truth)는 불일치가 발생했을 때 판단·복구의 기준이 되는 데이터다. 단순히 먼저 저장한다는 순서 때문에 정해지는 것은 아니다.

현재 구조에서는 Redis 반영에 실패해도 DB 증가와 Outbox를 유지하므로, DB에 확정한 재고 변경을 유효한 기록으로 취급한다.

2. 증가 반영 누락은 과소판매 방향일 수 있음

처음에 DB와 Redis가 모두 100이고, 20을 추가한 상황이다.

DB: 120으로 커밋 성공
Redis: 증가가 실행되지 않아 100 유지

이후 Redis 수량으로 판매 가능 여부를 판단한다면 실제보다 20개 적게 판매 가능하다고 보게 된다.

하지만 DB를 먼저 변경한다고 항상 과대판매를 막는 것은 아니다.

DB: 100 → 80 차감
Redis: 차감 실패로 100 유지

차감에서는 Redis가 실제보다 큰 값을 유지할 수 있다. 따라서 증가 처리 순서만으로 전체 판매 안전성을 판단할 수는 없다.

또한 Redis 요청이 타임아웃됐다고 반드시 실행되지 않은 것은 아니다. 변경은 성공하고 응답만 유실됐을 수 있으므로, 재시도 시 중복 반영 여부도 고려해야 한다.

3. 대사는 단순한 DB 값 복사가 아님

대사는 DB와 Redis를 비교하여 불일치를 찾고 보정하는 작업이다.

예약을 아직 실재고에서 차감하지 않는 모델이라면 다음 관계가 된다.

실재고: 100
예약:    30
가용:    70

이때 Redis 가용 재고를 DB의 100으로 덮어쓰면 예약된 30개를 다시 판매할 수 있다.

따라서 대사를 구현할 때는 다음을 함께 고려해야 한다.

현재 Lua는 잘못된 가용 재고 생성을 피하는 역할까지만 한다. 자동으로 DB·Redis의 값을 맞춰 주지는 않는다.

4. 이후 학습할 범위

다른 참여자의 구현에서는 예약 수량을 분리한 Redis 구조, 주기적 대사, DB 롤백 시 Redis 보상 처리 등을 확인했다. 다만 트랜잭션 경계와 목표가 다른 부분이 있어 그대로 도입하지 않았다.

이번에는 #16·#17의 흐름을 완성하고 다음을 후속 과제로 남겼다.

후속 과제확인할 내용
주문의 재고 예약재고 확인과 예약을 원자적으로 처리
중복 처리 방지같은 주문의 예약·반환이 반복 적용되지 않도록 처리
DB·Redis 대사예약과 동시 변경을 고려한 보정
Redis 장애 복구키 유실 시 가용 재고 복원
Kafka 실패 복구재시도 소진 후 처리 및 DLT

현재 완료한 것은 상품 생성부터 재고·스냅샷 생성, 그리고 재고 증가까지의 이벤트 흐름이다. 대사와 과대판매 방지는 구현 완료로 표현하지 않고, 이후 주문·예약 기능과 함께 검토할 과제로 정리했다.


Share this post on:

Previous Post
DB 동시성 제어 핵심 정리
Next Post
5. 재고 증가 API와 Outbox 이벤트 저장