아직 경험이 부족한 개발자들은 React 개발을 하다보면 간혹 아래와 같은 문제를 마주할 때도 있습니다.
export default function App() {
const [count, setCount] = useState(0)
function handleClick() {
setCount(count + 1)
// API를 요청하는 비동기 함수
fetchAPI(count)
}
return <button onClick={handleClick}>{count}</button>
}
개발자가 예상한 코드의 동작은 다음과 같습니다.
버튼을 클릭하면 handleClick
함수가 호출됩니다.
setCount()
를 호출해 count
를 1 증가 시킵니다.
증가된 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
는 정의된 순서대로 실행되기 때문에 아래와 같은 동작을 예상할 수 있습니다.
첫 번째 useEffect
가 실행되고 마커를 생성합니다.
두 번째 useEffect
가 실행되고 isBounce
가 true
인 경우 마커가 bounce 합니다.
하지만 예상과는 달리 마커는 bounce 하지 않습니다. 비동기 함수 때문에 먼저 정의된 useEffect
라도 더 나중에 처리될 수 있습니다.
React 안에서 비동기처리가 어떻게 되길래 저희를 힘들게 하는걸까요? 이런 예상치 못한 버그들을 더 이상 겪지 않도록 사용법과 시점에 대해 이해해야 합니다.
아래 순서대로 필요한 지식들을 간략하게 알아보면서 최종적으로는 이러한 문제들이 더이상 저희를 힘들게 하지 못하도록 함께 성장해보겠습니다.
JavaScript가 비동기 작업을 처리하는 방식
React의 상태 업데이트가 처리되는 방식
어떤 문제가 발생하고 어떻게 해결 할 수 있는지
JavaScript는 단일 스레드 언어지만, 이벤트 루프(event loop) 메커니즘을 통해 비동기 작업을 처리합니다.
이벤트 루프의 핵심은 콜 스택(call stack)과 콜백 큐(callback queue)로 볼 수 있습니다. 여기서 콜백 큐는 마이크로태스크 큐(microtask queue)와 매크로태스크 큐(macrotask queue)로 구분 할 수 있습니다.
콜 스택: 현재 실행 중인 함수들이 쌓이는 곳
마이크로태스트 큐: Promise
콜백과 같은 높은 우선순위 비동기 작업이 대기하는 곳
매크로태스크 큐: setTimeout
, 이벤트 콜백 등 낮은 우선순위 비동기 작업이 대기하는 곳
아래 순서대로 이벤트 루프가 실행됩니다.
콜 스택의 모든 작업을 처리
마이크로태스크 큐의 모든 작업을 처리
매크로태스크 큐에서 하나의 작업을 가져와 처리
다시 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는 여러 상태 업데이트를 한꺼번에 처리하여, 렌더링 횟수를 줄이고 성능을 높이기 위해 상태 업데이트를 비동기적으로 일시 보류합니다.
이후 업데이트가 모두 끝난 후 한 번만 렌더링하여 처리하는데 이렇게 모든 업데이트를 하나로 묶는 것을 배치 업데이트(batching)라고 합니다.
const [count, setCount] = useState(0)
setCount(count + 1)
setCount(count + 1)
// 배치 업데이트로 인해 count는 1이 됩니다.
이처럼 React는 배치 업데이트로 성능을 최적화하기 위해 상태 업데이트를 비동기적으로 처리합니다.
하지만, 실제로 상태 업데이트가 JavaScript 이벤트 루프의 콜백 큐로 들어가는 것은 아닙니다.
React는 자체 스케줄러를 가지고 있어서 React 내부에서 상태 업데이트를 처리합니다.
React의 상태 업데이트가 처리되는 기본 과정은 다음과 같습니다.
업데이트 큐(update queue) 등록: 상태 업데이트 함수가 호출되면 React는 해당 업데이트를 내부 큐에 등록합니다. 이 시점에서는 아직 상태가 실제로 변경되지 않습니다.
우선순위 결정: 사용자 입력과 같은 직접적인 상호작용은 높은 우선순위, 데이터 불러오기 같은 배경 작업은 낮은 우선순위가 할당됩니다.
상태 계산 및 렌더링 트리거: React는 적절한 시점에 큐에 있는 모든 업데이트를 처리하고, 최종 상태를 계산한 후 렌더링을 트리거합니다.
위 과정에서 상태 업데이트는 JavaScript처럼 즉각 실행되는게 아니라 렌더링을 예약하는 작업이므로 이벤트 루프의 처리 흐름과는 별개 시간 축을 갖습니다.
따라서 JavaScript 이벤트 루프와는 관련없는 별도의 메커니즘으로 처리되고, 이로 인해 우리는 예상치 못한 문제들을 마주할 수 있습니다.
이벤트 루프와 React의 렌더링 스케줄링은 별도의 메커니즘으로 작동하기 때문에, 둘 중 어떤 것이 먼저 실행될지 정확히 예측할 수 없고 예상치 못한 버그가 생길 수 있습니다. 따라서 명시적 흐름 제어와 최신 상태 보장과 같은 기법이 필요합니다.
처음 보았던 문제들을 하나씩 해결해보겠습니다.
처음 같이 보았던 문제의 코드입니다. 상태 업데이트 이후 비동기 함수를 실행했지만 업데이트 이전 상태를 가지고 요청을 보내는 문제가 있었습니다.
export default function App() {
const [count, setCount] = useState(0);
function handleClick() {
setCount(count + 1)
// API를 요청하는 비동기 함수
fetchAPI(count) // 이전 상태를 참조합니다.
}
return <button onClick={handleClick}>{count}</button>
}
해결 방법
useEffect
에서 처리하기 useEffect(() => {
fetchAPI(count)
}, [count])
function handleClick() {
setCount(prevCount => {
const updateCount = prevCount + 1
fetchAPI(updateCount)
return updateCount
})
}
마커가 비동기적으로 생성하기 때문에, 애니메이션을 적용하는 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 ...
}
해결 방법
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에서 비동기 작업과 상태 업데이트에서 발생하는 문제, 그리고 이러한 문제가 발생하는 이유와 이를 해결하는 방법도 알아보았습니다.
핵심만 간단하게 정리하자면
JavaScript는 이벤트 루프를 통해 비동기 작업을 처리합니다.
React의 상태 업데이트는 성능 최적화를 위해 비동기적으로 처리됩니다. 하지만 React 내부의 별도의 스케줄러를 통해 작업을 처리합니다.
이 두 시스템 간의 타이밍 차이로 인해 예상치 못한 문제가 발생합니다.
이러한 문제들을 명시적 흐름 제어와 최신 상태 보장과 같은 기법으로 해결할 수 있습니다.
참고
Task, microtasks, queues and schedules
자바스크립트 이벤트 루프 구조 동작 원리