(번역) 리액트 훅(React Hooks)의 클로저 트랩(Closure Trap) 이해하기

Chanhee Kim·2022년 7월 14일
52

FE 글 번역

목록 보기
4/22

고전적 문제 파고들기

원문: https://betterprogramming.pub/understanding-the-closure-trap-of-react-hooks-6c560c408cde
좋은글 번역을 허락해주신 bytefish님께 감사드립니다.

현재 우리는 리액트 프로젝트를 개발할 때 일반적으로 훅을 사용합니다.

하지만 개발 과정에서 종종 몇 가지 문제에 직면합니다. 가장 고전적인 문제는 리액트 훅의 클로저 트랩입니다.

몇몇 분들은 비슷한 문제를 겪었을 수 있지만, 리액트의 기본 원리 측면에서 이 문제를 이해하지 못했을 수 있습니다. 자 이제 이 주제에 대해 얘기해 보죠.

문제

여기 간단한 리액트 앱이 있습니다.

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의 값을 지속해서 증가시킵니다. 동시에 count의 최신 값을 또 다른 useEffect에서 출력합니다.

콘솔에 어떻게 출력될까요?

결과는 다음과 같습니다.

다음은 데모입니다.

콘솔에 우리가 예상했던 0, 1, 2, 3, ...대신 0, 0, 0...이 계속 출력됩니다.

이게 클로저 트랩입니다.

분석

React 런타임에서 컴포넌트란 무엇일까요?

  • 컴포넌트는 실제론 파이버 노드(fiber node)입니다.
  • 그리고 각 파이버 노드는 memorizedState라는 속성이 있으며 이는 링크드 리스트(linked list)입니다.
  • 컴포넌트의 각 훅은 memorizedState 링크드 리스트의 노드에 해당하며 해당 노드에서 자신의 값에 접근합니다.

예를 들어 위 예에서는 3개의 훅이 있으며 각각은 memorizedState 링크드 리스트의 노드에 해당합니다.

그다음 각 훅은 자신의 memorizedState에 접근해 로직을 완료합니다.

훅의 구현

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

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

다음은 useEffect의 구현입니다.

훅은 의존성(deps)을 어떻게 다룰까요?

여기서 우리는 매개변수 deps를 어떻게 처리하는지에 주목해야 합니다. 만약 deps가 undefined라면 deps는 null로 처리됩니다.

그다음 새로 전달된 depsmemorizedState의 기존 deps를 비교합니다. 둘이 같다면 이전에 주어진 함수가 그대로 사용되며, 같지 않은 경우 새 함수가 생성됩니다.

deps가 같은지 비교하기 위한 로직은 매우 간단합니다. 만약 이전 depsnull이라면 둘이 같지 않다는 의미로 false를 즉시 반환합니다. 그렇지 않다면 배열을 순회하며 차례로 비교합니다.

따라서 우리는 세 가지 결론을 얻을 수 있습니다.

  • useEffectdeps 매개변수가 undefined 또는 null인 경우 모든 리렌더링에서 콜백 함수가 재생성되고 실행됩니다.
  • deps가 빈 배열인 경우 이펙트는 한 번만 실행됩니다.
  • 그렇지 않은 경우 deps의 각 요소가 변경되었는지 비교해 이펙트의 실행 여부를 결정합니다.

이 글을 읽기 전 이미 이러한 결론을 알고 있으셨을지도 모르겠지만, 여기서는 소스코드 관점에서 이해합니다.

useMemouseCallback같은 훅도 같은 방식으로 deps를 처리합니다.


위에서 살펴본 내용들로부터 저희는 두 가지를 알 수 있습니다.

  • useEffect와 같은 훅은 memriorizedState의 데이터에 접근합니다.
  • 훅은 deps가 같은지 비교해 콜백 함수의 실행 여부를 결정합니다.

클로저 트랩

이제 클로저에 대한 질문으로 돌아옵시다. 다음과 같이 코드를 작성합니다.

useEffect(() => {
  const timer = setInterval(() => {
    setCount(count + 1);
  }, 500);
}, []);

useEffect(() => {
  const timer = setInterval(() => {
    console.log(count);
  }, 500);
}, []);

deps가 빈 배열이므로 이펙트는 한 번만 실행됩니다.

해당 소스 코드의 구현은 다음과 같습니다.

실행해야 할 이펙트는 HasEffect로 표시된 후 나중에 실행됩니다.

여기서는 deps가 빈 배열이므로 HasEffect 플래그가 없습니다. 이펙트는 더 이상 실행되지 않습니다.

그러므로 setInterval 타이머는 한 번만 설정됩니다.. 따라서 콜백 함수가 참조하는 상태는 항상 초기 상태이며 최신 상태를 얻을 수 없습니다.

최신 상태를 얻기 위해서는 리렌더링 할 때마다 fn을 실행해야 합니다. 즉, count를 종속성 배열에 넣어야 합니다.

useEffect(() => {
  setInterval(() => {
    setCount(count + 1);
  }, 1500);
}, [count]);

useEffect(() => {
  setInterval(() => {
    console.log(count);
  }, 1500);
}, [count]);

결과는 다음과 같습니다.

fn이 최신 상태를 얻는 걸로 보이지만, 왜 콘솔에 출력되는 결과는 엉망인 걸까요?

이는 각 이펙트가 타임 인터벌(time interval)을 생성하기 때문입니다. 따라서 이펙트에서 이전 타임 인터벌을 정리해야 합니다.

코드

useEffect(() => {
  let timer = setInterval(() => {
    setCount(count + 1);
  }, 1500);
  return () => clearInterval(timer);
}, [count]);

useEffect(() => {
  let timer = setInterval(() => {
    console.log(count);
  }, 1500);
  return () => clearInterval(timer);
}, [count]);

온라인 데모

이렇게 마침내 클로저 트랩을 해결했습니다.

결론

memorizedState라는 링크드 리스트가 파이버 노드에 저장됩니다. 링크드 리스트의 노드는 각각 하나의 훅에 대응하며, 각 훅은 해당 노드의 데이터에 접근합니다.

useEffect, useMemo 그리고 useCallback 과 같은 훅은 모두 deps 매개변수를 갖습니다. 리렌더링될때마다 새 dep와 이전 deps를 비교하며, deps가 변경되면 콜백 함수가 다시 실행됩니다.

따라서 deps 매개변수가 undefined 혹은 null인 훅은 매 렌더링마다 실행되고, []인 훅은 한 번만 실행되며, [state]인 훅은 상태가 변경될 때만 다시 실행됩니다.

클로저 트랩이 발생하는 이유는 useEffect와 같은 훅에서 특정 상태를 사용함에도 deps 배열에 추가하지 않아 상태가 변경되어도 콜백 함수가 다시 실행되지 않고 기존 상태를 계속 참조하기 때문입니다.

클로저 트랩은 고치기 쉽습니다. deps 배열을 올바르게 설정하기만 하면 됩니다. 이렇게 하면 상태가 변경될 때마다 콜백 함수가 다시 실행되며 새로운 상태를 참조하게 됩니다. 그렇지만 이전 타이머, 이벤트 리스너 등을 정리하는 데에도 주의를 기울여야 합니다.


다이어그램과 함께 설명하는 자바스크립트 클로저 챌린지 10선
시니어 리액트 개발자를 위한 리액트 훅 챌린지

🚀 한국어로 된 프런트엔드 아티클을 빠르게 받아보고 싶다면 Korean FE Article(https://kofearticle.substack.com/)을 구독해주세요!

profile
FE 개발을 하고 있어요🌱

5개의 댓글

comment-user-thumbnail
2022년 7월 20일

잘 봤습니다! 번역 감사드립니다

1개의 답글
comment-user-thumbnail
2022년 7월 21일

좋은 글 읽기 쉽게 번역해주셔서 감사합니다

1개의 답글
comment-user-thumbnail
2023년 11월 17일
답글 달기