Spring Cache AOP 문제 트러블슈팅 노트
🚨 문제 상황
발생한 문제
- Spring Cache (@Cacheable)가 전혀 작동하지 않음
- 해시값이 동일한데도 매번 getTopNewsByCategory() 메서드가 실행됨
- 캐시 HIT이 한 번도 발생하지 않고 항상 MISS 상태
문제 증상
1차 API 호출: ✅ 해시 동일 → 📋 Spring 캐시 MISS (정상)
2차 API 호출: ✅ 해시 동일 → 📋 Spring 캐시 MISS (비정상!)
3차 API 호출: ✅ 해시 동일 → 📋 Spring 캐시 MISS (비정상!)
정상적이라면:
1차 API 호출: ✅ 해시 동일 → 📋 Spring 캐시 MISS
2차 API 호출: ✅ 해시 동일 → 🚀 Spring 캐시 HIT ← 이게 나와야 함!
3차 API 호출: ✅ 해시 동일 → 🚀 Spring 캐시 HIT ← 이게 나와야 함!
🔍 원인 분석
핵심 원인: Spring AOP 프록시 문제
Spring Cache는 **AOP(Aspect-Oriented Programming)**으로 작동합니다. 하지만 같은 클래스 내에서 메서드를 직접 호출하면 AOP가 무시됩니다.
문제가 되는 코드 구조
@Service
public class PopularNewsService {
public List<PopularNewsResponse> getTopNewsByAllCategories() {
// 같은 클래스 내에서 직접 호출!
PopularNewsResponse topNews = this.getTopNewsByCategory(category);
}
@Cacheable(value = "popularNews", key = "#category")
public PopularNewsResponse getTopNewsByCategory(String category) {
// @Cacheable이 무시됨!
}
}
Spring AOP 동작 원리
1. 프록시 객체 생성
Spring은 @Cacheable이 붙은 클래스의 프록시 객체를 생성합니다:
// Spring이 자동 생성하는 프록시 (개념적 표현)
public class PopularNewsService$$SpringProxy extends PopularNewsService {
private CacheManager cacheManager;
@Override
public PopularNewsResponse getTopNewsByCategory(String category) {
// 1. 캐시 확인
if (캐시에_있음) return 캐시_데이터;
// 2. 캐시 없으면 실제 메서드 실행
PopularNewsResponse result = super.getTopNewsByCategory(category);
// 3. 결과를 캐시에 저장
캐시_저장(result);
return result;
}
}
2. 문제 발생 지점
// 같은 클래스 내에서 호출
this.getTopNewsByCategory(category);
호출 흐름:
클라이언트 → PopularNewsService$$SpringProxy (프록시)
↓
getTopNewsByAllCategories() 실행
↓
this.getTopNewsByCategory() 호출 ← 문제!
↓
"this"는 프록시가 아닌 원본 객체
↓
@Cacheable 무시하고 원본 메서드 직접 실행 😱
💡 해결 방안
방법 1: Self-Injection (순환 참조)
@Service
public class PopularNewsService {
private final PopularNewsService self; // 자기 자신 주입
public List<PopularNewsResponse> getTopNewsByAllCategories() {
PopularNewsResponse topNews = self.getTopNewsByCategory(category);
}
}
단점:
- 순환 참조 발생 → allow-circular-references: true 필요
- 코드가 직관적이지 않음
방법 2: 별도 서비스 분리 ⭐ 권장
@Service
public class PopularNewsService {
private final PopularNewsCacheService cacheService; // 다른 Bean!
public List<PopularNewsResponse> getTopNewsByAllCategories() {
// 다른 Bean 호출 → 프록시 거침 → AOP 작동!
PopularNewsResponse topNews = cacheService.getTopNewsByCategory(category);
}
}
@Service
public class PopularNewsCacheService {
@Cacheable(value = "popularNews", key = "#category")
public PopularNewsResponse getTopNewsByCategory(String category) {
// AOP 정상 작동!
}
}
장점:
- Single Responsibility Principle (SRP) 준수
- Spring AOP 정상 작동
- 테스트 용이성 증대
- 헥사고날 아키텍처에 적합
🎯 최종 해결책
구조 분리
- PopularNewsService: 비즈니스 로직 전담 (해시 비교, 순위 변화 감지)
- PopularNewsCacheService: 캐시 관리 전담 (Spring Cache + Redis + DB)
3단계 캐싱 구조
- Spring Cache (1차): 해시 동일 시 즉시 반환
- Redis topNews (2차): Spring 캐시 miss 시 Redis 확인
- DB 조회 (3차): Redis도 miss 시 DB 조회
의존성 흐름
PopularNewsService (비즈니스 로직)
↓ 의존
PopularNewsCacheService (캐시 관리)
↓ 의존
PopularNewsPort, NewsDetailProviderPort (인프라)
📚 학습 포인트
Spring AOP 핵심 원칙
- 다른 Bean 호출 시에만 AOP 작동
- 같은 클래스 내 호출은 프록시를 거치지 않음
- this 키워드는 원본 객체를 가리킴
아키텍처 설계 원칙
- 단일 책임 원칙 (SRP): 한 클래스는 한 가지 책임만
- 의존성 역전 원칙 (DIP): 구체적 구현보다 추상화에 의존
- 관심사 분리: 비즈니스 로직과 기술적 관심사 분리
🔧 적용 결과 예상
Before (문제 상황)
모든 API 호출: 📋 Spring 캐시 MISS → Redis/DB 조회
After (해결 후)
1차 호출: 📋 Spring 캐시 MISS → Redis/DB 조회 → 캐시 저장
2차 호출: 🚀 Spring 캐시 HIT → 즉시 반환 (최고 성능!)
3차 호출: 🚀 Spring 캐시 HIT → 즉시 반환 (최고 성능!)
성능 향상:
- 해시 동일 시: DB 조회 0회 (메모리에서 즉시 반환)
- 응답 시간: 90% 이상 단축 예상
📊 성능 측정 결과 (@TimeTracker 활용)
측정 도구 설정
프로젝트의 커스텀 @TimeTracker 어노테이션을 활용하여 정확한 성능 측정을 실시했습니다.
@TimeTracker(logLevel = "info")
@Override
public List<PopularNewsResponse> getTopNewsByAllCategories() {
// 전체 API 응답 시간 측정
}
@TimeTracker(logLevel = "info")
@Cacheable(value = "popularNews", key = "#category")
public PopularNewsResponse getTopNewsByCategory(String category) {
// 개별 카테고리 조회 시간 측정
}
실제 측정 결과
1차 요청 (서버 재시작 후 - 캐시 MISS)
⏱️ getTopNewsByAllCategories() = 0.839초
📋 개별 카테고리별 조회 시간:
- 전체: 0.152초 (Redis HIT)
- 정치: 0.010초 (Redis HIT)
- 경제: 0.022초 (Redis HIT)
- 사회: 0.020초 (Redis HIT)
- 국제: 0.007초 (Redis HIT)
- 연예: 0.024초 (Redis HIT)
- 스포츠: 0.010초 (Redis HIT)
2차 요청 (Spring Cache HIT) 🚀
⏱️ getTopNewsByAllCategories() = 0.127초
📋 개별 카테고리별 조회 시간:
- 전체: 0.000초 ⚡
- 정치: 0.001초 ⚡
- 경제: 0.000초 ⚡
- 사회: 0.000초 ⚡
- 국제: 0.000초 ⚡
- 연예: 0.000초 ⚡
- 스포츠: 0.000초 ⚡
성능 향상 분석
전체 API 응답 시간
- Before (캐시 MISS): 0.839초
- After (캐시 HIT): 0.127초
- 성능 향상: 85% 단축!
개별 카테고리 조회 시간
- Before: 평균 0.032초 (Redis 조회)
- After: 평균 0.000초 (메모리 캐시)
- 성능 향상: 99% 단축!
캐시 작동 증명
2차 요청에서 확인된 Spring Cache 완벽 작동:
- PopularNewsCacheService 내부 로그 없음 → 메서드 실행 안됨
- "📋 Spring 캐시 MISS" 로그 없음 → 캐시 HIT 성공
- "⚡ Redis topNews 캐시 HIT" 로그 없음 → Redis 조회 생략
- DB 쿼리 로그 없음 → DB 접근 완전 차단
3단계 캐싱 성능 비교
단계 1차 요청 2차 요청 성능 개선
Spring Cache | MISS (0.839초) | HIT (0.127초) | 85% ↑ |
Redis Cache | HIT (0.152초) | 접근 안함 | 100% ↑ |
DB 조회 | 접근 안함 | 접근 안함 | 유지 |
🎯 핵심 학습 포인트
캐시 계층별 성능 특성
- Spring Cache (메모리): 0.000~0.001초 → 극한 성능
- Redis Cache (네트워크): 0.007~0.152초 → 우수한 성능
- DB 조회 (디스크): 0.5초 이상 → 최후 수단
서버 재시작과 캐시 동작
- Spring Cache: 메모리 기반 → 서버 재시작 시 초기화
- Redis Cache: 별도 서버 → 서버 재시작 후에도 데이터 보존
- 해시 기반 변화 감지: 캐시 무효화 정책 완벽 작동
[실제 프로젝트 내 트러블 노트 작성]
-
🛠️ 트러블 제목
1. 문제 발생 상황 ❗- 발생 일시: 2025/05/27 오전 10시 경
- 발생 환경: 로컬 개발 환경
- 발생 상황 설명:
- Spring Cache (@Cacheable)가 전혀 작동하지 않음
- 해시값이 동일한데도 매번 getTopNewsByCategory() 메서드가 실행됨
- 캐시 HIT이 한 번도 발생하지 않고 항상 MISS 상태
- 관련 로그/에러 메시지: [파일 첨부 가능]
핵심 원인: Spring AOP 프록시 문제// 시나리오 테스트중 로그 1차 API 호출: ✅ 해시 동일 → 📋 Spring 캐시 MISS (정상) 2차 API 호출: ✅ 해시 동일 → 📋 Spring 캐시 MISS (비정상!) 3차 API 호출: ✅ 해시 동일 → 📋 Spring 캐시 MISS (비정상!) // 정상이라면 1차 API 호출: ✅ 해시 동일 → 📋 Spring 캐시 MISS 2차 API 호출: ✅ 해시 동일 → 🚀 Spring 캐시 HIT ← 이게 나와야 함! 3차 API 호출: ✅ 해시 동일 → 🚀 Spring 캐시 HIT ← 이게 나와야 함!
this.getTopNewsByCategory(category); ← 같은클래스내에서 원본 객체를 호출해버림 (프록시가 아님) @Cacheable 무시하고 원본 메서드 직접 실행 (AOP로 작동하기때문)@Service public class PopularNewsService { public List<PopularNewsResponse> getTopNewsByAllCategories() { // 같은 클래스 내에서 직접 호출! PopularNewsResponse topNews = this.getTopNewsByCategory(category); } @Cacheable(value = "popularNews", key = "#category") public PopularNewsResponse getTopNewsByCategory(String category) { // @Cacheable이 무시됨! } }
2. 모색한 해결 방법 🔍- 시도한 방법 1: Self-Injection (순환 참조)
- 시도한 방법 2: 별도 서비스 분리
3. 선택한 해결 방법과 그 이유 ✅- 최종 선택한 해결 방법: 별도 서비스 분리
- 헥사고날 아키텍쳐에 적합한 구조
- SRP를 준수
4. 문제 해결 전과 후 상태 변화 🔄- 문제 해결 전 상태: 모든 API 호출: 📋 Spring 캐시 MISS → Redis/DB 조회
⏱️ getTopNewsByAllCategories() = 0.839초 📋 개별 카테고리별 조회 시간: 전체: 0.152초 (Redis HIT) 정치: 0.010초 (Redis HIT) 경제: 0.022초 (Redis HIT) 사회: 0.020초 (Redis HIT) 국제: 0.007초 (Redis HIT) 연예: 0.024초 (Redis HIT) 스포츠: 0.010초 (Redis HIT)
- 문제 해결 후 상태:
⏱️ getTopNewsByAllCategories() = 0.127초 📋 개별 카테고리별 조회 시간: - 전체: 0.000초 ⚡ - 정치: 0.001초 ⚡ - 경제: 0.000초 ⚡ - 사회: 0.000초 ⚡ - 국제: 0.000초 ⚡ - 연예: 0.000초 ⚡ - 스포츠: 0.000초 ⚡
- 1차 호출: 📋 Spring 캐시 MISS → Redis/DB 조회 → 캐시 저장 2차 호출: 🚀 Spring 캐시 HIT → 즉시 반환 (최고 성능!) 3차 호출: 🚀 Spring 캐시 HIT → 즉시 반환 (최고 성능!)
- 성능 향상 분석
- Before (캐시 MISS): 0.839초
- After (캐시 HIT): 0.127초
- 성능 향상: 85% 단축!
- Before: 평균 0.032초 (Redis 조회)
- After: 평균 0.000초 (메모리 캐시)
- 성능 향상: 99% 단축!
- 전체 API 응답 시간
- 확인 방법: log.info로 명시적 확인 + 커스텀 어노테이션 @TimeTracker로 실행시간 측정
- 관련 확인 링크: 추후 추가예정
✍️ 회고 (선택사항)- AOP작동방식에 대해 다시 생각해보고 공부하는 계기가 되었습니다.
- 사진 첨부를 하면 더 좋아요 😊
- AOP는 프록시 객체를 통해 대상 Bean을 호출하는 방식으로 동작 내부 호출 (같은 클래스 안에서의 메서드 호출)은 프록시를 거치지 않아 AOP가 적용되지 않음
- Spring Cache는 **AOP(Aspect-Oriented Programming)**으로 작동합니다. 하지만 같은 클래스 내에서 메서드를 직접 호출하면 AOP가 무시됩니다.
'Project' 카테고리의 다른 글
동적데이터와 정적데이터 분리 필요성 (0) | 2025.06.08 |
---|---|
커스텀 어노테이션을 사용한 프로젝트 시나리오별 성능 테스트 (1) | 2025.05.28 |
'Youtube 인기영상 분석 및 시각화툴' 프로젝트 사전조사 (4) | 2025.04.14 |
Spring boot 세션이 유지가 안되는 오류 해결 (0) | 2025.01.08 |
Spring Boot 웹 프로젝트 Amazon EC2로 업로드하기 (0) | 2025.01.04 |