Skip to content

✨ Feat: Club 무한스크롤 조회 API + MyPosts 커서 기반 조회 API 구현#51

Merged
angoroa merged 4 commits intodevelopfrom
feat/club-api
Feb 8, 2026
Merged

✨ Feat: Club 무한스크롤 조회 API + MyPosts 커서 기반 조회 API 구현#51
angoroa merged 4 commits intodevelopfrom
feat/club-api

Conversation

@angoroa
Copy link
Copy Markdown
Member

@angoroa angoroa commented Feb 8, 2026

#️⃣ Issue Number

  • closed #

📝 요약(Summary)

•	동호회 목록/내 동호회 목록을 커서 기반 무한스크롤로 조회하는 API 추가
•	내가 작성한 게시글 목록을 커서 기반 무한스크롤로 조회하는 API 추가
•	커서 페이징 공통 응답(CursorPageResponse) 도입 및 인증 예외(UNAUTHORIZED) 에러코드 추가

🛠️ PR 유형

어떤 변경 사항이 있나요?

  • 새로운 기능 추가
  • 버그 수정
  • CSS 등 사용자 UI 디자인 변경
  • 코드에 영향을 주지 않는 변경사항(오타 수정, 탭 사이즈 변경, 변수명 변경)
  • 코드 리팩토링
  • 주석 추가 및 수정
  • 문서 수정
  • 테스트 추가, 테스트 리팩토링
  • 빌드 부분 혹은 패키지 매니저 수정
  • 파일 혹은 폴더명 수정
  • 파일 혹은 폴더 삭제

📸스크린샷 (선택)

💬 공유사항 to 리뷰어

•	ClubScrollResponse(동호회) / CursorPageResponse(게시글)로 무한스크롤 응답 형태를 분리해두었습니다.

Summary by CodeRabbit

  • New Features
    • 동호회 상세 조회 추가(멤버 수·이미지·생성일 등 표시)
    • 동호회 목록 무한 스크롤 지원 및 다음 페이지 커서 제공
    • 사용자의 동호회 목록 무한 스크롤 조회 추가
    • 사용자의 개인 게시물 목록 무한 스크롤 조회 추가
    • 인증 필요 시 명확한 응답(로그인 인증 필요) 추가

@angoroa angoroa self-assigned this Feb 8, 2026
@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Feb 8, 2026

Walkthrough

동호회와 게시물에 대해 커서 기반 무한 스크롤 조회 API(컨트롤러·서비스·리포지토리)와 관련 DTO들(상세·목록·스크롤/제네릭 CursorPageResponse)이 추가되었고, 인증 실패용 에러 코드와 일부 포맷/임포트 정리가 포함되었습니다.

Changes

Cohort / File(s) Summary
동호회 컨트롤러
src/main/java/com/be/sportizebe/domain/club/controller/ClubController.java
서비스 타입을 ClubService로 변경하고 GET /api/clubs, GET /api/clubs/{clubId}, GET /api/clubs/me 커서 기반 조회 엔드포인트 추가(기존 생성/수정/이미지 엔드포인트 유지).
동호회 응답 DTO
src/main/java/com/be/sportizebe/domain/club/dto/response/ClubDetailResponse.java, .../ClubListItemResponse.java, .../ClubScrollResponse.java
상세, 목록 아이템, 스크롤 응답을 위한 Java record 추가 및 from 팩토리 메서드와 Swagger 주석 구현.
동호회 저장소
src/main/java/com/be/sportizebe/domain/club/repository/ClubRepository.java, .../ClubMemberRepository.java
커서 기반 조회 쿼리(findClubsByCursor, findMyClubsByCursor) 및 멤버 수 집계용 countByClubId 메서드 추가.
동호회 서비스
src/main/java/com/be/sportizebe/domain/club/service/ClubService.java, .../ClubServiceImpl.java
인터페이스·구현에 getClub, getClubsByScroll, getMyClubsByScroll 추가. 커서 페이징(PageRequest size+1), hasNext 산출, nextCursor 결정 및 ClubListItemResponse 매핑 구현.
게시물 컨트롤러·DTO·서비스·저장소
src/main/java/com/be/sportizebe/domain/post/controller/PostController.java, .../dto/response/CursorPageResponse.java, .../service/PostService.java, .../service/PostServiceImpl.java, .../repository/PostRepository.java
인증 사용자 게시물 조회용 GET /api/posts/me 추가. 제네릭 CursorPageResponse 레코드 추가, 리포지토리의 top11 쿼리와 서비스의 커서 페이징(PAGE_SIZE=10) 구현.
글로벌 에러·설정·포맷
src/main/java/com/be/sportizebe/global/exception/GlobalErrorCode.java, .../config/RedisCacheConfig.java
UNAUTHORIZED 에러 코드 추가 및 RedisCacheConfig 포맷(빈 줄) 변경.

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>
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~22 minutes

Possibly related PRs

Poem

🐇 깡총깡총 커서 따라가며
동호회 줄 지어 걷고 게시물도 헤엄쳐요
새 응답 DTO에 당근 한 조각,
nextCursor 들고 폴짝폴짝,
토끼가 축하해요 — 코드도 기쁘다!

🚥 Pre-merge checks | ✅ 2 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 31.58% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed PR 제목은 변경사항의 핵심을 명확하게 나타냅니다. 동호회(Club) 무한스크롤 조회 API와 MyPosts 커서 기반 조회 API 구현이라는 두 가지 주요 기능 추가를 간결하게 요약하고 있으며, 제공된 코드 변경사항과 일치합니다.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/club-api

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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: getClubsByScrollgetMyClubsByScroll의 페이지네이션 로직이 중복됩니다.

두 메서드의 차이점은 리포지토리 호출 한 줄뿐이고, 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.java Lines 24). 인증 관련 에러는 AuthErrorCode에 통합하는 것이 일관성 측면에서 더 좋습니다.

src/main/java/com/be/sportizebe/domain/post/repository/PostRepository.java (1)

14-18: 페이지 크기가 메서드 이름에 하드코딩되어 있습니다.

findTop11PostServiceImpl.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
+        );

Comment on lines +8 to +45
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()
);
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

상세 조회 응답에 동호회장 정보 누락.

기존 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.

Comment on lines +106 to +107
// 새 이미지 S3에 업로드
String clubImageUrl = s3Service.uploadFile(PathName.CLUB, image);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

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.

Suggested change
// 새 이미지 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.

Comment on lines +139 to +144
List<ClubListItemResponse> items = clubs.stream()
.map(club -> {
int memberCount = clubMemberRepository.countByClubId(club.getId());
return ClubListItemResponse.from(club, memberCount);
})
.toList();
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

N+1 쿼리 문제: 각 동호회마다 개별 countByClubId 쿼리가 실행됩니다.

페이지 사이즈가 20이면, 목록 조회 1회 + 멤버 수 조회 20회 = 총 21개의 쿼리가 실행됩니다. getMyClubsByScroll (Line 166-171)도 동일한 문제를 가지고 있습니다.

리포지토리 쿼리에서 JOINGROUP 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.

Comment on lines +1 to +19
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);
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ 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.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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: getClubsByScrollgetMyClubsByScroll의 중복 로직을 공통 메서드로 추출하세요.

두 메서드는 리포지토리 호출 부분만 다르고 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: CursorPageResponse DTO가 두 패키지에 중복으로 존재합니다

CursorPageResponsecom.be.sportizebe.domain.post.dto.responsecom.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와 같이 분리하는 것도 고려해 보세요.

Comment on lines +75 to +84
@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));
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

@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.

Comment on lines +103 to +107
public CursorPageResponse<PostResponse> getMyPostsCursor(User user, Long cursor) {

if (user == null) {
throw new CustomException(GlobalErrorCode.UNAUTHORIZED);
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

인터페이스로 서비스로직 주입

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

countByClubId() 필요성

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

캐시 메모리를 먼저 뒤져야 하기 때문에 userDetail조회를 userAuthInfo로 교체

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

앞에 캐시 메모리를 먼저 뒤져야 하기 때문에 userDetail조회를 userAuthInfo로 교체 하고 타입 일치시켜주세요

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

getClubsdefaultValue = "20", getMyClubsdefaultValue = "10"입니다. 의도된 것이 아니라면 통일하는 것이 좋습니다. 또한 size에 대한 상한 검증이 없어 매우 큰 값이 들어올 경우 성능 문제가 발생할 수 있습니다.

@Max 어노테이션이나 서비스 레이어에서 상한을 제한하는 것을 권장합니다.

Also applies to: 86-95

Comment on lines +86 to +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));
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

# Search for `@AuthenticationPrincipal` usage in the controller
rg -n "@AuthenticationPrincipal" src/main/java/com/be/sportizebe/domain/club/controller/ -C 2

Repository: Sportize/be

Length of output: 3073


🏁 Script executed:

# Find the ClubService interface and implementation to check getMyClubsByScroll signature
fd "ClubService" src/main/java --type f

Repository: Sportize/be

Length of output: 196


🏁 Script executed:

# Check the ClubServiceImpl for getMyClubsByScroll method
rg -n "getMyClubsByScroll" src/main/java --type=java -A 5

Repository: 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.java

Repository: Sportize/be

Length of output: 166


🏁 Script executed:

# Search for User entity class definition
fd "User.java" src/main/java --type f | head -5

Repository: Sportize/be

Length of output: 115


🏁 Script executed:

# Look for Spring Security configuration
fd -e "java" src/main/java -path "*config*" -path "*security*" | head -10

Repository: Sportize/be

Length of output: 223


🏁 Script executed:

# Search for Spring Security configuration
rg -l "SecurityConfig\|SecurityConfiguration\|SecurityFilterChain" src/main/java --type=java

Repository: 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 5

Repository: 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 -30

Repository: 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.

@angoroa angoroa merged commit 63c9a08 into develop Feb 8, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants