DB 인덱스 설계와 Redis 캐싱(Port-Adapter) 적용기

2025. 8. 15. 00:46·Spring

TL;DR

  • 조회 시나리오(브랜드/카테고리/정렬)에 맞춰 복합 인덱스 4종을 설계·적용.
  • 커버링 인덱스 개념을 이해하고 적용 가능성 검토(이번 과제에선 부분 적용).
  • 캐시 계층은 Port-Adapter 구조로 설계: GenericCachePort(공통) + 도메인 전용 포트(ProductCache 등).
  • 빈 값 미캐싱 + 분산락(SETNX) 으로 캐시 스탬피드 최소화.
  • 기본 필터의 상품목록 1–3페이지만 캐싱하는 부분 캐싱 정책으로 메모리/일관성 균형.

1) 문제 정의

  • 상품 목록/상세 조회 트래픽이 증가하면서 정렬 + 필터 조합의 쿼리 비용이 커짐.
  • Redis 캐시를 적용하려는데, 서비스마다 유사한 캐시 로직이 중복될 우려.
  • 캐시 스탬피드, 빈 값 캐싱, 직렬화 문제 등 운영 이슈를 예방할 구조가 필요.

2) 접근 방법

데이터베이스

  • 실제 조회 유즈케이스(브랜드, 카테고리, 정렬 순서)에서 정렬 조건을 포함한 복합 인덱스로 접근.
  • ORDER BY like_count DESC, id DESC / ORDER BY basic_price ASC, id DESC 패턴에 맞춘 설계.
  • 커버링 인덱스: 인덱스만으로 쿼리를 처리해 테이블 접근을 줄이는 기법.다만 “정렬 컬럼 + where 컬럼”을 인덱스 선두로 두어 filesort 제거와 범위 축소를 달성.
  • 이번 과제에서는 select 컬럼 범위가 넓어 완전한 커버링 구성은 부분적으로만 가능.

캐시

  • 상품 목록 조회에서 기본 필터 + 13페이지(0-based 02) 만 캐싱
  • 이유: 호출 빈도는 높지만 데이터 변경이 잦지 않고, 후반 페이지는 트래픽 비율이 낮지 않을까? 라고 판단하였습니다.
  • 캐시 미스 시 DB 조회 후 Redis 저장

3) 구현

3-1. 인덱스 설계 (MySQL)

-- UC1: 브랜드 + 카테고리 + 좋아요순
CREATE INDEX idx_brand_category_like_id
    ON products (brand_id, product_category, like_count DESC, id DESC);
    
-- UC2: 브랜드 + 카테고리 + 가격순
CREATE INDEX idx_brand_category_price_id
    ON products (brand_id, product_category, basic_price ASC, id DESC);

-- UC3: 브랜드 + 좋아요순
CREATE INDEX idx_brand_like_id
    ON products (brand_id, like_count DESC, id DESC);

-- UC4: 브랜드 + 가격순
CREATE INDEX idx_brand_price_id
    ON products (brand_id, basic_price ASC, id DESC);

이는 실제 사용 시나리오를 기준으로 설계한 인덱스이며, 단순 단일 컬럼 인덱스보다 실행 계획 상에서 filesort를 줄이는 데 도움이 되었습니다.

다만, 커버링 인덱스는 select 컬럼이 많아 완전 적용은 어렵고, 필요한 컬럼 범위 안에서만 부분 적용했습니다.

 

3-2. 캐시 Port-Adapter 구조

공통 Port (도메인 불가지, 제네릭)

public interface GenericCachePort {
    <T> T get(String key, TypeReference<T> typeRef);
    <T> void put(String key, T value, Duration ttl);
    void evict(String key);
    <T> T getOrLoad(String key, Duration ttl, TypeReference<T> typeRef, Callable<T> loader);
}

 

Redis 어댑터(중복 로직 한 곳에)

@Component
@RequiredArgsConstructor
public class RedisGenericCachePort implements GenericCachePort {
    private final StringRedisTemplate redis;
    private final ObjectMapper objectMapper;

    @Override
    public <T> T get(String key, TypeReference<T> typeRef) {
        String json = redis.opsForValue().get(key);
        if (json == null) return null;
        try { return objectMapper.readValue(json, typeRef); }
        catch (IOException e) { throw new RuntimeException(e); }
    }

    @Override
    public <T> void put(String key, T value, Duration ttl) {
        try {
            String json = objectMapper.writeValueAsString(value);
            redis.opsForValue().set(key, json, ttl.toMillis(), TimeUnit.MILLISECONDS);
        } catch (JsonProcessingException e) { throw new RuntimeException(e); }
    }

    @Override
    public void evict(String key) { redis.delete(key); }

    @Override
    public <T> T getOrLoad(String key, Duration ttl, TypeReference<T> typeRef, Callable<T> loader) {
        T cached = get(key, typeRef);
        if (cached != null && !isEmptyValue(cached)) return cached;

        String lockKey = "lock:" + key;
        boolean acquired = Boolean.TRUE.equals(redis.opsForValue().setIfAbsent(lockKey, "1", 5, TimeUnit.SECONDS));
        try {
            if (!acquired) {
                try { Thread.sleep(50); } catch (InterruptedException ignored) {}
                T again = get(key, typeRef);
                if (again != null && !isEmptyValue(again)) return again;
            }
            T loaded = loader.call();
            if (loaded != null && !isEmptyValue(loaded)) put(key, loaded, ttl);
            return loaded;
        } catch (Exception e) {
            throw new RuntimeException("cache loader failed: " + e.getMessage(), e);
        } finally {
            if (acquired) redis.delete(lockKey);
        }
    }

    private boolean isEmptyValue(Object v) {
        if (v == null) return true;
        if (v instanceof CharSequence s) return s.isEmpty();
        if (v instanceof Collection<?> c) return c.isEmpty();
        if (v instanceof Map<?, ?> m) return m.isEmpty();
        if (v.getClass().isArray()) return Array.getLength(v) == 0;
        if (v instanceof Optional<?> o) return o.isEmpty();
        return false;
    }
}

 

도메인 포트(얇은 래퍼: 키/TTL만 책임)

public interface ProductCache {
    GenericCachePort delegate();
    Duration ttl();

    default String key(Long productId) { return "product:v1:" + productId; }

    default Product getOrLoad(Long productId, Callable<Product> loader) {
        return delegate().getOrLoad(key(productId), ttl(), new TypeReference<Product>() {}, loader);
    }
    default void put(Long productId, Product product, Duration ttl) {
        delegate().put(key(productId), product, ttl);
    }
    default void evict(Long productId) {
        delegate().evict(key(productId));
    }
}

@RequiredArgsConstructor
@Repository
public class ProductCacheImpl implements ProductCache {
    private final GenericCachePort delegate;
    @Override public GenericCachePort delegate() { return delegate; }
    @Override public Duration ttl() { return Duration.ofMinutes(5); }
}

 

 

3-3. 페이지 캐시 정책(부분 캐싱)

  • 기본 필터(브랜드/카테고리/키워드 없음 등)가 적용된 목록 중 1–3페이지만 캐싱.
  • 첫 페이지 인덱스는 0이므로, page in [0, 2] 범위만 캐시.
@Transactional(readOnly = true)
public Page<Product> searchByConditionWithPaging(ProductCriteria criteria, Pageable pageable) {
    int page = pageable.getPageNumber();     // 0-based
    boolean isDefaultFilter = criteria.isDefault();
    boolean isCacheablePage = page >= 0 && page <= 2;

    if (isDefaultFilter && isCacheablePage) {
        return productListCache.getOrLoad(page, () -> productRepository.findAllByCriteria(criteria, pageable));
    }
    return productRepository.findAllByCriteria(criteria, pageable);
}

 


4) 성과/수치 (요약)

  • 인덱스 적용 후, 주요 UC에서 filesort 제거 및 인덱스 스캔 비중 증가.
  • p95 기준 ~0.8–1.8ms 개선(케이스별 상이).
  • 캐시 도입 후 DB 조회 수 감소, 최초 미스/갱신 시에도 스탬피드 억제.

5) 트러블슈팅

  • 빈 리스트가 캐시되며 miss 재조회가 막히는 문제
  • → isEmptyValue() 정책으로 빈 값 미캐싱 처리.
  • 캐시 스탬피드 → SETNX + TTL 로 분산 락 구현, 실패 시 짧은 대기 후 재확인.
  • 테스트 정합성 → DB truncate + Redis flush 유틸 추가로 테스트 간섭 제거.

6) 캐시 무효화 전략: 이벤트 발행 기반 설계

이번 과제에서 캐시를 적용하면서 가장 신경 쓴 부분 중 하나가 캐시 무효화 정책이었습니다.

단순 TTL 만으로는 데이터 변경 직후의 짧은 불일치(데이터 stale) 문제를 완전히 해결하기 어렵습니다.

왜 이벤트 발행으로 했는가?

  • 실시간성: 상품명, 가격, 옵션 정보가 변경되었을 혹은 상품이 추가되었을 경우 즉시 캐시를 무효화해야 사용자에게 최신 정보가 보입니다.
  • 트랜잭션 안전성: 스프링 @TransactionalEventListener의 AFTER_COMMIT 단계에서 이벤트를 처리하면, DB 커밋이 확정된 이후에만 캐시 무효화를 실행할 수 있습니다. 이는 실패/롤백 시 캐시만 잘못 날아가는 상황을 방지합니다.

적용 시나리오

  • 상품 정보 변경 이벤트 → 상품 단건 캐시 무효화
  • 상품 옵션 변경 이벤트 → 해당 상품 상세 캐시 무효화 (옵션 포함)
  • 신규 상품 등록 이벤트 → 상품 목록 캐시 무효화 (1~3 페이지)

구현 예시

public record ProductChangedEvent(
        Long productId
) {
}


@Slf4j
@RequiredArgsConstructor
@Component
public class ProductCacheRefresher {
    private final ProductCache productCache;
    private final ProductRepository productRepository;

    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    @Transactional(readOnly = true, propagation = Propagation.REQUIRES_NEW)
    public void onProductChanged(ProductChangedEvent event) {
        Long productId = event.productId();
        try {
            productCache.evict(productId);

            Product p = productRepository.findWithBrandById(productId)
                    .orElseThrow(() -> new CoreException(
                            ErrorType.PRODUCT_NOT_FOUND, "존재하지 않는 상품입니다. id=" + productId));

            productCache.put(productId, p, productCache.ttl());
            log.info("[ProductCacheRefresher] refreshed cache for productId={}", productId);
        } catch (Exception e) {
            log.warn("[ProductCacheRefresher] failed to refresh product cache. productId={}", productId, e);
        }
    }
}

효과

  • 데이터 최신성 유지: 변경 즉시 캐시 제거 → 다음 조회 시 DB에서 최신 데이터 로드 후 캐시 반영
  • 코드 분리: 도메인 로직과 캐시 로직 분리로 유지보수성 향상
  • 운영 안정성: 트랜잭션 롤백 시 캐시 변경이 발생하지 않아 데이터 불일치 최소화

7) 회고 & 배운 점

이번 과제를 하면서 인덱스 설계는 데이터 모델이 아니라 실제 사용 패턴에서 출발해야 한다는 것을 배웠습니다.

복합 인덱스가 단일 인덱스보다 체감 성능 향상에 크게 기여했고, 커버링 인덱스는 이론적으로 강력하지만 현실적으로는 선택과 집중이 필요하다는 점을 깨달았습니다.

 

캐시 구조 설계에서는 Port-Adapter 패턴이 반복 로직 제거와 유지보수성 향상에 효과적임을 확인했습니다.

또한, 엔티티 캐싱 시 직렬화 문제 가능성을 학습했고, 가능하면 DTO 캐싱을 기본으로 하고, 불가피할 경우 fetch-join으로 완전 로딩 후 캐싱하는 습관을 가져야겠다고 생각했습니다.

 

마지막으로, 도메인 특화 캐싱 정책(1~3페이지 제한)은 메모리 사용량, 데이터 일관성, 성능 간의 균형을 잡는 데 도움이 되었고, 이런 정책 설계 과정이 실무에서도 중요하다는 점을 느꼈습니다.

 

'Spring' 카테고리의 다른 글

ApplicationEvent로 비즈니스 경계 나누기: 고민과 선택  (3) 2025.08.28
결제 시스템에 Circuit Breaker를 도입하며 – 계층별 책임 분리와 안정성 확보 전략  (1) 2025.08.22
Spring 트랜잭션 핵심 정리  (2) 2025.08.08
도메인 검증 책임은 어디에 둘까요? – Service vs Facade, 저는 이렇게 선택했습니다  (2) 2025.08.01
@Transactional 내부 호출 문제와 해결 방법  (0) 2025.03.12
'Spring' 카테고리의 다른 글
  • ApplicationEvent로 비즈니스 경계 나누기: 고민과 선택
  • 결제 시스템에 Circuit Breaker를 도입하며 – 계층별 책임 분리와 안정성 확보 전략
  • Spring 트랜잭션 핵심 정리
  • 도메인 검증 책임은 어디에 둘까요? – Service vs Facade, 저는 이렇게 선택했습니다
JoshDev
JoshDev
    • 분류 전체보기 (24)
      • Java (3)
      • Spring (9)
      • Test Code (2)
      • WIL (6)
      • Vue.js (2)
      • WEB (0)
      • DB (1)
        • MySQL (1)
  • 인기 글

  • hELLO· Designed By정상우.v4.10.4
JoshDev
DB 인덱스 설계와 Redis 캐싱(Port-Adapter) 적용기
상단으로

티스토리툴바