개요
콘서트 예약 프로젝트에서 API 응답 속도 개선과 서버 부하 감소를 위해 캐시를 적용하려 한다.
이를 위해 캐시의 개념과 다양한 캐싱 전략을 학습하고, Redis를 활용하여 캐시를 구현하는 방법을 알아보자.
캐시(Cache) 란?
캐시(Cache)는 컴퓨터 시스템에서 자주 사용되는 데이터나 값을 임시로 저장하여 동일한 데이터 요청 시 더 빠르게 접근할 수 있도록 하는 고속 저장소다. 캐시는 데이터 접근 시간을 단축하고 시스템 성능을 높이는 중요한 역할을 한다.
캐싱(Caching) 이란?
캐싱(Caching)은 데이터를 캐시에 저장하여 자주 사용하는 데이터를 빠르게 제공하는 과정이나 기술이다. 이를 통해 시스템 성능을 향상시키고 서버의 부하를 줄일 수 있다.
Cache Hit, Cache Miss
- Cache Hit: 요청한 데이터가 캐시에 있어 빠르게 가져올 수 있는 경우.
- Cache Miss: 캐시에 데이터가 없어 원본 데이터를 가져와야 하는 경우.
캐시는 적중률을 높이는 것이 중요하며, Cache Hit을 높이고 Cache Miss를 줄이는 것이 핵심 목표다.
로컬 캐시(Local Cache)
서버마다 각 캐시 서버를 두고 따로 저장하는 전략이다. 로컬 서버의 리소스(Memory, Disk)를 사용하여 캐싱을 처리한다.
- 장점: 개별 서버 내에서 작동하기 때문에 속도가 빠르다.
- 단점: 각 서버 내에서만 작동하므로 서버 간 데이터 공유가 어렵다.
e.g. Ehcache, Caffeine, Guava
글로벌 캐시(Global Cache)
여러 서버에서 한 캐시 서버에 접근하여 참조할 때 사용하는 전략이다.
- 장점: 별도의 캐시 서버를 두고 사용하기 때문에 서버 간 데이터 공유가 쉽다.
- 단점: 네트워크 트래픽을 사용하기 때문에 로컬 캐시보다는 속도가 느리다.
e.g. Redis, Memcached
캐시로 Redis를 선택한 이유?
우선 로컬 캐시를 사용하지 않고 글로벌 캐시를 사용하는 이유는 현재 콘서트 예약 프로젝트는 학습용 프로젝트이다.
대규모 트래픽 처리를 위한 서비스이므로 분산환경을 고려하여 글로벌 캐시를 사용하는 것이 적합하다.
그러면 글로벌 캐시는 Redis와 Memcached 중 어떤 것을 선택해야 할까? 나는 Redis를 선택했다. 그 이유는 다음과 같다.
- 데이터 구조 지원
- Redis: 문자열, 리스트, 해시, 집합, 정렬된 집합 등 다양한 데이터 구조를 지원해 복잡한 데이터 모델링과 다양한 연산을 효율적으로 처리할 수 있다.
- Memcached: 단순 키-값 저장소로, 문자열 형태의 데이터만 저장할 수 있어 복잡한 데이터 모델링에는 제한적이다.
- 영속성(Persistence)
- Redis: 메모리 내 데이터를 디스크에 저장해 서버 재시작 시에도 데이터 복구가 가능하다. RDB 스냅샷과 AOF(Append Only File) 방식으로 데이터 영속성을 제공한다.
- Memcached: 메모리 내 데이터의 영속성을 지원하지 않는다. 서버 재시작이나 장애 발생 시 모든 데이터가 손실되므로 단순 캐시 역할에만 적합하다.
- 복제 및 고가용성
- Redis: 마스터-슬레이브 복제를 통해 데이터의 고가용성과 부하 분산이 가능하다. 이를 통해 장애 시 슬레이브 노드로의 자동 페일오버를 구현할 수 있다.
- Memcached: 기본적으로 복제 기능을 제공하지 않으며, 복제를 위해 외부 도구나 애플리케이션 레벨에서 별도로 구현해야 한다.
- 스레드 모델
- Redis: 싱글 스레드로 동작하지만, 비동기 I/O 멀티플렉싱을 통해 높은 성능을 발휘한다. 여러 인스턴스를 활용해 멀티코어 시스템의 성능을 효과적으로 끌어낼 수 있다.
- Memcached: 멀티스레드 아키텍처를 사용해 멀티코어 시스템에서 효율적으로 동작한다.
- 추가 기능
- Redis: Pub/Sub 메시징, Lua 스크립팅, 트랜잭션 지원 등 다양한 부가 기능을 제공해 실시간 채팅, 스트리밍, 복잡한 연산 등 여러 애플리케이션 요구사항을 충족할 수 있다.
- Memcached: 이러한 부가 기능을 제공하지 않으며 단순 캐싱 용도로 주로 사용된다.
Redis는 다양한 데이터 구조 지원, 데이터 영속성, 복제 및 고가용성, 부가 기능 등에서 Memcached보다 우수한 기능을 제공한다.
그러면 Memcached는 어떨때 사용해야 할까? 단순 캐싱 용도로 사용하거나, 캐시 서버가 종료되어도 데이터가 손실되어도 상관없는 경우에 사용하면 된다.
왜냐하면 Redis는 트래픽이 몰리면 응답속도가 불안정 반면에 memcached은 트래픽이 몰려도 응답 속도는 안정적인 편이기 때문이다.
캐싱 전략(Caching Strategy)이란?
캐시를 이용하게 되면 데이터 정합성 문제가 발생할 수 있다.
따라서 적절한 캐시 읽기 전략(Read Cache Strategy)과 캐시 쓰기 전략(Write Cache Strategy)을 통해, 캐시와 DB간의 데이터 불일치 문제를 극복하면서도 빠른 성능을 잃지 않게 하기 위해 고심히 연구를 할 필요가 있다.
읽기 전략 (Read Strategy)
Look Aside (Lazy Loading)
Cache Aside 패턴이라고도 불리며 데이터를 찾을 때 우선 캐시에 저장된 데이터가 있는지 확인하는 전략이다.
만약 캐시에 데이터가 없으면 DB에서 조회하여 캐시에 저장한다.
- 반복적인 읽기가 많은 호출에 적합
- 캐시와 DB가 분리되어 가용되기 때문에 원하는 데이터만 별도로 구성하여 캐시에 저장할 수 있음
- 캐시와 DB가 분리되어 가용되기 때문에 캐시 장애 대비 구성이 되어있음
Read Through
캐시에서만 데이터를 읽어오는 전략으로 캐시 미스가 발생하면 DB에서 누락된 데이터를 로드 후에 캐시에 저장하고 애플리케이션에 반환하는 전략이다.
- 데이터 조회를 전적으로 캐시에 의지하므로 캐시 장애가 발생할 경우 장애가 전파될 수 있음
- 캐시와 DB간의 데이터 동기화가 이루어져 정합성 문제에서 벗어날 수 있음
Read Through 방식은 Cache Aside 방식과 비슷하지만, Cache Store에 저장하는 주체가 애플리케이션이냐 또는 Data Store 자체이냐에서 차이점이 있다.
쓰기 전략 (Write Strategy)
Write Back
Write Behind 패턴이라고도 불리며, 캐시와 DB 동기화를 비동기로 처리하는 전략이다.
데이터를 저장할 때 DB에 바로 쿼리하지 않고 캐시에 모아 일정 주기 배치 작업을 통해 DB에 반영한다.
- 쓰기 쿼리 회수 비용과 부하를 줄일 수 있음
- 데이터 정합성 확보
- 자주 사용되지 않은 불필요한 리소스 저장
- 캐시에서 오류가 발생하면 데이터 영구 소실
Write Through
캐시와 DB에 동시에 데이터를 저장하는 전략이다.
데이터를 저장할 때 먼저 캐시에 저장하고, 캐시에 저장된 데이터를 바로 DB에 저장한다.
- 캐시의 데이터는 항상 최신 상태 유지
- 데이터 일관성 유지
- 자주 사용되지 않은 불필요한 리소스 저장
- 매 요청마다 두번의 Write(Cache, DB)가 발생하게 도미으로써 빈번한 생성, 수정이 발생하는 서비스에서는 성능 이슈 발생
Write Around
모든 데이터는 DB에 저장하며 캐시는 갱신하지 않는 전략이다.
캐시 미스가 발생하는 경우에만 DB에서 데이터를 가져와 캐시에 저장한다.
- 캐시와 DB내의 데이터 불일치 가능성 있음
Spring Boot 에서 Redis 캐시 사용하기
Spring Boot에서 Redis를 사용하기 위해 Redis와 통신을 위한 의존성을 추가해야한다.
대표적으로 Letture, Redisson이 있는데 나는 spring-boot-starter-data-redis
를 사용해보려 한다.
왜냐하면 spring-boot-starter-data-redis
은 구현체가 Letture를 사용하고 있고 추상화된 RedisTemplate
를 사용하면 추후 업데이트에 내부 구현체가 바뀌더라도 내 비지니스에 영향이 없기 때문이다.
build.gradle
에 의존성 추가
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
빌드 관리 도구에 따라 형식은 달라 질 수 있으므로 https://mvnrepository.com/artifact/org.springframework.boot/spring-boot-starter-data-redis 여기를 참고하자.
https://spring.io/projects/spring-data-redis
application.yml
에 Redis 설정 추가
spring:
data:
redis:
host: localhost
port: 6379
cache:
type: redis
나는 yml형식을 더 좋아하기 때문에 예시로 yml형식으로 작성했지만, application.properties
에 작성해도 무방하다.
그리고 Spring Boot 3버전부터 Redis 설정이 spring.redis
에서 spring.data.redis
로 변경되었으니 주의하자.
- RedisConfig 추가
@Configuration
public class RedisConfig {
private final RedisProperties redisProperties;
@Bean
public RedisConnectionFactory redisConnectionFactory() {
return new LettuceConnectionFactory(redisProperties.getHost(), redisProperties.getPort());
}
}
Redis 연결을 위한 기본 설정을 추가
- CacheConfig 설정
@Configuration
@EnableCaching
public class CacheConfig {
@Bean
public RedisCacheConfiguration redisCacheConfiguration() {
return RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofSeconds(60))
.disableCachingNullValues()
.serializeKeysWith(
RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer())
)
.serializeValuesWith(
RedisSerializationContext.SerializationPair.fromSerializer(
new GenericJackson2JsonRedisSerializer())
);
}
@Bean
public RedisCacheManagerBuilderCustomizer redisCacheManagerBuilderCustomizer() {
return builder -> builder
.withCacheConfiguration("cache1",
RedisCacheConfiguration.defaultCacheConfig()
.computePrefixWith(cacheName -> "prefix::" + cacheName + "::")
.entryTtl(Duration.ofSeconds(120))
.disableCachingNullValues()
.serializeKeysWith(
RedisSerializationContext.SerializationPair.fromSerializer(
new StringRedisSerializer())
)
.serializeValuesWith(
RedisSerializationContext.SerializationPair.fromSerializer(
new GenericJackson2JsonRedisSerializer())
))
.withCacheConfiguration("cache2",
RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofHours(2))
.disableCachingNullValues());
}
}
Spring Data Redis를 사용하면 Spring Boot가 RedisCacheManager를 자동으로 설정해준다.
하지만 Redis는 직렬화/역직렬화가 필요해서 별도의 캐시 설정이 필요한데, 이때 사용하는 게 RedisCacheConfiguration이다. RedisCacheConfiguration 설정은 Redis 기본 설정을 오버라이드한다고 보면 된다.
computePrefixWith
: Cache Key prefix 설정entryTtl
: 캐시 만료 시간disableCachingNullValues
: 캐시할 때 null 값을 허용하지 않음 (#result == null
과 함께 사용)serializeKeysWith
: Key 직렬화 규칙. 일반적으로 String 형태로 저장serializeValuesWith
: Value 직렬화 규칙. 주로 Jackson2 사용
캐시 이름별로 설정을 다르게 하고 싶다면 RedisCacheManagerBuilderCustomizer
를 선언해 사용할 수 있다. 예를 들어, cache1
, cache2
두 가지 캐시를 설정했다면, 다른 이름의 캐시는 기본 설정인 RedisCacheConfiguration을 따른다.
@Cacheable
@Cacheable(value = "concert", key = "#query.id()")
public Concert getConcert(GetConcertByIdQuery query) {
return concertRepository.getConcert(new GetConcertByIdParam(query.id()));
}
@Cacheable
를 통해 캐싱이 필요한 메소드에 명시하자.@Cacheable
은 해당 메소드의 결과값을 캐시 저장소에 key값으로 저장하고 재 호출시 캐시에 값이 있다면 가져와서 반환한다. (Loock Aside)
이외에 캐시 제거를 위한 @CacheEvict
, 결과값을 항상 저장하는 @CachePut
, 여러 작업을 한번에 하는 @Caching
도 있으니 참고하자.
SerializationException
해결하기
org.springframework.data.redis.serializer.SerializationException: Could not write JSON: Java 8 date/time type `java.time.LocalDateTime` not supported by default: add Module "com.fasterxml.jackson.datatype:jackson-datatype-jsr310" to enable handling (through reference chain: com.example.hhplus.concert.domain.concert.model.Concert["createdAt"])
이 오류는 Java 8에서 추가된 LocalDate
, LocalTime
, LocalDateTime
과 같은 날짜/시간 타입이 기본적으로 Jackson 라이브러리에서 지원되지 않기 때문에 발생하는 오류다. 이를 해결하려면 com.fastxml.jackson.datatype:jackson-datatype-jsr310
모듈을 추가해야 한다. 이 모듈은 Jackson이 Java 8의 날짜/시간 타입을 올바르게 직렬화하고 역직렬화할 수 있도록 지원한다.
이미 포함된 의존성
com.fasterxml.jackson.datatype:jackson-datatype-jsr310
모듈은 spring-boot-starter-web
의존성에 포함되어 있다. 그러나 해당 모듈이 포함되어 있음에도 여전히 LocalDateTime
역직렬화 문제가 발생하는 경우가 있다. 그 원인이 무엇일까?
ObjectMapper 설정의 중요성
ObjectMapper
는 Jackson 라이브러리에서 JSON과 Java 객체 간의 직렬화와 역직렬화를 담당하는 주요 클래스다. 기본적으로 Jackson은 Java 8의 새로운 날짜/시간 타입을 지원하지 않으며, 이를 해결하기 위해 사용자 정의 모듈을 추가해야 한다.
- 직렬화 (Serialization): Java 객체를 JSON 형식으로 변환
- 역직렬화 (Deserialization): JSON 형식을 Java 객체로 변환
날짜/시간 타입과 같은 복잡한 데이터 유형을 처리하기 위해서는 ObjectMapper
에 Java 8의 날짜/시간 모듈을 수동으로 등록하는 것이 필요하다.
JavaTimeModule
추가
JavaTimeModule
은 Java 8의 LocalDate
, LocalTime
, LocalDateTime
과 같은 날짜 및 시간 API를 Jackson에서 처리할 수 있게 도와주는 모듈이다. 기본 설정으로는 이러한 데이터 타입을 인식하지 못하기 때문에 직렬화와 역직렬화 과정에서 오류가 발생할 수 있다.
이를 해결하기 위해 ObjectMapper
에 JavaTimeModule
을 등록하면 Jackson이 날짜/시간 타입을 올바르게 직렬화하고 역직렬화할 수 있다.
왜 기본적으로 등록되지 않을까?
Java 8의 날짜/시간 타입이 기본적으로 Jackson에 등록되어 있지 않은 이유는 이들 타입이 표준 Java 라이브러리 일부가 아니라 JSR 310 API로 Java 8에서 새롭게 도입된 것이기 때문이다. 따라서 Jackson에서 이를 인식하도록 하려면 해당 모듈을 ObjectMapper
에 명시적으로 등록해주어야 한다.
해결 방법
다음과 같이 JavaTimeModule
을 수동으로 등록하여 오류를 해결할 수 있다.
@Bean
public RedisCacheConfiguration redisCacheConfiguration() {
ObjectMapper objectMapper = new ObjectMapper();
objectMapper.registerModule(new JavaTimeModule());
return RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofSeconds(60))
.disableCachingNullValues()
.serializeKeysWith(
RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer())
)
.serializeValuesWith(
RedisSerializationContext.SerializationPair.fromSerializer(
new GenericJackson2JsonRedisSerializer(objectMapper))
);
}
이제 LocalDateTime
을 포함한 Java 8 날짜/시간 타입이 Jackson에서 정상적으로 직렬화와 역직렬화가 가능하다.
ClassCastException
해결하기
Redis에 정상적으로 데이터가 저장된 후 캐시에서 데이터를 꺼내려고 할 때, 아래와 같은 ClassCastException
이 발생했다.
java.lang.ClassCastException: class java.util.LinkedHashMap cannot be cast to class com.example.hhplus.concert.domain.concert.model.Concert (java.util.LinkedHashMap is in module java.base of loader 'bootstrap'; com.example.hhplus.concert.domain.concert.model.Concert is in unnamed module of loader 'app')
Redis에서 JSON 형태로 저장된 데이터를 가져올 때, 기본적으로 LinkedHashMap
으로 변환하기 때문에 발생하는 문제다. 이를 특정 클래스, 예를 들어 Concert
클래스에 바로 캐스팅하려 하면 타입 불일치로 인해 예외가 발생하게 된다.
해결 방법
이 문제를 해결하기 위해 ObjectMapper
설정을 수정해 JSON 데이터를 원하는 클래스로 변환하도록 설정할 수 있다. 다음과 같은 설정을 추가해 ClassCastException
을 방지했다.
@Bean
public RedisCacheConfiguration redisCacheConfiguration() {
ObjectMapper objectMapper = new ObjectMapper();
objectMapper.registerModule(new JavaTimeModule());
objectMapper.activateDefaultTyping(
objectMapper.getPolymorphicTypeValidator(), // 타입 검증기
ObjectMapper.DefaultTyping.EVERYTHING, // 모든 객체에 타입 정보 추가
JsonTypeInfo.As.WRAPPER_OBJECT // 타입정보를 객체를 감싸는 형태로 추가
);
return RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofSeconds(60))
.disableCachingNullValues()
.serializeKeysWith(
RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer())
)
.serializeValuesWith(
RedisSerializationContext.SerializationPair.fromSerializer(
new GenericJackson2JsonRedisSerializer(objectMapper))
);
}
참고로 Jackson 2.10 버전부터는 activateDefaultTyping()
을 사용할 때 보안상 TypeValidator
를 추가하는 것이 필수가 되었다. 우선 간단하게 설정하기 위해 PolymorphicTypeValidator
를 사용해 설정을 완료했다.
이제 type과 같이 저장된걸 확인할 수 있으며 정상 작동한다.
GenericJackson2JsonRedisSerializer
의 문제점
GenericJackson2JsonRedisSerializer
는 Class의 패키지 정보를 담아 저장한다.
이런 방식은 버저닝 이슈와 용량 문제에 대한 단점이 있다.
1. 버저닝 이슈: Class의 패키지 정보를 가지고 역직렬화를 하기에 패키지 정보가 달라진다면 예외가 발생한다.
2. 용량 문제: Class의 패키지 정보를 담는 과정에서 데이터의 용량이 커지게 된다.
문제 해결 방법
RedisSerializer
는 총 6개를 지원한다.
그중 위에 문제를 해결하기 위해선 StringRedisSerializer
, Jackson2JsonRedisSerializer
를 활용하면된다.
다른 RedisSerializer
는 아래의 문제가 있다.
- 버저닝 이슈가 없다.
- 용량 이슈가 없다.
- 호환성 이슈가 없다.
그러면 StringRedisSerializer
, Jackson2JsonRedisSerializer
만 무조건쓰는게 맞는건가?StringRedisSerializer
은 String
값 그대로 저장하는 방식으로 직접 직렬화/역직렬화하는 로직을 구성해주어야한다. (번거롭다.)
public <T> T getData(String key, Class<T> classType) throws Exception {
String jsonResult = redisTemplate.opsForValue().get(key).toString();
if (StringUtils.isBlank(jsonResult)) {
return null;
} else {
ObjectMapper objectMapper = new ObjectMapper();
T obj = objectMapper.readValue(jsonResult, classType);
return obj;
}
}
Jackson2JsonRedisSerializer
은 항상 Serializer에 ClassType을 지정해줘야한다. (번거롭다.)
@Bean
public RedisCacheConfiguration redisCacheConfiguration() {
ObjectMapper objectMapper = new ObjectMapper();
objectMapper.registerModule(new JavaTimeModule());
return RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofSeconds(60))
.disableCachingNullValues()
.serializeKeysWith(
RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer())
)
.serializeValuesWith(
RedisSerializationContext.SerializationPair.fromSerializer(
new Jackson2JsonRedisSerializer(objectMapper, Concert.class)) // Class Type 추가
);
}
그러므로 프로젝트 특성에 따라 맞는 RedisSerializer
를 고르는게 현명하다.
참고 링크
- 위키백과 - 캐시
- [REDIS] 📚 캐시(Cache) 설계 전략 지침 💯 총정리
- 캐시(Cache) 알아보기
- 스프링 캐시와 레디스를 활용한 효율적인 캐싱 전략
- 로컬 캐시 선택하기
- Redis vs Memcached
- [Java] Spring Boot Redis 환경 구성 및 활용하기 -1 : 환경 구성 및 데이터 조작 방법
- Spring Boot 에서 Redis Cache 사용하기
- [Spring + Redis] Redis cache를 적용해 조회 성능 개선 방법
- [Spring] ObjectMapper에서 LocalDateTime이 변환되지 않는 문제
- redis serializer
'Backend' 카테고리의 다른 글
분산 환경에서의 동시성 제어 (0) | 2024.10.05 |
---|