setState 하고 console.log 했는데 왜 state 가 안 바뀌어 있나요?

우현민·2022년 11월 2일
9

React

목록 보기
5/11
post-thumbnail
post-custom-banner

setStateconsole.log(state) 를 했을 때 업데이트가 안 되어서 당황하는 리액트 초심자분들을 위한 글입니다.

개요

const Component = () => {
  const [data, setData] = useState();
  
  useEffect(() => {
    axios.get('/api/users/me').then((response) => {
      console.log(response.data); // ✅ 내가 원했던 제대로 된 데이터
      setData(response.data);
      console.log(data); // ❌ undefined.. 왜?
    });
  }, [];

앞에서도 말했듯, 이 글은 위 예제에서 console.log(data);가 왜 undefined 를 출력하는지에 대한 글입니다.

특히 리액트 초심자의 경우 Promise 도 익숙하지 않고 react 도 익숙하지 않기 때문에 헷갈릴 여지가 있는 것 같습니다.

결론부터 말하면 정상적인 상황이고, 의도된 동작입니다. console.log 를 렌더 레벨로 한 단계 올리면 data가 정상적으로 반영되어 있습니다.

const Component = () => {
  const [data, setData] = useState();
  
  useEffect(() => {
    axios.get('/api/users/me').then((response) => {
      console.log(response.data); // ✅ 내가 원했던 제대로 된 데이터
      setData(response.data);
    });
  }, [];
  
  console.log(data); // ✅ 내가 원했던 제대로 된 데이터

먼저 "왜 그렇게 되는지"부터 알아보고, 그런 다음 "왜 리액트는 그렇게 만들어졌는지"를 알아보겠습니다.



어떻게 동작하는 건가요?

우선 Promise 가 끼면 머리아프니까, 복잡도를 낮추기 위해 예제를 조금 더 단순화하겠습니다.

아래 컴포넌트는 마운트될 때 count 를 1 증가시킵니다. 실제로 의미가 있는 컴포넌트는 아닐 것 같지만, 오늘의 주제를 다루기에는 가장 적합합니다.

const Component = () => {
  const [count, setCount] = useState(0);
  
  useEffect(() => {
    setCount(1);
    console.log(count); // ❌ 0이네..? 왜?
  }, [];

그동안의 삶의 지혜에 따르면, 이 코드는 아래처럼 읽힙니다. (왠지 당연히 이런 식으로 동작할 것 같습니다.)

const Component = () => {
  let count = 0;
  
  useEffect(() => {
    count = 1;
    console.log(count); // 1을 넣었으니까 당연히 1이어야 하지 않을까?
  }, [];

비유하자면 Mike 가 구슬이 0개 들어있는 구슬주머니를 들고 있다고 했을 때, 여기에 구슬을 1개 넣으면 Mike 가 들고 있는 구슬 주머니에 구슬이 1개가 되는 게 당연해 보입니다.


하지만 리액트 함수컴포넌트와 setState 는 이렇게 동작하지 않습니다. 오히려 아래와 좀더 가깝다고 볼 수 있습니다.

const Component = () => {
  let count = 0;
  
  useEffect(() => {
    count 에 1을 넣어서 날 다시 렌더해라 ();
    console.log(count);
  }, [];

이런 동작은 모든 렌더에 해당됩니다. react 는 모든 리렌더마다 컴포넌트라는 함수를 다시 실행합니다.

그러니 setState를 한 그 렌더에서는 (정상적인 방법으로는) 렌더 이후의 변경된 값에 접근할 수 없습니다.

const Component = () => {
  const [count, setCount] = useState(0);
  
  useEffect(() => {
	setCount(1); // state 를 1로 변경했지만
    console.log(count); // 바로 출력해도 0
    setTimeout(() => console.log(count), 1000); // 1초 이따 출력해도 0
    setTimeout(() => console.log(count), 86400000); // 다음 날에 출력해도 0
  }, [];

  /* setCount(1) 로 인해 다음 리렌더가 발생함.
     이건 위의 console.log 들과는 달리
     count 가 1인 평행우주에 있기 때문에
     1이 정상적으로 출력된다. */
  console.log(count); 

좀더 풀어서 확인해 볼까요?

  // 첫번째 렌더: count 가 0인 우주에 있다.
  useEffect(() => {
	setCount(1); // state 를 1로 변경
    console.log(count); // 0
    setTimeout(() => console.log(count), 1000); // 0
    setTimeout(() => console.log(count), 86400000); // 0
  }, [];
            
  console.log(count); 

  // 두번째 렌더: count 가 1인 우주에 있다.
  useEffect(() => {
    // 두 번째 렌더에서 이 4줄은 실행되지 않습니다.
	setCount(1);
    console.log(count);
    setTimeout(() => console.log(count), 1000);
    setTimeout(() => console.log(count), 86400000);
  }, [];

  console.log(count); // 1

앞의 구슬 예제를 다시 빌려오면, setState 는 Mike 가 (Component) 들고 있는 구슬주머니 (count) 에 구슬을 넣는 대신, Mike 를 거기 남겨둔 채로 저 멀리에 Mike가 구슬이 1개 들어있는 구슬주머니를 들고 있는 평행우주를 만들어 버립니다. 그러니까 당연히 원래의 Mike 가 있던 곳에서는 주머니를 아무리 살펴봐도, 하루가 지나도, 1년이 지나도 주머니에 구슬이 하나도 없습니다.

간혹 위와 같이 설명하지 않고 "비동기라서 바로 업데이트되지 않아요" 라고 설명하는 stackoverflow 답변들을 볼 수 있습니다. setState 가 비동기로 동작하는 건 맞습니다. 하지만 그게 state 가 업데이트된 채로 콘솔에 찍히지 않는 이유는 아닙니다.



대체 왜 그렇게 동작하는 건가요?

쓰다 보니, 이 섹션은 dan abramov 의 과 유사한 흐름을 가지게 되었습니다. react 에 익숙하신 분이라면, 이 섹션 대신 위 글을 읽는 걸 추천드립니다.

또한 JavaScript에 익숙하지 않다면, 아래 글을 읽는 데에 조금 어려움이 있을 수 있습니다.

이 이야기는 큰 틀에서 왜 리액트 컴포넌트가 class 에서 function 으로 넘어왔는가에 대한 이야기로 연결됩니다.

먼저 옛날 얘기를 해 보면, 리액트 함수 컴포넌트는 2019년에 hooks가 등장하며 대중화되었습니다. 그 전까지 99.9%의 리액트 컴포넌트는 class 문법이었습니다. 놀랍게도, 그땐 이렇게 평행우주를 만드는 식으로 동작하지 않았습니다!

위의 예제를 그대로 class 컴포넌트로 옮기면 아래와 같습니다.

class Component extends React.Component {
  state = { count: 0 };
  
  componentDidMount() {
    this.setState({ count: 1 }); // state 를 1로 변경
    console.log(this.state.count); // 바로 출력하면 0
    setTimeout(() => console.log(this.state.count), 1000); // 1초 이따 출력하면 1 😮
    setTimeout(() => console.log(this.state.count), 86400000); // 다음 날에 출력하면 1 💁‍♂️
  }
  
  render() {}
}

잘 보면 위 예제에서도 바로 출력하면 0이긴 한데, 이건 setState 가 비동기로 동작하기 때문이 맞습니다. (그래서 간혹 stackoverflow 등에서 setState가 비동기라서 바로 반영되지 않는다는 답변이 달리는 게 아닐까.. 하는 생각이 드네요)

아무튼 위 예제에서 볼 수 있듯, class component에서는 시간이 지나면 변경 이후의 state에 접근할 수 있는 여지가 충분히 있었습니다.

하지만 이런 방식에는 중요한 버그가 있습니다. 아래 예제를 볼까요?

class Component extends React.Component {
  state = { count: 0 };

  handleClick = () => {
    this.setState({ count: this.state.count + 1 });
    
    axios.post(`/api/visit/${this.state.count}`).then(() => {
      window.alert(`${this.state.count} visited!`); // 얘가 문제입니다 ❌
    })
  }

  render() {
    return <button onClick={this.handleClick}>다음</button>
  }
}

위 예제는 각 count 마다 서버에 api 콜을 날리는 예제입니다. 우리가 의도한 건 1을 방문하여 1 visited! 메세지를 alert 하고, 2 를 방문하여 2 visited! 를 alert 하고, ... 하는 컴포넌트였습니다.

하지만 다음 버튼을 매우 빠르게 누르거나 서버 응답이 느려서 실행 순서가 꼬이면 어떻게 될까요?

1 다음 2 다음 3이 떠야 하는데, 3이 세 번 뜨는 버그를 확인할 수 있습니다. 변경 후의 state에 접근할 수 있기 때문에 발생하는 버그입니다.

따라서 리액트는 변경 후의 state에 접근할 수 없도록 발전하게 되었습니다.


그리고 그 후..

사실 위에서 이 글의 결론은 다 났습니다. 리액트는 위와 같은 버그를 피하기 위해 의도적으로 하나의 렌더 단계에서 변경 후의 state에 접근하지 못하게 디자인되었습니다.

궁금해하실 분들을 위해 뒷이야기를 좀 해보면, 자바스크립트의 클로저 기능을 이용해서 render 함수를 클로저로 만들고 모든 것을 render 함수 안에 몰아넣으면 아래와 같이 작성하는 게 가능합니다.

class Component extends React.Component {
  state = { count: 0 };

  render() {
    const count = this.state.count;

    const handleClick = () => {
      this.setState({ count: count + 1 });
    
      axios.post(`/api/visit/${count}`).then(() => {
        window.alert(`${count} visited!`); // 이러면 버그는 없습니다 ✅
      });
    }
      
    return <button onClick={this.handleClick}>다음</button>
  }
}

이렇게 render 안에 다 몰아넣고 나니, "우리 class component 왜 써?" 라는 질문을 하게 됩니다. 저렇게 쓸 거면 render 함수만 만들어도 되니까요. (어째 코드가 익숙하죠? 네, 한 껍질 벗져서 render 만 쓰는 게 함수 컴포넌트입니다.)

그래서 함수 컴포넌트를 제대로 쓰기 위해 hooks가 등장했고, 이제는 class 때처럼 state에 대한 mutation 이니 클로져니 이런 복잡하고 어려운 걸 고민할 필요 없이 코드를 작성할 수 있게 되었습니다.

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

  const handleClick = () => {
    setCount(count + 1);
    
    axios.post(`/api/visit/${count}`).then(() => {
      window.alert(`${count} visited!`); // 이러면 버그는 없습니다 ✅
    });
  }
      
  return <button onClick={this.handleClick}>다음</button>;
}
profile
프론트엔드 개발자입니다
post-custom-banner

0개의 댓글