클라이언트 컴포넌트에서 데이터 페칭
지금까지 우리는 Next.js App Router의 핵심인 서버 컴포넌트에서 데이터를 페칭하는 다양한 방법(SSR, SSG, ISR)을 살펴보았습니다. 서버 컴포넌트는 초기 로딩 성능과 SEO에 큰 이점을 제공하지만, 모든 상황에 적합한 것은 아닙니다. 사용자 상호작용 후 동적으로 데이터를 업데이트하거나, 클라이언트 측에서만 사용 가능한 브라우저 API(예: Geolocation API)에 접근해야 하는 경우, 클라이언트 컴포넌트(Client Components) 에서 데이터를 페칭해야 합니다.
이 절에서는 클라이언트 컴포넌트에서 데이터를 페칭하는 방법과, 서버 컴포넌트와의 역할 분담을 통해 애플리케이션의 성능과 유연성을 극대화하는 전략에 대해 알아보겠습니다.
데이터 페칭의 필요성
클라이언트 컴포넌트는 브라우저(클라이언트)에서 렌더링되고 실행되는 React 컴포넌트입니다. useEffect
, useState
와 같은 React 훅을 사용하거나, 브라우저 전용 API에 접근해야 할 때 사용됩니다.
클라이언트 컴포넌트에서 데이터 페칭이 필요한 경우
- 사용자 상호작용에 따른 동적 데이터 업데이트: 버튼 클릭, 폼 제출, 검색어 입력 등 사용자 액션에 따라 실시간으로 데이터를 가져와 UI를 업데이트해야 할 때. (예: 댓글 등록 후 목록 갱신, 검색 필터 적용)
- 브라우저 전용 API 의존성:
localStorage
,navigator.geolocation
, WebSockets 등 브라우저 환경에서만 사용 가능한 API를 통해 데이터를 가져와야 할 때. - 작은 규모의 클라이언트 전용 데이터: 초기 로딩 시점에 필요하지 않고, 페이지 로드 후 사용자 경험을 향상시키기 위해 비동기적으로 가져오는 데이터.
- 서드파티 클라이언트 라이브러리 사용: SWR, React Query와 같은 클라이언트 사이드 데이터 페칭 라이브러리를 사용하고자 할 때.
데이터 페칭하는 방법
클라이언트 컴포넌트에서 데이터를 페칭하는 방법은 일반적인 React 애플리케이션에서 데이터를 가져오는 방식과 동일합니다. 주로 useEffect
훅과 useState
훅을 조합하여 사용합니다.
// src/app/client-data/page.tsx (새로 생성할 페이지)
// 이 파일은 서버 컴포넌트이지만, 그 안에서 클라이언트 컴포넌트를 임포트하여 사용합니다.
import ClientDataFetcher from './ClientDataFetcher'; // 클라이언트 컴포넌트 임포트
export default function ClientDataPage() {
return (
<div>
<h1>클라이언트 컴포넌트 데이터 페칭 예제</h1>
<p>이 페이지는 서버 컴포넌트이지만, 아래 데이터는 클라이언트 컴포넌트에서 가져옵니다.</p>
<ClientDataFetcher />
</div>
);
}
// src/app/client-data/ClientDataFetcher.tsx (새로 생성할 클라이언트 컴포넌트)
"use client"; // 이 파일은 클라이언트 컴포넌트임을 명시
import React, { useState, useEffect } from 'react';
interface Todo {
userId: number;
id: number;
title: string;
completed: boolean;
}
export default function ClientDataFetcher() {
const [todos, setTodos] = useState<Todo[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
async function fetchTodos() {
try {
setLoading(true);
setError(null);
// 클라이언트 측에서 fetch API를 사용하여 데이터를 가져옵니다.
const res = await fetch('https://jsonplaceholder.typicode.com/todos?_limit=5');
if (!res.ok) {
throw new Error('Failed to fetch todos');
}
const data: Todo[] = await res.json();
setTodos(data);
} catch (err: any) {
setError(err.message);
} finally {
setLoading(false);
}
}
fetchTodos();
}, []); // 빈 배열은 컴포넌트가 마운트될 때 한 번만 실행됨을 의미
if (loading) {
return <p>할 일 목록을 불러오는 중입니다...</p>;
}
if (error) {
return <p style={{ color: 'red' }}>에러 발생: {error}</p>;
}
return (
<div style={{ border: '1px dashed #f0ad4e', padding: '15px', marginTop: '20px', borderRadius: '8px' }}>
<h2 style={{ color: '#f0ad4e' }}>클라이언트에서 가져온 할 일 목록</h2>
<ul>
{todos.map((todo) => (
<li key={todo.id} style={{ textDecoration: todo.completed ? 'line-through' : 'none' }}>
{todo.title}
</li>
))}
</ul>
<button onClick={() => alert('클라이언트에서만 가능한 액션!')}>클라이언트 액션</button>
</div>
);
}
실습
src/app/client-data
폴더를 만듭니다.- 그 안에
page.tsx
와ClientDataFetcher.tsx
파일을 위 내용으로 생성합니다. - 개발 서버(
npm run dev
)가 실행 중이라면,http://localhost:3000/client-data
로 접속하여 페이지를 확인해 보세요. - 페이지가 로드된 후 "할 일 목록을 불러오는 중입니다..." 메시지가 잠시 나타났다가, 클라이언트에서 데이터를 가져와 할 일 목록이 표시되는 것을 볼 수 있습니다.
데이터 페칭 라이브러리 활용
Next.js는 클라이언트 컴포넌트에서 데이터 페칭을 더 효율적으로 관리할 수 있도록 SWR, React Query(TanStack Query)와 같은 라이브러리 사용을 권장합니다. 이러한 라이브러리는 캐싱, 재검증, 에러 처리, 로딩 상태 관리 등 복잡한 데이터 페칭 로직을 추상화하여 개발 편의성을 높여줍니다.
SWR을 사용한 예시
-
SWR 설치
npm install swr # 또는 yarn add swr
-
src/app/client-data/SWRFetcher.tsx
파일 생성src/app/client-data/SWRFetcher.tsx // src/app/client-data/SWRFetcher.tsx "use client"; // 클라이언트 컴포넌트임을 명시 import useSWR from 'swr'; import React from 'react'; interface Post { id: number; title: string; body: string; } // 데이터를 가져오는 fetcher 함수 (SWR에 전달) const fetcher = (url: string) => fetch(url).then(res => res.json()); export default function SWRFetcher() { // useSWR 훅을 사용하여 데이터 페칭 및 캐싱 관리 const { data, error, isLoading } = useSWR<Post[]>('https://jsonplaceholder.typicode.com/posts?_limit=3', fetcher); if (error) return <p style={{ color: 'red' }}>SWR 에러: {error.message}</p>; if (isLoading) return <p>SWR로 게시물을 불러오는 중입니다...</p>; return ( <div style={{ border: '1px dashed #6c757d', padding: '15px', marginTop: '20px', borderRadius: '8px' }}> <h2 style={{ color: '#6c757d' }}>SWR로 가져온 게시물 목록</h2> <ul> {data?.map((post) => ( <li key={post.id}> <strong>{post.title}</strong> </li> ))} </ul> <button onClick={() => alert('SWR 캐싱된 데이터!')}>데이터 확인</button> </div> ); }
-
src/app/client-data/page.tsx
에 SWRFetcher 추가src/app/client-data/page.tsx // src/app/client-data/page.tsx import ClientDataFetcher from './ClientDataFetcher'; import SWRFetcher from './SWRFetcher'; // SWRFetcher 임포트 export default function ClientDataPage() { return ( <div> <h1>클라이언트 컴포넌트 데이터 페칭 예제</h1> <p>이 페이지는 서버 컴포넌트이지만, 아래 데이터는 클라이언트 컴포넌트에서 가져옵니다.</p> <ClientDataFetcher /> <SWRFetcher /> {/* SWRFetcher 컴포넌트 추가 */} </div> ); }
실습 확인:
http://localhost:3000/client-data
로 다시 접속하여, SWR로 가져온 게시물 목록이 추가로 표시되는 것을 확인해 보세요. SWR은 내부적으로 캐싱과 재검증을 자동으로 처리하므로, 복잡한 로직 없이도 효율적인 데이터 관리가 가능합니다.
서버 컴포넌트와의 데이터 페칭 시너지
Next.js App Router의 가장 강력한 점은 서버 컴포넌트와 클라이언트 컴포넌트의 데이터 페칭을 조화롭게 사용할 수 있다는 것입니다.
- 초기 데이터는 서버에서: 페이지의 초기 로딩에 필요한 핵심 데이터(SEO, 빠른 사용자 경험)는 서버 컴포넌트에서 SSG, SSR, ISR을 통해 가져옵니다.
- 동적/사용자 상호작용 데이터는 클라이언트에서: 페이지 로드 후 사용자 상호작용에 따라 변경되거나, 브라우저 전용 기능이 필요한 데이터는 클라이언트 컴포넌트에서 가져옵니다.
일반적인 패턴
- 서버 컴포넌트 (부모): 페이지의 전체 구조와 초기 데이터를 담당합니다.
src/app/some-page/page.tsx // src/app/some-page/page.tsx (서버 컴포넌트) import ClientInteractiveComponent from './ClientInteractiveComponent'; async function getServerData() { // 서버에서만 접근 가능한 민감한 데이터나 초기 데이터 페칭 const data = await fetch('...'); return data.json(); } export default async function SomePage() { const initialData = await getServerData(); // 서버에서 초기 데이터 페칭 return ( <div> <h1>서버에서 렌더링된 제목</h1> <p>초기 데이터: {initialData.someValue}</p> {/* 클라이언트 컴포넌트에 초기 데이터를 prop으로 전달할 수 있습니다. */} <ClientInteractiveComponent initialClientData={initialData.clientSpecificValue} /> </div> ); }
- 클라이언트 컴포넌트 (자식): 사용자 상호작용, 동적 데이터 업데이트, 브라우저 API 접근 등을 담당합니다.
src/app/some-page/ClientInteractiveComponent.tsx // src/app/some-page/ClientInteractiveComponent.tsx (클라이언트 컴포넌트) "use client"; import React, { useState, useEffect } from 'react'; export default function ClientInteractiveComponent({ initialClientData }: { initialClientData: string }) { const [dynamicData, setDynamicData] = useState(initialClientData); const [count, setCount] = useState(0); useEffect(() => { // 사용자 상호작용 후 데이터 페칭 또는 브라우저 API 사용 const interval = setInterval(() => { setCount(prev => prev + 1); // fetch('/api/realtime-update').then(...) }, 1000); return () => clearInterval(interval); }, []); return ( <div style={{ border: '1px solid blue', padding: '10px', marginTop: '10px' }}> <p>클라이언트에서 업데이트되는 데이터: {dynamicData}</p> <p>카운트: {count}</p> <button onClick={() => setDynamicData('새로운 데이터: ' + new Date().toLocaleTimeString())}>데이터 업데이트</button> </div> ); }
이러한 분리된 접근 방식은 Next.js 애플리케이션의 성능을 최적화하고, 개발자가 각 컴포넌트의 역할에 집중할 수 있도록 돕습니다. 초기 로딩은 서버에서 빠르게 처리하고, 이후의 동적인 상호작용은 클라이언트에서 효율적으로 관리하여 사용자에게 최상의 경험을 제공할 수 있습니다.