React Hooks의 클로저 트랩 이해하기(번역)

햄햄·2022년 6월 26일
3

원문: Understanding the Closure Trap of React Hooks

리액트 프로젝트를 개발할때 보통 훅을 쓴다.

하지만 개발 과정에서 종종 몇가지 문제를 마주친다. 가장 고전적인 문제는 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 ...을 계속해서 입력한다. 이것이 클로저 트랩이다.

분석

리액트 런타임에서 컴포넌트는 무엇일까?

  • 컴포넌트는 사실 fiber node이다.
  • 그리고 각각의 fiber node는 memorizedState라는 프로퍼티를 가지고 있는데, 이것은 연결 리스트이다.
  • 컴포넌트의 각 훅은 memorizedState 연결리스트의 노드에 해당하며, 해당 노드에서 자신의 값에 접근한다.

위 예시에서 세 개의 훅이 있고 각 훅들은 memorizedState 연결리스트의 노드에 해당한다.

그리고 각각의 훅들은 자신의 memorizedState에 접근하여 로직을 완성시킨다.

Hooks 구현

훅에는 두 가지 단계가 있다. 마운트와 업데이트이다.

마운트 함수는 훅이 처음 생성될 때 실행되고, 이후 훅이 업데이트될 때마다 업데이트 함수가 실행된다.

useEffect 구현:

훅은 deps를 어떻게 처리할까?
여기 우리는 deps 매개변수를 처리하는 것에 주의를 기울여야 한다. 만약 deps가 undefined이면, null로 다뤄진다.

그런 다음 이전에 memorizedState에 있던 deps와 새로 전달된 deps를 비교한다. 만약 두 값이 같으면 이전에 제공된 함수를 곧바로 사용하고, 그렇지 않다면 새로운 함수를 생성한다.

deps가 같은지 비교하는 로직은 매우 간단하다. 만약 이전 depsnull이면 바로 false를 리턴한다. 즉, 동일하지 않게 된다. 그렇지 않으면 배열을 차례로 탐색하고 비교한다.

따라서 세가지 결론을 낼 수 있다.

  • 만약 useEffectdeps 매개변수가 undefined거나 null이라면, 매 렌더링마다 콜백함수를 다시 생성하고 실행한다.
  • 만약 빈 배열이라면, 함수(the effect)를 한번만 실행한다.
  • 빈 배열이 아니라면, deps 배열의 각 요소가 바뀌었는지를 비교하여 함수(the effect)를 실행할지 결정한다.

이 아티클을 읽기 전에도 이러한 결론을 이미 알고 있을 수도 있지만, 여기서는 소스 코드 관점에서 이해한다.

useMemouseCallback 같은 훅들도 같은 방식으로 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가 바뀌면 콜백 함수가 다시 실행된다.

그러므로 매개변수가 undefinednull인 훅들은 리렌더링마다 실행되고, 매개변수가 []인 훅들은 한번만 실행된다. 그리고 매개변수가 [state]인 훅들은 state가 바뀔 때만 다시 실행된다.

클로저 트랩의 원인은 useEffect같은 훅에 특정 상태들이 사용되었으나 deps 배열에는 추가되지 않아 상태가 바뀌어도 콜백 함수가 다시 실행되지 않고, 이전 상태를 계속 참조해서였다.

클로저 트랩은 고치기 쉽다. deps 배열만 정확하게 정해주면 된다. 이렇게 한다면, 상태가 바뀔때마다 콜백 함수가 다시 실행되고 새로우 상태를 참조하게 된다. 하지만 이전 타이머와 이벤트 리스너 등을 정리(cleaning up)하는 데에도 주의를 기울여야 한다.

profile
@Ktown4u 개발자

0개의 댓글