학습 화면을 유지하며 오래된 이론 응답 차단하기

이 글의 목차 10개 항목
  • 분야: FE
  • 기술 스택: React · TypeScript · Fetch API
  • 관련 기술: 요청 순번 · 부분 로딩 · 비동기 상태

문제와 원인

SW 이론을 다시 요청하면 contentLoading 분기가 현재 목록·본문보다 먼저 반환해 화면을 비웠다. 요청 A 다음에 B를 시작하고 B가 먼저 완료돼도, 늦은 A의 성공·오류·종료 처리가 B의 콘텐츠와 로딩 상태를 바꿨다.

수정 전 코드

loadTheories()

const response = await fetch(
  `/api/sw-study?view=theories&subjects=${encodeURIComponent(selectedSubjectQuery())}`,
);
const payload = await response.json() as { theories?: SwTheoryItem[]; error?: string };
if (!response.ok || !payload.theories) throw new Error(payload.error || "이론을 불러오지 못했습니다.");
setTheoryItems(payload.theories);
} catch (error) {
  setContentError(learnerSafeErrorMessage(error, "이론을 불러오지 못했습니다."));
  setContentRetry(() => () => {
    void loadTheories();
  });
} finally {
  setContentLoading(false);
}

로딩 렌더링

if (contentLoading) {
  return (
    <div className="sw-curriculum-page" aria-busy="true">
      <span className="sr-only" role="status">SW 학습 콘텐츠를 불러오고 있습니다.</span>
    </div>
  );
}

수정 후 코드

요청 시작 시 순번을 증가시키고, 성공·오류·종료 처리 전에 현재 순번과 비교하도록 바꿨다. 세션 초기화에서도 순번을 증가시켜 이전 화면의 요청을 무효화한다.

loadTheories()

const requestId = ++swContentRequestRef.current;
setContentLoading(true);
setContentError("");
setContentRetry(null);
try {
  void loadMarkdownRenderer();
  const payload = await requestSwStudyData<{ theories?: SwTheoryItem[] }>({
    view: "theories",
    params: { subjects: selectedSubjectQuery() },
    cacheMode: "reuse",
  });
  if (requestId !== swContentRequestRef.current) return;
  if (!payload.theories) throw new Error("이론을 불러오지 못했습니다.");
  setTheoryItems(payload.theories);
  setSelectedTheory(null);
  onTheoryChange(null);
  showView("theories");
  window.scrollTo({ top: 0, behavior: "smooth" });
} catch (error) {
  if (requestId !== swContentRequestRef.current) return;
  setContentError(learnerSafeErrorMessage(error, "이론을 불러오지 못했습니다."));
  setContentRetry(() => () => { void loadTheories(); });
} finally {
  if (requestId === swContentRequestRef.current) setContentLoading(false);
}

로딩 렌더링

전체 반환 분기를 제거하고 기존 콘텐츠 안에 부분 로딩 표시를 넣었다.

<div className="page-stack sw-curriculum-page sw-content-page" aria-busy={contentLoading}>
  {contentLoading && <SwContentLoadingIndicator />}
  {/* 기존 탐색·검색·목록 렌더링 */}
</div>

검증 결과

응답 순서별 결과

macOS arm64, Node.js, TypeScript에서 전후 원본 loadTheories·openTheory 스냅샷의 네트워크·상태 setter를 제어 가능한 대역으로 연결했다. 두 함수마다 A·B 요청을 시작하고 합성 ID 1·2의 성공·실패·완료 순서 3종을 전후 각각 1회 적용했다.

각 함수의 완료 조건수정 전수정 후
B 성공 → A 성공표시 ID 2 → 1ID 2 유지
B 성공 → A 실패old request failed 표시오류 없음·ID 2 유지
B 대기 중 A 종료loading=falseloading=true 유지
전체 6조건의 기대 상태 유지0/6개6/6개
코드 위치 집계수정 전수정 후
전체 로딩 반환 분기1개0개
부분 로딩 표시0곳7곳
두 이론 함수의 순번 검사0곳6곳

본문 보존과 최신 응답 유지

로컬 React 재현 화면은 같은 원본 함수·로딩 컴포넌트와 합성 응답을 연결했다. 화면 조건은 1,280×720 CSS px·DPR 2이며, 요청 전 콘텐츠 ID 99에서 두 함수의 순서 3종을 전후 각각 1회 적용했다.

화면 조건수정 전 DOM수정 후 DOM
본문 요청 A 대기현재 본문 없음·aria-busy=trueID 99 유지·aria-busy=true
B 성공 → A 성공목록·본문 ID 1목록·본문 ID 2
B 성공 → A 실패목록·본문에 요청 A의 합성 네트워크 오류오류 없음·ID 2 유지
B 대기 중 A 성공목록·본문 ID 1·aria-busy=falseID 99·aria-busy=true

A 응답 대기 중 기존 구현은 현재 본문을 지웠고, 수정 후에는 현재 본문과 부분 로딩 표시를 함께 유지했다.

수정 전: 요청 대기 중 현재 본문이 사라짐원본 보기 (새 창)

수정 전: 요청 대기 중 현재 본문이 사라짐

100%원본 보기 (새 창)

확대한 이미지는 상하좌우로 이동할 수 있습니다. 키보드는 이미지 영역에 초점을 두고 방향키를 사용하세요. Esc로 닫습니다.

수정 후: 현재 본문과 부분 로딩 표시 유지원본 보기 (새 창)

수정 후: 현재 본문과 부분 로딩 표시 유지

100%원본 보기 (새 창)

확대한 이미지는 상하좌우로 이동할 수 있습니다. 키보드는 이미지 영역에 초점을 두고 방향키를 사용하세요. Esc로 닫습니다.

B 성공 뒤 늦게 도착한 A 성공은 기존 구현의 본문을 A로 덮었다. 순번 검사 후에는 B 본문이 유지됐다.

수정 전: 늦은 A 성공이 B 본문을 덮음원본 보기 (새 창)

수정 전: 늦은 A 성공이 B 본문을 덮음

100%원본 보기 (새 창)

확대한 이미지는 상하좌우로 이동할 수 있습니다. 키보드는 이미지 영역에 초점을 두고 방향키를 사용하세요. Esc로 닫습니다.

수정 후: 늦은 A 성공에도 B 본문 유지원본 보기 (새 창)

수정 후: 늦은 A 성공에도 B 본문 유지

100%원본 보기 (새 창)

확대한 이미지는 상하좌우로 이동할 수 있습니다. 키보드는 이미지 영역에 초점을 두고 방향키를 사용하세요. Esc로 닫습니다.

늦은 A 실패는 기존 구현의 B 본문에 오류를 남겼다.

수정 전: 늦은 A 실패가 B 본문에 오류를 남김원본 보기 (새 창)

수정 전: 늦은 A 실패가 B 본문에 오류를 남김

100%원본 보기 (새 창)

확대한 이미지는 상하좌우로 이동할 수 있습니다. 키보드는 이미지 영역에 초점을 두고 방향키를 사용하세요. Esc로 닫습니다.