Loopers

상품 조회 성능 최적화 — 인덱스, 비정규화, 캐시 테스트 결과

QA

상품 조회 성능 최적화 — 인덱스, 비정규화, 캐시

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. 배경 및 목표
  2. Step 1: 인덱스 최적화
  3. Step 2: 좋아요 동기화 구조 개선
  4. Step 3: Redis 캐시 적용
  5. 테스트 검증
  6. 최종 성능 비교
  7. 회고

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를 선택한 이유:

  1. 인덱스 크기 축소: 5% soft-deleted 행 제외
  2. 정렬 키 직접 노출: (price) WHERE deleted_at IS NULL로 인덱스 자체가 정렬 순서 반영
  3. 플래너 친화적: WHERE deleted_at IS NULL 조건이 인덱스 조건과 정확히 매칭 → Index Scan 유도
  4. 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 기반 갱신

배운 점

  1. Partial Index의 위력: 일반 복합 인덱스가 실패한 곳에서 Partial Index가 성공. 선택도가 높은 조건(deleted_at IS NULL = 95%)에서는 해당 조건 자체를 인덱스 필터로 분리하는 것이 효과적.

  2. 캐시 무효화는 모든 변경 경로를 커버해야 한다: CRUD에만 evict를 적용하고 주문(재고 감소), 브랜드 삭제(cascade) 경로를 놓쳐 테스트 실패. 캐시를 도입할 때 데이터가 변경되는 모든 진입점을 파악하는 것이 핵심.

  3. @CacheEvict@Transactional은 같은 AOP 레벨: 캐시 evict이 트랜잭션 커밋보다 먼저 발생할 수 있다. @TransactionalEventListener(AFTER_COMMIT) 또는 TransactionSynchronizationManager를 사용하여 커밋 후 evict을 보장해야 한다.

  4. allEntries=true는 트래픽에 비례하는 시한폭탄: 좋아요 1회 = 목록 캐시 전체 소멸. 트래픽이 증가하면 캐시가 없는 것보다 나쁜 상태(Redis 왕복 + DB 쿼리)가 된다. 비즈니스 관점에서 어느 수준의 eventual consistency를 허용할지 결정하는 것이 기술적 해결보다 선행되어야 한다.

  5. 모니터링 없는 SafeCacheErrorHandler는 위험: 예외를 삼키는 패턴은 장애 격리에 유효하지만, 메트릭 없이 사용하면 문제를 은폐한다. 캐시 적중률과 오류율에 대한 가시성은 운영의 전제 조건이다.

  6. 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)