HOC에서 Hooks까지 — 로직 재사용 패턴의 변천사
같은 로직을 여러 컴포넌트에서 쓰고 싶을 때, 복붙 말고 어떤 방법이 있을까요?
React에서 "로직을 재사용하는 방법"은 시대에 따라 계속 바뀌어 왔습니다. Mixins에서 시작해 HOC, Render Props를 거쳐 현재의 Hooks까지. 각 패턴이 왜 등장했고, 어떤 문제를 해결했으며, 왜 다음 패턴에 자리를 내줬는지 정리하겠습니다.
Mixins — 최초의 로직 공유 시도
React 초기에는 createClass와 함께 Mixin을 사용했습니다.
// 더 이상 사용되지 않는 패턴
const TimerMixin = {
componentDidMount() {
this.timer = setInterval(this.tick, 1000);
},
componentWillUnmount() {
clearInterval(this.timer);
},
};
const Clock = React.createClass({
mixins: [TimerMixin],
tick() {
this.setState({ time: Date.now() });
},
render() {
return <div>{this.state.time}</div>;
},
});
Mixin의 문제점
- **이름 충돌 **: 여러 Mixin이 같은 이름의 메서드나 state를 정의하면 충돌합니다
- ** 암묵적 의존성 **: Mixin이
this.state나this.props에 의존하지만 명시적이지 않습니다 - ** 눈덩이 복잡성 **: Mixin끼리 의존하기 시작하면 어떤 Mixin이 어떤 상태를 변경하는지 추적이 불가능해집니다
결국 React 팀은 Mixin을 공식적으로 폐기하고, ES6 class에서는 아예 Mixin을 지원하지 않았습니다.
HOC (Higher-Order Component) — 합성으로 해결하기
HOC는 컴포넌트를 인자로 받아 새 컴포넌트를 반환하는 함수입니다.
function withAuth(WrappedComponent) {
return function AuthenticatedComponent(props) {
const { user, loading } = useAuth();
if (loading) return <Spinner />;
if (!user) return <Navigate to="/login" />;
return <WrappedComponent {...props} user={user} />;
};
}
// 사용
const ProtectedDashboard = withAuth(Dashboard);
HOC의 장점
- Mixin과 달리 ** 합성(composition)** 기반이라 이름 충돌이 없습니다
- 렌더링 자체를 가로채거나 조건부로 변경할 수 있습니다
- 관심사를 명확하게 분리할 수 있습니다
HOC의 문제점
// Wrapper Hell - 디버깅이 지옥
export default withRouter(
withTheme(
withAuth(
withLogging(MyComponent)
)
)
);
- Wrapper Hell: HOC를 여러 개 중첩하면 컴포넌트 트리가 깊어집니다
- **Props 충돌 **: 여러 HOC가 같은 이름의 prop을 주입하면 덮어쓰기 됩니다
- ** 정적 메서드 손실 **: 원래 컴포넌트의 static 메서드가 래퍼에 전달되지 않습니다
- **Ref 전달 문제 **:
React.forwardRef를 명시적으로 사용해야 합니다
Render Props — 제어의 역전
Render Props는 "무엇을 렌더링할지"를 함수 prop으로 위임하는 패턴입니다.
function MouseTracker({ render }) {
const [position, setPosition] = useState({ x: 0, y: 0 });
useEffect(() => {
const handleMove = (e) => setPosition({ x: e.clientX, y: e.clientY });
window.addEventListener('mousemove', handleMove);
return () => window.removeEventListener('mousemove', handleMove);
}, []);
return render(position);
}
// 사용
<MouseTracker
render={({ x, y }) => (
<div>마우스 위치: {x}, {y}</div>
)}
/>
Render Props의 장점
- Props가 명시적이라 어떤 데이터가 전달되는지 명확합니다
- HOC처럼 컴포넌트를 감싸지 않아도 됩니다
Render Props의 문제점
// Callback Hell과 비슷한 중첩
<Auth render={(user) => (
<Theme render={(theme) => (
<Language render={(lang) => (
<MyComponent user={user} theme={theme} lang={lang} />
)} />
)} />
)} />
- 중첩이 깊어지면 가독성이 급격히 떨어집니다
- JSX 안에 로직이 섞여 코드가 복잡해집니다
Hooks — 현재의 표준
Hooks는 함수 컴포넌트 안에서 상태와 사이드 이펙트를 직접 사용할 수 있게 합니다.
function useMousePosition() {
const [position, setPosition] = useState({ x: 0, y: 0 });
useEffect(() => {
const handleMove = (e) => setPosition({ x: e.clientX, y: e.clientY });
window.addEventListener('mousemove', handleMove);
return () => window.removeEventListener('mousemove', handleMove);
}, []);
return position;
}
// 사용 — 깔끔하게 로직 합성
function MyComponent() {
const { x, y } = useMousePosition();
const { user } = useAuth();
const theme = useTheme();
return <div style={{ color: theme.primary }}>
{user.name}: {x}, {y}
</div>;
}
Hooks가 해결한 것들
- **Wrapper Hell 제거 **: 컴포넌트 트리에 추가 래퍼가 없습니다
- ** 로직 합성이 자연스러움 **: 여러 Hook을 순서대로 호출하면 됩니다
- ** 명시적 데이터 흐름 **: 어떤 값이 어디서 오는지 코드만 보면 알 수 있습니다
- ** 테스트 용이 **:
renderHook으로 로직만 독립 테스트가 가능합니다
커스텀 Hook 작성 패턴
function useLocalStorage(key, initialValue) {
const [storedValue, setStoredValue] = useState(() => {
try {
const item = window.localStorage.getItem(key);
return item ? JSON.parse(item) : initialValue;
} catch {
return initialValue;
}
});
const setValue = (value) => {
const valueToStore = value instanceof Function ? value(storedValue) : value;
setStoredValue(valueToStore);
window.localStorage.setItem(key, JSON.stringify(valueToStore));
};
return [storedValue, setValue];
}
커스텀 Hook을 만들 때 기억할 점:
- 이름은 반드시
use로 시작합니다 - 내부에서 다른 Hook을 자유롭게 호출할 수 있습니다
- 반환값은 자유롭게 설계합니다 (값, 배열, 객체 등)
HOC가 여전히 유용한 경우
Hooks가 대부분의 경우 더 나은 선택이지만, HOC가 여전히 빛나는 순간이 있습니다.
1. 렌더링 가로채기
function withAuth(Component) {
return function Protected(props) {
const { user } = useAuth();
// 렌더링 자체를 조건부로 변경
if (!user) return <Navigate to="/login" />;
return <Component {...props} />;
};
}
커스텀 Hook은 JSX를 반환할 수 없기 때문에, 컴포넌트의 렌더링 자체를 가로채야 할 때는 HOC가 더 깔끔합니다.
2. 외부 라이브러리 통합
React Router의 이전 withRouter, Redux의 connect 등 라이브러리가 HOC 패턴을 제공하는 경우가 있습니다.
3. 크로스커팅 관심사
로깅, 에러 추적, 성능 측정처럼 컴포넌트 로직과 무관한 관심사를 주입할 때 유용합니다.
function withPerformanceTracking(Component, componentName) {
return function Tracked(props) {
useEffect(() => {
performance.mark(`${componentName}-mount`);
return () => performance.mark(`${componentName}-unmount`);
}, []);
return <Component {...props} />;
};
}
패턴 비교 정리
| 특성 | Mixins | HOC | Render Props | Hooks |
|---|---|---|---|---|
| 로직 재사용 | O | O | O | O |
| 이름 충돌 | 있음 | prop 충돌 가능 | 없음 | 없음 |
| Wrapper 추가 | 없음 | 있음 | 있음 | 없음 |
| 타입 추론 | 불가 | 어려움 | 가능 | 쉬움 |
| 현재 권장 | X | 제한적 | 제한적 | O |
주의할 점
HOC의 Wrapper Hell과 DevTools 디버깅
HOC를 여러 겹 감싸면(withAuth(withTheme(withRouter(Component)))) React DevTools에서 컴포넌트 트리가 Wrapper로 가득 차 디버깅이 어렵습니다. displayName을 설정해도 근본적인 해결은 되지 않습니다.
Render Props의 콜백 지옥
Render Props를 중첩하면 JSX가 오른쪽으로 계속 들여쓰기되어 가독성이 급격히 떨어집니다. 커스텀 Hook이 같은 기능을 flat한 코드로 제공하므로, 대부분의 경우 Hook이 더 나은 선택입니다.
정리
| 패턴 | 장점 | 단점 | 현재 상태 |
|---|---|---|---|
| Mixins | 단순한 재사용 | 이름 충돌, 암묵적 의존성 | 폐기 |
| HOC | 합성 기반 | Wrapper Hell, props 충돌 | 제한적 사용 |
| Render Props | 명시적 | 중첩 시 가독성 저하 | 제한적 사용 |
| Hooks | Wrapper 없이 합성 | 훅 규칙 준수 필요 | ** 현재 표준** |
변천사를 한 줄로 요약하면 "암묵적에서 명시적으로, 컴포넌트 레벨에서 로직 레벨로"입니다. 새 프로젝트라면 커스텀 Hook을 기본 전략으로, HOC는 보조적으로 활용하는 것이 실용적입니다.