[번역] 리액트가 마침내 가장 큰 문제를 해결했습니다 - `useEffectEvent`의 묘미

Chanhee Kim·2일 전

FE 글 번역

목록 보기
32/32
post-thumbnail

원문: https://blog.logrocket.com/react-has-finally-solved-its-biggest-problem-useeffectevent/

목차


리액트에서 버그를 가장 많이 유발하는 원인이 무엇이냐고 물으면 뭐라고 답하실 건가요? 아마 모두가 말하는 것과 같은 대답을 하실 겁니다. 바로 useEffect입니다. 비동기 작업을 할 수 있게 해주는 난해한 이름의 훅이죠. 훌륭하지만, 많은 문제를 일으킬 수 있습니다. 가령, 서버에서 계속해서 데이터를 가져오는 무한 루프가 발생할 수 있죠.

jack herrington useeffectevent

그럼에도 리액트 팀에게 박수를 보낼만한 일이 있습니다. 리액트 팀은 이 문제를 인식하고 useEffectEvent라는 새로운 훅을 만들어냈습니다. 다소 복잡한 이름이지만, 리액트 앱을 안정화하는 데 핵심적인 역할을 합니다.

아주 흔한 문제를 함께 살펴보겠습니다. 먼저 우리가 이미 알고 있는 훅들로 시작해서 문제점을 확인한 다음, useEffectEvent가 이를 어떻게 해결하는지 보여드리겠습니다.

Cloudflare의 잘못된 useEffect가 보여주는 중요한 교훈

Cloudflare는 지구상에서 가장 큰 배포 제공업체 중 하나이며, 훌륭한 엔지니어링 팀을 보유하고 있습니다. 하지만 그들조차도 useEffect와 관련해서는 실수를 할 수 있습니다.. 최근에 그들은 의존성 배열에 객체를 잘못 넣어서 자신들의 대시보드에 스스로 DDoS(분산 서비스 거부 공격)를 가했습니다. 그 객체는 매 리렌더링마다 참조가 바뀌어서, 무한 루프를 일으키고 전체 대시보드를 다운시켰습니다.

이것은 누구나 저지를 수 있는 실수입니다. 이것이 바로 리액트 컴파일러와 useEffectEvent 같은 새로운 훅이 중요한 이유입니다. 컴파일러는 객체 참조를 안정화시켜, 객체 참조 동일성(identity)과 관련된 잠재적 버그를 줄이는 데 도움을 줍니다. 그리고 useEffectEvent는 의존성 배열에서 객체를 완전히 제거합니다!

들어가기 전에

다음은 사용자 이름을 수정할 수 있는 아주 단순한 컴포넌트입니다.

function MyUserInfo() {
  const [userName, setUserName] = useState("Bob");

  return (
    <div>
      <input
        value={userName}
        onChange={(evt) => setUserName(evt.target.value)}
      />
    </div>
  );
}

지금까지 잘 됩니다. 사용자 이름을 변경할 수 있습니다. 이제 사용자가 얼마나 오래 로그인해 있었는지 추적하고 표시하고 싶다고 가정해 봅시다.

function MyUserInfo() {
  const [userName, setUserName] = useState("Bob");

  useEffect(() => {
    let loggedInTime = 0;
    const interval = setInterval(() => {
      loggedInTime++;
    }, 1000);
    return () => clearInterval(interval);
  }, []);

  return (
    <div>
      <input
        value={userName}
        onChange={(evt) => setUserName(evt.target.value)}
      />
    </div>
  );
}

우리는 이 사람이 로그인한 시간(초)을 추적하는 타이머를 설정하는 useEffect를 추가했습니다. (네, 진짜 로그인은 아니라는 거 알아요. 데모 코드입니다.)

이 코드는 실제로 작동하며 버그가 없습니다. 빈 의존성 배열 때문에 컴포넌트 마운트 시 한 번만 실행됩니다. 그리고 타이머를 종료하는 interval을 정리하는 cleanup 함수를 반환하여 스스로를 정리합니다.

하지만 기능적으로는 실제로 작동하지 않습니다. 왜냐하면 그 숫자를 어디에도 표시하지 않기 때문입니다. 이를 수정하기 위해 그 숫자를 보여줄 수 있는 loginMessage 문자열을 추가해 봅시다.

function MyUserInfo() {
  const [userName, setUserName] = useState("Bob");
  const [loginMessage, setLoginMessage] = useState("");

  useEffect(() => {
    let loggedInTime = 0;
    const interval = setInterval(() => {
      loggedInTime++;
      setLoginMessage(
        `${userName} has been logged in for ${loggedInTime} seconds`
      );
    }, 1000);
    return () => clearInterval(interval);
  }, []);

  return (
    <div>
      <div>{loginMessage}</div>
      <input
        value={userName}
        onChange={(evt) => setUserName(evt.target.value)}
      />
    </div>
  );
}

겉보기에는 정상적으로 동작해야 합니다. 그리고 실제로 어느 정도는 작동합니다. 바로 "Bob has been logged in for 1 second"라고 표시됩니다. 그리고 매초 충실하게 카운트됩니다. 성공적이네요!

오래된 클로저 문제

이런, 실제로 버그가 있습니다. useEffect에 전달한 함수가 "오래된" 상태가 될 수 있기 때문입니다.

gif of useEffect going stale

사용자 이름을 변경하면 어떻게 될까요? 물론, input은 변경되지만, 로그인 메시지는 계속 사용자 이름이 "Bob"이라고 합니다. 하지만 그렇지 않죠. 우리가 변경했잖아요.

useEffect에 전달한 함수가 그 당시 userName의 현재 값("Bob")을 캡처한 "클로저"를 생성했습니다. 그리고 그것은 절대 변하지 않을 것입니다. 그리고 이제 실제 값과 동기화되지 않았으므로, 우리는 이것을 "오래됨(stale)"으로 간주합니다. 즉, "오래된 클로저(stale closure)"가 있는 것입니다.

다행히 이를 수정하는 방법이 있습니다(아직 useEffectEvent의 차례는 아닙니다. 조금만 더 기다려주세요). 의존성 배열에 userName을 추가하면 됩니다.

useEffect(() => {
  let loggedInTime = 0;
  const interval = setInterval(() => {
    loggedInTime++;
    setLoginMessage(
      `${userName} has been logged in for ${loggedInTime} seconds`
    );
  }, 1000);
  return () => clearInterval(interval);
}, [userName]);

짜잔! 문제 해결. 이제 userName을 편집하면 로그인 메시지가 변경됩니다! 멋지네요. 어, 잠깐. 뭐라고요? 변경할 때마다 로그인 시간이 다시 1초로 돌아갑니다.

gif of useEffect going stale

아, 그래서 매번 새로운 userName 값으로 새 클로저를 만들 때마다 이전 타이머를 종료합니다(이건 좋습니다). 하지만 새로운 loggedInTime도 만들어서 다시 0부터 시작합니다. 이건 확실히 우리가 원하는 동작이 아닙니다.

이를 위한 쉬운 해결책은 loggedInTime을 상태로 추적하고 JSX에서 문자열을 포맷하는 것입니다. 알겠습니다. 하지만, 그렇게 할 수 없다고 가정해 봅시다.

useRef가 구해줍니다

이것을 어떻게 고칠 수 있을까요? useEffectEvent 이전에는 아마 이를 위해 ref를 사용했을 것입니다.

const nameRef = useRef(userName);
nameRef.current = userName;

useEffect(() => {
  let loggedInTime = 0;
  const interval = setInterval(() => {
    loggedInTime++;
    setLoginMessage(
      `${nameRef.current} has been logged in for ${loggedInTime} seconds`
    );
  }, 1000);
  return () => clearInterval(interval);
}, []);

여기서 몇 가지를 했습니다. 먼저, userName의 현재 값을 저장하는 참조를 만들었고, 매 렌더링마다 현재 값을 업데이트합니다. 리액트는 상태처럼 ref를 모니터링하지 않기 때문에 렌더링 중에 ref의 현재 값을 설정해도 괜찮습니다.

다음으로, 템플릿 문자열에서 userName 대신 nameRef.current를 사용합니다. 각 렌더링에서 업데이트되기 때문에 항상 userName의 현재 값을 가져옵니다. 마지막으로, 의존성 배열에서 userName을 제거했고, 이것이 리셋 버그를 없앴습니다.

gif of useRef working

이제 실제로 작동합니다. 문제 없이 잘 됩니다! 하지만 역시 투박하다는 단점이 있습니다. 바로 여기서 useEffectEvent가 등장합니다.

useEffectEvent가 훨씬 낫습니다

이 버전을 확인해 보세요.

const getName = useEffectEvent(() => userName);

useEffect(() => {
  let loggedInTime = 0;
  const interval = setInterval(() => {
    loggedInTime++;
    setLoginMessage(
      `${getName()} has been logged in for ${loggedInTime} seconds`
    );
  }, 1000);
  return () => clearInterval(interval);
}, []);

새로운 useEffectEvent 훅을 사용하여 userName의 현재 값을 반환하는 getter 함수를 만듭니다. 그리고 useEffect 내에서 호출할 수 있으며, 절대 오래되지 않습니다. 정말 깔끔합니다. useRef 버전보다 훨씬 깔끔하고 명확합니다.

하지만 실제로 그보다 조금 더 나아집니다. useEffect에 대해 더 일반적으로 생각할 수 있게 해주기 때문입니다. 생각해보면, 그 useEffect로 더 일반적인 "타이머"를 가지고 있는 셈입니다.

const onTick = useEffectEvent((tick: number) =>
  setLoginMessage(`${userName} has been logged in for ${tick} seconds`)
);

useEffect(() => {
  let ticks = 0;
  const interval = setInterval(() => onTick(++ticks), 1000);
  return () => clearInterval(interval);
}, []);

이제 모든 상태 관련 작업을 useEffectEvent로 옮겼습니다. 우리의 useEffect가 얼마나 깔끔해졌는지 보이시나요? useEffect는 단지 타이머를 처리하고 있습니다. 그리고 onTick은 그 타이머로 무엇을 할지에 대한 모든 로직을 처리하고 있습니다.

useEffectEvent는 게임 체인저입니다

더 좋은 것은, useEffect가 상태에 대한 의존성이 없다는 것입니다. 그리고 useEffect가 문제에 빠지는 것은 바로 상태 의존성입니다(우리가 봤듯이). 잘못된 상태에 의존하는 나쁜 의존성 배열은 오래된 클로저 문제, 잘못된 리셋, 또는 무한 루프까지 일으킬 수 있습니다. 그리고 useEffectEvent는 의존성 배열에서 상태를 제거할 수 있게 해줍니다. 이것이 더 나은 useEffect를 작성하는 데 도움이 됩니다.

더 일반적으로 만들어 커스텀 훅으로 바꿀 수도 있습니다.

function useInterval(onTick: (tick: number) => void) {
  const onTickEvent = useEffectEvent(onTick);
  useEffect(() => {
    let ticks = 0;
    const interval = setInterval(() => onTickEvent(++ticks), 1000);
    return () => clearInterval(interval);
  }, []);
}

이제 매우 깨끗하고 버그 없는 완전한 useInterval 구현이 있습니다.

작은 도전 과제

재미있는 작은 도전을 원하신다면, 밀리초 수(현재 1000)를 조정할 수 있는 버전을 어떻게 구현하시겠습니까?

function useInterval(onTick: (tick: number) => void, timeout: number = 1000) {
  // ????
}

제가 생각해낸 것을 보여드리겠습니다.

function useInterval(onTick: (tick: number) => void, timeout: number = 1000) {
  const onTickEvent = useEffectEvent(onTick);
  useEffect(() => {
    let ticks = 0;
    const interval = setInterval(() => onTickEvent(++ticks), timeout);
    return () => clearInterval(interval);
  }, [timeout]);
}

어, 잠깐, 이건 틀렸네요. 카운터가 매번 0에서 다시 시작하니까 다시 오래된 클로저 문제네요. 이런. 아, 맞아요, 또 다른 useEffectEvent를 사용할 수 있습니다.

function useInterval(onTick: (tick: number) => void, timeout: number = 1000) {
  const onTickEvent = useEffectEvent(onTick);
  const getTimeout = useEffectEvent(() => timeout);

  useEffect(() => {
    let ticks = 0;
    let mounted = true;
    function onTick() {
      if (mounted) {
        onTickEvent(++ticks);
        setTimeout(onTick, getTimeout());
      }
    }
    setTimeout(onTick, getTimeout());
    return () => {
      mounted = false;
    };
  }, []);
}

이번에 제가 취한 접근 방식은 조금 다릅니다. setInterval 대신 setTimeout을 사용하고 각 반복에서 timeout을 조정합니다. 이것을 더 줄일 수 있다면 알려주세요.

그동안, 이 이상한 이름을 가진 새로운 훅이 작은 개선처럼 보이지만 실제로는 리액트에 있어 큰 승리라는 것을 알 수 있기를 바랍니다. 리액트 팀은 useEffect 코드가 폭주하는 문제가 있음을 인정했습니다. 그들은 문제를 명확하게 식별했습니다. 바로 상태에 연결된 useEffect들입니다. 그리고 useEffect를 상태에서 분리하는 우아한 솔루션을 내놓았습니다.

결론: 더 안전해진 리액트

리액트 팀이 우리의 리액트 앱에서 많은 문제를 일으키는 이슈들을 해결하고 있는 것을 보니 기쁩니다. 컴파일러와 같은 새로운 도구와 useEffectEvent 같은 개선 사항으로 우리는 훨씬 더 신뢰할 수 있고 탄력적인 리액트 코드를 작성할 수 있습니다. 리액트는 여전히 건재하며, 각 릴리스마다 더 나아지고 있습니다!

useEffectEvent를 사용하여 useEffect 훅이 얼마나 더 깔끔하고 안전해질 수 있는지 직접 확인하려면 리액트 19.2를 꼭 체험해 보세요.

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

profile
FE 개발을 하고 있어요🌱

1개의 댓글

comment-user-thumbnail
어제

좋은 글 감사합니다. 디펜던시를 줄이는게 너무나 필요했는데 useEffectEvent 가 큰 변화가 되겠네요. 하지만 여전히 useEffect 는 사람실수를 유발하기 좋은 녀석이네요. 최소한으로, 조심히 써야한다는 점은 그대로일것 같아요. ㅎㅎ

답글 달기