최신 요청만 반영하기
180분 안팎
학습 목표
응답 도착 순서와 검색 의도를 구분합니다.
개념
최신 요청만 반영하기
사용자는 책 검색을 취소하고 음악 검색을 선택했는데 목록은 책 행사로 돌아오는 문제가 있습니다. 마지막에 도착한 응답을 그대로 그리는 코드에서는 정상 HTTP 응답끼리도 이 문제가 생깁니다. 성공 상태라고 사용자 의도에 맞는 것은 아닙니다. 이번 레슨은 검색을 시작한 순서를 기록하고 완료 시점에 그 요청이 아직 화면을 쓸 권한을 갖는지 확인하는 규칙을 만듭니다.
검색어 문자열만 비교하는 방식은 충분하지 않습니다. 책, 음악, 책 순으로 세 번 검색하면 첫 책 응답과 마지막 책 응답의 검색어가 같습니다. 지역이나 상세 id까지 달라질 수도 있습니다. 따라서 각 검색 시작에 단조 증가하는 requestId를 부여합니다. 같은 조건으로 재시도하더라도 별도 번호를 받습니다. 비교의 대상은 도착 시간이 아니라 현재 최신 번호와 해당 요청이 시작할 때 저장한 번호입니다.
요청을 시작할 때 latest를 하나 증가시키고 const mine에 저장합니다. await가 끝난 뒤 mine이 latest와 같은지 검사합니다. latest 변수만 콜백에서 읽으면 모든 요청이 현재 번호를 보는 것이므로 자신의 출신을 구분할 수 없습니다. 지역 행사 사이트에서는 목록 검색·상세 진입·목록 복귀 모두 현재 화면의 의도를 바꾸므로 같은 번호 체계 안에서 이전 작업을 무효화합니다.
새 검색은 먼저 조건을 복사하고 loading을 발행합니다. 이 실습의 정책은 이전 rows를 비우고 error를 null로 지우는 것입니다. 화면 제목과 카드가 서로 다른 조건을 가리키는 중간 상태를 피하기 위해 선택했습니다. 이전 카드를 유지하는 정책도 설계할 수 있지만 그 경우 이전 조건의 결과임을 표시해야 합니다. 여기서는 비교할 상태를 작게 유지하며 rows를 계속 보존하는 변형은 과제 범위에 넣지 않습니다.
성공 결과를 반영하기 직전에 요청 번호를 검사합니다. 오래된 성공은 rows와 kind를 모두 그대로 둡니다. 최신 성공의 rows가 비면 empty, 하나 이상이면 success입니다. 빈 응답도 이전의 성공 카드들을 지워야 하므로 번호 검사 뒤 새 배열을 명시적으로 대입합니다. 최신 응답이 비었다고 이전 응답의 카드로 채우면 실제로 없는 행사를 보여 주는 오류가 됩니다.
오류 경로에도 같은 검사가 필요합니다. B 결과를 이미 표시했는데 늦은 A의 500 오류가 도착하면 B 카드 대신 오류 안내가 나타날 수 있습니다. catch를 거치면 최신성 검사가 필요 없다는 생각은 잘못입니다. 현재 요청의 오류는 error 상태로 바꾸고 재시도 행동을 제공하지만 오래된 오류는 사용자 화면에 반영하지 않습니다. 진단 로그를 남기더라도 화면 상태와 책임을 분리합니다.
finally는 성공과 실패 뒤 공통 정리를 하는 위치입니다. A가 끝났다고 공용 busy를 false로 바꾸면 아직 B가 대기 중이어도 로딩이 끝난 것처럼 보입니다. 따라서 busy와 현재 controller의 정리는 mine이 latest일 때만 수행합니다. 요청마다 가진 지역 변수의 정리와 화면 전체의 공용 상태 정리를 구분합니다. finally를 사용했다는 사실만으로 경쟁 조건이 해결되지는 않습니다.
취소 신호는 자원을 덜 쓰도록 요청에 전달하는 수단이고 번호는 늦은 쓰기를 막는 수단입니다. 테스트의 모의 요청은 일부러 취소 신호를 무시하고 나중에 resolve합니다. 실제 fetch와 다르지만 앱의 방어 규칙을 강하게 검사하기 위한 장치입니다. 모든 하위 작업이 취소에 협조한다고 가정하지 않고 결과 반영 지점에서 번호를 확인하면 파싱 후 후처리에서도 같은 정책을 유지할 수 있습니다.
브라우저 과제는 start·success·failure 이벤트 배열을 입력받습니다. start에는 q가 있고 내부에서 번호를 하나 늘립니다. success에는 id와 rows, failure에는 id와 message가 있습니다. id는 start가 부여한 번호를 뜻합니다. 초깃값은 requestId 0, 빈 q, idle, 빈 rows, error null입니다. 최종 객체는 requestId·q·kind·rows·error 순서로 생성해 출력합니다.
완료 이벤트는 id가 현재 requestId와 같고 kind가 loading일 때만 받아들입니다. 아직 시작하지 않은 요청의 완료나 같은 요청의 중복 완료는 무시합니다. 이 제한은 이벤트 입력을 다루는 모델의 규칙입니다. 실제 Promise는 한 번만 정착하지만 테스트 이벤트에는 잘못된 순서도 들어갈 수 있으므로 상태 기계가 경계를 드러내도록 만들었습니다. 입력 이벤트의 순서를 정렬하지 않고 주어진 순서대로 소비합니다.
경계 사례는 빈 이벤트, 한 검색의 빈 결과, A 후 B 후 A 완료, B 오류 뒤 옛 A 성공, 같은 검색어의 두 시작입니다. 각 사례에서 최신 q가 어느 값인지 먼저 적고 kind와 rows를 예상합니다. 특히 오래된 성공을 무시한 결과는 empty가 아니라 현재 loading일 수 있습니다. 무시한다는 것은 별도 빈 상태를 발행한다는 의미가 아니라 기존 상태를 유지한다는 뜻입니다.
빠른 입력 문제를 버튼 비활성화만으로 막으면 사용자가 조건을 바꾸려는 행동까지 잃습니다. 이번 프로젝트는 새로운 검색을 허용하고 재시도 버튼의 중복 실행만 막습니다. 목록에서 상세로 들어갈 때도 진행 중 요청이 있으면 취소·무효화하고 상세 요청을 시작합니다. 어떤 행동을 차단하고 어떤 행동을 최신 의도로 받아들이는지 구분해야 API session의 busy 의미를 설명할 수 있습니다.
AssertionError에서 기대 rows는 음악인데 실제 rows는 책이면 응답을 해결한 순서를 기록합니다. 테스트를 길게 기다리게 바꾸는 대신 A와 B의 resolve 함수를 분리해 B를 먼저 완료합니다. 이때 A 요청 번호, B 요청 번호, 완료 직전 latest를 함께 확인합니다. undefined id가 나오면 번호를 콜백 지역 변수에 저장했는지와 이벤트 키를 읽는 위치가 맞는지 확인합니다.
요청을 구별하는 값으로 Date.now를 사용하면 같은 밀리초 안의 두 시작이 같은 번호를 받을 수 있습니다. 테스트에서도 시간에 의존할 이유가 없습니다. 한 session 내부 증가 정수가 더 단순합니다. session이 폐기되면 새 화면은 별도의 상태를 만들고 이전 session은 disposed로 갱신을 막습니다. 여러 탭 사이의 전역 고유 번호까지 요구하는 문제와 현재 화면의 순서 문제를 구분합니다.
레슨을 마치면 성공 경로에 if 한 줄을 넣었다고 보고하지 않습니다. 시작·성공·실패·공통 정리 네 지점에서 무엇을 저장하고 무엇을 검사하는지 설명합니다. 실제 미션에서는 request-control.js가 이 정책을 소유하고 api.js는 HTTP 계약에 집중합니다. 더 읽기의 async/await 장은 예외 전파와 대기 구조를 보완하는 경로이며 화면 최신성 정책은 이번 테스트의 입력과 출력으로 증명합니다.
따라하기
응답 역전을 재현합니다
resolve 함수를 분리해 B를 먼저 끝냅니다. 방어가 없으면 이전 조건으로 돌아갑니다.
(async()=>{let a,b;const pa=new Promise(r=>a=r),pb=new Promise(r=>b=r);let shown='';const A=pa.then(v=>shown=v),B=pb.then(v=>shown=v);b('음악');await B;a('책');await A;console.log(shown);})();실행 결과
책
번호로 반영 권한을 확인합니다
요청 시작 때 저장한 번호를 완료 직전에 검사합니다.
(async()=>{let latest=0,shown='',a,b;function run(p){const mine=++latest;return p.then(v=>{if(mine===latest)shown=v;});}const A=run(new Promise(r=>a=r)),B=run(new Promise(r=>b=r));b('음악');await B;a('책');await A;console.log(shown);})();실행 결과
음악
옛 정리에서 최신 로딩을 보호합니다
finally가 공용 상태를 바꾸기 전에도 동일 검사를 사용합니다.
let latest=2,busy=true;const mine=1;if(mine===latest)busy=false;console.log(busy);실행 결과
true
확인 문제
실습
start(q)·success(id, rows)·failure(id, message) 배열을 읽습니다. start마다 번호를 1 증가시킵니다. 최신 loading의 완료만 받아들이고 최종 requestId·q·kind·rows·error 객체를 출력합니다. 중복 완료와 미시작 완료는 무시합니다.
모범 답안
const events=JSON.parse(require('fs').readFileSync(0,'utf8'));
let s={requestId:0,q:'',kind:'idle',rows:[],error:null};
for(const e of events){if(e.type==='start')s={requestId:s.requestId+1,q:e.q,kind:'loading',rows:[],error:null};else if(e.id===s.requestId && s.kind==='loading'){if(e.type==='success')s={...s,kind:e.rows.length?'success':'empty',rows:[...e.rows],error:null};else if(e.type==='failure')s={...s,kind:'error',rows:[],error:e.message};}}
console.log(JSON.stringify(s));더 읽기
면접 질문
- 검색 요청이 순서와 다르게 도착할 때의 처리 방식을 설명합니다.