HOC는 무엇이고 React 컴포넌트에 실제로 어떻게 적용할까

Frontend

React를 조금 오래 봤다면 HOC라는 단어를 한 번쯤은 만납니다.

  • connect
  • withRouter
  • withAuth
  • memo

요즘은 Hooks가 중심이 되면서 예전보다 직접 작성하는 빈도는 줄었지만, 그렇다고 개념 자체가 사라진 것은 아닙니다. 실제 서비스에서는 인증, 로딩, 에러 처리, 추적 코드처럼 공통 관심사를 여러 컴포넌트에 일관되게 입히는 문제가 여전히 자주 나오기 때문입니다.

이 글에서는 HOC를 단순 정의로 끝내지 않고 아래 순서로 정리해보겠습니다.

  1. HOC가 정확히 무엇인지
  2. 어떤 문제를 풀 때 쓰는지
  3. 실제 컴포넌트에 어떻게 적용하는지
  4. TypeScript에서 타입을 어떻게 다루는지
  5. Hooks와 비교하면 언제 적합한지

한눈에 보면

짧게 요약하면 이렇습니다.

  • HOC는 컴포넌트를 받아 새로운 컴포넌트를 반환하는 함수입니다.
  • 목적은 공통 로직을 UI 바깥에서 감싸듯 주입하는 것입니다.
  • 인증, 로딩, 에러 바운더리, 추적 코드 같은 횡단 관심사에 특히 잘 맞습니다.
  • 다만 props 흐름이 복잡해지기 쉬워서, 지금은 Hooks나 컴포넌트 조합이 우선인 경우가 더 많습니다.

가장 단순한 형태는 아래처럼 볼 수 있습니다.

import type { ComponentType } from 'react';
 
function withSomething<P>(Component: ComponentType<P>) {
  return function WrappedComponent(props: P) {
    return <Component {...props} />;
  };
}

입력은 기존 컴포넌트이고, 출력은 무언가가 추가된 새 컴포넌트입니다. HOC의 본질은 이 한 줄로 거의 설명됩니다.

HOC는 정확히 무엇일까?

이름 그대로 Higher-Order Component입니다. 함수형 프로그래밍의 higher-order function과 비슷한 발상입니다.

  • 함수를 받아 함수를 반환하면 고차 함수
  • 컴포넌트를 받아 컴포넌트를 반환하면 HOC

예를 들어 기본 버튼이 있다고 해보겠습니다.

type ButtonProps = {
  label: string;
  onClick: () => void;
};
 
function Button({ label, onClick }: ButtonProps) {
  return <button onClick={onClick}>{label}</button>;
}

여기에 클릭 추적 로직을 공통으로 넣고 싶다면 이렇게 감쌀 수 있습니다.

import type { ComponentType } from 'react';
 
type TrackClickProps = {
  trackingName: string;
};
 
function withClickTracking<P extends object>(Component: ComponentType<P>) {
  return function TrackedComponent(props: P & TrackClickProps) {
    const { trackingName, ...rest } = props;
 
    console.log('track click:', trackingName);
 
    return <Component {...(rest as P)} />;
  };
}
 
const TrackedButton = withClickTracking(Button);

실제로는 위 코드처럼 렌더링 시점에 추적을 넣기보다 이벤트 핸들러를 감싸는 식으로 설계하는 편이 더 자연스럽지만, 예제의 핵심은 분명합니다. 원래 컴포넌트는 자신의 UI 역할에 집중하고, 공통 로직은 바깥에서 주입할 수 있다는 점입니다.

왜 HOC가 필요할까?

프로젝트가 커질수록 비슷한 요구가 반복됩니다.

  • 로그인 여부에 따라 페이지를 막고 싶다
  • 로딩 중이면 스켈레톤이나 스피너를 보여주고 싶다
  • 에러가 나면 공통 에러 UI를 쓰고 싶다
  • 페이지 뷰나 클릭 이벤트를 추적하고 싶다

이 로직을 모든 컴포넌트 안에 직접 넣으면 UI와 횡단 관심사가 뒤섞입니다.

function ProfilePage() {
  const { user, isLoading } = useAuth();
 
  usePageTracking('profile-page');
 
  if (isLoading) {
    return <Spinner />;
  }
 
  if (!user) {
    return <LoginRequired />;
  }
 
  return <Profile user={user} />;
}

위 방식이 항상 나쁜 것은 아니지만, 같은 구조가 여러 페이지에 반복된다면 관심사를 한 번 감싸서 공통화하고 싶어집니다. 이때 HOC는 꽤 직선적인 해법이 됩니다.

인증 로직을 감싸는 HOC 예제

가장 흔한 예제로 withAuth를 볼 수 있습니다.

import type { ComponentType } from 'react';
 
type AuthState = {
  user: { id: string; name: string } | null;
  isLoading: boolean;
};
 
function useAuth(): AuthState {
  return {
    user: { id: '1', name: 'Marco' },
    isLoading: false,
  };
}
 
function withAuth<P extends object>(Component: ComponentType<P>) {
  return function AuthenticatedComponent(props: P) {
    const { user, isLoading } = useAuth();
 
    if (isLoading) {
      return <div>로딩 중...</div>;
    }
 
    if (!user) {
      return <div>로그인이 필요합니다.</div>;
    }
 
    return <Component {...props} />;
  };
}

사용하는 쪽은 훨씬 단순해집니다.

type DashboardProps = {
  title: string;
};
 
function DashboardPage({ title }: DashboardProps) {
  return <h1>{title}</h1>;
}
 
export const ProtectedDashboardPage = withAuth(DashboardPage);

이 패턴의 장점은 인증 체크가 모든 페이지마다 흩어지지 않고, ProtectedDashboardPage라는 결과물만 보고도 의도를 읽을 수 있다는 점입니다.

로딩 상태를 감싸는 HOC 예제

withLoading도 비슷한 구조를 가집니다.

type LoadingProps = {
  isLoading: boolean;
};
 
function withLoading<P extends object>(Component: React.ComponentType<P>) {
  return function LoadingComponent(props: P & LoadingProps) {
    const { isLoading, ...rest } = props;
 
    if (isLoading) {
      return <div>데이터를 불러오는 중입니다...</div>;
    }
 
    return <Component {...(rest as P)} />;
  };
}
type UserTableProps = {
  users: Array<{ id: string; name: string }>;
};
 
function UserTable({ users }: UserTableProps) {
  return (
    <ul>
      {users.map((user) => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  );
}
 
const UserTableWithLoading = withLoading(UserTable);

이렇게 하면 UserTable은 로딩 상태를 몰라도 되고, 로딩 표시는 바깥에서 책임집니다.

TypeScript에서는 무엇을 조심해야 할까?

HOC의 어려움은 대개 타입과 props 전달에서 나옵니다.

1. 원래 props와 주입 props를 구분해야 한다

HOCtheme, user, isLoading 같은 값을 주입한다면, 외부에서 받는 props와 내부에서 만들어 넣는 props를 구분해야 합니다.

type InjectedThemeProps = {
  theme: 'light' | 'dark';
};
 
function withTheme<P extends InjectedThemeProps>(Component: React.ComponentType<P>) {
  return function ThemedComponent(props: Omit<P, keyof InjectedThemeProps>) {
    return <Component {...(props as P)} theme="dark" />;
  };
}

이 패턴에서 Omit을 쓰는 이유는 외부 사용자가 theme를 다시 넘기지 못하게 막기 위해서입니다.

2. displayName을 붙여주는 편이 좋다

디버깅할 때 감싸진 컴포넌트 이름이 Anonymous로 보이면 꽤 불편합니다.

function withAuth<P extends object>(Component: React.ComponentType<P>) {
  function AuthenticatedComponent(props: P) {
    return <Component {...props} />;
  }
 
  const componentName = Component.displayName ?? Component.name ?? 'Component';
  AuthenticatedComponent.displayName = `withAuth(${componentName})`;
 
  return AuthenticatedComponent;
}

React DevTools에서 계층을 읽기 쉬워지는 작은 습관입니다.

HOC의 장점

정리하면 아래 장점이 있습니다.

  • 공통 관심사를 재사용하기 쉽다
  • 원래 UI 컴포넌트를 비교적 순수하게 유지할 수 있다
  • 감싼 결과물의 의도가 이름으로 드러난다
  • 라이브러리 레벨 추상화에 잘 맞는다

특히 페이지 단위 인증, 추적, 에러 처리처럼 여러 곳에 비슷한 규칙을 입히는 문제에서는 여전히 유효합니다.

HOC의 단점

반대로 아래 단점도 분명합니다.

  • props 흐름을 따라가기 어렵다
  • 여러 HOC가 겹치면 디버깅이 힘들다
  • 타입이 복잡해지기 쉽다
  • Hooks가 더 단순한 해법인 경우가 많다

예를 들어 아래처럼 중첩되기 시작하면 가독성이 빠르게 떨어집니다.

export default withPageTracking(withAuth(withErrorBoundary(Page)));

이 시점부터는 "정말 HOC가 최선인가?"를 다시 묻게 됩니다.

Hooks와 비교하면 언제 적합할까?

요즘 기준으로는 대체로 이렇게 생각하면 편합니다.

Hooks가 더 자연스러운 경우

  • 로직을 컴포넌트 내부에서 직접 읽는 편이 더 명확할 때
  • UI와 상태 흐름이 강하게 연결되어 있을 때
  • 컴포넌트별로 약간씩 다른 동작이 필요할 때

HOC가 여전히 괜찮은 경우

  • 동일한 정책을 여러 컴포넌트에 공통 적용할 때
  • 페이지나 화면 단위로 감싸는 구조가 자연스러울 때
  • 라이브러리나 프레임워크 레벨 추상화를 만들 때

즉, HOC는 낡은 패턴이라서 버리는 것이 아니라, 문제가 "공통 정책을 감싼다"에 가까울 때 적합한 도구라고 보는 편이 맞습니다.

정리하면

HOC를 한 줄로 요약하면, 컴포넌트를 입력받아 공통 기능이 추가된 컴포넌트를 반환하는 추상화 패턴입니다.

실무 감각으로 정리하면:

  • 인증, 로딩, 추적 같은 횡단 관심사에는 여전히 쓸모가 있고
  • TypeScript에서는 주입 props와 외부 props를 명확히 구분해야 하며
  • 지금은 Hooks와 컴포넌트 조합이 기본 선택이지만
  • 라이브러리 수준 추상화나 페이지 보호 같은 문맥에서는 HOC가 더 읽기 좋을 때도 있습니다

중요한 것은 HOC를 오래된 패턴으로만 보지 않는 것입니다. 결국 React에서 좋은 추상화는 "최신 문법"보다 관심사를 어디에 두는 것이 가장 읽기 쉬운가의 문제에 더 가깝습니다.

같이 보면 좋은 글