적용 환경: React 16.8 이상
들어가며
최근 사내 코드 리뷰에서 HOC로 데이터 페칭을 구현한 PR을 보게 되었습니다. 동작에는 문제가 없었지만, 2019년 이후로는 거의 사용되지 않는 패턴이라 눈에 띄었습니다.
실제로 React 공식 문서에서도 HOC를 Legacy API로 분류하고 있습니다. "Higher-order components are not commonly used in modern React code"라고 명시하면서 Custom Hook 사용을 권장합니다.
정보
그렇다면 왜 이 패턴을 알아야 할까요? HOC가 등장한 배경과 대체된 이유를 이해하면 설계 판단에 도움이 됩니다. 레거시 코드를 마주했을 때 맥락을 파악하고 마이그레이션 방향을 잡는 데도 유용합니다.
HOC란
HOC(Higher-Order Component)는 컴포넌트를 인자로 받아 새로운 컴포넌트를 반환하는 함수입니다. JavaScript의 고차 함수(Higher Order Function) 개념을 React 컴포넌트에 적용한 패턴으로, 배열의 map이나 filter가 함수를 받아 새로운 결과를 만들어내는 것과 유사합니다.
function withExample<P extends object>(
WrappedComponent: React.ComponentType<P>
) {
return function EnhancedComponent(props: P) {
// 추가 로직
return <WrappedComponent {...props} />;
};
}HOC의 주요 용도는 로깅, 권한 검사, 데이터 페칭 같은 횡단 관심사(cross-cutting concerns)를 여러 컴포넌트에서 재사용하는 것이었습니다. 예를 들어 인증이 필요한 페이지가 10개 있다면, 각 페이지마다 인증 로직을 복사하는 대신 withAuth로 감싸기만 하면 되었습니다.
클래스 컴포넌트 시절
React 16.8 이전에는 함수형 컴포넌트에서 상태를 사용할 수 없었습니다. useState도 useEffect도 존재하지 않았기 때문에, 상태가 필요하면 반드시 클래스 컴포넌트를 사용해야 했습니다.
문제는 로직 재사용 방법이 마땅치 않았다는 점입니다. 클래스 상속을 통한 재사용(extends BaseComponent)은 컴포넌트 간 결합도가 지나치게 높아져서 React에서 권장하지 않았습니다. 믹스인(Mixin)이라는 대안도 있었지만, 이름 충돌 문제가 심각해서 React 팀이 공식적으로 폐기했습니다.
이런 상황에서 상태 로직을 재사용할 수 있는 유일한 방법이 바로 HOC였습니다. 컴포넌트를 감싸서 props로 필요한 데이터를 주입하는 방식으로 동작합니다.
데이터 페칭 HOC 예시
interface WithDataProps<T> {
data: T | null;
loading: boolean;
error: Error | null;
}
function withData<T, P extends WithDataProps<T>>(
WrappedComponent: React.ComponentType<P>,
dataSource: string
) {
return class DataFetcher extends React.Component<
Omit<P, keyof WithDataProps<T>>,
WithDataProps<T>
> {
state: WithDataProps<T> = {
data: null,
loading: true,
error: null,
};
componentDidMount() {
this.fetchData();
}
async fetchData() {
try {
const response = await fetch(dataSource);
const data = await response.json();
this.setState({ data, loading: false });
} catch (error) {
this.setState({ error: error as Error, loading: false });
}
}
render() {
return <WrappedComponent {...(this.props as P)} {...this.state} />;
}
};
}
// 사용
const UserListWithData = withData(UserList, "/api/users");이 패턴의 장점은 관심사의 분리입니다. UserList 컴포넌트는 데이터 페칭 로직을 전혀 알 필요가 없고, props로 전달받은 data, loading, error를 기반으로 렌더링만 담당합니다. 다른 컴포넌트에서 동일한 패턴이 필요하다면 withData로 감싸기만 하면 됩니다.
문제점
HOC는 한동안 잘 작동했지만, 프로젝트 규모가 커지면서 여러 문제가 드러나기 시작했습니다.
하나의 컴포넌트에 여러 기능이 필요하면 HOC를 중첩해서 적용해야 합니다. 라우터 정보, 인증, 데이터 페칭, 테마가 모두 필요한 컴포넌트라면 다음과 같은 형태가 됩니다:
const EnhancedComponent = withRouter(
withAuth(withData(withTheme(withTranslation(BaseComponent))))
);React DevTools에서 컴포넌트 트리를 확인하면 래퍼가 5개씩 쌓여 있는 모습을 볼 수 있습니다. 버그가 발생했을 때 어느 래퍼에서 문제가 생긴 것인지 추적하기가 매우 어렵습니다.
Hook이 나오고 나서
React 16.8(2019년 2월)에 Hook이 도입되면서 상황이 완전히 바뀌었습니다. 함수형 컴포넌트에서도 상태를 사용할 수 있게 되면서, HOC가 해결하던 문제를 훨씬 간단한 방식으로 해결할 수 있게 되었습니다.
같은 데이터 페칭 로직을 Hook으로 작성하면
function useData<T>(url: string) {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
fetch(url)
.then((res) => res.json())
.then(setData)
.catch(setError)
.finally(() => setLoading(false));
}, [url]);
return { data, loading, error };
}
// 사용
function UserList() {
const { data, loading, error } = useData<User[]>("/api/users");
if (loading) return <Spinner />;
if (error) return <ErrorMessage error={error} />;
return (
<ul>
{data?.map((user) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
);
}코드를 읽으면 data가 어디서 오는지 즉시 파악할 수 있습니다. useData 호출의 반환값이라는 것이 명확하게 드러납니다. HOC처럼 props를 통해 암묵적으로 주입되는 방식이 아니기 때문에 데이터 흐름을 추적하기 쉽습니다. React DevTools에서도 래퍼 컴포넌트가 쌓이지 않아 컴포넌트 트리가 깔끔하게 유지됩니다.
여러 로직이 필요할 때
여러 로직을 조합해야 할 때 두 방식의 차이가 더욱 극명하게 드러납니다.
// 바깥에서 안으로 읽어야 함
const Page = withAuth(withData(withTheme(BasePage), "/api/data"));
// BasePage가 어떤 props를 받는지 보려면
// 각 HOC의 구현을 다 봐야 합니다Hook 버전은 단순히 함수 호출 결과를 변수에 할당하는 형태이므로, 어떤 값이 어디서 오는지 코드만 보고도 바로 파악할 수 있습니다.
비교
| HOC | Hook | |
|---|---|---|
| 조합 | 중첩 래핑 | 단일 컴포넌트 내 |
| 데이터 출처 | 암묵적 (props 주입) | 명시적 (함수 호출) |
| 타입 추론 | 복잡한 제네릭 | 자연스러운 추론 |
| 테스트 | 래퍼 컴포넌트 필요 | Hook만 독립 테스트 가능 |
마이그레이션
레거시 코드베이스에 HOC가 존재한다면, 한 번에 모든 것을 변경하려 하기보다는 점진적으로 전환하는 것이 안전합니다.
1단계: Hook 추출
HOC 내부의 로직을 Custom Hook으로 추출합니다. 예를 들어 withAuth 안에 있는 인증 체크 로직을 useAuth Hook으로 분리합니다.
2단계: 새 코드에 적용
새로 작성하는 컴포넌트에서는 추출한 Hook을 직접 사용합니다.
3단계: 기존 코드 유지
기존 클래스 컴포넌트는 당분간 HOC를 그대로 유지합니다. 클래스 컴포넌트에서는 Hook을 사용할 수 없기 때문입니다.
4단계: 점진적 전환
여유가 생길 때 클래스 컴포넌트를 함수형 컴포넌트로 전환하면서 HOC를 제거합니다.
팁
급하게 전체를 변경하다 버그를 만드는 것보다, 새 코드는 Hook으로 작성하고 기존 코드는 천천히 전환하는 전략이 더 효과적입니다.
정리
핵심 정리
- HOC는 레거시 패턴: 클래스 컴포넌트 시절, 상태 로직을 재사용하기 위해 고안된 패턴입니다
- Hook으로 대체: 동일한 문제를 더 명시적이고 간단한 방식으로 해결할 수 있습니다
- 새 코드는 Hook 사용: React 공식 문서에서도 Custom Hook 사용을 권장합니다