Devin.KR

상세와 목록 복귀

150분 안팎

학습 목표

상세 진입과 뒤로 가기에서 검색 조건을 유지합니다.

개념

상세를 보고 돌아오는 비용을 줄입니다

지역을 부산으로 좁히고 책을 검색한 뒤 행사 상세를 보았습니다. 목록으로 돌아왔을 때 전체 목록이 나오고 첫 입력칸으로 이동하면 사용자는 검색을 다시 해야 합니다. 상세 이동은 선택 id만 바꾸고 목록의 검색어와 지역은 유지하도록 만듭니다. 상태를 URL로 표현하면 직접 상세 링크와 새로고침에서도 같은 조건을 복원할 수 있습니다.

이번 앱의 경로는 results.html 하나입니다. q와 region은 목록 조건이고 id가 있으면 상세 화면입니다. 예를 들어 results.html?q=책&region=busan&id=2는 책·부산 조건에서 행사 2의 상세를 뜻합니다. 조건 데이터와 현재 표시 화면을 함께 표현하되, 포커스를 돌려줄 링크 id는 URL의 검색 조건과 별도로 history state에 저장합니다.

문자열 연결 대신 URL 객체를 씁니다

검색어에는 공백, &, #, 한글이 들어갈 수 있습니다. 주소 문자열에 q= 뒤로 그대로 이어 붙이면 &는 다음 매개변수, #는 fragment로 해석될 수 있습니다. new URL(base)로 주소를 만들고 searchParams.set에 값을 전달합니다. URLSearchParams가 쿼리 값을 인코딩하며 get으로 읽을 때 복원합니다. 읽어 온 값에 decodeURIComponent를 다시 적용하지 않습니다.

buildUrl(base, conditions, id)는 주소 객체를 반환합니다. base는 절대 URL이며 실제 앱에서는 location.href를 전달합니다. q와 region을 설정하고 기존 id를 지운 뒤 상세인 경우만 새로운 id를 넣습니다. 목록 URL에서는 id 매개변수가 없어야 합니다. 이전 상세의 id가 남으면 목록으로 돌아가도 상세가 다시 표시되는 오류가 생깁니다.

URL에 비밀값이나 사용자 개인정보를 넣지 않습니다. 이번 입력은 공개 행사 검색어와 지역입니다. 경로를 사용자 입력에서 선택하지 않고 같은 results.html을 유지합니다. 링크 주소는 buildUrl 결과이며 행사 제목은 textContent로 처리합니다. URL 인코딩과 HTML 텍스트 삽입은 서로 다른 경계이므로 하나를 처리했다고 다른 경계까지 안전해졌다고 판단하지 않습니다.

외부 경로도 앱이 읽을 수 있게 합니다

readRoute는 URL의 q, region, id를 상태 객체로 반환합니다. q가 없으면 빈 문자열이고 지역은 all, seoul, busan만 허용합니다. 잘못된 지역은 all로 바꿉니다. id가 없으면 목록을 뜻하는 null, 유효한 숫자라면 그 숫자, 잘못된 표기라면 찾을 수 없는 상세를 뜻하는 -1을 반환합니다. 누락과 잘못된 id를 구분하는 정책입니다.

문자열 2x를 2로 받아들이지 않도록 전체 문자열을 정규식으로 검사하고 숫자 안전 범위도 확인합니다. id는 양의 정수 계약이며 실제 존재 여부는 원본 행사 배열에서 다시 확인합니다. 표기가 올바른 999도 데이터에 없다면 찾을 수 없는 상세입니다. 경로 유효성과 도메인 존재 여부는 다른 검사입니다. 없는 행사 안내에는 목록으로 돌아갈 수 있는 링크를 제공합니다.

새로고침 후 목록 조건은 URL로 복원할 수 있습니다. DOM 입력값은 상태에서 다시 채웁니다. history state가 있더라도 검색어의 기준을 두 군데에 중복 저장하지 않습니다. 표시 상태는 q, region, id 객체가 소유하고 화면은 그것을 반영합니다. 나중에 API가 추가되어도 URL의 조건을 읽는 규칙은 유지할 수 있습니다.

주소 이력과 화면 갱신을 함께 처리합니다

history.pushState는 새 이력 항목을 만들지만 페이지를 새로 받거나 popstate를 즉시 발생시키지 않습니다. 따라서 앱 내부 검색과 상세 이동에서는 pushState 뒤 render를 직접 호출합니다. 주소만 바뀌고 화면이 안 바뀌는 버그는 이 둘을 분리해서 빠뜨렸을 때 나타납니다. 초기 경로 정리나 현재 이력 정보 보완에는 replaceState를 써서 이력을 불필요하게 추가하지 않습니다.

브라우저 뒤로·앞으로 이동에서는 popstate를 듣고 현재 location.href를 다시 읽어 render합니다. 이때 pushState를 또 호출하면 뒤로 이동할 때 새로운 이력을 계속 만들게 됩니다. 사용자 이동을 복원하는 경로에서는 렌더링만 합니다. 모델 검사도 뒤로와 앞으로가 조건을 바꾸고 새 이력을 늘리지 않는지 확인합니다.

상세를 열기 직전 목록 항목의 history state에 returnId를 저장합니다. 새 상세 항목에는 fromList 표시를 넣습니다. 상세의 목록 링크는 앱에서 방금 목록으로부터 들어온 경우 history.back을 사용합니다. 직접 상세 URL을 연 경우에는 history.back으로 외부 페이지에 나가지 않고 조건을 유지한 목록 URL로 replaceState합니다. 방문 경로에 따라 복귀 전략이 달라집니다.

포커스는 새 요소에서 다시 찾습니다

상세가 나타나면 상세 h2에 tabindex=-1을 두고 focus를 호출합니다. 음수 tabindex는 평상시 Tab 순서에는 넣지 않으면서 코드로 포커스를 줄 수 있게 합니다. 숨겨진 목록의 링크에 포커스를 남겨 두지 않습니다. 상세에서 돌아오면 목록을 다시 그린 뒤 returnId와 같은 data-event-id를 가진 링크를 찾아 focus합니다.

렌더링 전에 저장한 링크 객체는 목록 교체 후 문서에 없는 옛 요소입니다. 그 참조에 focus를 호출하지 않고 새 목록에서 식별자로 찾습니다. 선택 행사가 더 이상 검색 결과에 없으면 query 입력을 대체 포커스로 사용합니다. 데이터 변화나 잘못된 직접 링크에서도 사용자가 이어서 행동할 수 있도록 복귀 경로를 제공합니다.

선택 링크를 찾을 때는 모든 행사 링크를 모아 dataset 문자열과 String(id)를 비교합니다. 외부 문자열을 CSS 선택자에 직접 붙이지 않습니다. 실제 스크린 리더의 읽기 순서와 초점 이동 경험은 모델 검사만으로 확인하지 못합니다. 별도 키보드·보조 기술 관찰을 적고 검증 범위를 설명합니다.

실패를 이력과 조건으로 나눠 읽습니다

뒤로 눌렀는데 상세가 그대로라면 popstate 등록과 현재 URL 재해석부터 봅니다. 조건이 사라졌다면 상세 href에 q와 region을 넣었는지 확인합니다. 포커스만 사라지면 렌더링 이후 찾은 새 링크인지 확인합니다. SecurityError는 같은 출처가 아닌 주소를 history에 넘겼는지 의심하고 TypeError: Invalid URL은 base가 절대 URL인지 확인합니다.

레슨 starter는 buildUrl과 readRoute 두 함수만 미구현입니다. 특수문자 검색어 왕복, id 제거, 잘못된 지역과 id의 경계 검사를 통과시킵니다. 실제 화면과 history 연결은 제공 앱에서 관찰하고 미션에서 확장합니다. Node URL 예제는 주소 계산만 검사하며 브라우저 이력과 포커스 자체가 통과했다는 증거로 쓰지 않습니다.

직접 링크도 완료 기준에 넣습니다

정상 검색에서 상세로 이동한 경우뿐 아니라 id=999 직접 주소, 검색어에 &와 #가 있는 주소, 목록 뒤로와 상세 앞으로를 시험합니다. 기존 index.html은 소개 화면으로 남습니다. 브라우저에서 로컬 HTTP를 사용하고 file 주소에서의 이력 동작에 의존하지 않습니다. harness는 서버를 시작하고 정리하며 환경상 실행하지 못하면 미확인으로 기록합니다.

완료 후에는 검색 조건은 URL, 복귀 초점 단서는 history state, 실제 카드 목록은 계산 결과라는 책임 구분을 설명합니다. 더 읽기의 이벤트 장에서 이벤트 등록과 기본 행동을 복습할 수 있습니다. 이 레슨은 URL 왕복 함수와 복귀 시점의 판단을 직접 구현하는 데 집중합니다. 다음 레슨은 같은 상태 모델로 빈 결과의 회복 행동을 추가합니다.

API 동작의 범위는 History API MDN 문서에서도 확인합니다.

따라하기

실습 파일과 이전 계약 확인

아래 실습 starter zip을 풀고 그 폴더의 터미널에서 npm ci 후 npm test를 실행합니다. 외부 의존 패키지는 없습니다. starter의 미구현 검사가 실패하는지 먼저 확인합니다. assets/data.js는 앞 모듈의 완성 함수이며 변경하지 않습니다.

이번에 고칠 함수는 buildUrl, readRoute입니다. HTML과 테스트 모델은 제공되며 모델의 범위를 README에서 확인합니다.

경계값을 Node로 실행

아래 코드를 example.cjs에 저장해 node example.cjs로 실행합니다. DOM이나 브라우저 이력이 아닌 문자열·값 계산 확인입니다.

const url=new URL('http://localhost/results.html');
url.searchParams.set('q','책 & # 모임');
url.searchParams.set('region','busan');
url.searchParams.set('id','2');
console.log(url.searchParams.get('q'));
url.searchParams.delete('id');
console.log(url.searchParams.has('id'));

실행 결과

책 & # 모임
false

TODO 구현과 테스트 대조

assets/dom.js의 해당 TODO를 자신의 코드로 구현합니다. 막히면 아래 기준 구현과 비교합니다. 파일 안의 api 내보내기와 다른 제공 함수는 유지합니다. 코드는 브라우저 문서 또는 제공 모델을 doc로 전달받으며 Node 전역 document를 사용하지 않습니다.

function buildUrl(base, conditions, id) {
  const url = new URL(base);
  url.searchParams.set('q', conditions.q); url.searchParams.set('region', conditions.region);
  url.searchParams.delete('id');
  if (id !== null) url.searchParams.set('id', String(id));
  return url;
}

function readRoute(url) {
  const p = new URL(url).searchParams; const raw = p.get('id');
  return {q:p.get('q') || '', region:['all','seoul','busan'].includes(p.get('region')) ? p.get('region') : 'all',
    id:raw === null ? null : (/^[1-9]\d*$/.test(raw) && Number.isSafeInteger(Number(raw)) ? Number(raw) : -1)};
}

npm test를 다시 실행해 각 기대값과 실제값을 비교합니다. 모든 검사가 통과하면 solution과 비교해 원인을 설명합니다. 출력으로 통과를 위장하거나 테스트를 수정하지 않습니다.

실제 화면과 미션 연결

로컬 브라우저가 있는 환경에서 npm run test:browser를 실행하거나 python3 -m http.server 8000 --bind 127.0.0.1로 열어 http://127.0.0.1:8000/browser-tests.html을 확인합니다. 이 작성 샌드박스에서는 서버 바인딩이 제한되어 실제 브라우저 실행은 미확인입니다. 수동 서버를 사용했다면 관찰 후 터미널에서 Ctrl+C로 종료합니다.

results.html에서 검색→필터→상세→복귀·빈 결과→초기화를 확인하고 실제 Tab·Enter, 320px, 200% 확대를 LAYOUT.md에 기록합니다. 자동 click은 실제 키보드 동선 검사를 대신하지 않습니다. 미션 starter는 m03 solution을 이어받았으므로 검증된 데이터·문서·CSS를 유지하면서 submit·상세 이동·수 안내 TODO를 연결합니다.

확인 문제

실습

상세 진입과 뒤로 가기에서 검색 조건을 유지합니다. starter zip을 풀고 npm ci 후 npm test를 실행합니다. assets/dom.js의 buildUrl, readRoute를 구현하며 제공 함수·테스트·원본 데이터를 수정하지 않습니다. 정상·빈 목록·잘못된 id·반복 렌더링 경계 중 해당 레슨 검사를 통과시킵니다. README의 실제 브라우저 확인과 모델 검사의 차이를 기록합니다.

시작 코드·테스트 내려받기

실행 명령

npm test

기대 결과

DOM 모델 검사 10개, 실패 0개. starter는 해당 TODO 관련 검사 실패, solution은 전부 통과합니다.

모범 답안모범 답안 내려받기

더 읽기

면접 질문

  • 키보드만으로 폼을 사용하는 흐름을 설명합니다.