diff --git a/components/features/my-page/recommend/RecommendedBookList.tsx b/components/features/my-page/recommend/RecommendedBookList.tsx index 048db40..22daa10 100644 --- a/components/features/my-page/recommend/RecommendedBookList.tsx +++ b/components/features/my-page/recommend/RecommendedBookList.tsx @@ -1,10 +1,9 @@ "use client"; -import { useEffect, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; import { Skeleton } from "@/components/ui/skeleton"; import { RecommendedBookListItem } from "./RecommendedBookListItem"; -import { fetchRecommendBooks } from "@/lib/mock/my-page-recommend-book"; -import type { MyPageRecommendBooksResponse } from "@/types/myPage"; +import { getRecommendBooks, MY_PAGE_QUERY_KEYS } from "@/lib/api/endpoints/myPage"; function ListItemSkeleton() { return ( @@ -25,17 +24,10 @@ function ListItemSkeleton() { } export function RecommendedBookList() { - const [booksData, setBooksData] = - useState(null); - const [isLoading, setIsLoading] = useState(true); - const [isError, setIsError] = useState(false); - - useEffect(() => { - fetchRecommendBooks() - .then((data) => setBooksData(data)) - .catch(() => setIsError(true)) - .finally(() => setIsLoading(false)); - }, []); + const { data: booksData, isLoading, isError } = useQuery({ + queryKey: MY_PAGE_QUERY_KEYS.recommendBooks, + queryFn: getRecommendBooks, + }); if (isError) { return ( diff --git a/components/features/my-page/recommend/RecommendedHomePostList.tsx b/components/features/my-page/recommend/RecommendedHomePostList.tsx index 5b1e19a..317791d 100644 --- a/components/features/my-page/recommend/RecommendedHomePostList.tsx +++ b/components/features/my-page/recommend/RecommendedHomePostList.tsx @@ -1,10 +1,9 @@ "use client"; -import { useEffect, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; import { Skeleton } from "@/components/ui/skeleton"; import { RecommendedHomePostListItem } from "./RecommendedHomePostListItem"; -import { fetchRecommendHomePosts } from "@/lib/mock/my-page-recommend-home"; -import type { MyPageRecommendContentsResponse } from "@/types/myPage"; +import { getRecommendContents, MY_PAGE_QUERY_KEYS } from "@/lib/api/endpoints/myPage"; function ListItemSkeleton() { return ( @@ -21,17 +20,10 @@ function ListItemSkeleton() { } export function RecommendedHomePostList() { - const [postsData, setPostsData] = - useState(null); - const [isLoading, setIsLoading] = useState(true); - const [isError, setIsError] = useState(false); - - useEffect(() => { - fetchRecommendHomePosts() - .then((data) => setPostsData(data)) - .catch(() => setIsError(true)) - .finally(() => setIsLoading(false)); - }, []); + const { data: postsData, isLoading, isError } = useQuery({ + queryKey: MY_PAGE_QUERY_KEYS.recommendContents, + queryFn: getRecommendContents, + }); if (isError) { return ( diff --git a/components/features/my-page/recommend/RecommendedSection.tsx b/components/features/my-page/recommend/RecommendedSection.tsx index 1774573..6d52f20 100644 --- a/components/features/my-page/recommend/RecommendedSection.tsx +++ b/components/features/my-page/recommend/RecommendedSection.tsx @@ -1,20 +1,18 @@ "use client"; -import { useEffect, useState } from "react"; import Link from "next/link"; import { ArrowRight } from "lucide-react"; +import { useQuery } from "@tanstack/react-query"; import { Skeleton } from "@/components/ui/skeleton"; import { RecommendedHomePostCard } from "./RecommendedHomePostCard"; import { RecommendedVideoCard } from "./RecommendedVideoCard"; import { RecommendedBookCard } from "./RecommendedBookCard"; -import { fetchRecommendHomePosts } from "@/lib/mock/my-page-recommend-home"; -import { fetchRecommendVideos } from "@/lib/mock/my-page-recommend-video"; -import { fetchRecommendBooks } from "@/lib/mock/my-page-recommend-book"; -import type { - MyPageRecommendContentsResponse, - MyPageRecommendYoutubeResponse, - MyPageRecommendBooksResponse, -} from "@/types/myPage"; +import { + getRecommendContents, + getRecommendYoutube, + getRecommendBooks, + MY_PAGE_QUERY_KEYS, +} from "@/lib/api/endpoints/myPage"; function SubSectionHeader({ title, href }: { title: string; href: string }) { return ( @@ -58,47 +56,37 @@ function BookCardSkeleton() { } export function RecommendedSection() { - const [homePostsData, setHomePostsData] = - useState(null); - const [videosData, setVideosData] = - useState(null); - const [booksData, setBooksData] = - useState(null); - const [isLoading, setIsLoading] = useState(true); - const [isError, setIsError] = useState(false); + const { + data: homePostsData, + isLoading: homeLoading, + isError: homeError, + } = useQuery({ + queryKey: MY_PAGE_QUERY_KEYS.recommendContents, + queryFn: getRecommendContents, + }); - useEffect(() => { - Promise.all([ - fetchRecommendHomePosts(4), - fetchRecommendVideos(4), - fetchRecommendBooks(4), - ]) - .then(([postsData, vidsData, bks]) => { - setHomePostsData(postsData); - setVideosData(vidsData); - setBooksData(bks); - }) - .catch(() => setIsError(true)) - .finally(() => setIsLoading(false)); - }, []); + const { + data: videosData, + isLoading: videosLoading, + isError: videosError, + } = useQuery({ + queryKey: MY_PAGE_QUERY_KEYS.recommendYoutube, + queryFn: getRecommendYoutube, + }); + + const { + data: booksData, + isLoading: booksLoading, + isError: booksError, + } = useQuery({ + queryKey: MY_PAGE_QUERY_KEYS.recommendBooks, + queryFn: getRecommendBooks, + }); const homePosts = homePostsData?.contents ?? []; const videos = videosData?.videos ?? []; const books = booksData?.books ?? []; - if (isError) { - return ( -
-

- 사용자 맞춤 추천 -

-

- 불러오는 중 오류가 발생했습니다. -

-
- ); - } - return (

@@ -108,12 +96,16 @@ export function RecommendedSection() {
- {isLoading ? ( + {homeLoading ? (
{Array.from({ length: 4 }).map((_, i) => ( ))}
+ ) : homeError ? ( +

+ 불러오는 중 오류가 발생했습니다. +

) : !homePostsData?.isPersonalized ? (

{homePostsData?.message ?? @@ -135,12 +127,16 @@ export function RecommendedSection() { title="추천 유튜브" href="/my-page/recommend/video" /> - {isLoading ? ( + {videosLoading ? (

{Array.from({ length: 4 }).map((_, i) => ( ))}
+ ) : videosError ? ( +

+ 불러오는 중 오류가 발생했습니다. +

) : !videosData?.isPersonalized ? (

{videosData?.message ?? "아직 추천할 영상이 부족해요. 더 많은 글을 읽어보세요!"} @@ -160,12 +156,16 @@ export function RecommendedSection() {

- {isLoading ? ( + {booksLoading ? (
{Array.from({ length: 4 }).map((_, i) => ( ))}
+ ) : booksError ? ( +

+ 불러오는 중 오류가 발생했습니다. +

) : !booksData?.isPersonalized ? (

{booksData?.message ?? "아직 추천할 도서가 부족해요. 더 많은 글을 읽어보세요!"} diff --git a/components/features/my-page/recommend/RecommendedVideoList.tsx b/components/features/my-page/recommend/RecommendedVideoList.tsx index a420ce7..54dcb35 100644 --- a/components/features/my-page/recommend/RecommendedVideoList.tsx +++ b/components/features/my-page/recommend/RecommendedVideoList.tsx @@ -1,10 +1,9 @@ "use client"; -import { useEffect, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; import { Skeleton } from "@/components/ui/skeleton"; import { RecommendedVideoListItem } from "./RecommendedVideoListItem"; -import { fetchRecommendVideos } from "@/lib/mock/my-page-recommend-video"; -import type { MyPageRecommendYoutubeResponse } from "@/types/myPage"; +import { getRecommendYoutube, MY_PAGE_QUERY_KEYS } from "@/lib/api/endpoints/myPage"; function ListItemSkeleton() { return ( @@ -20,17 +19,10 @@ function ListItemSkeleton() { } export function RecommendedVideoList() { - const [videosData, setVideosData] = - useState(null); - const [isLoading, setIsLoading] = useState(true); - const [isError, setIsError] = useState(false); - - useEffect(() => { - fetchRecommendVideos() - .then((data) => setVideosData(data)) - .catch(() => setIsError(true)) - .finally(() => setIsLoading(false)); - }, []); + const { data: videosData, isLoading, isError } = useQuery({ + queryKey: MY_PAGE_QUERY_KEYS.recommendYoutube, + queryFn: getRecommendYoutube, + }); if (isError) { return ( diff --git a/lib/api/endpoints/myPage.ts b/lib/api/endpoints/myPage.ts new file mode 100644 index 0000000..e339cba --- /dev/null +++ b/lib/api/endpoints/myPage.ts @@ -0,0 +1,34 @@ +import { apiClient } from "../client"; +import type { ApiResponse } from "@/types/api"; +import type { + MyPageRecommendContentsResponse, + MyPageRecommendYoutubeResponse, + MyPageRecommendBooksResponse, +} from "@/types/myPage"; + +export const MY_PAGE_QUERY_KEYS = { + recommendContents: ["myPage", "recommendContents"] as const, + recommendYoutube: ["myPage", "recommendYoutube"] as const, + recommendBooks: ["myPage", "recommendBooks"] as const, +}; + +export async function getRecommendContents(): Promise { + const res = await apiClient.get>( + "/recommend/contents", + ); + return res.data.data; +} + +export async function getRecommendYoutube(): Promise { + const res = await apiClient.get>( + "/recommend/youtube", + ); + return res.data.data; +} + +export async function getRecommendBooks(): Promise { + const res = await apiClient.get>( + "/recommend/books", + ); + return res.data.data; +} diff --git a/lib/mock/my-page-recommend-book.ts b/lib/mock/my-page-recommend-book.ts deleted file mode 100644 index 7a08c12..0000000 --- a/lib/mock/my-page-recommend-book.ts +++ /dev/null @@ -1,132 +0,0 @@ -import type { - MyPageRecommendBook, - MyPageRecommendBooksResponse, -} from "@/types/myPage"; - -export const MOCK_RECOMMEND_BOOKS: MyPageRecommendBook[] = [ - { - title: "클린 아키텍처", - authors: ["로버트 C. 마틴"], - publisher: "인사이트", - thumbnail: "https://picsum.photos/seed/book1/300/400", - url: "https://www.yes24.com/Product/Goods/77283734", - contents: - "소프트웨어 구조 설계의 핵심 원칙과 클린 아키텍처의 개념을 설명하는 책.", - price: 16000, - salePrice: 14400, - }, - { - title: "가상 면접 사례로 배우는 대규모 시스템 설계 기초", - authors: ["알렉스 쉬"], - publisher: "인사이트", - thumbnail: "https://picsum.photos/seed/book2/300/400", - url: "https://www.yes24.com/Product/Goods/102819435", - contents: - "대규모 시스템 설계 면접을 대비하기 위한 핵심 개념과 설계 방법을 다룬다.", - price: 21000, - salePrice: 18900, - }, - { - title: "이펙티브 타입스크립트", - authors: ["댄 밴더캄"], - publisher: "인사이트", - thumbnail: null, - url: "https://www.yes24.com/Product/Goods/102124327", - contents: - "타입스크립트를 더 안전하고 효과적으로 사용하는 방법을 소개하는 실전 가이드.", - price: 32000, - salePrice: -1, - }, - { - title: "데이터 중심 애플리케이션 설계", - authors: ["마틴 클레퍼만"], - publisher: "위키북스", - thumbnail: "https://picsum.photos/seed/book4/300/400", - url: "https://www.yes24.com/Product/Goods/59566585", - contents: - "데이터 시스템의 설계 원칙과 분산 시스템의 핵심 개념을 깊이 있게 설명한다.", - price: 28000, - salePrice: 25200, - }, - { - title: "리팩터링 2판", - authors: ["마틴 파울러"], - publisher: "한빛미디어", - thumbnail: "https://picsum.photos/seed/book5/300/400", - url: "https://www.yes24.com/Product/Goods/89649360", - contents: - "코드를 더 깔끔하게 만드는 리팩터링 기법을 JavaScript 예제와 함께 설명한다.", - price: 38000, - salePrice: -1, - }, - { - title: "HTTP 완벽 가이드", - authors: ["데이빗 고울리", "브라이언 토티"], - publisher: "인사이트", - thumbnail: null, - url: "https://www.yes24.com/Product/Goods/15381085", - contents: - "웹의 근간이 되는 HTTP 프로토콜의 동작 원리와 최신 스펙을 상세히 다룬다.", - price: 49000, - salePrice: 44100, - }, - { - title: "자바스크립트 딥 다이브", - authors: ["이웅모"], - publisher: "위키북스", - thumbnail: "https://picsum.photos/seed/book7/300/400", - url: "https://www.yes24.com/Product/Goods/92742567", - contents: - "자바스크립트의 동작 원리를 기초부터 깊이 있게 이해할 수 있는 국내 저술서.", - price: 45000, - salePrice: -1, - }, - { - title: "쏙쏙 들어오는 함수형 코딩", - authors: ["에릭 노먼드"], - publisher: "제이펍", - thumbnail: "https://picsum.photos/seed/book8/300/400", - url: "https://www.yes24.com/Product/Goods/108748841", - contents: - "함수형 프로그래밍 패러다임을 실용적인 관점에서 쉽게 설명하는 입문서.", - price: 33000, - salePrice: 29700, - }, - { - title: "실용주의 프로그래머", - authors: ["데이비드 토머스", "앤드류 헌트"], - publisher: "인사이트", - thumbnail: null, - url: "https://www.yes24.com/Product/Goods/107077663", - contents: - "더 나은 개발자가 되기 위한 실질적인 조언을 담은 개발 철학서의 고전.", - price: 35000, - salePrice: -1, - }, - { - title: "그림으로 배우는 네트워크 원리", - authors: ["Gene"], - publisher: "영진닷컴", - thumbnail: "https://picsum.photos/seed/book10/300/400", - url: "https://www.yes24.com/Product/Goods/104369152", - contents: - "네트워크의 기초 개념을 풍부한 그림과 함께 쉽게 이해할 수 있도록 설명한다.", - price: 24000, - salePrice: 21600, - }, -]; - -export async function fetchRecommendBooks( - count?: number, -): Promise { - await new Promise((resolve) => setTimeout(resolve, 400)); - const books = - count !== undefined - ? MOCK_RECOMMEND_BOOKS.slice(0, count) - : MOCK_RECOMMEND_BOOKS; - return { - books, - isPersonalized: true, - message: undefined, - }; -} diff --git a/lib/mock/my-page-recommend-home.ts b/lib/mock/my-page-recommend-home.ts deleted file mode 100644 index 98f48f1..0000000 --- a/lib/mock/my-page-recommend-home.ts +++ /dev/null @@ -1,229 +0,0 @@ -import type { - MyPageRecommendHomePost, - MyPageRecommendContentsResponse, -} from "@/types/myPage"; - -export const MOCK_RECOMMEND_HOME_POSTS: MyPageRecommendHomePost[] = [ - { - id: "content-301", - title: "React 서버 컴포넌트 완전 정복 — 렌더링 모델과 캐싱 전략", - translatedTitle: null, - author: null, - sourceName: "velog", - thumbnailUrl: "https://picsum.photos/seed/rec1/400/240", - thumbnailWidth: 400, - thumbnailHeight: 240, - canonicalUrl: "https://velog.io/@example/react-server-components", - tags: ["React", "Next.js"], - preview: "RSC의 렌더링 흐름과 fetch 캐싱 옵션을 심층 분석합니다.", - publishedAt: "2026-04-20T10:00:00Z", - isScrapped: false, - isLiked: false, - score: 0.98, - likes: 42, - commentsCount: null, - isAnswered: null, - }, - { - id: "content-302", - title: - "How I built a type-safe API client with TypeScript satisfies operator", - translatedTitle: - "TypeScript satisfies 연산자로 타입 안전 API 클라이언트 만든 방법", - author: "Dan Abramov", - sourceName: "medium", - thumbnailUrl: "https://picsum.photos/seed/rec2/400/240", - thumbnailWidth: 400, - thumbnailHeight: 240, - canonicalUrl: "https://medium.com/@example/typescript-satisfies-api-client", - tags: ["TypeScript"], - preview: "as와 satisfies의 차이점과 실전 활용 패턴을 정리합니다.", - publishedAt: "2026-04-18T09:00:00Z", - isScrapped: false, - isLiked: false, - score: 0.95, - likes: 38, - commentsCount: null, - isAnswered: null, - }, - { - id: "content-303", - title: "모노레포 환경에서 Turborepo로 빌드 캐시 최적화하기", - translatedTitle: null, - author: null, - sourceName: "toss_tech", - thumbnailUrl: null, - thumbnailWidth: null, - thumbnailHeight: null, - canonicalUrl: "https://toss.tech/posts/turborepo-build-cache", - tags: ["Turborepo", "Monorepo"], - preview: null, - publishedAt: "2026-04-15T14:00:00Z", - isScrapped: false, - isLiked: false, - score: 0.91, - likes: 27, - commentsCount: null, - isAnswered: null, - }, - { - id: "content-304", - title: "Understanding PostgreSQL Partitioning: Range, List, and Hash", - translatedTitle: "PostgreSQL 파티셔닝 이해하기: 레인지, 리스트, 해시", - author: "Bruce Momjian", - sourceName: "stackoverflow", - thumbnailUrl: "https://picsum.photos/seed/rec4/400/240", - thumbnailWidth: 400, - thumbnailHeight: 240, - canonicalUrl: - "https://stackoverflow.com/questions/example/postgresql-partitioning", - tags: ["PostgreSQL", "Database"], - preview: - "레인지/리스트/해시 파티셔닝을 실제 서비스 케이스에 적용한 경험을 공유합니다.", - publishedAt: "2026-04-13T11:00:00Z", - isScrapped: false, - isLiked: false, - score: 0.89, - likes: 55, - commentsCount: 12, - isAnswered: true, - }, - { - id: "content-305", - title: "Jest + React Testing Library로 컴포넌트 테스트 작성하기", - translatedTitle: null, - author: null, - sourceName: "kakao_tech", - thumbnailUrl: "https://picsum.photos/seed/rec5/400/240", - thumbnailWidth: 400, - thumbnailHeight: 240, - canonicalUrl: "https://tech.kakao.com/posts/jest-rtl-component-testing", - tags: ["Jest", "React", "Testing"], - preview: "단위 테스트부터 인터랙션 테스트까지 실전 패턴을 다룹니다.", - publishedAt: "2026-04-11T09:00:00Z", - isScrapped: false, - isLiked: false, - score: 0.87, - likes: 31, - commentsCount: null, - isAnswered: null, - }, - { - id: "content-306", - title: "Migrating from Spring Boot 2 to 3: What you need to know", - translatedTitle: "Spring Boot 2에서 3으로 마이그레이션: 알아야 할 것들", - author: "Josh Long", - sourceName: "medium", - thumbnailUrl: null, - thumbnailWidth: null, - thumbnailHeight: null, - canonicalUrl: "https://medium.com/@example/spring-boot-3-migration", - tags: ["Spring Boot", "Java"], - preview: - "Spring Boot 2에서 3으로 업그레이드할 때 반드시 확인해야 할 사항들을 정리합니다.", - publishedAt: "2026-04-09T14:00:00Z", - isScrapped: false, - isLiked: false, - score: 0.85, - likes: 49, - commentsCount: null, - isAnswered: null, - }, - { - id: "content-307", - title: "AWS Lambda + API Gateway로 서버리스 백엔드 구축하기", - translatedTitle: null, - author: null, - sourceName: "naver_d2", - thumbnailUrl: "https://picsum.photos/seed/rec7/400/240", - thumbnailWidth: 400, - thumbnailHeight: 240, - canonicalUrl: "https://d2.naver.com/posts/aws-lambda-api-gateway", - tags: ["AWS", "Serverless"], - preview: null, - publishedAt: "2026-04-07T10:00:00Z", - isScrapped: false, - isLiked: false, - score: 0.83, - likes: 22, - commentsCount: null, - isAnswered: null, - }, - { - id: "content-308", - title: "A deep dive into Tailwind CSS v4 and the new @theme directive", - translatedTitle: "Tailwind CSS v4 심층 분석 — 새로운 @theme 지시어", - author: "Adam Wathan", - sourceName: "medium", - thumbnailUrl: "https://picsum.photos/seed/rec8/400/240", - thumbnailWidth: 400, - thumbnailHeight: 240, - canonicalUrl: "https://medium.com/@example/tailwind-css-v4-theme", - tags: ["Tailwind CSS", "CSS"], - preview: "v3에서 v4로 마이그레이션하는 방법과 새 토큰 시스템을 설명합니다.", - publishedAt: "2026-04-05T11:00:00Z", - isScrapped: false, - isLiked: false, - score: 0.81, - likes: 36, - commentsCount: null, - isAnswered: null, - }, - { - id: "content-309", - title: "CI/CD 파이프라인 구축 — GitHub Actions로 자동 배포 환경 만들기", - translatedTitle: null, - author: null, - sourceName: "toss_tech", - thumbnailUrl: null, - thumbnailWidth: null, - thumbnailHeight: null, - canonicalUrl: "https://toss.tech/posts/github-actions-cicd", - tags: ["GitHub Actions", "CI/CD"], - preview: - "테스트, 빌드, 배포를 자동화하는 전체 워크플로우를 구성하는 방법을 소개합니다.", - publishedAt: "2026-04-03T09:00:00Z", - isScrapped: false, - isLiked: false, - score: 0.79, - likes: 18, - commentsCount: null, - isAnswered: null, - }, - { - id: "content-310", - title: "How to make your web app accessible: A practical WCAG 2.1 guide", - translatedTitle: "웹 앱 접근성 확보 방법: WCAG 2.1 실전 가이드", - author: "Léonie Watson", - sourceName: "stackoverflow", - thumbnailUrl: "https://picsum.photos/seed/rec10/400/240", - thumbnailWidth: 400, - thumbnailHeight: 240, - canonicalUrl: - "https://stackoverflow.com/questions/example/wcag-accessibility", - tags: ["Accessibility", "HTML"], - preview: null, - publishedAt: "2026-04-01T13:00:00Z", - isScrapped: false, - isLiked: false, - score: 0.77, - likes: 14, - commentsCount: 8, - isAnswered: true, - }, -]; - -export async function fetchRecommendHomePosts( - count?: number, -): Promise { - await new Promise((resolve) => setTimeout(resolve, 400)); - const contents = - count !== undefined - ? MOCK_RECOMMEND_HOME_POSTS.slice(0, count) - : MOCK_RECOMMEND_HOME_POSTS; - return { - contents, - isPersonalized: true, - message: null, - }; -} diff --git a/lib/mock/my-page-recommend-video.ts b/lib/mock/my-page-recommend-video.ts deleted file mode 100644 index 1d14166..0000000 --- a/lib/mock/my-page-recommend-video.ts +++ /dev/null @@ -1,152 +0,0 @@ -import type { - MyPageRecommendVideo, - MyPageRecommendYoutubeResponse, -} from "@/types/myPage"; - -export const MOCK_RECOMMEND_VIDEOS: MyPageRecommendVideo[] = [ - { - contentId: "video-content-001", - title: "10분 만에 이해하는 React 19 Actions — useActionState 실전편", - translatedTitle: null, - videoId: "dQw4w9WgXcQ", - channelName: "코딩애플", - duration: "PT10M24S", - thumbnailUrl: "https://picsum.photos/seed/vid1/400/240", - tags: ["React"], - publishedAt: "2026-04-19T00:00:00Z", - isScrapped: false, - isLiked: false, - }, - { - contentId: "video-content-002", - title: "Next.js App Router Full Course — Caching, Streaming & More", - translatedTitle: "Next.js App Router 완전 정복 — 캐싱부터 스트리밍까지", - videoId: "wm5gMKuwSYk", - channelName: "Traversy Media", - duration: "PT42M17S", - thumbnailUrl: "https://picsum.photos/seed/vid2/400/240", - tags: ["Next.js", "React"], - publishedAt: "2026-04-14T00:00:00Z", - isScrapped: false, - isLiked: false, - }, - { - contentId: "video-content-003", - title: "Docker & Kubernetes 입문 — 컨테이너 오케스트레이션 기초", - translatedTitle: null, - videoId: "bhBSlnQcq2k", - channelName: "드림코딩", - duration: "PT28M50S", - thumbnailUrl: null, - tags: ["Docker", "Kubernetes"], - publishedAt: "2026-04-10T00:00:00Z", - isScrapped: false, - isLiked: false, - }, - { - contentId: "video-content-004", - title: "TanStack Query v5 Complete Migration Guide", - translatedTitle: "TanStack Query v5 마이그레이션 완벽 가이드", - videoId: "novnyCaa7To", - channelName: "Jack Herrington", - duration: "PT18M3S", - thumbnailUrl: "https://picsum.photos/seed/vid4/400/240", - tags: ["TanStack Query", "React"], - publishedAt: "2026-04-07T00:00:00Z", - isScrapped: false, - isLiked: false, - }, - { - contentId: "video-content-005", - title: "TypeScript 제네릭 완전 정복 — 실전 패턴 7가지", - translatedTitle: null, - videoId: "nViEqpgwxHE", - channelName: "코딩애플", - duration: "PT22M15S", - thumbnailUrl: "https://picsum.photos/seed/vid5/400/240", - tags: ["TypeScript"], - publishedAt: "2026-04-04T00:00:00Z", - isScrapped: false, - isLiked: false, - }, - { - contentId: "video-content-006", - title: "Redis from Zero to Hero — Caching Strategies & Session Management", - translatedTitle: "Redis 기초부터 실전까지 — 캐싱 전략과 세션 관리", - videoId: "XltTfSMSHjA", - channelName: "드림코딩", - duration: "PT35M40S", - thumbnailUrl: null, - tags: ["Redis", "Backend"], - publishedAt: "2026-04-01T00:00:00Z", - isScrapped: false, - isLiked: false, - }, - { - contentId: "video-content-007", - title: "CI/CD in 100 Seconds — GitHub Actions", - translatedTitle: "CI/CD 파이프라인 구축 — GitHub Actions 실전편", - videoId: "scEDHsr3APg", - channelName: "Fireship", - duration: "PT14M32S", - thumbnailUrl: "https://picsum.photos/seed/vid7/400/240", - tags: ["GitHub Actions", "CI/CD"], - publishedAt: "2026-03-28T00:00:00Z", - isScrapped: false, - isLiked: false, - }, - { - contentId: "video-content-008", - title: "Spring Boot REST API 설계 — 실무 패턴과 예외 처리", - translatedTitle: null, - videoId: "9SGDpanrc8U", - channelName: "우아한테크", - duration: "PT51M8S", - thumbnailUrl: "https://picsum.photos/seed/vid8/400/240", - tags: ["Spring Boot", "Java"], - publishedAt: "2026-03-25T00:00:00Z", - isScrapped: false, - isLiked: false, - }, - { - contentId: "video-content-009", - title: "SQL 쿼리 최적화 — 인덱스 설계와 실행 계획 분석", - translatedTitle: null, - videoId: "7kmEBi0bPdQ", - channelName: "데이터리안", - duration: "PT38M20S", - thumbnailUrl: null, - tags: ["SQL", "Database"], - publishedAt: "2026-03-22T00:00:00Z", - isScrapped: false, - isLiked: false, - }, - { - contentId: "video-content-010", - title: "Tailwind CSS v4 — Everything New in 10 Minutes", - translatedTitle: "Tailwind CSS v4 신기능 총정리 — 10분 핵심 정리", - videoId: "6biMWgD6_T4", - channelName: "Traversy Media", - duration: "PT9M55S", - thumbnailUrl: "https://picsum.photos/seed/vid10/400/240", - tags: ["Tailwind CSS", "CSS"], - publishedAt: "2026-03-18T00:00:00Z", - isScrapped: false, - isLiked: false, - }, -]; - -export async function fetchRecommendVideos( - count?: number, -): Promise { - await new Promise((resolve) => setTimeout(resolve, 400)); - const videos = - count !== undefined - ? MOCK_RECOMMEND_VIDEOS.slice(0, count) - : MOCK_RECOMMEND_VIDEOS; - return { - videos, - isPersonalized: true, - message: null, - }; -}