일관된 화면 상태 전이
180분 안팎
학습 목표
결과·오류·선택 항목의 갱신 규칙을 하나로 모읍니다.
개념
일관된 화면 상태 전이
검색 조건은 음악인데 오류는 이전 책 요청에서 왔고 상세 제목은 또 다른 행사인 화면을 생각합니다. 개별 DOM 속성을 여러 콜백에서 고치면 각 문장은 맞아도 합쳐진 상태가 모순될 수 있습니다. 이번 레슨은 사용자 이벤트와 비동기 결과를 화면 상태 전이로 바꿔 한곳에서 판단합니다. 렌더러는 결정된 상태를 읽어 표시하고 검색 조건의 소유권이나 최신성 판단을 다시 수행하지 않습니다.
화면 상태는 kind·requestId·q·rows·error·selectedId로 정의합니다. kind는 idle·loading·success·empty·error 중 하나입니다. 여러 loading·hasError·isEmpty 불리언을 따로 관리하면 동시에 true인 조합을 만들기 쉽습니다. 하나의 kind와 그 상태에서 유효한 데이터 규칙을 함께 사용하면 무엇을 표시할지 명확해집니다. 프로젝트의 상세 404는 API 어댑터에서 not-found로 별도 안내하는 확장입니다.
초기 상태는 requestId 0, q 빈 문자열, kind idle, rows 빈 배열, error null, selectedId null입니다. search 이벤트를 받으면 requestId를 하나 늘리고 q를 교체하며 loading으로 바꿉니다. rows·error·선택은 초기화합니다. 이전 상세 항목을 유지하면 새 조건의 목록에 없는 제목이 남을 수 있으므로 이번 정책에서는 검색을 시작할 때 선택을 해제합니다.
success 이벤트는 현재 requestId와 같은 id이고 loading일 때만 처리합니다. rows를 새 배열로 복사하고 길이에 따라 success 또는 empty를 선택합니다. error는 null이며 selectedId도 null입니다. failure는 같은 최신성 조건에서 error 상태로 바꾸고 rows를 비우며 message를 저장합니다. 이벤트 도착 순서에 따라 결과가 달라지므로 테스트 이벤트를 순서대로 reduce합니다.
select 이벤트는 success 상태에서 실제 rows 안에 있는 행사 id에만 반응합니다. 존재하지 않는 id나 로딩 중 선택은 무시합니다. URL에서 상세 id를 읽는 실제 앱은 서버에 상세를 요청하며 404를 받을 수 있지만 이 순수 모델의 select는 이미 가진 목록의 선택만 다룹니다. 두 역할을 구분해서 없는 상세 링크의 상태와 목록 내부 선택의 상태를 같은 규칙으로 억지로 합치지 않습니다.
reset 이벤트는 검색을 시작하지 않고 idle로 돌아갑니다. 동시에 requestId를 하나 증가시켜 진행 중 결과가 나중에 들어와도 폐기합니다. 번호를 0으로 되돌리면 이후 새 search가 이전 요청과 같은 id를 사용할 수 있으므로 증가 규칙을 유지합니다. 입력 조건 초기화 버튼이 실제 전체 목록을 다시 요청하는 프로젝트에서는 reset 이후 search를 추가하는 정책으로 확장할 수 있습니다.
전이 함수는 현재 상태와 이벤트를 받아 새 상태를 반환합니다. DOM을 만들거나 fetch를 호출하거나 timer를 등록하지 않습니다. 효과가 없으므로 빈 이벤트와 잘못된 완료 순서를 작은 JSON 입력으로 검사할 수 있습니다. 조건을 실제 URL로 바꾸는 일, 요청을 보내는 일, 문구를 고치는 일은 밖에서 수행합니다. 테스트가 화면 구현 세부 대신 상태의 계약을 설명하게 만드는 이유입니다.
객체 전개만으로 모든 참조가 분리되지는 않습니다. rows 배열은 slice나 전개로 복사할 수 있지만 안쪽 행사 객체는 그대로 참조합니다. 여기서는 행사 객체의 필드를 수정하지 않는 계약을 사용합니다. 필요 없는 깊은 복사를 추가하기보다 쓰기 위치를 제한합니다. 이전 상태의 rows에 push하면 전이 함수 밖에서 보관한 상태 기록도 달라질 수 있으므로 새 배열을 반환하는지 확인합니다.
렌더러는 loading이면 안내와 빈 카드 영역, empty이면 조건 변경 행동, error이면 연결 안내와 재시도, success이면 카드 목록을 표시합니다. selectedId가 있으면 해당 행사의 상세를 보여 줍니다. 상태를 그리는 동안 requestId를 증가시키지 않습니다. 렌더링이 요청을 다시 시작하면 success를 그리는 순간 loading으로 돌아가는 루프가 생길 수 있습니다. 효과의 시작은 명시적인 사용자 이벤트에 연결합니다.
오류 문구는 실패의 종류와 사용자의 다음 행동을 함께 설명합니다. 취소된 요청은 failure 이벤트를 만들지 않습니다. 최신 HTTP 오류와 연결 실패는 어댑터가 reason으로 구분하고 사용자 안내는 별도의 표시 정책을 사용합니다. 이 브라우저 과제는 message 문자열을 저장하는 데 집중하며 서버 본문을 HTML로 삽입하지 않습니다. 실제 미션의 제목은 앞 단계처럼 textContent로 표시합니다.
상태 전이 표를 먼저 적으면 구현 누락을 찾기 쉽습니다. search는 어느 상태에서나 loading으로, 최신 success는 loading에서 success 또는 empty로, 최신 failure는 loading에서 error로 이동합니다. select는 success 안에서 선택만 바꾸고 reset은 어느 상태에서나 idle로 이동합니다. 오래된 완료와 알 수 없는 이벤트는 현재 상태를 그대로 반환합니다. 무시하는 이벤트도 테스트에 포함해야 판단 정책이 드러납니다.
브라우저 입력은 이벤트 배열이며 success의 rows는 id를 가진 행사 객체 배열입니다. search는 q, 완료는 id, failure는 message, select는 id를 사용합니다. 결과 객체의 키 순서는 kind·requestId·q·rows·error·selectedId입니다. 빈 배열 입력은 초기 객체를 출력합니다. 예제에서 row의 id와 완료 이벤트의 id가 같은 키 이름이지만 하나는 행사 식별자, 하나는 요청 식별자이므로 읽는 위치를 구분합니다.
Expected selectedId가 null인데 실제 값이 3이면 새 검색이나 빈 성공에서 이전 선택을 지우는 부분을 찾습니다. Expected kind가 loading인데 error라면 오래된 failure의 요청 번호 검사가 빠진 것입니다. Cannot read properties of undefined 오류가 나면 select에서 find 결과가 없을 때도 필드를 읽고 있는지 확인합니다. 조건에 맞지 않는 이벤트는 예외를 던지는 대신 현재 상태 유지가 이 과제의 계약입니다.
미션에서는 request-control.js가 시작·완료·실패 상태 발행과 생명 주기 정책을 모으고 results.js가 DOM과 URL을 연결합니다. session이 발행한 상태만 받아 카드와 안내를 바꾸므로 이전 응답의 렌더링 경로가 따로 남지 않아야 합니다. loading 때 상세 메타를 지우고 새 조건은 URL과 입력에 함께 반영합니다. 뒤로 가기로 새 경로를 읽을 때도 같은 session의 새 요청을 시작합니다.
마지막 점검은 네트워크 성공 한 장면으로 끝내지 않습니다. 정상 결과 뒤 선택, 선택 뒤 새 검색, 빈 결과, 현재 실패 후 재시도, 초기화 후 늦은 성공을 차례로 예상합니다. 각 전이에서 조건·카드·오류·선택이 같은 요청을 가리키는지 살펴봅니다. 상태 관리 도구 이름보다 이 규칙을 설명하는 것이 중요합니다. 배열과 객체의 추가 문법은 더 읽기로 연결하며 이번 제출은 실행되는 전이 함수와 경계 테스트입니다.
따라하기
검색에서 모순된 필드를 지웁니다
새 조건을 적용하면서 오류와 선택을 함께 해제합니다.
const old={kind:'error',rows:[{id:3}],error:'500',selectedId:3,requestId:1,q:'책'};const next={...old,kind:'loading',rows:[],error:null,selectedId:null,requestId:old.requestId+1,q:'음악'};console.log(JSON.stringify(next));실행 결과
{"kind":"loading","rows":[],"error":null,"selectedId":null,"requestId":2,"q":"음악"}
없는 항목 선택을 거절합니다
행사 id의 존재를 확인한 뒤 선택을 바꿉니다.
const s={kind:'success',rows:[{id:3}],selectedId:null};function select(id){return s.rows.some(row=>row.id===id)?{...s,selectedId:id}:s;}console.log(select(99).selectedId);console.log(select(3).selectedId);실행 결과
null 3
배열 복사와 객체 공유를 구분합니다
새 배열을 만든 것과 안쪽 객체까지 복사한 것은 다른 계약입니다.
const old=[{id:3,name:'책'}];const next=[...old];console.log(old===next);console.log(old[0]===next[0]);실행 결과
false true
확인 문제
실습
search(q)·success(id, rows)·failure(id, message)·select(id)·reset 이벤트 배열을 전이 함수로 처리합니다. 완료 id는 요청 번호이고 row.id와 select.id는 행사 번호입니다. 본문의 초기값과 무시 규칙에 따라 kind·requestId·q·rows·error·selectedId 객체를 출력합니다.
모범 답안
const events=JSON.parse(require('fs').readFileSync(0,'utf8'));
const initial=id=>({kind:'idle',requestId:id,q:'',rows:[],error:null,selectedId:null});
function transition(s,e){
if(e.type==='reset')return initial(s.requestId+1);
if(e.type==='search')return {...initial(s.requestId+1),kind:'loading',q:e.q};
if(e.type==='select')return s.kind==='success' && s.rows.some(row=>row.id===e.id)?{...s,selectedId:e.id}:s;
if(s.kind!=='loading' || e.id!==s.requestId)return s;
if(e.type==='success')return {...s,kind:e.rows.length?'success':'empty',rows:[...e.rows],error:null,selectedId:null};
if(e.type==='failure')return {...s,kind:'error',rows:[],error:e.message,selectedId:null};
return s;
}
console.log(JSON.stringify(events.reduce(transition,initial(0))));더 읽기
면접 질문
- 검색 요청이 순서와 다르게 도착할 때의 처리 방식을 설명합니다.
- 로딩·오류·빈 결과 화면을 나눈 이유를 설명합니다.