useEffect는 비동기적이지만, 동기적이다(?)

devAnderson·2022년 5월 1일
5

TIL

목록 보기
92/103

🚍 0. 작성이유

일전 "useEffect 디펜던시 무시하지 마라" 라는 주제로 글을 길게 썼던 게 있었는데, 그때의 내용은 대략 요약하자면 이런거였다

useEffect는 비동기적으로 작업하기 때문에, 서로간에 어떤 요청이 먼저 들어올지 모르므로 만약 useEffect끼리 서로간의 작업이 상태를 업데이트시켜서 긴밀하게 영향을 받는다면 디펜던시로 꼭 적어줘서 업데이트를 반영해야 의도한 리랜더링을 볼 수 있다

저 내용을 길게 쓸 수밖에 없었던 그 당시의 상황이, useEffect로 여러가지 상태가 동시에 업데이트 되는 상황에서 의도하는 대로 list가 업데이트가 되지 않아 UI에 아무것도 나오지 않았던 것이었다.

그래서 부랴부랴 디펜던시들을 집어넣고 나니 에러가 사라져서 그것이 모두 디펜던시의 이유라고 생각이 되었고 이를 재빨리 블로그에 작성했던 것이다.

물론, 그때의 글은 여러가지 의미로 테스트상 변수와 오해점이 많았어서 곧바로 삭제해버렸었다

그래서 여기저기 주말동안 공부하고 테스트를 진행하면서 머릿속에 정리한 결과를 요약해서 남기고자 한다

🚍 React의 Strict mode?

우선 위에 썼던 블로그의 테스트 환경에서 변수점이었던 strict mode를 써보려고 한다

CRA으로 리엑트 프로젝트를 만든 경험이 있다면, 이런 index.js 파일이 익숙할 것이다

import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import App from './App';
import reportWebVitals from './reportWebVitals';

ReactDOM.render(
  <React.StrictMode> 
    <App />
  </React.StrictMode>,
  document.getElementById('root')
);

그런데 저 App 컴포넌트 위를 감싸고 있는 래퍼 컴포넌트 "React.StrictMode" 라는 것에는 상당히 의미를 부여하지 않고 넘어가는 경우가 많다.

useEffect에 데이기 전까지 필자도 그러하였다

우선 공식문서에 따른 Strict mode의 설명은 아래와 같다

해당 컴포넌트는 따로 UI를 만들지는 않고, 자손들에 발생할 수 있는 잠재적인 에러를 미리 파악하는 용도로 사용된다고 한다.
개발 모드에서만 작동되기 때문에 배포 후에는 작동되지 않는다.

다만, 이 컴포넌트는 그 "잠재적인 에러" 를 잡아내기 위해서 랜더링을 한번 더 하는 행동을 하는데,
이것이 바로 리엑트에서 느닷없이 콘솔이 두번 찍히는 것에 대한 원인인 것이다.

react component render twice 라고 검색해보면 왜 콘솔이 두번이나 찍히는지에 대한 의문점들을 문의한분들이 많은 것을 알 수 있다.

따라서, 만약 아래와 같은 코드를 작성할 경우,

 const [value1, setValue1] = useState(1);
 const [value2, setValue2] = useState(1);

  console.log(value1, value2); // Strict mode 컴포넌트로 래핑되었을 경우, "1, 1" "3, 1" 로 찍히게 된다.

  useEffect(() => {
    setValue1(prev => prev + 1);
  }, []);

만약 value1이 2일것이라고 짐작했다면 안타깝게도 아니다. 정확히 말하면 개발환경에서 아니다
Strict mode로 인해서 컴포넌트가 검증용으로 한번 더 랜더링되므로, value1는 결과적으로 2가 더해진 3이 된다.

여튼, 이런 헛점이 있었던 것을 예상하지 못했다는 점에서 테스트의 변수라고 할 수 있었다.

만약 Strict mode의 이런 괴이한 행동을 미리 알지 못하고 코드를 짜면 얼마든지 개발 환경에서 필자와 같은 오해를 하면서 잘못된 고민에 빠질 가능성이 높아지므로 알아두면 좋을 것 같다.

🚍 Main dish, useEffect는 비동기적인가?

두번째 변수는, 아니 이건 변수라기보다는 필자의 착각에 기인한 것인데 생각보다 정말로 큰 오해를 하고 있었기 때문에 바로잡아야 함을 강하게 느끼고 절대 다시는 실수하지 않도록 정리하자는 의미로 쓴다.

우선 예시 코드를 작성해보면

function App() {
  useEffect(() => {
    console.log('hi!');
  }, []);

  return (
    <div className="App">
      <header className="App-header"></header>
    </div>
  );
}

컴포넌트는 결국 함수이고, 함수는 호출되는 것이다.

그러므로 위 내용을 함수 있는 그대로 받아들인다면,
무언가 useEffect라고 하는 함수를 먼저 호출하고, JSX로 써져있는 element를 리턴한다. 라는 심플한 이야기가 된다.

그런데 사실은 useEffect에 삽입되는 저 콜백 함수는 특이하게도 element가 리턴이 된 이후에 실행이 된다.

그 이유는 리엑트의 생명주기 함수들이 virtual dom을 생성하는 rendering phase가 아니라 해당 virtual dom을 기존의 메모리에 저장된 old virtual dom과 diffing algorithm을 이용하여 비교해서 실제 DOM을 효과적으로 CRUD하는 commit phase 에서 실행되기 때문이다.

따라서 이른바 "useEffect는 비동기적(asynchronous)으로 콜백함수를 호출합니다" 라는 의미로 영어로 작성된 문들이 종종 보이는데, 그래서 이것을 착각해서 useEffect의 콜백함수가 진짜로 실행 컨텍스트 작업이 완료되는 순대로 task queue에 들어가서 이벤트 루프가 비워질 때마다 콜스텍으로 들어가 처리가 됩니다...라고 착각하면 절대 안된다는 것이다.

해당 내용을 증명하기 위해서 간단하게 express 서버를 제작해보았다.

참고로, 리엑트에서는 useEffect 내부에서 상태 업데이트하는 로직을 작성하는 것을 권장하지는 않는다. 이는 예기치 못한 랜더링 과정을 만들어내기 때문이다. 아래는 그냥 예시로서 받아들어주시면 좋을 것 같다.

const express = require('express');
const cors = require('cors');

const app = express();
app.use(
  cors({
    origin: ['http://localhost:3000'],
  }),
);

app.get('/test1', async (req, res) => {
  await new Promise((resolve, reject) => {
    setTimeout(() => {
      resolve('wait for 1s');
    }, 1000);
  });

  res.status(200).json({ message: 'test1 ok' });
});

app.get('/test2', async (req, res) => {
  await new Promise((resolve, reject) => {
    setTimeout(() => {
      resolve('wait for 2s');
    }, 2000);
  });

  res.status(200).json({ message: 'test2 ok' });
});

app.listen(3001, () => console.log('hello wordl'));

심플하게, test1 엔드포인트로 get 요청이 들어오면 1초 뒤에 응답을, test2 엔드포인트로 get 요청이 들어오면 2초 뒤에 응답을 보내는 구조로 되어있다.

클라이언트 사이드에서는 이런 형태로 useEffect를 통해 get 요청을 보내고 있다.

  const [list, setList] = useState([]);
  const [result, setResult] = useState([]);
  console.log(list, result);

  useEffect(() => {
    console.log('called from test2 useEffect');
    axios.get('http://localhost:3001/test2').then(result => {
      console.log(result);
      setList(['1', '2', '3']);
    });
  }, []);

  useEffect(() => {
    console.log('called from test1 useEffect');
    axios.get('http://localhost:3001/test1').then(result => {
      console.log(result, 'is called twice');
      setResult(list.map(e => +e));
    });
  }, [list]);

  return <div>test</div>;

useEffect가 단순한 함수이며, 함수는 호출된 순서대로 처리된다는 것을 생각한다면 결과는 예상하기 쉬울 것이다.

두번째 새번째 줄에 보이는 "called from test2 useEffect"와 "called from test1 useEffect" 를 통해 해당 콜백 함수들이 동기적으로 호출되고 있다는 점을 확인할 수 있다.

컴포넌트를 함수 호출이라는 관점에서 위에부터 차례대로 중요한 부분만 보자면

  1. [][] = 첫 컴포넌트 호출 당시 console.log(list, result) 에 해당하는 부분이다.
  2. useEffect들이 호출이 된다. ( 이때 콜백은 리엑트 앱의 렉시컬 환경에 순서대로 등록된다 )
  3. return을 통해 JSX가 반환된다.
  4. useEffect의 콜백이 순서대로, 동기적으로 호출된다.
  5. 콜백 함수 내부의 콘솔은 먼저 호출되어 처리가 완료되고 비동기는 앱 내의 테스크 큐로 전달된다.
  6. 먼저 test1의 응답이 들어왔으므로, 해당 result 콘솔이 찍힌다.
  7. 그 후 test2의 응답이 들어왔으므로, 해당 result 콘솔이 찍히며 상태를 새로 업데이트한다
  8. test1쪽 useEffect의 dependency인 [list]가 이것을 감지했으므로, 다시금 해당 콜백을 호출한다.

즉, 여기서 알 수 있는 중요한 부분은 위에 bold로 처리한 부분과 같다.

useEffect의 콜백 함수는, 코드의 순서상 등록된 순으로 호출이 되고, dependency에 저장된 상태값에 변동이 확인되면 다시 호출된다.

단, 이 때에 주의할 점은 setState와 같은 상태 업데이트 함수의 업데이트 결과는 그 당시 환경에는 적용되지 않은 상태라는 점이다.

  const [value1, setValue1] = useState([]);
  const [value2, setValue2] = useState([]);
  console.log(value1, value2);

  useEffect(() => {
    setValue2(['1', '2', '3']);
  }, []);

  useEffect(() => {
    if (value2.length) {
      setValue1([1, 2, 3]);
    }
  }, []);

  return <div>test</div>;

// 위에서 보이는 첫번째 콜백 함수와 두번째 콜백함수는 둘 다 차례대로 호출이 되는 것은 맞다
// 하지만, setValue2(['1', '2', '3']) 이 되는 순간 상태 업데이트를 감지한 리액트 앱은 새로운 virtual dom을 만들고 있는데, 정작 useEffect의 콜백이 돌고 있던 시점의 virtual dom 스냅샷 입장에서는 value2는 여전히 아무것도 업데이트 되지 않은상태이다.
// 따라서, 두번째 useEffect의 콜백함수에 써져있는 if문은 실행되지 않는다. 만약 dependency 배열에 value1을 넣어두었다면, 이를 감지해서 재 호출된 후 콜백 로직이 새로 평가되어 처리되는 것이다.

이런 의미에서, 정말로 dependency 배열은 심사숙고하면서 그 의존관계를 잘 파악하면서 사용해야 한다.

요약

리엑트로 개발하면서 useEffect를 사용하게 되는 경우, 항상 머릿속에 새겨두면서 사용해야 하는 두가지의 사실을 갖고 가는 것에 의의를 두고 싶다.

  1. 여러 useEffect가 있을 경우, 해당 콜백함수는 동기적으로 순차적으로 호출되며, dependency 배열의 변화에 따라 재호출된다. (리스트중 어느 하나라도 주소값이 달라졌을 경우를 뜻함)

  2. 상태를 업데이트한다는 것은, render가 호출되며 새로운 virtual dom이 만들어진다는 뜻이다. 즉, old virtual dom 스냅샷의 입장에서는 아직 상태는 업데이트가 되지 않은 상태이므로 이에 따라 useEffect의 콜백은 기존 상태를 기반으로 호출된다.

  3. 만약 dependency에 대상이 되는 상태를 넣어둔 상황이라면, 새로운 virtual dom의 입장에서 생각해 봤을 때, 환경은 업데이트가 되었고 useEffect의 dependency에 해당 내용이 존재하므로 콜백함수가 return문 이후에 호출되는 것이다.

reference

React strict mode
useEffect

profile
자라나라 프론트엔드 개발새싹!

1개의 댓글

comment-user-thumbnail
2022년 9월 12일

덕분에 잘 읽고갑니다. 감사합니다!

답글 달기