Thumbnail

5분

The Two Reacts

들어가면서

무언가를 배우다보면, 특히 개발과 관련된 것, 저도 모르게 특정한 시야에 갇혀 버리는 경우가 종종 생깁니다. 이미 쉬운 방식 또는 단순한 이해를 분명히 알고 있는데도 불구하고, 그것을 망각하고 더 어렵고 고집스러운 방식으로 이해하려는 경우가 종종 생깁니다.

Dan Abramov가 작성한 The Two Reacts 블로그 글은 이러한 저의 경험을 떠올리게 합니다. 이 글에서 Dan은 RSC(React Server Components)에 대한 이야기를 합니다. 하지만 처음부터 끝까지 RSC에 대한 직접적인 이야기는 없습니다. 대신에, Dan은 애플리케이션이 어떻게 우리에게 나타나는지 차근차근 설명하며 우리가 잠깐 망각했던 부분을 다시 상기시켜 줍니다.

RSC는 아직도 트위터에서는 많은 토론과 반감들이 있다는 것을 Dan이 작성한 트윗1트윗2 를 보면 간접적으로 느낄 수 있습니다.
아마 이 글이 커뮤니티 일부에 대한 답변과 같은 역할을 할 수 있을 것 같습니다.

이 글을 옮겨보며 저도 다시 한번 이해하고, 더 나아가서 이해한 것을 다시 상기하고자 합니다.

The Two Reacts

만약 제가 여러분 화면에 어떤 것을 표시하고 싶다고 가정해보겠습니다. 이 블로그 페이지 같은 웹 페이지를 만들고 싶다면 인터렉티브 웹 앱 또는 네이티브 앱, 적어도 2개의 기기가 필요합니다.

여러분의 기기와 제 기기.

제 기기에서 몇 가지 코드와 데이터로 시작됩니다. 예를 들어, 이 블로그 게시물은 제 노트북에서 파일(Mdx)로 편집하고 있습니다. 이 글이 여러분의 화면에 표시된다는 것은 제 기기에서 여러분의 기기로 이미 데이터가 이동되었다는 것을 의미합니다. 제 코드와 데이터가 여러분의 기기에 표시되도록 HTML과 JavaScript로 변환된 것입니다.

이것이 리액트와 관련이 있을까요? 리액트는 컴포넌트(블로그 글, 가입 폼 또는 전체 앱과 같은)라고 불리는 독립적인 단위로 나누고 이를 레고처럼 조합할 수 있는 UI 프로그래밍 패러다임입니다.

컴포넌트는 코드이며, 그 코드는 어딘가에서 실행되어야 합니다. 하지만 잠깐만요, 누구의 컴퓨터에서 실행이 되어야 할까요? 여러분의 컴퓨터에서 실행해야 할까요? 아니면 제 컴퓨터에서 실행해야 할까요?

양쪽 모두에 대해서 예시를 한번 만들면서 이해해보겠습니다.

클라이언트에서 리액트

먼저, 여러분의 컴퓨터에서 컴포넌트가 실행되어야 한다고 가정해보겠습니다.

여기에 인터렉티브를 보여주기 위한 카운터 버튼이 있습니다. 클릭해보세요!

<Counter />

이 컴포넌트의 JavaScript 코드가 로드되었다면, 버튼을 누르는 즉시 숫자가 증가하는 것을 확인할 수 있습니다. 딜레이도 없고, 서버를 기다릴 필요도 없습니다. 또한 추가 데이터를 다운로드할 필요도 없습니다.

이 컴포넌트 코드가 여러분의 컴퓨터에서 실행되었기 때문입니다.

import { useState } from "react"
 
function Counter() {
  const [count, setCount] = useState(0)
 
  return (
    <button
      className="dark:color-white rounded-lg bg-purple-700 px-2 py-1 font-sans font-semibold text-white focus:ring active:bg-purple-600"
      onClick={() => setCount(count + 1)}
    >
      당신은 {count}번 클릭했습니다.
    </button>
  )
}
 
export { Counter }

count는 클라이언트 상태의 일부입니다. 버튼을 누를 때마다 업데이트되는 컴퓨터 메모리에 올라가는 정보입니다. 사용자가 버튼을 몇 번이나 누를지 모르기 때문에 컴퓨터에서 가능한 모든 클릭을 미리 계산해서 준비할 수 없습니다.

제 컴퓨터에서 준비할 수 있는 것은 초기 렌더링 출력("당신은 0번 클릭했습니다.")을 HTML로 전송하는 것 정도입니다. 하지만 그 이후부터는 여러분의 컴퓨터에서 이 코드가 실행되어야 합니다.

하지만 여러분이 여전히 여러분 자신의 컴퓨터에서 이 코드가 실행할 필요가 없다고 얘기할 수도 있습니다. "제 컴퓨터가 아닌 서버에서 대신 실행하면 안되는 건가요?" 버튼을 누를 때마다 여러분의 컴퓨터에서 저의 서버로 다음 렌더링 출력을 요청할 수도 있습니다. 클라이언트 사이드 JavaScript 프레임워크가 등장하기 전에는 웹 사이트가 이러한 방식으로 동작했다는 것을 기억하실 겁니다.

다음 슬라이드를 누른다거나, 탭을 클릭했다거나 등의 경우 새로운 페이지가 서버에서 로드되어 화면이 Flash되는 것을 기억하실 겁니다.

서버에 새로운 UI를 요청하는 방식은 약간의 지연이 예상되는 경우(링크 클릭 시 이동과 같은)에는 효과적입니다. 사용자가 앱에서 다른 화면으로 이동하고 있다는 것을 알고 있다면 기다릴 것입니다. 그러나 슬라이더 드래그, 탭 전환, 카드 스와이프, 메뉴 호버링, 차트 드래그와 같은 직관적인 조작은 즉각적인 피드백이 필요합니다. 이 경우 안정적으로 피드백을 제공하지 않으면 사용자는 불편함을 느낄 것입니다.

이는 기술적이라기 보다는 일상 생활에서 얻은 직관적인 경험입니다. 예를 들어, 엘리베이터 버튼을 누르면 바로 다음 층으로 이동하는 것을 기대하지는 않을 것입니다. 그러나 문 손잡이를 누를 때는 손의 움직임에 따라 손잡이가 바로 움직이기를 기대합니다. 그렇지 않으면 문 손잡이가 고장났다고 생각할 것입니다. 사실 엘리베이터 버튼을 누를때도 즉각적인 피드백을 기대합니다: 손가락 압력에 따라 버튼이 눌렸다는 것을 알 수 있어야 합니다. 그리고 그 압력에 따라 버튼 불이 켜져야 합니다.

UI를 만들 때 네트워크 왕복 없이 짧은 지연 시간을 보장하는 최소한의 인터렉션에 응답할 수 있어야 합니다.

리액트 멘탈 모델을 일종의 방정식으로 설명하는 것을 보셨을 겁니다: UI는 상태(state)의 함수,UI = f(state). 이는 UI 코드가 하나의 상태(state)를 가진 하나의 함수가 되어야한다는 것을 의미하지는 않습니다. 현재 상태(state)가 UI를 결정한다는 의미입니다. 상태(state)가 변경되면 UI는 다시 계산되어야 합니다. 상태(state)는 여러분 컴퓨터에 "존재"하기 때문에 UI를 계산하는 코드(컴포넌트)도 여러분 컴퓨터에서 실행되어야 합니다.

그렇지만, 이것이 반드시 맞는 것일까요?

서버에서 리액트

위와는 반대되는 의견으로 "컴퓨터에서 실행되어야 하는 코드는 제 컴퓨터가 아닌 서버에서 실행되어야 한다"에 대한 것입니다.

다음은 블로그 게시물에 대한 미리보기 카드입니다:

<PostPreview slug="react/rsc-mental-model" />
RSC Mental Model
1736 words

이 페이지의 컴포넌트는 해당 페이지의 단어 수를 어떻게 알 수 있었을까요?

A screenshot for evidence of no network roundtrip

네트워크 탭을 확인하면 추가로 데이터를 요청하지 않았음을 확인할 수 있습니다. 단어 수를 알기 위해 별도로 해당 블로그 게시물 전체를 다운로드 하는 것도 아닙니다. 이 페이지에 해당 블로그 글을 임베드하는 것도 아닙니다. 단어 수를 계산하기 위해 어떤 API도 호출하지 않습니다.

그래서 이 컴포넌트는 어떻게 동작하는 걸까요?

import { allPosts } from "contentlayer/generated"
 
interface PostPreviewProps {
  slug: string
}
 
function PostPreview({ slug }: PostPreviewProps) {
  const post = allPosts.find((post) => post.slugAsParams === slug)
  if (!post) {
    return null
  }
  const wordCount = post.body.raw.split(/\s+/).filter(Boolean).length
 
  return (
    <section className="rounded-md bg-black/10 p-2">
      <h5 className="font-bold">
        <a href={"/posts/" + slug} target="_blank" className="text-indigo-700 dark:text-indigo-500">
          {post.title}
        </a>
      </h5>
      <i>{wordCount} words</i>
    </section>
  )
}
 
export { PostPreview }

해당 <PostPreview /> 컴포넌트는 제 컴퓨터에서 실행됩니다. 파일을 읽기 위해서 contentlayer에서 미리 생성된 데이터를 사용하여 파일을 읽습니다. slug에 해당하는 게시물을 찾고 단어 수를 계산합니다. 단어 수를 계산하기 위해 빈칸 정규표현식(/\s+/)을 사용하여 단어를 분리하고, 빈 문자열을 제거한 후 배열의 길이를 반환합니다. 컴포넌트 코드가 데이터가 위치한 곳에서 바로 실행되기 때문에 네트워크 요청과 같은 추가 수행 작업이 필요 없습니다.

제 블로그의 모든 게시물을 단어 수와 함께 리스트 형태로 보여줄 수도 있습니다.

<PostList />
ChatGPT Prompt Engineering for Developers
10013 words
Figma cube, skew plugin
217 words
디자인 시스템
1393 words
Github Code Search
725 words
Github Copilot
962 words
SEONEST
51 words
블로그 생성
258 words
Bundle - Module Bundler
1941 words
ESM + TypeScript
1732 words
JavaScript Eventing Deep dive
1824 words
JavaScript Object(1) - Prototype
1843 words
JavaScript Object(2) - Class
1053 words
JavaScript 동작 원리
1573 words
Tailwind CSS v4.0
1765 words
You don't need a build step
2286 words
날렸습니다...
692 words
동적으로 Image 만들기, edge-function을 곁드린
1001 words
모노레포에 관하여
4208 words
Complex Context APIs
1784 words
Debug React "Hello world"
713 words
Delightful React File/Directory Structure
1849 words
Fixing race condition in React
1115 words
React 18이 애플리케이션 성능을 향상시키는 방법
2720 words
New features in React 18
4150 words
Next generation(Next.js 13.4)
2517 words
Next.js 13.1
1325 words
Next.js 13.2
1981 words
Next.js 13.3
1426 words
Next.js 13
1758 words
Nextjs 12.3
577 words
Nextjs Layout RFC: New Routing System
2882 words
Nextjs Layouts RFC Update
2187 words
Nextjs + React Server Component
1415 words
PlanetScale + Prisma + Next.js
1044 words
React 19 Beta!
3350 words
React 연대기
1987 words
React Compiler
2057 words
React Hydration 문제에 대해서
1962 words
React RFC: useEvent()
1512 words
React Server Components 이해하기(by Josh.W.Comeau)
4029 words
React Server Components 이해하기
1778 words
React를 좋아하는 이유(by Kent C.Dodds)
845 words
Remix 맛보기 (1)
3534 words
Remix 맛보기 (2)
3464 words
Remix@1.6.5
556 words
RSC Mental Model
1736 words
The End of Front-End Development
1771 words
The Two Reacts
1577 words
Traditional Approaches vs Suspense in React
1529 words
Understanding useMemo and useCallback
2726 words
Why React Re-Renders
1835 words
리액트 앱에 대한 프로파일링
877 words
ECMAScript 2022
1518 words
Great Developer Experience (Vercel)
1304 words
HTTP... 그것에 대하여
2788 words
Should I use pixels? or ems/rems?
2327 words
Use A Reverse Proxy(Nginx)
2032 words
알아놓으면 좋은 10가지 모던 웹 아키텍쳐 컨셉
1280 words
왜 Vercel은 Edge 렌더링을 다시 Node.js 로 되돌렸을까?
523 words
웹 렌더링
2178 words
웹 사이트 성능(1) - 지표
1056 words
웹 사이트 성능(2) - Core web vitals, LCP
3929 words
웹 사이트 성능(3) - Core web vitals, FID
1998 words
웹 사이트 성능(4) - Core web vitals, CLS
1494 words

간단하게 처리할 수 있습니다. 모든 게시물을 가져와서 <PostPreview />를 사용하여 렌더링합니다.

import { allPosts } from "contentlayer/generated"
 
import { PostPreview } from "./post-preview"
 
function PostList() {
  return (
    <div className="mb-4 flex h-72 flex-col gap-2 overflow-scroll font-sans">
      {allPosts.map((post) => (
        <PostPreview key={post._id} slug={post.slugAsParams} />
      ))}
    </div>
  )
}
 
export { PostList }

이 컴포넌트 코드는 여러분의 컴퓨터에서 실행될 필요가 없습니다. 실제로 여러분 컴퓨터에 제 파일이 존재하지 않기 때문에 실행될 수도 없습니다. 그러면 이 컴포넌트 코드가 언제 실행되었을까요?

<p className="font-bold text-purple-500">{new Date().toString()}</p>

Tue Aug 13 2024 14:18:59 GMT+0000 (Coordinated Universal Time)

저는 Next.js를 이용해 SSG(Static Site Generation)을 사용하고 있습니다. 이 페이지는 정적으로 생성되었기 때문에 이 페이지를 빌드할 때 이 코드가 실행됩니다. 이 시간은 제가 이 페이지를 빌드한 시간입니다.

이 시간은 제가 블로그를 정적 웹 호스팅에 마지막으로 배포한 시간이 표시가 됩니다. 정확하게는 이 페이지를 빌드할 때 시간이 표시됩니다.

데이터 소스(source) 가까운 곳에서 컴포넌트를 실행하게 되면 여러분의 기기로 해당 정보를 보내기 전에 자체적으로 데이터를 읽고 미리 처리할 수 있습니다.

여러분이 이 페이지를 로드할 때는 <PostList />, <PostPreview />등은 없습니다. 대신 <a>, <i> 등을 포함한 HTML 코드만 있습니다. 여러분의 기기는 컴포넌트가 UI를 만들기 위해 사용한 전체 데이터(실제 게시물)가 아니라 실제 페이지를 표시하는 UI(렌더링된 게시물 제목, 링크 URL, 단어 수 등)만 수신합니다.

여기서 멘탈 모델은, UI는 서버 데이터의 함수,UI = f(data). 해당 데이터들은 제 기기에서만 존재합니다. 그래서 제 기기에서 실행되어야 합니다.

그러면 어떤 것을 사용해야 하나요?

두 개의 리액트

UI는 컴포넌트로 만듭니다. 그러나 여기서는 매우 다른 두 가지 방법을 말하고 있습니다.

  • UI = f(state), 여기서 state는 클라이언트 사이드이고, f는 클라이언트에서 실행됩니다. 이 방식은 <Counter />와 같은 즉각적인 인터렉티브 컴포넌트를 만들 수 있습니다. (여기서 f는 서버에서 초기 상태의 HTML을 생성하여 클라이언트로 전송할 수도 있습니다.)
  • UI = f(data), 여기서 data는 서버 사이드이고, f는 서버에서만 실행됩니다. 이 방식은 <PostPreview />와 같은 데이터 처리 컴포넌트를 만들 수 있습니다.(여기서 f는 서버에서만 실행됩니다. 빌드 시간은 "서버"로 계산됩니다.)

더 익숙한 방식을 제쳐두면, 두 가지 방식은 모두 각자의 장점을 살릴 수 있는 매력적인 방식입니다. 불행하게도 이러한 방법은 서로 충돌하는 것처럼 보입니다.

<Counter />와 같은 즉각적인 상호작용을 위해서는 클라이언트에서 컴포넌트를 실행해야 합니다. 하지만 <PostPreview />와 같은 컴포넌트는 원칙적으로는 클라이언트에서 실행할 수 없습니다. 서버 전용 API를 사용하기 때문입니다.(이것이 바로 이 컴포넌트의 요점입니다! 그렇지 않다면 클라이언트에서 실행하는 편이 나을지도 모릅니다.)

그러면 모든 컴포넌트를 서버에서 실행하는 것은 어떨까요? 서버에서 <Counter />와 같은 컴포넌트는 초기 상태만 렌더링할 수 있습니다. 서버는 "현재" 상태를 알지 못하고 서버와 클라이언트 간에 해당 상태를 전달하는 것은 너무 느리고(URL처럼 작은게 아니라면) 항상 가능한 것도 아닙니다.(예: 제 블로그 서버 코드는 배포, 빌드 시점에만 실행되므로 서버에 "전달"할 수 없습니다. 즉, 서버리스로 동작하는 몇 가지 API를 제외하고는 요청을 받을 수 있는 서버 자체가 존재하지 않습니다.)

뭔가 두 개의 React 중 하나를 선택해야 하는 것처럼 보입니다:

  • "클라이언트" UI = f(state) 패러다임으로 <Counter />를 만들 수 있습니다.
  • "서버" UI = f(data) 패러다임으로 <PostPreview />를 만들 수 있습니다.

그러나 현실에서 실제 "공식"은 UI = f(data, state)에 더 가깝습니다. 데이터가 없거나 상태가 없는 경우에는 이러한 경우를 일반화할 수 있습니다. 그러나 대부분 다른 추상화를 선택할 필요 없이 두 가지 방식을 모두 처리할 수 있는 프로그래밍 패러다임을 선호합니다.

그렇다면 해결해야 할 문제는 f를 서로 다른 두 개의 프로그래밍 환경에서 어떻게 분할할 것인가입니다. 이것이 가능할까요? 여기서 f는 컴포넌트를 나타내는 실제 함수를 말하는 것이 아니라는 것을 기억하세요.

리액트의 장점은 그대로 유지하면서 컴포넌트를 여러분의 컴퓨터와 제 컴퓨터에서 분리할 수 있는 방법이 있을까요? 서로 다른 두 환경의 컴포넌트를 결합하고 중첩할 수 있을까요? 어떻게 하면 될까요?

어떻게 해야 할까요?

마무리하며

이 글은 RSC에 대한 직접적인 이야기는 없습니다. 하지만 이 글을 읽고 나면 RSC가 왜 필요한지, 리액트가 어떤 문제를 해결하기 위해 이를 만들어졌는지 이해할 수 있을 것입니다.

리액트가 왜 만들어졌는지, 만들어진 방식이 어떤 것인지, 그리고 왜 이러한 방식으로 만들어졌는지에 대한 이해와 멘탈 모델 을 가지게 된다면 좀 더 즐겁게 프로그래밍을 할 수 있을 것 같습니다.

아직까지는 RSC를 product-ready 기능으로, 쉽게 사용할 수 있는 환경이 Next.js가 거의 유일한 환경입니다. 그래서 RSC를 사용하기 위해서 Next.js에 대한 이해가 필요합니다. 이에 대한 비판, 토론도 꽤 있기도 합니다...

그러나 RSC 이해를 통해서 리액트 자체에 대한 이해를 높이는 것은 분명히 도움이 될 것입니다.

마지막으로 Dan이 글을 마무리하며 우리에게 질문을 던진 것과 관련해서 아래 트위터에서 더 많은 토론을 볼 수 있습니다.

마지막 업데이트

2/5/2024


Avatar

JHSeo

배우는 것을 좋아하고 관심이 많은 웹 엔지니어 입니다. 느리더라도 꾸준하게 성장하려고 노력하는 개발자입니다.