Back

React Server Components 심층 분석: useEffect, 이제 보내줍시다

Next.js 13, 14가 나오면서 React 생태계가 시끌시끌합니다. 단순히 새로운 훅이 나왔다거나 속도가 빨라졌다는 수준이 아니죠. 우리는 지금 React Server Components(RSC) 라는 거대한 파도를 마주하고 있습니다.

솔직히 말해서, 꽤 혼란스럽지 않나요? "직렬화(serialization) 오류" 같은 낯선 에러들이 튀어나오고, 그동안 잘 써왔던 useEffect로 데이터를 가져오는 방식은 이제 안티 패턴 취급을 받기 시작했습니다. 클라이언트 컴포넌트니 서버 컴포넌트니 경계를 나누는 것도 머리 아프고요.

핵심은 이겁니다. 우리가 수년간 "데이터 가져올 때 당연히 쓰는 것"이라 여겼던 useEffect가 이제 데이터 페칭의 주도권을 뺏겼다는 사실입니다.

그럼 useEffect는 이제 쓸모없는 걸까요? 그건 아닙니다. 하지만 "데이터 페칭" 용도로는 이제 놓아줄 때가 되었습니다.

이번 글에서는 겉핥기 식으로 "RSC 쓰는 법"만 다루지 않습니다. 도대체 내부가 어떻게 돌아가는지, 왜 RSC가 기존 SPA의 아키텍처 문제를 해결하는 "치트키"인지, 그리고 바뀐 멘탈 모델이 실제 대규모 앱 개발에 어떤 이점을 주는지 아주 깊게 파보겠습니다.

1. 우리가 겪던 고통: 클라이언트 사이드 워터폴 (Waterfall)

RSC가 왜 필요한지 납득하려면, 우리가 그동안 React로 개발하면서 겪었던 고질적인 문제를 직시해야 합니다.

일반적인 React 앱(SPA)을 생각해 봅시다. 브라우저가 JS 꾸러미를 잔뜩 내려받습니다. React가 실행되고, 컴포넌트 트리를 그립니다. 그제야 useEffect가 "아차, 데이터 가져와야지" 하고 API를 호출합니다.

// 익숙하시죠? 우리가 맨날 짜던 코드입니다. function UserProfile({ userId }) { const [user, setUser] = useState(null); const [loading, setLoading] = useState(true); // 1. 컴포넌트가 렌더링 되고 나서야 패칭 시작 useEffect(() => { fetch(`/api/users/${userId}`).then(data => { setUser(data); setLoading(false); }); }, [userId]); if (loading) return <Spinner />; return ( <div> <h1>{user.name}</h1> {/* 2. 이게 렌더링 되어야 비로소 자식의 패칭이 시작됨 */} <UserPosts userId={userId} /> </div> ); }

여기서 치명적인 문제는 <UserPosts />입니다. 부모인 UserProfile의 데이터가 다 로딩되고 렌더링이 끝나야, 비로소 자식인 UserPosts가 마운트되고 자신의 useEffect를 돌립니다.

  1. JS 로드
  2. UserProfile 렌더링 → API 호출
  3. (기다림...) → 데이터 도착 → 리렌더링
  4. UserPosts 마운트 → API 호출
  5. (또 기다림...) → 데이터 도착 → 리렌더링

이게 바로 네트워크 워터폴(Network Waterfall) 입니다. 폭포수처럼 순차적으로 떨어진다는 거죠. 사용자는 계속 돌아가는 로딩 스피너를 보며 기다려야 합니다. (Loading Spinner Hell 😈)

React Query나 SWR이 이 문제를 많이 완화해주긴 했지만, 근본적인 한계는 여전했습니다. "데이터 로직은 컴포넌트에 있는데, 실행은 무조건 클라이언트 브라우저에서 해야 한다" 는 제약 때문이죠.

2. React Server Components: 서버를 컴포넌트화 하다

RSC의 아이디어는 단순하지만 강력합니다. "데이터가 있는 곳(서버)에서 컴포넌트를 미리 렌더링하자."

RSC는 백엔드에 직접 접근할 수 있습니다. API를 따로 만들 필요 없이 바로 DB를 찔러도 됩니다. 클라이언트로 코드를 보낼 필요도 없죠.

RSC 방식으로 바꾸면 코드가 이렇게 바뀝니다:

// app/users/[id]/page.tsx import db from '@/lib/db'; // 1. async 컴포넌트입니다. async function UserProfile({ params }) { // 2. DB 직접 조회! API fetch 대신 await로 바로 데이터를 가져옵니다. const user = await db.user.findUnique({ where: { id: params.id } }); return ( <div> <h1>{user.name}</h1> <UserPosts userId={params.id} /> </div> ); } async function UserPosts({ userId }) { const posts = await db.post.findMany({ where: { authorId: userId } }); return ( <ul> {posts.map(post => <li key={post.id}>{post.title}</li>)} </ul> ); }

확 달라진 점이 보이시나요?

  1. useState, useEffect 삭제: 상태 관리나 사이드 이펙트 처리가 필요 없습니다.
  2. async/await 사용: 비동기 처리가 컴포넌트 레벨에서 바로 일어납니다.

서버가 UserProfile을 그릴 때 DB를 조회합니다. 데이터가 오면 바로 UserPosts로 넘어가서 또 DB를 조회합니다. 이 모든 게 서버 내부(데이터센터) 에서 일어납니다. 네트워크 지연(Latency)이 거의 없죠. 클라이언트와 서버를 왔다 갔다 하는 비용이 '0'에 수렴합니다.

3. 브라우저는 HTML 대신 "Flight"를 받는다

"그럼 그냥 SSR(Server Side Rendering)이랑 같은 거 아냐?"라고 생각하실 수 있습니다. 다릅니다.

RSC를 요청하면 서버는 완성된 HTML 문자열을 주는 게 아닙니다. HTML은 SSR의 역할이고요, RSC는 "Flight" 라고 불리는 특별한 포맷을 보냅니다.

1:I["./src/components/ClientCounter.js",["234","345"],"ClientCounter"] 0:["$","div",null,{"children":[["$","h1",null,{"children":"Hello World"}],["$","$L1",null,{}]]}]

이런 식의 데이터 덩어리가 날라옵니다. React Element 트리를 텍스트로 직렬화(Serialize)한 거죠.

  • 일반 HTML 태그(div, h1)는 그대로 들어있고,
  • Client Component가 필요한 자리에는 "여기에 ClientCounter.js 갖다 붙여!"라는 참조(Reference)만 남겨둡니다.

중요한 건 Server Component의 코드는 브라우저로 전송되지 않는다는 점입니다.
만약 서버 컴포넌트에서 날짜 포맷팅을 위해 무거운 라이브러리를 썼다고 칩시다. 브라우저는 결과 값인 "2025년 12월 9일"이라는 텍스트만 받습니다. 라이브러리 용량은 번들에 포함되지 않습니다. Zero Bundle Size의 꿈이 실현되는 순간이죠.

4. 'use client'는 클라이언트 전용이 아니다

RSC는 서버 전용이라 인터랙션(클릭, 입력 등)이 불가능합니다. 인터랙션이 필요하면 "use client" 를 선언해서 Client Component로 전환해야 합니다.

// src/components/LikeButton.tsx 'use client'; // "이보게, 여기부터는 React가 브라우저에서 돕게 해주게" import { useState } from 'react'; export default function LikeButton() { const [likes, setLikes] = useState(0); return <button onClick={() => setLikes(likes + 1)}>좋아요 {likes}</button>; }

오해하시면 안 되는 게, "use client"를 붙인다고 해서 "CSR(Client Side Rendering)"만 하는 게 아닙니다. 이 컴포넌트들도 서버에서 미리 HTML로 렌더링(SSR) 되어서 초기 로딩 속도를 확보합니다. 단지, 브라우저에서 JS가 붙어서(Hydration) 움직일 수 있게 된다는 뜻입니다.

컴포넌트 조합의 묘미 (Composition)

"그럼 최상위에 Context Provider 감싸려면 "use client" 붙여야 하는데, 그럼 하위 컴포넌트들도 다 클라이언트 컴포넌트 되는 거 아냐?"

아닙니다. Children 패턴을 쓰면 됩니다.

// app/page.tsx (Server Component) import ClientLayout from './ClientLayout'; // 클라이언트 컴포넌트 import ServerPostList from './ServerPostList'; // 서버 컴포넌트 export default function Page() { return ( // Server Component를 Client Component의 자식으로 넘깁니다! <ClientLayout> <ServerPostList /> </ClientLayout> ); }
// app/ClientLayout.tsx (Client Component) 'use client'; export default function ClientLayout({ children }) { // children으로 들어온 ServerPostList는 이미 서버에서 다 처리된 결과물(슬롯)입니다. // ClientLayout 입장에서는 그저 "뭔지 모를 리액트 노드"일 뿐이죠. return <div className="dark-theme">{children}</div>; }

이렇게 하면 ServerPostList는 여전히 서버 컴포넌트로 남아서 DB에 직접 접근할 수 있고, ClientLayout은 브라우저에서 상태 관리를 할 수 있습니다. 이 합성(Composition) 패턴이 RSC 아키텍처의 핵심입니다.

5. 결론: useEffect는 이제 보내주자

우리가 그동안 useEffect로 데이터를 가져왔던 건, React 렌더링 사이클 중에서 부수 효과(Side Effect)를 처리할 유일한 타이밍이 그때였기 때문입니다. 어쩔 수 없는 선택이었죠.
하지만 단점이 너무 컸습니다.

  1. 느립니다. (렌더링 → 마운트 → 이펙트 실행 → 패칭 시작)
  2. 복잡합니다. (로딩 상태 관리, 레이스 컨디션 처리, 메모리 누수 방지...)

이제 RSC에서는 렌더링 중에 데이터를 기다립니다.

const data = await getData(); // 기다려! return <View data={data} />; // 다 왔네? 그려!

코드가 동기(Synchronous) 코드처럼 직관적으로 변합니다. 데이터가 있다는 게 보장되니 옵셔널 체이닝(?.)을 남발할 필요도 줄어듭니다.

물론 웹소켓 연결이나 윈도우 리사이즈 이벤트 같은 건 여전히 useEffect가 필요합니다. 하지만 "페이지 로딩하자마자 API 찔러서 데이터 가져오기" 용도로는 이제 은퇴할 시간입니다.

Next.js App Router로 넘어가면서 멘탈 붕괴를 겪고 계신가요?
관점을 바꿔보세요. "클라이언트에서 서버 흉내를 내던 짐"을 내려놓고, "진짜 서버"에게 일을 맡기는 과정이라고요.

다음 프로젝트에서는 습관적으로 useEffect를 치기 전에 한번 멈칫해보세요.
"이거... 그냥 서버에서 await 한 줄이면 끝나는 거 아니야?"


Next.js App Router 도입 팁: 한 번에 모든 걸 바꾸려 하지 마세요. 버튼, 입력창 같은 작은 'Leaf Component'부터 클라이언트 컴포넌트로 만들고, 페이지 전체 구조는 서버 컴포넌트로 유지하는 '바깥에서 안으로' 전략이 유효합니다.

ReactNext.jsServer ComponentsPerformanceWeb Development

관련 도구 둘러보기

Pockit의 무료 개발자 도구를 사용해 보세요