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

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

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

---

#### 1\. RedisConfig 설정

```plaintext
@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

```plaintext
@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

```plaintext
 // 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

```plaintext
// 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

```plaintext
// 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 타입의 직렬화 충돌 문제였다.

```plaintext
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를 별도로 설계하여 필요한 정보만 효율적으로 저장했다.
    
* 설정 변경 후에는 기존 캐시 데이터를 꼭 날리자.
