스프링에서 캐시를 사용할 때 특정한 경우에서 캐시가 작동하지 않는 경우가 있었다.
문제 발생의 이유에 대해 이해하지 못하거나 날아가는 쿼리를 제대로 확인하지 않고 사용시 성능상 큰 문제가 되거나 db가 뻗어버릴 수 있다. 실제 운영db를 눕혀봤다
캐싱이 제대로 되지 않는 예제 코드는 아래와 같다.
public List<String> getAllKeysInItem() {
return getAllItems().stream().map(item -> item.getKey()).collect(Collectors.toList());
}
@Cacheable("items")
public List<Item> getAllItems() {
// query all items in db and return
}
코드를 간단히 설명하자면 먼저 getAllItems()
에서는 db에서 모든 item을 리스트로 가져오게 한다. @Cacheable
을 통해 이를 캐싱함으로서 매 요청마다 쿼리를 하지 않도록 해주었다.
하지만 특정 상황에서 item의 key만 필요한 경우가 생겨서 getAllKeysInItem
이라는 메소드를 추가하였고, 이를 캐싱된 getAllItems()
에서 가져와 간단히 map
으로 처리하여 갖고 있도록 했다.
당연히 getAllItems
가 캐싱되어 있으므로 저 캐싱된 리스트를 사용할 것이고 짠 하고 배포했다.
다음 날 출근하자 사수가 500응답이 많이 나와서 집에서 직접 서버를 다시 롤백했다고 했다. 내가 짠 뭐가 문제였을까하고 다시 찾아보니 위에 말한대로 캐싱이 제대로 작동하지 않고 있었다.
getAllKeysInItem
을 부를때마다 getAllItem
에 있는 쿼리를 매번 날린 것이다.
원인은 getAllKeysInItem
에 있는 코드가 스프링 프록시를 거쳐서 가지 않았기 때문이다. 기본적으로 스프링 캐시는 프록시를 통해서 작동하는데,
내 예상에서는
메소드 실행 -> 스프링 캐시 프록시 -> getAllKeysInItem -> 스프링 캐시 프록시 -> getAllItems
이렇게 갈 줄 알았지만 실제로는
메소드 실행 -> 스프링 캐시 프록시 -> getAllKeysInItem -> getAllItems
안에 작성된 메소드 즉 Nested Method
에서는 캐싱이 제대로 되지 않고 있었다.
이는 getAllKeysInItem
이 getAllItems
프록시에 접근하기 전에 이미 스레드가 그냥 getAllItems
을 실행하기에 발생하는 문제이다.
즉 빈에 올라간 서비스 클래스의 프록시가 캐싱이 되어 있는 것인데, 내 코드는 지금 프록시된 클래스 내의 메소드를 실행하고 있던 것이다.
이는 오래된 스프링 캐시의 문제였다. 의도한건지 아니면 실수인지는 모르지만 꽤나 오래된 문제였고 이에 대한 해결 방법은 여러가지가 있었다.
하나는 getAllKeysInItem
도 캐싱해버리면 된다. 그러면 자체가 캐싱되므로 안에서 프록시에 접근하기 전에 getAllKeysInItem
단에서 캐싱된 값을 돌려준다.
다른 방법은 서비스 클래스 자체를 자기 자신에게 의존성 주입을 하면 된다.
@Service
public class Service {
@Autowired private Service self;
}
이렇게 자기 자신을 주입하고
public List<String> getAllKeysInItem() {
return self.getAllItems().stream().map(item -> item.getKey()).collect(Collectors.toList());
}
@Cacheable("items")
public List<Item> getAllItems() {
// query all items in db and return
}
이렇게 주입받은 빈에서 메소드를 실행하게 한다. 그러면 프록시 처리가 된 빈에서 메소드를 실행하기 때문에 정상적으로 캐싱된 값을 map
처리하여 원하는 결과를 불러 올 수 있다.