Skip to content

✨ Feat: 동호회 관련 기능 구현 및 채팅 구조 최적화#33

Merged
imjuyongp merged 6 commits intodevelopfrom
feat/club
Jan 28, 2026
Merged

✨ Feat: 동호회 관련 기능 구현 및 채팅 구조 최적화#33
imjuyongp merged 6 commits intodevelopfrom
feat/club

Conversation

@imjuyongp
Copy link
Copy Markdown
Member

@imjuyongp imjuyongp commented Jan 28, 2026

#️⃣ Issue Number

📝 요약(Summary)

ChatRoom.java — 필드 3개 제거

  • name 제거 (NOTE → post.getTitle(), GROUP → club.getName()으로 파생)
  • maxMembers 제거 (NOTE → 고정 2명, GROUP → club.getMaxMembers()로 파생)
  • hostUser 제거 (post.getUser()로 파생)

ChatRoomRepository.java — 쿼리 메서드 변경

  • findByPostAndHostUserAndGuestUser → findByPostAndGuestUser
  • findByHostUserOrGuestUser → findByPost_UserOrGuestUser

동호회 수정 기능 + 예외처리

  1. Club.java — update(name, introduce, maxMembers) 메서드 추가
  2. ClubUpdateRequest.java — record 타입 DTO 신규 생성 (@notblank name 검증 포함)
  3. ClubErrorCode.java — CLUB_UPDATE_DENIED("CLUB_003", FORBIDDEN) 코드 추가
  4. ClubService.java / ClubServiceImpl.java — updateClub() 메서드 추가
    - Club 조회 → 리더 권한 확인 → 이름 변경 시 중복 검사 → entity.update() → ClubResponse 반환
  5. ClubController.java — PUT /api/clubs/{clubId} 엔드포인트 추가
  • ClubErrorCode.java — CLUB_MAX_MEMBERS_TOO_SMALL("CLUB_004", "최대 정원은 현재 참여 인원보다 적을 수 없습니다.", BAD_REQUEST) 추가
  • ClubServiceImpl.java — updateClub() 내에서 request.maxMembers()가 현재 club.getMembers().size()보다 작으면 예외를 던지는 검증 로직 추가

게시글 목록 조회 구현

  1. PostRepository.java — findByProperty(PostProperty, Pageable) 메서드 추가
  2. PostService.java — getPosts(PostProperty, Pageable) 인터페이스 추가
  3. PostServiceImpl.java — 게시글 목록 조회 구현 (Page 반환)
  4. PostController.java — GET /api/posts/{property} 엔드포인트 추가 (기본 페이지 사이즈 10)
  • Pageable 사용하여 임의로 페이지네이션 처리함

사용 예시:

  • GET /api/posts/SOCCER — 축구 게시판 조회
  • GET /api/posts/FREE?page=1&size=20 — 자유 게시판 2페이지, 20개씩

etc

stomp-test.html 중복 파일 제거 : /ws-stomp/* 엔드포인트 사용

🛠️ PR 유형

어떤 변경 사항이 있나요?

  • 새로운 기능 추가
  • 버그 수정
  • 코드 리팩토링
  • 주석 추가 및 수정
  • 테스트 추가, 테스트 리팩토링
  • 파일 혹은 폴더 삭제

📸스크린샷 (선택)

💬 공유사항 to 리뷰어

  • erd 수정사항 있습니다.

✅ PR Checklist

PR이 다음 요구 사항을 충족하는지 확인하세요.

  • 커밋 메시지 컨벤션에 맞게 작성했습니다.
  • 변경 사항에 대한 테스트를 했습니다.(버그 수정/기능에 대한 테스트).

Summary by CodeRabbit

릴리스 노트

  • New Features

    • 동호회 생성·수정 및 멤버십 관리 기능 추가
    • 동호회 전용 그룹 채팅 연동 제공
  • Documentation

    • 인증 API 입력 필드에 대한 Swagger 문서화 주석 추가
  • Refactor

    • 채팅 구조 및 구독/발행 경로와 메시지 페이로드 방식 개편
    • 채팅방/클럽 도메인 모델 및 조회 로직 정비
  • Chores

    • 개발용 STOMP 테스트 페이지 제거

✏️ Tip: You can customize this high-level summary in your review settings.

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Jan 28, 2026

Walkthrough

동호회(Club) 도메인 추가 및 관련 엔티티/레포지토리/서비스/컨트롤러/DTO/예외 도입, ChatRoom 모델·레포지토리·서비스·응답 DTO 리팩토링, STOMP 테스트 클라이언트 엔드포인트·페이로드 업데이트(및 테스트 파일 삭제), 로그인 DTO에 Swagger 스키마 주석 추가.

Changes

Cohort / File(s) 변경 요약
Club 도메인 추가
src/main/java/com/be/sportizebe/domain/club/entity/Club.java, .../ClubMember.java
src/main/java/com/be/sportizebe/domain/club/repository/ClubRepository.java, .../ClubMemberRepository.java
src/main/java/com/be/sportizebe/domain/club/service/ClubService.java, .../ClubServiceImpl.java
src/main/java/com/be/sportizebe/domain/club/controller/ClubController.java
src/main/java/com/be/sportizebe/domain/club/dto/request/ClubCreateRequest.java, ClubUpdateRequest.java
src/main/java/com/be/sportizebe/domain/club/dto/response/ClubResponse.java
src/main/java/com/be/sportizebe/domain/club/exception/ClubErrorCode.java
Club 엔티티 및 ClubMember 엔티티 추가, 저장소 인터페이스 추가, 서비스 인터페이스·구현 및 컨트롤러 추가, 요청/응답 DTO와 에러 코드 정의. 생성/수정 로직(권한·중복·멤버 수 검증) 포함
Chat 도메인 리팩토링
src/main/java/com/be/sportizebe/domain/chat/entity/ChatRoom.java
src/main/java/com/be/sportizebe/domain/chat/repository/ChatRoomRepository.java
src/main/java/com/be/sportizebe/domain/chat/service/ChatRoomService.java
src/main/java/com/be/sportizebe/domain/chat/dto/response/ChatRoomResponse.java
ChatRoom에서 name, maxMembers, hostUser 제거; chatRoomType을 Enum 문자열로 저장; club 1:1 관계 추가. 레포지토리 및 서비스 메서드 시그니처 변경(호스트 관련 파라미터 제거, 게시자 기준 조회로 전환). 응답 DTO에서 이름/호스트 유도 로직 조정
STOMP 클라이언트 테스트 변경/삭제
assets/stomp-test.html
test/stomp-test.html
클라이언트 SockJS/STOMP 엔드포인트를 /ws/ws-stomp, 라우팅을 /app/pub, 구독 경로를 /topic/.../sub/...으로 변경; 페이로드 필드 userId/nicknamesenderUserId/senderNickname으로 갱신; 기존 test/stomp-test.html 파일 삭제
인증 DTO 문서화
src/main/java/com/be/sportizebe/domain/auth/dto/request/LoginRequest.java
username/password 필드에 Swagger @Schema 주석 추가 (문서화 목적, 런타임 동작 불변)
게시글 페이징 API 추가
src/main/java/com/be/sportizebe/domain/post/controller/PostController.java
src/main/java/com/be/sportizebe/domain/post/service/PostService.java
src/main/java/com/be/sportizebe/domain/post/service/PostServiceImpl.java
src/main/java/com/be/sportizebe/domain/post/repository/PostRepository.java
게시판별 페이징 조회 API/서비스/레포지토리 추가 (Page<PostResponse> 반환, findByProperty(PostProperty, Pageable) 등)
사소한 주석 변화
src/main/java/com/be/sportizebe/domain/post/dto/request/CreatePostRequest.java
toEntity 메서드 위에 TODO 주석 추가

Sequence Diagram(s)

sequenceDiagram
    participant Client
    participant ClubController as Controller
    participant ClubService as Service
    participant ClubRepository as Repository
    participant ClubMemberRepository as MemberRepo
    participant ChatRoomService as ChatRoomSvc

    Client->>Controller: POST /api/clubs (ClubCreateRequest)
    Controller->>Service: createClub(request, authUser)
    Service->>Repository: existsByName(name)?
    alt name exists
        Repository-->>Service: true
        Service-->>Controller: throw CLUB_NAME_DUPLICATED
    else
        Repository-->>Service: false
        Service->>Repository: save(Club)
        Repository-->>Service: saved Club
        Service->>MemberRepo: save(ClubMember as LEADER)
        MemberRepo-->>Service: saved ClubMember
        Service->>ChatRoomSvc: createGroup(chat for club)
        ChatRoomSvc-->>Service: created ChatRoom
        Service-->>Controller: ClubResponse.from(saved Club)
        Controller-->>Client: 201 Created (ClubResponse)
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Poem

🐰 깡총 전해요:
동호회 땅에 새싹 심고,
채팅 길은 다듬었네.
송신자 이름 바꾸고, 문도 옮겼으니—
당근 한 입 축하해요! 🥕🎉

🚥 Pre-merge checks | ✅ 3 | ❌ 2
❌ Failed checks (2 warnings)
Check name Status Explanation Resolution
Out of Scope Changes check ⚠️ Warning 게시글 목록 조회 기능(getPosts) 추가는 이슈 #26의 범위를 벗어난 변경사항입니다. 게시글 목록 조회 기능은 별도의 이슈 또는 PR로 분리하는 것을 권장합니다.
Docstring Coverage ⚠️ Warning Docstring coverage is 33.33% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed 제목은 동호회 기능 구현과 채팅 구조 최적화라는 주요 변경사항을 명확하게 요약하고 있으며, 실제 PR 변경사항과 잘 일치합니다.
Linked Issues check ✅ Passed PR은 이슈 #26의 주요 요구사항인 동호회 CRUD 기능(생성, 수정, 삭제) 및 채팅방과 도메인 분리를 모두 구현했습니다.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

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

✨ Finishing touches
  • 📝 Generate docstrings

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

@imjuyongp imjuyongp changed the title ✨ Feat: 동호회 관련 기능 ✨ Feat: 동호회 관련 기능 구현 및 채팅 구조 최적화 Jan 28, 2026
@imjuyongp imjuyongp assigned angoroa and unassigned angoroa Jan 28, 2026
@imjuyongp imjuyongp requested a review from angoroa January 28, 2026 10:44
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: 4

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
assets/stomp-test.html (1)

444-466: roomId 공백일 때 구독/입장 요청 차단 필요
빈 값이면 /sub/chat/rooms/로 구독하고 roomId가 0/NaN으로 전송될 수 있어 서버 오류나 잘못된 방 조인이 발생합니다. 최소한 값 존재 확인 후 진행하세요.

✅ 간단한 가드 추가
-        const rid = roomIdValue();
+        const rid = roomIdValue();
+        if (!rid) return debug("[WARN] roomId is required");
         const dest = `/sub/chat/rooms/${rid}`;
         $("roomTitle").textContent = `room: ${rid}`;

         currentSub = stompClient.subscribe(dest, (frame) => {
@@
-        stompClient.send("/pub/chat.join", {}, JSON.stringify({
-            roomId: Number(roomIdValue()),
+        stompClient.send("/pub/chat.join", {}, JSON.stringify({
+            roomId: Number(rid),
             senderUserId: myUserId(),
             senderNickname: myNickname()
         }));
src/main/java/com/be/sportizebe/domain/chat/service/ChatRoomService.java (1)

36-37: Long 타입 비교에 == 대신 .equals()를 사용하세요.

hostUser.getId() == guestUser.getId()는 Long 객체의 참조 비교를 수행합니다. Java의 Long 캐시 범위(-128~127) 밖의 ID 값에서는 동일한 값이어도 false를 반환할 수 있어 자기 자신에게 채팅을 보낼 수 있는 버그가 발생할 수 있습니다.

🐛 제안된 수정
        // 자기 자신에게 채팅 불가
-        if (hostUser.getId() == guestUser.getId()) {
+        if (hostUser.getId().equals(guestUser.getId())) {
            throw new CustomException(ChatErrorCode.SELF_CHAT_NOT_ALLOWED);
        }
🤖 Fix all issues with AI agents
In `@src/main/java/com/be/sportizebe/domain/club/controller/ClubController.java`:
- Around line 30-36: The createClub method in ClubController currently injects a
User via `@AuthenticationPrincipal` User user but the principal is a
CustomUserDetails (which implements UserDetails and wraps the User entity);
change the parameter to either `@AuthenticationPrincipal`(expression =
"principal.user") User user OR to `@AuthenticationPrincipal` CustomUserDetails
customUserDetails and then call clubService.createClub(request,
customUserDetails.getUser()) so the actual User entity is passed to
ClubController.createClub.

In
`@src/main/java/com/be/sportizebe/domain/club/dto/request/ClubCreateRequest.java`:
- Around line 9-23: ClubCreateRequest currently allows maxMembers to be null or
non-positive which can cause DB/runtime errors; add validation annotations to
the record parameter by annotating maxMembers with `@NotNull` and `@Min`(1) (e.g.,
`@NotNull`(message="최대 정원은 필수 입니다.") `@Min`(value=1, message="최대 정원은 1 이상이어야 합니다.")
Integer maxMembers) and import javax.validation.constraints.NotNull and
javax.validation.constraints.Min; additionally, defensively check in toEntity
(method ClubCreateRequest.toEntity) and throw a clear IllegalArgumentException
or similar if maxMembers is null or <1 before calling Club.builder() to ensure
safe construction.

In `@src/main/java/com/be/sportizebe/domain/club/service/ClubServiceImpl.java`:
- Around line 58-60: The permission check currently compares Long IDs using '!='
which does reference comparison; replace it with a null-safe value comparison
using Objects.equals(club.getLeader().getId(), user.getId()) (negated) so the
condition becomes if (!Objects.equals(...)) throw new
CustomException(ClubErrorCode.CLUB_UPDATE_DENIED); and add the necessary import
for java.util.Objects to avoid NPEs and ensure correct equality semantics for
Long values.
- Line 70: The call in ClubServiceImpl that does club.update(request.name(),
request.introduce(), request.maxMembers()) will overwrite maxMembers with null
when request.maxMembers() is null; change the service to preserve the existing
value by either branching: if request.maxMembers() != null then call
club.update(request.name(), request.introduce(), request.maxMembers()) else call
a partial updater such as club.updatePartial(request.name(),
request.introduce()), or alter Club.update(...) itself to treat a null
maxMembers as “do not change” (add a null check inside Club.update and only set
maxMembers when the passed value is non-null); reference ClubServiceImpl,
Club.update, Club.updatePartial and ClubUpdateRequest.maxMembers to locate and
implement the fix.
🧹 Nitpick comments (15)
src/main/java/com/be/sportizebe/domain/post/dto/request/CreatePostRequest.java (1)

15-15: TODO 마무리 계획을 명시해 주세요.
S3 설정 이후 imgUrlmultipart/form-data로 전환할 예정이라면, 처리 범위(컨트롤러/서비스/DTO 변경)와 이슈/티켓 링크를 남겨 두면 추적이 쉬워집니다.

필요하시면 전환 작업의 변경 목록을 정리해 드리거나 이슈 템플릿을 만들어 드릴까요?

assets/stomp-test.html (1)

415-419: 환경 의존적인 WebSocket URL 하드코딩 제거
로컬 고정 URL은 배포 환경/프록시/HTTPS에서 연결 실패나 mixed-content 문제를 유발할 수 있습니다. 동일 오리진을 기준으로 구성하는 편이 안전합니다.

♻️ 변경 제안
-        const socket = new SockJS(
-            "http://localhost:8080/ws-stomp",
-            null,
-            { transports: ["xhr-streaming", "xhr-polling"] } // ✅ opCode=7 우회
-        );
+        const socket = new SockJS(
+            `${window.location.origin}/ws-stomp`,
+            null,
+            { transports: ["xhr-streaming", "xhr-polling"] } // ✅ opCode=7 우회
+        );
src/main/java/com/be/sportizebe/domain/club/dto/request/ClubCreateRequest.java (1)

14-16: TODO 항목은 이슈로 추적해 주세요.

Line 15의 TODO는 누락되기 쉬우니 별도 이슈/태스크로 등록해 관리하는 것을 권장합니다.

원하시면 이 작업을 위한 이슈 템플릿/체크리스트를 만들어 드릴까요?

src/main/java/com/be/sportizebe/domain/club/service/ClubService.java (1)

6-6: 사용되지 않는 import입니다.

SportType이 이 인터페이스에서 사용되지 않습니다. 제거하세요.

♻️ 제안된 수정
-import com.be.sportizebe.domain.user.entity.SportType;
src/main/java/com/be/sportizebe/domain/club/entity/Club.java (2)

4-4: 사용되지 않는 import입니다.

SportType이 이 엔티티에서 사용되지 않습니다. 제거하세요.

♻️ 제안된 수정
-import com.be.sportizebe.domain.user.entity.SportType;

47-51: 부분 업데이트 시 null 값 처리를 고려하세요.

현재 update() 메서드는 모든 필드를 무조건 덮어씁니다. 클라이언트가 특정 필드만 업데이트하고 싶을 때 null이 전달되면 기존 값이 null로 덮어씌워질 수 있습니다.

현재 구현이 의도된 동작이라면 무시해도 됩니다. 부분 업데이트가 필요하다면 아래 패턴을 고려하세요.

♻️ 부분 업데이트 패턴 예시
  public void update(String name, String introduce, Integer maxMembers) {
-    this.name = name;
-    this.introduce = introduce;
-    this.maxMembers = maxMembers;
+    if (name != null) {
+      this.name = name;
+    }
+    if (introduce != null) {
+      this.introduce = introduce;
+    }
+    if (maxMembers != null) {
+      this.maxMembers = maxMembers;
+    }
  }
src/main/java/com/be/sportizebe/domain/chat/dto/response/ChatRoomResponse.java (1)

29-37: NOTE 타입에서 post.getUser()가 null일 경우 NPE 가능성이 있습니다.

r.getPost() != null 체크는 있지만, r.getPost().getUser()가 null인 경우에 대한 방어 로직이 없습니다. Post 엔티티에서 user가 항상 존재함이 보장된다면 괜찮지만, 방어적 코딩을 위해 null 체크를 추가하는 것을 고려하세요.

♻️ 방어적 null 체크 추가
        if (r.getChatRoomType() == ChatRoom.ChatRoomType.NOTE && r.getPost() != null) {
            builder.name(r.getPost().getTitle());
            builder.postId(r.getPost().getId());
            // hostUser는 게시글 작성자로 파생
-            builder.hostUserId(r.getPost().getUser().getId())
-                   .hostUsername(r.getPost().getUser().getUsername());
+            if (r.getPost().getUser() != null) {
+                builder.hostUserId(r.getPost().getUser().getId())
+                       .hostUsername(r.getPost().getUser().getUsername());
+            }
        } else if (r.getChatRoomType() == ChatRoom.ChatRoomType.GROUP && r.getClub() != null) {
            builder.name(r.getClub().getName());
        }
src/main/java/com/be/sportizebe/domain/club/dto/request/ClubUpdateRequest.java (1)

10-10: maxMembers에 대한 유효성 검증이 누락되었습니다.

maxMembers가 null이거나 0 이하의 값이 전달될 수 있습니다. 서비스 레이어에서 검증하더라도 DTO 레벨에서 기본 유효성 검증을 추가하는 것이 좋습니다.

♻️ 유효성 검증 추가
 package com.be.sportizebe.domain.club.dto.request;
 
 import io.swagger.v3.oas.annotations.media.Schema;
 import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+import jakarta.validation.constraints.Min;
 
 public record ClubUpdateRequest(
     `@NotBlank`(message = "동호회 이름은 필수 입니다.")
     `@Schema`(description = "동호회 이름", example = "축구 동호회") String name,
     `@Schema`(description = "동호회 소개", example = "매주 토요일 축구합니다") String introduce,
-    `@Schema`(description = "최대 정원", example = "20") Integer maxMembers) {
+    `@NotNull`(message = "최대 정원은 필수입니다.")
+    `@Min`(value = 1, message = "최대 정원은 1명 이상이어야 합니다.")
+    `@Schema`(description = "최대 정원", example = "20") Integer maxMembers) {
 }
src/main/java/com/be/sportizebe/domain/club/dto/response/ClubResponse.java (1)

4-4: 사용되지 않는 import입니다.

SportType이 이 DTO에서 사용되지 않습니다. 제거하세요.

♻️ 제안된 수정
-import com.be.sportizebe.domain.user.entity.SportType;
src/main/java/com/be/sportizebe/domain/club/controller/ClubController.java (1)

6-7: 구현체 대신 인터페이스에 의존하고, 사용하지 않는 import를 제거하세요.

  • ClubServiceImpl 대신 ClubService 인터페이스에 의존하는 것이 좋습니다. 이렇게 하면 DIP(Dependency Inversion Principle)를 준수하고, 테스트 시 목(mock) 주입이 용이해집니다.
  • SportType import는 사용되지 않으므로 제거해야 합니다.
♻️ 제안된 수정
-import com.be.sportizebe.domain.club.service.ClubServiceImpl;
-import com.be.sportizebe.domain.user.entity.SportType;
+import com.be.sportizebe.domain.club.service.ClubService;

그리고 필드 선언도 수정:

-  private final ClubServiceImpl clubService;
+  private final ClubService clubService;
src/main/java/com/be/sportizebe/domain/chat/entity/ChatRoom.java (1)

39-51: ChatRoomType에 따른 필드 유효성 검사가 없습니다.

현재 엔티티 구조에서 ChatRoomType에 따라 다른 필드들이 사용됩니다:

  • GROUP: club이 필수, post/guestUser는 null
  • NOTE: post/guestUser가 필수, club은 null

하지만 엔티티 레벨에서 이러한 제약 조건을 강제하지 않아 잘못된 데이터가 저장될 수 있습니다. 서비스 계층에서만 검증하고 있다면, 데이터베이스 무결성 문제가 발생할 수 있습니다.

고려할 수 있는 방안:

  1. @PrePersist 또는 @PreUpdate에서 타입별 필드 검증
  2. 정적 팩토리 메서드로 생성 제한 (builder 대신)
♻️ `@PrePersist에` 검증 로직 추가 예시
  `@PrePersist`
  void prePersist() {
    if (createdAt == null) createdAt = Instant.now();
    // 기본 활성화
    active = true;
+   
+   // 타입별 필드 검증
+   if (chatRoomType == ChatRoomType.GROUP && club == null) {
+     throw new IllegalStateException("GROUP 채팅방은 club이 필수입니다.");
+   }
+   if (chatRoomType == ChatRoomType.NOTE && (post == null || guestUser == null)) {
+     throw new IllegalStateException("NOTE 채팅방은 post와 guestUser가 필수입니다.");
+   }
  }
src/main/java/com/be/sportizebe/domain/club/service/ClubServiceImpl.java (2)

12-12: 사용하지 않는 import를 제거하세요.

SportType이 import 되었지만 사용되지 않습니다.

♻️ 제안된 수정
-import com.be.sportizebe.domain.user.entity.SportType;

66-68: club.getMembers() 호출 시 N+1 또는 LazyInitializationException 가능성을 확인하세요.

@OneToMany 관계인 members가 기본적으로 LAZY 로딩이므로, 트랜잭션 내에서 호출되더라도 N+1 쿼리 문제가 발생할 수 있습니다. 현재 멤버 수만 필요하다면 ClubMemberRepository에 count 쿼리를 추가하는 것이 효율적입니다.

♻️ Count 쿼리 사용 제안

ClubMemberRepository에 추가:

long countByClub(Club club);
// 또는
long countByClubId(Long clubId);

서비스에서 사용:

-    if (request.maxMembers() != null && request.maxMembers() < club.getMembers().size()) {
+    if (request.maxMembers() != null && request.maxMembers() < clubMemberRepository.countByClubId(clubId)) {
      throw new CustomException(ClubErrorCode.CLUB_MAX_MEMBERS_TOO_SMALL);
    }
src/main/java/com/be/sportizebe/domain/club/entity/ClubMember.java (2)

14-16: @NoArgsConstructoraccess = AccessLevel.PROTECTED를 추가하세요.

JPA 엔티티는 기본 생성자가 필요하지만, public으로 열어두면 의도치 않은 객체 생성이 가능합니다. protected로 제한하면 JPA 프록시 생성은 허용하면서 직접 인스턴스화는 방지할 수 있습니다.

♻️ 제안된 수정
-@NoArgsConstructor
+@NoArgsConstructor(access = AccessLevel.PROTECTED)

AccessLevel import도 필요합니다:

+import lombok.AccessLevel;

23-29: 동일 사용자의 중복 가입 방지를 위해 유니크 제약조건을 추가하세요.

한 사용자가 동일한 동호회에 여러 번 가입하는 것을 방지하기 위해 (club_id, user_id) 조합에 유니크 제약조건이 필요합니다.

♻️ 제안된 수정
 `@Entity`
 `@Getter`
 `@Builder`
 `@AllArgsConstructor`
-@NoArgsConstructor
-@Table(name = "club_members")
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+@Table(name = "club_members", uniqueConstraints = {
+    `@UniqueConstraint`(columnNames = {"club_id", "user_id"})
+})
 public class ClubMember extends BaseTimeEntity {

Comment on lines +30 to +36
public ResponseEntity<BaseResponse<ClubResponse>> createClub(
@RequestBody @Valid ClubCreateRequest request,
@AuthenticationPrincipal User user) {
ClubResponse response = clubService.createClub(request, user);
return ResponseEntity.status(HttpStatus.CREATED)
.body(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 | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# CustomUserDetails 클래스 위치 및 구현 확인
find . -type f -name "*.java" | xargs grep -l "class CustomUserDetails"

Repository: Sportize/be

Length of output: 127


🏁 Script executed:

# CustomUserDetails 구현 세부사항 확인
rg -n "class CustomUserDetails" -A 30

Repository: Sportize/be

Length of output: 2962


🏁 Script executed:

# User 엔티티 구조 확인
find . -type f -name "*.java" -path "*/domain/*" | xargs grep -l "class User"

Repository: Sportize/be

Length of output: 265


🏁 Script executed:

cat -n src/main/java/com/be/sportizebe/global/security/CustomUserDetails.java

Repository: Sportize/be

Length of output: 1570


@AuthenticationPrincipal 주입 타입 변경 필요

CustomUserDetailsUserDetails 인터페이스를 구현하며 User를 상속하지 않습니다. @AuthenticationPrincipal의 기본 동작은 Authentication.getPrincipal()에서 CustomUserDetails 인스턴스를 반환하므로, 현재 @AuthenticationPrincipal User user로 직접 User 엔티티를 주입받을 수 없습니다.

다음 중 하나로 변경하세요:

  • @AuthenticationPrincipal(expression = "principal.user") User user 사용
  • 또는 @AuthenticationPrincipal CustomUserDetails customUserDetails로 변경하고 customUserDetails.getUser()로 User 엔티티 접근
🤖 Prompt for AI Agents
In `@src/main/java/com/be/sportizebe/domain/club/controller/ClubController.java`
around lines 30 - 36, The createClub method in ClubController currently injects
a User via `@AuthenticationPrincipal` User user but the principal is a
CustomUserDetails (which implements UserDetails and wraps the User entity);
change the parameter to either `@AuthenticationPrincipal`(expression =
"principal.user") User user OR to `@AuthenticationPrincipal` CustomUserDetails
customUserDetails and then call clubService.createClub(request,
customUserDetails.getUser()) so the actual User entity is passed to
ClubController.createClub.

Comment on lines +9 to +23
public record ClubCreateRequest(
@NotBlank(message = "동호회 이름은 필수 입니다.")
@Schema(description = "동호회 이름", example = "축구 동호회") String name,
@Schema(description = "동호회 소개", example = "매주 토요일 축구합니다") String introduce,
@Schema(description = "최대 정원", example = "20") Integer maxMembers) {
// 관련 종목은 파라미터로 받음
// TODO : S3 세팅 후 imgUrl은 multipartform으로 변경

public Club toEntity(User user) {
return Club.builder()
.name(name)
.introduce(introduce)
.maxMembers(maxMembers)
.leader(user)
.build();
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

maxMembers 검증이 누락되어 있습니다.

Line 13에서 maxMembers가 null/음수여도 통과할 수 있어 저장 시 제약 위반 또는 런타임 오류가 날 수 있습니다. 최소한 null/양수 검증을 추가해 주세요.

🔧 제안 수정
-import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+import jakarta.validation.constraints.Positive;

 public record ClubCreateRequest(
   `@NotBlank`(message = "동호회 이름은 필수 입니다.")
   `@Schema`(description = "동호회 이름", example = "축구 동호회") String name,
   `@Schema`(description = "동호회 소개", example = "매주 토요일 축구합니다") String introduce,
-  `@Schema`(description = "최대 정원", example = "20") Integer maxMembers) {
+  `@NotNull`(message = "최대 정원은 필수 입니다.")
+  `@Positive`(message = "최대 정원은 양수여야 합니다.")
+  `@Schema`(description = "최대 정원", example = "20") Integer maxMembers) {
🤖 Prompt for AI Agents
In
`@src/main/java/com/be/sportizebe/domain/club/dto/request/ClubCreateRequest.java`
around lines 9 - 23, ClubCreateRequest currently allows maxMembers to be null or
non-positive which can cause DB/runtime errors; add validation annotations to
the record parameter by annotating maxMembers with `@NotNull` and `@Min`(1) (e.g.,
`@NotNull`(message="최대 정원은 필수 입니다.") `@Min`(value=1, message="최대 정원은 1 이상이어야 합니다.")
Integer maxMembers) and import javax.validation.constraints.NotNull and
javax.validation.constraints.Min; additionally, defensively check in toEntity
(method ClubCreateRequest.toEntity) and throw a clear IllegalArgumentException
or similar if maxMembers is null or <1 before calling Club.builder() to ensure
safe construction.

Comment on lines +58 to +60
if (club.getLeader().getId() != user.getId()) {
throw new CustomException(ClubErrorCode.CLUB_UPDATE_DENIED);
}
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

Long 객체 비교 시 == 대신 equals()를 사용하세요.

Long 객체를 !=로 비교하면 참조 비교가 수행됩니다. -128에서 127 범위 외의 ID 값에서는 동일한 값이라도 false를 반환할 수 있어 권한 검증이 실패합니다.

🐛 수정 제안
-    if (club.getLeader().getId() != user.getId()) {
+    if (!club.getLeader().getId().equals(user.getId())) {
      throw new CustomException(ClubErrorCode.CLUB_UPDATE_DENIED);
    }
📝 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
if (club.getLeader().getId() != user.getId()) {
throw new CustomException(ClubErrorCode.CLUB_UPDATE_DENIED);
}
if (!club.getLeader().getId().equals(user.getId())) {
throw new CustomException(ClubErrorCode.CLUB_UPDATE_DENIED);
}
🤖 Prompt for AI Agents
In `@src/main/java/com/be/sportizebe/domain/club/service/ClubServiceImpl.java`
around lines 58 - 60, The permission check currently compares Long IDs using
'!=' which does reference comparison; replace it with a null-safe value
comparison using Objects.equals(club.getLeader().getId(), user.getId())
(negated) so the condition becomes if (!Objects.equals(...)) throw new
CustomException(ClubErrorCode.CLUB_UPDATE_DENIED); and add the necessary import
for java.util.Objects to avoid NPEs and ensure correct equality semantics for
Long values.

throw new CustomException(ClubErrorCode.CLUB_MAX_MEMBERS_TOO_SMALL);
}

club.update(request.name(), request.introduce(), request.maxMembers());
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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Find and examine the Club.update() method implementation
cd /root/repo 2>/dev/null || cd . 

# First, find the Club class
find . -type f -name "Club.java" | head -5

# Search for the update method in Club class
rg -n "public void update" -A 15 --type java | grep -A 15 "update"

# Also check if there's null checking in the method
rg -n "maxMembers" -B 3 -A 3 --type java | head -50

Repository: Sportize/be

Length of output: 6825


🏁 Script executed:

#!/bin/bash
# Get more context around the update method to understand the full picture
cd /root/repo 2>/dev/null || cd .

# Check the full Club.update() method
cat -n ./src/main/java/com/be/sportizebe/domain/club/entity/Club.java | sed -n '40,60p'

# Check if there are any other update calls or patterns
rg -n "\.update\(" --type java | grep -i club

Repository: Sportize/be

Length of output: 718


request.maxMembers()가 null일 때 null로 설정되는 문제를 수정하세요.

ClubUpdateRequestmaxMembers@NotNull 검증이 없어 null이 될 수 있으며, Club.update() 메서드는 전달된 값을 그대로 저장합니다. PATCH 요청에서 null은 "이 필드를 업데이트하지 않음"을 의미하므로, 다음과 같이 기존 값을 유지하도록 수정이 필요합니다:

if (request.maxMembers() != null) {
  club.update(request.name(), request.introduce(), request.maxMembers());
} else {
  club.updatePartial(request.name(), request.introduce());
}

또는 Club.update() 메서드 내에서 null 체크를 추가하여 기존 값을 보존하는 방식으로 구현하세요.

🤖 Prompt for AI Agents
In `@src/main/java/com/be/sportizebe/domain/club/service/ClubServiceImpl.java` at
line 70, The call in ClubServiceImpl that does club.update(request.name(),
request.introduce(), request.maxMembers()) will overwrite maxMembers with null
when request.maxMembers() is null; change the service to preserve the existing
value by either branching: if request.maxMembers() != null then call
club.update(request.name(), request.introduce(), request.maxMembers()) else call
a partial updater such as club.updatePartial(request.name(),
request.introduce()), or alter Club.update(...) itself to treat a null
maxMembers as “do not change” (add a null check inside Club.update and only set
maxMembers when the passed value is non-null); reference ClubServiceImpl,
Club.update, Club.updatePartial and ClubUpdateRequest.maxMembers to locate and
implement the fix.

@imjuyongp imjuyongp merged commit 7440455 into develop Jan 28, 2026
1 check passed
@imjuyongp imjuyongp deleted the feat/club branch January 31, 2026 18:16
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.

✨Feat: 동호회 관련 기능

2 participants