Spring Boot에서 데이터 캐싱 방법

이 글에서는 시스템 실행 중 성능을 향상시키기 위해 데이터를 캐싱하는 몇 가지 기술을 살펴보겠습니다. 일반적으로 계산하는 데 시간이 오래 걸리는 값을 캐싱하면 나중에 액세스할 때 시간을 절약할 수 있습니다. 캐싱되는 값은 자주 변경되지 않거나, 최신 버전이 필요하지 않은 경우가 많습니다.

Spring Boot에서 캐싱

Spring Boot REST API에서 캐싱을 활성화하는 가장 간단한 방법은 애플리케이션에 설정하고, 엔드포인트에 @Cacheable 어노테이션을 사용하는 것입니다.

애플리케이션에서 다음과 같이 @EnableCaching 어노테이션을 추가하기만 하면 됩니다.

1
2
3
4
5
6
7
8
9
@SpringBootApplication
@EnableCaching
public class SpringBootCachingApplication {

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

}

컨트롤러에서, 캐싱하고자 하는 메서드에 @Cacheable 어노테이션을 추가합니다.

  • /v1/status API는 캐싱이 설정되지 않았으므로, 호출할 때마다 실제 메서드가 실행됩니다.
  • /v2/status API는 캐싱이 설정되어 있으므로, 첫 번째 호출만 메서드를 실행하고 이후 호출에서는 캐싱된 결과를 반환합니다.
  • /v3/status API는 캐싱 설정에 매개변수를 포함하여, 캐시가 매개변수에 따라 결과를 다르게 저장하도록 합니다. 즉, 이전에 사용된 매개변수로 호출하면 캐싱된 결과를 사용하고, 그렇지 않으면 메서드를 실행합니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
@RestController
@Log4j2
public class CacheController {

@GetMapping("/v1/status")
public ResponseEntity<String> statusV1() {
log.info("status V1 called");
// 약간의 무거운 처리
return ResponseEntity.ok("DONE");
}

@GetMapping("/v2/status")
@Cacheable("status")
public ResponseEntity<String> statusV2() {
log.info("status V2 called");
// 약간의 무거운 처리
return ResponseEntity.ok("DONE");
}

@GetMapping("/v3/status/{param}")
@Cacheable("status")
public ResponseEntity<String> statusV3(@PathVariable final int param) {
log.info("status V3 called");
// 약간의 무거운 처리
return ResponseEntity.ok(param == 1 ? "DONE" : "IN PROCESS");
}
}

JPA 캐싱

때로는 API의 결과를 캐싱할 수 없지만, 쿼리의 결과를 캐싱함으로써 동일한 쿼리를 여러 번 실행하는 것을 피할 수 있습니다. JPA와 Spring Boot는 이를 스마트하게 처리할 수 있습니다.

엔티티를 정의해보겠습니다.

1
2
3
4
5
6
7
8
9
10
11
@Entity
@Getter
@Setter
public class Customer {

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

private String name;
}

그런 다음, 리포지토리에서 @Cacheable 어노테이션을 추가하여 결과를 캐시에 저장할 메서드를 지정합니다. findById와 같은 “standard” 메서드를 캐싱해야 하는 경우, 이를 오버라이드하고 어노테이션을 추가할 수 있습니다.

1
2
3
4
5
6
7
8
9
@Repository
public interface CustomerRepository extends CrudRepository<Customer, Long> {
@Override
@Cacheable("customers")
Optional<Customer> findById(Long aLong);

@Cacheable("customers")
List<Customer> findByName(String name);
}

Caffeine

일부 경우에는 REST 엔드포인트나 JPA 레벨에서 캐시를 사용할 수 없는 경우가 있습니다. 이때는 캐시를 수동으로 구현해야 하며, 이를 위해 Caffeine 라이브러리를 사용할 수 있습니다.

Caffeine은 캐시에 저장할 요소의 최대 개수를 정의하고 만료 조건을 설정할 수 있습니다.

프로젝트에 다음 의존성을 추가해야 합니다.

1
2
3
4
5
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
<version>3.1.8</version>
</dependency>

사용법은 매우 직관적입니다:

  • 키와 값을 사용해 캐시를 정의합니다.
  • 캐시의 최대 크기를 설정할 수 있습니다.
  • 만료 기간을 설정할 수 있습니다.
  • build 메서드에서 캐시 미스가 발생했을 때 캐시를 채우는 방법을 정의합니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@Service
@RequiredArgsConstructor
public class CustomerService {

private final LoadingCache<Long, String> customerCache =
Caffeine.newBuilder()
.maximumSize(10_000)
.expireAfterWrite(Duration.ofMinutes(5))
.refreshAfterWrite(Duration.ofMinutes(1))
.build(this::findCustomerName);

private final CustomerRepository customerRepository;

private String findCustomerName(final Long key) {
return customerRepository.findById(key).map(Customer::getName).orElse("");
}

public String getCustomerName(final Long id) {
return customerCache.get(id);
}
}

getCustomerName을 호출하면, 이 메서드는 캐시 정의에 따라 실행됩니다. 먼저, 해당 키와 일치하는 항목이 있는지 확인하고, 그렇지 않으면 findCustomerName 메서드를 호출해 캐시를 채웁니다.

Share