
나는 어느순간부터 '범용적이면서도 재사용성이 높은 공통 컴포넌트 만들기' 에 대해 고민을 많이했다. 팀 프로젝트를 할 때 마다 공통 컴포넌트를 먼저 만들고 시작하는데, 항상 내가 만든 컴포넌트가 만족스럽지 않았다.
특히 지난 코드잇 인턴 프로젝트를 하면서 이 부분에 대해 나의 부족함을 느꼈다. 드롭다운을 만들었는데, 드롭다운과 팝오버를 짬뽕해서 만들기도 했고 드롭다운의 디자인 분기가 너무 많아서 점점 무거워지는 현상을 겪었다. 이에 대해서 정말 잘 만들어진 공통 컴포넌트는 어떻게 생겼을까? 라는 질문이 생겼고, 이에 대해 공부하기 시작했다.
구글에 '공통 컴포넌트' 라는 제목을 치자마자 아래와 같은 글이 떴다.
"더 가치있는 공통 컴포넌트 만들기"
https://fe-developers.kakaoent.com/2024/240116-common-component/
이 글에서 처음에 뒷통수를 맞은 듯한 기분이 들었던 부분은 "만능 컴포넌트 지양하기" 였다. 항상 공통 컴포넌트라고 하면 어디서든 재사용이 가능하고 수많은 케이스들을 대응하는게 "맞다" 라고 생각했었는데, 그 생각의 틀을 깨게 해주었다.
공통 컴포넌트가 많은 역할을 수행하도록 변경할수록 사용하는 쪽에서는 prop을 더 구체적으로 명시해야 하고 유지보수에 많은 시간 소모를 하게 되는데, 이는 공통 컴포넌트의 본래 목적인 '개발시간을 줄이고 유지보수를 간편하게 하는 역할'을 퇴색하게 만든다. 때문에 프로젝트의 규모와 상황에 맞게 개발하고자 하는 공통 컴포넌트의 책임과 역할을 명확히 하고, 그 경계를 벗어나지 않는 선에서 확장성을 확보해야한다.
// common/BaseButton.tsx
import cn from "classnames";
interface Props {
className?: string;
}
export default function BaseButton({ className }: Props) {
return <button className={cn('base-button-class', className)} />;
}
// common/MelonButton.tsx
export default function MelonButton() {
return <BaseButton className="melon-button-class" />;
}
// common/KakaoButton.tsx
export default function KakaoButton() {
return <BaseButton className="kakao-button-class" />;
}
이런 식으로도 쓸 수 있다는 점을 새롭게 알게됐다. 기본적인 버튼의 틀만 있고, 이렇게 내가 필요한 버튼을 다양하게 만들 수 있다면 유지보수와 단일 책임 원칙등이 잘 지켜질 것 같다고 생각되었다.
여러 블로그들의 글만 보았을 때, 대부분 컴포넌트별 역할이 잘 분리되고 높은 재사용성을 가지기 위해 합성컴포넌트를 사용해서 조합하는 방식을 채택한다고 느꼈다. 저번에 드롭다운 만들 때 컴파운드 패턴을 사용했다가 점점 props가 늘어나는 경험을 해보긴 했지만, 이번에는 좀 더 제대로 만들어보고 싶다고 생각했다.


(현재 사용되는 곳은 위와 같이 두 군데로 각 버튼 마다 색이나 패딩이 다르다.)
합성 컴포넌트로 만들기 위해서 먼저
interface TabsContextType {
activeTab: string;
setActiveTab: (value: string) => void;
}
const TabsContext = createContext<TabsContextType | undefined>(undefined);
const useTabsContext = () => {
const context = useContext(TabsContext);
if (!context) {
throw new Error(
"Tabs 컴포넌트는 <Tabs> provider와 함께 사용되어야 합니다.",
);
}
return context;
};
TabsContext를 생성한다. 이 context에서는 현재 활성화된 탭과 활성 탭을 변경할 수 있는 함수를 하위로 전달한다.
자식 컴포넌트는 useTabsContext라는 커스텀 훅을 사용하여 activeTab과 setActiveTab에 접근할 수 있다.
function Tabs({
children,
defaultValue,
}: {
children: ReactNode;
defaultValue: string;
}) {
const [activeTab, setActiveTab] = useState(defaultValue);
const contextValue = useMemo(
() => ({ activeTab, setActiveTab }),
[activeTab, setActiveTab],
);
return (
<TabsContext.Provider value={contextValue}>{children}</TabsContext.Provider>
);
}
그리고 Tabs 컴포넌트를 생성하여 Prop으로 자식요소와 초기에 활성화될 탭의 기본값을 받는다. 탭의 상태를 TabsContext를 통해서 자식 컴포넌트에 전달하며, 탭 간의 활성화 상태를 관리하는 역할을 한다. 여기서 사용되는 useMemo는 activeTab 또는 setActiveTab이 변경될 때만 contextValue를 생성할 수 있도록 하여 불필요한 리렌더링을 방지한다.
function TabsTrigger({
value,
children,
buttonColor = "green",
rounded = "sm",
padding = "sm",
}: {
value: string;
children: ReactNode;
buttonColor?: "green" | "red" | "blue";
rounded?: "sm" | "md";
padding?: "sm" | "md";
}) {
const { activeTab, setActiveTab } = useTabsContext();
const isActive = activeTab === value;
const backgroundColors: { [key in "green" | "red" | "blue"]: string } = {
green: "bg-[#E9FFF0]",
red: "bg-[#FDEBEB]",
blue: "bg-[#EDF1FC]",
};
return (
<button
type="button"
className={cn(
"px-4 py-2 rounded-2",
isActive ? backgroundColors[buttonColor] : "text-[#B6B6B6]",
rounded === "md" && "rounded-4",
padding === "md" && "px-16 py-4",
)}
onClick={() => setActiveTab(value)}
>
{children}
</button>
);
}
각 탭을 나타내는 트리거 버튼이다. 현재 탭과 버튼의 value가 같으면 활성화 상태로 간주한다.
value는 탭의 고유 값이며, 클릭 시 Tabs의 activeTab과 비교하여 활성 상태를 결정한다. children에는 버튼에 표시할 text를 작성하면 된다.
버튼의 스타일을 확장성있게 카카오 블로그에서 본 것과 같이 만들까 고민했지만, 아직 프로젝트의 규모가 작은 편이라 다른 개발자들이 사용하기 편하게 프롭으로 받게 만들었다.
function TabsContent({
value,
children,
className,
}: {
value: string;
children: ReactNode;
className?: string;
}) {
const { activeTab } = useTabsContext();
if (activeTab !== value) return null;
return <div className={cn("mt-20", className)}>{children}</div>;
}
export { Tabs, TabsList, TabsTrigger, TabsContent };
활성화된 탭과 연관된 내용만 보여주는 역할을 하며 value로는 콘텐츠가 연결된 탭의 고유값을 넣어준다. TabsTrigger에서 클릭된 탭과 일치할 때만 이 콘텐츠가 렌더링 된다. 마찬가지로 children으로는 콘텐츠 내부에 렌더링할 컴포넌트를 넣어줄 수 있다.
현재 활성화된 탭의 value와 콘텐츠의 value가 일치하지 않을 경우 null을 반환하게 하여 활성화된 탭과 관련 없는 콘텐츠는 화면에 보이지 않게 했다.
이렇게 합성 컴포넌트를 스토리북 코드로 확인할 수 있도록 작성했다.
import type { Meta, StoryObj } from "@storybook/react";
import React from "react";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "./index";
const meta: Meta<typeof Tabs> = {
title: "Components/Tabs",
component: Tabs,
tags: ["autodocs"],
argTypes: {
defaultValue: {
control: "text",
defaultValue: "tab1",
},
},
};
export default meta;
type Story = StoryObj<typeof Tabs>;
export const Default: Story = {
args: {
defaultValue: "tab1",
children: (
<>
<TabsList>
<TabsTrigger value="tab1">Tab 1</TabsTrigger>
<TabsTrigger rounded="md" buttonColor="red" value="tab2">
Tab 2
</TabsTrigger>
<TabsTrigger buttonColor="blue" value="tab3">
Tab 3
</TabsTrigger>
</TabsList>
<TabsContent value="tab1">Tab1의 내용입니다.</TabsContent>
<TabsContent value="tab2">Tab2의 내용입니다.</TabsContent>
<TabsContent className="mt-23" value="tab3">
Tab3의 내용입니다.
</TabsContent>
</>
),
},
};
현재 복잡한 기능이 없어서 그런지 컴파운드 패턴에 대한 이해도가 높아지고, 어떻게 작용하는지 알 수 있어 좋았다. 만약 버튼의 디자인이 많아질 경우
// common/BaseButton.tsx
import cn from "classnames";
interface Props {
className?: string;
}
export default function BaseButton({ className }: Props) {
return <button className={cn('base-button-class', className)} />;
}
// common/MelonButton.tsx
export default function MelonButton() {
return <BaseButton className="melon-button-class" />;
}
// common/KakaoButton.tsx
export default function KakaoButton() {
return <BaseButton className="kakao-button-class" />;
}
이런 패턴으로 교체할 수 있게 만들어서 기본 탭의 스타일에 너무 많은 조건을 넣지 않을 수 있게 하는 것도 좋은 것 같다. 내가 현재 만든 Tabs가 정말 다른 이들의 눈에 '범용적인 공통 컴포넌트'로 보일지 모르겠지만, 현재 지식에서는 최선인걸로,,ㅎㅎㅎ😊