지난 챕터에서는 블로그 프로젝트를 서버/클라이언트 컴포넌트 관점에서 정리했습니다. 레이아웃은 서버 컴포넌트로 유지하고, 좋아요 버튼과 검색처럼 인터랙션이 필요한 부분만 클라이언트 컴포넌트로 분리했죠. 또 서버 페이지가 posts 배열을 준비해서 SearchablePostList에 넘기는 흐름도 만들었습니다.
문제는 그 구조 안의 데이터가 현재 하드코딩 배열이라는 점입니다. 화면 구조는 좋아졌지만, 실제 서비스처럼 데이터를 추가하거나 수정하고 재사용하기에는 한계가 있죠. 이번 세션에서는 바로 이 문제를 해결합니다. 먼저 지금 코드가 어떤 상태였는지 다시 보겠습니다:
// app/blog/page.tsx - Ch.2에서 만든 블로그
import { SearchablePostList } from '../components/SearchablePostList';
const posts = [
{ id: 1, slug: 'nextjs-routing', title: 'Next.js 라우팅 이해하기' },
{ id: 2, slug: 'react-server-components', title: 'React 서버 컴포넌트란?' },
];
export default function Blog() {
return (
<div>
<h1>글 목록</h1>
<SearchablePostList posts={posts} />
</div>
);
}
코드를 보면 posts 배열이 컴포넌트 파일 안에 직접 선언되어 있습니다. 블로그 글은 버튼의 열림 상태처럼 한 사람의 브라우저 안에서만 쓰이는 값이 아닙니다. 여러 사용자가 함께 보는 공유 데이터입니다. 이런 데이터는 코드 안에 두는 것이 아니라, 서버가 하나의 원본(source of truth)으로 관리하고 필요할 때 읽어 와야 합니다. 그래야 누가 접속하든 같은 글을 볼 수 있습니다. 지금 구조에서는 그 원본이 코드 안에 섞여 있어서 한계가 분명합니다:
즉, 이번 세션의 질문은 이것입니다. "여러 사용자가 함께 볼 posts의 원본을, 서버는 어디에서 읽어 와야 할까?"
React에서는 보통 다음과 같은 방식으로 데이터를 가져옵니다:
'use client';
import { useState, useEffect } from 'react';
import { SearchablePostList } from '../components/SearchablePostList';
export default function Blog() {
const [posts, setPosts] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
async function getPosts() {
const res = await fetch('/api/posts');
const data = await res.json();
setPosts(data);
setLoading(false);
}
getPosts();
}, []);
if (loading) return <p>로딩 중...</p>;
return (
<div>
<h1>글 목록</h1>
<SearchablePostList posts={posts} />
</div>
);
}
익숙한 방식입니다. 하지만 useState, useEffect를 사용하려면 페이지를 클라이언트 컴포넌트로 바꿔야 하고, 그 순간 데이터 fetching 로직과 상태 관리 로직이 모두 클라이언트 코드로 이동합니다. 즉 서버에서 바로 가져와도 될 첫 화면 데이터를 위해 브라우저 쪽 코드가 늘어나고, loading 상태와 effect를 직접 관리해야 하기 때문에 코드도 더 장황해집니다.
useEffect + fetch가 항상 나쁜 것은 아닙니다
사용자 클릭 이후 요청, 실시간 검색, 무한 스크롤, 폴링처럼 초기 화면 이후에 발생하는 데이터 요청은 여전히 클라이언트 fetching이 적절할 수 있습니다. 다만 첫 화면에 꼭 필요한 데이터라면 서버에서 먼저 가져오는 쪽이 보통 더 유리합니다.
useEffect + fetch 방식에는 세 가지 구조적 문제가 있습니다:
HTML 다운로드 → JS 다운로드 및 실행 → 렌더링 후 fetch 요청 → 응답 → 렌더링. 모든 단계가 직렬로 이어집니다. 앞 단계가 끝나야 다음 단계가 시작되므로, 각 단계의 지연이 그대로 누적됩니다. 이런 구조에서는 요청이 순차적으로 이어지면서 요청 워터폴(Request Waterfall)이 발생하기 쉽습니다.
useEffect는 브라우저에서만 실행되므로, 서버에서 바로 가져올 수 있는 데이터라도 반드시 로딩 UI를 거쳐야 합니다. 서버 컴포넌트라면 데이터를 가져와 완성된 HTML을 보낼 수 있는데, useEffect 방식에서는 항상 빈 상태로 시작해 브라우저에서 다시 요청합니다. 검색 엔진 크롤러 역시 이 초기 HTML을 기준으로 페이지를 분석하기 때문에 실제 콘텐츠를 인식하지 못할 수 있습니다.
모든 클라이언트 fetch가 위험한 것은 아닙니다. 공개되어도 괜찮은 API를 브라우저에서 호출하는 것은 흔한 패턴입니다. 문제는 비밀 키, DB 접속 정보, 관리자 권한 토큰처럼 숨겨야 하는 정보를 클라이언트 코드에 넣는 경우입니다. 이런 값은 브라우저 개발자 도구에서 노출될 수 있으므로, 서버에서만 다뤄야 합니다.
서버 컴포넌트는 이 문제들을 크게 줄여 줍니다:
[useEffect + fetch 방식]
서버 ─── 빈 HTML ──────────────────────────────▶
브라우저 ─── JS 다운로드 ─── 마운트 ────────▶
─── fetch ────▶
응답 ─── 렌더링 ▶
[서버 컴포넌트 방식]
서버 ─── fetch ─── 완성된 HTML ────────────▶
브라우저 ─── 콘텐츠 표시 ▶
↑ 훨씬 빠름!
await를 순차로 실행하면 서버 쪽 워터폴이 생길 수 있고, 느린 요청이 있으면 해당 경로 렌더링이 잠시 막힐 수 있습니다. Next.js는 서버 요청을 병렬로 처리할 수 있도록 하고, loading.tsx, Suspense, Streaming을 통해 느린 요청이 있어도 UI를 점진적으로 보여줄 수 있게 합니다. 이 부분은 다음 세션들에서 이어서 다룹니다.
Ch.2에서 만든 Blog 컴포넌트는 데이터가 코드 안에 있었기 때문에 일반 함수로 충분했습니다. 하지만 외부에서 데이터를 가져오려면 응답을 기다려야 합니다. 서버 컴포넌트는 async 함수로 만들 수 있어서, await로 데이터를 기다린 뒤 바로 렌더링할 수 있습니다.
데이터 소스에 따라 세 가지 방식을 살펴보겠습니다:
fetch, 우리 앱의 핵심 데이터가 데이터베이스에 있으면 ORM/쿼리로 직접 접근, 정적 문서나 샘플 콘텐츠를 읽으면 파일 시스템이 보통 가장 단순합니다.
가장 보편적인 방식입니다. 외부 API나 별도의 백엔드 서비스에서 데이터를 가져올 때 사용합니다. 다만 우리 서버가 직접 접근할 수 있는 데이터라면 API 라우트를 거치지 않고 바로 DB에 접근하는 것이 더 단순할 수 있습니다.
// app/blog/page.tsx - 서버 컴포넌트 (async 함수)
import { SearchablePostList } from '../components/SearchablePostList';
export default async function Blog() {
const res = await fetch('http://localhost:4000/posts');
const posts = await res.json();
return (
<div>
<h1>글 목록</h1>
<SearchablePostList posts={posts} />
</div>
);
}
useState도, useEffect도 필요 없습니다. 서버에서 데이터를 읽고 바로 렌더링에 사용할 수 있기 때문입니다. Ch.2에서 만든 SearchablePostList를 그대로 재사용하되, 데이터만 외부에서 가져오는 것이 달라졌습니다.
서버 컴포넌트는 서버에서 실행되므로, 데이터베이스에 직접 접근할 수 있습니다. API 라우트를 따로 만들 필요가 없습니다.
// app/blog/page.tsx
import { db } from '@/lib/database';
import { SearchablePostList } from '../components/SearchablePostList';
export default async function Blog() {
const posts = await db.post.findMany({
orderBy: { createdAt: 'desc' },
});
return (
<div>
<h1>글 목록</h1>
<SearchablePostList posts={posts} />
</div>
);
}
이 코드의 db 객체(예: Prisma Client)는 서버에서만 실행되므로, DB 접속 정보가 클라이언트에 노출되지 않습니다.
마크다운 블로그나 정적 데이터를 관리할 때 유용합니다. Node.js의 파일 시스템 API를 그대로 사용할 수 있습니다.
// app/blog/page.tsx
import { readFile } from 'fs/promises';
import path from 'path';
import { SearchablePostList } from '../components/SearchablePostList';
export default async function Blog() {
const filePath = path.join(process.cwd(), 'data', 'posts.json');
const raw = await readFile(filePath, 'utf-8');
const posts = JSON.parse(raw);
return (
<div>
<h1>글 목록</h1>
<SearchablePostList posts={posts} />
</div>
);
}
| useEffect + fetch | 서버 컴포넌트 | |
|---|---|---|
| 실행 위치 | 브라우저 | 서버 |
| 초기 HTML | 로딩 UI만 포함 | 데이터가 포함된 완성된 HTML |
| 로딩 UI | 로딩 UI나 빈 상태 처리가 대개 필요 | 느린 데이터에만 선택적으로 적용 |
| 클라이언트 번들 | fetching·상태 관리 로직 포함 | fetching 로직이 번들에 포함되지 않음 |
| 보안 | 비밀 값은 둘 수 없음 | 비밀 값을 서버에만 둘 수 있음 |
| DB 접근 | 불가 (API 필요) | 직접 가능 |
fetch는 캐싱과 함께 동작합니다. fetch에 캐싱 옵션을 설정할 수도 있는데, 이 부분은 세션 4에서 자세히 살펴보겠습니다.
| 개념 | 핵심 내용 |
|---|---|
| 하드코딩의 한계 | 데이터는 코드 밖(API, DB, 파일)에 있어야 한다 |
| useEffect + fetch | 동작하지만 fetching 로직이 클라이언트로 이동하고, 로딩 UI나 빈 상태 처리가 필요하다 |
| 서버 컴포넌트 | async/await으로 서버에서 데이터를 가져와 완성된 HTML을 바로 전달할 수 있다 |
| 데이터 소스 | fetch API, DB 직접 쿼리, 파일 시스템 모두 가능 |
| 기본값 | App Router에서 데이터 fetching의 기본 위치는 서버 |
다음 세션에서는 서버에서 데이터를 가져온 뒤 화면을 언제 만들어 둘 것인지 - 빌드 시점에 미리 만드는 정적 렌더링과, 요청마다 만드는 동적 렌더링을 학습합니다.