#2 Spring Security + Redis로 로그인 구현하기 가 끝나고 관리자가 상품을 등록/수정/삭제를 할 수 있는 CRUD를 만들었다.
게시판 CRUD와 다르게 프로젝트의 핵심 주제가 동시성 제어이기 때문에 재고라는 숫자의 정합성을 어떻게 지켜낼 것인가의 고민을 했다.
시리즈-경품 1:N 계층으로 설계
이치방 쿠지의 한 세트는 시리즈와 등급별 상품인 경품으로 나누어진다.
예를들어 원피스라는 시리즈에는 A상, B상, C상.. 처럼 여러 등급의 경품이 따로 존재하고 각 등급마다 재고가 다르다.
// KujiSeries.java
@OneToMany(mappedBy = "series", cascade = CascadeType.ALL, orphanRemoval = true)
private List<Prize> prizes = new ArrayList<>();
@Column(name = "total_prize_count", nullable = false)
private int totalPrizeCount = 0;
KujiSeries는 title(시리즈명/카테고리), productName(제품명), price(1회 뽑기 가격)를 갖고,
실제 등급별 재고는 자식인 Prize가 갖는다.
이렇게 나누면 "이 시리즈에 경품이 몇 종류, 총 몇 개 남았는가"라는 조회를 Prize 테이블을 순회하지 않고도 totalPrizeCount 하나로 답할 수 있게 된다.
총 재고가 몇개 남았는지 조회
시리즈 목록 화면에서는 매번 "총 재고가 몇 개 남았는지"를 보여줘야 한다.
관리자 화면에서도 지금 실제로 몇 개 남았는지 확인을 하고 싶어 시리즈 목록 API 응답의 totalPrizeCount 자리를 Redis 캐시 기준 실시간 잔여 재고의 합으로 채웠다.
// SeriesService.java
private Map<Long, Integer> resolveRemainingStockSums(List<Long> seriesIds) {
if (seriesIds.isEmpty()) return Map.of();
List<PrizeStockProjection> projections = prizeRepository.findStockProjectionsBySeriesIdIn(seriesIds);
if (projections.isEmpty()) return Map.of();
List<String> keys = projections.stream().map(p -> PrizeService.stockKey(p.getPrizeId())).toList();
List<String> cached = redisService.mget(keys); // Redis MGET 1회
Map<Long, Integer> sums = new HashMap<>();
for (int i = 0; i < projections.size(); i++) {
PrizeStockProjection p = projections.get(i);
int stock = PrizeService.resolveStockValue(cached.get(i), p.getStock());
sums.merge(p.getSeriesId(), stock, Integer::sum);
}
return sums;
}
DB 컬럼KujiSeries.totalPrizeCount과 API 응답 필드SeriesResponse.totalPrizeCount는 이름은 같지만 다른 값을 가리킨다.
전자는 "등록 총량"으로서 increasePrizeCount/decreasePrizeCount에 의해 정확하게 유지되고 후자는 관리자,사용자 모두가 보게 될 "지금 남은 개수"다.
시리즈 목록 조회의 팬아웃(N × M) 성능 문제 (예방)
값의 기준을 "등록 총량"에서 "실시간 잔여 재고"로 바꾸면서, 처음에는 시리즈마다 경품을 조회하고 그 경품마다 Redis를 조회하는 순진한 방식으로 구현했다.
// 문제가 있던 초기 구현 (의사코드)
for (KujiSeries series : allSeries) { // 시리즈 N개
for (Prize prize : findPrizesBySeriesId(series.getId())) { // 경품 M개
remainingStock += resolveStock(prize); // Redis GET 1회씩
}
}
시리즈가 N개, 시리즈당 경품이 평균 M개라면, 목록 화면 하나를 그리는 데 DB 쿼리가 N번, Redis 요청이 N×M번 나가는 구조였다.
관리자 화면이라 지금 당장은 시리즈 수가 적어 체감되지 않지만 시리즈가 늘어날수록 응답 시간이 그대로 늘어나는 구조적 문제였다.
WHERE series_id IN (:seriesIds)로 대상 시리즈 전체의 경품을 한 번에 가져온다(PrizeRepository.findStockProjectionsBySeriesIdIn). 이때도 이미지 같은 화면에 필요 없는 연관 데이터는 함께 가져오지 않도록 별도 프로젝션(PrizeStockProjection)을 만들었다.GET을 따로 하지 않고, 대상 키 전체를 MGET으로 한 번에 조회한다(RedisService.mget()).결과적으로 시리즈가 몇 개든, 경품이 몇 개든 DB 쿼리 1회 + Redis 왕복 1회로 고정되었다.
cascade 삭제와 캐시 정리 사이의 간극 (예방)
KujiSeries에 cascade = CascadeType.ALL, orphanRemoval = true를 걸어둔 덕분에 시리즈를 삭제하면 하위 Prize 및 PrizeImage가 DB에서 자동으로 함께 삭제된다.
// SeriesService.java
@Transactional
public void delete(Long seriesId) {
KujiSeries series = seriesRepository.findById(seriesId)
.orElseThrow(() -> new CustomException(ErrorCode.SERIES_NOT_FOUND));
seriesRepository.delete(series);
}
하지만 이 코드는 의도적으로 미완성 상태로 남겨두고 있다. Prize 단건 삭제PrizeService.delete()는 deleteStockAfterCommit()으로 Redis의 prize:stock:{id} 캐시를 함께 지우지만 시리즈 삭제는 JPA cascade에 맡기다 보니 하위 경품들의 Redis 캐시가 정리되지 않는다. 이건 추후 뽑기 시스템을 구현한 뒤 수정할 예정이다.
재고를 두 곳(DB, Redis)에 두면서도 정합성을 지키는 법
prize.stockprize:stock:{id}: 뽑기(추첨) 로직이 실시간으로 차감하며 참조하는 캐시뽑기가 발생할 때마다 매번 MySQL에 UPDATE ... SET stock = stock - 1을 날리면 동시 요청이 몰릴 때 락 경합이 심해진다.
그래서 실제 재고 차감은 Redis(+Redisson 락)에서 처리하게 했다.
관리자가 경품 재고를 수정했는데, 트랜잭션이 롤백되면 어떻게 될까? 라는 고민도 있었다.
// 잘못된 예시
prize.update(grade, name, description, newStock); // DB 변경 (아직 커밋 전)
redisService.set(stockKey(prizeId), newStock); // Redis는 즉시 반영됨
// ... 이후 로직에서 예외 발생 → @Transactional 롤백
만약 로직이 이렇게 구현 되어있었다면
DB는 롤백되어 이전 재고로 돌아가지만 Redis는 이미 새 값으로 덮어써진 뒤라 롤백되지 않는다.
그 순간부터 사용자는 실제로 존재하지 않는 재고로 뽑기를 시도하게 될 수도 있었다.
(해결방법) TransactionSynchronization.afterCommit() 으로 순서를 강제한다
PrizeService에는 아래와 같은 헬퍼가 있다 PrizeService.java:180
private void runAfterCommit(Runnable action) {
if (TransactionSynchronizationManager.isSynchronizationActive()) {
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override
public void afterCommit() {
action.run();
}
});
} else {
action.run();
}
}
그리고 경품 등록/수정/삭제 각각에서 Redis 갱신을 이 헬퍼를 통해 호출한다.
@Transactional
public PrizeResponse update(Long prizeId, PrizeUpdateRequest req) {
Prize prize = prizeRepository.findById(prizeId)
.orElseThrow(() -> new CustomException(ErrorCode.PRIZE_NOT_FOUND));
prize.update(req.grade(), req.name(), nullIfBlank(req.description()), req.stock());
// ... series 총 재고 갱신 ...
syncStockAfterCommit(prizeId, req.stock()); // 커밋 후에만 실행 예약
return PrizeResponse.of(prize, req.stock());
}
registerSynchronization으로 등록된 콜백은 트랜잭션이 실제로 커밋된 뒤에만 실행된다.
DB 트랜잭션이 중간에 예외로 롤백되면 Redis는 아예 건드리지 않은 상태로 남는다.
"DB가 먼저 확정되고, 그 다음에야 캐시가 바뀐다"