내 프로젝트에서는 사용자별로 사이드바에 찜 수, 장바구니 수들을 카운트 쿼리로 반환된 데이터 수를 view에 뿌려주는데,
이 사이드바는 여러 페이지에서 노출되어 페이지 이동마다 카운트 쿼리를 자주 호출하고 있었다.
그렇기 때문에 매번 카운트 쿼리를 호출하지 않고 해당 데이터를 캐시 처리하여 카운트 수가 변경되지 않을 경우에 Redis 캐시에서 해당 데이터 수를 반환하도록 하여 DB의 부담을 줄이도록 하였다.
레디스에 대해 알고 싶다면 아래 포스팅에서 설명한다.
Redistemplate 방식과 Redis repository 방식 비교
Redis 데이터에 접근하기 위한 방식으로 Spring에서 제공하는, Redistemplate 방식과 Redis repository 방식이 있다.
각각의 특징과 사용 시점을 비교하면 다음과 같다.
Redistemplate
- Key, Value 연산을 직접 수행할 수 있으며, String, Hash, List 등 Redis의 데이터 구조에 직접 접근 가능.
- 세밀한 제어가 필요한 경우나 특정 Redis 명령을 사용해야 할 때 유용.
- 사용자 정의 직렬화 전략을 적용하거나 복잡한 쿼리를 작성해야 하는 경우에 적합.
Redis repository
- JPA와 유사하게 동작하여 CrudRepository<>를 상속받는 인터페이스를 생성하면 기본적인 CRUD 메서드를 사용할 수 있다.
- 인터페이스만 정의하면 되므로 더 간편하게 구현 가능하다.
- 복잡한 쿼리나 세밀한 제어가 필요할 경우에는 RedisRepository가 지원하지 않는 기능이 있을 수 있다.
- 간단하고 일관된 CRUD 작업이 주된 작업의 경우에 적합하다.
Redis 설정하기
build.gradle 의존성 추가
implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310'
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
jsr310 의존성
redis 의존성 뿐 아니라 jsr310 의존성을 추가했는데 아래와 같이
Jackson 라이브러리가 Java 8의 날짜/시간 타입을 직렬화/역직렬화할 때 필요한 모듈이 없어서 발생하는 에러를 방지하기 위해서이다.
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
추가만 해서는 위 에러 방지가 되진 않는다.
추가적인 설정은 아래에서 다룬다.
application.properties 설정
# Redis Setting
spring.data.redis.host=localhost
spring.data.redis.port=6379
spring.cache.type=redis
Redis 설정 클래스 생성
@Configuration
@EnableCaching
public class RedisCacheConfig {
// 시간 관련된 값을 캐싱하지 않는다면 해당 설정을 적용하지 않아도된다.
@Bean
public ObjectMapper objectMapper() {
ObjectMapper objectMapper = new ObjectMapper();
objectMapper.registerModule(new JavaTimeModule());
objectMapper.activateDefaultTyping(
objectMapper.getPolymorphicTypeValidator(),
ObjectMapper.DefaultTyping.EVERYTHING,
JsonTypeInfo.As.WRAPPER_OBJECT
);
objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
return objectMapper;
}
// spring의 캐시 타입 Redis로 설정한다. (이 과정이 있다면 spring.cache.type 설정 생략 가능)
@Bean
public CacheManager cacheManager(RedisConnectionFactory cf) {
return RedisCacheManager.RedisCacheManagerBuilder
.fromConnectionFactory(cf)
.cacheDefaults(redisCacheConfiguration())
.build();
}
// Redis 연결 설정
@Bean
public RedisConnectionFactory redisConnectionFactory() {
return new LettuceConnectionFactory();
}
// Redis 캐시 설정
@Bean
public RedisCacheConfiguration redisCacheConfiguration() {
return RedisCacheConfiguration.defaultCacheConfig()
.serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer())) // 키를 String으로 직렬화해 저장
.serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer(objectMapper()))) // 객체를 JSON으로 바꿔서 Redis에 저장
.entryTtl(Duration.ofDays(1L));
}
// Redis의 세밀한 조작이 필요할 경우 RedisTemplate를 빈 등록하면된다.
// Redis의 모든 자료구조 다 쓸 수 있음 (String, List, Hash, Set 등)
// 간단한 캐시 조작이라면 추가하지 않아도 된다.
@Bean
public RedisTemplate<?, ?> redisTemplate() {
RedisTemplate<?, ?> redisTemplate = new RedisTemplate<>();
redisTemplate.setConnectionFactory(redisConnectionFactory());
redisTemplate.setDefaultSerializer(new StringRedisSerializer());
return redisTemplate;
}
}
ObjectMapper를 빈등록한 부분이 의아할 수도 있는데 쉽게 이해할 수 있도록 설명을 추가한다.
(나머지는 주석으로 충분히 이해가 가능할 것이다.)
jsr310 의존성 적용
위에서 설명했던 java의 날짜 타입을 직렬화/역직렬화 할 때 발생하는 문제를 해결하기 위한 설정으로
JSR-310에서 도입된 새로운 날짜/시간 타입들을 JSON으로 직렬화/역직렬화하기 위해
objectMapper.registerModule();를 통해 new JavaTimeModule() 모듈을 등록해주는 것이다.
추가로 objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); 로
날짜를 타임스탬프(숫자) 형태 대신 ISO-8601 문자열 형식으로 직렬화하도록 설정하는 것이다.
// enable (기본값) 일 경우 : 타임스탬프
{ "date": 1698422400000 }
// disable 일 경우 : ISO-8601
{ "date": "2024-10-27T12:00:00" }
타입 캐스팅 에러 해결
Redis에서 데이터를 가져올 때 JSON 형태로 저장된 데이터를 LinkedHashMap으로 자동 변환해준다.
근데 이걸 우리가 원하는 클래스(ex: User 클래스)로 바로 캐스팅하려고 하면
자바가 "야 이거 LinkedHashMap인데 갑자기 다른 클래스로 바꾸라고? 말이 돼?" 하면서 아래와 같은 에러를 뱉게 된다.
java.lang.ClassCastException: class java.util.LinkedHashMap cannot be cast to class
이 에러를 해결하기 위해 원하는 클래스로 변환하도록 다음과 같은 설정을 추가해줬다.
objectMapper.activateDefaultTyping(
objectMapper.getPolymorphicTypeValidator(), // 타입 검증기
ObjectMapper.DefaultTyping.EVERYTHING, // 모든 객체에 타입 정보 추가
JsonTypeInfo.As.WRAPPER_OBJECT // 타입정보를 객체를 감싸는 형태로 추가
);
jackson 2.10 버전부터는 activateDefaultTyping()을 설정할 때 꼭 TypeValidator를 인자로 추가해줘야한다.
아무 클래스나 역직렬화하면 보안상 문제가 있어 필수로 추가해야한다고 한다.
만약 복잡한 구조가 있다면 PolymorphicTypeValidator를 구현할 수 있지만, 필자는 구조가 간단해 기본 Validator를 추가해줬다.
이 외의 설정들은 간단하니 궁금하다면 간단하니 아래 공식를 확인해보자.
jason PolymorphicTypeValidator 공식 문서
jackson ObjectMapper.DefaultTyping 공식 문서
어노테이션으로 캐싱 적용
캐시를 설정했다면, 이제 캐싱을 적용해야한다.
CRUD 레포지토리를 상속받는 Redis 전용 레포지토리를 구현해 저장할 수도 있지만
캐시를 저장하는 로직이 복잡하지 않다면 어노테이션으로도 간단하게 적용할 수 있다.
필자는 Cache 어노테이션을 사용해 아주 간단하게 캐싱을 적용하고 제거하는 방법을 설명하려 한다.
LikeService에 캐시 적용해보기
다른 메서드에도 캐시를 적용하고 싶으면 아래 방식대로 캐시를 적용할 메서드에 해당 어노테이션을 적용해주면 된다.
@Service
@Transactional
@RequiredArgsConstructor
public class LikeService {
private final static String LIKE_COUNT = "likeCount";
private final LikeRepository likeRepository;
@CacheEvict(cacheNames = LIKE_COUNT, key = "#like.userIdNo")
public void insertLike(Like like) {
likeRepository.insertLike(like);
}
@CacheEvict(cacheNames = LIKE_COUNT, key = "#userId")
public void deleteLike(Long userId, Long id) {
likeRepository.deleteLike(userId, id);
}
@Cacheable(cacheNames = LIKE_COUNT, key = "#id", condition = "#id != null")
public int countLikesByUserId(Long id) {
return likeRepository.countLikesByUserId(id);
}
}
사용된 캐시 전략
위 코드에 적용된 캐시 전략은 다음과 같다.
캐시를 먼저 확인하고, 캐시에 데이터가 없을 경우 DB에서 데이터를 가져오는 Look Aside 읽기 전략 사용.
데이터를 업데이트 할 대마다 바로 캐시에 반영하지 않고, 변경된 내용만 DB에 반영하고 해당 캐시는 제거 또는 무효화하는 Write Around 쓰기 전략 사용.
캐시 전략에 대해 더 자세히 알아보려면 아래 블로그를 참고하는 것을 추천한다.
=> 캐시 전략에 대해 알아보기
2024.11.10 - [◼ CS 기초 지식/[데이터베이스]] - [Redis] 캐싱(caching) 설계 전략에 대해 알아보자
캐시 어노테이션
위 예시에서 사용한 어노테이션과 그 외의 어노테이션들에 대해 알아보자.
Spring은 총 4가지의 Cache 어노테이션을 제공한다.
@Cacheable
읽기 작업을 수행하는 메서드에 사용되는 어노테이션이다.
해당 어노테이션은 메서드의 특정 인자에 대한 메서드 결과값을 캐시 저장소에 key값으로 저장하고
이미 캐시되어 있다면 캐시 저장소에서 가져와 반환한다.
옵션 | 설명 |
value | 저장될 캐시 이름을 설정한다. |
cacheNames | 저장될 캐시 이름을 설정한다. (value랑 같음) |
key | Spring 표현식을 사용하여 동적으로 달라지는 메서드의 파라미터 값을 선언한다. |
condition | Spring 표현식을 사용해 해당 조건을 만족할 때만 캐시에 저장한다. |
unless | Spring 표현식을 사용해 해당 조건을 만족할 때만 캐시에 저장하지 않는다. |
sync | 동기화 여부를 설정한다. |
@CacheEvict
저장된 캐시를 제거할 때 사용된다. 여기서는 키 값을 지정해 해당 키 값만 캐시에서 제거하도록 하였다.
사용된 옵션은 위의 옵션에 대한 설명과 동일하다.
위에서 설명한대로 여기서 사용된 캐시 전략은 Look Aside + Write Around 조합이 사용되었다.
그렇기 때문에 DB에 저장된 데이터가 수정, 추가, 삭제 될 때마다 Cache 또한 삭제해주어야 한다.
만약 데이터가 변경될 때마다 캐시를 비워주지 않으면
캐시 DB(Redis)와 하드 DB(MySQL)의 데이터가 서로 다른 데이터를 갖고 있어 캐시DB에서 데이터를 꺼내 올 때 변경되기 전인 오래된 정보를 사용하는 데이터 정합성 문제점이 발생한다.
그렇기 때문에 데이터가 변경 될 때마다 캐시를 제거해주는 작업이 중요하다.
@CachePut
메서드의 결과값을 항상 캐시에 저장한다.
때문에 캐시 존재 여부를 신경쓰지 않고 메서드를 실행하고 결과를 캐시 저장소에 저장한다.
주로 update 작업에 사용한다.
@CachePut(value = "user", key = "#user.nickname")
public void updateNickname(User user) {
... 생략
}
@Caching
여러 캐시 작업을 한번에 처리한다.
(Cachable, CachePut, CacheEvict 어노테이션들을 복합적으로 사용해 캐시를 처리할 수 있다.)
@Caching(
cacheable = {
@Cacheable(value = "userId", key = "#userId"),
@Cacheable(value = "userEmai", key = "#email")
}
)
public void complexCacheOperation(User user) {
... 생략
}
'◼ Spring' 카테고리의 다른 글
[Spring Security] 일반 로그인 & 소셜 로그인 분리 및 Security 로그인 비동기 처리 방법 (2) | 2023.09.13 |
---|---|
[Spring] 자체 서비스 회원과 소셜 로그인 회원 통합 관리 방법 (0) | 2023.09.13 |
[Spring] 아임포트(import)로 결제 취소, 환불 기능 구현하기 (6) | 2023.09.05 |
[Spring] 아임포트(import)로 결제 기능 구현하기 (클라이언트 + 서버 코드 포함) (7) | 2023.09.01 |
[Spring] 로그인 어노테이션으로 세션 정보가져오기 (Argument Resolver 활용) (0) | 2023.08.31 |