11. 넥스트 보안이슈 분석 전, RSC가 완전히 이해하기.
2025년 12월 7일
React Server Components란?

토요일에 열심히 kcc 농구보면서 응원하고 있는데 갑자기 이런 메일을 받았습니다.
리액트 서버 컴포넌트관련하여 어떤 보안문제가 생겼다고 하는데.. 일단 직접적인 보안문제는 다음 게시글에 다루기로 하고, 일단 먼저 리액트 서버 컴포넌트가 무엇인지 자세하게 알고 넘어가야 할 것 같아서 확실하게 공부하고자 합니다.
Server Side Rendering
React Server Components를 맥락에 맞게 이해하려면, Server Side Rendering(SSR)이 어떻게 작동하는지 이해하는 것이 도움이 됩니다.
옛날 방식: 클라이언트 사이드 렌더링
2015년에 React를 처음 사용하기 시작했을 때, 대부분의 React 설정은 "클라이언트 사이드" 렌더링 전략을 사용했습니다. 사용자가 받는 HTML 파일은 다음과 같았습니다:
<!DOCTYPE html> <html> <body> <div id="root"></div> <script src="/static/js/bundle.js"></script> </body> </html>
bundle.js 스크립트에는 앱을 마운트하고 실행하는 데 필요한 모든 것이 포함되어 있습니다. React, 다른 서드파티 의존성, 그리고 우리가 작성한 모든 코드가 포함되어 있습니다.
JavaScript가 다운로드되고 파싱되면, React가 작동을 시작하여 전체 애플리케이션의 모든 DOM 노드를 생성하고, 빈 <div id="root"> 안에 배치합니다.
이 접근 방식의 문제점은 이 모든 작업을 수행하는 데 시간이 걸린다는 것입니다. 그리고 그 모든 일이 일어나는 동안 사용자는 빈 흰색 화면만 바라보고 있어야 합니다(리액트 쓰면 첫 로딩 오래걸린다는게 이거때문). 이 문제는 시간이 지나면서 악화되는 경향이 있습니다. 우리가 출시하는 모든 새로운 기능은 JavaScript 번들에 더 많은 용량 추가하여, 사용자가 앉아서 기다려야 하는 시간을 늘립니다.
여기서 도움이 될 수 있는 최적화(예: 특정 모듈의 지연 로딩 또는 라우트 기반 분할)가 있지만, 일반적으로는 JavaScript 번들은 계속 커지고 커지는 경향이 있습니다.
Server Side Rendering의 재?등장
Server Side Rendering은 이 경험을 개선하기 위해 설계되었습니다. 빈 HTML 파일을 보내는 대신, 서버가 애플리케이션을 렌더링하여 실제 HTML을 생성합니다. 사용자는 완전히 형성된 HTML 문서를 받습니다.
그 HTML 파일에는 여전히 <script> 태그가 포함됩니다. 왜냐하면 상호작용을 처리하기 위해 브라우저에서 React를 실행해야 하기 때문입니다. 하지만 우리는 브라우저에서 React가 조금 다르게 작동하도록 구성합니다. 처음부터 모든 DOM 노드를 생성하는 대신, 기존 HTML을 채택합니다. 이 프로세스를 Hydration이라고 합니다.
React 코어 팀 멤버 Dan Abramov는 다음과 같은 워딩을 사용합니다:
Hydration은 상호작용성과 이벤트 핸들러라는 물로 마른 HTML에 물을 주는 것과 같습니다.
JavaScript 번들이 다운로드되면, React는 전체 애플리케이션을 빠르게 실행하여 UI의 가상 스케치를 구축하고, 실제 DOM에 "맞추고", 이벤트 핸들러를 연결하고, 모든 effect를 실행합니다.
이것이 SSR의 요약입니다. 서버가 초기 HTML을 생성하므로 사용자는 JavaScript 번들이 다운로드되고 파싱되는 동안 빈 흰색 페이지를 응시할 필요가 없습니다. 클라이언트 사이드 React는 서버 사이드 React가 멈춘 지점부터 이어받아 DOM을 채택하고 상호작용성을 뿌립니다.
포괄적인 용어
Server Side Rendering에 대해 이야기할 때, 우리는 일반적으로 다음과 같은 흐름을 생각합니다:
- 사용자가 myWebsite.com을 방문합니다.
- Node.js 서버가 요청을 받고 즉시 React 애플리케이션을 렌더링하여 HTML을 생성합니다.
- 새로 구운 HTML이 클라이언트에 전송됩니다.
이것은 Server Side Rendering을 구현하는 한 가지 가능한 방법이지만, 유일한 방법은 아닙니다. 또 다른 옵션은 애플리케이션을 빌드할 때 HTML을 생성하는 것입니다.
일반적으로 React 애플리케이션은 컴파일되어야 합니다. JSX를 일반 JavaScript로 변환하고 모든 모듈을 번들링합니다. 그 같은 프로세스 중에 모든 다른 라우트에 대한 모든 HTML을 "사전 렌더링"하면 어떨까요?
이것은 일반적으로 Static Site Generation (SSG)이라고 알려져 있습니다. Server Side Rendering의 하위 변형입니다.
"Server Side Rendering"은 여러 다른 렌더링 전략을 포함하는 포괄적인 용어입니다. 그것들은 모두 한 가지 공통점을 가지고 있습니다: 초기 렌더링이 Node.js와 같은 서버 런타임에서 발생하며, ReactDOMServer API를 사용합니다. 이것이 실제로 언제 발생하는지는 중요하지 않습니다. 주문형?이든 컴파일 타임이든 상관없습니다. 어느 쪽이든 Server Side Rendering입니다.
왔다 갔다 하기
React에서 데이터 가져오기에 대해 이야기해 봅시다. 일반적으로 우리는 네트워크를 통해 통신하는 두 개의 별도 애플리케이션을 가지고 있었습니다:
- 클라이언트 사이드 React 앱
- 서버 사이드 REST API
React Query, SWR, Apollo 같은 것을 사용하여, 클라이언트는 백엔드에 네트워크 요청을 보내고, 백엔드는 데이터베이스에서 데이터를 가져와 네트워크를 통해 다시 보냅니다.
이 흐름을 그래프로 시각화할 수 있습니다. 아래 그래프들은 클라이언트(브라우저)와 서버(백엔드 API) 간에 데이터가 어떻게 이동하는지를 보여주는 네트워크 요청 그래프입니다.

출처 : https://www.joshwcomeau.com/react/server-components/
하단의 숫자들은 가상 시간 단위를 나타냅니다. 분이나 초가 아닙니다. 실제로는 수많은 다른 요인에 따라 숫자가 엄청나게 다릅니다. 이 그래프들은 개념에 대한 높은 수준의 이해를 제공하기 위한 것이며, 실제 데이터를 모델링하는 것이 아닙니다.
클라이언트 사이드 렌더링 (CSR) 방식

출처 : https://www.joshwcomeau.com/react/server-components/
이 첫 번째 그래프는 클라이언트 사이드 렌더링(CSR) 전략을 사용하는 흐름을 보여줍니다. 클라이언트가 HTML 파일을 받는 것으로 시작합니다. 이 파일에는 콘텐츠가 없지만 하나 이상의 <script> 태그가 있습니다.
JavaScript가 다운로드되고 파싱되면, React 앱이 부팅되어 많은 DOM 노드를 만들고 UI를 채웁니다. 하지만 처음에는 실제 데이터가 없으므로, 로딩 상태와 함께 셸(헤더, 푸터, 일반 레이아웃)만 렌더링할 수 있습니다.
이런 종류의 패턴을 많이 보셨을 것입니다. 예를 들어, UberEats는 실제 레스토랑을 채우는 데 필요한 데이터를 가져오는 동안 셸을 렌더링하기 시작합니다.
사용자는 네트워크 요청이 해결되고 React가 다시 렌더링하여 로딩 UI를 실제 콘텐츠로 교체할 때까지 이 로딩 상태를 봅니다.
서버 사이드 렌더링 (SSR) 방식

출처 : https://www.joshwcomeau.com/react/server-components/
다음 그래프는 같은 일반적인 데이터 가져오기 패턴을 유지하지만, 클라이언트 사이드 렌더링 대신 서버 사이드 렌더링을 사용합니다:
이 흐름에서:
- 서버에서 셸을 렌더링합니다 (빠름)
- 서버에서 클라이언트로 응답을 보냅니다
- 사용자는 즉시 콘텐츠를 볼 수 있습니다 (좋음!)
- 클라이언트가 JavaScript를 다운로드합니다
- 클라이언트가 서버로 요청을 보냅니다 (데이터 필요)
- 서버가 데이터베이스 쿼리를 실행합니다
- 서버에서 클라이언트로 응답을 보냅니다
- 클라이언트가 콘텐츠를 렌더링합니다
- 클라이언트가 Hydration을 수행합니다
SSR의 장점:
- 사용자는 JavaScript가 다운로드되기 전에 콘텐츠를 볼 수 있습니다.
하지만 여전히 문제가 있습니다:
- 서버에서 HTML을 생성했지만, 데이터는 아직 없습니다.
- 데이터를 가져오기 위해 또 다른 네트워크 왕복이 필요합니다.
React Server Components 소개
React Server Components는 이 문제를 해결합니다.
핵심 아이디어
React Server Components를 사용하면, 컴포넌트가 서버에서 실행되어 데이터베이스에 직접 접근할 수 있습니다. 네트워크 왕복이 필요 없습니다
새로운 흐름:
- 서버가 데이터베이스 쿼리를 실행합니다 (네트워크 왕복 없음!)
- 서버가 앱을 렌더링합니다 - 이제 데이터가 있습니다!
- 서버에서 클라이언트로 응답을 보냅니다
- 사용자는 완성된 콘텐츠를 즉시 봅니다
- 클라이언트가 JavaScript를 다운로드합니다
- 클라이언트가 Hydration을 수행합니다
중요한 차이점
전통적인 SSR:
- 서버에서 HTML 문자열을 생성합니다
- 이 HTML을 클라이언트에 보냅니다
React Server Components:
- 서버에서 React 컴포넌트를 실행합니다
- 컴포넌트의 결과를 직렬화하여 클라이언트에 보냅니다
- 클라이언트의 React가 이를 받아 화면에 표시합니다
실제 프로젝트 예시
제 블로그 포트폴리오 프로젝트에서 실제로 어떻게 사용되는지 살펴보겠습니다.
예시 1: 블로그 페이지 (Server Component)
// app/blog/page.tsx import Link from 'next/link' import { getAllTags, getAllProjects } from '@/lib/blog' import Header from '@/components/Home/Header/Header' // 항상 동적으로 렌더링하여 최신 데이터를 가져오도록 설정 export const dynamic = 'force-dynamic' export const revalidate = 0 export default async function BlogPage() { // 서버에서 직접 Firebase에서 데이터 가져오기 const tags = await getAllTags() const projects = await getAllProjects() // 프로젝트 정보가 있는 태그와 없는 태그를 구분 const projectMap = new Map(projects.map(p => [p.tag, p])) const projectTags = tags.map(tag => ({ tag, project: projectMap.get(tag) || null, })) return ( <> <Header /> <section className="min-h-screen bg-[#050a13] text-white pt-32 px-4 pb-16"> <div className="max-w-[1200px] mx-auto"> <h1 className="text-5xl text-white mb-4 text-center">Projects</h1> {/* 프로젝트 목록 렌더링 */} <div className="grid grid-cols-[repeat(auto-fill,minmax(300px,1fr))] gap-8"> {projectTags.map(({ tag, project }) => ( <Link key={tag} href={`/blog/${encodeURIComponent(tag)}`} > <h2>{tag}</h2> {project?.description && <p>{project.description}</p>} </Link> ))} </div> </div> </section> </> ) }
무슨 일이 일어나는가?
- 사용자가
/blog페이지를 방문합니다 - Next.js 서버가
BlogPage컴포넌트를 실행합니다 - 서버에서
getAllTags()와getAllProjects()를 실행하여 Firebase에서 데이터를 가져옵니다 - 서버에서 컴포넌트를 렌더링합니다
- 결과를 브라우저에 전송합니다
- 브라우저는 즉시 완성된 HTML을 표시합니다
장점:
- JavaScript 번들에
BlogPage컴포넌트 코드가 포함되지 않습니다 - Firebase에서 데이터를 가져오는 로직도 번들에 포함되지 않습니다
- 사용자는 완성된 콘텐츠를 즉시 봅니다
예시 2: 태그 페이지 (Server + Client Component 조합)
// app/blog/[tag]/page.tsx (Server Component) import { notFound } from 'next/navigation' import { getProjectByTag, getPostsByTagPaginated } from '@/lib/blog' import PostList from './PostList' // Client Component export const dynamic = 'force-dynamic' export const revalidate = 0 export default async function TagPage({ params }: PageProps) { const decodedTag = decodeURIComponent(params.tag) // 서버에서 Firebase에서 데이터 가져오기 const project = await getProjectByTag(decodedTag) const initialResult = await getPostsByTagPaginated(decodedTag, 12) const initialPosts = initialResult.posts if (initialPosts.length === 0) { notFound() } return ( <> <Header /> <section> <h1>{decodedTag}</h1> {/* 서버에서 렌더링된 프로젝트 설명 */} {project?.description && ( <ReactMarkdown>{project.description}</ReactMarkdown> )} {/* Client Component에 초기 데이터 전달 */} <PostList initialPosts={initialPosts} tag={decodedTag} /> </section> </> ) }
// app/blog/[tag]/PostList.tsx (Client Component) 'use client' import { useState, useEffect, useRef, useCallback } from 'react' import { getPostsByTagPaginated } from '@/lib/blog' export default function PostList({ initialPosts, tag }: PostListProps) { // 클라이언트에서 상태 관리 const [posts, setPosts] = useState<BlogPost[]>(initialPosts) const [loading, setLoading] = useState(false) const [hasMore, setHasMore] = useState(initialPosts.length === 12) // 무한 스크롤을 위한 Intersection Observer const loadMore = useCallback(async () => { if (loading || !hasMore) return setLoading(true) // 클라이언트에서 추가 데이터 가져오기 const result = await getPostsByTagPaginated(tag, 12, lastDoc) setPosts(prev => [...prev, ...result.posts]) setLoading(false) }, [tag, lastDoc, loading, hasMore]) useEffect(() => { // 스크롤 감지하여 자동으로 더 많은 포스트 로드 const observer = new IntersectionObserver( (entries) => { if (entries[0].isIntersecting) { loadMore() } }, { threshold: 0.1 } ) // ... }, [hasMore, loading, loadMore]) return ( <div> {posts.map(post => ( <Link key={post.id} href={`/blog/${tag}/${post.slug}`}> <h2>{post.title}</h2> </Link> ))} </div> ) }
작동하는 방식:
-
서버에서 (TagPage):
- Firebase에서 프로젝트 정보와 초기 12개의 포스트를 가져옵니다
- 프로젝트 설명을 서버에서 렌더링합니다
PostList컴포넌트에 초기 데이터를 props로 전달합니다
-
브라우저에서 (PostList):
- 서버에서 전달받은 초기 포스트를 표시합니다
- 사용자가 스크롤하면 Intersection Observer가 감지합니다
- 더 많은 포스트를 클라이언트에서 가져와 추가합니다
왜 이렇게 설계함?
- 초기 데이터는 서버에서 가져와 SEO에 유리합니다
- 무한 스크롤 같은 인터랙션은 클라이언트에서 처리합니다
- JavaScript 번들 크기가 최소화됩니다 (PostList만 번들에 포함)
호환 환경
React Server Components는 현재 Next.js App Router에서만 사용할 수 있습니다.
다른 프레임워크들도 지원을 추가하고 있지만, 현재로서는 Next.js가 가장 안정적이고 기능이 완전합니다.
서버가 필요 없을 수도 있음
이전에 Server Side Rendering은 여러 렌더링 전략을 포괄하는 용어라고 언급했습니다:
- 정적(Static): HTML이 애플리케이션을 빌드할 때, 배포 프로세스 중에 생성됩니다
- 동적(Dynamic): HTML이 사용자가 페이지를 요청할 때 "주문형"으로 생성됩니다
React Server Components는 이 두 렌더링 전략 모두와 호환됩니다. Server Components가 Node.js 런타임에서 렌더링될 때, 반환하는 JavaScript 객체가 생성됩니다. 이것은 주문형으로 발생할 수도 있고 빌드 중에 발생할 수도 있습니다.
이것은 서버 없이 React Server Components를 사용할 수 있다는 의미입니다. 정적 HTML 파일을 많이 생성하고 원하는 곳에 호스팅할 수 있습니다. 실제로 이것이 Next.js App Router에서 기본적으로 일어나는 일입니다. 정말로 "주문형"으로 일어나야 할 필요가 없다면, 이 모든 작업은 미리, 빌드 중에 일어납니다.
React가 전혀 필요 없을까?
궁금할 수 있습니다: 애플리케이션에 Client Component를 전혀 포함하지 않으면, 실제로 React를 다운로드할 필요가 있을까요? React Server Components를 사용하여 정말로 정적이고 JavaScript가 없는 웹사이트를 만들 수 있을까요?
문제는 React Server Components가 Next.js 프레임워크 내에서만 사용 가능하고, 그 프레임워크에는 라우팅 같은 것들을 관리하기 위해 클라이언트에서 실행되어야 하는 코드가 많다는 것입니다.
하지만 역설적으로, 이것은 실제로 더 나은 사용자 경험을 제공하는 경향이 있습니다. 예를 들어, Next의 라우터는 일반적인 <a> 태그보다 링크 클릭을 더 빠르게 처리합니다. 전체 HTML 문서를 로드할 필요가 없기 때문입니다.
잘 구조화된 Next.js 애플리케이션은 JavaScript가 다운로드되는 동안에도 작동하지만, JavaScript가 로드되면 더 빠르고 더 좋아집니다.
클라이언트 컴포넌트 지정
기본적으로 Next.js App Router의 모든 컴포넌트는 Server Component입니다. 클라이언트에서 실행되어야 하는 컴포넌트가 있다면, 파일 맨 위에 'use client' 지시어를 추가해야 합니다.
언제 Client Component를 사용해야 하나?
다음과 같은 경우에 Client Component가 필요합니다:
- 상태 관리:
useState,useReducer - 생명주기 이벤트:
useEffect,useLayoutEffect - 브라우저 전용 API:
window,document,localStorage등 - 이벤트 핸들러:
onClick,onChange등 - 커스텀 훅: 위의 것들을 사용하는 훅
프로젝트 예시
// ❌ Server Component에서 할 수 없는 것 export default async function BlogPage() { const [likes, setLikes] = useState(0) // 에러! useState는 클라이언트에서만 사용 가능 return ( <button onClick={() => setLikes(likes + 1)}>좋아요</button> // 에러! onClick은 클라이언트에서만 ) } // ✅ 올바른 방법: Client Component 분리 'use client' export default function LikeButton() { const [likes, setLikes] = useState(0) return ( <button onClick={() => setLikes(likes + 1)}> 좋아요 {likes} </button> ) } // ✅ Server Component에서 Client Component 사용 export default async function BlogPage() { const posts = await getPosts() return ( <div> {posts.map(post => ( <div key={post.id}> <h2>{post.title}</h2> <LikeButton /> {/* Client Component 사용 가능 */} </div> ))} </div> ) }
경계 (Boundaries)
중요한 규칙이 있습니다:
- ✅ Server Component 안에 Client Component를 넣을 수 있습니다
- ❌ Client Component 안에 Server Component를 직접 넣을 수 없습니다
왜?
Server Component는 서버에서 실행되어 결과만 전송합니다. Client Component는 브라우저에서 실행되어야 하므로(hydtration), 그 안에서 서버 컴포넌트를 직접 포함할 수 없습니다.
하지만 Server Component는 props로 데이터를 Client Component에 전달할 수 있습니다!
프로젝트 예시
// ✅ 올바른 방법: Server Component → Client Component // app/blog/[tag]/page.tsx export default async function TagPage({ params }: PageProps) { const posts = await getPostsByTagPaginated(decodedTag, 12) return ( <div> <PostList initialPosts={posts.posts} tag={decodedTag} /> {/* Server Component에서 Client Component를 직접 사용 */} </div> ) } // app/blog/[tag]/PostList.tsx 'use client' export default function PostList({ initialPosts, tag }) { // Client Component는 서버에서 전달받은 데이터를 사용 const [posts, setPosts] = useState(initialPosts) // ... } // ❌ 잘못된 방법: Client Component 안에 Server Component 'use client' export default function BlogPage() { return ( <div> <TagPage /> {/* 에러! Server Component를 직접 포함할 수 없음 */} </div> ) }
우회 방법 (Workarounds)
Client Component 안에서 서버 데이터가 필요하다면, Server Component에서 데이터를 가져와 props로 전달해야 합니다.
이것이 바로 제 프로젝트에서 사용한 패턴입니다:
// Server Component: 데이터 가져오기 export default async function TagPage({ params }: PageProps) { const initialPosts = await getPostsByTagPaginated(decodedTag, 12) // Client Component에 데이터 전달 return <PostList initialPosts={initialPosts.posts} tag={decodedTag} /> } // Client Component: 인터랙션 처리 'use client' export default function PostList({ initialPosts, tag }) { // 초기 데이터는 서버에서 받고 const [posts, setPosts] = useState(initialPosts) // 추가 데이터는 클라이언트에서 가져옴 const loadMore = async () => { const more = await getPostsByTagPaginated(tag, 12, lastDoc) setPosts(prev => [...prev, ...more.posts]) } return <div>{/* ... */}</div> }
내부 들여다보기
React Server Components가 어떻게 작동하는지 간단히 살펴보겠습니다.
직렬화 (Serialization)
Server Component는 서버에서 실행되어 결과를 직렬화합니다. 이것은 컴포넌트가 반환하는 JavaScript 객체를 네트워크를 통해 전송할 수 있는 형태로 변환하는 것을 의미합니다.
예시:
// Server Component export default async function BlogPage() { const posts = await getPosts() return ( <div> <h1>블로그</h1> {posts.map(post => ( <div key={post.id}>{post.title}</div> ))} </div> ) }
서버에서 이것이 실행되면, React는 다음과 같은 직렬화된 결과를 생성합니다:
{ "type": "div", "children": [ { "type": "h1", "children": "블로그" }, { "type": "div", "key": "1", "children": "첫 번째 포스트" } ] }
이 직렬화된 데이터가 클라이언트에 전송되고, 클라이언트의 React가 이를 받아 실제 DOM을 생성합니다.
중요한 제한사항
Server Component는 직렬화 가능한 것만 반환할 수 있습니다(Client Component 로 넘기는 Props가 직렬화 가능해야합니다):
Server Component자체는 async함수일 수도 있고, 서버 전용 객체(DB Connection 등)을 내부 변수로 가질 수 도있는데, 경게(Boundary)를 넘을때(Server->Client Component로 props를 줄 때) 그 데이터가 직렬화 가능해야합니다.
✅ 직렬화 가능:
- 문자열, 숫자, 불린
- 배열, 객체
- JSX 요소
- Date (직렬화 처리 필요)
❌ 직렬화 불가능:
- 함수
- 클래스 인스턴스
- Symbol
- Event 객체
장점
React Server Components는 React에서 서버 전용 코드를 실행하는 첫 번째 "공식" 방법입니다. 하지만 이것이 더 넓은 React 생태계에서 정말로 새로운 것은 아닙니다. Next.js에서는 2016년부터 서버 전용 코드를 실행할 수 있었습니다.
큰 차이점은 이전에는 컴포넌트 안에서 서버 전용 코드를 실행할 방법이 없었다는 것입니다.
가장 명백한 이점: 성능
Server Components는 JavaScript 번들에 포함되지 않아, 다운로드해야 하는 JavaScript 양과 Hydration해야 하는 컴포넌트 수를 줄입니다.
하지만 딱히 엄청나게 흥미로운 부분은 아닙니다. 솔직히 말하면, 대부분의 Next.js 앱은 이미 "Page Interactive" 타이밍에서 충분히 빠릅니다.
의미론적 HTML 원칙을 따르면, React가 Hydration되기 전에도 앱의 대부분이 작동해야 합니다. 링크를 따라갈 수 있고, 폼을 제출할 수 있으며, 아코디언을 펼치고 접을 수 있습니다 (<details>와 <summary> 사용). 대부분의 프로젝트에서 React가 Hydration되는 데 몇 초가 걸리는 것은 괜찮습니다.
실제로 중요한 점: 기능 vs 번들 크기의 타협이 필요 없음
예를 들어, 대부분의 기술 블로그는 어떤 종류의 구문 강조 라이브러리가 필요합니다. 이 블로그에서는 Prism을 사용합니다.
모든 인기 있는 프로그래밍 언어를 지원하는 적절한 구문 강조 라이브러리는 수 메가바이트가 될 수 있으며, JavaScript 번들에 넣기에는 너무 큽니다. 결과적으로, 우리는 타협을 해야 합니다. 핵심이 아닌 언어와 기능을 제거해야 합니다.
하지만, Server Component에서 구문 강조를 수행한다고 가정해 봅시다. 그 경우, 라이브러리 코드가 실제로 JavaScript 번들에 포함되지 않습니다. 결과적으로, 우리는 타협을 할 필요가 없습니다. 모든 기능을 사용할 수 있습니다.
이것이 핵심 아이디어입니다. React Server Components와 함께 작동하도록 설계된 현대적인 구문 강조 패키지입니다.
JavaScript 번들에 포함하기에는 비용이 너무 많이 드는 것들이 이제 서버에서 무료로 실행될 수 있으며, 번들에 0킬로바이트를 추가하고 더 나은 사용자 경험을 제공합니다.
성능과 UX뿐만이 아님
의존성 배열, 오래된 클로저, 메모이제이션, 또는 변화하는 것들로 인한 다른 복잡한 것들에 대해 걱정할 필요가 없습니다.
예를 들어, 제 프로젝트의 BlogPage 컴포넌트에서:
export default async function BlogPage() { // 서버에서 직접 실행 - useState 불필요! const tags = await getAllTags() const projects = await getAllProjects() // 간단한 데이터 변환 const projectMap = new Map(projects.map(p => [p.tag, p])) const projectTags = tags.map(tag => ({ tag, project: projectMap.get(tag) || null, })) // 그냥 렌더링 return ( <div> {projectTags.map(({ tag, project }) => ( <Link key={tag} href={`/blog/${tag}`}> <h2>{tag}</h2> </Link> ))} </div> ) }
클라이언트 컴포넌트였다면 useEffect, 의존성 배열, 로딩 상태 등을 모두 관리해야 했을 것입니다. 하지만 서버 컴포넌트에서는 그냥 데이터를 가져와서 렌더링하면 됩니다.
글에서 오해할 수 있는 이론
1. Client Component는 브라우저에서만 실행된다?
Client Component도 서버에서 실행(Pre-rendering)됩니다. 초기 로딩 시 서버에서 HTML로 렌더링 됩니다. 그 후 브라우저에서 JS가 로드되면서 Hydration이 일어납니다. Client Component는 서버에서 HTML로 미리 렌더링된 후, 브라우저에서 Hydration되어 상호작용(State, Effect 등)을 담당한다라고 이해하시면 됩니다.
2. Client Component 안에 Server Component를 직접 넣을 수 없다?
1. "액자(Client)"와 "사진(Server)"
- Client Component (액자): 사용자와 상호작용합니다. (클릭, 상태 변경 등)
- Server Component (사진): 데이터베이스에서 가져온 무거운 데이터로 만들어진 정적인 콘텐츠입니다.
안 되는 상황 (Import)
액자 공장(Client Component 파일)에서 "사진(Server Component)을 직접 그려서 넣어라"라고 시키는 것입니다. 액자 공장(브라우저)에는 물감(DB 접근 권한, 비밀키 등)이 없어서 사진을 그릴 수가 없습니다. 그래서 에러가 납니다.
되는 상황 (Children)
전시회 관리자(부모 Server Component)가 "이미 완성된 사진(Server Component)"*을 들고 와서, 액자(Client Component)에게 \이거 끼워주세요(children)라고 전달하는 것입니다.
액자는 그 사진이 어떻게 만들어졌는지 알 필요가 없습니다. 그냥 구멍 뚫린 공간({children})에 끼우기만 하면 됩니다.
2. 코드예시
레이아웃이나 Context Provider를 생각하면 편할 것 같습니다.
❌ 틀린 방법 (직접 Import)
ClientLayout.tsx 파일 안에서 ServerPage를 import 하려고 하면 실패합니다.
// ❌ ClientLayout.tsx (Client Component) 'use client'; // 에러 발생. 클라이언트 컴포넌트는 서버 컴포넌트를 import 할 수 없음 import ServerPage from './ServerPage'; export default function ClientLayout() { const [isOpen, setIsOpen] = useState(false); return ( <div> <button onClick={() => setIsOpen(!isOpen)}>메뉴 열기</button> {/* 브라우저는 ServerPage 내부의 DB 코드를 실행할 수 없음 */} <ServerPage /> </div> ); }
✅ 맞는 방법 (Children으로 전달)
제3의 부모(보통 page.tsx나 layout.tsx 같은 서버 컴포넌트)가 둘을 조립해 주는 겁니다.
// 1. ClientLayout.tsx (껍데기 역할) 'use client'; import { useState } from 'react'; export default function ClientLayout({ children }) { // children을 받음 const [isOpen, setIsOpen] = useState(false); return ( <div className="layout"> <button onClick={() => setIsOpen(!isOpen)}>메뉴 열기</button> <div className="content"> {/* children이 무엇인지 알 필요 없음. 그냥 렌더링만 함 */} {children} </div> </div> ); }
// 2. Page.tsx (실제 조립하는 부모 Server Component) // 이 파일은 서버 컴포넌트입니다. import ClientLayout from './ClientLayout'; import ServerPage from './ServerPage'; export default function Page() { return ( // 서버 컴포넌트(Page)가 클라이언트 컴포넌트(ClientLayout)를 부르고, // 그 안에 또 다른 서버 컴포넌트(ServerPage)를 '자식'으로 넘겨줍니다. <ClientLayout> <ServerPage /> </ClientLayout> ); }
3. 어떤 케이스에 쓰이나?
이 패턴이 없으면 Context API를 쓸 때 큰일이 납니다.
보통 리액트 앱은 ThemeProvider나 AuthProvider 같은 Context로 앱 전체를 감싸는 경우가 생기지 않습니까. Context는 useState 등을 쓰니까 무조건 Client Component여야 합니다.
만약 "Client Component 안에 Server Component가 못 들어간다"는 규칙만 있다면, 최상위를 Client Component로 감싸는 순간 그 하위의 모든 페이지가 강제로 Client Component로 변해버리는 일이 발생합니다. (RSC의 장점 다 사라짐)
하지만 Children 패턴 덕분에:
- 겉껍데기(Provider/Layout): Client Component로 만들어서 상호작용을 처리하고,
- 알맹이(Page content):
children으로 전달받아 여전히 Server Component로 유지할 수 있는 것입니다.
"Client Component가 Server Component를 import할 수는 없지만, 부모가 꽂아주는 children으로 받아서 표시할 수는 있다."
3. HTML vs RSC Payload
1. 형태의 차이: "사진" vs "설계도"
가장 먼저 보여지는 결과물(Response)의 형태가 다릅니다.
-
SSR (Server Side Rendering):
- 형태: 완성된 HTML 문자열입니다.
- 비유: 이미 현상된 사진입니다.
- 특징: 브라우저는 이걸 받자마자 그림(픽셀)을 그릴 수 있습니다. 하지만 이건 그냥 텍스트 덩어리라서, 내용물만 쏙 빼내서 기존 화면과 합치기가 어렵습니다. (보통 페이지 전체를 갈아엎어야 함)
<!DOCTYPE html> <div> <h1>내 블로그</h1> <div id="search-bar">검색어: 리액트</div> </div> -
RSC (React Server Component):
- 형태: RSC Payload라고 부르는, JSON과 유사한 직렬화된 데이터(Binary/Text Stream)입니다.
- 비유: 레고 조립 설계도 + 부품입니다.
- 특징: 여기에는 DOM 태그뿐만 아니라, "이 부분은 클라이언트 컴포넌트 A를 렌더링해라", "이 부분은 데이터 B를 넣어라" 같은 지시사항(Instruction)이 담겨 있습니다.
// RSC Payload의 개념적 형태 (실제로는 더 복잡한 줄글 포맷입니다) { "1": { "type": "div", "children": [ { "type": "h1", "children": "내 블로그" }, { "type": "ClientComponentReference", // 클라이언트 컴포넌트 위치 표시 "id": "./SearchBar.js", "props": { "initialValue": "리액트" } } ] } }
2. 역할의 차이: "교체" vs "병합(Merge)"
화면을 업데이트할 때의 방식이 다르다는게 중요한 것 가습니다.
-
SSR의 방식 (새로고침):
- 서버에서 새로운 HTML을 받아옵니다.
- 브라우저는 기존 페이지를 지우고 새 HTML로 통째로 교체합니다.
- 문제점: 이 과정에서 사용자가 입력하고 있던
input창의 텍스트나, 스크롤 위치 같은 클라이언트 상태(State)가 전부 날아갑니다.
-
RSC의 방식 (Reconciliation/재조정):
- 서버에서 RSC Payload(설계도)를 받아옵니다.
- 클라이언트의 React는 이 설계도를 보고, 현재 화면의 트리와 비교합니다.
- 핵심: "변경된 서버 데이터(Server Component)"만 쏙쏙 골라? 업데이트하고, "클라이언트 컴포넌트(Client Component)"는 건드리지 않고 그대로 둡니다.
- 마치 Git의 Merge처럼, 내가 작업하던 내용(State)은 유지하면서 서버에서 온 변경사항만 합치는 것입니다.
건드리지 않는다는게 약간 애매하게 들릴 수 있습니다. 정확히는 컴포넌트 인스턴스는 유지하되, 내용은 업데이트 한다 입니다.
RSC의 재조정 과정에서, 서버에서 새로운 Payload가 왔을 때 리액트가 비교해보니 이 위치에는 아까랑 똑같은 클라이언트 컴포넌트가 있네? 라고 판단합니다. 그러면 리액트는 그 컴포넌트를 파괴하지 않고 살려둡니다(Preserve Instance).->이로인해 State가 유지됩니다.
하지만 리렌더링은 일어날 수 있습니다. 부모(Server Component)가 새로운 데이터를 props로 줬을 수도 잇습니다. 클라이언트 컴포넌트는 살아있는 상태에서, 새로운 Props를 반영하기 위해 함수를 다시 실행 합니다. 화면의 숫자는 바뀌지만, useState값은 그대로 유지 됩니다.(입력창 텍스트 같은)
HTML(SSR) 방식의 새로고침: "집을 불도저로 밀어버리고(Unmount), 새 집을 짓습니다(Mount). 가구(State)는 다 부서졌으니 새로 사야 합니다."
RSC 방식의 업데이트: "집은 그대로 둡니다(Instance 유지). 가구(State)도 그대로 둡니다. 대신, 집주인(Server)이 벽지 색깔(Props)을 바꾸라고 해서 도배만 다시 합니다(Re-render)."
서버에서 변경사항이 넘어오면, 리액트는 클라이언트 컴포넌트의 인스턴스(껍데기)와 상태(State)는 파괴하지 않고 유지합니다. 단, 서버에서 새로운 데이터(Props)가 넘어왔다면, 그 데이터를 반영하기 위해 컴포넌트 함수는 다시 실행(Re-render) 될 수 있습니다. 즉, '상태 보존(State Preservation)' 과 '뷰 갱신(View Update)' 이 동시에 일어나는 것입니다.
3. 실제 효과 예시: "검색 필터와 인풋 창"
쇼핑몰의 상품 목록 페이지를 예로들면
- 구조:
- 좌측: 카테고리 필터 (Server Component - 클릭 시 URL 변경)
- 중앙: 상품 목록 (Server Component - DB에서 데이터 가져옴)
- 상단: 검색창 (Client Component -
useState로 검색어 입력 중)
상황: 사용자가 검색창에 "가방"이라고 치다가, 좌측의 "신상품순" 필터를 클릭했습니다.
-
HTML(SSR) 방식이었다면:
- "신상품순" 페이지의 HTML을 통째로 새로 받아옵니다.
- 페이지가 깜빡이면서 새로 그려집니다.
- 검색창에 치던 "가방"이라는 글자는 사라집니다. (State 초기화)
-
RSC 방식이라면:
- 서버에 "신상품순 데이터를 줘"라고 요청합니다.
- 서버는 상품 목록 부분만 바뀐 RSC Payload를 보냅니다.
- React는 상품 목록(Server Component) 부분만 갈아 끼웁니다.
- 검색창(Client Component)은 그대로 둡니다. 즉, "가방"이라는 글자와 입력 포커스가 그대로 살아있습니다.
전체 그림
React Server Components는 흥미로운 발전이지만, 실제로는 "Modern React" 퍼즐의 한 부분일 뿐입니다.
React Server Components를 Suspense와 새로운 Streaming SSR 아키텍처와 결합하면 정말 흥미로워집니다. 이것은 다음과 같은 것들을 할 수 있게 해줍니다:

출처 : https://www.joshwcomeau.com/react/server-components/
- 서버가 셸을 렌더링합니다
- 사용자는 즉시 콘텐츠를 봅니다 (로딩 스피너 없이!)
- 서버가 데이터베이스 쿼리를 실행합니다
- 콘텐츠가 준비되면 스트리밍으로 전송됩니다
- JavaScript가 다운로드됩니다
- Hydration이 수행됩니다
넥스트,리액트 서버컴포넌트 보안문제를 분석하기 전 먼저 서버컴포넌트에 대해서 자세히 알아보았습니다. 다음 게시글에는, 어떤 보안문제가 생겼는지, 그리고 제 프로젝트는 그럼 안전하지에 대해 분석해보고자 합니다.
학습 출처 : https://www.joshwcomeau.com/react/server-components/