Skip to Content
WebHTML모던 HTML19. 종합 점검: 완성된 포트폴리오 리뷰

이번 편의 결과물: 완성된 포트폴리오가 01_학습방향.md의 완료 기준 5가지를 모두 통과한다 · 다루는 개념: 전체 기능 자가 점검, template/slot/Declarative Shadow DOM은 javascript/web_component가 이어받는다는 안내, 다음 과목(css/css_fundamentals) 안내

이 편에서 만드는 파일

새 파일은 없다. index.html·projects.html·contact.html·js/main.js 네 파일이 03~18편의 모든 기능을 담은 최종 상태인지 아래 정본 코드와 비교해 확인한다.

완료 기준 자가 점검

01_학습방향.md가 정한 다섯 가지 기준을 하나씩 점검한다.

#확인할 것어느 편에서 만들었나
1프로젝트 카드를 클릭하면 dialog 상세 모달이 포커스 트랩과 함께 열리고, 내비게이션 popover 드롭다운/툴팁이 라이트 디스미스로 동작한다03, 04, 05, 06
2FAQ가 details name으로 하나만 열리는 아코디언으로 동작하고, search+hidden="until-found"로 프로젝트 목록을 걸러낸다07, 08
3프로젝트 썸네일이 최적화되어 로드되고, 자막이 붙은 소개 영상이 재생된다09, 10
4문의 폼이 새 입력 타입과 Constraint Validation API로 오류 메시지를 보여주고, inputmode/autocomplete로 모바일 입력이 편해진다11, 12
5문서에 Open Graph·manifest·JSON-LD·보안 속성·국제화 속성이 근거를 갖고 붙어 있고, 어떤 기능을 프로덕션에 써도 되는지 스스로 판단할 수 있다13, 14, 15, 16, 17, 18, 02

각 항목을 확인하는 방법은 해당 편의 “확인” 단계와 같다. 다섯 개 모두 통과하면 이 과목을 마친 것이다.

정본 코드 — modern_html 완료 시점

아래 네 파일은 03편부터 18편까지 쌓인 모든 기능을 담은 최종 상태다. js/utils.js는 13편에서 만든 qs/qsa/announce 그대로다. 지금까지 만든 파일과 다른 부분이 있다면 이 코드를 기준으로 맞춘다.

<!-- index.html --> <!doctype html> <html lang="ko"> <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1" /> <title>소개 · Zeno Kim</title> <link rel="icon" href="favicon.svg" type="image/svg+xml" /> <!-- 16편: 인라인 스크립트를 막고 출처를 좁히는 CSP --> <meta http-equiv="Content-Security-Policy" content="default-src 'self'; img-src 'self' https:; script-src 'self'; style-src 'self' https://fonts.googleapis.com; font-src https://fonts.gstatic.com" /> <!-- 14편: Open Graph와 설치 메타데이터 --> <meta name="theme-color" content="#0969da" /> <link rel="manifest" href="manifest.webmanifest" /> <meta property="og:type" content="profile" /> <meta property="og:title" content="Zeno Kim — 웹을 만드는 개발자" /> <meta property="og:description" content="HTML·CSS·JavaScript로 누구나 쓸 수 있는 화면을 만드는 개발자 Zeno Kim의 포트폴리오입니다." /> <meta property="og:image" content="https://zenokim.example/images/og-image.svg" /> <meta property="og:url" content="https://zenokim.example/index.html" /> <!-- 13편: 리소스 힌트와 importmap --> <link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <link rel="dns-prefetch" href="https://fonts.googleapis.com" /> <link rel="preload" href="videos/intro.mp4" as="video" type="video/mp4" /> <link rel="prefetch" href="projects.html" /> <script type="importmap"> { "imports": { "utils": "./js/utils.js" } } </script> <link rel="stylesheet" href="css/tokens.css" /> <link rel="stylesheet" href="css/styles.css" /> <!-- 15편: 구조화 데이터 --> <script type="application/ld+json"> { "@context": "https://schema.org", "@type": "Person", "name": "Zeno Kim", "jobTitle": "프론트엔드 개발자", "description": "HTML·CSS·JavaScript로 누구나 쓸 수 있는 화면을 만드는 개발자입니다.", "url": "https://zenokim.example/index.html", "sameAs": ["https://github.com/example", "https://www.linkedin.com/in/example", "https://blog.example.com"] } </script> </head> <body> <header class="site-header"> <a class="logo" href="index.html">Zeno Kim</a> <button type="button" class="menu-toggle" popovertarget="mobile-menu" popovertargetaction="toggle" aria-label="메뉴 열기"> 메뉴 </button> <nav id="mobile-menu" class="site-nav" popover aria-label="주요 메뉴"> <ul> <li><a href="index.html" aria-current="page">소개</a></li> <li><a href="projects.html">프로젝트</a></li> <li><a href="contact.html">연락</a></li> </ul> </nav> </header> <main> <section class="hero"> <h1>웹을 만드는 개발자, Zeno Kim입니다.</h1> <p>HTML·CSS·JavaScript로 누구나 쓸 수 있는 화면을 만듭니다.</p> <a href="projects.html" class="button-primary">프로젝트 보기</a> <a href="contact.html" class="button-secondary">연락하기</a> </section> <section class="about"> <h2>소개</h2> <img src="images/profile.svg" width="400" height="400" fetchpriority="high" alt="Zeno Kim 프로필 이미지" /> <p>저는 3년째 프론트엔드를 공부하고 만들고 있습니다. 화면이 예뻐 보이는 것보다 <strong>누가 봐도 쓸 수 있는가</strong>를 먼저 생각합니다.</p> <!-- 17편: 문장 안 영단어 lang 지정 --> <p>처음에는 디자인 툴로 화면을 그리다가, 직접 만들어 보고 싶어서 HTML과 CSS를 배웠습니다. 지금은 <span lang="en">React</span>로 서비스 화면을 만들고 있습니다.</p> <p>접근성과 웹 표준을 좋아합니다. 스크린 리더로 제 사이트를 읽어 보는 일이 습관이 됐습니다.</p> <!-- 10편: 자막 있는 소개 영상 --> <video controls width="640" height="360" poster="images/og-image.svg" preload="metadata"> <source src="videos/intro.mp4" type="video/mp4" /> <track kind="subtitles" src="captions/intro-ko.vtt" srclang="ko" label="한국어" default /> 영상을 재생할 수 없습니다. 브라우저가 video 태그를 지원하지 않습니다. </video> </section> <section class="skills"> <h2>기술 스택</h2> <ul> <li>마크업·스타일: HTML, CSS, Sass</li> <li>언어: <span lang="en">JavaScript</span>, <span lang="en">TypeScript</span></li> <li>프레임워크: <span lang="en">React</span>, <span lang="en">Next.js</span></li> <li>도구: Git, <ruby>Vite<rp>(</rp><rt>비트</rt><rp>)</rp></ruby>, Figma</li> </ul> </section> <section class="career"> <h2>경력</h2> <table> <thead> <tr><th>기간</th><th>회사</th><th>역할</th><th>주요 업무</th></tr> </thead> <tbody> <tr><td>2024.03 – 현재</td><td>스튜디오 파도</td><td>프론트엔드 개발자</td><td>디자인 시스템 컴포넌트 구현, 접근성 개선</td></tr> <tr><td>2023.01 – 2024.02</td><td>오늘의 기록</td><td>주니어 개발자</td><td>기록 서비스 웹 화면 개발, 반응형 전환</td></tr> <tr><td>2022.06 – 2022.12</td><td>코드포올 부트캠프</td><td>수료생</td><td>팀 프로젝트 3건, 발표 우수상</td></tr> </tbody> </table> </section> </main> <footer class="site-footer"> <p>© 2026 Zeno Kim. 이 사이트는 학습용으로 제작되었습니다.</p> <!-- 16편: 외부 링크 보안 속성 --> <ul> <li><a href="https://github.com/example" target="_blank" rel="noopener noreferrer">GitHub</a></li> <li><a href="https://www.linkedin.com/in/example" target="_blank" rel="noopener noreferrer">LinkedIn</a></li> <li><a href="https://blog.example.com" target="_blank" rel="noopener noreferrer">Blog</a></li> </ul> </footer> <script type="module" src="js/main.js"></script> </body> </html>
<!-- projects.html --> <!doctype html> <html lang="ko"> <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1" /> <title>프로젝트 · Zeno Kim</title> <link rel="icon" href="favicon.svg" type="image/svg+xml" /> <meta http-equiv="Content-Security-Policy" content="default-src 'self'; img-src 'self' https:; script-src 'self'; style-src 'self' https://fonts.googleapis.com; font-src https://fonts.gstatic.com" /> <!-- 14편: 설치·공유 메타데이터 --> <meta name="theme-color" content="#0969da" /> <link rel="manifest" href="manifest.webmanifest" /> <meta property="og:type" content="website" /> <meta property="og:title" content="Zeno Kim — 프로젝트" /> <meta property="og:description" content="Zeno Kim이 직접 만들고 배운 프로젝트 6개를 모았습니다." /> <meta property="og:image" content="https://zenokim.example/images/og-image.svg" /> <meta property="og:url" content="https://zenokim.example/projects.html" /> <!-- 13편: 리소스 힌트와 importmap --> <link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <link rel="dns-prefetch" href="https://fonts.googleapis.com" /> <link rel="prefetch" href="contact.html" /> <script type="importmap"> { "imports": { "utils": "./js/utils.js" } } </script> <link rel="stylesheet" href="css/tokens.css" /> <link rel="stylesheet" href="css/styles.css" /> <!-- 15편: 프로젝트 목록 구조화 데이터 --> <script type="application/ld+json"> { "@context": "https://schema.org", "@type": "ItemList", "itemListElement": [ { "@type": "CreativeWork", "position": 1, "name": "오늘의 날씨", "description": "위치 기반 날씨를 카드로 보여주는 웹앱입니다.", "keywords": "JavaScript, PWA", "url": "https://example.com/weather" }, { "@type": "CreativeWork", "position": 2, "name": "독서 기록 bookshelf", "description": "읽은 책과 별점을 기록하고 통계를 보는 React 앱입니다.", "keywords": "React, Vite", "url": "https://example.com/bookshelf" }, { "@type": "CreativeWork", "position": 3, "name": "메모 앱 notes", "description": "브라우저 저장소만으로 동작하는 메모 앱입니다.", "keywords": "Web APIs, IndexedDB", "url": "https://example.com/notes" }, { "@type": "CreativeWork", "position": 4, "name": "접근성 체크리스트", "description": "WCAG 항목을 하나씩 점검하는 정적 사이트입니다.", "keywords": "HTML, 접근성", "url": "https://example.com/a11y" }, { "@type": "CreativeWork", "position": 5, "name": "디자인 토큰 미리보기", "description": "CSS 변수로 만든 색·간격 토큰을 한 화면에서 비교합니다.", "keywords": "CSS, 디자인 시스템", "url": "https://example.com/tokens" }, { "@type": "CreativeWork", "position": 6, "name": "todo-cli", "description": "터미널에서 쓰는 할 일 관리 도구입니다.", "keywords": "Node.js", "url": "https://example.com/todo-cli" } ] } </script> </head> <body> <header class="site-header"> <a class="logo" href="index.html">Zeno Kim</a> <button type="button" class="menu-toggle" popovertarget="mobile-menu" popovertargetaction="toggle" aria-label="메뉴 열기"> 메뉴 </button> <nav id="mobile-menu" class="site-nav" popover aria-label="주요 메뉴"> <ul> <li><a href="index.html">소개</a></li> <li><a href="projects.html" aria-current="page">프로젝트</a></li> <li><a href="contact.html">연락</a></li> </ul> </nav> </header> <main> <h1>프로젝트</h1> <p>직접 만들고 배운 것들을 모았습니다.</p> <!-- 08편: 검색 랜드마크 --> <search> <label for="project-search">프로젝트 이름으로 찾기</label> <input type="search" id="project-search" placeholder="예: bookshelf" /> </search> <!-- 18편: Customizable select(폴백 포함) --> <label for="tag-filter">태그로 거르기</label> <select id="tag-filter"> <button type="button"> <selectedcontent></selectedcontent> </button> <option value="all">전체</option> <option value="JavaScript">JavaScript</option> <option value="React">React</option> <option value="Web APIs">Web APIs</option> <option value="HTML">HTML</option> <option value="CSS">CSS</option> <option value="Node.js">Node.js</option> </select> <div class="project-grid"> <article class="project-card" data-tags="JavaScript,PWA"> <img srcset="images/project-01.svg?w=480 480w, images/project-01.svg?w=960 960w, images/project-01.svg?w=1200 1200w" sizes="(min-width: 1024px) 33vw, (min-width: 768px) 50vw, 100vw" src="images/project-01.svg" width="1200" height="800" loading="lazy" decoding="async" alt="오늘의 날씨 프로젝트 화면" /> <h3>오늘의 날씨</h3> <p>위치 기반 날씨를 카드로 보여주는 웹앱입니다. 오프라인에서도 마지막 데이터를 보여줍니다.</p> <ul class="tags"> <li> <button type="button" class="tag" popovertarget="tip-weather-js">JavaScript</button> <div id="tip-weather-js" class="tooltip" popover role="tooltip">순수 자바스크립트로 위치 조회와 화면 갱신을 구현했습니다.</div> </li> <li> <button type="button" class="tag" popovertarget="tip-weather-pwa">PWA</button> <div id="tip-weather-pwa" class="tooltip" popover role="tooltip">Service Worker로 오프라인에서도 마지막 데이터를 보여줍니다.</div> </li> </ul> <button type="button" class="btn-detail" data-project-id="weather">자세히</button> </article> <article class="project-card" data-tags="React,Vite"> <img srcset="images/project-02.svg?w=480 480w, images/project-02.svg?w=960 960w, images/project-02.svg?w=1200 1200w" sizes="(min-width: 1024px) 33vw, (min-width: 768px) 50vw, 100vw" src="images/project-02.svg" width="1200" height="800" loading="lazy" decoding="async" alt="독서 기록 bookshelf 프로젝트 화면" /> <h3>독서 기록 <ruby>bookshelf<rp>(</rp><rt>북셸프</rt><rp>)</rp></ruby></h3> <p>읽은 책과 별점을 기록하고 통계를 보는 React 앱입니다.</p> <ul class="tags"> <li> <button type="button" class="tag" popovertarget="tip-bookshelf-react">React</button> <div id="tip-bookshelf-react" class="tooltip" popover role="tooltip">컴포넌트 단위로 책 목록과 상세 화면을 구성했습니다.</div> </li> <li> <button type="button" class="tag" popovertarget="tip-bookshelf-vite">Vite</button> <div id="tip-bookshelf-vite" class="tooltip" popover role="tooltip">개발 서버와 번들링에 Vite를 사용했습니다.</div> </li> </ul> <button type="button" class="btn-detail" data-project-id="bookshelf">자세히</button> </article> <article class="project-card" data-tags="Web APIs,IndexedDB"> <img srcset="images/project-03.svg?w=480 480w, images/project-03.svg?w=960 960w, images/project-03.svg?w=1200 1200w" sizes="(min-width: 1024px) 33vw, (min-width: 768px) 50vw, 100vw" src="images/project-03.svg" width="1200" height="800" loading="lazy" decoding="async" alt="메모 앱 notes 프로젝트 화면" /> <h3>메모 앱 <ruby>notes<rp>(</rp><rt>노츠</rt><rp>)</rp></ruby></h3> <p>브라우저 저장소만으로 동작하는 메모 앱입니다. 검색은 Worker가 담당합니다.</p> <ul class="tags"> <li> <button type="button" class="tag" popovertarget="tip-notes-webapis">Web APIs</button> <div id="tip-notes-webapis" class="tooltip" popover role="tooltip">IndexedDB와 Worker로 동작합니다.</div> </li> <li> <button type="button" class="tag" popovertarget="tip-notes-idb">IndexedDB</button> <div id="tip-notes-idb" class="tooltip" popover role="tooltip">메모를 브라우저 안에 영구 저장합니다.</div> </li> </ul> <button type="button" class="btn-detail" data-project-id="notes">자세히</button> </article> <article class="project-card" data-tags="HTML,접근성"> <img srcset="images/project-04.svg?w=480 480w, images/project-04.svg?w=960 960w, images/project-04.svg?w=1200 1200w" sizes="(min-width: 1024px) 33vw, (min-width: 768px) 50vw, 100vw" src="images/project-04.svg" width="1200" height="800" loading="lazy" decoding="async" alt="접근성 체크리스트 프로젝트 화면" /> <h3>접근성 체크리스트</h3> <p>WCAG 항목을 하나씩 점검하는 정적 사이트입니다.</p> <ul class="tags"> <li> <button type="button" class="tag" popovertarget="tip-a11y-html">HTML</button> <div id="tip-a11y-html" class="tooltip" popover role="tooltip">시맨틱 마크업만으로 만들었습니다.</div> </li> <li> <button type="button" class="tag" popovertarget="tip-a11y-a11y">접근성</button> <div id="tip-a11y-a11y" class="tooltip" popover role="tooltip">항목마다 스크린 리더 확인 방법을 함께 적었습니다.</div> </li> </ul> <button type="button" class="btn-detail" data-project-id="a11y">자세히</button> </article> <article class="project-card" data-tags="CSS,디자인 시스템"> <img srcset="images/project-05.svg?w=480 480w, images/project-05.svg?w=960 960w, images/project-05.svg?w=1200 1200w" sizes="(min-width: 1024px) 33vw, (min-width: 768px) 50vw, 100vw" src="images/project-05.svg" width="1200" height="800" loading="lazy" decoding="async" alt="디자인 토큰 미리보기 프로젝트 화면" /> <h3>디자인 토큰 미리보기</h3> <p>CSS 변수로 만든 색·간격 토큰을 한 화면에서 비교합니다.</p> <ul class="tags"> <li> <button type="button" class="tag" popovertarget="tip-tokens-css">CSS</button> <div id="tip-tokens-css" class="tooltip" popover role="tooltip">CSS 커스텀 속성만으로 구현했습니다.</div> </li> <li> <button type="button" class="tag" popovertarget="tip-tokens-design">디자인 시스템</button> <div id="tip-tokens-design" class="tooltip" popover role="tooltip">CSS 변수를 읽어 표로 렌더링합니다.</div> </li> </ul> <button type="button" class="btn-detail" data-project-id="tokens">자세히</button> </article> <article class="project-card" data-tags="Node.js"> <img srcset="images/project-06.svg?w=480 480w, images/project-06.svg?w=960 960w, images/project-06.svg?w=1200 1200w" sizes="(min-width: 1024px) 33vw, (min-width: 768px) 50vw, 100vw" src="images/project-06.svg" width="1200" height="800" loading="lazy" decoding="async" alt="todo-cli 프로젝트 화면" /> <h3><ruby>todo-cli<rp>(</rp><rt>투두 씨엘아이</rt><rp>)</rp></ruby></h3> <p>터미널에서 쓰는 할 일 관리 도구입니다. 파일에 저장하고 통계를 냅니다.</p> <ul class="tags"> <li> <button type="button" class="tag" popovertarget="tip-todo-node">Node.js</button> <div id="tip-todo-node" class="tooltip" popover role="tooltip">Node.js 파일 시스템 API로 저장합니다.</div> </li> </ul> <button type="button" class="btn-detail" data-project-id="todo-cli">자세히</button> </article> </div> <!-- 03, 04편: 공유 프로젝트 상세 모달 --> <dialog id="project-dialog" class="project-dialog"> <form method="dialog"> <button type="submit" value="closed" class="dialog-close" aria-label="닫기">×</button> </form> <h2 class="dialog-title"></h2> <p class="dialog-background"></p> <p class="dialog-role"></p> <p class="dialog-learned"></p> </dialog> </main> <footer class="site-footer"> <p>© 2026 Zeno Kim. 이 사이트는 학습용으로 제작되었습니다.</p> <ul> <li><a href="https://github.com/example" target="_blank" rel="noopener noreferrer">GitHub</a></li> <li><a href="https://www.linkedin.com/in/example" target="_blank" rel="noopener noreferrer">LinkedIn</a></li> <li><a href="https://blog.example.com" target="_blank" rel="noopener noreferrer">Blog</a></li> </ul> </footer> <script type="module" src="js/main.js"></script> </body> </html>
<!-- contact.html --> <!doctype html> <html lang="ko"> <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1" /> <title>연락 · Zeno Kim</title> <link rel="icon" href="favicon.svg" type="image/svg+xml" /> <meta http-equiv="Content-Security-Policy" content="default-src 'self'; img-src 'self' https:; script-src 'self'; style-src 'self' https://fonts.googleapis.com; font-src https://fonts.gstatic.com" /> <meta name="theme-color" content="#0969da" /> <link rel="manifest" href="manifest.webmanifest" /> <meta property="og:type" content="website" /> <meta property="og:title" content="Zeno Kim — 연락" /> <meta property="og:description" content="협업·채용 제안은 문의 폼으로 보내 주세요." /> <meta property="og:image" content="https://zenokim.example/images/og-image.svg" /> <meta property="og:url" content="https://zenokim.example/contact.html" /> <link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <link rel="dns-prefetch" href="https://fonts.googleapis.com" /> <script type="importmap"> { "imports": { "utils": "./js/utils.js" } } </script> <link rel="stylesheet" href="css/tokens.css" /> <link rel="stylesheet" href="css/styles.css" /> </head> <body> <header class="site-header"> <a class="logo" href="index.html">Zeno Kim</a> <button type="button" class="menu-toggle" popovertarget="mobile-menu" popovertargetaction="toggle" aria-label="메뉴 열기"> 메뉴 </button> <nav id="mobile-menu" class="site-nav" popover aria-label="주요 메뉴"> <ul> <li><a href="index.html">소개</a></li> <li><a href="projects.html">프로젝트</a></li> <li><a href="contact.html" aria-current="page">연락</a></li> </ul> </nav> </header> <main> <h1>연락</h1> <p>협업·채용 제안은 아래 폼으로 보내 주세요. 보통 2일 안에 답합니다.</p> <!-- 11, 12편: 현대적 입력 타입 + Constraint Validation --> <form class="contact-form" novalidate> <div class="field"> <label for="name">이름</label> <input type="text" id="name" name="name" autocomplete="name" required aria-describedby="name-error" /> <p class="error" id="name-error" aria-live="polite"></p> </div> <div class="field"> <label for="email">이메일</label> <input type="email" id="email" name="email" inputmode="email" autocomplete="email" required aria-describedby="email-help email-error" /> <p class="help" id="email-help">답장을 받을 주소를 적어 주세요.</p> <p class="error" id="email-error" aria-live="polite"></p> </div> <div class="field"> <label for="phone">전화번호(선택)</label> <input type="tel" id="phone" name="phone" inputmode="tel" autocomplete="tel" pattern="[0-9\-]{9,13}" placeholder="010-1234-5678" /> </div> <div class="field"> <label for="portfolio-url">참고 링크(선택)</label> <input type="url" id="portfolio-url" name="portfolioUrl" inputmode="url" autocomplete="url" placeholder="https://example.com" /> </div> <div class="field"> <label for="topic">문의 유형</label> <select id="topic" name="topic" autocomplete="off"> <option value="collab">협업 제안</option> <option value="hire">채용 제안</option> <option value="etc">기타</option> </select> </div> <div class="field"> <label for="message">메시지</label> <textarea id="message" name="message" minlength="10" enterkeyhint="send" required aria-describedby="message-help message-error"></textarea> <p class="help" id="message-help">프로젝트 내용이나 일정을 간단히 적어 주세요.</p> <p class="error" id="message-error" aria-live="polite"></p> </div> <div class="field field-checkbox"> <input type="checkbox" id="agree" name="agree" required aria-describedby="agree-error" /> <label for="agree">개인정보 수집·이용에 동의합니다</label> <p class="error" id="agree-error" aria-live="polite"></p> </div> <button type="submit">보내기</button> </form> <section class="contact-info"> <h2>연락처 정보</h2> <ul> <li>이메일: hello@zeno.example</li> <li>위치: 서울, 대한민국</li> <li>업무 시간: 평일 10:00 – 18:00 (<span lang="en">KST</span>)</li> </ul> </section> <!-- 07편: exclusive accordion --> <section class="faq"> <h2>자주 묻는 질문</h2> <details name="faq"> <summary>프리랜서 작업도 받나요?</summary> <p>네, 2주 이상 일정이면 가능합니다.</p> </details> <details name="faq"> <summary>원격 근무가 가능한가요?</summary> <p>네, 지금까지 프로젝트의 대부분을 원격으로 진행했습니다.</p> </details> <details name="faq"> <summary>사용하는 도구는 무엇인가요?</summary> <p><span lang="en">VS Code</span>, <span lang="en">Figma</span>, <span lang="en">GitHub</span>를 주로 씁니다.</p> </details> </section> </main> <footer class="site-footer"> <p>© 2026 Zeno Kim. 이 사이트는 학습용으로 제작되었습니다.</p> <ul> <li><a href="https://github.com/example" target="_blank" rel="noopener noreferrer">GitHub</a></li> <li><a href="https://www.linkedin.com/in/example" target="_blank" rel="noopener noreferrer">LinkedIn</a></li> <li><a href="https://blog.example.com" target="_blank" rel="noopener noreferrer">Blog</a></li> </ul> </footer> <script type="module" src="js/main.js"></script> </body> </html>
// js/main.js import { qs, qsa, announce } from './utils.js' // 03, 04편: 프로젝트 상세 모달 const projectDialog = qs('#project-dialog') const projectDetails = { weather: { title: '오늘의 날씨', background: '위치 기반 날씨 정보를 빠르게 확인할 수 있는 화면이 필요해서 시작한 프로젝트입니다.', role: 'fetch로 외부 날씨 API를 호출하고, Service Worker로 마지막 응답을 캐시했습니다.', learned: '네트워크가 끊겨도 마지막 데이터를 보여주는 오프라인 우선 설계를 배웠습니다.', }, bookshelf: { title: '독서 기록 bookshelf', background: '읽은 책과 별점을 기록할 곳이 마땅치 않아 직접 만든 React 앱입니다.', role: '컴포넌트를 상태별로 나누고, Context로 테마를 전역에서 관리했습니다.', learned: 'props와 state를 분리하는 기준, 리스트 렌더링 최적화를 배웠습니다.', }, notes: { title: '메모 앱 notes', background: '서버 없이 브라우저 저장소만으로 동작하는 메모 앱을 만들어 보고 싶었습니다.', role: 'IndexedDB로 메모를 저장하고, 검색은 Worker 스레드로 분리했습니다.', learned: '메인 스레드를 막지 않고 무거운 검색을 처리하는 방법을 배웠습니다.', }, a11y: { title: '접근성 체크리스트', background: 'WCAG 항목을 하나씩 점검할 수 있는 정적 사이트가 필요했습니다.', role: '시맨틱 요소와 ARIA 속성만으로 체크리스트 UI를 구성했습니다.', learned: '시맨틱 마크업이 접근성의 기본 토대라는 것을 실감했습니다.', }, tokens: { title: '디자인 토큰 미리보기', background: 'CSS 변수로 만든 색·간격 토큰을 한눈에 비교하고 싶어 만들었습니다.', role: 'CSS 커스텀 속성을 값별로 나열하고, 배경색을 그대로 미리보기로 썼습니다.', learned: '디자인 토큰을 코드로 관리하면 일관성을 지키기 쉽다는 것을 배웠습니다.', }, 'todo-cli': { title: 'todo-cli', background: '터미널에서 바로 쓸 수 있는 할 일 관리 도구가 필요해 만들었습니다.', role: 'Node.js로 파일 저장소를 구현하고, 명령어별로 모듈을 나눴습니다.', learned: '파일 I/O를 비동기로 다루는 법과 CLI 인자 파싱을 배웠습니다.', }, } let lastTrigger = null qsa('[data-project-id]').forEach((button) => { button.addEventListener('click', () => { const detail = projectDetails[button.dataset.projectId] projectDialog.querySelector('.dialog-title').textContent = detail.title projectDialog.querySelector('.dialog-background').textContent = detail.background projectDialog.querySelector('.dialog-role').textContent = detail.role projectDialog.querySelector('.dialog-learned').textContent = detail.learned lastTrigger = button projectDialog.showModal() }) }) // 06편: 모달이 열리면 배경(header)은 브라우저가 자동으로 inert 처리한다. 별도 코드가 필요 없다. projectDialog?.addEventListener('close', () => { lastTrigger?.focus() }) // 08편: 검색어로 카드 걸러내기(hidden=until-found로 브라우저 찾기와 연동) const searchInput = qs('#project-search') const projectCards = qsa('.project-card') function filterProjects(keyword) { const visible = projectCards.filter((card) => { const match = card.textContent.toLowerCase().includes(keyword.toLowerCase()) card.hidden = keyword.length > 0 && !match ? 'until-found' : false return match }) announce(`${visible.length}개의 프로젝트를 찾았습니다.`) } searchInput?.addEventListener('input', () => { filterProjects(searchInput.value.trim()) }) projectCards.forEach((card) => { card.addEventListener('beforematch', () => { console.log('beforematch: 검색으로 숨겨졌던 카드가 찾기로 다시 펼쳐집니다 —', card.querySelector('h3').textContent) }) }) // 18편: 태그 select로 필터링, Customizable select 지원 여부 감지 const tagFilter = qs('#tag-filter') tagFilter?.addEventListener('change', () => { const tag = tagFilter.value projectCards.forEach((card) => { const tags = (card.dataset.tags || '').split(',') card.hidden = tag !== 'all' && !tags.includes(tag) }) }) if (window.CSS?.supports?.('appearance', 'base-select')) { document.documentElement.classList.add('supports-base-select') } // 12편: Constraint Validation API로 한국어 오류 메시지 const contactForm = qs('.contact-form') const messages = { name: { valueMissing: '이름을 입력해 주세요.' }, email: { valueMissing: '이메일을 입력해 주세요.', typeMismatch: '올바른 이메일 형식이 아닙니다.', }, phone: { patternMismatch: '010-1234-5678 형식으로 입력해 주세요.' }, message: { valueMissing: '메시지를 입력해 주세요.', tooShort: '메시지는 10자 이상 입력해 주세요.', }, agree: { valueMissing: '개인정보 수집·이용에 동의해야 제출할 수 있습니다.' }, } function validateField(field) { field.setCustomValidity('') const rules = messages[field.name] if (!rules) return const validity = field.validity let text = '' for (const [key, msg] of Object.entries(rules)) { if (validity[key]) { text = msg break } } field.setCustomValidity(text) const errorEl = document.getElementById(`${field.id}-error`) if (errorEl) { errorEl.textContent = text } } if (contactForm) { const fields = contactForm.querySelectorAll('input[name], textarea[name]') fields.forEach((field) => { field.addEventListener('input', () => validateField(field)) field.addEventListener('blur', () => validateField(field)) }) contactForm.addEventListener('submit', (event) => { fields.forEach((field) => validateField(field)) if (!contactForm.checkValidity()) { event.preventDefault() contactForm.reportValidity() } }) }

다음 과목으로 넘어가기 전에

  • <template>/<slot>/Declarative Shadow DOM은 이 과목에서 다루지 않았다. 재사용 가능한 커스텀 요소를 직접 만들고 싶다면 javascript/web_component 과목에서 API 전체를 배운다.
  • 지금까지 만든 포트폴리오는 시맨틱 마크업과 표준 HTML 기능만으로 동작하지만 아직 CSS가 거의 없다. 다음 과목 css/css_fundamentals에서 이 사이트를 디자인 시안대로 스타일링한다.
  • 실험적 기능(Customizable select)처럼 지원 상태가 바뀌는 항목은 이후에도 02편에서 배운 방법으로 주기적으로 재확인한다.

자주 하는 실수

증상원인고치는 법
점검 항목 중 일부가 실패함앞선 편의 코드를 일부만 반영함위 정본 코드와 파일을 한 줄씩 비교해 누락된 속성을 채운다
모달을 닫았는데 배경이 계속 비활성화된 것처럼 보임showModal() 대신 show()로 열었거나 03편의 dialog 마크업이 바뀜06편에서 확인한 대로 showModal()은 배경을 자동으로 inert 처리하므로 별도 코드 없이 03·04편 구조를 그대로 유지한다
appearance: base-select가 모든 브라우저에서 다르게 보임@supports 분기 없이 적용18편의 @supports (appearance: base-select) 패턴을 그대로 쓴다

확인 문제

문제 14지선다
이 과목을 마쳤다고 볼 수 있는 기준으로 알맞지 않은 것은?
문제 24지선다
모달이 열려 있는 동안 배경 내비게이션이 inert 상태가 되게 하려면 main.js에 추가로 필요한 코드는?
문제 34지선다
다음 과목 css_fundamentals에서 이어받을 포트폴리오의 상태로 알맞은 것은?

참고 자료

Last updated on