← 전체 글로 돌아가기

Next.js

React 모달을 닫은 뒤에도 요청이 상태를 바꾸던 문제를 정리한 튜토리얼

모달을 빠르게 열고 닫을 때 늦게 도착한 응답이 화면 상태를 덮어쓰지 않도록 AbortController를 적용한 방법입니다.

빠르게 닫으면 다른 항목이 바뀌었다

댓글 수정 모달에서 저장 버튼을 누른 직후 닫았다가 다른 댓글을 열면, 이전 요청의 성공 응답이 새 모달의 상태를 덮어쓰는 일이 있었습니다. 느린 개발 서버에서는 잘 재현되지 않았고, 네트워크 속도를 제한했을 때만 눈에 띄었습니다.

단순히 isMounted 플래그를 추가하는 방법도 떠올랐지만, 취소할 수 있는 요청을 계속 실행하는 문제는 남습니다. 이번에는 요청 자체에 취소 신호를 연결했습니다.

모달 생명주기에 신호를 묶기

useEffect 안에서 컨트롤러를 만들고 cleanup에서 abort합니다. 모달이 다른 댓글로 바뀌면 기존 effect가 먼저 정리됩니다.

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

  async function loadComment() {
    setLoading(true);
    try {
      const response = await fetch(`/api/comments/${commentId}`, {
        signal: controller.signal,
      });
      if (!response.ok) throw new Error(`HTTP ${response.status}`);
      setComment(await response.json());
    } catch (error) {
      if (error instanceof DOMException && error.name === 'AbortError') return;
      setError('댓글을 불러오지 못했습니다.');
    } finally {
      if (!controller.signal.aborted) setLoading(false);
    }
  }

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

여기서 finally에서도 무조건 로딩을 끄지 않은 것이 헷갈리기 쉬운 부분입니다. 이미 새 요청이 시작된 상태에서 이전 요청이 끝나면 새 요청의 로딩 표시를 꺼 버릴 수 있기 때문입니다.

저장 요청과 화면 상태를 분리했다

저장 함수도 동일한 컨트롤러를 사용하되, 모달 닫기와 요청 취소의 순서를 정했습니다. 취소가 먼저 일어나면 정상 응답도 버려질 수 있으므로 성공을 확인한 후 닫습니다.

async function saveComment(text: string) {
  const response = await fetch(`/api/comments/${commentId}`, {
    method: 'PATCH',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ text }),
    signal: saveController.signal,
  });
  if (!response.ok) throw new Error('save failed');
  onSaved(await response.json());
  onClose();
}

실제 코드에서는 저장 중 닫기 버튼을 비활성화하고, 사용자가 취소할 수 있는 별도 버튼을 둘지 제품 동작으로 결정했습니다. 네트워크 취소와 사용자의 취소 메시지도 같은 오류로 표시하지 않았습니다.

확인한 시나리오

  • 모달을 열고 즉시 닫기
  • A 댓글을 연 뒤 B 댓글로 바로 이동
  • 요청 중 새로고침
  • 404와 네트워크 단절
  • 저장 중 중복 클릭

React 경고를 없애는 것만 목표로 삼지 않고, 늦은 응답이 어떤 상태를 갱신할 수 있는지부터 확인하니 구조가 분명해졌습니다. 비동기 작업은 시작한 컴포넌트의 생명주기와 함께 정리하는 편이 안전합니다.