diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index b9a771f0..34caac0e 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -5,6 +5,8 @@ import "@/shared/styles/globals.css";
import Header from "@/widgets/header/ui/Header";
import { ModalContainer } from "@/shared/ui/ModalContainer/ModalContainer";
import ChatContainer from "@/features/chat/ui/ChatContainer";
+import { ReactQueryProvider } from "@/shared/lib/provider";
+import { ClientLayout } from "@/shared/lib/ClientLayout";
const myFont = localFont({
src: "../shared/fonts/PretendardVariable.woff2",
@@ -23,25 +25,29 @@ export default function RootLayout({
return (
-
-
-
+
+
+
+
+
- Loading...}
- >
- {children}
-
+ Loading...}
+ >
+ {children}
+
-
+
-
-
-
+
+
+
-
-
-
+
+
+
+
+
);
diff --git a/src/app/page.tsx b/src/app/page.tsx
index 09fd7c4b..7c85431d 100644
--- a/src/app/page.tsx
+++ b/src/app/page.tsx
@@ -1,8 +1,10 @@
-import SideMenuWrapper from "@/widgets/main/ui/SideMenu/SideMenuWrapper";
-import PostList from "@/entities/post/ui/list/PostList";
-import { serverFetch } from "@/shared/api/fetcher.server";
-import { POST_PAGE_SIZE } from "@/entities/post/model/constants/api";
-import type { Post } from "@/entities/post/model/types/post";
+import {
+ QueryClient,
+ dehydrate,
+ HydrationBoundary,
+} from "@tanstack/react-query";
+import { getPosts } from "@/entities/post/api/getPosts";
+import HomePageClient from "@/widgets/main/ui/Client/HomePage.client";
export default async function HomePage({
searchParams,
@@ -10,39 +12,31 @@ export default async function HomePage({
searchParams: Promise>;
}) {
const params = await searchParams;
+ const initialCategory = params?.category ?? "전체";
+ const initialSort = params?.sort ?? "latest";
+ const initialKeyword = (params?.keyword ?? "").trim();
- const selectedCategory = params?.category ?? "전체";
- const selectedSortOption = params?.sort ?? "latest";
- const page = Number(params?.page ?? 1);
- const keyword = (params?.keyword ?? "").trim();
+ const queryClient = new QueryClient();
- const query = new URLSearchParams({
- page: String(page),
- size: String(POST_PAGE_SIZE),
- sort: selectedSortOption,
+ await queryClient.prefetchInfiniteQuery({
+ queryKey: ["posts", initialCategory, initialSort, initialKeyword],
+ queryFn: ({ pageParam = 1 }) =>
+ getPosts({
+ category: initialCategory,
+ sort: initialSort,
+ page: pageParam,
+ keyword: initialKeyword,
+ }),
+ initialPageParam: 1,
});
- if (selectedCategory !== "전체") query.append("category", selectedCategory);
- if (keyword) query.append("keyword", keyword);
-
- const { data: posts } = await serverFetch<{ data: Post[] }>(
- `/api/postings?${query.toString()}`,
- { method: "GET" },
- );
return (
-
+
+
+
);
}
diff --git a/src/entities/post/api/getPosts.ts b/src/entities/post/api/getPosts.ts
new file mode 100644
index 00000000..2eecd2cf
--- /dev/null
+++ b/src/entities/post/api/getPosts.ts
@@ -0,0 +1,30 @@
+import { serverFetch } from "@/shared/api/fetcher.server";
+import { POST_PAGE_SIZE } from "@/entities/post/model/constants/api";
+import type { Post } from "@/entities/post/model/types/post";
+
+export async function getPosts({
+ category,
+ keyword,
+ sort,
+ page,
+}: {
+ category: string;
+ keyword?: string;
+ sort?: string;
+ page?: number;
+}) {
+ const query = new URLSearchParams({
+ page: String(page),
+ size: String(POST_PAGE_SIZE),
+ });
+
+ if (category !== "전체") query.append("category", category);
+ if (keyword) query.append("keyword", keyword);
+ if (sort) query.append("sort", sort);
+
+ const { data } = await serverFetch<{ data: Post[] }>(
+ `/api/postings?${query.toString()}`,
+ { method: "GET" },
+ );
+ return data;
+}
diff --git a/src/entities/post/ui/list/PostList.tsx b/src/entities/post/ui/list/PostList.tsx
index 4bfda3aa..f7feb76d 100644
--- a/src/entities/post/ui/list/PostList.tsx
+++ b/src/entities/post/ui/list/PostList.tsx
@@ -1,85 +1,52 @@
"use client";
-import { useEffect, useState } from "react";
-import { useRouter, useSearchParams } from "next/navigation";
-import { apiFetch } from "@/shared/api/fetcher";
+import { useInfiniteQuery } from "@tanstack/react-query";
import { useInfiniteScroll } from "@/shared/lib/useInfiniteScroll";
+import { getPosts } from "@/entities/post/api/getPosts";
import { POST_PAGE_SIZE } from "@/entities/post/model/constants/api";
import { SORT_OPTION_LIST } from "@/widgets/main/model/constants";
import SortMenu from "@/widgets/main/ui/Select/Select";
import PostCard from "@/entities/post/ui/card/PostCard";
-import type { Post } from "@/entities/post/model/types/post";
interface Props {
- initialPosts: Post[];
selectedCategory: string;
selectedSortOption: string;
+ onSortChange: (value: string) => void;
selectedKeyword?: string;
}
export default function PostList({
- initialPosts,
selectedCategory,
selectedSortOption,
+ onSortChange,
selectedKeyword,
}: Props) {
- const router = useRouter();
- const sp = useSearchParams();
-
- const [posts, setPosts] = useState(initialPosts);
- const [page, setPage] = useState(1);
- const [hasMore, setHasMore] = useState(
- initialPosts.length === POST_PAGE_SIZE,
+ const { data, fetchNextPage, hasNextPage, isFetchingNextPage } =
+ useInfiniteQuery({
+ queryKey: [
+ "posts",
+ selectedCategory,
+ selectedSortOption,
+ selectedKeyword,
+ ],
+ queryFn: ({ pageParam = 1 }) =>
+ getPosts({
+ category: selectedCategory,
+ sort: selectedSortOption,
+ page: pageParam,
+ keyword: selectedKeyword,
+ }),
+ getNextPageParam: (lastPage, allPages) =>
+ lastPage.length === POST_PAGE_SIZE ? allPages.length + 1 : undefined,
+ initialPageParam: 1,
+ });
+
+ const posts = data?.pages.flat() ?? [];
+ const lastRef = useInfiniteScroll(
+ () => fetchNextPage(),
+ isFetchingNextPage,
+ hasNextPage,
);
- const [isLoading, setIsLoading] = useState(false);
-
- useEffect(() => {
- setPosts(initialPosts);
- setPage(1);
- setHasMore(initialPosts.length === POST_PAGE_SIZE);
- }, [initialPosts]);
-
- const fetchMore = async () => {
- if (isLoading || !hasMore) return;
- setIsLoading(true);
-
- try {
- const nextPage = page + 1;
-
- const query = new URLSearchParams({
- page: String(nextPage),
- size: String(POST_PAGE_SIZE),
- sort: selectedSortOption,
- });
- if (selectedCategory && selectedCategory !== "전체") {
- query.append("category", selectedCategory);
- }
- if (selectedKeyword) {
- query.append("keyword", selectedKeyword);
- }
-
- const res = await apiFetch<{ data: Post[] }>(
- `/api/postings?${query.toString()}`,
- { method: "GET" },
- );
-
- setPosts((prev) => [...prev, ...res.data]);
- setHasMore(res.data.length === POST_PAGE_SIZE);
- setPage(nextPage);
- } finally {
- setIsLoading(false);
- }
- };
-
- const lastRef = useInfiniteScroll(fetchMore, isLoading, hasMore);
-
- const handleSortChange = (value: string) => {
- const params = new URLSearchParams(sp.toString());
- params.set("sort", value);
-
- const qs = params.toString();
- router.replace(qs ? `/?${qs}` : "/", { scroll: true });
- };
return (
@@ -90,7 +57,7 @@ export default function PostList({
@@ -106,7 +73,7 @@ export default function PostList({
)}
- {isLoading && (
+ {isFetchingNextPage && (
불러오는 중...
)}
diff --git a/src/shared/commands/SearchCommands.ts b/src/shared/commands/SearchCommands.ts
new file mode 100644
index 00000000..3b495037
--- /dev/null
+++ b/src/shared/commands/SearchCommands.ts
@@ -0,0 +1,11 @@
+import { useSearchStore } from "@/shared/model/search.store";
+
+export const SearchCommands = {
+ changeCategory: (category: string) =>
+ useSearchStore.getState().setCategory(category),
+
+ changeSort: (sort: string) => useSearchStore.getState().setSort(sort),
+
+ changeKeyword: (keyword: string) =>
+ useSearchStore.getState().setKeyword(keyword),
+};
diff --git a/src/shared/lib/ClientLayout.tsx b/src/shared/lib/ClientLayout.tsx
new file mode 100644
index 00000000..6fcea7d2
--- /dev/null
+++ b/src/shared/lib/ClientLayout.tsx
@@ -0,0 +1,8 @@
+"use client";
+
+import { useResetKeywordOnPathChange } from "@/shared/lib/useResetKeyword";
+
+export function ClientLayout({ children }: { children: React.ReactNode }) {
+ useResetKeywordOnPathChange();
+ return <>{children}>;
+}
diff --git a/src/shared/lib/provider.tsx b/src/shared/lib/provider.tsx
new file mode 100644
index 00000000..e7b862c7
--- /dev/null
+++ b/src/shared/lib/provider.tsx
@@ -0,0 +1,33 @@
+"use client";
+
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
+import { useState } from "react";
+
+export function ReactQueryProvider({
+ children,
+}: {
+ children: React.ReactNode;
+}) {
+ const [queryClient] = useState(
+ () =>
+ new QueryClient({
+ defaultOptions: {
+ queries: {
+ staleTime: 1000 * 60,
+ gcTime: 1000 * 60 * 5,
+ refetchOnWindowFocus: false,
+ },
+ },
+ }),
+ );
+
+ return (
+
+ {children}
+ {process.env.NODE_ENV === "development" && (
+
+ )}
+
+ );
+}
diff --git a/src/shared/lib/useResetKeyword.ts b/src/shared/lib/useResetKeyword.ts
new file mode 100644
index 00000000..b92c3af7
--- /dev/null
+++ b/src/shared/lib/useResetKeyword.ts
@@ -0,0 +1,18 @@
+"use client";
+
+import { useEffect, useRef } from "react";
+import { usePathname } from "next/navigation";
+import { SearchCommands } from "@/shared/commands/SearchCommands";
+
+export function useResetKeywordOnPathChange() {
+ const pathname = usePathname();
+ const prevPathRef = useRef("");
+
+ useEffect(() => {
+ if (prevPathRef.current && prevPathRef.current !== pathname) {
+ SearchCommands.changeKeyword("");
+ }
+
+ prevPathRef.current = pathname;
+ }, [pathname]);
+}
diff --git a/src/shared/lib/useSearchMediator.ts b/src/shared/lib/useSearchMediator.ts
new file mode 100644
index 00000000..e5426d21
--- /dev/null
+++ b/src/shared/lib/useSearchMediator.ts
@@ -0,0 +1,40 @@
+import { useEffect } from "react";
+import { useSearchStore } from "@/shared/model/search.store";
+
+export function useSearchMediator({
+ initialCategory,
+ initialKeyword,
+ initialSort,
+}: {
+ initialCategory: string;
+ initialKeyword: string;
+ initialSort: string;
+}) {
+ const { category, keyword, sort, setCategory, setKeyword, setSort } =
+ useSearchStore();
+
+ // SSR 초기값 → Zustand 반영
+ useEffect(() => {
+ setCategory(initialCategory);
+ setKeyword(initialKeyword);
+ setSort(initialSort);
+ }, [
+ initialCategory,
+ initialKeyword,
+ initialSort,
+ setCategory,
+ setKeyword,
+ setSort,
+ ]);
+
+ // Zustand 상태 → URL 동기화
+ useEffect(() => {
+ const params = new URLSearchParams();
+
+ if (category !== "전체") params.set("category", category);
+ if (sort) params.set("sort", sort);
+ if (keyword) params.set("keyword", keyword);
+
+ window.history.replaceState(null, "", `/?${params.toString()}`);
+ }, [category, sort, keyword]);
+}
diff --git a/src/shared/model/search.store.ts b/src/shared/model/search.store.ts
new file mode 100644
index 00000000..da2bbb5b
--- /dev/null
+++ b/src/shared/model/search.store.ts
@@ -0,0 +1,19 @@
+import { create } from "zustand";
+
+interface SearchState {
+ category: string;
+ sort: string;
+ keyword: string;
+ setCategory: (c: string) => void;
+ setSort: (s: string) => void;
+ setKeyword: (k: string) => void;
+}
+
+export const useSearchStore = create((set) => ({
+ category: "전체",
+ sort: "latest",
+ keyword: "",
+ setCategory: (category) => set({ category }),
+ setSort: (sort) => set({ sort }),
+ setKeyword: (keyword) => set({ keyword }),
+}));
diff --git a/src/widgets/header/ui/SearchForm.tsx b/src/widgets/header/ui/SearchForm.tsx
index 82e152c0..f8c3cc66 100644
--- a/src/widgets/header/ui/SearchForm.tsx
+++ b/src/widgets/header/ui/SearchForm.tsx
@@ -1,8 +1,10 @@
"use client";
-import { useRouter, useSearchParams } from "next/navigation";
-import { useState } from "react";
+import { useState, useEffect } from "react";
import cn from "@/shared/lib/cn";
+import { useRouter, usePathname } from "next/navigation";
+import { useSearchStore } from "@/shared/model/search.store";
+import { SearchCommands } from "@/shared/commands/SearchCommands";
const SearchForm = ({
className,
@@ -20,18 +22,30 @@ const SearchForm = ({
onSubmitted?: () => void;
}) => {
const router = useRouter();
- const sp = useSearchParams();
- const [value, setValue] = useState(sp.get(name) ?? "");
+ const pathname = usePathname();
+ const { category, keyword } = useSearchStore();
+ const [value, setValue] = useState("");
+
+ useEffect(() => {
+ setValue("");
+ SearchCommands.changeKeyword("");
+ }, [category]);
const onSubmit = (e: React.FormEvent) => {
e.preventDefault();
- const params = new URLSearchParams(sp.toString());
-
const q = value.trim();
- if (q) params.set(name, q);
- else params.delete(name);
+ if (q === keyword) return;
+
+ if (pathname === "/") {
+ SearchCommands.changeKeyword(q);
+ } else {
+ const params = new URLSearchParams({
+ category,
+ keyword: q,
+ });
+ router.push(`/?${params.toString()}`);
+ }
- router.replace("/?" + params.toString(), { scroll: true });
onSubmitted?.();
};
@@ -63,7 +77,6 @@ const SearchForm = ({
value={value}
onChange={(e) => setValue(e.target.value)}
/>
-
diff --git a/src/widgets/main/ui/Client/HomePage.client.tsx b/src/widgets/main/ui/Client/HomePage.client.tsx
new file mode 100644
index 00000000..a47b434c
--- /dev/null
+++ b/src/widgets/main/ui/Client/HomePage.client.tsx
@@ -0,0 +1,39 @@
+"use client";
+
+import SideMenuWrapper from "@/widgets/main/ui/SideMenu/SideMenuWrapper";
+import PostList from "@/entities/post/ui/list/PostList";
+import { useSearchStore } from "@/shared/model/search.store";
+import { useSearchMediator } from "@/shared/lib/useSearchMediator";
+
+interface Props {
+ initialCategory: string;
+ initialSort: string;
+ initialKeyword: string;
+}
+
+export default function HomePageClient({
+ initialCategory,
+ initialSort,
+ initialKeyword,
+}: Props) {
+ useSearchMediator({ initialCategory, initialSort, initialKeyword });
+
+ const { category, keyword, sort, setSort } = useSearchStore();
+
+ return (
+
+ );
+}
diff --git a/src/widgets/main/ui/SideMenu/SideMenuWrapper.tsx b/src/widgets/main/ui/SideMenu/SideMenuWrapper.tsx
index 49a6f39e..aa2c36e3 100644
--- a/src/widgets/main/ui/SideMenu/SideMenuWrapper.tsx
+++ b/src/widgets/main/ui/SideMenu/SideMenuWrapper.tsx
@@ -1,24 +1,18 @@
"use client";
-import { useRouter, useSearchParams } from "next/navigation";
import SideMenu from "./SideMenu";
import { CATEGORY_LIST } from "@/widgets/main/model/constants";
+import { SearchCommands } from "@/shared/commands/SearchCommands";
interface Props {
selectedCategory: string;
}
export default function SideMenuWrapper({ selectedCategory }: Props) {
- const router = useRouter();
- const searchParams = useSearchParams();
-
const handleSelect = (category: string) => {
- const params = new URLSearchParams(searchParams.toString());
- if (category === "전체") params.delete("category");
- else params.set("category", category);
- params.delete("keyword");
- params.delete("sort");
- router.push(`/?${params.toString()}`);
+ if (category === selectedCategory) return;
+ SearchCommands.changeCategory(category);
+ SearchCommands.changeKeyword("");
};
return (