들어가며
개인 블로그를 Next.js 16으로 만들면서, 처음에는 빠르게 동작하는 것에만 집중했다. 모든 페이지를 "use client"로 선언하고 useEffect 안에서 API를 호출하는 방식이었다. 잘 돌아가니까 문제 없다고 생각했는데, 코드를 다시 보니 몇 가지 비효율이 눈에 들어왔다.
문제 인식
1. 모든 페이지가 클라이언트 컴포넌트
홈(/), 글 목록(/posts), 글 상세(/posts/[slug]) 페이지가 전부 "use client"로 되어 있었다.
// Before: 모든 페이지가 이런 패턴
"use client";
export default function PostsPage() {
const [posts, setPosts] = useState<Post[]>([]);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
fetchAllPosts().then((result) => {
if (result.ok) setPosts(result.data);
setIsLoading(false);
});
}, []);
if (isLoading) return <div>글을 불러오는 중...</div>;
// ...
}
2. 동일한 API를 여러 곳에서 중복 호출
같은 세션에서 홈 페이지, 글 목록 페이지, 레이아웃의 ProfileTabs 컴포넌트가 각각 fetchAllPosts()를 호출하고 있었다. 페이지를 이동할 때마다 같은 데이터를 3번씩 요청하는 셈이다.
3. Next.js의 캐싱 기능을 전혀 활용하지 않음
Next.js App Router는 서버 컴포넌트에서 fetch를 호출하면 자동으로 데이터 캐시를 적용한다. ISR(Incremental Static Regeneration)도 revalidate 옵션 하나로 설정할 수 있다. 하지만 클라이언트 컴포넌트에서 호출하는 fetch는 그냥 브라우저의 일반 fetch와 동일하게 동작하기 때문에, 이 기능들이 전부 무의미했다.
해결 전략
- 공개 읽기 페이지는 서버 컴포넌트로 전환하여 ISR 캐싱을 적용한다.
- 관리자 기능(수정/삭제 버튼, 로그인 체크)만 클라이언트 컴포넌트로 분리한다.
- 글을 작성하거나 삭제하면 Server Action으로 캐시를 즉시 무효화한다.
데이터 흐름을 정리하면 이렇다.
[ Before ]
브라우저 → useEffect → fetch(/api/posts) → 매번 새 요청 → 렌더링
[ After ]
서버 컴포넌트 → fetch(url, { next: { revalidate: 60, tags: ["posts"] } })
→ 60초간 캐시 재사용 → HTML 완성 후 전달
→ 글 작성/삭제 시 revalidateTag("posts")로 즉시 갱신
구현 과정
Step 1. 서버 전용 fetch 함수 만들기
기존 app/lib/api.ts는 클라이언트에서 localStorage의 토큰을 읽는 로직이 섞여 있어서 서버에서 사용할 수 없다. 서버 전용 fetch 함수를 별도 파일로 분리했다.
// app/lib/api.server.ts
import "server-only";
import type { Post } from "./api";
const ISR_REVALIDATE_SECONDS = 60;
export async function fetchAllPostsCached(): Promise<ApiResult<Post[]>> {
const url = getApiBaseUrl();
const response = await fetch(url, {
headers: { "Content-Type": "application/json" },
next: { revalidate: ISR_REVALIDATE_SECONDS, tags: ["posts"] },
});
// ... JSON 파싱 로직
}
export async function fetchPostBySlugCached(slug: string): Promise<ApiResult<Post>> {
const url = `${getApiBaseUrl()}?slug=${encodeURIComponent(slug)}`;
const response = await fetch(url, {
headers: { "Content-Type": "application/json" },
next: {
revalidate: ISR_REVALIDATE_SECONDS,
tags: ["posts", `post-${slug}`],
},
});
// ... JSON 파싱 로직
}
포인트는 세 가지다.
import "server-only": 이 모듈이 클라이언트 번들에 포함되면 빌드 에러를 발생시킨다. 실수로 클라이언트 컴포넌트에서 import하는 것을 방지한다.next: { revalidate: 60 }: 60초 동안 캐시된 응답을 재사용한다. 60초가 지나면 백그라운드에서 새 데이터를 가져와 캐시를 갱신한다.tags: ["posts"]: 이 태그를 기준으로 캐시를 선택적으로 무효화할 수 있다. 글 상세 페이지는post-${slug}태그도 추가하여 개별 글 단위로도 무효화가 가능하다.
Step 2. 캐시 무효화를 위한 Server Action
글을 작성하거나 삭제한 뒤에는 캐시를 즉시 갱신해야 한다. Next.js의 revalidateTag를 Server Action으로 감싸서 클라이언트에서 호출할 수 있게 했다.
// app/lib/actions.ts
"use server";
import { revalidateTag } from "next/cache";
export async function revalidatePostsCache() {
revalidateTag("posts", "max");
}
export async function revalidatePostCache(slug: string) {
revalidateTag(`post-${slug}`, "max");
revalidateTag("posts", "max");
}
Next.js 16 주의사항: revalidateTag의 시그니처가 변경되어 두 번째 인자가 필수가 되었다. "max"를 전달하면 해당 태그와 연결된 모든 캐시 항목을 완전히 무효화한다. 단일 인자로 호출하면 deprecated 경고가 발생한다.
Step 3. 공개 페이지를 서버 컴포넌트로 전환
"use client"를 제거하고, async function으로 바꿔서 서버에서 직접 데이터를 가져오도록 했다.
글 목록 페이지 (/posts)
// app/posts/page.tsx — After
import { fetchAllPostsCached } from "../lib/api.server";
import { AdminPostActions } from "./AdminPostActions";
export default async function PostsPage() {
const result = await fetchAllPostsCached();
if (!result.ok) {
return <main>{result.message}</main>;
}
const posts = result.data;
return (
<main>
<ul>
{posts.map((post) => (
<li key={post.slug}>
<Link href={`/posts/${post.slug}`}>{post.title}</Link>
<AdminPostActions slug={post.slug} title={post.title} />
</li>
))}
</ul>
</main>
);
}
변경 전과 비교하면:
"use client",useState,useEffect가 전부 사라졌다.- 로딩 상태를 직접 관리할 필요가 없다. 서버에서 데이터를 가져온 뒤 완성된 HTML을 보내기 때문이다.
- SEO도 자연스럽게 개선된다. 크롤러가 JavaScript 없이도 콘텐츠를 읽을 수 있다.
글 상세 페이지 (/posts/[slug])도 같은 방식으로 전환했다.
// app/posts/[slug]/page.tsx — After
import { notFound } from "next/navigation";
import { fetchPostBySlugCached } from "../../lib/api.server";
export default async function PostDetailPage({ params }) {
const { slug } = await params;
const result = await fetchPostBySlugCached(decodeURIComponent(slug));
if (!result.ok) {
notFound();
}
const post = result.data;
// ... 렌더링
}
홈 페이지 (/)는 ContributionGraph 같은 인터랙티브 컴포넌트가 있어서, 데이터만 서버에서 가져오고 렌더링은 클라이언트 컴포넌트에 위임하는 구조로 분리했다.
// app/page.tsx — 서버 컴포넌트 (데이터 fetch)
import { fetchAllPostsCached } from "./lib/api.server";
import { HomeContent } from "./HomeContent";
export default async function Home() {
const result = await fetchAllPostsCached();
const posts = result.ok ? result.data : [];
return (
<main>
{/* 정적 섹션들 */}
<HomeContent posts={posts} /> {/* 인터랙티브 부분 */}
</main>
);
}
// app/HomeContent.tsx — 클라이언트 컴포넌트 (인터랙션)
"use client";
export function HomeContent({ posts }: { posts: Post[] }) {
// ContributionGraph, YearSelector 등 인터랙티브 UI
// posts는 서버에서 이미 가져온 데이터를 props로 받음
}
Step 4. 관리자 기능을 클라이언트 컴포넌트로 분리
수정/삭제 버튼은 localStorage의 토큰을 확인해야 하므로 클라이언트에서만 동작한다. 이 부분만 별도 컴포넌트로 분리했다.
// app/posts/AdminPostActions.tsx
"use client";
import { isLoggedIn } from "../lib/auth";
import DeletePostButton from "./DeletePostButton";
export function AdminPostActions({ slug, title }: AdminPostActionsProps) {
const isAdmin = isLoggedIn();
if (!isAdmin) return null;
return (
<>
<Link href={`/admin/edit?slug=${slug}`}>수정</Link>
<DeletePostButton slug={slug} title={title} />
</>
);
}
서버 컴포넌트인 PostsPage 안에서 <AdminPostActions />를 렌더링하면, Next.js가 자동으로 서버/클라이언트 경계를 처리한다. 로그인하지 않은 사용자에게는 빈 컴포넌트가 렌더링되고, 관리자에게만 버튼이 보인다.
Step 5. 글 작성/삭제 시 캐시 무효화 연동
마지막으로 글을 게시하거나 삭제한 뒤 Server Action을 호출하여 캐시를 즉시 갱신하도록 했다.
// app/admin/new/hooks/usePublishPost.ts
import { revalidatePostCache } from "../../../lib/actions";
export function usePublishPost() {
const publish = useCallback(async (postData: Post, options?) => {
const result = await publishPost(postData);
if (result.ok) {
await revalidatePostCache(postData.slug); // 캐시 즉시 무효화
setPublishResult({ ok: true, message: result.data.message });
// ...
}
}, [router]);
}
revalidatePostCache는 Server Action이므로 클라이언트에서 await로 호출하면 서버에서 실행된다. revalidateTag("posts", "max")가 호출되면 tags: ["posts"]로 캐싱된 모든 fetch 결과가 무효화되고, 다음 요청 시 새 데이터를 가져온다.
변경된 파일 구조
app/
├── lib/
│ ├── api.ts # 클라이언트용 (기존 유지)
│ ├── api.server.ts # 서버 전용 + ISR 캐싱 (신규)
│ └── actions.ts # Server Action - 캐시 무효화 (신규)
├── page.tsx # 서버 컴포넌트로 전환
├── HomeContent.tsx # 인터랙티브 부분 분리 (신규)
├── posts/
│ ├── page.tsx # 서버 컴포넌트로 전환
│ ├── AdminPostActions.tsx # 관리자 기능 분리 (신규)
│ └── [slug]/
│ └── page.tsx # 서버 컴포넌트로 전환
└── admin/
└── new/hooks/
└── usePublishPost.ts # 캐시 무효화 연동 (수정)
Before / After 비교
| Before | After | |
|---|---|---|
| 렌더링 방식 | CSR (클라이언트에서 fetch 후 렌더링) | SSR + ISR (서버에서 렌더링, 60초 캐시) |
| 초기 로딩 | 빈 화면 → 로딩 스피너 → 콘텐츠 | 완성된 HTML 즉시 표시 |
| SEO | 크롤러가 빈 페이지를 봄 | 완전한 HTML을 크롤링 가능 |
| API 호출 | 페이지 이동마다 브라우저에서 호출 | 서버에서 캐시된 결과 재사용 |
| 캐시 갱신 | 없음 | 60초 자동 갱신 + 글 작성/삭제 시 즉시 무효화 |
| 클라이언트 JS 크기 | 모든 페이지 로직이 번들에 포함 | 관리자 기능만 클라이언트 번들에 포함 |
마치며
돌이켜 보면 크게 어려운 작업은 아니었다. 핵심은 "서버에서 할 수 있는 일은 서버에서 하자"는 원칙이다.
- 공개 콘텐츠는 서버 컴포넌트에서 가져와서 캐싱한다.
- 인터랙션이 필요한 부분만 클라이언트 컴포넌트로 분리한다.
- 데이터가 변경되면
revalidateTag로 캐시를 무효화한다.
특히 Next.js 16에서는 revalidateTag의 API가 변경되어 두 번째 인자("max")가 필수가 된 점을 주의해야 한다. 기존 코드를 마이그레이션할 때 놓치기 쉬운 부분이다.
블로그처럼 읽기가 대부분이고 쓰기가 드문 서비스에서는, 이 정도 캐싱만으로도 체감 성능이 확연히 달라진다. 아직 CDN 레벨의 캐싱이나 generateStaticParams를 활용한 완전한 정적 생성은 적용하지 않았는데, 글이 더 많아지면 그때 고려해 볼 생각이다.