일시적으로 데이터 하위 집합을 저장하는 고속 데이터 스토리지 계층이다. 이후 해당 데이터에 대한 요청이 있을 경우 데이터의 기본 스토리지 위치에서 읽어오는것 보다 더 빠르게 응답하여 요청을 처리할 수 있다. 캐싱을 사용하면 이전에 조회하고 계산했던 데이터에 대해서 효율적으로 재사용할 수 있다.
implementation group: 'org.springframework.boot', name: 'spring-boot-starter-cache', version: '3.2.0'
와 같은 의존성을 찾아 build.gradle에 라이브러리를 추가해줄 수 있다.
@Configuration
@EnableCaching
public class CachingConfig {
@Bean
public CacheManager cacheManager() {
return new ConcurrentMapCacheManager("addresses");
}
}
위와 같이 @EnableCaching
애너테이션을 활용함으로써 캐싱을 쉽게 사용할 수 있다.
직접 코드를 작성하여 테스트를 해보았다.
@Bean
public CacheManager cacheManager() {
return new ConcurrentMapCacheManager("addresses");
}
Config에서 위의 Bean을 등록하는 코드를 작성하지 않아도 캐싱이 올바르게 동작하는 것이었다.
이전에는 분명 "addresses"이름의 캐싱을 사용하려면 캐싱을 등록한 후 사용을 해야한다고 생각하고 있었다. 이에 대해서 알아본 결과
@EnableCaching
어노테이션을 사용하여 캐싱을 활성화@Cacheable("addresses")
를 사용하여 특정 메소드에 캐싱 적용CacheManager
가 필요하다. 하지만 위 조건을 만족하면 SpringBoot가 캐싱을 위한 자동 구성기능을 포함하고 있어 다음과 같은 방식으로 작동한다.CacheManager
Bean이 명시적으로 정의되어있지 않을 경우, Springboot는 기본 CacheManager를 제공한다. -> ConcurrentMapCacheManager
@Cacheable("addresses")
와 같은 애너테이션을 활용할 경우 CacheManager에 "addresses"이름을 가진 캐시가 존재하지 않는다면 Spring Boot가 자동으로 해당 이름의 캐시를 생성한다. 따라서 @EnableCaching
과 @Cacheable("addresses")
만 있으면 Spring Boot의 자동 구성 기능 덕분에 작동할 수 있다.package hello.cache;
import hello.cache.request.CustomerRequest;
import jakarta.persistence.EntityNotFoundException;
import lombok.RequiredArgsConstructor;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;
@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class CustomerService {
private final CustomerRepository customerRepository;
@Cacheable("addresses")
public String getAddress(Long customerId) {
log.info("CustomerService getAddress() 호출");
simulateSlowService();
Customer customer = customerRepository.findById(customerId)
.orElseThrow(() -> new EntityNotFoundException("customerId에 해당하는 customer가 없습니다."));
return customer.getAddress();
}
private void simulateSlowService() {
try {
long time = 2000L;
Thread.sleep(time);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
@Transactional
public void registerCustomer(CustomerRequest request) {
Customer customer = new Customer();
customer.setAddress(request.address());
customer.setName(request.name());
customerRepository.save(customer);
}
}
getAddress(Long customerId)메서드에 @Cacheable("addresses")
를 적용하였다.
작동 방식은 다음과 같다.
예시)
@Cacheable(value = "addresses", key = "#customerId")
package hello.cache;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.Id;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
@Setter
public class Customer {
@Id
@GeneratedValue
private Long id;
private String address;
private String name;
}
package hello.cache;
import hello.cache.request.CustomerRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@Slf4j
@RestController
@RequiredArgsConstructor
@RequestMapping("/customers")
public class CustomerController {
private final CustomerService customerService;
@GetMapping("/address")
public ResponseEntity<String> getCustomerAddress(@RequestParam Long id) {
log.info("getCustomerAddress 호출");
String address = customerService.getAddress(id);
log.info("address = {}", address);
return ResponseEntity.ok().body(address);
}
@PostMapping
public ResponseEntity<Void> registerCustomer(@RequestBody CustomerRequest request) {
customerService.registerCustomer(request);
return ResponseEntity.ok().build();
}
}
package hello.cache.request;
public record CustomerRequest(
String address,
String name
) {
}
위와 같이 각 클래스들을 작성해주고 실행시킨 후
이처럼 POST
요청으로 Customer를 등록 후 http://localhost:8080/customers/address?id=1 주소로 GET
요청을 통해 조회를 하였다.
위 로그를 보면 처음 호출한 경우에는 'getCustomerAddress 호출' 로그가 보인 후 2초 뒤에 데이터를 가져온것을 볼 수 있다. 하지만 두번째 호출한 경우에는 "addresses"이름을 가진 캐싱에 데이터가 존재하므로 0.002초
만에 데이터를 가져온것을 확인할 수 있다. 추가적으로 'CustomerService getAddrss() 호출' 로그도 찍히지 않은걸로 보아 메서드 실행이 되지 않았다는 사실도 알 수 있다.
캐시의 구조와 키가 어떻게 생성되는지가 궁금했다.
캐시의 구조는 캐시 이름으로 구분되는 여러 캐시 영역이 있다. 그리고 각 캐시 안에는 key-value형태로 데이터가 저장된다. 이는 세션의 구조와 동일한것 같다.
매개변수가 1개인 경우 : @Cacheable
애너테이션이 붙은 메서드의 매개변수가 키로 사용된다. 위 CustomerService의 코드를 예시로 들면 customerId가 key가 되는것이다.
매개변수가 여러개인 경우 : 메소드가 여러 매개변수를 가질 경우, 모든 매개변수가 키의 일부로 사용된다. 이는 메소드 호출 시 제공되는 모든 매개변수가 키를 형성하여 각 호출을 고유하게 식별하는 데 도움이 된다.
@Cacheable의 문제점
- 자주 접근하지 않는 데이터에 대해서도 캐싱을 한다. -> 이렇게 하나 둘씩 데이터가 캐시에 쌓이다보면 캐시에 사용되지도 않는 데이터들이 많이 쌓이게 된다.
캐시를 적절하게 삭제해줌으로써 데이터의 일관성을 유지시키는데 도움을 줄 수 있을것 같다. 데이터가 변경되었는데 이미 캐시에 담겨진 key-value로 인하여 변경된 데이터가 아닌 기존의 데이터를 반환해주면 곤란하니깐 말이다. 하지만 캐시를 자주 비우는것은 성능에 좋지 않다고 하니 적절한것이 어렵지만 적절히 잘 사용하는것이 중요할 것 같다.
@PatchMapping
public ResponseEntity<Void> updateCustomer(@RequestParam Long id, @RequestBody CustomerRequest request) {
customerService.updateCustomer(id, request);
return ResponseEntity.ok().build();
}
@Transactional
public void updateCustomer(Long customerId, CustomerRequest request) {
Customer customer = customerRepository.findById(customerId)
.orElseThrow(() -> new EntityNotFoundException("customerId에 해당하는 customer가 없습니다."));
customer.setAddress(request.address());
customer.setName(request.name());
}
위 실습과 동일하게 Customer를 등록하고 조회를 해보았다. (위와 동일하므로 생략)
그리고
PATCH
메서드를 통해서 이미지와 같이 데이터를 수정해주었다.
DataBase를 통해서 조회를 해보면 수정된것을 알 수 있다.
하지만 GET
메서드를 통해서 조회를 해보면 캐싱된 데이터가 조회되는것을 볼 수 있다.
이런 상황이 일관성이지 못한 상황이다.
이제 @CacheEvict
를 활용해보겠다.
아주 간단하다.
@Transactional
@CacheEvict(value = "addresses", key = "#customerId")
public void updateCustomer(Long customerId, CustomerRequest request) {
Customer customer = customerRepository.findById(customerId)
.orElseThrow(() -> new EntityNotFoundException("customerId에 해당하는 customer가 없습니다."));
customer.setAddress(request.address());
customer.setName(request.name());
}
위처럼 설정해주면 된다. 현재 key속성을 지정해준 이유는 getAddress()메서드 호출 시 데이터가 캐싱되는데 이때 key가 매개변수인 customerId이기 때문이다.
그리고 위에서 했던 과정을 동일하게 수행하면
에서 확인할 수 있듯 수정시 addresses캐시에 해당 customerId를 key로 가진 데이터가 삭제된다. 따라서 이후에 처음 get호출될 경우에는 해당 customerId를 key로 가지는 데이터가 존재하지 않으므로 'CustomerService getAddress() 호출' 로그 다음 2초 후에 로그가 찍히는것을 볼 수 있고(이때 새로 캐싱한다.) 수정된 데이터가 조회되는것도 확인할 수 있다.
@Transactional
@CachePut(value = "addresses", key = "#customerId")
public Customer updateCustomer(Long customerId, CustomerRequest request) {
Customer customer = customerRepository.findById(customerId)
.orElseThrow(() -> new EntityNotFoundException("customerId에 해당하는 customer가 없습니다."));
customer.setAddress(request.address());
customer.setName(request.name());
return customer;
}
애너테이션을 기존의 @CacheEvict에서 @CachePut으로만 변경해주었다.
그리고 메소드의 반환값을 캐시에 저장 해주므로 반환값을 캐시 저장하는 메소드와 동일하게 Customer로 변경해주었다.
그리고 로직을 실행해보았다.
(Customer 저장 -> Customer조회 -> Customer 수정 -> Customer 조회)
package hello.cache;
import hello.cache.request.CustomerRequest;
import jakarta.persistence.EntityNotFoundException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.cache.annotation.CacheConfig;
import org.springframework.cache.annotation.CachePut;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Slf4j
@Service
@RequiredArgsConstructor
@CacheConfig(cacheNames = ("addresses"))
public class CustomerService {
private final CustomerRepository customerRepository;
@Cacheable
public Customer getAddress(Long customerId) {
log.info("CustomerService getAddress() 호출");
simulateSlowService();
Customer customer = customerRepository.findById(customerId)
.orElseThrow(() -> new EntityNotFoundException("customerId에 해당하는 customer가 없습니다."));
return customer;
}
private void simulateSlowService() {
try {
long time = 2000L;
Thread.sleep(time);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
public void registerCustomer(CustomerRequest request) {
Customer customer = new Customer();
customer.setAddress(request.address());
customer.setName(request.name());
customerRepository.save(customer);
}
@Transactional
@CachePut(key = "#customerId")
public Customer updateCustomer(Long customerId, CustomerRequest request) {
Customer customer = customerRepository.findById(customerId)
.orElseThrow(() -> new EntityNotFoundException("customerId에 해당하는 customer가 없습니다."));
customer.setAddress(request.address());
customer.setName(request.name());
return customer;
}
}
위 코드에서 볼 수 있듯 @CacheConfig애너테이션을 활용하면 일일히 각 메소드의 캐시 관련 애너테이션에 캐시 이름("addresses")을 작성해주지 않고 클래스 레벨에 한번만 작성해주면 된다.
참고