이번시간에는 Compound component, HoC, React Portal에 대해서 알아보는 시간을 가져보도록 하겠습니다.
React에서 구성 요소들 간의 유연한 관계를 정의하고, 하나의 상위 컴포넌트와 하위 컴포넌트들이 함께 작동하도록 설계하는 디자인 패턴이다. 여러 컴포넌트들이 모여 하나의 동작을 할 수 있게 해 준다.
React의 Context API
를 활용해 컴파운드 패턴을 활용하여 드롭다운
을 만들어보도록 하겠습니다.
import { useState, createContext, useContext } from "react";
// 1. Dropdown Context 정의
const DropdownContext = createContext(null);
const useDropdown = () => {
const context = useContext(DropdownContext);
if (!context) {
throw new Error("Dropdown components must be used within a Dropdown.");
}
return context;
};
// 2. Dropdown 컴포넌트 정의
const Dropdown = ({ children }) => {
const [isOpen, setIsOpen] = useState(false);
const toggle = () => setIsOpen((prev) => !prev);
return (
<DropdownContext.Provider value={{ isOpen, toggle }}>
<div style={{ position: "relative", display: "inline-block" }}>
{children}
</div>
</DropdownContext.Provider>
);
};
// 3. Dropdown.Toggle 컴포넌트 정의
const DropdownToggle = ({ children }) => {
const { toggle } = useDropdown();
return (
<button
onClick={toggle}
style={{ cursor: "pointer", padding: "0.5rem 1rem" }}
>
{children}
</button>
);
};
DropdownToggle.displayName = "Dropdown.Toggle";
// 4. Dropdown.Menu 컴포넌트 정의
const DropdownMenu = ({ children }) => {
const { isOpen } = useDropdown();
return isOpen ? (
<div
style={{
position: "absolute",
top: "100%",
left: 0,
backgroundColor: "white",
border: "1px solid #ccc",
borderRadius: "4px",
padding: "0.5rem",
zIndex: 1000,
minWidth: "150px",
}}
>
{children}
</div>
) : null;
};
DropdownMenu.displayName = "Dropdown.Menu";
// 5. Dropdown.Item 컴포넌트 정의
const DropdownItem = ({ children, onClick }) => (
<div
onClick={onClick}
style={{
padding: "0.5rem",
cursor: "pointer",
borderBottom: "1px solid #eee",
}}
>
{children}
</div>
);
DropdownItem.displayName = "Dropdown.Item";
// Dropdown 컴포넌트에 서브 컴포넌트 연결
Dropdown.Toggle = DropdownToggle;
Dropdown.Menu = DropdownMenu;
Dropdown.Item = DropdownItem;
export default Dropdown;
하나씩 차근차근 살펴보도록 하자.
// 1. Dropdown Context 정의
const DropdownContext = createContext(null);
const useDropdown = () => {
const context = useContext(DropdownContext);
if (!context) {
throw new Error("Dropdown components must be used within a Dropdown.");
}
return context;
};
DropdownContext
: React Context를 생성하여 Dropdown 컴포넌트의 상태(isOpen)와 동작(toggle)을 공유.useDropdown
: Context를 사용하는 커스텀 훅으로, Context 값이 없으면 에러를 발생시켜 Dropdown 내부에서만 컴포넌트가 사용되도록 강제한다.// 2. Dropdown 컴포넌트 정의
const Dropdown = ({ children }) => {
const [isOpen, setIsOpen] = useState(false);
const toggle = () => setIsOpen((prev) => !prev);
return (
<DropdownContext.Provider value={{ isOpen, toggle }}>
<div style={{ position: "relative", display: "inline-block" }}>
{children}
</div>
</DropdownContext.Provider>
);
};
자식 컴포넌트들에게 Dropdown의 열고 닫히는 상태(open)와 동작(toggle)을 전달해주는 컴포넌트이다. Context Provider
를 통해서 자식요소에게 isopen
, setIsOpen
, 상태를 접근하고, 변경 가능하게 한다.
// 3. Dropdown.Toggle 컴포넌트 정의
const DropdownToggle = ({ children }) => {
const { toggle } = useDropdown();
return (
<button
onClick={toggle}
style={{ cursor: "pointer", padding: "0.5rem 1rem" }}
>
{children}
</button>
);
};
DropdownToggle.displayName = "Dropdown.Toggle";
Toggle
컴포넌트는 요소를 감싸서 해당 요소를 통해서 메뉴를 트리거 할 수 있게 하는 역할을 한다.
const DropdownMenu = ({ children }) => {
const { isOpen } = useDropdown();
return isOpen ? (
<div
style={{
position: "absolute",
top: "100%",
left: 0,
backgroundColor: "white",
border: "1px solid #ccc",
borderRadius: "4px",
padding: "0.5rem",
zIndex: 1000,
minWidth: "150px",
}}
>
{children}
</div>
) : null;
};
DropdownMenu.displayName = "Dropdown.Menu";
DropdownMenu
컴포넌트는 메뉴 리스트 아이템을 렌더링하는 틀 역할을한다.
isOpen
상태가 true
일 때 Dropdown
메뉴를 렌더링.// 5. Dropdown.Item 컴포넌트 정의
const DropdownItem = ({ children, onClick }) => (
<div
onClick={onClick}
style={{
padding: "0.5rem",
cursor: "pointer",
borderBottom: "1px solid #eee",
}}
>
{children}
</div>
);
DropdownItem.displayName = "Dropdown.Item";
Dropdown
메뉴의 개별 항목을 렌더링onClick
이벤트를 받아 클릭 동작을 처리Dropdown.Toggle = DropdownToggle;
Dropdown.Menu = DropdownMenu;
Dropdown.Item = DropdownItem;
Dropdown.Toggle
, Dropdown.Menu
, Dropdown.Item
을 Dropdown 컴포넌트의 정적 프로퍼티로 연결.Dropdown.Toggle
, Dropdown.Menu
, Dropdown.Item
처럼 사용 가능. => 가독성 goodimport Dropdown from './Dropdown';
const App = () => {
const handleItemClick = (item) => {
alert(`You selected: ${item}`);
};
return (
<Dropdown>
<Dropdown.Toggle>Open Menu</Dropdown.Toggle>
<Dropdown.Menu>
<Dropdown.Item onClick={() => handleItemClick('Item 1')}>Item 1</Dropdown.Item>
<Dropdown.Item onClick={() => handleItemClick('Item 2')}>Item 2</Dropdown.Item>
<Dropdown.Item onClick={() => handleItemClick('Item 3')}>Item 3</Dropdown.Item>
</Dropdown.Menu>
</Dropdown>
);
};
export default App;
Dropdown
, Toggle
, Menu
, Item
이 분리되어 다른 상황에서도 재사용 가능.Context
를 사용해 부모-자식 컴포넌트 간 상태를 공유. => Prop Drilling 문제 해결Dropdown.Toggle
, Dropdown.Menu
, Dropdown.Item
은 반드시 Dropdown
내부에서 사용되어야만 동작한다. 그렇기 때문에 다른 컨텍스트에서 독립적으로 사용하기 어렵습니다.상태
와 UI
가 밀접하게 연결된 복합적인 인터페이스를 설계할 때 유용하게 사용 가능.HOC (Higher-Order Component)는 하나의 컴포넌트를 입력으로 받아 확장된 새로운 컴포넌트를 반환하는 함수이다. 컴포넌트의 로직을 재사용하거나 기능을 추가할 때 유용하게 사용된다.
컴포넌트는 props를 UI로 변환하는 반면에, 고차 컴포넌트는 컴포넌트를 새로운 컴포넌트로 변환한다.
1. 공통 로직의 재사용
여러 컴포넌트에서 동일한 로직이 필요할 때 HOC를 사용하면 코드를 중복하지 않고 공통된 로직을 재사용할 수 있다.
2. 권한 부여 및 조건부 렌더링
특정 사용자 권한에 따라 컴포넌트를 렌더링하거나 제한하고 싶을 때.
3. 로깅 또는 디버깅
컴포넌트의 동작을 추적하거나 디버깅 정보를 출력하고 싶을 때.
4. 스타일링 관련 확장
특정 스타일링이나 테마를 주입하고 싶을 때.
5. 컴포넌트의 기능 확장
컴포넌트에 새로운 기능(예: 이벤트 처리, 상태 관리)을 추가하고 싶을 때.
Higher-Order Component (HoC) 패턴을 사용하여 리다이렉트 로직을 구현한 예제이다. 인증된 사용자만 특정 컴포넌트를 볼 수 있도록 보호하며, 인증되지 않은 사용자는 로그인 페이지로 리다이렉트되도록 하였다. 아래 예제는 로그인시 access-token
이 담겨있으면, Home
페이지에 접근 가능하고 그렇지 않은 경우 Login
페이지로 리다이렉트 시킨다.
import { useEffect } from "react";
import { useNavigate } from "react-router-dom";
const withAuthRedirect = (WrappedComponent) => {
const WithAuthRedirect = (props) => {
const navigate = useNavigate();
const redirectPath = "/login";
const isAuthenticated = Boolean(localStorage.getItem("token"));
useEffect(() => {
if (!isAuthenticated) {
console.log(
`인증이 유효하지 않습니다. ${redirectPath}으로 리다이렉트 됩니다`
);
navigate(redirectPath);
}
}, [isAuthenticated, navigate, redirectPath]);
if (!isAuthenticated) {
return null; // 인증되지 않으면 아무것도 렌더링하지 않음
}
return <WrappedComponent {...props} />;
};
WithAuthRedirect.displayName = `WithAuthRedirect(${
WrappedComponent.displayName || WrappedComponent.name || "Component"
})`;
return WithAuthRedirect;
};
export default withAuthRedirect;
const Home = () => {
return <h1>Welcome to the Home!</h1>;
};
export default Home;
import withAuthRedirect from "./hoc/withAuthRedirect";
import Home from "./Home";
const ProtectedHome = withAuthRedirect(Home);
export default ProtectedHome;
import { useNavigate } from "react-router-dom";
const Login = () => {
const navigate = useNavigate();
const handleLogin = () => {
// 토큰 저장 (예: 인증 성공 시)
localStorage.setItem("token", "user-auth-token");
// 로그인 후 대시보드로 리다이렉트
navigate("/home");
};
const handleLogout = () => {
// 토큰 제거 (로그아웃)
localStorage.removeItem("token");
// 로그아웃 후 로그인 페이지로 리다이렉트
navigate("/login");
};
return (
<div>
<h1>Login Page</h1>
<button onClick={handleLogin}>Login</button>
<button onClick={handleLogout}>Logout</button>
</div>
);
};
export default Login;
Login
버튼 클릭시, 로컬스토리지에 토큰을 담아서 Home
페이지로 navigate
한다.
Home
페이지로 navigate
잘되는걸 확인할 수 있다
Home
페이지로 navigate
되지 못하고, Login
페이지에 머물러 있음
HOC는 컴포넌트 간에 공통 로직을 공유하거나, 컴포넌트의 기능을 동적으로 확장해야 할 때 유용하다. 하지만 HOC의 사용이 복잡도를 증가시킬 수 있으므로, 필요에 따라 Custom Hook
과 비교하여 적절히 선택하는 것이 중요하다.
Portals
는 컴포넌트의 렌더링 위치를 변경할 수 있게 해주는 React
의 기능이다. 일반적으로 React
컴포넌트는 부모 DOM
계층 구조 내에서 렌더링되지만, Portals
를 사용하면 DOM
계층 구조의 다른 위치에 컴포넌트를 렌더링할 수 있다.
https://ko.react.dev/reference/react-dom/createPortal
ReactDOM.createPortal
은 두 가지 인수를 받는다:
React
노드 (React 컴포넌트나 JSX)DOM
요소 (HTML DOM 노드)import React from 'react';
import ReactDOM from 'react-dom';
function Modal({ children }) {
return ReactDOM.createPortal(
<div className="modal">
{children}
</div>,
document.getElementById('modal-root') // 여기로 렌더링
);
}
function App() {
return (
<div>
<h1>My App</h1>
<Modal>
<p>This is inside a portal!</p>
</Modal>
</div>
);
}
DOM
구조를 벗어남: 컴포넌트는 React
계층 구조에 머물지만 실제 DOM
위치는 다른 곳에 렌더링된다.UI
요소를 구현할 때 유용.Portals
를 통해 렌더링된 요소에서도 이벤트는 React
트리 상에서 버블링됩니다.createPortal
은 모달, 알림창, 오버레이, 툴팁 등 특정한 UI 패턴을 구현할 때 많이 사용된다.
안녕하세요 건휘님!
4주차 아티클 작성, 발표 준비, 그리고 실습까지 준비하시느라 정말 고생 많으셨습니다.
이번 주차 주제인 디자인 패턴은 평소 코드 작성 시 깊게 고민해본 적이 없는 부분이라 혼자 공부할 때 생소한 내용이 많았는데요. 건휘님의 아티클과 오늘 발표, 실습 덕분에 그동안 어렵게 느껴졌던 부분들을 좀 더 명확히 이해할 수 있었습니다.
다양한 코드 예시들을 통해 추상적이라고 생각했던 개념들을 직접 눈으로 확인하며 실습해보니 이해가 훨씬 수월했습니다. 각 패턴의 장점, 단점, 그리고 어떤 상황에서 사용해야 하는지를 명확히 정리해 주신 덕분에, 여러 디자인 패턴을 비교하고 적절한 상황에 선택하는 것이 중요하다는 것을 배웠습니다. 앞으로 합동 세미나나 앱잼 같은 협업 프로젝트에서 이 내용을 적용할 때 큰 도움이 될 것 같아요!
특히 마지막에 로그인 인증이 되지 않으면 아무것도 렌더링 되지 않음을 적용해야 하는 부분은 실제 사례도 보여주시면서 설명해주셔서 더 이해가 잘됐고 왜 이와 같이 코드를 작성해야 하는지 바로 이해가 되는 부분이었습니다.
좋은 아티클, 실습 준비해주셔서 감사드리고 수고 많으셨습니다!!
건휘님 안녕하세요! 사실 개인적으로 패턴을 이론적으로 공부할 때는 크게 와닿지는 못했던 것 같습니다. 그런데 실제로 간단한 실습 예제 코드를 통해서 Context API를 활용해서 어떻게 합성 컴포넌트를 활용했고 그 결과가 어떻게 되는지를 눈으로 직접 확인할 수 있어서 쉽게 이해할 수 있어서 좋았습니다.ㅎ