Introduction & Requirements
사용자가 ChatGPT형 대화 서비스에 접속합니다.
화면에는 이전에 생성한 답변이 보입니다. 답변에는 긴 문장과 코드 블록, 표가 포함되어 있습니다. 사용자는 내용을 읽다가 복사 버튼을 누르고, 아래 입력창에 후속 질문을 작성합니다.
겉으로는 하나의 화면이지만 각 기능에 필요한 실행 환경은 다릅니다.
이미 저장된 답변을 읽어서 문단과 코드 블록으로 구성하는 작업은 서버에서 처리할 수 있습니다. 반면 복사 버튼은 사용자가 클릭한 순간 브라우저의 클립보드에 접근해야 합니다. 입력창도 사용자가 글자를 입력할 때마다 즉시 반응해야 합니다.
여기서 복사 버튼에 클릭 이벤트를 추가하려고 페이지 맨 위에 "use client"를 붙였다고 해보겠습니다.
그 페이지가 직접 가져오는 메시지 렌더러와 관련 라이브러리까지 클라이언트 코드 경계에 포함될 수 있습니다. 사용자가 필요한 상호작용은 작은 버튼 하나인데, 그 버튼 때문에 읽기 전용 콘텐츠를 처리하는 코드도 브라우저가 내려받게 되는 것입니다.
React Server Components는 이런 상황에서 어떤 컴포넌트 코드를 서버에 남길지 구분하는 모델입니다. Server Component의 구현은 서버 환경에서 실행하고, 브라우저에는 그 결과를 전달할 수 있습니다.
하지만 서버에서 실행한다고 항상 더 빠르거나 더 좋은 설계가 되는 것은 아닙니다.
입력창의 글자 하나가 바뀔 때마다 서버를 호출하면 네트워크 지연이 입력 경험에 들어옵니다. 반대로 과거 대화를 표시하는 데만 필요한 무거운 코드를 모두 브라우저로 보내면 초기 다운로드와 실행 비용이 커질 수 있습니다.
따라서 이번 글에서는 Server Component와 Client Component 중 어느 쪽이 우월한지 비교하지 않습니다.
사용자가 무엇을 하는지 먼저 보고, 그 행동에 필요한 코드와 상태를 적절한 실행 환경에 배치합니다. 이후에는 두 환경 사이에 어떤 데이터를 전달하고, 변경된 결과를 어떻게 다시 연결할지 살펴보겠습니다.
설계 대상은 과거 대화를 조회하고 새로운 답변을 실시간으로 받는 대화형 웹 애플리케이션입니다. Next.js App Router를 사용하는 상황을 가정하되, 중요한 것은 프레임워크의 기능 목록보다 서버와 브라우저의 책임을 구분하는 기준입니다.
Functional Requirements
사용자는 권한이 있는 대화를 열고 과거 메시지와 첨부 결과를 확인할 수 있어야 합니다.
사용자는 질문을 작성하고 답변 복사, 메시지 펼치기, 대화 제목 변경 같은 상호작용을 수행할 수 있어야 합니다.
저장된 메시지 조회와 현재 답변이 실시간으로 생성되는 흐름을 함께 지원해야 합니다.
서버 데이터를 다시 조회하더라도 유지되어야 하는 입력 초안과 현재 작업 상태를 불필요하게 초기화하지 않아야 합니다.
특정 영역의 조회나 변경이 실패하면 해당 영역에서 오류와 복구 방법을 제공하고, 실패한 작업을 성공한 것처럼 표시하지 않아야 합니다.
Non-Functional Requirements
질문 작성과 메시지 펼치기처럼 서버 확인이 필요 없는 상호작용은 서버 왕복 없이 처리하고, 시각적 반응을 p95 100ms 이내로 목표합니다.
초기 진입에서는 읽기에 필요한 콘텐츠와 질문 작성 기능을 우선 준비하고, 부가 기능 때문에 초기 클라이언트 코드가 불필요하게 커지지 않도록 합니다.
개인 대화와 내부 인증 정보가 HTML, RSC Payload, Client Component props 또는 공유 캐시를 통해 다른 사용자에게 노출되지 않아야 합니다.
오래된 서버 응답이 더 최신의 메시지나 생성 상태를 덮어쓰지 않아야 합니다.
초기 JavaScript 크기뿐 아니라 RSC Payload 크기, 서버 렌더링 비용, 데이터 조회 횟수와 실제 상호작용 준비 시간을 함께 측정합니다.
여기서 응답 시간은 설계를 위한 목표입니다. p95 100ms는 측정한 상호작용의 95퍼센트가 100ms 이내에 시각적으로 반영되는 것을 뜻합니다. 실제 목표는 지원 기기와 사용자의 네트워크 환경을 측정한 뒤 조정합니다.
Out of Scope
언어 모델의 학습과 추론 엔진 내부는 설계하지 않습니다.
React의 RSC 직렬화 프로토콜이나 번들러를 직접 구현하지 않습니다.
음성 통화와 영상 스트리밍은 제외하고 텍스트 대화와 관련 UI를 중심으로 다룹니다.
SSR과 RSC는 서로 다른 질문에 답합니다
SSR은 서버가 초기 HTML을 만들어 브라우저에 보내는 방식입니다.
RSC는 어떤 컴포넌트 코드와 의존성을 서버에서 실행하고, 브라우저에는 어떤 결과와 클라이언트 코드만 전달할지 구분하는 모델입니다.
둘은 함께 사용할 수 있지만 같은 기능은 아닙니다.
예를 들어 서버가 과거 메시지를 읽어 Server Component의 결과를 만듭니다. 최초 문서 요청에서는 그 결과와 Client Component의 구현을 사용해 초기 HTML을 만들 수 있습니다. 브라우저는 HTML을 표시하고, 상호작용이 필요한 Client Component를 준비합니다.
Hydration은 이때 서버가 만든 HTML을 브라우저의 React 상태와 이벤트 처리에 연결하는 과정입니다.
Streaming SSR은 전체 HTML이 준비될 때까지 기다리지 않고 준비된 부분부터 전달하는 방식입니다. 반면 RSC는 그 화면을 구성하는 코드 중 무엇을 서버에 남길지 결정합니다.
따라서 다음과 같은 오해를 피해야 합니다.
Client Component라고 해서 최초 화면을 반드시 브라우저에서만 만드는 것은 아닙니다. 초기 HTML 생성 과정에서 서버가 Client Component를 렌더링할 수도 있습니다.
"use client"를 붙였다는 이유만으로 렌더링 도중 브라우저 전용 객체를 사용해도 되는 것은 아닙니다.
"use client"
// 초기 서버 렌더링에서 문제가 생길 수 있는 예입니다.
export function ScreenWidth() {
const width = window.innerWidth
return <div>{width}</div>
}서버 환경에는 브라우저의 window가 없습니다.
화면 폭이 필요한 이유부터 확인해야 합니다. 단순한 반응형 배치라면 CSS로 처리할 수 있습니다. 실제 브라우저 값을 읽어야 한다면 클라이언트에서 적절한 시점에 읽고, 서버와 브라우저의 최초 렌더링 결과가 달라지지 않도록 해야 합니다.
Server Component도 반드시 사용자 요청마다 실행되는 것은 아닙니다. 빌드 시점에 실행하거나 요청 시점에 실행할 수 있으며, 렌더링 결과나 데이터가 캐시될 수도 있습니다.
결국 컴포넌트의 실행 환경, 실행 시점, 캐시 정책은 함께 보되 같은 개념으로 합치지 않습니다.
Traffic & Capacity Assumptions
DAU를 100만 명, 사용자당 하루 대화 화면 조회와 이동을 4회로 가정하겠습니다.
전체 문서 요청과 RSC 기반 내비게이션에 해당하는 사용자 행동을 합치면 하루 400만 회입니다.
Daily route views
1,000,000 users
x 4 views
= 4,000,000 views
Average rate
4,000,000 / 86,400
= about 46 views per second
Peak rate
Average x 10
= about 463 views per second화면 조회 횟수가 서버 실행 횟수와 항상 같지는 않습니다.
브라우저의 라우터 캐시를 재사용하면 추가 서버 요청이 줄어들 수 있습니다. 반대로 사용하지 않은 화면까지 사전 가져오기하거나 실패한 요청을 다시 시도하면 사용자에게 실제로 보인 화면보다 서버 요청이 많아질 수도 있습니다.
따라서 이 값은 초기 용량 계산을 위한 기준으로 사용하고, 운영에서는 실제 서버 요청과 캐시 적중률을 따로 측정합니다.
RSC를 적용하면 브라우저에서 수행하던 일부 데이터 조합과 렌더링이 서버로 이동합니다. 서버가 화면 하나를 만드는 비용도 함께 계산해야 하는 이유입니다.
JavaScript가 줄어든 만큼 전체 전송량도 줄어드는가
한 화면의 초기 클라이언트 JavaScript가 압축 후 420KB였다고 가정하겠습니다.
완료된 메시지 렌더러와 관련 의존성을 서버에 남겨 260KB로 줄였다면 차이는 160KB입니다.
Client JavaScript
Before 420KB
After 260KB
Saved 160KB
Cold document loads per day
1,000,000 x 160KB
= about 160GB캐시가 없는 문서 진입이 하루 100만 회라면 해당 조건에서 JavaScript 전송량 차이는 약 160GB입니다. 여기서는 단위를 십진수 기준으로 계산했습니다.
하지만 이것이 전체 네트워크 전송량도 160GB 줄었다는 뜻은 아닙니다.
서버가 만든 결과를 HTML과 RSC Payload로 전달해야 합니다. Client Component에 전달하는 props에도 데이터가 들어갑니다. 이미 JavaScript를 캐시한 재방문에서는 줄어드는 비용이 다릅니다.
따라서 JavaScript 전송량과 실행 시간, HTML 크기, RSC Payload 크기, 내비게이션당 추가 전송량을 각각 비교합니다.
이 구분 없이 클라이언트 번들 하나만 보면, 코드는 줄었는데 전체 화면 전달량은 더 커진 상황을 놓칠 수 있습니다.
컴포넌트마다 DB를 조회하면 서버에서도 느려집니다
피크가 초당 463건일 때 페이지마다 DB 조회를 20번 수행하면 초당 약 9,260번의 조회가 발생합니다.
필요한 데이터를 묶어 페이지당 3번의 조회로 구성하면 약 1,389번입니다.
물론 조회 횟수가 적다고 항상 더 빠른 것은 아닙니다. 복잡한 단일 쿼리가 더 비쌀 수도 있으므로 실행 시간과 읽는 데이터량도 확인해야 합니다.
다만 컴포넌트 분리와 DB 조회 분리를 같은 기준으로 처리하면 안 된다는 점은 분명합니다.
화면에서 메시지 20개를 각각의 컴포넌트로 렌더링한다고, 작성자와 권한 정보를 메시지마다 다시 조회할 이유는 없습니다.
Server Component에서 데이터를 읽을 수 있다는 것은 선택지가 생겼다는 뜻이지, 컴포넌트마다 독립적인 DB 호출을 추가하라는 뜻이 아닙니다.
Core Entities & Data Modeling
Server Component는 데이터를 읽어 UI 결과를 만드는 실행 단위입니다.
사용자의 대화를 계속 메모리에 보관하는 저장소가 아닙니다. 서버 프로세스가 재시작되어도 대화가 유지되려면 메시지와 작업 상태는 별도의 저장소에 있어야 합니다.
이번 설계에서는 대화, 메시지, 생성 작업을 구분합니다.
type Conversation = {
id: string
ownerId: string
title: string
revision: number
updatedAt: string
}
type Message = {
id: string
conversationId: string
role: "user" | "assistant"
text: string
revision: number
createdAt: string
}
type ActiveGeneration = {
id: string
conversationId: string
messageId: string
status: "queued" | "generating" | "waiting_tool"
lastAppliedSequence: number
}Conversation, 대화의 소유권과 제목 같은 메타데이터를 나타냅니다.
Message, 사용자 질문이나 저장된 답변을 나타냅니다. 같은 대화 안에서도 메시지마다 별도 식별자를 가집니다.
ActiveGeneration, 현재 답변을 만들고 있는 실행을 나타냅니다. 메시지 내용과 실행 상태를 분리하면 답변이 아직 생성 중인지, 도구 결과를 기다리는지 표현할 수 있습니다.
revision, 같은 객체의 어느 버전을 읽었는지 나타냅니다.
lastAppliedSequence, 특정 생성 작업에서 어디까지 이벤트를 반영했는지 나타냅니다.
대화 제목의 버전과 생성 이벤트의 순서는 서로 다른 값입니다. 두 숫자를 하나의 전역 순서처럼 비교하지 않습니다.
저장 모델과 화면에 전달할 모델을 나눕니다
서버의 대화 레코드에는 소유자 정보, 내부 정책, 감사 정보와 검색 인덱싱 상태가 포함될 수 있습니다.
하지만 화면에서 대화를 표시하는 데 그 모든 정보가 필요한 것은 아닙니다.
그래서 브라우저에 전달할 데이터 객체를 따로 정의합니다. 필요한 필드를 골라 전달하는 객체를 DTO라고 부릅니다.
type ConversationView = {
conversationId: string
title: string
revision: number
messages: Array<{
id: string
role: "user" | "assistant"
text: string
revision: number
}>
activeGeneration: ActiveGeneration | null
canSend: boolean
}ConversationView, 현재 화면을 구성하는 데 필요한 데이터만 담습니다.
canSend, 질문 전송 버튼의 초기 표시 상태를 정하기 위한 값입니다. 이후 전송 요청에서 서버 권한 검사를 생략하기 위한 증명서는 아닙니다.
이렇게 구분하면 서버 데이터 모델이 커져도 그 필드가 모두 브라우저로 전달되는 것을 막을 수 있습니다.
브라우저에 보낼 데이터를 제한하는 일은 전송량 최적화이면서 동시에 개인정보 경계를 정하는 일입니다.
서버 데이터와 브라우저 상태는 서로 다른 수명을 가집니다
사용자가 아직 보내지 않은 질문은 서버에 저장된 메시지가 아닙니다.
입력할 때마다 Server Component를 다시 실행할 필요도 없습니다. 초안은 Client Component나 초안 Store에서 관리합니다.
반면 접수된 질문과 생성 완료 결과는 브라우저를 닫아도 남아야 합니다. 따라서 최종 원본은 서버에 둡니다.
여기서 상태의 수명은 몇 초 동안 캐시할지를 뜻하지 않습니다. 사용자가 화면을 이동하거나 탭을 닫아도 그 값이 남아야 하는 범위를 뜻합니다.
메시지 펼침 여부는 해당 화면을 떠나면 초기화해도 될 수 있습니다. 질문 초안은 대화를 잠깐 바꿨다가 돌아와도 유지하고 싶을 수 있습니다. 저장된 메시지는 다른 기기로 접속해도 다시 읽어야 합니다.
이 요구사항에 따라 저장 위치를 고릅니다.
같은 전역 Store를 사용한다는 사실 자체가 문제는 아닙니다. 다만 서버에서 갱신되는 데이터와 사용자가 작성 중인 초안을 구분하지 않고 함께 초기화하면 문제가 됩니다.
또 Client Component가 서버 메시지를 표시한다고 그 메시지의 최종 원본이 브라우저로 바뀌는 것은 아닙니다. Server Component가 읽은 값도 캐시 때문에 잠깐 오래된 값일 수 있습니다.
컴포넌트의 실행 위치와 데이터의 최종 원본을 별도로 판단해야 합니다.
RDBMS에서 제외할 데이터
RSC Payload는 이 설계에서 대화의 영구 원본으로 저장하지 않습니다.
메시지 본문과 도메인 상태를 저장하고, 필요한 시점에 화면 결과를 다시 만듭니다. 프레임워크가 렌더링 결과를 캐시하는 것과 업무 DB가 특정 배포의 렌더링 표현을 영구 데이터로 저장하는 것은 다릅니다.
메뉴 열림, 텍스트 선택, 복사 버튼의 일시적인 성공 표시도 일반적인 서버 업무 데이터가 아닙니다. 복원할 필요가 있다면 유지해야 하는 범위에 맞는 클라이언트 저장소를 사용합니다.
모델 출력의 모든 작은 조각을 메시지 행 전체의 갱신으로 기록하지도 않습니다. 조각을 일정 단위로 묶어 저장하거나 재생 가능한 이벤트 기록을 사용할 수 있습니다. 최종 메시지 보관과 고빈도 이벤트 전달은 다른 작업입니다.
관측 데이터에는 개인 대화 원문을 무조건 포함하지 않습니다. 렌더링 시간, 전송량, 메시지 수 구간과 배포 식별자처럼 문제 분석에 필요한 값만 기록합니다.
API Signatures
RSC를 사용한다고 애플리케이션의 API 계약이 사라지는 것은 아닙니다.
사용자가 대화를 처음 열면 문서 요청이 발생합니다.
GET /chat/c_123 HTTP/1.1
Accept: text/html
Cookie: session=...Next.js의 클라이언트 내비게이션에서는 프레임워크가 서버 컴포넌트 결과를 가져와 현재 화면에 연결할 수 있습니다.
그러나 이 내부 요청 형식과 직렬화 표현을 모바일 앱이나 외부 서비스의 공개 계약으로 사용하지는 않습니다.
웹 화면을 만드는 방식과 다른 클라이언트도 사용할 도메인 기능을 구분합니다.
또 서버 컴포넌트가 데이터를 읽을 때 반드시 같은 애플리케이션의 HTTP API를 다시 호출해야 하는 것은 아닙니다. 서버 전용 데이터 접근 함수를 직접 호출하고, HTTP API와 Server Action이 그 함수를 공유할 수 있습니다.
대화 제목을 변경하는 계약
사용자가 제목을 변경할 때는 자신이 보고 있던 대화 메타데이터 버전을 함께 전달합니다.

