너굴 개발 일지

[React] 변경에 유연한 Headless 컴포넌트 만들기 본문

React

[React] 변경에 유연한 Headless 컴포넌트 만들기

너굴냥 2024. 1. 16. 21:05

변경에 유연한 컴포넌트를 만들기 위한 방법 중 하나인 Headless 컴포넌트에 대해 정리한 글입니다.

목차

서론

본론

결론


컴포넌트란

일반적으로 기능적 단위로 각각의 독립된 모듈을 의미하며 프론트엔드 관점에서는 화면을 구성하는 UI 요소다.

모듈
일반적으로 프로그램을 구성하는 요소로 관련 데이터와 함수를 하나로 묶은 단위를 의미한다.

 

프론트엔드 관점에서 컴포넌트란

과거에는 서버로부터 HTML 리소스를 받아와 사용자에게 보여주고 사용자 인터렉션이 발생하면 새로운 HTML을 매번 받아와 클라이언트에게 제공하는 방식이었다. 페이지의 일부만 변경하고 싶어도 전체 페이지를 새로 내려줘야 했기에 화면 깜빡임으로 인한 사용자 경험 감소, 코드 재사용과 같은 문제점이 존재했다.

하지만 1990년대 후반에 AJAX가 등장하면서 서버에서 받아온 JSON 데이터와 자바스크립트를 이용해 HTML 일부만 수정할 수 있게 되면서 웹페이지를 하나의 덩어리가 아닌 작은 단위로 나눌 수 있게 되었다. 여기서 UI의 부분 요소를 컴포넌트라고 부르며 오늘날 컴포넌트 단위로 개발할 수 있게 되었다.

 

컴포넌트를 잘 만들어야 하는 이유

소프트웨어는 정적이지 않고 요구사항에 따라 계속 변화한다. 하지만 복잡한 컴포넌트는 작은 변경사항에도 빠르게 대처하기 어렵고 사이드 이펙트가 발생할 수 있기에 수정하기 쉽지 않다.

따라서 컴포넌트의 관심사 분리와 재사용성 있는 컴포넌트를 만들어 변경에 빠르게 대응할 수 있어야 한다.

재사용성 있는 컴포넌트를 만드는 방법은 다양하며 그 중 하나인 Headless 컴포넌트에 대해 알아본다.

 

Headless 컴포넌트

Headless란 말그대로 머리가 없다는 뜻으로 Head는 컨텐츠를 보여주는 방법, Body는 컨텐츠 자체를 의미한다.

Headless Architecture는 프론트엔드(UI), 백엔드(비즈니스 로직)가 분리된 소프트웨어 구조를 의미한다. 백엔드는 비즈니스 로직만을 담당하며 API를 통해 프론트엔드와 연결되기에 프론트엔드의 유연성이 커지고 다양한 디바이스 지원, 확장성과 같은 장점이 존재한다.

프론트엔드 관점에서 HeadUI 로직, Body데이터(상태 관리)를 의미하며 데이터를 다루는 로직 컴포넌트와 UI 로직(마크업) 컴포넌트로 관심사를 분리해 Headless한 컴포넌트로 만들 수 있다.

 

Headless 컴포넌트가 필요한 이유

사용자의 입력값을 받아서 보여줘야 하는 요구사항이 있다고 가정했을 때 아래처럼 Input 컴포넌트를 개발할 수 있다.

const Input = () => {
  const [value, setValue] = useState("");
  const onChangeValue = (e: React.ChangeEvent<HTMLInputElement>) => {
    setValue(e.target.value);
  };

  return (
    <div>
      <label htmlFor="name">Name</label>
      <input type="text" value={value} onChange={onChangeValue} id="name" />
    </div>
  );
};

function App() {
  return <Input />;
}

그런데 아래 사진처럼 디자인이 다른 수십 개의 Input 컴포넌트를 만들어야 하는 변경을 요구한다면 어떻게 할 것인가?

위 예시의 Input 컴포넌트는 데이터를 다루는 로직과 UI 로직이 함께 있기 때문에 재사용성이 떨어진다. 그렇다면 매번 다른 Input 컴포넌트를 만들어야 할까?

위 사진은 UI가 전부 다른 Input 컴포넌트이지만 사용자가 입력한 값을 보여주는 역할은 동일하다. 그렇다면 데이터를 다루는 로직과 UI 로직으로 관심사를 분리해 데이터 로직 담당 컴포넌트를 재사용하면 되지 않을까? 이럴 때 사용되는 것이 Headless 컴포넌트다.

 

Headless 컴포넌트 설계 방법

위 Input 컴포넌트의 문제는 하나의 컴포넌트에 두 가지 관심사(데이터 로직, UI)가 존재한다는 것이다.

따라서 데이터 로직만을 추출하기 위해선 컴포넌트가 무엇을 수행하는지 먼저 정의해야 한다. 그리고 사용자가 사용할 수 있는 기능들과 방법을 제공해야 한다. 이후에 그 기능을 어떻게 수행할 지 구현하면 된다.

여기서 중요한 점은 기능은 어떻게 구현할지 컴포넌트 내부에 정의하는 것으로, 외부의 다른 컴포넌트들이나 사용자가 전혀 알지 않아도 된다.

Input 컴포넌트를 기준으로 컴포넌트 역할과 사용자가 사용할 수 있는 기능은 아래처럼 정의할 수 있다.

Input 컴포넌트 역할

  • 상태에 대한 라벨(설명)이 있다.
  • 입력된 값을 보여준다.

사용자가 사용할 수 있는 기능

  • 키보드를 통해 값을 입력한다.

 

Headless 컴포넌트로 리팩토링하기

Headless한 컴포넌트를 만드는 방식은 다양하며 그 중 세 가지 방식에 대해 알아보자.

1. Compound Component

Compound 컴포넌트란 같이 사용되는 컴포넌트들의 상태를 공유할 수 있게 해주는 패턴으로 아래는 해당 패턴이 적용된 Input 컴포넌트다.

import React, { createContext, useContext } from "react";

type InputProps = {
  type: string;
  id: string;
  value: string;
  onChangeValue: (e: React.ChangeEvent<HTMLInputElement>) => void;
};

type InputContextProps = InputProps & {
  children: React.ReactNode;
};

const InputContext = createContext<InputProps>({
  type: "",
  id: "",
  value: "",
  onChangeValue: () => {},
});

export const InputWrapper = ({
  type,
  id,
  value,
  onChangeValue,
  children,
}: InputContextProps) => {
  const contextValue = { type, id, value, onChangeValue };
  return (
    <InputContext.Provider value={contextValue}>
      {children}
    </InputContext.Provider>
  );
};

const Input = () => {
  const { type, id, value, onChangeValue } = useContext(InputContext);
  return <input type={type} id={id} value={value} onChange={onChangeValue} />;
};
const Label = ({ children }: { children: React.ReactNode }) => {
  const { id } = useContext(InputContext);
  return <label htmlFor={id}>{children}</label>;
};

InputWrapper.Input = Input;
InputWrapper.Label = Label;
function App() {
  const [value, setValue] = useState("");
  const onChangeValue = (e: React.ChangeEvent<HTMLInputElement>) => {
    setValue(e.target.value);
  };
  return (
    <InputWrapper
      id="name"
      type="text"
      value={value}
      onChangeValue={onChangeValue}
    >
      <InputWrapper.Label>Name</InputWrapper.Label>
      <InputWrapper.Input />
    </InputWrapper>
  );
}

 

Context API를 통해 컴포넌트 내부에서 공유될 상태(데이터)를 정의한다. 그리고 InputWrapper라는 부모 컴포넌트를 생성해 Context API를 공유한다.

InputLabel은 자식 컴포넌트로 Context API를 통해 필요한 상태를 공유 받는다.

컴포넌트 내부에서 state를 공유하기 위해 작성해야 하는 코드가 많지만 컴포넌트를 사용하는 곳에서는 InputWrapper 컴포넌트 하위에 어떤 컴포넌트가 있는지 자볼 수 있고, 위치를 자유롭게 수정할 수 있다는 장점이 있다.

2. Function as Children Component

말 그대로 자식 컴포넌트로 함수를 사용한다는 것을 의미한다. 자식에 어떤 것이 들어올지 예상할 수 없기에 children prop으로 받아 그대로 전달하는 방식이다.

import { useState } from "react";

type InputHeadlessProps = {
  value: string;
  onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
};

type InputProps = {
  children: (args: InputHeadlessProps) => JSX.Element;
};
export const InputHeadless = ({ children }: InputProps) => {
  const [value, setValue] = useState("");
  const onChangeValue = (e: React.ChangeEvent<HTMLInputElement>) => {
    setValue(e.target.value);
  };

  if (!children || typeof children !== "function") return null;
  return children({ value, onChange: onChangeValue });
};
function App() {
  return (
    <div>
      <InputHeadless>
        {({ value, onChange }) => {
          return (
            <div>
              <label htmlFor="name">Name</label>
              <input type="text" value={value} onChange={onChange} id="name" />
            </div>
          );
        }}
      </InputHeadless>
    </div>
  );
}

Compound 컴포넌트보다 코드량이 적으며 사용하려는 state 값을 위에서 따로 선언할 필요가 없어, 다른 컴포넌트에 해당 state를 실수로 넣을 일이 적어진다. 그리고 관련 코드가 한 곳에 모여 있어 읽기 편하다.

하지만 다른 곳에서 state를 공유해야 한다면 InputHeadless 컴포넌트가 감싸야 할 코드량이 많아지는 단점이 존재한다.

3. Custom Hook

Hook은 함수 컴포넌트에서 React state와 생명주기 기능을 연동(hook into)할 수 있게 해주는 함수다. 데이터 로직을 재사용하기 위해 Custom Hook을 직접 만들어 사용할 수 있다.

import { useState } from "react";

export const useInput = () => {
  const [value, setValue] = useState("");
  const onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    setValue(e.target.value);
  };
  return { value, onChange };
};
function App() {
  const { value: name, onChange: onChangeName } = useInput();
  return (
    <div>
      <label htmlFor="name">Name</label>
      <input type="text" value={name} onChange={onChangeName} id="name" />
    </div>
  );
}

위 두 가지 방식보다 간단하고 직관적이라는 장점이 있지만 state 값을 Input이 아닌 다른 곳에 작성할 실수가 발생할 수 있다.

 

Headless 컴포넌트 장단점

위 예시를 통해 Headless 컴포넌트로 구현하는 방법을 살펴봤다. 장점이 많아보여도 모든 컴포넌트를 Headless하게 구현하는 것은 정답이 아니며 Headless하게 구현할 경우 고려할 사항들도 존재한다.

아래는 Headless 컴포넌트의 장점과 고려사항이다.

Headless 장점

  • 재사용성
    • Headless 컴포넌트는 스타일이 없고 로직만 존재하는 것을 뜻한다. DRY(Dont Repeat Yourself) 원칙을 준수해 여러 컴포넌트가 공유할 수 있는 로직을 캡슐화하므로 재사용성할 수 있으며, 애플리케이션 전반에 걸쳐 일관성을 강화한다.
  • 관심사 분리
    • 로직과 UI가 명확히 분리된다. 이로 인해 코드베이스를 관리하기 쉬워진다.
  • 유연성
    • UI에 구애받지 않기에 디자인 유연성을 높일 수 있으며 기본 로직에 영향을 주지 않고 다양한 UI를 정의할 수 있다.
  • 테스트 가능성
    • UI와 로직의 분리로 비즈니스 로직에 대한 단위 테스트 작성이 더 쉽다.

고려 사항

  • 초기 부담
    • 단순한 애플리케이션 또는 컴포넌트라면 헤드리스 컴포넌트를 생성하는 것이 오버 엔지니어링처럼 느껴져 불필요한 복잡성을 초래할 수 있다.
  • 학습 곡선
    • 해당 개념에 익숙하지 않다면 처음엔 이해하기 어렵고 학습 곡선이 더 가파르게 느껴질 수 있다.
  • 남용 가능성
    • 모든 컴포넌트를 Headless로 만드려 하면, 과도하게 사용해 코드베이스가 복잡해질 수 있다.

 

Headless UI 라이브러리가 무조건 정답인가?

지금까지 Headless 컴포넌트에 대해 알아봤다. Headless 컴포넌트를 직접 구현할 수 있지만 Headless 기반의 UI 라이브러리들을 사용할 수도 있다. 하지만 Headless 기반의 UI 라이브러리가 항상 정답은 아니며 상황에 맞게 사용해야 한다.

Headless UI 컴포넌트와 상반되는 Components 기반의 UI 라이브러리와 비교하며 알아보자.

  • Compoenents 기반 UI 라이브러리
    • 기능 + 스타일이 공존하는 라이브러리 (Material UI, Ant Design)
    • 장점
      • 바로 사용할 수 있는 마크업, 스타일이 존재한다.
      • 설정이 거의 필요 없다.
    • 단점
      • 마크업을 자유롭게 사용할 수 있다.
      • 스타일을 대부분 라이브러리의 테마 기반으로만 변경할 수 있어 한정적이다.
      • 큰 번들 사이즈
    • 언제 사용?
      • 빠르게 서비스를 제공해야 하는 경우
      • 디자인이 크게 중요하지 않고 커스터마이징이 별로 필요하지 않은 경우
  • Headless 기반 UI 라이브러리
    • 기능은 있지만 스타일이 없는 라이브러리 (Headless UI, Radix UI, Reach UI)
    • 장점
      • 개발자가 마크업과 스타일을 완벽하게 제어할 수 있다.
      • 모든 스타일링 패턴을 지원한다. (CSS, CSS-in-JS 등)
      • 작은 번들 사이즈
    • 단점
      • 추가 설정이 필요하다.
      • 스타일 렌더링 로직들을 스스로 구현해야 하기에 UX에 관한 결정을 내려야 한다.
    • 언제 사용?
      • 특별한 유즈케이스, 커스텀 디자인을 구현해야 하는 경우

 

결론

결론적으로 Headless 컴포넌트는 변경에 쉽게 대응하기 위해 나온 개념이다. UI와 데이터 로직이라는 두 가지 관심사가 분리되어 UI 변경에 유연하게 대응할 수 있고, 공통 로직을 재사용하기에도 용이하다.

변경에 유연하게 대응하기 위해선 해당 컴포넌트가 하는 일을 잘 파악하고, 내부와 외부를 완전히 분리해야 한다. 외부가 변경되어도 내부에는 영향을 미치면 안 되고, 내부가 수정되어도 외부는 영향을 받아서는 안된다.

 


참고 자료

Headless Component: React UI를 구성하기 위한 패턴

Decoupling UI and Logic in React: A Clean Code Approach with Headless Components

Headless UI

Headless components in React and why I stopped using a UI library for our design system