Feat: 동아리 소속 목록 조회 api, 동아리 목록 조회 api, 동아리 상세 정보 조회 api 구현#347
Feat: 동아리 소속 목록 조회 api, 동아리 목록 조회 api, 동아리 상세 정보 조회 api 구현#347
Conversation
|
@coderabbitai review |
✅ Actions performedReview triggered.
|
Walkthrough동아리 조회 기능을 위한 REST API 엔드포인트와 인프라를 추가합니다. 동아리 소속, 목록(커서 기반 페이지네이션), 상세 정보 조회를 지원하는 컨트롤러, DTO, 퍼시스턴스 어댑터, QueryDSL 기반 저장소, 애플리케이션 서비스를 구현합니다. Changes
Sequence Diagram(s)sequenceDiagram
actor Client
participant API as ClubQueryApiV2
participant Auth as JwtTokenProvider
participant Service as ClubQueryService
participant Port as ClubQueryPort
participant Repo as ClubQueryRepository
participant DB as Database
Client->>API: GET /api/v2/clubs?category=...&division=...
API->>Auth: Authorization 헤더 검증 (Bearer)
Auth-->>API: loginEmail / null
API->>Service: getClubs(ClubListCommand, loginEmail)
Service->>Port: searchClubs(category, divisions, cursor, size, sortBy, now)
Port->>Repo: QueryDSL 동아리 목록 조회
Repo->>DB: SELECT 동아리 데이터
DB-->>Repo: ClubReadModel[]
Repo-->>Port: results
Service->>Port: countClubs(category, divisions)
Port->>Repo: COUNT 쿼리
Repo->>DB: SELECT COUNT
DB-->>Repo: totalCount
alt loginEmail 존재
Service->>Port: findSubscribedClubIds(clubIds, loginUserId)
Port->>Repo: 구독 조회
Repo->>DB: SELECT 구독
end
Service->>Service: ClubListResult 생성
Service-->>API: ClubListResult
API->>Client: 200 + ClubListResponse
sequenceDiagram
actor Client
participant API as ClubQueryApiV2
participant Auth as JwtTokenProvider
participant Service as ClubQueryService
participant Port as ClubQueryPort
participant Repo as ClubQueryRepository
participant DB as Database
Client->>API: GET /api/v2/clubs/{id}
API->>Auth: Authorization 헤더 검증 (Bearer)
Auth-->>API: loginEmail / null
API->>Service: getClubDetail(id, loginEmail)
Service->>Port: findClubDetailById(id)
Port->>Repo: QueryDSL 상세 조회 (SNS 포함)
Repo->>DB: SELECT 동아리 + SNS + 모집 정보
DB-->>Repo: ClubDetailDto
Repo-->>Port: Optional<ClubDetailDto>
alt not found
Service-->>API: throw CLUB_NOT_FOUND
API->>Client: 404
else found
Service->>Port: countSubscribers(id)
Port->>Repo: SELECT COUNT(구독)
alt loginEmail 존재
Service->>Port: existsSubscription(id, loginUserId)
Port->>Repo: 사용자 구독 여부 조회
end
Service-->>API: ClubDetailResult
API->>Client: 200 + ClubDetailResponse
end
Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches
🧪 Generate unit tests (beta)
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. Comment |
There was a problem hiding this comment.
Actionable comments posted: 9
🧹 Nitpick comments (6)
src/main/java/com/kustacks/kuring/club/application/port/in/dto/ClubItemResult.java (1)
10-11:ClubDetailResult와category/division타입 불일치
ClubDetailResult는 동일한 필드에ClubCategory,ClubDivision도메인 열거형을 사용하지만,ClubItemResult는String을 사용합니다. 두 DTO가 같은 개념의 데이터를 나타내면서 타입이 다르면, 매핑 레이어에서 변환 실수가 발생하거나 API 응답 일관성이 깨질 수 있습니다. 통일된 표현 방식을 사용하거나, 의도적인 차이라면 주석으로 명시하는 것을 권장합니다.🤖 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/application/port/in/dto/ClubItemResult.java` around lines 10 - 11, ClubItemResult uses String for the category and division fields while ClubDetailResult uses the domain enums ClubCategory and ClubDivision, causing inconsistency; change the types of the category and division fields in ClubItemResult to ClubCategory and ClubDivision respectively, then update any mappers (e.g., where ClubItemResult is populated) and unit tests to pass/format the enums consistently (or add clear javadoc if the String choice was intentional) so both DTOs represent the same domain types.src/test/java/com/kustacks/kuring/club/application/service/ClubQueryServiceTest.java (2)
43-75:mockReadModels필드에final선언 추가를 권장합니다.해당 필드는 초기화 후 변경되지 않으므로
final로 선언하는 것이 적절합니다.♻️ 제안된 변경
- private List<ClubReadModel> mockReadModels = + private final List<ClubReadModel> mockReadModels =🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/test/java/com/kustacks/kuring/club/application/service/ClubQueryServiceTest.java` around lines 43 - 75, The field mockReadModels in ClubQueryServiceTest is immutable after initialization but not declared final; change its declaration to add the final modifier (i.e., make the List<ClubReadModel> mockReadModels field final) so the intent is explicit and the compiler enforces immutability of the reference; locate the field by the symbol mockReadModels in the ClubQueryServiceTest class and update its declaration accordingly.
112-113:countClubs목(mock)이 반환하는 값(2)이searchClubs반환 목록 크기(3)와 불일치합니다.
totalCount가 2이지만 실제로 3개 항목이 반환되는 설정은 현실적이지 않아 테스트 독자에게 혼동을 줄 수 있습니다.totalCount는 반환 목록 크기 이상이어야 의미가 일관됩니다.♻️ 제안된 변경
when(clubQueryPort.countClubs(category, divisionList)) - .thenReturn(2); + .thenReturn(3);🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/test/java/com/kustacks/kuring/club/application/service/ClubQueryServiceTest.java` around lines 112 - 113, The test sets clubQueryPort.countClubs(...) to return 2 while searchClubs returns 3 items, which is inconsistent; update the mock return of countClubs in the test (the when(clubQueryPort.countClubs(category, divisionList)).thenReturn(...)) so that totalCount is at least the size of the list returned by ClubQueryService.searchClubs (e.g., change to 3) or alternatively reduce the mocked searchClubs result to match the count, ensuring totalCount >= returned list size.src/main/java/com/kustacks/kuring/club/application/port/out/dto/ClubDetailDto.java (1)
42-83:@QueryProjection이 실제로 사용되지 않습니다.
ClubQueryRepositoryImpl.findClubDetailById는QClubDetailDto를 이용한 QueryDSL 프로젝션 대신Tuple방식으로ClubDetailDto를 직접 생성합니다. 또한recruitmentStatus는 DB 컬럼이 아닌 계산된 값이므로 QueryDSL 프로젝션으로 처리하는 것 자체가 불가능합니다. 불필요한QClubDetailDto생성을 방지하려면@QueryProjection을 제거하는 것이 좋습니다.🤖 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/application/port/out/dto/ClubDetailDto.java` around lines 42 - 83, Remove the unused QueryDSL projection by deleting the `@QueryProjection` annotation from the ClubDetailDto constructor declaration in ClubDetailDto; ensure no other code relies on QClubDetailDto (the repository ClubQueryRepositoryImpl.findClubDetailById constructs ClubDetailDto from Tuple), and remove any unused import of com.querydsl.core.annotations.QueryProjection or unused generated QClubDetailDto artifacts if present.src/main/java/com/kustacks/kuring/club/adapter/out/persistence/ClubQueryRepositoryImpl.java (1)
200-220:start,end모두null이고isAlways = false일 때RECRUITING을 반환하는 것이 의도된 동작인지 확인하세요.모집 기간 정보가 전혀 없는 경우 기본값으로
RECRUITING을 반환하면 의도치 않게 모집 중으로 표시될 수 있습니다.CLOSED혹은 별도의UNKNOWN상태가 더 적합할 수 있습니다.🤖 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/ClubQueryRepositoryImpl.java` around lines 200 - 220, The current calculateRecruitmentStatus method returns RECRUITING when start and end are both null and isAlways is false; change this to return a safer default (e.g., introduce ClubRecruitmentStatus.UNKNOWN and return UNKNOWN) when start==null && end==null && Boolean.FALSE.equals(isAlways), update the ClubRecruitmentStatus enum to include UNKNOWN, adjust any callers/tests that expect RECRUITING for missing period data, and ensure calculateRecruitmentStatus still handles the existing branches (ALWAYS, BEFORE, CLOSED, RECRUITING) unchanged otherwise.src/main/java/com/kustacks/kuring/club/application/service/ClubQueryService.java (1)
97-97:userToken파라미터가 사용되지 않습니다.
getClubDetail메서드에서userToken을 인자로 받지만 구현 내부에서 전혀 사용하지 않습니다. 향후 사용 예정이면// TODO주석을 추가하고, 그렇지 않다면 인터페이스와 함께 제거하는 것이 좋습니다.🤖 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/application/service/ClubQueryService.java` at line 97, getClubDetail 메서드의 userToken 파라미터가 사용되지 않으므로 사용자 토큰이 향후 필요하다면 ClubQueryService.getClubDetail(Long id, String userToken, Long loginUserId) 선언과 메서드 구현에 "// TODO: userToken will be used for X" 주석을 추가하고 호출부에 전달되는 값을 유지하되 사용 시점을 명시하세요; 필요 없다면 인터페이스와 구현에서 userToken 파라미터를 제거(메서드 시그니처 변경)하고, 관련 호출부들(및 테스트)을 찾아 모두 수정하여 컴파일 오류가 없도록 하세요.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@src/main/java/com/kustacks/kuring/club/adapter/in/web/ClubQueryApiV2.java`:
- Around line 78-114: The JWT handling between getClubs and getClubDetail is
inconsistent; update getClubDetail to match getClubs by removing the thrown
InvalidStateException and only setting loginUserId when
jwtTokenProvider.validateToken(jwt) returns true (i.e., if bearerToken != null
then extractAuthorizationValue(...), call validateToken(jwt) and if valid set
loginUserId = Long.parseLong(jwtTokenProvider.getPrincipal(jwt)), otherwise
leave loginUserId null), so getClubDetail uses the same silent fallback behavior
as getClubs (unless you intend to require authentication—if so, make getClubs
throw instead).
- Line 82: The code calls Long.parseLong(jwtTokenProvider.getPrincipal(jwt))
(used to set loginUserId at least in ClubQueryApiV2) which can throw
NumberFormatException if the JWT subject isn't a numeric string; add handling by
validating or parsing safely: either change JwtTokenProvider.getPrincipal() to
return a Long (or an Optional<Long>) if you can guarantee numeric subjects, or
wrap the parse in a try/catch that catches NumberFormatException and translates
it into a controlled response (e.g., throw a custom authentication/validation
exception or return a 401/400) so the service does not propagate 500; apply the
same fix for the other occurrence at the second parse around line 113.
In
`@src/main/java/com/kustacks/kuring/club/adapter/in/web/dto/ClubDetailResponse.java`:
- Around line 28-58: The null-check in ClubDetailResponse.from is ineffective
because ClubQueryService.getClubDetail always returns a
ClubDetailResult.Location instance even when all location fields are null; fix
this in ClubQueryService.getClubDetail by changing the logic that constructs
ClubDetailResult.Location so it returns null when all constituent fields
(building, room, lon, lat) are null/absent, i.e., only create new
ClubDetailResult.Location(...) when at least one of those fields is non-null;
keep ClubDetailResponse.from as-is so it will receive a true null when there is
no location.
In
`@src/main/java/com/kustacks/kuring/club/adapter/out/persistence/ClubQueryRepositoryImpl.java`:
- Around line 31-58: The cursor check in searchClubs currently uses
idAfterCursor(cursor) which only applies club.id.gt(Long.parseLong(cursor)),
causing missing rows when sort order is not by id; update idAfterCursor (or
create a new method used by searchClubs) to decode a composite cursor that
includes the last sort key and lastId (e.g. "lastKey|lastId") and produce a
boolean expression matching keyset pagination: for sortBy "name" produce
(club.name.gt(lastName).or(club.name.eq(lastName).and(club.id.gt(lastId)))) and
for "recruitEndAt" produce
(club.recruitEndAt.gt(lastEndAt).or(club.recruitEndAt.eq(lastEndAt).and(club.id.gt(lastId)))),
falling back to club.id.gt(lastId) for id-only sort; ensure searchClubs uses
getOrderSpecifiers(sortBy) and the new composite cursor predicate so ordering
and cursor logic align.
- Around line 180-183: The idAfterCursor method currently calls
Long.parseLong(cursor) without handling NumberFormatException; update
idAfterCursor(String cursor) to validate or safely parse the cursor (e.g.,
try-catch NumberFormatException or use a numeric-checked parse) and if parsing
fails return null (or an appropriate empty predicate) instead of letting the
exception propagate; ensure you keep the existing behavior of returning null for
null/"0" and use the parsed long value with club.id.gt(...) when parsing
succeeds.
- Around line 39-58: The projection is mapping club.posterImagePath into the
ClubReadModel's 4th constructor parameter (iconImageUrl), which is semantically
incorrect; pick one resolution and implement it consistently: either add an
iconImagePath field to the Club entity and use club.iconImagePath in the
QClubReadModel projection inside ClubQueryRepositoryImpl, or rename/change
ClubReadModel's 4th parameter to posterImageUrl and update
QClubReadModel/constructor usages accordingly so the projected field names and
entity fields match (references: ClubQueryRepositoryImpl, QClubReadModel,
club.posterImagePath, ClubReadModel constructor).
In
`@src/main/java/com/kustacks/kuring/club/application/port/in/ClubQueryUseCase.java`:
- Line 15: getClubDetail 메서드의 파라미터 중 userToken과 loginUserId가 중복되어 책임 경계가 모호합니다:
ClubQueryUseCase 인터페이스의 getClubDetail(Long id, String userToken, Long
loginUserId)에서 userToken이 단순 식별용이라면 userToken을 제거하고 loginUserId만 사용하도록 시그니처를
변경하고, 컨트롤러/시큐리티 레이어에서 토큰을 파싱해 loginUserId를 전달하도록 수정하세요; 만약 userToken이 외부 API 호출
등 별도 목적이라면 getClubDetail 선언에 해당 의도를 주석으로 명확히 남기고 getClubs(Long id, Long
loginUserId)와의 일관성을 위해 다른 메서드들도 같은 패턴(userToken 포함)으로 맞추거나 인터페이스 설계 문서를 업데이트하세요.
In
`@src/main/java/com/kustacks/kuring/club/application/service/ClubQueryService.java`:
- Around line 55-82: The current mapping in ClubQueryService iterates
cursorBasedList.getContents() and calls clubQueryPort.countSubscribers(...) and
clubQueryPort.existsSubscription(...) per club causing N+1 queries; instead add
port methods to fetch subscriber counts and subscription existence in bulk
(e.g., clubQueryPort.countSubscribersByClubIds(List<Long> clubIds) returning a
Map<Id,Integer> and clubQueryPort.findSubscribedClubIdsByUser(Long userId,
List<Long> clubIds) returning a Set<Id>), call those once before the stream,
then construct each ClubItemResult using the pre-fetched counts and subscription
set (replace per-item calls to countSubscribers and existsSubscription with
lookups into the returned collections).
- Around line 109-133: The code in ClubQueryService always constructs a new
ClubDetailResult.Location even when all location fields are null, causing
ClubDetailResponse.from()'s result.location() to be non-null with all-null
fields; change the return construction in ClubQueryService so you only create
and pass a new ClubDetailResult.Location when at least one of dto.getBuilding(),
dto.getRoom(), dto.getLon(), dto.getLat() is non-null (otherwise pass null),
ensuring Location is null for clubs without location data.
---
Nitpick comments:
In
`@src/main/java/com/kustacks/kuring/club/adapter/out/persistence/ClubQueryRepositoryImpl.java`:
- Around line 200-220: The current calculateRecruitmentStatus method returns
RECRUITING when start and end are both null and isAlways is false; change this
to return a safer default (e.g., introduce ClubRecruitmentStatus.UNKNOWN and
return UNKNOWN) when start==null && end==null && Boolean.FALSE.equals(isAlways),
update the ClubRecruitmentStatus enum to include UNKNOWN, adjust any
callers/tests that expect RECRUITING for missing period data, and ensure
calculateRecruitmentStatus still handles the existing branches (ALWAYS, BEFORE,
CLOSED, RECRUITING) unchanged otherwise.
In
`@src/main/java/com/kustacks/kuring/club/application/port/in/dto/ClubItemResult.java`:
- Around line 10-11: ClubItemResult uses String for the category and division
fields while ClubDetailResult uses the domain enums ClubCategory and
ClubDivision, causing inconsistency; change the types of the category and
division fields in ClubItemResult to ClubCategory and ClubDivision respectively,
then update any mappers (e.g., where ClubItemResult is populated) and unit tests
to pass/format the enums consistently (or add clear javadoc if the String choice
was intentional) so both DTOs represent the same domain types.
In
`@src/main/java/com/kustacks/kuring/club/application/port/out/dto/ClubDetailDto.java`:
- Around line 42-83: Remove the unused QueryDSL projection by deleting the
`@QueryProjection` annotation from the ClubDetailDto constructor declaration in
ClubDetailDto; ensure no other code relies on QClubDetailDto (the repository
ClubQueryRepositoryImpl.findClubDetailById constructs ClubDetailDto from Tuple),
and remove any unused import of com.querydsl.core.annotations.QueryProjection or
unused generated QClubDetailDto artifacts if present.
In
`@src/main/java/com/kustacks/kuring/club/application/service/ClubQueryService.java`:
- Line 97: getClubDetail 메서드의 userToken 파라미터가 사용되지 않으므로 사용자 토큰이 향후 필요하다면
ClubQueryService.getClubDetail(Long id, String userToken, Long loginUserId) 선언과
메서드 구현에 "// TODO: userToken will be used for X" 주석을 추가하고 호출부에 전달되는 값을 유지하되 사용
시점을 명시하세요; 필요 없다면 인터페이스와 구현에서 userToken 파라미터를 제거(메서드 시그니처 변경)하고, 관련 호출부들(및 테스트)을
찾아 모두 수정하여 컴파일 오류가 없도록 하세요.
In
`@src/test/java/com/kustacks/kuring/club/application/service/ClubQueryServiceTest.java`:
- Around line 43-75: The field mockReadModels in ClubQueryServiceTest is
immutable after initialization but not declared final; change its declaration to
add the final modifier (i.e., make the List<ClubReadModel> mockReadModels field
final) so the intent is explicit and the compiler enforces immutability of the
reference; locate the field by the symbol mockReadModels in the
ClubQueryServiceTest class and update its declaration accordingly.
- Around line 112-113: The test sets clubQueryPort.countClubs(...) to return 2
while searchClubs returns 3 items, which is inconsistent; update the mock return
of countClubs in the test (the when(clubQueryPort.countClubs(category,
divisionList)).thenReturn(...)) so that totalCount is at least the size of the
list returned by ClubQueryService.searchClubs (e.g., change to 3) or
alternatively reduce the mocked searchClubs result to match the count, ensuring
totalCount >= returned list size.
src/main/java/com/kustacks/kuring/club/adapter/in/web/ClubQueryApiV2.java
Outdated
Show resolved
Hide resolved
src/main/java/com/kustacks/kuring/club/adapter/in/web/ClubQueryApiV2.java
Outdated
Show resolved
Hide resolved
src/main/java/com/kustacks/kuring/club/adapter/in/web/dto/ClubDetailResponse.java
Show resolved
Hide resolved
src/main/java/com/kustacks/kuring/club/adapter/out/persistence/ClubQueryRepositoryImpl.java
Show resolved
Hide resolved
src/main/java/com/kustacks/kuring/club/adapter/out/persistence/ClubQueryRepositoryImpl.java
Show resolved
Hide resolved
src/main/java/com/kustacks/kuring/club/adapter/out/persistence/ClubQueryRepositoryImpl.java
Outdated
Show resolved
Hide resolved
src/main/java/com/kustacks/kuring/club/application/port/in/ClubQueryUseCase.java
Outdated
Show resolved
Hide resolved
src/main/java/com/kustacks/kuring/club/application/service/ClubQueryService.java
Show resolved
Hide resolved
src/main/java/com/kustacks/kuring/club/application/service/ClubQueryService.java
Show resolved
Hide resolved
There was a problem hiding this comment.
Actionable comments posted: 3
🧹 Nitpick comments (3)
src/main/java/com/kustacks/kuring/club/adapter/out/persistence/ClubQueryRepositoryImpl.java (1)
257-268:getOrderSpecifiers에서recruitmentGroup헬퍼 메서드를 재사용하지 않음Lines 259-262의 inline CASE 표현식이 lines 277-282의
recruitmentGroup(now)메서드와 완전히 동일합니다.cursorCondition은recruitmentGroup을 재사용하지만,getOrderSpecifiers는 복제합니다.♻️ 제안된 리팩터
case "recruitEndDate" -> { - - var statusOrder = new CaseBuilder() - .when(club.recruitEndAt.isNull()).then(2) - .when(club.recruitEndAt.lt(now)).then(1) - .otherwise(0); - + var statusOrder = recruitmentGroup(now); yield new OrderSpecifier[]{ statusOrder.asc(), club.recruitEndAt.asc().nullsLast(), club.id.asc() }; }🤖 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/ClubQueryRepositoryImpl.java` around lines 257 - 268, The order specifier for "recruitEndDate" duplicates the CASE logic already implemented in recruitmentGroup(now); modify getOrderSpecifiers to call recruitmentGroup(now) instead of inlining the CaseBuilder: obtain the CaseBuilder/Expression from recruitmentGroup(now) (as used by cursorCondition) and use it as statusOrder, then yield the same OrderSpecifier array (statusOrder.asc(), club.recruitEndAt.asc().nullsLast(), club.id.asc()); this removes duplication and ensures both cursorCondition and getOrderSpecifiers share the same recruitmentGroup logic.src/test/java/com/kustacks/kuring/club/application/service/ClubQueryServiceTest.java (1)
51-52:mockReadModels필드를final로 선언재할당되지 않는 테스트 픽스처 필드는
final로 선언하는 것이 관례입니다.♻️ 제안된 수정
- private List<ClubReadModel> mockReadModels = + private final List<ClubReadModel> mockReadModels =🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/test/java/com/kustacks/kuring/club/application/service/ClubQueryServiceTest.java` around lines 51 - 52, The field mockReadModels in ClubQueryServiceTest should be declared final since it’s a test fixture that is never reassigned; update the declaration of mockReadModels to be private final List<ClubReadModel> mockReadModels to reflect immutability and follow test convention, ensuring no other code tries to reassign it.src/main/java/com/kustacks/kuring/club/application/service/ClubQueryService.java (1)
44-65:null일 때 불필요한rootUserQueryPort.findRootUserByEmail(null)DB 호출 발생
getClubs와getClubDetail모두email == null인 경우에도findRootUserByEmail(null)을 호출합니다. 비로그인 사용자의 요청마다 불필요한 DB 조회가 발생합니다.♻️ 제안된 수정 (getClubs 및 getClubDetail 모두 동일하게 적용)
- Optional<RootUser> optionalRootUser = rootUserQueryPort.findRootUserByEmail(email); - Long loginUserId = optionalRootUser.map(RootUser::getId).orElse(null); + Long loginUserId = email != null + ? rootUserQueryPort.findRootUserByEmail(email) + .map(RootUser::getId) + .orElse(null) + : null;Also applies to: 107-112
🤖 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/application/service/ClubQueryService.java` around lines 44 - 65, Avoid calling rootUserQueryPort.findRootUserByEmail when email is null: in getClubs (and mirror the same change in getClubDetail) guard the DB call with an email null check so you only call rootUserQueryPort.findRootUserByEmail(email) when email != null, otherwise keep loginUserId as null; locate the usage around Optional<RootUser> optionalRootUser = rootUserQueryPort.findRootUserByEmail(email) and the mapping to loginUserId (RootUser::getId) and modify control flow so the optional lookup is skipped for null email and the rest of the method uses the existing loginUserId variable unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In
`@src/main/java/com/kustacks/kuring/club/adapter/out/persistence/ClubPersistenceAdapter.java`:
- Around line 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.
In
`@src/main/java/com/kustacks/kuring/club/application/service/ClubQueryService.java`:
- Around line 161-181: The cursor parsing in cursorCondition breaks when club
names contain '|' because it uses split("\\|") and then
Long.parseLong(parts[1]); update the parsing logic (not generateCursor) to
locate the last '|' via lastIndexOf('|'), extract the id as the substring after
that last delimiter and parse it to Long, and treat the prefix (name) as
everything before that last delimiter so names containing '|' are preserved and
parsing never attempts to parse non-numeric parts; adjust any null/empty checks
and exception handling accordingly in the method that currently performs
split("\\|") (cursorCondition).
In
`@src/test/java/com/kustacks/kuring/club/application/service/ClubQueryServiceTest.java`:
- Around line 200-204: 테스트 getClubs_withoutLogin에서 잘못된 메서드를 검증하고 있으므로
verify(clubQueryPort, never()).existsSubscription(...) 대신 로그인 없을 때 구독 관련 조회가
호출되지 않음을 검증하도록 변경하세요; 구체적으로 ClubQueryServiceTest의 getClubs_withoutLogin에서
verify(clubQueryPort, never()).findSubscribedClubIds(anyLong()) 를 추가하거나
existsSubscription 검증을 삭제하고 findSubscribedClubIds 호출 부재를 검증하여 비로그인 경로에서
findSubscribedClubIds가 호출되지 않음을 확인하십시오.
---
Duplicate comments:
In
`@src/main/java/com/kustacks/kuring/club/adapter/out/persistence/ClubQueryRepositoryImpl.java`:
- Line 47: The code maps Club.posterImagePath into the 4th parameter of
ClubReadModel (which is semantically iconImageUrl) causing a mismatch used later
by ClubQueryService (r.getIconImageUrl()); fix by making the mapping explicit
and correct: either pass club.getIconImageUrl() into the ClubReadModel
constructor (or add a getIconImageUrl() on Club that returns posterImagePath if
that is the intended source), or adjust the ClubReadModel constructor/parameter
order so the field names match; update ClubQueryRepositoryImpl to use the
correct symbol (iconImageUrl/getIconImageUrl) instead of posterImagePath so
ClubQueryService.r.getIconImageUrl() remains valid.
---
Nitpick comments:
In
`@src/main/java/com/kustacks/kuring/club/adapter/out/persistence/ClubQueryRepositoryImpl.java`:
- Around line 257-268: The order specifier for "recruitEndDate" duplicates the
CASE logic already implemented in recruitmentGroup(now); modify
getOrderSpecifiers to call recruitmentGroup(now) instead of inlining the
CaseBuilder: obtain the CaseBuilder/Expression from recruitmentGroup(now) (as
used by cursorCondition) and use it as statusOrder, then yield the same
OrderSpecifier array (statusOrder.asc(), club.recruitEndAt.asc().nullsLast(),
club.id.asc()); this removes duplication and ensures both cursorCondition and
getOrderSpecifiers share the same recruitmentGroup logic.
In
`@src/main/java/com/kustacks/kuring/club/application/service/ClubQueryService.java`:
- Around line 44-65: Avoid calling rootUserQueryPort.findRootUserByEmail when
email is null: in getClubs (and mirror the same change in getClubDetail) guard
the DB call with an email null check so you only call
rootUserQueryPort.findRootUserByEmail(email) when email != null, otherwise keep
loginUserId as null; locate the usage around Optional<RootUser> optionalRootUser
= rootUserQueryPort.findRootUserByEmail(email) and the mapping to loginUserId
(RootUser::getId) and modify control flow so the optional lookup is skipped for
null email and the rest of the method uses the existing loginUserId variable
unchanged.
In
`@src/test/java/com/kustacks/kuring/club/application/service/ClubQueryServiceTest.java`:
- Around line 51-52: The field mockReadModels in ClubQueryServiceTest should be
declared final since it’s a test fixture that is never reassigned; update the
declaration of mockReadModels to be private final List<ClubReadModel>
mockReadModels to reflect immutability and follow test convention, ensuring no
other code tries to reassign it.
| 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 | ||
| ) | ||
| )); | ||
| } | ||
|
|
||
| @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 | ||
| )); | ||
| } |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
find . -type f -name "ClubSubscribe.java" | head -5Repository: ku-ring/ku-ring-backend-web
Length of output: 137
🏁 Script executed:
find . -type f -name "ClubSubscribeRepository.java" | head -5Repository: 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.javaRepository: 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.javaRepository: 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.
| private String generateCursor(ClubReadModel club, String sortBy, LocalDateTime now) { | ||
| return switch (sortBy) { | ||
| case "name" -> club.getName() + "|" + club.getId(); | ||
| case "recruitEndDate" -> { | ||
| int group; | ||
| if (club.getRecruitEndDate() == null) { | ||
| group = 2; | ||
| } else if (club.getRecruitEndDate().isBefore(now)) { | ||
| group = 1; | ||
| } else { | ||
| group = 0; | ||
| } | ||
|
|
||
| String datePart = club.getRecruitEndDate() == null | ||
| ? "null" | ||
| : club.getRecruitEndDate().toString(); | ||
|
|
||
| yield group + "|" + datePart + "|" + club.getId(); | ||
| } | ||
| default -> club.getId().toString(); | ||
| }; |
There was a problem hiding this comment.
name 정렬 커서에 | 구분자 사용 시 동아리명에 | 포함될 경우 페이지네이션 파괴
generateCursor에서 name 정렬 커서를 "동아리명|id" 형태로 생성하는데, 동아리명에 |가 포함될 경우 ("A|B 동아리" → 커서 "A|B 동아리|123"), cursorCondition에서 split("\\|")로 분리하면 parts[1]이 "B 동아리"가 되어 Long.parseLong("B 동아리")에서 NumberFormatException → try-catch에 잡혀 null 반환 → 커서 조건 없이 처음부터 재조회 → 중복 항목 노출 및 무한 루프성 페이지네이션 오류가 발생합니다.
ID는 항상 마지막 | 이후에 위치하므로, lastIndexOf('|')로 파싱하면 동아리명 내의 |에 안전합니다.
🐛 제안된 수정
- case "name" -> {
- if (parts.length < 2) yield null;
-
- String lastName = parts[0];
- Long lastId = Long.parseLong(parts[1]);
-
+ case "name" -> {
+ int lastPipe = cursor.lastIndexOf('|');
+ if (lastPipe < 0) yield null;
+
+ String lastName = cursor.substring(0, lastPipe);
+ Long lastId = Long.parseLong(cursor.substring(lastPipe + 1));
+
yield club.name.gt(lastName)
.or(
club.name.eq(lastName)
.and(club.id.gt(lastId))
);
}그리고 generateCursor의 name 케이스는 변경 불필요 ("동아리명|id" 형식 유지, 파싱 쪽에서 처리).
🤖 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/application/service/ClubQueryService.java`
around lines 161 - 181, The cursor parsing in cursorCondition breaks when club
names contain '|' because it uses split("\\|") and then
Long.parseLong(parts[1]); update the parsing logic (not generateCursor) to
locate the last '|' via lastIndexOf('|'), extract the id as the substring after
that last delimiter and parse it to Long, and treat the prefix (name) as
everything before that last delimiter so names containing '|' are preserved and
parsing never attempts to parse non-numeric parts; adjust any null/empty checks
and exception handling accordingly in the method that currently performs
split("\\|") (cursorCondition).
| ClubListResult result = clubQueryService.getClubs(command, null); | ||
|
|
||
| //then | ||
| assertThat(result.clubs().get(0).isSubscribed()).isFalse(); | ||
| verify(clubQueryPort, never()).existsSubscription(anyLong(), anyLong()); |
There was a problem hiding this comment.
getClubs_withoutLogin 테스트에서 검증 대상 메서드가 잘못됨
existsSubscription은 getClubs()에서는 로그인 여부와 관계없이 호출되지 않습니다(오직 getClubDetail()에서만 사용). 따라서 line 204의 verify(never()) 는 항상 통과하며 실제 동작을 검증하지 않습니다. 비로그인 시 findSubscribedClubIds가 호출되지 않음을 검증해야 합니다.
🐛 제안된 수정
- verify(clubQueryPort, never()).existsSubscription(anyLong(), anyLong());
+ verify(clubQueryPort, never()).findSubscribedClubIds(any(), any());📝 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.
| ClubListResult result = clubQueryService.getClubs(command, null); | |
| //then | |
| assertThat(result.clubs().get(0).isSubscribed()).isFalse(); | |
| verify(clubQueryPort, never()).existsSubscription(anyLong(), anyLong()); | |
| ClubListResult result = clubQueryService.getClubs(command, null); | |
| //then | |
| assertThat(result.clubs().get(0).isSubscribed()).isFalse(); | |
| verify(clubQueryPort, never()).findSubscribedClubIds(any(), any()); |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In
`@src/test/java/com/kustacks/kuring/club/application/service/ClubQueryServiceTest.java`
around lines 200 - 204, 테스트 getClubs_withoutLogin에서 잘못된 메서드를 검증하고 있으므로
verify(clubQueryPort, never()).existsSubscription(...) 대신 로그인 없을 때 구독 관련 조회가
호출되지 않음을 검증하도록 변경하세요; 구체적으로 ClubQueryServiceTest의 getClubs_withoutLogin에서
verify(clubQueryPort, never()).findSubscribedClubIds(anyLong()) 를 추가하거나
existsSubscription 검증을 삭제하고 findSubscribedClubIds 호출 부재를 검증하여 비로그인 경로에서
findSubscribedClubIds가 호출되지 않음을 확인하십시오.
|
코드래빗 리뷰 확인해서 수정했습니다! 변경 사항
질문상세 조회 API 명세상 userToken을 함께 받도록 되어 있는데 현재 코드상에서는 사용되지 않고 있어서 이 파라미터를 제거해도 되는지 확인 부탁드립니다!! |
There was a problem hiding this comment.
고생하셨습니다 리뷰가 늦어져 죄송합니다! 코멘트 확인 후 답글 꼭 부탁드립니다~
전체적인 이야기를 해보자면 로직의 흐름은 잘 만들어진거 같으나, AI가 과도하게 영향을 끼쳐서 팀의 이전까지의 방향과 너무 다른 결로 작성하셨다는 생각이 드네요!!
-
팀 아키텍처에 맞지 않게 레이어, 객체 간 로직이 혼재되어 있다는 생각이 듭니다! 특히 비지니스 로직, 서비스의 중요 정책과 연관된 내용이 서비스 코드에 없다는 점이 가장 크게 느껴져요!
-
이전의 프로젝트에서 충분히 활용할 수 있는 점들도 활용이 안된 느낌입니다! 커서 관련 내용도 그러하고 전반적인 메서드 네이밍도 그렇구요! 이 또한 아마도 컨텍스트를 다 이해하지 못한 AI의 영향이지 않을까 싶어요..!! 활용하시는건 매우 좋지만 충분히 기능 기획과 팀과 동기화가 되길..!!
-
마지막으로 시간 걸리시더라도 테스트는 항상 꼭 작성해주세요! 만들어주신 서비스레이어 테스트도 좋지만 커버리지 만큼은 코어 흐름에 대한 테스트는 꼭..!!
+) AI를 사용하시는건 좋지만 단순 기능 구현도만 보지 말고 그 외적인 부분도 검토를 많이 해주세요!!
++) 전체 프로젝트 아키텍처와 이전까지의 객체지향 방향성을 조금 더 고민해주셨으면 합니다!
| /* Club */ | ||
| CLUB_DIVISION_SEARCH_SUCCESS(HttpStatus.OK.value(), "지원하는 동아리 소속 조회에 성공하였습니다"), | ||
| CLUB_LIST_SEARCH_SUCCESS(HttpStatus.OK.value(), "동아리 목록 조회에 성공하였습니다"), | ||
| CLUB_DETAIL_SEARCH_SUCCESS(HttpStatus.OK.value(), "동아리 상세 조회에 성공하였습니다"), | ||
|
|
There was a problem hiding this comment.
아 이 메시지 내용이 정확히 API 명세서에 반영이 안되어있을거같은데 머지 후에 확인한번만 부탁드립니다
src/main/java/com/kustacks/kuring/club/application/port/out/ClubQueryPort.java
Show resolved
Hide resolved
| } | ||
|
|
||
| @Override | ||
| public ClubListResult getClubs(ClubListCommand command, String email) { |
There was a problem hiding this comment.
Cursor 객체를 받을거라면?? CursorBasedList를 사용하는걸로 통일하는게 어떤지요?? NoticeQueryService에서 댓글 조회하는 쪽 내용 참고해보시면 좋을거같아유
src/main/java/com/kustacks/kuring/club/application/service/ClubQueryService.java
Show resolved
Hide resolved
| List<ClubItemResult> items = | ||
| cursorBasedList.getContents() | ||
| .stream() | ||
| .map(r -> new ClubItemResult( | ||
| r.getId(), | ||
| r.getName(), | ||
| r.getSummary(), | ||
| r.getIconImageUrl(), | ||
| r.getCategory().getName(), | ||
| r.getDivision().getName(), | ||
| subscribedMap.getOrDefault(r.getId(), false), | ||
| subscriberCountMap.getOrDefault(r.getId(), 0), | ||
| r.getRecruitStartDate(), | ||
| r.getRecruitEndDate() | ||
| )) | ||
| .toList(); | ||
|
|
||
|
|
||
| int totalCount = clubQueryPort.countClubs(command.category(), command.divisionList()); | ||
|
|
||
| return new ClubListResult( | ||
| items, | ||
| cursorBasedList.getEndCursor(), | ||
| cursorBasedList.hasNext(), | ||
| totalCount | ||
| ); | ||
| } |
There was a problem hiding this comment.
CursorBasedList의 of만 사용하고 다시 값을 꺼내서 리턴하는구조로 이해했는데 단순히 of뿐만 아니라 더 활용할 수 있을거 같아유 그냥 CursorBasedList를 Result자체로 생각하고 리턴해도 충분하지 않을까 싶어유
There was a problem hiding this comment.
totalCount도 내려줘야돼서 CursorBasedList에 필드 추가하는거 대신 ClubListResult로 리턴하도록 했습니더!!
| ClubRecruitmentStatus recruitmentStatus = calculateRecruitmentStatus( | ||
| first.get(club.recruitStartAt), | ||
| first.get(club.recruitEndAt), | ||
| first.get(club.isAlways), | ||
| now | ||
| ); | ||
|
|
There was a problem hiding this comment.
동아리 조회한 내용을 가지고 Status를 결정짓는건 비지니스 로직이라 생각해 여기 있는건 적절치 않다고 생각합니다.
| private ClubRecruitmentStatus calculateRecruitmentStatus( | ||
| LocalDateTime start, | ||
| LocalDateTime end, | ||
| Boolean isAlways, | ||
| LocalDateTime now | ||
| ) { | ||
|
|
||
| if (Boolean.TRUE.equals(isAlways)) { | ||
| return ClubRecruitmentStatus.ALWAYS; | ||
| } | ||
|
|
||
| if (start != null && now.isBefore(start)) { | ||
| return ClubRecruitmentStatus.BEFORE; | ||
| } | ||
|
|
||
| if (end != null && now.isAfter(end)) { | ||
| return ClubRecruitmentStatus.CLOSED; | ||
| } | ||
|
|
||
| return ClubRecruitmentStatus.RECRUITING; | ||
| } | ||
|
|
There was a problem hiding this comment.
이 내용은 repository 로직에 있을 내용이 아니라 생각들어요!
| private BooleanExpression cursorCondition(String sortBy, String cursor, LocalDateTime now) { | ||
|
|
||
| if (cursor == null || cursor.equals("0")) return null; | ||
|
|
||
| try { | ||
|
|
||
| String[] parts = cursor.split("\\|"); | ||
|
|
||
| return switch (sortBy) { | ||
|
|
||
| case "name" -> { | ||
| if (parts.length < 2) yield null; | ||
|
|
||
| String lastName = parts[0]; | ||
| Long lastId = Long.parseLong(parts[1]); | ||
|
|
||
| yield club.name.gt(lastName) | ||
| .or( | ||
| club.name.eq(lastName) | ||
| .and(club.id.gt(lastId)) | ||
| ); | ||
| } | ||
|
|
||
| case "recruitEndDate" -> { | ||
| if (parts.length < 3) yield null; | ||
|
|
||
| int lastGroup = Integer.parseInt(parts[0]); | ||
| String lastDateStr = parts[1]; | ||
| Long lastId = Long.parseLong(parts[2]); | ||
|
|
||
| NumberExpression<Integer> currentGroup = recruitmentGroup(now); | ||
|
|
||
| BooleanExpression groupCondition = currentGroup.gt(lastGroup); | ||
|
|
||
| BooleanExpression sameGroupCondition; | ||
|
|
||
| if ("null".equals(lastDateStr)) { | ||
| sameGroupCondition = currentGroup.eq(lastGroup) | ||
| .and(club.id.gt(lastId)); | ||
| } else { | ||
| LocalDateTime lastDate = LocalDateTime.parse(lastDateStr); | ||
| sameGroupCondition = currentGroup.eq(lastGroup) | ||
| .and( | ||
| club.recruitEndAt.gt(lastDate) | ||
| .or( | ||
| club.recruitEndAt.eq(lastDate) | ||
| .and(club.id.gt(lastId)) | ||
| ) | ||
| ); | ||
| } | ||
| yield groupCondition.or(sameGroupCondition); | ||
|
|
||
| } | ||
|
|
||
| default -> { | ||
| Long lastId = Long.parseLong(cursor); | ||
| yield club.id.gt(lastId); | ||
| } | ||
| }; | ||
|
|
||
| } catch (Exception e) { | ||
| return null; | ||
| } | ||
| } |
There was a problem hiding this comment.
매우매우 중요한 코어 로직인데... 이 내용이 어떤 내용인지 이해하기 너무 어렵습니다 ㅜㅜ
커서를 split하는데 커서를 id값으로 사용하면 스플릿해야할 이유가 없는거 같은데 왜 스플릿하는 걸까요??
커서 종류에 따라 조건절이 바뀌는건 맞지만 현재로써는 왜 필요한지 의문입니다!
There was a problem hiding this comment.
추가로 switch문을 사용하더라도 string값이 아닌 enum객체로 비교했어야 한다고 생각해요!
| private String generateCursor(ClubReadModel club, String sortBy, LocalDateTime now) { | ||
| return switch (sortBy) { | ||
| case "name" -> club.getName() + "|" + club.getId(); | ||
| case "recruitEndDate" -> { | ||
| int group; | ||
| if (club.getRecruitEndDate() == null) { | ||
| group = 2; | ||
| } else if (club.getRecruitEndDate().isBefore(now)) { | ||
| group = 1; | ||
| } else { | ||
| group = 0; | ||
| } | ||
|
|
||
| String datePart = club.getRecruitEndDate() == null | ||
| ? "null" | ||
| : club.getRecruitEndDate().toString(); | ||
|
|
||
| yield group + "|" + datePart + "|" + club.getId(); | ||
| } | ||
| default -> club.getId().toString(); | ||
| }; |
There was a problem hiding this comment.
이 부분도 QueryRepository와 같은 맥락입니다!
| public enum ClubRecruitmentStatus { | ||
|
|
||
| ALWAYS("always"), | ||
| BEFORE("before"), | ||
| RECRUITING("recruiting"), | ||
| CLOSED("closed"); | ||
|
|
||
| private final String value; | ||
|
|
||
| ClubRecruitmentStatus(String value) { | ||
| this.value = value; | ||
| } | ||
| } No newline at end of file |
There was a problem hiding this comment.
이게 왜 도메인에 있는지..?는 잘 와닿지 않습니다. 어떤 이유일까요?
#️⃣ 이슈
#336
📌 요약
동아리 소속 목록 조회 api, 동아리 목록 조회 api, 동아리 상세 정보 조회 api를 구현하고 관련 테스트도 추가하였습니다.
🛠️ 상세
GET /api/v2/clubs/divisions
GET /api/v2/clubs
GET /api/v2/clubs/{id}
💬 기타
Summary by CodeRabbit
릴리스 노트
새로운 기능
테스트