Modu Square

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

문제

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

해결 전략

  • Article Service에서 404를 받은 게시글 ID는 Redis에 MISSING 값으로 저장하고 TTL 60초 적용
  • 부재 캐시가 있으면 원본을 조회하지 않고 즉시 404 반환
  • 같은 ID의 동시 요청은 한 건만 원본을 조회하고 나머지는 캐시 생성까지 대기

기술 선택 이유

  • Bloom Filter - 제외

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

    • 게시글 생성과 삭제 때 별도 동기화가 필요 없음
    • 서비스에서 이미 사용하고 있는 Redis에 키 하나만 추가해 적용
클라이언트Read ServiceRedisMySQL최초 요청게시글 조회조회 결과 확인값 없음SET NX 락락 획득원본 조회게시글 없음MISSING 저장404 반환후속 요청게시글 조회MISSING 확인부재 확인MySQL 조회 없이 404 반환

구현

// 부재 캐시가 있으면 원본을 조회하지 않고 즉시 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초
비교변경 전, 부재 캐시
지표변경 전부재 캐시
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건 확인

한계

  • 락을 얻지 못한 요청은 2초까지만 대기해 원본 조회가 그보다 길어지면 대기 중인 요청이 한꺼번에 503으로 실패
  • 존재하지 않는 ID가 서로 다른 값으로 대량 유입되면 ID마다 원본을 한 번씩 조회. 검증은 삭제된 인기글 ID 1개로 단일 ID 집중만 측정