React Hook: 함수 컴포넌트에 상태를 더하기
Hook의 동작 원리와 규칙을 이해하고 올바르게 사용해요
Table Of Contents
들어가기
React 초기에는 상태를 다루려면 클래스 컴포넌트를 써야 했어요.
하지만 상태 로직을 재사용하기 어렵고, 관련 없는 코드가 한 메서드에 섞이고, this 바인딩을 빠뜨리면 런타임에서야 에러가 드러나는 문제가 있었어요.
이런 한계를 극복하기 위해서 함수 컴포넌트가 등장했고, 함수 컴포넌트에서 상태와 생명주기 기능을 쓸 수 있도록 해주는 함수가 Hook이에요.
이 글에서는 Hook이 필요한 이유, Hook에 대한 규칙에 대해서 알아볼게요.
- Hook이 무엇이고 왜 필요한지 설명할 수 있어요. 클래스 컴포넌트에서 어떤 문제가 있었고, Hook이 그 문제를 어떻게 해결하는지 알게 돼요.
- Hook의 규칙이 왜 존재하는지 이해할 수 있어요.
use접두사, 조건부 호출 금지 같은 규칙이 Hook의 동작 원리에서 어떻게 비롯되는지 알게 돼요. - 커스텀 Hook을 언제, 어떻게 만들지 판단할 수 있어요. 반복되는 상태 로직을 Hook으로 추출하는 기준을 잡을 수 있어요.
Hook이 등장하기까지
클래스 컴포넌트의 한계
React 16.8 버전이 등장하기 이전(2019년 2월 이전)에는 상태(state)를 사용하려면 클래스 컴포넌트를 써야 했어요. 상태(state)란 컴포넌트가 기억하고 있는 값으로, 이 값이 바뀌면 화면이 다시 그려져요. 클래스 컴포넌트에서 상태를 다루는 코드는 이런 형태였어요.
class Counter extends React.Component { constructor(props) { super(props); this.state = { count: 0 }; this.handleClick = this.handleClick.bind(this); } handleClick() { this.setState({ count: this.state.count + 1 }); } render() { return <button onClick={this.handleClick}>{this.state.count}</button>; } }
이 방식에는 세 가지 문제가 있었어요.
1. 상태 로직을 재사용하기 어려웠어요.
여러 컴포넌트에서 같은 로직(예: 윈도우 크기 추적, 마우스 위치 추적)을 쓰고 싶으면, HOC(Higher-Order Component)나 Render Props 패턴을 써야 했어요. HOC는 컴포넌트를 감싸는 함수로, 공통 로직을 주입하는 패턴이에요.
function withWindowSize(WrappedComponent) { return class extends React.Component { state = { width: window.innerWidth }; componentDidMount() { window.addEventListener("resize", this.handleResize); } componentWillUnmount() { window.removeEventListener("resize", this.handleResize); } handleResize = () => { this.setState({ width: window.innerWidth }); }; render() { return ( <WrappedComponent windowWidth={this.state.width} {...this.props} /> ); } }; } const MyComponentWithSize = withWindowSize(MyComponent);
이런 패턴을 여러 개 조합하면 컴포넌트 트리가 깊어져요. React DevTools에서 보면 실제 컴포넌트 위에 HOC 래퍼가 여러 겹 쌓여 있어서, 어떤 데이터가 어디서 오는지 추적하기 어려워져요.
2. 생명주기 메서드에 관련 없는 로직이 섞였어요.
클래스 컴포넌트의 생명주기 메서드(lifecycle method)는 컴포넌트가 생성·업데이트·제거될 때 실행되는 메서드예요.
componentDidMount에는 컴포넌트가 화면에 처음 그려진 직후에 실행할 코드를 넣어요.
하나의 생명주기 메서드에 서로 관련 없는 로직을 함께 넣어야 하는 경우가 많았어요.
class Dashboard extends React.Component { componentDidMount() { this.fetchData(); // ← 관심사 A window.addEventListener("resize", this.handleResize); // ← 관심사 B this.timer = setInterval(this.tick, 1000); // ← 관심사 C } componentWillUnmount() { window.removeEventListener("resize", this.handleResize); // ← 관심사 B clearInterval(this.timer); // ← 관심사 C } }
서로 관련 있는 코드(등록 ↔ 해제)는 다른 메서드에 흩어지고, 관련 없는 코드(데이터 가져오기, 이벤트, 타이머)는 한 메서드에 섞여요. 컴포넌트가 커질수록 어떤 로직이 어떤 로직과 짝인지 파악하기 어려워져요.
3. this 바인딩이 혼란스러웠어요.
클래스 메서드에서 this가 컴포넌트 인스턴스를 가리키려면 명시적으로 바인딩해야 했어요.
this.handleClick = this.handleClick.bind(this)를 빠뜨리면 this가 undefined가 돼서, 버튼을 클릭했을 때 TypeError: Cannot read properties of undefined가 발생해요.
이 에러는 이벤트가 실제로 발생하기 전까지 드러나지 않아서 놓치기 쉬워요.
Hook이란
Hook은 함수 컴포넌트에서 React의 기능(상태, 생명주기 등)을 사용할 수 있게 해 주는 함수예요. React 16.8에서 도입됐어요.
Hook을 사용하면 앞에서 본 Counter 클래스 컴포넌트를 이렇게 바꿀 수 있어요.
function Counter() { const [count, setCount] = useState(0); return <button onClick={() => setCount(count + 1)}>{count}</button>; }
여기에서 useState가 바로 Hook이에요.
useState(0)을 호출하면 React는 이 컴포넌트에 0이라는 초기값을 가진 상태를 하나 만들어요.
count는 현재 값이고, setCount는 그 값을 바꾸는 함수예요.
setCount를 호출하면 React가 컴포넌트를 다시 렌더링해요.
생명주기 메서드에 흩어지던 로직도 Hook으로 정리할 수 있어요.
useEffect는 컴포넌트가 렌더링된 뒤에 실행할 코드를 등록하는 Hook이에요.
function Dashboard() { const [data, setData] = useState(null); const [windowWidth, setWindowWidth] = useState(window.innerWidth); // 관심사 A: 데이터 가져오기 useEffect(() => { fetchData().then(setData); }, []); // 관심사 B: 윈도우 크기 추적하기 useEffect(() => { const handleResize = () => setWindowWidth(window.innerWidth); window.addEventListener("resize", handleResize); return () => window.removeEventListener("resize", handleResize); }, []); // ... }
클래스에서는 componentDidMount 하나에 섞여 있던 로직이, Hook에서는 관심사별로 나뉘어요.
등록과 해제 코드도 같은 useEffect 안에 함께 있어서, 짝이 맞는지 한눈에 확인할 수 있어요.
Hook의 규칙
Hook에는 반드시 지켜야 하는 두 가지 규칙이 있어요.
- Hook은 컴포넌트의 최상위에서만 호출해요. 조건문, 반복문, 중첩 함수 안에서 호출하면 안 돼요.
- Hook은 React 함수 컴포넌트나 커스텀 Hook 안에서만 호출해요. 일반 JavaScript 함수에서는 호출할 수 없어요.
이 규칙들은 Hook의 동작 원리에서 비롯되었어요. Hook의 동작 원리를 이해하면, 이 규칙들을 자연스럽게 이해할 수 있어요.
상태를 호출 순서로 추적해요
React는 Hook을 이름이나 변수명이 아니라 호출 순서로 구별해요.
컴포넌트가 렌더링될 때마다 React는 Hook 호출을 순서대로 기록해요. 첫 번째로 호출된 Hook은 첫 번째 상태 슬롯에, 두 번째로 호출된 Hook은 두 번째 상태 슬롯에 연결돼요.
function Form() { const [name, setName] = useState(""); // → 0번째 슬롯 const [age, setAge] = useState(0); // → 1번째 슬롯 const [email, setEmail] = useState(""); // → 2번째 슬롯 // ... }
React 내부에서는 이 컴포넌트의 상태를 배열처럼 관리해요.
따라서 React는 변수명 name, age, email을 식별하는 게 아니라, 첫 번째로 호출된 useState가 name이라는 것을 호출 순서로 파악해요.
이 방식이 동작하려면 매 렌더링마다 Hook이 같은 순서로, 같은 횟수만큼 호출돼야 해요. 그래서 조건문이나 반복문 안에서 Hook을 호출하면 안 돼요.
조건문·반복문에서 호출하면 안 되는 이유
Hook을 조건문이나 반복문 안에서 호출하면, 조건이나 반복 횟수에 따라 Hook의 호출 횟수가 달라져요. React는 호출 순서로 상태를 매칭하기 때문에, 순서가 어긋나면 상태가 뒤섞여요.
function Form({ showAge }) { const [name, setName] = useState(""); // → 항상 0번째 슬롯 // ❌ 조건에 따라 호출될 수도, 안 될 수도 있어요 if (showAge) { const [age, setAge] = useState(0); // → (호출될 때만) 1번째 슬롯 } const [email, setEmail] = useState(""); // → 위 조건문에 따라 1번째 슬롯 또는 2번째 슬롯 }
showAge가 true일 때와 false일 때, React가 보는 호출 순서가 달라져요.
- showAge === true일 때:
- 슬롯 0: name
- 슬롯 1: age
- 슬롯 2: email
- showAge === false일 때:
- 슬롯 0: name
- 슬롯 1: email ← age의 상태 값이 email에 들어가요
showAge가 true에서 false로 바뀌면, React는 슬롯 1에 있는 값(age의 값)을 email의 상태로 전달해요.
나이 값이 이메일 필드에 나타나는 것이에요.
이런 버그는 특정 조건에서만 발생하기 때문에 재현하기 어렵고, 원인을 찾기까지 시간이 오래 걸려요.
조건에 따라 다른 동작을 하고 싶다면, Hook 자체를 조건문 안에서 호출하는 대신 Hook 안에서 조건을 처리해요.
function Form({ showAge }) { const [name, setName] = useState(""); const [age, setAge] = useState(0); // 항상 호출해요 const [email, setEmail] = useState(""); return ( <> <input value={name} onChange={(e) => setName(e.target.value)} /> {showAge && ( <input type="number" value={age} onChange={(e) => setAge(Number(e.target.value))} /> )} <input value={email} onChange={(e) => setEmail(e.target.value)} /> </> ); }
Hook은 항상 호출하되, 조건에 따라 JSX에서 보여줄지 말지를 결정하는 거예요. 이렇게 하면 Hook의 호출 순서가 매 렌더링마다 동일하게 유지돼요. 반복문 안에서 Hook을 호출하면 렌더링마다 반복 횟수가 달라질 수 있어서, 호출 순서가 어긋나는 이유는 조건문과 동일해요.
use 접두사로 시작해야 해요
React의 모든 Hook은 use로 시작해요.
useState, useEffect, useRef, useMemo, useCallback처럼, use로 시작하지 않는 Hook은 없어요.
use 접두사는 두 가지 역할을 해요.
1. 이 함수가 Hook이라는 것을 알려줘요.
Hook은 일반 함수와 다른 제약이 있어요(최상위에서만 호출, 조건부 호출 금지 등).
use로 시작하면 이 함수를 호출할 때 Hook 규칙을 지켜야 한다는 것을 바로 알 수 있어요.
2. 린터가 규칙 위반을 자동으로 감지해요.
eslint-plugin-react-hooks는 use로 시작하는 함수를 Hook으로 인식하고, 규칙을 위반하면 경고를 띄워요.
use 접두사가 없으면 린터가 Hook으로 인식하지 못해서, 조건부 호출 같은 실수를 잡아내지 못해요.
// ❌ use 접두사가 없어서 린터가 Hook으로 인식하지 못해요 function getWindowSize() { const [size, setSize] = useState(window.innerWidth); useEffect(() => { const handler = () => setSize(window.innerWidth); window.addEventListener("resize", handler); return () => window.removeEventListener("resize", handler); }, []); return size; } // ✅ use 접두사가 있어서 린터가 Hook 규칙을 검사해요 function useWindowSize() { const [size, setSize] = useState(window.innerWidth); useEffect(() => { const handler = () => setSize(window.innerWidth); window.addEventListener("resize", handler); return () => window.removeEventListener("resize", handler); }, []); return size; }
커스텀 Hook
커스텀 Hook은 use로 시작하는 함수로, 다른 Hook을 조합해서 새로운 Hook을 만드는 것이에요.
재사용하고 싶은 상태 로직이 있을 때 커스텀 Hook으로 추출해요.
예를 들어서, 마우스 위치를 추적하는 로직은 여러 컴포넌트에서 공통적으로 사용할 수 있어요.
이럴 때 위치 추적 로직을 useMousePosition같은 Hook으로 만들 수 있어요.
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; }
이 Hook을 사용하는 컴포넌트는 마우스 위치 추적의 세부 구현을 알 필요가 없어요.
function Cursor() { const { x, y } = useMousePosition(); return <div style={{ position: "fixed", left: x, top: y }}>🔵</div>; } function Tooltip() { const { x, y } = useMousePosition(); return ( <div style={{ position: "fixed", left: x + 10, top: y + 10 }}> 안내 텍스트 </div> ); }
Cursor와 Tooltip은 같은 useMousePosition Hook을 호출하지만, 각자 독립된 상태를 가져요.
Hook을 호출한 컴포넌트마다 별도의 상태 슬롯이 만들어지기 때문이에요.
커스텀 Hook을 만들 때는 이런 기준을 고려하면 좋아요.
- 같은 상태 + 이펙트 조합이 두 곳 이상에서 반복된다면 Hook으로 추출하면 좋아요.
- 하나의 관심사를 묶고 싶다면 Hook으로 추출해요.
예를 들어 "폼 입력값 + 유효성 검사 + 제출 상태"를
useForm으로 묶으면 컴포넌트가 간결해져요. - 컴포넌트 안의 로직이 복잡해서 가독성을 높이고 싶다면 Hook으로 분리할 수 있어요. 테스트도 Hook 단위로 작성할 수 있어서 유지보수가 편해져요.
반면 단순히 값을 계산하거나 변환하는 함수는 Hook으로 만들 필요가 없어요. Hook은 React의 상태나 생명주기와 연결된 로직을 다룰 때 의미가 있어요.
// ❌ Hook으로 만들 필요가 없어요 — React 상태와 무관한 순수 함수예요 function useFormatDate(date) { return new Intl.DateTimeFormat("ko-KR").format(date); } // ✅ 일반 함수로 충분해요 function formatDate(date) { return new Intl.DateTimeFormat("ko-KR").format(date); }
잘못 사용하면 생기는 문제
Hook 규칙을 지키더라도, 잘못된 패턴으로 사용하면 성능 문제나 무한 루프가 발생할 수 있어요. 자주 발생하는 실수 두 가지를 살펴볼게요.
1. useEffect에서 무한 루프가 발생해요
useEffect의 의존성 배열에 매 렌더링마다 새로 만들어지는 값을 넣으면, 이펙트가 무한히 반복될 수 있어요.
// ❌ 매 렌더링마다 options 객체가 새로 만들어져서 useEffect가 무한 반복돼요 function SearchResults({ query }) { const [results, setResults] = useState([]); const options = { query, page: 1 }; useEffect(() => { fetchResults(options).then(setResults); }, [options]); }
JavaScript는 객체를 비교할 때, 속성 값이 같은지 보는 방법을 쓰지 않고, 같은 인스턴스를 가리키는지(참조가 같은지) 보는 방법을 써요.
options는 매 렌더링마다 새 객체로 만들어지기 때문에 React는 의존성이 변했다고 판단하고, 이펙트를 다시 실행해요.
이펙트가 상태를 바꾸면 다시 렌더링이 일어나고, 다시 새 객체가 만들어지고, 다시 이펙트가 실행되는 루프가 돼요.
// ✅ 의존성 배열에 원시 값을 넣으면 비교가 정확해요 function SearchResults({ query }) { const [results, setResults] = useState([]); useEffect(() => { const options = { query, page: 1 }; fetchResults(options).then(setResults); }, [query]); }
2. 불필요한 useEffect를 사용해요
렌더링 중에 계산할 수 있는 값을 useEffect와 useState로 처리하면 불필요한 렌더링이 한 번 더 발생해요.
// ❌ items가 바뀔 때마다 렌더링이 2번 발생해요 function FilteredList({ items, category }) { const [filtered, setFiltered] = useState([]); useEffect(() => { setFiltered(items.filter((item) => item.category === category)); }, [items, category]); return ( <ul> {filtered.map((item) => ( <li key={item.id}>{item.name}</li> ))} </ul> ); }
items가 바뀌면 먼저 빈 filtered로 렌더링되고, useEffect가 실행된 뒤 setFiltered로 다시 렌더링돼요.
렌더링이 두 번 일어나는 것이에요.
items에서 파생되는 값은 렌더링 중에 바로 계산하면 돼요.
// ✅ 렌더링 중에 계산하면 1번만 렌더링돼요 function FilteredList({ items, category }) { const filtered = items.filter((item) => item.category === category); return ( <ul> {filtered.map((item) => ( <li key={item.id}>{item.name}</li> ))} </ul> ); }
계산 비용이 큰 경우에는 useMemo를 사용해서 items나 category가 바뀔 때만 다시 계산하도록 할 수 있어요.
function FilteredList({ items, category }) { const filtered = useMemo( () => items.filter((item) => item.category === category), [items, category], ); return ( <ul> {filtered.map((item) => ( <li key={item.id}>{item.name}</li> ))} </ul> ); }
마치며
Hook은 함수 컴포넌트에서 상태와 사이드 이펙트를 다룰 수 있게 해 주는 함수예요.
React는 Hook의 호출 순서로 상태를 추적하기 때문에, 매 렌더링마다 같은 순서로 같은 횟수만큼 호출해야 해요.
use 접두사는 이 함수가 Hook이라는 것을 알려서, 린터가 규칙 위반을 감지할 수 있게 해 줘요.
조건에 따라 다른 동작이 필요하면 Hook을 조건부로 호출하는 대신, Hook 안에서 조건을 처리하면 돼요.
새 Hook을 만들 때는 "이 로직이 React의 상태나 생명주기와 연결돼 있는가?"를 먼저 확인해요. 순수한 계산이라면 일반 함수로 충분하고, 상태나 이펙트가 필요한 로직이라면 Hook이 적합해요.
참고 자료
- Introducing Hooks - React Blog - React 팀이 Hook을 도입한 이유
- Rules of Hooks - React 공식 문서 - Hook 규칙 공식 문서
- You Might Not Need an Effect - React 공식 문서 - 불필요한 useEffect를 줄이는 방법
- eslint-plugin-react-hooks - Hook 규칙을 자동으로 검사하는 ESLint 플러그인