합성 컴포넌트 패턴 : Compound or Composite
이전에 [ 합성 컴포넌트 패턴과 동적 프로퍼티 패턴 ] 포스트에서 나는 컴포넌트를 보다 유연하게 구성하고 재사용할 수 있도록 여러 하위 컴포넌트를 조합하여 만든 컴포넌트라고 소개했다. 이는 리액트 구 문서의 [ 합성 vs 상속 ]에서 제시된, 컴포넌트 내부에서 컴포넌트를 호출하는 대신 children을 포함한 다양한 props를 통해 다른 컴포넌트를 합성하는 방법에 근거한 것이었다.
그런데 모 회사의 면접 도중에 '그럼 모든 리액트 개발자들은 합성 컴포넌트 패턴을 쓰는 거 아닌가요?' 하는 질문을 받았다. 나는 하위 컴포넌트를 얼마나 잘게 분해하여 재사용하는지를 이야기하였으나 '잘은 모르지만 제가 알고 있는 합성 컴포넌트 패턴은 상태를 공유하는 패턴으로 알고 있다'는 말이 돌아왔다. 내게 있어서 합성 컴포넌트 패턴은 상태 공유와는 전혀 관련이 없었기 때문에 그 말이 굉장히 의아했다.
'합성 컴포넌트 패턴'이라는 표현 하나를 두고 왜 그 분과 나 사이에 이해가 일치하지 않았는지, 나는 오늘 알게되었다.
내가 말한 합성 컴포넌트 패턴은 Composite Component Pattern이며, 객체지향에서 상속 대신 합성이 유리하다고 말할 때 그 합성을 뜻한다. 즉, 작은 컴포넌트들을 조합하여 더 큰 컴포넌트를 구성하는 방식으로, 유연성과 재사용성을 극대화하는 것이 핵심이다.
한편, 그 분이 말씀하셨던 합성 컴포넌트 패턴은 ─ 줄임말은 CCP로 같지만 ─ Compound Component Pattern이며, 여러 개의 관련된 컴포넌트를 조합하여 하나의 기능적인 단위로 동작하도록 만드는 패턴이다. 이 패턴을 사용하면 컨텍스트(Context)나 상태를 공유하면서도, API를 직관적으로 설계할 수 있다.
이런 식으로 컴포넌트를 설계하는 방식에 대해서 오래전부터 알고 있었지만, 이 패턴의 이름도 합성 컴포넌트 패턴인 줄은 몰랐다. 내게는 그저 "context api의 등장 이후로 rander props 패턴을 대체한 패턴" 정도였으니까. 미리 말하자면 나는 이 패턴을 그렇게까지 좋아하지는 않는다. 컴포넌트 사이의 의존성이 숨겨지며, 이를 해소하기 위해 동적 프로퍼티 패턴이 어느정도 강제된다고 생각하기 때문이다. 또한 부모 컴포넌트와 자식 컴포넌트가 강결합되어 각 컴포넌트의 재사용이 사실상 불가능해진다는 점도 불만이다.
하지만 디자인 패턴도 잘못 쓰면 안티 패턴이 되고, 안티 패턴도 적재 적소에 사용할 경우 베스트 프랙티스가 된다. Compound 컴포넌트 패턴의 경우 각 컴포넌트를 재사용할 이유가 없는 몇몇 상황에서는 충분히 고려해볼만하다고 본다. 내 경우에는 아래와 같이 간단한 드롭다운 메뉴를 구현할 때 가끔 사용하곤 한다.
import { createContext, useContext, useState, useRef, ReactNode } from "react";
import { useEffect } from "react";
interface DropdownContextType {
isOpen: boolean;
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
}
const DropdownContext = createContext<DropdownContextType | undefined>(undefined);
export function Dropdown({ children }: DropdownProps) {
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
// 외부 클릭 감지하여 닫기
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
return (
<DropdownContext.Provider value={{ isOpen, setIsOpen }}>
<div ref={dropdownRef} className="relative inline-block text-left">
{children}
</div>
</DropdownContext.Provider>
);
}
Dropdown.Trigger = function Trigger({ children }: TriggerProps) {
const context = useContext(DropdownContext);
if (!context) {
throw new Error("Dropdown.Trigger must be used within a Dropdown");
}
const { isOpen, setIsOpen } = context;
return (
<button
onClick={() => setIsOpen(!isOpen)}
className="px-4 py-2 bg-blue-500 text-white rounded-md"
>
{children}
</button>
);
};
Dropdown.Menu = function Menu({ children }: MenuProps) {
const context = useContext(DropdownContext);
if (!context) {
throw new Error("Dropdown.Menu must be used within a Dropdown");
}
const { isOpen } = context;
return (
isOpen && (
<div className="absolute left-0 mt-2 w-48 bg-white border rounded shadow-lg">
{children}
</div>
)
);
};
Dropdown.Item = function Item({ children, onClick }: ItemProps) {
return (
<div
onClick={onClick}
className="px-4 py-2 cursor-pointer hover:bg-gray-200"
>
{children}
</div>
);
};
블로그의 정보
Ayden's journal
Beard Weard Ayden