setInterval을 쓰기 위한 여정(쓰레기 코드 주의)

sucream·2023년 2월 6일
1

react

목록 보기
7/9

주의) 본 게시물은 문제가 있는 코드를 실행한 codesandbox를 다수 포함하고 있습니다. 브라우징에 불편을 드릴 수 있습니다. 미리 죄송하다는 말씀을 드립니다.

목표 정의

  1. setInterval로 주기별 작업 해보기
  2. 컴포넌트 외부로 부터 delay를 받아 setInterval에 전달하기
  3. callback이 변경되면 감지하고 교체하기

1. setInterval로 주기별 작업 해보기

우선 생각나는 대로 컴포넌트에 setInterval을 적용해 보자.

import { useState } from "react";

export default function App() {
  const [count, setCount] = useState(0);

  // 1초에 한번씩 count를 1씩 증가시키기
  setInterval(() => {
    setCount(count + 1);
  }, 1000);

  return (
    <div className="App">
      <h1>{count}</h1>
    </div>
  );
}

결과는 처참하다. 왜그럴까?

useState re-rendering 이해하기

우리는 useState를 사용하면 해당 컴포넌트가 다시 렌더링된다는 사실을 알고 있다. 위 코드에서 다음 부분을 주의깊게 보자.

const [count, setCount] = useState(0);

// 1초에 한번씩 count를 1씩 증가시키기
setInterval(() => {
  setCount(count + 1);
}, 1000);

위 코드는 useState를 이용해 0으로 초기화된 count 변수를 생성하고 1초에 한번씩 setCount(count + 1)을 호출하고 있다. 그렇다. 우리는 setState를 호출하여 1초에 한번씩 컴포넌트가 재렌더링 되도록 하고 있던 것이다.

setInterval() 함수는 clearInterval()을 호출하여 종료하지 않으면 없어지지 않는다.

위 규칙이 우리를 힘들게 하는 주범이다. 즉, 컴포넌트는 매 초마다(실제로는 조금씩 차이가 날 수도 있다.) 새로운 setInterval 함수를 실행하고 있었다. 그래서 화면에 숫자들이 들쭉날쭉해지고 있었다. 이러한 setInterval의 처리는 side-effect로 봐야 할 것이다. 우리는 side-effect를 처리하기 위해 무엇이 필요한지 알고 있다.

useEffect 사용하기

컴포넌트에서 useEffect를 사용하면 side-effect를 처리할 수 있다. 아래 코드를 통해 살펴보자.

import { useState, useEffect } from "react";

export default function App() {
  const [count, setCount] = useState(0);

  // 1초에 한번씩 count를 1씩 증가시키기
  useEffect(() => {
    const id = setInterval(() => {
      console.log(count);
      setCount(count + 1);
    }, 1000);

    console.log(`TimerId: ${id}`);  // useEffect가 실행될 때마다 setInterval의 값을 출력

    return () => clearInterval(id);
  });

  return (
    <div className="App">
      <h1>{count}</h1>
    </div>
  );
}


결과를 확인해 보면 우리가 의도한 대로 잘 나오는 것 같다. useEffect의 cleanup 함수를 통해 매 렌더링마다 clearInterval()을 호출하여 기존의 setInterval로 지정된 반복하는 함수를 제거할 수있게 되었다. 하지만 이는 매번 새로운 setInterval 함수를 생성해 내기 때문에 좋은 방법이 아닌 것 같다. 실제로 매 useEffect마다 TimerId를 출력하면 새로운 값이 출력되는 것을 볼 수 있다. 그럼 어떻게 해...?

useEffect에 의존성 배열 사용하기

useEffect에는 두번째 인자로 의존성 배열을 등록할 수 있다. 이를 통해 특정 상황에만 useEffect가 실행되도록 제한할 수 있다. [](빈 배열)을 전달하여 컴포넌트가 처음 렌더링될 때만 useEffect를 실행하도록 해보자.

import { useState, useEffect } from "react";

export default function App() {
  const [count, setCount] = useState(0);

  // 1초에 한번씩 count를 1씩 증가시키기
  useEffect(() => {
    const id = setInterval(() => {
      console.log(count);
      setCount(count + 1);
    }, 1000);

    console.log(`TimerId: ${id}`);

    return () => clearInterval(id);
  }, []);  // 빈 의존성 배열을 추가하여 최초에만 useEffect가 실행되도록 함

  return (
    <div className="App">
      <h1>{count}</h1>
    </div>
  );
}

결과를 확인해 보면 이번에는 1만 나오고 멈춘 것처럼 보인다. 아니다. 우리의 useEffect는 첫 렌더링에 한 번 실행되어 setInterval을 정상적으로 실행했고, count의 값이 계속 출력되고 있다. 여기서 우리는 문제를 마주하게 된다. 분명 setCount(count + 1)을 통해 count의 값을 변경시켰는데, 왜 값이 증가하지 않는가? 이상하지 않은가? 이는 setInterval 내부에 작성된 함수가 클로저로 동작하고, setCount에 의해 컴포넌트가 재렌더링되면서 발생하는 이슈다.

Closure

클로저에 대해 알고 있는가? 클로저란, 함수에서 또다른 함수를 작성할 수 있고, 내부에 있는 함수는 외부변수를 기억하고 해당 변수에 접근할 수 있는 함수를 의미한다. 즉, 우리가 사용하는 setInterval을 기준으로 보면 내부의 callback 함수는 closure이다. 그렇다면 어떤 문제가 발생하는가?

함수에서 참조값을 찾는 여정과 클로저 사용시 주의점

setInterval 내부에 작성한 함수가 클로저라고 했는데, 여기서 핵심은 클로저가 외부 변수를 참조하는 방법이다.
setInterval 함수를 다시 자세히 보자

setInterval(() => {
  console.log(count);  //count라는 변수는 어디서 오는가?
  setCount(count + 1);  //count라는 변수는 어디서 오는가?
}, 1000);

setInterval 내에서도, 익명함수 내에서도 count라는 변수는 존재하지 않는다. count라는 변수는 더 위로 올라가 App이라는 함수의 const [count, setCount] = useState(0); 부분에 존재한다. 따라서 우리가 작성한 익명함수는 클로저가 외부 변수를 참조하는 방식에 의해 count 변수를 참조하고 사용하게 된다.

잘 생각해 보자. 우리의 App이라는 것도 결국은 하나의 함수다. 즉, 누군가 App이라는 함수를 호출할 것이고, return을 하면 함수가 종료된다. 여기서 클로저의 진가가 발휘된다. 처음 클로저가 생성될 때, 내가 참조하는 변수값을 Lexical Environment라고 하는 곳에 저장하고, 이를 사용하게 된다. 그래서 외부 함수가 콜스택에서 벗어나 없어져도 내부에 있는 클로저가 외부 변수에 접근이 가능한 것처럼 보이는 효과를 내게 된다. 우리 App은 사라져도, setCount는 count라는 변수값에 접근이 가능한 상태가 된다. 하지만, count는 useState에 의해 0으로 시작하고, setCount에 의해 수백번의 count + 1이 호출될 수 있지만, count의 값은 언제나 0일 것이다. 그래서 계속해서 0만 출력하고 있던 것이다.

정리하면, setCount에서 참조하는 count는 App에서 만들어진 값을 별도의 영역에서 참조하지만, count + 1이 변수의 값을 변경하지 못하기 때문에 결국 setCount(0 + 1)을 계속 호출하는 것과 같은 효과를 낸다.

useState에 callback을 넘기자

setState에 값을 넘기면 batch로 실행되는 문제와 동일하게 접근하면 된다. 우리가 useState에 callback을 넘겨서 적절한 시간에 처리되도록 하면 된다. 따라서 아래와 같이 변경하면 정상적으로 작동하는 모습을 확인할 수 있다.

import { useState, useEffect } from "react";

export default function App() {
  const [count, setCount] = useState(0);

  // 1초에 한번씩 count를 1씩 증가시키기
  useEffect(() => {
    const id = setInterval(() => {
      console.log(count);
      // callback을 전달하여 cnt에 최신 상태값이 전달되도록 함
      setCount((cnt) => {
        return cnt + 1;
      });
    }, 1000);

    console.log(`TimerId: ${id}`);

    return () => clearInterval(id);
  }, []);

  return (
    <div className="App">
      <h1>{count}</h1>
    </div>
  );
}

callback을 전달하여 값이 정상적으로 증가하는 모습을 확인할 수 있다. 이제는 여기서console.log(count) 부분에 계속 0이 찍히는 이유가 이해되는가?
우리의 count state는 App 컴포넌트 외부에서 react에 의해 관리되고, setInterval 내부의 count 변수는 자신의 Lexical Environment에 존재하기 때문이다.

useReducer를 사용하자

useState를 사용할 수 있는 곳에는 모두 useReducer를 사용할 수 있다. 다만 useReducer를 사용하면 side effect를 emit할 수 없다는 문제가 있다고 한다. 구체적으로 어떤 문제인지 아직 이해하지 못했다...

import { useReducer, useEffect } from "react";

// reducer 함수는 항상 최신의 state값을 받음
const reducer = (count, action) => {
  switch (action.type) {
    case "ADD_COUNT":
      return count + 1;
    default:
      throw new Error("Unsupported action type:", action.type);
  }
};

export default function App() {
  const [count, countDispatch] = useReducer(reducer, 0);

  // 1초에 한번씩 count를 1씩 증가시키기
  useEffect(() => {
    const id = setInterval(() => {
      console.log(count);
      // dispatch를 호출
      countDispatch({ type: "ADD_COUNT" });
    }, 1000);

    console.log(`TimerId: ${id}`);

    return () => clearInterval(id);
  }, []);

  return (
    <div className="App">
      <h1>{count}</h1>
    </div>
  );
}

2. 컴포넌트 외부로 부터 delay를 받아 setInterval에 전달하기

기존에 만든 setInterval은 새로운 state나 prop에 대해 반응할 수 없다. 우선 아래 코드를 통해 확인해 보자.

import { useState, useEffect } from "react";

// delay를 받아 setInterval을 적용하는 자식 컴포넌트
const Child = ({ delay }) => {
  const [count, setCounter] = useState(0);

  // 1초에 한번씩 count를 1씩 증가시키기
  useEffect(() => {
    const id = setInterval(() => {
      // dispatch를 호출
      setCounter((cnt) => cnt + 1);
    }, delay);

    return () => clearInterval(id);
  }, []);

  return (
    <div>
      <h1>Count: {count}</h1>
      <h3>Parent's delay: {delay}</h3>
    </div>
  );
};

export default function App() {
  const [delayValue, setDelayValue] = useState(1000);

  // delay를 변경하는 핸들러
  const delayHandler = (e) => {
    setDelayValue(e.target.value);
  };

  return (
    <div className="App">
      <input
        value={delayValue}
        onChange={delayHandler}
        type="range"
        min="100"
        max="5000"
      />
      <h1>Delay: {delayValue}</h1>
      <Child delay={delayValue} />
    </div>
  );
}

위 코드에는 delay prop을 받아 setInterval을 설정하는 Child 컴포넌트와 delay를 조절하는 기능이 있는 App 컴포넌트가 있다. 하지만 아무리 delay를 조절해도 count의 delay가 변경되지 않는 것 같다. 왜그럴까?
그 이유는 우리가 useEffect를 사용할 때 의존성 배열을 빈 배열로 넘겼고, 이로 인해 useEffect가 다시 실행될 순간을 보장하지 못했기 때문이다.

이번에는 유연하게 동작하는 setInterval을 만들어 보자.

import { useState, useEffect } from "react";

// delay를 받아 setInterval을 적용하는 자식 컴포넌트
const Child = ({ delay }) => {
  const [count, setCounter] = useState(0);

  // 1초에 한번씩 count를 1씩 증가시키기
  useEffect(() => {
    const id = setInterval(() => {
      // dispatch를 호출
      setCounter((cnt) => cnt + 1);
    }, delay);

    return () => clearInterval(id);
  }, [delay]); // 의존성 배열에 delay를 넣어서 delay 변경시 useEffect 실행되게 함

  return (
    <div>
      <h1>Count: {count}</h1>
      <h3>Parent's delay: {delay}</h3>
    </div>
  );
};

export default function App() {
  const [delayValue, setDelayValue] = useState(1000);

  // delay를 변경하는 핸들러
  const delayHandler = (e) => {
    setDelayValue(e.target.value);
  };

  return (
    <div className="App">
      <input
        value={delayValue}
        onChange={delayHandler}
        type="range"
        min="100"
        max="5000"
      />
      <h1>Delay: {delayValue}</h1>
      <Child delay={delayValue} />
    </div>
  );
}

useEffect의 의존성 배열에 [delay]를 추가하여 delay가 변경될 때마다 useEffect가 다시 실행되도록 했다. 이로 인해 정상적으로 기능이 동작하는 것을 확인할 수 있다.

3. callback이 변경되면 감지하고 교체하기

앞에서 의존성 배열에 delay를 추가하여 Delay가 변경되면 setInterval을 다시 설정하도록 했다. 만약 delay는 그대로인데, callback의 변경이 있을때, callback만 교체하고 싶다면 어떻게 해야 할까?
간단하게 생각하면 useEffect의 의존성 배열을 [callback, delay]로 수정하면 될 것 같다. 코드로 확인해 보자.

import { useState, useEffect } from "react";

// callback과 delay를 받아 setInterval을 적용하는 자식 컴포넌트
const Child = ({ callback, delay }) => {
  useEffect(() => {
    const id = setInterval(callback, delay);
    console.log(id);

    return () => clearInterval(id);
  }, [callback, delay]); // 의존성 배열에 callback과 delay를 넣어서 변경시 useEffect 실행되게 함

  return (
    <div>
      <h3>Parent's delay: {delay}</h3>
    </div>
  );
};

export default function App() {
  const [delayValue, setDelayValue] = useState(1000);
  const [flag, setFlag] = useState(true);

  // delay를 변경하는 핸들러
  const delayAddHandler = (e) => {
    console.log("Delay Add");
  };

  const delaySubHandler = (e) => {
    console.log("Delay Sub");
  };

  return (
    <div className="App">
      <button onClick={() => setFlag((f) => !f)}>Change Callback</button>
      <h1>Delay: {delayValue}</h1>
      <h2>{flag ? "ADD" : "SUB"}</h2>
      <Child
        callback={flag ? delayAddHandler : delaySubHandler}
        delay={delayValue}
      />
    </div>
  );
}

위 코드는 버튼을 클릭하면 Child 컴포넌트에 전달하는 핸들러를 변경한다. 이로 인해 Child 컴포넌트 내에 있는 useEffect가 다시 실행되어 새로운 setInterval 함수가 생성된다. 그냥 보기엔 괜찮아 보인다. 만약 버튼을 연속해서 누르면 어떤 일이 발생할까?
핸들러가 바뀌기 때문에, clearnup 함수인 clearInterval이 실행되고, 계속해서 새로운 setInterval이 생성될 것이다. 즉 callback이 실행될 기회를 얻지 못할 것이다. setInterval을 다시 설정하지 않고 callback을 변경할 수는 없을까?

useRef 대령이요

시간을 재설정 하지 않고 callback을 대체하기 위해 useRef를 사용할 수 있다. 우선 callback을 가리키는 특수한 변수를 만들고, 이 변수가 가리키는 callback을 변경하도록 하면 해결이 가능하다.
useRef로 초기화된 객체는 컴포넌트가 사라지기 전까지 유지되며, 값이 변경되어도 재렌더링이 발생하지 않는다.

Callback을 기억하는 ref를 생성하여 interval 내부에서 최신의 callback을 호출할 수 있도록 함

import { useState, useEffect, useRef } from "react";

// callback과 delay를 받아 setInterval을 적용하는 자식 컴포넌트
const Child = ({ callback, delay }) => {
  // 최신 callback을 가리키는 ref
  // 값이 변경되어도 재렌더링이 발생하지 않음
  const savedCallback = useRef();

  // callback의 변화가 있을 때마다, 가리키는 callback을 변경
  // delay가 변경되지 않아도 callback을 변경할 수 있음
  // 즉, setInterval이 재설정되지 않음
  useEffect(() => {
    savedCallback.current = callback;
  }, [callback]);

  // delay의 변경이 발생하면
  useEffect(() => {
    const tick = () => {
      savedCallback.current();
    };
    const id = setInterval(tick, delay);
    console.log(id);

    return () => clearInterval(id);
  }, [delay]); // 의존성 배열에 callback과 delay를 넣어서 변경시 useEffect 실행되게 함

  return (
    <div>
      <h3>Parent's delay: {delay}</h3>
    </div>
  );
};

export default function App() {
  const [delayValue, setDelayValue] = useState(1000);
  const [flag, setFlag] = useState(true);

  const delayAddHandler = (e) => {
    console.log("Delay Add");
  };

  const delaySubHandler = (e) => {
    console.log("Delay Sub");
  };

  const ChangeDelayHandler = (e) => {
    setDelayValue((d) => e.target.value);
  };

  return (
    <div className="App">
      <input
        value={delayValue}
        onChange={ChangeDelayHandler}
        type="range"
        min="100"
        max="5000"
      />
      <button onClick={() => setFlag((f) => !f)}>Change Callback</button>
      <h1>Delay: {delayValue}</h1>
      <h2>{flag ? "ADD" : "SUB"}</h2>
      <Child
        callback={flag ? delayAddHandler : delaySubHandler}
        delay={delayValue}
      />
    </div>
  );
}

Custom Hook으로 빼기

잘 보면 setInterval을 위와 같은 형태로 사용할 일은 비일비재할 것이다. 따라서 해당 기능을 하는 hook을 따로 분리하여 관리해 보자. 이미 useInterval이라는 훅으로 유명하니 아래에 코드를 올리도록 하겠다.

// Custom hook
import { useEffect, useRef } from "react";

function useInterval(callback, delay) {
  const savedCallback = useRef();

  // 새로운 callback 반영
  useEffect(() => {
    savedCallback.current = callback;
  }, [callback]);

  // delay 변경시 새로운 interval 지정
  useEffect(() => {
    function tick() {
      savedCallback.current();
    }
    if (delay !== null) {
      let id = setInterval(tick, delay);
      return () => clearInterval(id);
    }
  }, [delay]);
}

export default useInterval;
// custom hook을 사용하는 컴포넌트 예시
import { useState } from "react";
import useInterval from "./hooks";

function App() {
  let [count, setCount] = useState(0);

  useInterval(() => {
    setCount(count + 1);
  }, 1000);

  return <h1>{count}</h1>;
}

export default App;

Reference

profile
작은 오븐의 작은 빵

0개의 댓글