From 8a77b75825367abb80a5a3a178bbae5ec9ddbde4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=95=88=ED=9B=88=EA=B8=B0?= Date: Thu, 29 Jan 2026 19:25:50 +0900 Subject: [PATCH 1/6] =?UTF-8?q?:sparkles:Feat:=20Redis=20=EC=BA=90?= =?UTF-8?q?=EC=8B=9C=20=EB=8F=84=EC=9E=85=EC=9D=84=20=EC=9C=84=ED=95=9C=20?= =?UTF-8?q?=EB=8F=84=EC=BB=A4=20=EC=BB=B4=ED=8F=AC=EC=A6=88=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=20=EC=84=A4=EC=A0=95=20=EB=B0=8F=20=EC=84=9C=EB=B8=8C?= =?UTF-8?q?=EB=AA=A8=EB=93=88=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 3 +++ src/main/java/com/be/sportizebe/SportizeBeApplication.java | 2 ++ src/main/resources | 2 +- 3 files changed, 6 insertions(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index f042a27..d9b8ae0 100644 --- a/build.gradle +++ b/build.gradle @@ -58,6 +58,9 @@ dependencies { // JTS implementation 'org.locationtech.jts:jts-core:1.19.0' + // redis + implementation "org.springframework.boot:spring-boot-starter-data-redis" + implementation "org.springframework.boot:spring-boot-starter-cache" } tasks.named('test') { diff --git a/src/main/java/com/be/sportizebe/SportizeBeApplication.java b/src/main/java/com/be/sportizebe/SportizeBeApplication.java index 56556ae..4eb36dd 100644 --- a/src/main/java/com/be/sportizebe/SportizeBeApplication.java +++ b/src/main/java/com/be/sportizebe/SportizeBeApplication.java @@ -2,8 +2,10 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.cache.annotation.EnableCaching; import org.springframework.data.jpa.repository.config.EnableJpaAuditing; +@EnableCaching @EnableJpaAuditing @SpringBootApplication public class SportizeBeApplication { diff --git a/src/main/resources b/src/main/resources index 07ba8ff..a39d2f4 160000 --- a/src/main/resources +++ b/src/main/resources @@ -1 +1 @@ -Subproject commit 07ba8ffcf3fa141b112f56bb2937e24a66eab6c0 +Subproject commit a39d2f499313e65e9720ffd51b31988c31566dbe From d39286e01bcbca373b857c010a716ac5f0db0a90 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=95=88=ED=9B=88=EA=B8=B0?= Date: Sat, 31 Jan 2026 16:24:36 +0900 Subject: [PATCH 2/6] =?UTF-8?q?:sparkles:Feat:=20=EC=B2=B4=EC=9C=A1?= =?UTF-8?q?=EC=8B=9C=EC=84=A4=20=EC=A1=B0=ED=9A=8C=20Redis=20=EC=BA=90?= =?UTF-8?q?=EC=8B=9C=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../be/sportizebe/SportizeBeApplication.java | 2 - .../domain/facility/dto/CacheKeyProvider.java | 5 ++ .../dto/request/FacilityMarkerRequest.java | 15 ++++- .../dto/request/FacilityNearRequest.java | 16 ++++- .../service/SportsFacilityService.java | 30 ++++----- .../global/config/RedisCacheConfig.java | 63 +++++++++++++++++++ 6 files changed, 106 insertions(+), 25 deletions(-) create mode 100644 src/main/java/com/be/sportizebe/domain/facility/dto/CacheKeyProvider.java create mode 100644 src/main/java/com/be/sportizebe/global/config/RedisCacheConfig.java diff --git a/src/main/java/com/be/sportizebe/SportizeBeApplication.java b/src/main/java/com/be/sportizebe/SportizeBeApplication.java index 4eb36dd..56556ae 100644 --- a/src/main/java/com/be/sportizebe/SportizeBeApplication.java +++ b/src/main/java/com/be/sportizebe/SportizeBeApplication.java @@ -2,10 +2,8 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; -import org.springframework.cache.annotation.EnableCaching; import org.springframework.data.jpa.repository.config.EnableJpaAuditing; -@EnableCaching @EnableJpaAuditing @SpringBootApplication public class SportizeBeApplication { diff --git a/src/main/java/com/be/sportizebe/domain/facility/dto/CacheKeyProvider.java b/src/main/java/com/be/sportizebe/domain/facility/dto/CacheKeyProvider.java new file mode 100644 index 0000000..ec60438 --- /dev/null +++ b/src/main/java/com/be/sportizebe/domain/facility/dto/CacheKeyProvider.java @@ -0,0 +1,5 @@ +package com.be.sportizebe.domain.facility.dto; + +public interface CacheKeyProvider { + String generateCacheKey(); +} diff --git a/src/main/java/com/be/sportizebe/domain/facility/dto/request/FacilityMarkerRequest.java b/src/main/java/com/be/sportizebe/domain/facility/dto/request/FacilityMarkerRequest.java index 4b0ea85..92932be 100644 --- a/src/main/java/com/be/sportizebe/domain/facility/dto/request/FacilityMarkerRequest.java +++ b/src/main/java/com/be/sportizebe/domain/facility/dto/request/FacilityMarkerRequest.java @@ -1,6 +1,7 @@ // src/main/java/com/be/sportizebe/domain/facility/dto/request/FacilityMarkerRequest.java package com.be.sportizebe.domain.facility.dto.request; +import com.be.sportizebe.domain.facility.dto.CacheKeyProvider; import com.be.sportizebe.domain.facility.entity.FacilityType; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.*; @@ -9,7 +10,7 @@ @Getter @Setter -public class FacilityMarkerRequest { +public class FacilityMarkerRequest implements CacheKeyProvider { @Schema(description = "지도 중심 위도", example = "37.2869", requiredMode = Schema.RequiredMode.REQUIRED) @NotNull(message = "지도 중심 위도(centerLat)는 필수입니다") @@ -35,4 +36,14 @@ public class FacilityMarkerRequest { @Schema(description = "종목 필터(선택)", example = "SOCCER", nullable = true) private FacilityType type; -} \ No newline at end of file + + @Override + public String generateCacheKey() { + double gridLat = Math.round(this.lat * 10000) / 10000.0; + double gridLng = Math.round(this.lng * 10000) / 10000.0; + String typeName = (type == null) ? "ALL" : type.name(); + + return String.format("%.4f:%.4f:%d:%d:%s", + gridLat, gridLng, radiusM, limit, typeName); + } +} diff --git a/src/main/java/com/be/sportizebe/domain/facility/dto/request/FacilityNearRequest.java b/src/main/java/com/be/sportizebe/domain/facility/dto/request/FacilityNearRequest.java index 6408c24..32d6669 100644 --- a/src/main/java/com/be/sportizebe/domain/facility/dto/request/FacilityNearRequest.java +++ b/src/main/java/com/be/sportizebe/domain/facility/dto/request/FacilityNearRequest.java @@ -1,6 +1,7 @@ // src/main/java/com/be/sportizebe/domain/facility/dto/request/FacilityNearRequest.java package com.be.sportizebe.domain.facility.dto.request; +import com.be.sportizebe.domain.facility.dto.CacheKeyProvider; import com.be.sportizebe.domain.facility.entity.FacilityType; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.*; @@ -9,7 +10,7 @@ @Getter @Setter -public class FacilityNearRequest { +public class FacilityNearRequest implements CacheKeyProvider { @Schema(description = "위도", example = "37.2662", requiredMode = Schema.RequiredMode.REQUIRED) @NotNull(message = "위도(lat)는 필수입니다") @@ -35,4 +36,15 @@ public class FacilityNearRequest { @Schema(description = "종목 필터(선택)", example = "BASKETBALL", nullable = true) private FacilityType type; -} \ No newline at end of file + + @Override + public String generateCacheKey() { + // 소수점 4자리 반올림 (약 11m 격자) + double gridLat = Math.round(this.lat * 10000) / 10000.0; + double gridLng = Math.round(this.lng * 10000) / 10000.0; + String typeName = (type == null) ? "ALL" : type.name(); + + return String.format("%.4f:%.4f:%d:%d:%s", + gridLat, gridLng, radiusM, limit, typeName); + } +} diff --git a/src/main/java/com/be/sportizebe/domain/facility/service/SportsFacilityService.java b/src/main/java/com/be/sportizebe/domain/facility/service/SportsFacilityService.java index cd11eab..33a1e9c 100644 --- a/src/main/java/com/be/sportizebe/domain/facility/service/SportsFacilityService.java +++ b/src/main/java/com/be/sportizebe/domain/facility/service/SportsFacilityService.java @@ -7,6 +7,7 @@ import com.be.sportizebe.domain.facility.mapper.FacilityMapper; import com.be.sportizebe.domain.facility.repository.SportsFacilityRepository; import lombok.RequiredArgsConstructor; +import org.springframework.cache.annotation.Cacheable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -19,33 +20,24 @@ public class SportsFacilityService { private final SportsFacilityRepository sportsFacilityRepository; + @Cacheable(cacheNames = "facilityNear", key = "#root.args[0].generateCacheKey()") public List getNear(FacilityNearRequest request) { String type = (request.getType() == null) ? null : request.getType().name(); - var list = sportsFacilityRepository.findNear( - request.getLat(), - request.getLng(), - request.getRadiusM(), - request.getLimit(), - type - ); - - return list.stream() + return sportsFacilityRepository.findNear( + request.getLat(), request.getLng(), request.getRadiusM(), request.getLimit(), type + ).stream() .map(FacilityMapper::toNearResponse) .toList(); } - public List getMarkers(FacilityMarkerRequest request) { - String type = (request.getType() == null) ? null : request.getType().name(); // ✅ enum -> String - var list = sportsFacilityRepository.findMarkersNear( - request.getLat(), - request.getLng(), - request.getRadiusM(), - request.getLimit(), - type - ); + @Cacheable(cacheNames = "facilityMarkers", key = "#root.args[0].generateCacheKey()") + public List getMarkers(FacilityMarkerRequest request) { + String type = (request.getType() == null) ? null : request.getType().name(); - return list.stream() + return sportsFacilityRepository.findMarkersNear( + request.getLat(), request.getLng(), request.getRadiusM(), request.getLimit(), type + ).stream() .map(FacilityMapper::toMarkerResponse) .toList(); } diff --git a/src/main/java/com/be/sportizebe/global/config/RedisCacheConfig.java b/src/main/java/com/be/sportizebe/global/config/RedisCacheConfig.java new file mode 100644 index 0000000..49cd3c1 --- /dev/null +++ b/src/main/java/com/be/sportizebe/global/config/RedisCacheConfig.java @@ -0,0 +1,63 @@ +package com.be.sportizebe.global.config; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.databind.json.JsonMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +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.serializer.Jackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.RedisSerializationContext; +import org.springframework.data.redis.serializer.RedisSerializer; + +import java.time.Duration; +import java.util.HashMap; +import java.util.Map; + +@Configuration +@EnableCaching +public class RedisCacheConfig { + + @Bean + public CacheManager cacheManager(RedisConnectionFactory redisConnectionFactory) { + + ObjectMapper objectMapper = JsonMapper.builder() + .addModule(new JavaTimeModule()) + .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) + // ❗ 타입 정보 절대 쓰지 않음 + .build(); + + Jackson2JsonRedisSerializer valueSerializer = + new Jackson2JsonRedisSerializer<>(Object.class); + valueSerializer.setObjectMapper(objectMapper); + + RedisCacheConfiguration defaultConfig = + RedisCacheConfiguration.defaultCacheConfig() + .serializeValuesWith( + RedisSerializationContext.SerializationPair.fromSerializer(valueSerializer) + ) + .entryTtl(Duration.ofMinutes(5)); + + Map cacheConfigs = new HashMap<>(); + + cacheConfigs.put( + "facilityNear", + defaultConfig.entryTtl(Duration.ofSeconds(60)) + ); + + cacheConfigs.put( + "facilityMarkers", + defaultConfig.entryTtl(Duration.ofSeconds(60)) + ); + + return RedisCacheManager.builder(redisConnectionFactory) + .cacheDefaults(defaultConfig) + .withInitialCacheConfigurations(cacheConfigs) + .build(); + } +} \ No newline at end of file From f6fbf6871aae8d7871f50b97a766c534ef2efd84 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=95=88=ED=9B=88=EA=B8=B0?= Date: Sat, 31 Jan 2026 16:29:09 +0900 Subject: [PATCH 3/6] =?UTF-8?q?:wrench:=20Settings:=20be-config=20?= =?UTF-8?q?=EC=84=9C=EB=B8=8C=EB=AA=A8=EB=93=88=20=ED=8F=AC=EC=9D=B8?= =?UTF-8?q?=ED=84=B0=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/resources b/src/main/resources index a39d2f4..b5709e0 160000 --- a/src/main/resources +++ b/src/main/resources @@ -1 +1 @@ -Subproject commit a39d2f499313e65e9720ffd51b31988c31566dbe +Subproject commit b5709e0deb325454677824f227f14a6db270eef9 From a3cc44fbbf1f647e5b791c34152f730768705f87 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=95=88=ED=9B=88=EA=B8=B0?= Date: Sat, 31 Jan 2026 23:58:46 +0900 Subject: [PATCH 4/6] =?UTF-8?q?:sparkles:Feat:=20Post=20=EC=97=94=ED=8B=B0?= =?UTF-8?q?=ED=8B=B0=20=EC=BA=90=EC=8B=9C=20=EC=A0=81=EC=9A=A9=20&=20Page?= =?UTF-8?q?=20DTO=20=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dto/request/FacilityMarkerRequest.java | 2 +- .../dto/request/FacilityNearRequest.java | 2 +- .../service/SportsFacilityService.java | 41 ++-------- .../service/SportsFacilityServiceImpl.java | 60 ++++++++++++++ .../post/controller/PostController.java | 5 +- .../post/dto/response/PageInfoResponse.java | 21 +++++ .../post/dto/response/PostPageResponse.java | 33 ++++++++ .../domain/post/service/PostService.java | 3 +- .../domain/post/service/PostServiceImpl.java | 78 ++++++++++--------- .../global/cache/PostListKeyGenerator.java | 27 +++++++ .../global/config/RedisCacheConfig.java | 27 ++++--- 11 files changed, 217 insertions(+), 82 deletions(-) create mode 100644 src/main/java/com/be/sportizebe/domain/facility/service/SportsFacilityServiceImpl.java create mode 100644 src/main/java/com/be/sportizebe/domain/post/dto/response/PageInfoResponse.java create mode 100644 src/main/java/com/be/sportizebe/domain/post/dto/response/PostPageResponse.java create mode 100644 src/main/java/com/be/sportizebe/global/cache/PostListKeyGenerator.java diff --git a/src/main/java/com/be/sportizebe/domain/facility/dto/request/FacilityMarkerRequest.java b/src/main/java/com/be/sportizebe/domain/facility/dto/request/FacilityMarkerRequest.java index 92932be..4df692d 100644 --- a/src/main/java/com/be/sportizebe/domain/facility/dto/request/FacilityMarkerRequest.java +++ b/src/main/java/com/be/sportizebe/domain/facility/dto/request/FacilityMarkerRequest.java @@ -46,4 +46,4 @@ public String generateCacheKey() { return String.format("%.4f:%.4f:%d:%d:%s", gridLat, gridLng, radiusM, limit, typeName); } -} +} \ No newline at end of file diff --git a/src/main/java/com/be/sportizebe/domain/facility/dto/request/FacilityNearRequest.java b/src/main/java/com/be/sportizebe/domain/facility/dto/request/FacilityNearRequest.java index 32d6669..b4dcec4 100644 --- a/src/main/java/com/be/sportizebe/domain/facility/dto/request/FacilityNearRequest.java +++ b/src/main/java/com/be/sportizebe/domain/facility/dto/request/FacilityNearRequest.java @@ -47,4 +47,4 @@ public String generateCacheKey() { return String.format("%.4f:%.4f:%d:%d:%s", gridLat, gridLng, radiusM, limit, typeName); } -} +} \ No newline at end of file diff --git a/src/main/java/com/be/sportizebe/domain/facility/service/SportsFacilityService.java b/src/main/java/com/be/sportizebe/domain/facility/service/SportsFacilityService.java index 33a1e9c..e4795d7 100644 --- a/src/main/java/com/be/sportizebe/domain/facility/service/SportsFacilityService.java +++ b/src/main/java/com/be/sportizebe/domain/facility/service/SportsFacilityService.java @@ -4,41 +4,16 @@ import com.be.sportizebe.domain.facility.dto.request.FacilityNearRequest; import com.be.sportizebe.domain.facility.dto.response.FacilityMarkerResponse; import com.be.sportizebe.domain.facility.dto.response.FacilityNearResponse; -import com.be.sportizebe.domain.facility.mapper.FacilityMapper; -import com.be.sportizebe.domain.facility.repository.SportsFacilityRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.cache.annotation.Cacheable; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; import java.util.List; -@Service -@RequiredArgsConstructor -@Transactional(readOnly = true) -public class SportsFacilityService { +public interface SportsFacilityService { - private final SportsFacilityRepository sportsFacilityRepository; + List getNear(FacilityNearRequest request); // 현재 위치 기준 반경 내 체육시설을 거리순으로 조히 + // @Param: request 위치, 반경, 종목 등의 조회 조건 + // @return: 체육시설 상세 목록 - @Cacheable(cacheNames = "facilityNear", key = "#root.args[0].generateCacheKey()") - public List getNear(FacilityNearRequest request) { - String type = (request.getType() == null) ? null : request.getType().name(); - - return sportsFacilityRepository.findNear( - request.getLat(), request.getLng(), request.getRadiusM(), request.getLimit(), type - ).stream() - .map(FacilityMapper::toNearResponse) - .toList(); - } - - @Cacheable(cacheNames = "facilityMarkers", key = "#root.args[0].generateCacheKey()") - public List getMarkers(FacilityMarkerRequest request) { - String type = (request.getType() == null) ? null : request.getType().name(); - - return sportsFacilityRepository.findMarkersNear( - request.getLat(), request.getLng(), request.getRadiusM(), request.getLimit(), type - ).stream() - .map(FacilityMapper::toMarkerResponse) - .toList(); - } -} + List getMarkers(FacilityMarkerRequest request); // 지도 중심 좌표 기준 반경 내 체육시설 마커 목록 조회 + // @Param: request 지도 중심 좌표, 반경, 종목 등의 조회 조건 + // @return: 지도 마커용 체육시설 목록 +} \ No newline at end of file diff --git a/src/main/java/com/be/sportizebe/domain/facility/service/SportsFacilityServiceImpl.java b/src/main/java/com/be/sportizebe/domain/facility/service/SportsFacilityServiceImpl.java new file mode 100644 index 0000000..ab319ef --- /dev/null +++ b/src/main/java/com/be/sportizebe/domain/facility/service/SportsFacilityServiceImpl.java @@ -0,0 +1,60 @@ +package com.be.sportizebe.domain.facility.service; + +import com.be.sportizebe.domain.facility.dto.request.FacilityMarkerRequest; +import com.be.sportizebe.domain.facility.dto.request.FacilityNearRequest; +import com.be.sportizebe.domain.facility.dto.response.FacilityMarkerResponse; +import com.be.sportizebe.domain.facility.dto.response.FacilityNearResponse; +import com.be.sportizebe.domain.facility.mapper.FacilityMapper; +import com.be.sportizebe.domain.facility.repository.SportsFacilityRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class SportsFacilityServiceImpl implements SportsFacilityService { + + private final SportsFacilityRepository sportsFacilityRepository; + + @Override + @Cacheable( + cacheNames = "facilityNear", + key = "#root.args[0].generateCacheKey()" + ) + public List getNear(FacilityNearRequest request) { + String type = (request.getType() == null) ? null : request.getType().name(); + + return sportsFacilityRepository.findNear( + request.getLat(), + request.getLng(), + request.getRadiusM(), + request.getLimit(), + type + ).stream() + .map(FacilityMapper::toNearResponse) + .toList(); + } + + @Override + @Cacheable( + cacheNames = "facilityMarkers", + key = "#root.args[0].generateCacheKey()" + ) + public List getMarkers(FacilityMarkerRequest request) { + String type = (request.getType() == null) ? null : request.getType().name(); + + return sportsFacilityRepository.findMarkersNear( + request.getLat(), + request.getLng(), + request.getRadiusM(), + request.getLimit(), + type + ).stream() + .map(FacilityMapper::toMarkerResponse) + .toList(); + } +} \ No newline at end of file diff --git a/src/main/java/com/be/sportizebe/domain/post/controller/PostController.java b/src/main/java/com/be/sportizebe/domain/post/controller/PostController.java index 175ae5c..fe8b9fa 100644 --- a/src/main/java/com/be/sportizebe/domain/post/controller/PostController.java +++ b/src/main/java/com/be/sportizebe/domain/post/controller/PostController.java @@ -2,6 +2,7 @@ import com.be.sportizebe.domain.post.dto.request.CreatePostRequest; import com.be.sportizebe.domain.post.dto.request.UpdatePostRequest; +import com.be.sportizebe.domain.post.dto.response.PostPageResponse; import com.be.sportizebe.domain.post.dto.response.PostResponse; import com.be.sportizebe.domain.post.entity.PostProperty; import com.be.sportizebe.domain.post.service.PostService; @@ -61,10 +62,10 @@ public ResponseEntity> deletePost( @GetMapping("/posts/{property}") @Operation(summary = "게시글 목록 조회", description = "게시판 종류별 게시글 목록을 페이징하여 조회합니다.") - public ResponseEntity>> getPosts( + public ResponseEntity> getPosts( @Parameter(description = "게시판 종류 (SOCCER, BASKETBALL, FREE)") @PathVariable PostProperty property, @Parameter(hidden = true) @PageableDefault(size = 10, sort = "id", direction = Sort.Direction.DESC) Pageable pageable) { - Page response = postService.getPosts(property, pageable); + PostPageResponse response = postService.getPosts(property, pageable); return ResponseEntity.ok(BaseResponse.success("게시글 목록 조회 성공", response)); } } diff --git a/src/main/java/com/be/sportizebe/domain/post/dto/response/PageInfoResponse.java b/src/main/java/com/be/sportizebe/domain/post/dto/response/PageInfoResponse.java new file mode 100644 index 0000000..fc687a3 --- /dev/null +++ b/src/main/java/com/be/sportizebe/domain/post/dto/response/PageInfoResponse.java @@ -0,0 +1,21 @@ +package com.be.sportizebe.domain.post.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "페이지 정보") +public record PageInfoResponse( + @Schema(description = "현재 페이지 (0부터 시작)", example = "0") + int page, + + @Schema(description = "페이지 크기", example = "10") + int size, + + @Schema(description = "전체 요소 수", example = "123") + long totalElements, + + @Schema(description = "전체 페이지 수", example = "13") + int totalPages, + + @Schema(description = "다음 페이지 존재 여부", example = "true") + boolean hasNext +) {} \ No newline at end of file diff --git a/src/main/java/com/be/sportizebe/domain/post/dto/response/PostPageResponse.java b/src/main/java/com/be/sportizebe/domain/post/dto/response/PostPageResponse.java new file mode 100644 index 0000000..be36f1f --- /dev/null +++ b/src/main/java/com/be/sportizebe/domain/post/dto/response/PostPageResponse.java @@ -0,0 +1,33 @@ +package com.be.sportizebe.domain.post.dto.response; + +import com.be.sportizebe.domain.post.entity.Post; +import io.swagger.v3.oas.annotations.media.Schema; +import org.springframework.data.domain.Page; + +import java.util.List; + +@Schema(title = "PostPageResponse", description = "게시글 목록 + 페이지 정보 응답") +public record PostPageResponse( + + @Schema(description = "게시글 목록") + List content, + + @Schema(description = "페이지 정보") + PageInfoResponse pageInfo + +) { + public static PostPageResponse from(Page page) { + return new PostPageResponse( + page.getContent().stream() + .map(PostResponse::from) + .toList(), + new PageInfoResponse( + page.getNumber(), + page.getSize(), + page.getTotalElements(), + page.getTotalPages(), + page.hasNext() + ) + ); + } +} \ No newline at end of file diff --git a/src/main/java/com/be/sportizebe/domain/post/service/PostService.java b/src/main/java/com/be/sportizebe/domain/post/service/PostService.java index 1490c63..75af6f5 100644 --- a/src/main/java/com/be/sportizebe/domain/post/service/PostService.java +++ b/src/main/java/com/be/sportizebe/domain/post/service/PostService.java @@ -2,6 +2,7 @@ import com.be.sportizebe.domain.post.dto.request.CreatePostRequest; import com.be.sportizebe.domain.post.dto.request.UpdatePostRequest; +import com.be.sportizebe.domain.post.dto.response.PostPageResponse; import com.be.sportizebe.domain.post.dto.response.PostResponse; import com.be.sportizebe.domain.post.entity.PostProperty; import com.be.sportizebe.domain.user.entity.User; @@ -15,5 +16,5 @@ public interface PostService { void deletePost(Long postId, User user); // 게시글 삭제 - Page getPosts(PostProperty property, Pageable pageable); // 게시글 목록 조회 + PostPageResponse getPosts(PostProperty property, Pageable pageable); // 게시글 목록 조회 } diff --git a/src/main/java/com/be/sportizebe/domain/post/service/PostServiceImpl.java b/src/main/java/com/be/sportizebe/domain/post/service/PostServiceImpl.java index af5ec47..b3e0213 100644 --- a/src/main/java/com/be/sportizebe/domain/post/service/PostServiceImpl.java +++ b/src/main/java/com/be/sportizebe/domain/post/service/PostServiceImpl.java @@ -2,6 +2,7 @@ import com.be.sportizebe.domain.post.dto.request.CreatePostRequest; import com.be.sportizebe.domain.post.dto.request.UpdatePostRequest; +import com.be.sportizebe.domain.post.dto.response.PostPageResponse; import com.be.sportizebe.domain.post.dto.response.PostResponse; import com.be.sportizebe.domain.post.entity.Post; import com.be.sportizebe.domain.post.entity.PostProperty; @@ -12,6 +13,8 @@ import jakarta.transaction.Transactional; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.cache.annotation.Cacheable; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; @@ -21,51 +24,56 @@ @RequiredArgsConstructor public class PostServiceImpl implements PostService { - private final PostRepository postRepository; + private final PostRepository postRepository; - @Override - @Transactional - public PostResponse createPost(PostProperty property, CreatePostRequest request, User user) { - Post post = request.toEntity(property, user); // 요청 dto 데이터를 entity로 변환 + @Override + @CacheEvict(cacheNames = "postList", allEntries = true) + @Transactional + public PostResponse createPost(PostProperty property, CreatePostRequest request, User user) { + Post post = request.toEntity(property, user); // 요청 dto 데이터를 entity로 변환 - Post savedPost = postRepository.save(post); // db에 저장 + Post savedPost = postRepository.save(post); // db에 저장 - return PostResponse.from(savedPost); // entity를 dto로 변환하여 응답 - } + return PostResponse.from(savedPost); // entity를 dto로 변환하여 응답 + } + + @Override + @CacheEvict(cacheNames = "postList", allEntries = true) + @Transactional + public void deletePost(Long postId, User user) { + Post post = postRepository.findById(postId) + .orElseThrow(() -> new CustomException(PostErrorCode.POST_NOT_FOUND)); - @Override - @Transactional - public PostResponse updatePost(Long postId, UpdatePostRequest request, User user) { - Post post = postRepository.findById(postId) - .orElseThrow(() -> new CustomException(PostErrorCode.POST_NOT_FOUND)); + // 작성자 확인 + if (post.getUser().getId() != user.getId()) { + throw new CustomException(PostErrorCode.POST_DELETE_DENIED); + } - // 작성자 확인 - if (post.getUser().getId() != user.getId()) { - throw new CustomException(PostErrorCode.POST_UPDATE_DENIED); + postRepository.delete(post); } - post.update(request.title(), request.content(), request.imgUrl()); + @Override + @CacheEvict(cacheNames = "postList", allEntries = true) + @Transactional + public PostResponse updatePost(Long postId, UpdatePostRequest request, User user) { + Post post = postRepository.findById(postId) + .orElseThrow(() -> new CustomException(PostErrorCode.POST_NOT_FOUND)); - return PostResponse.from(post); - } + // 작성자 확인 + if (post.getUser().getId() != user.getId()) { + throw new CustomException(PostErrorCode.POST_UPDATE_DENIED); + } - @Override - @Transactional - public void deletePost(Long postId, User user) { - Post post = postRepository.findById(postId) - .orElseThrow(() -> new CustomException(PostErrorCode.POST_NOT_FOUND)); + post.update(request.title(), request.content(), request.imgUrl()); - // 작성자 확인 - if (post.getUser().getId() != user.getId()) { - throw new CustomException(PostErrorCode.POST_DELETE_DENIED); + return PostResponse.from(post); } - postRepository.delete(post); - } + @Cacheable(cacheNames = "postList", keyGenerator = "postListKeyGenerator") + @Override + public PostPageResponse getPosts(PostProperty property, Pageable pageable) { + Page page = postRepository.findByProperty(property, pageable); - @Override - public Page getPosts(PostProperty property, Pageable pageable) { - return postRepository.findByProperty(property, pageable) - .map(PostResponse::from); - } -} + return PostPageResponse.from(page); + } +} \ No newline at end of file diff --git a/src/main/java/com/be/sportizebe/global/cache/PostListKeyGenerator.java b/src/main/java/com/be/sportizebe/global/cache/PostListKeyGenerator.java new file mode 100644 index 0000000..9502ab9 --- /dev/null +++ b/src/main/java/com/be/sportizebe/global/cache/PostListKeyGenerator.java @@ -0,0 +1,27 @@ +package com.be.sportizebe.global.cache; + +import com.be.sportizebe.domain.post.entity.PostProperty; +import org.springframework.cache.interceptor.KeyGenerator; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Component; + +import java.lang.reflect.Method; + +@Component("postListKeyGenerator") +public class PostListKeyGenerator implements KeyGenerator { + + @Override + public Object generate(Object target, Method method, Object... params) { + + PostProperty property = (PostProperty) params[0]; + Pageable pageable = (Pageable) params[1]; + + return String.format( + "%s:%d:%d:%s", + property.name(), + pageable.getPageNumber(), + pageable.getPageSize(), + pageable.getSort().toString() + ); + } +} \ No newline at end of file diff --git a/src/main/java/com/be/sportizebe/global/config/RedisCacheConfig.java b/src/main/java/com/be/sportizebe/global/config/RedisCacheConfig.java index 49cd3c1..d274ad4 100644 --- a/src/main/java/com/be/sportizebe/global/config/RedisCacheConfig.java +++ b/src/main/java/com/be/sportizebe/global/config/RedisCacheConfig.java @@ -1,8 +1,10 @@ package com.be.sportizebe.global.config; +import com.fasterxml.jackson.annotation.JsonTypeInfo; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.SerializationFeature; import com.fasterxml.jackson.databind.json.JsonMapper; +import com.fasterxml.jackson.databind.jsontype.impl.LaissezFaireSubTypeValidator; import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; import org.springframework.cache.CacheManager; import org.springframework.cache.annotation.EnableCaching; @@ -11,7 +13,7 @@ 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.serializer.Jackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; import org.springframework.data.redis.serializer.RedisSerializationContext; import org.springframework.data.redis.serializer.RedisSerializer; @@ -29,13 +31,17 @@ public CacheManager cacheManager(RedisConnectionFactory redisConnectionFactory) ObjectMapper objectMapper = JsonMapper.builder() .addModule(new JavaTimeModule()) .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) - // ❗ 타입 정보 절대 쓰지 않음 .build(); - - Jackson2JsonRedisSerializer valueSerializer = - new Jackson2JsonRedisSerializer<>(Object.class); - valueSerializer.setObjectMapper(objectMapper); - + objectMapper.activateDefaultTyping( + LaissezFaireSubTypeValidator.instance, + ObjectMapper.DefaultTyping.NON_FINAL, + JsonTypeInfo.As.PROPERTY + ); + // 캐시에 들어가는 값의 직렬화 방식 결정 + RedisSerializer valueSerializer = + new GenericJackson2JsonRedisSerializer(objectMapper); + // TTL(캐시 수명) 정책 정의 + // 아무 설정 없는 캐시(Default) = 5분 RedisCacheConfiguration defaultConfig = RedisCacheConfiguration.defaultCacheConfig() .serializeValuesWith( @@ -45,16 +51,19 @@ public CacheManager cacheManager(RedisConnectionFactory redisConnectionFactory) Map cacheConfigs = new HashMap<>(); + // 캐시별로 TTL override 가능 ( post, commet 등 우리가 원하는 값으로 TTL 설정 가능 ) cacheConfigs.put( "facilityNear", defaultConfig.entryTtl(Duration.ofSeconds(60)) ); - cacheConfigs.put( "facilityMarkers", defaultConfig.entryTtl(Duration.ofSeconds(60)) ); - + cacheConfigs.put( + "postList", + defaultConfig.entryTtl(Duration.ofSeconds(30)) + ); return RedisCacheManager.builder(redisConnectionFactory) .cacheDefaults(defaultConfig) .withInitialCacheConfigurations(cacheConfigs) From fd256ba40d4a6daec8b7c7d8c76e8c4d05fd3309 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=95=88=ED=9B=88=EA=B8=B0?= Date: Sun, 1 Feb 2026 03:05:55 +0900 Subject: [PATCH 5/6] =?UTF-8?q?:wrench:=20Settings:=20=EC=84=9C=EB=B8=8C?= =?UTF-8?q?=EB=AA=A8=EB=93=88=20=ED=8F=AC=EC=9D=B8=ED=84=B0=20=EC=97=85?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/resources b/src/main/resources index b5709e0..05d0469 160000 --- a/src/main/resources +++ b/src/main/resources @@ -1 +1 @@ -Subproject commit b5709e0deb325454677824f227f14a6db270eef9 +Subproject commit 05d0469d24d5c627fe5a94af9f6a7e797be874e6 From a8c9a959fbb24b7ca40d18655bf5a79dee995fb8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=95=88=ED=9B=88=EA=B8=B0?= Date: Sun, 1 Feb 2026 03:07:20 +0900 Subject: [PATCH 6/6] =?UTF-8?q?:bug:=20Fix:=20Redis=20=EC=BA=90=EC=8B=9C?= =?UTF-8?q?=20postList=20=EC=97=AD=EC=A7=81=EB=A0=AC=ED=99=94=20=ED=83=80?= =?UTF-8?q?=EC=9E=85=20=EC=9D=B4=EC=8A=88=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../global/config/RedisCacheConfig.java | 35 +++++++++++-------- 1 file changed, 21 insertions(+), 14 deletions(-) diff --git a/src/main/java/com/be/sportizebe/global/config/RedisCacheConfig.java b/src/main/java/com/be/sportizebe/global/config/RedisCacheConfig.java index d274ad4..d3348ab 100644 --- a/src/main/java/com/be/sportizebe/global/config/RedisCacheConfig.java +++ b/src/main/java/com/be/sportizebe/global/config/RedisCacheConfig.java @@ -1,10 +1,9 @@ package com.be.sportizebe.global.config; -import com.fasterxml.jackson.annotation.JsonTypeInfo; +import com.be.sportizebe.domain.post.dto.response.PostPageResponse; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.SerializationFeature; import com.fasterxml.jackson.databind.json.JsonMapper; -import com.fasterxml.jackson.databind.jsontype.impl.LaissezFaireSubTypeValidator; import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; import org.springframework.cache.CacheManager; import org.springframework.cache.annotation.EnableCaching; @@ -13,9 +12,8 @@ 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.serializer.GenericJackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer; import org.springframework.data.redis.serializer.RedisSerializationContext; -import org.springframework.data.redis.serializer.RedisSerializer; import java.time.Duration; import java.util.HashMap; @@ -27,30 +25,35 @@ public class RedisCacheConfig { @Bean public CacheManager cacheManager(RedisConnectionFactory redisConnectionFactory) { - + // 캐시 역직렬화를 위해 타입 정보(@class)를 포함하도록 설정 ObjectMapper objectMapper = JsonMapper.builder() .addModule(new JavaTimeModule()) .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) + // ❗ 타입 정보 절대 쓰지 않음 .build(); - objectMapper.activateDefaultTyping( - LaissezFaireSubTypeValidator.instance, - ObjectMapper.DefaultTyping.NON_FINAL, - JsonTypeInfo.As.PROPERTY - ); // 캐시에 들어가는 값의 직렬화 방식 결정 - RedisSerializer valueSerializer = - new GenericJackson2JsonRedisSerializer(objectMapper); + + // 기본 캐시(대부분)는 Object로 직렬화/역직렬화 + Jackson2JsonRedisSerializer defaultValueSerializer = + new Jackson2JsonRedisSerializer<>(Object.class); + defaultValueSerializer.setObjectMapper(objectMapper); + + // postList 캐시는 PostPageResponse 타입으로 역직렬화되어야 ClassCastException이 나지 않음 + Jackson2JsonRedisSerializer postListValueSerializer = + new Jackson2JsonRedisSerializer<>(PostPageResponse.class); + postListValueSerializer.setObjectMapper(objectMapper); // TTL(캐시 수명) 정책 정의 // 아무 설정 없는 캐시(Default) = 5분 RedisCacheConfiguration defaultConfig = RedisCacheConfiguration.defaultCacheConfig() .serializeValuesWith( - RedisSerializationContext.SerializationPair.fromSerializer(valueSerializer) + RedisSerializationContext.SerializationPair.fromSerializer(defaultValueSerializer) ) .entryTtl(Duration.ofMinutes(5)); Map cacheConfigs = new HashMap<>(); + // 캐시별로 TTL override 가능 ( post, commet 등 우리가 원하는 값으로 TTL 설정 가능 ) cacheConfigs.put( "facilityNear", @@ -62,7 +65,11 @@ public CacheManager cacheManager(RedisConnectionFactory redisConnectionFactory) ); cacheConfigs.put( "postList", - defaultConfig.entryTtl(Duration.ofSeconds(30)) + RedisCacheConfiguration.defaultCacheConfig() + .serializeValuesWith( + RedisSerializationContext.SerializationPair.fromSerializer(postListValueSerializer) + ) + .entryTtl(Duration.ofSeconds(30)) ); return RedisCacheManager.builder(redisConnectionFactory) .cacheDefaults(defaultConfig)