useEffect 의존성 배열을 거짓말하면 생기는 문제
useEffect 의존성 배열을 거짓말하면 생기는 문제
Effect는 외부 시스템을 현재 props와 state에 동기화한다. 의존성 배열은 원하는 실행 횟수를 적는 옵션이 아니라 Effect가 읽는 반응형 값의 목록이다. 의존성을 숨겨 경고를 없애면 Effect가 오래된 렌더의 값을 계속 사용한다. 실행이 너무 잦다면 배열을 속이지 말고 Effect의 책임과 코드를 바꿔야 한다.
목차
- #빈 배열로 한 번만 실행하고 싶은 유혹
- #Effect는 생명주기 이벤트가 아니라 동기화다
- #의존성을 빼면 오래된 렌더에 고정된다
- #cleanup과 setup은 한 쌍이다
- #의존성을 추가했더니 무한 반복되는 경우
- #객체와 함수 의존성을 줄이는 방법
- #이벤트에서 해야 할 일을 Effect로 옮기지 않기
- #이전 state에 의존하면 updater 함수를 사용한다
- #비동기 요청의 경쟁과 취소
- #서로 다른 동기화는 Effect를 분리한다
- #lint 예외가 필요한 경우
- #테스트와 리뷰 체크리스트
- #정리
- #관련 노트와 참고 자료
빈 배열로 한 번만 실행하고 싶은 유혹
채팅방에 연결하는 컴포넌트를 만든다고 해 보자.
function ChatRoom({ roomId }: { roomId: string }) {
useEffect(() => {
const connection = createConnection(roomId);
connection.connect();
return () => connection.disconnect();
}, []);
return <h1>{roomId} 채팅방</h1>;
}
개발자는 “연결은 mount할 때 한 번만”이라는 의도로 빈 배열을 넣었다. 첫 화면에서는 동작한다. 그러나 사용자가 general에서 travel 방으로 이동해 prop이 바뀌면 제목은 travel인데 connection은 여전히 general에 남는다.
Effect 안에서 roomId를 읽으면서 의존성에는 없다고 적었기 때문이다.
useEffect(() => {
const connection = createConnection(roomId);
connection.connect();
return () => connection.disconnect();
}, [roomId]);
이제 roomId가 바뀔 때 이전 연결을 끊고 새 방에 연결한다.
“언제 실행하고 싶은가”를 적는 곳이 아니라 “이 동기화 코드가 어떤 반응형 값을 읽는가”를 선언하는 곳이다.
Effect는 생명주기 이벤트가 아니라 동기화다
컴포넌트는 mount, update, unmount라는 생명주기를 갖지만 Effect는 외부 시스템과의 동기화를 시작하고 중지하는 한 쌍으로 보는 편이 이해하기 쉽다.
sequenceDiagram
participant R as React
participant E as Effect
participant C as Chat Connection
R->>E: roomId=general setup
E->>C: general 연결
R->>E: roomId=travel로 commit
E->>C: general cleanup
E->>C: travel setup
R->>E: unmount
E->>C: travel cleanuproomId가 바뀌면 Effect가 “다시 실행”되는 것만 보지 말고, 이전 값에 대한 동기화를 멈추고 새 값에 맞춰 다시 시작한다고 생각한다.
Effect가 적합한 대상은 React 밖의 시스템이다.
- WebSocket 연결
- 브라우저 event listener
- timer
- 지도·차트 같은 imperative widget
- 데이터 패칭
- DOM API와의 동기화
렌더 중 계산할 수 있는 값이나 사용자의 특정 클릭 때문에 생긴 작업은 Effect가 아닐 가능성이 높다.
의존성을 빼면 오래된 렌더에 고정된다
1초마다 현재 count를 로그로 남기는 예제다.
function CounterLogger() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => {
console.log(count);
}, 1000);
return () => clearInterval(id);
}, []);
return (
<button onClick={() => setCount((value) => value + 1)}>
{count}
</button>
);
}
화면 count는 증가해도 interval은 계속 0을 출력한다. 빈 배열인 Effect는 첫 렌더에서 만든 callback을 유지하고, 그 callback은 첫 렌더의 count === 0을 닫아 두었다.
이를 stale closure라고 부른다. 문제는 React가 값을 늦게 갱신해서가 아니라 Effect가 오래된 렌더의 함수를 계속 쓰도록 선언된 것이다.
단순히 [count]를 추가하면 최신 값은 출력된다.
useEffect(() => {
const id = setInterval(() => {
console.log(count);
}, 1000);
return () => clearInterval(id);
}, [count]);
하지만 count가 바뀔 때마다 interval을 제거하고 다시 만들게 된다. 요구사항이 “count 변경마다 timer를 재시작”이 아니라면 구조를 다르게 해야 한다. 최신 값을 읽기만 하는 목적에는 ref나 사용하는 React 버전의 Effect Event를 검토할 수 있다.
const latestCountRef = useRef(count);
useEffect(() => {
latestCountRef.current = count;
}, [count]);
useEffect(() => {
const id = setInterval(() => {
console.log(latestCountRef.current);
}, 1000);
return () => clearInterval(id);
}, []);
ref는 반응형 흐름을 벗어나는 도구이므로 화면 렌더가 ref에 의존하게 만들면 안 된다.
cleanup과 setup은 한 쌍이다
Effect는 다음 순서로 동작한다.
- 처음 commit 후 setup을 실행한다.
- 의존성이 바뀐 commit 후 이전 값의 cleanup을 실행한다.
- 새 값으로 setup을 실행한다.
- unmount할 때 마지막 cleanup을 실행한다.
useEffect(() => {
function handleResize() {
setViewportWidth(window.innerWidth);
}
window.addEventListener("resize", handleResize);
handleResize();
return () => {
window.removeEventListener("resize", handleResize);
};
}, []);
cleanup에서 setup이 만든 정확한 listener를 제거해야 한다. 익명 함수를 다시 만들면 같은 함수 참조가 아니어서 제거되지 않는다.
useEffect(() => {
window.addEventListener("resize", () => updateWidth());
return () => {
window.removeEventListener("resize", () => updateWidth());
};
}, []);
개발 Strict Mode에서는 setup → cleanup → setup 주기를 추가로 실행해 cleanup 누락을 드러낼 수 있다. 개발에서 연결이 두 번 보인다고 guard ref로 두 번째 setup을 막으면 실제 unmount/remount에서 자원 정리가 깨질 수 있다.
사용자가 setup이 한 번 실행된 경우와 setup → cleanup → setup을 거친 경우의 차이를 느끼지 못해야 한다.
의존성을 추가했더니 무한 반복되는 경우
Effect에서 state를 바꾸고 그 state를 의존성으로 읽으면 반복이 생길 수 있다.
function ProductSearch({ products }: Props) {
const [visibleProducts, setVisibleProducts] = useState<Product[]>([]);
useEffect(() => {
setVisibleProducts(
products.filter((product) => product.visible),
);
}, [products, visibleProducts]);
return <ProductList products={visibleProducts} />;
}
Effect가 visibleProducts를 변경하면 렌더되고 의존성이 바뀌어 Effect가 다시 실행된다. 하지만 이 예제의 근본 문제는 의존성 배열이 아니라 파생 값을 Effect로 state에 저장한 것이다.
function ProductSearch({ products }: Props) {
const visibleProducts = products.filter(
(product) => product.visible,
);
return <ProductList products={visibleProducts} />;
}
계산이 실제로 비싸고 참조 안정성이 성능에 필요하다는 측정이 있다면 useMemo를 고려한다.
const visibleProducts = useMemo(
() => products.filter((product) => product.visible),
[products],
);
lint 경고를 끄기 전에 “이 Effect가 어떤 외부 시스템을 동기화하는가?”에 답하지 못하면 Effect 자체를 삭제할 가능성이 높다.
객체와 함수 의존성을 줄이는 방법
렌더 중 만든 객체는 매번 새 참조다.
function ChatRoom({ roomId }: Props) {
const options = {
serverUrl: "https://chat.example.test",
roomId,
};
useEffect(() => {
const connection = createConnection(options);
connection.connect();
return () => connection.disconnect();
}, [options]);
}
컴포넌트가 다른 이유로 렌더되어도 options는 새 객체이므로 연결이 끊겼다가 다시 만들어진다. 객체를 Effect 안에서 만들고 실제 반응형 원시 값만 의존성에 둔다.
function ChatRoom({ roomId }: Props) {
useEffect(() => {
const options = {
serverUrl: "https://chat.example.test",
roomId,
};
const connection = createConnection(options);
connection.connect();
return () => connection.disconnect();
}, [roomId]);
}
컴포넌트 밖으로 옮길 수 있는 상수는 모듈 범위로 옮긴다.
const SERVER_URL = "https://chat.example.test";
함수도 매 렌더마다 새로 만들어진다.
function SearchPage({ query }: Props) {
function createRequest() {
return { query, pageSize: 20 };
}
useEffect(() => {
void fetchProducts(createRequest());
}, [createRequest]);
}
함수를 Effect 안으로 옮기거나 필요한 값만 직접 사용한다.
useEffect(() => {
const request = { query, pageSize: 20 };
void fetchProducts(request);
}, [query]);
useCallback은 마지막 수단 중 하나다. 함수가 실제로 props로 전달되며 memoized child에 필요하거나 다른 Hook의 안정적인 dependency여야 할 때 사용한다. 단지 lint를 만족하려고 모든 함수를 감싸면 의존성 추적만 복잡해진다.
이벤트에서 해야 할 일을 Effect로 옮기지 않기
사용자 클릭 때문에 결제 요청을 보내는 로직을 state와 Effect로 우회할 필요가 없다.
function Checkout({ cart }: Props) {
const [submitted, setSubmitted] = useState(false);
useEffect(() => {
if (submitted) {
void createOrder(cart);
}
}, [submitted, cart]);
return (
<button onClick={() => setSubmitted(true)}>
주문하기
</button>
);
}
주문 후 theme이나 cart 참조가 바뀌면 의도하지 않은 재실행 가능성이 생긴다. 특정 상호작용 때문에 실행되는 작업은 이벤트 핸들러에 둔다.
function Checkout({ cart }: Props) {
async function handleCheckout() {
await createOrder(cart);
showToast("주문을 접수했습니다.");
}
return <button onClick={handleCheckout}>주문하기</button>;
}
Effect는 “이 컴포넌트가 화면에 있고 roomId가 이 값인 동안 채팅 연결을 유지한다”처럼 화면 상태 때문에 외부 시스템과 계속 동기화해야 할 때 적합하다.
| 질문 | 위치 |
|---|---|
| 사용자가 버튼을 눌러 발생했는가 | event handler |
| 컴포넌트가 표시되는 동안 외부 시스템과 맞춰야 하는가 | Effect |
| props와 state만으로 계산할 수 있는가 | render |
| 서버 상태 캐시와 요청 생명주기인가 | data fetching 도구 검토 |
이전 state에 의존하면 updater 함수를 사용한다
WebSocket 메시지를 받을 때 기존 messages를 읽으면 Effect 의존성에 messages가 추가되고 메시지마다 재연결할 수 있다.
useEffect(() => {
const connection = connect(roomId);
connection.onMessage((message) => {
setMessages([...messages, message]);
});
return () => connection.disconnect();
}, [roomId, messages]);
updater 함수를 쓰면 callback이 messages를 읽지 않는다.
useEffect(() => {
const connection = connect(roomId);
connection.onMessage((message) => {
setMessages((current) => [...current, message]);
});
return () => connection.disconnect();
}, [roomId]);
의존성을 억지로 제거한 것이 아니라 코드가 더 이상 해당 반응형 값을 읽지 않도록 바꿨기 때문에 제거할 수 있다. 공식 문서가 말하는 “의존성이 필요하지 않음을 증명한다”는 의미가 여기에 가깝다.
비동기 요청의 경쟁과 취소
검색어가 빠르게 바뀌면 먼저 시작한 요청이 나중 요청보다 늦게 끝날 수 있다.
sequenceDiagram
participant U as User
participant A as apple 요청
participant B as app 요청
U->>A: query=apple
U->>B: query=app
B-->>U: 최신 결과 먼저 도착
A-->>U: 오래된 결과가 뒤늦게 덮어씀Effect cleanup에서 이전 요청을 취소한다.
useEffect(() => {
const controller = new AbortController();
async function load() {
try {
const response = await fetch(
`/api/products?q=${encodeURIComponent(query)}`,
{ signal: controller.signal },
);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
setProducts(await response.json());
} catch (error) {
if (!controller.signal.aborted) {
setError(toDisplayError(error));
}
}
}
void load();
return () => controller.abort();
}, [query]);
취소를 지원하지 않는 API라면 cleanup flag로 오래된 결과 반영을 막는다.
useEffect(() => {
let ignore = false;
void fetchProducts(query).then((result) => {
if (!ignore) {
setProducts(result);
}
});
return () => {
ignore = true;
};
}, [query]);
실제 애플리케이션에서는 캐시, deduplication, SSR, 재시도까지 필요한 경우가 많으므로 TanStack Query나 프레임워크 데이터 계층 같은 전용 도구를 검토한다.
서로 다른 동기화는 Effect를 분리한다
채팅 연결과 방문 분석을 한 Effect에 묶으면 한쪽 의존성 때문에 다른 작업도 반복된다.
useEffect(() => {
logVisit(roomId);
const connection = connect(roomId, serverUrl);
connection.connect();
return () => connection.disconnect();
}, [roomId, serverUrl]);
serverUrl만 바뀌어 재연결할 때 방문 로그도 다시 전송된다. 서로 독립적인 프로세스로 나눈다.
useEffect(() => {
logVisit(roomId);
}, [roomId]);
useEffect(() => {
const connection = connect(roomId, serverUrl);
connection.connect();
return () => connection.disconnect();
}, [roomId, serverUrl]);
나누는 기준은 코드 길이나 관심사의 이름이 아니라 setup과 cleanup의 생명주기가 같은가이다. 하나를 삭제해도 다른 동기화가 깨지지 않는다면 별도 Effect일 가능성이 높다.
lint 예외가 필요한 경우
react-hooks/exhaustive-deps 경고는 대부분 실제 버그 가능성을 알려 준다.
useEffect(() => {
synchronize(userId, locale);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [userId]);
이 주석은 locale 변경에 반응하지 않겠다고 선언하지만 Effect 코드는 locale을 읽는다. 요구사항과 구현이 모순된다.
불가피하게 예외를 둘 때는 다음을 문서화한다.
- 어떤 값이 반응형이 아님을 왜 보장하는가
- 변경 시 어떤 테스트가 실패하는가
- 왜 ref나 Effect 분리로 해결하지 못하는가
- 언제 제거할 것인가
하지만 library boundary나 안정성이 계약된 값은 linter가 이미 인식하거나 컴포넌트 밖으로 옮겨 표현할 수 있는 경우가 많다. suppression은 마지막 선택이어야 한다.
Effect가 반응형 값을 읽지 않을 때의 결과다. 값을 읽으면서 빈 배열을 쓰면 React에 거짓말하는 셈이다.
테스트와 리뷰 체크리스트
Effect 테스트는 mount 횟수보다 외부 시스템이 올바르게 동기화되는지 본다.
it("roomId가 바뀌면 이전 연결을 끊고 새 방에 연결한다", () => {
const { rerender, unmount } = render(
<ChatRoom roomId="general" />,
);
expect(connect).toHaveBeenCalledWith("general");
rerender(<ChatRoom roomId="travel" />);
expect(disconnect).toHaveBeenCalledWith("general");
expect(connect).toHaveBeenCalledWith("travel");
unmount();
expect(disconnect).toHaveBeenCalledWith("travel");
});
비동기 검색은 오래된 응답이 최신 결과를 덮지 않는지 검증한다.
it("마지막 검색어의 결과만 표시한다", async () => {
const user = userEvent.setup();
render(<ProductSearch />);
await user.type(screen.getByRole("searchbox"), "apple");
await user.clear(screen.getByRole("searchbox"));
await user.type(screen.getByRole("searchbox"), "app");
resolveRequest("app", ["App result"]);
resolveRequest("apple", ["Old result"]);
expect(await screen.findByText("App result")).toBeVisible();
expect(screen.queryByText("Old result")).not.toBeInTheDocument();
});
리뷰에서는 다음을 묻는다.
- Effect가 동기화하는 외부 시스템을 한 문장으로 말할 수 있는가?
- Effect 안에서 읽는 모든 props, state, 컴포넌트 내부 변수가 의존성에 있는가?
- lint 경고를 끄지 않고 코드를 바꿀 수 있는가?
- 렌더에서 계산하거나 이벤트 핸들러에서 실행할 수 있는가?
- setup이 만든 자원을 cleanup이 정확히 해제하는가?
- 객체나 함수가 매 렌더마다 바뀌어 불필요한 재동기화를 만드는가?
- 비동기 요청이 취소되거나 오래된 결과가 무시되는가?
정리
Effect 의존성 배열은 실행 횟수를 직접 제어하는 장치가 아니다. Effect가 현재 렌더에서 읽는 반응형 값을 선언해 외부 시스템을 최신 상태와 동기화하는 계약이다.
의존성이 많아 불편하다면 배열에서 제거하기 전에 코드를 바꾼다.
- 파생 값은 렌더 중 계산한다.
- 상호작용에 따른 작업은 이벤트 핸들러로 옮긴다.
- 객체와 함수는 Effect 안에서 만들거나 필요한 원시 값만 읽는다.
- 이전 state 기반 갱신은 updater 함수를 쓴다.
- 독립적인 동기화는 Effect를 나눈다.
- 오래된 비동기 요청은 취소하거나 결과를 무시한다.
의존성 경고를 없애는 것이 목표가 아니라 Effect 코드와 의존성 목록이 같은 사실을 말하게 만드는 것이 목표다.