TechCompare
프론트엔드2026년 4월 18일· 12 분 읽기

React 19 서버 컴포넌트: 12년차 엔지니어가 겪은 오해와 실전 삽질기

React 19와 Next.js 환경에서 서버 컴포넌트(RSC)를 다루며 흔히 하는 오해 3가지를 풀고, 직렬화와 하이드레이션의 실제 동작 원리를 분석합니다.

금요일 오후 5시, 배포를 딱 30분 남겨두고 스테이징 서버의 빌드가 터졌다. 로컬에서는 분명히 멀쩡하게 돌아가던 코드가 ReferenceError: window is not defined라는 불길한 메시지를 뿜어낸다. 범인은 뻔하다. 컴포넌트 상단에 'use client' 한 줄을 빼먹었거나, 서버 컴포넌트에서 브라우저 전용 API를 호출했을 때 발생하는 전형적인 사고다. 12년 동안 코드를 만지며 수많은 프레임워크를 거쳤지만, React 19.0.0으로 넘어오면서 겪는 이 '서버와 클라이언트의 경계선'에서의 삽질은 베테랑에게도 여전히 낯설고 당혹스럽다.

우리가 서버 컴포넌트를 오해하는 이유

많은 개발자가 React 19의 서버 컴포넌트(RSC)를 접하며 가장 먼저 하는 오해는 "이거 예전 PHP나 JSP 시절로 회귀하는 것 아니냐"는 생각이다. 사실 나도 처음엔 그렇게 느꼈다. 하지만 이건 서버 사이드 렌더링(SSR)과 서버 컴포넌트의 개념을 혼동하기 때문에 생기는 아주 자연스러운 오해다.

두 번째 오해는 서버 액션(Server Actions)이 단순히 REST API나 GraphQL을 완전히 대체할 수 있다는 믿음이다. 막상 실무에서 복잡한 폼 라이브러리나 상태 관리 도구와 엮어보면, 이게 생각만큼 만만한 녀석이 아니라는 걸 금세 깨닫게 된다. 세 번째는 클라이언트 컴포넌트가 '나쁜 것' 혹은 '피해야 할 것'이라는 강박이다. 서버 컴포넌트가 번들 사이즈를 줄여주니 무조건 서버에서 다 처리해야 한다는 압박에 시달리지만, 이는 오히려 사용자 경험(UX)을 망치는 지름길이 되기도 한다.

직렬화라는 마법의 실체

서버 컴포넌트가 동작하는 핵심은 HTML을 내려주는 게 아니라, 'RSC Payload'라고 불리는 특수한 형태의 직렬화된 데이터를 브라우저에 전달하는 데 있다. SSR은 서버에서 완성된 HTML 스트링을 쏴주는 방식이지만, RSC는 React 트리 구조를 JSON과 유사한 형태(하지만 함수나 프로미스까지 포함할 수 있는 특수한 포맷)로 전달한다.

직접 네트워크 탭을 열어 확인해보면, 일반적인 JSON과는 다른 기괴한 문자열들이 나열된 것을 볼 수 있다. 이 Payload 안에는 클라이언트 컴포넌트가 들어갈 '구멍(Slot)'과 그 구멍에 들어갈 데이터가 정의되어 있다. 브라우저는 이 데이터를 받아서 클라이언트 사이드에서 하이드레이션을 진행하며 최종적인 DOM을 완성한다.

  • SSR: 서버에서 HTML 생성 -> 브라우저에서 전체 하이드레이션 (TBT 발생 확률 높음)
  • RSC: 서버에서 트리 구조 생성 -> 브라우저에서 필요한 부분만 점진적 렌더링 (번들 사이즈 감소)

실제로 React 19 기반의 Next.js App Router를 적용했을 때, 순수 클라이언트 렌더링 대비 자바스크립트 번들 사이즈가 약 20%에서 50%까지 감소하는 효과를 볼 수 있다. (출처: Next.js 공식 사례 연구 및 직접 측정, Node 22 LTS 환경). 하지만 여기서 트레이드오프가 발생한다. 직렬화할 수 없는 데이터(예: 클래스 인스턴스, 이벤트 핸들러 등)를 서버에서 클라이언트로 넘기려 할 때 발생하는 런타임 에러는 개발자를 미치게 만든다.

멘탈 모델의 전환: 데이터 소유권

이제 우리는 '어디서 렌더링하느냐'보다 '데이터의 소유권이 어디에 있느냐'로 사고를 전환해야 한다. 서버 컴포넌트는 데이터베이스나 파일 시스템 같은 민감한 자원에 직접 접근할 수 있는 '데이터 소유자'다. 반면 클라이언트 컴포넌트는 사용자의 클릭, 스크롤, 입력 같은 '상태(State) 소유자'다.

tsx
// React 19 Server Action Example
async function updateInventory(formData: FormData) {
  'use server';
  const itemId = formData.get('id');
  const quantity = Number(formData.get('quantity'));

  // DB에 직접 접근 (서버 전용 로직)
  await db.inventory.update({ where: { id: itemId }, data: { quantity } });

  // 캐시 갱신을 통해 UI 업데이트 유도
  revalidatePath('/inventory');
}

export default function InventoryForm({ item }) {
  return (
    <form action={updateInventory}>
      <input type="hidden" name="id" value={item.id} />
      <input type="number" name="quantity" defaultValue={item.quantity} />
      <button type="submit">재고 수정</button>
    </form>
  );
}

위 코드에서 볼 수 있듯, 서버 액션은 API 엔드포인트를 따로 만들지 않고도 함수 호출처럼 DB를 수정할 수 있게 해준다. 하지만 단점도 명확하다. 에러 핸들링이 기존 try-catch만으로는 부족하며, 낙관적 업데이트(Optimistic Updates)를 구현하려면 useOptimistic 같은 새로운 훅을 익혀야 한다. 단순히 코드가 짧아진다고 좋아할 게 아니라, 네트워크 레이턴시가 발생했을 때 사용자가 느낄 '먹통' 현상을 어떻게 제어할지 고민하는 게 진짜 엔지니어의 몫이다.

실전에서 마주할 쓴맛

경험상 서버 컴포넌트 도입 시 가장 큰 걸림돌은 서드파티 라이브러리다. 아직도 많은 유명 UI 라이브러리들이 내부적으로 useContextuseEffect를 사용하면서도 상단에 'use client'를 선언하지 않고 있다. 이런 라이브러리를 서버 컴포넌트에서 직접 불러오면 여지없이 빌드 에러가 난다. 결국 라이브러리를 한 번 더 래핑하는 '어댑터 컴포넌트'를 만들어야 하는 번거로움이 생긴다.

또한 디버깅의 난이도가 수직 상승한다. 브라우저 개발자 도구의 콘솔에는 찍히지 않는 로그가 서버 터미널에만 찍힐 때의 그 소외감이란. 특히 Suspense와 함께 사용할 때 데이터 로딩 시점이 꼬이면, 화면 일부가 무한 로딩에 빠지는 현상을 잡기 위해 며칠을 허비할 수도 있다.

결국 기술은 도구일 뿐이다. 서버 액션을 쓸지, 기존처럼 REST API를 짤지 고민된다면 '내가 관리해야 할 상태의 생명주기'가 어디까지인지부터 다시 그려보자. 복잡한 비즈니스 로직이 서버에 숨겨져야 한다면 RSC가 정답이겠지만, 실시간성이 중요하고 클라이언트의 상태가 파편화되어 있다면 기존 방식이 훨씬 유리할 수 있다. 12년 동안 삽질하며 배운 게 있다면, 유행하는 기술을 쫓기보다 내 서비스의 병목이 어디인지 정확히 짚어내는 눈이 훨씬 중요하다는 사실이다. 지금 당장 당신의 프로젝트 브라우저 네트워크 탭을 열어보자. 정말 그 많은 자바스크립트 뭉치가 사용자에게 필요한 것인지 확인하는 것부터가 시작이다.

# React19# ServerComponents# Nextjs# Fullstack# WebDev

관련 글