ReactJS

[React] MUI로 커스텀 Tabs 컴포넌트 구현하기 (Overflow scroll 및 디자인 디테일 추가)

youjeong_choi 2025. 5. 20. 17:18

React 프로젝트를 개발하다 보면, 상단 탭 인터페이스를 통해 다양한 정보를 전환하고자 하는 요구사항을 자주 마주칩니다. MUI(Material UI)를 활용하면 기본적인 Tabs 컴포넌트를 빠르게 구현할 수 있지만, 복잡한 비즈니스 로직과 사용자 경험을 녹여낸 맞춤형 Tabs 시스템은 꽤나 섬세한 구조를 필요로 합니다. 이번 글에서는 제가 직접 구현한 AppTabs 컴포넌트를 구성 요소별로 분석하며, 주요 기능들을 하나씩 소개해 보겠습니다. 이를 통해 여러분이 확장 가능한 탭 시스템을 구축할 때 어떤 방식으로 접근하면 좋을지 함께 고민해 볼 수 있기를 바랍니다.

 

문제 상황

기존 방식이었던 MUI에서 제공하는 Tabs 컴포넌트를 사용하는 방법의 경우, 디자인 디테일(탭 꼬리 디자인)이 추가됨에 따라 전체적인 Tabs가 흔들리는 문제가 발견되었습니다. 이는 Tabs에서 사용하는 상태인 transientTabsAtom가 바뀔 때마다 MUI에서 추가된 탭 꼬리 컴포넌트를 새롭게 인식하면서 발생한 문제였습니다. 이러한 문제는 Tab에 표시된 name이 바뀔 때 뿐만이 아니고, 해당 페이지가 처음 렌더링될 때도 발생하여 UX를 저해하였습니다. 결국 MUI 방식의 구조적 문제를 해결하기위해 MUI에서 제공하는 Tabs 컴포넌트를 직접 사용하지 않고 Button 컴포넌트를 사용하여 동일한 기능들을 구현해보았습니다. 

 

기본 구조와 목적

AppTabs는 아래와 같은 요구사항을 해결하기 위한 목적을 갖고 설계되었습니다:

  • Persistent Tabs: 항상 존재하고, 삭제되지 않는 고정 탭들
  • Transient Tabs: 사용자의 행동에 따라 동적으로 추가되거나 제거되는 탭들
  • Overflow 감지 및 좌우 스크롤 지원
  • 탭 꼬리(Tail) 디자인으로 시각적 포커스 제공
  • 탭 삭제 버튼 제공 (조건부 렌더링)

 

1. Props와 상태관리

interface AppTabsProps {
  app: App;
  label?: string;
}
  • app: 현재 앱의 정보를 담고 있는 객체 (route, category 등 포함)
  • label: 현재 탭의 라벨을 결정하기 위한 선택적 prop
const [userTransientTabs, setUserTransientTabs] = useAtom(transientTabsAtom);
  • Jotai를 활용한 전역 상태 관리로 transient tabs를 관리합니다.

 

2. Persistent vs Transient Tabs

  • Persistent Tabs는 defaultTabs 또는 앱별 wholeTabs 설정을 기반으로 생성됩니다.
    • disabledIfRetired와 같은 비즈니스 로직도 반영
    • 라우팅도 자동 설정
  • Transient Tabs는 사용자의 행동(탭 클릭, 이동 등)에 따라 동적으로 추가됩니다.
    • 동일한 route의 탭이 이미 있는 경우 timestamp만 갱신
    • 최대 갯수(maxNumberOfTabsPerApp) 초과 시 오래된 탭 제거
if (transientTabs.length >= maxNumberOfTabsPerApp) {
  // 오래된 탭 제거 로직
}
  • 에러 탭 관리도 포함되어 있어, 특정 조건에서 자동 제거됩니다.

 

3. Overflow 스크롤 감지 및 제어

const useScrollState = (ref: RefObject<HTMLDivElement>, tabs: TransientTab[]) => { ... }

내부 기능

  • ResizeObserver를 통해 스크롤 overflow 여부 및 위치 상태 추적
  • scrollToEdge()를 통해 좌우로 부드럽게 스크롤 가능
  • atStart, atEnd 상태값을 통해 좌우 화살표 버튼 활성화 판단
{isOverflowing && (
  <IconButton onClick={() => scrollToEdge('left')} disabled={atStart}>
    <NavigateBeforeRoundedIcon />
  </IconButton>
)}

 

 

4. 커스텀 탭 디자인 (Tail 포함)

선택된 탭 양 옆에 시각적 강조를 위한 Tail 컴포넌트를 추가하여 유저가 현재 어떤 탭을 보고 있는지 명확히 인지할 수 있도록 돕습니다.

const Tail = ({ leftSide }: { leftSide?: boolean }) => {
  const borderRadius = {
    [leftSide ? 'borderBottomRightRadius' : 'borderBottomLeftRadius']: '10px',
  };
  return <Box ... />;
};
  • selected 상태에 따라 Tail이 좌우에 렌더링됨
  • theme.palette를 활용하여 라이트/다크 테마 모두 대응

 

5. 탭 삭제 기능

조건부로 탭의 우측에 CloseIcon 버튼이 나타나며, 클릭 시 해당 탭이 제거됩니다. 단, 특정 최소 갯수(minNumberOfTabsPerAppForClose) 이상일 때만 표시됩니다.

{transientTabs.length >= minNumberOfTabsPerAppForClose && (
  <IconButton onClick={(event) => handleOnClickDelete(event, e.route)}>
    <CloseIcon fontSize="inherit" />
  </IconButton>
)}

 

6. 라우팅 연동

React Router의 useLocation, useNavigate, useParams를 사용하여 아래와 같은 로직을 구현합니다:

  • 현재 route와 탭 정보가 일치하면 자동으로 selected 상태 유지
  • 삭제 시 현재 탭이 제거된다면 기본 persistent 탭으로 리디렉션
if (pathname === route) navigate(persistentTabs[0].route);

 

7. 라벨 처리 및 Tooltip UX

label={
  <Tooltip title={getTransientTabLabel(e)} placement="top">
    <Box>...</Box>
  </Tooltip>
}
  • 탭의 이름이 길어질 경우 ellipsis + Tooltip 패턴으로 UX 개선
  • 앱 카테고리에 따라 라벨 표시 형식을 다르게 구성 (예: /id 추출 등)

 

마무리 ✨

✅ Transient vs Persistent 구조 분리
✅ Overflow 및 반응형 대응
✅ 사용자 경험을 위한 Tail 및 Tooltip
✅ 동적 탭 추가/삭제, 자동 갱신, 스크롤 컨트롤
✅ 전역 상태 관리와 라우팅 연동