Devin.KR

DOM으로 카드 렌더링

150분 안팎

학습 목표

텍스트와 구조를 구분해 행사 카드를 만듭니다.

개념

목록을 데이터에서 다시 만듭니다

앞 모듈에서 행사 배열을 검색하고 날짜순으로 정렬했습니다. 반환 배열이 올바르더라도 화면에 예전 카드가 남으면 사용자는 검색이 실패했다고 느낍니다. 이번 목표는 함수의 결과를 DOM 카드로 변환하는 것입니다. 검색 조건 계산은 assets/data.js에 남기고, 표시 책임을 assets/dom.js에 둡니다. 새로운 지역 조건이나 API가 들어와도 카드 만드는 규칙을 다시 쓰지 않게 합니다.

DOM은 브라우저가 문서를 요소 객체의 트리로 표현한 것입니다. document.createElement는 아직 문서에 붙지 않은 요소를 만들고 append는 부모의 자식으로 연결합니다. article 아래 h2, p, a를 넣어 카드의 구조를 만듭니다. h2는 results.html의 h1 아래 행사 제목이라는 의미를 갖습니다. 기존 CSS의 card 클래스를 유지해 앞 모듈의 반응형 규칙을 재사용합니다.

구조와 외부 텍스트를 분리합니다

행사 이름에는 꺾쇠나 따옴표가 들어갈 수 있습니다. 이름을 HTML 문자열에 끼워 넣으면 글자가 아니라 태그로 해석될 수 있습니다. 제목 요소를 코드로 생성하고 제목.textContent에 이름을 대입합니다. 예를 들어 <img src=x onerror=alert(1)>라는 이름도 그대로 글자로 표시해야 합니다. 서버가 보낸 이름도 사람이 입력한 데이터일 수 있으므로 같은 원칙을 적용합니다.

이 실습은 일반 텍스트 삽입만 다룹니다. textContent가 링크 주소나 모든 웹 보안 문제까지 해결하는 것은 아닙니다. href는 행사 이름에서 가져오지 않고 앱이 만든 같은 페이지 URL로 설정합니다. 이벤트 처리 코드도 입력값으로 만들지 않습니다. 태그처럼 생긴 제목을 거절하는 대신 내용으로 보존하면서 해석하지 않는 경계를 선택합니다.

요소의 textContent에 새 값을 대입하면 기존 자식이 제거됩니다. 따라서 카드 전체에 제목을 넣은 뒤 링크를 기대해서는 안 됩니다. 제목은 h2, 지역과 날짜는 p, 상세 안내는 a의 span에 각각 넣습니다. 부모 article은 구조를 유지하고 자식이 텍스트를 책임지게 합니다. 테스트는 제목의 글자와 자식 수를 함께 확인해 HTML 제목에 img가 생기지 않는지 검사합니다.

카드 계약을 작은 함수로 만듭니다

createCard(doc, event, href)는 문서, 행사 객체, 앱에서 만든 상세 URL을 받아 article을 반환합니다. 행사에는 숫자 id, 문자열 name와 region, YYYY-MM-DD date가 있다는 앞 모듈 계약을 사용합니다. API의 데이터 검증은 다음 모듈의 별도 경계입니다. 이번 함수에서 원본 객체를 바꾸거나 검색어를 다시 해석하지 않습니다. 이미 선택된 한 행사를 표시하는 역할에 집중합니다.

dataset.eventId에는 String(event.id)를 넣습니다. DOM의 data-event-id는 문자열이므로 숫자 3과 읽어 온 문자열 3을 엄격 비교하면 서로 다릅니다. 지금은 식별자를 표시하고 다음 레슨에서 클릭 시 숫자로 검증해 변환합니다. 링크 텍스트에는 행사 이름과 상세 보기라는 행동을 함께 적어 동일한 문구만 반복되는 목록보다 목적지를 구분하기 쉽게 만듭니다.

링크 안에는 span을 하나 둡니다. 스타일이나 아이콘을 추가했을 때 클릭 대상이 링크 자체가 아닐 수 있다는 상황을 미리 만듭니다. 실제 동작은 href가 있는 a가 담당하므로 Tab과 Enter, 새 탭 열기 등 브라우저 기능을 사용할 수 있습니다. article 자체에 클릭만 붙여 상세로 보내면 키보드 사용자에게 같은 기능을 제공하기 어려워집니다.

목록을 교체하는 이유

renderList는 행사마다 createCard를 호출하고 결과 요소 배열을 replaceChildren에 펼쳐 전달합니다. 같은 검색을 반복해도 이전 카드가 누적되지 않습니다. 빈 배열에서는 인자 없이 replaceChildren을 호출한 것과 같은 효과로 목록이 비워집니다. 새로운 카드와 이전 카드를 혼합해 수정하기보다 이번 검색 결과 배열 하나가 표시 목록을 결정하도록 합니다.

hrefFor는 id를 받아 URL을 반환하는 함수입니다. 렌더러가 주소 규칙까지 소유하면 검색 조건 유지나 직접 상세 링크 정책을 바꿀 때 렌더러도 수정해야 합니다. 이 실습에서는 호출자가 주소를 제공하고 카드 함수는 그 주소를 사용합니다. 원본 배열과 객체를 변경하지 않으며 표시한 카드의 개수는 전달한 배열 길이와 일치해야 합니다.

전체 교체는 이전 요소의 포커스와 직접 붙인 이벤트를 유지하지 않습니다. 이 사실을 숨기지 않습니다. 다음 레슨에서는 살아 있는 부모 목록에 이벤트를 위임하고 상세 복귀 때 새로 만들어진 링크를 id로 찾아 포커스를 지정합니다. 카드 DOM을 영구 상태 저장소처럼 사용하지 않으면 이후 API 결과로 목록을 바꿀 때도 같은 원칙을 적용할 수 있습니다.

파일을 고치고 실패를 읽습니다

이 레슨 zip의 starter는 카드 생성과 목록 교체가 미구현입니다. assets/dom.js의 두 TODO를 구현합니다. 테스트나 fixtures를 수정하지 않습니다. Node 환경에는 document가 없으므로 제공 테스트는 필요한 DOM 동작만 가진 모델을 주입합니다. ReferenceError: document is not defined가 보이면 브라우저 전용 코드를 Node 전역에 의존하게 만든 것은 아닌지 확인합니다.

실제 브라우저에서 Cannot read properties of null 오류가 나면 선택자와 script 실행 시점을 확인합니다. results.html에서는 필요한 요소 뒤에 스크립트를 배치합니다. 제목이 undefined로 표시되면 앞 모듈 데이터의 필드가 title이 아니라 name인지 확인합니다. 테스트에서 기대 카드 수 1, 실제 2라면 교체 대신 append만 사용해 이전 결과가 남았을 가능성이 있습니다.

자동 검사의 innerHTML 문자열 삽입 금지 오류는 모델이 의도적으로 HTML 삽입을 거절한 결과입니다. 이를 우회하려고 테스트 모델을 수정하지 말고 createElement와 textContent 경계를 점검합니다. 자동 검사는 모델의 반환 구조와 호출 결과를 확인하며 브라우저 HTML 파서나 CSS 배치가 통과했다는 증거는 아닙니다.

브라우저에서 끝내는 확인

완성 후 results.html을 로컬 HTTP로 열고 전체 목록과 빈 결과를 번갈아 확인합니다. HTML 형태 제목 검사는 browser-tests.html에서 별도로 카드를 추가합니다. 제목에 img 요소가 생기지 않고 텍스트가 유지되는지 검사합니다. 실제 Tab 포커스와 좁은 화면 줄바꿈은 LAYOUT.md에 브라우저와 환경을 적어 관찰합니다. 실행하지 못한 항목은 미확인으로 남깁니다.

기존 index.html은 required 검색어가 있는 소개 폼이며 이번 results.html의 확장 폼은 빈 검색어 전체 정책입니다. 두 화면의 정책 차이를 설명할 수 있어야 합니다. 더 읽기에서는 DOM 선택과 수정 API를 넓혀 볼 수 있습니다. 여기서는 한 카드의 구조, 외부 데이터의 텍스트 처리, 반복 렌더링의 교체 계약을 자신의 코드로 설명하고 실행하는 데 집중합니다.

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

따라하기

실습 파일과 이전 계약 확인

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

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

경계값을 Node로 실행

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

const title='<img src=x onerror=alert(1)>';
console.log(title);
console.log(String(3) === 3);

실행 결과

<img src=x onerror=alert(1)>
false

TODO 구현과 테스트 대조

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

function createCard(doc, event, href) {
  const card = doc.createElement('article'); card.className = 'card';
  const title = doc.createElement('h2'); title.textContent = event.name;
  const meta = doc.createElement('p'); meta.textContent = `${event.region} · ${event.date}`;
  const link = doc.createElement('a'); link.href = href;
  link.dataset.eventId = String(event.id);
  const label = doc.createElement('span'); label.textContent = `${event.name} 상세 보기`;
  link.append(label); card.append(title, meta, link); return card;
}

function renderList(doc, list, events, hrefFor) {
  list.replaceChildren(...events.map(e => createCard(doc, e, hrefFor(e.id))));
}

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

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

실행 명령

npm test

기대 결과

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

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

더 읽기

면접 질문

  • 시맨틱 HTML을 선택한 이유를 설명합니다.