상태의 소유 위치
210분 안팎
학습 목표
공유 상태와 지역 상태 및 파생 값을 구분합니다.
개념
상태의 소유 위치
검색 조건을 폼에도 저장하고 URL에도 저장하고 카드에도 저장하면 각각 다른 값이 되는 순간을 놓치기 쉽습니다. 지역 행사 사이트에서 검색어가 책인데 목록은 음악을 보여 주는 문제는 화면을 그리는 함수보다 상태의 주인이 불분명해서 생길 수 있습니다. 이 레슨은 어떤 값을 원본으로 삼을지, 어떤 값은 계산할지 정합니다. 저장 위치를 결정한 뒤 갱신 경로를 하나씩 추적합니다.
공유 상태는 여러 화면 역할이 함께 읽어야 하는 정보입니다. 이 앱의 q와 region은 폼과 목록과 목록 복귀 링크가 사용합니다. 선택 id는 상세와 목록 복귀가 함께 사용합니다. 따라서 카드 하나가 아니라 이들을 조립하는 initApp의 route가 소유합니다. 가장 위에 모두 올리는 방식이 정답은 아닙니다. 함께 읽고 변경하는 범위에서 가장 가까운 공통 소유자를 고르는 것이 출발점입니다.
지역 상태는 특정 역할의 수명과 연결된 정보입니다. 한국어 입력이 조합 중인지 나타내는 composing은 입력 처리에 필요하고 다른 카드가 알 필요는 없습니다. 요청 controller와 타이머는 createSearch가 생성하고 종료합니다. 결과가 성공했는지 여부와 선택 id를 카드 내부에 각각 복사하면 공유 상태가 지역 상태인 것처럼 흩어집니다. 변수의 이름보다 누가 읽고 어느 시점에 사라져야 하는지를 보고 분류합니다.
파생 값은 이미 저장한 원본에서 다시 계산할 수 있는 값입니다. 현재 결과 수는 rows.length이며 선택 행사는 rows.find로 찾습니다. count를 별도 저장하면 응답 교체 때 rows만 바꾸고 count는 잊을 수 있습니다. selectedEvent 객체를 별도 저장하면 같은 id의 제목이 갱신되어도 상세는 옛 제목을 보여 줄 수 있습니다. 계산이 단순한 이번 데이터에서는 중복 저장보다 원본을 읽어 계산하는 편이 일관성을 유지하기 쉽습니다.
선택 id와 선택 객체는 다릅니다. id는 어떤 행사를 보고 싶은지라는 사용자 의도이며 객체는 현재 응답에 그 행사가 있는지라는 데이터 사실입니다. id가 null이면 목록입니다. 숫자 id가 있지만 rows에 없으면 selected는 null로 계산합니다. 선택이 없다는 이유로 임의의 첫 행사를 보여 주면 잘못된 상세를 정상처럼 보이게 합니다. 찾을 수 없음 안내와 목록으로 가는 행동을 제공하는 것이 적절합니다.
이번 브라우저 실습은 state에 q, region, selectedId, rows를 받습니다. rows의 각 행은 고유한 양의 정수 id와 문자열 name을 가진다고 가정합니다. q와 region은 이미 적용된 검색 조건이며 함수가 rows를 재검색하지 않습니다. 출력 키는 q, region, count, selected입니다. selected는 찾은 행의 id와 name만 담고 없으면 null입니다. selectedId가 null인 입력과 오래된 id인 입력을 별개 경계 사례로 검사합니다.
서버가 조건에 맞는 배열을 보내고 난 뒤의 상태 투영을 연습하므로 여기서는 요청 완료 순서를 다루지 않습니다. 앞 모듈의 최신 요청 검사가 먼저 적용된 결과만 rows에 들어온다는 계약을 둡니다. 투영 함수에서 generation을 새로 만들면 요청 소유자가 둘이 됩니다. 실제 앱은 createSearch가 최신성을 결정하고 results가 성공 rows를 받아 화면을 계산합니다. 역할마다 소유해야 할 상태의 종류가 다릅니다.
URL은 공유 가능한 조건 표현입니다. 검색 실행 시 부모가 route를 갱신하고 URL에 쓰며, popstate에서는 바뀐 URL을 읽어 route를 복원합니다. 폼 값은 화면에서 편집 중인 값이므로 제출 전부터 항상 URL과 같다고 단정하지 않습니다. 이 앱은 입력 이벤트를 받아 새 조건을 부모에게 전달하고 예약 검색을 시작합니다. 폼과 URL 사이의 읽기·쓰기 시점을 명시하면 뒤로 이동했을 때 옛 조건을 복원하는 이유를 설명할 수 있습니다.
부모가 route를 소유한다는 말은 다른 함수에 같은 객체 참조를 보내 마음대로 수정하게 한다는 뜻이 아닙니다. 폼은 새로운 조건 객체를 전달하고 부모는 navigate로 갱신합니다. 카드에는 행사와 링크만 전달합니다. 데이터가 아래로 내려가고 사용자 행동은 콜백 또는 이벤트로 위에 전달됩니다. 이 경로가 있어야 누가 검색 의도를 바꾸었는지 찾을 수 있고, 카드 렌더링 중 검색이 다시 시작되는 사고도 피할 수 있습니다.
클로저는 상태를 감추는 도구지만 소유권을 자동으로 올바르게 만들지는 않습니다. createOwner를 호출할 때마다 서로 다른 변수가 만들어집니다. 목록과 상세가 같은 상태를 사용해야 한다면 부모가 만든 한 인스턴스를 공유해야 합니다. 반대로 독립된 검색 위젯 둘이 있다면 두 인스턴스가 서로의 조건을 덮지 않아야 합니다. 인스턴스 수와 사용자 화면의 독립 범위를 함께 그리면 전역 상태 사용 여부를 판단하기 쉽습니다.
얕은 복사는 객체 내부의 배열까지 복제하지 않습니다. state를 펼쳐 만든 객체의 rows는 원본 배열과 같은 참조일 수 있습니다. 이번 투영 함수는 입력을 바꾸지 않고 필요한 출력 객체를 새로 만듭니다. rows.sort나 selected.name 대입을 넣지 않습니다. 결과 배열의 순서 변경이 필요하면 앞 모듈에서 배운 복사 규칙을 적용합니다. 공유 참조를 갖는 것과 그 참조를 수정할 권한이 있는 것은 구분합니다.
빈 결과는 count가 0이고 selected가 null인 정상 상태입니다. null을 읽을 때 selected.name을 직접 사용하면 TypeError가 납니다. 오류 메시지에서 Cannot read properties of null이 보이면 find 결과가 없는 경로를 먼저 재현합니다. 수를 문자열로 만들어 JSON에 넣으면 기대한 숫자 0과 달라집니다. 확인할 때 출력 JSON의 모양뿐 아니라 count의 자료형과 selected의 null 여부도 비교합니다.
따라하기는 원본 교체 후에도 파생 값이 자동으로 바뀌는 모습을 보여 줍니다. 행 하나를 가진 state의 count를 계산한 다음 rows를 빈 배열로 교체합니다. count를 따로 유지할 필요 없이 다시 계산하면 0이 됩니다. 다음 단계는 독립된 두 소유자가 서로 영향을 주지 않는지 관찰합니다. 마지막 단계는 존재하지 않는 선택을 null로 표현합니다. 각 작은 사례를 실제 화면 상태 표의 규칙과 연결해 봅니다.
실습을 마치면 STATE.md에 값 이름, 원본 소유자, 읽는 역할, 변경 계기, 파생 여부를 표로 적습니다. q와 region은 부모의 검색 의도이며 count는 rows에서 계산하고 composing은 입력 수명에 속한다고 설명할 수 있어야 합니다. 상태를 한곳으로 모았다는 말만으로는 충분하지 않습니다. 새 응답과 뒤로 이동과 화면 이탈에서 누가 무엇을 바꾸는지를 사례로 말하며 클로저의 추가 원리는 더 읽기로 확인합니다.
결과 수가 커서 계산 비용이 생긴다면 파생 값을 캐시할 수도 있습니다. 그 경우에는 어떤 원본 변경이 캐시를 무효화하는지 규칙이 필요합니다. 이번 결과 수는 배열 길이 읽기로 충분하므로 성능을 이유로 count를 별도 상태에 넣지 않습니다. 상태를 추가할 때마다 초기 값과 성공과 실패와 초기화와 복귀에서 그 값의 일관성을 유지해야 한다는 비용을 고려합니다. 필요한 원본만 저장하는 판단은 작은 앱에서도 효과가 있습니다.
따라하기
파생 수를 다시 계산합니다
코드를 demo.cjs에 저장하고 node demo.cjs로 실행합니다. 출력과 위 계약을 비교합니다.
const state={rows:[{id:1,name:'책 모임'}]};const count=s=>s.rows.length;console.log(count(state));state.rows=[];console.log(count(state));실행 결과
1 0
두 소유자를 독립시킵니다
코드를 demo.cjs에 저장하고 node demo.cjs로 실행합니다. 출력과 위 계약을 비교합니다.
function createOwner(){let q='';return {set:x=>{q=x;},get:()=>q};}const a=createOwner(),b=createOwner();a.set('책');console.log(JSON.stringify([a.get(),b.get()]));실행 결과
["책",""]
없는 선택을 null로 표현합니다
코드를 demo.cjs에 저장하고 node demo.cjs로 실행합니다. 출력과 위 계약을 비교합니다.
const state={selectedId:9,rows:[{id:1,name:'음악 모임'}]};const selected=state.rows.find(e=>e.id===state.selectedId)??null;console.log(JSON.stringify({count:state.rows.length,selected}));실행 결과
{"count":1,"selected":null}
확인 문제
실습
이미 검색된 state JSON을 받아 q·region·count·selected 순서의 객체를 출력합니다. count는 rows 길이이며 selected는 selectedId와 같은 행의 id·name만 담습니다. null 선택 또는 없는 id는 null입니다. 조건으로 재검색하거나 입력 배열을 수정하지 않습니다. 본문의 입력 계약을 따릅니다.
모범 답안
const fs=require('fs');const s=JSON.parse(fs.readFileSync(0,'utf8'));function project(s){const e=s.selectedId===null?null:s.rows.find(e=>e.id===s.selectedId);return {q:s.q,region:s.region,count:s.rows.length,selected:e?{id:e.id,name:e.name}:null};}console.log(JSON.stringify(project(s)));더 읽기
면접 질문
- 컴포넌트의 상태 위치를 정한 사례를 설명합니다.