2024. 12. 20. 15:34ㆍUX Engineer 이야기
들어가며
이번 프로젝트에서 Accordion 컴포넌트를 Headless 방법으로 만들어보면서 "Compound Pattern"이라는 디자인 패턴에 대해 알게 되었습니다. 이와 관련해 React의 다른 디자인 패턴들에 대해 궁금증이 생겨 몇 가지를 찾아보았습니다.
검색해 보니 정말 다양한 패턴들이 있었는데요, 그중 몇 가지를 소개해 보려 합니다.
디자인 패턴
우선, 디자인 패턴의 사전적 의미가 무엇인지 Chat Gpt에게 물어보았습니다.
소프트웨어 설계에서 자주 발생하는 문제를 해결하기 위한, 재사용 가능한 모범적인 설계 구조를 의미합니다.
사전적 의미에서도 알 수 있듯이, 디자인 패턴은 컴포넌트를 만들 때 주로 고려하는 재사용 가능성, 가독성, 확장성 등을 높여 개발 효율성을 향상시키는 코드를 설계할 수 있는 일종의 청사진 역할을 합니다.
Container/Presentational Pattern
Container/Presentational Pattern은 제목 그대로 Container, Presentational 두 가지 컴포넌트로 구성되어 있습니다. React와 같은 프레임워크에서 많이 사용되는 패턴으로 뷰를 애플리케이션 로직에서 분리할 수 있습니다.
Container 컴포넌트는 애플리케이션의 상태를 관리하고 데이터를 전달하거나 사용자로부터 이벤트를 받아 상태를 업데이트하는 역할을 합니다.
Presentational 컴포넌트는 UI를 어떻게 보여줄지를 담당하는 컴포넌트입니다. 스타일, 레이아웃을 관리하고 데이터를 받아서 표시하는 역할을 합니다.
만약 api에서 데이터를 받아서 리스트를 표시해야 한다면 아래처럼 적용할 수 있습니다.
// Container 컴포넌트
import { useState, useEffect } from 'react';
import { UserList } from './UserList';
const UserListContainer = () => {
const [users, setUsers] = useState([]);
useEffect(() => {
fetch('<https://api.example.com/users>')
.then(response => response.json())
.then(data => setUsers(data));
}, []);
return ;
}
export { UserListContainer };
// Presentational 컴포넌트
const UserList = ({ users }) => {
return (
<ul>
{users.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
);
}
export { UserList };
Container 컴포넌트인 UserListContainer에서는 데이터를 받아와서 UserList에 전달하는 역할을 하고 있습니다. 데이터가 변경되면 UserListContainer만 변경하면 되는 것이죠.
그리고 Presentational 컴포넌트인 UserList는 데이터를 받아서 UI를 그리고 있습니다.
아마 가장 흔하게 접할 수 있는 패턴이라고 생각됩니다. 이렇게 사용하면 데이터와 UI의 관리가 명확히 분리됨으로써 코드의 유지 보수와 재사용성을 높일 수 있습니다.
다만 비즈니스와 렌더링 로직을 쉽게 분리할 수 있지만 hook을 활용하면 굳이 컴포넌트를 만들어서 사용하지 않고도 같은 효과를 볼 수 있기 때문에 너무 작은 규모의 앱에서는 필요하지 않을 수 있습니다.
Compound Pattern
이 글을 작성하게 된 계기인 Compound Pattern 은 컴포넌트 간의 상호작용을 더 유연하게 관리할 수 있도록 도와주는 디자인 패턴입니다. 이 패턴에서는 하나의 큰 컴포넌트를 구성하기 위해 여러 작은 컴포넌트를 만들어 사용합니다.
주로 Context API를 사용하여 컴포넌트 계층 구조 내에서 데이터와 기능을 공유합니다.
예시 컴포넌트를 만들어 보면서 Compound Pattern에 대해 더 자세히 알아보겠습니다.
일반적으로 Modal 컴포넌트를 만들 때, 저는 부모 컴포넌트에서 모달의 상태를 직접 관리하는 방식으로 작업해 왔습니다.
// Modal.jsx
const Modal = ({ isOpen, setIsOpen, children }) => {
if (!isOpen) return null;
return (
<div className="modal">
<div className="modal-content">
{children}
<button onClick={() => setIsOpen(false)}>Close Modal</button>
</div>
</div>
);
};
export { Modal }
이렇게 하면 부모 컴포넌트에서 모든 상태를 관리해야 하므로, 상태 관리가 복잡해지는 경우가 있었습니다.
이번에는 Compound Pattern을 활용하여 Modal컴포넌트를 만들어보겠습니다.
예시에서는 Modal, Modal.OpenButton, Modal.Content, Modal.CloseButton 네 가지 컴포넌트를 사용하여 모달을 구성하겠습니다.
- Modal 컴포넌트: 모달의 상태를 관리합니다.
- Modal.OpenButton 컴포넌트: 모달을 여는 버튼입니다.
- Modal.Content 컴포넌트: 모달의 내용을 렌더링합니다.
- Modal.CloseButton 컴포넌트: 모달을 닫는 버튼입니다.
1. ModalContext 생성 먼저, 상태를 서로 공유하기 위해 Context를 생성합니다.
// ModalComponents.js
import { createContext, useState, useContext } from 'react';
const ModalContext = createContext();
const Modal = ({ children }) => {
const [isOpen, setIsOpen] = useState(false);
const openModal = () => setIsOpen(true);
const closeModal = () => setIsOpen(false);
return (
<ModalContext.Provider value={{ isOpen, openModal, closeModal }}>
{children}
</ModalContext.Provider>
);
};
const useModal = () => useContext(ModalContext);
export { Modal, useModal };
2. Modal.OpenButton, Modal.Content, Modal.CloseButton 컴포넌트 구현 이제, 모달을 열고 닫을 수 있는 컴포넌트 함수로 만듭니다.
/ ModalComponents.js
// ... 위의 코드 적용
const OpenButton = ({ children }) => {
const { openModal } = useModal();
return <button onClick={openModal}>{children}</button>;
};
const Content = ({ children }) => {
const { isOpen } = useModal();
if (!isOpen) return null;
return (
<div className="modal">
<div className="modal-content">{children}</div>
</div>
);
};
const CloseButton = ({ children }) => {
const { closeModal } = useModal();
return <button onClick={closeModal}>{children}</button>;
};
Modal.OpenButton = OpenButton;
Modal.Content = Content;
Modal.CloseButton = CloseButton;
3. 이제 위에서 정의한 컴포넌트들을 사용하여 모달 기능을 구현해 보겠습니다.
import { Modal, ModalOpenButton, ModalContent, ModalCloseButton } from './ModalComponents';
const App = () => (
<div>
<Modal>
<Modal.OpenButton>Open Modal</Modal.OpenButton>
<Modal.Content>
<h1>This is a Modal</h1>
<Modal.CloseButton>Close Modal</Modal.CloseButton>
</Modal.Content>
</Modal>
</div>
);
export default App;
위와 같이 Compound Pattern으로 구현하면, Modal 컴포넌트 내부에서 상태와 관련된 로직이 캡슐화되므로 외부에서 불필요하게 상태를 신경 쓸 필요가 없습니다. 이를 통해 코드의 복잡성을 줄이고 유지보수성을 높일 수 있습니다. 또한, 부모 컴포넌트에서 자식 컴포넌트의 구조를 정의하지 않아도 됩니다. Modal.OpenButton, Modal.Content, Modal.CloseButton을 정해진 위치가 아닌 자유롭게 배치할 수 있어, 다양한 형태의 모달 구성이 가능합니다.
HOC Pattern
HOC (Higher-Order Component, 고차 컴포넌트) 패턴은 React에서 재사용 가능한 로직을 다른 컴포넌트에 주입하기 위해 사용하는 디자인 패턴입니다. HOC는 하나의 컴포넌트를 입력받아 새로운 컴포넌트를 반환하는 함수입니다. 이 패턴은 컴포넌트의 로직을 재사용하거나 공통된 기능을 여러 컴포넌트에 적용해야 할 때 매우 유용합니다.
const withSomeFunctionality = (WrappedComponent) => {
return (props) => {
// 추가적인 로직 또는 상태 관리
return <WrappedComponent {...props} />;
};
};
간단한 예제로 로딩 상태를 처리하는 HOC를 만들어 보겠습니다. 예를 들어, 이미지를 API에서 불러올 때 데이터가 로드되기 전까지 로딩 화면을 보여주고 싶다면, withLoading HOC를 만들어 공통적으로 적용할 수 있습니다.
import { useState, useEffect } from 'react';
const withLoading = (WrappedComponent, url) => {
return (props) => {
const [data, setData] = useState([]);
useEffect(() => {
fetch(url)
.then(response => response.json())
.then(d => setData(d));
}, []);
if (!data) {
return <div>Loading...</div>;
}
return <WrappedComponent {...props} data={data} />;
};
};
export { withLoading };
withLoading 컴포넌트를 아래와 같이 적용할 수 있습니다.
import withLoading from "./withLoading";
function UserImage(props) {
return (
<img src={props.src} alt="이미지1" />
);
}
export default withLoading(
UserImage,
"<https://dog.ceo/api/breed/labrador/images/random/6>"
);
import withLoading from "./withLoading";
function ListImage(props) {
return (
<div>
<img src={props.src} alt="이미지1" />
</div>
);
}
export default withLoading(
ListImage,
"<https://dog.ceo/api/breed/labrador/images/random/6>"
);
UserImage 뿐만 아니라 동일한 로딩 기능이 필요한 다양한 컴포넌트에도 간단하게 적용할 수 있습니다.
하지만 HOC는 각각을 커스터마이징이 어렵기 때문에, 커스텀 로직 추가 없이 단독으로 동작하는 컴포넌트에 사용하는 것이 좋습니다.
마치며
패턴들을 찾아보면서, 오늘 소개해 드린 패턴 외에도 정말 다양한 패턴들이 존재한다는 것을 알게 되었습니다. 그동안 Container/Presentational 한 가지 패턴만을 사용해 코드를 작성해 왔던 것은 아닌가 하는 생각이 들었습니다.
이런 다양한 방법들을 각 기능에 맞게 적절히 활용하면 더 좋은 결과물을 만들 수 있을 것이라고 생각합니다.
읽어주셔서 감사합니다.
참고자료
이 글은 pxd XE Group Blog에서도 보실 수 있습니다.