발단: "왜 다른 데이터가 나오죠?"
API 개발 중 황당한 버그를 만났습니다.
GET /api/items?category=A → A 카테고리 데이터 ✓
GET /api/items?category=B → A 카테고리 데이터 ✗ (B여야 함)
GET /api/items?category=C → A 카테고리 데이터 ✗ (C여야 함)
파라미터를 바꿔서 호출해도 첫 번째 조회 결과만 계속 반환됩니다. 서버를 재시작하면 잠깐 고쳐지다가 또 재발합니다.
대체 뭐가 문제일까요?
---
1차 시도: Hibernate 쿼리 의심
Repository 코드를 확인했습니다.
@Query("SELECT DISTINCT e.name FROM #{#entityName} e WHERE e.category = :category")
List<String> findNamesByCategory(@Param("category") String category);
#{#entityName}이 SpEL 표현식이라 캐싱이 꼬인 걸까요? Hibernate query plan cache 문제일까요?
설정을 변경해봤습니다.
spring.jpa.properties.hibernate.query.plan_cache_max_size=16
해결되지 않았습니다.
Native SQL로 변경해봤습니다.
@Query(value = "SELECT DISTINCT name FROM items WHERE category = :category",
nativeQuery = true)
그래도 해결되지 않았습니다.
---
2차 시도: Database 의심
혹시 DB prepared statement 캐시 문제일까요?
DB 콘솔에서 직접 쿼리를 실행해봤습니다.
SELECT DISTINCT name FROM items WHERE category = 'B';
정상 작동합니다. 올바른 결과가 반환됩니다.
DB 문제가 아닙니다.
---
결정적 단서: 같은 Repository인데 왜 하나만 문제?
디버그용 API를 만들어서 확인해봤습니다.
// 이건 정상! 각 카테고리별 올바른 데이터 반환
List<Item> items = repository.findByCategory("B");
// → B 카테고리 아이템들 (정상)
// 이건 비정상! A 카테고리 이름 반환
List<String> names = repository.findNamesByCategory("B");
// → A 카테고리 이름들 (비정상)
같은 Repository, 같은 파라미터, 같은 조건인데 하나만 잘못됩니다.
이건 Repository 레이어 문제가 아닙니다. 더 상위에서 뭔가 꼬인 겁니다.
---
범인 검거: Service 레이어 캐시
갑자기 @Cacheable이 떠올랐습니다.
grep -r "@Cacheable" src/main/java/
// AbstractService.java (부모 클래스)
@Cacheable(value = "items", key = "#root.target.class.simpleName + '_' + #category")
public List<String> getNamesByCategory(String category) {
return repository.findNamesByCategory(category);
}
캐시가 있었습니다. 캐시 키에 #category가 있으니 카테고리별로 다른 키가 생성되어야 하는데...
잠깐, 자식 클래스를 확인해봤습니다.
// ItemService.java (자식 클래스)
@Override
public List<String> getNamesByCategory(String categoryCode) {
// ^^^^^^^^^^^^
// 어?!
}
파라미터명이 다릅니다.
- 부모 클래스: String category
- 자식 클래스: String categoryCode
캐시 키 SpEL은 #category를 참조하는데, 자식 클래스 메서드에는 category라는 파라미터가 없습니다!
---
원인 분석
Spring @Cacheable은 AOP 프록시로 동작합니다. 부모 클래스에 붙은 어노테이션이 자식 클래스 메서드 호출에도 적용됩니다.
문제는 SpEL 표현식 #category가 실제 호출되는 메서드의 파라미터명을 참조한다는 것입니다.
// 기대한 캐시 키
"ItemService_A"
"ItemService_B"
"ItemService_C"
// 실제 생성된 캐시 키 (#category 파라미터 없음 → null)
"ItemService_null"
"ItemService_null"
"ItemService_null"
모든 호출이 동일한 캐시 키 ItemService_null을 사용합니다.
첫 번째 호출(A 카테고리)이 캐시되고, 이후 B/C 호출도 같은 캐시에서 A 데이터를 반환한 것입니다.
---
해결
파라미터명을 부모 클래스와 일치시켰습니다.
// Before
public List<String> getNamesByCategory(String categoryCode)
// After
public List<String> getNamesByCategory(String category)
끝입니다.
3시간 삽질의 원인이 파라미터명 4글자 차이(category vs categoryCode)였습니다.
---
대안적 해결 방법
방법 1: 인덱스 기반 참조 사용
// 파라미터명 대신 인덱스로 참조
@Cacheable(value = "items", key = "#root.target.class.simpleName + '_' + #p0")
public List<String> getNamesByCategory(String category) { ... }
방법 2: 자식 클래스에 명시적 캐시 선언
@Override
@Cacheable(value = "items", key = "'ItemService_' + #categoryCode")
public List<String> getNamesByCategory(String categoryCode) { ... }
---
교훈
1. "서버 재시작하면 고쳐져요"는 캐시 문제 신호입니다
- 재시작으로 캐시가 초기화되어 일시적으로 해결된 것입니다
- 근본 원인을 놓치기 딱 좋은 증상입니다
2. @Cacheable 상속 시 파라미터명 일치가 필수입니다
- SpEL #paramName은 실제 메서드의 파라미터명을 참조합니다
- 부모-자식 간 파라미터명이 다르면 SpEL 평가가 실패합니다
3. 증상과 원인이 멀리 있을 수 있습니다
- 증상: Repository 쿼리가 이상한 데이터 반환
- 원인: Service 레이어 캐시 키 문제
- 증상 발생 지점만 파고들면 원인을 찾기 어렵습니다
4. 인덱스 기반 참조(#p0)가 더 안전할 수 있습니다
- 파라미터명에 의존하지 않습니다
- 단, 가독성이 떨어지는 단점이 있습니다
---
마무리
Hibernate 버그, DB 버그, Spring Data JPA 버그를 차례로 의심했지만, 결국 제 코드의 파라미터명 불일치가 원인이었습니다.
프레임워크 탓하기 전에 내 코드부터 의심해야 합니다. 특히 상속 구조에서 메서드 시그니처를 변경할 때는 캐시 어노테이션까지 확인하시기 바랍니다.