지난 글에 이어서, Fluent React에서 다루는 컴포넌트 설계 패턴. 이번에는 HOC 패턴을 다뤄본다.
High Order Component (HOC) 고차 컴포넌트
고차 컴포넌트(HOC)는 다른 컴포넌트를 인자로 받아 새로운 컴포넌트를 반환하는 컴포넌트입니다. HOC는 여러 컴포넌트 간에 공통적으로 사용되는 동작을 반복하지 않고도 공유할 경우에 유용합니다.
function withExample(WrappedComponent) {
return function(props) {
// 여기에서 컴포넌트 공용으로 적용될 추가적인 로직 구현
return <WrappedComponent {...props} />;
};
}
HOC는 다른 컴포넌트를 함수의 매개변수로 받는 점이 props를 통한 상속과는 다르다. 이를 사용해 여러 컴포넌트가 렌더링되기 이전에 특정한 로직을 실행하도록 할 수 있다. 다른 패턴들과 마찬가지로 클래스형 컴포넌트에서 자주 쓰였던 로직이지만, React.memo, forwardRef처럼 리액트의 기능에서도 여전히 사용되고 있다.
여러 컴포넌트에 공용된 로직을 적용하는 방법은 커스텀 훅이나 전역 상태를 사용할 수도 있기 때문에, HOC만의 장점이라고 보긴 어려울 수 있다. 이래도 되고, 저래도 된다면 굳이 사용할 필요가 있을까? 하지만 HOC는 공유하려는 로직을 컴포넌트 단위로 공용화하여 로직을 분리한다는 점은 커스텀 훅이나 전역상태의 사용과 구분된다. 즉 컴포넌트의 구조적인 접근으로 해결하는 점에서 유용할 수 있다.
Use Case & Code Snippet
그렇다면 HOC가 유용한 케이스를 코드와 함께 살펴보자.
적용 이전
const AdminPage = (props) ⇒ {
const isSignIn = useRecoilValue(isSignInState)
const navigate = useNavigation()
const [input, setInput] = useState('')
if(!isSignIn) {
return <FallbackComponent />
}
const handleInput = (e) => ...
return …
}
const MySettings = (props) ⇒ {
const isSignIn = useRecoilValue(isSignInState)
const navigate = useNavigation()
const param = useParam();
if(!isSignIn) {
return <FallbackComponent />
}
useEffect(() => {
if(!param) {
....
}
} , [param])
return …
}
HOC 패턴 적용
function withAuthorization(WrappedComponent,FallbackComponent,checkAuthorization)
{
return function(props) {
if (checkAuthorization()) {
// 권한이 있는 경우 원래 컴포넌트 렌더링
return <WrappedComponent {...props} />; }
else {
// 권한이 없는 경우 대체 컴포넌트 렌더링
return <FallbackComponent />;
}
};
}
// 사용 예시
const Unauthorized = () => <div>접근 권한이 없습니다.</div>;
// 권한 확인 로직(실제로는 더 복잡할 수 있음)
function checkUserIsAdmin() {
/* 로그인 여부를 전역상태로 확인하는 경우 */
const isSIgnIn = useRecoilValue(isSignInState)
/* 로그인 여부를 localStorage로 확인하는 경우 */
const localStorageIsSignIn = localStorage.getItem(’isSignIn’)
const isSIgnIn = JSON.parse(localStorageIsSignIn)
return isSignIn
}
const AdminPageWithAuthorization = withAuthorization(AdminPage, Unauthorized, checkUserIsAdmin);
const MySettingsWithAuthorization = withAuthorization(MySettings, Unauthorized, checkUserIsAdmin);
유저의 인증이 필요한 페이지에 HOC를 적용하는 snippet이다. 인증 로직을 컴포넌트 단위로 분리하여 생긴 장점을 확인할 수 있다. 기존 AdminPage와 MySettings에는 인증을 확인하기 위해 recoil의 전역 상태를 사용해도 여전히 중복된 코드가 추가된다. 인증되지 않은 상태에서 fallback을 보여주는 처리를 HOC가 담당하게 하여 중복되는 로직을 한곳으로 모으고, 각 컴포넌트에서는 각자의 로직을 담당한다.
간단한 예시여서 장점이 부각되지 않아 보일 수 있다. 이런 상황을 가정해보자. 각 컴포넌트의 내부에서 로직이 길고 복잡해지거나, isSignIn 상태를 사용하는 서로 다른 함수가 추가된다면, 코드의 가독성과 더욱 복잡해질 수 있다. 이러한 경우에 HOC의 장점이 더욱 보일 수 있을 것이다. 다음 예시를 보자.
HOC 고차 컴포넌트 적용 이전
function ComponentA() {
const router = useRouter();
const { id } = router.query;
const handleClick = async () => {
const response = await fetch(`/api/posts/${id}`, { method: 'POST' });
// POST 요청에 대한 처리
};
// id를 다른 방식으로 가공하는 로직
const processedId = `PostID-${id}`;
useEffect(() => {
if (!id) {
router.push('/some-other-page'); // id가 nullish인 경우 리디렉션
}
}, [id, router]);
return (
<div>
<button onClick={handleClick}>Send Post Request with ID</button>
<p>Processed ID: {processedId}</p>
</div>
);
}
function ComponentB() {
const router = useRouter();
const { id } = router.query;
const [data, setData] = useState(null);
// 받아온 데이터를 가공하는 로직
const processedData = data ? `Data for ID ${id}: ${JSON.stringify(data)}` : 'Loading...';
// id를 다른 방식으로 가공하는 로직
const processedId = `DataID-${id}`;
useEffect(() => {
if (!id) {
router.push('/some-other-page'); // id가 nullish인 경우 리디렉션
} else {
const fetchData = async () => {
const response = await fetch(`/api/data/${id}`);
const jsonData = await response.json();
setData(jsonData); // GET 요청으로 받은 데이터 저장
};
fetchData();
}
}, [id, router]);
return (
<div>
<p>{processedData}</p>
<p>Processed ID: {processedId}</p>
</div>
);
}
HOC 고차 컴포넌트 적용
function withIdFromQuery(WrappedComponent) {
return function(props) {
const router = useRouter();
const { id } = router.query;
useEffect(() => {
if (!id) {
router.push('/some-other-page'); // id가 nullish인 경우 리디렉션
}
}, [id, router]);
// id가 있는 경우, WrappedComponent에 id와 나머지 props를 전달
return <WrappedComponent {...props} id={id} />;
};
}
function ComponentA({ id }) {
const handleClick = async () => {
const response = await fetch(`/api/posts/${id}`, { method: 'POST' });
// POST 요청에 대한 처리
};
// id를 다른 방식으로 가공하는 로직
const processedId = `PostID-${id}`;
return (
<div>
<button onClick={handleClick}>Send Post Request with ID</button>
<p>Processed ID: {processedId}</p>
</div>
);
}
export default withIdFromQuery(ComponentA);
function ComponentB({ id }) {
const [data, setData] = useState(null);
// 받아온 데이터를 가공하는 로직
const processedData = ...
// id를 다른 방식으로 가공하는 로직
const processedId = `DataID-${id}`;
useEffect(() => {
const fetchData = async () => {...};
fetchData();
}, [id]);
return (
<div>
<p>{processedData}</p>
<p>Processed ID: {processedId}</p>
</div>
);
}
이 예시에선 url의 query parameter id가 null일 경우 페이지를 이동하고, 이 id에 따라 컴포넌트 A,B에서는 각기 다른 로직을 구성한다. !id 라우팅 이동 동작은 A, B에서 동일하나, 각자의 컴포넌트에선 id를 사용하여 JSX 렌더링과 이벤트, 데이터 fetch 등 각기 다른 로직을 포함한다.
같은 동작을 위해 import, useEffect, router.push 같은 중복된 코드가 추가되는 것도 문제다. 그러나 더 큰 문제는 같은 동작을 하는 코드가 산재하게 되는 것이다. id가 nullish일 시 라우팅 동작에 에러가 생긴다면 컴포넌트 A, B에서 id를 사용하는 로직에서 모두 문제를 살펴봐야 한다. 반대도 마찬가지다. 에러를 찾을 때까지 컴포넌트 A의 라우팅 동작, A의 핸들러, B의 라우팅 동작, B의 데이터 fetching, A,B의 렌더링까지 모두 살펴봐야한다. 보통 웹 서비스에서 컴포넌트는 이보다 더욱 복잡할 것이기에, 문제를 찾는 것이 무척 까다로워진다. 또한 코드가 작성된 이후 몇개월만 지나도 유지보수도 매우 까다로워질 것이다.
이를 통해 HOC 패턴의 장점은 어느정도 분명히 알게 되었다.
- 여러 컴포넌트가 동일한 상태, 로직을 공유하여 같은 동작을 할 때 적용
- 각 컴포넌트에서 공통된 동작과 내부의 로직을 분리하여 가독성, 테스팅, 디버깅 등의 이점이 생긴다
- 이를 컴포넌트 구조적인 접근으로 해결하는 방법
Fluent React에서는 HOC와 hook 패턴의 비교를 논의하는데, 이 또한 유익해보인다.
이 표에서 우리는 HOCs(고차 컴포넌트)와 후크가 컴포넌트 간에 로직을 공유하는 데 있어 React에서 핵심적인 역할을 한다는 것을 관찰할 수 있으며, 이들은 약간 다른 사용 사례에 적합합니다. HOCs는 여러 컴포넌트 간에 로직을 공유하는 데 뛰어나며, 특히 감싸진 컴포넌트의 렌더링을 제어하고 속성을 조작하여 컴포넌트에 추가 데이터나 함수를 제공하는 데 능숙합니다. 이들은 감싸진 컴포넌트 외부에서 상태를 관리하고 감싸진 컴포넌트와 관련된 생명주기 로직을 캡슐화할 수 있습니다. 그러나, 특히 많은 HOCs가 함께 중첩될 때 잘 관리되지 않으면 "래퍼 지옥"으로 이어질 수 있습니다. 이러한 중첩은 테스팅을 더 복잡하게 만들 수 있으며, 특히 깊게 중첩된 HOCs와 함께할 때 TypeScript로 타입 안전성을 확보하는 것이 까다로울 수 있습니다.
반면에, 훅Hook은 컴포넌트 내부나 유사한 컴포넌트 간에 로직을 추출하고 공유하는 데 이상적이며, 추가적인 컴포넌트 계층을 추가하지 않아 "래퍼 지옥" 시나리오를 피할 수 있습니다. HOCs와 달리, 훅은 직접적으로 렌더링에 영향을 주지 않으며 속성을 직접 주입하거나 조작할 수 없습니다. 훅은 함수형 컴포넌트 내부의 로컬 상태를 관리하고 useEffect 훅을 포함한 다른 훅을 사용하여 생명주기 이벤트를 처리하도록 설계되었습니다. 훅은 구성의 용이성을 촉진하며 HOCs보다 격리하기 쉬워 일반적으로 테스트하기 더 쉽습니다. 또한, TypeScript와 함께 사용될 때, 훅은 더 나은 타입 추론을 제공하고 타입을 지정하기 쉬워 타입 오류와 관련된 버그를 줄일 수 있습니다.
HOCs와 훅Hook 모두 로직 재사용 메커니즘을 제공하지만, 훅은 함수형 컴포넌트 내에서 상태, 생명주기 이벤트 및 기타 React 기능을 관리하는 데 더 직접적이고 덜 복잡한 접근 방식을 제공합니다. 반면에, HOCs는 컴포넌트에 행동을 주입하는 더 구조화된 방식을 제공하는데, 이는 더 큰 코드베이스나 아직 훅을 채택하지 않은 코드베이스에서 유용할 수 있습니다. 각각은 자신만의 장점 세트를 가지고 있으며, HOCs 또는 훅을 사용할지의 선택은 대부분 프로젝트의 특정 요구 사항과 팀의 이러한 패턴에 대한 익숙함에 크게 의존할 것입니다.