DEVELOPMENT NOTE / Modu Square

삭제된 게시글 반복 조회 문제 해결

문제

클라이언트Read ServiceRedisArticle ServiceMySQL같은 ID 요청캐시 확인값 없음원본 조회MySQL 조회게시글 없음404 반환404 반환
  • 인기글 삭제 뒤에도 검색 결과·공유 링크·방문 기록에 기존 URL이 남아 같은 게시글 ID 조회 반복
  • Redis에 조회 결과가 없는지 실제로 게시글이 없는지 구분하지 못하므로 요청마다 Article Service와 MySQL 재조회

기술 선택 이유

  • Bloom Filter — 제외

    • 게시글 생성·삭제 때마다 동기화와 재구축 필요. 단일 ID 집중 문제에 비해 운영 부담이 큼
  • 짧은 부재 캐시 — 채택

    • 원본에서 404가 확인된 ID에 MISSING 값을 60~70초 저장
    • 타임아웃과 5xx는 캐시하지 않아 원본 장애를 게시글 부재로 오인하지 않음
    • 조회 모델과 부재 캐시가 모두 없을 때만 Redis SET NX로 3초 잠금 획득
    • 잠금을 얻은 요청 한 건만 원본을 확인하고 나머지는 결과 생성까지 대기
클라이언트Read ServiceRedisMySQL최초 요청게시글 조회조회 결과 확인값 없음SET NX 잠금잠금 획득원본 조회게시글 없음MISSING 저장404 반환후속 요청게시글 조회MISSING 확인부재 확인MySQL 조회 없이 404 반환
최초 요청만 MySQL에서 게시글 부재를 확인하고, 같은 ID의 후속 요청은 Redis에서 바로 반환

구현

// 부재 캐시가 있으면 원본을 조회하지 않고 즉시 404 반환
if (missingArticleCacheRepository.isMissing(articleId)) {
    throw articleNotFound(articleId);
}

// 동일 ID의 동시 원본 조회는 한 요청만 수행
String lockOwner = UUID.randomUUID().toString();
if (!articleLookupLockRepository.tryAcquire(articleId, lockOwner)) {
    // 잠금을 얻지 못한 요청은 캐시가 생성될 때까지 대기
    return awaitLookupResult(articleId);
}

try {
    // 조회 모델과 부재 캐시가 모두 없으므로 원본 서비스에서 게시글 조회
    Optional<ArticleQueryModel> loaded = fetch(articleId);
    if (loaded.isEmpty()) {
        // 확인된 404를 짧게 저장해 후속 원본 조회 차단
        missingArticleCacheRepository.markMissing(articleId);
    }
    return loaded;
} finally {
    // 예외가 발생해도 자신이 획득한 잠금 해제
    articleLookupLockRepository.release(articleId, lockOwner);
}
  • 잠금 획득 후 조회 모델과 부재 캐시를 다시 확인해 중복 원본 조회 방지
  • 잠금을 얻지 못한 요청은 최대 2초 동안 조회 결과나 MISSING 생성 대기

부하 테스트

항목설정
대상삭제된 인기글 ID 1개
부하2,000 req/s · 30초
방식k6 constant-arrival-rate
비교변경 전 · 부재 캐시

검증 결과

지표변경 전부재 캐시
Article Service 원본 조회55,688건1건
p95859.05ms1.26ms
시작하지 못한 요청4,313건0건

변경 전후 p95와 p99

  • p95 859.05ms → 1.26ms, p99 953.09ms → 6.24ms로 감소

누적 미실행 요청 변화

  • 시작하지 못한 요청 4,313건 → 0건, 원본 조회 55,688건 → 1건 확인