HOC, 고차 컴포넌트란?
고차 컴포넌트 ( HOC, Higher Order Component )는 컴포넌트 로직을 재사용하기 위한 React의 패턴이다. React API가 아닌, 리액트의 디자인 패턴이라고 할 수 있다. 구체적으로 설명하자면,
기본 기능을 가진 컴포넌트를 받아서 새 기능을 추가하여 확장된
컴포넌트를 반환하는 패턴이다.일반 컴포넌트는 props를 받아서 UI를 반환하지만, 이 HOC패턴의 특징은 컴포넌트를 받아서 새로운 컴포넌트를 반환하는 것이 특징이다.
왜 이 패턴이 필요할까?
일단 어떤 문제점이 있길래 이러한 패턴을 사용하는 것일까? 그래서 문제가 되는 상황을 살펴보고, 이 글을 읽으시는 분들이 직접 판단해서 사용해보는 것이 좋을 것이라 생각한다.
일단 일반적인 Profile 컴포넌트와 Dashboard 컴포넌트를 보여주는 페이지가 있다고 가정해 보자.
import { useState, useEffect } from "react"; function Profile() { const [isLoading, setIsLoading] = useState(true); useEffect(() => { const timer = setTimeout(() => { setIsLoading(false); }, 2000); return () => clearTimeout(timer); }, []); if (isLoading) { return ( <div> <h2>Profile</h2> <div className="spinner" /> </div> ); } const user = { name: "John Doe", email: "john@example.com" }; return ( <div> <h2>Profile</h2> <pre>{JSON.stringify(user, null, 2)}</pre> </div> ); } function Dashboard() { const [isLoading, setIsLoading] = useState(true); useEffect(() => { const timer = setTimeout(() => { setIsLoading(false); }, 3000); return () => clearTimeout(timer); }, []); if (isLoading) { return ( <div> <h2>Dashboard</h2> <div className="spinner" /> </div> ); } const data = { key: "value" }; return ( <div> <h2>Dashboard</h2> <pre>{JSON.stringify(data, null, 2)}</pre> </div> ); } function WithoutHoc() { return ( <main className="container"> <Profile /> <Dashboard /> </main> ); } export default WithoutHoc;
현재 코드의 구조를 간단하게 설명을 하자면,
- Profile에서 2초간의 로딩을 useEffect로 관리하며 isLoading에 대한 로딩UI를 처리하고 있다.
- Dashboard는 3초간의 로딩을 같은 방식으로 관리하며 isLoading 상태에 대한 로딩UI를 처리하고 있다.
- WithoutHoc함수에서는 두 가지 컴포넌트를 묶어서 반환하고 있다.
이 구조에서는 조금 붚편한 사항이 있다는 걸 느낄 수 가 있는데, 비슷한 loading상태관리에 대한 로직이 중복된다는 점이다. 이러한 구조를 사용한다면 대규모 프로젝트에서는 어마어마한 코드중복이 될 가능성이 높다. 어차피 로딩 상태를 관리하는 로직이 어떤 컴포넌트든 비슷하다면, 이러한 상황에서 HOC패턴을 적용을 고려해 볼 수 있다.
HOC 패턴 적용하기
그럼 로딩상태를 관리하는 함수를 만들어보자. 핵심 포인트는 원본 컴포넌트를 받아서, 로딩 로직을 추가한 후, 새로운 컴포넌트를 반환하면 된다.
function withLoading(Component, delay = 2000) { return function WithLoading(props) { const [isLoading, setIsLoading] = useState(true); useEffect(() => { const timer = setTimeout(() => { setIsLoading(false); }, delay); return () => clearTimeout(timer); }, []); if (isLoading) { return ( <div> <h2>{props.title}</h2> <div className="spinner" /> </div> ); } return <Component {...props} />; }; }
- 파라메터로 Component와 각기 다른 딜레이 값을 받을 수 있도록 2개의 파라메터를 받는다.
- 이 부분이 HOC를 생성하는 팩토리 함수라고 할 수 있겠다.
- 여기서 함수명이 카멜케이스임을 확인할 수 있는데, 함수임을 명확하게 하기 위해서이다
function withLoading(Component, delay = 2000) {
- return은 실제로 렌더링이 되는 리액트 컴포넌트를 리턴을 한다.
- 여기서 사용되는 함수명은 파스칼케이스를 사용한다. 왜냐하면 여기서 반환되는 값은 리액트 컴포넌트임을 명확하게 하기 위해서이다.
return function WithLoading(props) {
- 그리고 실제 loading로직을 작성한다.
- WithLoading컴포넌트의 return은 전달받은 리액트 컴포넌트를 그대로 반환 해준다.
이렇게 로딩로직을 HOC패턴으로 구현한다면, 실제 렌더링되는 Profile과 Dashboard 컴포넌트는 온전하게 관심사가 분리되어서 핵심 UI로직만 남길 수 있게 된다.
function Profile() { const user = { name: "John Doe", email: "john@example.com" }; return ( <div> <h2>Profile</h2> <pre>{JSON.stringify(user, null, 2)}</pre> </div> ); } function Dashboard() { const data = { key: "value" }; return ( <div> <h2>Dashboard</h2> <pre>{JSON.stringify(data, null, 2)}</pre> </div> ); }
이렇게 분리된 관심사를 횡단 관심사 분리 (cross-cutting-concerns)라고 하는데, 어느 한곳에 종속된 관심사가 아닌 여러 함수들이 같이 고민하는 관심사이기 때문에 종속 관심사가 아니라 횡단 관심사가 되기 떄문이다.
이제 이렇게 나누어진 관심사를 묶어서 선언해주면 최종 HOC패턴이 적용된 컴포넌트가 된다.
const ProfileWithLoading = withLoading(Profile, 2000); const DashboardWithLoading = withLoading(Dashboard, 3000);
- 여기서 의미를 명확하게 하기위해서 ProfileLoadingWrapper 로 이름을 정할 수도 있을 것이다. 이건 본인의 네이밍 컨벤션대로 적용해보면 좋을 것 같다.
마지막으로 사용하는 페이지에서 선언적으로 사용하면 된다.
function WithHoc() { return ( <main className="container"> <ProfileWithLoading title="Profile" /> <DashboardWithLoading title="Dashboard" /> </main> ); }
HOC 패턴 전체 코드 펼쳐보기
import { useState, useEffect } from "react"; function withLoading(Component, delay = 2000) { return function WithLoading(props) { const [isLoading, setIsLoading] = useState(true); useEffect(() => { const timer = setTimeout(() => { setIsLoading(false); }, delay); return () => clearTimeout(timer); }, []); if (isLoading) { return ( <div> <h2>{props.title}</h2> <div className="spinner" /> </div> ); } return <Component {...props} />; }; } function Profile() { const user = { name: "John Doe", email: "john@example.com" }; return ( <div> <h2>Profile</h2> <pre>{JSON.stringify(user, null, 2)}</pre> </div> ); } function Dashboard() { const data = { key: "value" }; return ( <div> <h2>Dashboard</h2> <pre>{JSON.stringify(data, null, 2)}</pre> </div> ); } const ProfileWithLoading = withLoading(Profile, 2000); const DashboardWithLoading = withLoading(Dashboard, 3000); function WithHoc() { return ( <main className="container"> <ProfileWithLoading title="Profile" /> <DashboardWithLoading title="Dashboard" /> </main> ); } export default WithHoc;
이렇게 적용함으로서 얻게된 장점을 다시 상기시켜 보자면,
- 로딩 로직을 여러 컴포넌트에 쉽게 적용할 수 있게 되어 재사용성이 높아졌다.
- 원본 컴포넌트는 자신의 관심사와 로딩 로직의 관심사가 분리되어 Single Responsilbility Principle을 적용할 수 있게 되었다.
- 그리고 원본코드를 수정하지 않고 추가로직을 구현함으로서 Open-Closed Principle이 적용 되었다.
결론
어플리케이션은 돌아가게 만드는 것은 누구나 할 수있지만, 시간이 갈수록 유지비용이 줄어들고 변경이 쉬워지는 아키텍처를 지키며 코드를 작성하는 것이 아마추어와 프로를 나누는 핵심이라고 생각이 든다. 마지막으로 클린 아키텍처의 저자인 로버트 C. 마틴이 남긴 말을 한 번 곱씹어 보면서 마무리 해보려고 한다.
“소프트웨어 아키텍처의 목표는 필요한 시스템을 만들고 유지보수하는 데 투입되는 인력을 최소화 하는데 있다.” - Robert C. Martin