- 캐시 성능을 정량적으로 분석하는 주요 지표
- 적중률(Hit Ratio)를 계산하고 해석하는 법
- 캐시가 응답 시간에 미치는 영향을 비교 및 분석
- 메모리 샤용량과 병목 현상을 모니터링하는 법
- 실무에서 캐시 성능을 점검하고 개선 방향 수립하기
캐시 성능 분석 개요
캐시는 응답속도를 향상시키기 위한 핵심 구성 요소이지만, 무조건 캐시를 사용한다고 해서 성능이 개선되는건 아님
효율적 캐시 설계를 위해선 측정 -> 분석 -> 개선의 반복적 검증 과정이 필요함
1. 캐시 성능 지표의 중요성
| Hit Ratio(캐시 적중률) | 캐시에서 데이터를 성공적으로 반환한 비율 |
| Miss Ratio(캐시 미적중률) | 캐시가 데이터를 찾지 못해 원본 데이터 소스를 조회한 비율 |
| Eviction Count(캐시 제거 횟수) | 용량 초과 또는 만료로 제거된 항목의 수 |
| Load Time(데이터 로딩 시간) | 캐시 미적중시 데이터를 새로 불러오는 데 걸린 시간 |
| Memory Usage(메모리 사용량) | 캐시 데이터가 차지하는 메모리의 크기 |
캐시 적중률(Hit Ratio) 측정
1. 개념
캐시 적중률은 캐시의 성능을 가장 직관적으로 보여주는 지표라고 볼 수 있음
Hit Ratio = Cache Hit / (Cache Hit + Cache Miss)
2. Spring Actuator를 통한 조회 예시
Actuator의 /actuator/metrics/cache.gets 엔드포인트를 사용하면 데이터를 얻을 수 있다
{
"name": "cache.gets",
"description": "The number of times cache lookup methods have returned a cached (hit) or uncached (newly loaded or null) value (miss).",
"measurements": [
{
"statistic": "COUNT",
"value": 0.0
}
],
"availableTags": [
{
"tag": "result",
"values": [
"hit",
"miss"
]
},
{
"tag": "cache.manager",
"values": [
"caffeine"
]
},
{
"tag": "cache",
"values": [
"productCache",
"userCache"
]
},
{
"tag": "name",
"values": [
"productCache",
"userCache"
]
}
]
여기에서 데이터를 얻을 수 있다고 함
나는 캐싱한게 없으니 나오질 않는다..
Hit / Miss 값 계산 예시
GET /actuator/metrics/cache.gets?tag=result:hit
GET /actuator/metrics/cache.gets?tag=result:miss
이걸 postman이나 브라우저에 입력해주면 값이 나온다
가령 hit가 4200, miss가 800이라고 하면 Hit Ratio = (4200 / 5000) * 100 = 84%가 된다
3. 기준값 설정
| 구분 | Hit Ratio | 판단 기준 |
| 우수 | 90% 이상 | 캐시가 효과적으로 동작중 |
| 보통 | 70 ~ 89% | 캐시 정책 개선 필요 |
| 낮음 | 70% 미만 | 캐시 구조 또는 TTL 재설계 필요 |
응답 시간 분석
1. 캐시 사용 전후 비교 실험(?)
Product.java
import lombok.AllArgsConstructor;
import lombok.NoArgsConstructor;
@AllArgsConstructor
@NoArgsConstructor
public class Product {
private Long id;
private String name;
private int price;
}
ProductService.java
import com.b1uffer.actuatortest.entity.Product;
import lombok.RequiredArgsConstructor;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;
@Service
@RequiredArgsConstructor
public class ProductService {
@Cacheable("productCache")
public Product getProduct(Long id) {
simulateSlowService();
return new Product(id, "노트북", 1500000);
}
private void simulateSlowService() {
try {
Thread.sleep(1000);
} catch(InterruptedException e) {
}
}
}
테스트코드
import com.b1uffer.actuatortest.service.ProductService;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
@SpringBootTest
class ActuatorTestApplicationTests {
@Autowired
ProductService productService;
@Test
void testCacheEffect() {
long start = System.currentTimeMillis();
for(int i = 0; i < 3; i++) {
productService.getProduct(1L);
System.out.println("duration : " + (System.currentTimeMillis() - start));
}
long duration = System.currentTimeMillis() - start;
System.out.println("Total Time : " + duration + "ms");
}
}
결과
2026-01-16T23:34:21.294+09:00 INFO 39752 --- [actuatorTest] [ Test worker] c.b.a.ActuatorTestApplicationTests : Starting ActuatorTestApplicationTests using Java 17.0.15 with PID 39752 (started by index in C:\practice\actuator\actuatorTest)
2026-01-16T23:34:21.295+09:00 INFO 39752 --- [actuatorTest] [ Test worker] c.b.a.ActuatorTestApplicationTests : No active profile set, falling back to 1 default profile: "default"
2026-01-16T23:34:22.379+09:00 WARN 39752 --- [actuatorTest] [ Test worker] i.m.core.instrument.MeterRegistry : This Gauge has been already registered (MeterId{name='custom.cache.size', tags=[]}), the registration will be ignored. Note that subsequent logs will be logged at debug level.
2026-01-16T23:34:23.321+09:00 INFO 39752 --- [actuatorTest] [ Test worker] o.s.b.a.e.web.EndpointLinksResolver : Exposing 4 endpoints beneath base path '/actuator'
2026-01-16T23:34:23.407+09:00 INFO 39752 --- [actuatorTest] [ Test worker] c.b.a.ActuatorTestApplicationTests : Started ActuatorTestApplicationTests in 2.389 seconds (process running for 3.344)
duration : 1010
duration : 1012
duration : 1012
Total Time : 1012ms
OpenJDK 64-Bit Server VM warning: Sharing is only supported for boot loader classes because bootstrap classpath has been appended
> Task :test
BUILD SUCCESSFUL in 6s
4 actionable tasks: 2 executed, 2 up-to-date
오후 11:34:25: 실행이 완료되었습니다 ':test --tests "com.b1uffer.actuatortest.ActuatorTestApplicationTests.testCacheEffect"'.
캐시가 미적용일 때는 DB에 접근하기 때문에 duration이 1000ms 이상 걸린다
이후 캐시가 적용되어 메모리를 조회할 땐 0ms ~ 10ms 정도 걸린다고 한다. 실제로 duration이 2ms 걸렸음
메모리 사용량 모니터링
1. JVM Heap 사용 추적
캐시는 메모리에 데이터를 저장하기 때문에 Heap 영역을 지속적으로 점유함
이 때문에 JVM의 메모리 사용량을 함께 모니터링 하는게 중요함
나는 윈도우니까 cmd에 이걸 입력해줌
jstat -gcutil <PID> 1s 10
여기서 <PID>는 spring을 실행한 뒤
jps -l
이걸 cmd에 입력해주면 spring에 대한 PID가 나옴
<PID> com.b1uffer.actuatortest.ActuatorTestApplication
뭐.. 이런식으로..
그리고 jstat 어쩌고를 입력해주면
S0 S1 E O M CCS YGC YGCT FGC FGCT CGC CGCT GCT
0.00 52.21 11.11 91.05 98.53 96.12 10 0.030 0 0.000 4 0.004 0.033
0.00 52.21 11.11 91.05 98.53 96.12 10 0.030 0 0.000 4 0.004 0.033
0.00 52.21 11.11 91.05 98.53 96.12 10 0.030 0 0.000 4 0.004 0.033
0.00 52.21 11.11 91.05 98.53 96.12 10 0.030 0 0.000 4 0.004 0.033
0.00 52.21 11.11 91.05 98.53 96.12 10 0.030 0 0.000 4 0.004 0.033
0.00 52.21 11.11 91.05 98.53 96.12 10 0.030 0 0.000 4 0.004 0.033
0.00 52.21 11.11 91.05 98.53 96.12 10 0.030 0 0.000 4 0.004 0.033
0.00 52.21 11.11 91.05 98.53 96.12 10 0.030 0 0.000 4 0.004 0.033
0.00 52.21 11.11 91.05 98.53 96.12 10 0.030 0 0.000 4 0.004 0.033
0.00 52.21 11.11 91.05 98.53 96.12 10 0.030 0 0.000 4 0.004 0.033
이런식으로 뜬다
* Old (O) 영역과 Metaspace (M)가 일정하게 증가한다면, 캐시 데이터가 과도하게 쌓이고 있을 가능성이 있음
2. 시각화 도구 활용 (JVisualVM)
1) JVisualVM 실행 - > 애플리케이션 프로세스 선택
2) Heap 탭에서 메모리 사용량 변화를 실시간으로 확인하기
3) GC(Activity) 그래프를 통해 캐시로 인한 GC 빈도 증가 여부 확인하기
* Caffeine은 LRU 정책과 만료시간 설정을 통해 메모리 사용량을 자동으로 조절할 수 있음
병목 현상 식별
캐시는 일반적으로 성능을 높이지만, 잘못된 설계는 오히려 병목현상을 일으킬 수 있다고 한다
1. 대표적 병목 사례
| 원인 | 설명 | 해결법 |
| 단일 캐시 키 집중 | 모든 요청이 동일한 캐시 키를 공유함 | 캐시 키 분산 구조 설계 |
| TTL 동시 만료 | 캐시 항목이 한꺼번에 만료되어 DB 부하 증가 | 만료시간 랜덤화 적용 |
| 과도한 캐시 락 | 여러 스레드가 동시에 캐시 로드를 시도 | synchronized block 최소화, Async Cache 사용 |
2. 병목 감지 실험 예시
@Cacheable("heavyCache")
public String loadData(String key) {
try {
Thread.sleep(500);
} catch(InterruptedException e) {
}
return "data for " + key;
}
다중 스레드 요청시
ExecutorService executor = Executors.newFixedThreadPool(10);
for(int i = 0; i < 10; i++) {
executor.submit(() -> service.loadData("sameKey"));
}
모든 스레드가 같은 키(sameKey)를 요청하면, 첫번째 스레드가 데이터를 로드하는 동안 나머지는 대기상태(blocking)가 됨
* Caffeine의 CacheLoader 또는 비동기 캐시(AsyncCache)를 활용하면 병목 문제를 완화할 수 있음
팁
* 캐시 Hit Ratio가 높더라도, 데이터 신선도(Staleness)를 반드시 확인해야함
* 캐시 크기를 늘리는 것은 메모리 부담을 증가시키므로, TTL과 Eviction 정책을 함께 조정해야함
* Actuator와 Prometheus를 연동해 장기적 캐시 성능 추세를 시각화하면 문제를 조기에 감지할 수 있음
정리
| 항목 | 설명 |
| Hit Ratio | 캐시 효율성을 판단하는 핵심 지표 |
| 응답 시간 분석 | 캐시 적용 전후 성능 비교로 효과 측정 |
| 메모리 모니터링 | Heap / Garbage Collection 추적 필수 |
| 병목 현상 식별 | TTL 동시 만료, 캐시 락 주의 |
'Spring Boot > Cache' 카테고리의 다른 글
| 분산 캐시의 이해와 Redis : 분산 캐시의 필요성 (0) | 2026.01.17 |
|---|---|
| 캐시 모니터링과 문제 해결 : 일반적 캐시 문제와 해결 방법 (0) | 2026.01.15 |
| 캐시 모니터링과 문제 해결 : Spring Boot Actuator를 활용한 모니터링 (0) | 2026.01.15 |
| 로컬 캐시 구현과 최적화 : 로컬 캐시 성능 측정 (0) | 2026.01.12 |
| 로컬 캐시 구현과 최적화 : 로컬 캐시 구성 최적화 (0) | 2026.01.12 |