카드와 폼 컴포넌트 계약
210분 안팎
학습 목표
입력 데이터와 콜백으로 컴포넌트 책임을 표현합니다.
개념
카드와 폼 컴포넌트 계약
행사 카드를 함수로 옮겼다고 컴포넌트 책임이 명확해지는 것은 아닙니다. 함수 안에서 전역 검색어를 읽고 API를 부르고 history를 바꾸면 파일만 나뉜 셈입니다. 이 레슨은 카드와 폼과 상태 안내가 받는 입력, 만드는 출력, 알리는 행동을 계약으로 정합니다. 재사용 횟수보다 호출하는 사람이 그 함수의 영향을 예측할 수 있는지가 중요합니다.
EventCard의 입력은 doc, event, href입니다. doc은 요소 생성 환경이며 event는 id와 이름과 지역과 날짜를 포함하고 href는 부모가 계산한 목적지입니다. 출력은 article 요소 하나입니다. 함수는 전달된 event를 수정하지 않고 전역 route를 읽지 않습니다. 카드가 현재 URL에서 검색어를 추측하면 목록을 만드는 코드와 링크 규칙이 서로 다른 곳에 생기므로 URL 조립은 부모에게 남깁니다.
제목은 textContent로 넣습니다. 행사 이름에 꺾쇠괄호가 있어도 문자로 보여야 합니다. 컴포넌트로 옮기는 동안 innerHTML로 바꾸면 표시 구조는 같아 보여도 외부 제목의 해석 방식이 달라집니다. 계약 테스트는 태그처럼 생긴 이름을 전달하고 제목 노드의 textContent가 원문과 같은지 확인합니다. 카드 입력을 Object.freeze로 감싸 두면 함수가 속성을 수정하려 한 경우도 드러낼 수 있습니다.
카드의 링크는 실제 a 요소입니다. 부모 목록의 이벤트 위임이 일반 클릭을 읽어 탐색하고 수정 키 클릭은 기본 링크 동작을 유지합니다. 컴포넌트 내부에서 모든 클릭에 preventDefault를 넣으면 새 탭 열기와 복사 가능한 링크가 손상됩니다. 이 프로젝트의 카드 계약은 링크를 반환하고 부모가 행동을 처리하는 방식입니다. 클릭 콜백을 카드마다 등록하는 다른 설계도 가능하지만 한 프로젝트 안에서는 중복 경로를 만들지 않습니다.
SearchForm의 입력은 form과 onSearch 콜백입니다. 제출되면 기본 GET 이동을 취소하고 현재 q와 region을 새 객체로 읽어 콜백에 한 번 전달합니다. 어떤 URL로 이동하고 어떤 API를 호출할지는 부모가 결정합니다. form을 받아 놓고 document 전체에서 입력을 찾으면 같은 페이지에 폼 두 개가 있을 때 섞이기 쉽습니다. 이 함수는 해당 form.elements에서 이름이 있는 입력을 읽습니다.
폼은 read와 dispose를 반환합니다. read는 현재 입력을 관찰하고 dispose는 자신이 등록한 submit 핸들러를 제거합니다. 익명 함수를 새로 만들어 removeEventListener에 넘기면 처음 등록한 함수와 다르므로 제거되지 않습니다. 등록 시 만든 submit 참조를 클로저에 보관해 해제에 다시 사용합니다. 반환 객체에 정리 메서드가 있는 이유를 설명하는 것이 입력 계약만 외우는 것보다 실무에 가깝습니다.
폼 컴포넌트는 검색어를 trim할지 대문자를 바꿀지 결정하지 않습니다. 원래 검색 정규화는 data의 계약이기 때문입니다. 컴포넌트가 문자열을 몰래 바꾸면 사용자가 입력한 값과 공유 가능한 URL 값이 예상과 달라질 수 있습니다. 전달하는 값은 입력 필드의 문자열이며 부모가 필요한 정책을 적용합니다. 종류가 다른 검증을 폼에 모으기보다 어떤 레이어의 규칙인지 판단합니다.
StatusPanel은 status 요소와 retry 버튼과 상태 스냅샷을 받습니다. loading이면 불러오는 중 안내를 쓰고 재시도는 숨기며 비활성화합니다. error이면 이유에 맞는 안내와 재시도 버튼을 보여 줍니다. success나 empty이면 이전 오류 텍스트를 지웁니다. 오류에서 성공으로 바뀌는 경로를 생각하지 않으면 결과가 나타났는데도 오류 문장이 남는 문제가 생깁니다.
상태 패널은 요청을 직접 재시도하지 않습니다. 기존 session 어댑터가 재시도 버튼의 클릭을 등록하고 마지막 URL과 옵션을 유지합니다. 같은 버튼에 패널도 retry 핸들러를 등록하면 한 번 클릭에 두 요청이 나갈 수 있습니다. 상태를 표시하는 계약과 네트워크 수명 계약을 구분합니다. 전달받은 상태 객체 역시 표시를 위해 읽을 뿐 kind나 rows를 바꾸지 않습니다.
생성과 갱신의 수명은 다릅니다. EventCard는 목록 렌더링마다 새 요소를 만들지만 SearchForm은 initApp에서 한 번 연결합니다. render 안에서 SearchForm을 매번 호출하면 조회 성공 때마다 제출 리스너가 늘어납니다. StatusPanel은 상태마다 표시를 갱신하되 리스너를 만들지 않습니다. 같은 컴포넌트라는 이름으로 묶여 있어도 각 역할의 생성 횟수와 정리 시점은 따로 정합니다.
계약 테스트는 입력 준비, 호출, 관찰로 나눕니다. 카드에서는 제목·href·data-event-id를 읽고 폼에서는 callback 인자와 횟수를 읽습니다. 폼 dispose 후 다시 submit을 보내 호출 횟수가 늘지 않는지도 확인합니다. 상태 패널에서는 error 다음 loading 다음 success를 연속 전달합니다. 각각 한 상태만 독립해서 검사하면 이전 표시가 남는 결함을 놓칠 수 있습니다.
이번 DOM fixture는 요소 생성과 이벤트 전달과 포커스 대상 참조를 흉내 냅니다. 실제 HTML 파싱, 폼의 브라우저 기본 제출, CSS 레이아웃은 구현하지 않습니다. 따라서 자동 테스트가 통과했다는 것은 계약 모델에서 전달값과 표시가 맞다는 증거입니다. 실제 Enter·Tab·스크린 크기에서 같은 흐름인지 별도 관찰합니다. 도구가 무엇을 흉내 내는지 알면 테스트 결과의 의미를 과장하지 않을 수 있습니다.
로컬 과제 frontend-m07-contracts에서는 카드 제목·폼 조건 전달·재시도 표시 TODO를 고칩니다. components.mjs의 EventCard가 event.name을 textContent로 쓰게 하고 나머지 입력을 보존합니다. SearchForm이 현재 입력을 전달하고 StatusPanel이 error와 loading에 맞춰 재시도 표시를 갱신하도록 완성한 뒤 각 입력과 콜백과 정리 메서드를 설명합니다. components-test.mjs가 첫 실패 위치를 보여 주며 Actual이 TODO이고 Expected가 행사 제목이면 API가 아니라 표시 계약을 확인해야 합니다.
흔한 오류는 onSearch를 연결할 때 괄호를 붙여 초기화 순간 실행하는 것입니다. 콜백 자체를 전달하고 제출 이벤트 안에서 호출합니다. TypeError: onSearch is not a function이 나면 넘긴 값이 함수인지 부모 호출부를 확인합니다. 또 callback에 DOM 이벤트 전체를 보내면 부모가 폼 구조에 결합되므로 q와 region으로 바꾸어 전달합니다. 계약을 문장으로 적고 테스트가 그 문장을 확인하는지 대조합니다.
완성 후 카드가 api나 results를 import하지 않는지, 폼이 route에 직접 대입하지 않는지, 상태 패널이 fetch를 부르지 않는지 검토합니다. 책임이 작은 함수도 잘못된 의존 하나로 변경 범위가 넓어질 수 있습니다. 새로운 화면에 이들을 쓰게 된다면 필요한 입력을 전달하는 조립 코드만 바꾸도록 설계합니다. 더 읽기의 모듈 장에서는 공개 이름과 의존 방향을 더 다양한 상황에서 살펴봅니다.
컴포넌트의 이름은 파일 위치보다 사용자가 기대하는 역할을 드러내야 합니다. 모든 기능을 Widget으로 부르면 호출부만 보고 반환 요소와 행동을 알기 어렵습니다. EventCard는 한 행사, SearchForm은 조건 전달, StatusPanel은 요청 안내라는 단위를 가집니다. 이름과 실제 효과가 어긋나면 먼저 계약을 고칩니다. 컴포넌트 크기를 줄이기 위해 의미 없는 한 줄 함수까지 나누기보다 독립된 변경 이유와 검사 가능한 입력을 기준으로 추출합니다.
따라하기
카드 입력의 변경을 방지합니다
코드를 demo.cjs에 저장하고 node demo.cjs로 실행합니다. 출력과 위 계약을 비교합니다.
'use strict';const event=Object.freeze({id:1,name:'책 모임'});const label=e=>`${e.name} 상세 보기`;console.log(label(event));console.log(event.name);실행 결과
책 모임 상세 보기 책 모임
폼 행동을 콜백으로 전달합니다
코드를 demo.cjs에 저장하고 node demo.cjs로 실행합니다. 출력과 위 계약을 비교합니다.
function submit(values,onSearch){onSearch({q:values.q,region:values.region});}submit({q:'책',region:'busan'},conditions=>console.log(JSON.stringify(conditions)));실행 결과
{"q":"책","region":"busan"}
상태 전환의 표시를 초기화합니다
코드를 demo.cjs에 저장하고 node demo.cjs로 실행합니다. 출력과 위 계약을 비교합니다.
function status(kind){return {text:kind==='loading'?'불러오는 중':kind==='error'?'재시도 필요':'',retry:kind==='error'};}console.log(JSON.stringify(['error','loading','success'].map(status)));실행 결과
[{"text":"재시도 필요","retry":true},{"text":"불러오는 중","retry":false},{"text":"","retry":false}]
확인 문제
실습
assets/components.mjs 카드 제목·폼 조건·재시도 표시 TODO를 완성합니다. 테스트 파일은 유지합니다. npm ci 후 npm test로 확인하고 README-M07.md와 STATE.md에서 모듈 책임을 설명합니다. Node DOM 모델 통과와 실제 브라우저 관찰을 구분합니다.
실행 명령
npm test
기대 결과
starter는 미완성 동작 assertion 실패, solution은 기존 회귀와 ESM·컴포넌트 검사 전부 통과
모범 답안
모범 답안 내려받기더 읽기
면접 질문
- 컴포넌트의 상태 위치를 정한 사례를 설명합니다.