[Spring Boot에서 캐시(Cache) 활용하기] - 글로벌 캐시 사용하기
지난 포스트에서는 Spring Boot 애플리케이션에 로컬 캐시(Caffeine Cache)를 적용하는 방법과 그 효과에 대해 살펴보았다. 이번 포스트에서는 저번 포스트에서 사용한 책 정보 서비스에 글로벌 캐시(Redis)를 적용과 관련하여 알아보자.
도입 배경
로컬 캐시의 한계
로컬 캐시는 애플리케이션 인스턴스의 메모리에 직접 데이터를 저장하여 빠른 접근 속도를 제공하지만, 여러 가지 한계점을 가지고 있다. 그 중 가장 치명적인 한계가 바로 분산 환경에서 발생한다. 분산 환경에서 로컬 캐시를 사용할 경우, 각 서버마다 독립적인 캐시를 유지하기 때문에 데이터의 일관성(Consistency) 문제가 발생한다.
일관성 문제 발생 예시 분산 환경에서 로컬 캐시를 사용할 경우, 각 인스턴스는 자신의 메모리에 독립적으로 캐시를 유지한다. 따라서 Instance-1에서 책 삭제 요청이 처리되어 해당 인스턴스의 캐시가 무효화되더라도, Instance-2는 이 변경 사실을 알지 못하고 여전히 삭제된 책의 정보를 캐시에 보관하게 됩니다.
이로 인해 사용자는 서버에 요청할 때마다 다른 결과를 받을 수 있으며, 이는 사용자 경험에 매우 좋지 않은 영향을 미치게 된다. 예를 들어, 사용자가 책을 삭제한 후 페이지를 새로고침했는데 여전히 삭제된 책이 보인다면 혼란스러울 것이다.
글로벌 캐시의 필요성
글로벌 캐시로 문제 해결 이러한 로컬 캐시의 한계를 극복하기 위한 방법 중 하나가 글로벌 캐시를 도입이다. 글로벌 캐시는 별도의 캐시 서버를 두어 모든 애플리케이션 인스턴스가 동일한 캐시 저장소에 접근하는 방식이다.
물론 글로벌 캐시도 네트워크 통신 비용이 발생하여 로컬 캐시보다 접근 속도가 다소 느리다는 단점이 있지만, 분산 환경에서의 데이터 일관성과 확장성이라는 큰 이점을 얻을 수 있다.
글로벌 캐시(Redis) 적용
프로젝트에 글로벌 캐시가 적용된 전체 코드는 Github에 있으니 참고해주세요.
글로벌 캐시는 분산 환경에서 데이터 일관성 문제를 해결하는 효과적인 방법이다. 여러 글로벌 캐시 솔루션 중에서도 Redis는 높은 성능, 다양한 데이터 구조 지원, 그리고 Spring과의 뛰어난 통합성으로 인해 많은 개발자들이 선택하는 저장소이다.
Redis는 인메모리 데이터 저장소로, 모든 애플리케이션 인스턴스가 동일한 캐시에 접근하기 때문에 데이터 일관성이 자연스럽게 보장된다. 또한 Redis는 단순한 키-값 저장소를 넘어 문자열, 해시, 리스트, 세트 등 다양한 데이터 타입을 지원하여 다양한 캐싱 요구사항을 충족시킬 수 있다.
의존성 주입
Redis도 MySQL와 같은 외부 저장소이기 때문에 로컬 캐시와 다르게 접근하는 코드가 필요하다. Spring Data Redis는 저장소와 상호작용하기 위한 저수준 및 고수준 추상화를 모두 제공해준다. 이를 의존성에 추가해준다.
1
2
implementation 'org.springframework.boot:spring-boot-starter-cache'
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
Redis 설정
Redis에 별다른 설정을 하지 않았다. compose.yml파일을 보면 알 수 있듯이 포트와 비밀번호 설정만 하였다.
1
2
3
4
5
6
7
8
9
my-redis:
image: redis
command: redis-server --requirepass 1234 // 비밀번호 설정
ports:
- "6379:6379" // Redis서버 포트 설정
healthcheck:
test: [ "CMD", "redis-cli", "ping" ]
interval: 5s
retries: 10
애플리케이션과 Redis의 연결을 위해서 RedisConfig도 추가한다. RedisTemplate은 Spring에서 Redis와 상호작용을 쉽게 해주는 클래스이다. SQL 데이터베이스에서 JpaRepository를 사용하듯, Redis에 데이터를 저장, 조회, 삭제 등 다양한 작업을 간단하게 수행할 수 있다.
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
@Configuration
public class RedisConfig {
@Value("${spring.redis.host}")
private String host;
@Value("${spring.redis.port}")
private int port;
@Value("${spring.redis.password}")
private String password;
@Bean // Redis 연결을 위한 ConnectionFactory 빈 등록
public RedisConnectionFactory redisConnectionFactory() {
// Redis 단일 서버 설정(host, port)
RedisStandaloneConfiguration config = new RedisStandaloneConfiguration(host, port);
config.setPassword(password);
// Lettuce 클라이언트로 RedisConnectionFactory 생성 및 반환
return new LettuceConnectionFactory(config);
}
@Bean // RedisTemplate을 스프링 빈으로 등록
public RedisTemplate<String, Object> redisTemplate() {
// String 타입의 키와 Object 타입의 값을 사용하는 RedisTemplate 객체 생성
RedisTemplate<String, Object> template = new RedisTemplate<>();
// 위에서 생성한 Redis 연결 팩토리 설정
template.setConnectionFactory(redisConnectionFactory());
// 키를 직렬화할 때 문자열 형식으로 변환하는 StringRedisSerializer 설정
template.setKeySerializer(new StringRedisSerializer());
// 값을 직렬화할 때 JSON 형식으로 변환하는 GenericJackson2JsonRedisSerializer 설정
template.setValueSerializer(new GenericJackson2JsonRedisSerializer());
// 구성이 완료된 RedisTemplate 반환
return template;
}
}
캐시 설정 및 빈 등록
Spring에서 위에 추가한 Redis를 캐시 저장소로 사용하기 위한 설정을 추가해준다.
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
48
49
50
51
52
@Slf4j
@Configuration
@EnableCaching
@RequiredArgsConstructor
public class CacheConfig {
private final RedisConnectionFactory redisConnectionFactory;
@Bean
public CacheManager cacheManager() {
// Redis 캐시 기본 설정
RedisCacheConfiguration defaultConfig = RedisCacheConfiguration.defaultCacheConfig()
// 캐시 키를 직렬화하는 방식을 StringRedisSerializer로 설정
.serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(
new StringRedisSerializer()
))
// 캐시 값을 직렬화하는 방식을 GenericJackson2JsonRedisSerializer로 설정
// 이를 통해 객체를 JSON 형태로 저장
.serializeValuesWith(
RedisSerializationContext.SerializationPair.fromSerializer(
new GenericJackson2JsonRedisSerializer()
)
)
// null 값은 캐싱하지 않도록 설정
.disableCachingNullValues();
// 각 캐시 타입별 설정을 저장할 Map을 생성
Map<String, RedisCacheConfiguration> cacheConfigurations = new HashMap<>();
for (CacheType cacheType : CacheType.values()) {
// 각 캐시 타입별로 만료 시간을 설정하여 캐시 설정에 추가
cacheConfigurations.put(
cacheType.getCacheName(), // 캐시 이름을 키로 사용
defaultConfig.entryTtl(Duration.ofSeconds(cacheType.getExpireAfterWrite())) // 만료 시간 설정
);
log.info("Redis 캐시 설정: {}, TTL: {} 초",
cacheType.getCacheName(),
cacheType.getExpireAfterWrite());
}
// Redis 캐시 매니저 생성 및 설정
RedisCacheManager cacheManager = RedisCacheManager.builder(redisConnectionFactory)
.cacheDefaults(defaultConfig) // 기본 캐시 설정 적용
.withInitialCacheConfigurations(cacheConfigurations) // 캐시 타입별 설정
.transactionAware() // 트랜잭션 인식 활성화
.build();
// 생성된 캐시 매니저를 반환
return cacheManager;
}
}
서비스 계층에 캐시 어노테이션 적용
로컬 캐시를 적용했을 때와 마찬가지로 동일한 파라미터로 메서드가 다시 호출될 경우, 실제 메서드 실행 없이 캐시에서 결과를 반환할 수 있도록 어노테이션을 추가해준다.
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);
}
}
캐시 무효화 처리
이 부분도 로컬 캐시를 적용했을 때와 동일하다.
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
@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);
}
}
캐시 동작 확인
응답속도
데이터가 캐싱되기 전과 후의 응답속도를 비교해보면 다음과 같다. (응답속도 측정 방식은 Postman을 사용)
| 요청 메소드 | 첫번째 요청(데이터 캐시 전) | 두번째 요청(데이터 캐시 후) |
|---|---|---|
전체 도서 목록 조회 (getList) | 93ms | 20ms |
저자별 도서 목록 조회 (getListByAuthor) | 24ms | 8ms |
ID로 도서 상세 조회 (getDetail) | 26ms | 8ms |
ISBN으로 도서 상세 조회 (getDetailByIsbn) | 18ms | 9ms |
로그 확인
로그를 확인하면 응답속도가 더 빨라지는 이유를 알 수 있다. 아래 로그는 전체 도서 목록 조회 (getList)를 요청했을 때의 로그이다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<!-- 첫 번째 요청 -->
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
?
<!-- 두 번째 요청 -->
첫 번째 요청에서는 데이터베이스에서 실제로 쿼리가 실행된 것을 확인할 수 있다. 조회 쿼리가 로그에 출력되고, 전체 도서 목록을 데이터베이스에서 읽어오는 데 약 93ms가 소요되었다.
반면, 두 번째 요청에서는 데이터베이스 쿼리 로그가 전혀 보이지 않는다. 이는 데이터베이스에 접근하지 않았다는 의미다. 즉, 이미 캐시에 저장된 데이터를 바로 반환했기 때문에, 추가적인 쿼리 실행 없이 응답이 처리된다.
결과 또한 로컬 캐시의 결과와 똑같은 것을 확인할 수 있고, 캐시가 잘 적용 되었다는 것을 확인할 수 있었다.
마무리
이번 포스트에서는 Spring Boot 애플리케이션에 글로벌 캐시(Redis)를 적용하는 방법에 대해서 살펴보았다. Redis와 연결하기 위해 Spring Data Redis 의존성을 추가하고, RedisTemplate를 정의하는 것 외에는 서비스 계층의 비즈니스 로직 코드는 거의 동일하게 유지할 수 있었다. 특히 Spring의 @Cacheable 어노테이션을 활용하면 기존 코드 변경을 최소화하면서도 캐싱 기능을 쉽게 적용할 수 있다는 장점을 직접 체감할 수 있었다.
또한 Redis를 캐시 저장소로 사용함으로써 여러 서버 환경에서도 캐싱되는 데이터의 일관성을 지킬 수 있게 되었다. 또한 인메모리 데이터 저장소인 Redis의 특성상 빠른 응답 시간을 보장하기 때문에 분산 환경에서 로컬 캐시를 사용할 경우 발생할 수 있는 데이터의 일관성(Consistency) 문제를 해결하면서 캐시의 장점을 누릴 수 있도록 하였다.
예제 프로젝트를 만들면서 한가지 아쉬운 부분이 있다. 바로 테스트 환경 구성이다. Spring Data Redis를 적용한 이후 Docker Compose를 통해 빌드할 때 Redis가 실행되어 있지 않기 때문에 테스트에 실패하게 되었다. 이에 대한 해결책을 조사한 결과 다음과 같은 방법들이 있었다.
Embedded Redis 사용: 테스트 실행 시 애플리케이션 내부에서 Redis 서버를 실행하는 방식으로, 별도의 Redis 설치 없이 테스트가 가능하다.
it.ozimov:embedded-redis라이브러리를 활용하면 쉽게 구현할 수 있다.TestContainers 사용: Docker 컨테이너를 활용하여 테스트 환경을 구성하는 방식으로, 실제 운영 환경과 가장 유사한 테스트가 가능하다. 다만 Docker가 설치되어 있어야 한다는 제약이 있다.
추후에 이러한 테스트 환경 구성에 대한 자세한 내용을 다루는 포스트를 작성해 보겠다.
