Devin.KR

검색과 이벤트 위임

150분 안팎

학습 목표

폼 제출과 목록 클릭을 이벤트로 연결합니다.

개념

검색 버튼보다 폼 제출을 듣습니다

사용자는 검색 버튼을 누를 수도 있고 검색어 입력 중 Enter를 누를 수도 있습니다. 버튼 click에만 검색을 넣으면 두 행동이 다른 경로가 됩니다. form의 submit을 한 번 등록하고 preventDefault로 기본 페이지 이동을 취소한 뒤 앞 모듈 searchEvents를 호출합니다. 이 앱에서는 조건을 URL에 반영하고 화면을 갱신하는 코드가 기본 이동을 대신합니다.

기본 동작 취소와 이벤트 전파 중지는 다른 작업입니다. preventDefault는 폼 제출이나 링크 이동 같은 기본 행동을 취소하며 부모에게 이벤트가 전달되는 일을 막지 않습니다. stopPropagation을 폼 검색 해결책으로 사용하면 기본 제출은 남고 상위 처리만 끊길 수 있습니다. 이번 실습은 전파를 막지 않고 처리할 행동의 기본 동작만 필요한 순간 취소합니다.

값을 읽는 시점이 검색 조건입니다

readConditions(form)은 form.elements.q.value와 form.elements.region.value를 읽어 객체를 반환합니다. value는 사용자가 현재 입력한 값입니다. textContent나 HTML의 value 속성 문자열을 읽으면 현재 편집값을 놓칠 수 있습니다. form.elements는 name을 기준으로 찾으므로 q와 region 이름을 기존 HTML과 맞춥니다. 변수 query와 폼 필드 q의 이름을 혼동하지 않습니다.

검색어 정규화는 앞 모듈 data.js에서 이미 검사했습니다. 이벤트 코드에서 또 공백 규칙을 만들지 않습니다. 표시 입력값은 사용자가 적은 원문을 보존하고 계산 함수가 정규화한 값으로 비교합니다. 이번 지역 필터는 select를 바꾼 즉시 실행하지 않고 검색을 제출할 때 함께 적용합니다. 화면 안내에도 이 정책을 적어 언제 목록이 바뀌는지 알 수 있게 합니다.

조건 초기화는 HTML reset 동작에 맡기지 않고 type=button 클릭에서 검색어를 빈 문자열, 지역을 all로 명시합니다. form.reset은 컨트롤 기본값으로 되돌리는 동작이며 기본값이 변경되면 원하는 전체 정책과 달라질 수 있습니다. 폼 값만 지워도 기존 검색 결과와 주소는 남습니다. 미션의 reset 함수는 상태·URL·화면·입력 포커스를 함께 갱신합니다.

동적 목록에서 위임을 선택합니다

검색을 다시 하면 카드 요소가 교체됩니다. 처음 카드 각각에 click을 등록하면 새 카드에 동작이 없을 수 있습니다. result-list는 교체하지 않고 자식만 바꾸므로 이 부모에 리스너 하나를 등록합니다. 카드 내부에서 발생한 click이 부모까지 전달되는 버블링을 이용해 어느 행사 링크인지 판단합니다. 이 방식은 이후 추가된 카드도 같은 리스너가 처리합니다.

event.target은 최초 클릭 대상이고 currentTarget은 지금 리스너가 등록된 목록입니다. 링크의 span을 누르면 target.dataset에는 행사 id가 없습니다. target.closest를 이용해 자신 또는 상위 a[data-event-id]를 찾습니다. 단순히 target.tagName이 A인지 검사하면 중첩 레이블 클릭이 누락됩니다. 위임은 한 번의 부모 등록과 클릭 시 대상 탐색으로 이루어집니다.

closest가 링크를 찾았어도 list.contains(link)를 확인합니다. 같은 selector를 가진 다른 영역의 요소까지 처리하지 않도록 소유 범위를 확인하는 것입니다. 배경 클릭이나 링크 없는 카드 영역에서는 null을 반환하고 아무 동작도 하지 않습니다. 모든 클릭을 먼저 preventDefault로 막으면 앱과 관계없는 링크 행동까지 잃을 수 있으므로 유효한 대상 확인 뒤 취소합니다.

식별자를 엄격하게 검사합니다

delegatedId는 문자열 id가 1 이상의 정수 표기인지 검사하고 Number.isSafeInteger도 확인합니다. 문자열 2x를 parseInt로 읽으면 2가 되어 다른 행사를 여는 문제가 생길 수 있습니다. 빈 문자열, 0, 음수, 소수, 너무 큰 숫자는 이 계약의 행사 id가 아닙니다. 검증된 id만 숫자로 반환하고 나머지는 null입니다. 앞 모듈에서 사용한 숫자 id와 같은 타입으로 검색합니다.

행사 이름을 식별자로 사용하지 않습니다. 이름은 바뀔 수 있고 서로 같은 행사도 있을 수 있습니다. DOM에는 숫자 id의 문자열 표현을 붙이고 원본 배열에서 그 id를 찾습니다. 카드 글자를 파싱해 검색 조건이나 식별자를 복원하면 문구 변경만으로 동작이 깨집니다. 데이터와 표시 문자열을 분리하면 테스트도 특정 문구에 덜 의존합니다.

리스너의 수명을 렌더링과 분리합니다

render 함수는 현재 상태에서 화면을 만드는 함수입니다. 여기에 addEventListener를 넣으면 검색할 때마다 새로운 익명 함수가 등록될 수 있습니다. 클릭 한 번에 주소 이력이 두 번 추가되거나 상세가 두 번 그려지는 증상이 생깁니다. 등록은 초기화 함수에서 한 번 수행하고 렌더링은 목록 교체만 합니다. 제공 initApp은 같은 문서에 다시 호출되어도 기존 인스턴스를 반환합니다.

이번 앱은 문서 전체가 살아 있는 동안 한 번 초기화합니다. 컴포넌트 제거 후 재사용하는 일반적인 수명 관리는 이후 모듈에서 다룹니다. 한 번 등록했다는 사실은 재렌더링으로 확인해야 합니다. 초기 목록뿐 아니라 두 번째 검색 이후 카드와 나중에 붙인 카드도 눌러 봅니다. 모델 테스트는 재초기화 뒤 리스너 개수와 주소 push 호출 횟수를 검사합니다.

상세 이동은 href가 있는 링크에 제공됩니다. 일반 왼쪽 클릭이나 키보드로 생성된 click만 앱이 처리하고 Ctrl/Command·Shift·Alt 클릭은 기본 행동을 유지합니다. 가운데 클릭은 별도 브라우저 행동이므로 앱이 막지 않습니다. 이렇게 하면 링크의 새 탭 기능을 잃지 않습니다. 링크를 버튼처럼 다루며 모든 click을 취소하는 구현과 결과를 비교해 봅니다.

실패 메시지에서 위치를 좁힙니다

form.elements.q가 undefined라면 HTML input의 name과 테스트 폼 계약을 확인합니다. 값이 바뀌지 않으면 value 대신 textContent를 읽었는지 살펴봅니다. target.closest is not a function은 이벤트 대상이 요소가 아닌 경우일 수 있습니다. 제공 helper는 closest 함수 존재부터 검사합니다. 데이터 검색 결과가 올바른데 화면만 그대로라면 계산 결과를 renderList에 전달하는 경로를 확인합니다.

실습 starter는 readConditions와 delegatedId만 미구현이고 카드 렌더링은 제공됩니다. npm test의 기대값과 실제값을 읽어 조건 읽기 실패인지 위임 탐색 실패인지 구분합니다. 테스트 자체를 통과하도록 바꾸지 않습니다. 모델이 click 버블링 자체를 구현하지 않으므로 부모에 이벤트를 전달하는 검사와 실제 브라우저의 중첩 클릭 관찰을 구분합니다.

같은 흐름을 키보드로 확인합니다

브라우저에서 검색어를 입력하고 Enter를 누른 다음 지역을 바꿔 검색 버튼을 누릅니다. 결과가 바뀐 뒤 새 링크의 span을 클릭하고 Tab·Enter로도 상세가 열리는지 확인합니다. 결과가 0건인 경우까지 반복해도 검색 리스너가 추가되지 않아야 합니다. 실제 브라우저 검사 파일은 기본 submit과 중첩 click을 함께 확인하며 작성 환경에서는 실행 미확인입니다.

완료 기준은 이벤트 이름을 외우는 것이 아니라 한 사용자 행동이 조건 읽기, 데이터 검색, 렌더링, 주소 반영으로 이어지는 순서를 설명하는 것입니다. 더 읽기에서는 등록·해제와 전파를 넓혀 읽습니다. 이번 레슨의 제출물은 두 helper 구현, 정상·경계 테스트 통과, 반복 검색 이후의 위임 관찰입니다. 미션에서 이 helper를 실제 앱의 submit과 click 흐름에 연결합니다.

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

따라하기

실습 파일과 이전 계약 확인

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

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

경계값을 Node로 실행

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

const raw='2x';
console.log(/^[1-9]\d*$/.test(raw));
console.log(Number.isSafeInteger(Number('2')));

실행 결과

false
true

TODO 구현과 테스트 대조

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

function readConditions(form) {
  return {q: form.elements.q.value, region: form.elements.region.value};
}

function delegatedId(target, list) {
  const link = target && typeof target.closest === 'function' ? target.closest('a[data-event-id]') : null;
  if (!link || !list.contains(link)) return null;
  const raw = link.dataset.eventId;
  return /^[1-9]\d*$/.test(raw) && Number.isSafeInteger(Number(raw)) ? Number(raw) : null;
}

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의 readConditions, delegatedId를 구현하며 제공 함수·테스트·원본 데이터를 수정하지 않습니다. 정상·빈 목록·잘못된 id·반복 렌더링 경계 중 해당 레슨 검사를 통과시킵니다. README의 실제 브라우저 확인과 모델 검사의 차이를 기록합니다.

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

실행 명령

npm test

기대 결과

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

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

더 읽기

면접 질문

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