[React] 컴파운드 패턴을 사용해 Custom SelectBox 컴퍼넌트 구현하기

dev_woo·2025년 1월 27일
post-thumbnail

<select> 태그의 최악의 사용성


프로젝트에서 국가 선택 기능을 단순히 <select> 태그로 구현했을 때, 수십 혹은 수백 개의 국가가 나열되는 경우 사용자가 일일이 스크롤을 해야하는 불편함이 발생했습니다.

따라서 이 문제를 해결하기 위해서 검색 기능을 구현하면서 자유로운 디자인을 위해 Custom Select 컴퍼넌트를 직접 구현하기로 결정했습니다.

왜? “컴파운드 패턴” 을 적용했는가?


컴파운드 패턴이란?

Compound Component Design Pattern in React

Compound Compoenet Design Pattern 에 대해서 소개하는 수 많은 글 중에서 이 글은 다음과 같이 표현하고 있습니다.

컴파운드 컴퍼넌트 패턴은 부모와 자식 컴퍼넌트간의 로직과 UI 를 각각 명확하게 분리함으로써, 유연한 표현을 제공하는 리액트 패턴이다.

다시 말해, 컴파운트 패턴은 부모와 자식 컴퍼넌트 로직과 UI 를 명확하게 분담하도록 함으로써, 이를 조립하여 하나의 유연한 형태의 컴퍼넌트를 만드는 것이라고 할 수 있습니다.

React로 사고하기 – React

이 내용만 접했을때, 컴파운드, React 에서 이야기하는 컴퍼넌트 패턴과 무엇이 다른가? 라는 의문이 들었습니다.

실제로 컴파운드 패턴은 React 가 지향하는 컴퍼넌트 기반 설계의 철학과 크게 다르지 않습니다.
위 공식 문서에서도, 상태를 기준으로 UI 계층을 구분하고, props를 통해 데이터를 전달하는 방식(컴퍼넌트 쪼개기 + props 전파, 데이터 기반 사고)을 권장하고 있기 때문입니다.

컴파운드 패턴은 무엇이 다른건가?

React의 기본 철학

  • 부모-자식 구조로 컴포넌트를 쪼개고, props를 통해 데이터와 이벤트 핸들러를 전달한다
  • 상태는 일반적으로 상위 컴포넌트에서 관리한다(Props Drilling)

컴파운드 패턴

  • 필요에 따라 Context를 활용하여 부모와 자식이 전역적으로 상태를 공유할 수 있도록 한다(물론 ‘무조건’ Context를 써야 하는 것은 아님)
  • , <SelectBox.Input>, <SelectBox.List>와 같이 명시적인 하위 컴포넌트를 제공하여, 개발자가 HTML 태그를 작성하듯 필요한 자식 컴포넌트를 조합할 수 있도록 한다

이로 인해 코드 가독성도 높아지고, 각 자식 컴포넌트가 어떤 역할을 담당하는지 명확해진다

즉, 두 방식이 추구하는 바는 동일합니다. 다만, 컴파운드 상태와 로직의 분리를 더 의도적으로 명확히 했다는 것이라고 생각합니다.

<SelectBox
				items={OLYMPIC_COUNTRIES_LIST}
				value={stateForm[STATE_FORM.COUNTRY]}
				onSelect={(value) =>
					setStateForm((prev) => ({
						...prev,
						country: value,
					}))
				}
			>
				<SelectBox.Input
					label="국가"
					placeholder="국가를 선택해주세요"
				/>
				<SelectBox.List />
			</SelectBox>

도입 이유

실제로 현재 프로젝트에서는 당장 필요한 패턴은 아닙니다.
하지만 앞으로 유연한 컴퍼넌트를 만들어야할 때, 어떻게 구조를 잡고, 관리 해야할지 방향을 제시 해 줄 수 있는 내용이라 생각되어서, 프로젝트에 경험삼아 적용해보기로 결정 했습니다.

개발 과정

위 그림은 개발에 들어가기전, 설계한 그림입니다.

  1. SelectBox(부모 컴퍼넌트)
    • isOpen, setIsOpen, filteredItems 등의 상태와 함수를 Context API 를 사용해 전역적으로 상태를 관리
  2. SelectBox.Input( 검색 영역)
    • 사용자가 검색어를 입력하거나, Item 을 선택하면, onSelect()로 상태를 처리
    • focus, blur 이벤트로 List 열림/닫힘 제어
  3. SelectBox.List(드롭 다운 목록 영역)
    • isOpen 상태 값에 따라서 List 열림/닫힘 제어
    • 필터링된 items 를 받아서 UI 를 화면에 표시해줌
  4. ListItem(아이탬 영역)
    • 선택 된 item 의 값과 List 의 열림/닫힘 여부를 부모 컴퍼넌트에 전달

도입 후 느낀점

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

전체 코드

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;

참고자료


Compound 패턴

React Hooks: Compound Components

리액트 디자인 패턴 : 컴파운드 컴포넌트 패턴 [Compound Component Pattern] 2

Compound Component Design Pattern in React

React로 사고하기 – React

profile
꾸준히 한걸음씩

0개의 댓글