React 비동기와 상태 업데이트

Jemin·2일 전
0

개발 지식

목록 보기
55/55
post-thumbnail

아직 경험이 부족한 개발자들은 React 개발을 하다보면 간혹 아래와 같은 문제를 마주할 때도 있습니다.

export default function App() {
  const [count, setCount] = useState(0)
  
  function handleClick() {
    setCount(count + 1)
    
    // API를 요청하는 비동기 함수
    fetchAPI(count)
  }
  
  return <button onClick={handleClick}>{count}</button>
}

개발자가 예상한 코드의 동작은 다음과 같습니다.

  1. 버튼을 클릭하면 handleClick 함수가 호출됩니다.

  2. setCount()를 호출해 count를 1 증가 시킵니다.

  3. 증가된 count값으로 API를 요청합니다.


하지만 예상대로 동작하지 않고 API 요청 시 증가된 count값이 아니라 버튼을 누르기 이전 count값이 파라미터로 들어갑니다.

개발자는 디버깅을 위해 콘솔로 count를 확인해봤지만, count는 정상적으로 증가하는 것을 확인할 수 있습니다. 하지만 API 요청이 원하는대로 되지 않는 문제가 계속해서 발생합니다.

이것은 상태 업데이트보다 비동기 호출이 먼저 실행되기 때문에 발생하는 문제입니다.



한 가지 예시를 더 들어보겠습니다.

export default function App({ type, isBounce }) {
  const ref = useRef()
  
  // 컴포넌트 마운트 시 마커를 생성하기
  useEffect(() => {
    const createMarker = async () => {
      const icon = await getImageUrl(type) // 비동기 함수
      
      ...
      
      ref.current = marker
      
    }
    
    createMarker()
    
  }, [])
  
  // isBounce prop에 따라서 마커에 bounce 애니메이션 적용하기
  useEffect(() => {
    if(ref.current) {
      ref.current.setAnimation(isBounce ? 'bounce' : null)
    }
  }, [isBounce])
  
  return ...
}

실제로 제가 사용하는 마커를 생성하는 컴포넌트로 일부 코드만 약간 변형해서 가져와봤습니다.

마커는 처음 생성될 때 type에 따라 아이콘 이미지를 가져오고 isBounce 상태에 따라서 bounce 할 수도 있습니다.

getImageUrl 함수는 동적으로 이미지를 가져오는 비동기 함수입니다. 이 함수를 호출하기 위해 useEffect에서 createMarker 라는 비동기 함수를 만들어서 호출합니다.



React에서 useEffect는 정의된 순서대로 실행되기 때문에 아래와 같은 동작을 예상할 수 있습니다.

  1. 첫 번째 useEffect가 실행되고 마커를 생성합니다.

  2. 두 번째 useEffect가 실행되고 isBouncetrue인 경우 마커가 bounce 합니다.

하지만 예상과는 달리 마커는 bounce 하지 않습니다. 비동기 함수 때문에 먼저 정의된 useEffect라도 더 나중에 처리될 수 있습니다.



React 안에서 비동기처리가 어떻게 되길래 저희를 힘들게 하는걸까요? 이런 예상치 못한 버그들을 더 이상 겪지 않도록 사용법과 시점에 대해 이해해야 합니다.

아래 순서대로 필요한 지식들을 간략하게 알아보면서 최종적으로는 이러한 문제들이 더이상 저희를 힘들게 하지 못하도록 함께 성장해보겠습니다.

  1. JavaScript가 비동기 작업을 처리하는 방식

  2. React의 상태 업데이트가 처리되는 방식

  3. 어떤 문제가 발생하고 어떻게 해결 할 수 있는지




JavaScript의 이벤트 루프

JavaScript는 단일 스레드 언어지만, 이벤트 루프(event loop) 메커니즘을 통해 비동기 작업을 처리합니다.

이벤트 루프의 핵심은 콜 스택(call stack)콜백 큐(callback queue)로 볼 수 있습니다. 여기서 콜백 큐는 마이크로태스크 큐(microtask queue)매크로태스크 큐(macrotask queue)로 구분 할 수 있습니다.

  1. 콜 스택: 현재 실행 중인 함수들이 쌓이는 곳

  2. 마이크로태스트 큐: Promise 콜백과 같은 높은 우선순위 비동기 작업이 대기하는 곳

  3. 매크로태스크 큐: setTimeout, 이벤트 콜백 등 낮은 우선순위 비동기 작업이 대기하는 곳



이벤트루프 동작과정

아래 순서대로 이벤트 루프가 실행됩니다.

  1. 콜 스택의 모든 작업을 처리

  2. 마이크로태스크 큐의 모든 작업을 처리

  3. 매크로태스크 큐에서 하나의 작업을 가져와 처리

  4. 다시 1번으로 돌아가 반복

간단한 코드를 예시로 이벤트 루프와 대입시켜 생각해본다면

console.log("start")

setTimeout(() => console.log("setTimeout"), 0)

Promise.resolve().then(() => console.log("promise"))

console.log("end")

위 코드를 실행시켰을 때 아래와 같은 결과가 나옵니다.

> start
> end
> promise
> setTimeout

비동기가 아닌 코드는 콜 스택에 들어가서 바로 처리됩니다. 비동기 함수들은 콜백 큐에 들어가고 우선 순위에 따라 setTimeout은 매크로태스크 큐에 Promise는 마이크로태스크 큐에 들어가 Promise가 먼저 처리되고 이후에 setTimeout이 처리됩니다.




React의 상태 업데이트

React는 여러 상태 업데이트를 한꺼번에 처리하여, 렌더링 횟수를 줄이고 성능을 높이기 위해 상태 업데이트를 비동기적으로 일시 보류합니다.

이후 업데이트가 모두 끝난 후 한 번만 렌더링하여 처리하는데 이렇게 모든 업데이트를 하나로 묶는 것을 배치 업데이트(batching)라고 합니다.

const [count, setCount] = useState(0)

setCount(count + 1)
setCount(count + 1)

// 배치 업데이트로 인해 count는 1이 됩니다.

이처럼 React는 배치 업데이트로 성능을 최적화하기 위해 상태 업데이트를 비동기적으로 처리합니다.
하지만, 실제로 상태 업데이트가 JavaScript 이벤트 루프의 콜백 큐로 들어가는 것은 아닙니다.

React는 자체 스케줄러를 가지고 있어서 React 내부에서 상태 업데이트를 처리합니다.



React의 상태 업데이트가 처리되는 기본 과정은 다음과 같습니다.

  1. 업데이트 큐(update queue) 등록: 상태 업데이트 함수가 호출되면 React는 해당 업데이트를 내부 큐에 등록합니다. 이 시점에서는 아직 상태가 실제로 변경되지 않습니다.

  2. 우선순위 결정: 사용자 입력과 같은 직접적인 상호작용은 높은 우선순위, 데이터 불러오기 같은 배경 작업은 낮은 우선순위가 할당됩니다.

  3. 상태 계산 및 렌더링 트리거: React적절한 시점에 큐에 있는 모든 업데이트를 처리하고, 최종 상태를 계산한 후 렌더링을 트리거합니다.

위 과정에서 상태 업데이트는 JavaScript처럼 즉각 실행되는게 아니라 렌더링을 예약하는 작업이므로 이벤트 루프의 처리 흐름과는 별개 시간 축을 갖습니다.

따라서 JavaScript 이벤트 루프와는 관련없는 별도의 메커니즘으로 처리되고, 이로 인해 우리는 예상치 못한 문제들을 마주할 수 있습니다.




JavaScript 이벤트 루프와 React 상태 업데이트의 차이에서 발생하는 문제들

이벤트 루프와 React의 렌더링 스케줄링은 별도의 메커니즘으로 작동하기 때문에, 둘 중 어떤 것이 먼저 실행될지 정확히 예측할 수 없고 예상치 못한 버그가 생길 수 있습니다. 따라서 명시적 흐름 제어최신 상태 보장과 같은 기법이 필요합니다.

처음 보았던 문제들을 하나씩 해결해보겠습니다.


1. 상태 업데이트 타이밍 불일치

처음 같이 보았던 문제의 코드입니다. 상태 업데이트 이후 비동기 함수를 실행했지만 업데이트 이전 상태를 가지고 요청을 보내는 문제가 있었습니다.

export default function App() {
  const [count, setCount] = useState(0);
  
  function handleClick() {
    setCount(count + 1)
    
    // API를 요청하는 비동기 함수
    fetchAPI(count) // 이전 상태를 참조합니다.
  }
  
  return <button onClick={handleClick}>{count}</button>
}

해결 방법

  1. 상태 변경 후 작업은 useEffect에서 처리하기
  useEffect(() => {
    fetchAPI(count)
  }, [count])
  1. 함수형 업데이트로 콜백 함수 안에서 최신 상태 참조하기
  function handleClick() {
    setCount(prevCount => {
      const updateCount = prevCount + 1
      fetchAPI(updateCount)
      return updateCount
    })
  }



2. useEffect 처리 시점 차이

마커가 비동기적으로 생성하기 때문에, 애니메이션을 적용하는 useEffect가 마커가 생성되기 전에 먼저 처리되어 애니메이션이 적용되지 않는 문제가 있었습니다.

export default function App({ type, isBounce }) {
  const ref = useRef()
  
  // 컴포넌트 마운트 시 마커를 생성하기
  useEffect(() => {
    const createMarker = async () => {
      const icon = await getImageUrl(type) // 비동기 함수
      
      ...
      
      ref.current = marker
      
    }
    
    createMarker()
    
  }, [])
  
  // isBounce prop에 따라서 마커에 bounce 애니메이션 적용하기
  useEffect(() => {
    if(ref.current) {
      // 마커가 아직 생성되지 않아서 애니메이션이 적용되지 않을 수 있습니다.
      ref.current.setAnimation(isBounce ? 'bounce' : null)
    }
  }, [isBounce])
  
  return ...
}

해결 방법

  1. 마커가 생성된 후에 애니메이션 적용하기
const [markerReady, setMarkerReady] = useState(false)

useEffect(() => {
  const createMarker = async () => {
    const icon = await getImageUrl(type) // 비동기 함수

    ...

    ref.current = marker
	setMarkerReady(true) // 마커 생성 완료
  }

  createMarker()
  
}, [])

useEffect(() => {
    if(markerReady && ref.current) {
      ref.current.setAnimation(isBounce ? 'bounce' : null)
    }
  }, [isBounce, markerReady]) // markerReady 의존성 추가



정리

이렇게 React에서 비동기 작업과 상태 업데이트에서 발생하는 문제, 그리고 이러한 문제가 발생하는 이유와 이를 해결하는 방법도 알아보았습니다.

핵심만 간단하게 정리하자면

  1. JavaScript는 이벤트 루프를 통해 비동기 작업을 처리합니다.

  2. React의 상태 업데이트는 성능 최적화를 위해 비동기적으로 처리됩니다. 하지만 React 내부의 별도의 스케줄러를 통해 작업을 처리합니다.

  3. 이 두 시스템 간의 타이밍 차이로 인해 예상치 못한 문제가 발생합니다.

  4. 이러한 문제들을 명시적 흐름 제어최신 상태 보장과 같은 기법으로 해결할 수 있습니다.



참고
Task, microtasks, queues and schedules
자바스크립트 이벤트 루프 구조 동작 원리

profile
경험은 일어난 무엇이 아니라, 그 일어난 일로 무엇을 하느냐이다.

0개의 댓글