From cc5afeb821f43a87aee42a783ee456ca5062569b Mon Sep 17 00:00:00 2001 From: unikal1 Date: Fri, 9 Jan 2026 14:47:55 +0900 Subject: [PATCH 01/14] =?UTF-8?q?Feat:=20=EA=B7=B8=EB=A3=B9=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20=EC=8B=9C=20hashtag=20=EB=8F=84=20=ED=95=A8?= =?UTF-8?q?=EA=BB=98=20=EB=B0=98=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ref: #164 --- .../dao/GroupHashTagRepository.java | 4 +++ .../dao/GroupMemberRepository.java | 2 +- .../groupManage/dto/GetGroupDetailRes.java | 7 ++++- .../domain/groupManage/dto/GetGroupsRes.java | 7 ++++- .../groupManage/service/GroupServiceImpl.java | 8 +++++- .../worker/GroupGoalCalculator.java | 2 -- .../worker/GroupHashTagWorker.java | 28 +++++++++++++++++++ .../studypals/global/utils/StringUtils.java | 2 +- .../GroupControllerRestDocsTest.java | 4 +++ 9 files changed, 57 insertions(+), 7 deletions(-) diff --git a/src/main/java/com/studypals/domain/groupManage/dao/GroupHashTagRepository.java b/src/main/java/com/studypals/domain/groupManage/dao/GroupHashTagRepository.java index 0e92cf8a..ba2da022 100644 --- a/src/main/java/com/studypals/domain/groupManage/dao/GroupHashTagRepository.java +++ b/src/main/java/com/studypals/domain/groupManage/dao/GroupHashTagRepository.java @@ -33,4 +33,8 @@ public interface GroupHashTagRepository extends JpaRepository findAllByGroupIdWithTag(@Param("groupId") Long groupId); + + List findAllByGroupId(Long groupId); + + List findAllByGroupIdIn(List groupIds); } diff --git a/src/main/java/com/studypals/domain/groupManage/dao/GroupMemberRepository.java b/src/main/java/com/studypals/domain/groupManage/dao/GroupMemberRepository.java index aaaf997c..6bad7003 100644 --- a/src/main/java/com/studypals/domain/groupManage/dao/GroupMemberRepository.java +++ b/src/main/java/com/studypals/domain/groupManage/dao/GroupMemberRepository.java @@ -39,7 +39,7 @@ public interface GroupMemberRepository extends JpaRepository, @Query( """ SELECT new com.studypals.domain.groupManage.dto.GroupSummaryDto( - g.id, g.name, g.tag, g.totalMember, g.chatRoom.id, g.isOpen, g.isApprovalRequired, g.createdDate + g.id, g.name, g.tag, g.totalMember, g.chatRoom.id, g.isOpen, g.isApprovalRequired, g.createdDate ) FROM GroupMember gm JOIN gm.group g diff --git a/src/main/java/com/studypals/domain/groupManage/dto/GetGroupDetailRes.java b/src/main/java/com/studypals/domain/groupManage/dto/GetGroupDetailRes.java index 44f6a950..cdae9f27 100644 --- a/src/main/java/com/studypals/domain/groupManage/dto/GetGroupDetailRes.java +++ b/src/main/java/com/studypals/domain/groupManage/dto/GetGroupDetailRes.java @@ -7,16 +7,21 @@ public record GetGroupDetailRes( Long id, String name, + String tag, + List hashTags, boolean isOpen, boolean isApprovalRequired, int totalMemberCount, int currentMemberCount, List profiles, GroupTotalGoalDto groupGoals) { - public static GetGroupDetailRes of(Group group, List profiles, GroupTotalGoalDto goals) { + public static GetGroupDetailRes of( + Group group, List hashTags, List profiles, GroupTotalGoalDto goals) { return new GetGroupDetailRes( group.getId(), group.getName(), + group.getTag(), + hashTags, group.isOpen(), group.isApprovalRequired(), group.getMaxMember(), diff --git a/src/main/java/com/studypals/domain/groupManage/dto/GetGroupsRes.java b/src/main/java/com/studypals/domain/groupManage/dto/GetGroupsRes.java index e892933f..df62522f 100644 --- a/src/main/java/com/studypals/domain/groupManage/dto/GetGroupsRes.java +++ b/src/main/java/com/studypals/domain/groupManage/dto/GetGroupsRes.java @@ -9,6 +9,7 @@ public record GetGroupsRes( Long groupId, String groupName, String groupTag, + List hashTags, int memberCount, String chatRoomId, boolean isOpen, @@ -17,11 +18,15 @@ public record GetGroupsRes( List profiles, List categoryIds) { public static GetGroupsRes of( - GroupSummaryDto dto, List rawProfiles, List categoryIds) { + GroupSummaryDto dto, + List hashTags, + List rawProfiles, + List categoryIds) { return new GetGroupsRes( dto.id(), dto.name(), dto.tag(), + hashTags, dto.memberCount(), dto.chatRoomId(), dto.open(), diff --git a/src/main/java/com/studypals/domain/groupManage/service/GroupServiceImpl.java b/src/main/java/com/studypals/domain/groupManage/service/GroupServiceImpl.java index 6433b6ec..79d49d9d 100644 --- a/src/main/java/com/studypals/domain/groupManage/service/GroupServiceImpl.java +++ b/src/main/java/com/studypals/domain/groupManage/service/GroupServiceImpl.java @@ -109,9 +109,12 @@ public List getGroups(Long userId) { Map> categoriesMap = groupCategories.stream().collect(Collectors.groupingBy(GroupCategoryDto::groupId)); + Map> hashTagsMap = groupHashTagWorker.getHashTagsByGroups(groupIds); + return groups.stream() .map(group -> GetGroupsRes.of( group, + hashTagsMap.getOrDefault(group.id(), Collections.emptyList()), membersMap.getOrDefault(group.id(), Collections.emptyList()), categoriesMap.getOrDefault(group.id(), Collections.emptyList()))) .toList(); @@ -131,6 +134,9 @@ public GetGroupDetailRes getGroupDetails(Long userId, Long groupId) { // 그룹에 속한 유저들의 목표 달성률 계산 GroupTotalGoalDto userGoals = groupGoalCalculator.calculateGroupGoals(groupId, profiles); - return GetGroupDetailRes.of(group, profiles, userGoals); + // 그룹에 속한 해시태그 + List hashTags = groupHashTagWorker.getHashTagsByGroup(groupId); + + return GetGroupDetailRes.of(group, hashTags, profiles, userGoals); } } diff --git a/src/main/java/com/studypals/domain/groupManage/worker/GroupGoalCalculator.java b/src/main/java/com/studypals/domain/groupManage/worker/GroupGoalCalculator.java index 77086d4b..f48dad89 100644 --- a/src/main/java/com/studypals/domain/groupManage/worker/GroupGoalCalculator.java +++ b/src/main/java/com/studypals/domain/groupManage/worker/GroupGoalCalculator.java @@ -22,8 +22,6 @@ /** * 그룹에 속한 유저들의 달성 수준을 계산하는 클래스입니다. - *

- * 코드에 대한 작동 원리 등을 적습니다. * *

빈 관리:
* Worker diff --git a/src/main/java/com/studypals/domain/groupManage/worker/GroupHashTagWorker.java b/src/main/java/com/studypals/domain/groupManage/worker/GroupHashTagWorker.java index 0c724682..48b8bb7b 100644 --- a/src/main/java/com/studypals/domain/groupManage/worker/GroupHashTagWorker.java +++ b/src/main/java/com/studypals/domain/groupManage/worker/GroupHashTagWorker.java @@ -85,6 +85,34 @@ public void saveTags(Group group, List inputTags) { groupHashTagRepository.saveAll(groupHashTags); } + /** + * groupId 에 대해, 처음 설정한 해시 태그 원본을 반환합니다. 해당 데이터는 정규화 되지 않은 데이터를 반환합니다. + * @param groupId 검색할 그룹의 아이디 + * @return 초기 입력한 hash tag 원본 리스트 + */ + public List getHashTagsByGroup(Long groupId) { + List groupHashTags = groupHashTagRepository.findAllByGroupId(groupId); + return groupHashTags.stream().map(GroupHashTag::getDisplayTag).toList(); + } + + /** + * 여러 groupId에 대한 해시태그 목록을 조회합니다. + * @param groupIds 조회할 그룹 ID 목록 + * @return groupId -> displayTag list 매핑 + */ + public Map> getHashTagsByGroups(List groupIds) { + if (groupIds == null || groupIds.isEmpty()) { + return Collections.emptyMap(); + } + + List groupHashTags = groupHashTagRepository.findAllByGroupIdIn(groupIds); + Map> result = new HashMap<>(); + for (GroupHashTag groupHashTag : groupHashTags) { + Long groupId = groupHashTag.getGroup().getId(); + result.computeIfAbsent(groupId, k -> new ArrayList<>()).add(groupHashTag.getDisplayTag()); + } + return result; + } /** * 각 태그에 대한 정규화 진행. 띄어쓰기는 _ 로 대체, 중복된 띄어쓰기 제거/특수문제 제거, trim 제거, lowercase * @param tags 정규화 대상 리스트 diff --git a/src/main/java/com/studypals/global/utils/StringUtils.java b/src/main/java/com/studypals/global/utils/StringUtils.java index f270247e..458903a2 100644 --- a/src/main/java/com/studypals/global/utils/StringUtils.java +++ b/src/main/java/com/studypals/global/utils/StringUtils.java @@ -29,7 +29,7 @@ @Component public class StringUtils { - private static final Pattern DISALLOWED = Pattern.compile("[^a-zA-Z0-9_\\s]"); + private static final Pattern DISALLOWED = Pattern.compile("[^a-zA-Z0-9_\\s\\p{IsHangul}]"); private static final Pattern SPACES = Pattern.compile("\\s+"); private static final Pattern MULTI_UNDERSCORE = Pattern.compile("_+"); diff --git a/src/test/java/com/studypals/domain/groupManage/restDocsTest/GroupControllerRestDocsTest.java b/src/test/java/com/studypals/domain/groupManage/restDocsTest/GroupControllerRestDocsTest.java index d690411a..7283cc17 100644 --- a/src/test/java/com/studypals/domain/groupManage/restDocsTest/GroupControllerRestDocsTest.java +++ b/src/test/java/com/studypals/domain/groupManage/restDocsTest/GroupControllerRestDocsTest.java @@ -121,6 +121,7 @@ void getGroups_success() throws Exception { 101L, "알고리즘 코딩 마스터", "취업준비", + List.of(), 10, "chat_algo_01", true, @@ -132,6 +133,7 @@ void getGroups_success() throws Exception { 205L, "프론트엔드 리액트 스터디", "프론트개발", + List.of(), 20, "chat_react_fe", false, @@ -194,6 +196,8 @@ void getGroupDetail_success() throws Exception { GetGroupDetailRes groupDetailRes = new GetGroupDetailRes( 100L, "핵심 CS 전공 스터디", + "tag", + List.of(), true, // 공개 false, // 승인 불필요 10, // 최대 10명 From 8ebcdd5f8bc47244596420078462aeb203e1f6b9 Mon Sep 17 00:00:00 2001 From: unikal1 Date: Tue, 13 Jan 2026 15:49:47 +0900 Subject: [PATCH 02/14] =?UTF-8?q?Refactor:=20SortTypeResolver=20=EA=B0=80?= =?UTF-8?q?=20=EC=A0=81=EC=A0=88=ED=95=9C=20sortType=20=EA=B5=AC=ED=98=84?= =?UTF-8?q?=EC=B2=B4=EB=A5=BC=20=EC=B0=BE=EB=8A=94=20=EA=B3=BC=EC=A0=95=20?= =?UTF-8?q?=EB=A6=AC=ED=8E=99=ED=86=A0=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 기존에는 enum 을 펼쳐서 모든 내부 필드 값에 대해 대조하여 판별하였지만, 현재는 string-sortType 을 매핑하여 저장. - 중복된 string 인 경우 예외 발생 Ref: #164 --- .../global/resolver/SortTypeResolver.java | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/src/main/java/com/studypals/global/resolver/SortTypeResolver.java b/src/main/java/com/studypals/global/resolver/SortTypeResolver.java index 1fdcbfd2..f6755897 100644 --- a/src/main/java/com/studypals/global/resolver/SortTypeResolver.java +++ b/src/main/java/com/studypals/global/resolver/SortTypeResolver.java @@ -2,7 +2,9 @@ import java.util.Arrays; import java.util.List; +import java.util.Map; import java.util.Optional; +import java.util.stream.Collectors; import com.studypals.global.request.SortType; @@ -14,19 +16,21 @@ * @since 2025-06-05 */ public class SortTypeResolver { - private final List> sortTypeClasses; + + private final Map cache; public SortTypeResolver(List> sortTypeClasses) { - this.sortTypeClasses = sortTypeClasses; + this.cache = sortTypeClasses.stream() + .flatMap(clazz -> Arrays.stream(clazz.getEnumConstants())) + .map(capture -> (SortType) capture) + .collect(Collectors.toMap(type -> type.name().toLowerCase(), type -> type, (a, b) -> { + throw new IllegalArgumentException("Duplicate sort key : " + a.name()); + })); } public Optional resolve(String sort) { if (sort == null) return Optional.empty(); - return sortTypeClasses.stream() - .flatMap(clazz -> Arrays.stream(clazz.getEnumConstants())) - .map(capture -> (SortType) capture) - .filter(type -> type.name().equalsIgnoreCase(sort)) - .findFirst(); + return Optional.ofNullable(cache.get(sort.toLowerCase())); } } From 55de82f9a466fa0db53a5a383f140ded8cb5603d Mon Sep 17 00:00:00 2001 From: unikal1 Date: Tue, 13 Jan 2026 21:53:54 +0900 Subject: [PATCH 03/14] =?UTF-8?q?Refactor:=20cursor=20/=20queryDsl=20?= =?UTF-8?q?=EB=A6=AC=ED=8E=99=ED=86=A0=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 같은 sortType 에 대해, id 기반 정렬이 가능한 메서드 추가 - cursor 에서 최근 데이터에 대해, id 뿐만 아니라 기준이 되는 값도 받도록 설정 Ref: #164 --- build.gradle | 5 +- .../groupManage/api/GroupController.java | 19 ++- .../groupManage/api/GroupEntryController.java | 5 +- .../GroupEntryCodeRedisRepository.java | 2 +- .../GroupEntryRequestCustomRepository.java | 2 +- ...GroupEntryRequestCustomRepositoryImpl.java | 4 +- .../GroupEntryRequestRepository.java | 2 +- .../GroupMemberCustomRepository.java | 2 +- .../GroupMemberCustomRepositoryImpl.java | 4 +- .../GroupMemberRepository.java | 2 +- .../GroupCustomRepository.java | 25 ++++ .../GroupCustomRepositoryImpl.java | 112 ++++++++++++++++++ .../GroupRepository.java | 4 +- .../dao/groupRepository/GroupSortType.java | 44 +++++++ .../groupManage/dto/GroupSearchDto.java | 28 +++++ .../groupManage/service/GroupServiceImpl.java | 15 +++ .../worker/GroupAuthorityValidator.java | 2 +- .../worker/GroupEntryCodeManager.java | 2 +- .../worker/GroupEntryRequestReader.java | 2 +- .../worker/GroupEntryRequestWriter.java | 2 +- .../groupManage/worker/GroupMemberReader.java | 2 +- .../groupManage/worker/GroupMemberWriter.java | 4 +- .../groupManage/worker/GroupReader.java | 2 +- .../groupManage/worker/GroupWriter.java | 2 +- .../GroupCategoryStrategy.java | 2 +- .../global/annotations/CursorDefault.java | 6 + .../configs/ArgumentResolverConfig.java | 17 +-- .../global/dao/AbstractPagingRepository.java | 82 ++++++++++--- .../exceptions/errorCode/GroupErrorCode.java | 4 +- .../studypals/global/redis/RedisConfig.java | 2 +- .../com/studypals/global/request/Cursor.java | 2 +- .../resolver/CursorDefaultResolver.java | 25 ++-- .../global/resolver/SortTypeResolver.java | 31 ++--- .../dao/GroupEntryRequestRepositoryTest.java | 1 + .../dao/GroupMemberRepositoryTest.java | 1 + .../worker/GroupAuthorityValidatorTest.java | 2 +- .../worker/GroupEntryCodeManagerTest.java | 2 +- .../worker/GroupEntryRequestReaderTest.java | 2 +- .../worker/GroupEntryRequestWriterTest.java | 2 +- .../worker/GroupMemberReaderTest.java | 2 +- .../worker/GroupMemberWriterTest.java | 4 +- .../groupManage/worker/GroupReaderTest.java | 2 +- .../groupManage/worker/GroupWriterTest.java | 2 +- .../AbstractGroupIntegrationTest.java | 2 +- .../GroupEntryIntegrationTest.java | 2 +- .../integrationTest/GroupIntegrationTest.java | 2 +- 46 files changed, 392 insertions(+), 98 deletions(-) rename src/main/java/com/studypals/domain/groupManage/dao/{ => groupEntryRepository}/GroupEntryCodeRedisRepository.java (89%) rename src/main/java/com/studypals/domain/groupManage/dao/{ => groupEntryRepository}/GroupEntryRequestCustomRepository.java (93%) rename src/main/java/com/studypals/domain/groupManage/dao/{ => groupEntryRepository}/GroupEntryRequestCustomRepositoryImpl.java (93%) rename src/main/java/com/studypals/domain/groupManage/dao/{ => groupEntryRepository}/GroupEntryRequestRepository.java (93%) rename src/main/java/com/studypals/domain/groupManage/dao/{ => groupMemberRepository}/GroupMemberCustomRepository.java (95%) rename src/main/java/com/studypals/domain/groupManage/dao/{ => groupMemberRepository}/GroupMemberCustomRepositoryImpl.java (97%) rename src/main/java/com/studypals/domain/groupManage/dao/{ => groupMemberRepository}/GroupMemberRepository.java (96%) create mode 100644 src/main/java/com/studypals/domain/groupManage/dao/groupRepository/GroupCustomRepository.java create mode 100644 src/main/java/com/studypals/domain/groupManage/dao/groupRepository/GroupCustomRepositoryImpl.java rename src/main/java/com/studypals/domain/groupManage/dao/{ => groupRepository}/GroupRepository.java (92%) create mode 100644 src/main/java/com/studypals/domain/groupManage/dao/groupRepository/GroupSortType.java create mode 100644 src/main/java/com/studypals/domain/groupManage/dto/GroupSearchDto.java diff --git a/build.gradle b/build.gradle index 2f8ccad9..921a6fc9 100644 --- a/build.gradle +++ b/build.gradle @@ -39,8 +39,8 @@ dependencies { annotationProcessor 'org.projectlombok:lombok-mapstruct-binding:0.2.0' // Querydsl - implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta' - annotationProcessor "com.querydsl:querydsl-apt:${dependencyManagement.importedProperties['querydsl.version']}:jakarta" + implementation 'com.querydsl:querydsl-jpa:5.1.0:jakarta' + annotationProcessor 'com.querydsl:querydsl-apt:5.1.0:jakarta' annotationProcessor 'jakarta.persistence:jakarta.persistence-api:' annotationProcessor 'jakarta.annotation:jakarta.annotation-api' @@ -50,6 +50,7 @@ dependencies { runtimeOnly "io.jsonwebtoken:jjwt-jackson:0.12.6" // redis + implementation 'org.springframework.boot:spring-boot-starter-cache' implementation 'org.springframework.session:spring-session-data-redis' implementation 'org.springframework.boot:spring-boot-starter-data-redis' diff --git a/src/main/java/com/studypals/domain/groupManage/api/GroupController.java b/src/main/java/com/studypals/domain/groupManage/api/GroupController.java index 903d4560..550fbbc8 100644 --- a/src/main/java/com/studypals/domain/groupManage/api/GroupController.java +++ b/src/main/java/com/studypals/domain/groupManage/api/GroupController.java @@ -7,20 +7,18 @@ import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.*; import lombok.RequiredArgsConstructor; +import com.studypals.domain.groupManage.dao.groupRepository.GroupSortType; import com.studypals.domain.groupManage.dto.CreateGroupReq; import com.studypals.domain.groupManage.dto.GetGroupDetailRes; import com.studypals.domain.groupManage.dto.GetGroupTagRes; import com.studypals.domain.groupManage.dto.GetGroupsRes; import com.studypals.domain.groupManage.service.GroupService; +import com.studypals.global.annotations.CursorDefault; +import com.studypals.global.request.Cursor; import com.studypals.global.responses.CommonResponse; import com.studypals.global.responses.Response; import com.studypals.global.responses.ResponseCode; @@ -70,4 +68,13 @@ public ResponseEntity> getGroupDetail( GetGroupDetailRes response = groupService.getGroupDetails(userId, groupId); return ResponseEntity.ok(CommonResponse.success(ResponseCode.GROUP_DETAIL, response)); } + + @GetMapping("/search") + public ResponseEntity>> searchByHashTag( + @CursorDefault(sortType = GroupSortType.class, cursor = 0, size = 5, sort = "POPULAR") Cursor cursor, + @RequestParam(required = false, name = "hashTag") String hashTag, + @RequestParam(required = false, name = "tag") String tag, + @RequestParam(required = false, name = "name") String name) { + return ResponseEntity.ok().build(); + } } diff --git a/src/main/java/com/studypals/domain/groupManage/api/GroupEntryController.java b/src/main/java/com/studypals/domain/groupManage/api/GroupEntryController.java index abc68ca2..afce7823 100644 --- a/src/main/java/com/studypals/domain/groupManage/api/GroupEntryController.java +++ b/src/main/java/com/studypals/domain/groupManage/api/GroupEntryController.java @@ -15,6 +15,7 @@ import com.studypals.domain.groupManage.service.GroupEntryService; import com.studypals.global.annotations.CursorDefault; import com.studypals.global.request.Cursor; +import com.studypals.global.request.DateSortType; import com.studypals.global.responses.CommonResponse; import com.studypals.global.responses.CursorResponse; import com.studypals.global.responses.Response; @@ -83,7 +84,9 @@ public ResponseEntity requestGroupParticipant( @GetMapping("/{groupId}/entry-requests") public ResponseEntity> getEntryRequests( - @AuthenticationPrincipal Long userId, @PathVariable Long groupId, @CursorDefault Cursor cursor) { + @AuthenticationPrincipal Long userId, + @PathVariable Long groupId, + @CursorDefault(sortType = DateSortType.class) Cursor cursor) { CursorResponse.Content response = groupEntryService.getEntryRequests(userId, groupId, cursor); return ResponseEntity.ok(CursorResponse.success(ResponseCode.GROUP_ENTRY_REQUEST_LIST, response)); diff --git a/src/main/java/com/studypals/domain/groupManage/dao/GroupEntryCodeRedisRepository.java b/src/main/java/com/studypals/domain/groupManage/dao/groupEntryRepository/GroupEntryCodeRedisRepository.java similarity index 89% rename from src/main/java/com/studypals/domain/groupManage/dao/GroupEntryCodeRedisRepository.java rename to src/main/java/com/studypals/domain/groupManage/dao/groupEntryRepository/GroupEntryCodeRedisRepository.java index 0b167cf6..46f4db7a 100644 --- a/src/main/java/com/studypals/domain/groupManage/dao/GroupEntryCodeRedisRepository.java +++ b/src/main/java/com/studypals/domain/groupManage/dao/groupEntryRepository/GroupEntryCodeRedisRepository.java @@ -1,4 +1,4 @@ -package com.studypals.domain.groupManage.dao; +package com.studypals.domain.groupManage.dao.groupEntryRepository; import org.springframework.data.repository.CrudRepository; import org.springframework.stereotype.Repository; diff --git a/src/main/java/com/studypals/domain/groupManage/dao/GroupEntryRequestCustomRepository.java b/src/main/java/com/studypals/domain/groupManage/dao/groupEntryRepository/GroupEntryRequestCustomRepository.java similarity index 93% rename from src/main/java/com/studypals/domain/groupManage/dao/GroupEntryRequestCustomRepository.java rename to src/main/java/com/studypals/domain/groupManage/dao/groupEntryRepository/GroupEntryRequestCustomRepository.java index c87da7e9..e6a4cc9e 100644 --- a/src/main/java/com/studypals/domain/groupManage/dao/GroupEntryRequestCustomRepository.java +++ b/src/main/java/com/studypals/domain/groupManage/dao/groupEntryRepository/GroupEntryRequestCustomRepository.java @@ -1,4 +1,4 @@ -package com.studypals.domain.groupManage.dao; +package com.studypals.domain.groupManage.dao.groupEntryRepository; import org.springframework.data.domain.Slice; import org.springframework.stereotype.Repository; diff --git a/src/main/java/com/studypals/domain/groupManage/dao/GroupEntryRequestCustomRepositoryImpl.java b/src/main/java/com/studypals/domain/groupManage/dao/groupEntryRepository/GroupEntryRequestCustomRepositoryImpl.java similarity index 93% rename from src/main/java/com/studypals/domain/groupManage/dao/GroupEntryRequestCustomRepositoryImpl.java rename to src/main/java/com/studypals/domain/groupManage/dao/groupEntryRepository/GroupEntryRequestCustomRepositoryImpl.java index 559af128..91b6e724 100644 --- a/src/main/java/com/studypals/domain/groupManage/dao/GroupEntryRequestCustomRepositoryImpl.java +++ b/src/main/java/com/studypals/domain/groupManage/dao/groupEntryRepository/GroupEntryRequestCustomRepositoryImpl.java @@ -1,4 +1,6 @@ -package com.studypals.domain.groupManage.dao; +package com.studypals.domain.groupManage.dao.groupEntryRepository; + +// import static com.studypals.domain.groupManage.entity.QGroupEntryRequest.groupEntryRequest; import static com.studypals.domain.groupManage.entity.QGroupEntryRequest.groupEntryRequest; diff --git a/src/main/java/com/studypals/domain/groupManage/dao/GroupEntryRequestRepository.java b/src/main/java/com/studypals/domain/groupManage/dao/groupEntryRepository/GroupEntryRequestRepository.java similarity index 93% rename from src/main/java/com/studypals/domain/groupManage/dao/GroupEntryRequestRepository.java rename to src/main/java/com/studypals/domain/groupManage/dao/groupEntryRepository/GroupEntryRequestRepository.java index 4d68b937..44b68156 100644 --- a/src/main/java/com/studypals/domain/groupManage/dao/GroupEntryRequestRepository.java +++ b/src/main/java/com/studypals/domain/groupManage/dao/groupEntryRepository/GroupEntryRequestRepository.java @@ -1,4 +1,4 @@ -package com.studypals.domain.groupManage.dao; +package com.studypals.domain.groupManage.dao.groupEntryRepository; import java.time.LocalDate; diff --git a/src/main/java/com/studypals/domain/groupManage/dao/GroupMemberCustomRepository.java b/src/main/java/com/studypals/domain/groupManage/dao/groupMemberRepository/GroupMemberCustomRepository.java similarity index 95% rename from src/main/java/com/studypals/domain/groupManage/dao/GroupMemberCustomRepository.java rename to src/main/java/com/studypals/domain/groupManage/dao/groupMemberRepository/GroupMemberCustomRepository.java index b630f57a..b4969f52 100644 --- a/src/main/java/com/studypals/domain/groupManage/dao/GroupMemberCustomRepository.java +++ b/src/main/java/com/studypals/domain/groupManage/dao/groupMemberRepository/GroupMemberCustomRepository.java @@ -1,4 +1,4 @@ -package com.studypals.domain.groupManage.dao; +package com.studypals.domain.groupManage.dao.groupMemberRepository; import java.util.List; diff --git a/src/main/java/com/studypals/domain/groupManage/dao/GroupMemberCustomRepositoryImpl.java b/src/main/java/com/studypals/domain/groupManage/dao/groupMemberRepository/GroupMemberCustomRepositoryImpl.java similarity index 97% rename from src/main/java/com/studypals/domain/groupManage/dao/GroupMemberCustomRepositoryImpl.java rename to src/main/java/com/studypals/domain/groupManage/dao/groupMemberRepository/GroupMemberCustomRepositoryImpl.java index ed2f1b51..b458fb83 100644 --- a/src/main/java/com/studypals/domain/groupManage/dao/GroupMemberCustomRepositoryImpl.java +++ b/src/main/java/com/studypals/domain/groupManage/dao/groupMemberRepository/GroupMemberCustomRepositoryImpl.java @@ -1,4 +1,4 @@ -package com.studypals.domain.groupManage.dao; +package com.studypals.domain.groupManage.dao.groupMemberRepository; import static com.studypals.domain.groupManage.entity.QGroupMember.groupMember; import static com.studypals.domain.memberManage.entity.QMember.member; @@ -17,7 +17,7 @@ /** * group member custom repository 의 구현 클래스입니다. - * + *./ *

group member 관련 커스텀 쿼리를 구현합니다. * *

상속 정보:
diff --git a/src/main/java/com/studypals/domain/groupManage/dao/GroupMemberRepository.java b/src/main/java/com/studypals/domain/groupManage/dao/groupMemberRepository/GroupMemberRepository.java similarity index 96% rename from src/main/java/com/studypals/domain/groupManage/dao/GroupMemberRepository.java rename to src/main/java/com/studypals/domain/groupManage/dao/groupMemberRepository/GroupMemberRepository.java index 6bad7003..8592943e 100644 --- a/src/main/java/com/studypals/domain/groupManage/dao/GroupMemberRepository.java +++ b/src/main/java/com/studypals/domain/groupManage/dao/groupMemberRepository/GroupMemberRepository.java @@ -1,4 +1,4 @@ -package com.studypals.domain.groupManage.dao; +package com.studypals.domain.groupManage.dao.groupMemberRepository; import java.util.List; import java.util.Optional; diff --git a/src/main/java/com/studypals/domain/groupManage/dao/groupRepository/GroupCustomRepository.java b/src/main/java/com/studypals/domain/groupManage/dao/groupRepository/GroupCustomRepository.java new file mode 100644 index 00000000..278da25b --- /dev/null +++ b/src/main/java/com/studypals/domain/groupManage/dao/groupRepository/GroupCustomRepository.java @@ -0,0 +1,25 @@ +package com.studypals.domain.groupManage.dao.groupRepository; + +/** + * 코드에 대한 전체적인 역할을 적습니다. + *

+ * 코드에 대한 작동 원리 등을 적습니다. + * + *

상속 정보:
+ * 상속 정보를 적습니다. + * + *

주요 생성자:
+ * {@code ExampleClass(String example)}
+ * 주요 생성자와 그 매개변수에 대한 설명을 적습니다.
+ * + *

빈 관리:
+ * 필요 시 빈 관리에 대한 내용을 적습니다. + * + *

외부 모듈:
+ * 필요 시 외부 모듈에 대한 내용을 적습니다. + * + * @author jack8 + * @see + * @since 2026-01-13 + */ +public interface GroupCustomRepository {} diff --git a/src/main/java/com/studypals/domain/groupManage/dao/groupRepository/GroupCustomRepositoryImpl.java b/src/main/java/com/studypals/domain/groupManage/dao/groupRepository/GroupCustomRepositoryImpl.java new file mode 100644 index 00000000..2d34f13c --- /dev/null +++ b/src/main/java/com/studypals/domain/groupManage/dao/groupRepository/GroupCustomRepositoryImpl.java @@ -0,0 +1,112 @@ +package com.studypals.domain.groupManage.dao.groupRepository; + +import static com.studypals.domain.groupManage.entity.QGroup.group; +import static com.studypals.domain.groupManage.entity.QGroupHashTag.groupHashTag; +import static com.studypals.domain.groupManage.entity.QHashTag.hashTag; + +import java.util.List; + +import org.springframework.data.domain.Slice; +import org.springframework.stereotype.Repository; + +import lombok.RequiredArgsConstructor; + +import com.querydsl.core.types.OrderSpecifier; +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.jpa.JPAExpressions; +import com.querydsl.jpa.impl.JPAQueryFactory; +import com.studypals.domain.groupManage.dto.GroupSearchDto; +import com.studypals.domain.groupManage.dto.GroupSummaryDto; +import com.studypals.domain.groupManage.entity.Group; +import com.studypals.global.dao.AbstractPagingRepository; +import com.studypals.global.request.Cursor; +import com.studypals.global.request.SortType; +import com.studypals.global.utils.StringUtils; + +/** + * 코드에 대한 전체적인 역할을 적습니다. + *

+ * 코드에 대한 작동 원리 등을 적습니다. + * + *

상속 정보:
+ * 상속 정보를 적습니다. + * + *

주요 생성자:
+ * {@code ExampleClass(String example)}
+ * 주요 생성자와 그 매개변수에 대한 설명을 적습니다.
+ * + *

빈 관리:
+ * 필요 시 빈 관리에 대한 내용을 적습니다. + * + *

외부 모듈:
+ * 필요 시 외부 모듈에 대한 내용을 적습니다. + * + * @author jack8 + * @see + * @since 2026-01-13 + */ +@Repository +@RequiredArgsConstructor +public class GroupCustomRepositoryImpl extends AbstractPagingRepository + implements GroupCustomRepository { + + private JPAQueryFactory queryFactory; + private final StringUtils stringUtils; + + public Slice search(GroupSearchDto dto, Cursor cursor) { + List results = queryFactory + .selectFrom(group) + .where( + assembleWhere(dto), + + ) + .orderBy(getOrderSpecifier(cursor.sort())) + .fetch(); + return + } + + private BooleanExpression assembleWhere(GroupSearchDto dto) { + if(hasText(dto.tag())) { + return group.tag.containsIgnoreCase(stringUtils.normalize(dto.tag())); + } + if(hasText(dto.name())) { + return group.name.containsIgnoreCase(dto.name().trim()); + } + if(hasText(dto.hashTag())) { + return getHashTagByGroup(dto.hashTag()); + } + return null; + + } + + private BooleanExpression getHashTagByGroup(String rawHashTag) { + String normHashTag = stringUtils.normalize(rawHashTag); + return JPAExpressions + .selectOne() + .from(groupHashTag) + .where( + groupHashTag.group.id.eq(group.id), + hashTag.tag.contains(normHashTag) + ) + .exists(); + } + + private OrderSpecifier getOrderSpecifier(SortType sort) { + if(sort instanceof GroupSortType) { + return super.getOrderSpecifier(Group.class, sort); + } + else { + throw new IllegalArgumentException( + "[GroupCustomRepositoryImpl#getOrderSpecifier] not supported sort type" + ); + } + } + + private boolean hasText(String s) { + return s != null && !s.isBlank(); + } + + private String normalize(String s) { + return s.toLowerCase().trim(); + } +} diff --git a/src/main/java/com/studypals/domain/groupManage/dao/GroupRepository.java b/src/main/java/com/studypals/domain/groupManage/dao/groupRepository/GroupRepository.java similarity index 92% rename from src/main/java/com/studypals/domain/groupManage/dao/GroupRepository.java rename to src/main/java/com/studypals/domain/groupManage/dao/groupRepository/GroupRepository.java index 2b409327..5c020a90 100644 --- a/src/main/java/com/studypals/domain/groupManage/dao/GroupRepository.java +++ b/src/main/java/com/studypals/domain/groupManage/dao/groupRepository/GroupRepository.java @@ -1,4 +1,4 @@ -package com.studypals.domain.groupManage.dao; +package com.studypals.domain.groupManage.dao.groupRepository; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Modifying; @@ -21,7 +21,7 @@ * @since 2025-04-12 */ @Repository -public interface GroupRepository extends JpaRepository { +public interface GroupRepository extends JpaRepository, GroupCustomRepository { @Modifying @Query( diff --git a/src/main/java/com/studypals/domain/groupManage/dao/groupRepository/GroupSortType.java b/src/main/java/com/studypals/domain/groupManage/dao/groupRepository/GroupSortType.java new file mode 100644 index 00000000..e833a531 --- /dev/null +++ b/src/main/java/com/studypals/domain/groupManage/dao/groupRepository/GroupSortType.java @@ -0,0 +1,44 @@ +package com.studypals.domain.groupManage.dao.groupRepository; + +import org.springframework.data.domain.Sort; + +import lombok.Getter; + +import com.studypals.global.request.SortType; + +/** + * 코드에 대한 전체적인 역할을 적습니다. + *

+ * 코드에 대한 작동 원리 등을 적습니다. + * + *

상속 정보:
+ * 상속 정보를 적습니다. + * + *

주요 생성자:
+ * {@code ExampleClass(String example)}
+ * 주요 생성자와 그 매개변수에 대한 설명을 적습니다.
+ * + *

빈 관리:
+ * 필요 시 빈 관리에 대한 내용을 적습니다. + * + *

외부 모듈:
+ * 필요 시 외부 모듈에 대한 내용을 적습니다. + * + * @author jack8 + * @see + * @since 2026-01-13 + */ +@Getter +public enum GroupSortType implements SortType { + POPULAR("totalMember", Sort.Direction.DESC), + NEW("createdDate", Sort.Direction.DESC), + OLD("createdDate", Sort.Direction.ASC); + + private final String field; + private final Sort.Direction direction; + + GroupSortType(String field, Sort.Direction direction) { + this.field = field; + this.direction = direction; + } +} diff --git a/src/main/java/com/studypals/domain/groupManage/dto/GroupSearchDto.java b/src/main/java/com/studypals/domain/groupManage/dto/GroupSearchDto.java new file mode 100644 index 00000000..dab9d68f --- /dev/null +++ b/src/main/java/com/studypals/domain/groupManage/dto/GroupSearchDto.java @@ -0,0 +1,28 @@ +package com.studypals.domain.groupManage.dto; + +import lombok.Builder; + +/** + * + * + * @author jack8 + * @since 2026-01-13 + */ +@Builder +public record GroupSearchDto(String tag, String hashTag, String name) { + public void validate() { + int count = 0; + + if (hasText(tag)) count++; + if (hasText(hashTag)) count++; + if (hasText(name)) count++; + + if (count > 1) { + throw new IllegalArgumentException("tag, hashTag, name 중 하나만 허용됩니다."); + } + } + + private static boolean hasText(String s) { + return s != null && !s.isBlank(); + } +} diff --git a/src/main/java/com/studypals/domain/groupManage/service/GroupServiceImpl.java b/src/main/java/com/studypals/domain/groupManage/service/GroupServiceImpl.java index 79d49d9d..482a9352 100644 --- a/src/main/java/com/studypals/domain/groupManage/service/GroupServiceImpl.java +++ b/src/main/java/com/studypals/domain/groupManage/service/GroupServiceImpl.java @@ -24,6 +24,8 @@ import com.studypals.domain.studyManage.dto.GroupCategoryDto; import com.studypals.domain.studyManage.entity.StudyType; import com.studypals.domain.studyManage.worker.StudyCategoryReader; +import com.studypals.global.exceptions.errorCode.GroupErrorCode; +import com.studypals.global.exceptions.exception.GroupException; import com.studypals.global.retry.RetryTx; /** @@ -139,4 +141,17 @@ public GetGroupDetailRes getGroupDetails(Long userId, Long groupId) { return GetGroupDetailRes.of(group, hashTags, profiles, userGoals); } + + @Transactional + public List search(GroupSearchDto dto) { + try { + dto.validate(); + } catch (IllegalArgumentException e) { + throw new GroupException( + GroupErrorCode.GROUP_SEARCH_FAIL, + e.getMessage(), + "[GroupServiceImpl#search] validate dto fail : " + e.getMessage()); + } + return List.of(); + } } diff --git a/src/main/java/com/studypals/domain/groupManage/worker/GroupAuthorityValidator.java b/src/main/java/com/studypals/domain/groupManage/worker/GroupAuthorityValidator.java index 01102071..046e03ff 100644 --- a/src/main/java/com/studypals/domain/groupManage/worker/GroupAuthorityValidator.java +++ b/src/main/java/com/studypals/domain/groupManage/worker/GroupAuthorityValidator.java @@ -2,7 +2,7 @@ import lombok.RequiredArgsConstructor; -import com.studypals.domain.groupManage.dao.GroupMemberRepository; +import com.studypals.domain.groupManage.dao.groupMemberRepository.GroupMemberRepository; import com.studypals.domain.groupManage.entity.GroupMember; import com.studypals.global.annotations.Worker; import com.studypals.global.exceptions.errorCode.GroupErrorCode; diff --git a/src/main/java/com/studypals/domain/groupManage/worker/GroupEntryCodeManager.java b/src/main/java/com/studypals/domain/groupManage/worker/GroupEntryCodeManager.java index 630813a9..4e63e293 100644 --- a/src/main/java/com/studypals/domain/groupManage/worker/GroupEntryCodeManager.java +++ b/src/main/java/com/studypals/domain/groupManage/worker/GroupEntryCodeManager.java @@ -2,7 +2,7 @@ import lombok.RequiredArgsConstructor; -import com.studypals.domain.groupManage.dao.GroupEntryCodeRedisRepository; +import com.studypals.domain.groupManage.dao.groupEntryRepository.GroupEntryCodeRedisRepository; import com.studypals.domain.groupManage.entity.Group; import com.studypals.domain.groupManage.entity.GroupEntryCode; import com.studypals.global.annotations.Worker; diff --git a/src/main/java/com/studypals/domain/groupManage/worker/GroupEntryRequestReader.java b/src/main/java/com/studypals/domain/groupManage/worker/GroupEntryRequestReader.java index 728069c6..6ef2d998 100644 --- a/src/main/java/com/studypals/domain/groupManage/worker/GroupEntryRequestReader.java +++ b/src/main/java/com/studypals/domain/groupManage/worker/GroupEntryRequestReader.java @@ -4,7 +4,7 @@ import lombok.RequiredArgsConstructor; -import com.studypals.domain.groupManage.dao.GroupEntryRequestRepository; +import com.studypals.domain.groupManage.dao.groupEntryRepository.GroupEntryRequestRepository; import com.studypals.domain.groupManage.entity.Group; import com.studypals.domain.groupManage.entity.GroupEntryRequest; import com.studypals.global.annotations.Worker; diff --git a/src/main/java/com/studypals/domain/groupManage/worker/GroupEntryRequestWriter.java b/src/main/java/com/studypals/domain/groupManage/worker/GroupEntryRequestWriter.java index bf4a43ee..e0563b72 100644 --- a/src/main/java/com/studypals/domain/groupManage/worker/GroupEntryRequestWriter.java +++ b/src/main/java/com/studypals/domain/groupManage/worker/GroupEntryRequestWriter.java @@ -4,7 +4,7 @@ import lombok.RequiredArgsConstructor; -import com.studypals.domain.groupManage.dao.GroupEntryRequestRepository; +import com.studypals.domain.groupManage.dao.groupEntryRepository.GroupEntryRequestRepository; import com.studypals.domain.groupManage.dto.mappers.GroupEntryRequestMapper; import com.studypals.domain.groupManage.entity.Group; import com.studypals.domain.groupManage.entity.GroupEntryRequest; diff --git a/src/main/java/com/studypals/domain/groupManage/worker/GroupMemberReader.java b/src/main/java/com/studypals/domain/groupManage/worker/GroupMemberReader.java index 4a5ed36f..ff06ae7f 100644 --- a/src/main/java/com/studypals/domain/groupManage/worker/GroupMemberReader.java +++ b/src/main/java/com/studypals/domain/groupManage/worker/GroupMemberReader.java @@ -4,7 +4,7 @@ import lombok.RequiredArgsConstructor; -import com.studypals.domain.groupManage.dao.GroupMemberRepository; +import com.studypals.domain.groupManage.dao.groupMemberRepository.GroupMemberRepository; import com.studypals.domain.groupManage.dto.GroupMemberProfileDto; import com.studypals.domain.groupManage.dto.GroupMemberProfileMappingDto; import com.studypals.domain.groupManage.dto.GroupSummaryDto; diff --git a/src/main/java/com/studypals/domain/groupManage/worker/GroupMemberWriter.java b/src/main/java/com/studypals/domain/groupManage/worker/GroupMemberWriter.java index 1514ca5f..efa79146 100644 --- a/src/main/java/com/studypals/domain/groupManage/worker/GroupMemberWriter.java +++ b/src/main/java/com/studypals/domain/groupManage/worker/GroupMemberWriter.java @@ -2,8 +2,8 @@ import lombok.RequiredArgsConstructor; -import com.studypals.domain.groupManage.dao.GroupMemberRepository; -import com.studypals.domain.groupManage.dao.GroupRepository; +import com.studypals.domain.groupManage.dao.groupMemberRepository.GroupMemberRepository; +import com.studypals.domain.groupManage.dao.groupRepository.GroupRepository; import com.studypals.domain.groupManage.dto.mappers.GroupMemberMapper; import com.studypals.domain.groupManage.entity.Group; import com.studypals.domain.groupManage.entity.GroupMember; diff --git a/src/main/java/com/studypals/domain/groupManage/worker/GroupReader.java b/src/main/java/com/studypals/domain/groupManage/worker/GroupReader.java index d219cd3e..52db3a1c 100644 --- a/src/main/java/com/studypals/domain/groupManage/worker/GroupReader.java +++ b/src/main/java/com/studypals/domain/groupManage/worker/GroupReader.java @@ -4,8 +4,8 @@ import lombok.RequiredArgsConstructor; -import com.studypals.domain.groupManage.dao.GroupRepository; import com.studypals.domain.groupManage.dao.GroupTagRepository; +import com.studypals.domain.groupManage.dao.groupRepository.GroupRepository; import com.studypals.domain.groupManage.entity.Group; import com.studypals.domain.groupManage.entity.GroupTag; import com.studypals.global.annotations.Worker; diff --git a/src/main/java/com/studypals/domain/groupManage/worker/GroupWriter.java b/src/main/java/com/studypals/domain/groupManage/worker/GroupWriter.java index 0c75f3a5..16445759 100644 --- a/src/main/java/com/studypals/domain/groupManage/worker/GroupWriter.java +++ b/src/main/java/com/studypals/domain/groupManage/worker/GroupWriter.java @@ -2,8 +2,8 @@ import lombok.RequiredArgsConstructor; -import com.studypals.domain.groupManage.dao.GroupRepository; import com.studypals.domain.groupManage.dao.GroupTagRepository; +import com.studypals.domain.groupManage.dao.groupRepository.GroupRepository; import com.studypals.domain.groupManage.dto.CreateGroupReq; import com.studypals.domain.groupManage.dto.mappers.GroupMapper; import com.studypals.domain.groupManage.entity.Group; diff --git a/src/main/java/com/studypals/domain/studyManage/worker/categoryStrategy/GroupCategoryStrategy.java b/src/main/java/com/studypals/domain/studyManage/worker/categoryStrategy/GroupCategoryStrategy.java index 532afa5d..f80d3c64 100644 --- a/src/main/java/com/studypals/domain/studyManage/worker/categoryStrategy/GroupCategoryStrategy.java +++ b/src/main/java/com/studypals/domain/studyManage/worker/categoryStrategy/GroupCategoryStrategy.java @@ -7,7 +7,7 @@ import lombok.RequiredArgsConstructor; -import com.studypals.domain.groupManage.dao.GroupMemberRepository; +import com.studypals.domain.groupManage.dao.groupMemberRepository.GroupMemberRepository; import com.studypals.domain.groupManage.entity.Group; import com.studypals.domain.groupManage.entity.GroupMember; import com.studypals.domain.studyManage.entity.StudyCategory; diff --git a/src/main/java/com/studypals/global/annotations/CursorDefault.java b/src/main/java/com/studypals/global/annotations/CursorDefault.java index 4b8826ca..32e26e3e 100644 --- a/src/main/java/com/studypals/global/annotations/CursorDefault.java +++ b/src/main/java/com/studypals/global/annotations/CursorDefault.java @@ -5,12 +5,18 @@ import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; +import com.studypals.global.request.SortType; + @Target(ElementType.PARAMETER) @Retention(RetentionPolicy.RUNTIME) public @interface CursorDefault { + Class sortType(); + long cursor() default 0; int size() default 10; String sort() default "NEW"; + + String value() default ""; } diff --git a/src/main/java/com/studypals/global/configs/ArgumentResolverConfig.java b/src/main/java/com/studypals/global/configs/ArgumentResolverConfig.java index 7257b2e7..a0ad677e 100644 --- a/src/main/java/com/studypals/global/configs/ArgumentResolverConfig.java +++ b/src/main/java/com/studypals/global/configs/ArgumentResolverConfig.java @@ -4,14 +4,13 @@ import org.jetbrains.annotations.NotNull; import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication; -import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.web.method.support.HandlerMethodArgumentResolver; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; -import com.studypals.global.request.DateSortType; +import lombok.RequiredArgsConstructor; + import com.studypals.global.resolver.CursorDefaultResolver; -import com.studypals.global.resolver.SortTypeResolver; /** * Controller {@link com.studypals.global.annotations.CursorDefault} 파라미터에 대한 @@ -24,19 +23,13 @@ */ @Configuration @ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET) +@RequiredArgsConstructor public class ArgumentResolverConfig implements WebMvcConfigurer { - @Bean - public SortTypeResolver sortTypeResolver() { - return new SortTypeResolver(List.of(DateSortType.class)); - } - @Bean - public CursorDefaultResolver cursorDefaultResolver() { - return new CursorDefaultResolver(sortTypeResolver()); - } + private final CursorDefaultResolver cursorDefaultResolver; @Override public void addArgumentResolvers(@NotNull List resolvers) { - resolvers.add(cursorDefaultResolver()); + resolvers.add(cursorDefaultResolver); } } diff --git a/src/main/java/com/studypals/global/dao/AbstractPagingRepository.java b/src/main/java/com/studypals/global/dao/AbstractPagingRepository.java index 93056e14..c54628a6 100644 --- a/src/main/java/com/studypals/global/dao/AbstractPagingRepository.java +++ b/src/main/java/com/studypals/global/dao/AbstractPagingRepository.java @@ -1,8 +1,11 @@ package com.studypals.global.dao; +import java.lang.reflect.Field; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; +import jakarta.persistence.Id; + import org.springframework.data.domain.Sort; import lombok.Getter; @@ -28,14 +31,13 @@ protected Order getOrder(Sort.Direction direction) { return direction == Sort.Direction.ASC ? Order.ASC : Order.DESC; } - @SuppressWarnings("unchecked") + @SuppressWarnings({"rawtypes", "unchecked"}) protected OrderSpecifier getOrderSpecifier(Class entityClass, SortType sortType) { String field = sortType.getField(); Sort.Direction direction = sortType.getDirection(); EntityMetadata metadata = getEntityMetadata(entityClass); Class sortFieldType = metadata.getFieldType(field); - CACHE.put(entityClass, metadata); PathBuilder path = new PathBuilder<>(entityClass, metadata.getEntityName()); Order order = direction == Sort.Direction.ASC ? Order.ASC : Order.DESC; @@ -43,14 +45,27 @@ protected OrderSpecifier getOrderSpecifier(Class entityClass, SortType sor return new OrderSpecifier<>(order, (Expression) path.get(field, sortFieldType)); } - private EntityMetadata getEntityMetadata(Class entityClass) { - if (CACHE.containsKey(entityClass)) return CACHE.get(entityClass); + @SuppressWarnings({"rawtypes", "unchecked"}) + protected OrderSpecifier[] getOrderSpecifierWithId(Class entityClass, SortType type) { + OrderSpecifier primary = getOrderSpecifier(entityClass, type); + + Sort.Direction direction = type.getDirection(); + Order order = direction == Sort.Direction.ASC ? Order.ASC : Order.DESC; + EntityMetadata metadata = getEntityMetadata(entityClass); + PathBuilder path = new PathBuilder<>(entityClass, metadata.getEntityName()); - // querydsl 에서 필요한 건 Class 명 자체가 아니라 QEntity 의 static final 변수명 - // ex) QGroup.group => "group" - String className = entityClass.getSimpleName(); - String entityName = Character.toLowerCase(className.charAt(0)) + className.substring(1); - return CACHE.put(entityClass, new EntityMetadata(entityClass, entityName)); + OrderSpecifier secondary = new OrderSpecifier<>(order, (Expression) + path.get(metadata.getIdFieldName(), metadata.getIdFieldType())); + + return new OrderSpecifier[] {primary, secondary}; + } + + private EntityMetadata getEntityMetadata(Class entityClass) { + return CACHE.computeIfAbsent(entityClass, cls -> { + String className = cls.getSimpleName(); + String entityName = Character.toLowerCase(className.charAt(0)) + className.substring(1); + return new EntityMetadata(cls, entityName); + }); } private static class EntityMetadata { @@ -61,22 +76,53 @@ private static class EntityMetadata { private final Map> fieldClasses; + @Getter + private final String idFieldName; + + @Getter + private final Class idFieldType; + public EntityMetadata(Class entityClass, String entityName) { this.entityClass = entityClass; this.entityName = entityName; this.fieldClasses = new ConcurrentHashMap<>(); + + IdField idField = findIdField(entityClass); + this.idFieldName = idField.name; + this.idFieldType = idField.type; } - public Class getFieldType(String fieldName) { - if (fieldClasses.containsKey(fieldName)) return fieldClasses.get(fieldName); - - try { - return fieldClasses.put( - fieldName, entityClass.getDeclaredField(fieldName).getType()); - } catch (NoSuchFieldException e) { - throw new IllegalArgumentException("[AbstractPagingRepository.EntityMetadata#getFieldType] field " - + fieldName + " does not exists"); + private static IdField findIdField(Class cls) { + for (Class c = cls; c != null && c != Object.class; c = c.getSuperclass()) { + for (Field f : c.getDeclaredFields()) { + if (f.isAnnotationPresent(Id.class)) { + return new IdField(f.getName(), f.getType()); + } + } } + throw new IllegalArgumentException("No @Id field found in " + cls.getName()); + } + + private static class IdField { + final String name; + final Class type; + + IdField(String name, Class type) { + this.name = name; + this.type = type; + } + } + + public Class getFieldType(String fieldName) { + return fieldClasses.computeIfAbsent(fieldName, fn -> { + try { + return entityClass.getDeclaredField(fn).getType(); + } catch (NoSuchFieldException e) { + throw new IllegalArgumentException( + "[AbstractPagingRepository.EntityMetadata#getFieldType] field " + fn + " does not exists", + e); + } + }); } } } diff --git a/src/main/java/com/studypals/global/exceptions/errorCode/GroupErrorCode.java b/src/main/java/com/studypals/global/exceptions/errorCode/GroupErrorCode.java index 84487283..93de82c3 100644 --- a/src/main/java/com/studypals/global/exceptions/errorCode/GroupErrorCode.java +++ b/src/main/java/com/studypals/global/exceptions/errorCode/GroupErrorCode.java @@ -46,8 +46,8 @@ public enum GroupErrorCode implements ErrorCode { GROUP_ENTRY_REQUEST_NOT_FOUND( ResponseCode.GROUP_ENTRY_REQUEST, HttpStatus.NOT_FOUND, "can't findAndDelete group entry request"), - GROUP_HASHTAG_FAIL(ResponseCode.GROUP_HASHTAG, HttpStatus.BAD_REQUEST, "fail to save group hash tag"); - + GROUP_HASHTAG_FAIL(ResponseCode.GROUP_HASHTAG, HttpStatus.BAD_REQUEST, "fail to save group hash tag"), + GROUP_SEARCH_FAIL(ResponseCode.GROUP_SEARCH, HttpStatus.BAD_REQUEST, "perhaps, parameter invalid"); private final ResponseCode responseCode; private final HttpStatus status; private final String message; diff --git a/src/main/java/com/studypals/global/redis/RedisConfig.java b/src/main/java/com/studypals/global/redis/RedisConfig.java index f33ac710..552cdc16 100644 --- a/src/main/java/com/studypals/global/redis/RedisConfig.java +++ b/src/main/java/com/studypals/global/redis/RedisConfig.java @@ -13,7 +13,7 @@ import org.springframework.data.redis.serializer.RedisSerializationContext; import org.springframework.data.redis.serializer.StringRedisSerializer; -import com.studypals.domain.groupManage.dao.GroupEntryCodeRedisRepository; +import com.studypals.domain.groupManage.dao.groupEntryRepository.GroupEntryCodeRedisRepository; import com.studypals.domain.memberManage.dao.RefreshTokenRedisRepository; import com.studypals.domain.studyManage.dao.StudyStatusRedisRepository; diff --git a/src/main/java/com/studypals/global/request/Cursor.java b/src/main/java/com/studypals/global/request/Cursor.java index 260239b0..6eae1949 100644 --- a/src/main/java/com/studypals/global/request/Cursor.java +++ b/src/main/java/com/studypals/global/request/Cursor.java @@ -10,7 +10,7 @@ * @author s0o0bn * @since 2025-06-05 */ -public record Cursor(long cursor, int size, SortType sort) { +public record Cursor(Long cursor, String value, int size, SortType sort) { public boolean isFirstPage() { return cursor == 0; diff --git a/src/main/java/com/studypals/global/resolver/CursorDefaultResolver.java b/src/main/java/com/studypals/global/resolver/CursorDefaultResolver.java index 9eaf8604..f65b8cd6 100644 --- a/src/main/java/com/studypals/global/resolver/CursorDefaultResolver.java +++ b/src/main/java/com/studypals/global/resolver/CursorDefaultResolver.java @@ -3,11 +3,14 @@ import java.util.Optional; import org.springframework.core.MethodParameter; +import org.springframework.stereotype.Component; import org.springframework.web.bind.support.WebDataBinderFactory; import org.springframework.web.context.request.NativeWebRequest; import org.springframework.web.method.support.HandlerMethodArgumentResolver; import org.springframework.web.method.support.ModelAndViewContainer; +import lombok.AllArgsConstructor; + import com.studypals.global.annotations.CursorDefault; import com.studypals.global.request.Cursor; import com.studypals.global.request.SortType; @@ -19,17 +22,16 @@ * @author s0o0bn * @since 2025-06-05 */ +@Component +@AllArgsConstructor public class CursorDefaultResolver implements HandlerMethodArgumentResolver { private static final String CURSOR_PARAM = "cursor"; private static final String SIZE_PARAM = "size"; private static final String SORT_PARAM = "sort"; + private static final String VALUE_PARAM = "value"; private final SortTypeResolver sortTypeResolver; - public CursorDefaultResolver(SortTypeResolver sortTypeResolver) { - this.sortTypeResolver = sortTypeResolver; - } - @Override public boolean supportsParameter(MethodParameter parameter) { return parameter.getParameterType() == Cursor.class && parameter.hasParameterAnnotation(CursorDefault.class); @@ -46,7 +48,8 @@ public Object resolveArgument( long cursor = getCursor(webRequest, annotation); int size = getSize(webRequest, annotation); SortType sort = getSort(webRequest, annotation); - return new Cursor(cursor, size, sort); + String value = getValue(webRequest, annotation); + return new Cursor(cursor, value, size, sort); } /** @@ -108,8 +111,14 @@ private int getSize(NativeWebRequest webRequest, CursorDefault cursorDefault) { private SortType getSort(NativeWebRequest webRequest, CursorDefault cursorDefault) { String sortParam = Optional.ofNullable(webRequest.getParameter(SORT_PARAM)).orElse(cursorDefault.sort()); - return sortTypeResolver - .resolve(sortParam) - .orElseThrow(() -> new IllegalArgumentException("Invalid sort parameter")); + return sortTypeResolver.resolve(sortParam, cursorDefault.sortType()); + } + + private String getValue(NativeWebRequest webRequest, CursorDefault cursorDefault) { + + String raw = Optional.ofNullable(webRequest.getParameter(VALUE_PARAM)).orElse(cursorDefault.value()); + if (raw == null || raw.isBlank()) return null; + + return raw; } } diff --git a/src/main/java/com/studypals/global/resolver/SortTypeResolver.java b/src/main/java/com/studypals/global/resolver/SortTypeResolver.java index f6755897..4ee3ab3b 100644 --- a/src/main/java/com/studypals/global/resolver/SortTypeResolver.java +++ b/src/main/java/com/studypals/global/resolver/SortTypeResolver.java @@ -1,10 +1,8 @@ package com.studypals.global.resolver; import java.util.Arrays; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.stream.Collectors; + +import org.springframework.stereotype.Component; import com.studypals.global.request.SortType; @@ -15,22 +13,25 @@ * @author s0o0bn * @since 2025-06-05 */ +@Component public class SortTypeResolver { - private final Map cache; + public SortType resolve(String sort, Class sortEnumClass) { + if (sort == null || sort.isBlank()) { + throw new IllegalArgumentException("sort is required"); + } - public SortTypeResolver(List> sortTypeClasses) { - this.cache = sortTypeClasses.stream() - .flatMap(clazz -> Arrays.stream(clazz.getEnumConstants())) - .map(capture -> (SortType) capture) - .collect(Collectors.toMap(type -> type.name().toLowerCase(), type -> type, (a, b) -> { - throw new IllegalArgumentException("Duplicate sort key : " + a.name()); - })); + return resolveEnum(sort, sortEnumClass); } - public Optional resolve(String sort) { - if (sort == null) return Optional.empty(); + private SortType resolveEnum(String value, Class enumClass) { + for (SortType type : enumClass.getEnumConstants()) { + if (type.name().equalsIgnoreCase(value)) { + return type; + } + } - return Optional.ofNullable(cache.get(sort.toLowerCase())); + throw new IllegalArgumentException("Unsupported sort type: " + value + " (allowed: " + + Arrays.toString(enumClass.getEnumConstants()) + ")"); } } diff --git a/src/test/java/com/studypals/domain/groupManage/dao/GroupEntryRequestRepositoryTest.java b/src/test/java/com/studypals/domain/groupManage/dao/GroupEntryRequestRepositoryTest.java index 3eb4bd33..8304edf6 100644 --- a/src/test/java/com/studypals/domain/groupManage/dao/GroupEntryRequestRepositoryTest.java +++ b/src/test/java/com/studypals/domain/groupManage/dao/GroupEntryRequestRepositoryTest.java @@ -10,6 +10,7 @@ import org.springframework.data.domain.Slice; import org.springframework.data.domain.SliceImpl; +import com.studypals.domain.groupManage.dao.groupEntryRepository.GroupEntryRequestRepository; import com.studypals.domain.groupManage.entity.Group; import com.studypals.domain.groupManage.entity.GroupEntryRequest; import com.studypals.domain.memberManage.entity.Member; diff --git a/src/test/java/com/studypals/domain/groupManage/dao/GroupMemberRepositoryTest.java b/src/test/java/com/studypals/domain/groupManage/dao/GroupMemberRepositoryTest.java index 525b6e54..a1471df9 100644 --- a/src/test/java/com/studypals/domain/groupManage/dao/GroupMemberRepositoryTest.java +++ b/src/test/java/com/studypals/domain/groupManage/dao/GroupMemberRepositoryTest.java @@ -10,6 +10,7 @@ import org.springframework.beans.factory.annotation.Autowired; import com.studypals.domain.chatManage.entity.ChatRoom; +import com.studypals.domain.groupManage.dao.groupMemberRepository.GroupMemberRepository; import com.studypals.domain.groupManage.dto.GroupMemberProfileDto; import com.studypals.domain.groupManage.dto.GroupMemberProfileMappingDto; import com.studypals.domain.groupManage.entity.Group; diff --git a/src/test/java/com/studypals/domain/groupManage/worker/GroupAuthorityValidatorTest.java b/src/test/java/com/studypals/domain/groupManage/worker/GroupAuthorityValidatorTest.java index b9aaef5d..19900834 100644 --- a/src/test/java/com/studypals/domain/groupManage/worker/GroupAuthorityValidatorTest.java +++ b/src/test/java/com/studypals/domain/groupManage/worker/GroupAuthorityValidatorTest.java @@ -12,7 +12,7 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import com.studypals.domain.groupManage.dao.GroupMemberRepository; +import com.studypals.domain.groupManage.dao.groupMemberRepository.GroupMemberRepository; import com.studypals.domain.groupManage.entity.GroupMember; import com.studypals.global.exceptions.errorCode.GroupErrorCode; import com.studypals.global.exceptions.exception.GroupException; diff --git a/src/test/java/com/studypals/domain/groupManage/worker/GroupEntryCodeManagerTest.java b/src/test/java/com/studypals/domain/groupManage/worker/GroupEntryCodeManagerTest.java index 45c76b30..0c2b7ef1 100644 --- a/src/test/java/com/studypals/domain/groupManage/worker/GroupEntryCodeManagerTest.java +++ b/src/test/java/com/studypals/domain/groupManage/worker/GroupEntryCodeManagerTest.java @@ -11,7 +11,7 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import com.studypals.domain.groupManage.dao.GroupEntryCodeRedisRepository; +import com.studypals.domain.groupManage.dao.groupEntryRepository.GroupEntryCodeRedisRepository; import com.studypals.domain.groupManage.entity.Group; import com.studypals.domain.groupManage.entity.GroupEntryCode; import com.studypals.global.exceptions.errorCode.GroupErrorCode; diff --git a/src/test/java/com/studypals/domain/groupManage/worker/GroupEntryRequestReaderTest.java b/src/test/java/com/studypals/domain/groupManage/worker/GroupEntryRequestReaderTest.java index 497fa4d3..63df87f0 100644 --- a/src/test/java/com/studypals/domain/groupManage/worker/GroupEntryRequestReaderTest.java +++ b/src/test/java/com/studypals/domain/groupManage/worker/GroupEntryRequestReaderTest.java @@ -16,7 +16,7 @@ import org.springframework.data.domain.Slice; import org.springframework.data.domain.SliceImpl; -import com.studypals.domain.groupManage.dao.GroupEntryRequestRepository; +import com.studypals.domain.groupManage.dao.groupEntryRepository.GroupEntryRequestRepository; import com.studypals.domain.groupManage.entity.Group; import com.studypals.domain.groupManage.entity.GroupEntryRequest; import com.studypals.global.exceptions.errorCode.GroupErrorCode; diff --git a/src/test/java/com/studypals/domain/groupManage/worker/GroupEntryRequestWriterTest.java b/src/test/java/com/studypals/domain/groupManage/worker/GroupEntryRequestWriterTest.java index cbb591d4..72e9ea5d 100644 --- a/src/test/java/com/studypals/domain/groupManage/worker/GroupEntryRequestWriterTest.java +++ b/src/test/java/com/studypals/domain/groupManage/worker/GroupEntryRequestWriterTest.java @@ -10,7 +10,7 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import com.studypals.domain.groupManage.dao.GroupEntryRequestRepository; +import com.studypals.domain.groupManage.dao.groupEntryRepository.GroupEntryRequestRepository; import com.studypals.domain.groupManage.dto.mappers.GroupEntryRequestMapper; import com.studypals.domain.groupManage.entity.Group; import com.studypals.domain.groupManage.entity.GroupEntryRequest; diff --git a/src/test/java/com/studypals/domain/groupManage/worker/GroupMemberReaderTest.java b/src/test/java/com/studypals/domain/groupManage/worker/GroupMemberReaderTest.java index a42e5264..e914a885 100644 --- a/src/test/java/com/studypals/domain/groupManage/worker/GroupMemberReaderTest.java +++ b/src/test/java/com/studypals/domain/groupManage/worker/GroupMemberReaderTest.java @@ -11,7 +11,7 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import com.studypals.domain.groupManage.dao.GroupMemberRepository; +import com.studypals.domain.groupManage.dao.groupMemberRepository.GroupMemberRepository; import com.studypals.domain.groupManage.dto.GroupMemberProfileDto; import com.studypals.domain.groupManage.entity.Group; import com.studypals.domain.groupManage.entity.GroupRole; diff --git a/src/test/java/com/studypals/domain/groupManage/worker/GroupMemberWriterTest.java b/src/test/java/com/studypals/domain/groupManage/worker/GroupMemberWriterTest.java index b0c45811..f759434d 100644 --- a/src/test/java/com/studypals/domain/groupManage/worker/GroupMemberWriterTest.java +++ b/src/test/java/com/studypals/domain/groupManage/worker/GroupMemberWriterTest.java @@ -10,8 +10,8 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import com.studypals.domain.groupManage.dao.GroupMemberRepository; -import com.studypals.domain.groupManage.dao.GroupRepository; +import com.studypals.domain.groupManage.dao.groupMemberRepository.GroupMemberRepository; +import com.studypals.domain.groupManage.dao.groupRepository.GroupRepository; import com.studypals.domain.groupManage.dto.mappers.GroupMemberMapper; import com.studypals.domain.groupManage.entity.Group; import com.studypals.domain.groupManage.entity.GroupMember; diff --git a/src/test/java/com/studypals/domain/groupManage/worker/GroupReaderTest.java b/src/test/java/com/studypals/domain/groupManage/worker/GroupReaderTest.java index e8af096d..6866c89a 100644 --- a/src/test/java/com/studypals/domain/groupManage/worker/GroupReaderTest.java +++ b/src/test/java/com/studypals/domain/groupManage/worker/GroupReaderTest.java @@ -11,8 +11,8 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import com.studypals.domain.groupManage.dao.GroupRepository; import com.studypals.domain.groupManage.dao.GroupTagRepository; +import com.studypals.domain.groupManage.dao.groupRepository.GroupRepository; import com.studypals.domain.groupManage.entity.GroupTag; /** diff --git a/src/test/java/com/studypals/domain/groupManage/worker/GroupWriterTest.java b/src/test/java/com/studypals/domain/groupManage/worker/GroupWriterTest.java index e78a7ed0..3a220a41 100644 --- a/src/test/java/com/studypals/domain/groupManage/worker/GroupWriterTest.java +++ b/src/test/java/com/studypals/domain/groupManage/worker/GroupWriterTest.java @@ -12,8 +12,8 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import com.studypals.domain.groupManage.dao.GroupRepository; import com.studypals.domain.groupManage.dao.GroupTagRepository; +import com.studypals.domain.groupManage.dao.groupRepository.GroupRepository; import com.studypals.domain.groupManage.dto.CreateGroupReq; import com.studypals.domain.groupManage.dto.mappers.GroupMapper; import com.studypals.domain.groupManage.entity.Group; diff --git a/src/test/java/com/studypals/integrationTest/AbstractGroupIntegrationTest.java b/src/test/java/com/studypals/integrationTest/AbstractGroupIntegrationTest.java index a78bec1f..40606cc3 100644 --- a/src/test/java/com/studypals/integrationTest/AbstractGroupIntegrationTest.java +++ b/src/test/java/com/studypals/integrationTest/AbstractGroupIntegrationTest.java @@ -11,7 +11,7 @@ import lombok.Builder; -import com.studypals.domain.groupManage.dao.GroupEntryCodeRedisRepository; +import com.studypals.domain.groupManage.dao.groupEntryRepository.GroupEntryCodeRedisRepository; import com.studypals.testModules.testSupport.IntegrationSupport; /** diff --git a/src/test/java/com/studypals/integrationTest/GroupEntryIntegrationTest.java b/src/test/java/com/studypals/integrationTest/GroupEntryIntegrationTest.java index 9afe3654..54208c76 100644 --- a/src/test/java/com/studypals/integrationTest/GroupEntryIntegrationTest.java +++ b/src/test/java/com/studypals/integrationTest/GroupEntryIntegrationTest.java @@ -23,7 +23,7 @@ import org.springframework.test.web.servlet.ResultActions; import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; -import com.studypals.domain.groupManage.dao.GroupEntryCodeRedisRepository; +import com.studypals.domain.groupManage.dao.groupEntryRepository.GroupEntryCodeRedisRepository; import com.studypals.domain.groupManage.dto.GroupEntryReq; import com.studypals.domain.groupManage.entity.GroupEntryCode; import com.studypals.global.responses.ResponseCode; diff --git a/src/test/java/com/studypals/integrationTest/GroupIntegrationTest.java b/src/test/java/com/studypals/integrationTest/GroupIntegrationTest.java index 7fd6eb8a..a9ff1989 100644 --- a/src/test/java/com/studypals/integrationTest/GroupIntegrationTest.java +++ b/src/test/java/com/studypals/integrationTest/GroupIntegrationTest.java @@ -14,7 +14,7 @@ import org.springframework.http.MediaType; import org.springframework.test.web.servlet.ResultActions; -import com.studypals.domain.groupManage.dao.GroupEntryCodeRedisRepository; +import com.studypals.domain.groupManage.dao.groupEntryRepository.GroupEntryCodeRedisRepository; import com.studypals.domain.groupManage.dto.CreateGroupReq; import com.studypals.global.responses.ResponseCode; From 0735cbd4c2c3e22652bd0a973fedd19ed5050ad5 Mon Sep 17 00:00:00 2001 From: unikal1 Date: Wed, 14 Jan 2026 11:44:50 +0900 Subject: [PATCH 04/14] =?UTF-8?q?Feat:=20search=20=EA=B4=80=EB=A0=A8=20dsl?= =?UTF-8?q?=20=EC=BD=94=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 각 조건에 따라 쿼리문을 조립하도록 설정 Ref: #164 --- .../GroupCustomRepository.java | 10 +- .../GroupCustomRepositoryImpl.java | 103 ++++++++++++------ .../groupManage/dto/GroupSearchDto.java | 2 +- .../groupManage/worker/GroupReader.java | 8 ++ 4 files changed, 88 insertions(+), 35 deletions(-) diff --git a/src/main/java/com/studypals/domain/groupManage/dao/groupRepository/GroupCustomRepository.java b/src/main/java/com/studypals/domain/groupManage/dao/groupRepository/GroupCustomRepository.java index 278da25b..59c4497a 100644 --- a/src/main/java/com/studypals/domain/groupManage/dao/groupRepository/GroupCustomRepository.java +++ b/src/main/java/com/studypals/domain/groupManage/dao/groupRepository/GroupCustomRepository.java @@ -1,5 +1,11 @@ package com.studypals.domain.groupManage.dao.groupRepository; +import org.springframework.data.domain.Slice; + +import com.studypals.domain.groupManage.dto.GroupSearchDto; +import com.studypals.domain.groupManage.entity.Group; +import com.studypals.global.request.Cursor; + /** * 코드에 대한 전체적인 역할을 적습니다. *

@@ -22,4 +28,6 @@ * @see * @since 2026-01-13 */ -public interface GroupCustomRepository {} +public interface GroupCustomRepository { + Slice search(GroupSearchDto dto, Cursor cursor); +} diff --git a/src/main/java/com/studypals/domain/groupManage/dao/groupRepository/GroupCustomRepositoryImpl.java b/src/main/java/com/studypals/domain/groupManage/dao/groupRepository/GroupCustomRepositoryImpl.java index 2d34f13c..86a61699 100644 --- a/src/main/java/com/studypals/domain/groupManage/dao/groupRepository/GroupCustomRepositoryImpl.java +++ b/src/main/java/com/studypals/domain/groupManage/dao/groupRepository/GroupCustomRepositoryImpl.java @@ -4,9 +4,12 @@ import static com.studypals.domain.groupManage.entity.QGroupHashTag.groupHashTag; import static com.studypals.domain.groupManage.entity.QHashTag.hashTag; +import java.time.LocalDate; import java.util.List; +import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Slice; +import org.springframework.data.domain.SliceImpl; import org.springframework.stereotype.Repository; import lombok.RequiredArgsConstructor; @@ -16,7 +19,6 @@ import com.querydsl.jpa.JPAExpressions; import com.querydsl.jpa.impl.JPAQueryFactory; import com.studypals.domain.groupManage.dto.GroupSearchDto; -import com.studypals.domain.groupManage.dto.GroupSummaryDto; import com.studypals.domain.groupManage.entity.Group; import com.studypals.global.dao.AbstractPagingRepository; import com.studypals.global.request.Cursor; @@ -47,66 +49,101 @@ */ @Repository @RequiredArgsConstructor -public class GroupCustomRepositoryImpl extends AbstractPagingRepository - implements GroupCustomRepository { +public class GroupCustomRepositoryImpl extends AbstractPagingRepository implements GroupCustomRepository { - private JPAQueryFactory queryFactory; + private final JPAQueryFactory queryFactory; private final StringUtils stringUtils; - public Slice search(GroupSearchDto dto, Cursor cursor) { - List results = queryFactory + @Override + public Slice search(GroupSearchDto dto, Cursor cursor) { + OrderSpecifier[] orders = getOrderSpecifier(cursor.sort()); + List results = queryFactory .selectFrom(group) - .where( - assembleWhere(dto), - - ) - .orderBy(getOrderSpecifier(cursor.sort())) + .where(assembleWhere(dto), assembleCursor(cursor), assembleType(dto)) + .orderBy(orders) + .limit(cursor.size() + 1) .fetch(); - return + + boolean hasNext = results.size() > cursor.size(); + if (hasNext) { + results.remove(results.size() - 1); + } + return new SliceImpl<>(results, PageRequest.of(0, cursor.size()), hasNext); } private BooleanExpression assembleWhere(GroupSearchDto dto) { - if(hasText(dto.tag())) { + if (hasText(dto.tag())) { return group.tag.containsIgnoreCase(stringUtils.normalize(dto.tag())); } - if(hasText(dto.name())) { + if (hasText(dto.name())) { return group.name.containsIgnoreCase(dto.name().trim()); } - if(hasText(dto.hashTag())) { + if (hasText(dto.hashTag())) { return getHashTagByGroup(dto.hashTag()); } return null; + } + + private BooleanExpression assembleType(GroupSearchDto dto) { + BooleanExpression cond = null; + + if (dto.isOpen()) { + cond = and(cond, group.isOpen.isTrue()); + cond = and(cond, group.totalMember.lt(group.maxMember)); + } + + if (dto.isApprovalRequired()) { + cond = and(cond, group.isApprovalRequired.isTrue()); + } + + return cond; + } + + private BooleanExpression assembleCursor(Cursor cursor) { + if (cursor == null || cursor.cursor() == null || cursor.value() == null) { + return null; + } + + if (cursor.sort() == GroupSortType.POPULAR) { + Integer total = Integer.parseInt(cursor.value()); + return group.totalMember.lt(total).or(group.totalMember.eq(total).and(group.id.lt(cursor.cursor()))); + } + + if (cursor.sort() == GroupSortType.NEW) { + LocalDate date = LocalDate.parse(cursor.value()); + return group.createdDate.lt(date).or(group.createdDate.eq(date).and(group.id.lt(cursor.cursor()))); + } + + if (cursor.sort() == GroupSortType.OLD) { + LocalDate date = LocalDate.parse(cursor.value()); + return group.createdDate.gt(date).or(group.createdDate.eq(date).and(group.id.gt(cursor.cursor()))); + } + throw new IllegalArgumentException("cursor sort not matched"); } private BooleanExpression getHashTagByGroup(String rawHashTag) { String normHashTag = stringUtils.normalize(rawHashTag); - return JPAExpressions - .selectOne() + return JPAExpressions.selectOne() .from(groupHashTag) - .where( - groupHashTag.group.id.eq(group.id), - hashTag.tag.contains(normHashTag) - ) + .where(groupHashTag.group.id.eq(group.id), hashTag.tag.contains(normHashTag)) .exists(); } - private OrderSpecifier getOrderSpecifier(SortType sort) { - if(sort instanceof GroupSortType) { - return super.getOrderSpecifier(Group.class, sort); - } - else { - throw new IllegalArgumentException( - "[GroupCustomRepositoryImpl#getOrderSpecifier] not supported sort type" - ); + private OrderSpecifier[] getOrderSpecifier(SortType sort) { + if (sort instanceof GroupSortType) { + return super.getOrderSpecifierWithId(Group.class, sort); + } else { + throw new IllegalArgumentException("[GroupCustomRepositoryImpl#getOrderSpecifier] not supported sort type"); } } - private boolean hasText(String s) { - return s != null && !s.isBlank(); + private BooleanExpression and(BooleanExpression base, BooleanExpression add) { + if (add == null) return base; + return base == null ? add : base.and(add); } - private String normalize(String s) { - return s.toLowerCase().trim(); + private boolean hasText(String s) { + return s != null && !s.isBlank(); } } diff --git a/src/main/java/com/studypals/domain/groupManage/dto/GroupSearchDto.java b/src/main/java/com/studypals/domain/groupManage/dto/GroupSearchDto.java index dab9d68f..6dd8b349 100644 --- a/src/main/java/com/studypals/domain/groupManage/dto/GroupSearchDto.java +++ b/src/main/java/com/studypals/domain/groupManage/dto/GroupSearchDto.java @@ -9,7 +9,7 @@ * @since 2026-01-13 */ @Builder -public record GroupSearchDto(String tag, String hashTag, String name) { +public record GroupSearchDto(String tag, String hashTag, String name, Boolean isOpen, Boolean isApprovalRequired) { public void validate() { int count = 0; diff --git a/src/main/java/com/studypals/domain/groupManage/worker/GroupReader.java b/src/main/java/com/studypals/domain/groupManage/worker/GroupReader.java index 52db3a1c..345bf110 100644 --- a/src/main/java/com/studypals/domain/groupManage/worker/GroupReader.java +++ b/src/main/java/com/studypals/domain/groupManage/worker/GroupReader.java @@ -2,15 +2,19 @@ import java.util.List; +import org.springframework.data.domain.Slice; + import lombok.RequiredArgsConstructor; import com.studypals.domain.groupManage.dao.GroupTagRepository; import com.studypals.domain.groupManage.dao.groupRepository.GroupRepository; +import com.studypals.domain.groupManage.dto.GroupSearchDto; import com.studypals.domain.groupManage.entity.Group; import com.studypals.domain.groupManage.entity.GroupTag; import com.studypals.global.annotations.Worker; import com.studypals.global.exceptions.errorCode.GroupErrorCode; import com.studypals.global.exceptions.exception.GroupException; +import com.studypals.global.request.Cursor; /** * group 도메인의 조회 Worker 클래스입니다. @@ -37,4 +41,8 @@ public List getGroupTags() { public Group getById(Long groupId) { return groupRepository.findById(groupId).orElseThrow(() -> new GroupException(GroupErrorCode.GROUP_NOT_FOUND)); } + + public Slice search(GroupSearchDto dto, Cursor cursor) { + return groupRepository.search(dto, cursor); + } } From 5e9be1aca84666afd7bf559b13e006323231ae26 Mon Sep 17 00:00:00 2001 From: unikal1 Date: Wed, 14 Jan 2026 15:26:17 +0900 Subject: [PATCH 05/14] =?UTF-8?q?Feat:=20=EA=B2=80=EC=83=89=20=20API=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ref: #164 --- .../groupManage/api/GroupController.java | 23 +++-- .../GroupCustomRepositoryImpl.java | 2 +- .../groupManage/dto/mappers/GroupMapper.java | 7 ++ .../groupManage/service/GroupService.java | 18 ++-- .../groupManage/service/GroupServiceImpl.java | 86 ++++++++++++------- .../global/dao/AbstractPagingRepository.java | 25 +++--- 6 files changed, 104 insertions(+), 57 deletions(-) diff --git a/src/main/java/com/studypals/domain/groupManage/api/GroupController.java b/src/main/java/com/studypals/domain/groupManage/api/GroupController.java index 550fbbc8..fe33899d 100644 --- a/src/main/java/com/studypals/domain/groupManage/api/GroupController.java +++ b/src/main/java/com/studypals/domain/groupManage/api/GroupController.java @@ -12,14 +12,12 @@ import lombok.RequiredArgsConstructor; import com.studypals.domain.groupManage.dao.groupRepository.GroupSortType; -import com.studypals.domain.groupManage.dto.CreateGroupReq; -import com.studypals.domain.groupManage.dto.GetGroupDetailRes; -import com.studypals.domain.groupManage.dto.GetGroupTagRes; -import com.studypals.domain.groupManage.dto.GetGroupsRes; +import com.studypals.domain.groupManage.dto.*; import com.studypals.domain.groupManage.service.GroupService; import com.studypals.global.annotations.CursorDefault; import com.studypals.global.request.Cursor; import com.studypals.global.responses.CommonResponse; +import com.studypals.global.responses.CursorResponse; import com.studypals.global.responses.Response; import com.studypals.global.responses.ResponseCode; @@ -70,11 +68,22 @@ public ResponseEntity> getGroupDetail( } @GetMapping("/search") - public ResponseEntity>> searchByHashTag( + public ResponseEntity> searchByHashTag( @CursorDefault(sortType = GroupSortType.class, cursor = 0, size = 5, sort = "POPULAR") Cursor cursor, @RequestParam(required = false, name = "hashTag") String hashTag, @RequestParam(required = false, name = "tag") String tag, - @RequestParam(required = false, name = "name") String name) { - return ResponseEntity.ok().build(); + @RequestParam(required = false, name = "name") String name, + @RequestParam(required = false, name = "open") Boolean open, + @RequestParam(required = false, name = "approval") Boolean approval) { + GroupSearchDto dto = GroupSearchDto.builder() + .hashTag(hashTag) + .tag(tag) + .name(name) + .isOpen(open == null || open) + .isApprovalRequired(approval == null || approval) + .build(); + CursorResponse.Content response = groupService.search(dto, cursor); + + return ResponseEntity.ok(CursorResponse.success(ResponseCode.GROUP_SEARCH, response)); } } diff --git a/src/main/java/com/studypals/domain/groupManage/dao/groupRepository/GroupCustomRepositoryImpl.java b/src/main/java/com/studypals/domain/groupManage/dao/groupRepository/GroupCustomRepositoryImpl.java index 86a61699..5ded1d96 100644 --- a/src/main/java/com/studypals/domain/groupManage/dao/groupRepository/GroupCustomRepositoryImpl.java +++ b/src/main/java/com/studypals/domain/groupManage/dao/groupRepository/GroupCustomRepositoryImpl.java @@ -132,7 +132,7 @@ private BooleanExpression getHashTagByGroup(String rawHashTag) { private OrderSpecifier[] getOrderSpecifier(SortType sort) { if (sort instanceof GroupSortType) { - return super.getOrderSpecifierWithId(Group.class, sort); + return super.getOrderSpecifierWithId(group, sort); } else { throw new IllegalArgumentException("[GroupCustomRepositoryImpl#getOrderSpecifier] not supported sort type"); } diff --git a/src/main/java/com/studypals/domain/groupManage/dto/mappers/GroupMapper.java b/src/main/java/com/studypals/domain/groupManage/dto/mappers/GroupMapper.java index 321bbf56..b41dd81f 100644 --- a/src/main/java/com/studypals/domain/groupManage/dto/mappers/GroupMapper.java +++ b/src/main/java/com/studypals/domain/groupManage/dto/mappers/GroupMapper.java @@ -5,6 +5,7 @@ import com.studypals.domain.groupManage.dto.CreateGroupReq; import com.studypals.domain.groupManage.dto.GetGroupTagRes; +import com.studypals.domain.groupManage.dto.GroupSummaryDto; import com.studypals.domain.groupManage.entity.Group; import com.studypals.domain.groupManage.entity.GroupTag; @@ -18,4 +19,10 @@ public interface GroupMapper { Group toEntity(CreateGroupReq dto); GetGroupTagRes toTagDto(GroupTag entity); + + @Mapping(target = "memberCount", source = "totalMember") + @Mapping(target = "chatRoomId", source = "chatRoom.id") + @Mapping(target = "open", source = "open") + @Mapping(target = "approvalRequired", source = "approvalRequired") + GroupSummaryDto toDto(Group group); } diff --git a/src/main/java/com/studypals/domain/groupManage/service/GroupService.java b/src/main/java/com/studypals/domain/groupManage/service/GroupService.java index 8e73b8f6..57100849 100644 --- a/src/main/java/com/studypals/domain/groupManage/service/GroupService.java +++ b/src/main/java/com/studypals/domain/groupManage/service/GroupService.java @@ -2,10 +2,9 @@ import java.util.List; -import com.studypals.domain.groupManage.dto.CreateGroupReq; -import com.studypals.domain.groupManage.dto.GetGroupDetailRes; -import com.studypals.domain.groupManage.dto.GetGroupTagRes; -import com.studypals.domain.groupManage.dto.GetGroupsRes; +import com.studypals.domain.groupManage.dto.*; +import com.studypals.global.request.Cursor; +import com.studypals.global.responses.CursorResponse; /** * GroupService 의 인터페이스입니다. 메서드를 정의합니다. @@ -34,7 +33,7 @@ public interface GroupService { * @param userId 그룹을 생성할 사용자 * @param dto 그룹 생성 시 필요한 데이터 * @return 생성된 그룹 ID - * @throws com.studypals.global.exceptions.exception.GroupException + * @throws com.studypals.global.exceptions.exception.GroupException 중복 발생 등 */ Long createGroup(Long userId, CreateGroupReq dto); @@ -52,4 +51,13 @@ public interface GroupService { * @return 자세한 그룹 정보 */ GetGroupDetailRes getGroupDetails(Long userId, Long groupId); + + /** + * dto 에서 정의된 필드에 의해 검색을 수행합니다. (hashtag, tag, name). 이를 기반으로 Slice 로 반환합니다. + * 또한, 기타 옵션(참가 가능 여부, 참가 시 승인 여부) + * @param dto 검색에 필요한 데이터 파라미터 + * @param cursor 페이징 시 필요한 데이터 종류 + * @return 그룹 정보가 포함된 CursorResponse + */ + CursorResponse.Content search(GroupSearchDto dto, Cursor cursor); } diff --git a/src/main/java/com/studypals/domain/groupManage/service/GroupServiceImpl.java b/src/main/java/com/studypals/domain/groupManage/service/GroupServiceImpl.java index 482a9352..3407f01f 100644 --- a/src/main/java/com/studypals/domain/groupManage/service/GroupServiceImpl.java +++ b/src/main/java/com/studypals/domain/groupManage/service/GroupServiceImpl.java @@ -6,6 +6,7 @@ import java.util.stream.Collectors; import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.data.domain.Slice; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -26,6 +27,8 @@ import com.studypals.domain.studyManage.worker.StudyCategoryReader; import com.studypals.global.exceptions.errorCode.GroupErrorCode; import com.studypals.global.exceptions.exception.GroupException; +import com.studypals.global.request.Cursor; +import com.studypals.global.responses.CursorResponse; import com.studypals.global.retry.RetryTx; /** @@ -90,36 +93,8 @@ public Long createGroup(Long userId, CreateGroupReq dto) { @Override @Transactional(readOnly = true) public List getGroups(Long userId) { - // jwt filter 에서 주입한 userId이므로 DB에 존재하는지 체크하지 않음 List groups = groupMemberReader.getGroups(userId); - - List groupIds = groups.stream().map(GroupSummaryDto::id).toList(); - - // 각 그룹에 속한 멤버들 프로필, 역할 조회하기 - List profileImages = groupMemberReader.getTopNMemberProfileImages( - groupIds, GroupConst.GROUP_SUMMARY_MEMBER_COUNT.getValue()); - - // 그룹id : 속한 멤버들 - Map> membersMap = - profileImages.stream().collect(Collectors.groupingBy(GroupMemberProfileMappingDto::groupId)); - - // 각 그룹이 가지고 있는 카테고리 조회하기 - List groupCategories = - studyCategoryReader.findByStudyTypeAndTypeId(StudyType.GROUP, groupIds); - - // 그룹id : 속한 카테고리들 - Map> categoriesMap = - groupCategories.stream().collect(Collectors.groupingBy(GroupCategoryDto::groupId)); - - Map> hashTagsMap = groupHashTagWorker.getHashTagsByGroups(groupIds); - - return groups.stream() - .map(group -> GetGroupsRes.of( - group, - hashTagsMap.getOrDefault(group.id(), Collections.emptyList()), - membersMap.getOrDefault(group.id(), Collections.emptyList()), - categoriesMap.getOrDefault(group.id(), Collections.emptyList()))) - .toList(); + return assembleGroupResponses(groups); } @Override @@ -142,8 +117,9 @@ public GetGroupDetailRes getGroupDetails(Long userId, Long groupId) { return GetGroupDetailRes.of(group, hashTags, profiles, userGoals); } - @Transactional - public List search(GroupSearchDto dto) { + @Override + @Transactional(readOnly = true) + public CursorResponse.Content search(GroupSearchDto dto, Cursor cursor) { try { dto.validate(); } catch (IllegalArgumentException e) { @@ -152,6 +128,52 @@ public List search(GroupSearchDto dto) { e.getMessage(), "[GroupServiceImpl#search] validate dto fail : " + e.getMessage()); } - return List.of(); + + Slice groupSlice = groupReader.search(dto, cursor); + List groups = groupSlice.getContent(); + + if (groups.isEmpty()) { + return new CursorResponse.Content<>(Collections.emptyList(), -1L, groupSlice.hasNext()); + } + + List summary = groups.stream().map(groupMapper::toDto).toList(); + List responseContent = assembleGroupResponses(summary); + + return new CursorResponse.Content<>( + responseContent, responseContent.get(responseContent.size() - 1).groupId(), groupSlice.hasNext()); + } + + private List assembleGroupResponses(List groups) { + List groupIds = groups.stream().map(GroupSummaryDto::id).toList(); + + Map> membersMap = loadTopMembersMap(groupIds); + Map> categoriesMap = loadCategoriesMap(groupIds); + Map> hashTagsMap = loadHashTagsMap(groupIds); + + return groups.stream() + .map(g -> GetGroupsRes.of( + g, + hashTagsMap.getOrDefault(g.id(), Collections.emptyList()), + membersMap.getOrDefault(g.id(), Collections.emptyList()), + categoriesMap.getOrDefault(g.id(), Collections.emptyList()))) + .toList(); + } + + private Map> loadTopMembersMap(List groupIds) { + List profileImages = groupMemberReader.getTopNMemberProfileImages( + groupIds, GroupConst.GROUP_SUMMARY_MEMBER_COUNT.getValue()); + + return profileImages.stream().collect(Collectors.groupingBy(GroupMemberProfileMappingDto::groupId)); + } + + private Map> loadCategoriesMap(List groupIds) { + List groupCategories = + studyCategoryReader.findByStudyTypeAndTypeId(StudyType.GROUP, groupIds); + + return groupCategories.stream().collect(Collectors.groupingBy(GroupCategoryDto::groupId)); + } + + private Map> loadHashTagsMap(List groupIds) { + return groupHashTagWorker.getHashTagsByGroups(groupIds); } } diff --git a/src/main/java/com/studypals/global/dao/AbstractPagingRepository.java b/src/main/java/com/studypals/global/dao/AbstractPagingRepository.java index c54628a6..afeca3a1 100644 --- a/src/main/java/com/studypals/global/dao/AbstractPagingRepository.java +++ b/src/main/java/com/studypals/global/dao/AbstractPagingRepository.java @@ -10,9 +10,7 @@ import lombok.Getter; -import com.querydsl.core.types.Expression; -import com.querydsl.core.types.Order; -import com.querydsl.core.types.OrderSpecifier; +import com.querydsl.core.types.*; import com.querydsl.core.types.dsl.PathBuilder; import com.studypals.global.request.SortType; @@ -32,27 +30,30 @@ protected Order getOrder(Sort.Direction direction) { } @SuppressWarnings({"rawtypes", "unchecked"}) - protected OrderSpecifier getOrderSpecifier(Class entityClass, SortType sortType) { + protected OrderSpecifier getOrderSpecifier(EntityPath root, SortType sortType) { String field = sortType.getField(); Sort.Direction direction = sortType.getDirection(); - EntityMetadata metadata = getEntityMetadata(entityClass); + EntityMetadata metadata = getEntityMetadata(root.getType()); Class sortFieldType = metadata.getFieldType(field); - PathBuilder path = new PathBuilder<>(entityClass, metadata.getEntityName()); - Order order = direction == Sort.Direction.ASC ? Order.ASC : Order.DESC; + // ✅ alias를 문자열로 만들지 말고 root의 metadata를 그대로 사용 + PathMetadata rootMetadata = root.getMetadata(); + PathBuilder path = new PathBuilder<>((Class) root.getType(), rootMetadata); + Order order = direction == Sort.Direction.ASC ? Order.ASC : Order.DESC; return new OrderSpecifier<>(order, (Expression) path.get(field, sortFieldType)); } @SuppressWarnings({"rawtypes", "unchecked"}) - protected OrderSpecifier[] getOrderSpecifierWithId(Class entityClass, SortType type) { - OrderSpecifier primary = getOrderSpecifier(entityClass, type); + protected OrderSpecifier[] getOrderSpecifierWithId(EntityPath root, SortType type) { + OrderSpecifier primary = getOrderSpecifier(root, type); Sort.Direction direction = type.getDirection(); Order order = direction == Sort.Direction.ASC ? Order.ASC : Order.DESC; - EntityMetadata metadata = getEntityMetadata(entityClass); - PathBuilder path = new PathBuilder<>(entityClass, metadata.getEntityName()); + + EntityMetadata metadata = getEntityMetadata(root.getType()); + PathBuilder path = new PathBuilder<>((Class) root.getType(), root.getMetadata()); OrderSpecifier secondary = new OrderSpecifier<>(order, (Expression) path.get(metadata.getIdFieldName(), metadata.getIdFieldType())); @@ -60,7 +61,7 @@ protected OrderSpecifier[] getOrderSpecifierWithId(Class entityClass, Sort return new OrderSpecifier[] {primary, secondary}; } - private EntityMetadata getEntityMetadata(Class entityClass) { + private EntityMetadata getEntityMetadata(Class entityClass) { return CACHE.computeIfAbsent(entityClass, cls -> { String className = cls.getSimpleName(); String entityName = Character.toLowerCase(className.charAt(0)) + className.substring(1); From d09b0b49c8d31d99896f066980e1a48f2d5276e7 Mon Sep 17 00:00:00 2001 From: unikal1 Date: Fri, 16 Jan 2026 16:12:25 +0900 Subject: [PATCH 06/14] =?UTF-8?q?Refactor:=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EB=A6=AC=EB=B7=B0=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 주석 추가 - 일부 헷갈리는 부분 데이터 수정 Ref: #164 --- build.gradle | 1 - .../groupManage/api/GroupController.java | 2 +- .../dao/GroupHashTagRepository.java | 6 + .../GroupCustomRepository.java | 49 +++- .../GroupCustomRepositoryImpl.java | 263 +++++++++++++++++- .../dao/groupRepository/GroupSortType.java | 47 +++- .../groupManage/dto/GroupSearchDto.java | 26 ++ .../groupManage/service/GroupServiceImpl.java | 2 +- .../worker/GroupHashTagWorker.java | 12 +- .../global/dao/AbstractPagingRepository.java | 2 +- 10 files changed, 368 insertions(+), 42 deletions(-) diff --git a/build.gradle b/build.gradle index 921a6fc9..ac1cd0b9 100644 --- a/build.gradle +++ b/build.gradle @@ -50,7 +50,6 @@ dependencies { runtimeOnly "io.jsonwebtoken:jjwt-jackson:0.12.6" // redis - implementation 'org.springframework.boot:spring-boot-starter-cache' implementation 'org.springframework.session:spring-session-data-redis' implementation 'org.springframework.boot:spring-boot-starter-data-redis' diff --git a/src/main/java/com/studypals/domain/groupManage/api/GroupController.java b/src/main/java/com/studypals/domain/groupManage/api/GroupController.java index fe33899d..7de1a781 100644 --- a/src/main/java/com/studypals/domain/groupManage/api/GroupController.java +++ b/src/main/java/com/studypals/domain/groupManage/api/GroupController.java @@ -68,7 +68,7 @@ public ResponseEntity> getGroupDetail( } @GetMapping("/search") - public ResponseEntity> searchByHashTag( + public ResponseEntity> searchGroups( @CursorDefault(sortType = GroupSortType.class, cursor = 0, size = 5, sort = "POPULAR") Cursor cursor, @RequestParam(required = false, name = "hashTag") String hashTag, @RequestParam(required = false, name = "tag") String tag, diff --git a/src/main/java/com/studypals/domain/groupManage/dao/GroupHashTagRepository.java b/src/main/java/com/studypals/domain/groupManage/dao/GroupHashTagRepository.java index ba2da022..8c81c323 100644 --- a/src/main/java/com/studypals/domain/groupManage/dao/GroupHashTagRepository.java +++ b/src/main/java/com/studypals/domain/groupManage/dao/GroupHashTagRepository.java @@ -36,5 +36,11 @@ public interface GroupHashTagRepository extends JpaRepository findAllByGroupId(Long groupId); + /** + * GroupHashTag 에서 Group 에 대해, id 를 제외한 다른 데이터를 조회 시 N + 1 문제를 야기할 수 있습니다. 따라서 해당 메서드를 + * 사용하기 앞서 group 에 대해 ID 외 다른 칼럼을 조회하는지에 대한 확인이 필요합니다. + * @param groupIds 검색하고자 하는 그룹 ID 리스트 + * @return groupHashTag 리스트(단 group 은 lazy loading) + */ List findAllByGroupIdIn(List groupIds); } diff --git a/src/main/java/com/studypals/domain/groupManage/dao/groupRepository/GroupCustomRepository.java b/src/main/java/com/studypals/domain/groupManage/dao/groupRepository/GroupCustomRepository.java index 59c4497a..ecffd4fe 100644 --- a/src/main/java/com/studypals/domain/groupManage/dao/groupRepository/GroupCustomRepository.java +++ b/src/main/java/com/studypals/domain/groupManage/dao/groupRepository/GroupCustomRepository.java @@ -7,27 +7,58 @@ import com.studypals.global.request.Cursor; /** - * 코드에 대한 전체적인 역할을 적습니다. + * 그룹 도메인에 대한 커스텀 조회 로직을 정의하는 Repository 인터페이스입니다. + * + *

+ * Spring Data JPA 기본 Repository로는 표현하기 어려운 + * 동적 검색 조건, 복합 정렬, 커서 기반 페이징을 처리하기 위해 사용됩니다. + *

+ * *

- * 코드에 대한 작동 원리 등을 적습니다. + * 구현체에서는 QueryDSL 등을 사용하여 {@link GroupSearchDto}의 조건에 따라 + * 그룹 목록을 조회하며, 대량 데이터 환경을 고려해 {@link Slice} 기반 조회를 수행합니다. + *

* *

상속 정보:
- * 상속 정보를 적습니다. + * 본 인터페이스는 Spring Data Repository를 직접 상속하지 않으며, + * {@code GroupRepository}에서 조합하여 사용됩니다. + *

* - *

주요 생성자:
- * {@code ExampleClass(String example)}
- * 주요 생성자와 그 매개변수에 대한 설명을 적습니다.
+ *

주요 메서드:
+ * {@link #search(GroupSearchDto, Cursor)}
+ * 검색 조건과 커서를 기반으로 그룹 목록을 조회합니다. + *

* *

빈 관리:
- * 필요 시 빈 관리에 대한 내용을 적습니다. + * 직접 빈으로 등록되지 않으며, 구현 클래스가 Spring Data JPA 규칙에 따라 + * 자동으로 Repository에 조합됩니다. + *

* *

외부 모듈:
- * 필요 시 외부 모듈에 대한 내용을 적습니다. + * Spring Data JPA, QueryDSL (구현체 기준) + *

* * @author jack8 - * @see * @since 2026-01-13 */ public interface GroupCustomRepository { + + /** + * 그룹 목록을 커서 기반으로 검색합니다. + * + *

+ * {@link GroupSearchDto}에 포함된 검색 조건(태그, 공개 여부, 승인 필요 여부 등)을 + * 기준으로 그룹을 조회하며, {@link Cursor}를 사용해 다음 페이지 여부를 판단합니다. + *

+ * + *

+ * 결과는 {@link Slice} 형태로 반환되며, 전체 개수(count 쿼리)는 수행하지 않습니다. + * 대량 데이터 환경에서 성능을 고려한 조회 방식입니다. + *

+ * + * @param dto 검색 조건을 담은 DTO + * @param cursor 커서 기반 페이징을 위한 기준 정보 + * @return 검색 조건에 맞는 그룹 목록 Slice + */ Slice search(GroupSearchDto dto, Cursor cursor); } diff --git a/src/main/java/com/studypals/domain/groupManage/dao/groupRepository/GroupCustomRepositoryImpl.java b/src/main/java/com/studypals/domain/groupManage/dao/groupRepository/GroupCustomRepositoryImpl.java index 5ded1d96..30c1326b 100644 --- a/src/main/java/com/studypals/domain/groupManage/dao/groupRepository/GroupCustomRepositoryImpl.java +++ b/src/main/java/com/studypals/domain/groupManage/dao/groupRepository/GroupCustomRepositoryImpl.java @@ -2,7 +2,6 @@ import static com.studypals.domain.groupManage.entity.QGroup.group; import static com.studypals.domain.groupManage.entity.QGroupHashTag.groupHashTag; -import static com.studypals.domain.groupManage.entity.QHashTag.hashTag; import java.time.LocalDate; import java.util.List; @@ -26,25 +25,35 @@ import com.studypals.global.utils.StringUtils; /** - * 코드에 대한 전체적인 역할을 적습니다. + * 그룹 목록 조회를 위한 QueryDSL 기반 커스텀 Repository 구현체입니다. + * + *

+ * {@link GroupSearchDto}의 검색 조건(태그/이름/해시태그/공개 여부/승인 필요 여부 등)과 + * {@link Cursor}의 커서 기반 페이징 정보를 조합하여 {@link Slice} 형태로 결과를 반환합니다. + *

+ * *

- * 코드에 대한 작동 원리 등을 적습니다. + * {@link Slice}를 사용하기 때문에 전체 개수(count 쿼리)는 수행하지 않습니다. + * 대신 {@code limit = size + 1} 방식으로 다음 페이지 존재 여부({@code hasNext})를 판단합니다. + *

* *

상속 정보:
- * 상속 정보를 적습니다. + * {@link AbstractPagingRepository}를 상속하여 정렬(OrderSpecifier) 생성 로직을 재사용합니다. + *

* *

주요 생성자:
- * {@code ExampleClass(String example)}
- * 주요 생성자와 그 매개변수에 대한 설명을 적습니다.
+ * {@code GroupCustomRepositoryImpl(JPAQueryFactory queryFactory, StringUtils stringUtils)} + *

* *

빈 관리:
- * 필요 시 빈 관리에 대한 내용을 적습니다. + * {@code @Repository} + *

* *

외부 모듈:
- * 필요 시 외부 모듈에 대한 내용을 적습니다. + * Spring Data JPA({@link Slice}), QueryDSL({@link JPAQueryFactory}, {@link BooleanExpression}) + *

* * @author jack8 - * @see * @since 2026-01-13 */ @Repository @@ -54,12 +63,54 @@ public class GroupCustomRepositoryImpl extends AbstractPagingRepository i private final JPAQueryFactory queryFactory; private final StringUtils stringUtils; + + /** + * 검색 조건과 커서 정보를 기반으로 그룹 목록을 조회합니다. + * + *

+ * 아래 요소들을 조합하여 단일 조회 쿼리를 구성합니다. + *

+ *
    + *
  • {@link #assembleWhere(GroupSearchDto)}: 키워드 기반 검색 조건(태그/이름/해시태그)
  • + *
  • {@link #assembleType(GroupSearchDto)}: 필터 조건(공개, 정원 미달, 승인 필요)
  • + *
  • {@link #assembleCursor(Cursor)}: 커서 기반 페이징 조건(정렬 기준 + id tie-break)
  • + *
  • {@link #getOrderSpecifier(SortType)}: 정렬 조건(정렬 기준 + id tie-break)
  • + *
+ * + *

+ * 최종적으로 만들어지는 쿼리 형태는 다음과 같습니다(조건은 입력에 따라 생략/추가). + *

+ *
+     * {@code
+     * SELECT g
+     * FROM Group g
+     * WHERE
+     *   (키워드 조건)
+     *   AND (타입/상태 필터 조건)
+     *   AND (커서 조건)
+     * ORDER BY (정렬 기준), (id tie-break)
+     * LIMIT size + 1
+     * }
+     *
+ *

+ * {@code LIMIT size + 1}로 한 개를 더 가져온 뒤, + * 실제 반환은 {@code size}개로 잘라 {@link Slice#hasNext()}를 계산합니다. + *

+ * + * @param dto 검색 조건 DTO + * @param cursor 커서 기반 페이징 정보(정렬, 커서 id, 정렬값, size) + * @return 다음 페이지 존재 여부를 포함한 {@link Slice} 결과 + */ @Override public Slice search(GroupSearchDto dto, Cursor cursor) { OrderSpecifier[] orders = getOrderSpecifier(cursor.sort()); List results = queryFactory .selectFrom(group) - .where(assembleWhere(dto), assembleCursor(cursor), assembleType(dto)) + .where( + assembleWhere(dto), // 키워드 검색 조건 (tag/name/hashTag 중 1개) + assembleCursor(cursor), // 커서 조건 (정렬 기준 + id tie-break) + assembleType(dto) // 공개/정원/승인 필터 + ) .orderBy(orders) .limit(cursor.size() + 1) .fetch(); @@ -68,52 +119,163 @@ public Slice search(GroupSearchDto dto, Cursor cursor) { if (hasNext) { results.remove(results.size() - 1); } + // Slice는 "요청 페이지 번호"가 의미가 약하므로 0 고정 / CursorResponse 에서 사라짐 return new SliceImpl<>(results, PageRequest.of(0, cursor.size()), hasNext); } + + /** + * 키워드 기반 검색 조건을 조립합니다. + * + *

+ * dto에 들어온 검색 키워드 중 하나만 적용됩니다(우선순위: tag → name → hashTag). + * 여러 조건을 동시에 검색해야 한다면 OR/AND 조합으로 확장해야 합니다. + *

+ * + *

+ * 생성될 수 있는 조건 예시는 다음과 같습니다. + *

+ *
    + *
  • tag 검색: + * {@code WHERE lower(g.tag) like %:normalizedTag%}
  • + *
  • name 검색: + * {@code WHERE lower(g.name) like %:trimmedName%}
  • + *
  • hashTag 검색(서브쿼리 exists): + *
    +     *       {@code WHERE exists (SELECT 1 FROM GroupHashTag ght
    +     *       WHERE ght.group_id = g.id
    +     *       AND ght.hashTag.tag like %:norm% )}
  • + * + * + *
+ * + * @param dto 검색 조건 DTO + * @return 키워드 조건(없으면 null 반환하여 where 절에서 무시) + */ private BooleanExpression assembleWhere(GroupSearchDto dto) { if (hasText(dto.tag())) { + // tag는 normalize 후 containsIgnoreCase로 처리 + // {@code lower(g.tag) like %:normalized%} return group.tag.containsIgnoreCase(stringUtils.normalize(dto.tag())); } if (hasText(dto.name())) { + // name은 trim 후 containsIgnoreCase + // {@code lower(g.name) like %:trimmed%} return group.name.containsIgnoreCase(dto.name().trim()); } if (hasText(dto.hashTag())) { + // hashtag는 "그룹-해시태그 매핑" 테이블을 통해 존재 여부로 필터링 + // {where exists (...) } return getHashTagByGroup(dto.hashTag()); } return null; } + /** + * 그룹 상태/타입 필터 조건을 조립합니다. + * + *

+ * 이 메서드는 "검색 키워드"가 아닌 "그룹의 상태"를 필터링합니다. + * 필요성: + *

    + *
  • 공개 그룹만 보여주기({@code isOpen = true})
  • + *
  • 정원 미달 그룹만 노출({@code totalMember < maxMember})
  • + *
  • 승인 필요 여부로 필터({@code isApprovalRequired = true})
  • + *
+ *

+ * + *

+ * 만들어지는 조건 예시는 다음과 같습니다(입력에 따라 일부만 적용). + *

+ *
+     * {@code
+     * WHERE
+     *   g.isOpen = true
+     *   AND g.totalMember < g.maxMember
+     *   AND g.isApprovalRequired = true
+     * }
+     * 
+ * @param dto 검색 조건 DTO + * @return 상태 필터 조건(없으면 null) + */ private BooleanExpression assembleType(GroupSearchDto dto) { BooleanExpression cond = null; - if (dto.isOpen()) { + if (dto.isOpen() != null && dto.isOpen()) { + // 공개 그룹 + 정원 미달 필터 cond = and(cond, group.isOpen.isTrue()); cond = and(cond, group.totalMember.lt(group.maxMember)); } - if (dto.isApprovalRequired()) { + if (dto.isApprovalRequired() != null && dto.isApprovalRequired()) { + // 승인 필요 필터 cond = and(cond, group.isApprovalRequired.isTrue()); } return cond; } + /** + * 커서 기반 페이징 조건을 조립합니다. + * + *

+ * 커서 페이징은 "정렬 기준 값"만으로는 안정적인 페이지 이동이 불가능합니다. + * 동일한 정렬 값(tie)이 존재할 수 있기 때문입니다. + * 따라서 정렬 기준 값 + {@code id}를 함께 사용하여 tie-break를 수행합니다. + *

+ * + *

+ * 정렬 타입별로 생성되는 커서 조건은 다음과 같습니다. + *

+ * + *

POPULAR (totalMember desc, id desc)

+ * {@code + * WHERE + * (g.totalMember < :total) + * OR (g.totalMember = :total AND g.id < :cursorId) + * } + * + *

NEW (createdDate desc, id desc)

+ * {@code + * WHERE + * (g.createdDate < :date) + * OR (g.createdDate = :date AND g.id < :cursorId) + * } + * + *

OLD (createdDate asc, id asc)

+ * {@code + * WHERE + * (g.createdDate > :date) + * OR (g.createdDate = :date AND g.id > :cursorId) + * } + * + *

+ * 위 조건은 "이전 페이지의 마지막 row" 다음부터 조회되도록 설계되었습니다. + * 정렬 방향이 바뀌면 비교 연산 방향({@code < / >})도 함께 바뀌어야 합니다. + *

+ * + * @param cursor 커서(정렬타입, cursor id, 정렬값(value))를 포함 + * @return 커서 조건(없으면 null) + * @throws IllegalArgumentException sort 타입이 지원되지 않는 경우 + */ private BooleanExpression assembleCursor(Cursor cursor) { if (cursor == null || cursor.cursor() == null || cursor.value() == null) { return null; } + // POPULAR: totalMember desc, id desc if (cursor.sort() == GroupSortType.POPULAR) { Integer total = Integer.parseInt(cursor.value()); return group.totalMember.lt(total).or(group.totalMember.eq(total).and(group.id.lt(cursor.cursor()))); } + // NEW: createdDate desc, id desc if (cursor.sort() == GroupSortType.NEW) { LocalDate date = LocalDate.parse(cursor.value()); return group.createdDate.lt(date).or(group.createdDate.eq(date).and(group.id.lt(cursor.cursor()))); } + // OLD: createdDate asc, id asc if (cursor.sort() == GroupSortType.OLD) { LocalDate date = LocalDate.parse(cursor.value()); return group.createdDate.gt(date).or(group.createdDate.eq(date).and(group.id.gt(cursor.cursor()))); @@ -122,14 +284,71 @@ private BooleanExpression assembleCursor(Cursor cursor) { throw new IllegalArgumentException("cursor sort not matched"); } + + /** + * 해시태그 기준으로 그룹을 필터링하기 위한 exists 서브쿼리 조건을 생성합니다. + * + *

+ * 그룹 - 해시태그는 일반적으로 N:M 관계이므로 조인을 통한 중복 row 발생 가능성이 있습니다. + * 여기서는 중복을 피하고 "존재 여부"만 확인하기 위해 {@code exists}를 사용합니다. + *

+ * + *

+ * 생성되는 쿼리 형태는 다음과 같습니다. + *

+ *
+     * {@code
+     * WHERE exists (
+     *   SELECT 1
+     *   FROM GroupHashTag ght
+     *   WHERE ght.group_id = g.id
+     *     AND ght.hashTag.tag like %:normHashTag%
+     * )
+     * }
+     * 
+ * + * @param rawHashTag 사용자가 입력한 해시태그 문자열(정규화 전) + * @return 해시태그 존재 조건 + */ private BooleanExpression getHashTagByGroup(String rawHashTag) { String normHashTag = stringUtils.normalize(rawHashTag); return JPAExpressions.selectOne() .from(groupHashTag) - .where(groupHashTag.group.id.eq(group.id), hashTag.tag.contains(normHashTag)) + .where(groupHashTag.group.id.eq(group.id), groupHashTag.hashTag.tag.contains(normHashTag)) .exists(); } + + /** + * 정렬 조건(OrderSpecifier)을 생성합니다. + * + *

+ * 커서 기반 페이징에서 정렬은 "페이지 안정성"을 위해 반드시 결정적(deterministic)이어야 합니다. + * 즉, 정렬 기준 컬럼 값이 동일한 row(tie)가 존재해도 결과 순서가 항상 같아야 합니다. + *

+ * + *

+ * 이를 위해 구현체는 기본 정렬 기준 외에 {@code id}를 tie-breaker로 추가합니다. + * 예시: + *

+ *
    + *
  • POPULAR: {@code ORDER BY totalMember DESC, id DESC}
  • + *
  • NEW: {@code ORDER BY createdDate DESC, id DESC}
  • + *
  • OLD: {@code ORDER BY createdDate ASC, id ASC}
  • + *
+ * + *

+ * tie-break가 없으면 다음 문제가 발생할 수 있습니다. + *

+ *
    + *
  • 페이지를 넘길 때 일부 row가 중복 노출되거나 누락됨
  • + *
  • 같은 커서로 조회해도 결과가 흔들림(DB 실행 계획/물리적 순서 영향)
  • + *
+ * + * @param sort 정렬 타입 + * @return 정렬 OrderSpecifier 배열 + * @throws IllegalArgumentException 지원하지 않는 sort 타입인 경우 + */ private OrderSpecifier[] getOrderSpecifier(SortType sort) { if (sort instanceof GroupSortType) { return super.getOrderSpecifierWithId(group, sort); @@ -138,11 +357,29 @@ private OrderSpecifier[] getOrderSpecifier(SortType sort) { } } + /** + * BooleanExpression을 누적(and)하기 위한 유틸 메서드입니다. + * + *

+ * QueryDSL에서는 where 절에 null을 넣으면 무시되므로, + * 조건을 단계적으로 조립할 때 null-safe 조합이 필요합니다. + *

+ * + * @param base 누적 조건(없으면 null) + * @param add 추가할 조건(없으면 null) + * @return 누적된 조건 + */ private BooleanExpression and(BooleanExpression base, BooleanExpression add) { if (add == null) return base; return base == null ? add : base.and(add); } + /** + * 문자열이 null/blank가 아닌지 확인합니다. + * + * @param s 검사할 문자열 + * @return null이 아니고 공백이 아닌 경우 true + */ private boolean hasText(String s) { return s != null && !s.isBlank(); } diff --git a/src/main/java/com/studypals/domain/groupManage/dao/groupRepository/GroupSortType.java b/src/main/java/com/studypals/domain/groupManage/dao/groupRepository/GroupSortType.java index e833a531..21417a3c 100644 --- a/src/main/java/com/studypals/domain/groupManage/dao/groupRepository/GroupSortType.java +++ b/src/main/java/com/studypals/domain/groupManage/dao/groupRepository/GroupSortType.java @@ -7,25 +7,52 @@ import com.studypals.global.request.SortType; /** - * 코드에 대한 전체적인 역할을 적습니다. + * 그룹 목록 조회 시 사용되는 정렬 타입을 정의하는 열거형입니다. + * *

- * 코드에 대한 작동 원리 등을 적습니다. + * {@link GroupSortType}은 그룹 검색 및 목록 조회 API에서 사용할 수 있는 + * 정렬 기준을 명시적으로 제한하기 위해 사용됩니다. + * 임의의 필드 정렬을 허용하지 않고, 서버에서 허용한 정렬 방식만 노출하는 것이 목적입니다. + *

* - *

상속 정보:
- * 상속 정보를 적습니다. + *

+ * 각 정렬 타입은 다음 두 정보를 함께 가집니다. + *

+ *
    + *
  • {@code field}: 정렬에 사용되는 엔티티 필드명
  • + *
  • {@code direction}: 정렬 방향 ({@link Sort.Direction})
  • + *
+ * + *

+ * 이 값들은 {@link com.studypals.global.dao.AbstractPagingRepository}에서 + * QueryDSL {@code OrderSpecifier}를 생성하는 데 사용되며, + * 커서 기반 페이징 시에는 반드시 {@code id}를 tie-breaker로 추가하여 + * 정렬의 결정성(deterministic ordering)을 보장합니다. + *

+ * + *

+ * 정의된 정렬 타입은 다음과 같습니다. + *

+ *
    + *
  • {@link #POPULAR}: 참여 인원 수 기준 내림차순 정렬 (인기순)
  • + *
  • {@link #NEW}: 생성일 기준 내림차순 정렬 (최신순)
  • + *
  • {@link #OLD}: 생성일 기준 오름차순 정렬 (오래된 순)
  • + *
* - *

주요 생성자:
- * {@code ExampleClass(String example)}
- * 주요 생성자와 그 매개변수에 대한 설명을 적습니다.
+ *

상속 정보:
+ * {@link SortType} 인터페이스를 구현하여, + * 공통 정렬 처리 로직에서 다형적으로 사용됩니다. + *

* *

빈 관리:
- * 필요 시 빈 관리에 대한 내용을 적습니다. + * enum 타입으로 Spring Bean으로 관리되지 않습니다. + *

* *

외부 모듈:
- * 필요 시 외부 모듈에 대한 내용을 적습니다. + * Spring Data ({@link Sort}) + *

* * @author jack8 - * @see * @since 2026-01-13 */ @Getter diff --git a/src/main/java/com/studypals/domain/groupManage/dto/GroupSearchDto.java b/src/main/java/com/studypals/domain/groupManage/dto/GroupSearchDto.java index 6dd8b349..999e7401 100644 --- a/src/main/java/com/studypals/domain/groupManage/dto/GroupSearchDto.java +++ b/src/main/java/com/studypals/domain/groupManage/dto/GroupSearchDto.java @@ -3,13 +3,39 @@ import lombok.Builder; /** + * 그룹 검색 조건을 전달하기 위한 DTO입니다. * + *

+ * 그룹 목록 조회 시 사용자가 입력한 검색 조건을 담으며, + * 키워드 기반 검색은 {@code tag}, {@code hashTag}, {@code name} 중 + * 하나만 허용하도록 제한합니다. + *

+ * + *

+ * 상태 필터 조건으로 공개 여부({@code isOpen})와 + * 승인 필요 여부({@code isApprovalRequired})를 함께 전달할 수 있습니다. + *

+ * + *

+ * {@link #validate()} 메서드는 키워드 조건이 동시에 여러 개 지정되는 것을 + * 방지하기 위한 검증 로직을 담당합니다. + *

* * @author jack8 * @since 2026-01-13 */ @Builder public record GroupSearchDto(String tag, String hashTag, String name, Boolean isOpen, Boolean isApprovalRequired) { + /** + * 키워드 검색 조건의 유효성을 검증합니다. + * + *

+ * {@code tag}, {@code hashTag}, {@code name} 중 + * 두 개 이상이 동시에 지정되면 예외를 발생시킵니다. + *

+ * + * @throws IllegalArgumentException 키워드 조건이 둘 이상 지정된 경우 + */ public void validate() { int count = 0; diff --git a/src/main/java/com/studypals/domain/groupManage/service/GroupServiceImpl.java b/src/main/java/com/studypals/domain/groupManage/service/GroupServiceImpl.java index 3407f01f..7c2f47bc 100644 --- a/src/main/java/com/studypals/domain/groupManage/service/GroupServiceImpl.java +++ b/src/main/java/com/studypals/domain/groupManage/service/GroupServiceImpl.java @@ -133,7 +133,7 @@ public CursorResponse.Content search(GroupSearchDto dto, Cursor cu List groups = groupSlice.getContent(); if (groups.isEmpty()) { - return new CursorResponse.Content<>(Collections.emptyList(), -1L, groupSlice.hasNext()); + return new CursorResponse.Content<>(Collections.emptyList(), 0L, groupSlice.hasNext()); } List summary = groups.stream().map(groupMapper::toDto).toList(); diff --git a/src/main/java/com/studypals/domain/groupManage/worker/GroupHashTagWorker.java b/src/main/java/com/studypals/domain/groupManage/worker/GroupHashTagWorker.java index 48b8bb7b..fb32130e 100644 --- a/src/main/java/com/studypals/domain/groupManage/worker/GroupHashTagWorker.java +++ b/src/main/java/com/studypals/domain/groupManage/worker/GroupHashTagWorker.java @@ -1,6 +1,7 @@ package com.studypals.domain.groupManage.worker; import java.util.*; +import java.util.stream.Collectors; import org.springframework.dao.DataIntegrityViolationException; @@ -106,12 +107,11 @@ public Map> getHashTagsByGroups(List groupIds) { } List groupHashTags = groupHashTagRepository.findAllByGroupIdIn(groupIds); - Map> result = new HashMap<>(); - for (GroupHashTag groupHashTag : groupHashTags) { - Long groupId = groupHashTag.getGroup().getId(); - result.computeIfAbsent(groupId, k -> new ArrayList<>()).add(groupHashTag.getDisplayTag()); - } - return result; + + return groupHashTags.stream() + .collect(Collectors.groupingBy( + gh -> gh.getGroup().getId(), + Collectors.mapping(GroupHashTag::getDisplayTag, Collectors.toList()))); } /** * 각 태그에 대한 정규화 진행. 띄어쓰기는 _ 로 대체, 중복된 띄어쓰기 제거/특수문제 제거, trim 제거, lowercase diff --git a/src/main/java/com/studypals/global/dao/AbstractPagingRepository.java b/src/main/java/com/studypals/global/dao/AbstractPagingRepository.java index afeca3a1..2634e5ab 100644 --- a/src/main/java/com/studypals/global/dao/AbstractPagingRepository.java +++ b/src/main/java/com/studypals/global/dao/AbstractPagingRepository.java @@ -37,7 +37,7 @@ protected OrderSpecifier getOrderSpecifier(EntityPath root, SortType s EntityMetadata metadata = getEntityMetadata(root.getType()); Class sortFieldType = metadata.getFieldType(field); - // ✅ alias를 문자열로 만들지 말고 root의 metadata를 그대로 사용 + //alias를 문자열로 만들지 말고 root의 metadata를 그대로 사용 PathMetadata rootMetadata = root.getMetadata(); PathBuilder path = new PathBuilder<>((Class) root.getType(), rootMetadata); From b4e7a937f4bc83d34c541e5042371917024f0d2b Mon Sep 17 00:00:00 2001 From: unikal1 Date: Tue, 20 Jan 2026 15:01:56 +0900 Subject: [PATCH 07/14] =?UTF-8?q?Test:=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ref: #164 --- .../domain/groupManage/dto/GetGroupsRes.java | 4 +- .../resolver/CursorDefaultResolver.java | 2 +- .../dao/GroupEntryRequestRepositoryTest.java | 2 +- .../GroupCustomRepositoryTest.java | 338 ++++++++++++++++++ .../GroupControllerRestDocsTest.java | 13 +- .../service/GroupEntryServiceTest.java | 4 +- .../groupManage/service/GroupServiceTest.java | 164 +++++---- .../worker/GroupEntryRequestReaderTest.java | 4 +- .../GlobalExceptionHandlerTest.java | 3 +- .../testComponent/TestSupportConfig.java | 31 ++ .../testSupport/DataJpaSupport.java | 3 +- .../testSupport/RestDocsSupport.java | 3 +- 12 files changed, 483 insertions(+), 88 deletions(-) create mode 100644 src/test/java/com/studypals/domain/groupManage/dao/groupRepository/GroupCustomRepositoryTest.java create mode 100644 src/test/java/com/studypals/testModules/testComponent/TestSupportConfig.java diff --git a/src/main/java/com/studypals/domain/groupManage/dto/GetGroupsRes.java b/src/main/java/com/studypals/domain/groupManage/dto/GetGroupsRes.java index df62522f..d1c3a34b 100644 --- a/src/main/java/com/studypals/domain/groupManage/dto/GetGroupsRes.java +++ b/src/main/java/com/studypals/domain/groupManage/dto/GetGroupsRes.java @@ -7,8 +7,8 @@ public record GetGroupsRes( Long groupId, - String groupName, - String groupTag, + String name, + String tag, List hashTags, int memberCount, String chatRoomId, diff --git a/src/main/java/com/studypals/global/resolver/CursorDefaultResolver.java b/src/main/java/com/studypals/global/resolver/CursorDefaultResolver.java index f65b8cd6..3dee5f93 100644 --- a/src/main/java/com/studypals/global/resolver/CursorDefaultResolver.java +++ b/src/main/java/com/studypals/global/resolver/CursorDefaultResolver.java @@ -22,8 +22,8 @@ * @author s0o0bn * @since 2025-06-05 */ -@Component @AllArgsConstructor +@Component public class CursorDefaultResolver implements HandlerMethodArgumentResolver { private static final String CURSOR_PARAM = "cursor"; private static final String SIZE_PARAM = "size"; diff --git a/src/test/java/com/studypals/domain/groupManage/dao/GroupEntryRequestRepositoryTest.java b/src/test/java/com/studypals/domain/groupManage/dao/GroupEntryRequestRepositoryTest.java index 8304edf6..daba4456 100644 --- a/src/test/java/com/studypals/domain/groupManage/dao/GroupEntryRequestRepositoryTest.java +++ b/src/test/java/com/studypals/domain/groupManage/dao/GroupEntryRequestRepositoryTest.java @@ -45,7 +45,7 @@ void findByGroupIdAndSortBy_success() { Slice expected = new SliceImpl<>(List.of(request1, request2)); // when - Cursor cursor = new Cursor(0, 10, DateSortType.NEW); + Cursor cursor = new Cursor(0L, "2025-03-02", 10, DateSortType.NEW); Slice actual = entryRequestRepository.findAllByGroupIdWithPagination(group.getId(), cursor); // then diff --git a/src/test/java/com/studypals/domain/groupManage/dao/groupRepository/GroupCustomRepositoryTest.java b/src/test/java/com/studypals/domain/groupManage/dao/groupRepository/GroupCustomRepositoryTest.java new file mode 100644 index 00000000..e1bcf58f --- /dev/null +++ b/src/test/java/com/studypals/domain/groupManage/dao/groupRepository/GroupCustomRepositoryTest.java @@ -0,0 +1,338 @@ +package com.studypals.domain.groupManage.dao.groupRepository; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.*; + +import java.time.LocalDate; +import java.util.*; +import java.util.stream.Collectors; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +import com.studypals.domain.groupManage.dto.GroupSearchDto; +import com.studypals.domain.groupManage.entity.Group; +import com.studypals.domain.groupManage.entity.GroupHashTag; +import com.studypals.domain.groupManage.entity.HashTag; +import com.studypals.global.request.Cursor; +import com.studypals.global.utils.StringUtils; +import com.studypals.testModules.testSupport.DataJpaSupport; + +/** + * GroupCustomRepsitory 에 대한 query dsl jpa test 입니다. + * + * @author jack8 + * @since 2026-01-16 + */ +@DisplayName("GroupCustomRepository_Querydsl_test") +class GroupCustomRepositoryTest extends DataJpaSupport { + + private static final int PAGE_SIZE = 20; + private static final int TOTAL_GROUPS = 200; + + @Autowired + GroupRepository groupRepository; + + @Autowired + StringUtils stringUtils; + + private HashTag htJava; + private HashTag htSpring; + + // 필터 대상 그룹을 따로 기억해두면 “필터 결과가 예상과 일치하는지” 계산하기 쉬움 + private final Set tagMatchedGroupIds = new HashSet<>(); + private final Set nameMatchedGroupIds = new HashSet<>(); + private final Set hashTagMatchedGroupIds = new HashSet<>(); + + @BeforeEach + void setUp() { + htJava = insertHashTag("java"); + htSpring = insertHashTag("spring"); + + seed200GroupsWithBoundaries(); + + em.flush(); + em.clear(); + } + + @Test + @DisplayName("POPULAR: tie(totalMember 동일) 블록이 페이지 경계를 넘을 때 중복/누락 없이 페이징된다") + void popular_cursorPaging_noOverlap_noMissing_onTieBoundary() { + GroupSearchDto dto = new GroupSearchDto( + null, null, null, null, null // isOpen, isApprovalRequired (필요하면 켜서 추가 테스트) + ); + + List all = fetchAllByCursor(dto, GroupSortType.POPULAR); + + assertThat(all).hasSize(TOTAL_GROUPS); + assertNoDuplicateIds(all); + assertSortedPopularDeterministic(all); + } + + @Test + @DisplayName("NEW: tie(createdDate 동일) 블록이 페이지 경계를 넘을 때 중복/누락 없이 페이징된다") + void new_cursorPaging_noOverlap_noMissing_onTieBoundary() { + GroupSearchDto dto = new GroupSearchDto(null, null, null, null, null); + + List all = fetchAllByCursor(dto, GroupSortType.NEW); + + assertThat(all).hasSize(TOTAL_GROUPS); + assertNoDuplicateIds(all); + assertSortedNewDeterministic(all); + } + + @Test + @DisplayName("OLD: tie(createdDate 동일) 블록이 페이지 경계를 넘을 때 중복/누락 없이 페이징된다") + void old_cursorPaging_noOverlap_noMissing_onTieBoundary() { + GroupSearchDto dto = new GroupSearchDto(null, null, null, null, null); + + List all = fetchAllByCursor(dto, GroupSortType.OLD); + + assertThat(all).hasSize(TOTAL_GROUPS); + assertNoDuplicateIds(all); + assertSortedOldDeterministic(all); + } + + @Test + @DisplayName("tag 검색: POPULAR/NEW/OLD에서 tag 필터가 정확히 작동하고 정렬도 유지된다") + void tagFilter_works_onAllSorts() { + // tag 검색은 normalize + containsIgnoreCase + GroupSearchDto dto = new GroupSearchDto("TAG-POOL", null, null, null, null); + + // POPULAR + List popular = fetchAllByCursor(dto, GroupSortType.POPULAR); + assertThat(popular).allSatisfy(g -> assertThat(g.getTag().toLowerCase()).contains("tag-pool".toLowerCase())); + assertNoDuplicateIds(popular); + assertSortedPopularDeterministic(popular); + assertThat(popular.stream().map(Group::getId).collect(Collectors.toSet())) + .isSubsetOf(tagMatchedGroupIds); + + // NEW + List newer = fetchAllByCursor(dto, GroupSortType.NEW); + assertNoDuplicateIds(newer); + assertSortedNewDeterministic(newer); + assertThat(newer.stream().map(Group::getId).collect(Collectors.toSet())).isSubsetOf(tagMatchedGroupIds); + + // OLD + List older = fetchAllByCursor(dto, GroupSortType.OLD); + assertNoDuplicateIds(older); + assertSortedOldDeterministic(older); + assertThat(older.stream().map(Group::getId).collect(Collectors.toSet())).isSubsetOf(tagMatchedGroupIds); + } + + @Test + @DisplayName("name 검색: POPULAR/NEW/OLD에서 name 필터가 정확히 작동한다 (tag가 비어있을 때만)") + void nameFilter_works_onAllSorts() { + GroupSearchDto dto = new GroupSearchDto(null, "NamePool", null, null, null); + + List popular = fetchAllByCursor(dto, GroupSortType.POPULAR); + assertThat(popular) + .allSatisfy(g -> assertThat(g.getName().toLowerCase()).contains("namepool".toLowerCase())); + assertThat(popular.stream().map(Group::getId).collect(Collectors.toSet())) + .isSubsetOf(nameMatchedGroupIds); + + List newer = fetchAllByCursor(dto, GroupSortType.NEW); + assertThat(newer.stream().map(Group::getId).collect(Collectors.toSet())).isSubsetOf(nameMatchedGroupIds); + + List older = fetchAllByCursor(dto, GroupSortType.OLD); + assertThat(older.stream().map(Group::getId).collect(Collectors.toSet())).isSubsetOf(nameMatchedGroupIds); + } + + @Test + @DisplayName("hashTag 검색: POPULAR/NEW/OLD에서 exists 서브쿼리 기반 필터가 정확히 작동한다 (tag/name 없을 때만)") + void hashTagFilter_works_onAllSorts() { + GroupSearchDto dto = new GroupSearchDto(null, null, "java", null, null); + + List popular = fetchAllByCursor(dto, GroupSortType.POPULAR); + assertThat(popular.stream().map(Group::getId).collect(Collectors.toSet())) + .isSubsetOf(hashTagMatchedGroupIds); + assertSortedPopularDeterministic(popular); + + List newer = fetchAllByCursor(dto, GroupSortType.NEW); + assertThat(newer.stream().map(Group::getId).collect(Collectors.toSet())).isSubsetOf(hashTagMatchedGroupIds); + assertSortedNewDeterministic(newer); + + List older = fetchAllByCursor(dto, GroupSortType.OLD); + assertThat(older.stream().map(Group::getId).collect(Collectors.toSet())).isSubsetOf(hashTagMatchedGroupIds); + assertSortedOldDeterministic(older); + } + + // ========================= + // Cursor 페이징 공용 유틸 + // ========================= + + private List fetchAllByCursor(GroupSearchDto dto, GroupSortType sort) { + List acc = new ArrayList<>(); + + Cursor cursor = new Cursor(0L, null, PAGE_SIZE, sort); + + while (true) { + var slice = groupRepository.search(dto, cursor); + List content = slice.getContent(); + + if (content.isEmpty()) break; + + acc.addAll(content); + + if (!slice.hasNext()) break; + + Group last = content.get(content.size() - 1); + cursor = nextCursor(sort, last); + } + + return acc; + } + + private Cursor nextCursor(GroupSortType sort, Group last) { + String value; + if (sort == GroupSortType.POPULAR) { + value = String.valueOf(last.getTotalMember()); + } else if (sort == GroupSortType.NEW || sort == GroupSortType.OLD) { + value = String.valueOf(last.getCreatedDate()); // LocalDate -> "yyyy-MM-dd" + } else { + throw new IllegalArgumentException("unsupported sort: " + sort); + } + return new Cursor(last.getId(), value, PAGE_SIZE, sort); + } + + private void assertNoDuplicateIds(List all) { + List ids = all.stream().map(Group::getId).toList(); + Set uniq = new HashSet<>(ids); + assertThat(uniq).as("중복 ID가 없어야 함").hasSize(ids.size()); + } + + private void assertSortedPopularDeterministic(List all) { + // totalMember desc, id desc + for (int i = 0; i < all.size() - 1; i++) { + Group a = all.get(i); + Group b = all.get(i + 1); + + if (!Objects.equals(a.getTotalMember(), b.getTotalMember())) { + assertThat(a.getTotalMember()).isGreaterThan(b.getTotalMember()); + } else { + assertThat(a.getId()).isGreaterThan(b.getId()); + } + } + } + + private void assertSortedNewDeterministic(List all) { + // createdDate desc, id desc + for (int i = 0; i < all.size() - 1; i++) { + Group a = all.get(i); + Group b = all.get(i + 1); + + if (!a.getCreatedDate().equals(b.getCreatedDate())) { + assertThat(a.getCreatedDate()).isAfter(b.getCreatedDate()); + } else { + assertThat(a.getId()).isGreaterThan(b.getId()); + } + } + } + + private void assertSortedOldDeterministic(List all) { + // createdDate asc, id asc + for (int i = 0; i < all.size() - 1; i++) { + Group a = all.get(i); + Group b = all.get(i + 1); + + if (!a.getCreatedDate().equals(b.getCreatedDate())) { + assertThat(a.getCreatedDate()).isBefore(b.getCreatedDate()); + } else { + assertThat(a.getId()).isLessThan(b.getId()); + } + } + } + + // ========================= + // 데이터 풀 생성 + // ========================= + + private void seed200GroupsWithBoundaries() { + // POPULAR tie boundary를 확실히 만들기 위한 totalMember 블록 + // 10 + 25 + 30 + 40 + 50 + 45 = 200 + // pageSize=20 => 10(100) + 10(90)에서 페이지1 끝. 다음 페이지는 남은 15(90)부터 시작해야 함. + List totals = new ArrayList<>(); + totals.addAll(Collections.nCopies(10, 100)); + totals.addAll(Collections.nCopies(25, 90)); // tie block crosses boundary + totals.addAll(Collections.nCopies(30, 80)); + totals.addAll(Collections.nCopies(40, 70)); + totals.addAll(Collections.nCopies(50, 60)); + totals.addAll(Collections.nCopies(45, 50)); + assertThat(totals).hasSize(TOTAL_GROUPS); + + // NEW/OLD tie boundary 만들기 위한 createdDate 블록(25개짜리 덩어리 여러 개) + // 200개를 8블록(각 25개)으로 구성 -> 항상 페이지 경계를 넘는 tie를 만들기 쉬움 + List dates = new ArrayList<>(); + LocalDate base = LocalDate.of(2026, 1, 1); + for (int block = 0; block < 8; block++) { + LocalDate d = base.plusDays(block); + dates.addAll(Collections.nCopies(25, d)); + } + assertThat(dates).hasSize(TOTAL_GROUPS); + + // 필터용: tag/name/hashTag 매칭 그룹을 일부 골라서 심기 + // - tag contains "tag-pool"로 매칭되게 + // - name contains "namepool"로 매칭되게 + // - hashtag: java 매핑되게 + for (int i = 0; i < TOTAL_GROUPS; i++) { + int total = totals.get(i); + LocalDate createdDate = dates.get(i); + + String tag = "tag-" + i; + String name = "group-" + i; + + boolean isTagPool = (i % 10 == 0); // 20개 정도 + boolean isNamePool = (i % 10 == 1); // 20개 정도 + boolean isJavaHash = (i % 10 == 2); // 20개 정도 + + if (isTagPool) tag = "TAG-POOL-" + i; + if (isNamePool) name = "NamePool-" + i; + + Group g = insertGroup(total, tag, name, createdDate); + + if (isTagPool) tagMatchedGroupIds.add(g.getId()); + if (isNamePool) nameMatchedGroupIds.add(g.getId()); + + if (isJavaHash) { + insertGroupHashTag(htJava, g, "#Java"); + hashTagMatchedGroupIds.add(g.getId()); + } + } + } + + // ========================= + // 사용자 제공 helper 기반 확장 + // ========================= + + private HashTag insertHashTag(String displayName) { + return em.persist(HashTag.builder() + .tag(stringUtils.normalize(displayName)) + .usedCount(1L) + .build()); + } + + private Group insertGroup(int total, String tag, String name, LocalDate createdDate) { + Group g = Group.builder() + .name(name) + .tag(tag) + .totalMember(total) + // 필요하면 maxMember / isOpen / isApprovalRequired도 채움 + .maxMember(999) + .isOpen(true) + .isApprovalRequired(false) + .createdDate(createdDate) // 여기 막히면 리플렉션으로 세팅 + .build(); + + return em.persist(g); + } + + private GroupHashTag insertGroupHashTag(HashTag hashTag, Group group, String displayName) { + return em.persist(GroupHashTag.builder() + .group(group) + .hashTag(hashTag) + .displayTag(displayName) + .build()); + } +} diff --git a/src/test/java/com/studypals/domain/groupManage/restDocsTest/GroupControllerRestDocsTest.java b/src/test/java/com/studypals/domain/groupManage/restDocsTest/GroupControllerRestDocsTest.java index 7283cc17..04659de3 100644 --- a/src/test/java/com/studypals/domain/groupManage/restDocsTest/GroupControllerRestDocsTest.java +++ b/src/test/java/com/studypals/domain/groupManage/restDocsTest/GroupControllerRestDocsTest.java @@ -121,7 +121,7 @@ void getGroups_success() throws Exception { 101L, "알고리즘 코딩 마스터", "취업준비", - List.of(), + List.of("알고리즘", "백준", "프로그래머스"), 10, "chat_algo_01", true, @@ -133,7 +133,7 @@ void getGroups_success() throws Exception { 205L, "프론트엔드 리액트 스터디", "프론트개발", - List.of(), + List.of("리엑트", "안드로이드 스튜디오"), 20, "chat_react_fe", false, @@ -161,8 +161,9 @@ void getGroups_success() throws Exception { // 그룹 기본 정보 fieldWithPath("data[].groupId").description("그룹의 고유 ID"), - fieldWithPath("data[].groupName").description("그룹 이름"), - fieldWithPath("data[].groupTag").description("그룹의 태그"), + fieldWithPath("data[].name").description("그룹 이름"), + fieldWithPath("data[].tag").description("그룹의 태그"), + fieldWithPath("data[].hashTags").description("그룹의 해시태그"), fieldWithPath("data[].memberCount").description("그룹에 속한 전체 회원 수"), fieldWithPath("data[].chatRoomId").description("그룹에 연결된 채팅방 ID"), fieldWithPath("data[].isOpen").description("그룹 공개 여부 (true: 공개, false: 비공개)"), @@ -197,7 +198,7 @@ void getGroupDetail_success() throws Exception { 100L, "핵심 CS 전공 스터디", "tag", - List.of(), + List.of("운영체제", "네트워크", "자료구조"), true, // 공개 false, // 승인 불필요 10, // 최대 10명 @@ -223,6 +224,8 @@ void getGroupDetail_success() throws Exception { // GetGroupDetailRes 필드 설명 fieldWithPath("data.id").description("그룹의 고유 ID"), fieldWithPath("data.name").description("그룹 이름"), + fieldWithPath("data.tag").description("그룹의 태그"), + fieldWithPath("data.hashTags").description("그룹의 해시태그"), fieldWithPath("data.isOpen").description("그룹 공개 여부 (true: 공개, false: 비공개)"), fieldWithPath("data.isApprovalRequired") .description("그룹 가입 시 승인 필요 여부 (true: 필요, false: 불필요)"), diff --git a/src/test/java/com/studypals/domain/groupManage/service/GroupEntryServiceTest.java b/src/test/java/com/studypals/domain/groupManage/service/GroupEntryServiceTest.java index cffd8a68..a9c368d6 100644 --- a/src/test/java/com/studypals/domain/groupManage/service/GroupEntryServiceTest.java +++ b/src/test/java/com/studypals/domain/groupManage/service/GroupEntryServiceTest.java @@ -311,7 +311,7 @@ void getEntryRequests_success() { // given Long userId = 1L; Long groupId = 10L; - Cursor cursor = new Cursor(0, 10, DateSortType.NEW); + Cursor cursor = new Cursor(0L, "2025-03-02", 10, DateSortType.NEW); Member member1 = Member.builder().id(1L).build(); Member member2 = Member.builder().id(2L).build(); @@ -341,7 +341,7 @@ void getEntryRequests_fail_invalidAuthority() { // given Long userId = 1L; Long groupId = 1L; - Cursor cursor = new Cursor(0, 10, DateSortType.NEW); + Cursor cursor = new Cursor(0L, "2025-03-02", 10, DateSortType.NEW); willThrow(new GroupException(GroupErrorCode.GROUP_FORBIDDEN)) .given(authorityValidator) diff --git a/src/test/java/com/studypals/domain/groupManage/service/GroupServiceTest.java b/src/test/java/com/studypals/domain/groupManage/service/GroupServiceTest.java index 6b5d15f6..e9b17abd 100644 --- a/src/test/java/com/studypals/domain/groupManage/service/GroupServiceTest.java +++ b/src/test/java/com/studypals/domain/groupManage/service/GroupServiceTest.java @@ -7,6 +7,7 @@ import java.time.LocalDate; import java.util.List; +import java.util.Map; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -14,11 +15,13 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import com.studypals.domain.chatManage.dto.CreateChatRoomDto; import com.studypals.domain.chatManage.entity.ChatRoom; import com.studypals.domain.chatManage.worker.ChatRoomWriter; import com.studypals.domain.groupManage.dto.*; import com.studypals.domain.groupManage.dto.mappers.GroupMapper; import com.studypals.domain.groupManage.entity.Group; +import com.studypals.domain.groupManage.entity.GroupConst; import com.studypals.domain.groupManage.entity.GroupRole; import com.studypals.domain.groupManage.entity.GroupTag; import com.studypals.domain.groupManage.worker.*; @@ -52,13 +55,13 @@ public class GroupServiceTest { private GroupReader groupReader; @Mock - private GroupMemberWriter groupMemberWriter; + private GroupMemberReader groupMemberReader; @Mock - private GroupMemberReader groupMemberReader; + private GroupMemberWriter groupMemberWriter; @Mock - private GroupAuthorityValidator groupAuthorityValidator; + private GroupAuthorityValidator validator; @Mock private GroupMapper groupMapper; @@ -66,6 +69,9 @@ public class GroupServiceTest { @Mock private GroupGoalCalculator groupGoalCalculator; + @Mock + private GroupHashTagWorker groupHashTagWorker; + @Mock private ChatRoomWriter chatRoomWriter; @@ -84,9 +90,6 @@ public class GroupServiceTest { @Mock private ChatRoom mockChatRoom; - @Mock - private GroupHashTagWorker groupHashTagWorker; - @InjectMocks private GroupServiceImpl groupService; @@ -94,7 +97,6 @@ public class GroupServiceTest { void getGroupTags_success() { // given GetGroupTagRes res = new GetGroupTagRes(mockGroupTag.getName()); - given(groupReader.getGroupTags()).willReturn(List.of(mockGroupTag)); given(groupMapper.toTagDto(mockGroupTag)).willReturn(res); @@ -102,7 +104,9 @@ void getGroupTags_success() { List actual = groupService.getGroupTags(); // then - assertThat(actual).isEqualTo(List.of(res)); + assertThat(actual).containsExactly(res); + then(groupReader).should(times(1)).getGroupTags(); + then(groupMapper).should(times(1)).toTagDto(mockGroupTag); } @Test @@ -112,17 +116,34 @@ void createGroup_success() { CreateGroupReq req = new CreateGroupReq( "group name", "group tag", 10, false, false, "image.example.com", List.of("hashtag1", "hashtag2")); - given(memberReader.getRef(userId)).willReturn(mockMember); given(groupWriter.create(req)).willReturn(mockGroup); - given(chatRoomWriter.create(any())).willReturn(mockChatRoom); - willDoNothing().given(chatRoomWriter).joinAsAdmin(mockChatRoom, mockMember); + given(memberReader.getRef(userId)).willReturn(mockMember); + + // 해시태그 저장 willDoNothing().given(groupHashTagWorker).saveTags(mockGroup, req.hashTags()); + // 채팅방 생성/참여 + given(chatRoomWriter.create(any(CreateChatRoomDto.class))).willReturn(mockChatRoom); + willDoNothing().given(chatRoomWriter).joinAsAdmin(mockChatRoom, mockMember); + + // groupId 반환을 위해 + given(mockGroup.getId()).willReturn(777L); + // when Long actual = groupService.createGroup(userId, req); // then - assertThat(actual).isEqualTo(mockGroup.getId()); + assertThat(actual).isEqualTo(777L); + + then(groupWriter).should().create(req); + then(memberReader).should().getRef(userId); + then(groupMemberWriter).should().createLeader(mockMember, mockGroup); + then(groupHashTagWorker).should().saveTags(mockGroup, req.hashTags()); + then(chatRoomWriter).should().create(any(CreateChatRoomDto.class)); + then(chatRoomWriter).should().joinAsAdmin(mockChatRoom, mockMember); + + // ★ 변경 반영 포인트: group.setChatRoom(chatRoom) 호출됐는지 + then(mockGroup).should().setChatRoom(mockChatRoom); } @Test @@ -140,6 +161,12 @@ void createGroup_fail_whileGroupCreating() { .isInstanceOf(GroupException.class) .extracting("errorCode") .isEqualTo(errorCode); + + // 생성 실패면 이후 로직 호출되면 안 됨 + then(memberReader).shouldHaveNoInteractions(); + then(groupMemberWriter).shouldHaveNoInteractions(); + then(groupHashTagWorker).shouldHaveNoInteractions(); + then(chatRoomWriter).shouldHaveNoInteractions(); } @Test @@ -150,8 +177,8 @@ void createGroup_fail_whileGroupMemberCreating() { CreateGroupReq req = new CreateGroupReq("group name", "group tag", 10, false, false, "image.example.com", List.of()); - given(memberReader.getRef(userId)).willReturn(mockMember); given(groupWriter.create(req)).willReturn(mockGroup); + given(memberReader.getRef(userId)).willReturn(mockMember); given(groupMemberWriter.createLeader(mockMember, mockGroup)).willThrow(new GroupException(errorCode)); // when & then @@ -159,111 +186,104 @@ void createGroup_fail_whileGroupMemberCreating() { .isInstanceOf(GroupException.class) .extracting("errorCode") .isEqualTo(errorCode); + + // 리더 생성에서 터졌으면 이후는 호출되면 안 됨 + then(groupHashTagWorker).shouldHaveNoInteractions(); + then(chatRoomWriter).shouldHaveNoInteractions(); + then(mockGroup).should(never()).setChatRoom(any()); } @Test - void getGroups_success() { - // 1. Given: 그룹 요약 데이터 준비 + void getGroups_success_includingHashTags() { + // given Long userId = 1L; - int limit = 4; + int limit = GroupConst.GROUP_SUMMARY_MEMBER_COUNT.getValue(); + List groups = List.of( new GroupSummaryDto( - 101L, "CS 전공 지식 뿌시기", "취업준비", 10, "chat_cs001", true, false, LocalDate.of(2025, 11, 15)), - new GroupSummaryDto( - 205L, - "자바 스터디 (Spring Boot)", - "백엔드개발", - 20, - "chat_java05", - false, - true, - LocalDate.of(2025, 10, 20))); + 101L, "CS 전공 지식", "취업준비", 10, "chat_cs001", true, false, LocalDate.of(2025, 11, 15)), + new GroupSummaryDto(205L, "자바 스터디", "백엔드", 20, "chat_java05", false, true, LocalDate.of(2025, 10, 20))); List groupIds = List.of(101L, 205L); given(groupMemberReader.getGroups(userId)).willReturn(groups); - // 2. Given: 멤버 프로필 데이터 준비 (groupId가 포함된 DTO여야 함) + // 프로필(TopN) List profiles = List.of( new GroupMemberProfileMappingDto(101L, "https://img.com/1", GroupRole.LEADER), new GroupMemberProfileMappingDto(205L, "https://img.com/2", GroupRole.MEMBER)); - given(groupMemberReader.getTopNMemberProfileImages(groupIds, limit)).willReturn(profiles); - // 3. Given: 카테고리 데이터 준비 + // 카테고리 List categories = List.of(new GroupCategoryDto(101L, 1L), new GroupCategoryDto(101L, 2L), new GroupCategoryDto(205L, 3L)); given(studyCategoryReader.findByStudyTypeAndTypeId(StudyType.GROUP, groupIds)) .willReturn(categories); - // When + // ★ 변경 반영 포인트: 해시태그 Map + Map> hashTagsMap = Map.of( + 101L, List.of("cs", "interview"), + 205L, List.of("java", "spring")); + given(groupHashTagWorker.getHashTagsByGroups(groupIds)).willReturn(hashTagsMap); + + // when List result = groupService.getGroups(userId); - // Then + // then assertThat(result).hasSize(2); - // 첫 번째 그룹 검증 + // 101 assertThat(result.get(0).groupId()).isEqualTo(101L); assertThat(result.get(0).profiles()).hasSize(1); assertThat(result.get(0).profiles().get(0).role()).isEqualTo(GroupRole.LEADER); assertThat(result.get(0).categoryIds()).containsExactlyInAnyOrder(1L, 2L); + assertThat(result.get(0).hashTags()).containsExactlyInAnyOrder("cs", "interview"); - // 두 번째 그룹 검증 + // 205 assertThat(result.get(1).groupId()).isEqualTo(205L); assertThat(result.get(1).profiles()).hasSize(1); assertThat(result.get(1).profiles().get(0).role()).isEqualTo(GroupRole.MEMBER); assertThat(result.get(1).categoryIds()).containsExactly(3L); + assertThat(result.get(1).hashTags()).containsExactlyInAnyOrder("java", "spring"); + + then(groupHashTagWorker).should(times(1)).getHashTagsByGroups(groupIds); } @Test - void getGroupDetails_success() { + void getGroupDetails_success_includingHashTags_andValidatorCalled() { + // given Long userId = 1L; Long groupId = 1L; - List profiles = List.of( - new GroupMemberProfileDto(1L, "개발자A", "https://example.com/img/profile_a.png", GroupRole.LEADER), - new GroupMemberProfileDto(2L, "열공학생B", "https://example.com/img/profile_b.png", GroupRole.MEMBER), - new GroupMemberProfileDto(3L, "스터디봇C", "https://example.com/img/profile_c.png", GroupRole.MEMBER)); - - // 1. GroupCategoryGoalDto 목록 생성 - List categoryGoals = List.of( - new GroupCategoryGoalDto( - 501L, // categoryId (CS 공부) - 1000L, // categoryGoal (목표량) - "CS 공부", // categoryName - 75 // achievementPercent (75% 달성) - ), - new GroupCategoryGoalDto( - 502L, // categoryId (알고리즘) - 50L, // categoryGoal (목표량) - "알고리즘", // categoryName - 100 // achievementPercent (100% 달성) - ), - new GroupCategoryGoalDto( - 503L, // categoryId (면접 준비) - 200L, // categoryGoal (목표량) - "면접 준비", // categoryName - 40 // achievementPercent (40% 달성) - )); - - // 2. GroupTotalGoalDto 생성 (평균 71% 가정: (75 + 100 + 40) / 3 = 71.66... -> 71 (버림)) - GroupTotalGoalDto totalGoals = new GroupTotalGoalDto(categoryGoals, 71); + + // validator는 void + willDoNothing().given(validator).isMemberOfGroup(userId, groupId); given(groupReader.getById(groupId)).willReturn(mockGroup); + + List profiles = List.of( + new GroupMemberProfileDto(1L, "개발자A", "https://example.com/a.png", GroupRole.LEADER), + new GroupMemberProfileDto(2L, "열공학생B", "https://example.com/b.png", GroupRole.MEMBER)); given(groupMemberReader.getAllMemberProfiles(mockGroup)).willReturn(profiles); + + GroupTotalGoalDto totalGoals = + new GroupTotalGoalDto(List.of(new GroupCategoryGoalDto(501L, 1000L, "CS", 75)), 75); given(groupGoalCalculator.calculateGroupGoals(groupId, profiles)).willReturn(totalGoals); - // When - GetGroupDetailRes result = groupService.getGroupDetails(userId, groupId); + // ★ 변경 반영 포인트: 단건 해시태그 조회 + given(groupHashTagWorker.getHashTagsByGroup(groupId)).willReturn(List.of("java", "spring")); - // Then - assertThat(result.profiles().size()).isEqualTo(profiles.size()); + // GroupDetailRes.of 내부에서 group을 읽을 수 있으니, mockGroup의 필드 접근이 필요하면 stub 필요 + // 여기서는 profiles/goals/hashTags만 검증 - // GroupTotalGoalDto 객체의 userGoals 리스트를 검증합니다. - assertThat(result.groupGoals().categoryGoals().size()).isEqualTo(categoryGoals.size()); + // when + GetGroupDetailRes result = groupService.getGroupDetails(userId, groupId); - // 평균 달성률 확인 (선택 사항) - assertThat(result.groupGoals().overallAveragePercent()).isEqualTo(71); + // then + then(validator).should().isMemberOfGroup(userId, groupId); + then(groupReader).should().getById(groupId); + then(groupHashTagWorker).should().getHashTagsByGroup(groupId); - // 카테고리별 목표 중 첫 번째 항목의 categoryName이 올바른지 확인 - assertThat(result.groupGoals().categoryGoals().get(0).categoryName()).isEqualTo("CS 공부"); + assertThat(result.profiles()).hasSize(2); + assertThat(result.groupGoals().overallAveragePercent()).isEqualTo(75); + assertThat(result.hashTags()).containsExactlyInAnyOrder("java", "spring"); } } diff --git a/src/test/java/com/studypals/domain/groupManage/worker/GroupEntryRequestReaderTest.java b/src/test/java/com/studypals/domain/groupManage/worker/GroupEntryRequestReaderTest.java index 63df87f0..a2953a90 100644 --- a/src/test/java/com/studypals/domain/groupManage/worker/GroupEntryRequestReaderTest.java +++ b/src/test/java/com/studypals/domain/groupManage/worker/GroupEntryRequestReaderTest.java @@ -77,7 +77,7 @@ void getByGroup_success() { List requests = List.of(request1, request2, request3); Long groupId = 1L; - Cursor cursor = new Cursor(0, 10, DateSortType.NEW); + Cursor cursor = new Cursor(0L, "2025-03-02", 10, DateSortType.NEW); given(mockGroup.getId()).willReturn(groupId); given(entryRequestRepository.findAllByGroupIdWithPagination(groupId, cursor)) @@ -94,7 +94,7 @@ void getByGroup_success() { @Test void getByGroup_success_empty() { Long groupId = 1L; - Cursor cursor = new Cursor(0, 10, DateSortType.NEW); + Cursor cursor = new Cursor(0L, "2025-03-02", 10, DateSortType.NEW); given(mockGroup.getId()).willReturn(groupId); given(entryRequestRepository.findAllByGroupIdWithPagination(groupId, cursor)) diff --git a/src/test/java/com/studypals/global/exceptions/exceptionHandler/GlobalExceptionHandlerTest.java b/src/test/java/com/studypals/global/exceptions/exceptionHandler/GlobalExceptionHandlerTest.java index cf0b510b..36d02127 100644 --- a/src/test/java/com/studypals/global/exceptions/exceptionHandler/GlobalExceptionHandlerTest.java +++ b/src/test/java/com/studypals/global/exceptions/exceptionHandler/GlobalExceptionHandlerTest.java @@ -14,6 +14,7 @@ import org.springframework.test.context.TestPropertySource; import org.springframework.test.web.servlet.MockMvc; +import com.studypals.global.resolver.SortTypeResolver; import com.studypals.testModules.testComponent.TestController; import com.studypals.testModules.testComponent.TestErrorCode; @@ -25,7 +26,7 @@ * @since 2025-04-01 */ @WebMvcTest(TestController.class) -@Import(GlobalExceptionHandler.class) +@Import({GlobalExceptionHandler.class, SortTypeResolver.class}) @TestPropertySource(properties = "debug.message.print=false") @AutoConfigureMockMvc(addFilters = false) class GlobalExceptionHandlerTest { diff --git a/src/test/java/com/studypals/testModules/testComponent/TestSupportConfig.java b/src/test/java/com/studypals/testModules/testComponent/TestSupportConfig.java new file mode 100644 index 00000000..d2f91e2a --- /dev/null +++ b/src/test/java/com/studypals/testModules/testComponent/TestSupportConfig.java @@ -0,0 +1,31 @@ +package com.studypals.testModules.testComponent; + +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; + +import com.studypals.global.resolver.SortTypeResolver; +import com.studypals.global.utils.StringUtils; + +/** + * 일부 slice/jpa 테스트 등에서 누락되는 유틸 클래스를 관리하는 곳 입니다. + * + * @author jack8 + * @since 2026-01-16 + */ +@TestConfiguration +public class TestSupportConfig { + + @Bean + public StringUtils stringUtils() { + return new StringUtils(); + } + + @Bean + public SortTypeResolver sortTypeResolver() { + return new SortTypeResolver(); + } + + // public CursorDefaultResolver cursorDefaultResolver(SortTypeResolver sortTypeResolver) { + // return new CursorDefaultResolver(sortTypeResolver); + // } +} diff --git a/src/test/java/com/studypals/testModules/testSupport/DataJpaSupport.java b/src/test/java/com/studypals/testModules/testSupport/DataJpaSupport.java index 85898b9e..7f448a1a 100644 --- a/src/test/java/com/studypals/testModules/testSupport/DataJpaSupport.java +++ b/src/test/java/com/studypals/testModules/testSupport/DataJpaSupport.java @@ -8,9 +8,10 @@ import com.studypals.domain.memberManage.entity.Member; import com.studypals.global.config.QueryDslTestConfig; +import com.studypals.testModules.testComponent.TestSupportConfig; @DataJpaTest -@Import(QueryDslTestConfig.class) +@Import({QueryDslTestConfig.class, TestSupportConfig.class}) @AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) public abstract class DataJpaSupport extends TestEnvironment { @Autowired diff --git a/src/test/java/com/studypals/testModules/testSupport/RestDocsSupport.java b/src/test/java/com/studypals/testModules/testSupport/RestDocsSupport.java index f1f82ea5..619d078e 100644 --- a/src/test/java/com/studypals/testModules/testSupport/RestDocsSupport.java +++ b/src/test/java/com/studypals/testModules/testSupport/RestDocsSupport.java @@ -19,6 +19,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.studypals.testModules.restDocs.RestDocsConfig; +import com.studypals.testModules.testComponent.TestSupportConfig; /** * rest docs 를 사용한 통합테스트 시, 해당 클래스를 extend 하여야 합니다. 기본적인 설정 및 필수 bean을 autowired 한 상태입니다. @@ -40,7 +41,7 @@ * @since 2025-04-06 */ @DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) -@Import({RestDocsConfig.class}) +@Import({RestDocsConfig.class, TestSupportConfig.class}) @ExtendWith(RestDocumentationExtension.class) public abstract class RestDocsSupport { From a726ededbd87a2dd71a0a9a9258c5eba21995b31 Mon Sep 17 00:00:00 2001 From: unikal1 Date: Tue, 20 Jan 2026 15:32:13 +0900 Subject: [PATCH 08/14] =?UTF-8?q?Docs:=20hashTag=20=EA=B4=80=EB=A0=A8=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20=EB=B0=8F,=20=EA=B2=80=EC=83=89=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EB=AC=B8=EC=84=9C=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ref: #164 --- src/asciidoc/api/group.adoc | 109 +++++++++++++----- .../GroupCustomRepositoryImpl.java | 12 +- .../global/dao/AbstractPagingRepository.java | 2 +- .../GroupCustomRepositoryTest.java | 1 - .../GroupControllerRestDocsTest.java | 98 ++++++++++++++++ 5 files changed, 181 insertions(+), 41 deletions(-) diff --git a/src/asciidoc/api/group.adoc b/src/asciidoc/api/group.adoc index 5512931a..706fb23b 100644 --- a/src/asciidoc/api/group.adoc +++ b/src/asciidoc/api/group.adoc @@ -83,31 +83,9 @@ include::{snippets}/group-controller-rest-docs-test/create-group_success/http-re include::{snippets}/group-controller-rest-docs-test/create-group_success/response-headers.adoc[] include::{snippets}/group-controller-rest-docs-test/create-group_success/http-response.adoc[] -=== 그룹 가입 관련 API - -==== 1. 초대 코드 생성 - -*Description* + - -''' - -그룹 초대 코드 생성을 위해 사용합니다. - -*Request* + - -''' - -include::{snippets}/group-entry-controller-rest-docs-test/generate-entry-code_success/http-request.adoc[] - -*Response* + - -''' +=== 그룹 조회 API -include::{snippets}/group-entry-controller-rest-docs-test/generate-entry-code_success/response-headers.adoc[] -include::{snippets}/group-entry-controller-rest-docs-test/generate-entry-code_success/response-fields.adoc[] -include::{snippets}/group-entry-controller-rest-docs-test/generate-entry-code_success/http-response.adoc[] - -==== 2. 유저가 속한 그룹 조회 +==== 1. 유저가 속한 그룹 조회 *Description* + @@ -128,7 +106,7 @@ include::{snippets}/group-controller-rest-docs-test/get-groups_success/http-requ include::{snippets}/group-controller-rest-docs-test/get-groups_success/http-response.adoc[] include::{snippets}/group-controller-rest-docs-test/get-groups_success/response-fields.adoc[] -==== 3. 그룹 상세 조회 +==== 2. 그룹 상세 조회 *Description* + @@ -148,7 +126,7 @@ include::{snippets}/group-controller-rest-docs-test/get-group-detail_success/htt include::{snippets}/group-controller-rest-docs-test/get-group-detail_success/http-response.adoc[] include::{snippets}/group-controller-rest-docs-test/get-group-detail_success/response-fields.adoc[] -==== 4. 그룹 대표 정보 조회 +==== 3. 그룹 대표 정보 조회 *Description* + @@ -173,7 +151,76 @@ include::{snippets}/group-entry-controller-rest-docs-test/get-group-summary_succ include::{snippets}/group-entry-controller-rest-docs-test/get-group-summary_success/http-response.adoc[] include::{snippets}/group-entry-controller-rest-docs-test/get-group-summary_success/response-fields.adoc[] -==== 5. 그룹 가입 +==== 4. 그룹 검색 + +*Description* + + +''' + +각 그룹을 검색합니다. tag, hashTag, name 에 기반하여 검색을 수행할 수 있습니다. 검색 시 기타 필터링 및 정렬 기준을 +선택할 수 있습니다. + +[NOTE] +approval 의 경우 true(혹은 기입 X)로 둔다면 (true/false) 가 전부 검색됩니다. false 로 두게 되면 (false)만 검색됩니다. +즉, true 인 경우 모든 경우, false 인 경우 가입 요청 없이 바로 가입 가능한 그룹만 검색됩니다. + +[NOTE] +open 의 경우 true(혹은 기입 X)로 둔다면 (true) 만 검색됩니다. false 로 두게 된다면 (true/false) 가 전부 검색됩니다. +즉, true 인 경우 당장 가입이 가능한 그룹, false 인 경우 가입 가능한 그룹 및 불가능한 그룹 전부 검색됩니다. + +[NOTE] +tag/name/hashTag 중 하나로만 검색이 가능합니다. + +[NOTE] +request param 의 cursor 데이터는 이전 응답의 "data.next" 필드로 주어집니다. (이전 데이터의 가장 마지막 ID) + +[NOTE] +request param 의 value 데이터는 이전 응답의 "data.next" 필드에서 주어진 식별자를 보고, 해당 데이터의 정렬 기준 값을 +문자열로 주어야 합니다. (주어진 resposne 의 경우 "next" 가 205 이므로 groupId = 205 인 필드에 대해 정렬 기준 (POPULAR) 과 +대응되는 memberCount 값을 줘야 함) + + + +*Request* + + +''' + +include::{snippets}/group-controller-rest-docs-test/search-groups_success/http-request.adoc[] +include::{snippets}/group-controller-rest-docs-test/search-groups_success/query-parameters.adoc[] + +*Response* + + +''' + +include::{snippets}/group-controller-rest-docs-test/search-groups_success/http-response.adoc[] +include::{snippets}/group-controller-rest-docs-test/search-groups_success/response-fields.adoc[] + +=== 그룹 가입 관련 API + +==== 1. 초대 코드 생성 + +*Description* + + +''' + +그룹 초대 코드 생성을 위해 사용합니다. + +*Request* + + +''' + +include::{snippets}/group-entry-controller-rest-docs-test/generate-entry-code_success/http-request.adoc[] + +*Response* + + +''' + +include::{snippets}/group-entry-controller-rest-docs-test/generate-entry-code_success/response-headers.adoc[] +include::{snippets}/group-entry-controller-rest-docs-test/generate-entry-code_success/response-fields.adoc[] +include::{snippets}/group-entry-controller-rest-docs-test/generate-entry-code_success/http-response.adoc[] + + +==== 2. 그룹 가입 *Description* + @@ -196,7 +243,7 @@ include::{snippets}/group-entry-controller-rest-docs-test/join-group_success/req include::{snippets}/group-entry-controller-rest-docs-test/join-group_success/http-response.adoc[] include::{snippets}/group-entry-controller-rest-docs-test/join-group_success/response-headers.adoc[] -==== 6. 그룹 가입 요청 +==== 3. 그룹 가입 요청 *Description* + @@ -219,7 +266,7 @@ include::{snippets}/group-entry-controller-rest-docs-test/request-participant_su include::{snippets}/group-entry-controller-rest-docs-test/request-participant_success/http-response.adoc[] include::{snippets}/group-entry-controller-rest-docs-test/request-participant_success/response-headers.adoc[] -==== 7. 그룹 가입 요청 조회 +==== 4. 그룹 가입 요청 조회 *Description* + @@ -242,7 +289,7 @@ include::{snippets}/group-entry-controller-rest-docs-test/get-entry-requests_suc include::{snippets}/group-entry-controller-rest-docs-test/get-entry-requests_success/http-response.adoc[] include::{snippets}/group-entry-controller-rest-docs-test/get-entry-requests_success/response-fields.adoc[] -==== 8. 그룹 가입 요청 승인 +==== 5. 그룹 가입 요청 승인 *Description* @@ -263,7 +310,7 @@ include::{snippets}/group-entry-controller-rest-docs-test/accept-entry-request_s include::{snippets}/group-entry-controller-rest-docs-test/accept-entry-request_success/http-response.adoc[] include::{snippets}/group-entry-controller-rest-docs-test/accept-entry-request_success/response-headers.adoc[] -==== 9. 그룹 가입 요청 거절 +==== 6. 그룹 가입 요청 거절 *Description* diff --git a/src/main/java/com/studypals/domain/groupManage/dao/groupRepository/GroupCustomRepositoryImpl.java b/src/main/java/com/studypals/domain/groupManage/dao/groupRepository/GroupCustomRepositoryImpl.java index 30c1326b..16a4cddf 100644 --- a/src/main/java/com/studypals/domain/groupManage/dao/groupRepository/GroupCustomRepositoryImpl.java +++ b/src/main/java/com/studypals/domain/groupManage/dao/groupRepository/GroupCustomRepositoryImpl.java @@ -63,7 +63,6 @@ public class GroupCustomRepositoryImpl extends AbstractPagingRepository i private final JPAQueryFactory queryFactory; private final StringUtils stringUtils; - /** * 검색 조건과 커서 정보를 기반으로 그룹 목록을 조회합니다. * @@ -107,10 +106,10 @@ public Slice search(GroupSearchDto dto, Cursor cursor) { List results = queryFactory .selectFrom(group) .where( - assembleWhere(dto), // 키워드 검색 조건 (tag/name/hashTag 중 1개) + assembleWhere(dto), // 키워드 검색 조건 (tag/name/hashTag 중 1개) assembleCursor(cursor), // 커서 조건 (정렬 기준 + id tie-break) - assembleType(dto) // 공개/정원/승인 필터 - ) + assembleType(dto) // 공개/정원/승인 필터 + ) .orderBy(orders) .limit(cursor.size() + 1) .fetch(); @@ -123,7 +122,6 @@ public Slice search(GroupSearchDto dto, Cursor cursor) { return new SliceImpl<>(results, PageRequest.of(0, cursor.size()), hasNext); } - /** * 키워드 기반 검색 조건을 조립합니다. * @@ -145,7 +143,7 @@ public Slice search(GroupSearchDto dto, Cursor cursor) { * {@code WHERE exists (SELECT 1 FROM GroupHashTag ght * WHERE ght.group_id = g.id * AND ght.hashTag.tag like %:norm% )} - * + * * * * @@ -284,7 +282,6 @@ private BooleanExpression assembleCursor(Cursor cursor) { throw new IllegalArgumentException("cursor sort not matched"); } - /** * 해시태그 기준으로 그룹을 필터링하기 위한 exists 서브쿼리 조건을 생성합니다. * @@ -318,7 +315,6 @@ private BooleanExpression getHashTagByGroup(String rawHashTag) { .exists(); } - /** * 정렬 조건(OrderSpecifier)을 생성합니다. * diff --git a/src/main/java/com/studypals/global/dao/AbstractPagingRepository.java b/src/main/java/com/studypals/global/dao/AbstractPagingRepository.java index 2634e5ab..93608d4d 100644 --- a/src/main/java/com/studypals/global/dao/AbstractPagingRepository.java +++ b/src/main/java/com/studypals/global/dao/AbstractPagingRepository.java @@ -37,7 +37,7 @@ protected OrderSpecifier getOrderSpecifier(EntityPath root, SortType s EntityMetadata metadata = getEntityMetadata(root.getType()); Class sortFieldType = metadata.getFieldType(field); - //alias를 문자열로 만들지 말고 root의 metadata를 그대로 사용 + // alias를 문자열로 만들지 말고 root의 metadata를 그대로 사용 PathMetadata rootMetadata = root.getMetadata(); PathBuilder path = new PathBuilder<>((Class) root.getType(), rootMetadata); diff --git a/src/test/java/com/studypals/domain/groupManage/dao/groupRepository/GroupCustomRepositoryTest.java b/src/test/java/com/studypals/domain/groupManage/dao/groupRepository/GroupCustomRepositoryTest.java index e1bcf58f..44dc3968 100644 --- a/src/test/java/com/studypals/domain/groupManage/dao/groupRepository/GroupCustomRepositoryTest.java +++ b/src/test/java/com/studypals/domain/groupManage/dao/groupRepository/GroupCustomRepositoryTest.java @@ -1,7 +1,6 @@ package com.studypals.domain.groupManage.dao.groupRepository; import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.*; import java.time.LocalDate; import java.util.*; diff --git a/src/test/java/com/studypals/domain/groupManage/restDocsTest/GroupControllerRestDocsTest.java b/src/test/java/com/studypals/domain/groupManage/restDocsTest/GroupControllerRestDocsTest.java index 04659de3..80a58235 100644 --- a/src/test/java/com/studypals/domain/groupManage/restDocsTest/GroupControllerRestDocsTest.java +++ b/src/test/java/com/studypals/domain/groupManage/restDocsTest/GroupControllerRestDocsTest.java @@ -11,6 +11,8 @@ import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.get; import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.post; import static org.springframework.restdocs.payload.PayloadDocumentation.*; +import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; +import static org.springframework.restdocs.request.RequestDocumentation.queryParameters; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -28,7 +30,9 @@ import com.studypals.domain.groupManage.dto.*; import com.studypals.domain.groupManage.entity.GroupRole; import com.studypals.domain.groupManage.service.GroupService; +import com.studypals.global.request.Cursor; import com.studypals.global.responses.CommonResponse; +import com.studypals.global.responses.CursorResponse; import com.studypals.global.responses.Response; import com.studypals.global.responses.ResponseCode; import com.studypals.testModules.testSupport.RestDocsSupport; @@ -257,4 +261,98 @@ void getGroupDetail_success() throws Exception { fieldWithPath("data.groupGoals.categoryGoals[].achievementPercent") .description("그룹 목표 대비 카테고리 달성률 (%)")))); } + + @Test + @WithMockUser + void searchGroups_success() throws Exception { + + // given + List content = List.of( + new GetGroupsRes( + 101L, + "알고리즘 코딩 마스터", + "취업준비", + List.of("알고리즘", "백준"), + 10, + "chat_algo_01", + true, + false, + LocalDate.of(2025, 12, 1), + List.of(new GroupMemberProfileImageDto("https://exam.com/user1.png", GroupRole.LEADER)), + List.of(1L, 2L)), + new GetGroupsRes( + 205L, + "자바 스터디", + "백엔드", + List.of("java", "spring"), + 20, + "chat_java_01", + true, + false, + LocalDate.of(2025, 11, 20), + List.of(new GroupMemberProfileImageDto("https://exam.com/user2.png", GroupRole.MEMBER)), + List.of(3L))); + + CursorResponse.Content cursorContent = new CursorResponse.Content<>(content, 205L, true); + + CursorResponse response = CursorResponse.success(ResponseCode.GROUP_SEARCH, cursorContent); + + given(groupService.search(any(GroupSearchDto.class), any(Cursor.class))).willReturn(cursorContent); + + // when + ResultActions result = mockMvc.perform(get("/groups/search") + .param("tag", "취업") + .param("cursor", "0") + .param("size", "5") + .param("sort", "POPULAR") + .contentType(MediaType.APPLICATION_JSON)); + + // then + result.andExpect(status().isOk()) + .andExpect(hasKey(response)) + .andDo(restDocs.document( + httpRequest(), + httpResponse(), + + /* ===== Query Parameters ===== */ + queryParameters( + parameterWithName("tag").optional().description("그룹 태그 검색 (tag/hashTag/name 중 하나만 허용)"), + parameterWithName("hashTag").optional().description("해시태그 검색"), + parameterWithName("name").optional().description("그룹 이름 검색"), + parameterWithName("open").optional().description("공개 그룹 여부 (default: true)"), + parameterWithName("approval").optional().description("승인 필요 여부 (default: true)"), + parameterWithName("cursor").optional().description("커서 기준 ID (첫 페이지는 0, default: 0)"), + parameterWithName("value") + .optional() + .description("커서 기준 tie point (첫 페이지는 null, default: \"\")"), + parameterWithName("size").description("페이지 크기"), + parameterWithName("sort").description("정렬 기준 (POPULAR | NEW | OLD)")), + + /* ===== Response Fields ===== */ + responseFields( + fieldWithPath("code").description("응답 코드"), + fieldWithPath("status").description("응답 상태"), + fieldWithPath("message").description("응답 메시지"), + fieldWithPath("data").description("커서 기반 페이징 응답"), + fieldWithPath("data.content").description("조회된 그룹 목록"), + fieldWithPath("data.next").description("다음 페이지 커서 값"), + fieldWithPath("data.hasNext").description("다음 페이지 존재 여부"), + + // content[].* + fieldWithPath("data.content[].groupId").description("그룹 ID"), + fieldWithPath("data.content[].name").description("그룹 이름"), + fieldWithPath("data.content[].tag").description("그룹 태그"), + fieldWithPath("data.content[].hashTags").description("그룹 해시태그 목록"), + fieldWithPath("data.content[].memberCount").description("그룹 인원 수"), + fieldWithPath("data.content[].chatRoomId").description("채팅방 ID"), + fieldWithPath("data.content[].isOpen").description("공개 여부"), + fieldWithPath("data.content[].isApprovalRequired") + .description("승인 필요 여부"), + fieldWithPath("data.content[].createdDate").description("그룹 생성일"), + fieldWithPath("data.content[].profiles").description("상위 멤버 프로필 목록"), + fieldWithPath("data.content[].profiles[].imageUrl") + .description("프로필 이미지 URL"), + fieldWithPath("data.content[].profiles[].role").description("그룹 내 역할"), + fieldWithPath("data.content[].categoryIds").description("그룹 카테고리 ID 목록")))); + } } From afcede60db9ce4ede9bcf3a0392a8a64660cd656 Mon Sep 17 00:00:00 2001 From: unikal1 Date: Tue, 20 Jan 2026 16:03:23 +0900 Subject: [PATCH 09/14] =?UTF-8?q?Docs:=20=EC=9D=BC=EB=B6=80=20=EC=A3=BC?= =?UTF-8?q?=EC=84=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ref: #164 --- .../studypals/domain/chatManage/worker/ChatImageManager.java | 2 +- .../groupMemberRepository/GroupMemberCustomRepository.java | 5 +++-- .../domain/groupManage/service/GroupRankingService.java | 2 +- .../domain/memberManage/worker/MemberProfileManager.java | 2 +- .../domain/studyManage/service/StudySessionServiceImpl.java | 2 +- .../domain/groupManage/worker/GroupGoalCalculatorTest.java | 2 +- 6 files changed, 8 insertions(+), 7 deletions(-) diff --git a/src/main/java/com/studypals/domain/chatManage/worker/ChatImageManager.java b/src/main/java/com/studypals/domain/chatManage/worker/ChatImageManager.java index f30640c0..787b0b00 100644 --- a/src/main/java/com/studypals/domain/chatManage/worker/ChatImageManager.java +++ b/src/main/java/com/studypals/domain/chatManage/worker/ChatImageManager.java @@ -21,7 +21,7 @@ * {@link AbstractImageManager} * * @author sleepyhoon - * @See AbstractImageManager + * @see AbstractImageManager * @since 2026-01-13 */ @Component diff --git a/src/main/java/com/studypals/domain/groupManage/dao/groupMemberRepository/GroupMemberCustomRepository.java b/src/main/java/com/studypals/domain/groupManage/dao/groupMemberRepository/GroupMemberCustomRepository.java index b94cba36..39aeffbc 100644 --- a/src/main/java/com/studypals/domain/groupManage/dao/groupMemberRepository/GroupMemberCustomRepository.java +++ b/src/main/java/com/studypals/domain/groupManage/dao/groupMemberRepository/GroupMemberCustomRepository.java @@ -33,8 +33,9 @@ public interface GroupMemberCustomRepository { /** * 여러 그룹에 속한 모든 멤버의 정보(id,nickname,imageUrl,role)를 한번에 조회합니다. - * @param groupIds - * @return + * @param groupIds 검색할 groupId 리스트 + * @param limit 가져올 사용자의 개수 제한 + * @return 입력된 groupIds 에 대해, 각 그룹당 소속된 상위 limit 명을 포함한 데이터. */ List findTopNMemberInGroupIds(List groupIds, int limit); } diff --git a/src/main/java/com/studypals/domain/groupManage/service/GroupRankingService.java b/src/main/java/com/studypals/domain/groupManage/service/GroupRankingService.java index 476ac95d..fb698ad6 100644 --- a/src/main/java/com/studypals/domain/groupManage/service/GroupRankingService.java +++ b/src/main/java/com/studypals/domain/groupManage/service/GroupRankingService.java @@ -24,7 +24,7 @@ public interface GroupRankingService { * @param userId 조회를 시도하는 사용자 ID * @param groupId 랭킹을 조회하려고 하는 그룹 ID * @param period 조회하고 싶은 랭킹 종류 (daily/weekly/monthly) - * @return + * @return 그룹에 속한 사용자 및 공부 시간에 대한 데이터(정렬되지 않음) */ List getGroupRanking(Long userId, Long groupId, GroupRankingPeriod period); } diff --git a/src/main/java/com/studypals/domain/memberManage/worker/MemberProfileManager.java b/src/main/java/com/studypals/domain/memberManage/worker/MemberProfileManager.java index d0cad9e8..046d1671 100644 --- a/src/main/java/com/studypals/domain/memberManage/worker/MemberProfileManager.java +++ b/src/main/java/com/studypals/domain/memberManage/worker/MemberProfileManager.java @@ -19,7 +19,7 @@ * {@link AbstractImageManager} * * @author sleepyhoon - * @See AbstractImageManager + * @see AbstractImageManager * @since 2026-01-13 */ @Component diff --git a/src/main/java/com/studypals/domain/studyManage/service/StudySessionServiceImpl.java b/src/main/java/com/studypals/domain/studyManage/service/StudySessionServiceImpl.java index cd21ff7f..a4e7d903 100644 --- a/src/main/java/com/studypals/domain/studyManage/service/StudySessionServiceImpl.java +++ b/src/main/java/com/studypals/domain/studyManage/service/StudySessionServiceImpl.java @@ -156,7 +156,7 @@ public void afterCommit() { /** * 사용자의 공부 상태를 반환합니다. * 공부하고 있다면 해당 공부에 대한 정보를 반환하고, 아니면 단순 false 값만 담아 반환합니다. - * @param userId + * @param userId 검색할 사용자 아이디 * @return StudyStatusRes */ @Override diff --git a/src/test/java/com/studypals/domain/groupManage/worker/GroupGoalCalculatorTest.java b/src/test/java/com/studypals/domain/groupManage/worker/GroupGoalCalculatorTest.java index 4433f79f..9f0d1154 100644 --- a/src/test/java/com/studypals/domain/groupManage/worker/GroupGoalCalculatorTest.java +++ b/src/test/java/com/studypals/domain/groupManage/worker/GroupGoalCalculatorTest.java @@ -90,7 +90,7 @@ void setUp() { @Test @DisplayName("성공 케이스: 모든 StudyTime을 가져와 정확한 달성률을 계산하고 반환한다") - void calculateGroupGoals_ShouldReturnAccuratePercentages() throws Exception { + void calculateGroupGoals_ShouldReturnAccuratePercentages() { // Given // 1L, 2L ID를 가진 실제 멤버 객체 생성 (memberIds [1,2,3,4]와 매칭됨) Member m1 = createMemberEntity(1L, "개발자A", "img_a"); From 39ff738bab73c4eeaf16ef25fa73385f63362528 Mon Sep 17 00:00:00 2001 From: unikal1 Date: Tue, 20 Jan 2026 16:11:25 +0900 Subject: [PATCH 10/14] =?UTF-8?q?Test:=20merge=20=ED=9B=84=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ref: #164 --- .../domain/groupManage/service/GroupServiceTest.java | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/test/java/com/studypals/domain/groupManage/service/GroupServiceTest.java b/src/test/java/com/studypals/domain/groupManage/service/GroupServiceTest.java index d6912a6b..c5a45460 100644 --- a/src/test/java/com/studypals/domain/groupManage/service/GroupServiceTest.java +++ b/src/test/java/com/studypals/domain/groupManage/service/GroupServiceTest.java @@ -263,10 +263,6 @@ void getGroupDetails_success_includingHashTags_andValidatorCalled() { given(groupMemberReader.getAllMemberProfiles(groupId)).willReturn(groupMembers); - GroupTotalGoalDto totalGoals = - new GroupTotalGoalDto(List.of(new GroupCategoryGoalDto(501L, 1000L, "CS", 75)), 75); - given(groupGoalCalculator.calculateGroupGoals(groupId, groupMembers)).willReturn(totalGoals); - // 1. GroupCategoryGoalDto 목록 생성 List categoryGoals = List.of( new GroupCategoryGoalDto( @@ -288,6 +284,9 @@ void getGroupDetails_success_includingHashTags_andValidatorCalled() { 40 // achievementPercent (40% 달성) )); + GroupTotalGoalDto totalGoals = new GroupTotalGoalDto(categoryGoals, 75); + given(groupGoalCalculator.calculateGroupGoals(groupId, groupMembers)).willReturn(totalGoals); + // 2. GroupTotalGoalDto 생성 (평균 71% 가정: (75 + 100 + 40) / 3 = 71.66... -> 71 (버림)) given(groupReader.getById(groupId)).willReturn(mockGroup); given(mockGroup.getId()).willReturn(groupId); @@ -313,7 +312,7 @@ void getGroupDetails_success_includingHashTags_andValidatorCalled() { // GroupTotalGoalDto 객체의 userGoals 리스트를 검증합니다. assertThat(result.groupGoals().categoryGoals().size()).isEqualTo(categoryGoals.size()); - assertThat(result.profiles()).hasSize(2); + assertThat(result.profiles()).hasSize(4); assertThat(result.groupGoals().overallAveragePercent()).isEqualTo(75); assertThat(result.hashTags()).containsExactlyInAnyOrder("java", "spring"); } From db20f3d16eabb58fba159389d2610687a3e8207f Mon Sep 17 00:00:00 2001 From: unikal1 Date: Fri, 23 Jan 2026 10:42:17 +0900 Subject: [PATCH 11/14] =?UTF-8?q?Feat:=20approval=20=EB=B0=8F=20open=20?= =?UTF-8?q?=EA=B2=80=EC=83=89=20=EC=8B=9C=20null=20=EC=A1=B0=EA=B1=B4=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 기존 로직에서 null 인 경우 필터맅 X로 변경 Ref: #164 --- .../groupManage/api/GroupController.java | 4 +-- .../GroupCustomRepositoryImpl.java | 35 ++++++++++++------- 2 files changed, 25 insertions(+), 14 deletions(-) diff --git a/src/main/java/com/studypals/domain/groupManage/api/GroupController.java b/src/main/java/com/studypals/domain/groupManage/api/GroupController.java index 98438294..14fbf6d4 100644 --- a/src/main/java/com/studypals/domain/groupManage/api/GroupController.java +++ b/src/main/java/com/studypals/domain/groupManage/api/GroupController.java @@ -80,8 +80,8 @@ public ResponseEntity> searchGroups( .hashTag(hashTag) .tag(tag) .name(name) - .isOpen(open == null || open) - .isApprovalRequired(approval == null || approval) + .isOpen(open) + .isApprovalRequired(approval) .build(); CursorResponse.Content response = groupService.search(dto, cursor); diff --git a/src/main/java/com/studypals/domain/groupManage/dao/groupRepository/GroupCustomRepositoryImpl.java b/src/main/java/com/studypals/domain/groupManage/dao/groupRepository/GroupCustomRepositoryImpl.java index 16a4cddf..90f9e165 100644 --- a/src/main/java/com/studypals/domain/groupManage/dao/groupRepository/GroupCustomRepositoryImpl.java +++ b/src/main/java/com/studypals/domain/groupManage/dao/groupRepository/GroupCustomRepositoryImpl.java @@ -176,9 +176,10 @@ private BooleanExpression assembleWhere(GroupSearchDto dto) { * 이 메서드는 "검색 키워드"가 아닌 "그룹의 상태"를 필터링합니다. * 필요성: *
    - *
  • 공개 그룹만 보여주기({@code isOpen = true})
  • - *
  • 정원 미달 그룹만 노출({@code totalMember < maxMember})
  • - *
  • 승인 필요 여부로 필터({@code isApprovalRequired = true})
  • + *
  • open = null: 전체
  • + *
  • open = true: 공개 + 정원 미달
  • + *
  • open = false: 비공개 또는 정원 꽉 참
  • + *
  • approval = true/false: 승인 필요/불필요 필터
  • *
*

* @@ -188,8 +189,7 @@ private BooleanExpression assembleWhere(GroupSearchDto dto) { *
      * {@code
      * WHERE
-     *   g.isOpen = true
-     *   AND g.totalMember < g.maxMember
+     *   (g.isOpen = true AND g.totalMember < g.maxMember)
      *   AND g.isApprovalRequired = true
      * }
      * 
@@ -199,15 +199,26 @@ private BooleanExpression assembleWhere(GroupSearchDto dto) { private BooleanExpression assembleType(GroupSearchDto dto) { BooleanExpression cond = null; - if (dto.isOpen() != null && dto.isOpen()) { - // 공개 그룹 + 정원 미달 필터 - cond = and(cond, group.isOpen.isTrue()); - cond = and(cond, group.totalMember.lt(group.maxMember)); + if (dto.isOpen() != null) { + if (dto.isOpen()) { + // 공개 그룹 + 정원 미달 필터 + cond = and(cond, group.isOpen.isTrue()); + cond = and(cond, group.totalMember.lt(group.maxMember)); + } else { + // 비공개 그룹 필터 + cond = and(cond, group.isOpen.isFalse()); + cond = and(cond, group.isOpen.isFalse().or(group.totalMember.goe(group.maxMember))); + } } - if (dto.isApprovalRequired() != null && dto.isApprovalRequired()) { - // 승인 필요 필터 - cond = and(cond, group.isApprovalRequired.isTrue()); + if (dto.isApprovalRequired() != null) { + if (dto.isApprovalRequired()) { + // 승인 필요 필터 + cond = and(cond, group.isApprovalRequired.isTrue()); + } else { + // 승인 불필요 필터 + cond = and(cond, group.isApprovalRequired.isFalse()); + } } return cond; From 95c325586c8842cb8fd9179d8bbdc3b41cacda32 Mon Sep 17 00:00:00 2001 From: unikal1 Date: Fri, 23 Jan 2026 10:46:01 +0900 Subject: [PATCH 12/14] =?UTF-8?q?Docs:=20=EB=B3=80=EA=B2=BD=EB=90=9C=20?= =?UTF-8?q?=ED=8C=8C=EB=9D=BC=EB=AF=B8=ED=84=B0=20=EC=A1=B0=EA=B1=B4?= =?UTF-8?q?=EC=97=90=20=EB=8C=80=ED=95=B4=20=EB=AC=B8=EC=84=9C=20=EA=B0=B1?= =?UTF-8?q?=EC=8B=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ref: #164 --- src/asciidoc/api/group.adoc | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/src/asciidoc/api/group.adoc b/src/asciidoc/api/group.adoc index 0efd9dd9..f6732529 100644 --- a/src/asciidoc/api/group.adoc +++ b/src/asciidoc/api/group.adoc @@ -161,13 +161,16 @@ include::{snippets}/group-entry-controller-rest-docs-test/get-group-summary_succ 각 그룹을 검색합니다. tag, hashTag, name 에 기반하여 검색을 수행할 수 있습니다. 검색 시 기타 필터링 및 정렬 기준을 선택할 수 있습니다. -[NOTE] -approval 의 경우 true(혹은 기입 X)로 둔다면 (true/false) 가 전부 검색됩니다. false 로 두게 되면 (false)만 검색됩니다. -즉, true 인 경우 모든 경우, false 인 경우 가입 요청 없이 바로 가입 가능한 그룹만 검색됩니다. -[NOTE] -open 의 경우 true(혹은 기입 X)로 둔다면 (true) 만 검색됩니다. false 로 두게 된다면 (true/false) 가 전부 검색됩니다. -즉, true 인 경우 당장 가입이 가능한 그룹, false 인 경우 가입 가능한 그룹 및 불가능한 그룹 전부 검색됩니다. +approval:: +* `null` -> 전체 +* `true` -> 승인 필요만 +* `false` -> 승인 불필요만 + +open:: +* `null` -> 전체 +* `true` -> 공개 + 정원 미달 (`isOpen = true` AND `totalMember < maxMember`) +* `false` -> 비공개 또는 정원 꽉 참 (`isOpen = false` OR `totalMember >= maxMember`) [NOTE] tag/name/hashTag 중 하나로만 검색이 가능합니다. From 7707d81fa94e3142bbb9fcbd5af7749a6d7acba4 Mon Sep 17 00:00:00 2001 From: unikal1 Date: Fri, 23 Jan 2026 11:12:39 +0900 Subject: [PATCH 13/14] =?UTF-8?q?Refactor=20:=20=EA=B2=80=EC=83=89=20?= =?UTF-8?q?=EC=8B=9C=20sortType=20=ED=8C=8C=EC=8B=B1=20=EA=B2=80=EC=A6=9D?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GroupSortType 에 함수형 인터페이스를 통해 parser 메서드를 추가하고 worker 에서 이를 통해 파싱 가능 유무 검증 Ref: #164 --- .../dao/groupRepository/GroupSortType.java | 13 +++++++++---- .../domain/groupManage/worker/GroupReader.java | 10 ++++++++++ 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/studypals/domain/groupManage/dao/groupRepository/GroupSortType.java b/src/main/java/com/studypals/domain/groupManage/dao/groupRepository/GroupSortType.java index 21417a3c..c1be34aa 100644 --- a/src/main/java/com/studypals/domain/groupManage/dao/groupRepository/GroupSortType.java +++ b/src/main/java/com/studypals/domain/groupManage/dao/groupRepository/GroupSortType.java @@ -1,5 +1,8 @@ package com.studypals.domain.groupManage.dao.groupRepository; +import java.time.LocalDate; +import java.util.function.Function; + import org.springframework.data.domain.Sort; import lombok.Getter; @@ -57,15 +60,17 @@ */ @Getter public enum GroupSortType implements SortType { - POPULAR("totalMember", Sort.Direction.DESC), - NEW("createdDate", Sort.Direction.DESC), - OLD("createdDate", Sort.Direction.ASC); + POPULAR("totalMember", Sort.Direction.DESC, Integer::parseInt), + NEW("createdDate", Sort.Direction.DESC, LocalDate::parse), + OLD("createdDate", Sort.Direction.ASC, LocalDate::parse); private final String field; private final Sort.Direction direction; + private final Function parser; - GroupSortType(String field, Sort.Direction direction) { + GroupSortType(String field, Sort.Direction direction, Function parser) { this.field = field; this.direction = direction; + this.parser = parser; } } diff --git a/src/main/java/com/studypals/domain/groupManage/worker/GroupReader.java b/src/main/java/com/studypals/domain/groupManage/worker/GroupReader.java index 345bf110..2439ef37 100644 --- a/src/main/java/com/studypals/domain/groupManage/worker/GroupReader.java +++ b/src/main/java/com/studypals/domain/groupManage/worker/GroupReader.java @@ -8,6 +8,7 @@ import com.studypals.domain.groupManage.dao.GroupTagRepository; import com.studypals.domain.groupManage.dao.groupRepository.GroupRepository; +import com.studypals.domain.groupManage.dao.groupRepository.GroupSortType; import com.studypals.domain.groupManage.dto.GroupSearchDto; import com.studypals.domain.groupManage.entity.Group; import com.studypals.domain.groupManage.entity.GroupTag; @@ -43,6 +44,15 @@ public Group getById(Long groupId) { } public Slice search(GroupSearchDto dto, Cursor cursor) { + GroupSortType sortType = (GroupSortType) cursor.sort(); + try { + sortType.getParser().apply(cursor.value()); + } catch (RuntimeException e) { + throw new GroupException( + GroupErrorCode.GROUP_SEARCH_FAIL, + "value 타입 문자열 형식이 올바르지 않습니다.", + "[GroupReader#search] parsing value fail"); + } return groupRepository.search(dto, cursor); } } From 9e87fade600d05d770fe5503664a8a473d6271af Mon Sep 17 00:00:00 2001 From: unikal1 Date: Fri, 23 Jan 2026 11:20:43 +0900 Subject: [PATCH 14/14] =?UTF-8?q?Fix:=20=EB=94=94=EB=A0=89=ED=86=A0?= =?UTF-8?q?=EB=A6=AC=20=EB=B3=80=EA=B2=BD=EC=97=90=20=EB=94=B0=EB=A5=B8=20?= =?UTF-8?q?import=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ref: #164 --- .../dao/groupEntryRepository/GroupEntryCodeRedisRepository.java | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/java/com/studypals/domain/groupManage/dao/groupEntryRepository/GroupEntryCodeRedisRepository.java b/src/main/java/com/studypals/domain/groupManage/dao/groupEntryRepository/GroupEntryCodeRedisRepository.java index 431e90cd..2682ab21 100644 --- a/src/main/java/com/studypals/domain/groupManage/dao/groupEntryRepository/GroupEntryCodeRedisRepository.java +++ b/src/main/java/com/studypals/domain/groupManage/dao/groupEntryRepository/GroupEntryCodeRedisRepository.java @@ -3,6 +3,7 @@ import org.springframework.data.repository.CrudRepository; import org.springframework.stereotype.Repository; +import com.studypals.domain.groupManage.dao.GroupEntryCodeRedisRepositoryCustom; import com.studypals.domain.groupManage.entity.GroupEntryCode; /**