본 글은 Dan Abramov의 The Two Reacts 글의 한글 번역본입니다.

The Two Reacts

본문

화면에 무언가를 표시하고 싶다고 가정해보자. 이 블로그 게시물과 같은 웹 페이지, 인터렉티브 웹앱 또는 앱 스토어에서 다운로드할 수 있는 네이티브 앱을 표시하려면 최소 두 대의 기기가 필요하다.

당신의 기기와 내 기기.

내 기기에 있는 코드와 데이터로 시작한다. 예를 들어, 나는 내 블로그 게시물을 노트북에서 파일로 편집하고 있다. 이 글이 여러분의 화면에 표시된다면 이미 나의 기기에서 여러분의 기기로 전송 되었을 것이다. 어떤 특정한 순간과 장소에 나의 코드와 데이터가 여러분의 디바이스에 이 글을 표시하도록 지시하는 HTML과 자바스크립트로 바뀐 것이다.

그렇다면 이것이 React와 어떤 관련이 있을까? React는 블로그 게시물, 가입 폼 또는 전체 애플리케이션을 표시할 내용을 컴포넌트라고 하는 독립적인 조각으로 나누고 이를 레고 블록처럼 구성할 수 있도록 하는 UI 프로그래밍 패러다임이다. 컴포넌트에 대해 이미 알고 있고 좋아하신다고 가정하고 react.dev에서 소개를 확인해주길 바란다.

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

각각의 케이스를 만들어보자.


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

다음은 상호작용을 보여주기 위한 카운터 버튼이다. 몇 번 클릭해보자!

<Counter />

1

이 컴포넌트에 대한 자바스크립트 코드가 이미 로드 되었다고 가정하면 숫자가 증가할 것이다. 누르면 숫자가 즉시 증가한다. 지연이 없다. 서버를 기다릴 필요가 없다. 추가 데이터를 다운로드할 필요도 없다.

이 컴포넌트의 코드가 여러분의 컴퓨터에서 실행되고 있기 때문에 가능한 것이다.

import { useState } from "react";

export 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)}
    >
      You clicked me {count} times
    </button>
  );
}

여기서 count는 클라이언트 상태의 일부로, 버튼을 누를 때마다 업데이트되는 여러분컴퓨터 메모리의 일부 정보이다. 사용자가 버튼을 몇 번이나 누를지 모르기 때문에 컴퓨터에서 가능한 모든 출력을 예측하고 준비할 수는 없다. 나의 컴퓨터에서 준비할 수 있는 것은 초기 렌더링 출력(“저를 0번 클릭했습니다”)을 HTML로 전송하는 것 정도다. 하지만 그 이후부터는 여러분의 컴퓨터가 이 코드를 실행해야 한다.

이 코드를 여러분의 컴퓨터에서 실행할 필요가 없다고 주장할 수도 있다. 대신 내 서버에서 실행하면 어떨까? 여러분이 버튼을 누를 때마다 컴퓨터가 내 서버에 다음 렌더링 출력을 요청할 수 있다. 클라이언트 사이드 자바스크립트 프레임워크가 등장하기 전에는 웹 사이트는 이런 식으로 동작했었다.

서버에 새로운 UI를 요청하는 것은 사용자가 링크를 클릭할 때와 같이 약간의 지연이 예상되는 경우 효과적이다. 사용자가 앱의 다른 위치로 이동하고 있다는 것을 알면 기다릴 것이다. 그러나 슬라이더를 끌거나, 탭을 전환, 블로그 포스트 에디터에 입력, 좋아요 버튼 클릭, 카드 스와이프, 메뉴 위로 호버, 차트 드래그 등 직접적인 조작은 최소한 즉각적인 피드백을 안정적으로 제공하지 않으면 불편함을 느낄 수 있다.

이 원칙은 엄밀히 말해 기술적인 것이 아니라 일상 생활에서 얻은 직관이다. 예를 들어, 엘리베이터 버튼을 누르면 바로 다음 층으로 이동하는 것은 기대하지 않을 것이다. 하지만 문 손잡이를 누를 때는 손의 움직임에 따라 바로 움직이기를 기대하고, 그렇지 않으면 멈춰 있는 듯한 느낌을 받을 것이다. 사실 엘리베이터 버튼을 사용하더라도 최소한 즉각적인 피드백을 기대할 수 있다. 엘리베이터 버튼은 손의 압력에 반응하기 위해 불이 들어와야 한다.

사용자 인터페이스를 제작할 때는 최소한 일부 상호작용에 대해 지연 시간이 짧고 네트워크 왕복이 없는 상태로 응답할 수 있어야 한다.

React 멘탈 모델을 일종의 방정식으로 설명하는 것을 본적이 있을 것이다. UI는 state의 함수, 즉 UI = f(state)이다. 이는 UI 코드가 state를 인수로 받는 단일 함수여야 한다는 뜻이 아니라, 현재 상태가 UI를 결정한다는 의미다. 상태가 변경되면 UI는 다시 계산되어야 한다. 상태는 여러분의 컴퓨터에 존재하기에 UI를 계산하는 코드(컴포넌트)도 여러분의 컴퓨터에서 실행되어야 한다.

또는 아래와 같이 주장할 수도 있다.


다음으로 반대 의견을 제시해보겠다. 바로 컴포넌트가 나의 컴퓨터에서 돌아가야 한다는 것이다.

아래는 이 블로그의 다른 글에 대한 프리뷰 카드다.

<PostPreview slug="a-chain-reaction" />

2

현재 페이지의 컴포넌트는 대상 페이지의 단어 수를 어떻게 알 수 있을까?

네트워크 탭을 확인하면 추가 요청이 없는 것을 볼 수 있다. 단어 수를 세기 위해 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-black/5 p-2">
      <h5 className="font-bold">
        <a href={"/" + slug} target="_blank">
          {data.title}
        </a>
      </h5>
      <i>{wordCount} words</i>
    </section>
  );
}

이 컴포넌트는 내 컴퓨터에서 실행된다. 파일을 읽고자 할 때 나는 fs.readFile로 파일을 읽는다. 마크다운 헤더를 파싱하고 싶을 때는 gray-matter 라이브러리를 이용하여 파싱한다. 단어 수를 세고 싶을 때는 텍스트를 분할해서 카운팅한다. 작성된 코드는 데이터가 있는 곳에서 바로 실행되기 때문에 추가로 수행해야 할 작업이 없다.

블로그에 있는 모든 글들의 단어 수와 함께 나열하고 싶다고 가정해 보자. 간단히 아래와 같이 작성한다:

<PostList />

3

이제 해야하는 것은 <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-scroll font-sans">
      {dirs.map((dir) => (
        <PostPreview key={dir.name} slug={dir.name} />
      ))}
    </div>
  );
}

이 코드는 여러분의 컴퓨터에서 실행할 필요가 없으며, 실제로 여러분의 컴퓨터에는 내가 작성한 파일들이 없기 때문에 실행할 수 없다. 이 코드가 언제 실행 되었는지 확인해 보자:

<p className="text-purple-500 font-bold">{new Date().toString()}</p>
Fri Jan 05 2024 00:50:25 GMT+0000 (Coordinated Universal Time)

아하! 내가 마지막으로 블로그를 정적 웹 호스팅 서비스에 배포한 시점이다! 내가 작성한 컴포넌트들은 빌드 과정에서 실행되었기에 글 전체에 대한 접근 권한이 있었다.

컴포넌트를 데이터 소스 근처에서 실행하면 컴포넌트가 자체 데이터를 읽고 사전 처리한 후 해당 정보를 여러분의 기기로 전송할 수 있게 된다.

여러분이 이 페이지를 열어볼 때가 되면, 더 이상 <PostList><PostPreview>는 존재하지 않고, fileContentdirs, fs, gray-matter도 존재하지 않는다. 대신, <div>와 몇 개의 <section> 그리고 그 내부에 <a><i>가 존재할 뿐이다. 당신의 디바이스는 컴포넌트가 UI를 계산해야 하는 raw data가 아니라 실제로 보여줘야 하는 UI를 전송받게 된다. (여기서 보여줘야 하는 UI는 렌더링 된 포스트 제목, 링크 URL, 그리고 계산된 글자수 등을 의미한다)

이 멘탈 모델에서 UI는 서버 데이터의 함수, 즉 UI = f(data)다. 이 데이터는 코드를 작성한 나의 기기에만 존재하므로 컴포넌트는 나의 디바이스에서 실행되어야 한다.

또는 아래와 같이 주장할 수도 있다.


UI는 컴포넌트로 구성되어 있지만, 우리는 매우 다른 두 가지 비전을 주장했다:

  • UI = f(state), 여기서 state는 클라이언트 측이고 f는 클라이언트에서 실행된다. 이 접근 방식을 사용하면 <Counter />와 같은 즉각적인 인터랙티브 컴포넌트를 작성할 수 있다. (여기서 f는 HTML을 생성하기 위해 초기 상태로 서버에서 실행될 수도 있다.)

  • UI = f(data) 여기서 데이터는 서버 측이고 f는 서버에서만 실행된다. 이 접근 방식을 사용하면 <PostPreview />와 같은 데이터 처리 컴포넌트를 작성할 수 있다. (여기서 f는 서버에서만 실행된다. 빌드 시간은 “서버”에서 계산된다.)

친숙성 편향을 제쳐두고 보자면, 이 두 가지 접근 방식은 각자의 장점을 극대화할 수 있다. 허나 안타깝게도 이러한 비전은 서로 양립할 수 없는 것처럼 보인다.

<Counter />에서 필요로 하는 것과 같은 즉각적인 상호작용을 허용하려면 클라이언트에서 컴포넌트를 실행해야 한다. 하지만 <PostPreview />와 같은 컴포넌트는 원칙적으로 클라이언트에서 실행할 수 없는데, 그 이유는 readFile과 같은 서버 전용 API를 사용하기 때문이다. (이것이 바로 이 컴포넌트의 요점이다! 그렇지 않다면 차라리 클라이언트에서 실행하는 편이 낫다고 할 수 있다).

그렇다면 모든 컴포넌트를 서버에서 실행하면 어떨까? 하지만 서버에서 <Counter />와 같은 컴포넌트는 초기 상태만 렌더링할 수 있다. 서버는 현재 상태를 알지 못하며, 서버와 클라이언트 간에 해당 상태를 전달하는 것은 너무 느리고 (URL처럼 작은 것이 아니라면) 항상 가능한 것도 아니다. (예를들어 나의 블로그의 서버 코드는 배포 시에만 실행되므로 서버에 “전달”할 수 없다).

다시 말하지만, 우리는 두 가지 다른 React 중 하나를 선택해야 하는 것 같다:

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

그러나 실제로는 실제 “공식”은 UI = f(data, state)에 더 가깝다. 데이터가 없거나 상태가 없는 경우에는 위에 제시된 케이스로 일반화할 수 있다. 하지만 나는 이상적으로는 꼭 하나의 추상화를 선택하지 않고도 두 가지 경우를 모두 처리할 수 있는 프로그래밍 패러다임을 선호하며, 여러분 중 적어도 몇 명은 이런 방향을 원할 것이다.

그렇다면 해결해야 할 문제는 ‘f’를 서로 다른 두 개의 프로그래밍 환경으로 분할하는 방법이다. 이것이 가능하기는 할까? 여기서 f는 모든 컴포넌트를 나타내는 실제 함수를 말하는 것이 아니라는 점을 기억하자.

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

어떻게 해야 할 것일까?

생각해 보고 다음 시간에는 서로의 작성한 필기를 비교해 보도록 하자.