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