Ver2.5 React Server Components: 웹 개발의 판도를 뒤집을 새로운 패러다임 🚀

React Server Components: 웹 개발의 판도를 뒤집을 새로운 패러다임 🚀
서버 사이드 렌더링, 이제 지각변동이 시작된다! 친구야, 커피 한 잔 들고 따라와 봐.
들어가며: "아... 내 웹사이트는 왜 이렇게 느린 걸까?" 🤔
안녕! 너도 혹시 이런 고민해본 적 없어? 열심히 코딩해서 런칭한 내 웹사이트가 생각보다 느려서 속상했던 경험 말이야. 분명 로컬에서는 날아다녔는데, 막상 배포하고 나니 첫 화면이 뜨는 데 한 세월... 사용자들이 하나둘씩 떠나가는 것 같은 기분... 😭
웹 개발자라면 누구나 한 번쯤 겪어봤을 이 '성능'과의 전쟁. 특히 오늘날처럼 사용자의 기대치가 하늘을 찌르는 시대에는 웹사이트의 속도가 곧 경쟁력이잖아. 우리는 이 문제를 해결하기 위해 정말 많은 노력을 해왔어. 코드를 압축하고, 이미지를 최적화하고, 캐싱 전략을 짜고... 그중에서도 가장 핵심적인 논쟁거리는 바로 '어디서 페이지를 그릴 것인가?' 즉, 렌더링(Rendering) 전략이었지.
지금까지 우리는 주로 클라이언트 사이드 렌더링(CSR)과 서버 사이드 렌더링(SSR)이라는 두 가지 거대한 산맥 사이에서 길을 찾아 헤맸어. 어떤 방식은 초기 로딩이 빠르고, 어떤 방식은 상호작용이 풍부하고... 마치 '짜장면이냐 짬뽕이냐'처럼 완벽한 정답은 없는 문제였지.
그런데 말이야, 2020년 12월, React 팀이 이 오랜 고민에 종지부를 찍을지도 모를 엄청난 물건을 들고 나타났어. 바로 React Server Components(RSC), 줄여서 '서버 컴포넌트'라고 불리는 녀석이야. 이건 단순히 기존 SSR을 좀 더 개선한 수준이 아니야. 아예 렌더링에 대한 우리의 생각을 뿌리부터 뒤흔드는, 그야말로 '패러다임의 전환'이라고 할 수 있어.
"서버 컴포넌트? 그거 그냥 서버에서 돌리는 컴포넌트 아냐? SSR이랑 뭐가 다른데?" 라고 생각했다면, 바로 이 글을 끝까지 읽어야 할 이유가 생긴 거야. 서버 컴포넌트는 우리가 알던 SSR과는 완전히 다른 차원의 이야기거든. 자, 이제부터 이 새롭고 강력한 무기가 어떻게 웹 개발의 판도를 바꾸고 있는지, 최대한 쉽고 재미있게, 그리고 아주 깊숙하게 파헤쳐 보자. 준비됐어? Let's go! 🚀
이 글은 React에 대한 기본적인 지식(컴포넌트, props, state, hooks 등)이 있다는 가정하에 작성되었어. 만약 React가 처음이라면, 기본적인 개념을 먼저 익히고 오는 걸 추천할게! 하지만 걱정 마, 최대한 쉽게 설명해 줄 테니 잘 따라와 봐!
1장: 우리가 살던 세상 - 렌더링 삼대장 (CSR, SSR, SSG) 복습하기
새로운 영웅 '서버 컴포넌트'를 만나기 전에, 우리가 지금껏 살아온 세상의 룰을 다시 한번 짚고 넘어갈 필요가 있어. 바로 웹 페이지를 그려내는 세 가지 대표적인 방법, CSR, SSR, SSG야. 이들의 특징과 한계를 명확히 알아야만, 서버 컴포넌트가 왜 혁명적인지 제대로 이해할 수 있거든.
1. 클라이언트 사이드 렌더링 (CSR: Client-Side Rendering) - "일단 와, 나머진 너네 집에서 알아서 해!"
CSR은 말 그대로 '클라이언트 측에서 렌더링한다'는 뜻이야. SPA(Single Page Application)의 부흥을 이끈 주역이지. React, Vue, Angular 같은 모던 프레임워크들이 기본적으로 채택하는 방식이야.
동작 방식:
- 사용자가 웹사이트에 접속하면, 서버는 거의 텅 빈 HTML 파일과 거대한 JavaScript 번들 파일을 보내줘.
- 브라우저는 이 JavaScript 파일을 다운로드하고 실행(Parse & Execute)해.
- JavaScript 코드가 실행되면서 비로소 API를 호출해 데이터를 가져오고, 동적으로 HTML DOM 요소를 생성해서 화면에 페이지를 그려줘.
장점:
- 풍부한 상호작용: 일단 로딩이 끝나면, 페이지 이동이나 사용자 인터랙션이 매우 빨라. 서버에 매번 새로운 페이지를 요청하는 게 아니라, 필요한 데이터만 받아와서 클라이언트에서 직접 화면을 바꾸기 때문이지. 마치 데스크톱 앱처럼 부드러운 사용자 경험을 제공할 수 있어.
- 서버 부하 감소: 서버는 처음에 정적 파일만 툭 던져주고, 렌더링이라는 힘든 일은 모두 클라이언트(사용자 컴퓨터)에게 떠넘겨. 그래서 서버 비용이 상대적으로 저렴해질 수 있어.
단점:
- 느린 초기 로딩 속도 (TTV/TTI): 이게 가장 큰 문제야. 사용자는 거대한 JavaScript 번들이 모두 다운로드되고 실행될 때까지 흰 화면만 쳐다봐야 해. 이걸 TTV(Time To View) 또는 TTI(Time To Interactive)가 느리다고 표현해.
- 검색 엔진 최적화 (SEO) 문제: 검색 엔진 봇이 사이트에 방문했을 때, 처음에는 내용이 거의 없는 텅 빈 HTML만 보게 돼. 물론 요즘 구글 봇은 JavaScript를 실행할 줄 알지만, 여전히 완벽하지 않고 다른 검색 엔진들은 어려움을 겪을 수 있어.
// CSR 방식의 간단한 React 코드 예시
// 사용자는 처음에 <div id="root"></div> 만 받는다.
// app.js가 실행되면서 아래 컴포넌트가 그려진다.
import React, { useState, useEffect } from 'react';
import ReactDOM from 'react-dom';
function App() {
const [data, setData] = useState(null);
useEffect(() => {
fetch('/api/data')
.then(res => res.json())
.then(setData);
}, []);
if (!data) {
return <div>Loading...</div>;
}
return <h1>{data.message}</h1>;
}
ReactDOM.render(<App />, document.getElementById('root'));
2. 서버 사이드 렌더링 (SSR: Server-Side Rendering) - "완제품으로 보내줄게, 넌 받기만 해!"
CSR의 느린 초기 로딩과 SEO 문제를 해결하기 위해 등장한 구원투수가 바로 SSR이야. 이름처럼 '서버 측에서 렌더링'해서 완성된 HTML을 보내주는 방식이지. Next.js(Pages Router), Nuxt.js 같은 프레임워크들이 이 방식을 쉽게 구현할 수 있도록 도와줘.
동작 방식:
- 사용자가 웹사이트에 접속하면, 서버는 요청을 받고 페이지에 필요한 데이터를 미리 다 가져와.
- 가져온 데이터를 바탕으로 React 컴포넌트를 실행해서 완전한 HTML 페이지를 생성해.
- 서버는 이 완성된 HTML을 브라우저에 보내줘. 브라우저는 받자마자 바로 내용을 표시할 수 있어. (빠른 FCP/LCP)
- 그와 동시에, 페이지를 인터랙티브하게 만들어 줄 JavaScript 코드도 함께 다운로드해.
- JavaScript 다운로드가 끝나면, 기존에 있던 HTML 위에 이벤트 핸들러 등을 연결하는 '하이드레이션(Hydration)' 과정을 거쳐 비로소 상호작용이 가능해져.
장점:
- 빠른 초기 로딩 속도 (FCP/LCP): 사용자는 접속하자마자 완성된 페이지를 볼 수 있어. 콘텐츠를 보는 시간(FCP: First Contentful Paint, LCP: Largest Contentful Paint)이 매우 빨라.
- SEO 친화적: 검색 엔진 봇이 처음부터 내용이 꽉 찬 HTML을 받기 때문에, 페이지를 분석하고 색인을 생성하기가 아주 쉬워.
단점:
- 느린 TTFB (Time to First Byte): 서버가 페이지를 다 그릴 때까지 기다렸다가 응답을 시작하기 때문에, 첫 바이트를 받는 시간이 CSR보다 오래 걸릴 수 있어.
- 하이드레이션 동안의 비활성 상태: 화면은 보이지만, JavaScript가 다운로드되고 하이드레이션이 끝날 때까지는 버튼을 눌러도 아무 반응이 없어. 이걸 '섬뜩한 계곡(Uncanny Valley)'이라고 부르기도 해.
- 서버 부하 증가: 모든 요청마다 서버에서 페이지를 그려야 하므로, 사용자가 많아지면 서버에 부하가 커져.
- 복잡한 개발 환경: 서버와 클라이언트 양쪽에서 코드가 실행되기 때문에, 개발 환경이 복잡해지고 고려해야 할 점들이 많아져.
3. 정적 사이트 생성 (SSG: Static Site Generation) - "미리 다 만들어 놨어, 가져가기만 해!"
SSG는 SSR의 아이디어를 극단으로 밀어붙인 거야. '사용자가 요청할 때마다 만들지 말고, 아예 빌드할 때 모든 페이지를 미리 다 만들어두자!'는 거지. 블로그, 문서 사이트, 마케팅 페이지처럼 내용이 자주 바뀌지 않는 곳에 최적화된 방식이야.
동작 방식:
- 개발자가 '빌드' 명령을 실행하면, 프레임워크(Gatsby, Next.js 등)가 모든 가능한 페이지들을 미리 HTML 파일로 생성해.
- 이 생성된 HTML, CSS, JS 파일들을 CDN(Content Delivery Network) 같은 곳에 올려둬.
- 사용자가 접속하면, 가장 가까운 CDN에서 이미 만들어진 HTML 파일을 즉시 가져와 보여줘.
장점:
- 미친 듯이 빠른 속도: 동적으로 생성할 필요 없이, 이미 완성된 파일을 그냥 가져오기만 하면 되니까 속도가 정말 빨라. TTFB도 거의 0에 가까워.
- 강력한 보안과 안정성: 서버 로직이나 데이터베이스 연결이 없기 때문에, 해킹의 위협이 적고 서버가 다운될 걱정도 없어.
단점:
- 동적 콘텐츠 처리의 한계: 내용이 조금이라도 바뀌면 전체 사이트를 다시 빌드해야 해. 사용자별로 다른 내용을 보여주거나, 실시간 데이터가 중요한 사이트에는 부적합해.
- 긴 빌드 시간: 페이지가 수천, 수만 개가 되면 빌드하는 데 시간이 아주 오래 걸릴 수 있어.
자, 이렇게 세 가지 방식은 각자 명확한 장단점을 가지고 있었어. 우리는 프로젝트의 성격에 맞춰 최적의 방식을 선택해야만 했지. 그런데 이 모든 방식에는 한 가지 공통적인 딜레마가 숨어있어. 바로 '클라이언트-서버 분리'라는 태생적 한계야.
데이터는 서버에 있고, 상호작용 로직은 클라이언트에 있어. 그래서 우리는 이 둘을 연결하기 위해 항상 API라는 중간 다리를 놓아야만 했지. 데이터를 가져오기 위한 로딩 상태 처리, 에러 처리, 캐싱... `useEffect`와 `useState`로 범벅이 된 복잡한 컴포넌트들을 만들어야만 했어. 이 과정에서 수많은 버그와 비효율이 발생했지.
바로 이 지점에서 React 팀은 근본적인 질문을 던진 거야.
"컴포넌트가 꼭 클라이언트에서만 돌아야 할까? 서버의 능력을 온전히 활용하면서, 클라이언트의 장점도 누릴 수는 없을까?"
이 질문에 대한 대답이 바로... 다음 장에서 만나볼 React Server Components야. 기존의 룰을 파괴하고 새로운 게임을 시작하는 진짜 주인공이지. 🤩
2장: 영웅의 등장 - React Server Components, 대체 넌 누구냐?
자, 이제부터 진짜 본론이야. React Server Components(RSC)가 대체 뭐길래 이렇게 호들갑인지, 그 정체를 샅샅이 파헤쳐 보자. 결론부터 말하면, RSC는 "서버에서만 렌더링되고, 클라이언트에는 단 한 줄의 JavaScript 코드도 보내지 않는 새로운 유형의 컴포넌트"야.
...응? 뭐라고? 🤨
"잠깐만. 서버에서 렌더링되는 건 SSR이랑 똑같잖아. 그리고 JavaScript를 안 보낸다고? 그럼 어떻게 버튼 누르고 상태를 바꿔? 말이 돼?"
그래, 바로 그 지점이 RSC를 이해하는 핵심이야. RSC는 SSR의 확장판이나 개선 버전이 아니야. 아예 종족이 다른 새로운 존재라고 생각해야 해. 이 둘의 가장 결정적인 차이점을 기억해줘.
- SSR: 서버에서 HTML을 만들어서 보내지만, 결국 클라이언트에서 동일한 컴포넌트의 JavaScript를 다시 실행해서(하이드레이션) 상호작용을 구현해. 즉, 서버와 클라이언트가 같은 일을 두 번 하는 셈이지.
- RSC: 서버에서 렌더링을 끝내고, 그 결과물을 아주 특별한 포맷으로 클라이언트에게 보내. 클라이언트에서는 JavaScript를 실행할 필요 없이 그 결과물을 화면에 바로 끼워 넣어. 상호작용이 필요한 부분은? 그 부분만 '클라이언트 컴포넌트'로 만들어서 처리해.
아직도 좀 헷갈리지? 당연해. 이건 완전히 새로운 개념이니까. 그림을 보면서 차근차근 이해해 보자.
서버 컴포넌트와 클라이언트 컴포넌트의 공존
RSC의 세계관에서는 모든 컴포넌트가 두 종류로 나뉘어. 바로 '서버 컴포넌트'와 '클라이언트 컴포넌트'야. 우리가 앞으로 React로 개발한다는 건, 이 둘을 적재적소에 배치하는 퍼즐을 맞추는 것과 같아.
1. 서버 컴포넌트 (Server Components)
- 실행 위치: 오직 서버에서만! (빌드 시 또는 요청 시)
- 특징:
- `async/await`를 컴포넌트에서 바로 사용할 수 있어. 데이터 fetching이 혁신적으로 간편해져.
- 데이터베이스, 파일 시스템, 내부 API 등 서버 환경의 모든 자원에 직접 접근할 수 있어. 더 이상 API 레이어를 만들 필요가 없어!
- `useState`, `useEffect`, `onClick` 같은 클라이언트 측 기능(Hooks, 이벤트 핸들러)은 사용할 수 없어. 왜? 클라이언트에 JS가 없으니까!
- 라이브러리를 마음껏 가져다 써도 클라이언트 번들 크기에 영향을 주지 않아. 예를 들어, 마크다운을 HTML로 변환하는 무거운 라이브러리를 서버 컴포넌트에서 사용해도, 클라이언트에는 변환된 HTML 결과만 전달될 뿐, 라이브러리 코드는 넘어가지 않아.
- 파일 확장자 (Next.js App Router 기준): 기본적으로 모든 컴포넌트(`.js`, `.jsx`, `.tsx`)는 서버 컴포넌트로 간주돼.
2. 클라이언트 컴포넌트 (Client Components)
- 실행 위치: 서버에서 초기 HTML을 렌더링하고(SSR처럼), 클라이언트에서 하이드레이션되어 생명을 얻어.
- 특징:
- 우리가 지금까지 React로 컴포넌트를 만들던 방식과 똑같아.
- `useState`로 상태를 관리하고, `useEffect`로 생명주기를 다루고, `onClick`으로 사용자와 상호작용할 수 있어.
- `window`, `localStorage` 같은 브라우저 전용 API를 사용할 수 있어.
- 서버 환경의 자원(DB, 파일 시스템 등)에는 직접 접근할 수 없어. 기존처럼 API를 호출해야 해.
- 파일 확장자 (Next.js App Router 기준): 파일 맨 위에 `'use client';` 라는 지시어를 명시해줘야 해. 이 한 줄이 서버 컴포넌트와 클라이언트 컴포넌트를 구분하는 마법의 주문이야.
"그럼 이제 모든 걸 서버 컴포넌트로 만들어야 하나?" 절대 아니야! 그건 불가능하고, 바람직하지도 않아. 웹 애플리케이션의 핵심은 '상호작용'이잖아. 버튼 클릭, 입력, 애니메이션 등 사용자와의 모든 상호작용은 반드시 클라이언트 컴포넌트가 담당해야 해. RSC의 목표는 '모든 것을 서버로'가 아니라 '상호작용이 필요 없는 부분은 최대한 서버에 남겨두어 클라이언트를 가볍게 만들자'는 거야.
RSC는 어떻게 렌더링 결과를 전달할까? (RSC Payload)
서버 컴포넌트는 HTML을 직접 보내는 게 아니라고 했지? 대신, 렌더링된 컴포넌트 트리를 설명하는 일종의 '설계도'를 보내. 이건 HTML도 아니고 JSON도 아닌, React가 이해할 수 있는 매우 압축된 스트리밍 가능한 포맷이야. (RSC Payload라고 불러)
클라이언트에 있는 React 런타임은 이 설계도를 받아서, 마치 레고 조립하듯이 DOM을 만들거나 업데이트해. 이 방식 덕분에 서버와 클라이언트가 상태를 공유하지 않고도 매끄럽게 UI를 합칠 수 있는 거야.
예를 들어, 서버 컴포넌트가 렌더링한 `
M1:{"id":"./app/page.js","chunks":[],"name":""}
J0:["$","div",null,{"className":"greeting","children":"Hello, World!"}]
클라이언트는 이 데이터를 보고 '아, div 태그를 만들고, className은 greeting으로 하고, 그 안에 Hello, World! 텍스트를 넣으라는 거구나' 하고 알아듣고 화면에 그려주는 거지. 이 과정에는 우리가 작성한 컴포넌트의 로직(예: DB 조회 코드)이 전혀 포함되지 않아. 오직 결과물만 있을 뿐!
자, 이제 서버 컴포넌트가 기존의 렌더링 방식들과는 근본적으로 다른, 새로운 종족이라는 게 조금 감이 와? 아직은 모든 게 새롭고 낯설겠지만, 걱정하지 마. 다음 장에서는 이 새로운 영웅이 우리에게 어떤 엄청난 초능력들을 선물하는지 하나씩 알아볼 거야. 그 능력들을 알고 나면, 왜 전 세계 개발자들이 RSC에 열광하는지 온몸으로 느끼게 될 테니! 😉
3장: RSC의 초능력 - 웹 개발의 게임 체인저가 된 이유
서버 컴포넌트의 정체를 대략 파악했으니, 이제 이 녀석이 가진 필살기들을 하나씩 구경해 볼 시간이야. 이 능력들은 단순히 '조금 더 빨라진다' 수준이 아니라, 우리가 웹 애플리케이션을 설계하고 개발하는 방식 자체를 바꿔버릴 만큼 강력해. 💪
초능력 1: 제로 번들 사이즈 (Zero Bundle-Size) 컴포넌트
이게 바로 RSC의 존재 이유이자 가장 파괴적인 능력이야. 앞서 말했듯이, 서버 컴포넌트는 클라이언트로 JavaScript를 보내지 않아. 이게 왜 그렇게 중요할까?
기존의 웹 개발에서 성능 저하의 가장 큰 주범은 바로 거대한 JavaScript 번들이었어. 사용자는 이 JS 파일을 다운로드하고, 압축을 풀고, 파싱하고, 실행해야 비로소 페이지와 상호작용할 수 있었지. 이 과정은 특히 저사양 모바일 기기나 느린 네트워크 환경에서 치명적이었어.
하지만 서버 컴포넌트는 애초에 클라이언트로 넘어올 JS 코드가 없어. 예를 들어, 블로그 게시물을 보여주는 `Post` 컴포넌트가 있다고 상상해 봐. 이 컴포넌트는 마크다운을 파싱하고, 코드 블록에 하이라이팅을 입히고, 날짜를 포맷팅하는 등 여러 라이브러리를 사용할 수 있어.
// Post.jsx (서버 컴포넌트)
import { Remarkable } from 'remarkable'; // 마크다운 파서 (용량이 꽤 큼)
import hljs from 'highlight.js'; // 코드 하이라이팅 라이브러리 (이것도 큼)
import dayjs from 'dayjs'; // 날짜 포맷팅 라이브러리
async function Post({ slug }) {
const postData = await getPostDataFromDB(slug); // DB에서 데이터 가져오기
const md = new Remarkable();
const renderedHTML = md.render(postData.content);
// ... 코드 하이라이팅 로직 ...
return (
<article>
<h1>{postData.title}</h1>
<p>{dayjs(postData.date).format('YYYY년 MM월 DD일')}</p>
<div dangerouslySetInnerHTML={{ __html: renderedHTML }} />
</article>
);
}
만약 이게 클라이언트 컴포넌트였다면? `remarkable`, `highlight.js`, `dayjs` 같은 라이브러리들이 전부 사용자의 브라우저로 전송되어야 해. 수백 KB, 심지어 MB 단위의 JS가 추가될 수도 있지. 하지만 이건 서버 컴포넌트야! 이 모든 무거운 작업은 서버에서 깔끔하게 끝내고, 클라이언트에는 최종 결과물인 순수한 HTML(처럼 보이는 RSC Payload)만 보내줘. 사용자는 라이브러리의 존재조차 알 수 없어. 그저 빛처럼 빠른 로딩 속도를 경험할 뿐이지.
결과적으로, UI의 많은 부분을 서버 컴포넌트로 만들수록 클라이언트의 부담은 기하급수적으로 줄어들고, 초기 로딩 속도(TTV/TTI)는 극적으로 향상돼.
초능력 2: 백엔드 직접 접근 (Direct Backend Access)
이것도 정말 혁명적인 변화야. 지금까지 프론트엔드 개발자는 데이터를 가져오기 위해 항상 'API 엔드포인트'라는 중간 다리를 거쳐야 했어. 백엔드 개발자에게 API를 만들어달라고 요청하거나, 직접 Node.js 등으로 API 서버를 구축해야 했지. 그리고 프론트엔드에서는 `fetch`나 `axios`를 써서 이 API를 호출하고, 로딩 상태와 에러 상태를 관리하는 복잡한 코드를 작성해야 했어.
Before (기존 방식):
- 백엔드: `/api/users/:id` 엔드포인트 개발
- 프론트엔드: `useEffect` 안에서 `fetch('/api/users/123')` 호출
- 프론트엔드: `useState`로 `isLoading`, `error`, `data` 상태 관리
하지만 서버 컴포넌트는 서버에서 실행되잖아? 그래서 그냥 데이터베이스에 직접 쿼리를 날리거나, 파일 시스템을 읽거나, 내부 서비스/마이크로서비스를 호출할 수 있어. API 엔드포인트 따위는 필요 없어!
// UserProfile.jsx (서버 컴포넌트)
import { db } from '@/lib/db'; // Prisma, Drizzle 등 DB 클라이언트
import fs from 'fs/promises'; // Node.js 파일 시스템 모듈
async function UserProfile({ userId }) {
// 1. 데이터베이스에 직접 쿼리
const user = await db.user.findUnique({ where: { id: userId } });
// 2. 파일 시스템에서 직접 파일 읽기
const bio = await fs.readFile(`./private/bios/${userId}.txt`, 'utf8');
// 3. 내부 서비스 직접 호출 (환경 변수 사용)
const internalData = await fetch(process.env.INTERNAL_ANALYTICS_API, {
headers: { 'Authorization': `Bearer ${process.env.API_SECRET_KEY}` }
}).then(res => res.json());
return (
<div>
<h1>{user.name}</h1>
<p>{bio}</p>
{/* ... */}
</div>
);
}
위 코드를 봐. `useEffect`도, `useState`도, 로딩 스피너도 없어. 그냥 `async/await`로 필요한 데이터를 가져와서 바로 렌더링하면 끝이야. 코드가 미친 듯이 간결하고 직관적으로 변해. 데이터베이스 접속 정보나 API 시크릿 키 같은 민감한 정보도 클라이언트에 노출될 걱정이 전혀 없어. 어차피 이 코드는 서버에만 존재하니까.
보안 경고!서버 컴포넌트에서 데이터베이스에 직접 접근할 때는 SQL 인젝션 같은 보안 위협에 항상 주의해야 해. ORM(Prisma, Drizzle 등)을 사용하면 대부분의 경우 안전하게 처리해주지만, 직접 쿼리를 작성할 때는 반드시 파라미터를 바인딩하는 방식을 사용해야 해!
초능력 3: 자동 코드 분할 (Automatic Code Splitting)
코드 분할은 거대한 JS 번들을 여러 개의 작은 청크(chunk)로 나누어, 현재 페이지에 필요한 코드만 먼저 로딩하는 최적화 기법이야. 기존에는 `React.lazy`나 라이브러리를 사용해서 수동으로 분할 지점을 정해줘야 했지.
서버 컴포넌트는 이 코드 분할을 거의 '공짜'로 제공해. 서버 컴포넌트에서만 사용되는 모든 코드는 애초에 클라이언트 번들에 포함되지 않기 때문이야. 즉, 서버 컴포넌트로 분리하는 것 자체가 곧 코드 분할인 셈이지.
예를 들어, 관리자 페이지에만 사용되는 거대한 차트 라이브러리가 있다고 해보자.
// AdminDashboard.jsx (서버 컴포넌트)
import HugeChartLibrary from 'huge-chart-library'; // 500KB짜리 라이브러리
import AdminDataFetcher from '@/utils/admin-data-fetcher';
export default async function AdminDashboard() {
const data = await AdminDataFetcher.getSalesData();
return <HugeChartLibrary data={data} />;
}
이 `AdminDashboard` 컴포넌트는 서버 컴포넌트야. 따라서 일반 사용자가 웹사이트에 접속했을 때, `huge-chart-library`의 코드는 전혀 다운로드되지 않아. 오직 관리자가 `/admin` 경로에 접속했을 때만 서버에서 이 코드가 실행되어 차트를 그리고, 그 결과물(아마도 SVG나 이미지 같은)만 클라이언트로 보내주겠지. 우리는 그저 컴포넌트를 분리했을 뿐인데, 가장 이상적인 형태의 코드 분할이 자동으로 이루어진 거야.
초능력 4: 서버-클라이언트 경계를 넘나드는 컴포넌트 구성
RSC의 진정한 힘은 서버 컴포넌트와 클라이언트 컴포넌트를 함께 사용할 때 나타나. 이 둘은 서로를 포함하고 호출하면서 매끄럽게 하나의 UI를 완성할 수 있어.
- 서버 컴포넌트는 클라이언트 컴포넌트를 import해서 렌더링할 수 있어.
- 클라이언트 컴포넌트는 서버 컴포넌트를 '자식(children)'이나 'prop'으로 받을 수 있어. (단, 클라이언트 컴포넌트가 서버 컴포넌트를 직접 import할 수는 없어. 이건 매우 중요한 규칙이야!)
이게 무슨 말이냐면, 페이지의 전체적인 레이아웃과 정적인 콘텐츠는 서버 컴포넌트가 담당하고, 중간중간에 있는 버튼이나 폼, 인터랙티브한 차트 같은 부분만 클라이언트 컴포넌트로 '섬(island)'처럼 끼워 넣는 '아일랜드 아키텍처(Islands Architecture)'를 아주 자연스럽게 구현할 수 있다는 뜻이야.
// page.jsx (서버 컴포넌트)
import PostContent from './PostContent'; // 이것도 서버 컴포넌트
import LikeButton from './LikeButton'; // 이건 클라이언트 컴포넌트 ('use client')
import CommentBox from './CommentBox'; // 이것도 클라이언트 컴포넌트 ('use client')
export default async function BlogPostPage({ params }) {
const post = await getPost(params.slug);
return (
<main>
<h1>{post.title}</h1>
{/* 서버 컴포넌트가 정적인 콘텐츠를 렌더링 */}
<PostContent content={post.content} />
<div className="actions">
{/* 서버 컴포넌트가 클라이언트 컴포넌트를 렌더링 */}
<LikeButton postId={post.id} />
</div>
{/* 서버 컴포넌트가 또 다른 클라이언트 컴포넌트를 렌더링 */}
<CommentBox postId={post.id} />
</main>
);
}
위 예시에서 `BlogPostPage`와 `PostContent`는 서버에서 모든 걸 처리하고 JS를 보내지 않아. 반면 `LikeButton`과 `CommentBox`는 상호작용이 필요하므로 클라이언트 컴포넌트로 만들어졌고, 이들의 JS 코드만 클라이언트로 전송돼. 이렇게 하면 우리는 성능과 상호작용이라는 두 마리 토끼를 모두 잡을 수 있게 되는 거야. 🐰🐰
초능력 5: 스트리밍과 Suspense의 완벽한 조화
전통적인 SSR의 문제 중 하나는 'All or Nothing' 방식이었어. 서버에서 페이지 전체의 데이터 fetching과 렌더링이 끝날 때까지 브라우저는 아무것도 받지 못하고 하염없이 기다려야 했지 (느린 TTFB). 만약 페이지의 한 부분이 유독 느리다면, 그 부분 때문에 전체 페이지 로딩이 막혀버리는 거야.
RSC는 React의 Suspense와 결합하여 이 문제를 우아하게 해결해. 서버는 준비된 부분부터 먼저 UI 뼈대를 보내주고, 아직 준비되지 않은(데이터를 가져오는 중인) 부분은 나중에 스트리밍(Streaming)으로 보내줄 수 있어.
// page.jsx
import { Suspense } from 'react';
import Sidebar from './Sidebar'; // 빠른 컴포넌트
import Post from './Post'; // 느린 컴포넌트 (DB 조회 등)
import PostSkeleton from './PostSkeleton'; // 로딩 상태 UI
export default function Page({ params }) {
return (
<main>
<Sidebar />
<Suspense fallback={<PostSkeleton />}>
{/* Post 컴포넌트가 준비될 때까지 fallback UI를 보여줌 */}
<Post slug={params.slug} />
</Suspense>
</main>
);
}
이렇게 하면 사용자는 접속하자마자 `Sidebar`와 `PostSkeleton`(로딩 스피너 같은 것)이 포함된 페이지를 즉시 보게 돼. 그리고 백그라운드에서 서버가 느린 `Post` 컴포넌트의 렌더링을 마치면, 그 결과물을 스트림으로 보내줘. 클라이언트는 이 스트림을 받아서 `PostSkeleton`을 실제 `Post` 콘텐츠로 스르륵 교체해. 이 모든 과정이 페이지 깜빡임이나 전체 리로딩 없이 이루어져. 사용자 경험이 훨씬 부드러워지는 거지.
어때? 이 정도면 RSC가 왜 '게임 체인저'라고 불리는지 실감 나지? 🤩 하지만 모든 강력한 힘에는 책임과 규칙이 따르는 법. 다음 장에서는 서버 컴포넌트와 클라이언트 컴포넌트를 다룰 때 반드시 지켜야 할 규칙과, 둘 사이의 미묘한 관계에 대해 더 깊이 파고들어 볼 거야. 여기가 조금 헷갈릴 수 있으니 정신 바짝 차리고 따라와야 해!
4장: 게임의 법칙 - 서버 컴포넌트 vs 클라이언트 컴포넌트, 심층 분석
자, 이제 우리는 RSC의 강력한 힘을 알게 됐어. 하지만 이 힘을 제대로 사용하려면 게임의 룰을 정확히 알아야 해. "어떤 컴포넌트를 서버로 만들고, 어떤 것을 클라이언트로 만들어야 할까?", "이 둘은 어떻게 상호작용할까?" 이 질문에 답할 수 있어야 진짜 RSC를 마스터할 수 있어. 이번 장에서는 서버 컴포넌트와 클라이언트 컴포넌트의 특징과 규칙을 아주 상세하게 비교 분석해 볼 거야. 여기가 RSC의 핵심이자 가장 헷갈리는 부분이기도 하니, 집중 또 집중! 🧐
언제 서버 컴포넌트를 써야 할까? (Default)
Next.js App Router에서는 기본적으로 모든 컴포넌트가 서버 컴포넌트야. 그래서 '언제 서버 컴포넌트를 쓸까?' 보다는 '언제 클라이언트 컴포넌트로 전환해야 할까?'를 고민하는 게 더 정확한 접근법이야. 그럼에도 불구하고, 서버 컴포넌트가 빛을 발하는 대표적인 시나리오들은 다음과 같아.
- ✅ 데이터를 Fetching해야 할 때: `async/await`로 DB나 API에서 데이터를 가져오는 모든 컴포넌트는 서버 컴포넌트의 완벽한 후모야.
- ✅ 백엔드 자원에 직접 접근해야 할 때: 파일 시스템, 외부 SDK, 민감한 환경 변수(API 키 등)를 사용해야 한다면 무조건 서버 컴포넌트를 써야 해.
- ✅ 무거운 라이브러리를 사용해야 할 때: 클라이언트 번들 사이즈에 영향을 주고 싶지 않은 대용량 라이브러리(날짜, 마크다운, 분석 등)를 사용하는 컴포넌트는 서버 컴포넌트로 만들자.
- ✅ 상호작용이 전혀 없는 순수 UI를 표시할 때: 정적인 텍스트, 이미지, 레이아웃 등 사용자의 입력에 반응할 필요가 없는 부분은 모두 서버 컴포넌트로 두는 것이 좋아.
언제 클라이언트 컴포넌트로 전환해야 할까? ('use client')
반대로, 다음과 같은 경우에는 반드시 파일 상단에 `'use client';`를 선언해서 클라이언트 컴포넌트로 만들어야 해.
- ✅ 사용자 이벤트에 반응해야 할 때: `onClick`, `onChange`, `onSubmit` 등 이벤트 리스너를 사용하려면 클라이언트 컴포넌트여야 해.
- ✅ 상태(State)와 생명주기(Lifecycle)를 사용해야 할 때: `useState`, `useReducer`, `useEffect`, `useLayoutEffect` 같은 React Hooks는 클라이언트에서만 동작해.
- ✅ 브라우저 전용 API를 사용해야 할 때: `window`, `document`, `localStorage`, `navigator` 등 브라우저 환경에서만 제공되는 API를 써야 한다면 클라이언트 컴포넌트가 필수야.
- ✅ 상태 관리가 필요한 상위 레벨의 Provider를 사용할 때: `React.Context`나 `Redux`, `Zustand` 같은 상태 관리 라이브러리의 Provider 컴포넌트는 내부에 상태를 가지고 있으므로 클라이언트 컴포넌트여야 해.
최적화 팁: 컴포넌트를 최대한 잘게 쪼개라!
페이지 전체를 `'use client';`로 만드는 건 최악의 선택이야. 예를 들어, 페이지에 상호작용이 필요한 작은 버튼 하나가 있다고 해서 페이지 전체를 클라이언트 컴포넌트로 만들면, 그 페이지의 모든 컴포넌트와 라이브러리가 클라이언트 번들에 포함되어 버려. RSC의 장점을 하나도 살리지 못하는 거지.
정답은? 상호작용이 필요한 그 버튼만 별도의 클라이언트 컴포넌트(`LikeButton.jsx`)로 분리하고, 페이지의 나머지 부분은 서버 컴포넌트로 유지하는 거야. 이렇게 하면 클라이언트로는 `LikeButton.jsx`의 코드만 전송되고, 페이지의 나머지 무거운 부분은 서버에 남게 돼. 이걸 '리프 노드(leaf node)에 클라이언트 컴포넌트를 위치시킨다'고 표현해.
서로를 부르는 규칙: 가장 중요하고 헷갈리는 부분!
서버 컴포넌트와 클라이언트 컴포넌트가 서로를 어떻게 호출하고 데이터를 전달하는지, 그 규칙을 명확히 이해해야 해. 여기서 실수가 가장 많이 발생하거든.
규칙 1: 서버 컴포넌트는 클라이언트 컴포넌트를 직접 `import`하고 렌더링할 수 있다. (O)
이건 가장 일반적인 패턴이야. 서버 컴포넌트가 뼈대를 잡고, 필요한 부분에 클라이언트 컴포넌트를 끼워 넣는 방식이지.
// app/page.jsx (서버 컴포넌트)
import Counter from '../components/Counter'; // 'use client'가 있는 클라이언트 컴포넌트
export default function Page() {
// 서버에서 실행되는 로그
console.log("여기는 서버에서만 보여요!");
return (
<div>
<h1>서버 컴포넌트 페이지</h1>
{/* 서버 컴포넌트가 클라이언트 컴포넌트를 렌더링 */}
<Counter />
</div>
);
}
// components/Counter.jsx
'use client';
import { useState } from 'react';
export default function Counter() {
const [count, setCount] = useState(0);
// 클라이언트에서 실행되는 로그
console.log("여기는 클라이언트에서만 보여요!");
return (
<button onClick={() => setCount(c => c + 1)}>
Count: {count}
</button>
);
}
규칙 2: 클라이언트 컴포넌트는 서버 컴포넌트를 직접 `import`할 수 *없다*. (X)
이게 정말 중요해. 왜 안될까? 생각해보면 당연해. 서버 컴포넌트는 DB 접근 같은 서버 전용 코드를 가지고 있잖아. 이 코드는 클라이언트로 보내지면 안 되고, 클라이언트 환경에서는 실행될 수도 없어. 그래서 React는 이 패턴을 원천적으로 막아놨어.
// MyClientComponent.jsx
'use client';
// 💥 에러 발생! 클라이언트 컴포넌트는 서버 컴포넌트를 import할 수 없다.
import MyServerComponent from './MyServerComponent';
export default function MyClientComponent() {
return (
<div>
<h1>클라이언트 컴포넌트</h1>
{/* 이 코드는 동작하지 않는다! */}
<MyServerComponent />
</div>
);
}
규칙 3 (규칙 2의 해결책): 서버 컴포넌트를 클라이언트 컴포넌트의 `prop`으로 전달할 수 있다. (O)
"아니, 그럼 클라이언트 컴포넌트 안에서는 서버 컴포넌트를 절대 못 쓰는 거야?" 아니, 방법이 있어. 바로 'props'를 이용하는 거야. 특히 `children` prop을 활용하는 게 일반적이지. 이걸 '서버 컴포넌트를 구멍(hole)으로 뚫어 전달한다'고 표현하기도 해.
이게 어떻게 가능할까? 서버 컴포넌트가 클라이언트 컴포넌트에게 전달될 때는, 컴포넌트 코드 자체가 넘어가는 게 아니야. 서버에서 이미 렌더링이 끝난 결과물(RSC Payload)이 prop으로 전달되는 거야. 클라이언트 컴포넌트는 그게 서버 컴포넌트였는지 뭔지 알 필요 없이, 그냥 '렌더링된 무언가'를 받아서 제자리에 표시해주기만 하면 돼.
// app/page.jsx (서버 컴포넌트)
import ClientLayout from '../components/ClientLayout';
import ServerInfo from '../components/ServerInfo';
export default function Page() {
return (
// ClientLayout의 children으로 ServerInfo를 전달
<ClientLayout>
<ServerInfo />
</ClientLayout>
);
}
// components/ClientLayout.jsx
'use client';
// children prop을 받는다. 이 children은 서버에서 렌더링된 결과물이다.
export default function ClientLayout({ children }) {
return (
<div style={{ border: '2px solid blue', padding: '1rem' }}>
<h2>이건 클라이언트 레이아웃</h2>
{/* 서버에서 렌더링된 컴포넌트를 여기에 표시 */}
{children}
</div>
);
}
// components/ServerInfo.jsx (서버 컴포넌트)
import os from 'os';
export default function ServerInfo() {
// 서버 전용 코드
const serverUptime = os.uptime();
return <p>서버 가동 시간: {serverUptime}초</p>;
}
이 패턴을 이해하는 것이 RSC 아키텍처 설계의 핵심이야. 우리는 서버 컴포넌트와 클라이언트 컴포넌트를 마치 레고 블록처럼 조립할 수 있게 돼. 상태가 필요한 부분은 클라이언트 컴포넌트로 감싸고, 그 안에 들어갈 정적인 콘텐츠는 서버 컴포넌트로 만들어서 `children`으로 쏙 넣어주는 거지.
데이터는 어떻게 주고받을까?
- 서버 -> 클라이언트: 아주 쉬워. 그냥 `props`로 내려주면 돼. 서버 컴포넌트가 `async/await`로 데이터를 가져온 다음, 그 데이터를 클라이언트 컴포넌트의 prop으로 전달하면 끝. 단, prop으로 전달되는 데이터는 반드시 직렬화(Serializable)가 가능해야 해. 함수, Date 객체, Set, Map 등은 직접 전달할 수 없고, JSON으로 변환 가능한 형태(문자열, 숫자, 객체, 배열 등)여야 해.
- 클라이언트 -> 서버: 이건 좀 더 생각해봐야 해. 클라이언트에서 서버 컴포넌트의 함수를 직접 호출할 수는 없어. 대신, 두 가지 방법이 있어.
- 전통적인 API 라우트 사용: 클라이언트 컴포넌트에서 `fetch`를 사용해 Next.js의 라우트 핸들러(API 엔드포인트)를 호출하는 거야. 이건 기존 방식과 동일해.
- 서버 액션 (Server Actions): RSC와 함께 등장한 또 다른 강력한 기능이야. 폼(form) 제출이나 버튼 클릭 시, API 엔드포인트를 만들 필요 없이 서버에 정의된 함수를 직접 호출할 수 있게 해줘. 이건 다음 장에서 더 자세히 다룰게. 정말 멋진 기능이거든!
재능 공유 플랫폼 '재능넷'에서의 활용 예시 만약 우리가 재능넷 같은 플랫폼의 전문가 프로필 페이지를 만든다고 상상해보자.
- 페이지 전체 (`/profile/[id]/page.jsx`): 서버 컴포넌트. `async/await`로 전문가의 정보, 포트폴리오 목록, 후기 데이터를 DB에서 모두 가져온다.
- 포트폴리오 목록, 후기 목록: 서버 컴포넌트. 그냥 데이터를 받아서 쭉 나열해주기만 하면 된다.
- '문의하기' 버튼, '찜하기' 버튼: 클라이언트 컴포넌트. `onClick` 이벤트와 `useState`로 상태(ex: 찜 여부)를 관리해야 한다.
- 후기 작성 폼: 클라이언트 컴포넌트. 사용자의 입력을 받아야 하고, `onSubmit` 이벤트로 후기를 제출해야 한다.
자, 이제 서버와 클라이언트 컴포넌트의 규칙이 머릿속에 좀 정리됐어? 처음에는 분명 헷갈릴 거야. "이건 서버인가? 클라이언트인가?", "어? 왜 import가 안되지?" 하는 순간들이 분명히 올 거야. 하지만 몇 번만 직접 코드를 짜보면 금방 감을 잡을 수 있어. 핵심은 '상호작용이 필요한가?' 이 질문을 계속 던지는 거야.
다음 장에서는 드디어 실제 코드를 보면서 이 모든 개념을 실제로 구현해 볼 거야. Next.js 13+ App Router를 사용해서 간단한 블로그 애플리케이션을 만들어보면서, 서버 컴포넌트, 클라이언트 컴포넌트, 데이터 fetching, 스트리밍까지 오늘 배운 모든 것을 직접 경험해보자! 💻
5장: 실전! Next.js App Router로 서버 컴포넌트 맛보기
백문이 불여일견! 이제 이론은 충분히 배웠으니, 직접 코드를 만져보면서 서버 컴포넌트의 세계에 발을 들여놓을 시간이야. 현재 RSC를 가장 잘 지원하고, 사실상 표준으로 자리 잡은 Next.js의 App Router를 사용해서 간단한 프로젝트를 만들어 볼 거야. 이 장을 끝내고 나면, 여러분은 RSC의 작동 방식을 몸으로 체득하게 될 거야. 자, 에디터를 켜고 따라와! 🔥
*(참고: 이 장의 코드는 Next.js 13.4 이상 버전을 기준으로 작성되었어. `create-next-app`으로 프로젝트를 생성할 때 App Router 사용을 선택해야 해.)*
프로젝트 구조: App Router의 기본
Next.js App Router에서는 `app` 디렉토리가 모든 것의 중심이야. 폴더 구조가 곧 URL 라우팅이 되지.
- `app/page.jsx`: 홈페이지 (`/`)
- `app/about/page.jsx`: About 페이지 (`/about`)
- `app/blog/[slug]/page.jsx`: 동적 블로그 게시물 페이지 (`/blog/my-first-post`)
그리고 중요한 점! `app` 디렉토리 안의 `.jsx`, `.tsx` 파일은 기본적으로 모두 서버 컴포넌트로 취급돼.
예제 1: 데이터 가져오는 서버 컴포넌트 만들기
가장 먼저 해볼 것은 블로그 게시물 목록을 보여주는 페이지야. 서버 컴포넌트가 얼마나 데이터 fetching을 간단하게 만드는지 직접 확인해보자.
먼저, 가짜 블로그 데이터를 만들어 둘게. 실제로는 DB나 CMS에서 가져오겠지.
// 이 파일은 서버에서만 사용될 거야.
const posts = [
{ slug: 'rsc-intro', title: '서버 컴포넌트, 그것이 알고싶다', body: '...' },
{ slug: 'nextjs-basics', title: 'Next.js 기초 다지기', body: '...' },
{ slug: 'css-is-awesome', title: 'CSS는 정말 멋져요', body: '...' },
];
export async function getPosts() {
// 실제 DB 호출을 흉내 내기 위해 약간의 딜레이를 줌
await new Promise(resolve => setTimeout(resolve, 1000));
return posts;
}
export async function getPostBySlug(slug) {
await new Promise(resolve => setTimeout(resolve, 500));
return posts.find(post => post.slug === slug);
}
이제 블로그 목록 페이지를 만들어보자.
import Link from 'next/link';
import { getPosts } from '@/lib/posts';
// 이 컴포넌트는 서버 컴포넌트야!
export default async function BlogPage() {
// 컴포넌트 안에서 직접 async/await 사용!
const posts = await getPosts();
return (
<div>
<h1>블로그</h1>
<ul>
{posts.map(post => (
<li key={post.slug}>
<Link href={`/blog/${post.slug}`}>{post.title}</Link>
</li>
))}
</ul>
</div>
);
}
어때? `useEffect`도, `useState`도, `fetch` API 호출도 없어. 그냥 함수(`getPosts`)를 호출하고 `await` 했을 뿐인데 데이터가 가져와졌어. 정말 간결하지 않아? 이 코드는 서버에서 실행되고, 렌더링된 HTML만 클라이언트로 전달돼.
예제 2: 서버 컴포넌트와 클라이언트 컴포넌트 섞어 쓰기
이제 게시물 상세 페이지를 만들어보자. 여기서는 정적인 게시물 내용(서버 컴포넌트)과 인터랙티브한 '좋아요' 버튼(클라이언트 컴포넌트)을 함께 사용해 볼 거야.
먼저, 상호작용이 필요한 '좋아요' 버튼부터 만들자. 이건 상태와 이벤트 핸들러가 필요하니 당연히 클라이언트 컴포넌트여야 해.
'use client'; // 클라이언트 컴포넌트임을 명시!
import { useState } from 'react';
export default function LikeButton() {
const [likes, setLikes] = useState(0);
const [isLiked, setIsLiked] = useState(false);
const handleClick = () => {
setLikes(prev => isLiked ? prev - 1 : prev + 1);
setIsLiked(prev => !prev);
};
return (
<button onClick={handleClick}>
{isLiked ? '❤️ 좋아요 취소' : '🤍 좋아요'} ({likes})
</button>
);
}
이제 이 버튼을 사용할 게시물 상세 페이지를 만들자. 이 페이지 자체는 데이터를 가져와야 하므로 서버 컴포넌트로 만들 거야.
import { getPostBySlug } from '@/lib/posts';
import LikeButton from '@/components/LikeButton'; // 클라이언트 컴포넌트 import
import { notFound } from 'next/navigation';
// params.slug를 통해 URL의 동적 세그먼트를 받을 수 있어.
export default async function PostPage({ params }) {
const post = await getPostBySlug(params.slug);
if (!post) {
notFound(); // Next.js의 404 페이지 표시 기능
}
return (
<article>
<h1>{post.title}</h1>
<p>{post.body}</p>
<hr />
{/* 서버 컴포넌트가 클라이언트 컴포넌트를 렌더링 */}
<LikeButton />
</article>
);
}
자, 이제 `/blog/rsc-intro` 같은 주소로 접속해봐. 게시물 내용과 '좋아요' 버튼이 함께 보일 거야. 페이지 소스를 확인해보면 `post.title`과 `post.body`는 HTML에 그대로 박혀있지만, `LikeButton` 부분은 처음엔 기본 형태로 렌더링되고, 관련 JS 파일이 로드된 후 하이드레이션을 거쳐 인터랙티브하게 변하는 걸 볼 수 있어. 우리는 그냥 컴포넌트를 가져다 썼을 뿐인데, Next.js가 알아서 최적의 방식으로 처리해준 거야!
예제 3: 스트리밍과 로딩 UI (Suspense)
만약 게시물 데이터를 가져오는 데 시간이 아주 오래 걸린다면? 사용자는 흰 화면만 보고 있어야 할까? 아니지! Suspense를 이용해 멋진 로딩 UI를 보여주자.
Next.js App Router에서는 이게 놀랍도록 쉬워. 그냥 로딩 UI를 담은 `loading.jsx` 파일을 같은 폴더에 만들어주기만 하면 돼.
// 이 컴포넌트는 자동으로 page.jsx를 Suspense로 감싸는 역할을 해.
export default function BlogLoading() {
return (
<div>
<h1>블로그 목록을 불러오는 중... 🏃♂️</h1>
<ul>
<li>...</li>
<li>...</li>
<li>...</li>
</ul>
</div>
);
}
이 파일을 추가하고 `/blog` 페이지를 새로고침해봐. `getPosts` 함수에 설정해둔 1초의 딜레이 동안 "블로그 목록을 불러오는 중..."이라는 로딩 화면이 먼저 뜨고, 1초 뒤에 실제 목록으로 스르륵 바뀔 거야. 우리는 `Suspense` 태그를 직접 쓰지도 않았는데 말이지! Next.js가 파일 규약을 통해 자동으로 처리해준 거야. 이게 바로 컨벤션의 힘이지.
물론 더 세밀한 제어를 위해 직접 `Suspense`를 사용할 수도 있어. 예를 들어, 페이지에 아주 느린 추천 게시물 목록과 빠른 댓글 목록이 함께 있다고 가정해보자.
import { Suspense } from 'react';
import PostDetails from '@/components/PostDetails';
import RecommendedPosts from '@/components/RecommendedPosts'; // 매우 느림
import Comments from '@/components/Comments'; // 빠름
import Spinner from '@/components/Spinner';
export default function PostPage({ params }) {
return (
<article>
{/* 게시물 본문은 바로 보여줌 */}
<PostDetails slug={params.slug} />
<hr />
{/* 댓글은 바로 보여줌 */}
<Comments slug={params.slug} />
<hr />
{/* 추천 게시물은 로딩이 오래 걸리므로 Suspense로 감싼다 */}
<Suspense fallback={<Spinner text="추천 게시물 로딩 중..." />}>
<RecommendedPosts slug={params.slug} />
</Suspense>
</article>
);
}
이렇게 하면 사용자는 게시물 본문과 댓글을 먼저 보고, 추천 게시물 영역에서는 스피너가 돌아가는 걸 보게 돼. 그리고 서버에서 추천 게시물 렌더링이 끝나면, 그 부분만 스트림으로 날아와 스피너를 대체하게 되지. 전체 페이지 로딩이 느린 컴포넌트 하나 때문에 막히는 현상을 완벽하게 방지할 수 있어.
예제 4: 서버 액션 (Server Actions)으로 데이터 변경하기
지금까지는 데이터를 읽기만 했어. 그럼 데이터 생성, 수정, 삭제는 어떻게 할까? API를 만들어야 할까? 아니, 우리에겐 '서버 액션'이 있어! 폼(form)과 함께 사용하면 그 위력이 배가 돼.
간단한 방명록을 만들어보자. 먼저 서버에서 실행될 '액션' 함수를 정의할 거야.
'use server'; // 이 파일의 함수들은 서버에서만 실행되는 액션임을 명시!
import { db } from '@/lib/db'; // 가상의 DB
import { revalidatePath } from 'next/cache';
// formData는 form의 입력값들을 담고 있어.
export async function addEntry(formData) {
const name = formData.get('name');
const message = formData.get('message');
// DB에 데이터 추가
await db.guestbook.create({ data: { name, message } });
// 방명록 페이지의 캐시를 무효화해서 새로운 내용을 다시 불러오게 함
revalidatePath('/guestbook');
}
이제 이 액션을 사용할 방명록 폼 컴포넌트를 만들자. 폼 자체는 클라이언트 컴포넌트일 필요가 없어. 서버 컴포넌트로도 충분히 만들 수 있어.
import { addEntry } from '@/app/guestbook/actions';
export default function GuestbookForm() {
return (
// form의 action prop에 서버 액션 함수를 직접 연결!
<form action={addEntry}>
<label htmlFor="name">이름:</label>
<input type="text" id="name" name="name" required />
<label htmlFor="message">메시지:</label>
<textarea id="message" name="message" required />
<button type="submit">등록</button>
</form>
);
}
이게 다야! `form`의 `action`에 우리가 만든 서버 액션 함수를 그냥 넘겨주기만 하면 돼. 사용자가 폼을 제출하면, Next.js는 페이지 리로드 없이 백그라운드에서 `addEntry` 함수를 호출해. 함수 실행이 끝나고 `revalidatePath`가 호출되면, 방명록 목록이 자동으로 새로고침되어 방금 등록한 내용이 화면에 나타나. JavaScript를 한 줄도 쓰지 않고 말이야! 🤯
물론 클라이언트 컴포넌트에서 `useTransition` 훅과 함께 서버 액션을 사용하면, 로딩 중 상태(pending)를 표시하는 등 더 향상된 UX를 제공할 수도 있어.
어때? 직접 코드를 보니 RSC가 얼마나 개발 경험을 바꾸는지 느껴지지? 데이터 fetching, 로딩 상태 관리, 데이터 변경까지, 이전에는 수많은 `useEffect`와 `useState`, API 엔드포인트가 필요했던 작업들이 훨씬 직관적이고 간결한 코드로 해결됐어. 물론 이 새로운 패러다임에 익숙해지려면 시간이 좀 필요하겠지만, 그만한 가치는 충분하고도 남아.
하지만 모든 신기술이 그렇듯, RSC와 서버 액션에도 주의할 점과 잠재적인 함정들이 존재해. 마지막 장에서는 우리가 이 강력한 도구를 사용하면서 빠지기 쉬운 함정들과 고려해야 할 점들을 짚어보며 여정을 마무리할 거야.
6장: 동굴 탐험 가이드 - RSC 사용 시 주의할 점과 함정들
자, 우리는 이제 RSC라는 강력한 랜턴을 손에 넣었어. 이 랜턴만 있으면 웹 개발이라는 어두운 동굴을 훨씬 밝고 빠르게 탐험할 수 있을 거야. 하지만 동굴 안에는 예기치 못한 함정이나 낭떠러지가 숨어있을 수 있어. 마지막으로, 우리가 RSC를 사용하면서 마주칠 수 있는 몇 가지 주의사항과 함정들을 짚어보고, 안전하게 동굴을 탐험하는 방법을 알아보자. 🗺️
함정 1: "내 코드는 지금 어디서 돌고 있는 거지?" (The Mental Model Shift)
이건 RSC를 처음 접하는 모든 개발자가 겪는 가장 큰 혼란이야. 이전에는 '서버(Node.js) 코드'와 '클라이언트(브라우저) 코드'가 비교적 명확히 분리되어 있었어. 하지만 RSC의 세계에서는 하나의 React 컴포넌트 트리 안에 서버 전용 코드와 클라이언트 전용 코드가 섞여있지.
- 흔한 실수: 서버 컴포넌트 안에서 무심코 `window.localStorage`를 사용하거나, 클라이언트 컴포넌트에서 `fs` 모듈을 `import`하려고 시도하는 경우.
- 극복 방법:
- 파일 상단을 확인하는 습관: 컴포넌트를 볼 때마다 `'use client';` 지시어가 있는지부터 확인하자. 없으면 서버 컴포넌트, 있으면 클라이언트 컴포넌트다. 이 간단한 습관만으로도 많은 실수를 줄일 수 있어.
- 명확한 역할 분리: "이 컴포넌트의 주된 역할은 무엇인가?"를 스스로에게 물어보자. 데이터 fetching과 렌더링이 주 목적이면 서버 컴포넌트로, 사용자 상호작용이 주 목적이면 클라이언트 컴포넌트로 분리하는 연습을 계속해야 해.
- 컴포넌트 이름 규칙: 팀 내에서 `*.client.jsx`, `*.server.jsx` 처럼 파일명에 명시적인 접미사를 붙이는 규칙을 정하는 것도 좋은 방법이야. (Next.js가 강제하는 건 아니지만, 코드 가독성에 큰 도움이 돼)
함정 2: 데이터 Fetching 폭포 (Data Fetching Waterfalls)
서버 컴포넌트에서 `async/await`를 자유롭게 쓸 수 있게 되면서, 자칫 데이터 요청이 순차적으로 일어나는 '폭포' 현상을 만들기 쉬워. 예를 들어보자.
// 나쁜 예: 데이터 요청이 순차적으로 발생한다.
async function Page() {
// 1. 유저 정보를 기다린다 (500ms 소요)
const user = await getUser();
// 2. 유저 정보를 받은 후에야 게시물 정보를 요청한다 (1000ms 소요)
const posts = await getPostsForUser(user.id);
// 총 소요 시간: 500ms + 1000ms = 1500ms
return <.../>;
}
위 코드에서 `getPostsForUser`는 `getUser`가 끝날 때까지 기다려야 해. 하지만 두 요청은 사실 서로 의존 관계가 없다면 동시에 보낼 수 있잖아?
// 좋은 예: Promise.all로 병렬 처리한다.
async function Page() {
// 두 요청을 동시에 시작한다.
const userPromise = getUser();
const postsPromise = getPosts(); // user.id가 필요없다고 가정
// 두 Promise가 모두 완료될 때까지 기다린다.
const [user, posts] = await Promise.all([userPromise, postsPromise]);
// 총 소요 시간: max(500ms, 1000ms) = 1000ms
return <.../>;
}
이렇게 `Promise.all`을 사용하면 여러 데이터 요청을 병렬로 처리해서 전체 로딩 시간을 크게 단축할 수 있어. 컴포넌트를 분리해서 `Suspense`와 함께 사용하는 것도 좋은 해결책이야. 각 컴포넌트가 독립적으로 데이터를 가져오도록 만들면, Next.js가 알아서 병렬로 처리해주거든.
함정 3: 클라이언트 컴포넌트의 무분별한 사용
"어, 이거 상태가 필요할지도 모르겠는데? 일단 `'use client';` 붙여놓고 시작하자." 이런 생각은 RSC의 장점을 스스로 걷어차는 것과 같아. `'use client';`는 전염성이 있어. 어떤 컴포넌트가 클라이언트 컴포넌트가 되면, 그 컴포넌트가 `import`하는 모든 자식 컴포넌트들(서버 컴포넌트로 만들어진 것 제외)도 클라이언트 번들에 포함돼 버려.
- 해결책: '최대한 늦게' 클라이언트 컴포넌트로 전환하는 원칙을 지키자. 즉, 상호작용이 필요한 가장 작은 단위(리프 노드, Leaf Node)의 컴포넌트만 `'use client';`로 만들고, 그 부모 컴포넌트들은 최대한 서버 컴포넌트로 유지해야 해.
// 나쁜 예: 페이지 전체를 클라이언트 컴포넌트로 만듦
'use client';
import BigStaticComponent from './BigStaticComponent';
import InteractiveButton from './InteractiveButton';
export default function Page() {
return (
<div>
<BigStaticComponent /> {/* 이 무거운 컴포넌트까지 클라이언트 번들에 포함됨 😭 */}
<InteractiveButton />
</div>
);
}
// 좋은 예: 필요한 부분만 클라이언트 컴포넌트로 분리
// Page.jsx (서버 컴포넌트)
import BigStaticComponent from './BigStaticComponent';
import InteractiveButton from './InteractiveButton'; // 'use client'
export default function Page() {
return (
<div>
<BigStaticComponent /> {/* 이건 서버에 남음 😊 */}
<InteractiveButton /> {/* 이것만 클라이언트 번들에 포함됨 */}
</div>
);
}
함정 4: 서버 액션의 보안 문제
서버 액션은 정말 편리하지만, 서버의 코드를 클라이언트의 이벤트(폼 제출 등)에 직접 노출하는 것이기 때문에 보안에 각별히 신경 써야 해. 특히 사용자의 입력을 받아 DB를 수정하는 작업은 더욱더!
- 방어 전략:
- 인증 및 인가 확인: 서버 액션 함수의 가장 첫 부분에서는 항상 현재 사용자가 이 작업을 수행할 권한이 있는지(예: 로그인 여부, 역할 등)를 반드시 확인해야 해.
- 입력값 유효성 검사: 클라이언트에서 넘어온 `formData`는 절대 신뢰하면 안 돼. `Zod` 같은 라이브러리를 사용해서 서버 측에서 다시 한번 모든 입력값의 타입, 길이, 형식 등을 철저히 검증해야 해.
- ORM 활용: SQL 쿼리를 직접 작성하기보다는 Prisma나 Drizzle 같은 타입-세이프(Type-safe) ORM을 사용해서 SQL 인젝션 공격을 원천적으로 방어하는 것이 좋아.
함정 5: 아직은 성장 중인 생태계
RSC는 비교적 최신 기술이야. React와 Next.js는 완벽하게 지원하지만, 우리가 사용하는 다른 서드파티 라이브러리들은 아직 RSC 환경에 완전히 준비되지 않았을 수 있어. 예를 들어, `Context`를 사용하거나 내부적으로 `useEffect`를 사용하는 UI 라이브러리 컴포넌트는 서버 컴포넌트에서 직접 사용할 수 없어. `'use client';`로 감싸줘야만 하지.
- 대처법: 라이브러리를 사용하기 전에 문서를 잘 읽어보고, 'RSC 호환성'에 대한 언급이 있는지 확인하자. 만약 호환되지 않는다면, 해당 라이브러리를 사용하는 부분을 별도의 클라이언트 컴포넌트로 분리해서 사용하는 것이 현재로서는 최선의 방법이야. 다행히 시간이 지날수록 점점 더 많은 라이브러리들이 RSC를 지원하도록 업데이트되고 있어.
두려워하지 말고, 실험하고, 부딪혀라! 이런 함정들이 있다고 해서 RSC 사용을 주저할 필요는 없어. 모든 기술에는 러닝커브가 있고, 직접 부딪혀보고 에러 메시지를 마주하면서 배우는 것이 가장 빠른 길이야. 작은 개인 프로젝트부터 시작해서 서버 컴포넌트와 클라이언트 컴포넌트를 나눠보고, 서버 액션도 만들어보고, 스트리밍도 적용해보자. 그 과정에서 얻는 경험이 여러분을 한 단계 더 성장시켜 줄 거야.
마치며: 새로운 시대의 문을 열다
정말 긴 여정이었어! 우리는 CSR, SSR의 한계를 짚어보는 것부터 시작해서, React Server Components라는 새로운 영웅의 등장과 그 엄청난 능력들을 살펴봤어. 그리고 Next.js를 통해 직접 그 힘을 체험해보고, 마지막으로 우리가 조심해야 할 함정들까지 확인했지.
RSC는 단순히 'SSR의 다음 버전'이 아니야. 이것은 서버와 클라이언트의 역할을 재정의하고, 웹 애플리케이션을 구축하는 방식에 대한 근본적인 패러다임 전환이야. 우리는 더 이상 데이터 fetching 로직과 UI 로직을 분리하기 위해 복잡한 API 레이어를 만들고 상태 관리에 골머리를 앓을 필요가 없어. 대신, 각 컴포넌트의 역할에만 집중해서 가장 적합한 환경(서버 또는 클라이언트)에서 렌더링하면 돼.
물론, 이 새로운 세상에 적응하려면 시간이 걸릴 거야. 새로운 개념을 익히고, 새로운 패턴을 만들고, 때로는 실수도 하겠지. 하지만 그 변화의 끝에는 더 나은 개발자 경험과, 사용자들에게는 훨씬 더 빠르고 쾌적한 웹 경험이 기다리고 있어.
이제 여러분은 React Server Components라는 강력하고 새로운 무기를 손에 넣었어. 이 무기를 어떻게 사용하고 연마할지는 이제 여러분의 몫이야. 두려워하지 말고, 지금 바로 `create-next-app`을 실행해봐. 그리고 직접 만들어보고, 경험하고, 즐겨봐. 웹 개발의 새로운 시대는 이미 시작됐으니까! 🚀
관련 키워드
댓글 작성
이 글에 대한 여러분의 생각을 들려주세요
로그인이 필요합니다
댓글을 작성하려면 먼저 로그인해주세요.
댓글 0
지식인의 숲 - 지적 재산권 보호 고지
지적 재산권 보호 고지
- 저작권 및 소유권: 본 컨텐츠는 재능넷의 독점 AI 기술로 생성되었으며, 대한민국 저작권법 및 국제 저작권 협약에 의해 보호됩니다.
- AI 생성 컨텐츠의 법적 지위: 본 AI 생성 컨텐츠는 재능넷의 지적 창작물로 인정되며, 관련 법규에 따라 저작권 보호를 받습니다.
- 사용 제한: 재능넷의 명시적 서면 동의 없이 본 컨텐츠를 복제, 수정, 배포, 또는 상업적으로 활용하는 행위는 엄격히 금지됩니다.
- 데이터 수집 금지: 본 컨텐츠에 대한 무단 스크래핑, 크롤링, 및 자동화된 데이터 수집은 법적 제재의 대상이 됩니다.
- AI 학습 제한: 재능넷의 AI 생성 컨텐츠를 타 AI 모델 학습에 무단 사용하는 행위는 금지되며, 이는 지적 재산권 침해로 간주됩니다.
아직 댓글이 없습니다.