diff --git a/src/main/java/com/be/sportizebe/domain/club/controller/ClubController.java b/src/main/java/com/be/sportizebe/domain/club/controller/ClubController.java index 9d80b84..9c82b15 100644 --- a/src/main/java/com/be/sportizebe/domain/club/controller/ClubController.java +++ b/src/main/java/com/be/sportizebe/domain/club/controller/ClubController.java @@ -2,11 +2,14 @@ import com.be.sportizebe.domain.club.dto.request.ClubCreateRequest; import com.be.sportizebe.domain.club.dto.request.ClubUpdateRequest; +import com.be.sportizebe.domain.club.dto.response.ClubDetailResponse; import com.be.sportizebe.domain.club.dto.response.ClubImageResponse; import com.be.sportizebe.domain.club.dto.response.ClubResponse; -import com.be.sportizebe.domain.club.service.ClubServiceImpl; -import com.be.sportizebe.global.response.BaseResponse; +import com.be.sportizebe.domain.club.dto.response.ClubScrollResponse; +import com.be.sportizebe.domain.club.service.ClubService; +import com.be.sportizebe.domain.user.entity.User; import com.be.sportizebe.global.cache.dto.UserAuthInfo; +import com.be.sportizebe.global.response.BaseResponse; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; @@ -25,36 +28,69 @@ @Tag(name = "club", description = "동호회 관리 관련 API") public class ClubController { - private final ClubServiceImpl clubService; - - @PostMapping(value = "", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) - @Operation(summary = "동호회 생성", description = "종목별 동호회를 생성합니다. 생성한 사용자가 동호회장이 됩니다. (이미지 첨부 가능)") - public ResponseEntity> createClub( - @RequestPart("request") @Valid ClubCreateRequest request, - @RequestPart(value = "image", required = false) MultipartFile image, - @AuthenticationPrincipal UserAuthInfo userAuthInfo) { - ClubResponse response = clubService.createClub(request, image, userAuthInfo.getId()); - return ResponseEntity.status(HttpStatus.CREATED) - .body(BaseResponse.success("동호회 생성 성공", response)); - } - - @PutMapping("/{clubId}") - @Operation(summary = "동호회 수정", description = "동호회 정보를 수정합니다. 동호회장만 수정할 수 있습니다.") - public ResponseEntity> updateClub( - @PathVariable Long clubId, - @RequestBody @Valid ClubUpdateRequest request, - @AuthenticationPrincipal UserAuthInfo userAuthInfo) { - ClubResponse response = clubService.updateClub(clubId, request, userAuthInfo.getId()); - return ResponseEntity.ok(BaseResponse.success("동호회 수정 성공", response)); - } - - @PostMapping(value = "/{clubId}/image", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) - @Operation(summary = "동호회 사진 수정", description = "동호회 사진을 수정합니다. 동호회장만 수정할 수 있습니다.") - public ResponseEntity> updateClubImage( - @Parameter(description = "동호회 ID") @PathVariable Long clubId, - @RequestPart("image") MultipartFile image, - @AuthenticationPrincipal UserAuthInfo userAuthInfo) { - ClubImageResponse response = clubService.updateClubImage(clubId, image, userAuthInfo.getId()); - return ResponseEntity.ok(BaseResponse.success("동호회 사진 수정 성공", response)); - } + private final ClubService clubService; + + @PostMapping(value = "", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @Operation(summary = "동호회 생성", description = "종목별 동호회를 생성합니다. 생성한 사용자가 동호회장이 됩니다. (이미지 첨부 가능)") + public ResponseEntity> createClub( + @RequestPart("request") @Valid ClubCreateRequest request, + @RequestPart(value = "image", required = false) MultipartFile image, + @AuthenticationPrincipal UserAuthInfo userAuthInfo) { + ClubResponse response = clubService.createClub(request, image, userAuthInfo.getId()); + return ResponseEntity.status(HttpStatus.CREATED) + .body(BaseResponse.success("동호회 생성 성공", response)); + } + + @PutMapping("/{clubId}") + @Operation(summary = "동호회 수정", description = "동호회 정보를 수정합니다. 동호회장만 수정할 수 있습니다.") + public ResponseEntity> updateClub( + @PathVariable Long clubId, + @RequestBody @Valid ClubUpdateRequest request, + @AuthenticationPrincipal UserAuthInfo userAuthInfo) { + ClubResponse response = clubService.updateClub(clubId, request, userAuthInfo.getId()); + return ResponseEntity.ok(BaseResponse.success("동호회 수정 성공", response)); + } + + @PostMapping(value = "/{clubId}/image", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @Operation(summary = "동호회 사진 수정", description = "동호회 사진을 수정합니다. 동호회장만 수정할 수 있습니다.") + public ResponseEntity> updateClubImage( + @Parameter(description = "동호회 ID") @PathVariable Long clubId, + @RequestPart("image") MultipartFile image, + @AuthenticationPrincipal UserAuthInfo userAuthInfo) { + ClubImageResponse response = clubService.updateClubImage(clubId, image, userAuthInfo.getId()); + return ResponseEntity.ok(BaseResponse.success("동호회 사진 수정 성공", response)); + } + + @GetMapping + @Operation(summary = "모든 동호회 조회 (무한스크롤)", + description = "커서 기반 무한스크롤 방식으로 동호회 목록을 조회합니다.") + public ResponseEntity> getClubs( + @RequestParam(required = false) Long cursor, + @RequestParam(defaultValue = "20") int size + ) { + ClubScrollResponse response = clubService.getClubsByScroll(cursor, size); + return ResponseEntity.ok( + BaseResponse.success("동호회 목록 조회 성공", response) + ); + } + + @GetMapping("/{clubId}") + @Operation(summary = "동호회 상세 조회", description = "동호회 단건 상세 정보를 조회합니다.") + public ResponseEntity> getClub( + @Parameter(description = "동호회 ID") @PathVariable Long clubId + ) { + ClubDetailResponse response = clubService.getClub(clubId); + return ResponseEntity.ok(BaseResponse.success("동호회 상세 조회 성공", response)); + } + + @GetMapping("/me") + @Operation(summary = "내가 가입한 동호회 조회(무한스크롤)", description = "로그인한 사용자가 가입한 동호회를 커서 기반 무한스크롤로 조회합니다.") + public ResponseEntity> getMyClubs( + @Parameter(description = "커서(마지막 clubId). 첫 조회는 null") @RequestParam(required = false) Long cursor, + @Parameter(description = "조회 개수", example = "10") @RequestParam(defaultValue = "10") int size, + @AuthenticationPrincipal User user + ) { + ClubScrollResponse response = clubService.getMyClubsByScroll(cursor, size, user); + return ResponseEntity.ok(BaseResponse.success("내 동호회 조회 성공", response)); + } } diff --git a/src/main/java/com/be/sportizebe/domain/club/dto/response/ClubDetailResponse.java b/src/main/java/com/be/sportizebe/domain/club/dto/response/ClubDetailResponse.java new file mode 100644 index 0000000..0d70862 --- /dev/null +++ b/src/main/java/com/be/sportizebe/domain/club/dto/response/ClubDetailResponse.java @@ -0,0 +1,46 @@ +package com.be.sportizebe.domain.club.dto.response; + +import com.be.sportizebe.domain.club.entity.Club; +import io.swagger.v3.oas.annotations.media.Schema; +import java.time.LocalDateTime; + +@Schema(title = "ClubDetailResponse", description = "동호회 상세 조회 응답") +public record ClubDetailResponse( + + @Schema(description = "동호회 ID", example = "1") + Long clubId, + + @Schema(description = "동호회 이름", example = "수원 FC 풋살 동호회") + String name, + + @Schema(description = "동호회 소개글 (전체)", example = "매주 화, 토 저녁에 풋살을 즐기는 동호회입니다.") + String introduce, + + @Schema(description = "동호회 종목", example = "SOCCER") + String clubType, + + @Schema(description = "최대 정원", example = "20") + int maxMembers, + + @Schema(description = "현재 동호회 인원 수", example = "12") + int currentMembers, + + @Schema(description = "동호회 대표 이미지 URL", example = "https://bucket.s3.ap-northeast-2.amazonaws.com/club/uuid.jpg") + String clubImageUrl, + + @Schema(description = "동호회 생성 일시", example = "2026-02-01T12:30:00") + LocalDateTime createdAt +) { + public static ClubDetailResponse from(Club club, int memberCount) { + return new ClubDetailResponse( + club.getId(), + club.getName(), + club.getIntroduce(), + club.getClubType().name(), + club.getMaxMembers(), + memberCount, + club.getClubImage(), + club.getCreatedAt() + ); + } +} \ No newline at end of file diff --git a/src/main/java/com/be/sportizebe/domain/club/dto/response/ClubListItemResponse.java b/src/main/java/com/be/sportizebe/domain/club/dto/response/ClubListItemResponse.java new file mode 100644 index 0000000..b57430b --- /dev/null +++ b/src/main/java/com/be/sportizebe/domain/club/dto/response/ClubListItemResponse.java @@ -0,0 +1,42 @@ +package com.be.sportizebe.domain.club.dto.response; + +import com.be.sportizebe.domain.club.entity.Club; +import io.swagger.v3.oas.annotations.media.Schema; +import java.time.LocalDateTime; + +@Schema(title = "ClubListItemResponse", description = "동호회 목록(무한스크롤) 카드 단위 응답") +public record ClubListItemResponse( + + @Schema(description = "동호회 ID", example = "1") + Long clubId, + + @Schema(description = "동호회 이름", example = "수원 FC 풋살 동호회") + String name, + + @Schema(description = "동호회 종목", example = "SOCCER") + String sportType, + + @Schema(description = "동호회 소개글 (목록용 요약)", example = "주 2회 풋살을 즐기는 동호회입니다.") + String description, + + @Schema(description = "동호회 대표 이미지 URL", example = "https://bucket.s3.ap-northeast-2.amazonaws.com/club/uuid.jpg") + String imageUrl, + + @Schema(description = "현재 동호회 인원 수", example = "12") + int memberCount, + + @Schema(description = "동호회 생성 일시", example = "2026-02-01T12:30:00") + LocalDateTime createdAt +) { + public static ClubListItemResponse from(Club club, int memberCount) { + return new ClubListItemResponse( + club.getId(), + club.getName(), + club.getClubType().name(), + club.getIntroduce(), + club.getClubImage(), + memberCount, + club.getCreatedAt() + ); + } +} \ No newline at end of file diff --git a/src/main/java/com/be/sportizebe/domain/club/dto/response/ClubScrollResponse.java b/src/main/java/com/be/sportizebe/domain/club/dto/response/ClubScrollResponse.java new file mode 100644 index 0000000..c11f235 --- /dev/null +++ b/src/main/java/com/be/sportizebe/domain/club/dto/response/ClubScrollResponse.java @@ -0,0 +1,18 @@ +package com.be.sportizebe.domain.club.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.List; + +@Schema(title = "ClubScrollResponse", description = "동호회 목록 무한스크롤 조회 응답") +public record ClubScrollResponse( + + @Schema(description = "동호회 목록") + List items, + + @Schema(description = "다음 조회를 위한 커서 값 (마지막 clubId)", example = "15") + Long nextCursor, + + @Schema(description = "다음 페이지 존재 여부", example = "true") + boolean hasNext +) { +} \ No newline at end of file diff --git a/src/main/java/com/be/sportizebe/domain/club/repository/ClubMemberRepository.java b/src/main/java/com/be/sportizebe/domain/club/repository/ClubMemberRepository.java index f98e969..fc9e982 100644 --- a/src/main/java/com/be/sportizebe/domain/club/repository/ClubMemberRepository.java +++ b/src/main/java/com/be/sportizebe/domain/club/repository/ClubMemberRepository.java @@ -7,6 +7,8 @@ public interface ClubMemberRepository extends JpaRepository { - // 특정 사용자가 특정 동호회에 이미 가입했는지 확인 - boolean existsByClubAndUser(Club club, User user); + // 특정 사용자가 특정 동호회에 이미 가입했는지 확인 + boolean existsByClubAndUser(Club club, User user); + + int countByClubId(Long clubId); } diff --git a/src/main/java/com/be/sportizebe/domain/club/repository/ClubRepository.java b/src/main/java/com/be/sportizebe/domain/club/repository/ClubRepository.java index f5f5110..ac946e8 100644 --- a/src/main/java/com/be/sportizebe/domain/club/repository/ClubRepository.java +++ b/src/main/java/com/be/sportizebe/domain/club/repository/ClubRepository.java @@ -2,7 +2,29 @@ import com.be.sportizebe.domain.club.entity.Club; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; public interface ClubRepository extends JpaRepository { boolean existsByName(String name); + + @Query(""" + SELECT c FROM Club c + WHERE (:cursor IS NULL OR c.id < :cursor) + ORDER BY c.id DESC + """) + List findClubsByCursor(@Param("cursor") Long cursor, Pageable pageable); + + @Query(""" + select c + from ClubMember cm + join cm.club c + where cm.user.id = :userId + and (:cursor is null or c.id < :cursor) + order by c.id desc + """) + List findMyClubsByCursor(@Param("userId") Long userId, @Param("cursor") Long cursor, Pageable pageable); } diff --git a/src/main/java/com/be/sportizebe/domain/club/service/ClubService.java b/src/main/java/com/be/sportizebe/domain/club/service/ClubService.java index 5ad3028..a8f51bb 100644 --- a/src/main/java/com/be/sportizebe/domain/club/service/ClubService.java +++ b/src/main/java/com/be/sportizebe/domain/club/service/ClubService.java @@ -2,14 +2,23 @@ import com.be.sportizebe.domain.club.dto.request.ClubCreateRequest; import com.be.sportizebe.domain.club.dto.request.ClubUpdateRequest; +import com.be.sportizebe.domain.club.dto.response.ClubDetailResponse; import com.be.sportizebe.domain.club.dto.response.ClubImageResponse; import com.be.sportizebe.domain.club.dto.response.ClubResponse; +import com.be.sportizebe.domain.club.dto.response.ClubScrollResponse; +import com.be.sportizebe.domain.user.entity.User; import org.springframework.web.multipart.MultipartFile; public interface ClubService { - ClubResponse createClub(ClubCreateRequest request, MultipartFile image, Long userId); // 동호회 생성 + ClubResponse createClub(ClubCreateRequest request, MultipartFile image, Long userId); // 동호회 생성 - ClubResponse updateClub(Long clubId, ClubUpdateRequest request, Long userId); // 동호회 수정 + ClubResponse updateClub(Long clubId, ClubUpdateRequest request, Long userId); // 동호회 수정 - ClubImageResponse updateClubImage(Long clubId, MultipartFile image, Long userId); // 동호회 사진 수정 + ClubDetailResponse getClub(Long clubId); // 동호회 개별 조회 + + ClubScrollResponse getClubsByScroll(Long cursor, int size); // 동호회 전체 조회 (무한 스크롤) + + ClubScrollResponse getMyClubsByScroll(Long cursor, int size, User user); // 내가 가입한 동호회 조회 (무한 스크롤) + + ClubImageResponse updateClubImage(Long clubId, MultipartFile image, Long userId); // 동호회 사진 수정 } diff --git a/src/main/java/com/be/sportizebe/domain/club/service/ClubServiceImpl.java b/src/main/java/com/be/sportizebe/domain/club/service/ClubServiceImpl.java index c96d6ae..d12c1e7 100644 --- a/src/main/java/com/be/sportizebe/domain/club/service/ClubServiceImpl.java +++ b/src/main/java/com/be/sportizebe/domain/club/service/ClubServiceImpl.java @@ -3,8 +3,7 @@ import com.be.sportizebe.domain.chat.service.ChatRoomService; import com.be.sportizebe.domain.club.dto.request.ClubCreateRequest; import com.be.sportizebe.domain.club.dto.request.ClubUpdateRequest; -import com.be.sportizebe.domain.club.dto.response.ClubImageResponse; -import com.be.sportizebe.domain.club.dto.response.ClubResponse; +import com.be.sportizebe.domain.club.dto.response.*; import com.be.sportizebe.domain.club.entity.Club; import com.be.sportizebe.domain.club.entity.ClubMember; import com.be.sportizebe.domain.club.exception.ClubErrorCode; @@ -17,9 +16,12 @@ import com.be.sportizebe.global.s3.enums.PathName; import com.be.sportizebe.global.s3.service.S3Service; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.multipart.MultipartFile; +import java.util.List; @Service @RequiredArgsConstructor @@ -113,6 +115,70 @@ public ClubImageResponse updateClubImage(Long clubId, MultipartFile image, Long // 동호회 이미지 URL 업데이트 club.updateClubImage(clubImageUrl); - return ClubImageResponse.from(clubImageUrl); - } + return ClubImageResponse.from(clubImageUrl); + } + @Override + @Transactional(readOnly = true) + public ClubDetailResponse getClub(Long clubId) { + Club club = clubRepository.findById(clubId) + .orElseThrow(() -> new CustomException(ClubErrorCode.CLUB_NOT_FOUND)); + + int memberCount = clubMemberRepository.countByClubId(clubId); + + return ClubDetailResponse.from(club, memberCount); + } + @Override + @Transactional(readOnly = true) + public ClubScrollResponse getClubsByScroll(Long cursor, int size) { + + // +1 조회해서 다음 페이지 존재 여부 판단 + Pageable pageable = PageRequest.of(0, size + 1); + + List clubs = clubRepository.findClubsByCursor(cursor, pageable); + + boolean hasNext = clubs.size() > size; + + if (hasNext) { + clubs = clubs.subList(0, size); + } + + List items = clubs.stream() + .map(club -> { + int memberCount = clubMemberRepository.countByClubId(club.getId()); + return ClubListItemResponse.from(club, memberCount); + }) + .toList(); + + Long nextCursor = items.isEmpty() + ? null + : items.get(items.size() - 1).clubId(); + + return new ClubScrollResponse(items, nextCursor, hasNext); + } + @Override + @Transactional(readOnly = true) + public ClubScrollResponse getMyClubsByScroll(Long cursor, int size, User user) { + + Pageable pageable = PageRequest.of(0, size + 1); + + List clubs = clubRepository.findMyClubsByCursor(user.getId(), cursor, pageable); + + boolean hasNext = clubs.size() > size; + + if (hasNext) { + clubs = clubs.subList(0, size); + } + + List items = clubs.stream() + .map(club -> { + int memberCount = clubMemberRepository.countByClubId(club.getId()); + return ClubListItemResponse.from(club, memberCount); + }) + .toList(); + + Long nextCursor = items.isEmpty() ? null : items.get(items.size() - 1).clubId(); + + return new ClubScrollResponse(items, nextCursor, hasNext); + } + } 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 4143c4b..16dd57c 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,12 +2,14 @@ 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.CursorPageResponse; 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; -import com.be.sportizebe.global.response.BaseResponse; +import com.be.sportizebe.domain.user.entity.User; import com.be.sportizebe.global.cache.dto.UserAuthInfo; +import com.be.sportizebe.global.response.BaseResponse; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; @@ -70,4 +72,15 @@ public ResponseEntity> getPosts( PostPageResponse response = postService.getPosts(property, pageable); return ResponseEntity.ok(BaseResponse.success("게시글 목록 조회 성공", response)); } + @GetMapping("/posts/me") + @Operation(summary = "내 게시글 목록 조회", description = "로그인한 사용자가 작성한 게시글을 최신순으로 무한 스크롤(커서 기반) 조회합니다.") + public ResponseEntity>> getMyPosts( + @Parameter(description = "커서(마지막으로 조회된 게시글 ID). 첫 요청은 생략", example = "123") + @RequestParam(required = false) Long cursor, + @AuthenticationPrincipal User user) + { + CursorPageResponse response = postService.getMyPostsCursor(user, cursor); + return ResponseEntity.ok(BaseResponse.success("내 게시글 목록 조회 성공", response)); + } + } diff --git a/src/main/java/com/be/sportizebe/domain/post/dto/response/CursorPageResponse.java b/src/main/java/com/be/sportizebe/domain/post/dto/response/CursorPageResponse.java new file mode 100644 index 0000000..505e22f --- /dev/null +++ b/src/main/java/com/be/sportizebe/domain/post/dto/response/CursorPageResponse.java @@ -0,0 +1,20 @@ +package com.be.sportizebe.domain.post.dto.response; +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.List; + +@Schema(description = "커서 기반 무한 스크롤 응답") +public record CursorPageResponse( + + @Schema(description = "조회된 데이터 목록") + List items, + + @Schema(description = "다음 조회를 위한 커서 값 (없으면 null)", example = "123") + Long nextCursor, + + @Schema(description = "다음 페이지 존재 여부", example = "true") + boolean hasNext +) { + public static CursorPageResponse of(List items, Long nextCursor, boolean hasNext) { + return new CursorPageResponse<>(items, nextCursor, hasNext); + } +} \ No newline at end of file diff --git a/src/main/java/com/be/sportizebe/domain/post/repository/PostRepository.java b/src/main/java/com/be/sportizebe/domain/post/repository/PostRepository.java index 29e7a29..6012abe 100644 --- a/src/main/java/com/be/sportizebe/domain/post/repository/PostRepository.java +++ b/src/main/java/com/be/sportizebe/domain/post/repository/PostRepository.java @@ -6,6 +6,15 @@ import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import java.util.List; + public interface PostRepository extends JpaRepository { - Page findByProperty(PostProperty property, Pageable pageable); + Page findByProperty(PostProperty property, Pageable pageable); + + // 첫 페이지: 11개(10 + 1) + List findTop11ByUserIdOrderByIdDesc(Long userId); + + // 다음 페이지: 11개(10 + 1) + List findTop11ByUserIdAndIdLessThanOrderByIdDesc(Long userId, Long cursor); + } \ 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 06e680c..5a80f0c 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,18 +2,23 @@ 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.CursorPageResponse; 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; import org.springframework.data.domain.Pageable; import org.springframework.web.multipart.MultipartFile; public interface PostService { - PostResponse createPost(PostProperty property, CreatePostRequest request, MultipartFile image, Long userId); // 게시글 생성 + PostResponse createPost(PostProperty property, CreatePostRequest request, MultipartFile image, Long userId); // 게시글 생성 - PostResponse updatePost(Long postId, UpdatePostRequest request, Long userId); // 게시글 수정 + PostResponse updatePost(Long postId, UpdatePostRequest request, Long userId); // 게시글 수정 - void deletePost(Long postId, Long userId); // 게시글 삭제 + void deletePost(Long postId, Long userId); // 게시글 삭제 - PostPageResponse getPosts(PostProperty property, Pageable pageable); // 게시글 목록 조회 + + PostPageResponse getPosts(PostProperty property, Pageable pageable); // 게시글 목록 조회 + + CursorPageResponse getMyPostsCursor(User user, Long cursor); // 내가 쓴 게시글 무한 스크롤 조회 } 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 ba77c07..b57ce9f 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 @@ -1,6 +1,7 @@ package com.be.sportizebe.domain.post.service; import com.be.sportizebe.domain.post.dto.request.CreatePostRequest; +import com.be.sportizebe.domain.post.dto.response.CursorPageResponse; 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; @@ -12,6 +13,7 @@ import com.be.sportizebe.domain.user.exception.UserErrorCode; import com.be.sportizebe.domain.user.repository.UserRepository; import com.be.sportizebe.global.exception.CustomException; +import com.be.sportizebe.global.exception.GlobalErrorCode; import com.be.sportizebe.global.s3.enums.PathName; import com.be.sportizebe.global.s3.service.S3Service; import jakarta.transaction.Transactional; @@ -20,10 +22,13 @@ import org.springframework.cache.annotation.CacheEvict; import org.springframework.cache.annotation.Cacheable; import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; import org.springframework.web.multipart.MultipartFile; +import java.util.List; + @Slf4j @Service @RequiredArgsConstructor @@ -32,6 +37,7 @@ public class PostServiceImpl implements PostService { private final PostRepository postRepository; private final UserRepository userRepository; private final S3Service s3Service; + private static final int PAGE_SIZE = 10; @Override @CacheEvict(cacheNames = "postList", allEntries = true) @@ -91,4 +97,28 @@ public PostPageResponse getPosts(PostProperty property, Pageable pageable) { Page page = postRepository.findByProperty(property, pageable); return PostPageResponse.from(page); } + + + @Override + public CursorPageResponse getMyPostsCursor(User user, Long cursor) { + + if (user == null) { + throw new CustomException(GlobalErrorCode.UNAUTHORIZED); + } + // cursor 없으면 첫 페이지, cursor 있으면 다음 페이지 + List posts = (cursor == null) + ? postRepository.findTop11ByUserIdOrderByIdDesc(user.getId()) + : postRepository.findTop11ByUserIdAndIdLessThanOrderByIdDesc(user.getId(), cursor); + + boolean hasNext = posts.size() > PAGE_SIZE; + if (hasNext) posts = posts.subList(0, PAGE_SIZE); + + Long nextCursor = posts.isEmpty() ? null : posts.get(posts.size() - 1).getId(); + + return CursorPageResponse.of( + posts.stream().map(PostResponse::from).toList(), + hasNext ? nextCursor : null, + hasNext + ); + } } \ 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 0140a25..9a7275c 100644 --- a/src/main/java/com/be/sportizebe/global/config/RedisCacheConfig.java +++ b/src/main/java/com/be/sportizebe/global/config/RedisCacheConfig.java @@ -33,6 +33,7 @@ public CacheManager cacheManager(RedisConnectionFactory redisConnectionFactory) .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) // ❗ 타입 정보 절대 쓰지 않음 .build(); + // 캐시에 들어가는 값의 직렬화 방식 결정 // 기본 캐시(대부분)는 Object로 직렬화/역직렬화 Jackson2JsonRedisSerializer defaultValueSerializer = diff --git a/src/main/java/com/be/sportizebe/global/exception/GlobalErrorCode.java b/src/main/java/com/be/sportizebe/global/exception/GlobalErrorCode.java index 90f8662..24a5ba0 100644 --- a/src/main/java/com/be/sportizebe/global/exception/GlobalErrorCode.java +++ b/src/main/java/com/be/sportizebe/global/exception/GlobalErrorCode.java @@ -10,7 +10,8 @@ public enum GlobalErrorCode implements BaseErrorCode { INVALID_INPUT_VALUE("G001", "유효하지 않은 입력입니다.", HttpStatus.BAD_REQUEST), RESOURCE_NOT_FOUND("G002", "요청한 리소스를 찾을 수 없습니다.", HttpStatus.NOT_FOUND), - INTERNAL_SERVER_ERROR("G003", "서버 내부 오류가 발생했습니다.", HttpStatus.INTERNAL_SERVER_ERROR); + INTERNAL_SERVER_ERROR("G003", "서버 내부 오류가 발생했습니다.", HttpStatus.INTERNAL_SERVER_ERROR), + UNAUTHORIZED("G004", "로그인 인증이 필요합니다.", HttpStatus.UNAUTHORIZED); private final String code; private final String message;