React에서 setInterval 사용하기

김민석·2021년 4월 10일
1

react

목록 보기
7/11

아래의 내용은 정리가 뒤죽박줄 할 수 있다.
쓰면서 계속 새로운 사실을 알게 되었기 때문에 그때그때 수정했기 때문이다.

내가 결국 마지막에 이해가 된 것은 overReact에서였다.
따라서 링크를 첨부하여 아래 내용이 이해가 되지 않으면 다시 볼 수 있도록 하였다.
Making setInterval Declarative with React Hooks
A Complete Guide to useEffect

아 참고로 overReact를 모두 읽어보는 것이 매우 좋겠다.

zerocho의 강의를 듣다가 이상한 것을 발견했다.
(강의는 react 웹게임 강좌의 가위바위보 게임이다.)

아래는 이번 여정의 시작이 된 사진이다.
class 형태로 구현된 코드다.

사진의 코드는 가위/바위/보를 연달아 보여주지 못하고 딱 한 번만 시행이 되었다.

클로저 문제였다.

그 이유는 imgCoord에 담은 변수가 Component에 담길 때 한번 시행된 후, 고정되기 떄문이다.(class 형식에서는 render만 재시행되는 것을 기억하도록.)
따라서 zeroCho는 이후 setInterval 안에서 const {imgCoord} = this.state 할당을 시행하였다.


위 부분을 이해하고 나는 hooks로 넘어갔다.
그런데,,

참고
Class와 hook은 완전 다른 방식으로 작동한다. overreacted
따라서 위에서 살펴본 class 형태에서 발생한 문제와 아래 hooks에서 발생한 문제는 결이 다르다.
처음 작성시에는 같다고 생각했었으나, overreacted에서 관련 자료를 읽어보고 잘 못생각했다는 것을 깨달았다.

(전체 코드는 맨 아래에)


    const changePicture = () => {
      
        if(imgCoord === rspCoords['바위']){
            setImgCoord(rspCoords['가위'])
        } else if(imgCoord === rspCoords['가위']){
            setImgCoord(rspCoords['보'])
        } else {
            setImgCoord(rspCoords['바위'])
        }
        console.log('hello')
        console.log(imgCoord)
    }

    useEffect(()=>{
        interval.current = setInterval(changePicture, 1000)
        return () => {
            clearInterval(interval.current)
        }
    },[])

처음에는 이렇게 짜면 정상적으로 시행이 될 것으로 보였다.
허나 그렇지가 않았다.

console.log('hello')
console.log(imgCoord)

위 두 코드는 주기적으로 작동하지만, 화면의 사진은 딱 1번 주먹에서 가위로 변하고 끝난다.

이 문제를 해결하기 위해 찾아보다가 이 링크까지 가게 된 것이다.
(이 게시글을 보면, 내가 짠 코드가 왜 정상적으로 작동하지 않는지 알 수 있으며, 더 나아가 setIntverval을 React 내에서 가장 잘 활용할 수 있는 방법을 알려준다.)

이 게시글을 읽으면서 아래와 같은 사실들을 알 수 있었다.

1. React Hooks는 class 방식과는 다르게 전체를 re-render 하는 방식을 따른다.

이와 같은 특성으로 버그가 발생할 수 있다.

아래와 같은 코드는 작동하기는 한다.

import React, { useState, useEffect, useRef } from "react";
import ReactDOM from "react-dom";

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

  useEffect(() => {
    let id = setInterval(() => {
      setCount(count + 1);
    }, 1000);
    return () => clearInterval(id);
  });

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

const rootElement = document.getElementById("root");
ReactDOM.render(<Counter />, rootElement);

아래와 같은 코드는 화면에 표시되는 count가 0으로 멈춰있게 된다.
1초당 +1을 시행하도록 코드를 위에서 짜 놓았는데,
컴포넌트 자체의 rendering을 0.1초마다 시키기 때문에 그렇다.

import React, { useState, useEffect, useRef } from "react";
import ReactDOM from "react-dom";

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

  useEffect(() => {
    let id = setInterval(() => {
      setCount(count + 1);
    }, 1000);
    return () => clearInterval(id);
  });

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

const rootElement = document.getElementById("root");

// Second interval to demonstrate the issue.
// Fast updates from it cause the Counter's
// interval to constantly reset and never fire.
setInterval(() => {
  ReactDOM.render(<Counter />, rootElement);
}, 100);

2. 클로저 클로저 클로저!!!

내 실수가 해당하는 부분이다.
다시 한번 내 코드를 가지고 와보면,


    const changePicture = () => {
        if(imgCoord === rspCoords['바위']){
            setImgCoord(rspCoords['가위'])
        } else if(imgCoord === rspCoords['가위']){
            setImgCoord(rspCoords['보'])
        } else {
            setImgCoord(rspCoords['바위'])
        }
        console.log('hello')
        console.log(imgCoord)
    }

    useEffect(()=>{
        interval.current = setInterval(changePicture, 1000)
        return () => {
            clearInterval(interval.current)
        }
    },[])

useEffect의 두 번째 인자에 []를 부여하면 componentDidMount와 같은 역할을 한다는 점에 착안하여 짠 코드다.

그러나 이는

클로저에 대한 이해와 useEffect가 어떻게 동작하는 지에 대한 이해가 부족했기 때문에 발생한 문제다.

useEffect는 imgCoord를 첫 번째 render 과정에서 내부에 저장한 것이다.

이 부분에 대해서 짐작하건데 useEffect가 내부에
imgCoord를 함수 내부에서 만든 변수에 자동으로 할당하는 것 같다.
따라서 setInterval에 의해 클로저가 형성 되었을 때, setInterval의 callback이 참조하는 imgCoord의 값은 상위 스코프에서 선언된 imgCoord가 되고, 이는 바뀐 컴포넌트 내의 imgCoord가 아니라 useEffect 함수 내에서 선언된 imgCoord가 되는 것으로 보인다.

수정!🔥🔥🔥🔥🔥
overreacted에서 관련 부분을 찾았다!!!!!
각각의 effect는 해당 effect가 실행될 때의 props와 state 값을 활용한다고 한다. (말그대로 capture 한다.)
즉, react에서 매 컴포넌트 전체(함수)가 재실행되며, 이전 effect는 이전 함수 내의 클로저이기 때문에 해당 effect가 참조하는 것은 이전 함수의 props와 state라는 것이다.

// 처음 랜더링 시
function Counter() {
  const count = 0; // useState() 로부터 리턴
  // ...
  function handleAlertClick() {
    setTimeout(() => {
      alert('You clicked on: ' + count);
    }, 3000);
  }
  // ...
}

// 클릭하면 함수가 다시 호출된다
function Counter() {
  const count = 1; // useState() 로부터 리턴
  // ...
  function handleAlertClick() {
    setTimeout(() => {
      alert('You clicked on: ' + count);
    }, 3000);
  }
  // ...
}

// 또 한번 클릭하면, 다시 함수가 호출된다
function Counter() {
  const count = 2; // useState() 로부터 리턴
  // ...
  function handleAlertClick() {
    setTimeout(() => {
      alert('You clicked on: ' + count);
    }, 3000);
  }
  // ...
}

따라서 setInverval이 시행되는 순간의 lexicalScope는 최초의 component였다.

console.log(imgCoord)를
1) changePicture 내부
2) changePicture 외부에
3) useEffect 내부에
각각 하나씩 넣어서 값을 비교한다면, useEffect 내부의 console.log는 단 한번 시행되며, changePicture 내/외부의 imgCoord은 서로 값이 다르다는 것을 알 수 있다.

이 글을 작성하다가 간략하게 작성한 클로저(미완)
여기 작성되어 있는 코드를 보면 외부에서 선언된 변수를 update하지 못하는 것을 볼 수 있다.

stale closure 관련 글

실험 등을 통해 나온 나의 결론은 다음과 같다.

function outer(){
    let cnt = 0 
    let cnt2 = cnt + 1
    function inner1(){
        cnt ++
        console.log(cnt)
    }
    function inner2(){
        console.log(cnt2)
    }
    return [inner1, inner2]
}
const [inner1, inner2] = outer()

inner1() // 1
inner2() // 2
inner3() // 3

inner2() // 1 
  1. 전역 환경에서 outer() 실행
  2. outer 함수의 환경에서 차례대로 cnt, cnt2에 값 할당
  3. 전역 환경에서 inner1() 실행
  4. inner1의 환경에서 outer의 환경에 있는 cnt를 참조하여 실행 * 3
  5. inner2의 내부 환경에서 cnt2가 log에 찍히는데, 이때 cnt2에는 1이 할당된 상태로 고정이 됨.(cnt2에 cnt1이 변해도 값이 재할당 되지 않는다. 어찌보면 당연하다.)

이 코드에서 useState의 두 번째 인자를 건드리지 않고 강제로 update 시키고 싶다면,

1) "updater" form, 즉 setCount(prevState => prevState + 1)식을 사용하거나 (prop으로 들어온 인자에 대해서는 적용할 수 없음)

왜 이렇게 작동하는지 정확하게 알지는 못하겠다.
더 깊게 들어가면 지칠 것 같으므로 이 부분은 여기서 멈추려고 한다.

하지만 한 가지 의심해볼만한 사항은 updater form으로 작성시 react 내부에 강제적으로 component를 re-render 시키는 로직이 있지 않을까 하는 생각이 든다.

updater form은 항상 state가 직전 상태임을 보장을 한다고 하는데, 그것이 강제 re-render로 구현되었을 것 같기 때문이다.

2) redux에서 많이 본듯한 useReducer()와 dispatch 구문을 이용.

하면 된다.


이 코드가 react에서 setInterval을 사용하는 가장 적절한 방식이다.

setInterval이 React와 같이 사용될 때 느껴지는 이질감의 원인은 setInterval의 내부 값들을 조작할 수 없다는데에 있다.
따라서 setInterval안의 값들을 바꿔주고 싶으면,

1) setInterval를 clear 한 후,
2) 새로운 setInterval를 만들어서

바꿔주어야 한다.

이 방식을 해결해주는 것이 바로 useRef다.

useRef는 컴포넌트의 생애주기 전체에서 값을 공유한다. 따라서 개별의 effect는 그 effect가 선언된 해당 render 안의 props와 state만을 사용하는데 반해, ref 객체는 이전 render에서의 effect와 다음 render에서의 effect가 같은 값을 공유한다.

이와 같은 속성이 따라서 hooks에서 클로저 문제를 해결할 수 있다. (정확하게 말하면 effect가 props나 state를 'capture'하면서 발생하는 문제)

아래 코드를 보면 callback 함수는 render가 발생할 때마다 바뀐 count 값을 참조한다.
그리고 이 callback 함수를 Ref 객체savedCallbackcurrent 안에 담겨서 이전 render에서 실행된 interval에게 전달해주고 있다.
따라서 interval이 클로저 문제에서 벗어나 계속 새로운 count를 갖고 시행될 수 있다.

function Counter() {
  const [count, setCount] = useState(0);
  const savedCallback = useRef();

  function callback() {
    setCount(count + 1);
  }

  useEffect(() => {
    savedCallback.current = callback;
  });

  useEffect(() => {
    function tick() {
      savedCallback.current();
    }

    let id = setInterval(tick, 1000);
    return () => clearInterval(id);
  }, []);

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

더 나아가 delay도 동적으로 바뀌게 코드를 구성할 수 있다.

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

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

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

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

  useEffect(() => {
    savedCallback.current = callback;
  });

  useEffect(() => {
    function tick() {
      savedCallback.current();
    }

    let id = setInterval(tick, delay);
    return () => clearInterval(id);
  }, [delay]);
}



전체 코드

import React, {useState, useRef, useEffect} from 'react'

const rspCoords = {
    바위: '0',
    가위: '-142px',: '-284px',
};


const scores = {
    가위: 1,
    바위: 0,: -1,
};
  
const RSP = function (props){
    const [result, setResult] = useState('')
    const [imgCoord, setImgCoord] = useState('0')
    const [score, setScore] = useState(0)
    const interval = useRef(null)

    const computerChoice = (imgCoord) => {
        return Object.entries(rspCoords).find(v => v[1] === imgCoord )[0]
    }

    const changePicture = () => {
      
        if(imgCoord === rspCoords['바위']){
            setImgCoord(rspCoords['가위'])
        } else if(imgCoord === rspCoords['가위']){
            setImgCoord(rspCoords['보'])
        } else {
            setImgCoord(rspCoords['바위'])
        }
        console.log('hello')
        console.log(imgCoord)
    }

    useEffect(()=>{
        interval.current = setInterval(changePicture, 1000)
        return () => {
            clearInterval(interval.current)
        }
    },[])

    const onClickBtn = string => {
        clearInterval(interval.current)
        const myScore = scores[string]
        const cpuScore = scores[computerChoice(imgCoord)]
        const diff = myScore - cpuScore
        if(diff === 0 ){
            setScore(score => score)
            setResult('비겼습니다.')
        } else if([1,-2].includes(diff)){
            //컴이 이김
            setScore(score => score - 1)
            setResult('졌습니다.')
        } else {
            setScore(score => score + 1)
            setResult('이겼습니다.')
        }
    }


    return (
        <>
            <div id="computer" style={{ background: `url(https://en.pimg.jp/023/182/267/1/23182267.jpg) ${imgCoord} 0` }} />
            <div>
                <button id="rock" className="btn" onClick={() => onClickBtn('바위')}>바위</button>
                <button id="scissor" className="btn" onClick={() => onClickBtn('가위')}>가위</button>
                <button id="paper" className="btn" onClick={() => onClickBtn('보')}></button>
            </div>
            <div>{result}</div>
            <div>현재 {score}</div>
        </>

    )
}


export default RSP

0개의 댓글