Skip to content

Commit c09fc46

Browse files
i3monthsclaude
andcommitted
feat(session): 세션 목록 페이지네이션 + 히스토리 "더 보기"
GET /api/sessions 가 전체 세션을 List 로 반환하던 것을 페이지네이션으로. Core: - InterviewSessionRepository.findByUser_IdAndDeletedFalse(userId, Pageable) - SessionService.list → listPaged(userId, Pageable): Page<SessionResult> - SessionController.list → PageResponse<SessionResponse> (@PageableDefault size=20, id DESC) (common/response/PageResponse 재사용). openapi 재생성. Front: - historyApi.listSessions(page, size) → ?page=&size= → PageResponseSessionResponse - useSessions → useInfiniteQuery (getNextPageParam: last면 종료) - SessionHistoryList: pages 평탄화 + "더 보기" 버튼(hasNextPage 시) 검증: backend compile + OpenApiSpec BUILD SUCCESSFUL(응답 PageResponse), 프론트 build 통과, lint 클린. GET 목록 소비처는 history 뿐(POST 생성 무관). DB 변경 없음. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 9597b96 commit c09fc46

8 files changed

Lines changed: 145 additions & 27 deletions

File tree

backend/openapi.json

Lines changed: 64 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -220,16 +220,21 @@
220220
"tags" : [ "Sessions" ],
221221
"summary" : "내 세션 히스토리 (US-15)",
222222
"operationId" : "listSessions",
223+
"parameters" : [ {
224+
"name" : "pageable",
225+
"in" : "query",
226+
"required" : true,
227+
"schema" : {
228+
"$ref" : "#/components/schemas/Pageable"
229+
}
230+
} ],
223231
"responses" : {
224232
"200" : {
225233
"description" : "세션 목록",
226234
"content" : {
227235
"*/*" : {
228236
"schema" : {
229-
"type" : "array",
230-
"items" : {
231-
"$ref" : "#/components/schemas/SessionResponse"
232-
}
237+
"$ref" : "#/components/schemas/PageResponseSessionResponse"
233238
}
234239
}
235240
}
@@ -239,10 +244,7 @@
239244
"content" : {
240245
"*/*" : {
241246
"schema" : {
242-
"type" : "array",
243-
"items" : {
244-
"$ref" : "#/components/schemas/SessionResponse"
245-
}
247+
"$ref" : "#/components/schemas/PageResponseSessionResponse"
246248
}
247249
}
248250
}
@@ -3070,6 +3072,60 @@
30703072
}
30713073
}
30723074
},
3075+
"Pageable" : {
3076+
"type" : "object",
3077+
"properties" : {
3078+
"page" : {
3079+
"type" : "integer",
3080+
"format" : "int32",
3081+
"minimum" : 0
3082+
},
3083+
"size" : {
3084+
"type" : "integer",
3085+
"format" : "int32",
3086+
"minimum" : 1
3087+
},
3088+
"sort" : {
3089+
"type" : "array",
3090+
"items" : {
3091+
"type" : "string"
3092+
}
3093+
}
3094+
}
3095+
},
3096+
"PageResponseSessionResponse" : {
3097+
"type" : "object",
3098+
"properties" : {
3099+
"content" : {
3100+
"type" : "array",
3101+
"items" : {
3102+
"$ref" : "#/components/schemas/SessionResponse"
3103+
}
3104+
},
3105+
"page" : {
3106+
"type" : "integer",
3107+
"format" : "int32"
3108+
},
3109+
"size" : {
3110+
"type" : "integer",
3111+
"format" : "int32"
3112+
},
3113+
"totalElements" : {
3114+
"type" : "integer",
3115+
"format" : "int64"
3116+
},
3117+
"totalPages" : {
3118+
"type" : "integer",
3119+
"format" : "int32"
3120+
},
3121+
"first" : {
3122+
"type" : "boolean"
3123+
},
3124+
"last" : {
3125+
"type" : "boolean"
3126+
}
3127+
}
3128+
},
30733129
"FeedbackResponse" : {
30743130
"type" : "object",
30753131
"properties" : {

backend/src/main/java/com/stackup/stackup/session/application/SessionService.java

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@
2121
import java.util.List;
2222
import lombok.RequiredArgsConstructor;
2323
import org.springframework.context.ApplicationEventPublisher;
24+
import org.springframework.data.domain.Page;
25+
import org.springframework.data.domain.Pageable;
2426
import org.springframework.stereotype.Service;
2527
import org.springframework.transaction.annotation.Transactional;
2628

@@ -65,11 +67,10 @@ public SessionResult create(Long userId, SessionCreateCommand command) {
6567
return SessionResult.of(session, linkedIds);
6668
}
6769

68-
public List<SessionResult> list(Long userId) {
70+
public Page<SessionResult> listPaged(Long userId, Pageable pageable) {
6971
loadUser(userId);
70-
return sessionRepository.findByUser_IdAndDeletedFalseOrderByIdDesc(userId).stream()
71-
.map(s -> SessionResult.of(s, contextDocumentIds(s.getId())))
72-
.toList();
72+
return sessionRepository.findByUser_IdAndDeletedFalse(userId, pageable)
73+
.map(s -> SessionResult.of(s, contextDocumentIds(s.getId())));
7374
}
7475

7576
public SessionResult get(Long userId, Long sessionId) {

backend/src/main/java/com/stackup/stackup/session/domain/InterviewSessionRepository.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import java.util.List;
44
import java.util.Optional;
5+
import org.springframework.data.domain.Page;
56
import org.springframework.data.domain.Pageable;
67
import org.springframework.data.jpa.repository.JpaRepository;
78

@@ -15,6 +16,8 @@ public interface InterviewSessionRepository extends JpaRepository<InterviewSessi
1516

1617
List<InterviewSession> findByUser_IdAndDeletedFalseOrderByIdDesc(Long userId);
1718

19+
Page<InterviewSession> findByUser_IdAndDeletedFalse(Long userId, Pageable pageable);
20+
1821
long countByUser_Id(Long userId);
1922

2023
long countByUser_IdAndStatus(Long userId, SessionStatus status);

backend/src/main/java/com/stackup/stackup/session/presentation/SessionController.java

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package com.stackup.stackup.session.presentation;
22

3+
import com.stackup.stackup.common.response.PageResponse;
34
import com.stackup.stackup.common.security.UserPrincipal;
45
import com.stackup.stackup.session.application.SessionService;
56
import com.stackup.stackup.session.presentation.dto.SessionCreateRequest;
@@ -11,8 +12,10 @@
1112
import io.swagger.v3.oas.annotations.responses.ApiResponses;
1213
import io.swagger.v3.oas.annotations.tags.Tag;
1314
import jakarta.validation.Valid;
14-
import java.util.List;
1515
import lombok.RequiredArgsConstructor;
16+
import org.springframework.data.domain.Pageable;
17+
import org.springframework.data.domain.Sort;
18+
import org.springframework.data.web.PageableDefault;
1619
import org.springframework.http.HttpStatus;
1720
import org.springframework.security.core.annotation.AuthenticationPrincipal;
1821
import org.springframework.web.bind.annotation.DeleteMapping;
@@ -60,10 +63,14 @@ public SessionResponse create(
6063
@ApiResponse(responseCode = "401", description = "인증 실패")
6164
})
6265
@GetMapping
63-
public List<SessionResponse> list(@AuthenticationPrincipal UserPrincipal principal) {
64-
return sessionService.list(principal.userId()).stream()
65-
.map(SessionResponse::from)
66-
.toList();
66+
public PageResponse<SessionResponse> list(
67+
@AuthenticationPrincipal UserPrincipal principal,
68+
@PageableDefault(size = 20, sort = "id", direction = Sort.Direction.DESC)
69+
Pageable pageable
70+
) {
71+
return PageResponse.from(
72+
sessionService.listPaged(principal.userId(), pageable).map(SessionResponse::from)
73+
);
6774
}
6875

6976
@Operation(operationId = "getSession", summary = "세션 상세 (US-16)")

frontend/src/features/history/api/historyApi.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,14 @@ import type { components } from '@/shared/api/generated'
33

44
type S = components['schemas']
55
export type Session = S['SessionResponse']
6+
export type SessionPage = S['PageResponseSessionResponse']
67
export type UserStats = S['UserStatsResponse']
78

8-
export async function listSessions(): Promise<Session[]> {
9-
return (await apiClient.get<Session[]>('/api/sessions')).data
9+
// Spring 이 ?page=&size= 를 바인딩하므로 쿼리 파라미터로 직접 전달.
10+
export async function listSessions(page = 0, size = 20): Promise<SessionPage> {
11+
return (
12+
await apiClient.get<SessionPage>('/api/sessions', { params: { page, size } })
13+
).data
1014
}
1115

1216
export async function getUserStats(): Promise<UserStats> {

frontend/src/features/history/model/useHistory.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,19 @@
1-
import { useQuery } from '@tanstack/react-query'
1+
import { useInfiniteQuery, useQuery } from '@tanstack/react-query'
22
import { getUserStats, listSessions } from '../api/historyApi'
33

44
export const historyKeys = {
55
sessions: ['history', 'sessions'] as const,
66
stats: ['history', 'stats'] as const,
77
}
88

9+
// 무한 스크롤: last 가 아니면 다음 page 번호를 pageParam 으로.
910
export function useSessions() {
10-
return useQuery({ queryKey: historyKeys.sessions, queryFn: listSessions })
11+
return useInfiniteQuery({
12+
queryKey: historyKeys.sessions,
13+
queryFn: ({ pageParam }) => listSessions(pageParam),
14+
initialPageParam: 0,
15+
getNextPageParam: (last) => (last.last ? undefined : (last.page ?? 0) + 1),
16+
})
1117
}
1218

1319
export function useUserStats() {

frontend/src/features/history/ui/SessionHistoryList.tsx

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,15 @@ import { useSessions } from '../model/useHistory'
22
import { SessionCard } from './SessionCard'
33

44
export function SessionHistoryList() {
5-
const { data, isLoading, isError, refetch } = useSessions()
5+
const {
6+
data,
7+
isLoading,
8+
isError,
9+
refetch,
10+
fetchNextPage,
11+
hasNextPage,
12+
isFetchingNextPage,
13+
} = useSessions()
614

715
if (isLoading) {
816
return <p className="py-8 text-center text-body text-fg-muted">불러오는 중…</p>
@@ -17,17 +25,28 @@ export function SessionHistoryList() {
1725
</div>
1826
)
1927
}
20-
if (!data || data.length === 0) {
28+
29+
const sessions = data?.pages.flatMap((p) => p.content ?? []) ?? []
30+
if (sessions.length === 0) {
2131
return (
2232
<p className="py-8 text-center text-body text-fg-muted">아직 진행한 면접이 없어요.</p>
2333
)
2434
}
2535

2636
return (
2737
<div className="flex flex-col gap-3">
28-
{data.map((s) => (
38+
{sessions.map((s) => (
2939
<SessionCard key={s.id} session={s} />
3040
))}
41+
{hasNextPage && (
42+
<button
43+
className="mt-2 self-center text-caption text-fg-muted underline"
44+
onClick={() => fetchNextPage()}
45+
disabled={isFetchingNextPage}
46+
>
47+
{isFetchingNextPage ? '불러오는 중…' : '더 보기'}
48+
</button>
49+
)}
3150
</div>
3251
)
3352
}

frontend/src/shared/api/generated.ts

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1064,6 +1064,26 @@ export interface components {
10641064
/** Format: date-time */
10651065
timestamp?: string;
10661066
};
1067+
Pageable: {
1068+
/** Format: int32 */
1069+
page?: number;
1070+
/** Format: int32 */
1071+
size?: number;
1072+
sort?: string[];
1073+
};
1074+
PageResponseSessionResponse: {
1075+
content?: components["schemas"]["SessionResponse"][];
1076+
/** Format: int32 */
1077+
page?: number;
1078+
/** Format: int32 */
1079+
size?: number;
1080+
/** Format: int64 */
1081+
totalElements?: number;
1082+
/** Format: int32 */
1083+
totalPages?: number;
1084+
first?: boolean;
1085+
last?: boolean;
1086+
};
10671087
FeedbackResponse: {
10681088
/** Format: int64 */
10691089
id?: number;
@@ -1312,7 +1332,9 @@ export interface operations {
13121332
};
13131333
listSessions: {
13141334
parameters: {
1315-
query?: never;
1335+
query: {
1336+
pageable: components["schemas"]["Pageable"];
1337+
};
13161338
header?: never;
13171339
path?: never;
13181340
cookie?: never;
@@ -1325,7 +1347,7 @@ export interface operations {
13251347
[name: string]: unknown;
13261348
};
13271349
content: {
1328-
"*/*": components["schemas"]["SessionResponse"][];
1350+
"*/*": components["schemas"]["PageResponseSessionResponse"];
13291351
};
13301352
};
13311353
/** @description 인증 실패 */
@@ -1334,7 +1356,7 @@ export interface operations {
13341356
[name: string]: unknown;
13351357
};
13361358
content: {
1337-
"*/*": components["schemas"]["SessionResponse"][];
1359+
"*/*": components["schemas"]["PageResponseSessionResponse"];
13381360
};
13391361
};
13401362
};

0 commit comments

Comments
 (0)