diff --git a/build.gradle b/build.gradle index 502f07a..e4d2288 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/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..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 @@ -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; + + @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); + } } \ 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 6408c24..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 @@ -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; + + @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); + } } \ 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 cd11eab..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,49 +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.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: 체육시설 상세 목록 - 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() - .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 - ); - - return list.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 4160c9d..3cacfeb 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; @@ -64,10 +65,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 8cde826..6191f2f 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; @@ -16,5 +17,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 bc62910..f77e0b0 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; @@ -14,6 +15,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; @@ -24,58 +27,62 @@ @RequiredArgsConstructor public class PostServiceImpl implements PostService { - private final PostRepository postRepository; - private final S3Service s3Service; + private final PostRepository postRepository; + private final S3Service s3Service; - @Override - @Transactional - public PostResponse createPost(PostProperty property, CreatePostRequest request, MultipartFile image, User user) { - // 이미지가 있으면 S3에 업로드 - String imgUrl = null; - if (image != null && !image.isEmpty()) { - imgUrl = s3Service.uploadFile(PathName.POST, image); - } + @Override + @CacheEvict(cacheNames = "postList", allEntries = true) + @Transactional + public PostResponse createPost(PostProperty property, CreatePostRequest request, MultipartFile image, User user) { + // 이미지가 있으면 S3에 업로드 + String imgUrl = null; + if (image != null && !image.isEmpty()) { + imgUrl = s3Service.uploadFile(PathName.POST, image); + } - Post post = request.toEntity(property, user, imgUrl); + Post post = request.toEntity(property, user, imgUrl); + Post savedPost = postRepository.save(post); - Post savedPost = postRepository.save(post); + return PostResponse.from(savedPost); + } - return PostResponse.from(savedPost); - } + @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)); + // 여기서도 imgUrl 업데이트가 request에 포함이면 그대로 반영 + 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); - } - - @Override - public Page getPosts(PostProperty property, Pageable pageable) { - return postRepository.findByProperty(property, pageable) - .map(PostResponse::from); - } -} + @Override + @Cacheable(cacheNames = "postList", keyGenerator = "postListKeyGenerator") + public PostPageResponse getPosts(PostProperty property, Pageable pageable) { + Page page = postRepository.findByProperty(property, pageable); + 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 new file mode 100644 index 0000000..d3348ab --- /dev/null +++ b/src/main/java/com/be/sportizebe/global/config/RedisCacheConfig.java @@ -0,0 +1,79 @@ +package com.be.sportizebe.global.config; + +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.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 java.time.Duration; +import java.util.HashMap; +import java.util.Map; + +@Configuration +@EnableCaching +public class RedisCacheConfig { + + @Bean + public CacheManager cacheManager(RedisConnectionFactory redisConnectionFactory) { + // 캐시 역직렬화를 위해 타입 정보(@class)를 포함하도록 설정 + ObjectMapper objectMapper = JsonMapper.builder() + .addModule(new JavaTimeModule()) + .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) + // ❗ 타입 정보 절대 쓰지 않음 + .build(); + // 캐시에 들어가는 값의 직렬화 방식 결정 + + // 기본 캐시(대부분)는 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(defaultValueSerializer) + ) + .entryTtl(Duration.ofMinutes(5)); + + 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", + RedisCacheConfiguration.defaultCacheConfig() + .serializeValuesWith( + RedisSerializationContext.SerializationPair.fromSerializer(postListValueSerializer) + ) + .entryTtl(Duration.ofSeconds(30)) + ); + return RedisCacheManager.builder(redisConnectionFactory) + .cacheDefaults(defaultConfig) + .withInitialCacheConfigurations(cacheConfigs) + .build(); + } +} \ No newline at end of file