상품 조회 성능 최적화 — 인덱스, 비정규화, 캐시 테스트 결과
상품 조회 성능 최적화 — 인덱스, 비정규화, 캐시
10만건 데이터 기반 상품 목록/상세 API 성능 개선 전과정
테스트 환경
| 항목 | 사양 |
|---|---|
| Machine | MacBook Pro 18,3 (2021) |
| Chip | Apple M1 Pro (10코어 — 8P + 2E) |
| Memory | 32 GB |
| OS | macOS Sonoma (Darwin 23.4.0) |
| Java | OpenJDK 21.0.6 LTS |
| Spring Boot | 3.4.4 |
| DB | PostgreSQL 16.13 (Docker) |
| Cache | Redis (Docker) |
| Docker | 26.0.0 |
| 데이터 규모 | 상품 10만건 |
목차
1. 배경 및 목표
커머스 서비스의 상품 목록/상세 조회 API에서 다음과 같은 성능 병목이 확인되었습니다.
| 문제 | 증상 |
|---|---|
| 인덱스 부재 | 10만건 기준 모든 쿼리가 Seq Scan, 10ms+ 소요 |
| Lost Update | 좋아요 + 어드민 수정 동시 발생 시 데이터 유실 가능 |
| 캐시 없음 | 동일 요청이 반복되어도 매번 DB 조회 |
목표: 인덱스 → 비정규화(원자적 UPDATE) → 캐시를 순차 적용하며, 각 단계별 AS-IS / TO-BE를 비교합니다.
환경
| 항목 | 값 |
|---|---|
| DB | PostgreSQL 16 (Docker) |
| 데이터 | 100,000건 (20 브랜드 x 5,000 상품) |
| 활성 상품 | 94,993건 / 삭제 상품 5,007건 (5%) |
| 캐시 | Redis 6.x (Master-Replica) |
| 프레임워크 | Spring Boot 3 + JPA |
2. Step 1: 인덱스 최적화
분석 대상 쿼리 4가지
| 쿼리 | API | 패턴 |
|---|---|---|
| Q1 | 브랜드별 인기상품 | WHERE brand_id = ? AND deleted_at IS NULL ORDER BY like_count DESC |
| Q2 | 가격순 목록 | WHERE deleted_at IS NULL ORDER BY price ASC LIMIT 20 OFFSET ? |
| Q3 | 최신순 (기본) | WHERE deleted_at IS NULL ORDER BY created_at DESC |
| Q4 | 전체 인기순 | WHERE deleted_at IS NULL ORDER BY like_count DESC |
AS-IS: 인덱스 없음 (PK만 존재)
모든 쿼리가 Seq Scan + in-memory heapsort로 동작합니다.
-- Q1: 브랜드 필터 + 좋아요순
Seq Scan on products (cost=0.00..3230.00 rows=4514)
Filter: ((deleted_at IS NULL) AND (brand_id = 1))
Rows Removed by Filter: 95247
Buffers: shared hit=1980
Execution Time: 10.757 ms
- 10만건 전체를 스캔한 뒤 95,247건을 필터링으로 버림
- 남은 4,753건을 메모리에서 heapsort
-- Q3: 최신순 정렬
Parallel Seq Scan on products (cost=0.00..2568.24 rows=55825)
Filter: (deleted_at IS NULL)
Sort Method: top-N heapsort Memory: 33kB
Execution Time: 11.774 ms
| 쿼리 | 실행 시간 | 스캔 방식 | 버퍼 |
|---|---|---|---|
| Q1 (브랜드+좋아요순) | 10.757ms | Seq Scan | 1,980 |
| Q2 (가격순+OFFSET) | 11.092ms | Parallel Seq Scan | 2,016 |
| Q3 (최신순) | 11.774ms | Parallel Seq Scan | 2,016 |
| Q4 (좋아요순) | 11.549ms | Parallel Seq Scan | 2,016 |
첫 시도: 일반 복합 인덱스 (실패)
CREATE INDEX idx_products_deleted_price ON products (deleted_at, price);
CREATE INDEX idx_products_deleted_likes ON products (deleted_at, like_count DESC);
결과: Q2~Q4에서 여전히 Seq Scan. deleted_at IS NULL의 선택도가 95%로 너무 높아 플래너가 인덱스를 선택하지 않음.
일반 복합 인덱스에서 deleted_at을 선두 컬럼으로 놓으면, NULL 값의 비율이 95%이므로 인덱스로 필터링해도 거의 전체를 읽어야 합니다. PostgreSQL 플래너는 이 경우 Seq Scan이 더 효율적이라고 판단합니다.
TO-BE: Partial Index 적용
-- 활성 상품만 인덱싱 (WHERE deleted_at IS NULL)
CREATE INDEX idx_products_active_brand_likes
ON products (brand_id, like_count DESC) WHERE deleted_at IS NULL;
CREATE INDEX idx_products_active_price
ON products (price ASC) WHERE deleted_at IS NULL;
CREATE INDEX idx_products_active_likes
ON products (like_count DESC) WHERE deleted_at IS NULL;
CREATE INDEX idx_products_active_created
ON products (created_at DESC) WHERE deleted_at IS NULL;
Partial Index를 선택한 이유:
- 인덱스 크기 축소: 5% soft-deleted 행 제외
- 정렬 키 직접 노출:
(price) WHERE deleted_at IS NULL로 인덱스 자체가 정렬 순서 반영 - 플래너 친화적:
WHERE deleted_at IS NULL조건이 인덱스 조건과 정확히 매칭 → Index Scan 유도 - JPA
@Index미사용 사유: Partial Index의WHERE절은 JPA 표준으로 표현 불가 → SQL 마이그레이션 스크립트로 관리
적용 후 EXPLAIN ANALYZE:
-- Q1: 브랜드 필터 + 좋아요순
Index Scan using idx_products_active_brand_likes on products
Index Cond: (brand_id = 1)
Buffers: shared hit=22 read=2
Execution Time: 0.082 ms ← 10.757ms → 0.082ms (131x 개선)
전후 비교
| 쿼리 | AS-IS | TO-BE | 개선율 | 스캔 전환 |
|---|---|---|---|---|
| Q1 (브랜드+좋아요순) | 10.757ms | 0.082ms | 131x | Seq Scan → Index Scan |
| Q2 (가격순+OFFSET) | 11.092ms | 0.549ms | 20x | Parallel Seq → Index Scan |
| Q3 (최신순) | 11.774ms | 0.143ms | 82x | Parallel Seq → Index Scan |
| Q4 (좋아요순) | 11.549ms | 0.157ms | 74x | Parallel Seq → Index Scan |
버퍼(I/O) 사용량:
| 쿼리 | AS-IS | TO-BE | 감소율 |
|---|---|---|---|
| Q1 | 1,980 | 24 | 98.8% |
| Q2 | 2,016 | 250 | 87.6% |
| Q3 | 2,016 | 22 | 98.9% |
| Q4 | 2,016 | 22 | 98.9% |
인덱스 오버헤드:
| 인덱스 | 크기 |
|---|---|
| idx_products_active_brand_likes | 1,264 kB |
| idx_products_active_created | 856 kB |
| idx_products_active_likes | 784 kB |
| idx_products_active_price | 672 kB |
| 총 추가 오버헤드 | 3,576 kB |
3.5MB의 인덱스 추가로 모든 핵심 쿼리에서 20~131배 성능 개선을 달성했습니다.
3. Step 2: 좋아요 동기화 구조 개선
AS-IS: Read-Modify-Write 안티패턴
기존 좋아요 처리 흐름:
LikeService.like()
├─ findProductWithLock(productId) ← 1차 비관적 락 (SELECT FOR UPDATE)
├─ existsByUserIdAndProductId() ← 중복 체크
├─ Like.create() → save() ← likes 테이블 INSERT
└─ publishEvents(like)
└─ LikeEventHandler.handle()
├─ findActiveByIdWithLock() ← 2차 비관적 락 (동일 트랜잭션)
├─ product.increaseLikeCount()← 메모리에서 likeCount + 1
└─ productRepository.save() ← 전체 엔티티 UPDATE
문제 1: Lost Update
-- 실제 발생하는 SQL: 모든 필드를 덮어씀
UPDATE products SET brand_id=?, name=?, price=?, sale_price=?,
stock_quantity=?, like_count=?, description=?, updated_at=?, deleted_at=?
WHERE id = ?
시간 Thread A (좋아요) Thread B (어드민 수정)
─────────────────────────────────────────────────────────
t1 READ product (price=100k)
t2 READ product (price=100k)
t3 price=120k → SAVE (전체 UPDATE)
t4 likeCount+1 → SAVE (전체 UPDATE)
→ price가 100k로 복원됨 ← Lost Update!
문제 2: 이중 비관적 락
같은 트랜잭션 내에서 동일 row에 대해 2번 SELECT FOR UPDATE → 불필요한 오버헤드.
TO-BE: 원자적 SQL UPDATE
LikeService.like()
├─ validateProductExists(productId) ← 단순 조회 (락 없음)
├─ existsByUserIdAndProductId() ← 중복 체크
├─ Like.create() → save()
└─ publishEvents(like)
└─ LikeEventHandler.handle()
└─ productRepository.incrementLikeCount(productId) ← 원자적 UPDATE
핵심 변경: JPA Repository에 원자적 쿼리 추가
// ProductJpaRepository
@Modifying
@Query("UPDATE ProductJpaEntity p SET p.likeCount = p.likeCount + 1 WHERE p.id = :productId")
void incrementLikeCount(@Param("productId") Long productId);
@Modifying
@Query("UPDATE ProductJpaEntity p SET p.likeCount = p.likeCount - 1 WHERE p.id = :productId AND p.likeCount > 0")
void decrementLikeCount(@Param("productId") Long productId);
발생하는 SQL:
-- AS-IS: 전체 엔티티 덮어쓰기 (11개 컬럼)
UPDATE products SET brand_id=?, name=?, price=?, sale_price=?,
stock_quantity=?, like_count=?, description=?, updated_at=?, deleted_at=?
WHERE id = ?
-- TO-BE: 단일 컬럼 원자적 업데이트
UPDATE products SET like_count = like_count + 1 WHERE id = ?
도메인 인터페이스에 의도 표현 (DDD)
// ProductRepository (도메인 레이어)
void incrementLikeCount(Long productId);
void decrementLikeCount(Long productId);
도메인 인터페이스에 incrementLikeCount라는 메서드명으로 비즈니스 의도를 표현하고, 원자적 SQL은 인프라 레이어의 구현 세부사항으로 캡슐화했습니다.
LikeService — 비관적 락 제거
// AS-IS: 비관적 락으로 상품 조회
private Product findProductWithLock(Long productId) {
return productRepository.findActiveByIdWithLock(productId)
.orElseThrow(() -> new CoreException(ErrorType.PRODUCT_NOT_FOUND));
}
// TO-BE: 단순 존재 확인 (락 불필요)
private void validateProductExists(Long productId) {
productRepository.findActiveById(productId)
.orElseThrow(() -> new CoreException(ErrorType.PRODUCT_NOT_FOUND));
}
전후 비교
| 항목 | AS-IS (Read-Modify-Write) | TO-BE (Atomic UPDATE) |
|---|---|---|
| 쿼리 수/좋아요 | SELECT FOR UPDATE x2 + UPDATE (전체) | SELECT + UPDATE (likeCount만) |
| 비관적 락 | 2회/요청 | 0회 |
| Lost Update 위험 | 있음 (어드민 동시 수정) | 없음 |
| UPDATE 대상 | 모든 컬럼 (11개) | likeCount 1개 |
| likeCount 음수 방지 | 없음 | AND p.likeCount > 0 조건 |
4. Step 3: Redis 캐시 적용
AS-IS: 캐시 없음
모든 요청이 DB 직접 조회:
GET /api/v1/products/1
→ ProductQueryService.getProduct()
→ DB SELECT (매번 실행)
→ 응답
TO-BE: Redis 캐시 레이어
GET /api/v1/products/1
→ @Cacheable("product", key="#productId")
├─ Cache HIT → Redis GET → 즉시 반환 (sub-ms)
└─ Cache MISS → DB SELECT → Redis SET (TTL 5분) → 반환
캐시 키 설계 및 TTL
| 캐시 | 키 패턴 | TTL | 설계 이유 |
|---|---|---|---|
| 상품 상세 | product::{id} |
5분 | 개별 단위 무효화 가능, 변경 시 즉시 evict |
| 상품 목록 | products::brand:{brandId}:sort:{sort}:page:{page}:size:{size} |
1분 | 조합이 많아 TTL 짧게, 변경 시 전체 evict |
| 브랜드 목록 | brands::{key} |
10분 | 변경 빈도 낮음 |
CacheConfig 핵심 구현
@Configuration
@EnableCaching
public class CacheConfig implements CachingConfigurer {
@Bean
public CacheManager cacheManager(RedisConnectionFactory connectionFactory) {
// 캐시 전용 ObjectMapper (PROPERTY 방식 타입 정보)
ObjectMapper redisObjectMapper = new ObjectMapper();
redisObjectMapper.registerModule(new JavaTimeModule());
redisObjectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
redisObjectMapper.activateDefaultTyping(
BasicPolymorphicTypeValidator.builder()
.allowIfBaseType(Object.class).build(),
ObjectMapper.DefaultTyping.EVERYTHING,
JsonTypeInfo.As.PROPERTY // ← WRAPPER_ARRAY가 아닌 PROPERTY 방식
);
// 캐시별 독립 TTL
Map<String, RedisCacheConfiguration> cacheConfigs = Map.of(
PRODUCT_DETAIL, defaultConfig.entryTtl(Duration.ofMinutes(5)),
PRODUCT_LIST, defaultConfig.entryTtl(Duration.ofMinutes(1)),
BRAND_LIST, defaultConfig.entryTtl(Duration.ofMinutes(10))
);
return RedisCacheManager.builder(connectionFactory)
.cacheDefaults(defaultConfig.entryTtl(Duration.ofMinutes(5)))
.withInitialCacheConfigurations(cacheConfigs)
.build();
}
@Override
public CacheErrorHandler errorHandler() {
return new SafeCacheErrorHandler(); // Redis 장애 시 DB fallback
}
}
ObjectMapper 설정 포인트:
DefaultTyping.EVERYTHING+JsonTypeInfo.As.PROPERTY: Record 타입도 정확한 역직렬화 보장JavaTimeModule:LocalDateTime직렬화 지원- 애플리케이션 메인
ObjectMapper와 분리하여 캐시 전용으로 사용
캐시 무효화 전략
상품 CRUD:
// ProductService
@CacheEvict(value = PRODUCT_LIST, allEntries = true)
public void createProduct(ProductCreateCommand command) { ... }
@Caching(evict = {
@CacheEvict(value = PRODUCT_DETAIL, key = "#command.productId()"),
@CacheEvict(value = PRODUCT_LIST, allEntries = true)
})
public void updateProduct(ProductUpdateCommand command) { ... }
@Caching(evict = {
@CacheEvict(value = PRODUCT_DETAIL, key = "#productId"),
@CacheEvict(value = PRODUCT_LIST, allEntries = true)
})
public void deleteProduct(Long productId) { ... }
좋아요 이벤트:
// LikeEventHandler
@EventListener
@Caching(evict = {
@CacheEvict(value = PRODUCT_DETAIL, key = "#event.productId()"),
@CacheEvict(value = PRODUCT_LIST, allEntries = true)
})
public void handle(ProductLikedEvent event) {
productRepository.incrementLikeCount(event.productId());
}
주문 (재고 감소):
// OrderService
@Caching(evict = {
@CacheEvict(value = PRODUCT_DETAIL, allEntries = true),
@CacheEvict(value = PRODUCT_LIST, allEntries = true)
})
public void createOrder(UserId userId, OrderCommand command) { ... }
브랜드 삭제 cascade:
// BrandDeletedEventHandler
@EventListener
@Caching(evict = {
@CacheEvict(value = PRODUCT_DETAIL, allEntries = true),
@CacheEvict(value = PRODUCT_LIST, allEntries = true)
})
public void handle(BrandDeletedEvent event) {
// 브랜드 하위 모든 상품 soft-delete
}
| 이벤트 | 무효화 대상 | 전략 |
|---|---|---|
| 상품 수정/삭제 | product::{id} + products::* |
해당 상세 + 목록 전체 |
| 상품 생성 | products::* |
목록 전체 |
| 좋아요 등록/취소 | product::{id} + products::* |
해당 상세 + 목록 전체 |
| 주문 (재고 감소) | product::* + products::* |
상세/목록 전체 (다수 상품 영향) |
| 브랜드 삭제 | product::* + products::* |
상세/목록 전체 (cascade) |
Redis 장애 대응: SafeCacheErrorHandler
public class SafeCacheErrorHandler implements CacheErrorHandler {
@Override
public void handleCacheGetError(RuntimeException exception, Cache cache, Object key) {
log.warn("Redis 캐시 조회 실패 - cache: {}, key: {}", cache.getName(), key);
// 예외를 던지지 않음 → @Cacheable이 Cache MISS로 처리 → DB fallback
}
// put, evict, clear도 동일하게 경고 로그만 남기고 무시
}
Redis 정상 → Cache HIT → 즉시 반환 (DB 부하 0)
Redis 장애 → SafeCacheErrorHandler → warn 로그 → DB fallback (서비스 정상)
5. 테스트 검증
테스트 결과: 273개 전체 통과
모든 변경 후 전체 테스트를 실행하여 273개 테스트가 100% 통과했습니다.
발견한 문제와 수정
문제 1: DatabaseCleanUp MySQL 문법 오류
// AS-IS (MySQL 전용): PostgreSQL에서 PSQLException 발생
entityManager.createNativeQuery("SET FOREIGN_KEY_CHECKS = 0").executeUpdate();
entityManager.createNativeQuery("TRUNCATE TABLE `" + table + "`").executeUpdate();
entityManager.createNativeQuery("SET FOREIGN_KEY_CHECKS = 1").executeUpdate();
// TO-BE (PostgreSQL 호환): CASCADE로 FK 제약 무시
entityManager.createNativeQuery(
"TRUNCATE TABLE " + table + " RESTART IDENTITY CASCADE"
).executeUpdate();
문제 2: LikeServiceTest mock 불일치
비관적 락 제거 후 findActiveByIdWithLock 대신 findActiveById를 사용하므로, 단위 테스트의 mock stubbing을 일괄 수정했습니다.
// AS-IS
when(productRepository.findActiveByIdWithLock(1L)).thenReturn(Optional.of(product));
// TO-BE
when(productRepository.findActiveById(1L)).thenReturn(Optional.of(product));
문제 3: 캐시 무효화 누락 (StockConcurrencyTest, ProductApiE2ETest)
| 테스트 | 증상 | 원인 | 수정 |
|---|---|---|---|
| StockConcurrencyTest x2 | expected: 0, but was: 100 |
주문으로 재고 감소 시 상품 캐시 미무효화 | OrderService에 @CacheEvict 추가 |
| ProductApiE2ETest x1 | 삭제된 상품이 200 반환 | 브랜드 cascade 삭제 시 캐시 미무효화 | BrandDeletedEventHandler에 @CacheEvict 추가 |
캐시를 도입할 때 모든 데이터 변경 경로에서 무효화가 누락되지 않았는지 확인해야 합니다. 상품 CRUD에만 evict를 적용하고, 주문(재고 감소)과 브랜드 삭제(cascade) 경로를 놓친 것이 원인이었습니다.
문제 4: CacheConfig JSON 역직렬화 오류
// 초기 설정: WRAPPER_ARRAY 방식 (기본값)
objectMapper.activateDefaultTyping(..., DefaultTyping.NON_FINAL)
// 오류: PUT은 성공하지만 GET에서 역직렬화 실패
// "Unexpected token (START_OBJECT), expected START_ARRAY"
원인: NON_FINAL + 기본 WRAPPER_ARRAY 방식은 JSON을 ["타입", {데이터}] 배열로 저장하는데, Record 타입과 호환되지 않았습니다.
// 수정: EVERYTHING + PROPERTY 방식
redisObjectMapper.activateDefaultTyping(
BasicPolymorphicTypeValidator.builder()
.allowIfBaseType(Object.class).build(),
ObjectMapper.DefaultTyping.EVERYTHING,
JsonTypeInfo.As.PROPERTY // {"@class":"...", "field":"value"} 방식
);
6. 최종 성능 비교
Phase별 전체 비교
| Phase | 적용 내용 | 핵심 변경 |
|---|---|---|
| Phase 0 | 현재 상태 (PK만) | Seq Scan, 비관적 락 2회, 캐시 없음 |
| Phase 1 | Partial Index 4개 | Index Scan 전환, 20~131x 개선 |
| Phase 2 | 원자적 SQL UPDATE | Lost Update 해결, 비관적 락 0회 |
| Phase 3 | Redis 캐시 | DB 조회 생략, sub-ms 응답 |
쿼리 성능
| 쿼리 | Phase 0 | Phase 1 | 개선율 |
|---|---|---|---|
| 브랜드+좋아요순 | 10.757ms (Seq Scan) | 0.082ms (Index Scan) | 131x |
| 가격순+OFFSET | 11.092ms (Parallel Seq) | 0.549ms (Index Scan) | 20x |
| 최신순 | 11.774ms (Parallel Seq) | 0.143ms (Index Scan) | 82x |
| 좋아요순 | 11.549ms (Parallel Seq) | 0.157ms (Index Scan) | 74x |
캐시 적용 후 기대 성능
| 시나리오 | Phase 1 (인덱스만) | Phase 3 (인덱스+캐시) |
|---|---|---|
| 상품 상세 (캐시 HIT) | ~0.1ms (DB) | <0.5ms (Redis GET) |
| 상품 목록 (캐시 HIT) | ~0.5ms (DB) | <0.5ms (Redis GET) |
| Redis 장애 시 | ~0.1ms | ~0.1ms (DB fallback) |
좋아요 동기화
| 항목 | AS-IS | TO-BE |
|---|---|---|
| 비관적 락 | 2회/요청 | 0회 |
| UPDATE 대상 | 모든 컬럼 (11개) | likeCount 1개 |
| Lost Update 위험 | 있음 | 없음 |
| SQL 수/요청 | 3개 | 2개 |
7. 아키텍트 리뷰 기반 구조 개선
초기 캐시 구현 후 대규모 트래픽 관점에서 구조적 리스크를 점검한 결과, 3가지 핵심 문제를 발견하고 수정했습니다.
문제 1: @CacheEvict과 @Transactional의 실행 순서 불일치
AS-IS 문제:
t1: LikeService.like() → 트랜잭션 시작
t2: Like INSERT + incrementLikeCount() 실행
t3: @CacheEvict 실행 → Redis에서 캐시 삭제됨
t4: (다른 스레드) Cache MISS → DB 조회 → 아직 커밋 전이므로 이전 값으로 캐시 재생성
t5: 트랜잭션 커밋 → DB 값 변경 확정
→ 캐시에는 이전 값이 TTL 동안 고정됨
@CacheEvict은 AOP 프록시 레벨에서 동작하므로 트랜잭션 커밋보다 먼저 실행됩니다. 이 사이에 다른 스레드가 캐시를 다시 채우면 stale 데이터가 TTL 동안 고정됩니다.
TO-BE 해결:
@EventListener(DB 작업) + @TransactionalEventListener(AFTER_COMMIT)(캐시 무효화)로 책임을 분리했습니다.
// LikeEventHandler — DB 작업만 수행 (트랜잭션 내)
@EventListener
public void handle(ProductLikedEvent event) {
productRepository.incrementLikeCount(event.productId());
}
// ProductCacheEvictHandler — 커밋 후 캐시 무효화 (신규)
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void handleLiked(ProductLikedEvent event) {
Cache cache = cacheManager.getCache(CacheConfig.PRODUCT_DETAIL);
if (cache != null) cache.evict(event.productId());
}
t1: 트랜잭션 시작
t2: @EventListener → incrementLikeCount() 실행
t3: 트랜잭션 커밋 ← DB 값 확정
t4: @TransactionalEventListener(AFTER_COMMIT) → 캐시 삭제
t5: (다른 스레드) Cache MISS → DB 조회 → 커밋된 최신 값으로 캐시 생성 ✅
ProductService, OrderService에도 동일하게 TransactionSynchronizationManager.registerSynchronization(afterCommit)을 적용하여 모든 캐시 evict가 트랜잭션 커밋 이후에 실행되도록 변경했습니다.
문제 2: PRODUCT_LIST allEntries=true — Cache Stampede 위험
AS-IS 문제:
좋아요 1회 → products::* 전체 캐시 삭제 → 수백 개의 목록 조회 요청이 동시에 Cache MISS → DB에 동시 쿼리 폭증 (Cache Stampede).
트래픽이 증가하면 좋아요 TPS도 증가하므로, 목록 캐시의 실효성이 사라지고 stampede가 반복됩니다.
TO-BE 해결:
좋아요/주문 경로에서 PRODUCT_LIST 전체 무효화를 제거했습니다.
| 이벤트 | AS-IS 무효화 | TO-BE 무효화 |
|---|---|---|
| 좋아요 등록/취소 | product::{id} + products::* |
product::{id} 만 |
| 주문 (재고 감소) | product::* + products::* |
product::{affected_ids} 만 |
| 브랜드 삭제 | product::* + products::* |
product::* + products::* (유지) |
| 상품 CRUD (어드민) | 유지 | 유지 |
- 목록 캐시의 좋아요 수/재고는 TTL 1분 이내에 자연 갱신 (eventual consistency)
- 상품 상세 캐시는 변경 즉시 evict하여 정확한 값 보장
- 상품 CRUD, 브랜드 삭제는 어드민 작업(빈도 낮음)이므로
allEntries=true유지
이 설계는 “좋아요 count가 목록에서 최대 1분 지연될 수 있다”는 비즈니스 트레이드오프를 수용합니다. 대신 Cache Stampede 위험을 근본적으로 제거합니다.
문제 3: 캐시 장애의 가시성 부재
AS-IS 문제:
SafeCacheErrorHandler가 모든 예외를 warn 로그로 삼키므로, Redis가 반쯤 죽어도 서비스는 “정상”으로 보입니다. 적중률 저하, evict 실패를 인지하는 시점이 “고객이 느린 응답을 체감할 때”가 됩니다.
TO-BE 해결:
// SafeCacheErrorHandler — Micrometer 메트릭 추가
public class SafeCacheErrorHandler implements CacheErrorHandler {
private final MeterRegistry meterRegistry;
@Override
public void handleCacheGetError(RuntimeException exception, Cache cache, Object key) {
log.warn("Redis 캐시 조회 실패 - cache: {}, key: {}", cache.getName(), key);
Counter.builder("cache.errors")
.tag("cache", cache.getName())
.tag("operation", "get")
.register(meterRegistry)
.increment();
}
// put, evict, clear도 동일
}
// CacheConfig — 통계 활성화
return RedisCacheManager.builder(connectionFactory)
.cacheDefaults(defaultConfig)
.withInitialCacheConfigurations(cacheConfigs)
.enableStatistics() // ← Micrometer 캐시 통계 노출
.build();
Prometheus /actuator/prometheus 엔드포인트에서 확인 가능한 메트릭:
| 메트릭 | 용도 |
|---|---|
cache_gets_total{result="hit"} |
캐시 적중 수 |
cache_gets_total{result="miss"} |
캐시 미스 수 |
cache_puts_total |
캐시 저장 수 |
cache_evictions_total |
캐시 무효화 수 |
cache.errors{cache, operation} |
캐시 오류 수 (커스텀) |
8. 회고
적용한 원칙
DDD:
- 도메인 레이어의
ProductRepository인터페이스에incrementLikeCount의도 표현 - 원자적 SQL, 캐시 설정은 인프라 레이어의 구현 세부사항으로 캡슐화
- Like → ProductLikedEvent → LikeEventHandler(DB) / ProductCacheEvictHandler(캐시) 관심사 분리
SOLID:
- SRP:
LikeEventHandler(DB 변경),ProductCacheEvictHandler(캐시 무효화) 각각 단일 책임 - OCP: 새로운 캐시 대상 추가 시
@Cacheable어노테이션만 추가 - DIP: 도메인 인터페이스에 의존, 구현은 인프라 레이어
안티패턴 해결:
- Read-Modify-Write → 원자적 SQL UPDATE
- 이중 비관적 락 → 락 제거
- 전체 엔티티 덮어쓰기 → 단일 컬럼 UPDATE
- 캐시 evict 타이밍 불일치 →
AFTER_COMMIT패턴 - Cache Stampede 위험 → 목록 캐시의 TTL 기반 갱신
배운 점
-
Partial Index의 위력: 일반 복합 인덱스가 실패한 곳에서 Partial Index가 성공. 선택도가 높은 조건(
deleted_at IS NULL= 95%)에서는 해당 조건 자체를 인덱스 필터로 분리하는 것이 효과적. -
캐시 무효화는 모든 변경 경로를 커버해야 한다: CRUD에만 evict를 적용하고 주문(재고 감소), 브랜드 삭제(cascade) 경로를 놓쳐 테스트 실패. 캐시를 도입할 때 데이터가 변경되는 모든 진입점을 파악하는 것이 핵심.
-
@CacheEvict과@Transactional은 같은 AOP 레벨: 캐시 evict이 트랜잭션 커밋보다 먼저 발생할 수 있다.@TransactionalEventListener(AFTER_COMMIT)또는TransactionSynchronizationManager를 사용하여 커밋 후 evict을 보장해야 한다. -
allEntries=true는 트래픽에 비례하는 시한폭탄: 좋아요 1회 = 목록 캐시 전체 소멸. 트래픽이 증가하면 캐시가 없는 것보다 나쁜 상태(Redis 왕복 + DB 쿼리)가 된다. 비즈니스 관점에서 어느 수준의 eventual consistency를 허용할지 결정하는 것이 기술적 해결보다 선행되어야 한다. -
모니터링 없는 SafeCacheErrorHandler는 위험: 예외를 삼키는 패턴은 장애 격리에 유효하지만, 메트릭 없이 사용하면 문제를 은폐한다. 캐시 적중률과 오류율에 대한 가시성은 운영의 전제 조건이다.
-
ObjectMapper 분리: 애플리케이션 메인 ObjectMapper와 Redis 캐시용 ObjectMapper를 분리해야 함. 캐시는 타입 정보(
@class)를 포함해야 역직렬화가 가능하지만, API 응답에는 타입 정보가 불필요.
파일 변경 요약
| 파일 | 변경 |
|---|---|
scripts/mock-data-100k.sql |
신규 — 10만건 테스트 데이터 |
scripts/V5__create_product_indexes.sql |
신규 — Partial Index 마이그레이션 |
infrastructure/cache/CacheConfig.java |
신규 — RedisCacheManager + TTL + enableStatistics |
infrastructure/cache/SafeCacheErrorHandler.java |
신규 — Redis 장애 격리 + Micrometer 메트릭 |
infrastructure/cache/ProductCacheEvictHandler.java |
신규 — 트랜잭션 커밋 후 캐시 무효화 |
ProductJpaRepository.java |
수정 — 원자적 increment/decrement 쿼리 |
ProductRepository.java |
수정 — 도메인 인터페이스에 원자적 메서드 |
ProductRepositoryImpl.java |
수정 — 원자적 메서드 구현 |
LikeEventHandler.java |
수정 — DB 작업만 수행, @CacheEvict 제거 |
LikeService.java |
수정 — 비관적 락 → 단순 조회 |
ProductQueryService.java |
수정 — @Cacheable 적용 |
ProductService.java |
수정 — afterCommit 캐시 무효화 |
OrderService.java |
수정 — afterCommit 개별 상품 캐시 무효화 |
BrandDeletedEventHandler.java |
수정 — DB 작업만 수행, @CacheEvict 제거 |
BrandService.java |
수정 — @CacheEvict 제거 (이벤트 핸들러로 위임) |
DatabaseCleanUp.java |
수정 — MySQL → PostgreSQL 문법 |
LikeServiceTest.java |
수정 — mock 변경 (findActiveById) |