메랜샵 - 캐시 도입으로 조회 응답 시간 81.5% 단축 (650ms → 120ms)

Murhyun2·2025년 8월 10일
4

메랜샵

목록 보기
2/7
post-thumbnail

mashop.kr

메랜샵 서비스 런칭 2주차, 특정 지역의 조회가 간헐적으로 느려진다는 사용자 피드백을 받았습니다.
특히 사용량이 급증하는 루더스 호수(루디브리엄) 지역에서 문제가 두드러졌는데요.
24시간 내 모든 거래 내역을 조회하는 비교적 단순한 API임에도 불구하고, 왜 체감 속도가 떨어졌을까요?

사용자 피드백을 쭉 정리해보니 패턴이 보였습니다:

  • 특정 지역(루더스 호수)에서만 집중적으로 발생
  • 평상시엔 괜찮은데 사람 많을 때만 느려짐
  • 그것도 24시간 거래 내역 조회할 때만

이번 글에서는 인덱스 최적화만으로는 한계가 있었던 성능 저하의 원인을 분석하고,
캐시(Cache) 전략을 도입하여 응답 시간을 650ms에서 120ms까지 개선한 과정과 그 결과를 공유합니다.

⚠️ 주의: 아래에 등장하는 서비스명, 지역명, API 경로, 엔티티명, 데이터 값은 모두 임의의 예시이며 실제 서비스와 무관합니다.


문제의 원인: 반복되는 정렬 작업의 비용

느려짐의 원인은 데이터베이스(DB) 단에서 발생하고 있었습니다.
저희의 지역 조회 API는 다음과 같은 로직을 가집니다.

SELECT * 
FROM tb_sample
WHERE col_region = ? 
  AND col_created >= ?
  AND col_deleted = false
ORDER BY col_created DESC

단순해 보이지만, 이 구조에는 두 가지 성능 저하 포인트가 있었습니다.

  1. 정렬 비용(Filesort)

    특정 조건으로 데이터를 찾은 후, 최신순으로 보여주기 위해 생성시간으로 정렬하는 과정에서 filesort가 발생했습니다.

  2. 반복적인 동일 쿼리

    인기 지역(Hot Region)은 짧은 시간 동안 수많은 동일 조회 요청이 집중되는 패턴을 보였습니다.

또한, 페이지네이션 없이 24시간 내 모든 결과를 한 번에 보여줘야 했기 때문에 결과 세트의 크기 자체가 부담이었습니다.


해결 전략: 캐시로 DB 부하를 흡수

인덱스를 추가했지만 정렬 비용을 완전히 해소하기 어렵다고 판단하여, 캐시(Cache) 를 도입했습니다. 특히 인기 지역의 경우 같은 요청이 짧은 시간에 몰리는 패턴이었으니까요.

저희 상황을 고려해봤을 때, 로컬 메모리 캐시(Caffeine) 가 가장 적합해 보였습니다. 이유는:

  1. 응답 속도가 가장 중요했고
  2. 서비스 규모가 아직 크지 않아서 서버 간 동기화는 큰 문제가 아니었고
  3. 별도 인프라 구축 없이 빠르게 적용할 수 있었기 때문입니다

캐시 전략 설계

구체적으로 이런 전략으로 설계했습니다:

  • 짧은 TTL (20초): 데이터 최신성 보장
  • 이벤트 기반 무효화: 아이템 등록/수정 시 즉시 캐시 삭제
  • 권한별 분리: 관리자는 캐시 미사용 (실시간 데이터 필요)
  • 키 정규화: 같은 요청을 하나로 묶어서 히트율 향상

구현 상세: 캐시 적용과 무효화

1. 캐시 설정부터

일단 Caffeine 라이브러리를 사용해서 캐시를 설정했습니다:

@Configuration
@EnableCaching
public class CfConfig {
    @Bean
    public CacheManager cacheManager() {
        CaffeineCache c1 = buildCache("cache_a1", 20);
        CaffeineCache c2 = buildCache("cache_a2", 20);
        return new ConcurrentMapCacheManager("cache_a1", "cache_a2");
    }
}

2. 지역 조회에 캐시 적용

가장 문제가 됐던 지역 조회 API에 캐시를 적용했습니다:

@Cacheable(
    value = "cache_a1",
    key = "#kw == null ? null : normalizeKey(#kw)"
)
public List<ResDto> findByTag(String kw) {
    // DB 조회 로직
}

3. 키워드 조회 캐시 적용

키 정규화 함수도 만들었습니다. 대소문자나 공백 차이 때문에 같은 요청이 다른 캐시로 들어가는 걸 방지하려고요:

private String normalizeKey(String kw) {
    return kw.trim().toLowerCase().replaceAll("\\s+", "_");
}

4. 캐시 무효화

데이터가 변경될 때마다 캐시를 삭제하도록 했습니다:

@CacheEvict(value = {"cache_a1", "cache_a2"}, allEntries = true)
public void createNewItem(ReqCreate req) {
    repo.save(newEntity);
}

@CacheEvict(value = {"cache_a1", "cache_a2"}, allEntries = true)
public void updateItem(Long id, ReqUpdate req) {
    repo.save(updatedEntity);
}

5. 권한별 처리

관리자는 실시간 데이터를 봐야 하니까 캐시를 우회하도록 했습니다:

public List<ResDto> searchData(String kw, UserInfo principal) {
    if (principal != null && principal.isAdmin()) {
        return repo.findActiveByKeyword(kw);
    }
    return getCachedResult(kw);
}

6. 추가 최적화: 프론트엔드와의 협업

백엔드 캐시 적용과 함께, 프론트엔드에서도 추가 최적화를 진행해주었습니다:

  • 무한 스크롤 도입: 24시간 전체 데이터를 한번에 로드하는 대신 페이지네이션 방식으로 변경
  • 이미지 Lazy Loading: 유저 디스코드 프로필 이미지를 필요할 때만 로드하도록 개선

이런 프론트엔드 최적화까지 더해져서 실제 사용자가 체감하는 로딩 속도는 더욱 빨라졌습니다.

결과는 어땠을까?

항목변경 전변경 후개선 효과
지역 조회 평균 응답 시간240ms80ms66.7% 감소
지역 조회(루더스호수) 응답 시간650ms120ms81.5% 감소
키워드 조회 평균(일반)270ms90ms66.6% 감소
DB 호출 수 (동일 요청)~20회1회획기적 감소

특히 피크 시간대에 성능이 많이 안정화됐습니다. 사용자 피드백도 "이제 빨라졌다"는 긍정적인 반응이 많이 들어왔고요.

마치며

  1. 캐시는 강력한 도구
    정렬 비용과 반복 조회 문제 해결에 큰 효과를 봤습니다.
  2. 디테일의 힘
    캐시 키 정규화가 히트율에 큰 차이를 만들었습니다.
  3. 유연한 설계
    권한별 캐시 적용 분리로 보안과 성능을 동시에 확보했습니다.

처음에는 "그냥 서버 좀 더 좋은 걸로 바꾸면 되는 거 아닌가?" 하고 생각했는데, 막상 체계적으로 접근해보니 생각보다 고려할 게 많더라고요.
특히 사용자 피드백을 단순히 "느리다"로만 받아들이지 않고, 언제, 어디서, 어떤 상황에서 느린지 패턴을 찾아보는 게 중요했습니다. 그 과정에서 진짜 원인을 찾을 수 있었고, 효과적인 해결책도 도출할 수 있었던 것 같아요.
다음에 또 이런 성능 이슈가 생기면, 이번 경험을 바탕으로 더 빠르고 정확하게 해결할 수 있을 것 같습니다.

profile
왜?를 생각하며 개발하기

0개의 댓글