Post

[Spring Boot에서 캐시(Cache) 활용하기] - 캐시 적용해보기

[Spring Boot에서 캐시(Cache) 활용하기] - 캐시 적용해보기

지난 포스트에서는 캐시의 기본 개념과 Spring에서 제공하는 캐시 추상화에 대해 알아보았다. 이번 포스트에서는 간단한 서비스를 직접 구현한 뒤, 여기에 로컬(Caffeine Cache) 캐시를 적용해보고 실제로 어떤 변화가 있는지 살펴보겠다. 캐시를 적용하기 전후의 성능 차이를 비교하면서, 캐시가 서비스에 어떤 장점을 가져다주는지 구체적으로 확인해보자.

프로젝트 설명


캐시(Cache)는 자주 사용되는 데이터를 임시로 저장해 두었다가 빠르게 제공함으로써 시스템의 성능과 효율성을 크게 높이는 역할을 한다. 이번 포스트에서 캐시를 도입할 베이스 프로젝트는 다음과 같은 구조와 특징을 갖는다.

프로젝트 개요

이 포스트에서 캐시를 적용할 예제 프로젝트는 도서 정보 관리 서비스를 구현해보도록 하자. 이 서비스는 책 정보를 관리하고 조회하는 기능을 제공하는 간단한 웹 어플리케이션이다.

주요 기능

도서 조회 기능
도서 조회는 사용자들이 가장 빈번하게 사용하는 기능으로, 다양한 방식의 조회를 지원한다.
  • 전체 도서 목록 조회: 페이지네이션을 적용하여 대량의 도서 데이터를 효율적으로 조회 가능
  • 저자별 도서 목록 조회: 특정 저자가 작성한 모든 도서를 조회
  • ID로 도서 조회: 특정 ID를 통해 도서의 상세 정보를 조회
  • ISBN으로 도서 조회: 국제 표준 도서 번호(ISBN)를 통해 도서를 검색
도서 관리 기능
관리자를 위한 도서 관리 기능
  • 도서 등록: 새로운 도서 정보를 시스템에 등록
  • 도서 삭제: 더 이상 필요하지 않은 도서 정보를 시스템에서 제거

기능 구현

Base 프로젝트의 전체 코드는 Github에 있으니 참고해주세요.

주요 기능을 구현한 Service계층의 코드를 살펴보자

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
28
29
@Service
@RequiredArgsConstructor
public class BookManageService { // 도서 관리 기능

  private final BookRepository bookRepository;

  // 도서 등록
  @Transactional
  public BookResponse register(BookCreateRequest request) {
    Book book = Book.builder()
      .author(request.author())
      .title(request.title())
      .description(request.description())
      .isbn(request.isbn())
      .price(request.price())
      .build();
    bookRepository.save(book);
    return BookResponse.from(book);
  }

  // 도서 삭제
  @Transactional
  public void delete(Long id) {
    Book book = bookRepository.findById(id).orElseThrow(
      () -> new CustomException(ErrorCode.BOOK_NOT_FOUND)
    );
    bookRepository.delete(book);
  }
}
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
28
29
30
31
32
33
34
35
36
37
38
39
40
@Service
@RequiredArgsConstructor
public class BookSearchService { // 도서 조회 기능

  private final BookRepository bookRepository;

  // 전체 도서 목록 조회
  public BookListResponse getList(Pageable pageable) {
    Slice<Book> books = bookRepository.findAllByOrderById(pageable);
    List<BookResponse> list = books.stream()
      .map((BookResponse::from))
      .toList();
    return new BookListResponse(list, books.hasNext());
  }

  // 저자별 도서 목록 조회
  public BookListResponse getListByAuthor(String author, Pageable pageable) {
    Slice<Book> books = bookRepository.findAllByAuthor(author, pageable);
    List<BookResponse> list = books.stream()
      .map((BookResponse::from))
      .toList();
    return new BookListResponse(list, books.hasNext());
  }

  // ID로 도서 조회
  public BookDetailResponse getDetail(Long id) {
    Book book = bookRepository.findById(id).orElseThrow(
      () -> new CustomException(ErrorCode.BOOK_NOT_FOUND)
    );
    return BookDetailResponse.from(book);
  }

  // ISBN으로 도서 조회
  public BookDetailResponse getDetailByIsbn(String isbn) {
    Book book = bookRepository.findByIsbn(isbn).orElseThrow(
      () -> new CustomException(ErrorCode.BOOK_NOT_FOUND)
    );
    return BookDetailResponse.from(book);
  }
}

캐시 적용 대상 선정의 필요성과 기준


왜 캐시 적용 대상을 선정해야 할까?

캐시는 시스템의 성능과 효율성을 크게 높일 수 있지만, 모든 데이터를 무작정 캐싱하는 것은 오히려 역효과를 불러올 수 있다. 캐시 메모리는 한정되어 있고, 잘못된 대상 선정은 불필요한 메모리 낭비, 데이터 불일치, 심지어 시스템 장애로 이어질 수 있다. 따라서 어떤 데이터를 캐싱할지 신중하게 선정하는 과정이 반드시 필요하다.

캐시 적용 대상을 선정하는 기준

캐시 적용 대상을 선정할 때는 다음과 같은 기준을 고려해보았다.

  • 자주 조회되는 데이터
    반복적으로 요청되는 데이터는 캐시에 저장하면 데이터베이스나 외부 시스템에 대한 부하를 크게 줄일 수 있다. 예를 들어, 전체 도서 목록, 인기 도서, 특정 저자별 도서 목록 등은 다수의 사용자가 반복적으로 조회할 가능성이 높으므로 캐시 적용에 적합하다.
  • 변경이 적은 데이터
    자주 변경되지 않는 데이터는 캐시에 저장해도 데이터 불일치 위험이 낮아 캐시 효율이 높다. 반대로 실시간성이 매우 중요한 데이터나 자주 변경되는 데이터(예: 실시간 거래, 재고 등)는 캐시 적용에 신중해야 한다..
  • 조회 시 계산 비용이 높은 데이터
    복잡한 연산이나 집계, 다수의 테이블 조인이 필요한 데이터는 매번 DB에서 계산하는 것보다 결과를 캐시에 저장해두고 재사용하는 것이 효율적이다.
  • 데이터 크기
    데이터 크기가 너무 크면 캐시 효율이 떨어질 수 있으므로, 캐시할 데이터의 크기도 고려해야 한다. 너무 큰 객체는 캐시 공간을 빠르게 소모해 다른 유용한 데이터를 밀어낼 수 있다.

캐시 적용 대상 선정


위 기준을 바탕으로 프로젝트에서 어떤 데이터/메서드에 캐시를 적용할지 선정해보았다. 실제 존재하는 프로젝트는 아니지만, 일반적인 도서 관리 시스템의 특성을 고려하여 가상의 시나리오로 생각해보았다.

데이터/메서드조회 빈도변경 빈도계산 비용비고
전체 도서 목록 조회 (getList)높음낮음높음페이지네이션 적용, 다수 사용자 요청
저자별 도서 목록 조회 (getListByAuthor)높음낮음중간~높음특정 저자 인기 시 반복 요청 많음
ID로 도서 상세 조회 (getDetail)중간낮음낮음상세 조회 반복 시 효과적
ISBN으로 도서 상세 조회 (getDetailByIsbn)중간낮음낮음외부 연동/검색 등에서 반복 요청 가능
도서 등록/삭제 (register, delete)낮음높음낮음데이터 변경 작업, 캐시 대상 아님
전체 도서 목록 조회(getList) 및 저자별 도서 목록 조회(getListByAuthor)
이 두 기능은 다수의 사용자가 반복적으로 요청하는 대표적인 조회 작업이다. 데이터 변경도 드물기 때문에 캐시를 적용하면 DB 부하를 크게 줄이고, 응답 속도를 향상시킬 수 있다.
도서 상세 조회(getDetail, getDetailByIsbn)
상세 조회는 목록 조회에 비해 상대적으로 요청 빈도가 낮지만, 동일한 데이터에 대한 반복 조회가 발생할 수 있다. 캐시 효율이 목록 조회만큼 높지는 않지만, 캐시를 적용하면 불필요한 DB 접근을 줄일 수 있을 것이다.
도서 등록/삭제(register, delete)
이 기능들은 데이터 변경 작업이므로 캐시 적용 대상이 아니다. 오히려 이러한 작업 이후에는 관련된 캐시를 적절히 무효화해야 데이터 일관성을 유지할 수 있다.

로컬 캐시(Local Cache) 적용


프로젝트에 로컬 캐시가 적용된 전체 코드는 Github에 있으니 참고해주세요.

로컬 캐시는 애플리케이션 서버 내부에 데이터를 임시 저장하여, 데이터베이스나 외부 시스템에 대한 반복적인 요청을 줄이고 응답 속도를 향상시키는 데 목적이 있다. 애플리케이션과 같은 프로세스 내에서 동작하므로 별도의 네트워크 오버헤드가 없어 매우 빠르게 데이터 접근이 가능하다.

Spring Boot는 별도의 캐시 라이브러리 의존성이 없을 경우, 기본적으로 ConcurrentMapCacheManager를 제공한다. 이름에서 알 수 있듯이 내부적으로 java.util.concurrent.ConcurrentHashMap을 사용하여 캐시를 관리한다. 이는 별도의 설정 없이 바로 사용할 수 있다는 장점이 있으며, 간단한 캐시 요구사항에는 충분할 수 있다. 하지만 ConcurrentHashMap 기반의 기본 캐시는 단순한 저장 기능만 제공하고 캐시 만료 정책이나 크기 제한 같은 기능이 부족하다.

캐시의 장점을 더 누리기 위해서는 다양한 기능을 제공하는 로컬 캐시 라이브러리인 Caffeine이나 Ehcache 등을 사용하는 것이 일반적이다. 이 라이브러리들은 다양한 캐시 제거 정책과 부가 기능을 통해 메모리 사용을 최적화하고, 캐시 히트율을 높여 시스템 성능을 극대화하는 데 도움을 준다.

Caffeine과 Ehcache 중 무엇을 사용할지 고민된다면,
이 포스트(니들이 caffeine 맛을 알아? - NAVER Pay Dev Blog)를 한번 참고해보자

1. 의존성 주입

1
2
implementation 'org.springframework.boot:spring-boot-starter-cache'
implementation 'com.github.ben-manes.caffeine:caffeine:3.2.0'

2. 캐시 설정 및 빈 등록

Caffeine 캐시 설정을 위한 CacheConfig 클래스를 생성해준다. 캐시 만료 및 용량 정책 설정을 한다. 여기서는 두 가지 방식으로 구현할 수 있다.

두 방식 모두 소개를 하지만 이번 프로젝트에서는 enum을 활용하여 여러 캐시를 설정하는 방식을 사용한다.

단일 캐시 설정

단일 캐시 설정은 모든 캐시에 동일한 설정을 적용하는 방식이다. 다음과 같은 상황에서 사용하기 적합하다.

  • 애플리케이션에서 필요한 캐시 종류가 적을 때(1-2개)
  • 모든 캐시가 동일한 특성(만료 시간, 크기 등)을 가질 때
  • 간단한 프로토타입이나 소규모 프로젝트
  • 설정의 단순함이 중요할 때
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Configuration
@EnableCaching // 스프링 캐시 기능 활성화
public class CacheConfig {
  @Bean
  public Caffeine<Object, Object> caffeineCacheBuilder() {
      return Caffeine.newBuilder()
          .expireAfterWrite(10, TimeUnit.MINUTES) // 캐시에 저장된 항목을 10분 후 자동 만료
          .maximumSize(100) // 최대 100개의 항목까지만 캐시에 저장
          .recordStats(); // 캐시 통계 정보 수집 활성화
  }
  
  @Bean // CacheManager 빈 등록 (스프링 캐시 추상화에서 사용)
  public CacheManager cacheManager(Caffeine caffeine) {
      // "bookCache"라는 이름의 캐시 생성
      CaffeineCacheManager caffeineCacheManager = new CaffeineCacheManager("bookCache");

      // 위에서 만든 caffeineCacheBuilder로 만든 캐시 설정 적용
      caffeineCacheManager.setCaffeine(caffeine);

      //  // CacheManager 빈 반환
      return caffeineCacheManager;
  }
}

여러 캐시 설정(Enum 활용)

Enum을 활용한 다중 캐시 설정은 각 캐시마다 다른 설정을 적용할 수 있는 방식이다. 다음과 같은 상황에서 사용하기 적합하다.

  • 여러 종류의 캐시가 필요할 때
  • 캐시마다 다른 설정이 필요할 때(데이터 특성에 따라 만료 시간 차별화)
  • 캐시 설정을 중앙에서 관리하고 싶을 때
  • 대규모 프로젝트에서 캐시 관리가 중요할 때
  • 데이터 특성에 맞게 최적화된 캐시 설정이 필요할 때
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 캐시 유형을 정의하는 enum 클래스
@Getter
public enum CacheType {
  // 캐시 유형별 설정 (캐시명, 최대크기, 만료시간(초))
  BOOK_LIST("bookList", 100, 300),            // 5분 유지 캐시
  AUTHOR_BOOKS("authorBooks", 100, 300),      // 5분 유지 캐시
  BOOK_DETAIL("bookDetail", 500, 1800),       // 30분 유지 캐시
  BOOK_DETAIL_BY_ISBN("bookDetailByIsbn", 500, 1800);  // 30분 유지 캐시

  private final String cacheName; 
  private final int maximumSize;
  private final int expireAfterWrite;

  CacheType(String cacheName, int maximumSize, int expireAfterWrite) {
    this.cacheName = cacheName;
    this.maximumSize = maximumSize;
    this.expireAfterWrite = expireAfterWrite;
  }
}
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
28
29
30
@Slf4j
@Configuration
@EnableCaching // 스프링 캐시 기능 활성화
public class CacheConfig {
  @Bean
  public CacheManager cacheManager() {
    // CacheType enum 값을 기반으로 캐시 생성
    List<CaffeineCache> caches = Arrays.stream(CacheType.values())
       // 각 CacheType에 대해 CaffeineCache 인스턴스 생성
      .map(cache -> new CaffeineCache(cache.getCacheName(),
        Caffeine.newBuilder() // Caffeine 빌더 시작
          // 캐시 항목 생성 후 지정 시간(초) 후 자동 삭제
          .expireAfterWrite(cache.getExpireAfterWrite(), TimeUnit.SECONDS)
          // 항목 제거 시 로그 출력 리스너 등록
          .evictionListener((key, value, cause) ->
            log.info("키 {} 제거됨 ({}): {}", key, cause, value))
          // 캐시 통계 수집 활성화
          .recordStats()
          // 최대 보관 가능 항목 수 설정
          .maximumSize(cache.getMaximumSize())
          .build())) // Caffeine 캐시 빌드
      .collect(Collectors.toList());

    // 스프링의 기본 캐시 관리자 생성
    SimpleCacheManager cacheManager = new SimpleCacheManager();
    // 생성한 CaffeineCache 리스트 설정
    cacheManager.setCaches(caches);
    return cacheManager;
  }
}

3. 서비스 계층에 캐시 어노테이션 적용

아래 코드에서 사용된 @Cacheable 어노테이션은 메서드의 결과를 캐시에 저장하는 역할을 한다. 동일한 파라미터로 메서드가 다시 호출될 경우, 실제 메서드 실행 없이 캐시에서 결과를 반환한다.

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
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
@Service
@RequiredArgsConstructor
public class BookSearchService {

	private final BookRepository bookRepository;

	// 페이지 번호와 크기를 키로 사용하여 결과를 캐시
	@Cacheable(value = "bookList", key = "#pageable.pageNumber + '_' + #pageable.pageSize")
	public BookListResponse getList(Pageable pageable) {
		Slice<Book> books = bookRepository.findAllByOrderById(pageable);
		List<BookResponse> list = books.stream()
			.map((BookResponse::from))
			.toList();
		return new BookListResponse(list, books.hasNext());
	}

	// author와 페이지 번호, 크기 정보를 키로 사용하여 결과를 캐시
	@Cacheable(value = "authorBooks", key = "#author + '_' + #pageable.pageNumber + '_' + #pageable.pageSize")
	public BookListResponse getListByAuthor(String author, Pageable pageable) {
		Slice<Book> books = bookRepository.findAllByAuthor(author, pageable);
		List<BookResponse> list = books.stream()
			.map((BookResponse::from))
			.toList();
		return new BookListResponse(list, books.hasNext());
	}

	// 도서 ID를 키로 사용하여 결과를 캐시
	@Cacheable(value = "bookDetail", key = "#id")
	public BookDetailResponse getDetail(Long id) {
		Book book = bookRepository.findById(id).orElseThrow(
			() -> new CustomException(ErrorCode.BOOK_NOT_FOUND)
		);
		return BookDetailResponse.from(book);
	}

	// ISBN을 키로 사용하여 결과를 캐시
	@Cacheable(value = "bookDetailByIsbn", key = "#isbn")
	public BookDetailResponse getDetailByIsbn(String isbn) {
		Book book = bookRepository.findByIsbn(isbn).orElseThrow(
			() -> new CustomException(ErrorCode.BOOK_NOT_FOUND)
		);
		return BookDetailResponse.from(book);
	}
}

4. 캐시 무효화(@CacheEvict) 처리

@CacheEvict는 캐시에서 항목을 제거하는 데 사용된다. 데이터가 변경되었을 때 관련 캐시를 데이터의 일관성을 위해서 무효화하는 용도로 활용된다.

@Caching은 여러 캐시 작업을 하나의 메서드에 그룹화할 때 사용한다.

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
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
@Service
@RequiredArgsConstructor
public class BookManageService {

	private final CacheManager cacheManager;

	private final BookRepository bookRepository;

	@Caching(evict = { // 여러 캐시 작업을 하나의 메서드에 그룹화
		@CacheEvict(value = "bookList", allEntries = true), // 목록 캐시 전체 무효화
		@CacheEvict(value = "authorBooks", allEntries = true), // 등록된 저자 관련 캐시 전체 무효화
		@CacheEvict(value = "bookDetail", key = "#result.id"), // 등록된 도서 상세 캐시 무효화
		@CacheEvict(value = "bookDetailByIsbn", key = "#request.isbn") // ISBN 캐시 무효화
	})
	@Transactional
	public BookResponse register(BookCreateRequest request) {
		Book book = Book.builder()
			.author(request.author())
			.title(request.title())
			.description(request.description())
			.isbn(request.isbn())
			.price(request.price())
			.build();
		bookRepository.save(book);
		return BookResponse.from(book);
	}

	@Caching(evict = { // 여러 캐시 작업을 하나의 메서드에 그룹화
		@CacheEvict(value = "bookList", allEntries = true), // 목록 캐시 전체 무효화
		@CacheEvict(value = "authorBooks", allEntries = true), // 도서의 저자 관련 캐시 무효화
		@CacheEvict(value = "bookDetail", key = "#id"), // 삭제된 도서 상세 캐시 무효화
	})
	@Transactional
	public void delete(Long id) {
		Book book = bookRepository.findById(id).orElseThrow(
			() -> new CustomException(ErrorCode.BOOK_NOT_FOUND)
		);

		// ISBN 캐시 직접 무효화
		String isbn = book.getIsbn();
		if (isbn != null) {
			cacheManager.getCache("bookDetailByIsbn").evict(isbn);
		}

		bookRepository.delete(book);
	}
}

5. 캐시 동작 확인

여기까지 캐시 적용을 완료했다. 이제 캐시가 제대로 동작하는지 확인해보자.

응답속도

데이터가 캐싱되기 전과 후의 응답속도를 비교해보면 다음과 같다. (응답속도 측정 방식은 Postman을 사용)

요청 메소드첫번째 요청(데이터 캐시 전)두번째 요청(데이터 캐시 후)
전체 도서 목록 조회 (getList)30ms9ms
저자별 도서 목록 조회 (getListByAuthor)24ms8ms
ID로 도서 상세 조회 (getDetail)26ms8ms
ISBN으로 도서 상세 조회 (getDetailByIsbn)18ms9ms

비록 간단한 예제라 캐시를 적용하지 않은 속도도 빠르지만 데이터가 캐시된 후의 속도와의 차이가 명확하게 보인다.

로그 확인

로그를 확인하면 응답속도가 더 빨라지는 이유를 알 수 있다. 아래 로그는 전체 도서 목록 조회 (getList)를 요청했을 때의 로그이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<!-- 첫 번째 요청 -->
     select
         b1_0.id,
         b1_0.author,
         b1_0.create_at,
         b1_0.description,
         b1_0.isbn,
         b1_0.price,
         b1_0.title,
         b1_0.update_at 
     from
         book b1_0 
     order by
         b1_0.id 
     limit
         ?
INFO 1 --- ...: BookController.getBookList(..): 14ms

<!-- 두 번째 요청 -->
INFO 1 --- ...: BookController.getBookList(..): 0ms

첫 번째 요청에서는 데이터베이스에서 실제로 쿼리가 실행된 것을 확인할 수 있다. 조회 쿼리가 로그에 출력되고, 전체 도서 목록을 데이터베이스에서 읽어오는 데 약 14ms가 소요되었다.

반면, 두 번째 요청에서는 데이터베이스 쿼리 로그가 전혀 보이지 않는다. 이는 데이터베이스에 접근하지 않았다는 의미다. 즉, 이미 캐시에 저장된 데이터를 바로 반환했기 때문에, 추가적인 쿼리 실행 없이 응답이 처리된다.

이처럼 스프링의 캐시 추상화를 사용하면, 동일한 서비스 메서드가 반복 호출되더라도 실제로 메서드가 실행되지 않고, 캐시에 저장된 결과를 즉시 반환한다. 따라서 두 번째 요청의 실행 시간은 0ms로, 첫 번째 요청에 비해 훨씬 빠른 응답 속도를 확인할 수 있다.

결론적으로, 캐시가 잘 동작하면 불필요한 데이터베이스 접근이 줄어들고, 서비스의 전체적인 성능이 크게 향상된다.

마무리


이번 포스트에서는 Spring Boot 애플리케이션에 로컬 캐시(Caffeine Cache)를 적용하는 방법과 그 효과에 대해 살펴보았다. 간단한 도서 정보 관리 서비스에 캐시를 적용해봄으로써, 실제로 응답 속도가 얼마나 빨라지는지 직접 확인할 수 있었다.

특히 스프링에서 제공하는 캐시 추상화 덕분에 복잡한 캐싱 로직을 직접 구현하지 않아도 되고, 어노테이션만으로 손쉽게 캐시를 적용할 수 있다는 점이 큰 장점이었다. Caffeine은 강력한 성능과 다양한 캐시 정책(만료, 최대 크기 제한 등)을 지원해, 실무 환경에서도 충분히 활용할 수 있는 로컬 캐시 솔루션임을 알 수 있었다.

실제로 캐시를 적용한 후에는 데이터베이스 쿼리 실행이 줄어들고, 서비스 응답 속도가 눈에 띄게 빨라졌다. 로그와 응답 시간 측정 결과를 통해, 캐시가 서비스 성능 개선에 얼마나 큰 역할을 하는지 체감할 수 있었다.

이번 포스트를 작성하면서 캐시를 사용할 때는 데이터 일관성, 캐시 만료 정책, 메모리 사용량 등도 함께 고려해야 하고 이것이 쉽지 않다는 것을 알게되었다. 하지만 적절한 대상 선정과 무효화 전략만 잘 세운다면, 캐시는 서비스의 효율성과 확장성을 크게 높여주는 강력한 도구가 될 수 있다는 것을 깨달았다.

다음 포스트에서는 분산 캐시(Redis 등)와 같이 여러 서버 환경에서의 캐시 활용 방법도 다뤄볼 예정이다. 이번 글이 Spring Boot에서 캐시를 처음 적용해보는 분들께 실질적인 도움이 되었길 바란다.

참고


Spring Boot and Caffeine Cache | baeldung

Caching | 스프링 부트 공식문서

This post is licensed under CC BY 4.0 by the author.