이전의 글에서 게시글 내역(게시글/댓글/조회수) 조회 기능의 최적화를 위해 CQRS Pattern을 도입하였는데, 한가지 아쉬운점이 보였다.
Article Service의 기능사용에 대해 병목이 예상되며, 이로 인해 트래픽 불균형이 발생할 것이기에 CQRS Pattern을 도입하였더라도 게시글 조회 자체 트래픽이 너무 많아 질 것을 대비하여 Query Side에서 충분한 최적화가 필요하지 않을까 싶었다.
기존의 Query Side구조에서 어떠한 부족한 점을 느꼈고, 이를 보완하기 위해 도입하였던 최적화 방안을 정리해보았다.
일단 현재의 구조를 살펴보았을때 추가적으로 최적화할 수 있는 부분을 몇군데 찾을 수 있었다.
따라서 결론적으로, 현재 사용중인 Redis 체계를 최대한 활용하여 게시글 조회 시 발생할 수 있는 성능적 결함을 최대한 보완하기로 하였다.
Redis를 다중 활용하여 게시글 조회 기능, 나아가 Query Side의 기능을 보완하고 최대한 최적화할 수 있는 방안을 고민해보았으며 이 과정에서 실무적으로 유의미한 결론을 도출할 수 있었기에 정리한다.
현재 Query Side의 ArticleQueryModel 구조를 살펴보면 아래와 같다.
@Getter
public class ArticleQueryModel {
private Long articleId;
private String title;
private String content;
private Long boardId;
private Long writerId;
private LocalDateTime createdAt;
private LocalDateTime modifiedAt;
private Long articleCommentCount;
private Long articleLikeCount;
private Long articleViewCount;
...
}
위 구조를 자세히 보면 게시글/댓글/좋아요/조회수로 구성해주었는데, 이 항목들은 조회요청이 들어왔을때 MySQL을 거쳐 실시간 데이터를 추출해오는 Model 객체이다.
여기서 생각하였던 문제점은 조회수까지 굳이 MySQL로 받아와야 하는 것이었다.
문제점
- 기본적으로 게시글 조회 서비스 기능에 대한 정책이며, 조회하는 시점에 MySQL에 접근하는 요인을 1개라도 줄인다.
- 게시글/댓글/좋아요 수는 MySQL에 저장되어있는 데이터가 실시간이기에, 이 항목들은 그대로 ArticleQueryModel을 활용한다.
- 조회수는 MySQL이 아닌 Redis에 있는 데이터가 실시간 데이터이기도 하고, MySQL보다는 Redis를 통한 접근이 훨씬 빠르고 성능적으로 유리하다.
조회수는 별도로 MSA까지 만들어 실시간 데이터를 Redis에서 가져오고 있는 상태였고, MySQL에서는 실시간 데이터가 아닌 Eventually Consistency의 비동기 데이터를 보유하고 있는 체계이다.
따라서, 조회수에 대한 항목은 EventHandler를 통한 ArticleQueryModel에 저장하지 않고 Redis에서 바로 받아오도록 한다.
@Getter
public class ArticleQueryModel {
private Long articleId;
private String title;
private String content;
private Long boardId;
private Long writerId;
private LocalDateTime createdAt;
private LocalDateTime modifiedAt;
private Long articleCommentCount;
private Long articleLikeCount;
...
}
즉, 이처럼 ArticleQueryModel에서 조회수 항목은 제거하였고, 이때 분리한 DTO(ArticleQueryModel)를 효과적으로 관리하기 위한 정책을 추가로 구성해주었다.
DTO 분리관리 정책
- 단순 실시간 데이터 전달용도인 ArticleQueryModel과 최종 API호출 후 데이터를 endpoint에 전달하기 위한 DTO는 다르다.
- ArticleQueryModel 구성 자체가 비용을 많이 소모하는 과정이기에, endpoint에 전달할 조회수 데이터는 최종 ResponseDTO 초기화 시점에 구성한다.
public ArticleReadResponse read(Long articleId) {
ArticleQueryModel articleQueryModel = articleQueryModelRepository.read(articleId)
//data null -> fetch
.or(() -> fetch(articleId))
.orElseThrow();
//create response entity from query model
return ArticleReadResponse.from(
articleQueryModel,
viewClient.count(articleId)
);
}
위와 같이, Query Side의 기능은 최초 fetch를 통한 ArticeQueryModel 구성을 시작으로, 이후 조회수데이터까지 구성한 ArticleResponseDTO를 마지막으로 조회 Service layer 로직을 마쳤다.
이처럼 성능적으로 최대한 최적화하고자 ArticleQueryModel과 ResponseDTO를 분리하였으며, 각 분리목적에 맞는 데이터 추출(MySQL데이터 추출 vs Redis데이터 추출)을 위해 Redis를 좀 더 세부적으로 활용하였다(viewClient).
여기에 그치지 않고, 이참에 Redis Caching까지 활용하여 조회비용을 최대한 줄여보고자 하였다.
물론 경험적인 데이터를 적재하여 추후에 Caching을 활용할 수 있겠지만, 선제적 조치를 통해 최적화 수준을 조금이라도 향상하고 성능적으로 불리한 점을 최소화하고자 하였다.
다만 모든 데이터를 캐싱처리하지는 않고, 조회수 카운트에 대해 1차적으로 캐싱처리를 먼저 진행해주었다.
먼저 Cache 컨테이너 환경(RedisCacheManager)을 구성해주기 위한 CacheConfiguration 컴포넌트를 구성해주었다.
@Configuration
@EnableCaching
public class CacheConfig {
/*
* Configuration -> Bean 등록, 이후 스프링 컨테이너에서 사용가능하도록 / 객체활용가능하도록 한다.
* */
@Bean
public RedisCacheManager cacheManager(RedisConnectionFactory connectionFactory) {
return RedisCacheManager.builder(connectionFactory)
/*
* 빠른 시간 내 조회수를 캐싱하여 최대한 빠르게 데이터를 추출해오기 위함
* 너무 길면 실시간 반영 힘듦.
* TTL = 1초.
*
* Configuration -> Application Context에 등록하여 redisCacheManger 빈을 초기화 및 등록
* -> 환경설정 정보를 읽고, 컨테이너가 해당 환경대로 후에 cache AOP 동작을 진행하고 Redis와 상호작용할 수 있도록 함.
* -> 말 그대로 환경설정을 위함
* EnableCaching -> Cache Interceptor / Advisor 생성하여 메서드 실행 시 AOP동작 및 CacheEvict 등의 메타정보를 읽도록 한다.
* -> Configuration 설정정보를 기반으로 Cachable 등의 Bean을 감지하고 읽어서 AOP 동작을 완료하도록 한다.
* -> key : articleViewCount::#articleId(매개변수) / value : articleViewCount(실제 해당 메서드의 반환값)
* */
.withInitialCacheConfigurations(
Map.of(
"articleViewCount", RedisCacheConfiguration.defaultCacheConfig().entryTtl(Duration.ofSeconds(1))
)
)
.build();
}
}
그리고 이 캐싱 Manager를 활용하여(캐싱 AOP), 캐시데이터를 Redis에 저장하는 시점은 게시글 조회 시 조회수를 Redis로 부터 추출해오는 때이다.
@Cacheable(key = "#articleId", value = "articleViewCount")
public long count(Long articleId) {
log.info("[ViewClient.count] articleId={}", articleId);
try {
return restClient.get()
.uri("/v1/article-views/articles/{articleId}/count", articleId)
.retrieve()
.body(Long.class);
} catch (Exception e) {
log.error("[ViewClient.count] articleId={}", articleId, e);
return 0;
}
}
따라서, 캐싱처리를 통해 조회수 데이터를 추출해올 시점에 먼저 Redis에 해당 데이터(key::articleId)가 있는지 확인하고, 있으면 바로 캐싱처리를 통해 데이터를 추출한다. 없을 경우에만 Redis에 최종 접근하여 원본데이터를 추출해온다. 이때 캐싱한 데이터는 1초동안 Redis에서 보유한다.
참고로 적절한 캐싱처리를 위해 도입한 캐싱정책은 다음과 같다.
캐싱은 Redis에 있는 실시간 데이터를 빠르게 가져오는 것이 목적이기에, 너무 느리지 않고 빠르지도 않은 적당한 수준으로 데이터를 Redis에 저장해두도록 한다.
- 조회수 캐싱 데이터는 TTL = 1sec로 설정한다.
- 캐싱처리는 필요시점에 발생하도록 한다(조회수 데이터가 필요하여 해당 데이터베이스로 비용소모가 일어나는 시점).
위와 같이 모든 과정에서 Redis를 충분히 활용하여, MySQL 및 심지어 Redis로 이동하며 발생하는 비용소모를 최대한 줄이고자 하였고 이에 따라 최적화 수준을 최대한 높이고자 노력하였다.
이 과정에서, 단순히 Redis를 사용하는 부분은 MySQL에 비해 상대적으로 비용을 덜 소모하고 성능적으로 유리하기에 추가적인 개선점은 없을 것이다는 생각이 아니라, Redis를 통한 데이터 추출도 성능적으로 유리하긴 하지만, 이 또한 트래픽이 많아질 경우를 생각하여 비용소모를 줄일 수 있는 경로와 방안을 최대한 고민하였고, 가용한 모든 항목에 적용해보는 것이 좋겠다는 생각을 하였다.
그 결과 할 수 있는 모든 곳에서 Redis를 최대한 활용하여 최적화 수준을 높일 수 있었고, 선제적으로 기능/성능적인 불리함을 어느 정도 보완할 수 있었다.
캐싱동작은 실무적으로 상당히 중요한 내용이기에, 이번 기회에 한번 확실히 짚고 넘어가고자 Caching Configuration/EnableCaching 등에 대해 찬찬히 살펴보았다.
- @EnableCaching 은 스프링의 캐시 추상화(Cache Abstraction) 기능을 활성화하는 어노테이션이다(Cache Configuration을 기반으로 생성한 CacheManager 객체로 AOP 프록시 객체의 로직을 진행한다).
@Configuration 클래스 (CacheConfig)를 컨텍스트에 등록한다.
이때 RedisCacheManager 빈이 생성되어 ApplicationContext 에 등록, 즉, 캐시를 관리할 주체(여기선 Redis)가 준비된 상태로 전환된다(Kafka 컨테이너처럼 Redis와 통신하고 관리할 컨테이너 빈 객체가 생성된 것).
@EnableCaching 은 CachingConfigurationSelector를 통해 CacheInterceptor 와 CacheOperationSource 등을 빈으로 자동 등록한다.
캐싱 AOP의 핵심으로, EnableCaching 시 AOP 및 프록시를 동작시켜 메서드 호출 전/후의 동작이 가능하도록 한다.
CacheOperationSource가 @Cacheable, @CachePut, @CacheEvict 등의 메타데이터를 읽어내어 메서드 호출 전/후 특정 동작을 할 수 있도록 하며, CacheInterceptor를 통해 실제 AOP 프록시에서 메서드 호출 전후로 캐시 로직을 실행한다.
스프링은 @Cacheable 등 캐시 어노테이션이 붙은 빈을 찾아 프록시 객체로 감싸는데, 스케쥴링 처리할 때처럼 캐싱처리가 필요한 항목에 대해 프록시 객체로 감싸 캐싱관리대상으로 인식하는 것이다.
이후 실제로 코드를 호출할 때, 프록시가 먼저 가로채서 캐시를 조회하거나 저장한다(AOP 동작).
정리하면,
@EnableCaching → 내부적으로 CacheAdvisor + CacheInterceptor 등록 → 프록시 생성 후 실제 Interceptor 동작 시 캐싱 로직을 가로채는 과정으로 캐싱이 이루어진다.
참고로 실제 캐싱 과정은,
(1) 호출 시 AOP 프록시가 개입
getViewCount(1L) 호출 → 실제 객체가 아니라 프록시 객체가 intercept 함.
프록시는 먼저 CacheManager 에게 articleViewCount 캐싱이름으로 저장된 실제 캐싱데이터인 "articleViewCount::1" key로 캐시 조회를 시도.
(2) 캐시 hit/miss 판단
RedisCacheManager → RedisTemplate → Redis로 key 존재 여부 확인.
key 존재 시 → 바로 value 반환 (메서드 본문 실행 안 함)
key 미존재 시 → 메서드 실제 호출 후, 반환값을 캐시에 저장.
(3) 캐시 저장
캐시 미스인 경우 fetchViewCountFromRedis() 실행.
결과 값이 123이라면 Redis에 다음처럼 저장하는데, Caching 이름과 실제 Caching key는 서로 다르다는 것을 유의한다.
key: articleViewCount::1
value: 123
TTL: 1초
실제 Redis에는 위와 같이 저장된다.
Caching Configuration에서 이어지는 내용인데, application에서 관리하는 캐시이름과 실제 redis에 저장하는 캐시 key는 서로 다른 개념이다.
RedisCacheManger 설정 시 redis cache이름은
Map.of("articleViewCount", RedisCacheConfiguration...)
→ “articleViewCount라는 캐시 이름(cache name) 에 대한 설정을 등록한다”
Redis에서 관리하는 key 값은
articleViewCount::1 (=CacheName :: CacheKey)
→ “해당 캐시(articleViewCount)의 내부 키(cache key)가 1인 캐시 항목”
즉, 정리하면 Redis Cache Name :: Redis Cache Key의 형태로 관리하여 Key를 통한 캐싱데이터 구분 및 관리가 가능한 것이다.
Map.of(
"articleViewCount",
RedisCacheConfiguration.defaultCacheConfig().entryTtl(Duration.ofSeconds(1))
)
= “articleViewCount라는 이름의 캐시 공간(cache name)에 대해 TTL을 1초로 설정하겠다.”는 의미로, 캐시 이름별 구성(Cache Configuration) 을 미리 정의해두는 설정이다.
@Cacheable(value = "articleViewCount", key = "#articleId")
public long count(Long articleId) { ... }
이때 value는 CahceManger에서 설정한 Cache Name으로, 해당 Cache Name을 설정한 Redis 환경 컨테이너 객체(프록시) 실행되는 것이다. 그 Name space 내부에 key를 구분하기 위한 인자로 articleId를 사용하는 것이고, 실제로 value로 반환값이 캐싱처리 되는 것이다.
즉 위에서 기재되어있는 value는 실제로는 Cache Name이고, 실제 key의 'member value'에는 반환값이 저장이 되는 구조이다. 이 구조가 헷갈렸는데, 잘 알아두도록 한다.
아래 구조를 보면 이해가 훨씬 용이할 것이다.
Map.of("articleViewCount", ...)
↓
RedisCacheManager 내부에 "articleViewCount" 캐시 구성 등록
↓
@Cacheable(value="articleViewCount") 호출 시, 해당 구성 참조
↓
실제 Redis key = "articleViewCount::#articleId"
의 과정을 통해
| 캐시 이름 | 캐시키| Redis 실제 Key | Value | TTL |
| ---------------- | ---- | ------------------- | ----- | --- |
| articleViewCount | 1 | articleViewCount::1 | 157 | 1초 |
| articleViewCount | 2 | articleViewCount::2 | 324 | 1초 |
의 형태로 Redis 캐싱처리(저장) 된다.
참고로, Configuration 클래스는 금번의 경우 주도메인 MSA에서 직접 실행하여 컴포넌트 스캔하는 대상이므로, 일전처럼 Boot Start Pattern(별도 META-INF/imports 구성)이 필요없다.
즉, Configuration을 스캔하여 필요 캐싱정보에 맞는 CacheManger를 초기화하는 역할을 하므로 다른 염려없이 바로 EnableCaching를 통한 AOP 처리가 가능하다.
현재 게시글 조회의 경우 페이징/무한스크롤을 고려하지 않은 단건 조회에 대해서 구현하였기에 고려사항이 일단 상기 두가지 전략으로 어느정도 맺음할 수 있었다.
하지만 나아가, "게시글 목록"을 페이징 및 무한스크롤을 통해 조회하고자 한다면 말이 달라진다.
일단 사실 지금의 상황도, 게시글/댓글/좋아요 이벤트를 발행하여 데이터 건건마다 모두 Redis에 저장하여 조회기능을 극대화하고 있기에 Redis 활용능률이 그렇게 좋다고 볼 수 만은 없다.
여기에 페이징/무한스크롤까지 고려해야 한다면 모든 건건의 데이터를 Redis에 저장은 저장대로 하고, page/lastIndex와 같은 고정 key에 과거 데이터가 그대로 남겨져있어 무의미한 캐싱을 계속 남겨두게 된다.
따라서 인기글처럼, 최신목록의 글(게시글 조회 특성상 최초 진입 시 보여지는 1000개의 최신데이터)을 미리 Redis에 저장해두고 이를 목록조회 시 캐싱하는 방안으로 처리할 수 있을 것이다.
즉, 기존 전략처럼 모든 데이터를 캐싱하지 않고 일부 최신 데이터 1000개에 대해서만 Redis 저장 및 캐싱하는 전략을 도입한다면 Redis 부하를 줄이고, 활용능률도 향상할 수 있을 것이다.