-
Notifications
You must be signed in to change notification settings - Fork 3
Description
어떤 기능인가요?
일반 유저의 장바구니(Cart/CartItem) 조회·수정 기능에서 발생하는 DB 조회 부하/동시성 경합/금액 정합성 문제를 줄이기 위해,
- 쓰기 경로는 정합성 우선(비관락 + 짧은 재시도 + 즉시 flush) 로 안전하게 고정하고
- 읽기 경로는 카탈로그(상품 기본정보/브랜드할인%)를 Caffeine TTL 캐시로 흡수하여
- 쿼리 수를 상한화하고 p95/p99 지연을 안정화하는 기준과 구현 원칙을 정의합니다.
도메인 구조
Cart (Aggregate Root)
-
의미: “유저 1명당 1개의 장바구니”
-
주요 필드(코드 기준 개념)
idnormalUserId(userId)items: List<CartItem>totalLines(아이템 라인 수)
-
핵심 불변조건
- 유저당 Cart는 유일(동시 생성 레이스 발생 가능 → 안전 생성 로직 필요)
- Cart 변경은 항상 락을 잡고(쓰기 경로), 커밋 직전 오류를 flush로 조기 감지
CartItem (Value-like Entity under Cart)
-
의미: “상품 옵션(productId + size + color) 단위 라인”
-
주요 필드(코드 기준)
idproductIdsize(String, enumSize로 파싱)color(String, enumColor로 파싱)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단계-
findWithItemsByUserIdForUpdate(userId)(있으면 즉시 반환) -
없으면
cartRepo.save(Cart.createFor(userId))시도- 동시 생성 유니크 충돌은 흡수(
DataIntegrityViolationException무시)
- 동시 생성 유니크 충돌은 흡수(
-
다시
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- 값:
BasicProjection을BasicView(불변 래퍼)로 감싼 스냅샷
(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)
- 입력
productIds는LinkedHashSet으로 중복 제거 - 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이 왜 필요하나?
“트랜잭션이 롤백됐는데 캐시만 먼저 지워지는” 역전이 생길 수 있음.
예시:
- 상품 수정 서비스에서 캐시를 먼저 evict 해버림
- DB 트랜잭션이 롤백(실패)됨 → DB 값은 원래대로
- 그런데 캐시는 이미 삭제됨 → 다음 조회는 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
Projects
Status