리액트 프로젝트를 개발할때 보통 훅을 쓴다.
하지만 개발 과정에서 종종 몇가지 문제를 마주친다. 가장 고전적인 문제는 React Hooks의 클로저 트랩이다.
어떤 친구들은 비슷한 문제에 마주쳤지만 리액트의 기본 원칙에서부터 이 문제를 이해하지 못할 수 있다. 함께 이 토픽에 대해 논의해보자.
심플한 리액트 앱:
import { useEffect, useState } from 'react';
export default function App() {
const [count, setCount] = useState(0);
useEffect(() => {
setInterval(() => {
setCount(count + 1);
}, 1500);
}, []);
useEffect(() => {
setInterval(() => {
console.log(count);
}, 1500);
}, []);
return <div>Hello world</div>;
}
useState
를 사용하여 count
상태를 만들고, 첫번째 useEffect
에서 count
값을 계속 증가시킨다. 동시에 다른 useEffect
에서 최신 count
값을 출력한다.
console에 어떤 값이 출력될까?
결과는 이렇다:
embed demo:
콘솔은 예상했던 0, 1, 2, 3, ...
대신에 0, 0, 0 ...
을 계속해서 입력한다. 이것이 클로저 트랩이다.
리액트 런타임에서 컴포넌트는 무엇일까?
memorizedState
라는 프로퍼티를 가지고 있는데, 이것은 연결 리스트이다.memorizedState
연결리스트의 노드에 해당하며, 해당 노드에서 자신의 값에 접근한다.위 예시에서 세 개의 훅이 있고 각 훅들은 memorizedState
연결리스트의 노드에 해당한다.
그리고 각각의 훅들은 자신의 memorizedState
에 접근하여 로직을 완성시킨다.
훅에는 두 가지 단계가 있다. 마운트와 업데이트이다.
마운트 함수는 훅이 처음 생성될 때 실행되고, 이후 훅이 업데이트될 때마다 업데이트 함수가 실행된다.
useEffect
구현:
훅은 deps를 어떻게 처리할까?
여기 우리는 deps
매개변수를 처리하는 것에 주의를 기울여야 한다. 만약 deps
가 undefined이면, null로 다뤄진다.
그런 다음 이전에 memorizedState
에 있던 deps
와 새로 전달된 deps
를 비교한다. 만약 두 값이 같으면 이전에 제공된 함수를 곧바로 사용하고, 그렇지 않다면 새로운 함수를 생성한다.
두 deps
가 같은지 비교하는 로직은 매우 간단하다. 만약 이전 deps
가 null
이면 바로 false
를 리턴한다. 즉, 동일하지 않게 된다. 그렇지 않으면 배열을 차례로 탐색하고 비교한다.
따라서 세가지 결론을 낼 수 있다.
useEffect
의 deps
매개변수가 undefined거나 null이라면, 매 렌더링마다 콜백함수를 다시 생성하고 실행한다.deps
배열의 각 요소가 바뀌었는지를 비교하여 함수(the effect)를 실행할지 결정한다.이 아티클을 읽기 전에도 이러한 결론을 이미 알고 있을 수도 있지만, 여기서는 소스 코드 관점에서 이해한다.
useMemo
와 useCallback
같은 훅들도 같은 방식으로 deps
를 처리한다.
이전 논의에서 두 가지를 알 수 있다.
useEffect
와 같은 훅들은 memorizedState
에 있는 데이터에 접근한다.deps
가 같은지 비교하면서 콜백 함수를 실행시킬지 결정한다.이제 클로저 문제로 돌아가보자. 우리는 코드를 이렇게 썼다.
useEffect(() => {
const timer = setInterval(() => {
setCount(count + 1);
}, 500);
}, []);
useEffect(() => {
const timer = setInterval(() => {
console.log(count);
}, 500);
}, []);
deps
는 빈 배열이고, 함수는 한번만 실행된다.
이에 해당하는 소스 코드 구현은 다음과 같다.
실행되어야 하는 함수(the effect)는 HasEffect
로 표시되고 나중에 실행된다.
deps
가 빈 배열이었기 때문에, HasEffect
플래그는 없다. 함수는 더 이상 실행되지 않을 것이다.
따라서 타이머 setIntervel
은 한 번만 설정된다. 그러므로 콜백 함수가 참조한 상태는 항상 초기 상태이고, 최신 상태를 얻을 수 없다.
최신 상태를 얻고 싶다면 리렌더링할 때마다 fn
을 실행시켜야 한다. 즉, dependency 배열에 count
를 넣어야 한다는 것이다.
결과는 이렇다.
fn
이 최신 상태를 얻은 것처럼 보이지만, console 결과가 왜 이렇게 엉망일까?
Code:
useEffect(() => {
let timer = setInterval(() => {
setCount(count + 1);
}, 1500);
return () => clearInterval(timer);
}, [count]);
Online demo:
이 방식으로 마침내 클로저 트랩을 해결했다.
momerizedState
라는 연결 리스트는 fiber node에 저장된다. 연결 리스트의 노드는 각 훅에 하나씩 대응되며, 각 훅들은 해당 노드에 있는 데이터에 접근한다.
useEffect
, useMemo
, 그리고 useCallback
같은 훅들은 모두 deps
매개변수를 가진다. 리렌더링 될때마다 새로운 deps
와 이전 deps
가 비교되고 deps
가 바뀌면 콜백 함수가 다시 실행된다.
그러므로 매개변수가 undefined
와 null
인 훅들은 리렌더링마다 실행되고, 매개변수가 []
인 훅들은 한번만 실행된다. 그리고 매개변수가 [state]
인 훅들은 state가 바뀔 때만 다시 실행된다.
클로저 트랩의 원인은 useEffect
같은 훅에 특정 상태들이 사용되었으나 deps
배열에는 추가되지 않아 상태가 바뀌어도 콜백 함수가 다시 실행되지 않고, 이전 상태를 계속 참조해서였다.
클로저 트랩은 고치기 쉽다. deps
배열만 정확하게 정해주면 된다. 이렇게 한다면, 상태가 바뀔때마다 콜백 함수가 다시 실행되고 새로우 상태를 참조하게 된다. 하지만 이전 타이머와 이벤트 리스너 등을 정리(cleaning up)하는 데에도 주의를 기울여야 한다.