Devin.KR

ESM과 역할 경계

210분 안팎

학습 목표

데이터 처리·API·DOM의 의존성을 나눕니다.

개념

ESM과 역할 경계

지역 행사 탐색이 잘 동작하더라도 검색 계산과 통신과 DOM 갱신이 한 덩어리에 있으면 수정 범위를 판단하기 어렵습니다. 제목 표시를 바꾸려고 파일을 열었는데 요청 번호와 재시도까지 읽어야 한다면 역할 경계가 흐린 상태입니다. 이번 레슨에서는 사용자 기능을 추가하지 않고 어떤 코드가 무엇을 알아야 하는지 정합니다. 파일 수보다 변경 이유와 의존 방향을 먼저 적습니다.

ESM은 export로 공개하는 이름과 import로 사용하는 이름을 선언하는 모듈 형식입니다. 이 프로젝트의 새 파일은 .mjs를 사용하고 상대 경로에 확장자를 적습니다. Node의 기존 .cjs 테스트와 이전 단계의 .js는 비교 자료로 보존합니다. 브라우저는 results.html의 type="module" 진입점에서 새 모듈을 로드합니다. 같은 기능을 두 진입점에서 함께 초기화하면 리스너가 중복되므로 화면에서는 새 진입점 하나만 사용합니다.

공통 실습 환경은 Node 18 이상과 Python 3입니다. local 압축을 풀고 해당 폴더에서 npm ci 다음 npm test를 실행합니다. 외부 패키지 없이 lockfile과 내장 테스트를 사용합니다. browser 과제는 표준 입력의 JSON 한 값을 읽고 JSON 한 줄을 출력합니다. 안내 로그를 섞지 않습니다. 이 모듈의 미션 starter에는 앞 모듈 미션 solution 전체가 들어 있으며 별도로 파일을 옮길 필요가 없습니다.

경계를 그릴 때 먼저 함수의 입력과 외부 효과를 분류합니다. 검색은 행사 배열과 조건을 받아 새 결과 배열을 반환합니다. API는 URL과 옵션으로 응답을 읽고 계약을 검사합니다. view는 전달받은 결과로 요소를 만듭니다. 결과 배열을 화면에 쓰는 조립 코드는 results.mjs에 둡니다. 검색 함수가 document를 읽으면 테스트마다 화면 구조까지 준비해야 하므로 조건을 인자로 전달하는 편이 책임을 좁힙니다.

이 실습에서 data.mjs는 DOM과 API를 import하지 않습니다. api.mjs는 DOM 없이 응답 계약을 처리합니다. request-session.mjs가 API와 request-control.mjs를 조립하고 전달받은 재시도 버튼을 연결합니다. 이 어댑터는 통신 요청 함수와 구분해 읽습니다. 순수 request는 fetcher를 인자로 받고 UI가 없어도 실행됩니다. 이후 API를 다른 화면에서 재사용할 때는 request를 선택하고 session의 UI 수명 연결은 필요에 따라 대체합니다.

의존 그래프는 boot에서 results로, results에서 request-session과 dom과 components로 이어집니다. request-session은 api와 request-control을 사용하고 dom은 components의 카드 생성기를 사용합니다. 반대로 카드가 results를 import해서 route를 바꾸는 연결은 만들지 않습니다. 필요한 링크는 부모가 계산해 전달합니다. 검색 함수가 API를 부르는 방식도 피합니다. 이미 받은 데이터의 계산과 데이터를 가져오는 시점은 서로 다른 변경 이유를 가집니다.

모든 함수를 공개할 필요는 없습니다. 외부에서 실제 사용하는 검색 함수와 카드 생성 함수부터 named export로 내보냅니다. 내부 보조 함수를 export하면 다른 파일이 그 구현에 기대기 쉬워집니다. 다만 현재 단계는 기존 테스트와 비교하기 위해 기존 공개 함수도 유지합니다. 공개 범위를 줄이는 작업과 ESM 전환을 한 번에 섞지 않고, 어떤 이름을 누가 사용하는지 확인한 뒤 별도 변경으로 판단합니다.

순환 import가 언제나 즉시 예외를 내는 것은 아닙니다. 그러나 초기화되지 않은 바인딩을 읽으면 실패할 수 있고 실행 순서를 이해하기 어려워집니다. api가 view를 가져오고 view가 api를 가져오는 상황이면 파일 순서를 바꾸는 것으로 끝내지 않습니다. results가 둘을 조립하도록 호출을 위로 옮기거나 필요한 함수를 인자로 받습니다. 공유 상수만 필요하면 상태를 담지 않는 별도 모듈로 분리합니다.

모듈 최상위에서 document.querySelector를 실행하면 import 자체에 화면이 필요해집니다. 테스트가 data만 읽으려 해도 문서가 없다는 오류를 만날 수 있습니다. DOM 초기화는 boot가 initApp에 window와 document를 넘기는 시점에 수행합니다. 이것은 같은 앱을 모델 문서로 검사할 수 있게 하고, import와 앱 시작을 구분합니다. DOM을 쓰는 함수도 doc을 전달받으면 의존성이 호출부에 드러납니다.

따라하기 첫 단계는 하나의 CommonJS 실행 파일 안에서 동적 import로 간단한 ESM을 불러옵니다. 실제 여러 파일의 연결은 다운로드 코드에서 확인합니다. export 이름이 없다는 오류가 나면 중괄호 안의 이름과 내보낸 선언을 비교합니다. ERR_MODULE_NOT_FOUND는 함수 구현보다 경로와 확장자를 먼저 봅니다. 파일명이 같은 디렉터리인지 대소문자가 일치하는지도 확인합니다.

브라우저 모듈은 파일을 더블 클릭한 file URL 대신 제공된 HTTP 서버로 엽니다. 모듈 요청의 MIME 유형과 응답 상태가 맞아야 합니다. mock-server.cjs는 .mjs에 text/javascript를 보내도록 확장되어 있습니다. 서버가 HTML 오류 페이지를 보내면 JavaScript 코드가 맞아도 로딩이 실패합니다. Network에서 진입점과 그 의존 파일을 각각 보고 첫 실패 요청부터 해결합니다.

로컬 과제는 frontend-m07-boundaries의 assets/data.mjs에 있는 검색 연결 TODO를 완성하는 일입니다. filterEvents에 events와 query와 region을 전달하고 sortUniqueEvents로 정렬·중복 제거를 적용합니다. DOM 접근이나 fetch를 추가하지 않습니다. npm test는 이전 단계 검사와 새 ESM 흐름을 모두 실행합니다. 검색 결과를 빈 배열로 고정한 starter에서는 최신 검색의 카드 검사가 실패하므로 실패 이름을 입력 조건과 연결해 읽습니다.

모듈로 바꾸는 동안 기존 .js 구현이 남는 이유는 이전 동작의 비교 기준을 유지하기 위해서입니다. 이것을 실제 화면에서도 두 번 실행하라는 뜻으로 해석하지 않습니다. results.html에는 boot.mjs만 연결합니다. 테스트의 기존 결과와 ESM 결과를 나란히 보며 동일한 입력에서 출력이 같은지 확인합니다. 비교가 끝난 후 옛 파일 제거는 별도 범위의 작업이며 이번 과제에서는 보존합니다.

완성 후 STATE.md의 화살표와 import 문을 대조합니다. 파일마다 공개 함수, 읽는 입력, 만드는 출력, 외부 효과를 한 줄로 적으면 역할을 설명하기 쉽습니다. 코드가 짧다는 사실보다 검색 계산을 통신 없이 실행할 수 있는지, API 응답을 DOM 없이 검사할 수 있는지로 경계를 평가합니다. 모듈 시스템의 더 많은 문법과 순환 참조 사례는 연결된 서재에서 이어 읽습니다.

ESM 실습 파일의 상대 경로는 가져오는 파일을 기준으로 해석합니다. boot가 assets에 있으면 ./results.mjs를 사용하고 results가 api를 읽을 때도 같은 폴더의 ./api.mjs를 사용합니다. 브라우저의 현재 주소 문자열을 코드 경로와 혼동하지 않습니다. HTML의 script src는 문서 기준이며 import는 모듈 기준이므로 두 기준을 종이에 적고 실제 폴더 구조와 비교합니다. 경계를 바꿔 파일을 옮겼다면 해당 경로와 서버 응답을 함께 재검사합니다.

모듈 로딩 환경은 MDN 모듈 안내, Node의 확장자 규칙은 Node ESM 문서에서 확인합니다.

따라하기

ESM 공개 함수를 호출합니다

코드를 demo.cjs에 저장하고 node demo.cjs로 실행합니다. 출력과 위 계약을 비교합니다.

(async()=>{const source='export function label(e){return e.name;}';const m=await import('data:text/javascript,'+encodeURIComponent(source));console.log(m.label({name:'책 모임'}));})();

실행 결과

책 모임

순수 검색 입력을 분리합니다

코드를 demo.cjs에 저장하고 node demo.cjs로 실행합니다. 출력과 위 계약을 비교합니다.

function search(rows,q){return rows.filter(e=>e.name.includes(q));}const rows=[{name:'책 모임'},{name:'음악 모임'}];console.log(JSON.stringify(search(rows,'책')));console.log(rows.length);

실행 결과

[{"name":"책 모임"}]
2

조립 코드에서 의존을 주입합니다

코드를 demo.cjs에 저장하고 node demo.cjs로 실행합니다. 출력과 위 계약을 비교합니다.

async function load(request,render){render(await request());}load(async()=>[{name:'책 모임'}],rows=>console.log(rows.map(e=>e.name).join(',')));

실행 결과

책 모임

확인 문제

실습

assets/data.mjs 검색 연결 TODO를 완성합니다. 테스트 파일은 유지합니다. npm ci 후 npm test로 확인하고 README-M07.md와 STATE.md에서 모듈 책임을 설명합니다. Node DOM 모델 통과와 실제 브라우저 관찰을 구분합니다.

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

실행 명령

npm test

기대 결과

starter는 미완성 동작 assertion 실패, solution은 기존 회귀와 ESM·컴포넌트 검사 전부 통과

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

더 읽기

면접 질문

  • 컴포넌트의 상태 위치를 정한 사례를 설명합니다.