함수형 컴포넌트에서 상태 관리, 사이드 이펙트 제어, DOM 접근, 렌더링 최적화를 가능하게 해주는 React의 핵심 내장 함수들
| 훅 | 한 줄 역할 | 리렌더링 유발 |
| useState | 렌더링과 연동되는 상태 관리 | ✅ |
| useEffect | 렌더링 후 사이드 이펙트 실행 | ❌ |
| useRef | 렌더링 없이 값/DOM 참조 유지 | ❌ |
| useLayoutEffect | DOM 변경 직후 동기적 사이드 이펙트 | ❌ |
| useMemo | 계산 결과값 메모이제이션 | ❌ |
| useCallback | 함수 자체 메모이제이션 | ❌ |
함수형 컴포넌트는 Mount → Update → Unmount 세 단계로 이루어지며, 각 훅은 이 흐름 위에서 아래와 같이 동작한다.
──────────────────────────────함수 본문 실행├─ useState → 초기값으로 state 생성├─ useRef → ref 객체 생성 ({ current: 초기값 })├─ useMemo → deps 기준으로 최초 계산 실행└─ useCallback → deps 기준으로 함수 생성↓실제 DOM 반영 (Commit)↓useLayoutEffect → 콜백 실행 (브라우저 페인트 전, 동기)↓브라우저 화면 페인트↓
──────────────────────────────함수 본문 재실행├─ useState → 새로운 state 값 반환├─ useRef → current 값 유지 (재생성 없음, 리렌더링 유발 안 함)├─ useMemo → deps 변경 시에만 재계산, 아니면 캐시 반환└─ useCallback → deps 변경 시에만 함수 재생성, 아니면 캐시 반환↓실제 DOM 반영 (Commit)↓useLayoutEffect → 이전 클린업 실행 → 새 콜백 실행 (동기)↓브라우저 화면 페인트↓useEffect → 이전 클린업 실행 → 새 콜백
──────────────────────────────useLayoutEffect → 클린업 함수 실행 (동기)↓useEffect → 클린업 함수 실행 (비동기)
deps 배열에 따른 useEffect / useLayoutEffect 실행 시점
deps 없음 useEffect(() => { ... })→ 매 렌더링마다 실행deps 빈 배열 useEffect(() => { ... }, [])→ Mount 시 1회만 실행, Unmount 시 클린업
useLayoutEffect도 동일한 deps 규칙을 따른다. 차이는 실행 타이밍(페인트 전/후)뿐이다.
함수형 컴포넌트에서 렌더링과 연동되는 상태를 선언하고 업데이트하는 훅. setState 호출 시 컴포넌트 리렌더링을 트리거한다.
const [state, setState] = useState(initialValue);
React는 훅 호출 순서를 인덱스로 내부 배열에 저장해 상태를 추적한다.
Mount → useState(0) 호출 → 내부 배열[0] = 0 저장setState(1) 호출 → 배열[0] = 1 업데이트 → 리렌더링 트리거Update → useState(0) 재호출 → 초기값 무시, 배열[0] = 1 반환
이것이 훅을 조건문·반복문 안에서 쓰면 안 되는 이유다. 순서가 달라지면 상태가 잘못 매핑된다.
함수형 업데이트 — 이전 상태 기반으로 업데이트할 때
// ❌ stale state 위험setCount(count + 1);setCount(count + 1); // 둘 다 같은 count 참조 → 1만 증가// ✅ 항상 최신 상태 보장setCount(prev => prev + 1);setCount(
불변성 유지 — 객체·배열 상태 업데이트
// ❌ 직접 변경 → 같은 참조 → 리렌더링 안 됨user.age = 26;setUser(user);// ✅ 새 객체 생성setUser(prev => ({ ...prev, age: 26 }));
지연 초기화 — 초기값 계산 비용이 클 때
// ❌ 매 렌더링마다 실행const [value, setValue] = useState(heavyCalculation());// ✅ 최초 렌더링 시에만 실행const [value, setValue] = useState(() => heavyCalculation(
렌더링 이후 브라우저 페인트가 끝난 뒤 비동기적으로 실행되는 사이드 이펙트 처리 훅. API 호출, 구독, 타이머 등 렌더링과 직접 관련 없는 작업을 담당한다.
useEffect(() => {// 사이드 이펙트 실행return () => {// 클린업 (unmount 또는 다음 effect 실행 전)};}, [deps]);
Mount렌더링 → DOM 반영 → 페인트 → useEffect 콜백 실행Update (deps 변경 시)렌더링 → DOM 반영 → 페인트→ 이전 useEffect 클린업 실행→ 새 useEffect 콜백 실행Unmount→ useEffect 클린업 실행
useEffect(() => { ... }) // 매 렌더링마다 실행useEffect(() => { ... }, []) // Mount 시 1회만 실행useEffect(() =>
function UserProfile({ userId }) {const [user, setUser] = useState(null);useEffect(() => {let cancelled = false
// 이벤트 리스너 등록/해제useEffect(() => {const handleResize = () => setWidth(window.innerWidth);window.addEventListener('resize', handleResize);
리렌더링을 유발하지 않으면서 값을 유지하거나 DOM 요소에 직접 접근할 수 있는 훅. { current: value } 형태의 객체를 반환하며, current를 변경해도 렌더링이 발생하지 않는다.
const ref = useRef(initialValue);// ref.current → 저장된 값
Mount → useRef(null) → { current: null } 객체 생성DOM 반영 후 → ref.current = 실제 DOM 노드 자동 할당Update → ref.current 값 유지 (재생성 없음)ref.current 변경 → 리렌더링 트리거 안 함Unmount → ref.current = null 자동 해제
DOM 접근
function SearchInput() {const inputRef = useRef(null);useEffect(() => {inputRef.current.focus(); // 마운트 시 자동 포커스}
이전 값 저장
function Counter() {const [count, setCount] = useState(0);const prevCount = useRef(0);useEffect(() =>
타이머 ID 저장
function Timer() {const timerRef = useRef(null);const start = () => {timerRef.current = setInterval(() =>
useEffect와 사용법이 동일하지만, DOM 변경 직후 브라우저 페인트 전에 동기적으로 실행되는 훅. 페인트 전에 DOM을 읽거나 수정해야 할 때 사용한다.
useLayoutEffect(() => {// DOM 측정 또는 수정return () => { /* 클린업 */ };}, [deps]);
렌더링 → DOM 반영 (Commit)↓useLayoutEffect 실행 ← 여기서 DOM 읽기/수정 가능↓브라우저 페인트 (화면에 표시)↓useEffect 실행
useLayoutEffect는 동기적으로 실행되므로 완료될 때까지 브라우저 페인트가 블로킹된다. 무거운 작업을 여기서 하면 화면이 늦게 나타날 수 있다.
| useEffect | useLayoutEffect | |
| 실행 시점 | 페인트 후 (비동기) | 페인트 전 (동기) |
| 블로킹 | ❌ | ✅ |
| 사용 상황 | 대부분의 사이드 이펙트 | DOM 측정/수정, 깜빡임 방지 |
function Tooltip({ text, targetRef }) {const tooltipRef = useRef(null);useLayoutEffect(() => {const tooltip = tooltipRef.current;
계산 비용이 큰 연산의 결과값을 메모이제이션하는 훅. deps가 변경될 때만 재계산하고, 그렇지 않으면 이전에 계산한 값을 그대로 반환한다.
const memoizedValue = useMemo(() => expensiveCalculation(a, b), [a, b]);
Mount → deps 기준으로 최초 계산 실행 → 결과값 캐시Updatedeps 변경 없음 → 캐시된 값 반환 (계산 스킵)deps 변경됨 → 재계산 실행 → 새 결과값 캐시
무거운 연산 캐싱
function ProductList({ products, filterKeyword }) {// filterKeyword가 바뀔 때만 재계산const filteredProducts = useMemo(() =>products.filter(p => p.name.includes(filterKeyword)
참조 동일성 유지
function Parent() {const [count, setCount] = useState(0);// count가 바뀌어도 config 객체의 참조가 유지됨// → Child가 불필요하게 리렌더링되지 않음const config = useMemo(() =>
⚠️ 남용 금지: 모든 값에 useMemo를 쓰면 오히려 메모이제이션 비교 비용이 더 커질 수 있다. 실제로 느리다고 측정된 경우에만 적용한다.
함수 자체를 메모이제이션하는 훅. deps가 변경될 때만 함수를 재생성하고, 그렇지 않으면 이전에 생성한 함수의 참조를 그대로 반환한다.
const memoizedFn = useCallback(() => {doSomething(a, b);}, [a, b]);
useCallback(fn, deps)는 useMemo(() => fn, deps)와 동일하다. useMemo가 값을 캐싱한다면, useCallback은 함수를 캐싱한다.
Mount → deps 기준으로 함수 생성 → 함수 참조 캐시Updatedeps 변경 없음 → 캐시된 함수 참조 반환 (재생성 스킵)deps 변경됨 → 새 함수 생성 → 새 참조 캐시
React.memo와 함께 사용
const Button = React.memo(({ onClick, label }) => {console.log(`${label} 렌더링`);return <button onClick
useEffect deps에 함수가 필요할 때
function DataFetcher({ userId }) {// ✅ userId가 바뀔 때만 fetchData 재생성// → useEffect도 그때만 재실행const fetchData = useCallback(async () => {const data = await fetch(`/api/users/
렌더링 트리거└─ useState → state 변경 시 리렌더링 유발렌더링 최적화├─ useMemo → 값 캐싱 (deps 변경 시에만 재계산)├─ useCallback → 함수 캐싱 (deps 변경 시에만 재생성)└─ useRef → 값 저장 (변경해도 리렌더링 없음)사이드 이펙트├─ useEffect → 페인트 후 비동기 실행└─ useLayoutEffect → 페인트 전 동기 실행 (DOM 측정/수정)
클래스 컴포넌트는 componentDidMount, componentDidUpdate, componentWillUnmount처럼 라이프사이클 메서드 기준으로 로직을 나눠야 했다. 하나의 기능(예: 구독 등록과 해제)이 서로 다른 메서드에 흩어져 응집도가 낮았고, 서로 관련 없는 로직이 같은 메서드 안에 뒤섞이는 문제가 있었다.Hooks는 이 구조를 관심사 기준으로 뒤집었다. 구독 등록과 해제를 하나의 useEffect 안에 같이 쓰고, 관련 없는 로직은 별도 훅으로 분리한다. 덕분에 코드 응집도가 높아지고 커스텀 훅으로 로직을 재사용하기도 훨씬 쉬워졌다.
둘 다 값을 저장하지만 목적이 다르다.
function StopWatch() {const [displayTime, setDisplayTime] = useState(0); // 화면에 표시 → useStateconst intervalRef = useRef(null); // 타이머 ID 저장 → useRefconst startTimeRef =
화면에 반영되어야 하는 값 → useState렌더링과 무관한 값 저장 → useRef
클린업이 없으면 메모리 누수와 예상치 못한 버그가 발생한다.
// ❌ 클린업 없음useEffect(() => {const id = setInterval(() => setCount(c => c + 1), 1000);// 컴포넌트가 언마운트돼도 interval이 계속 실행됨// 언마운트 후 setState 호출 → 메모리 누수 경고
클린업이 필요한 대표적인 케이스들:
setInterval / setTimeout → clearInterval / clearTimeoutaddEventListener → removeEventListenerWebSocket / 구독 → 연결 해제 / unsubscribefetch 요청 → AbortController로 취소
// useMemo → 콜백의 "반환값"을 캐싱const value = useMemo(() => compute(a, b), [a, b]);// ↑ compute(a, b)의 결과값// useCallback → 콜백 "함수 자체"를 캐싱const fn = useCallback(
사실 useCallback은 useMemo로 표현할 수 있다.
// 아래 두 코드는 완전히 동일하다useCallback(() => doSomething(a), [a]);useMemo(() => () => doSomething(a), [a])
실무에서의 판단 기준:
반환값이 필요한가? → useMemo함수 참조가 필요한가? → useCallback
대부분의 경우 useEffect로 충분하다. 아래 상황에서만 useLayoutEffect로 교체한다.
// 문제 상황: useEffect로 DOM 측정 후 스타일 변경 시 깜빡임 발생useEffect(() => {const height = boxRef.current.offsetHeight;boxRef.current.style.marginTop = `${height / 2}px
DOM을 읽고 → 바로 수정해야 하고 → 깜빡임이 생긴다면 → useLayoutEffect그 외 모든 경우 → useEffect
"React Hooks에 대해 설명해주세요. 주요 훅들을 각각 설명해주실 수 있나요?"
💡 면접관이 개념 이해도를 빠르게 확인하고 싶을 때 — 핵심만 간결하게
"React Hooks는 함수형 컴포넌트에서 상태 관리와 사이드 이펙트 처리를 가능하게 해주는 내장 함수들입니다.
대표적으로 6가지를 말씀드리면, 먼저 useState는 렌더링과 연동되는 상태를 선언하는 훅으로, 상태가 변경되면 컴포넌트 리렌더링을 트리거합니다.
useEffect는 렌더링 후 브라우저 페인트가 끝난 뒤 비동기적으로 실행되는 훅으로, API 호출이나 이벤트 리스너 등록 같은 사이드 이펙트를 처리합니다. 클린업 함수를 반환해 언마운트 시 정리 작업도 처리할 수 있습니다.
useRef는 렌더링을 유발하지 않으면서 값을 저장하거나 DOM 요소에 직접 접근할 때 사용합니다.
useLayoutEffect는 useEffect와 동일하지만 DOM 변경 직후 브라우저 페인트 전에 동기적으로 실행되어, DOM 측정 후 즉시 스타일을 수정해야 할 때 깜빡임을 방지합니다.
useMemo는 계산 비용이 큰 연산의 결과값을, useCallback은 함수 자체를 메모이제이션해서 불필요한 재계산과 리렌더링을 방지합니다."
💡 "그 중에서 X 훅에 대해 더 자세히 설명해주세요." 꼬리 질문 대비
useState
"렌더링과 연동되는 상태를 선언하는 훅입니다. setState 호출 시 리렌더링이 트리거되며, 이전 상태 기반으로 업데이트할 때는 stale state 문제를 방지하기 위해 prev => prev + 1 같은 함수형 업데이트를 사용해야 합니다. 객체나 배열은 직접 변경하면 참조가 같아 React가 변경을 감지하지 못하므로 반드시 새 객체를 생성해 전달해야 합니다."
useEffect
"렌더링 후 브라우저 페인트가 끝난 뒤 비동기적으로 실행되는 훅입니다. deps 배열이 비어있으면 마운트 시 1회, deps가 있으면 해당 값이 바뀔 때마다 실행됩니다. 클린업 함수를 반환하면 언마운트 시 또는 다음 effect 실행 전에 호출되는데, 이벤트 리스너 해제나 타이머 취소처럼 메모리 누수를 방지하는 데 반드시 필요합니다."
useRef
"렌더링을 유발하지 않으면서 값을 유지하는 훅입니다. { current: value } 형태의 객체를 반환하며 current를 변경해도 리렌더링이 일어나지 않습니다. DOM 요소에 직접 접근하거나, 타이머 ID처럼 화면에 표시할 필요는 없지만 컴포넌트 생애주기 동안 유지해야 하는 값을 저장할 때 사용합니다."
useLayoutEffect
"useEffect와 사용법은 동일하지만 실행 시점이 다릅니다. DOM 변경 직후, 브라우저 페인트 전에 동기적으로 실행됩니다. 페인트 전에 DOM 크기나 위치를 측정하고 즉시 스타일을 수정해야 할 때 사용합니다. useEffect로 처리하면 잠깐 잘못된 위치에 렌더링됐다가 이동하는 깜빡임이 생기는데, 이를 방지할 수 있습니다. 다만 동기 실행이라 무거운 작업을 여기서 하면 페인트가 지연될 수 있어 꼭 필요한 경우에만 사용합니다."
useMemo
"계산 비용이 큰 연산의 결과값을 메모이제이션하는 훅입니다. deps가 변경될 때만 재계산하고 그 외에는 캐시된 값을 반환합니다. 무거운 필터링이나 정렬 연산을 캐싱하거나, 자식 컴포넌트에 객체를 props로 전달할 때 참조 동일성을 유지해 불필요한 리렌더링을 방지하는 데 사용합니다. 다만 모든 값에 남용하면 메모이제이션 비교 비용이 오히려 커질 수 있어, 실제로 성능 문제가 측정된 경우에만 적용합니다."
useCallback
"함수 자체를 메모이제이션하는 훅입니다. deps가 변경될 때만 함수를 재생성하고 그 외에는 같은 함수 참조를 반환합니다. React.memo로 감싼 자식 컴포넌트에 콜백을 props로 전달할 때, 함수가 매 렌더링마다 새로 생성되면 자식이 불필요하게 리렌더링되는 문제를 방지합니다. useMemo와의 차이는 useMemo가 값을 캐싱한다면 useCallback은 함수를 캐싱한다는 점입니다."
두 가지 규칙이 있습니다. 첫째, 훅은 항상 컴포넌트 최상단에서만 호출해야 합니다. 조건문, 반복문, 중첩 함수 안에서 호출하면 React가 훅 호출 순서를 인덱스로 관리하기 때문에 순서가 달라져 상태가 잘못 매핑됩니다. 둘째, 훅은 React 함수형 컴포넌트 또는 커스텀 훅 안에서만 호출해야 합니다. 일반 JavaScript 함수에서는 호출할 수 없습니다.
deps를 빈 배열 []로 전달하면 마운트 시 1회만 실행됩니다. deps를 아예 생략하면 매 렌더링마다 실행됩니다. 대부분의 경우 의도치 않은 무한 실행을 방지하기 위해 deps를 명시적으로 관리하는 것이 좋습니다.
가장 큰 차이는 리렌더링 유발 여부입니다. useState는 값이 변경되면 리렌더링을 트리거하지만, useRef는 current를 변경해도 리렌더링이 발생하지 않습니다. 화면에 반영되어야 하는 값은 useState, 렌더링과 무관하게 컴포넌트 생애주기 동안 유지만 하면 되는 값은 useRef를 사용합니다.
useMemo는 콜백의 반환값을 캐싱하고, useCallback은 함수 자체를 캐싱합니다. 사실 useCallback(fn, deps)는 useMemo(() => fn, deps)와 동일합니다. 실무에서는 계산 결과값이 필요하면 useMemo, 자식 컴포넌트에 전달할 함수 참조의 안정성이 필요하면 useCallback을 사용합니다.
두 가지 시점에 실행됩니다. 첫째, 컴포넌트가 언마운트될 때 실행됩니다. 둘째, deps가 변경되어 effect가 다시 실행되기 직전에 이전 effect의 클린업이 먼저 실행됩니다. 이를 통해 이벤트 리스너 해제, 타이머 취소, 구독 해제 등 정리 작업을 처리해 메모리 누수를 방지합니다.
useEffect의 콜백은 클린업 함수 또는 undefined만 반환해야 합니다. 그런데 async 함수는 항상 Promise를 반환하기 때문에, useEffect 콜백을 async로 만들면 Promise가 반환되어 React가 이를 클린업 함수로 잘못 처리할 수 있습니다. 해결 방법은 useEffect 내부에 async 함수를 별도로 선언하고 즉시 호출하는 것입니다.
useEffect(() => {async function fetchData() {const data =
useEffect(() => {async function load() { await fetchData(); }load();}, []);
화면에 반영되어야 하는 값인가?✅ → useState렌더링 없이 값만 유지하면 되는가?✅ → useRef렌더링 후 사이드 이펙트가 필요한가?✅ → useEffect└─ DOM을 읽고 즉시 수정해야 하고 깜빡임이 생기는가?✅ → useLayoutEffect비용이 큰 연산 결과를 캐싱해야 하는가?✅ → useMemo자식에게 전달하는 함수 참조를 안정화해야 하는가?✅ → useCallback