리액트 서버컴포넌트

한상우·2025년 4월 16일

리액트

목록 보기
14/24
post-thumbnail

React 서버 컴포넌트 (RSC) 완벽 가이드

안녕하세요,오늘은 React의 혁신적인 기능인 서버 컴포넌트(React Server Components, RSC)에 대해 자세히 알아보겠습니다. 서버 컴포넌트는 React 개발 방식을 크게 바꿀 수 있는 중요한 기술입니다. 이 글에서는 서버 컴포넌트의 작동 원리, 기존 렌더링 방식과의 차이점, 그리고 실제 개발에서 어떻게 활용할 수 있는지 살펴보겠습니다.

1. React 서버 컴포넌트란?

React 서버 컴포넌트는 서버에서 렌더링되고 클라이언트로 전송되는 컴포넌트입니다. 기존의 클라이언트 컴포넌트와 달리, 서버 컴포넌트는 서버에서만 실행되며 클라이언트 번들에 포함되지 않습니다. 이를 통해 더 나은 성능과 사용자 경험을 제공할 수 있습니다.

주요 특징:

  • 번들 크기 감소: 서버 컴포넌트 코드는 클라이언트로 전송되지 않아 번들 크기를 줄일 수 있습니다.
  • 서버 리소스 직접 접근: 데이터베이스, 파일 시스템 등 서버 리소스에 직접 접근할 수 있습니다.
  • 클라이언트-서버 컴포넌트 혼합: 하나의 앱에서 서버와 클라이언트 컴포넌트를 함께 사용할 수 있습니다.
  • 점진적 업데이트: 페이지 전체를 다시 로드하지 않고도 서버 컴포넌트를 업데이트할 수 있습니다.

2. SSR vs CSR vs RSC: 차이점 이해하기

렌더링 접근 방식의 차이점을 명확히 이해하는 것이 중요합니다

서버 사이드 렌더링(SSR)

  • 서버에서 전체 HTML을 생성하여 클라이언트로 전송
  • 초기 로딩 시 완성된 HTML을 보여줌
  • JS가 로드되면 React가 HTML에 이벤트 핸들러를 연결(하이드레이션)
  • 페이지 전환 시 전체 페이지 다시 로드 또는 CSR 방식으로 전환

클라이언트 사이드 렌더링(CSR)

  • 서버에서 최소한의 HTML만 전송
  • 클라이언트에서 JS를 통해 전체 UI 렌더링
  • 초기 로딩 시간이 길지만, 그 후 페이지 전환이 빠름
  • 모든 컴포넌트 코드가 클라이언트로 전송됨

React 서버 컴포넌트(RSC)

  • 컴포넌트 단위로 서버/클라이언트 렌더링 선택 가능
  • 서버 컴포넌트는 서버에서만 실행되고, 렌더링 결과만 클라이언트로 전송
  • 클라이언트 컴포넌트는 기존처럼 클라이언트에서 실행
  • 서버 컴포넌트 코드는 클라이언트 번들에 포함되지 않음
  • 필요할 때만 컴포넌트 단위로 업데이트 가능

서버 액션(Server Actions)

  • React 서버 컴포넌트와 함께 도입된 기능
  • 클라이언트에서 서버의 함수를 직접 호출할 수 있는 메커니즘
  • 폼 제출, 데이터 변경 등의 작업을 서버에서 처리
  • useFormState, useFormStatus 같은 React 훅과 함께 사용 가능
  • 클라이언트-서버 간 데이터 전송 로직을 단순화

3. React 서버 컴포넌트의 작동 원리

서버 컴포넌트가 어떻게 작동하는지 내부 메커니즘을 살펴보겠습니다:

1) 빌드 시점

  • 컴포넌트 분석: 빌드 과정에서 use client 지시어를 통해 클라이언트/서버 컴포넌트 구분
  • Module.prototype._compile을 오버라이드하여 클라이언트 컴포넌트에 특별한 태그 추가 (CLIENT_REFERENCE_TAG)

2) 서버에서의 렌더링

  • renderToPipeableStream() 함수가 React 트리를 렌더링
  • React 엘리먼트를 재귀적으로 처리하면서 각 컴포넌트 타입 확인
  • 서버 컴포넌트는 실행하고 그 결과를 직렬화
  • 클라이언트 컴포넌트는 지연 로딩을 위한 참조만 전송
  • Promise를 반환하는 서버 컴포넌트는 자동으로 Suspense 처리

3) 청크(Chunk) 기반 스트리밍

  • 렌더링 결과를 여러 청크로 나누어 스트리밍
  • 각 청크는 고유 ID로 식별되며 다음 형태로 구분
    • 모델 청크: 렌더링된 React 트리를 JSON으로 직렬화
    • 모듈 청크: 클라이언트 컴포넌트 참조(경로, 이름 등)
    • 참조 청크: 내장 컴포넌트와 심볼 정보

4) 클라이언트에서의 처리

  • createFromFetch()가 스트리밍 데이터 수신
  • 청크 처리 및 모델 조립
  • JSON.parse() reviver 함수로 특수 데이터 타입 복원
    • "$" → React.Element
    • "$L{id}" → 지연 로딩 컴포넌트
    • "$S{name}" → Symbol.for(name)
  • use() 훅을 사용하여 Promise 렌더링
  • 지연 로딩된 컴포넌트와 비동기 데이터는 준비되면 자동으로 UI 업데이트

4. 서버/클라이언트 컴포넌트 구분하기

React 서버 컴포넌트에서는 컴포넌트를 서버 또는 클라이언트에서 실행할지 구분해야 합니다

서버 컴포넌트 (기본값)

// 서버 컴포넌트 (별도 지시어 없음)
export default function ServerComponent() {
  // 서버에서만 실행되는 코드
  return <div>서버에서 렌더링됨</div>;
}

클라이언트 컴포넌트

'use client'; // 클라이언트 컴포넌트 지시어

import { useState } from 'react';

export default function ClientComponent() {
  const [count, setCount] = useState(0);
  
  return (
    <div>
      <p>카운트: {count}</p>
      <button onClick={() => setCount(count + 1)}>증가</button>
    </div>
  );
}

컴포넌트 타입별 기능 비교

기능서버 컴포넌트클라이언트 컴포넌트
데이터 fetch✅ (useEffect 사용)
파일 시스템 접근
백엔드 리소스 접근
환경 변수 접근 (서버)
useState, useEffect
이벤트 핸들러
브라우저 API
번들 크기에 영향

5. 실제 사용 패턴과 최적화 전략

서버 컴포넌트를 효과적으로 활용하기 위한 패턴을 살펴보겠습니다

데이터 페칭 패턴

// 서버 컴포넌트에서 직접 데이터 페칭
async function ProductPage({ id }) {
  // 서버에서 직접 데이터베이스 쿼리
  const product = await db.product.findUnique({ where: { id } });
  
  return (
    <div>
      <h1>{product.name}</h1>
      <p>{product.description}</p>
      <ProductActions id={product.id} /> {/* 클라이언트 컴포넌트 */}
    </div>
  );
}

서버-클라이언트 컴포넌트 구성 패턴

// 서버 컴포넌트
export default async function Page() {
  const data = await fetchData();
  
  return (
    <Layout>
      <ServerContent data={data} />
      <ClientInteractive id={data.id} /> {/* Props로 데이터 전달 */}
    </Layout>
  );
}

// 클라이언트 컴포넌트
'use client';
function ClientInteractive({ id }) {
  const [state, setState] = useState(initialState);
  // 상호작용 로직...
  return <div>{/* 인터랙티브 UI */}</div>;
}

서버 액션 활용

// 서버 액션 정의
'use server';
export async function submitForm(formData) {
  const name = formData.get('name');
  const email = formData.get('email');
  
  // 서버에서 데이터 처리
  await db.user.create({ data: { name, email } });
  
  // 리다이렉트 또는 상태 반환
  return { success: true };
}

// 클라이언트 컴포넌트에서 사용
'use client';
import { submitForm } from './actions';
import { useFormState } from 'react-dom';

export function ContactForm() {
  const [state, formAction] = useFormState(submitForm, { success: false });
  
  return (
    <form action={formAction}>
      <input name="name" required />
      <input name="email" type="email" required />
      <button type="submit">제출</button>
      {state.success && <p>성공적으로 제출되었습니다!</p>}
    </form>
  );
}

6. 서버 컴포넌트의 내부 구현 살펴보기

React 서버 컴포넌트의 내부 구현은 매우 복잡하지만, 주요 부분을 간략히 살펴볼 수 있습니다

서버 컴포넌트 타입 판별

// 클라이언트 참조인지 확인
function isClientReference(reference) {
  return reference.$$typeof === CLIENT_REFERENCE_TAG;
}

// 서버에서 컴포넌트 처리
function attemptResolveElement(request, type, key, ref, props, prevThenableState) {
  if (typeof type === "function") {
    if (isClientReference(type)) {
      // 클라이언트 컴포넌트는 참조만 반환
      return [REACT_ELEMENT_TYPE, type, key, props];
    }
    // 서버 컴포넌트는 실행
    prepareToUseHooksForComponent(prevThenableState);
    const result = type(props);
    // Promise인 경우 지연 래퍼로 감싸기
    if (typeof result === "object" && result !== null && typeof result.then === "function") {
      return createLazyWrapperAroundWakeable(result);
    }
    return result;
  }
  // ...
}

스트리밍 처리

// 청크 직렬화 및 스트리밍
function processModelChunk(request, id, model) {
  const json = stringify(model, request.toJSON);
  const row = id.toString(16) + ':' + json + '\n';
  return stringToChunk(row);
}

// 직렬화 처리기 (JSON.stringify replacer)
function resolveModelToJSON(request, parent, key, value) {
  if (value === REACT_ELEMENT_TYPE) {
    return '$'; // 심볼 단순화
  }
  
  // 서버/클라이언트 컴포넌트 처리
  if (typeof value === "object" && value !== null &&
      ((value).$$typeof === REACT_ELEMENT_TYPE || (value).$$typeof === REACT_LAZY_TYPE)) {
    // 컴포넌트 처리 로직...
  }
  
  // 클라이언트 참조 처리
  if (isClientReference(value)) {
    return serializeClientReference(request, parent, key, value);
  }
  
  // Promise 처리
  if (typeof value.then === "function") {
    const promiseId = serializeThenable(request, value);
    return serializePromiseID(promiseId);
  }
  
  // 기본값 처리
  return value;
}

클라이언트 처리

// 청크 처리 및 복원
function parseModelString(response, parentObject, key, value) {
  if (value[0] === "$") {
    if (value === "$") {
      return REACT_ELEMENT_TYPE;
    }
    switch (value[1]) {
      case "L": { // 지연 청크
        const id = parseInt(value.substring(2), 16);
        const chunk = getChunk(response, id);
        return createLazyChunkWrapper(chunk);
      }
      case "S": { // 심볼
        return Symbol.for(value.substring(2));
      }
      // 기타 타입 처리...
    }
  }
  return value;
}

// Promise 렌더링
function use(usable) {
  if (usable !== null && typeof usable === "object" && typeof usable.then === "function") {
    const thenable = usable;
    // Promise 상태 추적 로직...
    switch (thenable.status) {
      case "fulfilled":
        return thenable.value;
      case "rejected":
        throw thenable.reason;
      default:
        // Suspense 로직...
        throw SuspenseException;
    }
  }
  // ...
}

7. 서버 컴포넌트의 장단점과 고려사항

장점

  • 성능 향상: 번들 크기 감소, 초기 로딩 시간 단축
  • 데이터 접근 단순화: 백엔드 리소스에 직접 접근
  • 코드 분리: UI 로직과 데이터 로직 분리
  • 점진적 채택: 기존 React 앱에 점진적으로 도입 가능
  • SEO 최적화: 서버에서 렌더링되므로 검색 엔진 최적화에 유리

단점

  • 복잡한 구현: 내부 구현이 복잡하고 이해하기 어려울 수 있음
  • 디버깅 어려움: 서버-클라이언트 경계에서 디버깅이 어려울 수 있음
  • 상태 관리 제한: 서버 컴포넌트에서는 상태 관리가 제한적
  • 학습 비용: 새로운 패러다임 학습 필요

고려사항

  • Next.js와 같은 프레임워크 사용 권장
  • 명확한 서버/클라이언트 컴포넌트 경계 설정
  • 상태 관리 전략 재고
  • 빌드 및 배포 프로세스 최적화

8. 실전 Next.js에서의 서버 컴포넌트 활용

App Router 구조

app/
  layout.js       # 루트 레이아웃 (서버 컴포넌트)
  page.js         # 홈페이지 (서버 컴포넌트)
  dashboard/
    layout.js     # 대시보드 레이아웃 (서버 컴포넌트)
    page.js       # 대시보드 페이지 (서버 컴포넌트)
    actions.js    # 서버 액션
    client-components/
      form.js     # 클라이언트 컴포넌트

데이터 페칭

// app/posts/[id]/page.js
export default async function PostPage({ params }) {
  const post = await fetch(`https://api.example.com/posts/${params.id}`)
    .then(res => res.json());
  
  return (
    <article>
      <h1>{post.title}</h1>
      <p>{post.content}</p>
      <CommentSection postId={post.id} />
    </article>
  );
}

서버 액션

// app/actions.js
'use server';

export async function createPost(formData) {
  const title = formData.get('title');
  const content = formData.get('content');
  
  // 서버에서 데이터 처리
  const result = await db.post.create({
    data: { title, content }
  });
  
  revalidatePath('/posts');
  return result;
}

// app/new-post/page.js
import { createPost } from '../actions';

export default function NewPostPage() {
  return (
    <form action={createPost}>
      <input name="title" required />
      <textarea name="content" required />
      <button type="submit">포스트 작성</button>
    </form>
  );
}

9. 결론

React 서버 컴포넌트는 웹 개발의 새로운 패러다임을 제시합니다. 서버와 클라이언트의 경계를 컴포넌트 단위로 세분화하여 각각의 장점을 최대한 활용할 수 있게 해줍니다. 이는 성능 향상, 코드 분리, 개발 경험 개선 등 다양한 이점을 제공합니다.

서버 컴포넌트를 잘 활용하기 위해서는
1. 서버/클라이언트 경계 명확히 구분하기
2. 서버에서 처리할 작업과 클라이언트에서 처리할 작업 구분
3. 데이터 흐름 최적화
4. Next.js와 같은 프레임워크 활용

profile
안녕하세요

0개의 댓글