#3 상품 추가/삭제/수정 CRUD 만들기1

영우·2026년 7월 6일

#2 Spring Security + Redis로 로그인 구현하기 가 끝나고 관리자가 상품을 등록/수정/삭제를 할 수 있는 CRUD를 만들었다.
게시판 CRUD와 다르게 프로젝트의 핵심 주제가 동시성 제어이기 때문에 재고라는 숫자의 정합성을 어떻게 지켜낼 것인가의 고민을 했다.

시리즈-경품 1:N 계층으로 설계

시리즈-경품 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;

KujiSeriestitle(시리즈명/카테고리), 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) 성능 문제 (예방)

시리즈 목록 조회의 팬아웃(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번 나가는 구조였다.
관리자 화면이라 지금 당장은 시리즈 수가 적어 체감되지 않지만 시리즈가 늘어날수록 응답 시간이 그대로 늘어나는 구조적 문제였다.

  • DB 쪽: 시리즈마다 따로 쿼리하지 않고, WHERE series_id IN (:seriesIds)로 대상 시리즈 전체의 경품을 한 번에 가져온다(PrizeRepository.findStockProjectionsBySeriesIdIn). 이때도 이미지 같은 화면에 필요 없는 연관 데이터는 함께 가져오지 않도록 별도 프로젝션(PrizeStockProjection)을 만들었다.
  • Redis 쪽: 경품마다 GET을 따로 하지 않고, 대상 키 전체를 MGET으로 한 번에 조회한다(RedisService.mget()).

결과적으로 시리즈가 몇 개든, 경품이 몇 개든 DB 쿼리 1회 + Redis 왕복 1회로 고정되었다.

cascade 삭제와 캐시 정리 사이의 간극 (예방)

cascade 삭제와 캐시 정리 사이의 간극 (예방)

KujiSeriescascade = CascadeType.ALL, orphanRemoval = true를 걸어둔 덕분에 시리즈를 삭제하면 하위 PrizePrizeImage가 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)에 두면서도 정합성을 지키는 법

재고를 두 곳(DB, Redis)에 두면서도 정합성을 지키는 법

  • MySQL의 prize.stock
  • Redis의 prize: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() 으로 순서를 강제한다

(해결방법) 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가 먼저 확정되고, 그 다음에야 캐시가 바뀐다"

0개의 댓글