재사용할 수 있고 편리한 컴포넌트를 만들어보자

드뮴·2025년 2월 28일
1

🪴 개발일지

목록 보기
4/6
post-thumbnail

현재 리팩토링 있는 프로젝트에서 탭 컴포넌트를 사용하고 있다. 탭 컴포넌트는 총 4군데에서 사용되고 있는데 이 탭 컴포넌트는 사용하는 모든 곳에서 각각 만들어주었다. (구현 방식은 제각각이긴 했다.)
디자인도 같은데, 이를 공통 컴포넌트로 만들어 사용하면 더 효율적일 거 같다는 생각이 들었다.

하지만 생각보다 간단한 문제는 아니었다. 공통 컴포넌트로 만든 과정을 작성했다.

처음으로 리액트 Context API를 사용해보았는데, 전역 상태가 아닌 Context API가 이번 리팩토링에서 편리했던게 인상적이어서 그 부분에 대해서도 글을 작성해보았다.


현재 탭 컴포넌트의 문제는?

import Tab from "./Tab";

interface CategoryTabProps {
  currentTab: boolean;
  setCurrentTab: React.Dispatch<React.SetStateAction<boolean>>;
}

const TabContainer = ({ currentTab, setCurrentTab }: CategoryTabProps) => {
  return (
    <section className="relative border-b flex gap-4">
      <Tab
        currentTab={currentTab}
        tabStatus={false}
        setCurrentTab={setCurrentTab}
      />
      <Tab
        currentTab={currentTab}
        tabStatus={true}
        setCurrentTab={setCurrentTab}
      />
      <div
        className={`absolute -z-0 pointer-events-none top-0 ${currentTab ? "left-32" : "left-0"} transition-all duration-200 pt-4 py-2 p-1 border-b border-green-200 h-full w-28`}
      />
    </section>
  );
};

export default TabContainer;
interface TabProps {
  currentTab: boolean;
  tabStatus: boolean;
  setCurrentTab: React.Dispatch<React.SetStateAction<boolean>>;
}

const Tab = ({ currentTab, tabStatus, setCurrentTab }: TabProps) => {
  return (
    <>
      <button
        className={`w-28 h-full ${currentTab === tabStatus
          ? "text-green-600 text-semibold-r"
          : "text-semibold-r text-gray-400"
          }`}
        onClick={() => setCurrentTab(tabStatus)}
      >
        <span className={`block py-2 ${currentTab === tabStatus ? " " : "border-transparent"}`}>
          {tabStatus ? "진행 중인 채널" : "대기 중인 채널"}
        </span>
      </button>
    </>
  );
};

export default Tab;

  • 탭 컨테이너는 말 그대로 탭을 여러개 나열해두면 보여주는 컴포넌트다.
  • 탭 컴포넌트는 현재 tabStatus에 따라 탭의 이름을 보여준다.
  • 즉, 현재 채널 탭은 2개로 구성되어 tab 상태를 false, true로만 구분이 가능하도록 구현되어있었다.

이런 구조는 무엇이 잘못된걸까?
이런 구조는 사용하기 불편하고 확장성도 없다고 생각을 했다. 탭을 만약 여러 개를 만든다면 분명 이 구조는 그대로 사용할 수 없었고, 탭 컴포넌트 안에서 탭에 대한 이름을 넣어주기 때문에 다른 페이지에서는 이용이 불가능했다. 즉, 재사용할 수 없었고 탭을 추가할 수 없었다. 또한 탭에 해당하는 내용을 보여주는 부분이 없이 그냥 currentTab이 무엇인지에 따라 다른 컴포넌트에서 내용을 관리해주고 있었다. 사용하는 입장에서도 탭 컴포넌트를 어떻게 만들었는지 제대로 이해하고 써야 수정할 수 있는 구조였다.


탭 컴포넌트를 재사용 가능하게 수정하려면?

일단 위 탭 컴포넌트의 문제를 정리하면 다음과 같다.

  • 탭 타입이 boolean 타입이라 탭이 2개로 제한된다.
  • 탭 내용, 스타일이 하드코딩되어 있어 다른 컨텍스트에서 재사용이 어렵다.
  • 탭 이동 애니메이션이 하드코딩된 위치 값에 의존한다.
  • tabStatus도 boolean 값으로 탭 식별이 직관적이지 않다. (tabStauts가 true인 것과 tabStatus가 false인 것의 의미를 어떻게 해석할 수 있을까? 생각해보면 의미를 알기 어렵다. 확장성에 제한이 있는 것도 사실이지만 코드의 의미를 알 수 없다는 점도 큰 단점이라 생각했다.)
  • 각 탭은 자신이 어떤 탭인지 tabStatus와 currentTab을 모두 알아야한다.
  • 각 탭에 해당하는 내용을 관리해주는게 아닌 currentTab 상태를 공유해서 다른 컴포넌트에서 내용을 처리해줬다.

탭도 사용하는 컨텍스트에 따라 조금씩 달랐는데 다른 곳에서 사용하는 탭의 경우에는 선택된 탭을 표시하는 밑줄이 탭이 3개부터 추가되면 위치가 이상했다. (2개일 때만 고려해서 위치 계산이 잘못되어 있었던 것이다.)

코드를 작성하기 편하려면?

코드를 작성하기 편하게 설계하려면 가장 중요한 건 내부 구조를 몰라도 사용할 수 있어야한다는 생각이 들었다. 탭 이름만 전달해도 탭이 만들어지고 그 내부가 어떻게 구성되던 탭을 쉽게 사용할 수 있는 컴포넌트를 만들어야겠다 생각했다.

  1. 탭의 내부를 보지 않고도 사용 방법을 보고 쉽게 가져다 쓸 수 있는 컴포넌트를 만들자.
  2. 탭 개수 제한 없이 탭을 만들고 코드를 읽었을 때 가독성이 좋도록 의미있는 코드를 작성하자.

탭 컴포넌트 사용하기

TabProvider 안에 TabList와 TabPanel을 넣어주고, TabList 안에는 탭에 보여질 탭들을 추가해준다.

최종적으로 완성된 탭 컴포넌트 코드는 위와 같다.

TabProvider

  • 탭 관련 컴포넌트 상태를 관리해주는 최상위 컴포넌트다.
  • props로 기본 설정 탭(defaultTab에는 탭 id를 전달해서 기본으로 보여줄 탭을 설정할 수 있다)과 className(선택)을 전달해줄 수 있다.

TabList

  • TabItem을 넣을 수 있다.
  • 어떤 탭이던 원하는 탭을 넣을 수 있고, TabItem에는 id를 정해준다. 이 id를 통해 탭을 식별한다.
  • TabItem 외에 TabListContent를 통해 탭 말고 다른 아이템도 넣을 수 있다. 이는 필요할 때만 사용해서 원하는 위치에 위치시킬 수 있지만, 탭만 필요하다면 사용할 필요가 없다.

TabPanel

  • 선택된 탭의 내용을 보여주는 컴포넌트다.
  • 위에 작성한 탭 id와 통일시키면 해당 id에 해당하는 탭의 내용을 보여줄 수 있다.
  • 마찬가지로 className을 전달할 수 있다.

위와 같이 구성되게 했고, 탭 컴포넌트를 사용하는 영역에서 개발자는 위 구성요소를 이용해 원하는 탭을 쉽게 추가해서 id만 넣어주고, 탭에 보여질 내용 컴포넌트만 만들어서 넣어주면 된다. 사용자는 탭의 상태 관리나 스타일 변경에 대해 신경 쓸 필요가 없다.


완성된 탭 컴포넌트

탭 컴포넌트르 어떻게하면 쉽게 가져다 쓸 수 있게 할 수 있을까?에 대한 고민이 많았다. 그러다 알게된 패턴이 컴포지션 패턴(Compound Component Pattern)이었다. 복잡한 UI를 직관적으로 사용할 수 있게 해주는 패턴이라고 해서 이에 대해서도 공부해보았다.

📌 컴포지션 패턴

여러 개의 작은 컴포넌트들이 서로 결합하여 하나의 복합적 기능을 제공하는 방식이다. 각 컴포넌트는 독립적인 역할을 하지만, Context API와 같은 상태 공유를 통해 통신할 수 있다.

  • 코드만 보고도 무엇을 하는지 쉽게 이해할 수 있는 패턴이다.
  • props API에서는 어려운 유연성을 제공한다.
  • 내부 상태를 숨겨서 사용자는 상태 관리 로직을 신경 쓸 필요없이 구성할 수 있다.
  • 컴포넌트는 각자 자신의 책임만 가지고 있어 관심사 분리가 이루어지고, 코드 이해나 수정이 쉽다.

전에 React Bootstrap의 탭 컴포넌트를 사용한 적이 있었는데, 이를 보고 유사하게 만들면 사용하기 편하겠다는 생각이 들었다. 이를 컴포지션 패턴으로 구현해서 사용하기 쉬운 컴포넌트를 구성할 수 있었다. 컴포지션 패턴은 자주 들어봤었고 사용하게 되면 편할까?를 전체 리팩토링 시 적용하며 느껴보고 싶었는데 탭 컴포넌트에 적용해보며 패턴을 이해할 수 있었다.


TabContext

export const TabContext = createContext<TabContextType | null>(null);

export const useTabContext = () => {
  const context = useContext(TabContext);
  if (!context) {
    throw new Error("useTabContext는 TabProvider 내부에서 사용되어야 합니다.");
  }
  return context;
}

TabProvider

const TabProvider = ({
  children,
  defaultTab,
  className = ""
}: ProviderProps) => {
  const [activeTab, setActiveTab] = useState(defaultTab);

  const contextValue = {
    activeTab,
    setActiveTab
  };

  return (
    <TabContext.Provider value={contextValue}>
      <div className={className}>
        {children}
      </div>
    </TabContext.Provider>
  )
}
  • 탭 상태를 관리하는 최상위 컴포넌트로, defaultTab도 입력 받아서 선택하지 않았을 때 기본 탭을 정할 수 있다.

TabList

const TabList = ({ children }: ListProps) => {
  const { activeTab } = useTabContext();
  const listRef = useRef<HTMLDivElement>(null);
  const [indicatorStyle, setIndicatorStyle] = useState({ left: 0, width: 0 });

  useEffect(() => {
    if (!listRef.current) return;

    const tabRefs = Array.from(listRef.current.children)
      .filter(child => child.getAttribute("role") === "tab");

    const activeTabElement = tabRefs.find(
      tab => tab.getAttribute("data-tab-id") === activeTab
    ) as HTMLElement;

    if (activeTabElement) {
      setIndicatorStyle({
        left: activeTabElement.offsetLeft,
        width: activeTabElement.offsetWidth
      });
    }
  }, [activeTab]);

  return (
    <div className="relative flex gap-6 my-4 ml-2 pb-2" ref={listRef} >
      {children}
      <div
        className="absolute -z-0 pointer-events-none top-0 transition-all duration-200 pt-4 py-2 p-1 border-b-2 border-green-300 h-full"
        style={{
          left: `${indicatorStyle.left - 4}px`,
          width: `${indicatorStyle.width + 8}px`
        }
        }
      />
    </div >
  )
}
  • 탭들을 관리하는 컴포넌트다.
  • 탭 여러개 중 선택된 컴포넌트에 밑줄을 줘야하므로, 선택된 탭이 무엇인지 확인하고 이에 맞춰 위치를 조정하도록 한다.
  • useRef로 실제 DOM을 참조하는 이유는 그냥 테두리 효과만 주는게 아닌 애니메이션을 주기 위해서 선택했다. (애니메이션을 넣을게 아니라면 그냥 탭 컴포넌트 자체에 선택된 상태라면 아래 테두리를 주면 된다.)

TabItem

const TabItem = ({ id, children }: ItemProps) => {
  const { activeTab, setActiveTab } = useTabContext();
  const isActive = activeTab === id;

  return (
    <button
      role="tab"
      data-tab-id={id}
      aria-selected={isActive}
      className={`flex flex-row gap-1 
        ${isActive
          ? "text-bold-s text-green-500"
          : "text-medium-l text-gray-400"
        }`}
      onClick={() => setActiveTab(id)}
    >
      <span className="px-1 pb-1">
        {children}
      </span>
    </button>
  )
}
  • 탭에 있는 요소들을 구성하는 컴포넌트다.
  • 탭 버튼을 누르면 선택된 탭의 상태가 바뀌고, 상태에 따라 색상이 달라진다.

TabListContent

const TabListContent = ({
  children,
  className
}: ListContentProps) => {
  return (
    <div className={className}>
      {children}
    </div>
  )
}
  • TabList 컴포넌트 내부에서 사용할 수 있는 컴포넌트로, 탭 말고 다른 버튼이나 다른 요소가 필요한 경우 사용할 수 있다.
  • className을 통해 원하는 위치에 위치시킬 수 있다.

TabPanel

const TabPanel = ({
  id,
  children,
  className = ""
}: PanelProps) => {
  const { activeTab } = useTabContext();
  const isActive = activeTab === id;

  if (!isActive) return null;

  return (
    <div role="tabpanel" className={className}>
      {children}
    </div>
  )
}
  • 탭의 내용을 보여주는 컴포넌트다.
  • id를 전달 받아 현재 선택된 탭의 아이디와 일치하는 경우에만 내용을 보여주고, 아니라면 해당 컴포넌트를 보여주지 않는다.

전역 상태 라이브러리가 아닌 Context API를 사용한 이유

프로젝트에서 전역 상태 라이브러리인 Zustand를 사용하고 있었다. 그런데 탭 컴포넌트를 리팩토링할 때 props drilling의 불편함을 전역 상태가 아닌 Context API를 통해 해결했다.

왜 Context API를 선택했을까?

Context API를 이용해 현재 activeTab 즉, 현재 선택된 탭 상태를 관리했다.

const TabProvider = ({ ... }: ProviderProps) => {
  const [activeTab, setActiveTab] = useState(defaultTab);

  const contextValue = {
    activeTab,
    setActiveTab
  };

  return (
    <TabContext.Provider value={contextValue}>
      <div className={className}>
        {children}
      </div>
    </TabContext.Provider>
  )
}
  • TabProvider에서는 자체적인 상태를 가질 수 있게 만들었다.
  • 즉, TabProvider를 다른 컨텍스트에서 가져와 사용하게 되면 독립된 인스턴스를 가지게 된다.
  • 따라서 복잡한 상태 구조 없이 간단하게 구현이 가능하다.
  • context의 activeTab 상태를 가져와 어떤 탭이 선택되었는지 보여줄 수 있고, 탭을 선택할 때 setTabActiveTab을 호출해 상태를 바꿔줄 수 있게 한다.

Context API는 TabProvider 인스턴스가 자체 상태를 가질 수 있게 해주고, 리액트가 생명주기를 관리하기 때문에 신경 쓸 필요가 없다. 또한 복잡한 스토어를 생성할 필요 없이 한 번 컴포넌트로 만들어두면, 이를 탭을 사용하려는 컨텍스트에서 구조에 맞게 작성만 해주면 상태 관리를 신경 쓸 필요가 없는 것이다.


Zustand를 선택했다면?

Zustand로도 독립된 인스턴스를 구현할 수 있다. 그러나 동적으로 스토어를 생성해야한다.

import { create } from "zustand";
import { nanoid } from "nanoid"; // 고유 id 생성을 위한 라이브러리

interface TabStore {
  activeTab: string;
  setActiveTab: (id: string) => void;
}

// 전역 스토어 인스턴스 맵
const storeInstances = new Map<string, ReturnType<typeof create<TabStore>>>();
                                                  
// 새로운 탭 스토어 생성 함수
export const createTabStore = (defaultTab = "") => {
  const storeId = nanoid(); // 고유 id 생성
  
  // 새 스토어 인스턴스 생성
  const useStore = create<TabStore>((set) => ({
    activeTab: defaultTab,
    setActiveTab: (id: string) => set({ activeTab: id })
  }));

  // 스토어 인스턴스 맵에 저장
  storeInstances.set(storeId, useStore);

  return {
    useStore,
    storeId,
    cleanup: () => storeInstances.delete(storeId)
  }
}

// 특정 스토어 인스턴스 가져오기
export const getTabStore = (storeId: string) => {
  return storeInstances.get(storeId);
}
  • 위 코드를 보면, storeInstances에 탭을 사용하는 컴포넌트에서 호출하는 탭 스토어 인스턴스를 저장해서 관리해준다.
  • 탭을 사용하는 컨텍스트에서는 고유한 스토어를 생성해야하기 때문에 const storeRef = useRef(createTabStore(defaultTab));을 호출한다.
    • useRef로 탭 스토어를 관리하는 이유는 useRef로 관리하지 않게 되면, 컴포넌트가 렌더링 될 때마다 createTabStore가 호출되어 새 스토어가 생성되어 상태가 초기화된다.
    • 그렇게 되면 불필요한 스토어 인스턴스가 계속 생성되고, 메모리 누수가 발생한다.

이렇게 스토어를 만들고 더 이상 구현하지 않았다.
Zustand를 사용하지 않아야 생각했던 건 목적에 대해 더 생각하보니 명확해졌다. 탭 컴포넌트는 독립된 상태를 가져야했는데, Zustand는 전역 상태를 위해 설계되었는데 이를 이용해 지역 상태처럼 사용하려하는 방식이었다. 그렇다면 굳이 전역 상태 라이브러리로 지역 상태를 복잡하게 구현하려는 노력이 의미있을까?하는 생각이 들었다.

profile
안녕하세오

0개의 댓글

관련 채용 정보