[번역] 리액트의 양면성

Saetbyeol·2024년 3월 8일
27

translations.zip

목록 보기
11/13
post-thumbnail

원문 The Two Reacts 을 저자의 허락을 받아 한국어로 번역한 글입니다 :)

화면에 무언가를 표시하고 싶다고 가정해 봅시다. 이 블로그 게시물과 같은 웹 페이지, 대화형 웹 앱 또는 앱 스토어에서 다운로드할 수 있는 기본 앱을 표시하려면 최소 대의 디바이스가 필요합니다.

여러분의 기기와 제 것이죠.

제 기기에 있는 일부 코드와 데이터로부터 시작합니다. 예를 들어, 저는 이 글을 제 노트북에서 파일로 편집하고 있습니다. 만약, 여러분이 이 글을 각자의 화면에서 보고 있다면 이미 제 기기에서 여러분의 기기로 이동한 것입니다. 어느 순간, 제 코드와 데이터가 여러분의 기기에 이 글을 표시하도록 지시하는 HTML과 자바스크립트로 바뀐 것입니다.

그렇다면 이것이 리액트와 어떤 관련이 있을까요? 리액트는 표시할 내용(블로그 글, 가입 양식 또는 전체 앱)을 컴포넌트라고 하는 독립적인 조각으로 분해하여 레고 블록처럼 구성할 수 있는 UI 프로그래밍 패러다임입니다. 컴포넌트에 대해 이미 알고 있고 좋아하신다고 가정하겠습니다. 자세한 내용은 react.dev에서 소개를 확인해 보세요.

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

두 경우에 대한 상황을 살펴봅시다.


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

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

역자주: 원문에서는 각 코드를 실행한 실제 컴포넌트를 표시하지만, Velog 특성상 스크린샷으로 대체합니다. 🥲

<Counter />

이 컴포넌트에 대한 자바스크립트 코드가 이미 로드되었다면 숫자가 증가합니다. 누르는 즉시 숫자가 증가하며 지연이 발생하지 않습니다. 서버를 기다리거나 추가 데이터를 다운로드할 필요가 없습니다.

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

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를 요청하는 것이 좋습니다. 사용자가 앱의 다른 위치로 이동하고 있다는 것을 안다면 기다릴 것입니다. 그러나 직접적인 조작(슬라이더 드래그, 탭 전환, 글 입력, 좋아요 클릭, 카드 스와이프, 메뉴에 마우스 올리기, 차트 드래그 등)을 수행했을 때 최소한의 어떤 즉각적인 피드백을 안정적으로 제공하지 않으면 사용자는 불안정하다고 느낄 수 있습니다.

엄밀히 말하자면 이 원칙은 기술적인 것이 아니라 일상생활에서 얻은 직관입니다. 예를 들어, 엘리베이터 버튼을 누르는 즉시 다음 층으로 이동하리라고 기대하지는 않을 것입니다. 하지만 문손잡이를 누를 때는 손의 움직임에 따라 바로 움직이거나 멈춰 있는 느낌이 들 것이라고 기대합니다. 실제로 엘리베이터 버튼 또한 적어도 특정한 즉각적인 피드백이 필요합니다. 손의 압력에 따라 반응하며, 누른 것을 인식할 수 있도록 버튼에 불이 들어와야 합니다.

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

리액트의 멘탈 모델이 UI는 상태의 함수이거나, UI = f(상태) 등 일종의 방정식으로 설명되는 것을 본 적이 있을 것입니다. 이것은 여러분의 UI 코드가 상태를 인수로 하는 단일 함수여야 한다는 것을 의미하는 것이 아닙니다. 단지 현재 상태가 UI를 결정한다는 것을 의미합니다. 상태가 변경되면 UI를 다시 계산해야 합니다. 상태가 여러분의 컴퓨터에 "살아있기" 때문에 UI를 계산하는 코드(여러분의 컴포넌트)도 여러분의 컴퓨터에서 실행되어야 합니다.

이러한 주장으로 가능합니다.


이번에는 반대 측, 즉 컴포넌트가 컴퓨터에서 실행되어야 한다는 입장에서 주장해 보겠습니다.

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

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

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

네트워크 탭을 확인해 보면 추가적인 네트워크 요청이 보이지 않습니다. 다른 글의 단어 수를 세기 위해 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 />

제가 해야 할 일은 모든 게시글 폴더에 대해 <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>도 없었고, fileContentdir도 없었으며, fsgray-matter도 없었습니다. 대신 <div>와 그 내부에 각각 <a><i>가 있는 몇 개의 <section>만 있었습니다. 여러분의 기기는 컴포넌트가 해당 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 />와 같은 컴포넌트는 readFile과 같은 서버 전용 API를 사용하기 때문에 원칙적으로 클라이언트에서 실행할 수 없습니다. (이것이 서버에서 실행되는 컴포넌트의 목적입니다! 그렇지 않으면 클라이언트에서 실행하는 것이 낫겠죠.)

그렇다면 모든 컴포넌트를 서버에서 실행하면 어떨까요? 그러나 서버에서는 <Counter />와 같은 컴포넌트는 초기 상태만 렌더링 할 수 있습니다. 서버는 현재 상태를 알지 못하며, 서버와 클라이언트 간에 그 상태를 전달하는 것은 너무 느리고(URL과 같이 작은 경우를 제외하고) 항상 가능한 것도 아닙니다. (예: 제 블로그의 서버 코드는 배포될 때만 실행되므로 "전달"할 수 없습니다).

또 다시 두 리액트 중 하나를 선택해야 하는 것처럼 보입니다.

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

현실 세계에서 실제 "공식"은 UI = f(데이터, 상태)에 더 가깝습니다. data가 없거나 state가 없는 경우, 이 공식은 위에서 설명한 두 경우로 일반화됩니다. 하지만 이상적으로는 다른 추상화를 선택할 필요 없이, 두 가지 경우를 모두 처리할 수 있는 프로그래밍 패러다임을 선호합니다. 여러분 중 적어도 몇 명은 이를 원하고 있습니다.

그렇다면 남은 문제는 이 "f"를 아주 다른 두 프로그래밍 환경에 어떻게 분할할 것인가입니다. 가능할까요? 여기서 f는 모든 컴포넌트를 나타내는 함수가 아니라 실제 함수에 관해 이야기하고 있다는 점을 기억하세요.

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

어떻게 해야 할까요?

한 번 생각해 보시고, 다음에는 서로의 생각을 비교해 봅시다.

3개의 댓글

comment-user-thumbnail
2024년 3월 12일

감사합니다! 흥미로운 글 잘 읽고 갑니다!

답글 달기
comment-user-thumbnail
2024년 3월 13일

클라이언트 컴포넌트와 서버 컴포넌트에 대해 일상적인 단어를 사용해서 이야기로 설명해주시니 이해가 잘되었습니다! 필력이 너무 좋으세요! 감사합니다!!

답글 달기
comment-user-thumbnail
2024년 3월 28일

Thank you for providing such an engaging article! I thoroughly enjoyed reading it. xciptv

답글 달기