아직도 개발을 하면서 컴포넌트란 무엇인지에 대해 고민을 종종 하곤 한다. 이름은 무엇으로 짓는 게 좋을지, 어떻게 재사용할지, 역할의 범위는 어디까지 정해야할지, 확장성, 의존성 관리는 어떻게 하면 좋을지 등등..
나만 이렇게 고민하는 건 아닌 것 같다. 프론트엔드 관련 컨퍼런스에는 컴포넌트에 관한 주제로 다양한 발표들을 하시는 것을 보면 대부분의 개발자들 에게도 해당하는 것 같다.
컴포넌트를 만들면서 위에서 했던 고민들을 염두에 두지 않고 만들게 되면 금방 만들 수 있을지 모르겠지만, 지나고보면 유지보수에 문제가 생길 수 있고, 때때로 급하게 변하는 요구사항에 민첩하게 대응하기도 쉽지 않을 수 있다.
그렇다고 만들어본 경험도 없이 처음부터 위에서 했던 고민들을 바탕으로 만들기도 쉽지가 않다. 개인적인 생각으로는 나름 잘 만들기 위해서는 경험도 필요하다고 생각하기 때문이다.
컴포넌트를 보다 더 잘 만들 수 있는 여러 방법들이 있겠지만, 이번 포스팅은 개인적으로 많은 공부가 되었던 방법을 소개할까 한다. 나는 디자인 시스템들을 보면서 많은 공부가 되었다.
이번에는 잘 만들어진 UI 라이브러리들은 어떻게 컴포넌트를 만드는지 확인해보고, 기존에 고민했던 것들에 대해 어떤 인사이트를 얻게 되었는지 간단하게 살펴보려 한다.
처음에 컴포넌트를 만들때 UI가 어떻게 동작하는지는 알겠지만, 그 걸 뭐라고 불러야 하는지 가끔 생각이 안날때가 있었다. 그게 Dropdown
이었다. 그런데 이러한 UI가 몇 가지의 이름을 가지고있었다. Select
, Menu
등등 비슷한 구성이지만, 각자 역할이 있다. 난 그것도 모르고 Dropdown
을 사용했다가 결국은 모두 지우고 Select
를 사용했다. 그 이유는 form
에 사용할 select/option
을 사용했었어야 했는데, Dropdown
은 단순 유저에게 버튼 클릭에 의해 실행되는 작업들을 제공하는 컴포넌트였기 때문에, 사용하기에 적절하지 않았었다.
이를 통해 알 수 있는 사실이 있다. 사용하려는 맥락에 따라 비슷한 UI여도 개념적으로 다르게 다루어야 하는 일들이 생긴다. 이는 컴포넌트 이름으로 나타낼 수 있다.
만약 이러한 배경지식 없이 컴포넌트를 직접 만들었다면 어떻게 됐을까? 아마도 저 셋 중 이름을 아무거나 짓고 사용했을 것이다. 추후에 비슷한 UI지만 다른 역할을 하는 컴포넌트를 만들게 되면 그때부터 큰 고민에 빠질 것 같다. 다시 이름부터 정의하여 사용하는 모든 컴포넌트를 다시 손봐야 할지, 아니면 더 큰 모순 덩어리를 만들지..
만약 컴포넌트를 만들기전에 여러 디자인 시스템 라이브러리들을 보면서 사전에 조사를 진행했더라면 그래도 조금은 더 만족스러운 컴포넌트를 만들었을지 모른다.
가끔 디자인 시스템 라이브러리를 둘러보면 정말 신기할 정도로 보자마자 와닿는 이름들이 정말 많다. 그 중 가장 재밌었던 컴포넌트중 하나는 아코디언이다.
우리가 평소에 생각하는 아코디언을 생각하면 위의 컴포넌트의 이름이 왜 아코디언인지 단 번에 이해가 될것이다! 만약 위와같은 컴포넌트를 사전 조사 없이 직접 만들고 구현한다면 이름을 과연 아코디언이라고 지을 수 있을까? 구글에 ‘accordion ui’를 검색하고 이미지탭으로 이동하면 아래와 같이 결과를 볼 수 있고, 4,780,000건의 검색 결과를 볼 수 있다.
구글에 ‘accordion ui’라고 검색한 후 이미지 탭을 열었을 때의 화면이다.
여기서 설명하는 테마는 Typography, Color(Palette), Elevation(Shadow), Round(Radius) 등등 디자인 시스템을 구축하는 모든 디자인 관련들을 뜻한다. 생각보다 각각의 디자인 시스템들의 정의하는 테마의 범위가 다 달라서 미리 정의를 하는 게 오해를 없앨 수 있을것 같다.
테마는 디자인 시스템을 구축하는 데 중요한 역할을 한다고 생각한다. 개인적으로 테마를 잘 정의하면 일관성이 생기고 생산성을 올려주고, 디자이너와의 원활한 커뮤니케이션을 만들어준다고 생각한다.
대부분의 디자인 시스템은 갑자기 짜잔! 하면서 바로 완성본이 나타난다고 생각하지 않는다. 지속적으로 업데이트하면서 점점 구축하는 것이라고 생각하고, 끊임없이 관리해야하는 것이라고 생각한다.
여기저기 흩어져있는 다양한 컴포넌트들 중에 가장 중복이 되는 것이 있을 것이다. 개인적으로 난 Text와 Button이라고 생각한다. 그리고 대부분의 프로젝트에서 공통 컴포넌트를 만든다고 하면 저 둘을 먼저 생각할것이다. Emotion
을 사용하여 Text를 예시로 디자인 토큰을 만들고, 공통 컴포넌트를 만드는 과정을 예제 코드로 담아보려 한다.
여기저기 컴포넌트에 설정된 텍스트 관련 엘리먼트들의 css들을 살펴보니 대강 font-size, line-height들이 특정 패턴을 가지고있었고, css 속성들이 특정 모듈에 의해 실행되는게 아닌, 아닌 단순 중복되어있다고 가정해보자. 단순 중복되어있지만 특정 패턴들을 갖는 css 속성들을 추려서 정리해보면 아래와 같은 코드가 나온다.
const fontSize = {
s: 14,
m: 16,
l: 20,
xl: 24,
};
const lineHeight = {
s: 21,
m: 24,
l: 30,
xl: 36,
};
const S = css`
font-size: ${fontSize.s}px;
line-height: ${lineHeight.s}px;
`;
// .. M, L, XL도 동일하게 작성된다.
const Typograpgy = {
S,
L,
M,
XL,
};
export default Typography;
위의 코드 중 fontSize와 lineHeight의 각각 속성들은 디자인 토큰이 되고, 디자인 토큰을 조합하면 더 넓은 개념의 각각의 Size가 되고, 이것들은 Typography가 된다.
const StyledText = styled.span`
${(props) => Typography[props.size]}
`;
const Text = ({ size, children }) => {
return <StyledText size={size}>{children}</StyledText>;
};
export default Text;
간단하게 Text 컴포넌트를 만들어봤다.
그리고 아래처럼 사용한다.
const SomeComponent = () => {
return (
<>
<Text size="S">Hello</Text>
<Text size="M">Hello</Text>
<Text size="L">Hello</Text>
<Text size="XL">Hello</Text>
</>
);
};
export default SomeComponent;
그러면 화면에는 아래처럼 렌더링된다.
SomeComponent를 화면에 렌더링한 모습
이외에 여백(margin, padding), 색상, 얼라인먼트, 등등 설정하여 위의 과정을 거치게 된다면, 공통 컴포넌트로써 더 유연하게 사용할 수 있다.
단순 UI의 모양 뿐만 아니라 컴포넌트(요소)들을 어떻게 배치할건지를 설정하는 컴포넌트들도 있다. 대표적으로 Flex와 Stack이 있다. Flex와 Stack은 css의 속성중 하나인 dispay를 flex로 설정되어있는 컴포넌트이다. Flex와 Stack과의 차이점은 여러가지가 있지만 그 중에 하나는 spacing(gap) prop의 여부이다.
그리고 Stack은 수직으로 쌓을지, 수평으로 쌓을지를 선택하는 VStack과 HStack이 있다. 디자인 시스템마다 달라서 어떤 곳은 Stack 컴포넌트 내의 direction 이라는 prop으로 설정하기도 한다.
하나의 예시로, Mui는 Flex 컴포넌트가 없고 Stack 컴포넌트에 direction prop으로 수직으로 할지 수평으로 할지 설정할 수 있다. 그리고 Chakra UI는 Flex와 Stack이 구분되어있고, Stack 안에서도 VStack, HStack이 있다. 각각의 라이브러리에서 어떻게 사용하는지, 사용했을 때 개인적으로 어떤 것이 더 개발 경험이 좋았는지는 개개인마다 다르기 때문에 각각의 장단점을 살펴보면 좋을 것 같다.
Headless Component는 특정 화면의 스타일에 의존하지 않고, 어디서든 보다 잘 작동할 수 있도록 설계된 컴포넌트를 뜻한다. 쉽게 말해 해당 UI의 인터페이스만을 갖는 것을 뜻하기도 한다.
이는 더 유연하고, 재사용 가능하도록 설계되어있다. 가능한 UI의 인터페이스만을 갖도록 하고, 스타일을 최소화하기 때문에 다양한 상황에 따라 보다 유연하게 만들 수 있다.
같은 역할을 하는 컴포넌트가 두 개 있다고 가정하자. 결국 그 컴포넌트가 하는 역할은 동일하더라도 스타일이 다른 경우가 있다. 만약 공통 컴포넌트에는 다른 스타일에 대한 코드가 없다면 결국 재사용성을 위해 다른 스타일에 대한 코드를 추가해야 할 것이다.
만약 다른 예외적인 상황이 계속 생겨 컴포넌트가 복잡해진다면, 사용하는 개발자도, 유지보수를 하는 개발자도 모두 난처한 상황에 빠질 수 있다.
어떻게 하면 유연하게 사용할 수 있는 공통 컴포넌트를 만들 수 있을까? Select 컴포넌트를 구현해보면서 어떻게 해결하는 지 살펴보자.
Select 컴포넌트를 구현하기 전, 만약 컴포넌트를 사용한다면 아래와 같이 사용할 것이다.
// ...
const items = [
{ label: 'foo', value: 'foo' },
{ label: 'bar', value: 'bar' },
{ label: 'baz', value: 'baz' },
];
const handleChangeValue = () => {}
return (
<Select initialValue="foo" items={items} onChange={handleChangeValue} />
);
// ...
대강 Select 컴포넌트 구현은 아래처럼 될 것 같다.
import React from 'react';
interface Props {
items: { label: string; value: string }[];
initialValue: string;
onChange: () => {};
}
const Select = ({ items, initialValue, onChange }) => {
return (
<select className="custom-select-css" value={initialValue} onChange={onChange}>
{items.map(item => (
<option className="custom-option-css" key={item.value} value={item.value}>
{item.label}
</option>
))}
</select>
);
};
export default Select;
어째저째 잘 사용하고 있었다. 그러다 요구사항이 추가가 됐다. 특정 페이지에서만 Select 스타일이 바뀌었으면 좋겠다는 요구사항이다.
음… 고민해보다가 아래처럼 추가했다.
import React from 'react';
interface Props {
// 새로 추가된 prop. variant에 따라 select의 스타일이 변함.
variant?: 'style1' | 'style2';
items: { label: string; value: string }[];
initialValue: string;
onChange: () => {};
}
const Select = ({ variant = 'style1', items, initialValue, onChange }) => {
return (
<select
// 분기에 따른 css 할당
className={variant === 'style1' ? 'custom-select-css-style1' : 'custom-select-css-style2'}
value={initialValue}
onChange={onChange}
>
{items.map(item => (
<option
// 분기에 따른 css 할당
className={variant === 'style1' ? 'custom-option-css-style1' : 'custom-option-css-style2'}
key={item.value}
value={item.value}
>
{item.label}
</option>
))}
</select>
);
};
export default Select;
또 요구사항이 하나 접수됐다. 기존 Select는 클릭하면 아이템들이 아래에서만 보여지도록 구성되어있었는데, 클릭하면 오른쪽에서 나오도록 만들어달라는 요구사항이었다.
음… 또 고민하다가 아래처럼 추가해봤다.
import React from 'react';
type Side = 'bottom' | 'right';
interface Props {
variant?: 'style1' | 'style2';
// 새로 추가된 prop. side에 따라 select의 아이템들이 어디서 보여질지 결정함.
side?: Side;
items: { label: string; value: string }[];
initialValue: string;
onChange: () => {};
}
const Select = (
{
variant = 'style1',
// 기존의 컴포넌트들은 기본적으로 bottom이었기 때문에 기본값을 bottom으로 할당했다.
side = 'bottom',
items,
initialValue,
onChange
}
) => {
return (
<Styled.Select
className={variant === 'style1' ? 'custom-select-css-style1' : 'custom-select-css-style2'}
// 어떤 쪽에서 보여줄지 prop으로 side를 전달한다.
side={side}
value={initialValue}
onChange={onChange}
>
{items.map(item => (
<option
className={variant === 'style1' ? 'custom-option-css-style1' : 'custom-option-css-style2'}
key={item.value}
value={item.value}
>
{item.label}
</option>
))}
</select>
);
};
export default Select;
const Select = styled.select<{ side: Side }>`
// ...
`;
Select 컴포넌트를 개발하면서 스타일이 바뀌는 요구사항이 생기면서 variant prop을 추가하였고, 이후 Select의 아이템이 보여지는 위치를 결정하는 side라는 prop도 추가하였다.
점점 이렇게 모든 경우에 스타일을 설정하게 되면 코드가 난잡해질 가능성이 높아지고, 난잡해질 수록 유지보수는 어려워진다. 그렇다면, 어떻게 해결하는 게 좋을까?
일단, Select는 Select이다. 다만 어떤 UI이느냐에 따라 모양(css)이 조금 다를 수 있다. 그래도 역할은 Select라는 컴포넌트의 역할을 한다. 그렇다면 Select 역할을 하는 인터페이스만 컴포넌트에 담아낸다. 그 외에 모양(css)을 만드는 건, 일반적으로 사용하려는 컴포넌트에서 구현한다. 이 것이 Headless Component의 의도이자, 탄생 배경 중 하나이다.
어떻게 리팩토링 하는 것이 좋을까? Headless Component를 구현하기 위해서는 Compound Component의 개념을 이해하는 것이 좋다.
또 다른 유용한 컴포넌트 디자인 패턴으로는 Compound Component가 있다. Compound Component는 하나의 부모 컴포넌트에서 여러 개의 자식 컴포넌트를 조합하여 하나의 컴포넌트처럼 사용하는 패턴이다. 이 패턴을 사용하면 사용자는 하나의 컴포넌트로 여러 개의 기능을 구현할 수 있어 코드를 간결하게 유지할 수 있다.
위의 Select 컴포넌트를 Compound Component로 구성한다면, 대략 아래와 같은 코드가 될 것 같다.
import * as Select from 'components/common/Select';
const items = [
{ label: 'foo', value: 'foo' },
{ label: 'bar', value: 'bar' },
{ label: 'baz', value: 'baz' },
];
const SomeSelectComponent = () => {
<Select.Root>
<Select.Trigger>
<Select.Value placeholder="select..." />
</Select.Trigger>
<Select.Content>
{items.map((item) => (
<Select.Item key={item.value} value={item.value}>
{item.label}
</Select.Item>
))}
</Select.Content>
</Select.Root>;
};
export default SomeSelectComponent;
위의 컴포넌트를 보면 Root, Trigger, Value, Content, Item 같은 것들이 보인다. 이것들은 모두 Select라는 컴포넌트의 자식 컴포넌트이다.
Select의 자식 컴포넌트에 대해 각각의 역할에 대해 알아보자. Root는 Select 컴포넌트의 자식 컴포넌트들을 모두 포함하는 것으로, 일반적으로 Root 안에서는 자식 컴포넌트들 간에 사용되는 상태들을 공유할 수 있다.
Trigger는 해당 Select를 토글할 때의 컴포넌트이다. Content는 Select를 클릭했을 때 보여지는 option들이고, Item은 그 안의 각각의 option을 가르킨다.
왜 각각 자식 컴포넌트들이 있는 걸까?
일단. 각각의 컴포넌트가 있으면 css 설정이 비교적 쉬워진다. 기존의 Select 컴포넌트로 잠시 기억을 돌이켜보자. 만약 Item의 스타일을 바꾸기 위해서는 반드시 Select에서 prop을 통해 무언가를 바꾸거나, 자식 결합자를 통해 생각보다 깊게 들어가서 변경해야 하는 경우가 생긴다. 이마저도 잘 안된다면 !important
를 사용하기 시작하게 된다. 즉, 커스터마이징에 있어 곤란한 경우가 생길 수 있다. 그에 비해 자식 컴포넌트를 가진 Compound Component는 자식 컴포넌트에서 스타일을 오버라이딩하면 된다.
이제는 사용 관점이 아닌, 컴포넌트를 관리하는 관점에서도 장점이 있다. 아까 잠시 “Root 안에서는 자식 컴포넌트들 간에 사용되는 상태들을 공유할 수 있다.”고 했다. 이는, Root에서 React의 Context API를 사용하여 상태관리를 한다. 그래서 Root 안에 있는 자식 컴포넌트들은 Root에서 설정한 상태들을 공유할 수 있게 된다. 의존성을 주입한다는 관점에서 봤을 때 결합도를 낮출 수 있게 되는데, 이는 유지보수적인 측면에서 굉장한 장점이 된다.
이것 저것 만들어도 되는 것에 대해 부담이 비교적 적어진다. 어차피 사용하는 컴포넌트 입장에서는 필요한 것만 갖다 쓰기만 하면 되기 때문이다. 이는 더 유연한 컴포넌트를 만들 수 있게 된다.
디자인 시스템은 대규모 프로젝트에서 여러 개발자들이 협업하는 환경에서 중요한 역할을 한다. 일관된 디자인 시스템을 사용하면, 개발자들은 일관성 있는 UI를 개발할 수 있어서 사용자 경험을 향상시키고 프로젝트의 효율성을 높일 수 있게 된다.
최근에는, 많은 기업들이 컴포넌트 디자인 시스템을 도입하고 있다. 그 예시로는 Airbnb의 Design Language System과 IBM의 Carbon Design System이 될 것 같다. 이러한 디자인 시스템은 개발자들에게 많은 도움을 주고 있으며, 깃헙 오픈소스는 많은 참고 예시가 될 거라 생각한다.
컴포넌트 디자인 시스템과 디자인 패턴을 학습하고 이를 프로젝트에 적용하는 것은 프론트엔드 개발자로서 필요한 역량이라고 개인적으로 생각한다. 이를 통해 개발자는 더욱 효율적이고 유지보수하기 쉬운 코드를 작성할 수 있으며, 사용자 경험을 향상시키는 데 큰 도움을 줄 수 있다고도 생각한다.
https://github.com/alexpate/awesome-design-systems
https://kentcdodds.com/blog/compound-components-with-react-hooks