프론트엔드에서 Headless가 어떤 의미를 뜻할까? 먼저 프론트엔드 개발을 하면서 느껴봤을 법한 일을 떠올려보자.
외부 UI 라이브러리를 사용할 경우, 유스케이스에 맞게 기능을 새로 추가하거나 변경하고 싶어도 그에 맞게 디자인이나 기능을 수정하기가 매우 어렵다.
더 나아가 해당 라이브러리에 심각한 버그가 있거나, 유지보수를 종료한다고 하면 언젠가는 바꿔야 한다. 그러다 결국 ‘그냥 컴포넌트를 만들까?‘라는 생각을 문뜩 들게 한다.
그래서 나온 개념이 Headless UI Component로 기능은 있지만, 스타일이 없는 컴포넌트를 의미한다.
물론 언제나 Headless 라이브러리가 컴포넌트 기반의 라이브러리보다 좋다는 건 아니다. 모두 장단점이 존재하며, 상황에 맞게 사용하면 된다.
Component 기반 UI 라이브러리는 기능과 스타일이 존재하는 라이브러리를 말하며, 대표적으로 Material UI, Ant Design가 있다.
Headless는 기능은 있지만 스타일이 없는 라이브러리로, Headless UI, Radix UI, Reach UI 등이 있다.
디자인이 그렇게 중요하지 않고, 커스텀할 곳이 많지 않다면 Component 기반 라이브러리를 사용하면 된다. 하지만 만약 반응형에 따라 디자인이 달라지고, 기능 변경이나 추가가 많이 발생한다면 Headless 라이브러리가 유지보수에 더 좋을 것 같다.
하지만 아직 Headless 라이브러리에는 컴포넌트의 종류가 상대적으로 적다. 그래서 바로 사용해야 하는 컴포넌트가 없다면 만들어야 한다.
사실 Headless Component를 만드는 원칙이라기 보다는, 유지보수 하기 좋은 컴포넌트를 만드는 원칙이라고 볼 수 있다.
만드려는 컴포넌트에 어떤 메서드가 있는지를 먼저 결정하기보다, 그 컴포넌트가 무엇을 수행할 수 있는지부터 결정해야 한다. 그리고 사용자가 사용할 수 있는 기능들과 방법을 제공해야 한다. 이후에 그 기능을 어떻게 수행할 지 구현하면 된다.
여기서 중요한 점은 기능은 어떻게 구현할지는 컴포넌트 내부에 정의하는 것으로, 외부의 다른 컴포넌트들이나 사용자가 전혀 알지 않아도 된다. 밑의 예시로 한 번 알아보자.
위와 같은 Checkbox 컴포넌트를 만든다면 아래와 같을 것이다.
//Checkbox.tsx
import { useState } from 'react'
const Checkbox = () => {
const [isChecked, setIsChecked] = useState(false)
return (
<label>
<input
type="checkbox"
checked={isChecked}
onChange={() => setIsChecked(!isChecked)}
/>
<span>체크박스 만들기</span>
</label>
)
}
export default Checkbox
이를 다른 곳에서 바로 사용할 수 있게 하려면 어떤 내용을 체크하는 지에 대한 라벨, 체크가 되었는지의 상태 값, 체크하는 로직을 props를 받아야 한다. 수정한다면 아래와 같은 형태가 될 것이다.
//Checkbox.tsx
type CheckboxProps = {
label: string
isChecked: boolean
onChange: () => void
}
const Checkbox = ({ label, isChecked, onChange }: CheckboxProps) => {
return (
<label>
<input type="checkbox" checked={isChecked} onChange={onChange} />
<span>{label}</span>
</label>
)
}
export default Checkbox
//App.tsx
export default function App() {
const [isChecked, setIsChecked] = useState(false)
return (
<Checkbox
label="체크박스 만들기"
isChecked={isChecked}
onChange={() => setIsChecked(!isChecked)}
/>
)
}
만약 Checkbox를 사용하는 모든 곳에서 디자인과 기능이 동일하다면 이대로 사용해도 문제없다.
하지만 만약 특정 페이지들에서만 색상을 다르게 한다던지, 모바일에서는 체크박스를 오른쪽으로 옮겨야 한다던지 등의 레이아웃의 변경이 필요하다면 어떻게 해야할까? 디자인이 살짝 다르다는 이유로 컴포넌트를 새로 만들거나 내부에서 분기 처리하여 수정하기 시작한다면, 유지보수가 점점 더 힘들어질 것이다.
이럴 때 Headless 컴포넌트로 만들면 좋다. 다시 한 번 Headless 컴포넌트(aka. 유지보수 하기 좋은 컴포넌트)를 만드는 원칙들을 생각해보자.
Material UI, Reach UI등 많은 UI 라이브러리가 Compound 컴포넌트를 사용한다.
Compound 컴포넌트란 같이 사용되는 컴포넌트들의 상태(state) 값을 공유할 수 있게 만들어주는 패턴이다. 코드로 한 번 알아보자.
//CheckboxWrapper.tsx
import * as React from 'react'
type CheckboxContextProps = {
id: string
isChecked: boolean
onChange: () => void
}
type CheckboxProps = CheckboxContextProps & React.PropsWithChildren<{}>
const CheckboxContext = React.createContext<CheckboxContextProps>({
id: '',
isChecked: false,
onChange: () => {},
})
const CheckboxWrapper = ({
id,
isChecked,
onChange,
children,
}: CheckboxProps) => {
const value = {
id,
isChecked,
onChange,
}
return (
<CheckboxContext.Provider value={value}>
{children}
</CheckboxContext.Provider>
)
}
const useCheckboxContext = () => {
const context = React.useContext(CheckboxContext)
return context
}
const Checkbox = ({ ...props }) => {
const { id, isChecked, onChange } = useCheckboxContext()
return (
<input
type="checkbox"
id={id}
checked={isChecked}
onChange={onChange}
{...props}
/>
)
}
const Label = ({ children, ...props }: React.PropsWithChildren<{}>) => {
const { id } = useCheckboxContext()
return (
<label htmlFor={id} {...props}>
{children}
</label>
)
}
CheckboxWrapper.Checkbox = Checkbox
CheckboxWrapper.Label = Label
export default CheckboxWrapper
//App.tsx
import CheckboxWrapper from './CheckboxWrapper'
export default function App() {
const [isChecked, setIsChecked] = useState(false)
return (
<CheckboxWrapper
id="checkbox-1"
isChecked={isChecked}
onChange={() => setIsChecked(!isChecked)}
>
<CheckboxWrapper.Checkbox />
<CheckboxWrapper.Label>체크박스 만들기</CheckboxWrapper.Label>
</CheckboxWrapper>
)
}
컴포넌트 내부에서 state를 공유하기 위해 Context API를 사용해서 처음에 작성해야 하는 코드가 꽤 많다.
하지만 컴포넌트를 사용하는 곳에서는 하위에 어떤 컴포넌트가 있는지 볼 수 있고, 위치도 자유롭게 수정 가능하다.
Function as Child Component는 자식에 어떤 것이 들어올지 예상할 수 없기 때문에 children prop으로 받아 그대로 전달하는 것이다.
//CheckboxHeadless.ts
type CheckboxHeadlessProps = {
isChecked: boolean
onChange: () => void
}
const CheckboxHeadless = (props: {
children: (args: CheckboxHeadlessProps) => JSX.Element
}) => {
const [isChecked, setIsChecked] = useState(false)
if (!props.children || typeof props.children !== 'function') return null
return props.children({
isChecked,
onChange: () => setIsChecked(!isChecked),
})
}
export default CheckboxHeadless
//App.tsx
import CheckboxHeadless from './CheckboxHeadless'
export default function App() {
return (
<CheckboxHeadless>
{({ isChecked, onChange }) => {
return (
<label>
<input type="checkbox" checked={isChecked} onChange={onChange} />
<span>체크박스</span>
</label>
)
}}
</CheckboxHeadless>
)
}
Compound 컴포넌트보다 작성해야 하는 코드량이 훨씬 적다. 사용하려는 state 값을 위에서 따로 선언할 필요가 없어, 다른 컴포넌트에 해당 state를 실수로 넣을 일이 적어진다. 그리고 관련된 코드가 한 곳에 모여 있어 읽기 편하다. 하지만 다른 곳에서 해당 state를 공유할 경우, CheckboxHeadless가 감싸야 할 코드량이 많아지는 단점이 있다.
React를 사용해본 사람이라면 가장 익숙할 법한 커스텀 훅이다.
useCheckbox.ts
import { useState } from 'react'
export const useCheckbox = () => {
const [isChecked, setIsChecked] = useState(false)
return {
isChecked,
onChange: () => setIsChecked(!isChecked),
}
}
//App.tsx
import { useCheckbox } from './useCheckbox'
export default function App() {
const { isChecked, onChange } = useCheckbox()
return (
<label>
<input type="checkbox" checked={isChecked} onChange={onChange} />
<span>체크박스 만들기</span>
</label>
)
}
위의 두 방식보다 간단하고 직관적이다. 하지만 state 값을 사용되어야 하는 Checkbox 컴포넌트가 아니라 다른 곳에 작성할 실수가 발생할 수 있다.
Headless 컴포넌트는 스타일이 없고 로직만 존재하는 것을 뜻한다. 마크업과 스타일 수정이 자유롭기 때문에 기능 변경이 많은 곳에서 유용하다. 하지만 장단점이 명확하니 상황에 맞게 도입해야 한다.
그리고 사실 기능은 언제든 변경될 수 있다. 따라서 어느 컴포넌트든 유지보수 하기 좋은 컴포넌트를 만들어야 한다. 유지보수 하기 좋은 컴포넌트란, 변경에 쉽게 대응할 수 있는 컴포넌트다. Headless라는 개념도 변경에 쉽게 대응하기 위해 생겨난 것이라 생각한다.
변경에 쉽게 대응하기 위해서는 해당 컴포넌트가 무엇을 하는지 알아야 하며, 내부와 외부에 두어야 할 것을 완전히 분리해야 한다. 외부가 변경되었다 하더라도 내부 컴포넌트가 영향을 받아서도 안되고, 내부가 수정되었다 하더라도 외부가 변경되어서도 안된다