Contents

  • 문제 상황

  • 해결 방법: Spring Cache 적용

  • nGrinder 테스트 환경 및 스크립트 구성

  • Ehcache 성능 분석

  • 캐시 성능 분석

  • 결론


문제 상황

관광지 목록 API는 다음과 같은 기능을 제공한다:

  • 지역, 시군구, 관광지 타입 조건에 따른 필터링
  • 총 5만 건 이상의 관광지 데이터를 페이지 단위로 제공

문제 상황: 반복되는 전체 개수 조회로 인한 DB 부하

페이지네이션 구현을 위해 클라이언트에 총 페이지 수를 알려줘야 하므로,
SELECT COUNT(*) 쿼리를 통해 전체 개수를 조회해야 한다. 하지만 이 쿼리는 사용자마다 반복 호출되는 문제점이 있다:

  • DB에 과도한 부하: 동일한 조건이라도 매번 COUNT 쿼리 실행
  • 불필요한 중복 쿼리: 캐싱이 없다면 매번 동일 쿼리 수행

해결 방법: Spring Cache 적용

캐시 적용 이유

  • 관광지 데이터는 변동이 적고 정적
  • 조건 조합(areaCode + sigunguCode + contentTypeId)을 캐시 키로 설정
  • 중복 호출 시 DB 접근 없이 캐시로 응답
  • 등록/수정/삭제 시 @CacheEvict를 통해 정합성 유지

구현 코드

build.gradle

implementation 'org.ehcache:ehcache:3.10.8'
implementation 'org.springframework.boot:spring-boot-starter-cache'

CacheConfig.java

@Configuration
@EnableCaching
public class CacheConfig {
    @Bean
    public JCacheManagerCustomizer cacheManagerCustomizer() {
        return cm -> {
            if (!cm.getCacheNames().contains("attractionCount")) {
                cm.createCache("attractionCount", new MutableConfiguration<>()
                    .setExpiryPolicyFactory(CreatedExpiryPolicy.factoryOf(new Duration(TimeUnit.MINUTES, 10)))
                    .setStoreByValue(false)
                    .setStatisticsEnabled(true));
            }
        };
    }
}

AttractionCountServiceImpl.java

@Service
public class AttractionCountServiceImpl implements IAttractionCountService {
    @Autowired
    private AttractionDAO dao;
 
    @Override
    @Cacheable(
        value = "attractionCount",
        key = "T(String).format('%s_%s_%s', #requestDto.areaCode, #requestDto.sigunguCode, #requestDto.contentTypeId)"
    )
    public long countAttractionsByAreaAndSigunguAndType(final AttractionRequestDto requestDto) {
        return dao.countAttractionsByAreaAndSigunguAndType(
            requestDto.getAreaCode(),
            requestDto.getSigunguCode(),
            requestDto.getContentTypeId()
        );
    }
}

AttractionServiceImpl.java

@Slf4j
@Service
@RequiredArgsConstructor
public class AttractionServiceImpl implements IAttractionService {
 
    private final AttractionDAO dao;
    private final AttractionCountServiceImpl attractionCountService;
 
    @Override
    public AttractionPageResponseDto fetchAttractionsByAreaAndSigunguAndTypeWithPaging(
        final AttractionRequestDto requestDto,
        final Pageable pageable
    ) {
        long totalItems = attractionCountService.countAttractionsByAreaAndSigunguAndType(requestDto);
 
        return buildPagedResponse(
            () -> dao.fetchAttractionsByAreaAndSigunguAndTypeWithPaging(
                requestDto.getAreaCode(), requestDto.getSigunguCode(), requestDto.getContentTypeId(), pageable),
            totalItems,
            pageable
        );
    }
 
    @Override
    @CacheEvict(value = "attractionCount", allEntries = true)
    public int addAttraction(AttractionCreateDto dto) {
        // 관광지 등록 로직
    }
 
    @Override
    @CacheEvict(value = "attractionCount", allEntries = true)
    public int deleteAttraction(int no) {
        // 관광지 삭제 로직
    }
}

nGrinder 테스트 환경 및 스크립트 구성

테스트 목적

  • 캐시 적용 전후 count() 쿼리 성능 변화 측정
  • TPS, 평균 응답 시간, 에러율 비교

테스트 환경

항목
테스트 도구nGrinder 3.5.9, Scouter
테스트 API/api/attractions/search-with-paging
검색 조건{ areaCode: 1, sigunguCode: 2, contentTypeId: 12 }
요청 방식POST (JSON Body)
사용자 수 (vUser)10 / 99 / 198
테스트 시간각 10분

nGrinder 테스트 스크립트 요약

@Test
public void test() {
    String url = "http://localhost:8080/api/attractions/search-with-paging?page=0&size=10";
 
    def requestBody = new JsonBuilder([
        areaCode      : 1,
        sigunguCode   : 2,
        contentTypeId : 12
    ]).toString();
 
    long startTime = System.currentTimeMillis();
    HTTPResponse response = request.POST(url, requestBody.getBytes("UTF-8"));
    long responseTime = System.currentTimeMillis() - startTime;
 
    if (responseTime > 600) {
        fail("응답 시간이 600ms를 초과했습니다: " + responseTime + "ms");
    } else {
        assertThat(response.statusCode, is(200));
    }
}
  • 600ms 초과 시 실패 처리하여 사용자 경험 기준 반영함

성능 지표

Ehcache 기반 캐시를 적용한 후, nGrinder + Scouter를 통해 실제 성능을 측정했으며, 성능 지표는 다음과 같다:

  • TPS (초당 처리 건수)
  • 처리량 (총 요청 수)
  • 응답시간 (평균 Elapsed Time)
  • Heap Used (메모리 사용량)
  • XLog (요청별 응답 시간 분포)

Ehcache 성능 분석

결과

구분vUserTPS평균 응답시간(ms)총 요청 수에러 수
캐시 X1081.3122.8348,4490
캐시 O101258.47.82749,2170
캐시 O99965.2102.34573,61710
캐시 O198918.9211.17545,3743,166

캐시 미적용 상태에서 vUser 99 이상으로 부하를 주면 테스트 도중 서버 과부하로 중단됨

Spring Cache(Ehcache)를 활용해 count() 쿼리를 캐싱하는 것만으로도 응답시간을 94% 단축하고, TPS를 15배 향상시킬 수 있다.


vUser 10 기준 비교

캐시 적용 X

캐시 미적용 TPS

캐시 적용 O

캐시 적용 TPS

  • TPS: 약 1258.4 → 15배 증가
  • 처리량: 749,217 → 약 15.5배 증가
  • 응답 시간: 7.82ms → 94% 감소

vUser 99 vs vUser 198 비교

vUser 99 (캐시 적용)

TPS 99
Scouter 99

  • TPS: 약 965 → 안정적
  • 응답 시간: 평균 102.34ms → 실사용 범위
  • Heap Used: 100~300MB 구간에서 안정적 (평균 약 250MB)
  • XLog: 대부분 100~200ms 내에 응답

해석:

  • TPS와 응답 시간이 균형 있게 유지되고 있음
  • XLog 분포도 정상적이며 시스템 안정성 확보 가능
  • Heap 사용량이 일정 범위에서 안정적으로 유지되어 GC 지연이 발생하지 않음

vUser 198 (캐시 적용)

TPS 198
Scouter 198

  • TPS: 약 918.9 → 큰 하락 없이 유지
  • 응답 시간: 평균 211.17ms → 두 배 증가
  • 에러 수: 3,166건 발생 (약 0.6%)
  • Heap Used: 100~400MB 구간, 평균 약 300MB로 증가
  • XLog: 일부 요청이 1초 이상 소요

해석:

  • CPU 및 메모리 리소스가 임계치에 가까워지며 GC 또는 스레드 병목 가능성이 커짐
  • Heap 사용량이 평균 300MB 수준으로 증가, 처리 요청이 많아질수록 객체 생성과 캐시 데이터가 메모리에 축적됨
  • GC가 비동기적으로 수행되면서 순간적인 응답 지연이 발생 → 일부 요청이 1초 이상 소요됨
  • TPS는 유지되나, 응답시간 증가 + 에러 발생은 시스템 한계에 도달했다는 신호

Redis 성능 분석

Spring Cache는 다양한 구현체(Ehcache, Redis 등)를 추상화하여 사용할 수 있는 유연한 구조이다. 이번 실험에서는 기존 Ehcache 기반의 캐시를 Redis 기반 분산 캐시로 전환한 후 성능을 측정하고 비교 분석해본다.

결과

구분vUserTPS평균 응답시간(ms)총 요청 수에러 수
Redis 적용10900.010.91536,5590
Redis 적용99901.2109.68535,4544
Redis 적용198904.4214.35538,5093,273

💡 Ehcache에 비해 TPS는 소폭 낮지만, 분산 구조로 인한 확장성과 안정성에서 유리


vUser: 10 기준

nGrinder TPS
Scouter 상태

  • TPS: 900.0 → 안정적
  • 응답 시간: 평균 10.91ms
  • Heap Used: 100~200MB로 낮게 유지
  • XLog: 대부분 10~50ms 내 처리

해석:

  • Ehcache(1258.4 TPS)보다 약간 느리지만, Redis는 외부 메모리 기반이라 Heap 사용량이 현저히 낮음
  • 네트워크 오버헤드가 있지만 실 사용에는 큰 영향 없음

vUser: 99 기준

TPS 99
Scouter 99

  • TPS: 901.2 → 꾸준한 처리 유지
  • 응답 시간: 109.68ms
  • Heap Used: 평균 약 150MB
  • XLog: 대부분 100~300ms 분포

해석:

  • Redis는 JVM Heap에 캐시를 저장하지 않기 때문에 Ehcache보다 메모리 사용량이 적음
  • TPS와 응답 시간도 Ehcache와 유사 수준으로 안정적

vUser: 198 기준

TPS 198
Scouter 198

  • TPS: 904.4 → 큰 하락 없이 유지
  • 응답 시간: 평균 214.35ms
  • 에러 수: 3,273건 (0.6%)
  • Heap Used: 평균 250MB, 최대 300MB 근접
  • CPU 사용률: 90% 이상 → 과부하 경고 다수
  • XLog: 일부 요청은 1초 이상 지연

해석:

  • Redis도 vUser 198 수준에서는 CPU 및 GC의 영향을 받으며 처리 지연 발생
  • 다만, TPS는 일정하게 유지되어 부하 분산 구조의 안정성을 확인할 수 있음

캐시 전략별 성능 종합 비교

항목캐시 미적용EhcacheRedis
TPS (10 vUser)81.31258.4900.0
TPS (198 vUser)테스트 중단918.9904.4
평균 응답시간122.83ms7.82ms10.91ms
처리량48,449749,217536,559
에러 수 (198)테스트 실패3,1663,273
Heap 사용량비교적 낮음 (단순 쿼리)최대 400MB (평균 300MB)최대 300MB (평균 250MB)
캐시 저장 위치없음JVM 내부 메모리외부 Redis 서버 (네트워크)
확장성매우 낮음단일 서버 수준다중 서버/클러스터 가능
장애 대응해당 없음서버 재시작 시 캐시 소멸Replication, HA 구성 가능
인스턴스 간 공유불가능불가능가능

결론

  • 캐시 미적용: 단순한 구조이지만, 고트래픽에서 부하를 견디지 못하고 성능 저하

  • Ehcache: 빠른 성능과 간편한 설정 → 개발 초기/소규모 서비스에 최적

  • Redis: TPS는 약간 낮지만 메모리 효율, 확장성, 다중 인스턴스 대응 능력 탁월 → 운영 환경/고부하 서비스에 적합

캐시 성능을 최대한 끌어올리기 위해서는 애플리케이션 수준뿐 아니라 인프라 자원(스레드, 커넥션 등)의 튜닝도 함께 고려되어야 한다.