turin.blog
← 전체 글로 돌아가기

practical debugging note

React useEffect 요청이 두 번 보여서 잘못 고칠 뻔한 실수

개발 모드에서 useEffect 요청이 두 번 보이는 상황을 무조건 버그로 보고 우회하려다 StrictMode와 취소 처리를 다시 확인한 기록입니다.

빠른 요약

네트워크 탭만 보고 성급하게 판단했다 검색 필터 컴포넌트를 만들던 중 개발자 도구 Network 탭에 같은 요청이 두 번 찍혔다.

이 글에서 확인할 것
  • 네트워크 탭만 보고 성급하게 판단했다
  • 먼저 분리해서 본 것
  • 수정한 코드 모양
  • 억지 플래그보다 나았던 이유
  • 다음에 같은 화면을 볼 때

네트워크 탭만 보고 성급하게 판단했다

검색 필터 컴포넌트를 만들던 중 개발자 도구 Network 탭에 같은 요청이 두 번 찍혔다. 처음에는 디바운스가 깨졌다고 생각해서 useRef로 억지 플래그를 만들 뻔했다.

나중에 확인해 보니 개발 모드의 StrictMode 영향도 섞여 있었다. 문제는 "두 번 보인다"와 "운영에서도 중복 저장된다"를 구분하지 않고 바로 코드를 비틀려고 했다는 점이다.

먼저 분리해서 본 것

확인 순서는 이렇게 잡았다.

  1. 개발 모드에서만 두 번인지 확인한다.
  2. 요청이 조회인지, 저장/결제 같은 부작용인지 구분한다.
  3. 이전 요청이 늦게 도착했을 때 화면을 덮어쓰는지 본다.
  4. cleanup 함수가 있는지 확인한다.

조회 요청이라도 이전 응답이 나중에 도착하면 UI가 뒤집힐 수 있다.

수정한 코드 모양

처음 코드는 입력값이 바뀔 때마다 fetch만 호출했다.

useEffect(() => {
  fetch(`/api/search?q=${query}`)
    .then((res) => res.json())
    .then(setItems);
}, [query]);

수정 후에는 취소 기준을 명확히 했다.

useEffect(() => {
  const controller = new AbortController();

  async function load() {
    const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`, {
      signal: controller.signal,
    });
    const data = await res.json();
    setItems(data.items);
  }

  load().catch((error) => {
    if (error.name !== 'AbortError') {
      console.error(error);
    }
  });

  return () => controller.abort();
}, [query]);

억지 플래그보다 나았던 이유

didRun.current 같은 플래그로 두 번째 실행을 막으면 당장은 조용해 보인다. 하지만 의존성이 바뀌었을 때 다시 실행되어야 하는 정상 흐름까지 막을 수 있다. 나는 그 방식 대신 요청 취소와 멱등성을 먼저 확인하기로 했다.

다음에 같은 화면을 볼 때

  • 개발 모드와 운영 빌드를 구분해서 재현한다.
  • 저장 API는 서버에서 중복 처리 방어를 둔다.
  • 조회 API는 늦게 온 응답이 최신 상태를 덮지 않게 한다.
  • Network 탭의 개수만 보고 결론 내리지 않는다.

마무리는 조금 허무했다. 버그를 고친다기보다, 내가 보고 있는 현상이 어떤 환경의 신호인지 이름 붙이는 일이 먼저였다.