Spring을 사용할 때 성능 향상을 위해 Cache는 기본적으로 사용을 해야 한다.
Spring Cache를 사용하다가 몇 가지 궁금점이 있어서 정리를 해보았다.
- @Transactional이 적용된 메소드 내에서 Cache가 정상적으로 동작(commit/rollback)하기 위해서 어떻게 해야 할까?
- 트랜잭션 내에서 @CachePut은 언제 적용이 되는걸까? 트랜잭션이 끝난시점일까? 아님 해당 메소드 내에서 로직이 수행되는 시점일까?
- Cache를 적용한 동일 클래스 내에서 다른 메소드를 호출하면 왜 Cache가 사용되지 못할까?
우선 위의 케이스를 테스트하기 위해 기본적인 서비스를 하나 만들었다.
[사용자(User) 서비스]
controller
- UserController: Rest API
service
- UserManageService: 사용자 관리를 위한 서비스
- UserService : 사용자 정보를 CRUD하는 서비스
repository
- UserJpo: 사용자 JPA 엔티티
- UserRepository: 사용자 JPA 인터페이스
Application: Spring Boot 애플리케이션
CacheConfig: Spring Cache 사용을 위한 Bean 설정
[CacheConfig] Spring에서 기본제공을 하는 ConcurrentMapCache를 적용
@Configuration
@EnableCaching
public class CachingConfig {
@Bean
public CacheManager cacheManager() {
SimpleCacheManager cacheManager = new SimpleCacheManager();
cacheManager.setCaches(List.of(new ConcurrentMapCache("user")));
cacheManager.initializeCaches();
return cacheManager;
}
}
[UserService] Service에 Cache 적용을 하자. (@Cacheable, @CachePut, @CacheEvict)
@Cacheable(value = "user", key = "#userId")
public User find(int userId) {
log.info("#find: " + userId);
return userRepository.findById(userId).get().toDomain();
}
@CachePut(value = "user", key = "#user.userId")
public User register(User user) {
log.info("#register: " + user.getUserId());
userRepository.save(new UserJpo(user));
return user;
}
@CachePut(value = "user", key = "#userId")
public User modify(int userId, UserUdo userUdo) {
log.info("#modify: " + userId);
User user = new User(userId, userUdo.getName());
userRepository.save(new UserJpo(user));
return user;
}
@CacheEvict(value = "user", key = "#userId")
public void remove(int userId) {
log.info("#remove: " + userId);
userRepository.deleteById(userId);
}
테스트 1. @Transactional이 적용된 메소드 내에서 Cache가 정상적으로 동작(commit/rollback)하기 위해서 어떻게 해야 할까?
시나리오: 사용자 등록을 하는 중에 사용자 등록을 하고 예외가 발생하는 경우
UserManageService에서 UserService를 호출하고 예외를 발생시켜보자.
@Service
@RequiredArgsConstructor
public class UserManageService {
private final UserService userService;
@Transactional
public void register(User user) {
userService.register(user);
throw new RuntimeException("등록 실패");
}
}
그리고 UserController에서 해당 api를 실행시켜보자.
@RestController
@RequestMapping("users")
@RequiredArgsConstructor
public class UserController {
private final UserManageService userManageService;
@PostMapping
public void register(@RequestBody User user) {
userManageService.register(user);
}
}
아래 API를 실행해보면 500에러가 발생될 것이다.
POST http://localhost:8080/users
content-Type: application/json
{
"userId": 1,
"name": "홍길동"
}
오류 내용
{
"timestamp": "2023-01-12T10:55:04.058+00:00",
"status": 500,
"error": "Internal Server Error",
"path": "/users"
}
그리고 DB에도 데이터가 저장되지 않은 것을 확인할 수 있다.
그럼 조회 API를 실행해보자.
GET http://localhost:8080/users/1
하지만 위의 API를 실행하면 결과가 출력되는 것을 확인할 수 있다.
{
"userId": 1,
"name": "홍길동"
}
DB에는 데이터가 없는 왜 조회가 되는 것인가? 이는 @CachePut
에 의해 데이터가 캐싱이 되었고 @Transactional
에 의해 캐싱이 롤백되길 기대했지만 롤백이 되지 않았던 것이다.
스프링에서 트랜잭션 내에서 Cache를 사용하려면 TransactionAwareCacheManagerProxy
를 사용해야 한다. 이것이 CacheManager를 감싸고 트랜잭션 내에서 동작하게 해주는 것이다.
그럼 CacheConfig에서 TransactionAwareCacheManagerProxy를 추가해보자.
@Bean
public CacheManager cacheManager() {
SimpleCacheManager cacheManager = new SimpleCacheManager();
cacheManager.setCaches(List.of(new ConcurrentMapCache("user")));
cacheManager.initializeCaches();
return new TransactionAwareCacheManagerProxy(cacheManager); // 추가
}
위와 같이 cacheManager를 TransactionAwareCacheManagerProxy로 감싸면 위에서 실행한 코드가 트랜잭션 내에서 실행이 된다.
테스트 2. 트랜잭션 내에서 @CachePut은 언제 적용이 되는걸까? 트랜잭션이 끝난시점일까? 아님 해당 메소드 내에서 로직이 수행되는 시점일까?
시나리오: 사용자 등록을 하고 10초 대기를 하는 중에 다른 쓰레드로 해당 api를 호출해보면 캐시가 되었으면 조회될 것이고 캐시가 되지 않았다면 조회되지 않을 것이다.
# TransactionAwareCacheManagerProxy가 적용되어 있는 상태
우선 사용자 등록의 대기 시간을 10초로 만든다.
@Transactional
public void register(User user) {
userService.register(user); // 여기서 @CachePut이 된다.
try {
Thread.sleep(10000); // 10초 대기
// <---- 다른 쓰레드에서 이 시점에 조회하면 캐쉬가 조회될까?
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
10초가 지나기 전에 다른 쓰레드로 위에서 등록한 아이디로 사용자 조회를 해보자.
GET /users/1
위와 같이 @Transactional을 적용시키면 @CachePut이 있더라도 트랜잭션이 모두 끝난 시점에 캐쉬가 적용이 된다. 중간 위치에서 호출을 해봐도 데이터가 캐쉬에 저장되지 않았고 DB에도 없으므로 조회가 되지 않는다.
테스트 3. Cache를 적용한 동일 클래스 내에서 다른 메소드를 호출하면 왜 Cache가 사용되지 못할까?
UserService에 Cache가 적용되어 있고 해당 클래스 내에서 find 메소드를 5번 호출하는 메소드를 추가해보자.
@Slf4j
@Service
@RequiredArgsConstructor
public class UserService {
@Cacheable(value = "user", key = "#userId")
public User find(int userId) {
log.info("#find: " + userId); // DB에서 조회하는지 확인하기 위한 로그
return userRepository.findById(userId).get().toDomain();
}
public void find5times(int userId) {
for (int i=1; i<=5; i++) {
log.info("find5times: " + i);
find(userId); // 사용자 조회
}
}
}
일단 사용자 한명을 등록하고 나서 위의 find5times를 호출하는 api를 실행해보자.
GET http://localhost:8080/users/find5times
이런 경우 1번 DB에서 읽고 4번 Cache에서 조회될까? 아니면 Cache에서 5번 조회될까?
실행 결과
INFO 84055 --- [nio-8080-exec-1] com.example.service.UserService : find5times: 1
INFO 84055 --- [nio-8080-exec-1] com.example.service.UserService : #find: 1
INFO 84055 --- [nio-8080-exec-1] com.example.service.UserService : find5times: 2
INFO 84055 --- [nio-8080-exec-1] com.example.service.UserService : #find: 1
INFO 84055 --- [nio-8080-exec-1] com.example.service.UserService : find5times: 3
INFO 84055 --- [nio-8080-exec-1] com.example.service.UserService : #find: 1
INFO 84055 --- [nio-8080-exec-1] com.example.service.UserService : find5times: 4
INFO 84055 --- [nio-8080-exec-1] com.example.service.UserService : #find: 1
INFO 84055 --- [nio-8080-exec-1] com.example.service.UserService : find5times: 5
INFO 84055 --- [nio-8080-exec-1] com.example.service.UserService : #find: 1
find를 호출할때 Cache를 통해 조회할 것으로 기대했으니 실제로는 캐쉬에서 조회되지 못하고 5번 모두 DB에서 조회한 결과를 나타낸다.
캐쉬에서 조회되었다면 #find: 1은 출력되지 않는다.
그 이유는 Spring에서 Cache를 사용할 때 Spring AOP를 사용하기 때문이다. Spring Cache는 프록시를 통해 동작을 하기 때문에 Proxy를 통해 호출되는 외부 메서드 호출에만 동작하게 구현되어 있다.
캐시가 동작하려면 다른 클래스에서 @Cacheable이 적용된 클래스를 호출해야만 한다.
UserService 내에서 호출하지 않고 새로운 클래스 (UserManageService)에서 UserService를 호출하게 테스트 해보자.
@Slf4j
@Service
@RequiredArgsConstructor
public class UserManageService {
public void find5times(int userId) {
for (int i=1; i<=5; i++) {
log.info("find5times: " + i);
userService.find(userId);
}
}
}
위의 서비스를 호출하는 API를 만들고 실행해보자.
GET http://localhost:8080/users/manage/find5times
실행결과
INFO 84055 --- [nio-8080-exec-1] com.example.service.UserService : find5times: 1
INFO 84055 --- [nio-8080-exec-1] com.example.service.UserService : find5times: 2
INFO 84055 --- [nio-8080-exec-1] com.example.service.UserService : find5times: 3
INFO 84055 --- [nio-8080-exec-1] com.example.service.UserService : find5times: 4
INFO 84055 --- [nio-8080-exec-1] com.example.service.UserService : find5times: 5
위의 결과와 다르게 user를 캐쉬에서 읽어온 것을 확인할 수 있다. DB에서 가져왔다면 #find: 1이 호출되었을 것이다.