render props vs custom hooks vs HOC: 무엇을 언제 써야 할까
React를 하다 보면 비슷한 질문이 반복됩니다.
- 공통 로직은 어디에 둘까
- UI와 상태는 어느 정도까지 분리할까
- 재사용을 위해 추상화를 만들 때 어떤 패턴이 가장 읽기 쉬울까
이 질문 앞에서 자주 등장하는 선택지가 세 가지입니다.
render propscustom hooksHOC
셋 다 "재사용"을 위한 도구이지만, 재사용하는 대상과 코드가 드러나는 방식이 서로 다릅니다. 그래서 이름만 외우기보다, 같은 문제를 세 패턴으로 풀어보면서 차이를 보는 편이 훨씬 빠릅니다.
이 글에서는 같은 문제를 기준으로 세 패턴을 비교해보겠습니다.
한눈에 보면
먼저 아주 짧게 요약하면 이렇습니다.
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: 공통 정책을 래핑해서 주입하는 패턴
그리고 실무 기준 기본값은 대체로 이렇습니다.
- 먼저
custom hooks를 본다 - UI 유연성이 핵심이면
render props를 검토한다 - 화면 단위 정책 주입이면
HOC를 본다
결국 좋은 선택은 패턴 이름이 아니라, 이 문제에서 재사용해야 하는 것이 로직인지, 렌더링 계약인지, 정책인지를 먼저 구분하는 데서 나옵니다.
