Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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,119 @@
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.ClubDivisionResponse;
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.ClubDetailResult;
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.data.Cursor;
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 jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
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<ClubDivisionResponse> divisions = clubQueryUseCase.getClubDivisions()
.stream()
.map(ClubDivisionResponse::from)
.toList();

ClubDivisionListResponse response = new ClubDivisionListResponse(divisions);

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,
@RequestParam(required = false) String cursor,
@RequestParam(defaultValue = "20") @Min(1) @Max(30) int size,
@RequestParam(defaultValue = "name") String sortBy,
@RequestHeader(value = AuthorizationExtractor.AUTHORIZATION, required = false) String bearerToken
) {
String email = resolveLoginEmail(bearerToken);

ClubListCommand command = new ClubListCommand(category, division, Cursor.from(cursor), size, sortBy);

ClubListResult result = clubQueryUseCase.getClubs(command, email);

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

ClubDetailResult result = clubQueryUseCase.getClubDetail(id, email);

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,
int 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) {

Location location = result.location() == null ?
null
: new Location(
result.location().building(),
result.location().room(),
result.location().lon(),
result.location().lat()
);

Comment on lines +30 to +38
Copy link
Member

Choose a reason for hiding this comment

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

새로운 객체를 만드는 이유가 있는지요??

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

ClubDetailResult의 location을 그대로 반환하면 web 계층에서 application에 의존하게 된다고 생각해서 Response 전용 location으로 새로 만들었습니다!

Copy link
Member

Choose a reason for hiding this comment

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

아아 그런거라면 괜찮은거 같아유
그러면 ClubDetailDto가 일종의 ReadModel인거 같은데 아래처럼 표시하고 생성은 Result로 넘기는게 낫지 않을지용? 서비스로직에 신경 분산되는 큰 요인인거 같슴니다

//AS-IS
        ClubDetailResult.Location location = hasLocation(dto) ?
                new ClubDetailResult.Location(
                        dto.getBuilding(),
                        dto.getRoom(),
                        dto.getLon(),
                        dto.getLat()
                )
                : null;

        return new ClubDetailResult(
                dto.getId(),
                dto.getName(),
                dto.getSummary(),
                dto.getCategory(),
                dto.getDivision(),
                subscriberCount,
                isSubscribed,
                dto.getInstagramUrl(),
                dto.getYoutubeUrl(),
                dto.getEtcUrl(),
                dto.getDescription(),
                dto.getQualifications(),
                dto.getRecruitmentStatus(),
                dto.getRecruitStartAt(),
                dto.getRecruitEndAt(),
                dto.getApplyUrl(),
                dto.getPosterImagePath(),
                location
        );

//TO-BE
ClubDetailResult.from(dto);

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
);
}

public record Location(
String building,
String room,
Double lon,
Double lat
) {
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.kustacks.kuring.club.adapter.in.web.dto;

import java.util.List;

public record ClubDivisionListResponse(
List<ClubDivisionResponse> divisions
Copy link
Member

Choose a reason for hiding this comment

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

Result배열로 만들어도 될거같슴니다!

Copy link
Member

Choose a reason for hiding this comment

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

별도 Response단일 객체를 만들지 않아도!

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

앗 저도 첨에 그렇게 생각해봤는데 명세서에 응답 구조가 { divisions : [] } 이렇게 divisions 필드로 감싸는 형식으로 돼있어서 따로 response 만들었습니다!

Copy link
Member

Choose a reason for hiding this comment

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

아아 제 말은 ClubDivisionResult를 그대로 사용해도 되지 않나라는 이야기 였슴니다

) {
}
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.ClubDivisionResult;

public record ClubDivisionResponse(
String code,
String koreanName
) {
public static ClubDivisionResponse from(ClubDivisionResult result) {
return new ClubDivisionResponse(
result.code(),
result.koreanName()
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package com.kustacks.kuring.club.adapter.in.web.dto;

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

import java.time.LocalDateTime;

public record ClubItemResponse(
Long id,
String name,
String summary,
String iconImageUrl,
String category,
String division,
boolean isSubscribed,
int subscriberCount,
LocalDateTime recruitStartDate,
LocalDateTime recruitEndDate
) {
public static ClubItemResponse from(ClubItemResult result) {
return new ClubItemResponse(
result.id(),
result.name(),
result.summary(),
result.iconImageUrl(),
result.category(),
result.division(),
result.isSubscribed(),
result.subscriberCount(),
result.recruitStartDate(),
result.recruitEndDate()
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package com.kustacks.kuring.club.adapter.in.web.dto;

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

import java.util.List;

public record ClubListResponse(
List<ClubItemResponse> clubs,
String cursor,
boolean hasNext,
int totalCount
) {

public static ClubListResponse from(ClubListResult result) {
List<ClubItemResponse> clubs = result.clubs().stream()
.map(ClubItemResponse::from)
.toList();

return new ClubListResponse(
clubs,
result.cursor(),
result.hasNext(),
result.totalCount()
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
package com.kustacks.kuring.club.adapter.out.persistence;

import com.kustacks.kuring.club.application.port.out.ClubQueryPort;
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.ClubSubscribe;
import com.kustacks.kuring.common.annotation.PersistenceAdapter;
import com.kustacks.kuring.common.data.Cursor;
import lombok.RequiredArgsConstructor;

import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.stream.Collectors;

@PersistenceAdapter
@RequiredArgsConstructor
public class ClubPersistenceAdapter implements ClubQueryPort {

private final ClubRepository clubRepository;
private final ClubSubscribeRepository clubSubscribeRepository;

@Override
public List<ClubReadModel> searchClubs(
String category,
List<String> divisions,
Cursor cursor,
int size,
String sortBy,
LocalDateTime now
) {
return clubRepository.searchClubs(
category,
divisions,
cursor == null ? null : cursor.getStringCursor(),
size,
sortBy,
now
);
}

@Override
public int countClubs(String category, List<String> divisions) {
return clubRepository.countClubs(category, divisions);
}

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

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

@Override
public boolean existsSubscription(Long clubId, Long loginUserId) {
return clubSubscribeRepository.existsByClubIdAndUser_LoginUserId(clubId, loginUserId);
}

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

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

List<ClubSubscribe> subscriptions = clubSubscribeRepository.findByClubIdIn(clubIds);

return subscriptions.stream()
.collect(Collectors.groupingBy(
sub -> sub.getClub().getId(),
Collectors.collectingAndThen(
Collectors.counting(),
Long::intValue
Copy link
Member

Choose a reason for hiding this comment

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

저희 서비스에 당연히 구독자 수 21억을 넘지는 않겠지만 서도..? 약간 조심은 해야할 내용이지 않나 이런 생각이 듭니닷

)
));
}
Comment on lines +63 to +80
Copy link
Member

Choose a reason for hiding this comment

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

groupby 절로 작성해서 DB단에서 처리하는게 더 좋아보이는데 어떻게 생각하세유?


@Override
public Map<Long, Boolean> findSubscribedClubIds(
List<Long> clubIds,
Long loginUserId
) {

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

List<ClubSubscribe> subscriptions = clubSubscribeRepository.findByClubIdInAndUser_LoginUserId(clubIds, loginUserId);

return subscriptions.stream()
.collect(Collectors.toMap(
sub -> sub.getClub().getId(),
sub -> true
));
}
Comment on lines +64 to +99
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 . -type f -name "ClubSubscribe.java" | head -5

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

Length of output: 137


🏁 Script executed:

find . -type f -name "ClubSubscribeRepository.java" | head -5

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

Length of output: 164


🏁 Script executed:

find . -type f -name "*ClubSubscribe*" -type f | grep -E "\.(java|kt)$"

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

Length of output: 231


🏁 Script executed:

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

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

Length of output: 1428


🏁 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: 752


데이터베이스 수준의 집계 쿼리 사용 권장

countSubscribersByClubIds(line 74)와 findSubscribedClubIds(line 96)에서 전체 ClubSubscribe 엔티티를 메모리에 로드한 후 스트림으로 집계하고 있습니다. 클럽당 수천 명의 구독자가 있을 경우 성능 문제가 발생할 수 있습니다.

ClubSubscribe 엔티티에는 직접 clubId 필드가 없으며, club 필드는 FetchType.LAZY로 설정되어 있으므로 sub.getClub().getId() 호출 시 지연 로딩이 발생합니다. 더 효율적인 방식은 @Query를 사용하여 데이터베이스 수준에서 GROUP BY를 통해 집계하거나, 필요한 필드만 조회하는 프로젝션을 활용하는 것입니다.

🤖 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 64 - 99, countSubscribersByClubIds and findSubscribedClubIds
currently load full ClubSubscribe entities (risking lazy-load of club and OOM)
and aggregate in memory; change to repository-level aggregation/projection
queries that return clubId and count/exists directly. Add methods on
clubSubscribeRepository (e.g., findSubscriberCountsByClubIds(List<Long> clubIds)
with a `@Query` selecting s.club.id AS clubId, COUNT(s) AS cnt GROUP BY s.club.id
and findSubscribedClubIdsByUser(List<Long> clubIds, Long loginUserId) with a
`@Query` selecting s.club.id where s.user.loginUserId = :loginUserId) and adapt
countSubscribersByClubIds and findSubscribedClubIds to call those repo methods
and convert the projection results into Map<Long,Integer> / Map<Long,Boolean>
without loading full entities.

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
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 java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;

public interface ClubQueryRepository {

List<ClubReadModel> searchClubs(String category, List<String> divisions, String cursor, int size, String sortBy, LocalDateTime now);

int countClubs(String category, List<String> divisions);

Optional<ClubDetailDto> findClubDetailById(Long id);
}
Loading
Loading