Skip to content

[BE_6] Cart/CartItem (domain) / Cache 적용 기준 #206

@pjhcsols

Description

@pjhcsols

어떤 기능인가요?

일반 유저의 장바구니(Cart/CartItem) 조회·수정 기능에서 발생하는 DB 조회 부하/동시성 경합/금액 정합성 문제를 줄이기 위해,

  • 쓰기 경로는 정합성 우선(비관락 + 짧은 재시도 + 즉시 flush) 로 안전하게 고정하고
  • 읽기 경로는 카탈로그(상품 기본정보/브랜드할인%)를 Caffeine TTL 캐시로 흡수하여
  • 쿼리 수를 상한화하고 p95/p99 지연을 안정화하는 기준과 구현 원칙을 정의합니다.

도메인 구조

Cart (Aggregate Root)

  • 의미: “유저 1명당 1개의 장바구니”

  • 주요 필드(코드 기준 개념)

    • id
    • normalUserId (userId)
    • items: List<CartItem>
    • totalLines (아이템 라인 수)
  • 핵심 불변조건

    • 유저당 Cart는 유일(동시 생성 레이스 발생 가능 → 안전 생성 로직 필요)
    • Cart 변경은 항상 락을 잡고(쓰기 경로), 커밋 직전 오류를 flush로 조기 감지

CartItem (Value-like Entity under Cart)

  • 의미: “상품 옵션(productId + size + color) 단위 라인”

  • 주요 필드(코드 기준)

    • id
    • productId
    • size (String, enum Size로 파싱)
    • color (String, enum Color로 파싱)
    • quantity
    • (서버 확정값) brandUserNumber, brandFirmName
      → 쓰기 시점에 서버가 확정 저장(프론트 신뢰 X)

정합성/정책

  • 재고 검증은 쓰기 경로에서만 “실시간 옵션 재고(ProductOption.optionQuantity)”로 검증
  • 조회 화면에 필요한 제품 정보(이름/가격/브랜드/할인%)는 카탈로그 스냅샷으로 취급 가능(최대 60초 지연 허용)

핵심 플로우(현재 코드 기준)

1) 락 + 안전 생성 + 짧은 재시도(쓰기 경합 흡수)

  • withCartLock(userId, body)

    • getOrCreateLocked(userId)FOR UPDATE 선점

    • 처리 후 em.flush()유니크/락/무결성 예외 조기 감지

    • PessimisticLockException / LockTimeoutException / CannotAcquireLockException / DataIntegrityViolationException

      • 최대 3회(0,1,2) 재시도
      • 실패 시 ErrorCode.CONFLICT로 사용자 친화적 메시지 통일
  • getOrCreateLocked(userId) 3단계

    1. findWithItemsByUserIdForUpdate(userId) (있으면 즉시 반환)

    2. 없으면 cartRepo.save(Cart.createFor(userId)) 시도

      • 동시 생성 유니크 충돌은 흡수(DataIntegrityViolationException 무시)
    3. 다시 findWithItemsByUserIdForUpdate최종 락 확보 후 반환

목적: “동시 생성 레이스/락 경합/유니크 충돌”을 사용자에게 500이 아닌 409(CONFLICT) 로 안정적으로 노출하고, 장애 비용(롤백/재시도)을 줄임.


DB 조회 최적화 기준(읽기 경로)

목표

  • 장바구니 조회(/me)는 화면 구성에 필요한 데이터를 모아야 하지만,

    • JPA 다중 bag fetch join, N+1, 옵션/사진/할인 조회 폭증을 피해야 함
  • 따라서 조회는 다음 방식으로 고정:

    • Cart + Items는 1회 조회(필요 시 for-update)
    • 카탈로그(기본정보/할인%)는 캐시 + 미스만 배치(IN)
    • 색상 사진/옵션 재고는 키 기반 IN 일괄 로딩

실제 코드에서의 일괄 로딩 구성

  • 제품 기본정보: catalog.getBasicsByIdsCached(productIds)

  • 브랜드 할인 퍼센트: catalog.getActivePercentsByProductIdsCached(productIds, now)

  • 색상별 대표 이미지:

    • productRepo.findColorOptionsWithPhotos(productIds, colors)
    • key: productId|color
  • 옵션 재고(옵션별 수량):

    • productOptionRepo.findById_ProductIdInAndId_ProductColorInAndId_ProductSizeIn(productIds, colors, sizes)
    • key: productId|color|size

기준: “연관관계 fetch join으로 한 번에 땡기려다 터지는 것” 대신
‘필요 데이터’만 쿼리 3~4방으로 고정해 성능을 예측 가능하게 만든다.


캐시 적용 기준(무엇을 캐시하고, 무엇을 캐시하지 않는가)

캐시 대상(YES)

아래 조건을 만족하면 캐시 적용 대상:

  • 읽기 비중이 높고(조회 트래픽) DB 부하를 직접 유발
  • 조회 결과가 읽기 전용 스냅샷(Projection/DTO) 로 분리 가능
  • 최대 60초 내 지연 허용(즉시 반영이 필수 아님)
  • 동일 productId가 반복 조회되는 “핫키” 성격

✅ 현재 적용된 캐시 값(실제 코드)

  • product.basic

    • 값: BasicProjectionBasicView(불변 래퍼)로 감싼 스냅샷
      (productId, productName, productPrice, totalQuantity, brandUserNumber, brandFirmName)
    • 키: productId (per-id)
  • discount.activePercent

    • 값: 상품별 현재 브랜드 할인 퍼센트(Integer, 없으면 0)

    • 키: productId|minuteKey (per-minute)

      • minuteKey = now.truncatedTo(MINUTES)
      • TTL 60s와 자연 정합(1분 단위 갱신)

캐시 비대상(NO)

아래는 캐시하면 오히려 사고/불일치가 커짐:

  • 재고/옵션 수량(실시간 정합성이 중요, 결제·예약과 엮임)
  • 유저별 상태(장바구니 자체, 좋아요/쿠폰 보유 등 개인화 데이터)
  • 결제 최종 금액/승인 금액(서버에서 매번 재계산 + 검증이 원칙)
  • 락이 필요한 데이터(동시성 제어가 핵심인 데이터)

CacheManager 설정값 기준(성능 위주 비교/선정 이유)

Spring Cache + Caffeine을 택한 이유(성능/운영)

  • JPA 2nd-level cache

    • 프록시/세션/연관관계/더티체킹/무효화 전략과 얽혀,
    • “캐시 히트”여도 추가 로딩/예상 밖 동작/운영 리스크가 커질 수 있음
  • 반면 여기서 캐시하려는 것은

    • 엔티티가 아니라 읽기 전용 Projection/DTO 스냅샷
    • JVM 로컬 메모리에서 O(1) 로 반환 가능
    • TTL을 짧게 두면(60s) 신선도 리스크도 상한이 명확

즉, 엔티티 캐시가 아니라 “카탈로그 읽기 스냅샷 캐시”로 격리하는 것이 더 안전하고 성능효과가 즉각적.

expireAfterWrite=60s 선택 이유(성능/신선도 균형)

  • 트래픽 스파이크 시 DB 재유입(미스) 빈도를 줄여 쿼리 폭증을 억제
  • 동시에 변경 반영 지연을 최대 60초로 상한 → 운영상 예측 가능
  • 특히 할인 캐시는 minuteKey를 사용해 “1분 단위 자연 갱신”이라 TTL 60s와 정합이 좋음

maximumSize=20_000 선택 이유(안정성/p99)

  • 핫 상품 + discount.activePercent의 per-minute 키까지 고려하면 키 수가 급증할 수 있음
  • 무제한 캐시는 힙/GC 압박으로 p99 지연 스파이크를 만들 수 있으므로 상한을 둠
  • 20,000은 “핫키를 담아 hit율을 유지하면서”도 “메모리 폭주를 차단”하는 안전장치

캐시 조회 방식 기준(쿼리 수 상한화)

per-id 캐시 + miss만 batch(IN)

  • 입력 productIdsLinkedHashSet으로 중복 제거
  • Cache hit: O(1) get
  • Cache miss: miss 목록만 IN 배치 조회
  • 조회 결과는 불변 래퍼(BasicView) 로 감싸 캐시에 저장
  • discount는 negative caching(없음=0) 까지 저장 → 반복 미스 쿼리 제거

효과: “페이지당 아이템 수”가 늘어도 DB 쿼리는 상한이 고정되고, 핫 상품에서 쿼리 스파이크가 사라짐.


(중요) 캐시 무효화(evict) & afterCommit 기준

왜 필요한가?

TTL 60s만으로도 “언젠가 갱신”은 되지만, 아래 케이스에서 UX/정합 리스크가 남음:

  • 상품 가격/상태(ON_SALE 등)가 즉시 바뀐 경우
  • 브랜드 할인율이 즉시 바뀐 경우
  • 운영자가 수정 직후 바로 화면 확인/CS 대응해야 하는 경우

→ 변경 직후 해당 productId만 선택 무효화(evict) 하면, 다음 조회에서 바로 최신 값을 당겨올 수 있음(“TTL 기다림 제거”).

afterCommit이 왜 필요하나?

“트랜잭션이 롤백됐는데 캐시만 먼저 지워지는” 역전이 생길 수 있음.

예시:

  1. 상품 수정 서비스에서 캐시를 먼저 evict 해버림
  2. DB 트랜잭션이 롤백(실패)됨 → DB 값은 원래대로
  3. 그런데 캐시는 이미 삭제됨 → 다음 조회는 DB를 다시 때리거나, 순간적으로 “불필요 미스/부하/지연”이 발생

즉, DB 변경이 확정(커밋)된 뒤에만 evict 해야 캐시/DB 상태가 같은 방향으로 움직인다.

결론: evict 자체는 “선택사항”이지만, 넣는다면 반드시 afterCommit 로 안전하게 넣는 것이 정석.


작업 상세 내용

  • Cart/CartItem 도메인 불변조건 문서화(유저당 1 Cart, 옵션 단위 라인, 쓰기 시 서버 확정 필드)
  • 동시성 제어 기준 확정(withCartLock, getOrCreateLocked 3단계, flush, retry/backoff, CONFLICT 통일)
  • 조회 최적화 기준 확정(카탈로그 캐시 + 사진/옵션재고 IN 로딩, 다중 bag fetch join 회피)
  • 캐시 적용 기준 정리(캐시 대상/비대상, TTL=60s, maximumSize=20_000, negative caching, per-minute 키)
  • (후속) 상품/할인 변경 시점 선택 evict + afterCommit 연동 설계/구현
  • 성능 검증(쿼리 수/응답시간/p95/p99, 캐시 hit ratio) 및 회귀 테스트 추가

참고할만한 자료(선택)

  • Caffeine Cache (eviction/TTL/maximumSize 개념)
  • Spring Cache 추상화(CacheManager, Cache)
  • 트랜잭션 동기화(afterCommit) 패턴(TransactionSynchronization)

Cart/CartItem 테이블 인덱스 추천(유니크/조회용)과 “캐시 eviction 설계(코드 스니펫 포함)” 추후 검토 및 추가 개선

Metadata

Metadata

Assignees

Labels

feature새로운 기능 추가

Projects

Status

In progress

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions