• 카테고리

    질문 & 답변
  • 세부 분야

    프론트엔드

  • 해결 여부

    해결됨

USESTATE, 렌더링의 비동기 시점에 대해 질문드립니다.

23.11.10 15:43 작성 23.11.10 15:48 수정 조회수 384

0

강사님 안녕하세요
아래의 코드를 보고 질문을 받아주시면 감사하겠습니다.

type testDataType = {
	testNumber1:number;
	testNumber2:number;
}

const Test = () => {

    const [testData, setTestData] = useState<TestDataType>({
       		testNumber1:0,
		testNumber2:0
    });

    useEffect(()=>{
       AxiosPromise로 Api를 호출하는 메서드 1({})
	.then((res)=>{
		 const responseData = res.data;
                console.log("responseData"+ responseData);//8출력
                setTestData({
                    ...testData,
                    testNumber1: responseData
                })

	AxiosPromise로 Api를 호출하는 메서드 2({})
	.then((res)=>{
		 const responseData = res.data;
                console.log("responseData"+ responseData);//8출력
                setTestData({
                    ...testData,
                    testNumber2: responseData
                })
	}, [])

	return (<>{`${testData.testNumber1} / ${testData.testNumber2}`}</>);
}

일 때,
testData.testNumber1 에는 비동기로 가져온 데이터가 반영되는데
testData.testNumber2 에는 비동기로 가져온 데이터가 반영이 되지 않습니다.

제가 생각한 리액트 코드 순서는 이렇습니다.

순서

1.
맨 처음 마운트 시점에 Test ()가 실행됩니다.
그리고 useEffect() 가 실행됩니다.

2.

아직 마운트 시점입니다.
useEffect() 가
AxiosPromise로 Api를 호출하는 메서드 1({})과
AxiosPromise로 Api를 호출하는 메서드 2({})를 비동기로 실행합니다.

이때 각각의 Axios Promise들은 각각 8이라는 데이터를 읽고

useState의 setTestData() 를 실행하여
testData.testNumber1에는 8

testData.testNumber2에도 8이 들어갑니다.

 

  1.  

    그 다음에
    return (<>{`${testData.testNumber1} / ${testData.testNumber2}`}</>);
    가 실행됩니다.
    그렇게 웹페이지 UI에는
    8/8
    이 보이게 됩니다.


    그러나 막상 실행해보면

0/8

8/0

이 나옵니다.

 

그리고 그 원인이
2개의 AxiosPromise 메서드에서

setTestData({

...testData,

testNumber1: responseData

})


setTestData({

...testData,

testNumber2: responseData

})

를 실행하기 때문 같습니다.

그런데 조금 궁금한 게 있습니다.

아무리 AxiosPromise와 UseState의 setter가 비동기 메서드라도
자바스크립트의 메모리 구조에서는
태스크큐라는 줄을 서게 됩니다.

그리고 호출스택이 비워지면 차례대로 호출스택으로 이동하면서 차례대로 동기적으로 실행된다고 생각합니다.
즉, 저 2개의 setTestData() 도 동시에 일어나지 않고 실행 순서가 있다고 생각했습니다.

그래서 2개의 비동기 메서드가 동일한 setTestData를 호출해도 문제가 없다고 생각했습니다.

제 생각의 틀린 점을 지적해주시면 감사하겠습니다.

답변 2

·

답변을 작성해보세요.

1

안녕하세요 이정환입니다.
우선 AI인턴이 정확한 답변을 제시해 주었네요 😃
저도 이해하시기 편하게 몇자 덧붙여 보겠습니다.

우선 문제가 발생하는 결과적인 이유는 첫번째 비동기 호출의 then 메서드와 두번째 비동기 호출의 then 메서드가 현재 같은 State값을 참조하고 있기 때문입니다.

이 문제는 굳이 비동기 호출이 아니여도 비슷하게 재현할 수 있습니다.
아래의 예제 코드를 살펴봐주세요

export default function App() {
  const [state, setState] = useState({ a: 0, b: 0 });

  const onClick = () => {
    setState({
      ...state,
      a: state.a + 1
    });

    setState({
      ...state,
      b: state.b + 1
    });
  };

  return (
    <div className="App">
      <div>a : {state.a}</div>
      <div>b : {state.b}</div>
      <button onClick={onClick}>증가</button>
    </div>
  );
}

위 코드의 App 컴포넌트는 a와 b라는 프로퍼티를 가진 객체 형태의 State를 가집니다.
그리고 다음과 같은 UI를 렌더링 합니다.

image이때 "증가" 버튼을 클릭하게 되면 onClick 함수가 실행됩니다. 그럼 어떤 일이 일어날까요?
아마도 대부분 a와 b 각각 1씩 값이 증가할거라고 예상하실 겁니다.
정말 그럴까요? 실제로 증가 버튼을 클릭해보겠습니다.

image증가 버튼을 클릭하면 예상과는 다르게 a의 값은 그대로 유지되고 b의 값만 1 늘어납니다.
한번 더 눌러도 아래 그림처럼 b의 값만 늘어나게 됩니다. 왜 이렇게 되는 걸까요?

image그 이유는 동일한 함수 내부에서 호출된 setState의 경우 동일한 State 값을 참조하기 때문입니다. 더 쉽게 말하자면 아래의 코드에서 위의 setState와 아래의 setState 함수가 참조하는 state값이 동일하다는 것이죠

  const onClick = () => {
    setState({
      ...state, // 값 : {a: 0, b: 0}
      a: state.a + 1
    });

    setState({
      ...state, // 값 : {a: 0, b: 0}
      b: state.b + 1
    });
  };

그러므로 setState 메서드가 작성해둔 순서대로 실행되어 먼저 a의 값이 1로 증가했다고 해도 결국 뒤에 실행되는 setState에 의해 a의 값은 다시 0으로 덮이고 b의 값만 1로 증가하게 됩니다.

호출 순서를 바꿔서 해보면 더 쉽게 이해할 수 있습니다.

  const onClick = () => {
    setState({
      ...state,
      b: state.b + 1
    });

    setState({
      ...state,
      a: state.a + 1
    });
  };

이번에는 b의 값을 1 증가시키는 setState를 먼저 호출하도록 순서를 바꿔보았습니다. 이때의 결과는 어떻게 될까요? 슬슬 느낌이 오시나요?

image버튼을 클릭하면 a의 값만 1 증가하게 됩니다.

그러므로 위와 같은 기능을 구현 하려면 가장 쉬운 방법으로는 다음 코드처럼 setState를 최소화하여 호출하는게 좋습니다.

  const onClick = () => {
    setState({
      a: state.a + 1,
      b: state.b + 1
    });
  };

만약 이렇게 할 수 없는 상황이라면 아래 코드처럼 함수형 업데이트를 사용하시는것도 괜찮습니다. 함수형 업데이트는 언제나 최신의 State 값만을 참조하기 때문에 앞서 살펴본 문제가 발생하지 않도록 방지할 수 있습니다.

  const onClick = () => {
    setState((state) => ({ // state 값 : 최신
      ...state,
      a: state.a + 1
    }));

    setState((state) => ({ // state 값 : 최신
      ...state,
      b: state.b + 1
    }));
  };

이해가 되셨나요?

지금까지 설명드린 내용을 바탕으로 질문자님의 코드를 수정해보면 아래와 같이 해볼 수 있겠습니다. (useEffect 코드만 붙여넣을게요)

  useEffect(() => {
    axios.get("...").then((res) => {
      const responseData = 8;
      setTestData((data) => ({
        ...data,
        testNumber1: responseData
      }));
    });

    axios.get("...").then((res) => {
      const responseData = 8;
      setTestData((data) => ({
        ...data,
        testNumber2: responseData
      }));
    });
  }, []);

또 두번의 API 호출을 다음과 같이 Promise.all 등의 Promise API를 활용해 병렬로 호출하는 것도 좋아보입니다. (이게 더 좋은것 같아요)

  useEffect(() => {
    Promise.all([axios.get("..."), axios.get("...")]).then(
      ([res1, res2]) => {
        const res1Data = res1.data; // 8 이라고 가정
        const res2Data = res2.data; // 8 이라고 가정

        setTestData({
          testNumber1: res1Data,
          testNumber2: res2Data
        });
      }
    );
  }, []);

답변이 되셨다면 꼭 답글이나 답변 확인 부탁드립니다

감사합니다.

aalikesme님의 프로필

aalikesme

질문자

2023.11.13

강사님 상세한 답변을 주셔서 정말 감사합니다

이렇게 이해해도 될까요?

useState의 setter를 이용하는 방법을 2가지가 있습니다.

정리1.


1번째 방법은 setter의 인자로 값만 넣는 방법입니다.
2번째 방법은 setter의 인자로 화살표 함수를 넣는 방법입니다.

이 방법에 따라 돌아가는 메커니즘이 다릅니다.

정리2.


1번째 방법은 setter 를 호출할 때의 호출 시점의 복사본이 들어갑니다.
예를 들면 2개의 setter에서 setData( { 변수: 값 } ) 을 넣을 때

setter가 호출스택에서 실행되고 호출스택이 비워집니다.
이때, 리액트에게 렌더링 전에 변수: 값으로 세팅해달라고 예약을 합니다.

즉 2개의 setter가 리액트에게 예약하길,
자신들이 호출된 시점의 값의 복사본을 넣는 것입니다.

image즉,
setState({
...state
})
setState({
...state
})
는 호출된 시점의 state의 복사본이 들어가는 것이고
실제로 값이 set 되는 것은 비동기로 태스크큐에서 대기하다가 호출스택으로 올라갈 때죠

즉, useState의 setter 호출 시점의 state 복사본과,
리액트에서 useState의 setter가 예약한 state
값을 실제로 넣을 때의 시점이 다르기에 이런 문제가 생긴 것이라고 생각했습니다.

호출(예약)했을 때의 state 값과
예약한 걸 진행할 때의 state 값이 다르기 때문이지요


그렇기에
2개 setState의 ...state 은 동일한 초기값의 복사본입니다.

정리3.
그러나 setter에 화살표 함수를 넣게되면
그것은 최신 값으로 반영됩니다.
어쩌면 이때 setState 안에 인자로 들어간 콜백함수의 state 변수는 복사본이 아닌 원본이라고 생각했습니다.

 

이렇게 이해해도 되겠습니까?
상세한 답변을 남겨주셔서 감사합니다.

안녕하세요 이정환입니다.

정리 1 :

정확합니다! 핵심을 잘 짚어 내셨군요 😃

정리 2 :

넵 맞습니다. 다시한번 정리해 보겠습니다.

setState 함수가 호출되면 React에 state를 업데이트 시켜달라는 비동기 요청을 보내게 됩니다.

다음의 코드를 다시 한번 살펴보겠습니다.

 const onClick = () => {
    setState({
      ...state,
      a: state.a + 1
    }));

    setState({ 
      ...state,
      b: state.b + 1
    }));
  };

먼저 함수가 실행되면 첫번째 setState가 호출 됩니다. 그러나 이 setState 함수는 비동기 요청을 보내는 함수이기 때문에 호출 이후에 곧바로 State의 값이 변경되지는 않습니다.

따라서 두번째 setState가 호출될 때의 State의 값은 첫번째 setState가 호출될 때의 State의 값과 동일합니다. 아직 변경되지 않았기 때문이죠

추가로 자바스크립트의 비동기 호출은 CallStack이 다비워지고 나서야 수행되기 때문에 비동기 작업이 아무리 빨리 끝나더라도 두번째 setState 호출이 일어났을 때 state의 값이 변경될 수는 없습니다.

정리 3 :

아래 코드처럼 setState에 인수로 함수를 전달하는 함수형 업데이트를 사용하게 되면 다음 순서대로 동작합니다.

  const onClick = () => {
    setState((state) => ({ // state 값 : 최신
      ...state,
      a: state.a + 1
    }));

    setState((state) => ({ // state 값 : 최신
      ...state,
      b: state.b + 1
    }));
  };
  1. 첫번째 setState 요청을 보냅니다.

  2. 두번째 setState 요청을 보냅니다.

  3. 함수가 종료됩니다

  4. CallStack이 비워집니다.

  5. React가 내부적으로 첫번째 setState 요청을 처리합니다. 이때 함수형 업데이트를 사용했기 때문에 React는 인수로 전달된 값으로 상태를 업데이트 하는게 아닌 인수로 전달된 콜백함수를 실행합니다.

  6. 이 콜백함수를 실행할 때 React는 최신 State 값을 인수로 전달합니다. 따라서 함수형 업데이트의 매개변수 State에는 언제나 최신 State 값이 반영되게 됩니다.

  7. 두번째 setState 요청도 5,6번과 동일하게 처리합니다.

     

aalikesme님의 프로필

aalikesme

질문자

2023.11.14

강사님 가르쳐주셔서 정말 감사합니다

1

인프런 AI 인턴님의 프로필

인프런 AI 인턴

2023.11.12

안녕하세요 aalikesme님, 인프런 AI 인턴이에요.
우선, 코드와 관련된 질문에 답변드리겠습니다.

useState와 setState는 비동기적으로 업데이트되기 때문에 여러 개의 setState를 사용할 때 문제가 발생할 수 있습니다. 현재 코드에서는 두 개의 비동기 메서드에서 동일한 setTestData를 호출하고 있기 때문에 예상치 못한 결과가 발생할 수 있습니다.

해결하기 위해서는 setTestData를 호출할 때 이전 상태값(testData)을 참조하는 대신 함수형 업데이트를 사용해야 합니다. 이렇게 하면 이전 상태값을 정확하게 참조하여 업데이트할 수 있습니다.

setTestData((prevState) => ({
  ...prevState,
  testNumber1: responseData,
}));

위와 같이 변경하면 예상대로 동작할 것입니다. 이렇게 하면 이전 상태 값을 참조하면서 정확한 업데이트를 수행할 수 있습니다.

또한, 비동기적으로 데이터를 가져오기 때문에 Promise의 결과가 동시에 도착하는 것은 보장되지 않습니다. 따라서 두 개의 Promise가 병렬로 실행되도록 변경하는 것이 좋을 수 있습니다. Promise.all을 사용하여 병렬로 실행할 수 있습니다.

Promise.all([AxiosPromise로Api를호출하는메서드1({}), AxiosPromise로Api를호출하는메서드2({})])
  .then(([res1, res2]) => {
    const responseData1 = res1.data;
    console.log("responseData1: " + responseData1);
    setTestData((prevState) => ({
      ...prevState,
      testNumber1: responseData1,
      testNumber2: responseData2,
    }));
  });

위의 코드처럼 Promise.all을 사용하여 비동기 작업을 병렬로 처리하고, 그 결과를 받아와서 이전 상태값을 참조하면서 업데이트할 수 있습니다.

질문에 대한 답변이 도움이 되셨는지 확인해보시길 바랍니다. 추가로 궁금한 사항이 있으면 언제든지 질문해주세요. 감사합니다!

aalikesme님의 프로필

aalikesme

질문자

2023.11.13

AI 인턴 고마워요!