Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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.

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

Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,14 @@

import com.be.sportizebe.domain.club.dto.request.ClubCreateRequest;
import com.be.sportizebe.domain.club.dto.request.ClubUpdateRequest;
import com.be.sportizebe.domain.club.dto.response.ClubDetailResponse;
import com.be.sportizebe.domain.club.dto.response.ClubImageResponse;
import com.be.sportizebe.domain.club.dto.response.ClubResponse;
import com.be.sportizebe.domain.club.service.ClubServiceImpl;
import com.be.sportizebe.global.response.BaseResponse;
import com.be.sportizebe.domain.club.dto.response.ClubScrollResponse;
import com.be.sportizebe.domain.club.service.ClubService;
import com.be.sportizebe.domain.user.entity.User;
import com.be.sportizebe.global.cache.dto.UserAuthInfo;
import com.be.sportizebe.global.response.BaseResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
Expand All @@ -25,36 +28,69 @@
@Tag(name = "club", description = "동호회 관리 관련 API")
public class ClubController {

private final ClubServiceImpl clubService;

@PostMapping(value = "", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@Operation(summary = "동호회 생성", description = "종목별 동호회를 생성합니다. 생성한 사용자가 동호회장이 됩니다. (이미지 첨부 가능)")
public ResponseEntity<BaseResponse<ClubResponse>> createClub(
@RequestPart("request") @Valid ClubCreateRequest request,
@RequestPart(value = "image", required = false) MultipartFile image,
@AuthenticationPrincipal UserAuthInfo userAuthInfo) {
ClubResponse response = clubService.createClub(request, image, userAuthInfo.getId());
return ResponseEntity.status(HttpStatus.CREATED)
.body(BaseResponse.success("동호회 생성 성공", response));
}

@PutMapping("/{clubId}")
@Operation(summary = "동호회 수정", description = "동호회 정보를 수정합니다. 동호회장만 수정할 수 있습니다.")
public ResponseEntity<BaseResponse<ClubResponse>> updateClub(
@PathVariable Long clubId,
@RequestBody @Valid ClubUpdateRequest request,
@AuthenticationPrincipal UserAuthInfo userAuthInfo) {
ClubResponse response = clubService.updateClub(clubId, request, userAuthInfo.getId());
return ResponseEntity.ok(BaseResponse.success("동호회 수정 성공", response));
}

@PostMapping(value = "/{clubId}/image", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@Operation(summary = "동호회 사진 수정", description = "동호회 사진을 수정합니다. 동호회장만 수정할 수 있습니다.")
public ResponseEntity<BaseResponse<ClubImageResponse>> updateClubImage(
@Parameter(description = "동호회 ID") @PathVariable Long clubId,
@RequestPart("image") MultipartFile image,
@AuthenticationPrincipal UserAuthInfo userAuthInfo) {
ClubImageResponse response = clubService.updateClubImage(clubId, image, userAuthInfo.getId());
return ResponseEntity.ok(BaseResponse.success("동호회 사진 수정 성공", response));
}
private final ClubService clubService;

@PostMapping(value = "", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@Operation(summary = "동호회 생성", description = "종목별 동호회를 생성합니다. 생성한 사용자가 동호회장이 됩니다. (이미지 첨부 가능)")
public ResponseEntity<BaseResponse<ClubResponse>> createClub(
@RequestPart("request") @Valid ClubCreateRequest request,
@RequestPart(value = "image", required = false) MultipartFile image,
@AuthenticationPrincipal UserAuthInfo userAuthInfo) {
ClubResponse response = clubService.createClub(request, image, userAuthInfo.getId());
return ResponseEntity.status(HttpStatus.CREATED)
.body(BaseResponse.success("동호회 생성 성공", response));
}

@PutMapping("/{clubId}")
@Operation(summary = "동호회 수정", description = "동호회 정보를 수정합니다. 동호회장만 수정할 수 있습니다.")
public ResponseEntity<BaseResponse<ClubResponse>> updateClub(
@PathVariable Long clubId,
@RequestBody @Valid ClubUpdateRequest request,
@AuthenticationPrincipal UserAuthInfo userAuthInfo) {
ClubResponse response = clubService.updateClub(clubId, request, userAuthInfo.getId());
return ResponseEntity.ok(BaseResponse.success("동호회 수정 성공", response));
}

@PostMapping(value = "/{clubId}/image", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@Operation(summary = "동호회 사진 수정", description = "동호회 사진을 수정합니다. 동호회장만 수정할 수 있습니다.")
public ResponseEntity<BaseResponse<ClubImageResponse>> updateClubImage(
@Parameter(description = "동호회 ID") @PathVariable Long clubId,
@RequestPart("image") MultipartFile image,
@AuthenticationPrincipal UserAuthInfo userAuthInfo) {
ClubImageResponse response = clubService.updateClubImage(clubId, image, userAuthInfo.getId());
return ResponseEntity.ok(BaseResponse.success("동호회 사진 수정 성공", response));
}

@GetMapping
@Operation(summary = "모든 동호회 조회 (무한스크롤)",
description = "커서 기반 무한스크롤 방식으로 동호회 목록을 조회합니다.")
public ResponseEntity<BaseResponse<ClubScrollResponse>> getClubs(
@RequestParam(required = false) Long cursor,
@RequestParam(defaultValue = "20") int size
) {
ClubScrollResponse response = clubService.getClubsByScroll(cursor, size);
return ResponseEntity.ok(
BaseResponse.success("동호회 목록 조회 성공", response)
);
}

@GetMapping("/{clubId}")
@Operation(summary = "동호회 상세 조회", description = "동호회 단건 상세 정보를 조회합니다.")
public ResponseEntity<BaseResponse<ClubDetailResponse>> getClub(
@Parameter(description = "동호회 ID") @PathVariable Long clubId
) {
ClubDetailResponse response = clubService.getClub(clubId);
return ResponseEntity.ok(BaseResponse.success("동호회 상세 조회 성공", response));
}

@GetMapping("/me")
@Operation(summary = "내가 가입한 동호회 조회(무한스크롤)", description = "로그인한 사용자가 가입한 동호회를 커서 기반 무한스크롤로 조회합니다.")
public ResponseEntity<BaseResponse<ClubScrollResponse>> getMyClubs(
@Parameter(description = "커서(마지막 clubId). 첫 조회는 null") @RequestParam(required = false) Long cursor,
@Parameter(description = "조회 개수", example = "10") @RequestParam(defaultValue = "10") int size,
@AuthenticationPrincipal User user
) {
ClubScrollResponse response = clubService.getMyClubsByScroll(cursor, size, user);
return ResponseEntity.ok(BaseResponse.success("내 동호회 조회 성공", response));
}
Comment on lines +86 to +95
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.

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package com.be.sportizebe.domain.club.dto.response;

import com.be.sportizebe.domain.club.entity.Club;
import io.swagger.v3.oas.annotations.media.Schema;
import java.time.LocalDateTime;

@Schema(title = "ClubDetailResponse", description = "동호회 상세 조회 응답")
public record ClubDetailResponse(

@Schema(description = "동호회 ID", example = "1")
Long clubId,

@Schema(description = "동호회 이름", example = "수원 FC 풋살 동호회")
String name,

@Schema(description = "동호회 소개글 (전체)", example = "매주 화, 토 저녁에 풋살을 즐기는 동호회입니다.")
String introduce,

@Schema(description = "동호회 종목", example = "SOCCER")
String clubType,

@Schema(description = "최대 정원", example = "20")
int maxMembers,

@Schema(description = "현재 동호회 인원 수", example = "12")
int currentMembers,

@Schema(description = "동호회 대표 이미지 URL", example = "https://bucket.s3.ap-northeast-2.amazonaws.com/club/uuid.jpg")
String clubImageUrl,

@Schema(description = "동호회 생성 일시", example = "2026-02-01T12:30:00")
LocalDateTime createdAt
) {
public static ClubDetailResponse from(Club club, int memberCount) {
return new ClubDetailResponse(
club.getId(),
club.getName(),
club.getIntroduce(),
club.getClubType().name(),
club.getMaxMembers(),
memberCount,
club.getClubImage(),
club.getCreatedAt()
);
}
Comment on lines +8 to +45
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.

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package com.be.sportizebe.domain.club.dto.response;

import com.be.sportizebe.domain.club.entity.Club;
import io.swagger.v3.oas.annotations.media.Schema;
import java.time.LocalDateTime;

@Schema(title = "ClubListItemResponse", description = "동호회 목록(무한스크롤) 카드 단위 응답")
public record ClubListItemResponse(

@Schema(description = "동호회 ID", example = "1")
Long clubId,

@Schema(description = "동호회 이름", example = "수원 FC 풋살 동호회")
String name,

@Schema(description = "동호회 종목", example = "SOCCER")
String sportType,

@Schema(description = "동호회 소개글 (목록용 요약)", example = "주 2회 풋살을 즐기는 동호회입니다.")
String description,

@Schema(description = "동호회 대표 이미지 URL", example = "https://bucket.s3.ap-northeast-2.amazonaws.com/club/uuid.jpg")
String imageUrl,

@Schema(description = "현재 동호회 인원 수", example = "12")
int memberCount,

@Schema(description = "동호회 생성 일시", example = "2026-02-01T12:30:00")
LocalDateTime createdAt
) {
public static ClubListItemResponse from(Club club, int memberCount) {
return new ClubListItemResponse(
club.getId(),
club.getName(),
club.getClubType().name(),
club.getIntroduce(),
club.getClubImage(),
memberCount,
club.getCreatedAt()
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package com.be.sportizebe.domain.club.dto.response;

import io.swagger.v3.oas.annotations.media.Schema;
import java.util.List;

@Schema(title = "ClubScrollResponse", description = "동호회 목록 무한스크롤 조회 응답")
public record ClubScrollResponse(

@Schema(description = "동호회 목록")
List<ClubListItemResponse> items,

@Schema(description = "다음 조회를 위한 커서 값 (마지막 clubId)", example = "15")
Long nextCursor,

@Schema(description = "다음 페이지 존재 여부", example = "true")
boolean hasNext
) {
}
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() 필요성

Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@

public interface ClubMemberRepository extends JpaRepository<ClubMember, Long> {

// 특정 사용자가 특정 동호회에 이미 가입했는지 확인
boolean existsByClubAndUser(Club club, User user);
// 특정 사용자가 특정 동호회에 이미 가입했는지 확인
boolean existsByClubAndUser(Club club, User user);

int countByClubId(Long clubId);
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,29 @@

import com.be.sportizebe.domain.club.entity.Club;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

import java.util.List;

public interface ClubRepository extends JpaRepository<Club, Long> {
boolean existsByName(String name);

@Query("""
SELECT c FROM Club c
WHERE (:cursor IS NULL OR c.id < :cursor)
ORDER BY c.id DESC
""")
List<Club> findClubsByCursor(@Param("cursor") Long cursor, Pageable pageable);

@Query("""
select c
from ClubMember cm
join cm.club c
where cm.user.id = :userId
and (:cursor is null or c.id < :cursor)
order by c.id desc
""")
List<Club> findMyClubsByCursor(@Param("userId") Long userId, @Param("cursor") Long cursor, Pageable pageable);
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,23 @@

import com.be.sportizebe.domain.club.dto.request.ClubCreateRequest;
import com.be.sportizebe.domain.club.dto.request.ClubUpdateRequest;
import com.be.sportizebe.domain.club.dto.response.ClubDetailResponse;
import com.be.sportizebe.domain.club.dto.response.ClubImageResponse;
import com.be.sportizebe.domain.club.dto.response.ClubResponse;
import com.be.sportizebe.domain.club.dto.response.ClubScrollResponse;
import com.be.sportizebe.domain.user.entity.User;
import org.springframework.web.multipart.MultipartFile;

public interface ClubService {
ClubResponse createClub(ClubCreateRequest request, MultipartFile image, Long userId); // 동호회 생성
ClubResponse createClub(ClubCreateRequest request, MultipartFile image, Long userId); // 동호회 생성

ClubResponse updateClub(Long clubId, ClubUpdateRequest request, Long userId); // 동호회 수정
ClubResponse updateClub(Long clubId, ClubUpdateRequest request, Long userId); // 동호회 수정

ClubImageResponse updateClubImage(Long clubId, MultipartFile image, Long userId); // 동호회 사진 수정
ClubDetailResponse getClub(Long clubId); // 동호회 개별 조회

ClubScrollResponse getClubsByScroll(Long cursor, int size); // 동호회 전체 조회 (무한 스크롤)

ClubScrollResponse getMyClubsByScroll(Long cursor, int size, User user); // 내가 가입한 동호회 조회 (무한 스크롤)

ClubImageResponse updateClubImage(Long clubId, MultipartFile image, Long userId); // 동호회 사진 수정
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,7 @@
import com.be.sportizebe.domain.chat.service.ChatRoomService;
import com.be.sportizebe.domain.club.dto.request.ClubCreateRequest;
import com.be.sportizebe.domain.club.dto.request.ClubUpdateRequest;
import com.be.sportizebe.domain.club.dto.response.ClubImageResponse;
import com.be.sportizebe.domain.club.dto.response.ClubResponse;
import com.be.sportizebe.domain.club.dto.response.*;
import com.be.sportizebe.domain.club.entity.Club;
import com.be.sportizebe.domain.club.entity.ClubMember;
import com.be.sportizebe.domain.club.exception.ClubErrorCode;
Expand All @@ -17,9 +16,12 @@
import com.be.sportizebe.global.s3.enums.PathName;
import com.be.sportizebe.global.s3.service.S3Service;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
import java.util.List;

@Service
@RequiredArgsConstructor
Expand Down Expand Up @@ -113,6 +115,70 @@ public ClubImageResponse updateClubImage(Long clubId, MultipartFile image, Long
// 동호회 이미지 URL 업데이트
club.updateClubImage(clubImageUrl);

return ClubImageResponse.from(clubImageUrl);
}
return ClubImageResponse.from(clubImageUrl);
}
@Override
@Transactional(readOnly = true)
public ClubDetailResponse getClub(Long clubId) {
Club club = clubRepository.findById(clubId)
.orElseThrow(() -> new CustomException(ClubErrorCode.CLUB_NOT_FOUND));

int memberCount = clubMemberRepository.countByClubId(clubId);

return ClubDetailResponse.from(club, memberCount);
}
@Override
@Transactional(readOnly = true)
public ClubScrollResponse getClubsByScroll(Long cursor, int size) {

// +1 조회해서 다음 페이지 존재 여부 판단
Pageable pageable = PageRequest.of(0, size + 1);

List<Club> clubs = clubRepository.findClubsByCursor(cursor, pageable);

boolean hasNext = clubs.size() > size;

if (hasNext) {
clubs = clubs.subList(0, size);
}

List<ClubListItemResponse> items = clubs.stream()
.map(club -> {
int memberCount = clubMemberRepository.countByClubId(club.getId());
return ClubListItemResponse.from(club, memberCount);
})
.toList();
Comment on lines +145 to +150
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.


Long nextCursor = items.isEmpty()
? null
: items.get(items.size() - 1).clubId();

return new ClubScrollResponse(items, nextCursor, hasNext);
}
@Override
@Transactional(readOnly = true)
public ClubScrollResponse getMyClubsByScroll(Long cursor, int size, User user) {

Pageable pageable = PageRequest.of(0, size + 1);

List<Club> clubs = clubRepository.findMyClubsByCursor(user.getId(), cursor, pageable);

boolean hasNext = clubs.size() > size;

if (hasNext) {
clubs = clubs.subList(0, size);
}

List<ClubListItemResponse> items = clubs.stream()
.map(club -> {
int memberCount = clubMemberRepository.countByClubId(club.getId());
return ClubListItemResponse.from(club, memberCount);
})
.toList();

Long nextCursor = items.isEmpty() ? null : items.get(items.size() - 1).clubId();

return new ClubScrollResponse(items, nextCursor, hasNext);
}

}
Loading