프로젝트에서 Dropdown 컴포넌트를 수정할 일이 생길 때마다 한숨부터 나왔다. 약 500줄에 달하는 거대한 컴포넌트, 30개에 가까운 props, 조건부 렌더링의 향연... 새로운 스타일의 드롭다운이 필요할 때마다 props를 추가하거나 기존 로직을 건드려야 했고, 그때마다 사이드 이펙트가 걱정됐다. 🫨
이번 글에서는 어떻게 이 문제를 해결했는지, 그리고 그 과정에서 적용한 React 패턴들에 대해 공유하고자 한다.
기존 Dropdown.tsx를 살펴보자.
// 기존 Dropdown.tsx - 문제점이 보이는가?
const Dropdown = forwardRef<DropdownHandle, DropdownProps>(
({
size = 'medium',
icon,
placeholder = '',
isTitle = false,
maxHeight = 340,
options,
selectedOptions,
sortFn,
autoFocus = false,
disabled = false,
multiSelect = false,
searchable = false,
handleSelected,
handleKeyDown,
borderless = false,
textWrap = false,
toUppercase = false,
textColor,
onDelete,
showTooltip = false,
// ... props가 끝이 없다
}) => {
const [isOpen, setIsOpen] = useState(false);
const [searchTerm, setSearchTerm] = useState('');
const [filteredOptions, setFilteredOptions] = useState(options);
// ... 상태도 끝이 없다
하나의 컴포넌트가 너무 많은 역할을 담당했다:
새로운 디자인의 드롭다운이 필요할 때마다 props를 추가해야 했다. borderless, textColor, showTooltip 같은 props들이 하나씩 추가되면서 컴포넌트는 점점 비대해졌다.
// 특정 페이지에서만 필요한 스타일을 위해 props 추가...
borderless={true}
textColor="text-blue-500"
showTooltip={true}
{/* 닫혔을때 */}
{!searchable && (
<div className="flex w-full items-center justify-between">
{/* ... */}
</div>
)}
{searchable && (
<div className="flex w-full items-center justify-between">
{/* ... 거의 비슷한 코드 반복 */}
</div>
)}
{/* 열렸을때 */}
{isOpen && !disabled && (
<div className="...">
{!searchable && (/* ... */)}
{searchable && (/* ... */)}
</div>
)}
searchable, multiSelect, isOpen 등의 조건에 따라 렌더링이 분기되면서 코드 가독성이 급격히 떨어졌다.
dropdown/
├── DropdownBase.tsx # 컨테이너 (Render Props)
├── useDropdown.ts # 상태 관리 훅
├── types.ts # 타입 정의
└── elements/
├── DropdownTrigger.tsx # 트리거 버튼
├── DropdownMenu.tsx # 메뉴 컨테이너
└── DropdownItem.tsx # 개별 아이템
가장 먼저 상태 관리 로직을 커스텀 훅으로 분리했다.
export function useDropdown({
selectedOptions,
onSelectChange,
multiSelect = false,
searchable = false,
}: UseDropdownStateParams) {
const [isOpen, setIsOpen] = useState(false);
const [searchTerm, setSearchTerm] = useState('');
const [highlightedItem, setHighlightedItem] = useState<DropdownOption | null>(null);
const [triggerWidth, setTriggerWidth] = useState<number | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLDivElement>(null);
const toggleOption = useCallback((option: DropdownOption) => {
let next: DropdownOption[];
if (multiSelect) {
const isExist = selectedOptions.some((item) => item.value === option.value);
next = isExist
? selectedOptions.filter((item) => item.value !== option.value)
: [...selectedOptions, option];
} else {
next = [option];
setIsOpen(false);
}
onSelectChange?.(next);
}, [multiSelect, selectedOptions, onSelectChange]);
// ... 기타 로직
return {
isOpen,
setIsOpen,
searchTerm,
setSearchTerm,
toggleOption,
clearSelection,
// ...
};
}
이제 드롭다운의 "두뇌"가 UI와 분리되어, 테스트하기도 쉽고 다른 곳에서 재사용하기도 쉬워졌다.
export default function DropdownBase(props: DropdownBaseProps) {
const { selectedOptions, handleSelected, multiSelect, searchable, children } = props;
const dropdownState = useDropdown({
selectedOptions,
onSelectChange: handleSelected,
multiSelect,
searchable,
});
return (
<div ref={dropdownState.containerRef} className="relative w-full">
{children ? (
// Render Props 패턴: 완전한 커스터마이징 가능
children({ state: dropdownState, props })
) : (
// 기본 렌더링
<>
<DropdownTrigger {...props} state={dropdownState} />
<DropdownMenu {...props} state={dropdownState} />
</>
)}
</div>
);
}
children으로 렌더 함수를 전달받으면 완전히 커스텀한 드롭다운을 만들 수 있고, 없으면 기본 UI가 렌더링된다.
전체를 커스터마이징하지 않고 트리거나 메뉴, 아이템만 바꾸고 싶을 때는 renderTrigger, renderMenu, renderItem props를 사용한다.
// types.ts
export type DropdownBaseProps = {
// ...
renderTrigger?: (props: {
selected: DropdownOption[];
meta?: DropdownUIProps
}) => ReactNode;
renderMenu?: (props: {
content: ReactNode;
selected: DropdownOption[];
options: DropdownOption[];
meta?: DropdownUIProps;
}) => ReactNode;
renderItem?: (props: {
option: DropdownOption;
isSelected: boolean;
isHighlighted: boolean;
meta?: DropdownUIProps;
}) => ReactNode;
};
기본 사용 (변경 없음)
<DropdownBase
options={options}
selectedOptions={selected}
handleSelected={setSelected}
placeholder="선택하세요"
/>
트리거만 커스터마이징
<DropdownBase
options={options}
selectedOptions={selected}
handleSelected={setSelected}
renderTrigger={({ selected }) => (
<button className="custom-button">
{selected[0]?.label ?? '선택'}
</button>
)}
/>
완전 커스터마이징
완전히 새로운 드롭다운 디자인이 필요할 때 children을 함수로 전달한다.
<DropdownBase
options={options}
selectedOptions={selected}
handleSelected={setSelected}
>
{({ state, props }) => (
<MyCustomDropdown state={state} {...props} />
)}
</DropdownBase>
Headless UI의 힘: 로직과 스타일을 분리하면 재사용성이 극대화된다. Radix UI, Headless UI 같은 라이브러리들이 왜 이런 접근 방식을 택했는지 이해하게 됐다.
패턴 선택에는 트레이드오프가 있다: 처음에는 Compound Component 패턴을 적용하려고 했다. <Dropdown.Trigger>, <Dropdown.Menu> 형태로 Context를 통해 상태를 암묵적으로 공유하는 구조다. 하지만 고민 끝에 Render Props 패턴을 선택했다. 이유는 다음과 같다:
물론 나중에 드롭다운 내부에 더 복잡한 중첩 구조가 생기거나, 여러 곳에서 상태에 접근해야 한다면 Compound Component로 전환할 수 있다. 패턴은 정답이 아니라 상황에 맞는 선택이다.
과도한 Props는 설계 문제의 신호: Props가 20개를 넘어가면 컴포넌트 분리를 고민해야 한다.
점진적 마이그레이션의 중요성: 기존 Dropdown.tsx는 그대로 두고 새로운 DropdownBase를 만들어서, 팀원들이 서서히 마이그레이션할 수 있도록 했다.
완벽한 코드는 없다. 새로운 구조도 시간이 지나면 또 다른 문제가 생길 수 있다. 하지만 이번 리팩토링을 통해 확실히 "드롭다운 수정 = 스트레스"라는 등식에서 벗어날 수 있었다. (제발~~)
다음에 비슷한 문제를 마주하면, 바로 코드를 수정하기보다 한 발 물러서서 "이 컴포넌트의 책임은 무엇인가?", "어떻게 하면 확장 가능한 구조를 만들 수 있을까?"를 먼저 고민해야지! 💪