render props vs custom hooks vs HOC: 무엇을 언제 써야 할까

Frontend

React를 하다 보면 비슷한 질문이 반복됩니다.

  • 공통 로직은 어디에 둘까
  • UI와 상태는 어느 정도까지 분리할까
  • 재사용을 위해 추상화를 만들 때 어떤 패턴이 가장 읽기 쉬울까

이 질문 앞에서 자주 등장하는 선택지가 세 가지입니다.

  • render props
  • custom hooks
  • HOC

셋 다 "재사용"을 위한 도구이지만, 재사용하는 대상과 코드가 드러나는 방식이 서로 다릅니다. 그래서 이름만 외우기보다, 같은 문제를 세 패턴으로 풀어보면서 차이를 보는 편이 훨씬 빠릅니다.

이 글에서는 같은 문제를 기준으로 세 패턴을 비교해보겠습니다.

한눈에 보면

먼저 아주 짧게 요약하면 이렇습니다.

  • render props: UI 제어권을 소비자에게 많이 넘기고 싶을 때
  • custom hooks: 지금 React에서 가장 기본 선택이 되는 경우가 많을 때
  • HOC: 공통 정책을 화면 단위로 감싸는 구조가 자연스러울 때

표로 보면 더 분명합니다.

패턴 재사용 대상 장점 주의점
render props 상태와 렌더링 계약 UI 유연성이 높다 JSX 중첩이 늘기 쉽다
custom hooks 상태 로직 가장 자연스럽고 읽기 쉽다 UI까지 같이 추상화하진 않는다
HOC 공통 정책과 래핑 구조 페이지 보호나 횡단 관심사에 잘 맞는다 props 흐름이 복잡해질 수 있다

비교용 문제: 마우스 위치 추적

세 패턴을 비교하기 좋은 예제로 mouse position을 써보겠습니다. 요구사항은 단순합니다.

  • 현재 마우스 좌표를 추적한다
  • 이 좌표를 여러 화면에서 재사용하고 싶다
  • 화면마다 UI 표현은 다를 수 있다

1. render props

render props는 상태를 제공하는 컴포넌트가 있고, 실제 UI는 함수 자식이나 render 함수로 넘겨받는 쪽에서 그리는 방식입니다.

import { useEffect, useState } from 'react';
 
type MousePosition = {
  x: number;
  y: number;
};
 
type MouseTrackerProps = {
  children: (position: MousePosition) => React.ReactNode;
};
 
export function MouseTracker({ children }: MouseTrackerProps) {
  const [position, setPosition] = useState({ x: 0, y: 0 });
 
  useEffect(() => {
    const handleMouseMove = (event: MouseEvent) => {
      setPosition({ x: event.clientX, y: event.clientY });
    };
 
    window.addEventListener('mousemove', handleMouseMove);
    return () => window.removeEventListener('mousemove', handleMouseMove);
  }, []);
 
  return <>{children(position)}</>;
}

사용하는 쪽은 이렇게 됩니다.

export function MousePositionCard() {
  return (
    <MouseTracker>
      {({ x, y }) => (
        <div>
          <h2>현재 좌표</h2>
          <p>
            x: {x}, y: {y}
          </p>
        </div>
      )}
    </MouseTracker>
  );
}

장점은 분명합니다. 상태를 어떻게 그릴지 완전히 소비자에게 넘길 수 있습니다. 반면 JSX가 중첩되기 쉬워서 화면 구조가 복잡해지면 읽기 피곤해질 수 있습니다.

2. custom hooks

같은 문제를 custom hook으로 풀면 더 오늘날 React다운 코드가 됩니다.

import { useEffect, useState } from 'react';
 
export function useMousePosition() {
  const [position, setPosition] = useState({ x: 0, y: 0 });
 
  useEffect(() => {
    const handleMouseMove = (event: MouseEvent) => {
      setPosition({ x: event.clientX, y: event.clientY });
    };
 
    window.addEventListener('mousemove', handleMouseMove);
    return () => window.removeEventListener('mousemove', handleMouseMove);
  }, []);
 
  return position;
}

사용하는 쪽은 훨씬 평평합니다.

export function MousePositionCard() {
  const { x, y } = useMousePosition();
 
  return (
    <div>
      <h2>현재 좌표</h2>
      <p>
        x: {x}, y: {y}
      </p>
    </div>
  );
}

대부분의 React 코드베이스에서 이 방식이 가장 선호되는 이유는 간단합니다.

  • 로직이 함수로 분리되고
  • UI는 여전히 컴포넌트 안에 남아 있으며
  • 타입도 비교적 자연스럽고
  • 조합도 쉽습니다

즉, 상태 로직을 재사용하고 싶다custom hook이 기본 선택이 되는 경우가 많습니다.

3. HOC

이번에는 같은 문제를 HOC로 감싸보겠습니다.

import { useEffect, useState } from 'react';
 
type WithMousePositionProps = {
  mouse: {
    x: number;
    y: number;
  };
};
 
export function withMousePosition<P extends WithMousePositionProps>(
  Component: React.ComponentType<P>
) {
  return function MousePositionComponent(props: Omit<P, keyof WithMousePositionProps>) {
    const [mouse, setMouse] = useState({ x: 0, y: 0 });
 
    useEffect(() => {
      const handleMouseMove = (event: MouseEvent) => {
        setMouse({ x: event.clientX, y: event.clientY });
      };
 
      window.addEventListener('mousemove', handleMouseMove);
      return () => window.removeEventListener('mousemove', handleMouseMove);
    }, []);
 
    return <Component {...(props as P)} mouse={mouse} />;
  };
}

사용 예시는 아래처럼 됩니다.

type MouseCardProps = {
  title: string;
  mouse: {
    x: number;
    y: number;
  };
};
 
function MouseCard({ title, mouse }: MouseCardProps) {
  return (
    <div>
      <h2>{title}</h2>
      <p>
        x: {mouse.x}, y: {mouse.y}
      </p>
    </div>
  );
}
 
export const MouseCardWithPosition = withMousePosition(MouseCard);

HOC는 나쁜 패턴이 아니라, 무언가를 감싸서 공통 정책을 주입하는 구조가 필요할 때 읽기 쉬워지는 패턴입니다. 다만 단순 상태 재사용만 놓고 보면 custom hook보다 돌아가는 길처럼 느껴질 수 있습니다.

인증 예제로 다시 보면 더 분명하다

이번엔 실제 서비스에서 더 자주 마주치는 인증 문제를 보겠습니다.

render props로 인증 상태 제공

type AuthRenderProps = {
  user: { id: string; name: string } | null;
  isLoading: boolean;
};
 
function AuthProvider({ children }: { children: (auth: AuthRenderProps) => React.ReactNode }) {
  const auth = useAuth();
  return <>{children(auth)}</>;
}

이 방식은 소비자가 렌더링을 완전히 제어할 수 있다는 장점이 있지만, 페이지마다 비슷한 분기문이 반복되기 쉽습니다.

custom hook으로 인증 상태 사용

function DashboardPage() {
  const { user, isLoading } = useAuth();
 
  if (isLoading) return <Spinner />;
  if (!user) return <LoginRequired />;
 
  return <Dashboard user={user} />;
}

대부분의 팀에서 가장 익숙하게 받아들이는 방식입니다.

HOC로 페이지를 보호

function withAuth<P extends object>(Component: React.ComponentType<P>) {
  return function ProtectedComponent(props: P) {
    const { user, isLoading } = useAuth();
 
    if (isLoading) return <Spinner />;
    if (!user) return <LoginRequired />;
 
    return <Component {...props} />;
  };
}

페이지 단위 보호라는 목표에는 이쪽이 더 의도가 명확할 수 있습니다.

export default withAuth(DashboardPage);

이 예제에서 핵심은 "무엇이 더 최신인가"가 아니라, 문제의 형태가 무엇인가입니다.

그러면 언제 무엇을 고르면 될까?

실무에서는 아래 기준으로 보면 꽤 정리가 됩니다.

custom hooks를 먼저 보는 경우

  • 상태 로직을 재사용하고 싶다
  • UI 구조는 각 컴포넌트가 스스로 가지고 있어야 한다
  • 팀이 가장 익숙한 React 스타일을 원한다

대부분의 경우 출발점은 여기입니다.

render props가 더 잘 맞는 경우

  • 상태를 제공하는 쪽과 UI를 그리는 쪽을 강하게 분리하고 싶다
  • 같은 데이터를 여러 UI로 다양하게 표현해야 한다
  • headless 컴포넌트처럼 렌더링 제어권을 많이 넘기고 싶다

즉, UI 유연성 자체가 요구사항일 때 여전히 좋은 선택입니다.

HOC가 더 읽기 좋은 경우

  • 페이지 보호, 추적, 권한 확인처럼 공통 정책을 감싸고 싶다
  • 화면 단위로 래핑하는 구조가 자연스럽다
  • 라이브러리 수준에서 일관된 계약을 제공하고 싶다

즉, 로직 재사용보다 정책 주입에 더 가까운 문제에서 빛납니다.

정리하면

세 패턴을 한 줄씩 요약하면 이렇습니다.

  • render props: 렌더링 제어권을 소비자에게 크게 넘기는 패턴
  • custom hooks: 상태 로직을 가장 자연스럽게 재사용하는 패턴
  • HOC: 공통 정책을 래핑해서 주입하는 패턴

그리고 실무 기준 기본값은 대체로 이렇습니다.

  1. 먼저 custom hooks를 본다
  2. UI 유연성이 핵심이면 render props를 검토한다
  3. 화면 단위 정책 주입이면 HOC를 본다

결국 좋은 선택은 패턴 이름이 아니라, 이 문제에서 재사용해야 하는 것이 로직인지, 렌더링 계약인지, 정책인지를 먼저 구분하는 데서 나옵니다.

같이 보면 좋은 글