From c09fc46b4e7f338eb6019973e180aec06701d32d Mon Sep 17 00:00:00 2001 From: jmj Date: Wed, 3 Jun 2026 10:55:00 +0900 Subject: [PATCH] =?UTF-8?q?feat(session):=20=EC=84=B8=EC=85=98=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=20=ED=8E=98=EC=9D=B4=EC=A7=80=EB=84=A4=EC=9D=B4?= =?UTF-8?q?=EC=85=98=20+=20=ED=9E=88=EC=8A=A4=ED=86=A0=EB=A6=AC=20"?= =?UTF-8?q?=EB=8D=94=20=EB=B3=B4=EA=B8=B0"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /api/sessions 가 전체 세션을 List 로 반환하던 것을 페이지네이션으로. Core: - InterviewSessionRepository.findByUser_IdAndDeletedFalse(userId, Pageable) - SessionService.list → listPaged(userId, Pageable): Page - SessionController.list → PageResponse (@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 --- backend/openapi.json | 72 ++++++++++++++++--- .../session/application/SessionService.java | 9 +-- .../domain/InterviewSessionRepository.java | 3 + .../presentation/SessionController.java | 17 +++-- .../src/features/history/api/historyApi.ts | 8 ++- .../src/features/history/model/useHistory.ts | 10 ++- .../history/ui/SessionHistoryList.tsx | 25 ++++++- frontend/src/shared/api/generated.ts | 28 +++++++- 8 files changed, 145 insertions(+), 27 deletions(-) diff --git a/backend/openapi.json b/backend/openapi.json index e6867da9..ca31a921 100644 --- a/backend/openapi.json +++ b/backend/openapi.json @@ -220,16 +220,21 @@ "tags" : [ "Sessions" ], "summary" : "내 세션 히스토리 (US-15)", "operationId" : "listSessions", + "parameters" : [ { + "name" : "pageable", + "in" : "query", + "required" : true, + "schema" : { + "$ref" : "#/components/schemas/Pageable" + } + } ], "responses" : { "200" : { "description" : "세션 목록", "content" : { "*/*" : { "schema" : { - "type" : "array", - "items" : { - "$ref" : "#/components/schemas/SessionResponse" - } + "$ref" : "#/components/schemas/PageResponseSessionResponse" } } } @@ -239,10 +244,7 @@ "content" : { "*/*" : { "schema" : { - "type" : "array", - "items" : { - "$ref" : "#/components/schemas/SessionResponse" - } + "$ref" : "#/components/schemas/PageResponseSessionResponse" } } } @@ -3070,6 +3072,60 @@ } } }, + "Pageable" : { + "type" : "object", + "properties" : { + "page" : { + "type" : "integer", + "format" : "int32", + "minimum" : 0 + }, + "size" : { + "type" : "integer", + "format" : "int32", + "minimum" : 1 + }, + "sort" : { + "type" : "array", + "items" : { + "type" : "string" + } + } + } + }, + "PageResponseSessionResponse" : { + "type" : "object", + "properties" : { + "content" : { + "type" : "array", + "items" : { + "$ref" : "#/components/schemas/SessionResponse" + } + }, + "page" : { + "type" : "integer", + "format" : "int32" + }, + "size" : { + "type" : "integer", + "format" : "int32" + }, + "totalElements" : { + "type" : "integer", + "format" : "int64" + }, + "totalPages" : { + "type" : "integer", + "format" : "int32" + }, + "first" : { + "type" : "boolean" + }, + "last" : { + "type" : "boolean" + } + } + }, "FeedbackResponse" : { "type" : "object", "properties" : { diff --git a/backend/src/main/java/com/stackup/stackup/session/application/SessionService.java b/backend/src/main/java/com/stackup/stackup/session/application/SessionService.java index 1cb3ad36..c6083275 100644 --- a/backend/src/main/java/com/stackup/stackup/session/application/SessionService.java +++ b/backend/src/main/java/com/stackup/stackup/session/application/SessionService.java @@ -21,6 +21,8 @@ import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.context.ApplicationEventPublisher; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -65,11 +67,10 @@ public SessionResult create(Long userId, SessionCreateCommand command) { return SessionResult.of(session, linkedIds); } - public List list(Long userId) { + public Page listPaged(Long userId, Pageable pageable) { loadUser(userId); - return sessionRepository.findByUser_IdAndDeletedFalseOrderByIdDesc(userId).stream() - .map(s -> SessionResult.of(s, contextDocumentIds(s.getId()))) - .toList(); + return sessionRepository.findByUser_IdAndDeletedFalse(userId, pageable) + .map(s -> SessionResult.of(s, contextDocumentIds(s.getId()))); } public SessionResult get(Long userId, Long sessionId) { diff --git a/backend/src/main/java/com/stackup/stackup/session/domain/InterviewSessionRepository.java b/backend/src/main/java/com/stackup/stackup/session/domain/InterviewSessionRepository.java index ac58a777..2f6f02c8 100644 --- a/backend/src/main/java/com/stackup/stackup/session/domain/InterviewSessionRepository.java +++ b/backend/src/main/java/com/stackup/stackup/session/domain/InterviewSessionRepository.java @@ -2,6 +2,7 @@ import java.util.List; import java.util.Optional; +import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; @@ -15,6 +16,8 @@ public interface InterviewSessionRepository extends JpaRepository findByUser_IdAndDeletedFalseOrderByIdDesc(Long userId); + Page findByUser_IdAndDeletedFalse(Long userId, Pageable pageable); + long countByUser_Id(Long userId); long countByUser_IdAndStatus(Long userId, SessionStatus status); diff --git a/backend/src/main/java/com/stackup/stackup/session/presentation/SessionController.java b/backend/src/main/java/com/stackup/stackup/session/presentation/SessionController.java index 8bbf3ac3..03f1f894 100644 --- a/backend/src/main/java/com/stackup/stackup/session/presentation/SessionController.java +++ b/backend/src/main/java/com/stackup/stackup/session/presentation/SessionController.java @@ -1,5 +1,6 @@ package com.stackup.stackup.session.presentation; +import com.stackup.stackup.common.response.PageResponse; import com.stackup.stackup.common.security.UserPrincipal; import com.stackup.stackup.session.application.SessionService; import com.stackup.stackup.session.presentation.dto.SessionCreateRequest; @@ -11,8 +12,10 @@ import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; -import java.util.List; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.web.PageableDefault; import org.springframework.http.HttpStatus; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.DeleteMapping; @@ -60,10 +63,14 @@ public SessionResponse create( @ApiResponse(responseCode = "401", description = "인증 실패") }) @GetMapping - public List list(@AuthenticationPrincipal UserPrincipal principal) { - return sessionService.list(principal.userId()).stream() - .map(SessionResponse::from) - .toList(); + public PageResponse list( + @AuthenticationPrincipal UserPrincipal principal, + @PageableDefault(size = 20, sort = "id", direction = Sort.Direction.DESC) + Pageable pageable + ) { + return PageResponse.from( + sessionService.listPaged(principal.userId(), pageable).map(SessionResponse::from) + ); } @Operation(operationId = "getSession", summary = "세션 상세 (US-16)") diff --git a/frontend/src/features/history/api/historyApi.ts b/frontend/src/features/history/api/historyApi.ts index 754303b5..919d1182 100644 --- a/frontend/src/features/history/api/historyApi.ts +++ b/frontend/src/features/history/api/historyApi.ts @@ -3,10 +3,14 @@ import type { components } from '@/shared/api/generated' type S = components['schemas'] export type Session = S['SessionResponse'] +export type SessionPage = S['PageResponseSessionResponse'] export type UserStats = S['UserStatsResponse'] -export async function listSessions(): Promise { - return (await apiClient.get('/api/sessions')).data +// Spring 이 ?page=&size= 를 바인딩하므로 쿼리 파라미터로 직접 전달. +export async function listSessions(page = 0, size = 20): Promise { + return ( + await apiClient.get('/api/sessions', { params: { page, size } }) + ).data } export async function getUserStats(): Promise { diff --git a/frontend/src/features/history/model/useHistory.ts b/frontend/src/features/history/model/useHistory.ts index b3a81fc9..253b3c2e 100644 --- a/frontend/src/features/history/model/useHistory.ts +++ b/frontend/src/features/history/model/useHistory.ts @@ -1,4 +1,4 @@ -import { useQuery } from '@tanstack/react-query' +import { useInfiniteQuery, useQuery } from '@tanstack/react-query' import { getUserStats, listSessions } from '../api/historyApi' export const historyKeys = { @@ -6,8 +6,14 @@ export const historyKeys = { stats: ['history', 'stats'] as const, } +// 무한 스크롤: last 가 아니면 다음 page 번호를 pageParam 으로. export function useSessions() { - return useQuery({ queryKey: historyKeys.sessions, queryFn: listSessions }) + return useInfiniteQuery({ + queryKey: historyKeys.sessions, + queryFn: ({ pageParam }) => listSessions(pageParam), + initialPageParam: 0, + getNextPageParam: (last) => (last.last ? undefined : (last.page ?? 0) + 1), + }) } export function useUserStats() { diff --git a/frontend/src/features/history/ui/SessionHistoryList.tsx b/frontend/src/features/history/ui/SessionHistoryList.tsx index ae3ac8ad..f0fd9458 100644 --- a/frontend/src/features/history/ui/SessionHistoryList.tsx +++ b/frontend/src/features/history/ui/SessionHistoryList.tsx @@ -2,7 +2,15 @@ import { useSessions } from '../model/useHistory' import { SessionCard } from './SessionCard' export function SessionHistoryList() { - const { data, isLoading, isError, refetch } = useSessions() + const { + data, + isLoading, + isError, + refetch, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + } = useSessions() if (isLoading) { return

불러오는 중…

@@ -17,7 +25,9 @@ export function SessionHistoryList() { ) } - if (!data || data.length === 0) { + + const sessions = data?.pages.flatMap((p) => p.content ?? []) ?? [] + if (sessions.length === 0) { return (

아직 진행한 면접이 없어요.

) @@ -25,9 +35,18 @@ export function SessionHistoryList() { return (
- {data.map((s) => ( + {sessions.map((s) => ( ))} + {hasNextPage && ( + + )}
) } diff --git a/frontend/src/shared/api/generated.ts b/frontend/src/shared/api/generated.ts index 58cd0cd5..f75192c0 100644 --- a/frontend/src/shared/api/generated.ts +++ b/frontend/src/shared/api/generated.ts @@ -1064,6 +1064,26 @@ export interface components { /** Format: date-time */ timestamp?: string; }; + Pageable: { + /** Format: int32 */ + page?: number; + /** Format: int32 */ + size?: number; + sort?: string[]; + }; + PageResponseSessionResponse: { + content?: components["schemas"]["SessionResponse"][]; + /** Format: int32 */ + page?: number; + /** Format: int32 */ + size?: number; + /** Format: int64 */ + totalElements?: number; + /** Format: int32 */ + totalPages?: number; + first?: boolean; + last?: boolean; + }; FeedbackResponse: { /** Format: int64 */ id?: number; @@ -1312,7 +1332,9 @@ export interface operations { }; listSessions: { parameters: { - query?: never; + query: { + pageable: components["schemas"]["Pageable"]; + }; header?: never; path?: never; cookie?: never; @@ -1325,7 +1347,7 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["SessionResponse"][]; + "*/*": components["schemas"]["PageResponseSessionResponse"]; }; }; /** @description 인증 실패 */ @@ -1334,7 +1356,7 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["SessionResponse"][]; + "*/*": components["schemas"]["PageResponseSessionResponse"]; }; }; };