내 프로젝트에 좋아요(Like)기능 캐싱하기

고리·2024년 2월 1일
0

Server

목록 보기
12/12
post-thumbnail

최근 진행하는 프로젝트에는 [좋아요] 기능이 있다. 이 기능은 사용자가 게시글을 조회하면 게시글에 포함된 좋아요 수를 계산해 응답에 포함시켜 전달하는 방식으로 구현되어 있다. 코드로 보면 다음과 같다.

// 좋아요
@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

CacheManager를 선택해야 했는데 팀 재정 상 RAM 크키가 큰 EC2 인스턴스를 사용할 수 없었다. 그래서 메모리 사용량이 적은 캐시 솔루션인 EhCache나 Memcached를 사용하는 것이 적절했으나 TTL 에 기반한 캐시 삭제가 아니라 빈도수, 최근 사용 시간 등의 다양한 삭제 정책이 필요했기 때문에 RedisCache를 선택했다.

이 외에도 다양한 자료구조 및 트랜잭션 지원, 풍부한 레퍼런스, 성능(링크) 등의 장정이 있었으나 RAM 소비를 가장 많이 줄일 수 있는 삭제 정책이 주요 선택 이유가 되겠다.


Cache Configuration

@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

이제 캐시에 데이터가 없을 경우에는 기존의 로직을 실행한 후에 캐시에 데이터를 추가하고, 캐시에 데이터가 있으면 캐시의 데이터를 반환할 수 있도록 메서드에 @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를 사용한다면 문제 상황에 쉽게 대응할 수 있는 확장성 있는 설계가 될 것으로 예상된다.


CacheEvict

이제 캐시를 적절한 시점에 제거해야 한다. 이 적절한 시점이 언제일까? 소설에 좋아요 갯수는 실시간으로 확인될 필요가 없다. 사용자 수에 따라 다르겠지만 이제 막 배포한 상태인 현재로서는 1시간 혹은 좋아요가 눌렸을 때만 제거가 되어도 충분하단 생각이다. 그렇다면 이렇게 코드를 작성해보자

@CacheEvict(cacheNames = "novelLikeCnt", key = "#novelId")
public void setNovelLike(Long novelId, Long authorId){...}

public void deleteNovelLike(Long novelId, Long authorId){...}

Test

jmeter를 사용해서 테스트 해보자

Before

After

안하는게 더 빠른데 사실 로컬에서 redis와 h2를 사용하는 중이고 현재 test data의 size가 작기 때문에 그렇다(머쓱).


참고 문서
스프링공식문서 Cache
망나니 개발자 Cache

profile
Back-End Developer

0개의 댓글