Album 프로젝트의 로그 기능을 구현한 후, 해당 기능을 통해 Album 프로젝트의 주요 기능들의 동작 과정을 확인해보았다.
주요 기능들 중 피드 상세 조회 기능이 매우 높은 비용으로 수행되고 있음을 확인할 수 있었다.
- 피드 상세 조회 기능의 동작 과정
- ViewController.feedPage()가 호출되고, 피드 상세 페이지(HTML)이 반환된다.
- Ajax를 통해 피드 데이터를 반환하는 FeedController.getFeed(), 피드 댓글 데이터를 반환하는 CommentContoller.getFeedComments()가 호출된다.
- 피드 데이터, 피드 댓글 데이터를 반영하여 렌더링된 피드 상세 페이지가 클라이언트에게 보여진다.
- 피드 상세 조회 기능의 높은 수행 비용
- 피드 데이터와 피드 댓글 데이터를 반환하기 위해 매번 DB에서 데이터를 조회해야한다.
- DB에서 조회한 데이터를 API 반환 값으로 정제하기 위한 로직이 매번 수행된다.
# 피드 상세 페이지 조회 기능에 대한 로그
## 피드 상세 페이지(HTML)
[ee2f9ba3] viewController.feedPage(..)
[ee2f9ba3] viewController.feedPage(..) time=0ms
## 피드 데이터(Json)
[4eaac141] FeedController.getFeed(..)
[4eaac141] |-->FeedService.getFeed(..)
[4eaac141] | |-->CrudRepository.findById(..)
[4eaac141] | |<--CrudRepository.findById(..) time=1ms
[4eaac141] | |-->FeedImageRepository.findByFeed_Id(..)
[4eaac141] | |<--FeedImageRepository.findByFeed_Id(..) time=7ms
[4eaac141] |<--FeedService.getFeed(..) time=13ms
[4eaac141] FeedController.getFeed(..) time=45ms
## 피드 댓글 데이터(Json)
[098b7a18] CommentController.getFeedComments(..)
[098b7a18] |-->CommentService.getFeedComments(..)
[098b7a18] | |-->CommentRepository.findGroupComment(..)
[098b7a18] | |<--CommentRepository.findGroupComment(..) time=12ms
[098b7a18] | |-->CommentRepository.findBasicComment(..)
[098b7a18] | |<--CommentRepository.findBasicComment(..) time=6ms
[098b7a18] |<--CommentService.getFeedComments(..) time=23ms
[098b7a18] CommentController.getFeedComments(..) time=25ms
위와 같은 상황 때문에 피드 상세 페이지를 조회하는 요청이 많이 발생할 경우 DB에 많은 부하가 발생할 것으로 예상되었다.
해당 기능의 개선을 위해 레디스 캐시(Redis Cache) 기능을 아래와 같이 활용해보고자 하였다.
- 데이터 변경이 빈번할 것으로 예상되는 피드 댓글 데이터는 제외하고, 데이터 변경이 적을 것으로 예상되는 피드 데이터에 대해 캐시 기능을 적용할 것이다.
- 특정 피드아이디에 대한 피드 데이터를 캐시에 저장해둔다.
- 특정 피드아이디에 대한 피드 데이터가 캐시에 저장되어 있다면 캐시에 저장된 데이터를 반환한다. 만약 캐시에 저장되어 있지 않다면 피드 데이터를 생성하여 캐시에 저장한 후 데이터를 반환한다.
- 특정 피드 아이디에 대한 피드 데이터가 수정될 경우 캐시에 저장된 데이터를 갱신 또는 삭제한다.
구현 과정
Redis Cache 설정
레디스 캐시 기능을 사용하기 위해 @EnableCaching을 통해 캐시 기능을 활성화 했다.
그리고 레디스 캐시 기능을 실제로 수행할 RedisCacheManager 객체를 빈으로 등록했다.
redisCacheConfigMap() 내부를 살펴보면 cacheName이 feed인 캐시의 경우, 유효 시간을 30초로 지정했다.
피드 데이터는 feed라는 cachName으로 저장될 것이며 댓글 수, 조회 수 갱신을 위해 30초로 유효 시간을 지정했다.
@EnableCaching // 캐시 기능 활성화
@EnableRedisHttpSession(maxInactiveIntervalInSeconds = 300)
@Configuration
public class RedisConfig {
@Value("${spring.redis.host}")
private String redisHost;
@Value("${spring.redis.port}")
private String redisPort;
private ObjectMapper redisObjectMapper() {
// local date time 역직렬화 위한 추가 코드
ObjectMapper objectMapper = new ObjectMapper();
objectMapper.registerModule(new JavaTimeModule());
// LinkedHasmap cannot be cast to class DTO Object 에러 해결을 위한 코드
BasicPolymorphicTypeValidator polymorphicTypeValidator = BasicPolymorphicTypeValidator.builder()
.allowIfSubType(Object.class)
.build();
objectMapper.activateDefaultTyping(polymorphicTypeValidator, DefaultTyping.NON_FINAL);
return objectMapper;
}
private RedisCacheConfiguration redisCacheDefaultConfig() {
// Redis Cache 기본 설정
return RedisCacheConfiguration.defaultCacheConfig()
.computePrefixWith(CacheKeyPrefix.simple())
.disableCachingNullValues()
.entryTtl(Duration.ofSeconds(300))
.serializeKeysWith(SerializationPair.fromSerializer(new StringRedisSerializer()))
.serializeValuesWith(
SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer(redisObjectMapper())));
}
private Map<String, RedisCacheConfiguration> redisCacheConfigMap() {
// cacheName에 따른 Redis Cache 설정
Map<String, RedisCacheConfiguration> redisCacheConfigMap = new HashMap<>();
redisCacheConfigMap.put("feed", redisCacheDefaultConfig().entryTtl(Duration.ofSeconds(60)));
return redisCacheConfigMap;
}
@Bean
public RedisConnectionFactory redisConnectionFactory() {
RedisStandaloneConfiguration redisStandaloneConfiguration = new RedisStandaloneConfiguration(
redisHost, Integer.parseInt(redisPort));
return new LettuceConnectionFactory(redisStandaloneConfiguration);
}
@Bean
public RedisTemplate<?, ?> redisTemplate() {
RedisTemplate<?, ?> redisTemplate = new RedisTemplate<>();
redisTemplate.setConnectionFactory(redisConnectionFactory());
redisTemplate.setKeySerializer(new StringRedisSerializer());
redisTemplate.setValueSerializer(new GenericJackson2JsonRedisSerializer(redisObjectMapper()));
return redisTemplate;
}
@Bean
public RedisCacheManager redisCacheManager() {
return RedisCacheManager.RedisCacheManagerBuilder
.fromConnectionFactory(redisConnectionFactory())
.cacheDefaults(redisCacheDefaultConfig()) // 기본 캐시 설정
.withInitialCacheConfigurations(redisCacheConfigMap()) // cacheName에 따른 캐시 설정
.build();
}
@Bean
public ConfigureRedisAction configureRedisAction() {
return ConfigureRedisAction.NO_OP;
}
}
Redis Cache 기능 적용
캐시 기능이 적용될 FeedService 내부를 살펴보자.
FeedService.getFeed()는 특정 피드 데이터를 조회하는 메소드이다.
- @Cacheable을 적용하여 특정 피드 데이터가 캐시에 존재하지 않으면 메소드를 실행한 후 반환 값을 캐시에 저장하고, 캐시에 존재하면 메소드를 실행하지 않고 캐시의 데이터를 반환하도록 설정하였다.
- RedisCacheConfiguration을 통해 캐시의 유효 기간은 30초로 지정된다. 이것은 피드의 댓글 수, 조회 수를 주기적으로 갱신하기 위함이다.
FeedService.deleteFeed()는 특정 피드 데이터를 삭제하는 메소드이다.
- @CacheEvict를 적용하여 해당 메소드로 인해 삭제된 피드 데이터에 대한 캐시 데이터도 삭제되도록 설정하였다.
FeedService.modifiedFeed()는 특정 피드 데이터를 수정하는 메소드이다.
- @CachePut을 적용하여 해당 메소드로 인해 수정된 피드 데이터로 캐시 데이터가 갱신되도록 설정하였다.
FeedService.accuseFeed()는 특정 피드 데이터의 상태를 신고로 수정하는 메소드이다.
- @CachePut을 적용하여 상태 값이 변경된 피드 데이터로 캐시 데이터가 갱신되도록 설정하였다.
FeedService.changeFeedStatus()는 특정 피드 데이터의 상태 값을 변경하는 메소드이다.
- 변경 가능한 상태 중에 삭제가 있으므로 @CachePut이 아닌, @CacheEvict를 적용하여 상태가 변경된 피드 데이터에 대한 캐시 데이터가 삭제되도록 설정하였다.
// 특정 피드 조회
@Cacheable(value = "feed", key = "#feedId")
@Transactional
public FeedResponse getFeed(Long feedId) {
Feed findFeed = feedRepository.findById(feedId)
.orElseThrow(() -> new AlbumException(NOT_EXIST_FEED));
if (findFeed.getStatus().equals(DELETE)) {
throw new AlbumException(DELETED_FEED);
}
List<FeedImage> feedImages = feedImageRepository.findByFeed_Id(findFeed.getId());
findFeed.addHits();
return FeedResponse.createFeedResponse(findFeed, feedImages);
}
// 피드 삭제
@CacheEvict(value = "feed", key = "#feedId")
@Transactional
public FeedDto deleteFeed(Long feedId, MemberDto memberDto) {
// 로그인 여부 확인
if (memberDto == null) {
throw new AlbumException(REQUIRED_LOGIN);
}
// 목표 피드 데이터 조회
Feed findFeed = feedRepository.findById(feedId)
.orElseThrow(() -> new AlbumException(NOT_EXIST_FEED));
// 본인 또는 관리자 여부 확인
if (!findFeed.getMember().getUsername().equals(memberDto.getUsername())
&& !memberDto.getRole().equals(
MemberRole.ROLE_ADMIN)) {
throw new AlbumException(NO_AUTHORITY);
}
// Feed 상태 변경
findFeed.changeStatus(DELETE);
return FeedDto.from(findFeed);
}
// 피드 수정
@CachePut(value = "feed", key = "#feedModifiedForm.id")
@Transactional
public FeedResponse modifiedFeed(FeedModifiedForm feedModifiedForm, List<MultipartFile> imageFiles,
MemberDto memberDto) {
// 로그인 여부 확인
if (memberDto == null) {
throw new AlbumException(REQUIRED_LOGIN);
}
// 목표 피드 데이터 조회
Feed findFeed = feedRepository.findById(feedModifiedForm.getId())
.orElseThrow(() -> new AlbumException(NOT_EXIST_FEED));
// 본인 여부 확인
if (!findFeed.getMember().getUsername().equals(memberDto.getUsername())) {
throw new AlbumException(NO_AUTHORITY);
}
// Feed 데이터 수정
findFeed.modified(feedModifiedForm);
// 이전 FeedImage 데이터 삭제
feedImageRepository.deleteFeedImageByFeed_Id(findFeed.getId());
// 새로운 FeedImage 데이터 등록
List<Image> images = awsS3Manager.uploadImage(imageFiles);
List<FeedImage> feedImages = saveFeedImage(images, findFeed);
return FeedResponse.createFeedResponse(findFeed, feedImages);
}
// 피드 신고
@CachePut(value = "feed", key = "#feedId")
@Transactional
public FeedAccuseDto accuseFeed(Long feedId, FeedAccuseRequestForm feedAccuseRequestForm, MemberDto memberDto) {
// 로그인 여부 확인
if (memberDto == null) {
throw new AlbumException(REQUIRED_LOGIN);
}
// 신고 피드 조회 및 상태 변경
Feed findFeed = feedRepository.findById(feedId)
.orElseThrow(() -> new AlbumException(NOT_EXIST_FEED));
findFeed.changeStatus(ACCUSE);
// 신고자 조회
Member findMember = memberRepository.findByUsername(memberDto.getUsername())
.orElseThrow(() -> new AlbumException(NOT_EXIST_MEMBER));
// 신고 내역 저장
FeedAccuse savedFeedAccuse = feedAccuseRepository.save(
FeedAccuse.builder()
.content(feedAccuseRequestForm.getContent())
.member(findMember)
.feed(findFeed)
.build()
);
return FeedAccuseDto.from(savedFeedAccuse);
}
// 피드 상태 변경
@CacheEvict(value = "feed", key = "#feedChangeStatusForm.getId()")
@Transactional
public FeedDto changeFeedStatus(FeedChangeStatusForm feedChangeStatusForm) {
Feed findFeed = feedRepository.findById(feedChangeStatusForm.getId())
.orElseThrow(() -> new AlbumException(NOT_EXIST_FEED));
findFeed.changeStatus(feedChangeStatusForm.getFeedStatus());
return FeedDto.from(findFeed);
}
Redis Cache 적용 결과
이제 Redis Cache의 적용 결과를 알아보고자 한다.
동일한 피드에 대한 피드 상세 조회 기능을 아래와 같이 실행할 것이다.
- 첫 번째 피드 상세 조회
- 두 번째 피드 상세 조회
- 30초 후에 피드 상세 조회
- 피드 신고 후 상세 조회
- 피드 삭제 후 피드 상세 조회
# 첫 번째 피드 상세 조회
[7495b4d2] FeedController.getFeed(..)
[7495b4d2] |-->FeedService.getFeed(..)
[7495b4d2] | |-->CrudRepository.findById(..)
[7495b4d2] | |<--CrudRepository.findById(..) time=1ms
[7495b4d2] | |-->FeedImageRepository.findByFeed_Id(..)
[7495b4d2] | |<--FeedImageRepository.findByFeed_Id(..) time=6ms
[7495b4d2] |<--FeedService.getFeed(..) time=9ms
[7495b4d2] FeedController.getFeed(..) time=33ms
# 두 번째 피드 상세 조회
[c718716c] FeedController.getFeed(..)
[c718716c] FeedController.getFeed(..) time=11ms
# 30초 후 피드 상세 조회
[d2a7714d] FeedController.getFeed(..)
[d2a7714d] |-->FeedService.getFeed(..)
[d2a7714d] | |-->CrudRepository.findById(..)
[d2a7714d] | |<--CrudRepository.findById(..) time=1ms
[d2a7714d] | |-->FeedImageRepository.findByFeed_Id(..)
[d2a7714d] | |<--FeedImageRepository.findByFeed_Id(..) time=3ms
[d2a7714d] |<--FeedService.getFeed(..) time=8ms
[d2a7714d] FeedController.getFeed(..) time=26ms
# 피드 신고 후 피드 상세 조회
[a9949f51] FeedController.getFeed(..)
[a9949f51] FeedController.getFeed(..) time=1ms
# 삭제 후 피드 상세 조회
[0dba087e] FeedController.getFeed(..)
[0dba087e] |-->FeedService.getFeed(..)
[0dba087e] | |-->CrudRepository.findById(..)
[0dba087e] | |<--CrudRepository.findById(..) time=1ms
[0dba087e] |<X-FeedService.getFeed(..) time=3ms ex=com.maeng0830.album.common.exception.AlbumException: 삭제된 피드입니다.
[0dba087e] FeedController.getFeed(..) time=5ms ex=com.maeng0830.album.common.exception.AlbumException: 삭제된 피드입니다.
실행 결과는 다음과 같다.
- 첫 번째 피드 상세 조회 시에는 캐시에 저장된 데이터가 없기 때문에 FeedService.getFeed()가 실행되고 반환 값이 캐시에 저장된다.
- 두 번째 피드 상세 조회 시에는 캐시에 저장된 데이터가 있기 때문에 FeedService.getFeed()가 실행되지 않고, 캐시에 저장된 데이터가 반환된다.
- 30초 후 피드 상세 조회 시에는 유효 시간이 초과되어 캐시에 저장된 데이터가 삭제되었으므로, FeedService.getFeed()가 실행되고 반환 값이 캐시에 저장된다.
- 피드 신고 후 피드 상세 조회 시에는 피드가 신고 되었을 때 캐시에 저장된 데이터가 갱신되었으므로, FeedService.getFeed()가 실행되지 않고 캐시에 저장된 데이터가 반환된다.
- 피드 삭제 후 피드 상세 조회 시에는 피드를 삭제했을 때 캐시에 저장된 데이터가 삭제 되었으므로, FeedService.getFeed()가 실행된다. 해당 메소드는 예외 종료되어 캐시에 반환 값을 저장하지 않는다.
결과
- 피드 데이터에 대해 Redis Cache를 적용하여 DB 접근 횟수, 비즈니스 로직 구동 횟수를 줄일 수 있었다.