Caching in Spring

SexyWoong·2023년 11월 26일
0

spring

목록 보기
1/11

캐싱이란

일시적으로 데이터 하위 집합을 저장하는 고속 데이터 스토리지 계층이다. 이후 해당 데이터에 대한 요청이 있을 경우 데이터의 기본 스토리지 위치에서 읽어오는것 보다 더 빠르게 응답하여 요청을 처리할 수 있다. 캐싱을 사용하면 이전에 조회하고 계산했던 데이터에 대해서 효율적으로 재사용할 수 있다.

SpringBoot

의존성 추가

MVN Repository에서

implementation group: 'org.springframework.boot', name: 'spring-boot-starter-cache', version: '3.2.0'

와 같은 의존성을 찾아 build.gradle에 라이브러리를 추가해줄 수 있다.

Enable Caching

@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")를 사용하여 특정 메소드에 캐싱 적용
    캐싱을 사용하기 위해서 SpringFramework는 CacheManager가 필요하다. 하지만 위 조건을 만족하면 SpringBoot가 캐싱을 위한 자동 구성기능을 포함하고 있어 다음과 같은 방식으로 작동한다.
  1. CacheManager Bean이 명시적으로 정의되어있지 않을 경우, Springboot는 기본 CacheManager를 제공한다. -> ConcurrentMapCacheManager
  2. @Cacheable("addresses")와 같은 애너테이션을 활용할 경우 CacheManager에 "addresses"이름을 가진 캐시가 존재하지 않는다면 Spring Boot가 자동으로 해당 이름의 캐시를 생성한다. 따라서 @EnableCaching@Cacheable("addresses")만 있으면 Spring Boot의 자동 구성 기능 덕분에 작동할 수 있다.

@Cacheable

CustomerService

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()의 파라미터로는 저장할 캐시의 이름을 지정해주면 된다.

작동 방식은 다음과 같다.

  1. 메서드의 파라미터를 통한 캐시 존재 확인
  • @Cacheable이 적용된 메소드가 호출될 때, Spring은 먼저 메소드의 매개변수를 사용하여 캐시에서 해당 키로 저장된 값이 있는지 확인한다. 키는 기본적으로 메소드 매개변수의 조합으로 생성되며, 필요한 경우 key 속성을 통해 사용자 정의할 수 있다.

예시)

@Cacheable(value = "addresses", key = "#customerId")
  1. 캐시 내에 데이터가 저장되어 있다면 바로 결과값 반환
  • 만약 요청된 키에 해당하는 값이 캐시에 이미 존재한다면, 메소드를 실행하지 않고 캐시에서 값을 가져와 바로 반환한다. 이는 데이터베이스 조회, 복잡한 계산 등의 비용이 많이 드는 작업을 피할 수 있게 해준다.
  1. 존재하지 않는다면 메서드 호출
  • 캐시에 해당 키에 대한 데이터가 존재하지 않는 경우, @Cacheable이 적용된 메소드가 실제로 실행된다. 메소드의 실행 결과는 그 후 캐시에 저장되며, 이후 동일한 키로 메소드가 호출될 때 이 값을 재사용할 수 있다.

Customer

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;
}

CustomerController

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();
    }
}

CustomerRequest

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형태로 데이터가 저장된다. 이는 세션의 구조와 동일한것 같다.

  • 캐시 이름 : 위 설명에서 언급한 "addresses"와 같은 것이다.
  • 키 : 캐시 내에서 각각 데이터는 키를 통해 고유한 키로 식별된다. key에 대한 설정을 해주지 않으면 파라미터들을 통해 자동으로 생성된다. 물론 설정을 해준다면 해당 키 값으로 데이터가 저장된다.

키 생성은 어떻게 되는 것인가?

  • 매개변수가 1개인 경우 : @Cacheable애너테이션이 붙은 메서드의 매개변수가 키로 사용된다. 위 CustomerService의 코드를 예시로 들면 customerId가 key가 되는것이다.

  • 매개변수가 여러개인 경우 : 메소드가 여러 매개변수를 가질 경우, 모든 매개변수가 키의 일부로 사용된다. 이는 메소드 호출 시 제공되는 모든 매개변수가 키를 형성하여 각 호출을 고유하게 식별하는 데 도움이 된다.

@CacheEvict

@Cacheable의 문제점

  • 자주 접근하지 않는 데이터에 대해서도 캐싱을 한다. -> 이렇게 하나 둘씩 데이터가 캐시에 쌓이다보면 캐시에 사용되지도 않는 데이터들이 많이 쌓이게 된다.

기능

  • 캐시에서 데이터를 제거하는 데 사용한다.

속성

  • value : 제거할 데이터가 존재하는 캐시의 이름을 지정한다.
  • key : 캐시에서 제거할 데이터의 key를 지정한다. Spring Expression Language를 사용하여 key를 동적으로 지정할 수 있다.
  • allEntries : true로 설정하면 지정된 캐시의 모든 데이터를 삭제한다. 이 경우 어차피 캐시의 모든 데이터를 삭제하므로 key를 지정해주었더라면 key는 무시된다.
  • beforeInvocation
    • false (기본값) : 메서드가 성공적으로 수행된 후에 캐시의 데이터가 삭제된다.
    • true : 메서드 수행 전에 캐시의 데이터가 삭제된다.

캐시를 적절하게 삭제해줌으로써 데이터의 일관성을 유지시키는데 도움을 줄 수 있을것 같다. 데이터가 변경되었는데 이미 캐시에 담겨진 key-value로 인하여 변경된 데이터가 아닌 기존의 데이터를 반환해주면 곤란하니깐 말이다. 하지만 캐시를 자주 비우는것은 성능에 좋지 않다고 하니 적절한것이 어렵지만 적절히 잘 사용하는것이 중요할 것 같다.

실습

CustomerController에 수정 기능 추가

@PatchMapping
    public ResponseEntity<Void> updateCustomer(@RequestParam Long id, @RequestBody CustomerRequest request) {
        customerService.updateCustomer(id, request);

        return ResponseEntity.ok().build();
    }

CustomerService

@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초 후에 로그가 찍히는것을 볼 수 있고(이때 새로 캐싱한다.) 수정된 데이터가 조회되는것도 확인할 수 있다.

@CachePut

기능

  • 메소드가 실행될 때마다 메소드 호출 후 결과를 캐시에 저장한다.
    • 해당 key에 대한 value가 변경되지 않더라도 덮어쓰는 작업이 이루어진다.
  • 캐시에 저장된 데이터가 항상 최신 상태임을 보장한다.

속성

  • value
  • key
  • condition : 캐시에 데이터를 저장할 조건을 Spring Expression Language를 사용하여 정의한다. 이 조건이 true일 경우에만 메소드 호출 결과가 캐시에 저장된다.
  • unless : 결과 데이터에 대한 조건을 지정한다. 이 조건이 true일 경우에만 캐시에 저장이 되지 않는다.

실습

CustomerService

@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 조회)

  • 마지막 조회 시 데이터가 변경되었어도 캐싱이 되어있으므로 2초 후 조회가 아닌 바로 조회가 가능하다.

    위 이미지에서 드래그된 부분을 보면 getAddress()메소드가 호출되지 않고 캐시에서 바로 조회가 되었음을 확인할 수 있다.

@CacheConfig

CustomerService

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")을 작성해주지 않고 클래스 레벨에 한번만 작성해주면 된다.

참고

profile
함께 있고 싶은 사람, 함께 일하고 싶은 개발자

0개의 댓글