리액트 서버컴포넌트

한상우·2025년 4월 16일

리액트

목록 보기
14/25
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개의 댓글