Skip to main content

Command Palette

Search for a command to run...

[Redis] Redis 캐시 적용하기 - 인기글 조회 적용

Updated
3 min read

지난번 진행했던 Redis 초기 세팅을 바탕으로 오늘은 홈페이지의 인기 게시물 조회 API에 캐시를 적용했다.

인기 게시물은 좋아요 수 기준으로 상위 10개를 불러온다. 실시간으로 데이터가 급격히 변하지 않지만 메인 페이지의 특성상 호출 빈도가 매우 높다. 매번 DB를 조회하는 대신 Redis 캐시를 사용해 성능을 개선했다.


1. RedisConfig 설정

@Configuration
@EnableCaching
public class RedisConfig {

    ...
    public static final String POPULAR_POSTS_CACHE = "popularPosts"; // 홈페이지 사진 수다 미리 보기

    ...
    private static final long POPULAR_POSTS_TTL_MINUTES = 10L;

    @Bean
    public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory connectionFactory) {
        RedisTemplate<String, Object> template = new RedisTemplate<>();
        template.setConnectionFactory(connectionFactory);
        template.setKeySerializer(new StringRedisSerializer());
        template.setValueSerializer(new GenericJackson2JsonRedisSerializer(redisObjectMapper()));
        return template;
    }

    @Bean
    public CacheManager cacheManager(RedisConnectionFactory connectionFactory) {
        RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig()
                .entryTtl(Duration.ofMinutes(DEFAULT_CACHE_TTL_MINUTES))
                .disableCachingNullValues()
                .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()))
                .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer(redisObjectMapper())));

        return RedisCacheManager.builder(connectionFactory)
                .cacheDefaults(config)
                ...
                .withCacheConfiguration(POPULAR_POSTS_CACHE, config.entryTtl(Duration.ofMinutes(POPULAR_POSTS_TTL_MINUTES)))
                .build();
    }

    private ObjectMapper redisObjectMapper() {
        ObjectMapper objectMapper = new ObjectMapper();
        objectMapper.registerModule(new JavaTimeModule());
        objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
        objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);

        BasicPolymorphicTypeValidator ptv = BasicPolymorphicTypeValidator.builder()
                .allowIfBaseType(Object.class)
                .allowIfSubType("com.finders.api")
                .build();
        objectMapper.activateDefaultTyping(ptv, ObjectMapper.DefaultTyping.EVERYTHING);
        return objectMapper;
    }
}
  • 인기 게시물 전용 캐시 이름과 TTL 10분 설정을 추가했다.

  • EVERYTHING 설정을 사용하되, allowIfSubType("com.finders.api")를 통해 우리 패키지 하위 클래스만 타입 정보를 유지하도록 화이트리스트를 적용했다.

  • 코드 리뷰 중에 EVERYTHING 대신 NON_FINAL을 사용해 구성을 더 단순하게 유지하는 것을 고려해 보라는 의견이 있었지만, 아래 트러블 슈팅에서 언급할 record 이슈로 인해 현재 설정을 유지하기로 했다.


2. PostCacheDTO

@Builder
public record PostCacheDTO(
        Long id,
        String title,
        Integer likeCount,
        Integer commentCount,
        String objectPath
) implements Serializable {
}
  • Post 엔티티가 PostImage를 참조하고, PostImage가 다시 Post를 참조하는 등 순환 참조 문제가 발생할 수 있고, 캐시에 불필요한 정보까지 저장되는 것을 방지하기 위해 전용 DTO를 생성했다.

  • Entity를 직접 캐싱하지 않고, 필요한 데이터만 담은 PostCacheDTO를 생성했다.


3. PostResponse

 // PostResponse
 public static PostPreviewDTO fromCache(PostCacheDTO cache, boolean isLiked, String fullImageUrl) {
            return PostPreviewDTO.builder()
                    .postId(cache.id())
                    .title(cache.title())
                    .likeCount(cache.likeCount())
                    .commentCount(cache.commentCount())
                    .isLiked(isLiked)
                    .image(new PostImageResDTO(fullImageUrl, null, null))
                    .build();
        }
  • 캐시 DTO를 응답 DTO로 변환했다.

4. PostRepository

// PostRepository
@Cacheable(value = "popularPosts", key = "'home_top10'")
    public List<PostCacheDTO> findTop10PopularPosts() {
        List<Long> ids = queryFactory
                .select(post.id)
                .from(post)
                .where(post.status.eq(CommunityStatus.ACTIVE))
                .orderBy(post.likeCount.desc(), post.createdAt.desc()) // 좋아요 순, 같으면 최신순
                .limit(POPULAR_POSTS_LIMIT)
                .fetch();

        if (ids.isEmpty()) return List.of();

        List<Post> posts = queryFactory
                .selectFrom(post)
                .leftJoin(post.postImageList).fetchJoin()
                .where(post.id.in(ids))
                .orderBy(post.likeCount.desc(), post.createdAt.desc())
                .fetch();

        return posts.stream()
                .map(p -> PostCacheDTO.builder()
                        .id(p.getId())
                        .title(p.getTitle())
                        .likeCount(p.getLikeCount())
                        .commentCount(p.getCommentCount())
                        .objectPath(p.getPostImageList().isEmpty() ? null : p.getPostImageList().get(0).getObjectPath())
                        .build())
                .toList();
    }
  • @Cacheable을 적용하여 Redis에 데이터가 있으면 메서드 로직을 타지 않고 바로 반환한다.

  • 쿼리 튜닝을 위해 ID 목록을 먼저 뽑고 IN 절로 데이터를 갖고 오는 방식을 사용했다.

  • 조회 성능 개선이 목적이므로, 서비스가 아닌 조회 전용 Repository 메서드에 캐시를 적용했다. 해당 메서드는 상태 변경 로직이 없고, 캐시 무효화 시점도 명확해 안전하다고 판단했다.

  • 인기 게시물은 모든 사용자에게 동일한 결과를 반환하므로 사용자 ID를 포함하지 않은 고정 캐시 키(home_top10)를 사용했다.


5. PostQueryServiceImpl

// PostQueryServiceImpl
@Override
    public PostResponse.PostPreviewListDTO getPopularPosts(Long memberId) {
        List<PostCacheDTO> cachedPosts = postQueryRepository.findTop10PopularPosts();

        Set<Long> likedPostIds;
        if (memberId != null && !cachedPosts.isEmpty()) {
            List<Long> postIds = cachedPosts.stream().map(PostCacheDTO::id).toList();
            likedPostIds = postLikeRepository.findLikedPostIdsByMemberAndPostIds(memberId, postIds);
        } else {
            likedPostIds = java.util.Collections.emptySet();
        }

        List<PostResponse.PostPreviewDTO> previews = cachedPosts.stream()
                .map(dto -> {
                    boolean isLiked = likedPostIds.contains(dto.id());

                    String fullImageUrl = (dto.objectPath() != null)
                            ? storageService.getPublicUrl(dto.objectPath())
                            : null;

                    return PostResponse.PostPreviewDTO.fromCache(dto, isLiked, fullImageUrl);
                })
                .toList();
        return PostResponse.PostPreviewListDTO.from(previews);
    }
  • 전체 리스트는 캐시에서 가져오되, 내가 이 글에 좋아요를 눌렀는가와 같은 개인화된 정보는 서비스 레이어에서 동적으로 결합하도록 설계했다. 캐시의 효율성과 개인화 정보를 동시에 잡은 방식이다.

6. 트러블 슈팅

오늘 가장 시간을 많이 썼던 부분은 record 타입의 직렬화 충돌 문제였다.

Could not read JSON:Unexpected token (START_ARRAY) // 에러 발생
  • 코드 리뷰 과정에서 EVERYTHING 대신 NON_FINAL을 사용해 구성을 단순화하자는 의견이 있었다. 그러나 Java의 record 타입은 모든 필드가 final이기 때문에 NON_FINAL 설정에서는 타입 정보가 누락되어 역직렬화 오류가 발생했다. 해당 오류로 인해 EVERYTHING은 유지하되, allowIfSubType("com.finders.api")를 통해 패키지 화이트리스트를 적용했다.

  • 설정을 변경한 후에는 반드시 docker exec -it [컨테이너명] redis-cli flushall 을 통해 기존에 잘못된 데이터를 날려 줘야 한다. 설정 변경 후 기존에 잘못 직렬화된 데이터가 남아 있어 로컬 환경에서만 flushall로 캐시를 초기화했다.


오늘의 요약

  • 캐시용 DTO를 별도로 설계하여 필요한 정보만 효율적으로 저장했다.

  • 설정 변경 후에는 기존 캐시 데이터를 꼭 날리자.

More from this blog

[Elasticsearch] Elasticsearch 기본용어와 CRUD 명령어

-elastic ▶ 들어가며 이번 글에서는 Elasticsearch를 공부하면서 가장 먼저 익혀야 하는 기본 용어를 정리하고,직접 코드를 쳐가며 CRUD(Create / Read / Update / Delete) 명령어에 익숙해지는 시간을 가져보려고 한다. Elasticsearch는 처음 보면 생소한 용어가 많아서 막막할 수 있는데,사실 구조적으로는 우리가 익숙한 MySQL과 닮은 부분이 굉장히 많다. 둘 다 데이터베이스라는 큰 틀 안에서 데...

Feb 17, 20264 min read

Jpa N+1 문제, 우리는 이렇게 잡았다 — 1:1 문의 Api 실전 최적화기

코드 리뷰 한 줄에서 시작된 쿼리 최적화 여정 1. 시작 — "일단 돌아가게 만들자" Finders 프로젝트에서 1:1 문의(Inquiry) API를 맡았다. 현상소에 문의를 남기고, 답변을 받고, 목록을 조회하는 — 평범한 CRUD다. "JPA 쓰면 쿼리 안 짜도 되는 거 아니야?" 솔직히 처음엔 그렇게 생각했다. JpaRepository에 findAll, findById 쓰면 끝이니까. // 첫 번째 버전의 목록 조회 (QueryDS...

Feb 11, 20268 min read

[모니터링] Sentry 도입부터 Discord 에러 알림까지 — 서버 감시 시스템 구축기

"서버 죽었는데 아무도 몰랐다"에서 "에러나면 1분 안에 안다"까지 1. 모니터링을 시작한 계기 프론트엔드: "API 안 되는데요?"백엔드: "엥? 언제부터요?"프론트엔드: "...2시간 전부터요?"백엔드: 😱 어느 날 서버가 죽어있었는데 아무도 몰랐다. 그날 이후, 모니터링 시스템 구축을 결심했다. (Issue #102) 2. 모니터링 도구 비교: 뭘 쓸까? 처음에는 여러 도구를 비교했다. 도구무료 티어장점단점 Sent...

Feb 11, 20268 min read

[CI/CD] 수동 태그에서 자동 릴리즈까지 — Git Flow와 Auto Release

🚀 우리 auto-release.yml 바로 보러가기 → 이 글에서 설명하는 워크플로우의 전체 코드를 바로 확인할 수 있다! main에 머지만 하면 버전 태그부터 릴리즈 노트까지 알아서 생긴다 1. 우리의 Git 전략: Git Flow (경량 버전) Finders 프로젝트는 Git Flow 전략을 사용하고 있다. 다만 hotfix나 release 브랜치 없이, 조금 가볍게 운영한다. main ← 운영 서버 (prod) 배포 브...

Feb 11, 20265 min read

[ci/cd] 대학생 팀의 배포 파이프라인 진화기

PR 하나면 끝나는 무중단 배포까지, 삽질의 기록 1. 시작은 단순했다 "서버에 올려야 하는데... 어떻게 하지?" Finders 프로젝트를 시작했을 때, 배포라는 걸 해본 적이 없었다. 그래서 처음에는 이렇게 했다. 1. SSH로 서버 접속 2. git pull 3. ./gradlew build 4. java -jar app.jar 당연히 문제가 생겼다. 빌드하는 동안 서버가 꺼져있음 (프론트: "API 왜 안 돼요?" 🔥) 빌...

Feb 11, 20267 min read
F

Finders Tech Blog

16 posts