리액트 서버 컴포넌트의 함정 탐색
Emily Parker
Product Engineer · Leapcell

웹 개발의 다음 개척지와 그 숨겨진 함정
리액트 서버 컴포넌트(RSC)는 웹 애플리케이션 구축 방식에 있어 중요한 진화를 나타내며, 서버 측 렌더링이 클라이언트 측 상호작용과 원활하게 통합되는 미래를 약속합니다. 개발자가 서버에서 컴포넌트를 완전히 렌더링할 수 있게 함으로써, RSC는 번들 크기를 줄이고 초기 페이지 로드 성능을 개선하며 민감한 자격 증명을 클라이언트에 노출하지 않고 데이터베이스 및 파일 시스템과 같은 백엔드 리소스에 직접 액세스할 수 있도록 하는 것을 목표로 합니다. 이 패러다임 전환은 더 빠르고, 더 효율적이며, 더 안전한 애플리케이션을 구축할 수 있는 엄청난 잠재력을 제공합니다. 그러나 모든 강력한 신기술과 마찬가지로 RSC에도 고유한 뉘앙스와 잠재적인 함정이 있습니다. 이러한 일반적인 함정을 이해하는 것은 RSC의 힘을 효과적으로 활용하고 좌절스러운 디버깅 세션을 피하는 데 중요합니다. 이 글에서는 두 가지 일반적인 문제, 즉 서버 컨텍스트여야 하는 곳에서의 클라이언트 측 데이터 가져오기와 'use client' 지시문의 오용을 자세히 살펴보고, RSC를 성공적으로 활용할 수 있는 명확한 경로를 제공할 것입니다.
리액트 서버 컴포넌트의 핵심 개념 이해
일반적인 함정에 대해 자세히 알아보기 전에, 관련 핵심 개념을 간략하게 다시 숙지하는 것이 중요합니다.
리액트 서버 컴포넌트(RSC): 서버에서만 렌더링되는 컴포넌트입니다. 서버 측 리소스에 직접 액세스할 수 있으며, 클라이언트 측 워터폴 없이 데이터 가져오기를 수행할 수 있고, JavaScript 코드를 클라이언트로 보내지 않습니다. 정적 콘텐츠, 데이터 가져오기, 백엔드 서비스와의 상호 작용에 이상적입니다.
리액트 클라이언트 컴포넌트: 클라이언트에서 렌더링되는 기존 React 컴포넌트입니다. 상호 작용이 가능하며 브라우저 API(window 또는 localStorage 등)에 액세스할 수 있고, useState, useEffect, useRef와 같은 훅을 사용할 수 있습니다. JavaScript가 번들링되어 클라이언트로 전송되어야 합니다.
'use client' 지시문: 파일 최상단에 배치되는 이 특수 지시문은 React 빌드 시스템에 해당 컴포넌트(및 가져오는 모든 모듈)가 클라이언트 컴포넌트로 처리되어야 함을 알립니다. 이는 서버 컴포넌트 트리 내에서 렌더링되는 경우에도 마찬가지입니다. 서버 코드와 클라이언트 코드 간의 경계입니다.
RSC에서의 데이터 가져오기: 서버 컴포넌트는 별도의 API 계층 없이 표준 JavaScript async/await 구문을 사용하거나 직접 데이터베이스를 쿼리하여 데이터를 직접 가져올 수 있습니다. 그런 다음 이 데이터는 다른 서버 컴포넌트나 클라이언트 컴포넌트로 prop으로 전달됩니다.
이러한 기본 개념을 염두에 두고 일반적인 실수를 살펴보겠습니다.
서버 컴포넌트 컨텍스트에서의 클라이언트 측 데이터 가져오기 함정
리액트 서버 컴포넌트의 주요 이점 중 하나는 서버에서 직접 데이터 가져오기를 수행하여 데이터 소스에 대한 서버의 근접성을 활용하고 초기 데이터에 대한 클라이언트 측 네트워크 요청을 제거할 수 있다는 것입니다. 그러나 일반적인 실수는 컴포넌트가 서버 컴포넌트용이라고 가정할 때에도 클라이언트 측에서 계속해서 데이터를 가져오는 것입니다. 이는 종종 개발자가 기존 클라이언트 측 데이터 가져오기 패턴(예: useEffect와 fetch 사용)을 RSC 환경이라고 생각되는 곳으로 마이그레이션할 때 발생하며, 패러다임 전환을 완전히 이해하지 못합니다.
제품 목록을 표시하려는 시나리오를 생각해 보세요. 기존 클라이언트 측 애플리케이션에서는 다음과 같이 할 수 있습니다.
// components/ProductList.js (기존 클라이언트 컴포넌트) import React, { useState, useEffect } from 'react'; function ProductList() { const [products, setProducts] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); useEffect(() => { const fetchProducts = async () => { try { const response = await fetch('/api/products'); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } const data = await response.json(); setProducts(data); } catch (e) { setError(e); } finally { setLoading(false); } }; fetchProducts(); }, []); if (loading) return <div>Loading products...</div>; if (error) return <div>Error: {error.message}</div>; return ( <ul> {products.map(product => ( <li key={product.id}>{product.name} - ${product.price}</li> ))} </ul> ); } export default ProductList;
조정 없이 이 코드를 서버 컴포넌트 파일로 단순히 이동하면 실패합니다. useState와 useEffect는 클라이언트 측 훅이며 서버 컴포넌트에서는 사용할 수 없습니다. 전체 컴포넌트가 'use client'로 표시되어야 하므로 서버 측 데이터 가져오기의 목적을 달성하지 못합니다.
서버 컴포넌트의 올바른 접근 방식은 async/await를 사용하여 직접 데이터를 가져오는 것입니다.
// app/products/page.js (서버 컴포넌트) import ProductCard from '../../components/ProductCard'; // 서버 또는 클라이언트 컴포넌트 가능 async function getProducts() { // 실제 애플리케이션에서는 데이터베이스에서 직접 쿼리하거나 // 외부 HTTP를 통과하지 않는 내부 API 라우트를 호출할 수 있습니다. const res = await fetch('https://api.example.com/products'); if (!res.ok) { // 가장 가까운 `error.js` 오류 경계가 활성화됩니다. throw new Error('Failed to fetch data'); } return res.json(); } export default async function ProductsPage() { const products = await getProducts(); // 서버에서 직접 가져온 데이터 return ( <section> <h1>Our Products</h1> <div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '20px' }}> {products.map((product) => ( <ProductCard key={product.id} product={product} /> ))} </div> </section> ); }
여기서 getProducts는 컴포넌트가 렌더링되기 전에 서버에서 실행되는 async 함수입니다. 데이터에 직접 액세스할 수 있으며 초기 가져오기에 클라이언트 측 JavaScript가 필요하지 않습니다. 이 실수를 저지르는 것은 RSC의 핵심 이점을 활용하지 못하고, 잠재적으로 명시적인 지시문 없이 효과적으로 클라이언트 컴포넌트인 컴포넌트를 렌더링하며, 이는 혼란과 최적화되지 않은 성능 특성으로 이어집니다.
'use client'의 오용
'use client' 지시문은 서버 코드와 클라이언트 코드 간의 경계를 나타내는 강력한 명시적 마커입니다. 컴포넌트(및 가져오는 모든 것)가 클라이언트에서 하이드레이션되고 실행되어야 함을 명확하게 나타내는 것이 목적입니다. 그러나 개발자는 종종 'use client'를 너무 광범위하게 사용하거나 그 의미를 오해하는 함정에 빠집니다.
함정 1: 전체 컴포넌트 트리를 불필요하게 클라이언트 컴포넌트로 표시.
내부에 많은 하위 컴포넌트를 포함하는 복잡한 컴포넌트가 있고, 그중 작은 부분만이 클라이언트 측 상호 작용을 필요로 하는 경우, 상위 컴포넌트를 'use client'로 표시하면 해당 컴포넌트의 모든 자식(및 그 종속성)이 서버에서 렌더링될 수 있더라도 클라이언트 컴포넌트가 됩니다. 이는 클라이언트 번들 크기를 불필요하게 증가시킵니다.
사용자 프로필을 표시하는 페이지를 생각해 보세요. 프로필 정보의 대부분은 정적이지만, 클라이언트 측 상호 작용이 필요한 '팔로우' 버튼이 있습니다.
// app/profile/[id]/page.js (서버 컴포넌트) import { fetchUserProfile } from '@/lib/data'; // 서버 측 데이터 가져오기 import ProfileDetails from '@/components/ProfileDetails'; // 서버 컴포넌트 가능 import FollowButton from '@/components/FollowButton'; // 클라이언트 컴포넌트여야 함 export default async function UserProfilePage({ params }) { const user = await fetchUserProfile(params.id); return ( <div> <h1>User Profile</h1> <ProfileDetails user={user} /> {/* 서버 컴포넌트 */} <FollowButton userId={user.id} /> {/* 클라이언트 컴포넌트 */} <UserActivityFeed userId={user.id} /> {/* 또 다른 서버 컴포넌트 */} </div> ); }
그리고 FollowButton.js에서:
// components/FollowButton.js 'use client'; // 이 컴포넌트는 클라이언트 측 상호 작용이 필요합니다. import { useState } from 'react'; export default function FollowButton({ userId }) { const [isFollowing, setIsFollowing] = useState(false); // 예시 상태 const handleClick = () => { // 클라이언트 측 작업 수행, 예: API 요청 보내기 console.log(`Toggling follow for user ${userId}`); setIsFollowing(!isFollowing); }; return ( <button onClick={handleClick}> {isFollowing ? 'Following' : 'Follow'} </button> ); }
이 구조에서 ProfileDetails와 UserActivityFeed는 서버 컴포넌트로 유지되어 데이터를 가져오고 대부분의 정적 콘텐츠를 서버에서 렌더링할 수 있습니다. useState를 사용하고 사용자 상호 작용을 처리하기 때문에 FollowButton만 'use client' 지시문이 필요합니다. UserProfilePage 자체가 'use client'로 표시되면, 이 모든 컴포넌트가 클라이언트 컴포넌트가 되어 필요한 것보다 더 많은 JavaScript를 전송하게 됩니다.
함정 2: 클라이언트 컴포넌트에 가져온 서버 전용 모듈의 의미 무시.
클라이언트 컴포넌트('use client'로 표시된)가 모듈을 가져오면, 해당 모듈(및 그 종속성)도 클라이언트 번들의 일부가 됩니다. 민감한 자격 증명을 사용하여 데이터베이스를 직접 쿼리하는 서버 전용 유틸리티(예: 함수)가 실수로 클라이언트 컴포넌트에 가져와지면 문제가 발생할 수 있습니다. 빌드 시스템은 일반적으로 오류를 발생시키지만, 클라이언트/서버 경계에 대한 근본적인 오해를 강조합니다.
getData 유틸리티를 생각해 보세요:
// lib/data.js (서버 전용 유틸리티) import 'server-only'; // 이 파일이 클라이언트에 대해 절대 번들링되지 않도록 보장 import { db } from './db'; // db 클라이언트가 서버 전용이라고 가정 export async function getUsers() { const users = await db.query('SELECT * FROM users'); return users; }
실수로 getUsers를 클라이언트 컴포넌트에 가져오는 경우:
// components/BadComponent.js 'use client'; import { useEffect, useState } from 'react'; import { getUsers } from '@/lib/data'; // !!! 위험: 서버 전용을 클라이언트에 가져오기 export default function BadComponent() { const [users, setUsers] = useState([]); useEffect(() => { // 'server-only'가 사용된 경우 빌드 시점에 실패하거나 // 그렇지 않은 경우 자격 증명을 노출합니다. getUsers().then(setUsers); }, []); // ... }
'server-only' 패키지는 모듈이 클라이언트 컴포넌트에 가져와지면 빌드 오류를 발생시켜 이를 방지하는 데 도움이 됩니다. 그러나 핵심 문제는 어떤 코드가 어디에 속하는지에 대한 오해입니다. 클라이언트 컴포넌트는 서버 컴포넌트로부터 props로 데이터를 받거나 클라이언트에서 액세스 가능한 API 라우트에서 데이터를 가져와야 하며, 서버 전용 로직에서 직접 가져오면 안 됩니다.
모범 사례 채택
이러한 함정을 피하기 위해 개발자는 다음을 수행해야 합니다.
- 서버 컴포넌트 기본 설정: 컴포넌트를 서버 컴포넌트로 가정하고 시작하세요. 브라우저 전용 API(
window,localStorage등), 상태(useState,useReducer), 효과(useEffect) 또는 이벤트 핸들러가 실제로 필요한 경우에만'use client'를 도입하세요. - 함수 및 props 전달: 서버 컴포넌트는 prop으로 데이터를 클라이언트 컴포넌트에 전달할 수 있습니다. 또한 클라이언트 컴포넌트(예:
onClick핸들러를 통해)에서 호출될 때 서버 액션 또는 서버 측 로직을 트리거하는 함수를 전달할 수도 있습니다. - 클라이언트 측 논리 캡슐화: 클라이언트 측 상호 작용을 가능한 가장 작은 클라이언트 컴포넌트로 격리하세요. 이렇게 하면 애플리케이션 로직과 렌더링의 대부분이 서버에 유지되어 클라이언트 번들을 최소화할 수 있습니다.
- 모듈 그래프 이해: 모듈이 가져와지는 방식을 염두에 두세요. 클라이언트 컴포넌트가 모듈을 가져오면, 해당 모듈과 전체 종속성 트리가 클라이언트 번들에 포함됩니다. 절대 클라이언트에 닿아서는 안 되는 모듈에 대해서는
'server-only'패키지를 사용하세요.
결론
리액트 서버 컴포넌트는 웹 개발에 있어 강력한 진화를 제공하며, 더 효율적이고 성능이 뛰어난 애플리케이션을 가능하게 합니다. 그러나 이 패러다임으로 마이그레이션하려면 코드 실행 위치와 데이터 저장 위치에 대한 사고방식의 근본적인 변화가 필요합니다. 서버 컨텍스트에서의 클라이언트 측 데이터 가져오기와 'use client' 지시문의 무분별한 사용과 같은 일반적인 함정은 종종 RSC를 기존 React 컴포넌트처럼 취급하는 데서 비롯됩니다. 서버 우선 사고방식을 채택하고, 클라이언트 경계를 전략적으로 배치하며, 각 컴포넌트 유형의 의미를 이해함으로써 개발자는 튼튼하고 번개처럼 빠른 경험을 구축하여 리액트 서버 컴포넌트의 잠재력을 완전히 발휘할 수 있습니다. RSC를 마스터하는 열쇠는 컴포넌트 배치와 데이터 흐름에 대한 의도적이고 정보에 입각한 접근 방식에 있습니다.

