<select> 태그의 최악의 사용성
프로젝트에서 국가 선택 기능을 단순히 <select> 태그로 구현했을 때, 수십 혹은 수백 개의 국가가 나열되는 경우 사용자가 일일이 스크롤을 해야하는 불편함이 발생했습니다.
따라서 이 문제를 해결하기 위해서 검색 기능을 구현하면서 자유로운 디자인을 위해 Custom Select 컴퍼넌트를 직접 구현하기로 결정했습니다.
Compound Component Design Pattern in React
Compound Compoenet Design Pattern 에 대해서 소개하는 수 많은 글 중에서 이 글은 다음과 같이 표현하고 있습니다.

컴파운드 컴퍼넌트 패턴은 부모와 자식 컴퍼넌트간의 로직과 UI 를 각각 명확하게 분리함으로써, 유연한 표현을 제공하는 리액트 패턴이다.
다시 말해, 컴파운트 패턴은 부모와 자식 컴퍼넌트 로직과 UI 를 명확하게 분담하도록 함으로써, 이를 조립하여 하나의 유연한 형태의 컴퍼넌트를 만드는 것이라고 할 수 있습니다.
이 내용만 접했을때, 컴파운드, React 에서 이야기하는 컴퍼넌트 패턴과 무엇이 다른가? 라는 의문이 들었습니다.
실제로 컴파운드 패턴은 React 가 지향하는 컴퍼넌트 기반 설계의 철학과 크게 다르지 않습니다.
위 공식 문서에서도, 상태를 기준으로 UI 계층을 구분하고, props를 통해 데이터를 전달하는 방식(컴퍼넌트 쪼개기 + props 전파, 데이터 기반 사고)을 권장하고 있기 때문입니다.
이로 인해 코드 가독성도 높아지고, 각 자식 컴포넌트가 어떤 역할을 담당하는지 명확해진다
즉, 두 방식이 추구하는 바는 동일합니다. 다만, 컴파운드 상태와 로직의 분리를 더 의도적으로 명확히 했다는 것이라고 생각합니다.
<SelectBox
items={OLYMPIC_COUNTRIES_LIST}
value={stateForm[STATE_FORM.COUNTRY]}
onSelect={(value) =>
setStateForm((prev) => ({
...prev,
country: value,
}))
}
>
<SelectBox.Input
label="국가"
placeholder="국가를 선택해주세요"
/>
<SelectBox.List />
</SelectBox>
실제로 현재 프로젝트에서는 당장 필요한 패턴은 아닙니다.
하지만 앞으로 유연한 컴퍼넌트를 만들어야할 때, 어떻게 구조를 잡고, 관리 해야할지 방향을 제시 해 줄 수 있는 내용이라 생각되어서, 프로젝트에 경험삼아 적용해보기로 결정 했습니다.

위 그림은 개발에 들어가기전, 설계한 그림입니다.
컴파운드 패턴을 적용하면서, 단순히 데이터 중심으로만 생각하기 보다는, 부모-자식 컴퍼넌트의 관계를 기준으로 로직과 상태를 분리를 조금 더 명확히 해보는 경험이었습니다.

import { useState, useContext, createContext } from 'react';
import styles from './../styles/SelectBox.module.css';
import { STLYES_SELECTBOX } from '../constant/type';
import Input from './Input';
const SelectContext = createContext();
function SelectBox({ items = [], value, onSelect, children }) {
const [isOpen, setIsOpen] = useState(false); // 드롭다운 열림/닫힘 상태
const filteredItems = items.filter((item) =>
item.toLowerCase().includes(value.toLowerCase()),
);
const contextValue = {
isOpen,
setIsOpen,
filteredItems,
value,
onSelect,
};
return (
<SelectContext.Provider value={contextValue}>
<div className={styles[STLYES_SELECTBOX.CONTAINER]}>{children}</div>
</SelectContext.Provider>
);
}
function useSelectContext() {
const context = useContext(SelectContext);
if (!context) {
throw new Error(
'Select 내부에서만 사용 가능한 컴포넌트입니다. <Select>로 감싸주세요.',
);
}
return context;
}
function SelectInput({ label, placeholder }) {
const { setIsOpen, onSelect, value } = useSelectContext();
const handleFocus = (e) => {
setIsOpen(true);
e.target.select();
};
const handleBlur = () => {
setIsOpen(false);
};
const handleChange = (e) => {
onSelect(e.target.value);
};
const handleKeyDown = (e) => {
if (e.key === 'Enter') {
e.stopPropagation();
e.preventDefault();
}
};
return (
<Input
type="text"
label={label}
value={value}
onKeyDown={handleKeyDown}
onChange={handleChange}
onFocus={handleFocus}
onBlur={handleBlur}
placeholder={placeholder}
/>
);
}
function SelectList() {
const { isOpen, filteredItems } = useSelectContext();
if (!isOpen) return null;
return (
<div className={styles[STLYES_SELECTBOX.LIST]}>
{filteredItems.length === 0 ? (
<div className={styles[STLYES_SELECTBOX.NO_ITEM]}>
검색 결과가 없습니다.
</div>
) : (
filteredItems.map((value) => (
<ListItem key={value} value={value} />
))
)}
</div>
);
}
function ListItem({ value: itemValue }) {
const { onSelect, setIsOpen, value: selectedValue } = useSelectContext();
const handleClick = () => {
onSelect(itemValue);
setIsOpen(false);
};
const handleMouseDown = (e) => {
e.preventDefault();
};
return (
<div
className={styles[STLYES_SELECTBOX.ITEM]}
role="button"
onMouseDown={handleMouseDown}
onClick={handleClick}
tabIndex={0}
>
{itemValue}
</div>
);
}
SelectBox.Input = SelectInput;
SelectBox.List = SelectList;
export default SelectBox;
React Hooks: Compound Components
리액트 디자인 패턴 : 컴파운드 컴포넌트 패턴 [Compound Component Pattern] 2