@@ -63,14 +63,19 @@ public HouseholdConsumptionResponse.DailyConsumptionDetailDTO getDailyConsumptio
6363 return ConsumptionRecordConverter .toDailyConsumptionDetailDTO (consumptionRecord , consumptionCategory );
6464 }
6565
66- // 해당 월의 소비 내역 목록 조회 (커서 기반 무한스크롤)
67- // - 요구사항: 페이지 사이즈와 관계없이 "특정 날짜의 데이터가 여러 페이지에 분리되지 않도록" 보장.
68- // - 전략:
69- // 1) (consumeDate DESC, id DESC) 기준으로 pageSize+1개를 가져와 "경계 날짜(boundaryDate)"를 결정
70- // 2) 경계 날짜가 잘릴 위험이 있으므로, 경계 날짜의 "나머지 전부"를 추가 쿼리로 모아 합치기
71- // 3) 다음 페이지 존재 여부는 "경계 날짜보다 과거 데이터가 있는가"로 판정
72- // 4) nextCursorId는 "이번에 내려준 마지막(가장 오래된) 아이템의 id"로 반환
73- // (레포에서 커서를 '날짜 기준'으로만 쓰도록 수정하면, 같은 날짜 재등장 이슈가 사라집니다)
66+ /**
67+ * 해당 월의 소비 내역 목록 조회 (커서 기반 무한스크롤)
68+ * - 요구사항: 페이지 사이즈와 관계없이 "특정 날짜의 데이터가 여러 페이지에 분리되지 않도록" 보장.
69+ * - 핵심 규칙: 월 범위는 반드시 [monthStart, nextMonthStart) 반열린 구간으로 통일 (상한 미만)
70+ *
71+ * 전략:
72+ * 1) (consumeDate DESC, id DESC) 기준으로 pageSize+1개를 가져와 "경계 날짜(boundaryDate)"를 결정
73+ * 2) 경계 날짜가 잘릴 위험이 있으므로, 경계 날짜의 "나머지 전부"를 추가 쿼리로 모아 합치기
74+ * - 이때도 월 범위를 [monthStart, nextMonthStart)로 강제(클램핑)하여 월 넘김 혼입 방지
75+ * 3) 다음 페이지 존재 여부는 "경계 날짜보다 과거 데이터가 있는가"로 판정
76+ * 4) nextCursorId는 "이번에 내려준 마지막(가장 오래된) 아이템의 id"로 반환
77+ * (레포에서 커서를 '날짜 기준'으로만 쓰도록 하면, 같은 날짜가 다음 페이지에 재등장하지 않음)
78+ */
7479 @ Override
7580 public HouseholdConsumptionResponse .MonthlyHistoryResponseDTO getMonthlyConsumptionRecords (
7681 Long userId , int year , int month , Long cursorId ) {
@@ -79,14 +84,14 @@ public HouseholdConsumptionResponse.MonthlyHistoryResponseDTO getMonthlyConsumpt
7984 userRepository .findById (userId )
8085 .orElseThrow (() -> new ErrorHandler (ErrorStatus .USER_NOT_FOUND ));
8186
82- // 1) 해당 월 범위
83- LocalDate startDate = LocalDate . of ( year , month , 1 );
84- LocalDate endDate = startDate . withDayOfMonth ( startDate . lengthOfMonth () );
85- LocalDateTime monthStart = startDate .atStartOfDay ();
86- LocalDateTime monthEnd = endDate . atTime ( 23 , 59 , 59 , 999_999_999 );
87+ // 1) 해당 월 범위 계산
88+ // ⚠️ 월 범위는 반드시 [monthStart, nextMonthStart) 로 사용 (상한 미만)
89+ LocalDate firstDay = LocalDate . of ( year , month , 1 );
90+ LocalDateTime monthStart = firstDay .atStartOfDay (); // ex) 2025-07-01 00:00
91+ LocalDateTime nextMonthStart = firstDay . plusMonths ( 1 ). atStartOfDay (); // ex) 2025-08-01 00:00
8792
8893 // 2) 커서의 consumeDate 조회 (id → consumeDate)
89- // 다음 페이지는 "이 날짜보다 과거(< dayStart)"만 보도록 만들 것이므로
94+ // 다음 페이지는 "이 날짜의 0시보다 과거(< dayStart)"만 보도록 만들 것이므로
9095 // 레포지토리에서 id 커서 비교(= 같은 날짜에서 id<...)는 사용하지 않게 해야 "날짜 쪼개짐"이 사라집니다.
9196 LocalDateTime cursorConsumeDate = null ;
9297 if (cursorId != null ) {
@@ -100,11 +105,17 @@ public HouseholdConsumptionResponse.MonthlyHistoryResponseDTO getMonthlyConsumpt
100105 // 즉, "날짜만" 기준으로 다음 페이지를 자르도록 해야 같은 날짜 재등장이 없음.
101106 final int pageSize = PAGE_SIZE ; // 기존 상수 재사용
102107 List <DailyConsumptionItemProjection > chunk = consumptionRecordRepository
103- .findChunkByMonthUsingDateCursor (userId , monthStart , monthEnd , cursorConsumeDate , pageSize + 1 );
108+ .findChunkByMonthUsingDateCursor (
109+ userId ,
110+ monthStart , // ✅ 하한: 포함
111+ nextMonthStart , // ✅ 상한: 미만
112+ cursorConsumeDate ,
113+ pageSize + 1
114+ );
104115
105116 if (chunk .isEmpty ()) {
106117 return ConsumptionRecordConverter .toMonthlyHistoryResponseDTO (
107- List .of (), true , false , null
118+ List .of (), /*isFirst*/ true , /*hasNext*/ false , /*nextCursorId*/ null
108119 );
109120 }
110121
@@ -114,9 +125,19 @@ public HouseholdConsumptionResponse.MonthlyHistoryResponseDTO getMonthlyConsumpt
114125 int visibleCount = Math .min (pageSize , chunk .size ());
115126 List <DailyConsumptionItemProjection > visible = new ArrayList <>(chunk .subList (0 , visibleCount ));
116127
117- LocalDate boundaryDate = visible .get (visible .size () - 1 ).getConsumeDate ().toLocalDate ();
118- LocalDateTime boundaryStart = boundaryDate .atStartOfDay ();
119- LocalDateTime boundaryEnd = boundaryStart .plusDays (1 ).minusNanos (1 );
128+ LocalDate boundaryDate = visible .get (visibleCount - 1 ).getConsumeDate ().toLocalDate ();
129+ LocalDateTime boundaryStart = boundaryDate .atStartOfDay (); // ex) 2025-07-31 00:00
130+ LocalDateTime boundaryEnd = boundaryStart .plusDays (1 ).minusNanos (1 ); // ex) 2025-07-31 23:59:59.999999999
131+
132+ // 4-1) 경계 날짜 구간도 월 경계를 넘지 않도록 '클램핑'
133+ // - 하한은 monthStart 이상
134+ // - 상한은 nextMonthStart 미만
135+ LocalDateTime boundaryStartClamped = boundaryStart .isBefore (monthStart ) ? monthStart : boundaryStart ;
136+ LocalDateTime boundaryEndClampedExclusive = boundaryEnd .plusNanos (1 ); // [start, end] → [start, endExclusive)
137+ LocalDateTime boundaryEndClampedExclusiveFinal =
138+ boundaryEndClampedExclusive .isAfter (nextMonthStart ) ? nextMonthStart : boundaryEndClampedExclusive ;
139+ // 최종적으로 between 용으로 다시 [start, end] 포함구간으로 변환
140+ LocalDateTime boundaryEndClamped = boundaryEndClampedExclusiveFinal .minusNanos (1 );
120141
121142 // 5) 경계 날짜의 나머지 아이템 전부 추가 조회
122143 // - chunk는 pageSize 제한 때문에 "경계 날짜의 일부"만 담겼을 수 있음
@@ -127,8 +148,13 @@ public HouseholdConsumptionResponse.MonthlyHistoryResponseDTO getMonthlyConsumpt
127148 .min (Long ::compareTo ) // 정렬이 DESC이므로 "가장 오래된 id"가 min
128149 .orElse (Long .MAX_VALUE );
129150
130- List <DailyConsumptionItemProjection > extraSameDate = consumptionRecordRepository
131- .findRestOfBoundaryDate (userId , boundaryStart , boundaryEnd , minIncludedIdOnBoundary );
151+ List <DailyConsumptionItemProjection > extraSameDate =
152+ consumptionRecordRepository .findRestOfBoundaryDateClampedToMonth (
153+ userId ,
154+ monthStart , nextMonthStart , // ✅ 월 범위 [monthStart, nextMonthStart)
155+ boundaryStartClamped , boundaryEndClamped , // ✅ 하루 범위도 월에 클램핑
156+ minIncludedIdOnBoundary
157+ );
132158
133159 // 6) 결과 합치기 (정렬 유지: consumeDate DESC, id DESC)
134160 // - visible(앞부분) + extraSameDate(경계 날짜 나머지) 순으로 합칩니다.
@@ -137,7 +163,9 @@ public HouseholdConsumptionResponse.MonthlyHistoryResponseDTO getMonthlyConsumpt
137163 merged .addAll (extraSameDate );
138164
139165 // 7) hasNext 계산: 경계 날짜보다 과거 데이터가 있는지
140- boolean hasNext = consumptionRecordRepository .existsOlderThanDate (userId , monthStart , monthEnd , boundaryStart );
166+ boolean hasNext = consumptionRecordRepository .existsOlderThanDate (
167+ userId , monthStart , nextMonthStart , boundaryStartClamped
168+ );
141169
142170 // 8) nextCursorId: 이번에 내려준 리스트 중 "가장 오래된" 아이템의 id (마지막 요소)
143171 Long nextCursorId = hasNext && !merged .isEmpty ()
@@ -238,6 +266,5 @@ public HouseholdConsumptionResponse.CalendarDailyConsumeSliceResponse getCalenda
238266 nextCursorId ,
239267 isFirst
240268 );
241-
242269 }
243270}
0 commit comments