✨ Feat: Club 무한스크롤 조회 API + MyPosts 커서 기반 조회 API 구현#51
Conversation
Walkthrough동호회와 게시물에 대해 커서 기반 무한 스크롤 조회 API(컨트롤러·서비스·리포지토리)와 관련 DTO들(상세·목록·스크롤/제네릭 CursorPageResponse)이 추가되었고, 인증 실패용 에러 코드와 일부 포맷/임포트 정리가 포함되었습니다. Changes
Sequence Diagram(s)sequenceDiagram
participant Client
participant ClubController as Controller
participant ClubService as Service
participant ClubRepository as Repository
participant ClubMemberRepo as MemberRepo
participant Database
Client->>Controller: GET /api/clubs?cursor={cursor}&size={size}
Controller->>Service: getClubsByScroll(cursor, size)
Service->>Repository: findClubsByCursor(cursor, PageRequest(size+1))
Repository->>Database: SELECT clubs WHERE id < :cursor ORDER BY id DESC LIMIT size+1
Database-->>Repository: List<Club>
Repository-->>Service: List<Club>
Service->>MemberRepo: countByClubId(clubId) [for each club]
MemberRepo->>Database: SELECT COUNT(*) FROM club_member WHERE club_id = :clubId
Database-->>MemberRepo: counts
MemberRepo-->>Service: member counts
Service->>Service: determine hasNext, nextCursor, trim items to size
Service->>Controller: ClubScrollResponse(items, nextCursor, hasNext)
Controller->>Client: 200 OK BaseResponse<ClubScrollResponse>
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~22 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing touches
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 7
🤖 Fix all issues with AI agents
In `@src/main/java/com/be/sportizebe/domain/club/controller/ClubController.java`:
- Line 30: The controller is injecting the concrete ClubServiceImpl which
violates DIP; change the field and constructor injection in ClubController to
depend on the ClubService interface instead of ClubServiceImpl (replace usages
of the ClubServiceImpl-typed field named clubService with the ClubService type
and update the constructor parameter to ClubService) so Spring can inject the
implementation via the interface.
In
`@src/main/java/com/be/sportizebe/domain/club/dto/response/ClubDetailResponse.java`:
- Around line 8-45: ClubDetailResponse is missing the club leader's nickname
field present in ClubResponse; add a String leaderNickname field with
appropriate `@Schema` description to the record declaration and populate it in the
static factory ClubDetailResponse.from(Club club, int memberCount) by extracting
the leader nickname from the Club entity (e.g., club.getLeader().getNickname()
or club.getLeaderNickname() depending on your model), and include that value in
the record constructor call.
In `@src/main/java/com/be/sportizebe/domain/club/repository/ClubRepository.java`:
- Around line 3-12: The import block in ClubRepository contains duplicate
imports for Club and JpaRepository; edit the ClubRepository interface to remove
the repeated import lines so each type is imported only once (keep a single
import for com.be.sportizebe.domain.club.entity.Club and a single import for
org.springframework.data.jpa.repository.JpaRepository) while retaining the other
required imports (Pageable, Query, Param, List) used by the repository methods.
In `@src/main/java/com/be/sportizebe/domain/club/service/ClubServiceImpl.java`:
- Around line 139-144: ClubServiceImpl currently triggers an N+1 query by
calling clubMemberRepository.countByClubId(...) inside the stream mapping (and
similarly in getMyClubsByScroll), causing one count query per club; replace this
with a single aggregated query from the repository that returns clubs with their
member counts (e.g., a custom repo method using JOIN + GROUP BY or a DTO
projection) and map the returned tuples/DTOs to ClubListItemResponse instead of
calling countByClubId per club; alternatively add an `@EntityGraph/projection`
method in the repository to fetch member counts with the initial club query and
update the stream in ClubServiceImpl to use that aggregated result.
- Around line 106-107: The updateClubImage method calls
s3Service.uploadFile(PathName.CLUB, image) without validating the MultipartFile
image; add a guard (image != null && !image.isEmpty()) before calling
s3Service.uploadFile and only update the club image URL when the check passes,
otherwise handle the invalid input (e.g., throw a
BadRequest/IllegalArgumentException or return without upload) so updateClubImage
cannot throw a NullPointerException when image is null. Ensure you reference
updateClubImage, s3Service.uploadFile, PathName.CLUB and the image parameter
when making the fix.
- Around line 73-75: 클럽 리더 ID 비교가 Long 객체를 참조 비교(!=)하고 있어 127 초과 값에서 오동작하므로,
ClubServiceImpl 내 해당 비교문(club.getLeader().getId() != user.getId())을 값 비교로 변경하세요
(예: Objects.equals 또는 Long#equals 사용)하고 동일한 수정을 updateClubImage에 있는 같은 패턴에도 적용하여
값 기반 비교로 바꿔 정상적인 권한 검사를 보장하세요.
In
`@src/main/java/com/be/sportizebe/domain/post/dto/response/CursorPageResponse.java`:
- Around line 1-19: Duplicate record classes
com.be.sportizebe.domain.post.dto.response.CursorPageResponse and
com.be.sportizebe.domain.chat.dto.response.CursorPageResponse should be
consolidated into a single shared type: create a generic record
CursorPageResponse<T> under a common package (e.g.,
com.be.sportizebe.global.dto.response) preserving the existing fields and
`@Schema` annotations, then remove the duplicate classes; update all
references/imports in both post and chat code to import the new
com.be.sportizebe.global.dto.response.CursorPageResponse and ensure factory
method of(List<T>, Long, boolean) remains available.
🧹 Nitpick comments (5)
src/main/java/com/be/sportizebe/domain/club/controller/ClubController.java (1)
46-47:size파라미터에 상한값 검증이 없습니다.클라이언트가
size=100000같은 값을 전달하면 대량 데이터를 한 번에 조회하게 되어 성능 문제가 발생할 수 있습니다.@Max어노테이션이나 서비스 레이어에서 상한값을 제한하는 것을 권장합니다.♻️ 수정 예시 (컨트롤러)
+import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Positive; ... - `@RequestParam`(defaultValue = "10") int size, + `@RequestParam`(defaultValue = "10") `@Positive` `@Max`(50) int size,Also applies to: 56-57
src/main/java/com/be/sportizebe/domain/club/service/ClubServiceImpl.java (1)
124-176:getClubsByScroll와getMyClubsByScroll의 페이지네이션 로직이 중복됩니다.두 메서드의 차이점은 리포지토리 호출 한 줄뿐이고, hasNext 판별, subList 트리밍, 매핑, nextCursor 계산 로직이 모두 동일합니다. 공통 로직을 private 메서드로 추출하면 유지보수성이 향상됩니다.
♻️ 리팩토링 제안
+ private ClubScrollResponse toScrollResponse(List<Club> clubs, int size) { + 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); + }그리고 각 메서드에서 호출:
public ClubScrollResponse getClubsByScroll(Long cursor, int size) { Pageable pageable = PageRequest.of(0, size + 1); List<Club> clubs = clubRepository.findClubsByCursor(cursor, pageable); - boolean hasNext = clubs.size() > size; - ... - return new ClubScrollResponse(items, nextCursor, hasNext); + return toScrollResponse(clubs, size); }src/main/java/com/be/sportizebe/global/exception/GlobalErrorCode.java (1)
14-14:AuthErrorCode에 이미 유사한 에러코드가 존재합니다.
AuthErrorCode.AUTHENTICATION_NOT_FOUND("AUTH_4008", "로그인이 필요합니다.", HttpStatus.UNAUTHORIZED)와 목적이 중복됩니다 (relevant snippet:AuthErrorCode.javaLines 24). 인증 관련 에러는AuthErrorCode에 통합하는 것이 일관성 측면에서 더 좋습니다.src/main/java/com/be/sportizebe/domain/post/repository/PostRepository.java (1)
14-18: 페이지 크기가 메서드 이름에 하드코딩되어 있습니다.
findTop11이PostServiceImpl.PAGE_SIZE = 10에 의존하고 있어, 페이지 크기가 변경되면 리포지토리 메서드도 함께 수정해야 합니다.Pageable을 파라미터로 받으면 유연하게 대응할 수 있습니다.♻️ Pageable 기반으로 변경하는 제안
- // 첫 페이지: 11개(10 + 1) - List<Post> findTop11ByUserIdOrderByIdDesc(Long userId); - - // 다음 페이지: 11개(10 + 1) - List<Post> findTop11ByUserIdAndIdLessThanOrderByIdDesc(Long userId, Long cursor); + List<Post> findByUserIdOrderByIdDesc(Long userId, Pageable pageable); + + List<Post> findByUserIdAndIdLessThanOrderByIdDesc(Long userId, Long cursor, Pageable pageable);서비스에서는
PageRequest.of(0, PAGE_SIZE + 1)로 호출하면 됩니다.src/main/java/com/be/sportizebe/domain/post/service/PostServiceImpl.java (1)
96-117: 커서 기반 페이징 로직이 잘 구현되어 있습니다.N+1 fetch 패턴으로
hasNext를 판별하는 방식이 적절합니다.한 가지 사소한 개선점: Line 110에서
nextCursor를 계산한 뒤 Line 114에서hasNext가 false일 때 다시 null로 설정하고 있습니다. 아래처럼 단순화할 수 있습니다.♻️ nextCursor 계산 단순화
- Long nextCursor = posts.isEmpty() ? null : posts.get(posts.size() - 1).getId(); - - return CursorPageResponse.of( - posts.stream().map(PostResponse::from).toList(), - hasNext ? nextCursor : null, - hasNext - ); + Long nextCursor = hasNext ? posts.get(posts.size() - 1).getId() : null; + + return CursorPageResponse.of( + posts.stream().map(PostResponse::from).toList(), + nextCursor, + hasNext + );
src/main/java/com/be/sportizebe/domain/club/controller/ClubController.java
Outdated
Show resolved
Hide resolved
| 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() | ||
| ); | ||
| } |
There was a problem hiding this comment.
상세 조회 응답에 동호회장 정보 누락.
기존 ClubResponse에는 leaderNickname 필드가 포함되어 있는데, 더 상세한 정보를 제공해야 하는 ClubDetailResponse에는 동호회장 정보가 빠져 있습니다. 의도적인 설계라면 무시해 주세요.
🤖 Prompt for AI Agents
In
`@src/main/java/com/be/sportizebe/domain/club/dto/response/ClubDetailResponse.java`
around lines 8 - 45, ClubDetailResponse is missing the club leader's nickname
field present in ClubResponse; add a String leaderNickname field with
appropriate `@Schema` description to the record declaration and populate it in the
static factory ClubDetailResponse.from(Club club, int memberCount) by extracting
the leader nickname from the Club entity (e.g., club.getLeader().getNickname()
or club.getLeaderNickname() depending on your model), and include that value in
the record constructor call.
src/main/java/com/be/sportizebe/domain/club/repository/ClubRepository.java
Show resolved
Hide resolved
src/main/java/com/be/sportizebe/domain/club/service/ClubServiceImpl.java
Outdated
Show resolved
Hide resolved
| // 새 이미지 S3에 업로드 | ||
| String clubImageUrl = s3Service.uploadFile(PathName.CLUB, image); |
There was a problem hiding this comment.
image 파라미터에 대한 null/empty 검증이 누락되어 있습니다.
createClub (Line 44)에서는 image != null && !image.isEmpty() 검증을 수행하지만, updateClubImage에서는 검증 없이 바로 uploadFile을 호출합니다. image가 null이면 NullPointerException이 발생합니다.
🛡️ 수정 제안
+ if (image == null || image.isEmpty()) {
+ throw new CustomException(ClubErrorCode.CLUB_IMAGE_REQUIRED);
+ }
+
// 새 이미지 S3에 업로드
String clubImageUrl = s3Service.uploadFile(PathName.CLUB, image);📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| // 새 이미지 S3에 업로드 | |
| String clubImageUrl = s3Service.uploadFile(PathName.CLUB, image); | |
| if (image == null || image.isEmpty()) { | |
| throw new CustomException(ClubErrorCode.CLUB_IMAGE_REQUIRED); | |
| } | |
| // 새 이미지 S3에 업로드 | |
| String clubImageUrl = s3Service.uploadFile(PathName.CLUB, image); |
🤖 Prompt for AI Agents
In `@src/main/java/com/be/sportizebe/domain/club/service/ClubServiceImpl.java`
around lines 106 - 107, The updateClubImage method calls
s3Service.uploadFile(PathName.CLUB, image) without validating the MultipartFile
image; add a guard (image != null && !image.isEmpty()) before calling
s3Service.uploadFile and only update the club image URL when the check passes,
otherwise handle the invalid input (e.g., throw a
BadRequest/IllegalArgumentException or return without upload) so updateClubImage
cannot throw a NullPointerException when image is null. Ensure you reference
updateClubImage, s3Service.uploadFile, PathName.CLUB and the image parameter
when making the fix.
| List<ClubListItemResponse> items = clubs.stream() | ||
| .map(club -> { | ||
| int memberCount = clubMemberRepository.countByClubId(club.getId()); | ||
| return ClubListItemResponse.from(club, memberCount); | ||
| }) | ||
| .toList(); |
There was a problem hiding this comment.
N+1 쿼리 문제: 각 동호회마다 개별 countByClubId 쿼리가 실행됩니다.
페이지 사이즈가 20이면, 목록 조회 1회 + 멤버 수 조회 20회 = 총 21개의 쿼리가 실행됩니다. getMyClubsByScroll (Line 166-171)도 동일한 문제를 가지고 있습니다.
리포지토리 쿼리에서 JOIN과 GROUP BY를 사용하여 동호회 목록과 멤버 수를 한 번의 쿼리로 가져오거나, @EntityGraph 또는 별도의 DTO projection 쿼리를 사용하는 것을 권장합니다.
🤖 Prompt for AI Agents
In `@src/main/java/com/be/sportizebe/domain/club/service/ClubServiceImpl.java`
around lines 139 - 144, ClubServiceImpl currently triggers an N+1 query by
calling clubMemberRepository.countByClubId(...) inside the stream mapping (and
similarly in getMyClubsByScroll), causing one count query per club; replace this
with a single aggregated query from the repository that returns clubs with their
member counts (e.g., a custom repo method using JOIN + GROUP BY or a DTO
projection) and map the returned tuples/DTOs to ClubListItemResponse instead of
calling countByClubId per club; alternatively add an `@EntityGraph/projection`
method in the repository to fetch member counts with the initial club query and
update the stream in ClubServiceImpl to use that aggregated result.
| 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<T>( | ||
|
|
||
| @Schema(description = "조회된 데이터 목록") | ||
| List<T> items, | ||
|
|
||
| @Schema(description = "다음 조회를 위한 커서 값 (없으면 null)", example = "123") | ||
| Long nextCursor, | ||
|
|
||
| @Schema(description = "다음 페이지 존재 여부", example = "true") | ||
| boolean hasNext | ||
| ) { | ||
| public static <T> CursorPageResponse<T> of(List<T> items, Long nextCursor, boolean hasNext) { | ||
| return new CursorPageResponse<>(items, nextCursor, hasNext); | ||
| } |
There was a problem hiding this comment.
🛠️ Refactor suggestion | 🟠 Major
chat 도메인에 동일한 CursorPageResponse가 이미 존재합니다.
com.be.sportizebe.domain.chat.dto.response.CursorPageResponse<T>와 구조가 동일합니다 (relevant snippet: chat/dto/response/CursorPageResponse.java Lines 7-13). 공통 패키지(예: global.dto.response)로 통합하여 중복을 제거하는 것을 권장합니다.
🤖 Prompt for AI Agents
In
`@src/main/java/com/be/sportizebe/domain/post/dto/response/CursorPageResponse.java`
around lines 1 - 19, Duplicate record classes
com.be.sportizebe.domain.post.dto.response.CursorPageResponse and
com.be.sportizebe.domain.chat.dto.response.CursorPageResponse should be
consolidated into a single shared type: create a generic record
CursorPageResponse<T> under a common package (e.g.,
com.be.sportizebe.global.dto.response) preserving the existing fields and
`@Schema` annotations, then remove the duplicate classes; update all
references/imports in both post and chat code to import the new
com.be.sportizebe.global.dto.response.CursorPageResponse and ensure factory
method of(List<T>, Long, boolean) remains available.
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Fix all issues with AI agents
In `@src/main/java/com/be/sportizebe/domain/post/controller/PostController.java`:
- Around line 75-84: The getMyPosts handler uses `@AuthenticationPrincipal` User
user which can be null because the security principal is UserAuthInfo; change
the parameter to `@AuthenticationPrincipal` UserAuthInfo userAuthInfo (or extract
the needed user id from userAuthInfo) and update the call to
postService.getMyPostsCursor(...) to pass the correct principal type or user
identifier so authenticated requests resolve correctly in
PostController.getMyPosts and downstream service methods.
In `@src/main/java/com/be/sportizebe/domain/post/service/PostServiceImpl.java`:
- Around line 103-107: The getMyPostsCursor method in PostServiceImpl currently
accepts a User entity directly which is inconsistent with other endpoints and
will break when the Security Principal is a UserAuthInfo; change the service
signature to accept UserAuthInfo (or the userId) instead, extract the userId
from UserAuthInfo and load the User entity inside the service (e.g. via the
existing userRepository or userService) before proceeding; update
null/authorization checks to use the resolved User or throw
CustomException(GlobalErrorCode.UNAUTHORIZED) if the user cannot be found;
ensure references to getMyPostsCursor, UserAuthInfo, and User are updated
accordingly so controller and service call sites pass the principal
consistently.
🧹 Nitpick comments (6)
src/main/java/com/be/sportizebe/domain/club/service/ClubServiceImpl.java (3)
118-182: 들여쓰기가 기존 코드(2-space)와 신규 코드(4/8-space)에서 불일치합니다.Line 118부터 새로 추가된 코드가 기존 파일의 2-space 들여쓰기 컨벤션과 다르게 4-space/8-space를 혼용하고 있습니다. 특히
updateClubImage메서드의 마지막 부분(Line 118-119)도 영향을 받았습니다. 전체 파일의 일관성을 위해 기존 2-space 스타일에 맞춰 정리해 주세요.
120-129: 클래스 레벨에 이미@Transactional(readOnly = true)가 선언되어 있어 메서드 레벨 선언이 중복됩니다.Line 28의 클래스 레벨
@Transactional(readOnly = true)이 이미 적용되어 있으므로, Line 121, 131, 159의 메서드 레벨@Transactional(readOnly = true)는 불필요합니다. 제거하면 코드가 간결해집니다.
130-182:getClubsByScroll과getMyClubsByScroll의 중복 로직을 공통 메서드로 추출하세요.두 메서드는 리포지토리 호출 부분만 다르고
hasNext판단,subList트리밍,ClubListItemResponse매핑,nextCursor계산 로직이 완전히 동일합니다. 공통 헬퍼 메서드를 추출하면 유지보수성이 향상됩니다.♻️ 리팩토링 예시
+ private ClubScrollResponse buildScrollResponse(List<Club> clubs, int size) { + 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); + } + `@Override` - `@Transactional`(readOnly = true) public ClubScrollResponse getClubsByScroll(Long cursor, int size) { 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(); - Long nextCursor = items.isEmpty() ? null : items.get(items.size() - 1).clubId(); - return new ClubScrollResponse(items, nextCursor, hasNext); + return buildScrollResponse(clubs, size); } `@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); + return buildScrollResponse(clubs, size); }src/main/java/com/be/sportizebe/domain/post/service/PostServiceImpl.java (2)
109-114:subList반환값은 구조 변경 불가한 뷰(view)입니다
posts.subList(0, PAGE_SIZE)는 원본 리스트의 뷰를 반환합니다. 현재 코드에서는 읽기 전용으로만 사용하므로 문제가 없지만, 향후 이 리스트를 수정하려고 하면ConcurrentModificationException이 발생할 수 있습니다. 방어적으로new ArrayList<>(posts.subList(0, PAGE_SIZE))를 사용하는 것도 고려해 볼 수 있습니다.
100-123:CursorPageResponseDTO가 두 패키지에 중복으로 존재합니다
CursorPageResponse가com.be.sportizebe.domain.post.dto.response와com.be.sportizebe.domain.chat.dto.response패키지에 서로 다른 구현으로 중복 정의되어 있습니다.
- chat 버전: Lombok
@Builder를사용한 클래스, 정적 팩토리 메서드 없음- post 버전: Java record, static of() 팩토리 메서드, Swagger 주석 포함
현재는 각 도메인이 자신의 버전을 독립적으로 사용하고 있지만, 동일한 구조의 제네릭 DTO를 중복 정의하는 것은 코드 유지보수성을 낮춥니다. 공통 패키지(예:
global.dto.response)로 통합하고 post 버전의 record 구현과 factory 메서드를 표준으로 적용하는 것을 권장합니다.src/main/java/com/be/sportizebe/domain/post/controller/PostController.java (1)
67-75:GET /posts/{property}와GET /posts/me경로 충돌 주의
/api/posts/me는/api/posts/{property}의 패턴과 겹칩니다. Spring MVC는 리터럴 경로를 path variable보다 우선하므로 정상 동작하지만, 향후 유지보수 시 혼란을 줄 수 있습니다./api/posts/me를/api/my/posts또는/api/users/me/posts와 같이 분리하는 것도 고려해 보세요.
| @GetMapping("/posts/me") | ||
| @Operation(summary = "내 게시글 목록 조회", description = "로그인한 사용자가 작성한 게시글을 최신순으로 무한 스크롤(커서 기반) 조회합니다.") | ||
| public ResponseEntity<BaseResponse<CursorPageResponse<PostResponse>>> getMyPosts( | ||
| @Parameter(description = "커서(마지막으로 조회된 게시글 ID). 첫 요청은 생략", example = "123") | ||
| @RequestParam(required = false) Long cursor, | ||
| @AuthenticationPrincipal User user) | ||
| { | ||
| CursorPageResponse<PostResponse> response = postService.getMyPostsCursor(user, cursor); | ||
| return ResponseEntity.ok(BaseResponse.success("내 게시글 목록 조회 성공", response)); | ||
| } |
There was a problem hiding this comment.
@AuthenticationPrincipal User user — Principal 타입 불일치 가능성 (Critical)
다른 모든 엔드포인트에서는 @AuthenticationPrincipal UserAuthInfo userAuthInfo를 사용하고 있습니다. Security Context에 저장된 Principal이 UserAuthInfo 타입이라면, User 타입으로는 resolve되지 않아 null이 주입됩니다. 서비스 레이어에서 null 체크 후 UNAUTHORIZED 예외를 던지게 되어, 인증된 사용자도 항상 401 응답을 받게 됩니다.
제안된 수정사항
`@GetMapping`("/posts/me")
`@Operation`(summary = "내 게시글 목록 조회", description = "로그인한 사용자가 작성한 게시글을 최신순으로 무한 스크롤(커서 기반) 조회합니다.")
public ResponseEntity<BaseResponse<CursorPageResponse<PostResponse>>> getMyPosts(
`@Parameter`(description = "커서(마지막으로 조회된 게시글 ID). 첫 요청은 생략", example = "123")
`@RequestParam`(required = false) Long cursor,
- `@AuthenticationPrincipal` User user)
+ `@AuthenticationPrincipal` UserAuthInfo userAuthInfo)
{
- CursorPageResponse<PostResponse> response = postService.getMyPostsCursor(user, cursor);
+ CursorPageResponse<PostResponse> response = postService.getMyPostsCursor(userAuthInfo.getId(), cursor);
return ResponseEntity.ok(BaseResponse.success("내 게시글 목록 조회 성공", response));
}🤖 Prompt for AI Agents
In `@src/main/java/com/be/sportizebe/domain/post/controller/PostController.java`
around lines 75 - 84, The getMyPosts handler uses `@AuthenticationPrincipal` User
user which can be null because the security principal is UserAuthInfo; change
the parameter to `@AuthenticationPrincipal` UserAuthInfo userAuthInfo (or extract
the needed user id from userAuthInfo) and update the call to
postService.getMyPostsCursor(...) to pass the correct principal type or user
identifier so authenticated requests resolve correctly in
PostController.getMyPosts and downstream service methods.
| public CursorPageResponse<PostResponse> getMyPostsCursor(User user, Long cursor) { | ||
|
|
||
| if (user == null) { | ||
| throw new CustomException(GlobalErrorCode.UNAUTHORIZED); | ||
| } |
There was a problem hiding this comment.
User 엔티티 직접 전달 방식이 다른 엔드포인트와 불일치
이 메서드는 User 엔티티를 직접 받지만, 같은 컨트롤러의 다른 엔드포인트들은 UserAuthInfo를 통해 userId만 전달합니다. PostController에서 @AuthenticationPrincipal User user로 주입하고 있는데, Security Context에 저장된 Principal 타입이 UserAuthInfo라면 User로 resolve되지 않아 항상 null이 주입될 수 있습니다. 이 경우 Line 105-107의 null 체크에 항상 걸리게 됩니다.
다른 엔드포인트와 동일하게 UserAuthInfo에서 userId를 추출한 뒤, 서비스 레이어에서 사용자를 조회하는 패턴으로 통일하는 것을 권장합니다.
제안된 수정사항
서비스 메서드 시그니처 변경:
-public CursorPageResponse<PostResponse> getMyPostsCursor(User user, Long cursor) {
- if (user == null) {
- throw new CustomException(GlobalErrorCode.UNAUTHORIZED);
- }
+public CursorPageResponse<PostResponse> getMyPostsCursor(Long userId, Long cursor) {
+ User user = userRepository.findById(userId)
+ .orElseThrow(() -> new CustomException(UserErrorCode.USER_NOT_FOUND));🤖 Prompt for AI Agents
In `@src/main/java/com/be/sportizebe/domain/post/service/PostServiceImpl.java`
around lines 103 - 107, The getMyPostsCursor method in PostServiceImpl currently
accepts a User entity directly which is inconsistent with other endpoints and
will break when the Security Principal is a UserAuthInfo; change the service
signature to accept UserAuthInfo (or the userId) instead, extract the userId
from UserAuthInfo and load the User entity inside the service (e.g. via the
existing userRepository or userService) before proceeding; update
null/authorization checks to use the resolved User or throw
CustomException(GlobalErrorCode.UNAUTHORIZED) if the user cannot be found;
ensure references to getMyPostsCursor, UserAuthInfo, and User are updated
accordingly so controller and service call sites pass the principal
consistently.
There was a problem hiding this comment.
캐시 메모리를 먼저 뒤져야 하기 때문에 userDetail조회를 userAuthInfo로 교체
There was a problem hiding this comment.
앞에 캐시 메모리를 먼저 뒤져야 하기 때문에 userDetail조회를 userAuthInfo로 교체 하고 타입 일치시켜주세요
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Fix all issues with AI agents
In `@src/main/java/com/be/sportizebe/domain/club/controller/ClubController.java`:
- Around line 86-95: The getMyClubs endpoint is binding the wrong principal
type; replace the `@AuthenticationPrincipal` User user parameter in getMyClubs
with `@AuthenticationPrincipal` UserAuthInfo userAuthInfo (matching other
controllers like createClub/updateClub/updateClubImage) and pass the
authenticated user's id into the service; also update the service method
signature getMyClubsByScroll(cursor, size, user) to accept a Long userId (or
overload it) so the controller calls getMyClubsByScroll(cursor, size,
userAuthInfo.getId()) to avoid principal type mismatches and null injection.
🧹 Nitpick comments (2)
src/main/java/com/be/sportizebe/domain/club/repository/ClubRepository.java (1)
14-29: JPQL 키워드 대소문자 스타일이 일관되지 않습니다.
findClubsByCursor는 대문자(SELECT,FROM,WHERE),findMyClubsByCursor는 소문자(select,from,where)를 사용하고 있습니다. 한쪽으로 통일하는 것이 좋습니다.src/main/java/com/be/sportizebe/domain/club/controller/ClubController.java (1)
64-75:size파라미터의 기본값이 엔드포인트마다 다릅니다 (20 vs 10).
getClubs는defaultValue = "20",getMyClubs는defaultValue = "10"입니다. 의도된 것이 아니라면 통일하는 것이 좋습니다. 또한size에 대한 상한 검증이 없어 매우 큰 값이 들어올 경우 성능 문제가 발생할 수 있습니다.
@Max어노테이션이나 서비스 레이어에서 상한을 제한하는 것을 권장합니다.Also applies to: 86-95
| @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)); | ||
| } |
There was a problem hiding this comment.
🧩 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
@AuthenticationPrincipal의 타입이 다른 엔드포인트와 불일치합니다.
getMyClubs에서 @AuthenticationPrincipal User user를 사용하고 있으나, 동일 컨트롤러의 다른 엔드포인트(createClub, updateClub, updateClubImage)뿐 아니라 전체 애플리케이션의 모든 컨트롤러(UserController, LikeController, NotificationController 등)에서는 모두 @AuthenticationPrincipal UserAuthInfo userAuthInfo를 사용합니다.
Spring Security가 UserAuthInfo를 principal로 제공하도록 설정되어 있기 때문에, User 타입으로 바인딩을 시도하면 타입 불일치로 인해 바인딩 실패 또는 null 주입이 발생합니다.
🐛 수정 제안
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));
}서비스 레이어의 getMyClubsByScroll 메서드 시그니처도 User 대신 Long userId를 받도록 함께 수정이 필요합니다.
🤖 Prompt for AI Agents
In `@src/main/java/com/be/sportizebe/domain/club/controller/ClubController.java`
around lines 86 - 95, The getMyClubs endpoint is binding the wrong principal
type; replace the `@AuthenticationPrincipal` User user parameter in getMyClubs
with `@AuthenticationPrincipal` UserAuthInfo userAuthInfo (matching other
controllers like createClub/updateClub/updateClubImage) and pass the
authenticated user's id into the service; also update the service method
signature getMyClubsByScroll(cursor, size, user) to accept a Long userId (or
overload it) so the controller calls getMyClubsByScroll(cursor, size,
userAuthInfo.getId()) to avoid principal type mismatches and null injection.
#️⃣ Issue Number
📝 요약(Summary)
🛠️ PR 유형
어떤 변경 사항이 있나요?
📸스크린샷 (선택)
💬 공유사항 to 리뷰어
Summary by CodeRabbit