리팩터링과 회귀 검증
210분 안팎
학습 목표
구조 변경 전후에 사용자 동작이 같음을 확인합니다.
개념
리팩터링과 회귀 검증
리팩터링은 기능을 추가하기보다 내부 구조를 바꾸며 외부에서 관찰하는 동작을 보존하는 작업입니다. 행사 탐색의 ESM 전환에서는 파일명과 import가 달라져도 검색 조건과 상세 이동과 실패 복구가 같아야 합니다. 한 번 검색해 카드가 보였다는 확인만으로는 요청 역전이나 뒤로 이동의 회귀를 발견하기 어렵습니다. 먼저 보존할 사용자 계약을 적고 그 계약의 테스트를 유지합니다.
기준은 앞 모듈 미션 solution입니다. 이번 starter는 그 파일을 모두 이어받고 ESM 파일과 새 테스트를 덧붙였습니다. 기존 테스트를 제거하거나 Expected를 TODO로 바꾸면 구조 변경의 안전 근거가 사라집니다. 실제 화면은 boot.mjs를 사용하고 이전 js 파일은 비교용으로 남습니다. 테스트가 어느 구현을 읽는지 확인해야 옛 구현만 검사하면서 새 구현도 안전하다고 착각하지 않습니다.
비교할 핵심 흐름은 조건 제출, 결과 표시, 상세 진입, 목록 복귀, 오류 안내, 동일 조건 재시도입니다. 빠른 입력과 늦은 완료도 여기에 포함합니다. B가 먼저 끝난 뒤 A가 도착해도 B 제목을 유지해야 합니다. 화면 이탈 후 응답이 끝나더라도 DOM을 갱신하지 않아야 합니다. 최신성 검사를 제거해 코드가 짧아지는 것은 동작 보존이 아니라 사용자 의도를 잃는 변경입니다.
테스트는 관찰 가능한 결과를 중심으로 작성합니다. 카드 함수가 몇 번째 줄에서 호출되었는지보다 어떤 제목과 링크를 만들었는지 봅니다. 검색은 조건 문자열과 결과 제목을, 재시도는 마지막 요청 URL과 성공 상태를 비교합니다. 수명 정리는 리스너와 예약 작업이 남지 않는지 검사합니다. 다만 API 모듈이 DOM을 import하지 않는다는 구조 기준은 동작 검사만으로 알기 어려우므로 별도 정적 경계 검토를 함께 합니다.
이 프로젝트의 esm-flow-test.mjs는 앞 모듈 order-test.cjs의 시나리오를 새 ESM import 대상으로 바꾸어 실행합니다. 가짜 clock으로 249ms까지 요청이 없는지 확인하고 1ms를 더해 마지막 입력 요청 하나를 검사합니다. 테스트를 옮기면서 시간 정책이나 기대 결과를 바꾸지 않습니다. 옛 구현과 새 구현에서 같은 시나리오가 통과하면 요청 정책을 보존했다는 비교 근거가 됩니다.
esm-api-test.mjs는 검색과 상세 복귀와 실패 재시도를 새 results.mjs와 api.mjs로 검사합니다. 이전 api-test.cjs도 유지합니다. 제목이 태그처럼 생긴 fixture를 계속 사용해 안전 렌더링 회귀를 검사합니다. API의 JSON 계약과 404·500·연결 실패를 구분한 규칙도 그대로 유지합니다. 컴포넌트 추출이 네트워크 규칙을 바꿔야 할 이유는 없으므로 예상 결과를 고정한 채 대상을 교체합니다.
작업 순서는 순수 계산, API 의존, DOM 카드, 폼 연결, 화면 진입점 순으로 좁힙니다. 한 경계를 바꿀 때 관련 검사부터 실행하고 모두 통과하면 전체 검사로 돌아갑니다. 구조 변경과 새 정렬 정책을 함께 넣으면 실패 원인이 두 개가 됩니다. 이번 과제에서는 동작을 먼저 보존하고 새로운 기능 제안은 변경 기록에 남깁니다. 작은 단계마다 무엇을 보존했는지 설명할 수 있어야 합니다.
실패 메시지는 테스트 이름부터 읽습니다. B 완료 후 A 성공은 B를 덮지 않음이 실패하면 카드 CSS보다 request-control.mjs의 성공 경로를 봅니다. AssertionError의 Actual이 A이고 Expected가 B이면 현재 응답인지 검사하기 전에 publish를 호출했는지 확인합니다. 취소 signal만 확인하는 경우도 취소를 무시하는 mock에서 실패할 수 있으므로 generation과 disposed 조건을 함께 살펴봅니다.
비동기 검사는 기다리는 경계가 있어야 합니다. 요청을 시작한 뒤 바로 상태를 비교하면 완료 전의 loading을 읽거나 테스트가 먼저 끝납니다. 제어 가능한 Promise의 resolve를 호출하고 반환 Promise를 await한 뒤 결과를 확인합니다. 타이머를 실제로 오래 기다려 우연히 통과시키지 않습니다. 새로운 입력이 들어온 시점과 완료된 시점을 테스트에서 각각 조작하면 어떤 정책이 깨졌는지 분명해집니다.
정리 검사는 앱을 떠나는 흐름도 사용자 동작의 일부로 봅니다. pagehide 처리에서 session을 dispose하고 폼과 입력과 탐색 리스너를 제거합니다. pageshow의 캐시 복원에서는 앱을 새로 초기화할 수 있게 기존 문서 표시를 정리합니다. 테스트 모델은 해제 참조와 이후 콜백 억제를 확인하며 실제 뒤로 가기 캐시 동작은 브라우저로 관찰해야 합니다. 자동 테스트와 관찰 기록은 서로 보완하는 증거입니다.
이 레슨의 starter는 새 request-control.mjs 성공 경로의 최신 요청 검사가 빠져 있습니다. 원래 구현의 성공 조건을 참고해 disposed, generation 불일치, local.signal.aborted를 검사한 뒤만 publish하도록 복구합니다. 오류와 finally의 보호 조건도 읽어 서로 다른 경로가 같은 정책을 따르는지 확인합니다. tests를 수정하거나 모든 응답을 버려 빈 화면으로 맞추는 해결은 정상 검색 시나리오에서 실패합니다.
다운로드 과제는 npm test로 기존 회귀와 ESM 회귀를 함께 실행합니다. 통과한 검사 수만 적기보다 어떤 입력과 경계가 보호되는지 README-M07.md와 STATE.md를 연결해 설명합니다. 실제 브라우저에서는 제공된 mock 서버와 Network를 사용해 모듈 응답 유형을 확인합니다. 키보드와 좁은 화면과 확대와 한국어 조합도 관찰합니다. 이 작업에서 자동 모델로 확인한 내용을 실제 브라우저 검증 완료라고 쓰지 않습니다.
리뷰 기록은 변경 이유, 보존한 계약, 실행한 명령, 남은 관찰을 포함합니다. 예를 들어 카드 생성과 검색 조건 전달을 분리했으며 B→A 완료와 동일 URL 재시도 검사가 통과했다고 씁니다. 기능이 좋아졌다는 표현보다 검증할 수 있는 입력과 결과를 사용합니다. Git 커밋이나 푸시는 이 과제의 수행 항목이 아니며 파일 변경 자체와 테스트 결과로 설명 자료를 만듭니다.
미션에서는 components.mjs의 제목·폼 조건·상태 표시 TODO를 완료하고 STATE.md에서 q·region·id의 소유자와 결과 수의 파생 관계를 확인합니다. 원본의 문서와 CSS와 mock 데이터는 그대로 출발점으로 유지합니다. 실제 진입점에 두 스크립트 경로가 섞이지 않는지 마지막으로 봅니다. 다음 모듈은 이 결과를 사용자 흐름 검증으로 확장하므로 실행 방법과 실패 복구 규칙을 다음 사람이 재현할 수 있게 남깁니다.
리팩터링이 끝났다는 판단은 코드가 더 짧아졌다는 인상에 기대지 않습니다. 동작 계약을 보존했고 의존 방향을 설명할 수 있으며 알려진 미확인 범위를 기록했다는 근거가 필요합니다. 새 모듈만 단독 테스트하고 전체 회귀를 생략하면 기존 상세 링크를 깨뜨린 사실을 놓칠 수 있습니다. 더 읽기의 테스트 장에서는 비동기 단언과 테스트 격리 문제를 확인하고 이번 결과의 신뢰 범위를 점검합니다.
모델 검증의 한계를 기록하는 것은 실패를 숨기는 방법이 아니라 다음 검사 대상을 정하는 일입니다. Node에서 텍스트가 맞아도 실제 브라우저가 .mjs를 잘못된 MIME 유형으로 거절하면 앱이 시작되지 않습니다. 또한 모델의 focus는 activeElement 참조만 바꾸므로 사용자에게 보이는 포커스 외곽선을 보장하지 않습니다. 이 두 위험을 실행 환경과 화면 관찰 목록에 남기고, 확인하지 못한 항목에는 추측한 출력 대신 빈 결과와 확인 절차를 둡니다.
따라하기
같은 입력의 결과를 비교합니다
코드를 demo.cjs에 저장하고 node demo.cjs로 실행합니다. 출력과 위 계약을 비교합니다.
const assert=require('node:assert/strict');const before=rows=>rows.map(e=>e.name);const after=rows=>Array.from(rows,e=>e.name);assert.deepEqual(after([{name:'책 모임'}]),before([{name:'책 모임'}]));console.log('PASS 제목 계약 보존');실행 결과
PASS 제목 계약 보존
완료 순서를 제어합니다
코드를 demo.cjs에 저장하고 node demo.cjs로 실행합니다. 출력과 위 계약을 비교합니다.
(async()=>{let generation=0,value='';function run(){const mine=++generation;let resolve;const pending=new Promise(r=>resolve=r);const done=pending.then(x=>{if(mine===generation)value=x;});return {resolve,done};}const a=run(),b=run();b.resolve('B');await b.done;a.resolve('A');await a.done;console.log(value);})();실행 결과
B
실패 이름과 실제 값을 연결합니다
코드를 demo.cjs에 저장하고 node demo.cjs로 실행합니다. 출력과 위 계약을 비교합니다.
const assert=require('node:assert/strict');try{assert.equal('A','B');}catch(e){console.log(e.name);console.log(`Actual=${e.actual}, Expected=${e.expected}`);}실행 결과
AssertionError Actual=A, Expected=B
확인 문제
실습
assets/request-control.mjs 성공 최신성 검사 TODO를 완성합니다. 테스트 파일은 유지합니다. npm ci 후 npm test로 확인하고 README-M07.md와 STATE.md에서 모듈 책임을 설명합니다. Node DOM 모델 통과와 실제 브라우저 관찰을 구분합니다.
실행 명령
npm test
기대 결과
starter는 미완성 동작 assertion 실패, solution은 기존 회귀와 ESM·컴포넌트 검사 전부 통과
모범 답안
모범 답안 내려받기더 읽기
면접 질문
- 컴포넌트의 상태 위치를 정한 사례를 설명합니다.