[개발일기] Compount Component Pattern으로 Select 컴포넌트 리팩토링하기

ujinsim·2025년 4월 3일
0
post-thumbnail

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만 추가된 형태임에도 새로운 컴포넌트를 만들어야됐습니다


리팩토링 기준을 알게 된 계기

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. 컴포넌트를 분리하는 이유를 항상 생각하기

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

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


이 영상을 통해서 얻은 점

  1. 컴포넌트를 짜는 방식
  2. 내코드가 구리다 🐸

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

폴더구조

상태 관리 흐름

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개의 댓글