-
Notifications
You must be signed in to change notification settings - Fork 0
✨ Feat: Club 무한스크롤 조회 API + MyPosts 커서 기반 조회 API 구현 #51
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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<BaseResponse<ClubResponse>> 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<BaseResponse<ClubResponse>> 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<BaseResponse<ClubImageResponse>> 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<BaseResponse<ClubResponse>> 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<BaseResponse<ClubResponse>> 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<BaseResponse<ClubImageResponse>> 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<BaseResponse<ClubScrollResponse>> 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<BaseResponse<ClubDetailResponse>> 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<BaseResponse<ClubScrollResponse>> 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)); | ||
| } | ||
|
Comment on lines
+86
to
+95
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: # Search for `@AuthenticationPrincipal` usage in the controller
rg -n "@AuthenticationPrincipal" src/main/java/com/be/sportizebe/domain/club/controller/ -C 2Repository: Sportize/be Length of output: 3073 🏁 Script executed: # Find the ClubService interface and implementation to check getMyClubsByScroll signature
fd "ClubService" src/main/java --type fRepository: Sportize/be Length of output: 196 🏁 Script executed: # Check the ClubServiceImpl for getMyClubsByScroll method
rg -n "getMyClubsByScroll" src/main/java --type=java -A 5Repository: Sportize/be Length of output: 1746 🏁 Script executed: # Check User class import and definition in ClubController
rg -n "import.*User" src/main/java/com/be/sportizebe/domain/club/controller/ClubController.javaRepository: Sportize/be Length of output: 166 🏁 Script executed: # Search for User entity class definition
fd "User.java" src/main/java --type f | head -5Repository: Sportize/be Length of output: 115 🏁 Script executed: # Look for Spring Security configuration
fd -e "java" src/main/java -path "*config*" -path "*security*" | head -10Repository: Sportize/be Length of output: 223 🏁 Script executed: # Search for Spring Security configuration
rg -l "SecurityConfig\|SecurityConfiguration\|SecurityFilterChain" src/main/java --type=javaRepository: Sportize/be Length of output: 37 🏁 Script executed: # Search for UserAuthInfo to understand if it's the principal type
rg -n "class UserAuthInfo" src/main/java --type=java -A 5Repository: Sportize/be Length of output: 661 🏁 Script executed: # Check how other controllers use `@AuthenticationPrincipal`
rg -n "@AuthenticationPrincipal" src/main/java --type=java -B 2 | head -30Repository: Sportize/be Length of output: 3554
Spring Security가 🐛 수정 제안 public ResponseEntity<BaseResponse<ClubScrollResponse>> getMyClubs(
`@Parameter`(description = "커서(마지막 clubId). 첫 조회는 null") `@RequestParam`(required = false) Long cursor,
`@Parameter`(description = "조회 개수", example = "10") `@RequestParam`(defaultValue = "10") int size,
- `@AuthenticationPrincipal` User user
+ `@AuthenticationPrincipal` UserAuthInfo userAuthInfo
) {
- ClubScrollResponse response = clubService.getMyClubsByScroll(cursor, size, user);
+ ClubScrollResponse response = clubService.getMyClubsByScroll(cursor, size, userAuthInfo.getId());
return ResponseEntity.ok(BaseResponse.success("내 동호회 조회 성공", response));
}서비스 레이어의 🤖 Prompt for AI Agents |
||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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() | ||
| ); | ||
| } | ||
|
Comment on lines
+8
to
+45
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 상세 조회 응답에 동호회장 정보 누락. 기존 🤖 Prompt for AI Agents |
||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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() | ||
| ); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<ClubListItemResponse> items, | ||
|
|
||
| @Schema(description = "다음 조회를 위한 커서 값 (마지막 clubId)", example = "15") | ||
| Long nextCursor, | ||
|
|
||
| @Schema(description = "다음 페이지 존재 여부", example = "true") | ||
| boolean hasNext | ||
| ) { | ||
| } |
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. countByClubId() 필요성 |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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<Club> clubs = clubRepository.findClubsByCursor(cursor, pageable); | ||
|
|
||
| boolean hasNext = clubs.size() > size; | ||
|
|
||
| if (hasNext) { | ||
| clubs = clubs.subList(0, size); | ||
| } | ||
|
|
||
| List<ClubListItemResponse> items = clubs.stream() | ||
| .map(club -> { | ||
| int memberCount = clubMemberRepository.countByClubId(club.getId()); | ||
| return ClubListItemResponse.from(club, memberCount); | ||
| }) | ||
| .toList(); | ||
|
Comment on lines
+145
to
+150
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. N+1 쿼리 문제: 각 동호회마다 개별 페이지 사이즈가 20이면, 목록 조회 1회 + 멤버 수 조회 20회 = 총 21개의 쿼리가 실행됩니다. 리포지토리 쿼리에서 🤖 Prompt for AI Agents |
||
|
|
||
| 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<Club> clubs = clubRepository.findMyClubsByCursor(user.getId(), cursor, pageable); | ||
|
|
||
| boolean hasNext = clubs.size() > size; | ||
|
|
||
| if (hasNext) { | ||
| clubs = clubs.subList(0, size); | ||
| } | ||
|
|
||
| List<ClubListItemResponse> 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); | ||
| } | ||
|
|
||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
인터페이스로 서비스로직 주입