Skip to main content

Command Palette

Search for a command to run...

[Series. 2] Headless Component 패턴

싹뚝...

Updated
4 min read
[Series. 2] Headless Component 패턴


어제보다 더 나은 서비스를 만들어내는 사람이 되고자 노력하며, 내일의 나를 위해 기록합니다.

회고 모임을 진행하면서 헤드리스 컴포넌트에 대한 언급이 왕왕 있었다. 헤드리스 서버, 헤드리스 CMS에 대해서는 알고 있었지만 헤드리스 컴포넌트라니? 그게 뭘까?

그런 이유로 리액트 컴포넌트 설계 공부 두번째는 헤드리스 컴포넌트를 다루어보아야 겠다고 생각했다. 먼저 헤드리스 UI의 기원부터 찾아봤다.

Headless UI의 개념의 시작

헤드리스 브라우저와 헤드리스 CMS에서 영감을 받아 2017년 경부터 본격적으로 논의되었다. 2017년 Headless UI를 처음으로 상세히 설명한 게시글 링크

[글의 서두에는 이런 정의가 작성되어있다.]
헤드리스 사용자 인터페이스 구성 요소는 인터페이스를 제공하지 않음으로써 최대한의 시각적 유연성을 제공하는 구성요소입니다.

헤드리스(Headless)는 뭘까?

헤드리스의 용어 유례는 "GUI" 없이 동작하는 서버를 헤드리스 서버라고 부르는 것에서 시작되었는데 시각적인 부분 없이 기능만 존재하는 것을 지칭한다.

  • Headless Browser : 화면 출력 없이 브라우저의 기능만 제공하는 웹브라우저 (예시 : Headless Chrome 등)

  • Headless CMS : 관리자 화면 없이 API만 제공하는 콘텐츠 관리 시스템 (예시 : Sanity 등)

  • Headless Component : UI 없이 로직만 제공하는 컴포넌트

즉, 헤드리스에서의 헤드는 시각적인 부분을 의미한다.

왜 헤드리스를 도입할까?

  1. 관심사의 분리

    • 로직과 UI를 완벽히 분리

    • 각 부분을 독립적으로 테스트하고 유지보수 가능

  2. 제어의 역전

    • UI 결정권을 사용하는 쪽으로 위임

    • 재사용성과 유연성 극대화

  3. 합성 가능성

    • 작은 단위의 기능을 조합해 복잡한 기능 구현

위 3가지가 일반적으로 얘기되는 Headless Component의 핵심 컨셉이다. 눈에 띄는 것은 제어의 역전인데 UI 결정권을 사용하는 쪽으로 위임한다는 컨셉이다.

Headless Component는 UI에 관한 그 어떤 결정권도 없다. 그저 순수 로직을 작성하고 추후 작성될 UI에 주입될 뿐이다.

const HeadlessDropdown = ({ children }) => {
    const [isOpen, setIsOpen] = useState(false); 
    // 로직만 제공하고 UI 결정권은 사용하는 쪽으로 넘김 
    return children({ 
        isOpen,
        setIsOpen,
        toggle: () => setIsOpen(!isOpen) }) 
    }
}

만약 MUI 였다면 UI는 특정되어 있으며 사용자의 경우 원하지 않는 디자인이더라도 필요한 기능을 활용하기 위해 울며 겨자 먹기로 사용해야한다.

하지만 Headless Component로 구성된 라이브러리라면 원하는 기능은 활용하되 UI는 사용자가 입맛대로 구성할 수 있다는 점이 가장 큰 장점이다.

[💡Tip : Opinionated UI Components]
MUI, Chakra UI, Ant Design과 같이 UI가 이미 정의되어 있고 스타일링이 포함된 컴포넌트 라이브러리를 Opinionated UI Components 라고 한다.

Headless Component 구현

우선 Headless Component의 반대격인 Opinionated UI Components의 예시를 먼저 살펴보자.

Opinionated UI Components 의 대표격, MUI 예시

// MUI에서 제공하는 카드 컴포넌트 예시
<Card>
  <CardMedia
    component="img"
    alt="Yosemite National Park"
    image="/static/images/cards/yosemite.jpeg"
  />
  <Stack direction="row" alignItems="center" spacing={3} p={2} useFlexGap>
    <Stack direction="column" spacing={0.5} useFlexGap>
      <Typography>Yosemite National Park, California, USA</Typography>
      <Stack direction="row" spacing={1} useFlexGap>
        <Chip
          size="small"
          label={active ? 'Active' : 'Inactive'}
          color={active ? 'success' : 'default'}
        />
        <Rating defaultValue={4} size="small" />
      </Stack>
    </Stack>
    <Switch checked={active} />
  </Stack>
</Card>

아래는 위 예시 코드에 대해 구현된 UI이다. MUI를 사용하면 이미 만들어진 UI를 기준으로 커스텀해 활용하기 때문에 Material Design을 기반해 디자인된 프로덕트가 아니라면 개발을 오히려 지연시킬 수 있다.

반대로 구체적으로 마련된 디자인이 없거나 Material Design 기반이라면 MUI를 활용하는 것이 좋다. 디자인 시스템을 별도로 구축할 필요가 없기 때문에 개발 속도가 더욱 빨라진다.

Headless Component 라이브러리 1 : Radix UI

// Radix UI 공식문서에 작성되어있는 Alert Dialog 
<AlertDialog.Root>
    <AlertDialog.Trigger>
        <Button color="red">Revoke access</Button>
    </AlertDialog.Trigger>
    <AlertDialog.Content maxWidth="450px">
        <AlertDialog.Title>Revoke access</AlertDialog.Title>
        <AlertDialog.Description size="2">
            Desciption
        </AlertDialog.Description>

        <Flex gap="3" mt="4" justify="end">
            <AlertDialog.Cancel>
                <Button variant="soft" color="gray">
                    Cancel
                </Button>
            </AlertDialog.Cancel>
            <AlertDialog.Action>
                <Button variant="solid" color="red">
                    Revoke access
                </Button>
            </AlertDialog.Action>
        </Flex>
    </AlertDialog.Content>
</AlertDialog.Root>

Radix는 서브컴포넌트를 활용해 컴포넌트를 모듈화하고 역할을 명확히 한다. 각 컴포넌트 마다 제공하는 서브컴포넌트는 상이하다.

위 예시인 AlertDialog를 기준으로 설명하자면

  • <AlertDialog.Trigger/> : Dialog를 열 Trigge로서 활용되는 컴포넌트

  • <AlertDialog.Content/> : Content는 Dialog 컴포넌트를 감싸는 박스

  • <AlertDialog.Title/> : Dialog의 타이틀

  • <AlertDialog.Description/> : Dialog의 설명

  • <AlertDialog.Cancel/> : 취소버튼

  • <AlertDialog.Action/> : 실행버튼

으로 구성되어 있으며 이들 컴포넌트는 기본적으로 스타일링이 되어있지 않지만 Radix UI의 경우 Tailwind CSS를 활용해 스타일링 하게 되어 있다. (위 예시도 동일)

레이아웃을 위한 컴포넌트인 <Flex> 컴포넌트도 제공한다.

아래는 <AlertDialog.Trigger/> 로 작성된 부분.

아래는 Dialog

Headless Component 라이브러리 2 : Headless UI

import { Menu, MenuButton, MenuItem, MenuItems } from '@headlessui/react' 

// 아래의 컴포넌트를 보면 Example 내에 로직이 없으나 Menu의 기능을 수행한다.
// Menu, MenuButton 등 Headless Component 내부에 로직은 구현되어있다.
function Example() { 
    return ( 
        <Menu> 
            <MenuButton>My account</MenuButton> 
            <MenuItems anchor="bottom"> 
                <MenuItem> 
                    <a className="block data-[focus]:bg-blue-100" href="/settings"> 
                        Settings 
                    </a> 
                </MenuItem> 
                <MenuItem> 
                    <a className="block data-[focus]:bg-blue-100" href="/support"> 
                        Support 
                    </a> 
                </MenuItem> 
                <MenuItem> 
                    <a className="block data-[focus]:bg-blue-100" href="/license"> 
                        License 
                    </a> 
                </MenuItem> 
            </MenuItems> 
        </Menu> 
    ) 
}

Headless UI는 Tailwind css를 만든 Tailwind Labs의 Headless Component 라이브러리다.

사용법 자체는 Radix와 큰 차이는 없지만

  1. Tailwind CSS에 대해 더 친화적이며, 다른 스타일링 도구와의 호환성은 좋지 못하다.

  2. 상대적으로 더 적은 수의 기본 컴포넌트가 제공된다.

  3. 서브컴포넌트도 Radix에 비해 더 적은 수를 지원한다. 더 단순하고 직관적이나 복잡한 구현에는 맞지 않을 수 있다.

정도의 차이가 있다.

Headless 라이브러리 없이 구현된 Headless Component

const HeadlessCompoent = ({options, children}) => {
    // 헤드리스 컴포넌트 내부에서는 open과 setOpen을 관리
    // UI와 무관한 순수 로직만을 담당
    const [open, setOpen] = useState(false);

    // children prop으로 받은 함수에 상태와 데이터를 객체로 전달
    // 내부 로직(open, setOpen)과 prop으로 전달받는 데이터를 객체로 묶어 전달
    return children({
        open,
        setOpen,
        options,
    })
}

// 헤드리스 컴포넌트로부터 필요한 상태와 함수를 props로 전달 받게 됨
const ChildComponent = ({open, setOpen, options}) => (
    return (
        <div>
            {/*UI*/}
        </div>
    )
)

// 사용
// HeadlessComponent에 option으로 데이터를 전달하고
// render prop으로 ChildComponent에 필요한 props를 전달한다.
<HeadlessComponent options={options}>
    render={(props) => <ChildComponent {...props}/>}
</HeadlessComponent>

마치며...

지난 번에 작성한 포스트의 서브컴포넌트는 이곳에도 쓰이고 있었다. 확실히 유기적으로 모든 것이 연결되어있음을 느낀다. 아울러 Headless Component를 깊진 않아도 조금이나마 개념은 이해할 수 있는 좋은 시간이었다.

Frontend Tech

Part 2 of 3

회고와 기술을 분리해 작성하고자 합니다. 리액트를 중심으로 Front-End 관련 기술 아티클을 작성합니다.

Up next

[Series. 1] React 서브 컴포넌트와 Compound Component 패턴

더 다양한 방식의 리액트 컴포넌트 설계를 하기 위해 공부해봅니다.