들어가기 전

이번 포스팅에서는 Spring에서 기본적으로 제공해 주는 Local Cache에 대해서 알아보겠습니다.

먼저 Spring에서 제공해 주는 Local Cache에 대해서 알아보기 전 Cache가 무엇인지에 대해 알아보겠습니다.

 

Cache란?

자주 사용하는 데이터를  메모리에 미리 복사해 놓는 임시 장소입니다. 캐시는  저장공간이 작고 비용이 비쌉니다. 하지만  사용하는 이유는 빠른 성능을 제공하기 때문입니다.

 

보통 데이터를 사용할 때 디스크에 접근을 해서 저장된 데이터를 사용을 합니다. 지속적으로 데이터를 요청을 하면 DBMS의 부하가 늘어나고 시간도 오래 걸립니다.

 

 

DBMS의 부하를 줄이고 시간을 줄이기 위해 캐시를 사용합니다. 캐시는 메모리에 데이터를 저장하고 관리하기 때문에 DBMS에 접근하는 거에 비해 성능이 뛰어납니다.

클라이언트가 원하는 데이터를 요청을 했을대 캐시에 원하는 값이 있으면 Cache Hit이라고 하고 캐시에 있는 데이터를 사용하고  없으면 Cache Miss라고 합니다. Cache Miss일 경우 DB에서 조회를 하고 지속적으로 Cache Miss가 발생하는 데이터는 변경해야 합니다.

 

캐시 전략

1. Look Aside

  • 클라이언트가 서버에 데이터를 요청하면 서버는 먼저 캐시를 확인합니다.
  • 캐시에 클라이언트가 원하는 데이터가 있으면 Cache Hit이고 캐시에서 조회해 온 데이터를 클라이언트에게 응답을 해줍니다.
  • 캐시에 원하는 데이터가 없으면 Cache Miss이고 DB에서 데이터를 조회해 오고 조회해 온 값을 캐시에 저장하고 클라이언트에게 응답합니다.

Cache Hit일 경우

 

 

Cache Miss일 경우

 

 

 

2. Write Back

  • 서버는 모든 데이터를 Cache에 저장을 합니다.
  • 특정시간 동안만 캐시에 데이터가 저장되고 캐시 서버에 있는 데이터가 DB에 저장이 됩니다.
  • DB에 저장이 되면 캐시에 있던 데이터를 삭제합니다.

 

 

지금까지 캐시가 무엇인지에 대해서 알아보았습니다.
이제부터 스프링에서 제공해 주는 Local Cache에 대해서 알아보겠습니다.

*참고
CacheManager 종류는 아래와 같습니다.
- ConcurrentMapCacheManager
- SimpleCacheManager
- EhCacheCacheManager
- CompositeCacheManager
- CaffeineCacheManager
- JCacheCacheManager

Spring에서 기본적으로 제공하는 Cache는 ConcurrentMapCacheManager입니다.

 

예제에서 사용되는 공통 코드

 

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Account {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long Id;

    private String name;
    private String email;
    private int age;

    public Account(String name, String email, int age) {
        this.name = name;
        this.email = email;
        this.age = age;
    }

    public void update(String name, int age) {
        this.name = name;
        this.age = age;
    }
}

 

public interface AccountRepository extends JpaRepository<Account, Long> {
}

 

 

@AllArgsConstructor
@Getter
public class AccountJoinRequest
 {
     private String name;
     private String email;
     private int age;

    public AccountJoin ofAccountJoinByThis() {
        return new AccountJoin(name,
            email,
            age);
    }
}

 

@AllArgsConstructor
@Getter
public class AccountJoin {
    private String name;
    private String email;
    private int age;
}

 

 

@RestController
@RequestMapping("/api/accounts")
@RequiredArgsConstructor
public class AccountController {
    private final AccountWriteService accountWriteService;
    private final AccountReadService accountReadService;

    @PostMapping
    public void save(@RequestBody AccountJoinRequest accountJoinRequest){
        accountWriteService.save(accountJoinRequest.ofAccountJoinByThis());
    }
}

 

@Service
@Transactional
@RequiredArgsConstructor
public class AccountWriteService {
    private final AccountRepository accountRepository;


    public void save(AccountJoin accountJoin) {
        accountRepository.save(
            new Account(accountJoin.getName(),
                accountJoin.getEmail(),
                accountJoin.getAge())
        );
    }
}

 

Local Cache에서 사용되는 키워드

  • @EnableCaching : Local Cahce 활성화
  • @Cachable : 캐시를 저장 또는 조회
  • @CachePut : 캐시 저장 또는 기존에 있는 캐시 갱신
  • @CacheEvict : 캐시 삭제
  • @CacheConfig : 클래스단위에 사용하여 공통 cache 이름 설정

 

@EnableCaching을 적용하여 Spring에서 기본적으로 제공하는 Local Cache 활성화

 

@SpringBootApplication
@EnableCaching
public class DemoApplication {

    public static void main(String[] args) {
        SpringApplication.run(DemoApplication.class, args);
    }

}

 

 

@Cachable

@Cachable는 캐시를 저장 또는 조회를 할 때 사용합니다.

Cache Hit(캐시에 데이터가 존재할 경우) 조회를 하고 Cache Miss(캐시에 데이터가 존재하지 않을 경우)에서는 결괏값을 캐시에 저장합니다.

 

Cachable의 속성

  • value : 캐시의 이름을 지정합니다.
  • cacheNames : 캐시의 이름을 지정합니다.
  • key : 캐시의 키값을 지정합니다.
  • condition : 조건을 설정하여 조건에 부합할 때 캐시를 저장합니다.

 

@AllArgsConstructor
@Getter
public class AccountInfoResponse {
    private String name;
    private String email;
    private int age;

    public static AccountInfoResponse from(AccountInfo accountInfo) {
        return new AccountInfoResponse(accountInfo.getName(),
            accountInfo.getEmail(),
            accountInfo.getAge());
    }
}

 

@RestController
@RequestMapping("/api/accounts")
@RequiredArgsConstructor
public class AccountController {
    private final AccountReadService accountReadService;

    // 중략..

    @GetMapping("/{accountId}")
    public AccountInfoResponse findAccountById(@PathVariable Long accountId) {
        return AccountInfoResponse.from(accountReadService.findAccountById(accountId));
    }

 

accountId로 특정 회원을 조회하는 API입니다.

 

 

사용 전

 

@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class AccountReadService {
    private final AccountRepository accountRepository;

    public AccountInfo findAccountById(Long accountId) {
        Account account = accountRepository.findById(accountId)
            .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 사용자입니다."));

        return AccountInfo.entityToDto(account);
    }
}

 

 

캐시를 사용하기 전에 같은 데이터를 조회하면 여러 번 조회하면 위 결과처럼 호출한 횟수만큼 쿼리가 발생합니다.

 

 

사용 후

 

@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class AccountReadService {
    private final AccountRepository accountRepository;

    @Cacheable(value = "accounts", key = "#accountId")
    public AccountInfo findAccountById(Long accountId) {
        Account account = accountRepository.findById(accountId)
            .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 사용자입니다."));

        return AccountInfo.entityToDto(account);
    }
}

 

 

처음에 데이터 조회를 할 때 캐시에 데이터가 없기 때문에 캐시에 데이터를 저장하고 쿼리가 발생합니다. 그 후 몇 번을 호출하든 쿼리가 발생하지 않고 캐시에서 데이터를 조회합니다.

 

캐시 key에는 accounts::accountId, Value에는 응답값을 저장해 둡니다.

 

 

위 코드를 실행시키면 아래와 같이 동작합니다.

 

CacheMiss일 경우

 

 

클라이언트가 accountId 값으로 회원 정보를 조회하기 위해 서버에 요청을 보냅니다.

서버는 먼저 캐시의 키값인 accounts::accountId를 통해 Cache에 접근을 합니다.

Cache Miss일 경우 데이터베이스에 접근을 하여 클라이언트의 요청에 알맞은 데이터를 조회를 합니다.

데이터 조회 후 Cache에 캐시를 저장하고 클라이언트에게 응답합니다.

 

CacheHit일 경우

 

 

클라이언트가 accountId 값으로 회원 정보를 조회하기 위해 서버에 요청을 보냅니다.

서버는 먼저 캐시의 키값인 accounts::accountId를 통해 Cache에 접근을 합니다.

Cache Miss일 때와 다르게 Cache Hit일 때는 데이터베이스에 접근하지 않고 캐시에 저장되어 있는 값을 클라이언트에게 응답합니다.

 

@Cachable - condition

@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class AccountReadService {
    private final AccountRepository accountRepository;

    @Cacheable(value = "accounts", key = "#accountId", condition = "#accountId % 2 == 0")
    public AccountInfo findAccountById(Long accountId) {
        Account account = accountRepository.findById(accountId)
            .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 사용자입니다."));
        return AccountInfo.entityToDto(account);
    }
}

 

 

condition 속성에 정의한 조건에 부합하면 캐시에 저장하고 그렇지 않으면 캐시에 저장하지 않습니다.


@CachePut

캐시 값을 갱신 또는 저장할 때 사용합니다.

같은 키값을 가진 캐시가 존재하면 해당 캐시의 값을 갱신을 하고 없으면 캐시 값을 저장합니다.

그리고 데이터 갱신을 할 때 캐시 값이 없을 경우 update 쿼리가 발생하지만 캐시 값이 있을 경우에는 update 쿼리가 수행되지 않습니다.

 

 

public record AccountUpdateRequest(
    String name,
    int age
) {}

 

@RestController
@RequestMapping("/api/accounts")
@RequiredArgsConstructor
public class AccountController {
    private final AccountWriteService accountWriteService;

    @PutMapping("/{accountId}")
    public AccountInfoResponse update(@PathVariable Long accountId,@RequestBody AccountUpdateRequest accountUpdateRequest) {
    AccountInfo accountInfo = accountWriteService.update(accountId, accountUpdateRequest.name(),
            accountUpdateRequest.age());
    return AccountInfoResponse.from(accountInfo);
    }
}

 

@Service
@Transactional
@RequiredArgsConstructor
public class AccountWriteService {
    private final AccountRepository accountRepository;

    @CachePut(cacheNames = "accountput",key = "#accountId")
    public AccountInfo update(Long accountId, String name, int age) {
        Account account = accountRepository.findById(accountId)
            .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 사용자입니다."));
        account.update(name, age);
        return AccountInfo.entityToDto(account);
    }
}

 

 

 

 

 

 

 

 

 

 

유저 생성, 조회를 한 뒤 수정을 할 때 처음에 update를 하고 동일한 값으로 유저의 정보를 수정하려고 시도할 때 update 쿼리가 발생하지 않는 것을 확인할 수 있습니다.

 

이와 같이 캐시를 통해 조회, 수정에 대해서 알아보았습니다.

그런데 캐시에 저장된 데이터와 연관된 데이터를 수정할 때 아래와 같이 주의할 점이 있습니다.

 

주의할 점

 

 

유저 정보를 조회 한 뒤 유저 정보를 수정한 뒤 @CachePut을 활용하여 유저 정보 수정 시 발생하는 응답 값을 캐시로 저장을 한 뒤 다시 유저 정보를 조회를 하게 되면 유저 정보를 수정하기 전의 값이 나오게 됩니다.

 

이유는 캐시의 값이 아래와 같이 저장이 되어 있기 때문에 수정하기 전의 값이 나오게 되는 것입니다.

 

 

조회에 관한 캐시의 키값과 수정에 관한 캐시의 키값이 각각 존재하여 유저를 조회할 때 수정되기 전의 데이터를 클라이언트에게 응답해 주는 상황이 발생하게 됩니다.

 

이러한 상황을 방지하기 위해 아래와 같이 두 가지 방법이 있습니다.

  • @CachePut의 캐시 키값을 유저 정보 조회를 조회할 때 저장되는 캐시의 키값과 동일하게 설정
  • @CacheEvict를 사용하여 캐시 삭제

 

@CachePut의 캐시 키값을 유저 정보를 조회할 때 저장되는 캐시의 키값과 동일하게 설정

 

유저 정보 조회

 

@Cacheable(cacheNames = "accounts",key = "#accountId")
public AccountInfo findAccountById(Long accountId) {
    Account account = accountRepository.findById(accountId)
        .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 사용자입니다."));
    return AccountInfo.entityToDto(account);
}

 

 

유저 정보 수정

 

@CachePut(cacheNames = "accounts",key = "#accountId")
public AccountInfo update(Long accountId, String name, int age) {
    Account account = accountRepository.findById(accountId)
        .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 사용자입니다."));
    account.update(name, age);
    return AccountInfo.entityToDto(account);
}

 

 

Cache Miss일 경우

 

 

유저 정보 수정 시 캐시의 키값이 존재하지 않으면 키와 값을 저장합니다.

수정 후 유저 정보 조회 시 유저 정보를 수정하는 캐시의 키값과 유저 정보 조회의 캐시 키값이 동일하기 때문에 수정된 유저정보를  정상적으로 클라이언트에게 응답해 줄 수 있습니다.

 

Cache Hit일 경우

 

 

 

유저 정보 수정 시 캐시의 키값이 존재하면 저장되어 있는 캐시의 값을 수정합니다.

수정 후 유저 정보를 조회하게 되면 수정된 유저 정보를 클라이언트에게 응답해 줄 수 있습니다.

 

@CacheEvict를 사용하여 캐시 삭제

 

@CacheEvict는 저장된 캐시의 값을 삭제합니다.

그래서 유저 정보를 수정할 때 유저 정보 조회로 저장된 캐시의 값을 삭제하게 되면 유저 정보를 조회할 때 Cache Miss가 발생하여 데이터베이스에 접근하여 수정된 유저의 정보를 조회할 수 있습니다.

 

@CacheEvict(cacheNames = "accounts",key = "#accountId")
public AccountInfo update(Long accountId, String name, int age) {
    Account account = accountRepository.findById(accountId)
        .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 사용자입니다."));
    account.update(name, age);
    return AccountInfo.entityToDto(account);
}

 

위 코드를 수행하면 아래와 같이 동작합니다.

 

 

@CacheEvict로 인해 유저 정보를 수정할 때 캐시의 키가 존재하면 삭제하고 데이터베이스에 접근하여 유저 정보를 수정 후 클라이언트에게 수정된 유저 정보를 응답합니다.

 

 

 

@CacheEvict로 인해 캐시가 삭제되어 유저 정보 조회 시 캐시가 존재하지 않아 데이터베이스에 접근하여 유저의 정보를 조회하여 클라이언트에게 응답합니다.

이때 새롭게 캐시가 저장이 된 것이기 때문에 수정된 유저 정보를 클라이언트에게 정상적으로 응답해 줍니다.