diff --git a/components/features/my-page/scraps/ScrappedPostsList.tsx b/components/features/my-page/scraps/ScrappedPostsList.tsx
index 7f65ce9..e4af0f5 100644
--- a/components/features/my-page/scraps/ScrappedPostsList.tsx
+++ b/components/features/my-page/scraps/ScrappedPostsList.tsx
@@ -1,6 +1,6 @@
"use client";
-import { useEffect, useState, useMemo } from "react";
+import { useEffect, useReducer, useState } from "react";
import { ChevronDown, Search } from "lucide-react";
import { Skeleton } from "@/components/ui/skeleton";
import {
@@ -13,12 +13,32 @@ import {
import { ScrappedPostListItem } from "./ScrappedPostListItem";
import { MyPagePagination } from "../MyPagePagination";
import { fetchMyScraps } from "@/lib/mock/my-page-scraps";
-import type { MyPageScrap } from "@/types/myPage";
-
-const PAGE_SIZE = 10;
+import type { MyPageScrapResponse } from "@/types/myPage";
type SortOrder = "newest" | "oldest";
+type FetchState = {
+ data: MyPageScrapResponse | null;
+ isLoading: boolean;
+ isError: boolean;
+};
+
+type FetchAction =
+ | { type: "start" }
+ | { type: "success"; payload: MyPageScrapResponse }
+ | { type: "error" };
+
+function fetchReducer(state: FetchState, action: FetchAction): FetchState {
+ switch (action.type) {
+ case "start":
+ return { ...state, isLoading: true, isError: false };
+ case "success":
+ return { data: action.payload, isLoading: false, isError: false };
+ case "error":
+ return { ...state, isLoading: false, isError: true };
+ }
+}
+
function ListItemSkeleton() {
return (
@@ -34,43 +54,32 @@ function ListItemSkeleton() {
}
export function ScrappedPostsList() {
- const [scraps, setScraps] = useState
([]);
- const [isLoading, setIsLoading] = useState(true);
- const [isError, setIsError] = useState(false);
+ const [{ data, isLoading, isError }, dispatch] = useReducer(fetchReducer, {
+ data: null,
+ isLoading: true,
+ isError: false,
+ });
const [query, setQuery] = useState("");
const [sort, setSort] = useState("newest");
- const [currentPage, setCurrentPage] = useState(1);
+ const [page, setPage] = useState(0);
useEffect(() => {
- fetchMyScraps()
- .then((data) => setScraps(data))
- .catch(() => setIsError(true))
- .finally(() => setIsLoading(false));
- }, []);
+ let cancelled = false;
- const processed = useMemo(() => {
- const q = query.trim().toLowerCase();
- const filtered = q
- ? scraps.filter(
- (s) =>
- s.title.toLowerCase().includes(q) ||
- s.sourceName.toLowerCase().includes(q) ||
- (s.summary?.toLowerCase().includes(q) ?? false),
- )
- : scraps;
+ dispatch({ type: "start" });
- return [...filtered].sort((a, b) => {
- const diff =
- new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime();
- return sort === "newest" ? -diff : diff;
- });
- }, [scraps, query, sort]);
+ fetchMyScraps({ q: query || undefined, sort, page, size: 10 })
+ .then((res) => {
+ if (!cancelled) dispatch({ type: "success", payload: res });
+ })
+ .catch(() => {
+ if (!cancelled) dispatch({ type: "error" });
+ });
- const totalPages = Math.ceil(processed.length / PAGE_SIZE);
- const pagedItems = processed.slice(
- (currentPage - 1) * PAGE_SIZE,
- currentPage * PAGE_SIZE,
- );
+ return () => {
+ cancelled = true;
+ };
+ }, [query, sort, page]);
if (isLoading) {
return (
@@ -94,7 +103,7 @@ export function ScrappedPostsList() {
- {processed.length}개
+ {data?.totalElements ?? 0}개
@@ -103,7 +112,10 @@ export function ScrappedPostsList() {
type="text"
placeholder="검색"
value={query}
- onChange={(e) => { setQuery(e.target.value); setCurrentPage(1); }}
+ onChange={(e) => {
+ setQuery(e.target.value);
+ setPage(0);
+ }}
className="h-8 w-40 rounded-md border border-border bg-background pl-7 pr-3 text-sm placeholder:text-muted-foreground focus:outline-none"
/>
@@ -115,12 +127,18 @@ export function ScrappedPostsList() {
{ setSort(v as SortOrder); setCurrentPage(1); }}
+ onValueChange={(v) => {
+ setSort(v as SortOrder);
+ setPage(0);
+ }}
>
최신순
-
+
오래된순
@@ -129,23 +147,25 @@ export function ScrappedPostsList() {
- {processed.length === 0 ? (
+ {data?.totalElements === 0 ? (
{query ? "검색 결과가 없습니다." : "스크랩한 글이 없습니다."}
) : (
<>
- {pagedItems.map((scrap) => (
-
+ {data?.content.map((scrap) => (
+
))}
-
+ {data && data.totalPages > 1 && (
+
setPage(nextPage - 1)}
+ className="mt-8 mb-12"
+ />
+ )}
>
)}
diff --git a/components/features/my-page/scraps/ScrappedPostsSection.tsx b/components/features/my-page/scraps/ScrappedPostsSection.tsx
index b3c40bf..0e1bba1 100644
--- a/components/features/my-page/scraps/ScrappedPostsSection.tsx
+++ b/components/features/my-page/scraps/ScrappedPostsSection.tsx
@@ -13,8 +13,8 @@ export function ScrappedPostsSection() {
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
- fetchMyScrapsPreview(4).then((data) => {
- setScraps(data);
+ fetchMyScrapsPreview(4).then((res) => {
+ setScraps(res.content);
setIsLoading(false);
});
}, []);
@@ -56,7 +56,7 @@ export function ScrappedPostsSection() {
) : (
{scraps.map((scrap) => (
-
+
))}
)}
diff --git a/lib/mock/my-page-scraps.ts b/lib/mock/my-page-scraps.ts
index f322d01..2c137e4 100644
--- a/lib/mock/my-page-scraps.ts
+++ b/lib/mock/my-page-scraps.ts
@@ -1,122 +1,152 @@
-import type { MyPageScrap } from "@/types/myPage";
+import type { MyPageScrap, MyPageScrapResponse } from "@/types/myPage";
export const MOCK_SCRAPS: MyPageScrap[] = [
{
- id: "1",
contentId: "content-101",
title: "React 19의 새로운 기능들: useActionState, useFormStatus 완벽 정리",
sourceName: "medium",
thumbnail: "https://picsum.photos/seed/scrap1/400/240",
- createdAt: "2026-04-20T09:15:00Z",
summary:
"React 19에서 도입된 훅들과 Server Actions 통합 방식을 예제 코드와 함께 살펴봅니다.",
+ createdAt: "2026-04-20T09:15:00Z",
},
{
- id: "2",
contentId: "content-102",
title: "TypeScript 5.5 주요 변경사항 — infer 키워드 개선과 strictness 강화",
sourceName: "naver_d2",
thumbnail: "https://picsum.photos/seed/scrap2/400/240",
- createdAt: "2026-04-18T14:30:00Z",
summary:
"TypeScript 5.5의 핵심 업데이트를 마이그레이션 관점에서 정리한 글입니다.",
+ createdAt: "2026-04-18T14:30:00Z",
},
{
- id: "3",
contentId: "content-103",
- title: "Next.js App Router에서 Server Component와 Client Component 경계 설계하기",
+ title:
+ "Next.js App Router에서 Server Component와 Client Component 경계 설계하기",
sourceName: "kakao_tech",
thumbnail: "https://picsum.photos/seed/scrap3/400/240",
+ summary: null,
createdAt: "2026-04-15T11:00:00Z",
},
{
- id: "4",
contentId: "content-104",
title: "PostgreSQL EXPLAIN ANALYZE 읽는 법 — 쿼리 최적화 실전 가이드",
sourceName: "stack overflow",
thumbnail: null,
- createdAt: "2026-04-13T08:45:00Z",
summary: "슬로우 쿼리를 분석하고 인덱스 전략을 수립하는 방법을 다룹니다.",
+ createdAt: "2026-04-13T08:45:00Z",
},
{
- id: "5",
contentId: "content-105",
title: "Docker Compose로 로컬 개발 환경 구성하기 — 실전 템플릿 공개",
sourceName: "velog",
thumbnail: "https://picsum.photos/seed/scrap5/400/240",
+ summary: null,
createdAt: "2026-04-10T16:20:00Z",
},
{
- id: "6",
contentId: "content-106",
title: "REST API 설계 원칙 — 버저닝 전략과 에러 응답 포맷 표준화",
sourceName: "medium",
thumbnail: "https://picsum.photos/seed/scrap6/400/240",
- createdAt: "2026-04-08T10:00:00Z",
summary:
"실무에서 자주 마주치는 API 설계 결정들을 사례 중심으로 설명합니다.",
+ createdAt: "2026-04-08T10:00:00Z",
},
{
- id: "7",
contentId: "content-107",
title: "Redis 캐싱 전략 비교 — Cache-Aside, Write-Through, Write-Behind",
sourceName: "toss_tech",
thumbnail: null,
+ summary: null,
createdAt: "2026-04-05T13:10:00Z",
},
{
- id: "8",
contentId: "content-108",
title: "Zustand v5 마이그레이션 가이드 — 스토어 구조와 미들웨어 변경점",
sourceName: "velog",
thumbnail: "https://picsum.photos/seed/scrap8/400/240",
- createdAt: "2026-04-02T09:30:00Z",
summary:
"Zustand v4에서 v5로 업그레이드할 때 꼭 확인해야 할 변경사항들을 정리했습니다.",
+ createdAt: "2026-04-02T09:30:00Z",
},
{
- id: "9",
contentId: "content-109",
title: "Tailwind CSS v4 마이그레이션 — @theme 토큰과 CSS 변수 전환",
sourceName: "kakao_tech",
thumbnail: "https://picsum.photos/seed/scrap9/400/240",
+ summary:
+ "v3에서 v4로 넘어갈 때 바뀐 설정 방식과 토큰 시스템을 정리합니다.",
createdAt: "2026-03-30T10:00:00Z",
- summary: "v3에서 v4로 넘어갈 때 바뀐 설정 방식과 토큰 시스템을 정리합니다.",
},
{
- id: "10",
contentId: "content-110",
title: "Kubernetes 입문 — Pod, Service, Deployment 핵심 개념 정리",
sourceName: "naver_d2",
thumbnail: null,
- createdAt: "2026-03-27T14:00:00Z",
summary: "컨테이너 오케스트레이션의 핵심 리소스를 예제와 함께 설명합니다.",
+ createdAt: "2026-03-27T14:00:00Z",
},
{
- id: "11",
contentId: "content-111",
title: "웹 성능 최적화 — Core Web Vitals 개선 실전 사례",
sourceName: "toss_tech",
thumbnail: "https://picsum.photos/seed/scrap11/400/240",
- createdAt: "2026-03-24T09:00:00Z",
summary: "LCP, CLS, INP 지표를 실제 서비스에서 개선한 경험을 공유합니다.",
+ createdAt: "2026-03-24T09:00:00Z",
},
{
- id: "12",
contentId: "content-112",
title: "Git 브랜치 전략 — Trunk Based Development vs Git Flow 비교",
sourceName: "medium",
thumbnail: "https://picsum.photos/seed/scrap12/400/240",
+ summary: null,
createdAt: "2026-03-20T11:30:00Z",
},
];
-export async function fetchMyScraps(): Promise {
+type FetchMyScrapsParams = {
+ q?: string;
+ sort?: "newest" | "oldest";
+ page?: number;
+ size?: number;
+};
+
+export async function fetchMyScraps(
+ params?: FetchMyScrapsParams,
+): Promise {
await new Promise((resolve) => setTimeout(resolve, 400));
- return MOCK_SCRAPS;
+
+ const { q, sort = "newest", page = 0, size = 10 } = params ?? {};
+
+ let processed = [...MOCK_SCRAPS];
+
+ if (q) {
+ const keyword = q.trim().toLowerCase();
+ processed = processed.filter(
+ (s) =>
+ s.title.toLowerCase().includes(keyword) ||
+ s.sourceName.toLowerCase().includes(keyword) ||
+ (s.summary?.toLowerCase().includes(keyword) ?? false),
+ );
+ }
+
+ processed.sort((a, b) => {
+ const diff =
+ new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime();
+ return sort === "newest" ? -diff : diff;
+ });
+
+ const totalElements = processed.length;
+ const totalPages = Math.ceil(totalElements / size);
+ const start = page * size;
+ const content = processed.slice(start, start + size);
+
+ return { content, page, size, totalElements, totalPages };
}
-export async function fetchMyScrapsPreview(count = 4): Promise {
- await new Promise((resolve) => setTimeout(resolve, 400));
- return MOCK_SCRAPS.slice(0, count);
+export async function fetchMyScrapsPreview(
+ count = 4,
+): Promise {
+ return fetchMyScraps({ page: 0, size: count, sort: "newest" });
}
diff --git a/types/myPage.ts b/types/myPage.ts
index 72aa059..618afe8 100644
--- a/types/myPage.ts
+++ b/types/myPage.ts
@@ -14,13 +14,20 @@ export interface MyPageQuizHistory {
}
export interface MyPageScrap {
- id: string;
contentId: string;
title: string;
sourceName: string;
thumbnail: string | null;
+ summary: string | null;
createdAt: string;
- summary?: string;
+}
+
+export interface MyPageScrapResponse {
+ content: MyPageScrap[];
+ page: number;
+ size: number;
+ totalElements: number;
+ totalPages: number;
}
export interface MyPageRecommendHomePost {