Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
e1d43dc
[feat]: 동아리 소속 목록 조회 api 구현
jiyun921 Feb 15, 2026
4c42e72
[test]: 동아리 소속 목록 조회 테스트 구현
jiyun921 Feb 15, 2026
7090b05
[fix]: 동아리 소속 목록 조회 응답 구조를 명세에 맞게 수정
jiyun921 Feb 16, 2026
d4d83cb
[feat]: 동아리 목록 조회 api 구현
jiyun921 Feb 18, 2026
a2be583
[fix]: cursor 타입 수정
jiyun921 Feb 19, 2026
9c44e7d
[test]: 동아리 목록 조회 테스트 구현
jiyun921 Feb 19, 2026
d818cac
[feat]: 동아리 상세 조회 api 구현
jiyun921 Feb 20, 2026
840644f
[feat]: 동아리 목록 조회 API에 로그인 사용자 기반 구독 정보 조회 기능 추가
jiyun921 Feb 20, 2026
26f1195
[test]: 동아리 상세 조회 테스트 구현
jiyun921 Feb 20, 2026
2b65645
[fix]: 동아리 목록/상세 조회 api - JWT 기반 email 인증 방식으로 수정
jiyun921 Feb 22, 2026
8cec711
[refactor]: club 상세 조회 api - 위치 정보가 없을 경우 location을 null로 반환하도록 수정
jiyun921 Feb 22, 2026
c67ff24
[fix]: name/recruitEndDate 정렬 시 복합 커서 적용
jiyun921 Feb 22, 2026
e4e7276
[refactor]: ClubDetail 조회 시 불필요한 userToken 제거
jiyun921 Feb 22, 2026
9d613f4
[refactor]: club 목록 조회 시 구독 정보 관련 N+1 쿼리 제거
jiyun921 Feb 22, 2026
e0a0241
[fix]: recruitEndDate 정렬을 위한 복합 커서(group|date|id) 구조 도입
jiyun921 Feb 23, 2026
2c6280e
[refactor]: Persistence에서 findSubscribedClubIds 가공 로직 Service로 책임 이동
jiyun921 Feb 25, 2026
3bbe835
[refactor] ClubDetailCommand 도입 및 email을 Command에 포함하도록 수정
jiyun921 Feb 25, 2026
018f9b9
[refactor] 미로그인 시 loginUserId 변수 생성 제거
jiyun921 Feb 25, 2026
0be4f6b
[refactor] hasLocation() 로직을 Service에서 ClubDetailDto로 이동
jiyun921 Feb 25, 2026
18dbfc3
[refactor] ClubDetailResponse Location 매핑 구조 정리 및 from() 추가
jiyun921 Feb 25, 2026
64f8efe
[refactor] 메서드명 구체화 및 메서드 순서 정리
jiyun921 Feb 25, 2026
ab20703
[comment] 머지 전 주석 저장
jiyun921 Feb 27, 2026
cd4eb4b
[merge] develop 브랜치 머지
jiyun921 Feb 27, 2026
34a716b
[refactor] 동아리 목록 조회 커서 페이징 제거
jiyun921 Feb 28, 2026
0403b77
[refactor] ClubDivisionListResponse에서 Result 리스트 직접 사용하도록 수정
jiyun921 Feb 28, 2026
59782a7
[refactor] totalCount 필드 관련 메서드 제거
jiyun921 Feb 28, 2026
31bc32e
[refactor] 구독 조회 로직을 RootUser 기준으로 통일
jiyun921 Feb 28, 2026
2df4cc3
[refactor] ClubQueryService 구독 로직 메서드 추출 및 DTO from 패턴 적용
jiyun921 Feb 28, 2026
6a7d5f8
[refactor] 모집 상태 계산 로직을 Domain으로 이동하고 Repository 책임 분리
jiyun921 Feb 28, 2026
28e7dd7
[refactor] Club 조회 시 Enum 변환 책임을 Service로 이동
jiyun921 Feb 28, 2026
0d1c6c2
[refactor] 구독자 수 Long 타입 통일 및 구독 관련 조회 로직 ClubSubscriptionPort로 이동
jiyun921 Feb 28, 2026
b550602
[test] ClubPersistenceAdapter 테스트 추가
jiyun921 Feb 28, 2026
ba06ea0
Merge branch 'develop' into feat/#336-club-read
jiyun921 Feb 28, 2026
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
package com.kustacks.kuring.club.adapter.in.web;

import com.kustacks.kuring.auth.authentication.AuthorizationExtractor;
import com.kustacks.kuring.auth.authentication.AuthorizationType;
import com.kustacks.kuring.auth.token.JwtTokenProvider;
import com.kustacks.kuring.club.adapter.in.web.dto.ClubDetailResponse;
import com.kustacks.kuring.club.adapter.in.web.dto.ClubDivisionListResponse;
import com.kustacks.kuring.club.adapter.in.web.dto.ClubListResponse;
import com.kustacks.kuring.club.application.port.in.ClubQueryUseCase;
import com.kustacks.kuring.club.application.port.in.dto.ClubDetailCommand;
import com.kustacks.kuring.club.application.port.in.dto.ClubDetailResult;
import com.kustacks.kuring.club.application.port.in.dto.ClubDivisionResult;
import com.kustacks.kuring.club.application.port.in.dto.ClubListCommand;
import com.kustacks.kuring.club.application.port.in.dto.ClubListResult;
import com.kustacks.kuring.common.annotation.RestWebAdapter;
import com.kustacks.kuring.common.dto.BaseResponse;
import com.kustacks.kuring.common.exception.InvalidStateException;
import com.kustacks.kuring.common.exception.code.ErrorCode;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestParam;

import java.util.List;
import java.util.Optional;

import static com.kustacks.kuring.auth.authentication.AuthorizationExtractor.extractAuthorizationValue;
import static com.kustacks.kuring.common.dto.ResponseCodeAndMessages.CLUB_DETAIL_SEARCH_SUCCESS;
import static com.kustacks.kuring.common.dto.ResponseCodeAndMessages.CLUB_DIVISION_SEARCH_SUCCESS;
import static com.kustacks.kuring.common.dto.ResponseCodeAndMessages.CLUB_LIST_SEARCH_SUCCESS;

@Tag(name = "Club-Query", description = "동아리 정보 조회")
@Validated
@RequiredArgsConstructor
@RestWebAdapter(path = "/api/v2/clubs")
public class ClubQueryApiV2 {

private static final String FCM_TOKEN_HEADER_KEY = "User-Token";
private static final String JWT_TOKEN_HEADER_KEY = "JWT";

private final JwtTokenProvider jwtTokenProvider;
private final ClubQueryUseCase clubQueryUseCase;

@Operation(summary = "동아리 소속 목록 조회", description = "서버가 지원하는 동아리 소속 목록을 조회합니다")
@GetMapping("/divisions")
public ResponseEntity<BaseResponse<ClubDivisionListResponse>> getSupportedClubDivisions() {

List<ClubDivisionResult> results = clubQueryUseCase.getClubDivisions();

ClubDivisionListResponse response = ClubDivisionListResponse.from(results);

return ResponseEntity.ok().body(new BaseResponse<>(CLUB_DIVISION_SEARCH_SUCCESS, response));
}

@Operation(summary = "동아리 목록 조회", description = "필터 조건에 맞는 동아리 목록을 조회합니다")
@SecurityRequirement(name = JWT_TOKEN_HEADER_KEY)
@GetMapping
public ResponseEntity<BaseResponse<ClubListResponse>> getClubs(
@RequestParam(required = false) String category,
@RequestParam(required = false) String division,
@RequestHeader(value = AuthorizationExtractor.AUTHORIZATION, required = false) String bearerToken
) {
String email = resolveLoginEmail(bearerToken);

ClubListCommand command = new ClubListCommand(category, division, email);

ClubListResult result = clubQueryUseCase.getClubs(command);

ClubListResponse response = ClubListResponse.from(result);

return ResponseEntity.ok().body(new BaseResponse<>(CLUB_LIST_SEARCH_SUCCESS, response));
}

@Operation(summary = "동아리 상세 조회", description = "특정 동아리의 상세 정보를 조회합니다.")
@SecurityRequirement(name = FCM_TOKEN_HEADER_KEY)
@SecurityRequirement(name = JWT_TOKEN_HEADER_KEY)
@GetMapping("/{id}")
public ResponseEntity<BaseResponse<ClubDetailResponse>> getClubDetail(
@PathVariable Long id,
@RequestHeader(value = FCM_TOKEN_HEADER_KEY, required = false) String userToken,
@RequestHeader(value = AuthorizationExtractor.AUTHORIZATION, required = false) String bearerToken
) {
String email = resolveLoginEmail(bearerToken);

ClubDetailCommand command = new ClubDetailCommand(id, email);

ClubDetailResult result = clubQueryUseCase.getClubDetail(command);

ClubDetailResponse response = ClubDetailResponse.from(result);

return ResponseEntity.ok().body(new BaseResponse<>(CLUB_DETAIL_SEARCH_SUCCESS, response));
}

private String resolveLoginEmail(String bearerToken) {
return Optional.ofNullable(bearerToken)
.map(token -> extractAuthorizationValue(token, AuthorizationType.BEARER))
.map(this::validateJwtAndGetEmail)
.orElse(null);
}

private String validateJwtAndGetEmail(String jwtToken) {
if (!jwtTokenProvider.validateToken(jwtToken)) {
throw new InvalidStateException(ErrorCode.JWT_INVALID_TOKEN);
}
return jwtTokenProvider.getPrincipal(jwtToken);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
package com.kustacks.kuring.club.adapter.in.web.dto;

import com.kustacks.kuring.club.application.port.in.dto.ClubDetailResult;

import java.time.LocalDateTime;

public record ClubDetailResponse(
Long id,
String name,
String summary,
String category,
String division,
Long subscriberCount,
boolean isSubscribed,
String instagramUrl,
String youtubeUrl,
String etcUrl,
String description,
String qualifications,
String recruitmentStatus,
LocalDateTime recruitStartAt,
LocalDateTime recruitEndAt,
String applyUrl,
String posterImageUrl,
Location location
) {

public static ClubDetailResponse from(ClubDetailResult result) {
return new ClubDetailResponse(
result.id(),
result.name(),
result.summary(),
result.category().getName(),
result.division().getName(),
result.subscriberCount(),
result.isSubscribed(),
result.instagramUrl(),
result.youtubeUrl(),
result.etcUrl(),
result.description(),
result.qualifications(),
result.recruitmentStatus().getValue(),
result.recruitStartAt(),
result.recruitEndAt(),
result.applyUrl(),
result.posterImageUrl(),
Location.from(result.location())
);
}

public record Location(
String building,
String room,
Double lon,
Double lat
) {
public static Location from(ClubDetailResult.Location location) {
if (location == null) return null;

return new Location(
location.building(),
location.room(),
location.lon(),
location.lat()
);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package com.kustacks.kuring.club.adapter.in.web.dto;

import com.kustacks.kuring.club.application.port.in.dto.ClubDivisionResult;

import java.util.List;

public record ClubDivisionListResponse(
List<ClubDivisionResult> divisions
) {
public static ClubDivisionListResponse from(List<ClubDivisionResult> results) {
return new ClubDivisionListResponse(results);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package com.kustacks.kuring.club.adapter.in.web.dto;

import com.kustacks.kuring.club.application.port.in.dto.ClubItemResult;
import com.kustacks.kuring.club.application.port.in.dto.ClubListResult;

import java.util.List;

public record ClubListResponse(
List<ClubItemResult> clubs
) {

public static ClubListResponse from(ClubListResult result) {
return new ClubListResponse(result.clubs());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,11 @@
import com.kustacks.kuring.club.application.port.out.ClubQueryPort;
import com.kustacks.kuring.club.application.port.out.ClubSubscriptionCommandPort;
import com.kustacks.kuring.club.application.port.out.ClubSubscriptionQueryPort;
import com.kustacks.kuring.club.application.port.out.dto.ClubDetailDto;
import com.kustacks.kuring.club.application.port.out.dto.ClubReadModel;
import com.kustacks.kuring.club.domain.Club;
import com.kustacks.kuring.club.domain.ClubCategory;
import com.kustacks.kuring.club.domain.ClubDivision;
import com.kustacks.kuring.club.domain.ClubSubscribe;
import com.kustacks.kuring.common.annotation.PersistenceAdapter;
import com.kustacks.kuring.user.domain.RootUser;
Expand All @@ -12,7 +16,9 @@
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.stream.Collectors;

@PersistenceAdapter
@RequiredArgsConstructor
Expand All @@ -21,11 +27,58 @@ public class ClubPersistenceAdapter implements ClubQueryPort, ClubSubscriptionCo
private final ClubRepository clubRepository;
private final ClubSubscribeRepository clubSubscribeRepository;

@Override
public List<ClubReadModel> searchClubs(
ClubCategory category,
List<ClubDivision> divisions
) {
return clubRepository.searchClubs(category, divisions);
}

@Override
public Optional<ClubDetailDto> findClubDetailById(Long id) {
return clubRepository.findClubDetailById(id);
}

@Override
public Optional<Club> findClubById(Long id) {
return clubRepository.findById(id);
}

@Override
public List<Long> findSubscribedClubIds(
List<Long> clubIds,
Long rootUserId
) {
return clubSubscribeRepository
.findByClubIdInAndRootUserId(clubIds, rootUserId)
.stream()
.map(sub -> sub.getClub().getId())
.toList();
}
Comment on lines +48 to +58
Copy link

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:

find . -name "ClubSubscribeRepository*" -type f

Repository: ku-ring/ku-ring-backend-web

Length of output: 164


🏁 Script executed:

find . -name "ClubSubscribe.java" -type f

Repository: ku-ring/ku-ring-backend-web

Length of output: 137


🏁 Script executed:

rg -n "findByClubIdInAndRootUserId" --type java -A 15

Repository: ku-ring/ku-ring-backend-web

Length of output: 3999


🏁 Script executed:

cat -n ./src/main/java/com/kustacks/kuring/club/adapter/out/persistence/ClubSubscribeRepository.java

Repository: ku-ring/ku-ring-backend-web

Length of output: 1280


🏁 Script executed:

cat -n ./src/main/java/com/kustacks/kuring/club/domain/ClubSubscribe.java

Repository: ku-ring/ku-ring-backend-web

Length of output: 1609


findSubscribedClubIds에서 N+1 쿼리 문제 발생.

findByClubIdInAndRootUserId 메서드는 @Query 애너테이션 없이 Spring Data의 파생 쿼리로 정의되어 있고, ClubSubscribe.clubFetchType.LAZY로 설정되어 있습니다. 결과적으로 sub.getClub().getId() 호출 시 각 구독마다 추가 쿼리가 발생합니다(1 + N 쿼리).

repository 메서드를 다음 중 하나로 개선하세요:

  • @Query에서 JOIN FETCH를 사용하여 club을 즉시 로드
  • club ID만 직접 조회하는 프로젝션 쿼리 사용 (예: SELECT cs.club.id FROM ClubSubscribe cs ...)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@src/main/java/com/kustacks/kuring/club/adapter/out/persistence/ClubPersistenceAdapter.java`
around lines 48 - 58, The findSubscribedClubIds method causes an N+1 due to lazy
Club loading when calling sub.getClub().getId(); update the repository method
findByClubIdInAndRootUserId (used by
ClubPersistenceAdapter.findSubscribedClubIds) to avoid lazy fetches by either:
1) adding an `@Query` that JOIN FETCHes cs.club so the Club entities are loaded in
one query, or 2) replacing the derived finder with a projection query that
directly selects the club id (e.g. SELECT cs.club.id FROM ClubSubscribe cs WHERE
cs.club.id IN :clubIds AND cs.rootUser.id = :rootUserId) and return List<Long>
to eliminate entity hydration.



@Override
public Long countSubscribers(Long clubId) {
return clubSubscribeRepository.countByClubId(clubId);
}

@Override
public Map<Long, Long> countSubscribersByClubIds(List<Long> clubIds) {

if (clubIds == null || clubIds.isEmpty()) {
return Map.of();
}

List<Object[]> subscriptions = clubSubscribeRepository.countSubscribersByClubIds(clubIds);

return subscriptions.stream()
.collect(Collectors.toMap(
row -> (Long) row[0],
row -> (Long) row[1]
));
}

@Override
public List<Club> findClubsBetweenDates(LocalDateTime start, LocalDateTime end) {
return clubRepository.findClubsBetweenDates(start, end);
Expand Down Expand Up @@ -55,7 +108,7 @@ public void deleteSubscription(RootUser rootUser, Club club) {
}

@Override
public long countSubscriptions(Long rootUserId) {
public Long countSubscriptions(Long rootUserId) {
return clubSubscribeRepository.countByRootUserId(rootUserId);
}
}
Original file line number Diff line number Diff line change
@@ -1,11 +1,20 @@
package com.kustacks.kuring.club.adapter.out.persistence;

import com.kustacks.kuring.club.application.port.out.dto.ClubDetailDto;
import com.kustacks.kuring.club.application.port.out.dto.ClubReadModel;
import com.kustacks.kuring.club.domain.Club;
import com.kustacks.kuring.club.domain.ClubCategory;
import com.kustacks.kuring.club.domain.ClubDivision;

import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;

public interface ClubQueryRepository {

List<ClubReadModel> searchClubs(ClubCategory category, List<ClubDivision> divisions);

Optional<ClubDetailDto> findClubDetailById(Long id);

List<Club> findClubsBetweenDates(LocalDateTime start, LocalDateTime end);
}
Loading
Loading