React Server Components와 Client Components, 어떻게 데이터를 주고받을까? (상호작용 패턴 분석)
React Server Components와 Client Components 간의 데이터 흐름과 상호작용 패턴을 Props, Server Actions, 콜백 함수, 동적 임포트 등 실제 코드 예시와 함께 분석하여 효율적인 React 애플리케이션 구축 방법을 제시합니다.
React Server Components와 Client Components, 어떻게 데이터를 주고받을까? (상호작용 패턴 분석)
React 18부터 도입된 Server Components는 서버에서 렌더링되어 초기 로딩 성능을 개선하고 번들 크기를 줄이는 데 기여합니다. 하지만 Server Components만으로는 사용자와의 상호작용이 불가능하므로, Client Components와 함께 사용해야 합니다. 이 두 컴포넌트 타입 간의 데이터 흐름과 상호작용은 어떻게 이루어질까요? 이번 글에서는 Server Components와 Client Components 간의 주요 상호작용 패턴들을 실제 코드 예시와 함께 깊이 있게 살펴보겠습니다.
1. Server Components에서 Client Components로 데이터 전달하기 (Props)
가장 기본적인 데이터 전달 방식은 Server Components에서 Client Components로 Props를 통해 데이터를 넘겨주는 것입니다. Server Components는 서버 환경에서 실행되므로, 데이터베이스 조회, API 호출 등 무거운 작업을 수행하고 그 결과를 Client Components로 전달할 수 있습니다.
예시 1: Server Component에서 User 정보 조회 후 Client Component로 전달
1// app/page.tsx (Server Component) 2import UserProfile from './UserProfile'; 3 4async function getUsers() { 5 // 실제로는 DB 또는 API 호출 6 const users = [{ id: 1, name: 'Alice' }, { id: 2, name: 'Bob' }]; 7 return users; 8} 9 10export default async function Page() { 11 const users = await getUsers(); 12 13 return ( 14 <div> 15 <h1>User List</h1> 16 <UserProfile users={users} /> 17 </div> 18 ); 19} 20 21// components/UserProfile.tsx (Client Component - "use client" 지시어 필요) 22'use client'; 23 24interface User { 25 id: number; 26 name: string; 27} 28 29interface UserProfileProps { 30 users: User[]; 31} 32 33export default function UserProfile({ users }: UserProfileProps) { 34 return ( 35 <ul> 36 {users.map(user => ( 37 <li key={user.id}>{user.name}</li> 38 ))} 39 </ul> 40 ); 41}
이 예제에서 page.tsx (Server Component)는 getUsers 함수를 통해 사용자 목록을 가져오고, 이 데이터를 UserProfile (Client Component)에게 Props로 전달합니다. UserProfile 컴포넌트에서는 이 데이터를 받아 UI를 렌더링합니다. Client Component로 전달되는 Props는 직렬화 가능한(serializable) 데이터여야 합니다. 함수나 React 엘리먼트와 같이 직렬화할 수 없는 값은 전달할 수 없습니다.
2. Client Components에서 Server Components로 데이터 요청하기 (Server Actions)
Client Components는 사용자와의 상호작용(클릭, 폼 제출 등)을 처리하고, 이 과정에서 서버의 도움이 필요한 경우 Server Actions를 활용할 수 있습니다. Server Actions는 서버에서 실행되는 비동기 함수로, Client Components에서 직접 호출하여 서버에 데이터를 쓰거나 읽는 작업을 수행할 수 있습니다.
예시 2: Client Component에서 폼 제출 시 Server Action 호출
1// app/page.tsx (Server Component) 2import AddTodoForm from './AddTodoForm'; 3 4export default function Page() { 5 return ( 6 <div> 7 <h1>Todos</h1> 8 <AddTodoForm /> 9 </div> 10 ); 11} 12 13// components/AddTodoForm.tsx (Client Component) 14'use client'; 15 16import { revalidatePath } from 'next/cache'; 17import { addTodo } from './actions'; // Server Action을 임포트 18 19export default function AddTodoForm() { 20 async function handleSubmit(formData: FormData) { 21 'use server'; // Server Action임을 명시 22 const task = formData.get('task') as string; 23 await addTodo(task); 24 revalidatePath('/'); // 캐시된 데이터를 무효화하여 최신 데이터 로드 25 } 26 27 return ( 28 <form action={handleSubmit}> 29 <input type="text" name="task" /> 30 <button type="submit">Add Todo</button> 31 </form> 32 ); 33} 34 35// app/actions.ts (Server Actions 파일) 36'use server'; 37 38export async function addTodo(task: string) { 39 // 실제로는 DB에 todo 추가 40 console.log('Adding todo:', task); 41 // ... DB 로직 ... 42}
AddTodoForm (Client Component) 내에서 handleSubmit 함수는 폼 제출 시 호출되며, action={handleSubmit}을 통해 폼의 action 속성에 바인딩됩니다. handleSubmit 함수 내에서 'use server' 지시어를 사용하여 addTodo 함수가 Server Action임을 명시합니다. addTodo 함수는 서버 환경에서 실행되어 실제 데이터베이스 작업 등을 수행합니다. revalidatePath('/')는 해당 경로의 캐시를 무효화하여, 폼 제출 후 페이지를 다시 로드했을 때 변경된 내용을 반영하도록 합니다.
3. Server Components에서 Client Components 이벤트 핸들링 (Callback Props)
Server Components는 자체적으로 이벤트 핸들링(onClick, onChange 등)을 할 수 없습니다. 하지만 Client Components를 자식으로 렌더링하고, 해당 Client Components에서 발생한 이벤트를 Server Components로 전달받아 처리해야 하는 경우가 있습니다. 이때는 Client Component에 콜백 함수를 Props로 전달하는 방식을 사용합니다.
예시 3: Server Component에서 Client Component의 버튼 클릭 이벤트 처리
1// app/page.tsx (Server Component) 2import EventButton from './EventButton'; 3 4async function handleButtonClick(message: string) { 5 // 서버에서 처리할 로직 6 console.log('Received from client:', message); 7 // ... 8} 9 10export default function Page() { 11 return ( 12 <div> 13 <h1>Event Handling Example</h1> 14 {/* Server Component에서 Client Component로 콜백 함수 전달 */} 15 <EventButton onClick={handleButtonClick} /> 16 </div> 17 ); 18} 19 20// components/EventButton.tsx (Client Component) 21'use client'; 22 23type EventHandler = (message: string) => Promise<void>; 24 25interface EventButtonProps { 26 onClick: EventHandler; 27} 28 29export default function EventButton({ onClick }: EventButtonProps) { 30 const handleClick = async () => { 31 await onClick('Hello from client!'); // 부모로부터 받은 콜백 함수 호출 32 }; 33 34 return <button onClick={handleClick}>Send Event to Server</button>; 35}
page.tsx (Server Component)는 handleButtonClick 함수를 EventButton (Client Component)의 onClick Props로 전달합니다. EventButton 컴포넌트 내에서 버튼 클릭 시 onClick 함수를 호출하면, 이 함수는 서버로 전달되어 handleButtonClick이 실행됩니다. 이 방식은 Client Component에서 발생한 이벤트를 Server Components에서 감지하고 처리할 수 있게 해줍니다.
4. Client Components 내에서의 Server Component 렌더링 (Interleaving)
React 18에서는 Client Components 안에서도 Server Components를 렌더링할 수 있습니다. 이는 Client Component가 서버로부터 데이터를 받아와야 하거나, 서버에서만 가능한 로직을 수행해야 할 때 유용합니다. Client Component는 서버에 렌더링 요청을 보내고, 서버는 해당 Server Component를 렌더링하여 결과를 Client Component에 전달합니다.
예시 4: Client Component에서 Server Component 렌더링
1// app/page.tsx (Server Component) 2import DynamicContent from './DynamicContent'; 3 4export default function Page() { 5 return ( 6 <div> 7 <h1>Main Page</h1> 8 {/* Client Component 내에서 Server Component 렌더링 */} 9 <ClientWrapper /> 10 </div> 11 ); 12} 13 14// components/ClientWrapper.tsx (Client Component) 15'use client'; 16 17import dynamic from 'next/dynamic'; 18 19// 동적으로 Server Component 임포트 20const DynamicServerComponent = dynamic(() => import('./DynamicContent'), { 21 ssr: true, // 기본값이 true이므로 생략 가능 22}); 23 24export default function ClientWrapper() { 25 return ( 26 <div> 27 <h2>Client Wrapper</h2> 28 {/* Client Component 내에서 Server Component 렌더링 */} 29 <DynamicServerComponent /> 30 </div> 31 ); 32} 33 34// components/DynamicContent.tsx (Server Component) 35// "use client" 지시어 없음 36async function getServerData() { 37 // 서버에서만 가능한 로직 38 await new Promise(resolve => setTimeout(resolve, 1000)); // Simulate delay 39 return `Server rendered at: ${new Date().toLocaleTimeString()}`; 40} 41 42export default async function DynamicContent() { 43 const data = await getServerData(); 44 return <p>This is dynamic content from Server Component: {data}</p>; 45}
ClientWrapper (Client Component)는 next/dynamic을 사용하여 DynamicContent (Server Component)를 동적으로 불러옵니다. ssr: true 옵션(기본값)을 통해 DynamicContent는 서버에서 렌더링된 후 클라이언트로 전달됩니다. 이는 Client Component가 서버에서 가져온 데이터나 로직을 활용해야 할 때 효과적인 패턴입니다.
결론
Server Components와 Client Components의 상호작용은 React 애플리케이션의 복잡성을 관리하고 성능을 최적화하는 데 핵심적인 역할을 합니다. Props를 통한 데이터 전달, Server Actions를 이용한 서버 통신, 콜백 함수를 통한 이벤트 처리, 그리고 Client Component 내에서의 Server Component 렌더링 등 다양한 패턴을 이해하고 적재적소에 활용함으로써 더욱 효율적이고 반응성이 뛰어난 React 애플리케이션을 구축할 수 있습니다. 이러한 컴포넌트 간의 명확한 역할 분담과 데이터 흐름은 애플리케이션의 유지보수성과 확장성을 크게 향상시킬 것입니다.
참고 자료: