재시도와 자원 정리
180분 안팎
학습 목표
재시도와 화면 이탈에서 남은 작업을 정리합니다.
개념
재시도는 사용자의 의도를 다시 실행합니다
네트워크가 잠시 끊겼다고 사용자가 입력한 검색어까지 잃을 필요는 없습니다. 실패 화면에 재시도 버튼을 두고 마지막 URL과 상세 여부를 다시 사용합니다. 빈 결과의 조건 초기화와 달리 재시도는 같은 조건으로 새 요청을 만드는 행동입니다. 이번 레슨은 회복 경로를 제공하는 동시에 요청·이벤트 리스너가 화면 수명보다 오래 남지 않게 정리하는 방법을 구현합니다. 정상 성공뿐 아니라 종료 시점의 동작도 검증합니다.
마지막 요청은 last 객체의 url과 options에 저장합니다. retry는 run을 다시 호출하므로 실패했던 Promise를 재사용하지 않습니다. 요청이 아직 한 번도 없으면 retry는 아무 동작도 하지 않습니다. GET 조회는 이 프로젝트에서 데이터를 변경하지 않는 읽기 작업입니다. 예약이나 결제처럼 서버 상태를 바꾸는 작업에 같은 자동 재시도 정책을 그대로 적용하면 중복 부작용이 생길 수 있으므로 API의 의미를 별도로 확인해야 합니다.
실습은 사용자가 누르는 한 번의 재시도만 제공합니다. 자동 무한 재시도·지수 지연·타이머는 도입하지 않습니다. 실패 안내를 읽고 연결을 확인한 뒤 재시도할 수 있게 합니다. 모의 응답 선택을 500에서 normal로 바꾸는 것은 서버가 회복된 상황을 흉내 내는 교육용 제어입니다. 앱은 마지막 URL로 요청하고 서버 어댑터가 새 모드를 적용합니다. 검색어·지역·상세 id는 그대로 유지됩니다.
버튼 비활성화와 실행 차단을 함께 둡니다
요청을 시작할 때 busy를 true로 두고 retryButton.disabled를 설정합니다. 화면의 disabled만 믿으면 코드가 retry를 직접 호출하거나 이미 등록된 이벤트가 실행될 때 중복 작업이 생길 수 있습니다. run의 첫 줄에서도 busy와 disposed를 검사합니다. 두 번의 요청을 허용하지 않는 정책을 함수 경계와 화면 양쪽에 표현하는 것입니다. 일반 검색 간 요청 역전 정책은 다음 모듈에서 더 확장합니다.
busy는 finally에서 해제합니다. 성공에서만 해제하면 실패 뒤 버튼이 영원히 비활성화될 수 있습니다. dispose 후에는 화면 버튼 상태를 수정하지 않습니다. 종료된 화면의 DOM을 바꾸는 일을 피하기 위해 disposed 확인을 둡니다. 제공 테스트는 완료를 지연한 Promise가 있을 때 retry를 호출하고 실제 fetcher 호출 수가 한 번인지 검사합니다. 빠르게 클릭하는 수동 관찰만으로는 이런 경계를 안정적으로 재현하기 어렵습니다.
UI 연결에서 폼 제출·조건 초기화·상세 이동도 busy 동안 새 route를 만들지 않도록 합니다. 이 모듈의 정책은 진행 중 추가 행동을 보류하는 단순한 방식입니다. 브라우저 뒤로/앞으로가 발생하면 기존 세션을 정리하고 새 주소 기준으로 앱을 초기화합니다. 상태와 요청이 다른 route를 참조하는 것을 피합니다. 다음 단계에서는 빠른 검색을 적극 허용하면서 최신 결과만 유지하는 정책을 비교합니다.
AbortController를 요청 수명에 묶습니다
run마다 새 AbortController를 만듭니다. signal을 fetch 옵션까지 전달해야 취소 신호가 요청에 연결됩니다. controller를 만들고 변수에 저장하는 것만으로 네트워크 작업이 취소되지는 않습니다. dispose에서 controller.abort를 호출하면 신호가 취소 상태가 됩니다. 이미 취소된 signal은 다음 요청을 위한 새 출발점이 아니므로 재시도에서도 새 컨트롤러를 사용합니다.
AbortError는 화면 이탈로 요청을 중단한 상황의 기대 경로일 수 있습니다. load의 catch에서 이를 일반 network 안내로 변환하지 않습니다. 사용자가 떠난 화면에 연결 실패 경고를 쓰지 않도록 합니다. 본문 읽는 동안의 취소도 다루므로 request의 파싱 catch에서 AbortError를 그대로 전파합니다. 모든 파싱 예외를 contract로 바꾸면 의도된 취소가 형식 장애로 오인됩니다.
취소 신호만으로 모든 늦은 작업의 쓰기가 사라진다고 가정하지 않습니다. 모의 fetcher나 이미 완료 직전인 작업은 늦게 값을 반환할 수 있습니다. 완료 후 signal.aborted, publish 직전 disposed를 확인해 종료된 화면의 결과 쓰기를 막습니다. 테스트는 signal을 기록하고 dispose 후에도 결과를 resolve하여 success가 발행되지 않는지 확인합니다. 이 검사는 취소 호출 여부와 결과 차단을 따로 증명합니다.
리스너도 해제 가능한 자원입니다
retry 리스너는 retry라는 이름의 함수로 등록합니다. removeEventListener에는 같은 함수 참조와 같은 이벤트 이름을 전달합니다. 새 화살표 함수를 만들어 제거하려 하면 등록된 함수와 다른 객체여서 남을 수 있습니다. 실제 앱에서는 on이라는 등록 헬퍼가 해제 함수를 모아 둡니다. dispose가 폼·목록·복귀·popstate·pagehide 리스너를 제거하고 문서의 초기화 표식도 지웁니다.
초기화 함수가 여러 번 불려도 이미 초기화한 앱을 반환합니다. dispose 이후 다시 초기화할 수 있도록 표식을 제거합니다. 같은 클릭으로 요청이 두 번 나가면 네트워크 함수보다 초기화 횟수와 리스너 개수부터 확인합니다. 등록을 렌더 함수 안에 두면 화면 상태가 바뀔 때마다 핸들러가 쌓일 수 있습니다. 상태 표시와 자원 등록을 다른 경로로 유지해야 재시도 횟수가 늘어도 동작은 한 번입니다.
dispose는 두 번 호출해도 문제가 없도록 작성합니다. 브라우저 pagehide에서 정리하고 뒤로 가기 캐시로 화면이 복원되는 pageshow의 persisted 조건에서는 다시 초기화합니다. pagehide만 처리하면 복원된 화면이 정리된 상태로 남을 수 있습니다. 이 프로젝트의 함수 수준 자동 검사는 정리 계약을 검사하며 실제 브라우저 캐시 복원은 관찰 기록에서 별도로 확인합니다. 사용하지 않는 화면을 장기 참조하지 않는 방향을 선택합니다.
종료와 회복을 별도 사례로 확인합니다
첫 요청 500, 두 번째 요청 200이라는 fetcher로 오류→로딩→성공을 기록합니다. 두 URL이 같은지 확인해 재시도가 조건을 잃지 않았음을 입증합니다. 이탈 테스트에서는 리스너 수 0, signal.aborted true, 상태 기록이 loading까지만 있다는 세 가지 근거를 봅니다. 버튼이 보이지 않는다는 화면 관찰만으로 리스너 제거를 증명할 수 없으며 abort를 호출했다는 로그만으로 늦은 쓰기 차단을 증명할 수도 없습니다.
테스트의 expected 0 actual 1이 리스너 수에 관한 실패라면 제거 시 전달한 함수 참조를 확인합니다. 이탈 후 success가 기록되면 완료 이후 수명 검사 위치를 봅니다. 재시도가 항상 AbortError라면 이전 controller를 재사용했는지 확인합니다. 실패 뒤 busy가 true로 남으면 finally가 누락됐는지 살펴봅니다. 오류 이름을 모두 문자열 메시지 비교로 처리하지 말고 name과 실습의 kind 분류를 사용합니다.
미션에서는 기존 검색·상세·목록 복귀와 API 상태를 함께 유지합니다. 사용자의 조건을 보존한 재시도, 없는 상세의 목록 복귀, 안전한 제목 표시를 npm test로 검사합니다. 실제 브라우저의 Tab·Enter·모바일·캐시 복원은 LAYOUT.md에 확인 근거를 적습니다. 더 읽기의 async/await 장은 비동기 함수의 다른 구성법을 확장합니다. 이번 결과물은 회복 가능한 화면과 화면 수명에 맞춰 해제되는 자원입니다.
따라하기
취소한 signal을 재사용하지 않습니다
cleanup.cjs로 실행해 요청마다 새 컨트롤러가 필요한 이유를 확인합니다.
const first=new AbortController();first.abort();const next=new AbortController();console.log(first.signal.aborted);console.log(next.signal.aborted);실행 결과
true false
같은 함수 참조로 해제합니다
실제 EventTarget으로 등록과 해제를 실행합니다. 마지막 dispatch에서는 호출 수가 늘지 않습니다.
const button=new EventTarget();let count=0;function retry(){count++;}button.addEventListener('click',retry);button.dispatchEvent(new Event('click'));button.removeEventListener('click',retry);button.dispatchEvent(new Event('click'));console.log(count);실행 결과
1
실패에서도 정리가 실행됩니다
finally는 성공 표시가 아니라 정리 위치입니다. 이 예제에서 원래 오류는 앞 catch에서 처리합니다.
(async()=>{let busy=true;try{await Promise.reject(new Error('연결 실패'));}catch(e){console.log(e.message);}finally{busy=false;}console.log('busy:',busy);})();실행 결과
연결 실패 busy: false
수명 정리를 완성하고 미션에 연결합니다
assets/api.js dispose의 TODO에서 controller?.abort()와 retryButton.removeEventListener를 호출합니다. npm test에서 취소 신호·리스너 수·늦은 쓰기 차단을 함께 봅니다. 미션 starter로 옮겨 검색·상세·복귀를 유지하고 실패 후 모의 응답을 normal로 바꿔 재시도합니다. 실제 캐시 복원과 키보드 관찰은 LAYOUT.md에 미확인 여부를 포함해 기록합니다.
확인 문제
실습
assets/api.js session의 dispose TODO에서 요청을 취소하고 등록한 retry 리스너를 제거합니다. 두 번 정리·지연 응답·중복 재시도 경계를 검사합니다.
실행 명령
npm test
기대 결과
기존 회귀 검사와 API 14개 모두 통과합니다. starter는 TODO 관련 assertion 실패, solution은 실패 0개입니다.
모범 답안
모범 답안 내려받기더 읽기
면접 질문
- 로딩·오류·빈 결과 화면을 나눈 이유를 설명합니다.