5분
Fixing race condition in React
useEffect 내에서 data fetch를 다룰 때 종종 발생하는 버그를 이해하고 Suspense를 이용해 어떻게 다루는지 확인해보려고 합니다. https://17.reactjs.org/docs/concurrent-mode-suspense.html#suspense-and-race-conditions
입력 변화의 타이밍이나 순서가 예상과 다르게 작동하면 정상적인 결과가 나오지 않게 될 위험이 있는데 이를 Race condition(경쟁 상태)라고 합니다.
React useEffect 같은 react life-cycle 내에서 data fetch를 다룰 때 종종 이런 버그가 났던 것 같습니다.
마지막에 요청했던 결과가 아닌 이전 결과가 화면에 렌더링 된다던지... 그런 이슈 입니다.
이번 글에서는 Race condition을 어떻게 다루고 Suspense를 이용하면 간단하게 처리할 수 있는지 확인해보도록 하겠습니다.
Race condition in useEffect
이해를 위해 예시 프로젝트와 함께 살펴보겠습니다.
Next 버튼을 누르면 다음 id를 가진 user의 profile로 전환 해주는 page를 만들어보았습니다.
<ProfileComponent />
는 id를 prop으로 받아 user 정보와 user에 해당하는 post를 렌더링하는 component입니다.
2개의 fetch가 존재하고 user정보를 useEffect를 통해서 fetch를 하고 post도 마찬가지로 useEffect를 통해 fetch합니다.
fetchUser와 fetchPost는 Math.random()
을 통해 0~1 사이의 랜덤한 float값을 곱하여 delay후 실제 fetch를 수행합니다.
useEffect를 보면 dependency에 [id]
를 넣어둔 것을 확인할 수 있습니다.
id가 변경되면 useEffect를 다시 실행하도록 만들어야 하기 때문입니다. 그렇지 않으면 새로운 데이터를 fetch하지 못합니다.
처음에는 잘 작동하는 것처럼 보입니다. 하지만 "Next" 버튼을 아주 빠르게 클릭하게 되면 무언가 이상하게 동작하는 것을 콘솔 로그를 통해 볼 수 있습니다.
마지막에 보냈던 요청 뒤에 그 이전에 보냈던 요청이 돌아오는 경우가 발생할 때가 있는데 그로 인해서 setState시에 이전 요청의 결과값으로 덮어쓰게 되는 경우가 발생합니다.
fetchPost를 보게되면
- id가 2로 바뀌면서 fetchPost(2) 시작
- id가 3로 바뀌면서 fetchPost(3) 시작
- fetchPost(3) 완료되어 setState와 함께 re-render
- fetchPost(2) 완료되어 setState와 함께 re-render
위 이미지에서 그 이전 fetchPost: 2 가 마지막 요청인 fetchPost: 3 요청을 덮어쓰면서 기대했던 결과와는 다른 결과가 나오게 되었습니다.
useEffect 내에서 data fetch를 주의깊게 사용하지 않는다면 이런 race condition을 종종 마주칩니다.
이 문제는 고칠 수 있습니다. useEffect 내에서 cleanup function을 이용해 오래된 요청을 무시하거나 취소할 수 있습니다. 하지만 이는 직관적이지 않고 디버깅하기도 어렵습니다.
React component에는 고유한 "life-cycle"이 있습니다. 특정 시점마다 props를 받거나 state를 업데이트 합니다.
각각 비동기 요청 또한 자신만의 "life-cycle"을 가집니다. 요청이 출발했을 때 시작되며, 응답이 돌아오면 끝납니다.
어려운 점은 여러 프로세스가 서로에게 영향을 미치는 그 순간 여러 프로세스들을 "동기화"하는 작업입니다. 이 문제는 생각하기 어렵습니다.
Race condition in Suspense
위 예제를 Suspense를 이용해 다시 작성해보겠습니다.
resource는 suspense를 위한 특별한 형태입니다.
예시를 위해 제공된 코드 중에 있고 자세히 보면 특이한 것은 "pending"인 경우 promise 함수를 throw 하는 것을 볼 수 있습니다. error처럼 말이죠. React Suspense에서 promise를 다루는 방식이 이렇다라고만 이해하고 넘어가도록 하겠습니다. (주석처럼 이것을 그대로 프로젝트에 복사해서 쓰지 마세요.)
"Next"를 누르면 다음 profile정보에 대한 요청을 실행시키고, 그 resource를 ProfileComponent 컴포넌트 prop으로 전달합니다.
setState할 때 응답이 오기전까지 기다리지 않는다는 점을 확인하세요.
오히려 전혀 반대 방법입니다. 즉, 요청을 시작시킨 뒤에 즉시 state를 설정합니다.(그 후 렌더링을 시작합니다.)
데이터를 얻게 되자마자, React는 <Suspense>
컴포넌트 내에 컨텐츠를 "fills in(주입)"합니다.
실제 fetched 는 fetchUser(2)가 fetchUser(3) 보다 늦게 응답이 온 것으로 콘솔로그는 찍혔습니다.
그러나 위 코드에서 보았듯이 응답이 오기 전에 즉시 state를 설정했기 때문에 화면에 렌더링 된 것은 마지막에 요청한 fetchUser(3) 의 결과가 렌더링 됩니다.
fetchUser를 보게되면
- "Next" 클릭하여 id가 2인 suspense resource를 setState
- resource(id = 2)를 prop으로 받는 ProfileComponent re-render
- fetchUser(2) 시작
- "Next" 클릭하여 id가 3인 suspense resource를 setState
- resource(id = 3)를 prop으로 받는 ProfileComponent re-render
- fetchUser(3) 시작
- fetchUser(3) 완료
- resource(id = 3) 응답 결과 따라 ProfileComponent children 렌더링
- fetchUser(2) 완료 되지만 무시됨
이 코드는 매우 읽기 좋게 작성되어있지만. 기존 예제들과 달리 Suspense 버전에서는 race condition으로부터 고통받지 않습니다. 그 이유가 궁금하실 겁니다. 그 이유는 바로 Suspense 버전에서는 코드 상에서 time(시간) 에 대해 그다시 신경을 쓰지 않아도 되기 때문입니다.
race condition이 존재했던 기존의 코드에서는 이후의 적당한 시점에 state를 설정해야 했습니다. 그렇게 안하면 위에서 보듯이 기대하지 않은 결과가 나올 수 있습니다. 그러나 Suspense에서는 state를 즉시 설정합니다. 따라서 오작동하는 것이 더 어렵습니다.
위 예제코드는 공식 문서에서 codesandbox를 통해 소스를 제공하고 있고 제 github 코드에서도 확인할 수 있습니다.
마무리하며
useEffect내에서 data fetch를 주로 하고 state를 설정하는 방식을 많이 사용했습니다. 지금도 그런 프로젝트들이 많구요.
loading이나 error관리, 복잡해지는 구조 등에 매 번 좀 더 나은 구조를 위해서 고민했던 것 같습니다.
적당한 시점에 state를 설정해야한다는 것이 간단한 코드 내에서는 그래도 처리하겠는데 점점 더 코드가 복잡해지면 매우 힘든 경우도 있다는 것을 경험해보니 이런 Suspense와 같은 것이 너무 반갑습니다.
Suspense는 직관적이고 읽기 좋게 작성할 수 있습니다. 다음에 설명하겠지만 error-boundary를 통해 error 관리도 선언적으로 관리할 수 있게 되어 가독성 좋아집니다.
저 같은 경우 머리도 좋지 않고 "컨텍스트"를 잃어버리면 돌아오고 나서도 한참 헤매는 경우가 많습니다.(거기다 코드도 엄청 줄어듭니다)
이와 같이 직관적이라면 "컨텍스트"를 잃게 되더라도 빨리 돌아와 빠르게 회복될 것 같아 매우 좋을 것 같습니다.
적극적으로 사용해봐야겠다고 다짐 해봅니다.
reference
마지막 업데이트
4/23/2022