From 004e706e9602954475b897197eb67b862a91f320 Mon Sep 17 00:00:00 2001 From: seongjunnoh Date: Wed, 3 Sep 2025 17:08:31 +0900 Subject: [PATCH 1/3] =?UTF-8?q?[docs]=20=EC=98=A4=ED=83=80=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20=EB=B0=8F=20todo=20=EC=A3=BC=EC=84=9D=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20(#290)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../thip/room/application/port/out/dto/RoomQueryDto.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/konkuk/thip/room/application/port/out/dto/RoomQueryDto.java b/src/main/java/konkuk/thip/room/application/port/out/dto/RoomQueryDto.java index d90db4fbf..ac466fd11 100644 --- a/src/main/java/konkuk/thip/room/application/port/out/dto/RoomQueryDto.java +++ b/src/main/java/konkuk/thip/room/application/port/out/dto/RoomQueryDto.java @@ -1,12 +1,12 @@ package konkuk.thip.room.application.port.out.dto; import com.querydsl.core.annotations.QueryProjection; -import jakarta.annotation.Nullable; import lombok.Builder; import org.springframework.util.Assert; import java.time.LocalDate; +// TODO : RoomStatus 도입 + RoomQueryRepositoryImpl 코드 수정 @Builder public record RoomQueryDto( Long roomId, @@ -18,7 +18,7 @@ public record RoomQueryDto( LocalDate endDate, // 방 진행 마감일 or 방 모집 마감일 Boolean isPublic // 공개방 여부 ) { - // 내가 참여한 모임방(모집중, 진행중, 모집+진행중z, 완료된) 조회 시 활용 + // 내가 참여한 모임방(모집중, 진행중, 모집+진행중, 완료된) 조회 시 활용 @QueryProjection public RoomQueryDto { Assert.notNull(roomId, "roomId must not be null"); From 424d706cb2001115e936e4ec31304f2dde1168a6 Mon Sep 17 00:00:00 2001 From: seongjunnoh Date: Wed, 3 Sep 2025 17:10:28 +0900 Subject: [PATCH 2/3] =?UTF-8?q?[fix]=20=EB=82=B4=EA=B0=80=20=EC=B0=B8?= =?UTF-8?q?=EC=97=AC=ED=95=9C=20=EB=AA=A8=EC=9E=84=EB=B0=A9=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20api=EC=9D=98=20=EC=98=81=EC=86=8D=EC=84=B1=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=88=98=EC=A0=95=20(#290)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 모집중인 방의 경우, 반환하는 RoomQueryDto의 endDate 값을 방 모집마감일로 응답하도록 수정 - 모집+진행중인 방을 조회하는 경우, 페이징처리를 위한 커서에 Integer priority 추가 --- .../RoomQueryPersistenceAdapter.java | 21 +++++- .../repository/RoomQueryRepository.java | 2 +- .../repository/RoomQueryRepositoryImpl.java | 70 +++++++++++++++---- 3 files changed, 76 insertions(+), 17 deletions(-) diff --git a/src/main/java/konkuk/thip/room/adapter/out/persistence/RoomQueryPersistenceAdapter.java b/src/main/java/konkuk/thip/room/adapter/out/persistence/RoomQueryPersistenceAdapter.java index 667a85cb7..5fa848e00 100644 --- a/src/main/java/konkuk/thip/room/adapter/out/persistence/RoomQueryPersistenceAdapter.java +++ b/src/main/java/konkuk/thip/room/adapter/out/persistence/RoomQueryPersistenceAdapter.java @@ -103,8 +103,25 @@ public CursorBasedList findPlayingRoomsUserParticipated(Long userI @Override public CursorBasedList findPlayingAndRecruitingRoomsUserParticipated(Long userId, Cursor cursor) { - return findRoomsByDeadlineCursor(cursor, (lastLocalDate, lastId, pageSize) -> - roomJpaRepository.findPlayingAndRecruitingRoomsUserParticipated(userId, lastLocalDate, lastId, pageSize)); + Integer lastPriority = cursor.isFirstRequest() ? null : cursor.getInteger(0); + LocalDate lastLocalDate = cursor.isFirstRequest() ? null : cursor.getLocalDate(1); + Long lastId = cursor.isFirstRequest() ? null : cursor.getLong(2); + int pageSize = cursor.getPageSize(); + + List dtos = roomJpaRepository.findPlayingAndRecruitingRoomsUserParticipated( + userId, lastPriority, lastLocalDate, lastId, pageSize + ); + + return CursorBasedList.of(dtos, pageSize, dto -> { + int priority = dto.startDate().isAfter(LocalDate.now()) ? 1 : 0; // 0 : 진행중인 방, 1 : 모집중인 방 // TODO : dto에 RoomStatus 도입되면 수정해야함 + + Cursor nextCursor = new Cursor(List.of( + String.valueOf(priority), + dto.endDate().toString(), + dto.roomId().toString() + )); + return nextCursor.toEncodedString(); + }); } @Override diff --git a/src/main/java/konkuk/thip/room/adapter/out/persistence/repository/RoomQueryRepository.java b/src/main/java/konkuk/thip/room/adapter/out/persistence/repository/RoomQueryRepository.java index 8f3916baa..03af9f0b9 100644 --- a/src/main/java/konkuk/thip/room/adapter/out/persistence/repository/RoomQueryRepository.java +++ b/src/main/java/konkuk/thip/room/adapter/out/persistence/repository/RoomQueryRepository.java @@ -27,7 +27,7 @@ public interface RoomQueryRepository { List findPlayingRoomsUserParticipated(Long userId, LocalDate dateCursor, Long roomIdCursor, int pageSize); - List findPlayingAndRecruitingRoomsUserParticipated(Long userId, LocalDate dateCursor, Long roomIdCursor, int pageSize); + List findPlayingAndRecruitingRoomsUserParticipated(Long userId, Integer priorityCursor, LocalDate dateCursor, Long roomIdCursor, int pageSize); List findExpiredRoomsUserParticipated(Long userId, LocalDate dateCursor, Long roomIdCursor, int pageSize); diff --git a/src/main/java/konkuk/thip/room/adapter/out/persistence/repository/RoomQueryRepositoryImpl.java b/src/main/java/konkuk/thip/room/adapter/out/persistence/repository/RoomQueryRepositoryImpl.java index 4119a2f70..b708d4cc3 100644 --- a/src/main/java/konkuk/thip/room/adapter/out/persistence/repository/RoomQueryRepositoryImpl.java +++ b/src/main/java/konkuk/thip/room/adapter/out/persistence/repository/RoomQueryRepositoryImpl.java @@ -281,7 +281,7 @@ public List findPlayingRoomsUserParticipated( // 3) 진행+모집 통합 @Override public List findPlayingAndRecruitingRoomsUserParticipated( - Long userId, LocalDate dateCursor, Long roomIdCursor, int pageSize + Long userId, Integer priorityCursor, LocalDate dateCursor, Long roomIdCursor, int pageSize ) { LocalDate today = LocalDate.now(); BooleanExpression playing = room.startDate.loe(today).and(room.endDate.goe(today)); @@ -299,13 +299,7 @@ public List findPlayingAndRecruitingRoomsUserParticipated( .when(playing).then(0) .otherwise(1); - OrderSpecifier[] orders = new OrderSpecifier[]{ - priority.asc(), - cursorExpr.asc(), - room.roomId.asc() - }; - - return fetchMyRooms(base, cursorExpr, orders, true, dateCursor, roomIdCursor, pageSize); + return fetchMyRoomsWithPriority(base, priority, cursorExpr, priorityCursor, dateCursor, roomIdCursor, pageSize); } // 4) 만료된 방 @@ -412,9 +406,9 @@ private BooleanExpression userJoinedRoom(Long userId) { .exists(); } - /** - * 공통 커서 + 2단계 조회 (IDs → entities) 처리 - */ + // ====================================================== + // 공통 fetch (키셋: (date, id)) - 단일 축(모집/진행/만료) 전용 + // ====================================================== private List fetchMyRooms( BooleanExpression baseCondition, DateExpression cursorExpr, @@ -425,7 +419,7 @@ private List fetchMyRooms( int pageSize ) { BooleanBuilder where = new BooleanBuilder(baseCondition); - if (dateCursor != null && roomIdCursor != null) { // 첫 페이지가 아닌 경우 + if (dateCursor != null && roomIdCursor != null) { // 2중 복합 커서 if (ascending) { where.and(cursorExpr.gt(dateCursor) .or(cursorExpr.eq(dateCursor) @@ -437,7 +431,6 @@ private List fetchMyRooms( } } - // 2) DTO 프로젝션: 필요한 필드만 바로 조회 return queryFactory .select(new QRoomQueryDto( room.roomId, @@ -446,7 +439,7 @@ private List fetchMyRooms( room.recruitCount, room.memberCount, room.startDate, - room.endDate, + cursorExpr, // endDate 자리에 상황별 deadline 컬럼 전달 room.isPublic )) .from(participant) @@ -457,4 +450,53 @@ private List fetchMyRooms( .limit(pageSize + 1) .fetch(); } + + // ====================================================== + // 공통 fetch (키셋: (priority, date, id)) - 혼합(진행+모집) 전용 + // ====================================================== + private List fetchMyRoomsWithPriority( + BooleanExpression baseCondition, + NumberExpression priorityExpr, + DateExpression cursorExpr, + Integer priorityCursor, + LocalDate dateCursor, + Long roomIdCursor, + int pageSize + ) { + BooleanBuilder where = new BooleanBuilder(baseCondition); + + if (priorityCursor != null && dateCursor != null && roomIdCursor != null) { // 3중 복합 커서 + where.and( + priorityExpr.gt(priorityCursor) + .or(priorityExpr.eq(priorityCursor) + .and(cursorExpr.gt(dateCursor) + .or(cursorExpr.eq(dateCursor) + .and(room.roomId.gt(roomIdCursor)) + ) + ) + ) + ); + } + + return queryFactory + .select(new QRoomQueryDto( + room.roomId, + book.imageUrl, + room.title, + room.recruitCount, + room.memberCount, + room.startDate, + cursorExpr, // endDate 자리에 상황별 deadline 컬럼 전달 + room.isPublic + )) + .from(participant) + .join(participant.roomJpaEntity, room) + .join(room.bookJpaEntity, book) + .where(where) + .orderBy( + new OrderSpecifier[]{priorityExpr.asc(), cursorExpr.asc(), room.roomId.asc()} + ) + .limit(pageSize + 1) + .fetch(); + } } From 1c05c0d6fd4ffb6c07a8d5da4104245d5983955b Mon Sep 17 00:00:00 2001 From: seongjunnoh Date: Wed, 3 Sep 2025 17:11:17 +0900 Subject: [PATCH 3/3] =?UTF-8?q?[test]=20=EB=82=B4=EA=B0=80=20=EC=B0=B8?= =?UTF-8?q?=EC=97=AC=ED=95=9C=20=EB=AA=A8=EC=9E=84=EB=B0=A9=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20api=20=ED=86=B5=ED=95=A9=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8B=91=20=EC=BD=94=EB=93=9C=20=EC=B6=94=EA=B0=80=20(#290)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 모집 + 진행중인 방 조회시, 누락&중복되는 데이터 없이 무한스크롤 기능 동작하는지 검증하는 테스트 코드 추가 --- .../adapter/in/web/RoomShowMineApiTest.java | 87 +++++++++++++++++++ 1 file changed, 87 insertions(+) diff --git a/src/test/java/konkuk/thip/room/adapter/in/web/RoomShowMineApiTest.java b/src/test/java/konkuk/thip/room/adapter/in/web/RoomShowMineApiTest.java index b9f2b6854..907335375 100644 --- a/src/test/java/konkuk/thip/room/adapter/in/web/RoomShowMineApiTest.java +++ b/src/test/java/konkuk/thip/room/adapter/in/web/RoomShowMineApiTest.java @@ -492,4 +492,91 @@ void get_my_rooms_about_exit_rooms() throws Exception { .andExpect(jsonPath("$.data.roomList[1].roomName", is("과학-방-1일뒤-활동시작"))) .andExpect(jsonPath("$.data.roomList[1].memberCount", is(6))); // 기존 5명 + user } + + @Test + @DisplayName("혼합(진행+모집) 무한스크롤: (priority, date, id) 키셋으로 중복/누락 없이 페이징된다.") + void get_my_playing_and_recruiting_rooms_pagination() throws Exception { + // given + // 진행중인 방 6개 (endDate 임박 순서: +1d ~ +6d) + RoomJpaEntity playing1 = saveScienceRoom("진행중인방-책-P1", "pisbn1", "과학-방-1일뒤-활동마감", LocalDate.now().minusDays(10), LocalDate.now().plusDays(1), 10); + changeRoomMemberCount(playing1, 3); + RoomJpaEntity playing2 = saveScienceRoom("진행중인방-책-P2", "pisbn2", "과학-방-2일뒤-활동마감", LocalDate.now().minusDays(10), LocalDate.now().plusDays(2), 10); + changeRoomMemberCount(playing2, 4); + RoomJpaEntity playing3 = saveScienceRoom("진행중인방-책-P3", "pisbn3", "과학-방-3일뒤-활동마감", LocalDate.now().minusDays(10), LocalDate.now().plusDays(3), 10); + changeRoomMemberCount(playing3, 5); + RoomJpaEntity playing4 = saveScienceRoom("진행중인방-책-P4", "pisbn4", "과학-방-4일뒤-활동마감", LocalDate.now().minusDays(10), LocalDate.now().plusDays(4), 10); + changeRoomMemberCount(playing4, 6); + RoomJpaEntity playing5 = saveScienceRoom("진행중인방-책-P5", "pisbn5", "과학-방-5일뒤-활동마감", LocalDate.now().minusDays(10), LocalDate.now().plusDays(5), 10); + changeRoomMemberCount(playing5, 7); + RoomJpaEntity playing6 = saveScienceRoom("진행중인방-책-P6", "pisbn6", "과학-방-6일뒤-활동마감", LocalDate.now().minusDays(10), LocalDate.now().plusDays(6), 10); + changeRoomMemberCount(playing6, 8); + + // 모집중인 방 6개 (startDate 임박 순서: +1d ~ +6d) + RoomJpaEntity recruiting1 = saveScienceRoom("모집중인방-책-R1", "risbn1", "과학-방-1일뒤-활동시작", LocalDate.now().plusDays(1), LocalDate.now().plusDays(30), 10); + changeRoomMemberCount(recruiting1, 3); + RoomJpaEntity recruiting2 = saveScienceRoom("모집중인방-책-R2", "risbn2", "과학-방-2일뒤-활동시작", LocalDate.now().plusDays(2), LocalDate.now().plusDays(30), 10); + changeRoomMemberCount(recruiting2, 4); + RoomJpaEntity recruiting3 = saveScienceRoom("모집중인방-책-R3", "risbn3", "과학-방-3일뒤-활동시작", LocalDate.now().plusDays(3), LocalDate.now().plusDays(30), 10); + changeRoomMemberCount(recruiting3, 5); + RoomJpaEntity recruiting4 = saveScienceRoom("모집중인방-책-R4", "risbn4", "과학-방-4일뒤-활동시작", LocalDate.now().plusDays(4), LocalDate.now().plusDays(30), 10); + changeRoomMemberCount(recruiting4, 6); + RoomJpaEntity recruiting5 = saveScienceRoom("모집중인방-책-R5", "risbn5", "과학-방-5일뒤-활동시작", LocalDate.now().plusDays(5), LocalDate.now().plusDays(30), 10); + changeRoomMemberCount(recruiting5, 7); + RoomJpaEntity recruiting6 = saveScienceRoom("모집중인방-책-R6", "risbn6", "과학-방-6일뒤-활동시작", LocalDate.now().plusDays(6), LocalDate.now().plusDays(30), 10); + changeRoomMemberCount(recruiting6, 8); + + Alias alias = TestEntityFactory.createScienceAlias(); + UserJpaEntity user = userJpaRepository.save(TestEntityFactory.createUser(alias)); + + // 유저 참여 + saveSingleUserToRoom(playing1, user); + saveSingleUserToRoom(playing2, user); + saveSingleUserToRoom(playing3, user); + saveSingleUserToRoom(playing4, user); + saveSingleUserToRoom(playing5, user); + saveSingleUserToRoom(playing6, user); + saveSingleUserToRoom(recruiting1, user); + saveSingleUserToRoom(recruiting2, user); + saveSingleUserToRoom(recruiting3, user); + saveSingleUserToRoom(recruiting4, user); + saveSingleUserToRoom(recruiting5, user); + saveSingleUserToRoom(recruiting6, user); + + // when: 첫 페이지 (type 파라미터 없음 -> 혼합 조회) + ResultActions page1 = mockMvc.perform(get("/rooms/my") + .requestAttr("userId", user.getUserId())); + + // then: 첫 페이지는 10개, 진행중(6) 먼저, 이후 모집중(4) + page1.andExpect(status().isOk()) + .andExpect(jsonPath("$.data.isLast", is(false))) + .andExpect(jsonPath("$.data.roomList", hasSize(10))) + // 진행중 6개 (endDate 임박순) + .andExpect(jsonPath("$.data.roomList[0].roomName", is("과학-방-1일뒤-활동마감"))) + .andExpect(jsonPath("$.data.roomList[1].roomName", is("과학-방-2일뒤-활동마감"))) + .andExpect(jsonPath("$.data.roomList[2].roomName", is("과학-방-3일뒤-활동마감"))) + .andExpect(jsonPath("$.data.roomList[3].roomName", is("과학-방-4일뒤-활동마감"))) + .andExpect(jsonPath("$.data.roomList[4].roomName", is("과학-방-5일뒤-활동마감"))) + .andExpect(jsonPath("$.data.roomList[5].roomName", is("과학-방-6일뒤-활동마감"))) + // 이어서 모집중 4개 (startDate 임박순) + .andExpect(jsonPath("$.data.roomList[6].roomName", is("과학-방-1일뒤-활동시작"))) + .andExpect(jsonPath("$.data.roomList[7].roomName", is("과학-방-2일뒤-활동시작"))) + .andExpect(jsonPath("$.data.roomList[8].roomName", is("과학-방-3일뒤-활동시작"))) + .andExpect(jsonPath("$.data.roomList[9].roomName", is("과학-방-4일뒤-활동시작"))); + + // 다음 페이지 커서: 첫 페이지의 마지막 레코드 = recruiting4 + // 혼합 커서 형식 = priority|deadlineDate|roomId (priority: 진행=0, 모집=1; deadlineDate: 진행=endDate, 모집=startDate) + String nextCursor = "1|" + recruiting4.getStartDate() + "|" + recruiting4.getRoomId(); + + // when: 두 번째 페이지 + ResultActions page2 = mockMvc.perform(get("/rooms/my") + .requestAttr("userId", user.getUserId()) + .param("cursor", nextCursor)); + + // then: 남은 모집중 2개, isLast=true, 중복/누락 없음 + page2.andExpect(status().isOk()) + .andExpect(jsonPath("$.data.isLast", is(true))) + .andExpect(jsonPath("$.data.roomList", hasSize(2))) + .andExpect(jsonPath("$.data.roomList[0].roomName", is("과학-방-5일뒤-활동시작"))) + .andExpect(jsonPath("$.data.roomList[1].roomName", is("과학-방-6일뒤-활동시작"))); + } }