1. 배경
Gloddy 프로젝트에서 8월 중순 쯤, 새로운 디자이너가 들어오며 디자인 시스템을 구축하였습니다. 이전에는 프론트엔드 개발자들이 공통적으로 사용되는 컴포넌트를 따로 빼서 공통 컴포넌트를 만들었습니다. 그러나 디자이너 분께서 친절하게 디자인 시스템을 만들어주셔서 이를 바탕으로 공통 컴포넌트를 만들어 나가고 있습니다. React앱에서 편리하게 사용할 수 있도록 라이브러리 차원에서 각 컴포넌트의 역할을 정의하고 실제로 사용하는 상황을 고려하여 여러 컴포넌트를 완성해나가고 있습니다.
프로젝트 초기에 구현한 컴포넌트는 input 및 textarea 태그 역할을 하는 TextField, button 태그 역할을 하는 Button처럼 비교적 단순한 컴포넌트들과 display:flex가 적용된 요소를 선언적으로 사용할 수 있는 Flex, 구획을 나눌 수 있는 Divider등의 Layout을 구현했습니다.


개발이 점차 진행되면서 디자인 시스템에서 제공해야 하는 컴포넌트들의 복잡도도 올라가게 되었습니다. 가령, 아래 Carousel처럼 비교적 복잡도가 높은 컴포넌트는 어떻게 구현해야 할 지 고민하게 되었습니다.
이러한 컴포넌트들을 구현하기 위해서 선택한 방법인 Compound Component Pattern(합성 컴포넌트 패턴)에 대해서 알아보고, 이를 사용하여 Tabs 컴포넌트를 구현한 과정에 대해서 소개하겠습니다.
2. Compound Component Pattern
Compound Component Pattern을 소개하고 있는 수많은 글들 중 이 글에서는 이렇게 설명하고 있습니다.
Compound components are a React pattern that provides an expressive and flexible way for a parent component to communicate with its children, while expressively separating logic and UI.
Compound Component Pattern은 React 패턴 중 하나로, 여러 개의 작은 컴포넌트들이 각각의 역할을 분담하도록 하고 이를 조합하여 하나의 큰 컴포넌트를 만드는 것입니다. 부모 컴포넌트가 자식 컴포넌트와 분리된 로직과 분리된 사용자 인터페이스를 분리하면서 내부의 상태를 공유할 수 있는 패턴입니다.
합성 컴포넌트 패턴의 장점은 Context Provider를 가진 부모 컴포넌트로 여러 자식 컴포넌트를 감싸 props를 자식 컴포넌트에게 일일이 넘겨주지 않아도 된다는 점입니다. 또한, 개발자가 필요로 하는 자식 컴포넌트만 합성하여 사용할 수 있기 때문에 개발자에게 자율성을 줄 수 있다는 점입니다.
하지만, Context값이 변화하면서 Context값을 사용하는 컴포넌트에서 불필요한 리렌더링이 일어날 수 있고, JSX 소스 코드 길이가 길어질 수 있다는 단점이 있습니다.
따라서 합성 컴포넌트 패턴은 여러 컴포넌트가 함께 동작하고 내부적으로 상태와 로직을 공유할 때 사용할 것을 권장합니다. 예를 들어 select와 option요소가 이에 해당합니다. 이들은 '열림'과 '닫힘' 상태 등을 함께 공유하며, 독립적으로 사용될 수 없습니다. 아래에서 소개할 Tabs또한 동일합니다. Tabs의 상단부(Tab)와 하단부(Panel)은 '어떤 탭이 눌렸는지'에 대한 상태를 공유합니다. 이들도 독립적으로 사용될 수 없습니다.
3. Context API
Context API는 각 컴포넌트에 일일이 props를 넘겨주지 않고도 컴포넌트 트리 전체에 데이터를 제공할 수 있습니다. React 애플리케이션 안의 여러 컴포넌트들에 전해줘야 하는 상태값의 경우 Context API의 Provider로 감싸서, 이러한 값을 공유할 수 있습니다.
const Context = createContext(defaultValue);
<Context.Provicer value = {공유할 value}>
{공유할 컴포넌트}
</Context>
Provider 컴포넌트는 value prop을 받아서 하위에 있는 컴포넌트에게 전달합니다. 값을 전달받을 수 있는 컴포넌트의 수에 제한은 없습니다.
이를 합성 컴포넌트에서 사용하는 경우를 생각해보면 공유해야 하는 값은 Context를 사용하여 부모 컴포넌트 내부의 children들이 공유받도록 구현하고, 그렇지 않은 값은 각각의 자식 컴포넌트가 Props로 받아서 처리하게 됩니다.
4. Tabs 컴포넌트
간단하게 Compond Component Pattern과 Context API에 대해서 알아보았습니다. 여기서부터는 두 개념을 적용하여 Tabs 컴포넌트를 만들어 보겠습니다.
먼저 대략적으로 어떤 스펙으로 만들 것인지 생각해보겠습니다.
1. 스펙 정리
크게 기능과 디자인으로 나누어 보았습니다.
기능은 컴포넌트 내부에서 관리해야 하는 상태나 지원해야 하는 비즈니스 로직과 관련한 부분이고, 디자인은 사용자에게 선택지를 제공하여 어떤 형태로 보여줄 것인지와 관련한 부분입니다.
- 기능 : Tabs에서 사용자(개발자)가 기대하는 요소들
- 현재 선택된 Tab 정보
- Tab 활성화 / 비활성화 정보
- Tab을 눌렀을 때 현재 Tab과 Tab 하단 내용이 바뀜
- Tab안의 값을 설정할 수 있도록 구현
- 디자인 : Tabs에서 사용자(개발자)가 선택 가능한 옵션
- Tab의 기본 생김새
- Tab의 너비를 꽉 차게 배치할 수 있는지 여부
2. Context 생성
먼저 Tabs의 각 부분을 나누어 살펴보겠습니다. 상단에는 사용자가 선택할 수 있는 Tab선택지들이 존재하고 하단에는 선택한 Tab에 대응하는 내용이 표시되어야 합니다.
사용자가 선택한 Tab 정보가 상단과 하단에 모두 영향을 주기 때문에 해당 데이터는 Tabs 컴포넌트 내에서 공유되어야 하는 데이터가 됩니다. 선택한 Tab 정보를 담는 currentTab상태를 공유하는 Tabs 컴포넌트를 ContextAPI를 사용하여 만들어보겠습니다.
실제 코드와 달리 타입과 CSS 속성이 제외된 코드를 명기했습니다.
const TabsContext = createContext<{ activeTab: string | number; setActiveTab: (value: string | number) => void; } | null>(null); interface TabsProps { defaultActiveTab: string | number; } export default function Tabs({ defaultActiveTab, children }: StrictPropsWithChildren<TabsProps>) { const [activeTab, setActiveTab] = useState(defaultActiveTab); return ( <TabsContext.Provider value={{ activeTab, setActiveTab }}>{children}</TabsContext.Provider> ); }
맨 처음 활성화되어 있는 Tab 정보를 defaultActiveTab으로 받아서 useState로 관리합니다.
이제 위에서 상단과 하단으로 나누었던 부분을 대략적인 JSX구조로 나타내보겠습니다. 하위 컴포넌트의 이름은 MDN의 tab 접근성 가이드를 참고하여 명명했습니다.
<Tabs defaultActiveTab={currentTab}> <Tabs.List> <Tabs.Tab value="A" /> <Tabs.Tab value="B" /> </Tabs.List> <Tabs.Panel value="A">A영역</Tabs.Panel> <Tabs.Panel value="B">B영역</Tabs.Panel> </Tabs>
이제 각각의 부분을 실제 컴포넌트로 분리하고 개별 컴포넌트가 관리해야 하는 데이터를 Props로 전달받을 수 있도록 구현합니다.
3. Tabs - 상위 컴포넌트
상위 컴포넌트인 Tabs에서는 디자인적인 요소를 결정하는 props를 추가적으로 받습니다.
interface TabsProps {
defaultActiveTab: string | number;
variant:'underline' | 'solid'
}
export default function Tabs({ defaultActiveTab, variant = 'underline', children }: StrictPropsWithChildren<TabsProps>) {
// 생략
}
4. List - 하위 컴포넌트
List 컴포넌트는 내부에 Tab을 위치시키는 Wrapper컴포넌트로 사용합니다. 따라서 스타일 속성만 설정하였습니다.
function List({ children }: StrictPropsWithChildren) {
return <div className="flex h-50 border-b border-white3">{children}</div>;
}
5. Tab - 하위 컴포넌트
가장 중요한 Tab 컴포넌트입니다. 각각의 Tab은 고유한 value를 받을 수 있도록 하여 Tab이 선택되었을 때 동일한 value를 가진 Panel이 활성화되도록 구현할 것입니다. 또한 Tab에 글자와 아이콘 등을 표시할 수 있도록 text를 받습니다.
Tab이 현재 선택되었는지 판단하기 위해서 Context의 activeTab과 value가 동일한지 여부를 isActive변수에 저장하고 스타일을 적용할 때 사용합니다.
최종적인 Tab의 Props는 아래처럼 정의하였습니다.
function Tab({ value, text }: TabProps) {
const { activeTab, setActiveTab } = useContext(TabsContext)
const isActive = activeTab === value;
return (
<div
className={cn('flex flex-1 cursor-pointer items-center justify-center', {
'border-b-1 border-primary text-subtitle-2 text-primary': isActive,
})}
onClick={() => setActiveTab(value)}
>
{text}
</div>
);
}
6. Panel - 하위 컴포넌트
다음은 선택된 Tab에 대응하는 내용(컴포넌트)이 렌더링되는 Panel 부분입니다. List와 비슷하게 Panel은 children Props의 Wrapper 역할을 합니다. Trigger처럼 value를 받아서 activeTab와 동일한 Panel이 렌더링되도록 합니다.
function Panel({ value, children }: PropsWithChildren<Pick<TabProps, 'value'>>) {
const { activeTab } = useContext(TabsContext);
return <div className={activeTab === value ? 'block' : 'hidden'}>{children}</div>;
}
5. 실제 사용
실제 동작을 확인하기에 앞서 Tabs 컴포넌트의 각 속성에 하위 컴포넌트들을 등록해줍니다.
Tabs.List = List;
Tabs.Tab = Tab;
Tabs.Panel = Panel;
최종적으로 아래와 같이 JSX를 구성하고 렌더링 결과를 확인해보겠습니다.
// contentSection.client.tsx
export default function ContentSection({ detailNode, boardNode }: ContentSectionProps) {
return (
<section>
<Tabs defaultActiveTab={"detail"}>
<Tabs.List>
<Tabs.Tab value="detail" text="상세정보" />
<Tabs.Tab value="board" text="게시판" />
</Tabs.List>
<Tabs.Panel value="detail">
<div className="p-20">{detailNode}</div>
</Tabs.Panel>
<Tabs.Panel value="board">
<div className="p-20">{boardNode}</div>
</Tabs.Panel>
</Tabs>
</section>
);
}
6. 기능 개선
Tabs의 기본적인 형태를 구현하긴 했지만 조금 더 완성도 있는 컴포넌트를 만들고 싶습니다.
보완할만한 기능들을 추가로 정의하고 Tabs 컴포넌트를 개선하고자 합니다.
1. 접근성 지원
Tabs 내부에 위치할 하위 컴포넌트들의 이름을 정할 때 MDN 접근성 가이드를 따라서 지은 바 있습니다. 이름만 접근성을 따르는 것이 아니라 실제로 접근성을 지원하기 위한 여러 가지 HTML 속성을 추가해주었습니다. 이를 위해 사용자가 직접 Tabs 컴포넌트 내부에서 공통적으로 사용할 수 있는 label 값을 입력할 수 있도록 상위 컴포넌트에서 Props를 추가로 받습니다.
const Tabs = ({
label, // 추가
defaultValue,
variant = 'underline',
children,
}) => {
// (생략)
}
우선 List에서 적용할 수 있는 접근성 속성들은 다음과 같습니다.
role: List 컴포넌트가 tablist 역할이라는 것을 명시합니다.aria-label: 현재 List가 무엇을 위한 List인지에 대한 값을 전달합니다.aria-orientation: Tabs의 방향을 적어줍니다. 현재는 외부에서 방향을 제어할 수 없기 때문에 고정값으로 수평 방향을 의미하는 ‘horizontal’로 설정합니다.
const List = ({ children }) => {
const { label } = useTabsContext();
return (
<div
role='tablist'
aria-label={label}
aria-orientation='horizontal'
overflowX="auto"
>
<!-- (생략) -->
</div>
)
}
Tab에서 적용할 수 있는 접근성 속성은 다음과 같습니다.
role: 사용자가 상호작용하게 되는 tab 역할이라는 것을 명시합니다.aria-selected: 현재 선택되었을 경우 true, 아닐 경우 false를 줍니다.aria-controls: 특정 요소가 다른 요소에 변화를 줄 경우, 변화가 일어나는 요소의 Id를 명시합니다. Tab은 Panel에 변화를 일으키키 때문에 값을${label}-panel-${value}로 설정하고 Panel의 Id도 동일하게 설정합니다. (충분히 유일성이 보장될 수 있도록 임의로 Id를 설정했습니다.)tabIndex: MDN 문서에 따르면 Trigger가 focus된 상태에서 키보드 Tab 키를 누르면 다음 Trigger를 focus하는 것이 아니라 Panel 내부로 이동하는 것이 좋다고 합니다. 따라서 선택된 Tab는 tabIndex 값을 0으로 하고 다른 모든 Tab들은 -1로 설정합니다.
const Tab = ({ value, text, icon }) => {
const context = useContext(TabsContext);
const isActive = context.selectedIndex === value;
return (
<button
ref={triggerRef}
id=`${context.label}-trigger-${value}`
role='tab'
aria-selected={isActive}
aria-controls=`${context.label}-panel-${value}`
tabIndex={isActive ? 0 : -1}
disabled={disabled}
onClick={onSelect}
onKeyDown={onPressArrow}
>
<!-- (생략) -->
</button>
)
}
마지막으로 Panel에서 적용할 수 있는 접근성 속성입니다.
role: tabpanel 역할이라는 것을 명시합니다.aria-labelledby: 대응하는 Trigger의 Id를 명시하여 해당 Trigger가 Panel의 라벨 역할을 하도록 참조 관계를 설정합니다.tabIndex: Tab 키를 눌렀을 때 Panel 내부로 focus가 이동할 수 있도록 값을 0으로 설정합니다.
const Panel = ({ value, children }) => {
const context = useContext(TabsContext);
return (
<Container
id=`${context.label}-panel-${value}`
role='tabpanel'
aria-labelledby=`${context.label}-trigger-${value}`
tabIndex={0}
css={css`
padding: 1rem;
display: ${context.selectedIndex === value ? 'block' : 'none'};
`}
>
{children}
</Container>
);
};
2. Context null 확인용 커스텀 훅 생성
createContext()에 전달하는 초기값을 null로 설정했기 때문에 useContext()를 사용할 때마다 불필요한 코드가 생겨나게 됩니다. TypeScript를 사용했기 때문에 useContext()의 반환값이 null이 아닌지 항상 확인해주어야 했습니다. 만약 null일 경우 빈 Fragment를 반환하도록 했습니다.
이에 대한 해결법을 다음 블로그 글에서 찾을 수 있었습니다. Context를 사용할 때 null을 확인하는 로직을 커스텀 훅으로 분리하는 방법입니다.
const useTabsContext = () => {
const context = useContext(TabsContext);
if (context === null) {
throw new Error('useTabsContext should be used within Tabs');
}
return context;
};
이를 사용하면 Context 값을 사용하고자 하는 하위 컴포넌트에서 더 이상 반복적인 null 체크를 진행하지 않고 바로 Object destructuring으로 원하는 값을 가져올 수 있습니다.
const List = ({ children }) => {
const { label } = useTabsContext(); // 추가
return (
// (생략)
)
}
7. 맺으며
Compound Component Pattern을 사용하여 Tabs 컴포넌트를 구현할 때 작은 단위로 나누고 각 컴포넌트를 조합하는 방식을 사용했습니다.
앞으로도 디자인 시스템을 개발하면서 합성 컴포넌트 패턴이 필요한 곳에서 적절하게 도입하며 더 익숙해질 수 있도록 해야겠습니다. 개인적으로 합성 컴포넌트 패턴뿐만 아니라 접근성 관련 속성들을 자세하게 알아보고 적용해볼 수 있었다는 점에서 뜻깊었습니다.
디자인 시스템을 만들면서 추상화에 대해서 생각해보고, 컴포넌트를 사용하는 개발자의 입장을 염두에 두고 개발하는 것이 색다른 경험인 것 같습니다.