들어가기
최근 프로젝트 리펙토링을 진행하면서 성능 향상이라는 부분에 대해 생각을 해보았다. 성능 향상을 이루는 방법은 코드 리펙토링, 쿼리 최적화, 인덱스 사용 등 다양하게 있는데 항상 거론되는 것 중 하나가 캐시(cache)였다. '캐시'하면 떠오르는 것은 Redis라는 것이었고 프로젝트에도 적용해 보자고 생각하여 공부 및 실습을 진행하게 되었다.
그러던 중 패스트 캠퍼스에서 Redis에 대한 강의를 들었는데, 강의 내용을 듣고 관련 코드를 프로젝트에 적용하기 전에 다른 사람들은 어떻게 적용하였는지 궁금함이 생겼다. 그래서 다른 사람들의 코드를 참고하기 위해 인터넷을 뒤지다가 GenericJackson2JsonRedisSerializer()을 적용함에 있어 문제가 있다는 것을 알게 되었다.
이글에서는 개인적인 생각이 많이 포함되어 있고,GenericJackson2JsonRedisSerializer()에 문제를 확인하면서 필자의 프로젝트에 직접 redis를 적용하여 조회, 저장, 수정을 진행할 것이다. 궁금하거나 잘못된 부분들은 댓글을 달아주길 바란다.
Redis에 대한 기본적인 지식은 https://khdscor.tistory.com/98를 참고하길 바랍니다.
게시글 조회 시 Redis를 통한 성능 향상
1. GenericJackson2JsonRedisSerializer()를 통한 Redis 내의 데이터 저장 및 조회
Redis대해서 알 기위해 패스트 캠퍼스에서 Redis에 대한 강의를 들어보았다. 강의 내용을 듣고 관련 코드를 프로젝트에 적용하기 전에 다른 사람들은 어떻게 적용하였는지 참고하기 위해 인터넷을 뒤지다가 GenericJackson2JsonRedisSerializer()을 적용함에 있어 문제가 있다는 것을 알게 되었다.
아래는 redis에 대한 설정을 담은 config 파일 내에 redis 캐시 어노테이션을 적용하기 위해 설정한 RedisCacheManager 코드이다. 한번 봐보자.
@Configuration
public class RedisCashConfig {
@Bean
public RedisCacheManager cacheManager(RedisConnectionFactory connectionFactory) {
RedisCacheConfiguration configuration = RedisCacheConfiguration.defaultCacheConfig()
.disableCachingNullValues()
.entryTtl(Duration.ofSeconds(30))
.computePrefixWith(CacheKeyPrefix.simple())
.serializeKeysWith(
RedisSerializationContext.SerializationPair.fromSerializer(
new StringRedisSerializer())
)
.serializeValuesWith(
RedisSerializationContext.SerializationPair.fromSerializer(
new GenericJackson2JsonRedisSerializer())
);
return RedisCacheManager
.RedisCacheManagerBuilder
.fromConnectionFactory(connectionFactory)
.cacheDefaults(configuration)
.build();
}
}
여기서 확인할 것은 value를 직렬화하는 serializeValuesWith()에 GenericJackson2JsonRedisSerializer()가 사용된 것이다. redis 내에 값을 집어넣을 때는 redis 내 자료구조인 list, set, string, hash 등의 형식이어야 하지, 다양한 필드 값을 가지는 dto를 redis에 그대로 집어넣을 수는 없다. 그래서 이런 dto를 '직렬화' 하여 redis에 저장을 해줘야 하는데 이때 사용할 수 있는 것이 GenericJackson2JsonRedisSerializer()이다.
GenericJackson2JsonRedisSerializer()는 객체의 클래스를 지정하지 않고 모든 Class Type을 JSON 형태로 변환을 해줘서 redis에 문제없이 객체를 저장할 수 있도록 해준다. 그리고 redis에서 객체를 찾을 때도 알아서 역직렬화를 해주기 때문에 어떤 형태를 가지는 dto든 문제없이 redis에 저장할 수가 있다.
참고사항으로 아래는 GenericJackson2JsonRedisSerializer() 외에 라이브러리에 존재하는 직렬화 메서드들이다.
그래서 필자는 위와 같이 CacheManager를 빈으로 등록해 준 뒤 아래와 같이 서비스 로직에 @Cacheable 어노테이션을 적용하여 redis 적용을 완료하고자 하였다.
@Transactional(readOnly = true)
@Cacheable(key = "#articleId" + "#memberId", value = "articleDetails", cacheManager = "cacheManager")
public ArticlePageResponse findDetails(Long articleId, Long memberId) {
ArticlePageResponse response = new ArticlePageResponse();
//DB에서 데이터를 조회하여 response에 저장하는 메서드
addLoginInfo(articleId, memberId, response);
//조회한 데이터를 검증하는 메서드
validatePublicArticle(response);
return response;
}
@Cacheable 어노테이션을 달아줌으로써 config 파일에 bean으로 등록한 cacheManager 설정을 따른다. 캐시에 articleDetails::{articleId}란 이름의 키 값으로 데이터가 있으면 ArticlePageResponse 타입의 데이터 값을 가져와서 메서드 내부의 로직을 실행하지 않고 바로 반환을 한다. 만약 캐시에 데이터가 없다면, 내부의 로직을 실행하여 DB에 접근한 후 response를 반환하면서 캐시에도 값을 저장한다.
이렇게 캐시를 적용을 완료할 수 있었을 것인데...
GenericJackson2JsonRedisSerializer()에 대한 문제점을 발견하여 버렸다..!
바로 dto class 내용 뿐만아니라 package 정보까지 전부 Redis에 저장을 하는 것이다!
아래는 테스트로 GenericJackson2JsonRedisSerializer()을 통해 dto를 redis에 넣고 내용을 확인한 사진이다. 한번 봐보자.
패키지 정보가 그대로 들어가 있지 않는가? 이는 어떤 애플리케이션이든지 이 데이터를 꺼내기 위해서는 고정된 패키지 경로에 있는 dto 객체만으로 접근을 해야 한다는 것이다. MSA 형식의 프로젝트는 여러 애플리케이션이 각각의 독립된 기능을 담당하는 것인데, 위 방식은 고정된 패키지 정보에 묶인다는 문제가 있다.
결국 차후를 생각할 때는 위 직렬화 방법이 아닌 다른 방법으로 직렬화를 하는 게 좋다고 생각하였다.
2. 객체에서 Json 형식으로의 직렬화 로직을 통한 String 변환 및 cache 저장
다른 대책으로 많이 나온 의견은 바로 StringRedisSerializer()을 통해 String으로의 저장이다. 애초에 redis에 저장할 때부터 String을 넣자는 것이다.
dto를 redis에 넣기 전에 string으로 변환을 한 후 저장하고, 찾을 때는 반대의 과정을 밟는 것이다. 변환을 하기 위해서 ObjectMapper 객체가 사용되었다. 이를 통해 dto를 json 형태의 String 타입으로 변환 해 줄 수가 있고 객체의 타입만 있다면 역직렬화도 쉽게 해줄수가 있다.
ObjectMapper를 사용한 저장, 조회의 주요 로직은 여기를 참고하였다.(갱신은 좀 더 아래에서 다룬다.)
아래는 구현 및 적용된 부분이다. @Cacheable가 아닌 RedisTemplate를 직접 사용하였는데 그 이유는 아래에 적어놓았다.
@Component
@Slf4j
@RequiredArgsConstructor
public class ObjectSerializer {
private final RedisTemplate<String, String> redisTemplate;
public <T> void saveData(String key, T data, int seconds) {
try {
ObjectMapper mapper = new ObjectMapper();
String value = mapper.writeValueAsString(data);
redisTemplate.opsForValue().set(key, value, seconds, TimeUnit.SECONDS);
} catch (Exception e) {
log.info("예외 메세지: " + e.getMessage());
throw new WrongAccessRedisException("캐시에 데이터를 저장하는데 실패하였습니다.");
}
}
public <T> Optional<T> getData(String key, Class<T> classType) {
String value = redisTemplate.opsForValue().get(key);
if (value == null) {
return Optional.empty();
}
try {
ObjectMapper mapper = new ObjectMapper();
return Optional.of(mapper.readValue(value, classType));
} catch (Exception e) {
log.info("예외 메세지: " + e.getMessage());
throw new WrongAccessRedisException("캐시에서 데이터를 가져오는데 실패하였습니다.");
}
}
}
savData()는 key와 TTL(Time To Live)과 데이터를 받아서 redis에 데이터를 저장하는 메서드이다.
제네틱을 통해 어떤 타입이든지 상관없이 받을 수 있다. mapper.writeValueAsString()을 통해 Json 형식의 String 값으로 변환 후 redis에 저장되도록 하였다.
getData()는 key와 찾으려는 데이터의 타입을 받고 redis에서 key에 해당하는 데이터를 조회하는 메서드이다. 만약 캐시에 key에 해당하는 값이 없으면 Optional.empty()를 통해 빈 값을 반환하고, 값이 있으면 mapper.readValue()를 통해 Optional의 형태로 해당하는 데이터의 타입을 묶어서 반환한다.
아래는 saveData()와 getData()를 적용한 service 로직이다.
@Transactional(readOnly = true)
public ArticlePageResponse findDetails(Long articleId, Long memberId) {
String redisKey = "articleDetails::" + articleId + memberId;
Optional<ArticlePageResponse> cache = objectSerializer.getData(redisKey,
ArticlePageResponse.class);
// redis에 데이터가 있을 경우 - DB 접근 x
if (cache.isPresent()) {
validatePublicArticle(cache.get());
return cache.get();
}
// redis에 데이터가 없을 경우 - DB 접근 o
ArticlePageResponse response = new ArticlePageResponse();
//DB에서 데이터를 조회하여 response에 저장하는 메서드
addLoginInfo(articleId, memberId, response);
//조회한 데이터를 검증하는 메서드
validatePublicArticle(response);
// redis에 저장
objectSerializer.saveData(redisKey, response, 30);
return response;
}
ObjectSerializer를 의존성 주입한 후 getData()와 saveData()를 사용하였다.
어노테이션을 사용했을 때보다 코드가 길어져 가독성이 떨어지는 단점이 있지만 dto 객체의 패키지 정보 없이 Redis로 데이터를 저장할 수가 있었다.
위에서는 Redis에 데이터를 StringReisSerializer()를 통해 저장할 것이라는 것을 인지시켜야 하기에 RedisTemplete를 설정하여 Bean으로 등록해 주었다. setValueSerializer()를 통해 key에 해당하는 value를 StringReisSerializer()를 사용하여 직렬화하도록 하였다.
@Bean
public RedisTemplate<String, String> redisTemplate(RedisConnectionFactory connectionFactory) {
RedisTemplate<String, String> redisTemplate = new RedisTemplate<>();
redisTemplate.setKeySerializer(new StringRedisSerializer());
redisTemplate.setValueSerializer(new StringRedisSerializer());
redisTemplate.setConnectionFactory(connectionFactory);
return redisTemplate;
}
위의 방식은 어노테이션을 사용하는 것이 아닌, 직접 RedisTemplate을 의존성 주입받아서 사용하는 것이다. 처음에 목표로 한 것은 ObjectMapper를 통해 직렬화, 역직렬화를 하면서 @Cacheable 어노테이션을 적용하는 것이었다. 하지만 @Cacheable는 메서드의 반환되는 데이터가 Redis에 저장되는 데이터야 한다. StringRedisSerializer()를 사용하려면 반환타입이 String이어야 한다.
만약 String으로 설정한다면 Service 메서드의 반환타입을 String으로 바꾸고 Controller에서 Service 메서드를 호출하기 전, 후에 ObjectMapper를 통해 Json으로 직렬화하고 역직렬화하는 작업을 해주어야 한다.
혹은 Service 메서드의 반환타입을 그대로 두고 내부에 반환타입이 String인 또 다른 메서드를 생성해야 한다. 하지만 그렇게 되면 그 메서드는 다른 클래스를 의존성 주입하여 그 클래스의 메서드로서 사용해야만 한다.(@Transactional처럼 AOP 형식으로 @Cacheable가 동작을 하므로 service 클래스 내 private 한 메서드로서 사용할 수는 없다.)
이렇게 되면 데이터를 DB로부터 가져오는 작업도 그 클래스의 메서드에서 실행되어야 한다. 그 메서드는 Repository를 의존성 주입받아야 하기에 캐시를 사용하는 모든 Service에서 공통 메서드로서 사용하기 난감해진다(모든 service 에서 Redis 사용 대비를 위해 모든 Service에서 사용되는 Repository를 전부 의존성 주입해야 한다). 그러면 service마다 메서드를 구현해주어야 하기에 RedisTemplate을 통한 공통메서드를 사용했을 때보다 복잡한 형태가 될 것이다.
첫번째로 언급한 Controller에서 Service 메서드의 반환타입을 String으로 받도록 하고 받기 전과 후에 데이터를 가공하는 방법도 있지만, 이러한 가공을 Controller보다는 Service에서 해주는 것이 더 낫다고 생각하여 RedisTemplate을 사용하여 구현을 하게 되었다.
3. Enum을 활용하여 게시글 내부 수정 기능들 수행 시 Redis 갱신
이렇게 데이터 조회 시 캐시 기능을 적용할 수 있었다.
이제 Redis 적용은 다 끝난 줄 알았다. 하지만 고려해야 할 부분이 더 있었다...
데이터를 수정하면 어떻게 되는 것인가?
한 번 두 번 접근하여 테스트를 해보니 역시나 수정되지 않은 데이터가 조회되었다.
데이터가 수정되면 db에 데이터를 저장하지만 redis에는 수정되지 않은 데이터가 남아있기에 redis 데이터를 조회하여 수정 전의 데이터가 조회되는 것이었다...
이 문제를 해결하는 것은 단순했다. 그저 데이터 수정 시 redis에도 데이터를 업데이트하면 되는 것이다.
현재 프로젝트 에서 조회한 dto 데이터는 게시글 상세 페이지인데, 수정되는 부분들은 게시글 수정, 게시글 좋아요 변경, 댓글 작성, 댓글 삭제, 댓글 수정, 댓글 좋아요 등 6가지의 작업이 있다.
이를 위해서 @CachePut라는 수정에 사용되는 어노테이션을 적용하는 방법이 있지만 이 방법은 반환되는 데이터가 캐시에 저장되는 것이다. 이를 위해 모든 수정 사항마다 수정부분과 상관없는 부분도 포함한 ArticlePageResponse dto전체를 조회하고 String으로 변환하여 반환하는 것은 효율적인 방법이 아니다.
그래서 redis에 업데이트를 위해서는 RedisTemplate가 필요하기에 ObjectSerializer 객체 내부에 메서드를 생성해야 한다. 하지만 수정하는 정보들은 모두 다른 타입을 가진다.
dto로 사용되는 ArticlePageResponse는 아래의 값들을 가진다.
private ArticleDetails articleDetails; // 게시글 상세 정보
private boolean articleLike; // 게시글에 내가 좋아요를 했는지 유무
private List<CommentResponse> comments; // 댓글들 정보
private List<Long> commentLikes; // 어느 댓글에 내가 좋아요를 눌렀는지
private Long myMemberId; // 내 개인 Id
댓글 추가는 comments.add()를 통해 댓글이 추가되고, 게시글 수정은 articleDetaisl 내부에 String 형태의 내용이 수정되고, 게시글 좋아요 변경은 articleLike 값이 반대가 된다.
과연 이렇게 다양한 형태이기에 ObjectSerializer에 각각 다른 형태의 메서드로 만들어야 하는가?
단순히 ObjectSerializer 클래스 내에 메서드를 여러 개 만들어 내는 방식은 마음에 들지가 않았기에 많은 시간을 고민하였다.
어떻게 해야 더 깔끔하게, 가독성 있게 구현할 수 있을까? 공통메서드로는 구현이 안 되는 것일까..?
이때 문득 떠오른 것이 있었다.
캐시에서 데이터 조회 → 변화된 데이터로 데이터 업데이트 → 업데이트된 데이터 캐시에 저장
데이터를 수정한 후 위의 순서로 캐시 갱신이 이루어질 것이다. 앞 뒤 부분은 모든 수정 부분들이 똑같이 진행되고, 중심 부분인 데이터 업데이트만 다르게 구현하면 된다. service에서는 변화되는 내용만 알려주고 그 변화되는 내용에 맞게 다른 실행을 하도록 하는 메서드만을 구현한다.
타입이 다른 것은 제네릭을 통해 해결할 수 있다. 문제는 타입별로 다른 메서드가 실행되어야 한다.
여러 타입을 구분해 주는 값을 가지고 있고, 타입별로 다른 메서드가 실행되도록 하는 것...
바로 상수 열거형, Enum이다!
Enum 객체를 구현하여 내부에 수정하는 부분들을 선언하고, 부분마다 다른 로직을 실행시켜 주면 된다.
ObjectSerializer 객체 내부에서는 메서드 하나만을 선언하고 Service로부터 key와 수정될 부분을 알려주는
값, 수정될 값을 받고 Enum 내부에 공통 메서드를 실행해 주도록 하였다. 수정될 값들은 Enum 객체로 넘어올 때까지 제네릭으로 받고 각 수정메서드 별로 내부에서 데이터를 수정될 값에 맞게 변환하도록 하였다.
구현된 코드를 봐보자.
우선 ObectSerializer 내부에 updateArticleData라는 메서드를 추가한다.
public<T> void updateArticleData(String key, ArticleUpdatePart part, T data) {
Optional<ArticlePageResponse> cache = getData(key, ArticlePageResponse.class);
if (cache.isPresent()) {
part.apply(cache.get(), data);
saveData(key, cache.get(), 30);
}
}
key값과 ArticleUpatePart라는 Enum 값, 수정될 data를 매개변수로 갖는 공통 메서드이다.
getData와 saveData는 모든 수정 service가 동일하다. part.apply() 부분에서 part마다 다른 로직이 수행된다.
위 코드만 보고는 어떤 로직이 수행될지를 모를 것이다. ArticleUpdatePart를 봐보자.
public enum ArticleUpdatePart {
CHANGE_LIKE("change_like") {
public <T> void apply(ArticlePageResponse response, T data) {
response.changeLike();
}
},
CHANGE_COMMENT_LIKE("change_comment_like") {
public <T> void apply(ArticlePageResponse response, T data) {
response.changeCommentLike((Long) data);
}
},
EDIT_ARTICLE("edit_article") {
public <T> void apply(ArticlePageResponse response, T data) {
response.getArticleDetails().editContent((String) data);
}
},
EDIT_COMMENT("edit_comment") {
public <T> void apply(ArticlePageResponse response, T data) {
response.changeCommentContent((CommentUpdateDto) data);
}
},
ADD_COMMENT("add_comment") {
public <T> void apply(ArticlePageResponse response, T data) {
response.addComment((CommentResponse) data);
}
},
REMOVE_COMMENT("remove_comment") {
public <T> void apply(ArticlePageResponse response, T data) {
response.removeComment((Long) data);
}
};
ArticleUpdatePart(String updatePart) {
}
public abstract <T> void apply(ArticlePageResponse response, T data);
}
수정되는 부분은 6개이므로 6개의 상수로 선언을 하였고, apply 라는 추상메서드를 선언하여 상수별로 다른 로직이 수행되도록 하였다.
그렇다면 Service 메서드에서는 이를 어떻게 사용하는지 몇 가지만 확인해 보자.
댓글 추가
public CommentResponse createComment(Long id, String content, Long memberId) {
Article article = findAndValidateArticle(id);
if (!article.isPublic_map()) {
throw new WrongMapTypeException("게시글이 전체지도에 포함되지 않습니다.");
}
Member member = findAndValidateMember(memberId);
CommentResponse response = saveComment(id, content, AuthorDto.buildAuthorDto(member));
String redisKey = "articleDetails::" + id + memberId;
objectSerializer.updateArticleData(redisKey, ArticleUpdatePart.ADD_COMMENT, response);
return response;
}
게시글 수정
public void edit(Long articleId, Long memberId, String newContent) {
int result = editArticleRepository.editArticle(articleId, memberId, newContent);
if (result == 0) {
throw new NotMatchMemberException("글이 존재하지 않거나 수정할 권한이 없습니다.");
}
String redisKey = "articleDetails::" + articleId + memberId;
objectSerializer.updateArticleData(redisKey, ArticleUpdatePart.EDIT_ARTICLE, newContent);
}
게시글 좋아요 변경
public void changeArticleLike(ArticleLikeDto articleLikeDto) {
Article article = findAndValidateArticle(articleLikeDto.getArticleId());
if (!article.isPublic_map()) {
throw new WrongMapTypeException("게시글이 전체지도에 포함되지 않습니다.");
}
changeLike(articleLikeDto);
String redisKey = "articleDetails::" + articleLikeDto.getArticleId() + articleLikeDto.getmemberId();
objectSerializer.updateArticleData(redisKey, ArticleUpdatePart.CHANGE_LIKE, null);
}
위 3가지 Service 로직은 모두 다른 기능이지만 공통되는 부분을 쉽게 찾을 수 있을 것이다.
바로 redisKey를 생성하고 updateArticleDate()를 실행한 부분이다.
ADD_COMMENT, EDIT_ARTICLE, CHANGE_LIKE 등으로 어떤 기능인지를 구분하고 data를 추가하는 것일 뿐, Service 메서드 내에서는 updateArticleDate()라는 메서드만을 실행하여 redis 내에 데이터를 업데이트할 것이라는 것을 명확히 인지할 수 있다.
결론
이렇게 프로젝트에 Redis를 적용하여 게시글 조회 시 캐시에 데이터를 저장하였고, 조회 시 DB가 아닌 캐시부터 조회하도록 하여 성능향상을 이루었다.
그리고 수정 시 캐시에도 업데이트를 해주어 캐시와 DB를 상시 동기화하여 무결성 문제를 해결해 주었다.
프로젝트에 적용된 방식은 많은 고민을 통해 적용한 것이지만 무조건 정답은 아니다. 분명 더 좋은 방식이 있고 나의 생각이 잘못됐을 수도 있다. 필자도 한참 배우는 단계이니 궁금한 점, 보완할 부분, 잘못된 부분 등을 댓글로 달아주면 감사하겠습니다...
참고
https://bcp0109.tistory.com/386
https://bcp0109.tistory.com/384
https://mongsil-jeong.tistory.com/25
https://www.woolog.dev/backend/spring-boot/spring-boot-redis-cache-simple/
https://prodo-developer.tistory.com/157
https://velog.io/@kshired/Spring-Redis에서-객체-캐싱하기
https://velog.io/@akfls221/RedisDocker를-통한-Cache전략-알아보기
https://helloworld.kurly.com/blog/redis-fight-part-1/
https://github.com/binghe819/TIL/blob/master/DB/Redis/Redis는 무엇이고, 어떻게 사용하는 것이 좋은가/Redis는 무엇이고, 어떻게 사용하는 것이 좋은가.md#왜-collection이-중요한가
https://docs.spring.io/spring-data/redis/docs/current/reference/html/#redis:serializer
'redis' 카테고리의 다른 글
Springboot: redis를 통해 캐시 기능 간단 적용 (0) | 2023.10.02 |
---|---|
Redis: In-Memory DB로서 뛰어난 성능 (0) | 2023.09.24 |