[번역] 두 가지의 React

Jan 14, 2024

원문: https://overreacted.io/the-two-reacts/

React Core 팀의 일원인 Dan Abramov가 이야기하는 현재 두 가지 모습의 React입니다. 의역이 다소 들어간 점 참고 부탁드려요.

들어가면서

여러분의 화면에 무언가를 보여주고 싶다고 가정해 봅시다. 이 블로그와 같은 웹사이트이든, 앱 스토어에서 다운로드 받은 네이티브 앱 이든, 이 과정에 적어도 두 대의 컴퓨터가 관여됩니다.

바로 여러분의 컴퓨터와 제 컴퓨터입니다.

화면에 무언가를 보여주는 것은 제 컴퓨터에 있는 코드와 데이터로 시작됩니다. 예를 들어, 이 블로그 게시물은 제 노트북에서 마크다운 파일로 편집되고 있습니다. 여러분의 화면에서 이 게시물을 보고 있다면, 이미 이 파일이 제 컴퓨터에서 여러분의 기기로 전송되었었을 것입니다. 특정 시점에서, 제 코드와 데이터가 HTML과 JavaScript로 변환되어 이 게시물을 여러분의 기기에 보여줍니다.

그럼 이것이 React와 어떻게 관련이 있을까요? React는 UI 프로그래밍 패러다임으로, 화면에 보여질 내용(블로그 게시물, 회원가입 창, 전체 웹 어플리케인션)을 컴포넌트라고 하는 독립적인 부분으로 나누고, 이들을 레고 처럼 조립하게 됩니다. 저는 여러분들이 컴포넌트에 대해 이미 알고 있다고 가정하겠습니다. 더 자세한 내용은 react.dev의 소개를 참고해보세요.

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

각 측면을 모두 살펴보도록 합시다.

클라이언트에서의 실행

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

여기에 상호작용을 보여주기 위한 작고 소중한 카운터 버튼이 있습니다. 몇 번 클릭해보세요!

<Counter />
tsx

이 컴포넌트의 JavaScript 코드가 이미 로드되었다고 가정한다면, 클릭시 숫자는 증가할 것입니다. 여기서 숫자가 즉시 증가되는 것을 주목하세요. 지연이 없습니다. 서버를 기다릴 필요가 없습니다. 추가 데이터를 다운로드 받을 필요도 없습니다.

이것은 이 컴포넌트의 코드가 여러분의 컴퓨터에서 실행되기 때문에 가능합니다:

import { useState } from 'react';

export function Counter() {
  const [count, setCount] = useState(0);
  return (
    <button
      className="rounded-lg bg-gray-700 px-2 py-1 font-sans font-semibold text-gray-50 transition focus:ring focus:ring-gray-400 active:bg-gray-600"
      onClick={() => setCount(count + 1)}
    >
      저를 {count}번 클릭 했습니다
    </button>
  );
}
tsx

여기서 count는 클라이언트 상태의 일부로, 여러분 컴퓨터 메모리에 있는 정보이며 버튼을 누를 때마다 변경됩니다. 여러분이 버튼을 몇 번 클릭할지 모르기 때문에, 제 컴퓨터는 출력에 대한 모든 경우의 수를 대비할 수는 없습니다. 그저 초기 렌더링의 결과물(“저를 0번 클릭했습니다”)을 HTML로 보내는 것이 최선입니다. 하지만 그 시점 이후로는 여러분의 컴퓨터가 이 코드를 실행해야 합니다.

여러분은 이 코드를 여러분의 컴퓨터에서 실행할 필요가 없다고 주장할 수도 있습니다. 대신 제 컴퓨터에서 실행하는 것이죠. 버튼을 누를 때마다, 여러분의 컴퓨터가 제 컴퓨터에 다음 렌더링 출력을 요청할 수 있습니다. 이 동작 방식은 JavaScript 프레임워크가 생기기 이전의 웹 서버가 작동하던 방식과 흡사하지 않나요?

서버에게 새로운 UI를 요청하는 것은 사용자에게 약간의 지연이 예상될 때 유용합니다. 예를 들어 링크를 클릭할 때입니다. 사용자는 다른 페이지로 이동하게 될 것을 알기에 기다릴 것입니다. 그러나 직접적인 조작(좋아요 버튼을 클릭하는 것, 글 에디터에 입력하는 것, 카드를 스와이프하는 것, 메뉴에 마우스를 올리는 것, 차트를 드래그하는 것 등)에 즉각적인 피드백이 제공되지 않으면, 사용자는 페이지가 고장난 것처럼 느낄 것입니다.

이 원칙은 엄연히 말하면 기술적인 것이 아닌 일상 생활에서의 직관적으로 느낄 수 있는 것입니다. 예를 들어, 엘리베이터 버튼을 눌렀을 때 저희는 즉시 다음 층으로 이동하게 될 것을 기대하지는 않습니다. 반면, 문 손잡이를 밀 때는 문이 손의 움직임에 따라 바로 움직일 것으로 기대하며, 그렇지 않으면 막힌 것처럼 느껴질 것입니다. 사실 엘리베이터 버튼에서조차 즉각적인 피드백을 기대합니다. 버튼은 손의 압력에 눌려져야 하며, 이가 인식되어 불이 켜져야 합니다.

사용자 인터페이스를 구축할 때는, 적어도 일부 상호작용에 대해 낮은 지연시간을 보장하거나, 네트워크 응답 없이 반응하게 해야 합니다.

React의 핵심 원리를 하나의 방정식으로 설명하는 것을 보셨을 수도 있는데요. UI는 상태의 함수, 즉 UI = f(state)라는 것입니다. 이 방정식은 UI 코드가 상태를 인자로 받아야하는 함수임을 말하는 것이 아닙니다. 단지 현재 상태가 UI를 결정한다는 것을 말합니다. 상태가 변경되면, UI는 다시 계산되어야 합니다. 상태는 여러분의 컴퓨터에 “존재”하므로 UI를 계산하는 코드(컴포넌트)도 역시 여러분의 컴퓨터에서 실행되어야 합니다.

서버에서의 실행

이제 반대로 생각해보겠습니다. 컴포넌트는 제 컴퓨터에서 실행되어야 합니다.

아래는 이 블로그의 다른 글에 대한 미리보기 카드 입니다.

<PostPreview slug="svelte-compiler-operation" />
jsx

Svelte Compiler는 어떻게 동작할까?

1067 단어

어떻게 이 페이지의 컴포넌트가 다른 페이지의 단어 수를 알고 있을까요?

네트워크 탭을 확인해보시면, 추가 요청이 없음을 알 수 있습니다. 저는 단어 수를 세기 위해 GitHub에서 그 전체 블로그 포스트를 다운로드하지 않습니다. 또한 그 블로그 글의 내용을 이 페이지에 포함시키지도 않습니다. 단어를 세기 위해 API를 호출하지도 않습니다. 그리고 제가 그 모든 단어를 세지도 않았지요.

그렇다면 이 컴포넌트는 어떻게 작동할까요?

import { readFile } from 'fs/promises';
import matter from 'gray-matter';

export async function PostPreview({ slug }) {
  const fileContent = await readFile(`./public/${slug}/index.md`, 'utf8');
  const { data, content } = matter(fileContent);
  const wordCount = content.split(' ').filter(Boolean).length;

  return (
    <section className="rounded-md bg-gray-100 px-4 py-2">
      <h5 className="font-bold">
        <a href={`/${slug}`} target="_blank">
          {data.title}
        </a>
      </h5>
      <i>{wordCount} 단어</i>
    </section>
  );
}
js

이 컴포넌트는 제 컴퓨터에서 실행됩니다. fs.readFile을 통해 파일을 읽고, gray-matter을 통해 마크다운를 분석하고, 본문 텍스트를 공백 단위로 나누어 단어를 셉니다. 데이터가 있는 곳에서 바로 코드가 실행되기 때문에, 제가 추가로 할 일은 없습니다.

제 블로그의 모든 글와 그들의 단어 수를 나열하고 싶다고 가정해봅시다.

아주 쉬운 일입니다.

<PostList />
tsx

모든 포스트 폴더에 대해 <PostPreview />를 렌더링하기만 하면 됩니다:

import { readdir } from 'fs/promises';
import { PostPreview } from './post-preview';

export async function PostList() {
  const entries = await readdir('./public/', { withFileTypes: true });
  const dirs = entries.filter((entry) => entry.isDirectory());
  return (
    <div className="mb-4 flex h-72 flex-col gap-2 overflow-y-scroll">
      {dirs.map((dir) => (
        <PostPreview key={dir.name} slug={dir.name} />
      ))}
    </div>
  );
}
js

이 코드는 여러분의 컴퓨터에서 실행될 필요가 없습니다. 사실 여러분 컴퓨터는 제 파일이 없기 때문에 이를 실행할 수도 없습니다.

다음으로, 아래 코드가 실행된 시점을 확인해봅시다.

<p className="text-gray-700 font-bold">{new Date().toString()}</p>
html

Tue Nov 26 2024 10:24:43 GMT+0000 (Coordinated Universal Time)

오호라! 이것은 바로 제가 마지막으로 제 블로그를 배포했을 때입니다! 제 컴포넌트들은 빌드 과정 중에 실행되었으므로, 제 전체 게시물들에 대한 접근 권한이 있습니다.

제 컴포넌트들을 데이터 소스에 가까운 위치에 실행하면, 컴포넌트들은 여러분의 컴퓨터로 보내기 전에 데이터를 읽고 전처리할 수 있게 됩니다.

여러분 컴퓨터가 이 페이지를 로드할 땐, 앞서 작성했던 <PostList>, <PostPreview>, fileContent, dirs, fs, gray-matter는 더 이상 존재하지 않습니다. 대신 <div>, <section>, <a>, <i> 등으로 이뤄진 HTML 코드를 받게 되죠. 여러분의 컴퓨터는 컴포넌트들이 그 UI를 계산하기 위해 사용했던 원시 데이터(실제 글들) 대신 실제로 표시해야 할 UI만(렌더링된 글 제목, 링크 URL, 글 단어 수) 받았습니다.

이 원리로 살펴보면, UI는 서버 데이터의 함수이며, UI = f(data)입니다. 이 데이터는 제 장치에만 “존재”하기 때문에, 컴포넌트들은 제 장치에서 실행되어야 합니다.

두 가지의 React

UI는 컴포넌트로 구성되어 있지만, 저희는 두 가지 매우 다른 비전에 대해 살펴봤습니다.

UI = f(state)에서 state는 클라이언트 측에 있으며, f는 클라이언트에서 실행됩니다. 이 접근법은 <Counter />와 같이 즉시 상호작용이 가능한 컴포넌트를 작성할 수 있게 합니다. (여기서, f는 HTML을 생성하기 위해 초기 상태로 서버에서도 실행될 수 있습니다.)

UI = f(data)에서 data는 서버 측에 있으며, f는 오직 서버에서만 실행됩니다. 이 접근법은 <PostPreview />와 같이 데이터를 처리하는 컴포넌트를 작성할 수 있게 합니다. (빌드 시점은 “서버”로 간주됩니다.)

만약 저희가 익숙한 편견을 버리면, 이 두 접근법은 각자 잘하는 것에 있어서 모두 매력적입니다. 하지만 불행히도 이 비전들은 서로 상호 배타적인 것으로 보입니다.

만약 <Counter />와 같이 즉각적인 상호작용을 적용하고 싶다면, 컴포넌트를 클라이언트에서 실행해야 합니다. 하지만 <PostPreview />와 같은 컴포넌트는 readFile 같은 서버 전용 API를 사용하기 때문에 원칙적으로 클라이언트에서 실행할 수 없습니다. (이것이 문제의 핵심 포인트입니다! 그렇지 않다면 클라이언트에서 실행해도 괜찮을 것입니다.)

좋습니다. 그렇다면 대신 모든 컴포넌트를 서버에서 실행한다면 어떨까요? 서버에서 <Counter />와 같은 컴포넌트는 초기 상태만 렌더링할 수 있습니다. 서버는 그들의 현재 상태를 알지 못하기에 변경되는 상태를 실시간으로 서버와 클라이언트 간 통신을 해야 합니다. 그 상태 데이터가 URL 처럼 작지 않은 이상 통신 속도는 느리며, 통신이 항상 가능하지도 않습니다. (예를 들어, 제 블로그의 서버 코드는 배포 시에만 실행되므로 여러분은 그것에 “무언가를” 전달할 수 없습니다).

다시 돌아와서, 저희는 두 가지 다른 React 사이에서 선택해야 하는 것처럼 보입니다.

  • <Counter />를 작성할 수 있게 하는 “클라이언트” UI = f(state) 패러다임.
  • <PostPreview />를 작성할 수 있게 하는 “서버” UI = f(data) 패러다임.

하지만 실제 “공식”은 UI = f(data, state)에 더 가깝습니다. datastate가 없다면, 앞서 말한 문제를 고려하지 않아도 됩니다. 하지만 이상적으로, 저는 다른 추상화를 선택할 필요 없이 두 가지 경우를 모두 처리할 수 있는 프로그래밍 패러다임을 선호하며, 여러분 중 적어도 몇 명은 이을 좋아할 것입니다.

그렇다면, 저희가 해결해야 할 문제는 이 “f”를 매우 다른 두 프로그래밍 환경에서 어떻게 분할할 것인가입니다. 이것이 가능하기는 할까요? 기억하세요, 저희가 얘기하는 것은 실제로 f라고 불리는 함수가 아닙니다. f는 저희의 모든 컴포넌트를 대표합니다.

저희는 React의 좋은 점을 유지하면서 컴포넌트를 여러분의 컴퓨터와 제 컴퓨터 사이에서 어떻게 나눌 수 있을까요? 두 다른 환경에서 온 컴포넌트들을 결합하고 중첩시킬 수 있을까요? 이것은 작동 가능할까요?

이것은 어떻게 작동해야 할까요?

이에 대해 생각해보고, 다음에 우리의 노트를 비교해 봅시다.

Discuss on 𝕏 · Edit on GitHub

번역 후기

단순히 글로 생각을 전달하는 것이 아닌, 독자와 상호작용할 수 있는 장치를 배치하면서 자신의 생각을 전달했던 것이 인상깊은 글이었습니다.

서버 사이드 렌더링(SSR)이 점점 고도화되는 현대 웹 개발에서 React가 수행하는 역할을 더욱 명확하게 파악할 수 있었습니다. 또한 React 개발팀이 RSC(React Server Component) 개발에 힘을 쓰는 이유도 엿볼 수 있는 것 같네요.

UI = f(data)(state)
개인적으로 현대 웹 프레임워크를 가장 짧게 설명할 수 있는 공식인 것 같네요.