useEffect 상태 초기화가 9번 문항을 삼킨 0.05초

React의 useEffect로 상태를 초기화하면 화면은 새 문항인데 고른 답은 이전 값으로 남는 짧은 틈이 생긴다. React 17은 클릭 전에 이 작업을 끝내 결함을 가렸지만, 18부터는 클릭을 바로 처리해 더블클릭 한 번에 문항이 건너뛰어졌다.

React프론트엔드useEffect트러블슈팅TIL
--
useEffect 상태 초기화가 9번 문항을 삼킨 0.05초

온라인 시험을 보던 응시자가 8번 문제를 풀고 [다음] 버튼을 눌렀는데, 화면에 뜬 건 9번이 아니라 10번이었다. 9번 문제는 눈에 담을 새도 없이 지나갔다. 범인은 React의 useEffect로 상태를 초기화하던 코드였다.

응시자 입장에서는 풀어 보지도 못한 문제가 이미 지나가 버린 셈이다. 이 글에서는 시험 도중 문항이 사라진 순서, React 17에서는 멀쩡했던 이유, 그리고 실제로 막은 방법과 앞으로 고칠 방법을 차례로 기록해본다.

더블클릭 한 번에 문항이 스킵되는 증상

원래대로라면 답을 고르지 않고 [다음]을 누를 때 확인 창이 떠야 한다. 그런데 이 응시자의 화면에서는 확인 창도 없이 9번이 건너뛰어졌다.

서버에 남은 기록을 보면 상황이 더 분명해진다. 9번 문항을 화면에 내려준 지 0.05초 만에 10번을 달라는 요청이 들어왔고 그 사이에 답을 저장하는 요청은 하나도 없었다. 사람이 빠르게 더블클릭했거나, 마우스 버튼 접점이 낡아서 한 번 누른 것이 두 번으로 입력된 경우로 추측된다.

이상한 점은 따로 있었다. 다음 문항을 받아 오는 동안에는 버튼이 눌리지 않게 해 버튼을 연달아 누르지 못하게 막는 "연타 방지 잠금" 처리를 해두었었다.

그런데도 두 번째 클릭이 통과했다.

useEffect 상태 초기화가 늦게 반영되며 생긴 틈

useEffect는 "화면을 다 그린 다음에 할 일"을 적어 두는 자리다. 우리 코드는 문항이 바뀌면 이 자리에서 메모지에 적힌 답을 지웠다. 새 문항을 먼저 화면에 그리고 고른 답을 비우는 일은 그 뒤로 미뤄 둔 셈이다. 연타 방지 잠금은 다음 문항을 받아 오는 동안에만 버튼을 막는다.

이제 8번에서 3번 보기를 고른 뒤 [다음]을 0.05초 간격으로 두 번 누르면 어떻게 되는지 따라가 보자.

  1. 첫 클릭. 버튼이 잠기고 9번 문항을 요청한다.
  2. 9번 문항이 화면에 그려진다. 고른 답을 비우라는 지시도 나가지만, 실제 반영은 뒤로 미뤄진다.
  3. 요청이 끝났으니 잠금이 풀린다.
  4. 두 번째 클릭. 메모지에 적힌 답은 아직 8번에서 고른 "3번"이다. 답이 있다고 판단해 확인 창 없이 10번을 요청한다.
  5. 그제야 답이 비워진다. 10번 요청은 이미 나간 뒤다.

2번과 5번 사이에는 "화면은 9번인데 메모지의 답은 8번 것"인 순간이 생긴다. 두 번째 클릭이 바로 그 틈에 들어왔다. 계산대에 새 손님이 섰는데 화면에는 아직 앞 손님 금액이 떠 있는 것과 비슷하다.

React 공식 문서 "You Might Not Need an Effect"도 이 패턴을 피하라고 한다. 어떤 값이 바뀔 때 useEffect로 상태를 초기화하면 화면이 먼저 옛 값으로 한 번 그려진다는 이유에서다. 문서는 이를 비효율 문제로 설명하지만 그 옛 값을 보고 "확인 창을 띄울지 말지" 같은 판단을 내리면 비효율에서 끝나지 않고 버그가 된다. 이 구조는 React 17을 쓰던 예전 레거시 코드에도 그대로 있었다.

손으로는 재현이 안 되는 버그, 스크립트로 재현하기

이 틈은 아주 짧아서 손으로 더블클릭해서는 맞히기 어렵다. 새 문항이 뜬 직후의 찰나에 두 번째 클릭이 들어가야 한다. 실제 운영 환경에서도 하루 수십만 번의 페이지 전환 가운데 십여 건만 이 틈에 걸렸다.

그래서 사람 대신 클릭해 주는 작은 스크립트를 만들었다. 브라우저의 개발자 도구 콘솔, 즉 웹 페이지에 직접 명령을 내릴 수 있는 창에 붙여 넣어 실행하는 방식이다. 스크립트는 첫 번째 클릭을 넣은 뒤 화면의 "3 / 20" 같은 문항 번호 표시를 지켜본다. 숫자가 바뀌는 순간, 다시 말해 새 문항이 화면에 반영되는 순간을 잡아 그 직후에 두 번째 클릭을 넣는다. 그리고 1초 뒤 화면이 몇 번 문항에 와 있는지 기록한다.

사람의 손이 어쩌다 한 번 맞히던 타이밍을 스크립트가 대신 정확히 맞히게 한 것이다.

React 17 vs 18, 버그가 드러난 계기는 버전 차이

원인은 우리 코드에 있었지만 버그가 드러난 계기는 React 버전이었다. 차이는 클릭을 처리하기 직전에 있다.

React 17은 클릭을 처리하기 전에 뒤로 미뤄 둔 작업을 먼저 끝냈다. 계산대 직원이 다음 손님을 받기 전에 앞 손님 정리를 마저 끝내는 식이다. 그래서 두 번째 클릭이 들어올 때쯤에는 고른 답이 이미 비어 있었고 확인 창이 정상적으로 떴다. React 18부터는 이 과정 없이 클릭을 바로 처리한다.

React 17.0.2와 18.2.0의 소스 코드에서 이 차이를 확인했다. 실제 앱에서도 같은 코드에 React 버전만 바꿔 끼워 결과를 비교했다.

코드React결과
예전 운영 코드17.0.2확인 창
같은 예전 코드18.3.1건너뜀
현재 코드(수정 전)18.3.1로딩 가드에 막힘
같은 현재 코드19.2.8건너뜀

예전 코드는 React 17에서 확인 창을 띄웠지만 React만 18로 바꾸자 문항을 건너뛰었다. 현재 코드에는 "로딩 가드", 즉 데이터를 불러오는 동안 클릭을 막는 장치가 하나 더 있어서 18.3.1에서는 두 번째 클릭이 이 가드에 막혔다. 그런데 React 19에서는 이 가드가 새 문항이 뜨는 것과 동시에 내려가면서 틈이 그대로 드러났다.

React 18 릴리스 노트에는 클릭 같은 사용자 동작이 일으킨 effect는 바로 처리한다는 항목이 있다. 하지만 우리 effect는 클릭이 아니라 서버 응답이 일으킨 것이라 여기에 해당하지 않았다. useEffect 공식 문서도 effect 안에서 바꾼 상태는 화면이 다시 그려진 뒤에 반영될 수 있다고 적고 있다.

정리하면 React 17의 동작은 문서로 약속된 적 없는 내부 구현이었고 React 19는 문서에 적힌 대로 동작했을 뿐이다. React의 버그가 아니라, 보장된 적 없는 순서에 기대고 있던 우리 코드의 문제였다.

잠금을 0.5초 더 유지한 응급 조치

시험이 진행 중이었기 때문에 구조를 뜯어고치기 전에 먼저 막아야 했다. 그래서 문항이 바뀐 뒤에도 연타 방지 잠금을 0.5초 더 유지했다. 0.5초는 Windows가 두 번의 클릭을 더블클릭으로 인식하는 기본 시간이다. 서버 기록상 연타 간격은 모두 0.6초 미만이었고 대부분은 0.1초도 안 됐다.

마지막 문항의 [완료] 버튼과 답을 고르지 않았을 때 뜨는 확인 창의 [확인] 버튼도 같은 틈을 지나간다. 그래서 두 버튼에도 같은 잠금을 걸었다.

효과는 숫자로 확인했다. 배포 다음 날 서버 기록에서 답 저장 없이 0.6초 안에 다음 문항으로 넘어간 경우는 페이지 전환 약 21만 건 가운데 0건이었다. 배포 전날에는 같은 기준으로 18건이 나왔다.

다만 이건 시간에 기댄 방어다. 느린 기기에서 답을 비우는 작업이 0.5초 넘게 밀리면 틈이 다시 열린다. 그래서 응급 조치일 뿐 근본 해결은 아니다.

useEffect 없이 상태를 초기화하는 방법 2가지

근본적인 수정은 틈 자체를 없애는 것이다. 아직 적용하기 전이지만 방향은 두 가지로 잡아 두었다.

첫째, key로 문항마다 새로 만드는 방법이다. React에서 key는 "이 부분이 누구인지" 알려 주는 이름표다. 문항 번호를 key로 주면 번호가 바뀔 때 React가 그 부분을 아예 새로 만들고 고른 답도 처음 값에서 시작한다. 메모지를 지우는 대신 새 메모지로 갈아 끼우는 셈이다. 공식 문서가 권하는 방법이기도 하다.

둘째, 답에 문항 번호를 함께 저장하는 방법이다. 메모지에 "8번: 3번 보기"처럼 번호를 같이 적어 두고 화면을 그릴 때 적힌 번호가 지금 문항과 다르면 그 답은 쓰지 않는다. 사실 우리 상태에는 문항 번호를 담는 칸이 이미 있었는데 쓰지 않고 있었다.

정리: 버전 업그레이드 전에 사이드 이펙트를 미리 잘 대비하자.

useEffect로 상태를 초기화하면 화면과 상태가 어긋나는 순간이 생기고 연타 방지 잠금은 그 틈까지 막지 못한다. React 17은 이 결함을 가려 주고 있었을 뿐이다. 업그레이드 뒤에 버그가 터지면 새 버전이 무엇을 망가뜨렸는지보다 이전 버전이 무엇을 가려 주고 있었는지를 먼저 봐야겠다.

참고 자료

관련 글

댓글

0/2000
Newsletter

이 글이 도움이 되셨나요?

새로운 글이 발행되면 이메일로 알려드립니다.

뉴스레터 구독하기