5. 재고 증가 API와 Outbox 이벤트 저장
상품 생성 시 만들어 둔 재고에 수량을 추가하는 API를 구현했다. DB 재고 증가와 StockIncreased 이벤트 저장은 같은 트랜잭션으로 처리하고, DB 커밋 이후 Redis 가용 재고를 증가시킨다.
전체 처리 흐름
POST /api/stocks/{productId}/increase
↓
증가 수량 검증
↓
StockController
↓
StockService.increase()
├─ 비관적 락으로 재고 조회
├─ 재고 수량 증가
└─ StockIncreased Outbox 저장
↓
DB 커밋·잠금 해제
↓
Redis 가용 재고 증가 시도
↓
DB 수량·Redis 수량을 담아 200 응답
별도로 실행되는 OutboxRelay
↓
stock.events → Notification
이번 API는 이미 생성된 재고의 수량을 증가시키는 역할이다. 재고 행이 없으면 새로 만들지 않고 404 STOCK_NOT_FOUND를 반환한다.
1. 증가 요청과 응답 DTO 작성
요청에서는 증가시킬 수량 하나를 받는다.
public record IncreaseStockRequest(
@NotNull(message = "증가 수량은 필수입니다.")
@Min(value = 1, message = "증가 수량은 1 이상이어야 합니다.")
@Max(value = 10_000, message = "증가 수량은 10000 이하여야 합니다.")
Integer quantity
) {
}
{
"quantity": 100
}
int 대신 Integer를 사용하여 누락된 값을 null로 표현하고, @NotNull로 검사한다. Controller의 @Valid와 연결하여 범위를 벗어난 요청은 Service 호출 전에 차단한다.
응답에는 DB와 Redis의 수량을 구분해서 담았다.
public record IncreaseStockResponse(
Long productId,
int quantity,
Long availableQuantity
) {
}
| 필드 | 의미 |
|---|---|
productId | 재고를 증가시킨 상품 ID |
quantity | 증가 후 DB 실재고 |
availableQuantity | 증가 후 Redis 가용 재고 |
availableQuantity는 Redis 반영 실패 시 null을 표현할 수 있도록 Long을 사용했다.
기존 재고가 50이고 100을 추가했다면 정상 응답의 수량은 각각 150이다.
2. 재고 없음 오류와 증가 이벤트 정의
공통 ErrorCode에 재고가 없는 경우의 오류를 추가했다.
STOCK_NOT_FOUND(HttpStatus.NOT_FOUND, "재고를 찾을 수 없습니다."),
재고 증가 사실을 전달할 이벤트도 정의했다.
public record StockIncreased(
Long productId,
int increasedQuantity,
int quantity
) {
}
증가량과 증가 후 총수량은 다른 정보이므로 함께 담았다.
new StockIncreased(
1L, // 상품 ID
100, // 이번 증가량
150 // 증가 후 DB 수량
);
이 이벤트는 DB 재고가 증가했다는 사실을 의미한다. Redis 반영 성공까지 의미하지는 않는다.
3. Service에서 재고 증가와 Outbox 저장
기존 Stock.increase()를 활용하고, 증가 이벤트 저장을 연결했다.
@Transactional
public Stock increase(Long productId, int increasedQuantity) {
Stock stock = repository.findByProductIdForUpdate(productId)
.orElseThrow(() ->
new BusinessException(ErrorCode.STOCK_NOT_FOUND)
);
stock.increase(increasedQuantity);
outboxWriter.write(
"stock.events",
String.valueOf(stock.getProductId()),
new StockIncreased(
stock.getProductId(),
increasedQuantity,
stock.getQuantity()
)
);
return stock;
}
처리 순서는 다음과 같다.
재고 조회
├─ 없음 → STOCK_NOT_FOUND
└─ 있음
→ 수량 증가
→ 증가 이벤트 Outbox 저장
→ 함께 커밋
메시지 키에는 재고 행 자체의 ID인 stock.getId()가 아니라 **상품 ID인 stock.getProductId()**를 사용한다.
4. save() 없이 수량이 반영되는 이유
Service에서는 조회한 재고의 값을 바꾸고 별도 save()를 호출하지 않았다.
stock.increase(increasedQuantity);
트랜잭션 안에서 JPA로 조회한 엔티티는 영속성 컨텍스트의 관리 대상이다. JPA는 조회 당시 상태와 변경된 상태를 비교하고, flush 시 변경 사항을 UPDATE로 반영한다. 이를 변경 감지라고 한다.
조회 당시 수량: 50
↓
stock.increase(100)
↓
객체의 수량: 150
↓
변경 감지 → DB UPDATE
↓
커밋
아무 객체나 변경하면 자동 저장되는 것이 아니라, 현재 트랜잭션에서 JPA가 관리하는 엔티티이기 때문에 가능한 동작이다.
Outbox 저장 중 런타임 예외가 발생하면 같은 트랜잭션의 재고 변경도 롤백된다.
5. 동시 증가 문제를 검토하고 비관적 락 적용
처음에는 일반 findByProductId()로 조회하는 방식을 사용했다. 하지만 두 요청이 같은 이전 수량을 읽으면 증가분이 사라질 수 있다.
초기 재고: 100
A: 100 조회 → 10 증가 → 110 저장
B: 100 조회 → 20 증가 → 120 저장
기대 결과: 130
실제 결과: 120이 될 수 있음
@Transactional만으로는 두 요청을 순서대로 실행해 주지 않는다.
해결 방법으로 세 가지를 비교했다.
| 방법 | 처리 방식 |
|---|---|
| 비관적 락 | 조회 시 행을 잠그고 다른 변경 요청을 대기시킴 |
| 낙관적 락 | 버전으로 충돌을 감지하고 한쪽을 실패시킴 |
| 원자적 UPDATE | DB에서 현재 수량에 증가량을 직접 더함 |
현재 JPA 변경 감지와 Outbox 저장 흐름을 유지하기 위해 비관적 락을 선택했다.
Repository에 일반 조회와 별도로 잠금 조회를 추가했다.
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select s from Stock s where s.productId = :productId")
Optional<Stock> findByProductIdForUpdate(
@Param("productId") Long productId
);
| 코드 | 역할 |
|---|---|
@Lock | 조회에 사용할 잠금 방식 지정 |
@Query | 실행할 JPQL 지정 |
@Param | 인자를 쿼리의 파라미터에 연결 |
ForUpdate라는 메서드 이름 자체가 잠금을 거는 것은 아니다. 실제 잠금 방식은 @Lock이 지정한다.
이제 같은 상품의 증가 요청은 다음처럼 처리된다.
A: 재고 100 조회 + 잠금
B: 같은 행의 잠금을 기다림
A: 110으로 변경·커밋 → 잠금 해제
B: 변경된 110 조회 + 잠금
B: 130으로 변경·커밋
6. 잠금은 DB 트랜잭션이 끝날 때까지 유지
비관적 락은 조회 메서드가 끝날 때 해제되지 않는다. 락을 획득한 뒤 해당 트랜잭션이 커밋·롤백될 때까지 유지된다.
트랜잭션 시작
↓
재고 조회 + 잠금 획득
↓
재고 증가
↓
Outbox 저장
↓
커밋 또는 롤백 → 잠금 해제
flush()를 실행해도 트랜잭션이 끝난 것은 아니므로 잠금은 유지된다.
현재 Controller가 increase()의 반환값을 받았을 때는 Spring의 커밋 처리까지 완료된 상태다.
7. DB 커밋 이후 Redis 증가
Controller는 DB 처리와 Redis 처리를 순서대로 호출한다.
@RestController
@RequestMapping("/api/stocks")
@RequiredArgsConstructor
public class StockController {
private final StockService service;
@PostMapping("/{productId}/increase")
public ResponseEntity<ApiResponse<IncreaseStockResponse>> increase(
@PathVariable("productId") Long productId,
@Valid @RequestBody IncreaseStockRequest request
) {
Stock stock = service.increase(
productId,
request.quantity()
);
Long availableQuantity = service.increaseAvailable(
productId,
request.quantity()
);
IncreaseStockResponse response = new IncreaseStockResponse(
stock.getProductId(),
stock.getQuantity(),
availableQuantity
);
return ResponseEntity.ok(ApiResponse.ok(response));
}
}
Controller에 바깥 트랜잭션이 없으므로 두 호출은 다음처럼 이어진다.
DB 증가 메서드 호출
↓
재고 + Outbox 커밋·잠금 해제
↓
Redis 증가 메서드 호출
↓
응답 생성
Redis 응답이 느려져도 그동안 DB 행 잠금을 유지하지 않는다. 반대로 DB 처리가 실패하면 Redis 호출까지 진행하지 않는다.
Redis 메서드에 별도로 @Transactional을 붙인다고 앞선 트랜잭션의 잠금을 이어받지는 않는다. 잠금 유지 여부는 두 작업이 같은 DB 트랜잭션에 참여하는지에 달려 있다.
8. Redis 실패 시 DB 결과 유지
Redis 증가 호출에서 저장소 접근 예외를 잡고 null을 반환하도록 했다.
public Long increaseAvailable(Long productId, int quantity) {
try {
return stockRedisRepository.increase(productId, quantity);
} catch (DataAccessException e) {
log.warn(
"Redis 재고 증가 실패: productId={}, quantity={}",
productId,
quantity,
e
);
}
return null;
}
이는 #17의 **“Redis 반영에 실패해도 DB는 반영된 상태로 응답한다”**는 요구사항에 해당한다.
DB 재고 증가·Outbox 저장 성공
↓
Redis 접근 실패
↓
DB와 Outbox는 유지
응답의 availableQuantity는 null
DB와 Redis가 하나의 트랜잭션으로 묶인 것은 아니다. Redis가 실패하더라도 이미 커밋된 DB 변경은 취소되지 않는다.
9. 테스트로 증가량 전달 오류 확인
Controller를 연결하면서 Redis에 요청 증가량 대신 증가 후 총수량을 전달한 오류가 있었다.
// 잘못된 전달
service.increaseAvailable(productId, stock.getQuantity());
기존 재고 50에 100을 추가하면 다음 문제가 발생한다.
DB: 50 + 100 = 150
Redis: 50 + 150 = 200
Redis에도 이번 요청의 증가량을 전달하도록 수정했다.
service.increaseAvailable(productId, request.quantity());
초기 재고가 0인 경우에는 이 오류가 드러나지 않을 수 있으므로, 기존 재고가 있는 상황을 테스트한 것이 도움이 됐다.
검증한 내용
- 기존 재고에 요청 수량만큼 증가
- 수량 누락·범위 오류 시
400 - 재고 행이 없으면
404 STOCK_NOT_FOUND - 재고 증가와 Outbox 공동 저장
- Outbox 실패 시 재고 롤백 및 Redis 미호출
- Redis 실패 시 DB·Outbox 유지와
null응답 - 동시 DB 증가 시 증가분과 이벤트 저장
추가한 증가 API 테스트 12개가 통과했고, 당시 Stock 전체 테스트는 28개가 통과했다. API 테스트는 실제 HTTP와 H2를 사용했으며 Redis는 테스트 대역으로 검증했다.
여기까지 재고 증가 API의 DB·Outbox·Redis 연결을 완성했다. 이후에는 Redis 키가 없는 상황에서 잘못된 수량을 만들지 않도록 Lua를 적용하는 과정으로 이어진다.