From 54c0a29d7f3695fb0e8e1f00085cfd8d032ac135 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=EB=AF=B8?= Date: Wed, 29 Jul 2026 03:15:41 +0900 Subject: [PATCH] =?UTF-8?q?perf:=20=EA=B3=B5=EA=B0=9C=20=EC=BD=94=EC=8A=A4?= =?UTF-8?q?=20=EC=A1=B0=ED=9A=8C=20API=EC=97=90=20Redis=20=EC=BA=90?= =?UTF-8?q?=EC=8B=B1=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 부하테스트(초당 20건)에서 p95 9.3초, 에러율 71%로 KPI 미달성. 응답시간이 구간마다 계속 치솟고 대기 요청이 눈덩이처럼 쌓이는 패턴 확인 — 리전 간 DB 왕복(회당 약 60~90ms)이 동시 요청마다 반복되며 스레드/커넥션을 묶어두는 것이 원인으로 진단됨. 읽기 전용 3개 API(전체 페이지 수/마라톤/추천 목록)에 @Cacheable 적용, TTL 1분. total-page-count는 전체 유저 공통이라 키 없이 캐싱하고, 나머지 둘은 응답에 유저별 스크랩 여부가 섞여있어 userId를 캐시 키에 포함함(실 트래픽에서는 이번 부하테스트만큼 히트율이 높지 않을 수 있음). RedisConfig에 명시적 CacheManager 빈 추가 — GenericJackson2JsonRedisSerializer로 DTO 직렬화, TTL 1분으로 캐시 무효화 리스크를 제한. --- .../server/config/redis/RedisConfig.java | 25 +++++++++++++++++++ .../service/PublicCourseService.java | 9 +++++++ 2 files changed, 34 insertions(+) diff --git a/src/main/java/org/runnect/server/config/redis/RedisConfig.java b/src/main/java/org/runnect/server/config/redis/RedisConfig.java index 7cd9b7c..8491f0b 100644 --- a/src/main/java/org/runnect/server/config/redis/RedisConfig.java +++ b/src/main/java/org/runnect/server/config/redis/RedisConfig.java @@ -1,13 +1,21 @@ package org.runnect.server.config.redis; import org.springframework.beans.factory.annotation.Value; +import org.springframework.cache.CacheManager; import org.springframework.cache.annotation.EnableCaching; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.cache.RedisCacheConfiguration; +import org.springframework.data.redis.cache.RedisCacheManager; import org.springframework.data.redis.connection.RedisConnectionFactory; import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; import org.springframework.data.redis.core.ListOperations; import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.RedisSerializationContext; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +import java.time.Duration; @EnableCaching @Configuration @@ -29,5 +37,22 @@ public ListOperations listOperations( RedisTemplate redisStringTemplate) { return redisStringTemplate.opsForList(); } + + // 공개 코스 조회 API(전체 페이지 수/마라톤/추천 목록) 응답 캐싱용. + // TTL을 짧게(1분) 두어, 코스가 추가/삭제돼도 오래된 응답이 너무 길게 남지 않도록 함. + @Bean + public CacheManager cacheManager(RedisConnectionFactory redisConnectionFactory) { + RedisCacheConfiguration cacheConfiguration = RedisCacheConfiguration.defaultCacheConfig() + .entryTtl(Duration.ofMinutes(1)) + .disableCachingNullValues() + .serializeKeysWith(RedisSerializationContext.SerializationPair + .fromSerializer(new StringRedisSerializer())) + .serializeValuesWith(RedisSerializationContext.SerializationPair + .fromSerializer(new GenericJackson2JsonRedisSerializer())); + + return RedisCacheManager.builder(redisConnectionFactory) + .cacheDefaults(cacheConfiguration) + .build(); + } } diff --git a/src/main/java/org/runnect/server/publicCourse/service/PublicCourseService.java b/src/main/java/org/runnect/server/publicCourse/service/PublicCourseService.java index 0fb8248..9d6956a 100644 --- a/src/main/java/org/runnect/server/publicCourse/service/PublicCourseService.java +++ b/src/main/java/org/runnect/server/publicCourse/service/PublicCourseService.java @@ -38,6 +38,7 @@ import org.runnect.server.user.exception.userException.NotFoundUserException; import org.runnect.server.user.repository.UserRepository; import org.springframework.beans.factory.annotation.Value; +import org.springframework.cache.annotation.Cacheable; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Sort; @@ -63,6 +64,8 @@ private void setMARATHON_PUBLIC_COURSE_IDS(String MARATHON_PUBLIC_COURSE_ID) { .map(Long::parseLong).collect(Collectors.toList()); } + // 전체 유저에게 동일한 값이라 캐시 키에 별도 파라미터가 필요 없음 + @Cacheable(value = "publicCourseTotalPageCount") public GetPublicCourseTotalPageCountResponseDto getPublicCourseTotalPageCount() { Long totalPublicCourseCount = publicCourseRepository.countBy(); if (totalPublicCourseCount % PAGE_SIZE != 0) { @@ -71,6 +74,10 @@ public GetPublicCourseTotalPageCountResponseDto getPublicCourseTotalPageCount() return GetPublicCourseTotalPageCountResponseDto.of(totalPublicCourseCount / PAGE_SIZE); } + // 응답에 유저별 스크랩 여부(isScrap)가 섞여있어 userId를 캐시 키에 포함함 + // (그만큼 유저별로 캐시가 나뉘어서, 여러 유저가 섞인 실트래픽에서는 히트율이 + // 이번 부하테스트(단일 유저 반복 호출)만큼 극적이진 않을 수 있음) + @Cacheable(value = "marathonPublicCourse", key = "#userId") public GetMarathonPublicCourseResponseDto getMarathonPublicCourse(Long userId) { //1. 받은 userId가 유저가 존재하는지 확인 RunnectUser user = userRepository.findById(userId) @@ -150,6 +157,8 @@ public SearchPublicCourseResponseDto searchPublicCourse(Long userId, String keyw } + // marathonPublicCourse와 동일한 이유로 userId를 캐시 키에 포함 + @Cacheable(value = "recommendPublicCourse", key = "#userId + '_' + #pageNo + '_' + #sort") public RecommendPublicCourseResponseDto recommendPublicCourse(Long userId, Integer pageNo, String sort) { //1. 받은 userId가 유저가 존재하는지 확인 RunnectUser user = userRepository.findById(userId)