Compount Component Pattern으로 Select 컴포넌트 리팩토링하기

ujinsim·2025년 4월 3일
post-thumbnail

Select 컴포넌트를 리팩토링하기 전 고민

기존에 Select 컴포넌트 코드는 이러합니다.

'use client';
import React, { useState } from 'react';
import { Icon } from '../Icon';

type Props = {
  contents: string[];
  size?: 'md' | 'lg';
  placeholder?: string;
};

export function Select({ contents, size, placeholder }: Props) {
  const [selectedContent, setSelectedContent] = useState(placeholder ? placeholder : contents[0]);
  const [openSelect, setOpenSelect] = useState(false);

  return (
    <div
      className={`${openSelect ? 'rounded-lg' : 'rounded-lg border border-gray-100'} text-b'${
        size === 'md' ? 'md:min-w-26 w-fit min-w-24' : 'md:min-w-66 w-60'
      } cursor-pointer border border-gray-200 bg-white text-start text-base font-semibold text-gray-400 md:text-lg`}
    >
      <div
        className={`${
          openSelect
            ? 'border-b-2 border-gray-100 hover:rounded-lg hover:rounded-b-none'
            : 'border-0'
        } flex justify-between ${
          size === 'md' ? 'items-center py-1 pl-3 pr-2 text-sm' : 'px-5 py-2 md:py-3'
        } hover:rounded-md hover:bg-gray-50`}
        onClick={() => setOpenSelect(!openSelect)}
      >
        {selectedContent}

        <Icon
          name="arrowDown"
          className={`transform transition-transform duration-300 ${
            openSelect ? 'rotate-180' : ''
          } ${size === 'md' ? 'w-5' : ''} }`}
        />
      </div>

      {openSelect && (
        <div className="flex flex-col">
          {contents.map((item, key) => (
            <div
              onClick={() => {
                setSelectedContent(contents[key]);
                setOpenSelect(!openSelect);
              }}
              className={`cursor-pointer border-gray-100 ${
                size === 'md' ? 'px-3 py-1 text-sm' : 'px-5 py-2 md:py-3'
              } last:border-none hover:bg-gray-50 hover:last:rounded-b-md`}
              key={key}
            >
              {item}
            </div>
          ))}
        </div>
      )}
    </div>
  );
}

무언가 한덩이로 크게 묶여져있는 Select 코드..

모든 로직과 스타일이 하나의 컴포넌트 안에 꽉 묶여 있었습니다.
선택된 항목 상태 관리부터 드롭다운 토글, 아이템 렌더링, 스타일링까지… 모든 역할이 한 파일에 들어있는 모습입니다.

Select 뿐만 아니라 GroupingComponent도 있는데 groupName만 추가된 형태임에도 새로운 컴포넌트를 만들어야됐습니다

이 경험을 계기로 자연스럽게 이런 질문을 던지게 됐습니다.

Q1. Select 컴포넌트의 ‘책임’은 어디까지여야 할까?
Q2. 조금 다른 UI/동작을 위해 새로운 컴포넌트를 만드는 것이 과연 효율적인가?
Q3. 재사용성과 확장성을 모두 고려한 UI 컴포넌트 설계 기준은 무엇인가?

이 고민은 단순한 리팩토링을 넘어, "컴포넌트는 기능 단위가 아니라 역할 단위로 쪼개야 한다"는 깨달음으로 이어졌습니다.


리팩토링 방향을 얻은 인사이트

Effective Component 강의 「지속 가능한 성장과 컴포넌트」를 보고 다음과 같은 인사이트를 얻었습니다.

컴포넌트를 나누는 기준

1. Headless 기반으로 추상화하기

변하는 것과 변하지 않는 것을 나누자.
데이터는 같지만 UI는 달라질 수 있다면 UI와 데이터를 분리하자.

  • 예: 달력
    • 데이터는 훅으로 관리 (예: useCalendar)
    • UI는 별도 분리 → Headless 모듈화

💡 변화하는 부분은 , 보여지는 부분은 컴포넌트로 분리


2. 한 가지 역할만 하기 → Composition Pattern

하나의 역할을 가진 컴포넌트를 여러 개 조합해서 구성하자.

  • 예: Select
    • 선택된 값을 보여주는 Trigger
    • 드롭다운 메뉴인 Menu
    • 실제 아이템 목록인 Item

→ 이벤트 흐름: onClickonChange

이렇게 하면 각 컴포넌트는 서로를 몰라도 되고, 독립적으로 재사용 가능

3. 도메인 분리하기

데이터 주입받는 방식도 컴포넌트처럼 분리해야 한다.

  • 도메인을 알지 못하는 컴포넌트
  • 도메인을 포함하는 컴포넌트

이 둘을 분리하면 외부 의존성 없이도 관리하기 쉬움

예: 인터페이스 정의부터 고민하기


해당 영상을 보고 Select 컴포넌트의 문제를 명확하게 알 수 있었습니다.

만약 이 Select에 MultiSelect나 검색 기능이 추가된다면, 어디부터 손대야되는지 막막했습니다.
기능을 더하려 할수록 점점 커지고, 더 복잡해지는 컴포넌트. 확장에는 약하고, 변경에는 민감한 구조라는 걸 느꼈습니다.
때문에 변경에 유연한 컴포넌트 형태로 리팩토링 해야겠다고 생각이 되었습니다.


리팩토링 구조 예시

영상에서 dropdown컴포넌트를 다음과같이 구조화한 예시를 보여주었습니다

  • DropdownTrigger : 열고 닫는 역할
  • Menu : 리스트의 wrapper
  • Item : 실제 선택할 항목
  • 이벤트 흐름은 onClickonChange

변경된 컴포넌트는 서로의 존재를 몰라도 동작합니다.
그리고 이러한 구조는 확장성재사용성 유지보수성을 높입니다


영상에서는 컴포넌를 짜기 위한 2가지의 흐름을 알려줍니다

1. 인터페이스 먼저 고민하기

“이 컴포넌트의 의도는 무엇인가?”
“기능보다 표현 방식이 더 중요할 수도 있다.”


2. 컴포넌트를 분리하는 이유를 항상 생각하기

  • 정말 복잡도를 낮추는가?
  • 정말 재사용 가능한가?

“리팩토링은 기능을 더하는 것이 아니라, 이해를 더하는 과정이다.”


새롭게 Select 컴포넌트 리팩토링 방향을 정해보았습니다

  1. 역할 단위 분리: 기능을 기준으로 파일 분리를 통해 책임을 명확히 구분
  2. Context 도입: 하위 컴포넌트들이 상태를 공유하도록 하여 prop drilling 제거
  3. 재사용성 향상: Option, TriggerButton 등은 다른 Select 변형에도 재활용 가능
  4. Storybook 구성: 유지보수 시 시각적으로 컴포넌트를 빠르게 확인 가능

1. 폴더구조 (directory structure)

Select 컴포넌트는 역할별로 책임을 나눠 확장성과 재사용성을 고려한 구조로 설계했습니다.

  • index.ts: Select 관련 컴포넌트를 통합 export하는 진입 파일
  • SelectMain.tsx: Select의 루트 컴포넌트로, Context를 제공하고 전체 구조를 제어
  • TriggerButton.tsx: 선택된 값을 보여주고 드롭다운을 열고 닫는 버튼 역할
  • OptionList.tsx: 드롭다운 영역을 구성하며, 내부에 여러 Option을 포함하는 파일
  • Option.tsx: 실제 선택 가능한 항목
  • OptionGroupName.tsx: 옵션을 그룹으로 묶을 때 사용하는 제목 컴포넌트
  • Select.context.tsx: 선택 상태, 열림 여부 등을 관리하는 Context를 정의
  • useSelectMain.ts: Select 내부 로직을 담당하는 커스텀 훅
  • Select.stories.tsx: Storybook용 시각화 테스트 파일

각 파일이 명확한 역할을 가지도록 분리함으로써,기능 추가나 유지보수가 훨씬 쉬워졌습니다.

2. 상태 관리 흐름

useSelectMain: 선택 상태 및 열림 여부 등 4가지 상태를 지역 상태로 관리합니다

selected: 현재 선택된 항목
isOpen: 드롭다운 열림 여부
setIsOpen: 드롭다운 열고 닫기 제어
handleSelect: 옵션 선택 핸들러

SelectMain: UI 뼈대를 구성하고, context로 하위 컴포넌트에 상태 전달

SelectContext.Provider: context를 통해 하위 컴포넌트로 상태 전파

useSelectContext: 하위 컴포넌트들이 context 값을 받아 사용

그림으로 표현하면 이렇습니다

구조로 나타내면 이렇게 표현할 수 있습니다

[useSelectMain]         ← 지역 상태 생성
      │
      ▼
[SelectMain]            ← 상태를 context로 감싸서 하위에 전달
      │
      ▼
[SelectContext.Provider]    ← context 전파
      │
      ▼
[useSelectContext()]    ← 하위 컴포넌트가 context 값 사용
      ├── Option        → onSelect, size
      └── OptionList    → size
      └── TriggerButton    → size,onSelect, selected 

그리고 마지막으로는 만들어진 컴포넌트를 조립해서

각각의 컴포넌트처럼 사용할 수 있도록 만들었습니다

느낀점

이번 리팩토링을 통해,
그동안 컴포넌트를 나눈다 = 파일/폴더 단위로 나눈다 라고만 생각했던 저 자신을 돌아보게 되었습니다.

컴포넌트는 단순히 폴더에 따라 분리하는 것이 아니라,
그 안에서도 역할(기능 vs UI) 기반으로 더 깊이 있게 나눌 수 있고,
필요한 기능을 조립해서 사용하는 ‘블록형 사고방식’이 가능하다는 것을 새롭게 체감했습니다.

하나의 Select 컴포넌트 안에서도

  • 상태 관리 (useSelectMain)
  • 전역 상태 공유 (SelectContext)
  • UI 단위 분리 (Trigger, Option, OptionList, Group) 로 나눌 수 있다는 점에서 역할 중심의 설계 사고방식이 생겼습니다.

단순히 쓰기 좋게 만드는 게 아니라,
나중에 내가 다시 볼 때도 유용한 구조로 만드는 것이 진짜 설계라는 걸 느꼈습니다.

잘 만든 컴포넌트는 나에게 다시 되돌아온다 라는 생각이 가장 들었고
이 구조가 앞으로 제 프로젝트에서도 재사용될 수 있다는 생각에 설계의 중요성을 더 실감하게 됐습니다.

이번 경험을 바탕으로

  • 기능과 UI를 나누는 시선
  • context와 상태를 언제, 어떻게 공유할지에 대한 판단
  • 조립 가능한 설계 패턴을 염두에 둔 컴포넌트 작성

에 더 깊이 고민하며 성장해 나가고 싶습니다.

참고

https://www.youtube.com/watch?v=fR8tsJ2r7Eg&t=46s
https://velog.io/@aeong98/%EC%BB%B4%ED%8C%8C%EC%9A%B4%EB%93%9C-%EC%BB%B4%ED%8F%AC%EB%84%8C%ED%8A%B8-%ED%8C%A8%ED%84%B4%EC%9C%BC%EB%A1%9C-Select-%EB%A7%8C%EB%93%A4%EA%B8%B0
https://ui.shadcn.com/docs/components/dropdown-menu

profile
프론트엔드 공부 중.. 💻👩‍🎤

0개의 댓글