웹에서 제가 가장 좋아하는 일은 다른 개발자의 생산성을 높이는 데 도움이 되는 것을 만드는 것입니다. 특히 프로젝트 부트스트랩과 개발을 더 쉽게 하는 데 사용할 수 있는 컴포넌트 라이브러리와 핵심 개발 키트를 만드는 것을 좋아합니다. 그 결과 저는 제가 만드는 컴포넌트가 견고하고 다재다능하며 사용하기 쉽도록 하기 위해 새롭고 더 나은 방법을 찾는 데 상당한 시간을 보냈습니다. 컴포넌트에 대해 제가 느낀점 중 하나는 개발자들이 주어진 즉각적인 사용이나 해당 컨텍스트에서 작동하도록 만드는 경향이 있다는 것입니다.(구현하다보면 자연스럽게 이렇게 된다고 생각) 그래서 컴포넌트 구성의 일부로 비즈니스 로직, 레이아웃 로직 및 기타 세부 사항을 통합하는 경우가 많습니다. 많은 컴포넌트가 사용되는 곳과는 별개의 프로젝트에 추상화되어 있지만 그렇게 함으로써 얻을 수 있는 이점을 전혀 활용하지 못합니다. 제 생각에 가장 큰 이유 중 하나는 컴포넌트가 디자인 반복에 얽매여 있기 때문입니다. 컴포넌트는 제작 당시의 디자인에 맞춰 만들어지지만, 향후 개선 사항을 염두에 두지 않습니다. 사양에 맞게 작동하고 보기 좋고, 테스트가 잘 되어 있고, 적절한 문서가 있는 컴포넌트를 만드는데 몇 시간을 쏟아 부은 적이 많습니다. 하지만 바로 다음날 출근해서야 디자인이 변경되었거나 새로운 사용 사례가 추가되어 컴포넌트를 업데이트해야 한다는 사실을 알게 되곤 합니다. 이는 매우 실망스러운 일이며, 쉽게 피할 수 있는 많은 버그의 원인이 되기도 합니다.
그렇다면 해결책은 무엇일까요? 제목을 읽으셨다면 제가 다음에 무슨 말을 할지 짐작할 수 있을 것입니다. Headless UI 컴포넌트. 하지만 그게 무엇일까요? 간단히 설명하자면 Headless UI 컴포넌트는 UI를 명시적으로 결정하지 않고 기능의 집합을 제공하는 컴포넌트입니다. 무슨 뜻인지 예를 들어보겠습니다. 다음 예시는 헤드리스 컴포넌트가 아닙니다.
const Counter: FC = () => {
const [count, setCount] = useState(0);
return (
<div className="counter-wrapper">
<button onClick={() => setCount(count - 1)}>-</button>
<span>{count}</span>
<button onClick={() => setCount(count + 1)}>+</button>
</div>
);
}
이제 여기서 무슨 일이 일어나고 있는지 꽤 쉽게 알 수 있을 것입니다. 컴포넌트 상태와 컴포넌트 UI가 있습니다. UI는 카운트 값을 증가, 감소 시키는 두 개의 버튼과 값을 확인하는 출력으로 구성되어 있습니다. 이것은 잘 작동하고 해야 할 일을 수행합니다. 하지만 컴포넌트가 제공하는 UI에 제한이 있습니다. 버튼의 텍스트를 +와 -가 아닌 더 많고 적음을 말하도록 변경하고 싶다고 가정해 봅시다. 이를 위해 컴포넌트에 몇가지 props를 추가할 수 있습니다.
interface Props {
decrementText?: string;
incrementText?: string;
}
const Counter: FC<Props> = (props: Props) => {
const [count, setCount] = useState(0);
const {decrementText = '-', incrementText = '+'} = props;
return (
<div className="counter-wrapper">
<button onClick={() => setCount(count - 1)}>{decrementText}</button>
<span>{count}</span>
<button onClick={() => setCount(count + 1)}>{incrementText}</button>
</div>
);
};
멋지네요! 작동합니다. 걱정하지 마세요. 하지만 이제 버튼을 클릭할 때마다 카운터를 얼마나 증가/감소 시킬지 변경해야 한다고 가정해봅시다. 컴포넌트에 또 다른 프로퍼티를 추가해야 합니다.
interface Props {
decrementText?: string;
incrementText?: string;
stepAmount?: number;
}
const Counter: FC<Props> = (props: Props) => {
const [count, setCount] = useState(0);
const { decrementText = '-', incrementText = '+', stepAmount = 1 } = props;
return (
<div className="counter-wrapper">
<button onClick={() => setCount(count - stepAmount)}>
{decrementText}
</button>
<span>{count}</span>
<button onClick={() => setCount(count + stepAmount)}>
{incrementText}
</button>
</div>
);
};
이제 4가지 작업을 수행하는 컴포넌트가 있습니다.
1. 값을 증가시킬 수 있습니다.
2. 값을 감소시킬 수 있습니다.
3. 일부 프로퍼티를 설정할 수 있습니다.
4. 상태를 반영하기 위해 일부 UI를 렌더링합니다.
이제 이것이 원하는 것과 정확히 일치할 수도 있습니다.(물론 이와 같은 것이 최선의 방법일 때도 있습니다.) 하지만 보시다시피 컴포넌트의 UI를 변경할 때마다 미리 계획하고 컴포넌트에 빌드인입니다. 또한 새로운 상태나 옵션을 추가할 때마다 더 복잡해집니다.
그렇다면 카운터의 기능(카운터의 상태, 증가 및 감소 기능)은 원하지만 주어진 UI는 원하지 않는다면 어떻게 해결해야 할까요? 대부분의 경우 기존 컴포넌트와 동일한 방식으로 작동하는 새 컴포넌트를 빌드하되 다른 UI를 렌더링하거나 컴포넌트의 프로퍼티에 두 UI 사이를 전환하는 다른 구성을 추가하는 것이 해결책이 될 수 있습니다.
하지만 다른 방법도 있습니다. Headless UI 컴포넌트를 알아보세요. 이 시점에서 UI에 신경쓰지 않고 필요한 기능을 제공하는 컴포넌트의 사용 사례를 보셨기를 바랍니다. 어떻게 작동하는지 살펴봅시다.
interface Arguments {
count: number;
increment: (value: number) => void;
decrement: (value: number) => void;
}
const Counter = (props: { children: (args: Arguments) => JSX.Element }) => {
const [count, setCount] = useState(0);
if (!props.children || typeof props.children !== 'function') return null;
return props.children({
count,
increment: (value: number = 1) => setCount(value),
decrement: (value: number = 1) => setCount(value),
});
};
저게 뭐야?! 물론 이 코드는 앞서 살펴본 예제에 비해 매우 섹시해 보이지는 않습니다. 하지만 훨씬 더 많은 일을 할 수 있습니다. 자체 UI를 제어하지 않기 때문에 원하는 UI를 플러그인하고 원하는 대로 기능을 사용할 수 있습니다. 아래는 non-headless한 다른 유사한 컴포넌트의 구현입니다.
<CounterHeadless>
{({ count, increment, decrement }: any) => {
return (
<div className="counter-wrapper">
<button onClick={() => decrement(count - 1)}>less</button>
<span>{count}</span>
<button onClick={() => increment(count + 1)}>more</button>
</div>
);
}}
</CounterHeadless>
또는 다른 레이아웃을 가진 컴포넌트도 올 수 있습니다.
<CounterHeadless>
{({ count, increment, decrement }) => {
return (
<div className="counter-wrapper">
<h2>{count}</h2>
<button onClick={() => decrement(count - 1)}>-</button>
<button onClick={() => increment(count + 1)}>+</button>
</div>
);
}}
</CounterHeadless>
이 컴포넌트의 가능성은 무한하지는 않지만, UI를 원하는 대로 만들 수 있기 때문에 훨씬 더 큽니다. 헤드리스 컴포넌트를 사용하면 버튼에 패딩을 얼마나 넣어야 할지, 테두리의 색상을 어떤 색으로 할지, 다른 것은 테두리 반경을 5픽셀로 할지 3픽셀로 할지 고민할 필요 없이 다양한 컴포넌트에 대한 공통 유틸리티를 쉽게 패키징하여 배포할 수 있습니다. 필요한 모든 기능을 수행하는 강력한 컴포넌트를 만들고 실제로 컴포넌트를 사용할 때는 UI에 대해 걱정할 필요가 없습니다.
그렇다면 특정 방식으로 스타일을 지정해야 하는 컴포넌트는 어떨까요? 컴포넌트의 일반적인 사용 사례는 스타일에 대해 걱정할 필요 없이 페이지에 드롭할 수 있는 미리 스타일이 지정되고 테스트된 디자인 요소를 갖는 것 입니다. 문제는 헤드리스 컴포넌트에서는 그렇게 할 수 없다는 것입니다. 아니면 그럴 수 있을까요? 헤드리스 컴포넌트를 사용한다고 해서 UI가 있는 컴포넌트를 절대 만들어서는 안된다는 의미는 아닙니다. 사실 헤드리스 컴포넌트는 이 과정을 훨씬 더 쉽게 만들 수 있습니다. 위의 카운터를 예로 들어보면 이 카운터에 몇 가지 다른 변형을 만들었음을 알 수 있습니다. 헤드리스 카운터 컴포넌트를 사용하면 컴포넌트 간에 기능을 중복하지 않고도 이러한 카운터를 각각 고유한 컴포넌트로 만들 수 있습니다.
const Counter: FC = () => {
return (
<CounterHeadless>
{({ count, increment, decrement }) => {
return (
<div className="counter-wrapper">
<button onClick={() => decrement(count - 1)}>less</button>
<span>{count}</span>
<button onClick={() => increment(count + 1)}>more</button>
</div>
);
}}
</CounterHeadless>
);
};
const CounterStacked: FC = () => {
return (
<CounterHeadless>
{({ count, increment, decrement }) => {
return (
<div className="counter-wrapper">
<h3>{count}</h3>
<button onClick={() => decrement(count - 1)}>less</button>
<button onClick={() => increment(count + 1)}>more</button>
</div>
);
}}
</CounterHeadless>
);
};
const CounterLabeled: FC<{ label: string }> = ({ label }) => {
return (
<CounterHeadless>
{({ count, increment, decrement }) => {
return (
<div className="counter-wrapper">
<h3>
{label} - {count}
</h3>
<button onClick={() => decrement(count - 1)}>less</button>
<button onClick={() => increment(count + 1)}>more</button>
</div>
);
}}
</CounterHeadless>
);
};
export { CounterLabeled, Counter, CounterStacked };
여기까지입니다. 하나의 가격에 세 가지 컴포넌트를 제공합니다. 위의 각 카운터를 앱에서 사전 설정된 컴포넌트로 사용하거나, 필요한 경우 헤드리스 기본 버전을 사용하여 자신만의 변형을 만들 수 있습니다.
제 생각에는 컴포넌트가 특정 디자인에 너무 얽매여 있습니다. 오늘날 많은 컴포넌트는 불필요한 구성, 비즈니스 로직, 스타일링이 뒤섞인 엉망진창입니다. 디자이너 중 한 명이 이 페이지의 버튼 반대편에 화살표가 있으면 '더 멋질 것'이라고 생각한 것 때문에 많은 시간을 할애해 지정에 따라 정확하게 모양과 작동하는 컴포넌트를 만들다가 작업의 많은 부분을 덮어쓰게 되는 경우가 있습니다. 전반적으로 헤드리스 컴포넌트는 이 문제뿐만 아니라 컴포넌트를 만들 때 직면하는 다른 많은 문제를 해결할 수 있는 좋은 방법이라고 생각합니다.