Engineering

React 컴포넌트 기반 사고

상태, 데이터 흐름, 컴포넌트 경계와 긴장을 통해 React 컴포넌트 모델을 이해합니다.

검증일 근거 자료

컴포넌트는 React로 프론트엔드를 개발한다면 가장 중요한 개념이라 할 수 있다. 흔히 컴포넌트를 UI를 구성하는 단위 정도로 생각하지만, 실제로는 이벤트 처리, 상태 관리, 서버와의 통신까지 많은 부분을 컴포넌트가 담당한다. 로직을 컴포넌트로부터 아무리 잘 분리하더라도 사용자는 결국 컴포넌트를 통해 시스템과 만나기 때문에, 좋은 제품을 만들고 싶다면 컴포넌트에 대한 이해가 필수적이다.

이 시리즈에서 지금까지 Polymorphic, Render Delegation, Headless라는 구체적인 패턴들을 다뤄왔다. 이번 글은 반대로 한 걸음 물러선다. 패턴은 결국 컴포넌트를 어떻게 바라보느냐에서 나오는 것이라서, 시각이 서 있지 않으면 패턴은 외운 공식이 되고 시각이 서 있으면 패턴은 자연스러운 귀결이 된다. 그래서 이 글에서는 컴포넌트란 무엇인지, 무엇으로 이루어져 있는지, 서로 어떻게 협력하는지, 그리고 컴포넌트가 아닌 것들과는 어떤 긴장 관계에 있는지를 차례로 짚는다. 말하자면 컴포넌트를 기반으로 생각하는 방법에 대한 글이다.

컴포넌트란 무엇인가

React 공식 문서는 컴포넌트를 "앱의 재사용 가능한 UI 조각"이라고 소개한다. 틀린 말은 아니지만 절반만 담은 정의다. 프로그래밍에서 컴포넌트라는 개념은 UI가 등장하기 한참 전부터 있었다. 독립적으로 만들어져 있고, 다른 부품과 조합할 수 있고, 여러 곳에서 재사용할 수 있는 소프트웨어 부품을 가리키는 말이었다1. React 컴포넌트도 이 계보 위에 있다. 화면을 그리는 것은 컴포넌트가 하는 여러 일 중 하나일 뿐이고, 본질은 조합 가능한 부품이라는 데 있다.

말로만 하면 추상적이니 실제 컴포넌트를 하나 놓고 이야기하자. 커머스 상품 페이지에 있을 법한 장바구니 담기 버튼이다. 이 글이 끝날 때까지 여러 번 다시 만나게 될 것이다.

const AddToCartButton = ({ productId }) => {
  const [status, setStatus] = useState("idle"); // 'idle' | 'loading' | 'added'

  const handleClick = async () => {
    if (status !== "idle") return;
    setStatus("loading");
    await addCartItem(productId);
    setStatus("added");
  };

  return (
    <button
      className="add-to-cart"
      disabled={status === "loading"}
      onClick={handleClick}
    >
      {status === "idle" && "장바구니 담기"}
      {status === "loading" && "담는 중..."}
      {status === "added" && "담김 ✓"}
    </button>
  );
};

몇 줄 되지 않는 코드지만 안을 들여다보면 요소와 스타일(button, className), 데이터(productId, status), 사용자 이벤트 처리(onClick), 서버 통신(addCartItem)이 전부 들어 있다. UI 조각이라고 부르기에는 하는 일이 많다. 사용자가 상품을 장바구니에 담는 행위 전체가 이 컴포넌트 하나에 담겨 있다고 보는 편이 정확하다.

설계도, 호출 결과, 인스턴스

컴포넌트를 정의하려면 먼저 헷갈리기 쉬운 세 가지를 구분해야 한다. 컴포넌트, 엘리먼트, 인스턴스다. 일상 대화에서는 셋 다 뭉뚱그려 "컴포넌트"라고 부르지만, React가 동작하는 방식을 이해하려면 구분이 필요하다.

// 컴포넌트: props를 받아 화면을 묘사하는 함수, 즉 설계도다
const Greeting = ({ name }) => <p>안녕, {name}</p>;

// 엘리먼트: 설계도를 어떤 값으로 그릴지 적어둔 가벼운 객체다
const element = <Greeting name="React" />;
console.log(element);
// { type: Greeting, props: { name: 'React' }, ... }

컴포넌트는 함수다. props를 받아 화면이 어떤 모습이어야 하는지를 반환하는 설계도라고 할 수 있다. 엘리먼트는 그 설계도를 가리키는 주문서다. JSX2는 함수 호출로 변환되는 문법이라 <Greeting name="React" />라고 쓰는 순간 { type: Greeting, props: { name: 'React' } } 모양의 평범한 객체가 만들어진다. 이 시점에는 Greeting 함수가 실행되지도 않았다. React가 이 주문서를 받아 실제로 함수를 호출하는 일을 렌더링이라고 부른다.

그럼 인스턴스는 무엇일까. 위 코드의 useState를 떠올려보자. 함수는 호출이 끝나면 지역 변수가 사라지는데 status는 렌더링을 거듭해도 유지된다. React가 함수 바깥 어딘가에 이 컴포넌트 몫의 상태를 보관해 두기 때문이다. 이렇게 트리의 특정 위치에 매여 상태와 함께 살아 있는 존재가 인스턴스다. 클래스 컴포넌트 시절에는 실제로 클래스의 인스턴스가 만들어졌기에 붙은 이름이고, 함수 컴포넌트에서는 React 내부의 Fiber라는 자료 구조가 그 역할을 한다3.

중요한 것은 React가 인스턴스를 식별하는 방법이다. React는 트리에서의 위치와 타입으로 인스턴스를 알아본다4. 다시 렌더링했을 때 같은 위치에 같은 타입의 엘리먼트가 있으면 같은 인스턴스로 취급하고 상태를 유지한다. 이 규칙을 모르면 실무에서 꽤 당황스러운 버그를 만난다. 필자가 겪었던 것 중 하나는 상품 상세 페이지의 리뷰 폼이었다. 상품 A의 리뷰를 쓰다 말고 추천 목록을 눌러 상품 B로 이동했는데, B의 리뷰 폼에 A에 대해 쓰다 만 문장이 그대로 남아 있었다. 라우터가 <ProductPage productId={id} />를 같은 위치에 다시 그렸으니 React 입장에서는 같은 인스턴스였고, 폼의 상태를 버릴 이유가 없었던 것이다.

// 같은 위치, 같은 타입이므로 productId가 바뀌어도 인스턴스와 상태가 유지된다
<ProductPage productId={productId} />

// key가 바뀌면 React는 다른 인스턴스로 취급하고 상태를 처음부터 다시 만든다
<ProductPage key={productId} productId={productId} />

key는 목록을 그릴 때 경고를 없애려고 붙이는 장식이 아니라, 이 인스턴스의 정체성이 무엇인지 React에게 알려주는 수단이다. 컴포넌트가 설계도라면 key는 "이 설계도로 지은 몇 호 건물인지"를 구분하는 주소인 셈이다.

정리하면 이렇다. 우리가 작성하는 것은 컴포넌트(설계도)이고, 렌더링마다 만들어지는 것은 엘리먼트(주문서)이며, 화면 뒤에서 상태를 안고 살아가는 것은 인스턴스다. 이 셋을 구분하고 나면 "왜 상태가 안 사라지지?"나 "왜 상태가 날아갔지?" 같은 질문의 답이 대부분 위치와 key로 설명된다.

컴포넌트를 바라보는 여러 시각

컴포넌트가 조합 가능한 부품이라는 정의에 동의하더라도, 그 부품을 바라보는 시각은 하나가 아니다. 실제로 React 생태계에서 통용되는 시각만 나열해도 이 정도가 된다.

  • 컴포넌트는 로직과 뷰의 조합이다.
  • 컴포넌트는 요소와 스타일의 조합이다.
  • 컴포넌트는 서버 상태를 반영하는 뷰다.
  • 컴포넌트는 사용자 이벤트가 시스템으로 들어오는 통로다.
  • 컴포넌트는 상태의 주도권을 갖거나 넘겨주는 존재다.
  • 컴포넌트는 실패와 대기를 가두는 경계다.
  • 컴포넌트는 유한 상태 머신이다.

전부 같은 컴포넌트를 두고 하는 말인데 왜 이렇게 갈라질까. 목적이 다르기 때문이다. 재사용을 고민하는 사람에게 컴포넌트는 로직과 뷰의 조합으로 보이고, 데이터 정합성을 고민하는 사람에게는 서버 상태를 반영하는 뷰로 보인다. 그리고 어떤 시각으로 보느냐에 따라 실제 구현이 달라진다. 시각은 감상이 아니라 설계 결정이다.

몇 가지만 짚어보자. "로직과 뷰의 조합"이라는 시각을 끝까지 밀고 나가면 로직만 남기고 뷰를 비운 Headless 컴포넌트에 도착한다. "서버 상태를 반영하는 뷰"라는 시각은 컴포넌트가 데이터를 소유하는 것이 아니라 구독한다는 발상으로 이어지고, TanStack Query 같은 라이브러리의 토대가 된다. 뒤에서 다시 다룰 것이다. 나머지 시각들도 이 글 안에서 각자의 자리가 있다. "요소와 스타일의 조합"은 바로 다음 장에서 컴포넌트를 분해할 때의 언어가 되고, "주도권" 시각은 관계 장의 제어·비제어 절에서, "이벤트 통로"와 "실패와 대기의 경계" 시각은 평가 장의 신뢰성 절에서 다시 만난다.

목록의 끝에 있는 상태 머신 시각이 재미있는데, 아까의 AddToCartButton을 다시 보자. 이 컴포넌트의 상태는 idle, loading, added 셋뿐이고, 클릭이라는 사건이 일어나면 정해진 규칙에 따라 다음 상태로 넘어간다. 상태의 목록이 유한하고 전이 규칙이 표로 그려진다는 뜻에서, 이것은 교과서적인 유한 상태 머신(finite state machine)이다5. 아래 데모는 이 버튼의 한살이를 자동으로 반복 재생한 것이다. 왼쪽 버튼에 클릭이 들어올 때마다 오른쪽 상태 그래프에서 현재 상태가 이동한다. 그래프의 RESET은 재생을 처음으로 되돌리기 위해 얹은 전이다.

이 시각이 유용한 이유는 불가능한 상태를 설계 단계에서 제거해 주기 때문이다. 같은 버튼을 boolean 두 개로 만들었다고 해보자.

const [isLoading, setIsLoading] = useState(false);
const [isAdded, setIsAdded] = useState(false);

boolean 두 개는 조합이 네 가지다. 그런데 isLoadingisAdded가 동시에 true인 버튼은 무슨 화면을 그려야 할까. 그런 상태는 기획에 존재하지 않지만 코드에는 존재할 수 있고, 존재할 수 있는 상태는 언젠가 반드시 도달한다. 상태를 'idle' | 'loading' | 'added' 하나로 정의하면 이 조합 자체가 표현 불가능해진다. 컴포넌트가 커질수록 boolean은 늘어나고 조합은 지수적으로 불어나므로, 화면이 복잡해질수록 상태 머신 시각의 값어치도 커진다6.

여기서 이 절의 요점이 나온다. 컴포넌트는 목적에 따라 다르게 추상화할 수 있는 대상이라는 것이다. 어떤 시각이 옳은지 다툴 필요는 없다. 지금 풀려는 문제에 맞는 시각을 골라 들면 된다.

컴포넌트 분해하기

시각을 골랐으면 다음은 추상화인데, 추상화라는 것은 결국 무엇을 남기고 무엇을 뺄지 결정하는 일이다. 그 결정을 하려면 컴포넌트 안에 무엇이 들어 있는지부터 알아야 한다. 통째로 보면 한 덩어리 같지만, 분해해 보면 컴포넌트는 생각보다 많은 것으로 이루어져 있다.

크게 세 갈래로 나눌 수 있다.

첫째는 다. 어떤 태그로 그릴지(요소), 그 태그에 무엇을 붙일지(속성), 무슨 내용을 담을지(텍스트와 자식), 어떻게 보일지(스타일)가 여기에 속한다. AddToCartButton에서는 button 태그, disabled 속성, "장바구니 담기"라는 문구, add-to-cart 클래스가 뷰다.

데이터는 다시 세 종류로 나뉜다. 밖에서 주입받는 props, 스스로 기억하는 상태, 그리고 둘로부터 계산되는 파생 값이다. productId는 props고 status는 상태다. disabled={status === 'loading'}의 조건식은 상태에서 파생된 값이다. 파생 값을 상태로 착각하고 useState를 하나 더 만드는 것은 초보 시절 누구나 지나는 함정인데, 데이터를 이렇게 세 종류로 구분하는 습관이 그 함정을 피하게 해준다. 기억해야 하는 것만 상태이고, 계산할 수 있는 것은 상태가 아니다.

남은 갈래인 로직에는 사용자 이벤트에 반응하는 핸들러, 렌더링 바깥 세계와 상호작용하는 이펙트, 서버와의 통신이 속한다. handleClick과 그 안의 addCartItem 호출이 로직이다.

여기에 하나를 더 얹으면, 앞 장에서 본 정체성(트리에서의 위치와 key)이 있다. 코드에 명시적으로 드러나지 않아서 잊기 쉽지만 인스턴스의 상태가 어디에 매여 있는지를 결정하는 어엿한 구성 요소다.

분해가 왜 중요할까. 컴포넌트의 추상화라는 것이 결국 이 구성 요소들 중 무엇을 고정하고 무엇을 사용자에게 열어줄지 정하는 일이기 때문이다. 이 시리즈에서 다룬 패턴들을 이 언어로 다시 읽어보면 구도가 선명해진다.

  • Polymorphic 컴포넌트는 뷰 중에서 요소를 열어준 것이다. as 속성으로 button이 될지 a가 될지를 사용자가 정한다.
  • Render Delegation은 한발 더 나아가 렌더링 자체를 자식에게 위임한 것이다.
  • Headless 컴포넌트뷰 갈래 전체를 비우고 데이터와 로직만 제공하는 것이다.

패턴을 하나하나 외울 필요가 없다는 말은 이런 뜻이다. 구성 요소의 목록을 알고 있으면 "무엇을 열어줄 것인가"라는 질문에 답할 때마다 패턴이 따라 나온다. 스타일만 갈아입힐 수 있으면 되는가? className만 열면 된다. 어떤 요소로든 그려져야 하는가? 요소를 열자. 화면 구조 자체가 제품마다 다른가? 뷰를 통째로 비우자. 반대로 아무것도 열지 않고 전부 고정한 컴포넌트도 그 자체로 훌륭한 선택일 수 있다. 어드민의 UserSearchInput처럼 딱 한 곳에서 쓰이는 컴포넌트를 미리 일반화해 두면, 열어둔 축을 관리하는 복잡도만 쌓이고 정작 그 자유를 쓸 일은 오지 않는 경우가 많다.

컴포넌트 사이의 관계

컴포넌트를 부품이라고 정의했으니, 부품들이 어떻게 연결되어 하나의 화면을 이루는지를 살펴볼 차례다. React가 컴포넌트 사이의 관계에 대해 정해둔 규칙은 단순한데, 그 단순한 규칙에서 여러 협력 패턴이 자라난다.

데이터는 아래로, 사건은 위로

React의 데이터 흐름은 한 방향이다. 부모는 자식에게 props로 데이터를 내려보내고, 자식은 무언가 일어났음을 콜백 호출로 부모에게 알린다. 자식이 부모의 상태를 직접 고치는 길은 없다.

처음 배울 때는 이 제약이 답답하게 느껴진다. 형제 컴포넌트에게 값 하나 전달하려고 부모까지 올라갔다 내려와야 하기 때문이다. 하지만 이 제약은 React가 이전 세대 프레임워크에서 얻은 교훈이 반영된 설계다. 양방향 바인딩 시절에는 화면의 값 하나가 이상해졌을 때 그 값을 고칠 수 있는 용의자가 사방에 있었다7. 단방향에서는 상태가 이상하면 그 상태를 소유한 컴포넌트 하나만 조사하면 된다. 데이터의 이동 경로를 추적할 수 있게 되는 대신 타이핑이 몇 줄 늘어나는 것인데, 애플리케이션이 커질수록 이 교환은 점점 유리해진다.

이 규칙에서 첫 번째 협력 패턴이 바로 나온다. 두 컴포넌트가 같은 데이터를 봐야 한다면, 그 데이터는 둘의 공통 부모로 올라가야 한다. 상태 끌어올리기(lifting state up)다. AddToCartButton으로 상품을 담았을 때 헤더의 장바구니 배지 숫자가 올라가야 한다고 해보자. 버튼과 배지는 트리에서 한참 떨어져 있으므로, 장바구니 개수라는 상태는 둘을 모두 거느린 조상으로 올라간다.

const ProductPage = ({ product }) => {
  const [cartCount, setCartCount] = useState(0);

  return (
    <>
      <Header>
        <CartBadge count={cartCount} />
      </Header>
      <ProductDetail>
        <AddToCartButton
          productId={product.id}
          onAdded={() => setCartCount((count) => count + 1)}
        />
      </ProductDetail>
    </>
  );
};

AddToCartButton은 담기에 성공하면 onAdded를 호출할 뿐, 그 사건으로 배지가 갱신되는지 토스트가 뜨는지는 모른다. 사건의 의미를 정하는 것은 부모의 몫이다. 이 무지가 재사용성의 원천이다. 버튼이 배지의 존재를 알았다면 배지가 없는 화면에서는 쓸 수 없는 버튼이 됐을 것이다.

물론 이 코드에는 냄새가 하나 있다. 장바구니 개수의 진실은 서버에 있는데 클라이언트가 + 1로 어림하고 있다는 점이다. 이 문제는 컴포넌트와 비컴포넌트의 긴장을 다룰 때 다시 꺼내겠다.

합성, 구멍을 뚫어두는 설계

부모 자식 관계에는 데이터 전달 말고 다른 축이 하나 더 있다. 부모가 자식의 내용물을 결정하는 방식, 즉 합성(composition)이다. React에서 children은 특별한 문법이 아니라 그저 props의 하나지만, "이 컴포넌트의 이 자리는 사용하는 쪽이 채운다"라는 구멍을 선언하는 관례로 쓰인다.

합성이 왜 중요한지는 반대편 극단을 보면 안다. 주문 취소 확인 모달을 props만으로 만들었다고 해보자.

<ConfirmModal
  title="주문을 취소할까?"
  description="취소한 주문은 되돌릴 수 없다"
  confirmText="취소하기"
  confirmVariant="danger"
  cancelText="돌아가기"
  showCloseButton
  footerAlign="right"
  onConfirm={cancelOrder}
  onCancel={close}
/>

처음에는 props가 서너 개였을 것이다. 그런데 어느 화면에서 설명 안에 굵은 글씨가 필요해지고, 다른 화면에서 버튼이 세 개 필요해지고, 또 다른 화면에서 본문에 환불 금액 표가 들어가야 하면서 props가 하나씩 늘었을 것이다. props는 컴포넌트가 미리 상상해 둔 변형만 허용하는데, 실제 요구사항은 상상을 항상 초과한다. 이렇게 설정 항목이 불어나는 컴포넌트는 구멍을 뚫는 편이 낫다.

<Modal onClose={close}>
  <Modal.Title>주문을 취소할까?</Modal.Title>
  <Modal.Body>
    취소한 주문은 되돌릴 수 없다
    <RefundSummary orderId={order.id} />
  </Modal.Body>
  <Modal.Footer>
    <Button variant="ghost" onClick={close}>
      돌아가기
    </Button>
    <Button variant="danger" onClick={cancelOrder}>
      취소하기
    </Button>
  </Modal.Footer>
</Modal>

모달은 이제 겹침 순서, 바깥 클릭, Escape 처리처럼 모달다움에 해당하는 것만 책임지고, 내용물은 전부 사용하는 쪽이 JSX로 조립한다. 환불 금액 표가 필요하면 그냥 넣으면 된다. 모달에게 허락을 구할 일이 아니다. 설정(configuration)으로 시작해 요구사항에 쫓기다가 합성으로 다시 짜는 일은 필자도 여러 번 반복했는데, 경험상 "이 컴포넌트의 내용물이 화면마다 다른가?"라고 처음에 한 번 물어보는 것만으로 그 재작성의 상당수를 건너뛸 수 있었다. 내용물이 화면마다 다르면 children으로 열어두고, 항상 같으면 props로 고정하는 편이 낫다.

멀리 있는 협력, Context

트리에서 멀리 떨어진 컴포넌트끼리 협력해야 할 때 props는 비용이 커진다. 테마 색상을 최상단에서 버튼까지 내려보내려고 중간의 컴포넌트 다섯 개가 자기는 쓰지도 않는 theme을 받아서 넘기는, 이른바 props drilling이 생긴다. 이때를 위해 React는 Context를 제공한다. 부모가 Provider로 값을 걸어두면 깊이와 무관하게 자손이 꺼내 쓸 수 있는 통로다.

const CartContext = createContext(null);

const App = () => (
  <CartContext.Provider value={cart}>
    <ProductPage /> {/* 몇 단계 아래의 CartBadge가 바로 꺼내 쓴다 */}
  </CartContext.Provider>
);

const CartBadge = () => {
  const cart = useContext(CartContext);
  return <span className="badge">{cart.items.length}</span>;
};

Context는 편리한 만큼 남용되기 쉬운 도구다. 두 가지만 기억해 두면 실무에서 크게 데지 않는다. 하나는 Context가 자주 바뀌는 값에 약하다는 것이다. Provider의 값이 바뀌면 그 값을 구독하는 모든 컴포넌트가 다시 렌더링되므로8, 키 입력마다 바뀌는 값을 Context에 넣으면 화면 절반이 함께 다시 그려지는 광경을 보게 된다. 다른 하나는 Context가 컴포넌트의 숨은 입력이 된다는 것이다. props는 사용처에서 무엇이 들어가는지 보이지만 Context는 보이지 않아서, Provider 없이 렌더링되는 순간까지 의존을 알아차리지 못할 수 있다. 테마, 로케일, 로그인한 사용자처럼 넓은 범위에서 안정적으로 유지되는 값이 Context의 적성에 맞는 화물이다.

한편 Context에는 전역 값 배달보다 훨씬 컴포넌트다운 쓰임새가 하나 있다. 좁은 범위에 Provider를 치고 한 컴포넌트의 부품들끼리 상태를 공유하게 만드는 것, 즉 합성 컴포넌트(compound component) 패턴이다. 방금 본 Modal.TitleModal.Footer가 서로를 알아보는 것도, Headless 글에서 Select.TriggerSelect.Option이 협력했던 것도 전부 이 좁은 Context 위에서 일어나는 일이다.

주도권 협상, 제어와 비제어

관계의 축이 하나 더 남았다. 상태의 주도권이다. 어떤 컴포넌트는 자기 상태를 스스로 들고 있고(비제어, uncontrolled), 어떤 컴포넌트는 상태를 밖에서 받기만 한다(제어, controlled). <input defaultValue={...}><input value={...}>의 차이가 대표적인 예다.

이것은 기술 선택이라기보다 협상이다. 부모 입장에서 자식의 상태에 개입할 일이 없다면 자식이 알아서 들고 있는 편이 편하고, 자식의 상태에 따라 다른 화면을 갱신해야 한다면 상태가 부모에게 있어야 한다. 라이브러리로 배포되는 컴포넌트라면 어느 쪽 사용자가 올지 모르므로 둘 다 지원하는 것이 관례다. 구현 방법은 Headless 글의 해당 장에서 useControllableState로 자세히 다뤘으니 여기서는 관계의 관점만 짚어둔다. 컴포넌트를 설계할 때 "이 상태의 주인은 누구인가"를 한 번 물어보고, 사용처마다 답이 갈릴 것 같으면 협상의 여지를 열어두자.

이 협상을 끝까지 밀면 상태 값을 넘어 전이 규칙의 주도권까지 테이블에 올릴 수 있다. Downshift가 널리 알린 state reducer 패턴이다. 컴포넌트는 상태를 바꾸기 직전에 "이런 사건이 있었고 이렇게 바꾸려 한다"를 사용자가 넘긴 리듀서에 통과시키고, 사용자는 그 전이를 그대로 승인하거나 고쳐서 돌려준다.

useSelect({
  items,
  stateReducer: (state, { changes, type }) => {
    // 멀티 셀렉트: 옵션을 골라도 목록이 닫히지 않도록 전이를 고쳐서 돌려준다
    // (키보드로 선택하는 케이스는 지면상 생략했다)
    if (type === useSelect.stateChangeTypes.ItemClick) {
      return { ...changes, isOpen: true };
    }
    return changes;
  },
});

제어 컴포넌트가 "상태 값은 네가 가져라"였다면 state reducer는 "상태가 변하는 규칙까지 네가 정해라"다. 앞서 본 상태 머신 시각의 언어로 말하면 전이 표의 최종 결재권을 사용자에게 내주는 것이고, 그 덕에 라이브러리가 미처 예상하지 못한 요구사항을 사용자가 옵션 추가 요청 없이 스스로 풀 수 있게 된다.

컴포넌트 분류하기

부품이 늘어나면 정리가 필요해진다. 컴포넌트가 수십 개를 넘어가는 순간부터 "이 컴포넌트는 어느 폴더에 두지?", "이건 누가 고쳐야 하지?" 같은 질문이 매일 나오는데, 분류 체계는 이 질문에 팀이 매번 회의하지 않고 답하기 위한 장치다.

React 커뮤니티는 유명한 분류법을 여럿 거쳐왔는데, 돌아보면 대부분 지나간 유행이 됐다. Presentational과 Container의 구분은 화면을 그리는 컴포넌트와 데이터를 다루는 컴포넌트를 파일부터 분리하는 방식으로 한 시대를 풍미했지만, 이 구분을 널리 알린 Dan Abramov 본인이 훅 등장 이후 "더는 이렇게 나누지 말라"고 글에 주석을 달았다9. Atomic Design10은 컴포넌트를 원자, 분자, 유기체, 템플릿, 페이지의 다섯 단계로 나누는 방법론으로 디자인 시스템 쪽에서 크게 유행했지만, 컴포넌트 코드의 분류 기준으로는 요즘 거의 쓰이지 않는다. 필자도 한때 도입했다가 접었다. 검색창은 입력과 버튼이 붙었으니 분자인가, 자동완성 목록이 달렸으니 유기체인가 같은 경계 판정이 코드 리뷰마다 논쟁이 됐고, 판정이 바뀔 때마다 폴더를 옮기느라 import 경로가 출렁였다. 화학에서 빌려온 다섯 단계가 제품의 실제 관심사와 맞지 않았던 것이다.

분류법들이 이렇게 오고 가는 동안 살아남은 질문이 하나 있다. 이 컴포넌트는 무엇을 아는가다. 크기나 생김새와 달리 의존의 목록은 경계 판정이 흔들리지 않고, 재사용 가능한 범위와 정확히 일치한다. 사실 유행이 지나간 분류법들도 뜯어보면 이 질문을 각자의 어휘로 묻고 있었다. Container는 데이터 소스를 아는 컴포넌트였고, 원자는 아무것도 모르는 컴포넌트였다. 이 질문으로 직접 나누면 실무에서는 대체로 세 층이면 충분하다.

src/
├── components/          # 도메인을 모르는 공용 컴포넌트
│   ├── Button.tsx       #   "주문"도 "상품"도 모른다. 어느 서비스로 옮겨도 동작한다.
│   ├── Modal.tsx
│   └── Badge.tsx
├── features/            # 도메인을 아는 컴포넌트
│   ├── cart/
│   │   ├── AddToCartButton.tsx   # "장바구니"를 알지만 어느 화면에 놓일지는 모른다
│   │   └── CartBadge.tsx
│   └── order/
│       └── OrderStatusBadge.tsx  # 주문 상태와 색상의 대응 규칙을 안다
└── pages/               # 화면을 아는 컴포넌트
    └── ProductPage.tsx  # 어떤 부품이 어떤 배치로 모이는지, 라우팅과 데이터 로딩을 안다

층과 함께 의존의 방향 규칙이 따라온다. 페이지는 기능을 알고, 기능은 공용을 알지만, 역방향은 금지다. components/Button.tsxfeatures/cart의 무언가를 import하는 순간 그 버튼은 더 이상 공용이 아니다. 이 규칙의 좋은 점은 위반이 import 문에 그대로 드러나서 리뷰에서 기계적으로 잡힌다는 것이다. 예를 들어 OrderStatusBadge가 공용 Badge를 조합해 만들어지는 것은 자연스러운 방향이다.

분류 체계에서 정말 중요한 것은 어떤 체계를 고르느냐보다 팀이 같은 체계를 공유하느냐다. 필자의 Atomic Design 경험도 돌아보면 방법론의 한계가 절반, 경계 판정 기준을 팀이 합의해 두지 못한 몫이 절반이었다. 어떤 분류든 경계 사례는 나오므로, 경계에서 어느 쪽으로 보낼지 정하는 기본 규칙 하나("애매하면 일단 features에 두고, 두 번째 도메인에서 필요해질 때 공용으로 승격한다" 같은)까지 정해두면 분류가 비로소 굴러간다.

컴포넌트와 비컴포넌트 사이의 긴장

여기까지는 컴포넌트 세계 안의 이야기였다. 그런데 컴포넌트 기반으로 사고한다는 것이 모든 코드를 컴포넌트에 넣는다는 뜻은 아니다. 오히려 그 반대에 가깝다. React 애플리케이션에는 컴포넌트가 아닌 것들의 자리가 분명히 있고, 무엇을 컴포넌트에 남기고 무엇을 내보낼지 정하는 일이 설계의 절반을 차지한다.

이 긴장을 실감하려면 극단을 한번 봐야 한다. 필자가 물려받아 본 코드 중에 주문 상세 모달이 하나 있었다. 파일이 2천 줄쯤 됐는데, 주문 조회 API 호출, 취소 가능 여부 판정, 환불 금액 계산, 택배사별 배송 조회 URL 조립, 그리고 화면까지 전부 그 컴포넌트 안에 있었다. 처음 만든 사람에게는 합리적인 선택이었을 것이다. 전부 주문 상세에 관한 코드니까. 문제는 이후에 온다. 환불 금액 계산이 필요한 두 번째 화면이 생기자 누군가 그 로직을 복사해 갔고, 정책이 바뀌자 한쪽만 수정됐고, 고객센터로 "화면마다 환불 금액이 다르다"는 문의가 들어왔다. 컴포넌트는 화면의 단위라서, 화면과 무관한 코드가 컴포넌트에 갇히면 화면의 수만큼 복사되기 시작한다.

그렇다고 전부 빼내면 되느냐 하면 그것도 아니다. 반대편 극단도 겪어봤다. 모든 컴포넌트에서 로직이란 로직은 전부 훅과 유틸리티로 추출하는 규칙을 세운 팀이었는데, 한 곳에서만 쓰이는 세 줄짜리 로직까지 파일이 분리되니 버튼 하나의 동작을 이해하려면 파일 네 개를 오가야 했다. 네 번째 파일을 열 때쯤이면 첫 파일에서 본 내용이 머릿속에서 증발해 있었다. 간접 계층에도 읽기 비용이 있는 것이다.

그러니 질문은 "로직을 빼야 하는가"가 아니고 "어떤 로직이 컴포넌트 밖에 살아야 하는가"다. 컴포넌트 밖으로 나가는 출구는 크게 세 개 있다.

첫 번째 출구, 훅

가장 가까운 출구는 훅이다. AddToCartButton에서 로직을 훅으로 옮겨보자.

function useAddToCart(productId) {
  const [status, setStatus] = useState("idle");

  const add = async () => {
    if (status !== "idle") return;
    setStatus("loading");
    await addCartItem(productId);
    setStatus("added");
  };

  return { status, add };
}

const AddToCartButton = ({ productId }) => {
  const { status, add } = useAddToCart(productId);

  return (
    <button
      className="add-to-cart"
      disabled={status === "loading"}
      onClick={add}
    >
      {status === "idle" && "장바구니 담기"}
      {status === "loading" && "담는 중..."}
      {status === "added" && "담김 ✓"}
    </button>
  );
};

버튼에는 이제 뷰만 남았고, 담기라는 행위는 useAddToCart가 됐다. 상품 목록의 카드에서도, 상세 페이지에서도, 최근 본 상품 목록에서도 같은 훅을 쓰면 된다. 각자 생김새는 달라도 담기의 동작은 하나다.

이 편리함이 처음부터 있었던 것은 아니다. 로직을 컴포넌트에서 분리하려는 시도는 React의 역사만큼 오래됐는데, 훅은 그 계보의 세 번째쯤 되는 답이다. 초창기의 믹스인(mixin)은 어떤 로직이 어디서 왔는지 추적할 수 없게 만들어 공식적으로 폐기됐고11, 그 뒤를 이은 고차 컴포넌트(HOC)와 render props는 각자 다른 비용을 치렀다.

// HOC 시절 — 로직이 컴포넌트를 겹겹이 감쌌다
export default withRouter(withCart(AddToCartButton));

// render props 시절 — 로직이 함수를 자식으로 받아 JSX 안에 들어왔다
<CartProvider>
  {({ status, add }) => (
    <button onClick={() => add(productId)}>장바구니 담기</button>
  )}
</CartProvider>;

HOC는 감싸는 층이 쌓일수록 이 컴포넌트의 props가 어느 래퍼에서 주입된 것인지 알 수 없어졌고, render props는 로직이 두어 개만 겹쳐도 JSX가 피라미드가 됐다. 훅은 감싸지도 않고 JSX를 차지하지도 않으면서 로직을 함수 호출 한 줄로 재사용하게 했다. 지금 useAddToCart가 당연해 보인다면 앞선 세대들이 어디가 아픈지 미리 보여준 덕분이다.

다만 훅에 대해 정확히 해둘 것이 있다. 훅은 컴포넌트 밖으로 나간 것처럼 보이지만 사실 반쯤만 나갔다. 훅은 컴포넌트의 렌더링 중에만 호출될 수 있고, 훅이 만든 상태는 여전히 그 컴포넌트 인스턴스에 매여 산다. 컴포넌트가 화면에서 사라져 언마운트되면 useAddToCartstatus도 함께 사라진다. 훅은 로직을 컴포넌트 사이에서 재사용하게 해주는 장치이지, 로직을 컴포넌트 세계에서 해방시키는 장치가 아니다.

두 번째 출구, 트리 밖의 상태

상태 끌어올리기를 계속하다 보면 어떤 상태는 결국 트리 꼭대기까지 올라간다. 로그인한 사용자, 장바구니, 알림 목록처럼 애플리케이션 전역에서 쓰이는 상태다. 꼭대기까지 올라간 상태는 사실상 컴포넌트의 소유물이 아니게 되는데, 이 사실을 인정하고 상태를 아예 트리 밖으로 내보내는 것이 Redux나 Zustand 같은 상태 관리 라이브러리다. 스토어는 컴포넌트 트리와 무관하게 존재하고, 컴포넌트는 필요한 조각을 구독할 뿐이다.

그런데 전역 상태를 자세히 들여다보면 상당수가 특이한 공통점을 갖고 있다. 진실이 클라이언트에 없다는 점이다. 장바구니의 진짜 내용은 서버 데이터베이스에 있고, 클라이언트가 들고 있는 것은 마지막으로 받아온 사본이다. 앞서 상태 끌어올리기 예제에서 setCartCount(count => count + 1)라고 어림했던 냄새가 바로 이것이었다. 담기 요청이 실은 실패했다면? 다른 탭에서 이미 상품을 담았다면? 사본을 수동으로 관리하는 코드는 이런 질문 앞에서 계속 무너진다.

그래서 서버 상태는 아예 다른 종류의 상태로 취급하는 것이 현재의 정석이다. TanStack Query(구 react-query) 같은 라이브러리는 서버 데이터의 사본을 쿼리 키로 식별되는 캐시에 보관하고, 컴포넌트는 키를 구독한다12.

function useAddToCart(productId) {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: () => addCartItem(productId),
    // 담기에 성공하면 'cart' 키의 캐시를 무효화한다. 구독자들은 새 데이터를 다시 받는다.
    onSuccess: () => queryClient.invalidateQueries({ queryKey: ["cart"] }),
  });
}

const CartBadge = () => {
  const { data: cart } = useQuery({ queryKey: ["cart"], queryFn: fetchCart });
  return <span className="badge">{cart?.items.length ?? 0}</span>;
};

여기서 눈여겨볼 것은 협력의 형태가 바뀌었다는 점이다. 상태 끌어올리기 버전에서는 ProductPage가 버튼과 배지 사이에서 중매를 섰다. 지금은 그 중매가 사라졌다. AddToCartButton은 캐시를 무효화할 뿐이고 CartBadge는 캐시를 구독할 뿐이라, 둘은 서로의 존재를 모른 채 ['cart']라는 키를 통해 협력한다. 배지가 헤더에 있든 플로팅 버튼 안에 있든, 심지어 배지가 열 개 있든 아무 코드도 바뀌지 않는다. 컴포넌트 사이의 관계였던 것이 컴포넌트와 비컴포넌트(캐시) 사이의 관계 두 개로 재편된 것이다. 앞서 "컴포넌트는 서버 상태를 반영하는 뷰"라는 시각을 소개했는데, 그 시각을 채택하면 정확히 이 구조에 도착한다.

세 번째 출구, React를 모르는 코드

세 번째 출구는 가장 멀리 있는 만큼 가장 튼튼하다. 아까 그 주문 상세 모달의 환불 금액 계산을 꺼내보자. 이 로직이 React에 대해 알아야 할 것이 하나라도 있을까. 없다. 주문 항목과 쿠폰을 받아 금액을 돌려주는 계산일 뿐이다. 그렇다면 훅일 필요도 없다. 그냥 함수면 된다.

// features/order/calculateRefund.ts — React를 모르는 파일
export function calculateRefund(order, policy) {
  const usedAmount = order.items
    .filter((item) => item.status === "delivered")
    .reduce((sum, item) => sum + item.price * item.quantity, 0);

  const fee =
    order.shippingFee > 0 && usedAmount === 0 ? policy.returnShippingFee : 0;
  return { refundAmount: order.paidAmount - usedAmount - fee, fee };
}

이렇게 내려놓은 코드에는 보상이 따른다. 첫째, 어디서든 쓸 수 있다. 주문 상세 모달에서도, 고객센터 어드민에서도, 서버의 Node 스크립트에서도 부른다. 둘째, 테스트가 쉬워진다. 렌더링도 목(mock)도 없이 입력과 출력만 검사하면 되니, 환불 정책처럼 경우의 수가 많고 틀리면 돈이 나가는 로직일수록 이 형태의 값어치가 커진다. 셋째, 오래간다. React의 API는 클래스에서 훅으로, 또 서버 컴포넌트로 계속 변해왔지만 calculateRefund 같은 함수는 그 어떤 마이그레이션에도 휩쓸리지 않는다.

어디까지 내보낼 것인가

출구가 세 개나 있으니 이제 반대 방향의 질문이 남는다. 기준은 의외로 짧게 정리된다. 화면의 형태와 함께 변하는 코드는 컴포넌트에 남고, 화면과 무관하게 변하는 코드는 밖으로 나간다.

AddToCartButton으로 점검해 보자. 버튼의 문구와 비활성화 여부는 화면의 형태 그 자체이므로 컴포넌트에 남는다. 담기의 절차(중복 요청 방지, 성공 후 캐시 무효화)는 화면이 카드든 상세든 동일하므로 훅으로 나간다. 장바구니 데이터의 사본 관리는 어느 화면의 소유도 아니므로 쿼리 캐시로 나간다. 그리고 환불 금액이나 배송비 계산 같은 도메인 규칙은 React 자체와 무관하므로 순수 함수로 나간다.

거꾸로 말하면, 아직 한 화면에서만 쓰이고 화면과 함께 변하는 코드를 미리 내보낼 필요는 없다. 두 번째 사용처가 나타나는 순간이 로직이 이사할 때다. 필자는 이것을 "로직은 초대받았을 때 나간다"라고 기억해 두고 있는데, 이 한 문장이 2천 줄 모달과 파일 네 개짜리 버튼이라는 양 극단 사이에서 균형을 잡아줬다.

덧붙여 이 긴장의 지도는 지금도 다시 그려지는 중이다. React Server Components는 컴포넌트라는 단위를 유지한 채 그 실행 위치를 서버로 옮겨서, 지금까지 "컴포넌트 밖"이었던 데이터 조회를 컴포넌트 안으로 다시 끌어들인다13. 경계선의 위치는 계속 움직이겠지만, 무엇이 화면과 함께 변하고 무엇이 무관한지 가려내는 일 자체는 어떤 아키텍처에서도 사라지지 않을 것이다.

컴포넌트 평가하기

사고의 마지막 조각은 평가다. 만든 컴포넌트가 좋은 컴포넌트인지 아닌지 판단할 기준이 없으면 리뷰는 취향 싸움이 된다. 필자는 네 가지 축으로 본다. 사회성, 신뢰성, 가독성, 의존성이다.

사회성

컴포넌트는 혼자 쓰이지 않는다. 다른 컴포넌트와 조합되고, 레이아웃 안에 배치되고, 때로는 다른 라이브러리와 협력해야 한다. 사회성은 그 조합의 자리에서 얼마나 말이 통하는가에 대한 축이다. 사회성이 낮은 컴포넌트는 이렇게 생겼다.

const Button = ({ label, onClick }) => (
  <button className="btn" onClick={onClick}>
    {label}
  </button>
);

만든 사람의 화면에서는 완벽하게 동작했을 것이다. 문제는 다음 사용자부터다. 아이콘을 넣고 싶은데 label이 문자열이라 못 넣는다. 여백을 조정하고 싶은데 className을 받지 않는다. 폼 제출용으로 쓰려는데 type="submit"을 전달할 길이 없다. 포커스를 옮기려는데 ref가 막혀 있다. 이 버튼과의 대화는 매번 "그건 안 된다"로 끝난다.

const Button = ({ className, children, ...props }) => (
  <button className={clsx("btn", className)} {...props}>
    {children}
  </button>
);

children으로 내용의 자유를, ...props 전달로 네이티브 속성 전부를, className 병합으로 스타일 협상을 열어뒀다14. 사회성의 원리는 앞서 본 분해와 정확히 이어진다. 구성 요소 중 무엇을 열어줄지 결정하는 것이 추상화라고 했는데, 사회성은 그 결정이 실제 사용처의 요구를 감당하는지에 대한 성적표다. 자신이 예상하지 못한 사용처를 위해 표준적인 통로(children, 네이티브 속성, ref)를 열어두는 것은 거의 항상 남는 투자다.

신뢰성

신뢰성은 같은 입력에 대해 같은 화면을 그리는가, 그리고 렌더링이 몇 번 일어나도 세상이 망가지지 않는가에 대한 축이다. React는 컴포넌트의 렌더링이 순수하다고, 즉 화면을 계산하기만 할 뿐 바깥 세상을 건드리지 않는다고 가정하고 최적화를 수행하기 때문에 이 축은 취향이 아니라 계약이다. 계약 위반은 이렇게 조용히 일어난다.

const OrderTable = ({ orders }) => {
  orders.sort((a, b) => b.createdAt - a.createdAt); // 부모의 배열을 그 자리에서 파괴한다

  return <table>{/* ... */}</table>;
};

sort는 원본 배열을 변경하는 메서드라서, 이 한 줄은 렌더링 도중에 부모가 소유한 데이터를 바꿔버린다. 부모는 어느 날 자기 배열의 순서가 바뀌어 있는 것을 발견하지만 범인은 자식의 렌더링 안에 숨어 있다. orders.toSorted(...) 또는 복사 후 정렬로 바꾸면 계약 위반이 사라진다. React의 StrictMode가 개발 모드에서 컴포넌트를 일부러 두 번 호출하는 것이 바로 이런 위반을 들춰내기 위해서다15. 두 번 렌더링됐을 때 결과가 달라지는 컴포넌트는 어딘가에서 계약을 어기고 있다.

이벤트 핸들러 쪽의 신뢰성도 있다. 앞서 컴포넌트는 사용자 이벤트가 시스템으로 들어오는 통로라고 했는데, 통로에는 검문이 필요하다. AddToCartButtonif (status !== 'idle') return 한 줄은 사용자가 버튼을 연타했을 때 담기 요청이 두 번 나가는 것을 막는다. 성격 급한 사용자는 통계적으로 반드시 존재하므로, 핸들러가 연속으로 불려도 안전한가는 신뢰성 점검 목록의 단골 항목이다.

여기까지가 컴포넌트 자신의 행실이었다면, 남은 하나는 남의 실패에 대한 대비다. 자신이 결백해도 자식 컴포넌트는 언제든 죽을 수 있다. 렌더링 중에 에러가 던져지면 React는 트리를 거슬러 올라가며 그 에러를 받아줄 컴포넌트를 찾고, 아무도 받지 않으면 화면 전체를 내려버린다. 이 에러를 받아내는 컴포넌트가 Error Boundary16. 주문 상세 화면의 추천 상품 위젯이 죽었다고 결제 영역까지 백지가 될 이유는 없으므로, 위험한 위젯은 경계로 감싸서 실패를 그 안에 가둔다.

<ErrorBoundary fallback={<p>추천 상품을 불러오지 못했다</p>}>
  <Suspense fallback={<WidgetSkeleton />}>
    <RecommendedProducts productId={product.id} />
  </Suspense>
</ErrorBoundary>

Suspense는 같은 생각을 실패 대신 대기에 적용한 것이다. 자식이 "아직 준비되지 않았다"는 신호를 위로 올리면 가장 가까운 Suspense가 대신 스켈레톤을 보여준다(그러려면 자식이 React.lazy나 Suspense를 지원하는 데이터 페칭처럼 그 신호를 낼 줄 알아야 한다). 앞서 컴포넌트를 바라보는 시각 중에 "실패와 대기를 가두는 경계"가 있었는데, 이 시각으로 화면을 보면 컴포넌트 트리는 UI의 구조이면서 동시에 장애 격리의 구조가 된다.

가독성

컴포넌트는 쓰는 시간보다 읽히는 시간이 길다. 가독성 축에서 가장 먼저 볼 것은 이름과 props가 만드는 첫인상이다. 컴포넌트의 이름은 역할을 말해야 하고, props 목록은 그 컴포넌트와 맺을 계약서로 읽혀야 한다. 계약서가 이렇게 생겼다면 곤란하다.

<OrderBadge isPending isCancelled={false} isRefunded hideIcon small />

boolean props가 하나 늘 때마다 상태 공간이 두 배가 되는 것은 컴포넌트 내부만의 문제가 아니다. 사용하는 쪽도 isPendingisRefunded가 동시에 참이면 어떻게 되는지 소스를 열어봐야 알 수 있다. 앞서 상태 머신 시각에서 했던 이야기가 인터페이스에서 반복되는 것인데, 해법도 같다. 서로 배타적인 값들은 하나의 축으로 묶는다.

<OrderBadge status="refunded" size="sm" showIcon={false} />

내부 구현의 가독성은 대체로 이 글에서 이미 다룬 것들의 결과로 따라온다. 파생 값을 상태로 만들지 않았다면 상태 선언이 적어서 읽기 쉽고, 불가능한 상태를 제거했다면 방어 코드가 없어서 읽기 쉽고, 화면과 무관한 로직을 내보냈다면 남은 코드가 전부 화면 이야기라서 읽기 쉽다. 앞선 결정들을 잘 내렸다면 가독성은 대체로 따라오는 것이다.

의존성

네 번째 축은 이 컴포넌트가 무엇을 아는가다. 편리하게도 컴포넌트의 의존성은 파일 맨 위에 자백되어 있다. import 문이 곧 진단서다.

// components/Button.tsx 라는 파일인데...
import { useCartStore } from "@/stores/cart"; // 장바구니를 알고
import { useRouter } from "next/router"; // 라우터를 알고
import { trackEvent } from "@/analytics"; // 분석 도구까지 안다

공용이어야 할 버튼이 장바구니 스토어를 아는 순간, 이 버튼은 장바구니가 없는 화면에서 쓸 수 없고, 스토어 없이는 테스트할 수 없고, 다른 프로젝트로 가져갈 수 없다. 아는 것이 많은 컴포넌트일수록 운신의 폭이 좁아진다. 분류 장에서 세운 규칙이 여기서 평가 기준으로 다시 쓰인다. 트리 아래쪽의 공용 부품일수록 아는 것이 적어야 하고, 도메인 지식은 features 층에, 화면 배치와 라우팅 지식은 pages 층에 있어야 한다. 리뷰에서 컴포넌트를 열면 본문보다 import 목록을 먼저 훑는 습관을 들이면, 이 축의 문제는 대부분 30초 안에 드러난다.

네 축을 관통하는 공통점이 보일 것이다. 사회성은 열어둔 것의 목록이고, 의존성은 아는 것의 목록이고, 신뢰성은 지키는 계약의 목록이고, 가독성은 그 목록들이 겉으로 드러난 모습이다.

마치며

글을 정리하자. 컴포넌트는 UI 조각이기 이전에 조합 가능한 부품이고(정의), 뷰·데이터·로직·정체성으로 분해되며(분해), 목적에 따라 무엇을 열어줄지 결정하는 것이 추상화다. 부품들은 단방향 흐름과 합성과 Context로 협력하고(관계), 아는 것의 양에 따라 층으로 나뉘고(분류), 화면과 무관한 코드는 훅과 스토어와 순수 함수라는 출구로 내보내며(긴장), 사회성·신뢰성·가독성·의존성으로 평가된다(평가).

이 글을 시리즈의 다른 글보다 나중에 쓰게 됐지만, 내용상으로는 가장 앞에 놓일 글이다. Polymorphic도 Render Delegation도 Headless도 결국 "구성 요소 중 무엇을 열어줄 것인가"라는 같은 질문에 대한 서로 다른 답이었다는 것을, 시리즈를 다 쓰고 나서야 이렇게 묶어 말할 수 있게 됐다.

마지막으로 실용적인 제안 하나를 남긴다. 사고 방식은 실제 구현에 큰 영향을 미치고, 명확한 공통 기준은 팀의 소통 비용을 줄인다. 컴포넌트는 추상적이면서 구체적인 개념이라 같은 요구사항에서도 굉장히 다양한 구현이 나오는데, 그 다양성이 축복이 되려면 팀이 공유하는 기준이 있어야 한다. 꼭 이 글의 분류와 평가 축이 아니어도 좋으니, 자기 팀의 컴포넌트 기준을 세우고 문서로 남겨 공유하는 것부터 시작해 보길 권한다.

Footnotes

  1. 1968년 NATO 소프트웨어 공학 컨퍼런스에서 Douglas McIlroy가 발표한 "Mass Produced Software Components"까지 거슬러 올라가는 개념이다. 하드웨어처럼 규격화된 부품을 조립해 소프트웨어를 만들자는 제안이었다.

  2. JavaScript 안에 HTML처럼 생긴 문법을 쓸 수 있게 해주는 확장 문법이다. 빌드 도구가 <Greeting name="React" />jsx(Greeting, { name: 'React' }) 같은 함수 호출로 변환하며, 이 함수가 반환하는 것이 엘리먼트 객체다.

  3. Fiber는 React 16부터 도입된 내부 재조정(reconciliation) 엔진의 작업 단위로, 컴포넌트별 상태와 훅 목록이 여기에 매달려 보관된다. 공식 문서가 쓰는 표현은 아니지만 "함수 컴포넌트의 인스턴스"에 해당하는 실체를 찾자면 이것이다.

  4. 이때의 트리는 컴포넌트 트리지 DOM 트리가 아니다. createPortal을 쓰면 모달을 document.body 바로 밑에 그리면서도 컴포넌트로서는 원래 위치에 남는다. 그래서 Portal 너머에서도 Context가 그대로 이어지고, React의 합성 이벤트도 DOM이 아니라 컴포넌트 트리를 따라 버블링된다.

  5. 상태의 개수가 유한하고, 사건(event)에 따라 상태 사이를 옮겨 다니는 계산 모델이다. 자판기(동전 투입 → 상품 선택 가능)나 신호등이 교과서 단골 예시다. 이 시각을 본격적으로 채용한 라이브러리로 XState가 있고, Headless 글에서 소개한 Zag.js도 컴포넌트 로직을 상태 머신으로 정의한다.

  6. 눈치챘을지 모르지만 이 버튼에는 실패 상태가 없다. addCartItem이 실패하면 loading에 갇혀버리므로 실무라면 failed 상태와 재시도 전이를 더해야 한다. 상태 머신 시각의 좋은 점이 바로 이것인데, 상태와 전이를 표로 그려보면 이렇게 빠진 경로가 눈에 보인다.

  7. AngularJS(1.x)의 양방향 바인딩이 대표적이다. 화면과 모델이 서로를 자동으로 갱신해 주는 편리함 뒤에, 값 하나의 변경이 연쇄를 타고 어디까지 퍼지는지 추적하기 어렵다는 비용이 있었다. React의 단방향 흐름은 이에 대한 명시적인 반대 제안이었다.

  8. 정확히는 useContext로 그 Context를 구독한 컴포넌트들이 다시 렌더링된다. 값의 일부만 쓰는 컴포넌트도 전체 값 변경에 반응하므로, 자주 바뀌는 값과 안정적인 값을 한 Context에 섞으면 낭비가 커진다. 이 때문에 상태용과 디스패치용 Context를 분리하는 관례도 있다.

  9. Presentational and Container Components(2015). 글 상단에 2019년에 추가된 주석이 있는데, 훅으로 같은 분리를 인위적인 컴포넌트 분할 없이 할 수 있으니 이 구분을 강제하지 말라고 직접 밝히고 있다.

  10. Brad Frost가 제안한 디자인 시스템 방법론으로, 화학의 위계를 빌려 UI를 다섯 단계로 나눈다. 디자이너와 개발자가 같은 어휘를 쓰게 해준 공은 분명하고 디자인 시스템의 어휘로는 지금도 이름이 남아 있지만, 코드 폴더 구조의 기준으로 삼는 팀은 이제 드물다.

  11. React 팀이 2016년 공식 블로그에 Mixins Considered Harmful을 올리며 정리했다. 여러 믹스인이 같은 상태와 메서드를 건드리면서 이름 충돌과 암묵적 의존이 쌓이는 것이 주된 이유였다.

  12. 쿼리 키는 서버 데이터의 주소 역할을 하는 값이다. 같은 키로 useQuery를 부르는 컴포넌트들은 같은 캐시 항목을 공유하고, 그 키가 무효화되면 함께 새 데이터를 받는다. 캐싱, 재요청, 로딩 상태 관리까지 라이브러리가 대신해 준다.

  13. 서버에서만 실행되고 그 결과만 클라이언트로 전송되는 컴포넌트다. 데이터베이스 조회 코드가 컴포넌트 본문에 직접 들어갈 수 있게 된다. Next.js App Router가 이 모델을 채용하고 있다.

  14. clsx는 클래스 이름을 조건부로 이어 붙여주는 작은 유틸리티다. ref까지 열려면 예전에는 forwardRef로 감싸야 했는데, React 19부터는 ref가 일반 prop으로 전달되어 이 절차가 사라졌다.

  15. 개발 모드의 StrictMode는 렌더링 함수를 두 번 호출하고 이펙트를 두 번 실행해서, 순수하지 않은 코드가 만드는 차이를 눈에 띄게 만든다. 프로덕션 빌드에서는 아무것도 하지 않으므로 성능 걱정은 하지 않아도 된다.

  16. 에러 경계는 아직 클래스 컴포넌트로만 만들 수 있어서(getDerivedStateFromError), 실무에서는 이를 감싼 react-error-boundary 라이브러리를 흔히 쓴다. 예제의 ErrorBoundary도 그것이다. 참고로 경계가 잡는 것은 렌더링 중의 에러이고, 이벤트 핸들러 안의 에러는 각자 try/catch로 처리해야 한다.