최근 진행하는 프로젝트에는 [좋아요] 기능이 있다. 이 기능은 사용자가 게시글을 조회하면 게시글에 포함된 좋아요 수를 계산해 응답에 포함시켜 전달하는 방식으로 구현되어 있다. 코드로 보면 다음과 같다.
// 좋아요
@Override
public NovelInfo getNovelInfo(Long novelId) {
Novel novel = entityService.getNovel(novelId);
return NovelInfo.builder()
.title(novel.getTitle())
.createdAuthor(novel.getMainAuthor())
.genre(novel.getGenre())
.hashtag(novel.getHashtags())
.joinedAuthorCnt(authorityRepository.countAllByNovel(novel))
.commentCnt(commentRepository.countAllByNovel(novel))
.likeCnt(novelLikeRepository.countAllByNovel(novel)) // 이 부분
.build();
}
@Repository
public interface NovelLikeRepository extends JpaRepository<NovelLike, Long> {
Integer countAllByNovel(Novel novel);
}
다른 Cnt 조회 방식도 마찬가지지만 이번 포스팅은 [좋아요] 기능 리펙토링을 위해 작성했다.
소설에 대한 좋아요 갯수를 구하는 요청은 빈번하게 발생한다. 모든 사용자의 요청에 대해서 같은 소설의 좋아요 갯수를 매번 계산하는 것은 서버에 많은 부담을 줄 뿐더러. 이 기능은 데이터의 실시간성을 어느정도 포기하고, 성능과 속도를 가져갈 필요가 있다.
그렇다면 무엇이 필요할까? 내가 생각한 정답은 캐시(Cache) 였다. 마침 스프링은 캐시 관련 기능을 추상화하여 편리하게 개발할 수 있도록 지원하고 있었다. 이를 사용하면 CacheProvider(ex: redis, caffeine ...) 에 종속되지 않고 어플리케이션 코드를 작성할 수 있다.
CacheManager를 선택해야 했는데 팀 재정 상 RAM 크키가 큰 EC2 인스턴스를 사용할 수 없었다. 그래서 메모리 사용량이 적은 캐시 솔루션인 EhCache나 Memcached를 사용하는 것이 적절했으나 TTL 에 기반한 캐시 삭제가 아니라 빈도수, 최근 사용 시간 등의 다양한 삭제 정책이 필요했기 때문에 RedisCache를 선택했다.
이 외에도 다양한 자료구조 및 트랜잭션 지원, 풍부한 레퍼런스, 성능(링크) 등의 장정이 있었으나 RAM 소비를 가장 많이 줄일 수 있는 삭제 정책이 주요 선택 이유가 되겠다.
@Configuration
@EnableCaching
@EnableJpaAuditing
public class GlobalConfig {
}
Spring에서 @Cacheable
과 같은 어노테이션 기반의 캐시 기능을 사용하기 위해 @EnableCaching
어노테이션을 설정 클래스에 추가하자.
@Configuration
@EnableRedisRepositories
public class RedisConfig {
@Value("${spring.data.redis.host}")
private String redisHost;
@Value("${spring.data.redis.port}")
private int redisPort;
@Bean
public RedisConnectionFactory redisConnectionFactory() {
return new LettuceConnectionFactory(redisHost, redisPort);
}
@Bean
public RedisCacheManager redisCacheManager() {
RedisCacheConfiguration redisCacheConfiguration = RedisCacheConfiguration.defaultCacheConfig()
.serializeKeysWith(RedisSerializationContext
.SerializationPair.fromSerializer(new StringRedisSerializer()))
.serializeValuesWith(RedisSerializationContext
.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer()))
.entryTtl(Duration.ofHours(1));
return RedisCacheManager
.RedisCacheManagerBuilder
.fromConnectionFactory(redisConnectionFactory())
.cacheDefaults(redisCacheConfiguration)
.build();
}
}
이제 캐시를 관리해줄 CacheManager를 빈으로 등록하자.
이제 캐시에 데이터가 없을 경우에는 기존의 로직을 실행한 후에 캐시에 데이터를 추가하고, 캐시에 데이터가 있으면 캐시의 데이터를 반환할 수 있도록 메서드에 @Cacheable
어노테이션을 추가하자.
public interface NovelLikeJpaRepository extends JpaRepository<NovelLike, Long> {
Integer countAllByNovel(Novel novel);
}
@Repository
public interface NovelLikeRepository {
NovelLike save(NovelLike novelLike);
void delete(NovelLike novelLike);
Integer countAllByNovel(Novel novel);
NovelLike findAnyByNovelAndAuthor(Novel novel, Author author);
}
@Repository
@RequiredArgsConstructor
public class NovelLikeRepositoryImpl implements NovelLikeRepository {
private final NovelLikeJpaRepository novelLikeJpaRepository;
@Override
public NovelLike save(NovelLike novelLike) {
return novelLikeJpaRepository.save(novelLike);
}
@Override
public void delete(NovelLike novelLike) {
novelLikeJpaRepository.delete(novelLike);
}
@Override
@Cacheable(cacheNames = "novelLikeCnt", key = "#novel.id")
public Integer countAllByNovel(Novel novel) {
return novelLikeJpaRepository.countAllByNovel(novel);
}
}
캐시는 기본적으로 캐시 이름 하위에 key-value 형태로 데이터를 저장한다. 위의 예제에서는 novelLikeCnt 캐시의 이름이고, key 값을 novel 객체의 id로 사용하고 싶기 때문에 위처럼 #novel.id
로 하위 속성에 접근했다.
코드를 보면 Repository의 모양이 변한 것을 확인할 수 있다.
스프링 프레임워크는 인터페이스 메서드에
@Cache
어노테이션을 사용하는 것을 권하지 않는다. 인터페이스 메서드에@Cache
어노테이션을 사용하면 구현 클래스에서 해당 메서드를 구현할 때 어노테이션을 고려해야 하고, 해당 인터페이스를 사용하는 구현 클래스들도 캐시 기능에 대한 의존성이 높아지기 때문이다.또한 인터페이스와 구현 클래스를 분리하면 인터페이스를 의존하는 클래스들은 구현을 신경쓰지 않아도 되고,
JpaRepository
를 확장해서 사용한다면 후에JpaRepository
만으로 해결 불가능한 로직이 필요했을 때에 대응하기 어렵지만 위처럼 JpaRepository를 사용한다면 문제 상황에 쉽게 대응할 수 있는 확장성 있는 설계가 될 것으로 예상된다.
이제 캐시를 적절한 시점에 제거해야 한다. 이 적절한 시점이 언제일까? 소설에 좋아요 갯수는 실시간으로 확인될 필요가 없다. 사용자 수에 따라 다르겠지만 이제 막 배포한 상태인 현재로서는 1시간 혹은 좋아요가 눌렸을 때만 제거가 되어도 충분하단 생각이다. 그렇다면 이렇게 코드를 작성해보자
@CacheEvict(cacheNames = "novelLikeCnt", key = "#novelId")
public void setNovelLike(Long novelId, Long authorId){...}
public void deleteNovelLike(Long novelId, Long authorId){...}
jmeter를 사용해서 테스트 해보자
안하는게 더 빠른데 사실 로컬에서 redis와 h2를 사용하는 중이고 현재 test data의 size가 작기 때문에 그렇다(머쓱).
참고 문서
스프링공식문서 Cache
망나니 개발자 Cache