HOC(Higher-Order Component, 고차컴포넌트)로 게시판 구조 개선하기
안녕하세요 오랜만입니다! 오늘은 리액트 프로젝트에 HOC(Higher-Order Component) 패턴을 도입하게 된 과정을 공유해보려고 합니다!
기존에는 3개의 게시판이 유사한 UI와 로직을 공유하고 있었기 때문에 :boardName 경로를 여러 번 선언하는 방식으로 구현했었는데요. 이 방식에 문제가 있다는 걸 알게 되었습니다.
🔥 문제 상황
처음엔 다음과 같이 게시판마다 다른 라우트를 선언했습니다.
<Route path="/:boardName" element={<DakkuGallery />} />
<Route path="/:boardName" element={<ReviewAndTips />} />
<Route path="/:boardName" element={<DakkuQnA />} />
React Router v6에서는 동일한 경로(/boardName
)가 여러 번 선언되면 가장 먼저 매칭되는 한 개의 라우트만 렌더링됩니다. 따라서 위의 코드처럼 /:boardName 경로를 세 번 선언했을 경우에는 가장 위에 있는 DakkuGallery
만 렌더링되고 ReviewAndTips
, DakkuQnA
는 무시됩니다.
처음엔 이 구조에 문제가 있다는 걸 눈치채지 못했습니다. 왜냐하면 겉보기엔 게시판이 모두 잘 작동하고 있었거든요.
하지만 React Dev Tools의 Components 탭에서 확인해보니 항상 DakkuGallery
만 렌더링되고 있다는 사실을 발견할 수 있었습니다. 그렇다면 이런 문제가 있음에도 어째서 잘 작동했던 걸까요?
🤔 왜 문제처럼 보이지 않았을까?
const { boardName } = useParams();
useEffect(() => {
const fetchPosts = async () => {
const fetchedPosts = await getPost(boardName);
setPosts(fetchedPosts);
};
fetchPosts();
}, [boardName]);
그 이유는 Board
컴포넌트 내부에서 useParams()
를 사용해 URL의 boardName
값을 추출하고, 그에 따라 게시판 데이터를 fetch하는 방식으로 동작하고 있었기 때문입니다.
즉, 실제로는 DakkuGallery
하나만 렌더링되고 있었지만, useParams
와 useEffect
내부의 동적 처리 덕분에 다른 게시판도 잘 작동하는 것처럼 보였던 것이었죠.
따라서 저는 이 문제를 해결하고자 했고, 그 과정에서 Custom Hook 과 HOC(Higher-Order Component) 구조를 고민하게 되었습니다!
💡 커스텀 훅 vs. HOC
기준 | 커스텀 훅 | HOC |
---|---|---|
공통 구조 활용 | 게시판 구조가 거의 동일할 때 유리 | 게시판별로 기능이 다르게 발전할 경우 유리 |
유지보수성 | 설정만 추가하면 확장 가능 | 구조적으로 역할 분리 가능 → 단일 책임 원칙 충족 |
확장 가능성 | 설정만 추가하면 쉽게 게시판 추가 | 게시판마다 UI/로직이 달라질 경우 유연하게 대응 가능 |
SEO & 코드 분할 | 메타 태그 분리, 번들 분리가 어렵고 조건문 필요 | 게시판별 lazy-load 및 SEO 메타 설정이 쉬움 |
개발 초기 비용 | 가장 빠르게 개발 가능 | 구조화에 약간의 시간 필요 |
파일 구조 | 훅 1개, Board 1개 | HOC + Board + 필요 시 게시판별 UI 분리 |
처음엔 게시판이 3개뿐이라 커스텀 훅으로도 충분하다고 생각했지만, 게시판별로 기능이 점점 다르게 진화할 가능성을 고려해 구조적인 확장성과 유지보수의 장점을 가진 HOC 을 도입하기로 결정했습니다.
HOC(고차 컴포넌트)란?
HOC(Higher Order Component)는 컴포넌트를 가져와 새 컴포넌트를 반환하는 함수. HOC은 컴포넌트를 새로운 컴포넌트로 반환함.
🔎 HOC을 적용해보자!
구현한 HOC 구조 흐름은 다음과 같습니다.
withBoard
함수는 컴포넌트(WrappedComponent
)를 인자로 받습니다.- 그리고 새로운 컴포넌트 함수(
WithBoardComponent
)를 반환합니다. - 이 새로운 컴포넌트 함수(
WithBoardComponent
)는 렌더링될 때 - 필요한 로직을 처리합니다. (URL 파라미터 추출, 유효성 검사 등) - 그 후 원래의WrappedComponent
에 추출한 게시판 정보를 props로 전달하여 렌더링합니다.
// withBoard.tsx
import { ComponentType } from "react";
import { BOARD_CONFIG } from "../constant/boardConfig";
import { useParams } from "react-router-dom";
// 래핑된 컴포넌트에 전달될 props 인터페이스 정의
export interface BoardProps {
boardKey: string;
boardTitle: string;
}
// withBoard HOC: 게시판 관련 로직을 분리하여 컴포넌트에 주입하는 고차 컴포넌트
export const withBoard = (WrappedComponent: ComponentType<BoardProps>) => {
// 새로운 컴포넌트를 반환하는 HOC 패턴(DevTools에서 식별 가능하도록 내부 컴포넌트는 기명 함수로 구현)
return function WithBoardComponent() {
// useParams 통해 패스파라미터에서 boardName 추출
const { boardName } = useParams<{ boardName: string }>();
// 유효하지 않은 게시판 이름 처리
if (!boardName || !(boardName in BOARD_CONFIG)) {
return <div>존재하지 않는 게시판입니다.</div>;
}
// 해당 게시판의 제목 가져오기
const boardTitle = BOARD_CONFIG[boardName as keyof typeof BOARD_CONFIG];
// 원래 컴포넌트에 필요한 props를 전달하여 렌더링
return <WrappedComponent boardKey={boardName} boardTitle={boardTitle} />;
};
};
// BoardPage.tsx
import React from "react";
import Board from "../components/Board";
import { withBoard } from "../hoc/withBoard";
// React.memo를 사용해 불필요한 리렌더링 방지
const MemoizedBoard = React.memo(Board);
// HOC 적용해 내보내기
export default withBoard(Board);
// App.tsx
import { Route, Routes } from "react-router-dom";
import Header from "./components/Header";
import Home from "./pages/Home";
import BoardPage from "./pages/BoardPage";
import "./css/tailwind.css";
import "./css/custom.css";
function App() {
return (
<div>
<Header />
<main>
<Routes>
<Route path="/" element={<Home />} />
{/* boardName 파라미터를 통해 각 게시판 접근 가능 */}
<Route path="/:boardName" element={<BoardPage />} />
</Routes>
</main>
</div>
);
}
export default App;
결과적으로 Board
라는 하나의 컴포넌트는 재사용하면서도 게시판 관련 로직은 HOC로 분리해 관리할 수 있도록 구조를 개선했습니다. 이를 통해 단일 책임 원칙과 확장성 모두를 충족시킬 수 있었습니다.
🙌 결론
리액트에서는 상황에 따라 커스텀 훅, 고차 컴포넌트(HOC), 컴포지션 등 다양한 패턴을 유연하게 활용할 수 있습니다. 만약 컴포넌트가 진화할 가능성이 있다면 UI 단위로 감쌀 수 있는 HOC도 좋은 선택지가 될 수 있지 않을까요?
댓글남기기