React 훅 안에서 setInterval() setTimeout()

React

문제 발생 상황 : 카운팅 애니메이션을 settimeout으로 구현 중 훅 순서에 맞지 않는 리액트 애러 발생

기존로직:

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 import { useEffect, useState } from "react"; type UseCountUpType = { end: number | string }; export const useCountUp = ({ end }: UseCountUpType) => { const [currentCount, setCurrentCount] = useState<number>(0); const parseEndValue = (value: number | string): number | null => { if (typeof value === "number") return value; if (typeof value === "string") { const cleanedValue = value.replace(/,/g, ""); // 쉼표 제거 const parsedValue = Number(cleanedValue); return isNaN(parsedValue) ? null : parsedValue; } return null; }; useEffect(() => { const targetValue = parseEndValue(end); if (targetValue === null) return; // 목표 값에 도달하기 위해 증가할 간격 계산 (1초를 목표로 설정) const increment = Math.ceil(targetValue / 60); // 목표 값에 도달할 수 있는 적절한 증가값 let current = 0; const updateCount = () => { current += increment; if (current >= targetValue) { setCurrentCount(targetValue); // 목표 값 도달 } else { setCurrentCount(current); setTimeout(updateCount, 1000 / 60); // 1초 내에 완료하도록 조정 } }; updateCount(); // 첫 호출로 카운트 시작 }, [end]); const formattedCount = new Intl.NumberFormat().format(currentCount); return formattedCount; };

React에서 useEffect와 같은 **훅(Hooks)**을 사용할 때, 호출 순서와 상태 관리가 중요한 이유는 React가 컴포넌트 렌더링 시 훅을 정해진 순서대로 호출하기 때문입니다. 이 순서가 깨지면 React가 상태를 올바르게 추적하지 못하고 오류가 발생할 수 있습니다.

1. React 훅의 호출 순서

React 컴포넌트가 렌더링될 때, 모든 훅은 매번 동일한 순서로 호출되어야 합니다. 즉, 첫 번째 렌더링에서 호출된 훅은 항상 두 번째 렌더링에서도 첫 번째로 호출되어야 합니다. 만약 훅의 호출 순서가 달라지면, React는 어떤 훅이 이전 렌더링에서 어떤 값을 가졌는지 알 수 없게 됩니다.

예를 들어, 다음과 같은 코드에서 훅을 사용할 때 순서가 깨지면 오류가 발생할 수 있습니다:

1 2 useState('A'); // 첫 번째 훅 useEffect(() => { console.log('Effect!') }); // 두 번째 훅

React는 첫 번째 훅이 useState였는지 useEffect였는지 기억해야 하는데, 만약 useEffect를 호출하기 전에 useState를 호출하지 않으면 오류가 발생합니다.

2. setInterval 또는 setTimeout과 React 훅

setInterval이나 setTimeout은 JavaScript에서 시간을 기반으로 특정 작업을 일정 간격으로 실행하게 합니다. 이들은 비동기 방식으로 동작하므로, React에서는 상태를 업데이트하는 방법을 주의 깊게 다뤄야 합니다.

이 문제는 setInterval 또는 setTimeout이 동적으로 훅을 호출하게 만들기 때문에 발생합니다. 예를 들어, 아래 코드처럼 setInterval이 호출될 때마다 setState를 사용해 상태를 변경하는 경우, React는 상태 업데이트가 여러 번 이루어지는지, 그리고 상태가 변경될 때마다 해당 훅을 어떻게 처리해야 하는지에 혼란을 겪을 수 있습니다.

1 2 3 4 5 6 7 useEffect(() => { const interval = setInterval(() => { setCurrentCount(prev => prev + 1); // 상태를 계속 업데이트 }, 1000); return () => clearInterval(interval); // 컴포넌트가 사라질 때 타이머 정리 }, []);

3. 왜 "훅 호출 순서"가 중요한가?

React는 렌더링이 완료될 때마다 훅을 실행해서 컴포넌트의 상태를 관리합니다. 만약 어떤 훅이 "동적"으로 호출되는 구조라면, 그 훅이 매번 다른 순서로 호출될 수 있습니다. 이런 경우 React는 해당 훅이 이전 상태나 값에 대한 정보를 잘못 추적하게 되어, 예상치 못한 동작이나 경고 메시지가 발생합니다.

4. 실제 경고 메시지

React에서 발생하는 경고 메시지:

1 2 Warning: React has detected a change in the order of Hooks called by <ComponentName>. This will lead to bugs and errors if not fixed. For more information, read the Rules of Hooks.

이 메시지는 훅 호출 순서가 변경되었음을 알려줍니다. 위에서 설명한 대로, setInterval 또는 setTimeout을 재귀적으로 사용하는 방식에서 상태 업데이트가 동적으로 이루어지면, React는 훅들이 어떻게 실행되었는지 추적할 수 없게 되어, 이 경고를 표시합니다.

5. 어떻게 해결할 수 있을까?

이 문제를 해결하려면 setInterval 또는 setTimeout을 컴포넌트 외부에서 관리하지 말고, React의 상태 관리 훅(useState 등)을 잘 활용해야 합니다. 예를 들어, useRef를 사용하여 상태가 변경될 때마다 훅이 계속 호출되지 않도록 상태를 추적할 수 있습니다.

해결 방법 setInterval을 useRef와 함께 사용하여, React의 상태와 훅 호출 순서를 안정적으로 유지할 수 있습니다.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 import { useEffect, useState, useRef } from "react"; type UseCountUpType = { end: number | string }; export const useCountUp = ({ end }: UseCountUpType) => { const [currentCount, setCurrentCount] = useState<number>(0); const targetRef = useRef<number | null>(null); // `useRef`를 사용하여 상태를 저장 const parseEndValue = (value: number | string): number | null => { if (typeof value === "number") return value; if (typeof value === "string") { const cleanedValue = value.replace(/,/g, ""); const parsedValue = Number(cleanedValue); return isNaN(parsedValue) ? null : parsedValue; } return null; }; useEffect(() => { targetRef.current = parseEndValue(end); // `useRef`로 `end` 값을 저장 if (targetRef.current === null) return; const increment = Math.ceil(targetRef.current / 60); let current = 0; const interval = setInterval(() => { current += increment; if (current >= targetRef.current) { setCurrentCount(targetRef.current); // 목표 값에 도달하면 설정 clearInterval(interval); } else { setCurrentCount(current); } }, 1000 / 60); return () => clearInterval(interval); }, [end]); const formattedCount = new Intl.NumberFormat().format(currentCount); return formattedCount; };

최종로직(애니메이션 개선)

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 import { useEffect, useRef, useState } from "react"; export const useCountUp = (endValue: number | string) => { const [displayValue, setDisplayValue] = useState(""); const animationRef = useRef<number>(); const digitsRef = useRef<string[]>([]); const decimalsRef = useRef<string[]>([]); // 한 번만 계산하는 초기 파싱 함수 const parseEndValue = (value: number | string): [string[], string[]] => { const stringValue = typeof value === "string" ? value.replace(/,/g, "") : value.toString(); const [integer, decimal] = stringValue.split("."); return [integer.split(""), decimal ? decimal.split("") : []]; }; useEffect(() => { if (animationRef.current) { cancelAnimationFrame(animationRef.current); } // 유효성 검사 if ( endValue === undefined || endValue === null || (typeof endValue === "string" && !endValue.trim()) ) { setDisplayValue(""); return; } const targetNum = typeof endValue === "string" ? parseFloat(endValue.replace(/,/g, "")) : endValue; if (isNaN(targetNum)) { setDisplayValue(""); return; } // 시작 전에 미리 자릿수 배열 계산 const [integerDigits, decimalDigits] = parseEndValue(endValue); digitsRef.current = integerDigits; decimalsRef.current = decimalDigits; const duration = 1000; // 더 짧은 애니메이션 시간 const startTime = performance.now(); const animate = (currentTime: number) => { const elapsed = currentTime - startTime; const progress = Math.min(elapsed / duration, 1); // 단순화된 이징 const easeProgress = progress < 0.5 ? 2 * progress * progress : 1 - Math.pow(-2 * progress + 2, 2) / 2; // 정수부 계산 - 메모이제이션된 자릿수 사용 const newIntegerDigits = digitsRef.current.map((targetDigit, index) => { const digitProgress = Math.min(easeProgress * (1 + index * 0.05), 1); return Math.round(parseInt(targetDigit) * digitProgress).toString(); }); // 소수부 계산 - 필요한 경우만 let finalValue = new Intl.NumberFormat().format( parseInt(newIntegerDigits.join("")), ); if (decimalsRef.current.length > 0) { const newDecimalDigits = decimalsRef.current.map( (targetDigit, index) => { const digitProgress = Math.min( easeProgress * (1 + (index + digitsRef.current.length) * 0.05), 1, ); return Math.round(parseInt(targetDigit) * digitProgress).toString(); }, ); finalValue += `.${newDecimalDigits.join("")}`; } setDisplayValue(finalValue); if (progress < 1) { animationRef.current = requestAnimationFrame(animate); } }; // 첫 프레임 즉시 시작 animationRef.current = requestAnimationFrame(animate); return () => { if (animationRef.current) { cancelAnimationFrame(animationRef.current); } }; }, [endValue]); return displayValue; };

요약:

React는 훅을 호출하는 순서가 매 렌더링마다 일정해야만 제대로 동작합니다. setInterval 또는 setTimeout처럼 비동기적인 함수가 상태를 업데이트할 때, 훅 호출 순서가 변경되면 오류가 발생할 수 있습니다. 이를 해결하려면 useRef와 같은 React의 상태 추적 메커니즘을 사용하여 훅 호출 순서를 안정적으로 유지해야 합니다.