Skip to content

Commit 57e2592

Browse files
authored
Merge pull request #294 from manNomi/refactor/community
Refactor/community
2 parents c6e207f + c070dbf commit 57e2592

File tree

9 files changed

+317
-29
lines changed

9 files changed

+317
-29
lines changed

package-lock.json

Lines changed: 32 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
"@stomp/stompjs": "^7.1.1",
2424
"@tanstack/react-query": "^5.84.1",
2525
"@tanstack/react-query-devtools": "^5.84.1",
26+
"@tanstack/react-virtual": "^3.13.12",
2627
"@types/js-cookie": "^3.0.6",
2728
"axios": "^1.6.7",
2829
"class-variance-authority": "^0.7.1",
@@ -36,6 +37,7 @@
3637
"lucide-react": "^0.479.0",
3738
"next": "^14.2.4",
3839
"react": "^18",
40+
"react-dom": "^18",
3941
"react-hook-form": "^7.60.0",
4042
"sockjs-client": "^1.6.1",
4143
"tailwind-merge": "^3.0.2",

src/api/boards/clients/useGetPostList.ts

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,17 +13,18 @@ interface UseGetPostListProps {
1313
category?: string | null;
1414
}
1515

16-
const getPostList = (boardCode: string, category: string | null = null): Promise<AxiosResponse<ListPost[]>> =>
17-
publicAxiosInstance.get(`/boards/${boardCode}`, {
18-
params: {
19-
category,
20-
},
21-
});
16+
const getPostList = (boardCode: string, category: string | null = null): Promise<AxiosResponse<ListPost[]>> => {
17+
// "전체"는 필터 없음을 의미하므로 파라미터에 포함하지 않음
18+
const params = category && category !== "전체" ? { category } : {};
19+
20+
return publicAxiosInstance.get(`/boards/${boardCode}`, { params });
21+
};
2222

2323
const useGetPostList = ({ boardCode, category = null }: UseGetPostListProps) => {
2424
return useQuery({
2525
queryKey: [QueryKeys.postList, boardCode, category],
2626
queryFn: () => getPostList(boardCode, category),
27+
// HydrationBoundary로부터 자동으로 hydrate된 데이터 사용
2728
// staleTime을 무한으로 설정하여 불필요한 자동 refetch를 방지합니다.
2829
staleTime: Infinity,
2930
gcTime: 1000 * 60 * 30, // 예: 30분
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import serverFetch, { ServerFetchResult } from "@/utils/serverFetchUtil";
2+
3+
import { ListPost } from "@/types/community";
4+
5+
interface GetPostListParams {
6+
boardCode: string;
7+
category?: string | null;
8+
revalidate?: number | false;
9+
}
10+
11+
/**
12+
* @description 게시글 목록을 서버에서 가져오는 함수 (ISR 지원)
13+
* @param boardCode - 게시판 코드
14+
* @param category - 카테고리 (선택)
15+
* @param revalidate - ISR revalidate 시간(초) 또는 false (무한 캐시)
16+
* @returns Promise<ServerFetchResult<ListPost[]>>
17+
*/
18+
export const getPostList = async ({
19+
boardCode,
20+
category = null,
21+
revalidate = false, // 기본값: 자동 재생성 비활성화 (수동 revalidate만)
22+
}: GetPostListParams): Promise<ServerFetchResult<ListPost[]>> => {
23+
const params = new URLSearchParams();
24+
// "전체"는 필터 없음을 의미하므로 파라미터에 포함하지 않음
25+
if (category && category !== "전체") {
26+
params.append("category", category);
27+
}
28+
29+
const queryString = params.toString();
30+
const url = `/boards/${boardCode}${queryString ? `?${queryString}` : ""}`;
31+
32+
return serverFetch<ListPost[]>(url, {
33+
method: "GET",
34+
next: {
35+
revalidate,
36+
tags: [`posts-${boardCode}`], // 태그 기반 revalidation 지원 (글 작성 시만 revalidate)
37+
},
38+
});
39+
};
40+

src/api/community/client/useCreatePost.ts

Lines changed: 44 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,19 +6,22 @@ import { QueryKeys } from "./queryKey";
66

77
import { PostCreateRequest, PostIdResponse } from "@/types/community";
88

9+
import useAuthStore from "@/lib/zustand/useAuthStore";
910
import { toast } from "@/lib/zustand/useToastStore";
1011
import { useMutation, useQueryClient } from "@tanstack/react-query";
1112

1213
/**
1314
* @description 게시글 생성 API 함수
1415
* @param request - 게시글 생성 요청 데이터
15-
* @returns Promise<PostIdResponse>
16+
* @returns Promise<PostIdResponse & { boardCode: string }>
1617
*/
17-
const createPost = async (request: PostCreateRequest): Promise<PostIdResponse> => {
18+
const createPost = async (
19+
request: PostCreateRequest
20+
): Promise<PostIdResponse & { boardCode: string }> => {
1821
const convertedRequest: FormData = new FormData();
1922
convertedRequest.append(
2023
"postCreateRequest",
21-
new Blob([JSON.stringify(request.postCreateRequest)], { type: "application/json" }),
24+
new Blob([JSON.stringify(request.postCreateRequest)], { type: "application/json" })
2225
);
2326
request.file.forEach((file) => {
2427
convertedRequest.append("file", file);
@@ -27,20 +30,56 @@ const createPost = async (request: PostCreateRequest): Promise<PostIdResponse> =
2730
const response: AxiosResponse<PostIdResponse> = await axiosInstance.post(`/posts`, convertedRequest, {
2831
headers: { "Content-Type": "multipart/form-data" },
2932
});
30-
return response.data;
33+
34+
return {
35+
...response.data,
36+
boardCode: request.postCreateRequest.boardCode,
37+
};
38+
};
39+
40+
/**
41+
* @description ISR 페이지를 revalidate하는 함수
42+
* @param boardCode - 게시판 코드
43+
* @param accessToken - 사용자 인증 토큰
44+
*/
45+
const revalidateCommunityPage = async (boardCode: string, accessToken: string) => {
46+
try {
47+
if (!accessToken) {
48+
console.warn("Revalidation skipped: No access token available");
49+
return;
50+
}
51+
52+
await fetch("/api/revalidate", {
53+
method: "POST",
54+
headers: {
55+
"Content-Type": "application/json",
56+
Authorization: `Bearer ${accessToken}`,
57+
},
58+
body: JSON.stringify({ boardCode }),
59+
});
60+
} catch (error) {
61+
console.error("Revalidate failed:", error);
62+
}
3163
};
3264

3365
/**
3466
* @description 게시글 생성을 위한 useMutation 커스텀 훅
3567
*/
3668
const useCreatePost = () => {
3769
const queryClient = useQueryClient();
70+
const { accessToken } = useAuthStore();
3871

3972
return useMutation({
4073
mutationFn: createPost,
41-
onSuccess: () => {
74+
onSuccess: async (data) => {
4275
// 게시글 목록 쿼리를 무효화하여 최신 목록 반영
4376
queryClient.invalidateQueries({ queryKey: [QueryKeys.posts] });
77+
78+
// ISR 페이지 revalidate (사용자 인증 토큰 사용)
79+
if (accessToken) {
80+
await revalidateCommunityPage(data.boardCode, accessToken);
81+
}
82+
4483
toast.success("게시글이 등록되었습니다.");
4584
},
4685
onError: (error) => {

src/app/api/revalidate/route.ts

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import { revalidatePath, revalidateTag } from "next/cache";
2+
import { NextRequest, NextResponse } from "next/server";
3+
4+
/**
5+
* @description Revalidation 요청 body 타입
6+
*/
7+
interface RevalidateRequestBody {
8+
path?: string;
9+
tag?: string;
10+
boardCode?: string;
11+
}
12+
13+
/**
14+
* @description ISR 페이지를 수동으로 revalidate하는 API
15+
* POST /api/revalidate
16+
* Headers: Authorization: Bearer <accessToken>
17+
* Body: { path?: string, tag?: string, boardCode?: string }
18+
*
19+
* @security 사용자 인증(accessToken)으로 보호됨
20+
*/
21+
async function POST(request: NextRequest) {
22+
try {
23+
// 1. 사용자 인증 확인 (Authorization 헤더의 accessToken)
24+
const authHeader = request.headers.get("authorization");
25+
26+
if (!authHeader?.startsWith("Bearer ")) {
27+
return NextResponse.json({ revalidated: false, message: "Unauthorized" }, { status: 401 });
28+
}
29+
30+
const accessToken = authHeader.substring(7);
31+
32+
if (!accessToken) {
33+
return NextResponse.json({ revalidated: false, message: "Unauthorized" }, { status: 401 });
34+
}
35+
36+
// 2. 백엔드 API로 토큰 검증 (/my 엔드포인트 사용)
37+
// 실제 사용자인지 확인하여 악의적인 revalidation 방지
38+
try {
39+
const apiServerUrl = process.env.NEXT_PUBLIC_API_SERVER_URL || "";
40+
const verifyResponse = await fetch(`${apiServerUrl}/my`, {
41+
headers: {
42+
Authorization: `Bearer ${accessToken}`,
43+
},
44+
});
45+
46+
if (!verifyResponse.ok) {
47+
return NextResponse.json({ revalidated: false, message: "Forbidden" }, { status: 403 });
48+
}
49+
} catch (error) {
50+
console.error("Token verification failed:", error);
51+
return NextResponse.json({ revalidated: false, message: "Forbidden" }, { status: 403 });
52+
}
53+
54+
// 3. 인증 성공 - Revalidation 로직 수행
55+
const body = (await request.json()) as RevalidateRequestBody;
56+
const { path, tag, boardCode } = body;
57+
58+
// boardCode가 있으면 해당 커뮤니티 페이지 revalidate
59+
if (boardCode) {
60+
revalidatePath(`/community/${boardCode}`);
61+
revalidateTag(`posts-${boardCode}`);
62+
63+
return NextResponse.json({
64+
revalidated: true,
65+
message: `Community page for ${boardCode} revalidated`,
66+
timestamp: Date.now(),
67+
});
68+
}
69+
70+
// 특정 경로 revalidate
71+
if (path) {
72+
revalidatePath(path);
73+
return NextResponse.json({
74+
revalidated: true,
75+
message: `Path ${path} revalidated`,
76+
timestamp: Date.now(),
77+
});
78+
}
79+
80+
// 특정 태그 revalidate
81+
if (tag) {
82+
revalidateTag(tag);
83+
return NextResponse.json({
84+
revalidated: true,
85+
message: `Tag ${tag} revalidated`,
86+
timestamp: Date.now(),
87+
});
88+
}
89+
90+
return NextResponse.json({ revalidated: false, message: "Missing path, tag, or boardCode" }, { status: 400 });
91+
} catch (error) {
92+
console.error("Revalidate error:", error);
93+
return NextResponse.json({ revalidated: false, message: "Error revalidating" }, { status: 500 });
94+
}
95+
}
96+
97+
export { POST };

src/app/community/[boardCode]/CommunityPageContent.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,11 @@ const CommunityPageContent = ({ boardCode }: CommunityPageContentProps) => {
2121
const router = useRouter();
2222
const [category, setCategory] = useState<string | null>("전체");
2323

24-
const { data: posts = [] } = useGetPostList({ boardCode, category });
24+
// HydrationBoundary로부터 자동으로 prefetch된 데이터 사용
25+
const { data: posts = [] } = useGetPostList({
26+
boardCode,
27+
category,
28+
});
2529

2630
const handleBoardChange = (newBoard: string) => {
2731
router.push(`/community/${newBoard}`);

0 commit comments

Comments
 (0)