exhaustive-deps는 컴포넌트 안에서 만든 함수만 봅니다. 훅이 반환한 함수는 안정적이라고 가정하고 넘어갑니다. 실제로 규칙을 돌려 경계가 어디까지인지 확인했습니다.
exhaustive-deps는 의존성 배열에 불안정한 함수를 넣으면 경고해줍니다.
그래서 저는 이 규칙을 안전망으로 여기고 있었습니다. 경고가 없으면 괜찮은 줄 알았습니다.
그런데 경고가 없는데도 effect가 매 렌더 다시 도는 코드를 만났습니다. 규칙이 못 잡는 자리가 있는 건지 확인해보기로 했습니다.
eslint-plugin-react-hooks 7.1.1에 두 가지를 나란히 넣고 돌렸습니다.
// A: 컴포넌트 안에서 만든 함수
function A() {
const [count, setCount] = useState(0);
const handleClick = () => { console.log(count); };
useEffect(() => { handleClick(); }, [handleClick]);
}
// B: 커스텀 훅이 반환한 함수
function useAutoSave() {
const [lastSaved, setLastSaved] = useState(null);
const save = (content) => { setLastSaved(content); }; // 메모이제이션 없음
return { save, lastSaved };
}
function B() {
const [content, setContent] = useState("");
const { save } = useAutoSave();
useEffect(() => {
const timer = setInterval(() => save(content), 5000);
return () => clearInterval(timer);
}, [save, content]);
}
결과입니다.
6:9 warning The 'handleClick' function makes the dependencies of useEffect Hook
(at line 7) change on every render. Move it inside the useEffect
callback. Alternatively, wrap the definition of 'handleClick' in
its own useCallback() Hook react-hooks/exhaustive-deps
✖ 1 problem (0 errors, 1 warning)
A만 걸립니다. B는 조용합니다.
두 코드가 하는 일은 같습니다. 매 렌더마다 새 함수가 만들어지고, 그게 의존성 배열에 들어가 effect를 다시 돌립니다. 그런데 한쪽만 경고합니다.
"커스텀 훅이라서"인지, 아니면 더 넓은 규칙인지 궁금해서 몇 가지를 더 돌려봤습니다.
C. 훅 내부에서 useCallback을 썼지만 그 의존성이 불안정한 경우
function useAutoSave(onDone) {
const save = useCallback((c) => { onDone(c); }, [onDone]);
return { save };
}
function C() {
// 인라인 함수라 매 렌더 새로 생성 → onDone이 바뀜 → save도 바뀜
const { save } = useAutoSave((c) => console.log(c));
useEffect(() => { save(content); }, [save, content]);
}
경고 없습니다. useCallback을 썼는데도 그렇습니다.
감쌌다는 사실과 참조가 안정적이라는 사실은 별개인데, 규칙은 여기까지 보지 않습니다.
D. 구조분해 없이 훅이 함수를 직접 반환하는 경우 — 경고 없음
E. 훅이 아니라 평범한 함수가 반환한 함수
function makeFn() { return () => {}; }
function E() {
const fn = makeFn();
useEffect(() => { fn(); }, [fn]);
}
이것도 경고 없습니다.
정리하면 경계는 커스텀 훅이냐 아니냐가 아니었습니다.
규칙은 컴포넌트 본문에서 직접 정의한 함수만 추적합니다. 호출의 반환값으로 들어온 함수는 출처가 무엇이든 안정적이라고 가정합니다.
규칙 입장에서는 어쩔 수 없습니다.
useAutoSave()가 안정적인 함수를 주는지 아닌지는 그 훅의 구현을 봐야 압니다.
훅이 다른 파일에 있으면 린트 규칙의 시야 밖입니다.
설령 같은 파일이라도, 반환값을 끝까지 추적하는 건 타입 검사에 가까운 일이지
린트 규칙이 감당할 범위가 아닙니다.
그래서 "모르면 안정적이라고 친다" 를 고른 겁니다. 반대로 골랐다면 훅에서 함수를 받는 모든 코드가 경고를 뿜었을 테고, 그건 아무도 안 쓰는 규칙이 됐을 겁니다.
합리적인 선택입니다. 다만 그 결과로 생긴 사각지대는 내가 알고 있어야 합니다.
이 사각지대의 실질적 의미는 이겁니다.
훅이 함수를 반환하는 순간, 그 훅은 참조 안정성에 대한 책임을 진다. 쓰는 쪽에서는 확인할 방법이 없기 때문입니다.
쓰는 쪽에서 할 수 있는 게 없습니다. save가 안정적인지 아닌지는
호출부만 봐서는 알 수 없고, ESLint도 안 알려줍니다.
훅 구현을 직접 열어보는 것 말고는 방법이 없습니다.
그래서 커스텀 훅을 만들 때 규칙을 이렇게 정리했습니다.
useCallback으로 감싼다. 예외 없이.useMemo로 감싼다. 같은 이유입니다.그렇다고 모든 훅 반환값을 감싸라는 뜻은 아닙니다.
반환한 함수가 의존성 배열에 들어가지 않는다면 매 렌더 새로 만들어져도 문제없습니다.
onClick에만 쓰이는 함수를 useCallback으로 감싸는 건 비용만 늘립니다.
기준은 "이 함수가 남의 의존성 배열에 들어갈 가능성이 있는가" 입니다. 공용 훅이라면 대체로 예라고 봐야 하고, 그래서 감싸는 편이 맞습니다. 한 곳에서만 쓰는 훅이면 안 감싸도 됩니다.
exhaustive-deps는 컴포넌트 본문에서 정의한 함수만 추적합니다.
호출의 반환값으로 온 함수는 안정적이라고 가정하고 넘어갑니다.
커스텀 훅이냐 아니냐는 기준이 아닙니다.useCallback으로 감쌌어도 의존성이 불안정하면 결과는 같고, 이것도 경고가 없습니다.마지막이 이 글을 쓴 이유입니다. 저는 경고가 없으면 통과한 줄 알았는데, 규칙이 무엇을 보지 않는지를 모르면 그 판단 자체가 성립하지 않았습니다.
메타 태그를 다 넣었는데도 검색 결과는 파란 링크 한 줄뿐이었습니다. 검색엔진이 페이지를 '읽는' 것과 '이해하는' 것은 다른 문제였고, 그 사이를 메우는 게 구조화된 데이터였습니다.
전역 Suspense로 감싸면 경고는 사라지지만, 페이지의 정적 마크업까지 placeholder로 대체됩니다. Next.js 소스코드를 따라가 BailoutToCSRError가 CLIENT_RENDERED 경계로 어떻게 흐르는지 살펴봅니다.
hooks 기반 이벤트 드리븐 위젯을 만들고, 그 과정에서 왜 Electron을 선택했는지, hook과 기록 파일이라는 두 신호를 어떻게 교차 검증했는지 정리했습니다.
문서에는 "queued"라고만 적혀 있습니다. 호출 단위인지 컴포넌트 단위인지 탭 전체인지 알 수 없어서, 30개 요청을 세 가지 방식으로 직접 재봤습니다.
인앱 브라우저에서 기능이 깨지는 문제와 대용량 목록 렌더링 성능 문제, 두 가지를 실제로 부딪히고 풀어낸 기록입니다.
Promise 핸들러가 항상 비동기로 실행되는 이유인 마이크로태스크 큐를 짚고, 이를 편하게 다루는 async/await 문법과 실행 순서 종합 문제로 시리즈를 마무리합니다.