웹 개발 데일리 브리핑 — 2026-11-02
Soshy··조회수 —

기준 시점: 2026-11-02 06:00 KST
조사 범위: 전일 2026-11-01 06:00 ~ 당일 2026-11-02 06:00 KST
추천 글 범위: 기준 시점 당시 최근 7일
1️⃣ 프론트엔드
이번 조사 범위에서 포함할 만한 주요 신규 발표를 확인하지 못했습니다.
React, Next.js, Vercel, Vue, Nuxt, Angular, Svelte, SvelteKit, Astro, Remix의 공식 블로그와 릴리스 채널을 확인했지만, 이번 24시간에 해당하는 중요한 정식 릴리스나 새로운 프레임워크 API의 안정화 발표는 확인하지 못했다.
조사 과정에서 제외한 Next.js Canary 업데이트
Next.js의 공식 릴리스 채널에서는 v16.5.0-canary.36을 확인했다.
- 릴리스 표시일: 2026-10-31 — GitHub에 표시된 시각은 23:42이며 시간대 표시는 확인되지 않음
- 버전: Next.js 16.5.0-canary.36
- 안정화 단계: Canary Pre-release
- 원문: https://github.com/vercel/next.js/releases/tag/v16.5.0-canary.36
이번 버전의 주요 변경은 다음과 같다.
- React의 실험적 Snapshot 갱신
- Turbopack의 Module Graph 구성 과정에서 모듈별 데이터를 한 번만 해석하도록 내부 처리 개선
Turbopack 변경은 Module Graph를 구성하는 동안 동일한 모듈 정보를 반복해서 해석하는 작업을 줄이려는 목적이다.
일반적으로 Bundler는 여러 파일의 Import 관계를 분석해 Module Graph를 생성한다.
이 과정에서 하나의 모듈을 여러 경로에서 참조할 수 있다.
따라서 모듈별 분석 결과를 재사용하면 중복 작업을 줄일 가능성이 있다.
하지만 이번 릴리스에는 빌드 시간이 얼마나 개선됐는지에 관한 정량적인 Benchmark가 포함되지 않았다.
또한 새로운 사용자 API나 안정화된 기능을 발표한 버전도 아니다.
따라서 이번 브리핑의 '중요한 신규 발표' 기준에는 포함하지 않았다.
이전 브리핑에서 이미 다룬 v16.5.0-canary.35의 변경도 반복하지 않았다.
이번 조사 범위에서는 프론트엔드 프레임워크의 새로운 기능 발표보다 실제 운영 프로젝트에서 발견한 성능 및 아키텍처 문제를 분석한 기술 글이 더 유의미했다.
특히 Next.js의 정적 페이지에 불필요한 인증 클라이언트가 포함된 사례와 Astro의 Markdown 콘텐츠를 빌드 단계에서 검증하는 사례를 마지막 추천 글 섹션에서 자세히 다룬다.
2️⃣ 웹 플랫폼·브라우저
이번 조사 범위에서 포함할 만한 주요 신규 발표를 확인하지 못했습니다.
W3C, WHATWG, TC39, MDN, Chrome Developers·Chromium, Mozilla·Firefox, WebKit·Safari의 공식 발표와 JavaScript·TypeScript·CSS, 웹 성능 및 접근성 관련 정보를 확인했다.
이번 기간에 새롭게 정식 채택된 주요 웹 표준이나 안정화된 브라우저 API의 출시 발표는 확인하지 못했다.
최근 공개된 다음 항목은 이미 이전 브리핑에서 다뤘으므로 반복하지 않았다.
- Firefox 159의 정식 출시
- Chrome 158 Beta
- W3C Geolocation Candidate Recommendation Draft 개정
- WebMCP Polyfill 관련 W3C 발표
또한 브라우저의 개발 빌드와 규격 초안의 변경은 일반 사용자를 대상으로 한 안정적인 기능 출시와 구분했다.
이번 조사 범위에서는 새로운 웹 플랫폼 기능의 출시보다 기존 브라우저 기술이 실제 서비스의 성능과 동작에 미치는 영향을 다룬 기술 글을 확인했다.
특히 Google Analytics와 같은 외부 JavaScript가 메인 스레드에 추가하는 실행 비용을 직접 측정한 글을 추천 섹션에 포함했다.
해당 글에서는 async로 다운로드한 스크립트도 실제 실행 시에는 사용자 입력을 지연시킬 수 있다는 점을 실험으로 확인한다.
3️⃣ 백엔드·인프라
이번 조사 범위에서 포함할 만한 주요 신규 발표를 확인하지 못했습니다.
Cloudflare, AWS, Google Cloud, Microsoft Azure의 공식 업데이트와 서버리스·엣지, 데이터베이스, API 관련 발표를 검토했지만, 이번 24시간에 해당하는 중요한 신규 정식 기능이나 긴급 인프라 변경은 확인하지 못했다.
Cloudflare의 공식 Changelog에서 확인한 최근 주요 변경은 10월 30일에 공개된 내용이다.
대표적으로 다음 발표가 포함돼 있었다.
- Durable Objects의 지연 코드 업데이트
- D1 Query 결과 크기 제한 예고
- Browser Run의 Chrome 버전 업데이트
- Workers의 BYOIP Subrequest Context 보존
- Keyless SSL의 TLS 1.3 지원
이러한 항목은 이전 브리핑에서 이미 다뤘으므로 이번 조사에 다시 포함하지 않았다.
Vercel의 최근 주요 발표도 10월 29~30일에 집중돼 있었다.
Node.js 26 LTS 지원, Vercel Flags의 초기화 최적화, AI SDK의 Harness 실행 구조 변경 등은 이미 소개한 내용이다.
AWS와 Google Cloud에서도 조사 범위에 해당하는 새로운 주요 발표를 공식 원문으로 확인하지 못했다.
다만 발표가 없다는 사실이 해당 클라우드 서비스에서 아무런 내부 변경도 발생하지 않았다는 의미는 아니다.
실제 고객에게 공개된 중요한 기능·정책 변경을 기준으로 선정했다.
이번 추천 글에는 백엔드 아키텍처와 관련해 두 가지 자료를 포함했다.
첫 번째는 Next.js와 Capacitor를 이용해 온라인 API가 실패해도 정상적으로 작동하는 오프라인 우선 애플리케이션을 구현한 사례다.
두 번째는 Model Context Protocol(MCP) 서버를 단위 테스트부터 실제 HTTP 통신까지 검증하는 방법을 다룬 글이다.
4️⃣ 개발 도구 및 보안
이번 조사 범위에서 포함할 만한 주요 신규 발표를 확인하지 못했습니다.
Node.js, Deno, Bun, Vite, webpack, Rollup, esbuild, Babel, Biome, ESLint, Prettier, npm, pnpm, Yarn, GitHub, GitLab의 공식 릴리스와 변경 기록을 확인했다.
이번 기간에 해당하는 중요한 정식 릴리스나 새로운 긴급 보안 수정은 확인하지 못했다.
소규모 패치와 Canary 업데이트는 정식 기능 발표와 구분했다.
또한 이전 브리핑에서 소개한 다음 항목은 반복하지 않았다.
- GitHub Actions Workflow Lockfile
- Dependabot Reachability Analysis
- GitHub Code Scanning의 AI 관련 보안 기능
- GitHub Agentic Workflows
- Bun Formatter 성능 분석
- Node.js Brotli 관련 보안 공지
보안 관련 자료는 실제 취약점 발표일과 보안 공지 게시일, 패치 적용일을 구분해 확인했다.
새로운 분석 글이나 기존 취약점에 대한 설명이 게시됐다는 이유만으로 이를 새로운 보안 취약점의 발견으로 취급하지 않았다.
이번 추천 글에서는 새로운 개발 도구 출시 발표를 반복하는 대신 기존 도구의 실제 사용법과 한계를 다룬 자료를 선정했다.
특히 MCP 서버의 Tool Schema와 HTTP Transport를 검증하는 테스트 구조, Astro 빌드에서 잘못된 콘텐츠를 차단하는 방법을 자세히 살펴볼 수 있다.
📚 추천 글
A 436-word static page on our site downloads 263 KB of JavaScript, and a quarter of it is a realtime client we never open
- 저자: Daniel Pertu
- 매체: DEV Community
- 게시일: 2026-11-01 — 정확한 게시 시각 미확인
- 기술: Next.js App Router, React Server Components, Supabase, JavaScript Bundle 분석
- 원문: https://dev.to/daniel_pertu/a-436-word-static-page-on-our-site-downloads-263-kb-of-javascript-and-a-quarter-of-it-is-a-3him
실제 Next.js 웹사이트에서 단어 436개로 구성된 정적 페이지 하나를 표시하는 데 약 263KiB의 JavaScript가 다운로드되고, 그중 약 26%가 실제 페이지에서는 사용하지 않는 Supabase 클라이언트에서 발생한다는 사실을 분석한 글이다.
이번 글은 일반적인 프론트엔드 최적화 방법을 나열하는 자료가 아니다.
저자는 운영 중인 웹사이트의 실제 HTML과 JavaScript 파일을 가져와 다운로드 크기를 계산했다.
그리고 각 JavaScript Chunk가 어떤 기능 때문에 포함됐는지 소스코드의 Import 관계를 추적했다.
특히 정적으로 생성한 페이지라고 해서 클라이언트 JavaScript가 반드시 작아지는 것은 아니라는 점을 구체적인 측정 결과로 보여준다.
461개의 정적 콘텐츠 페이지
저자가 운영하는 웹사이트는 Munchable이다.
이 사이트에는 특정 음식 재료가 식단에 적합한지 설명하는 콘텐츠가 존재한다.
예를 들어 요거트가 특정 식단에 적합한지 설명하는 페이지다.
비슷한 구조의 페이지는 총 461개다.
각 페이지는 빌드 시점에 생성된다.
Route에는 다음 설정이 존재한다.
export const dynamic = 'error'
이 설정은 요청별 동적 기능을 사용할 수 없도록 제한하는 데 사용된다.
따라서 해당 페이지를 렌더링하기 위해 매 요청마다 서버에서 데이터를 조회할 필요는 없다.
또한 페이지에는 다음 기능도 존재하지 않는다.
- Form
- Chart
- Modal
- 복잡한 클라이언트 상태 관리
겉으로 보기에는 매우 단순한 정적 콘텐츠 페이지다.
하지만 실제 네트워크에서 다운로드하는 JavaScript의 크기는 예상보다 컸다.
실제 다운로드 크기
저자는 페이지의 HTML을 가져왔다.
그다음 HTML에 포함된 Script URL을 추출했다.
각 Script를 압축하지 않은 상태와 Brotli 압축 상태로 요청했다.
측정 결과는 다음과 같다.
| 항목 | 크기 |
|---|---|
| 실제 화면에 표시되는 텍스트 | 2,512바이트 |
| 화면의 단어 수 | 436개 |
| HTML 원본 크기 | 39,482바이트 |
| HTML Brotli 전송 크기 | 8,437바이트 |
| Inline Script | 24,571바이트 |
| 그중 React Server Components Payload | 22,821바이트 |
| 외부 JavaScript 파일 수 | 11개 |
| 외부 JavaScript 전송 크기 합계 | 269,233바이트 |
| 외부 JavaScript 원본 크기 합계 | 908,554바이트 |
외부 JavaScript의 전송 크기만 약 263KiB다.
반면 실제 페이지의 본문 텍스트는 약 2.5KB다.
물론 JavaScript와 본문 텍스트의 크기를 직접 비교한다고 해서 전부 불필요한 JavaScript라고 판단할 수는 없다.
React 애플리케이션에서는 컴포넌트 실행과 클라이언트 측 탐색 등을 위한 Runtime이 필요할 수 있기 때문이다.
하지만 이번 사례에서는 JavaScript Bundle의 상당 부분이 실제로 사용하지 않는 기능에서 발생하고 있었다.
Supabase 클라이언트의 크기
저자가 분석한 주요 Chunk는 다음과 같다.
| 구성요소 | 전송 크기 | 압축 해제 후 크기 |
|---|---|---|
| React DOM 및 Scheduler | 72,353바이트 | 227,532바이트 |
| Supabase 관련 클라이언트 | 69,019바이트 | 259,245바이트 |
| Turbopack Runtime | 4,336바이트 | 10,580바이트 |
Supabase 관련 Chunk에는 다음 코드가 포함돼 있었다.
GoTrueClientRealtimeClient
GoTrueClient는 인증과 관련된 기능을 제공한다.
RealtimeClient는 PostgreSQL의 변경 사항을 WebSocket 등으로 전달받는 기능과 연결된다.
그런데 저자는 실제 웹 애플리케이션에서 이러한 기능을 사용하고 있는지 확인했다.
대표적으로 다음 호출을 검색했다.
.channel()postgres_changessupabase.from().storage
검색 결과는 비어 있었다.
즉 브라우저에서 Realtime Subscription을 만들지 않았다.
브라우저에서 Supabase의 Table을 직접 조회하지도 않았다.
실제 데이터 조회는 서버의 ORM을 통해 수행하고 있었다.
그럼에도 Supabase의 브라우저 클라이언트 전체가 초기 JavaScript에 포함됐다.
원인은 공통 Header
저자는 해당 클라이언트가 어떤 Import 경로를 통해 포함됐는지 추적했다.
결과는 다음과 같았다.
app/layout.tsx → Header → AuthControls → use-auth → createBrowserClient()
애플리케이션의 Root Layout에는 공통 Header가 존재한다.
Header는 사용자의 로그인 상태에 따라 다른 UI를 표시한다.
로그인하지 않은 사용자에게는 로그인 버튼을 표시한다.
로그인한 사용자에게는 계정 메뉴와 이메일 주소를 표시한다.
따라서 Header에서는 현재 사용자의 Session을 확인해야 한다.
문제는 이 기능이 Root Layout에 존재한다는 점이다.
Root Layout은 461개의 정적 콘텐츠 페이지에도 적용된다.
따라서 실제로 로그인 상태가 중요하지 않은 콘텐츠 페이지에서도 Supabase 브라우저 클라이언트가 필요해졌다.
이 문제는 단순히 개발자가 실수로 불필요한 라이브러리를 Import한 사례가 아니다.
각 단계의 설계는 개별적으로 합리적이었다.
- 사이트 전체에서 일관된 Header 사용
- Header에서 사용자 로그인 상태 확인
- Supabase의 공식 인증 클라이언트 사용
하지만 이 세 가지 선택이 결합되면서 모든 정적 페이지의 JavaScript 크기가 증가했다.
Tree Shaking으로 제거되지 않은 이유
Tree Shaking은 실제로 사용하지 않는 JavaScript Export를 최종 Bundle에서 제거하는 최적화다.
하지만 이번 사례에서는 Supabase 브라우저 클라이언트 객체 자체를 생성하고 있었다.
따라서 해당 객체가 제공하는 기능 가운데 일부 메서드를 직접 호출하지 않는다고 해서 관련 코드가 모두 제거되는 것은 아니었다.
예를 들어 createBrowserClient()는 인증 기능뿐 아니라 여러 Supabase 기능을 포함하는 클라이언트를 생성한다.
실제 애플리케이션에서는 인증 관련 기능만 사용한다.
하지만 Bundler가 해당 객체의 일부 내부 기능을 안전하게 제거할 수 있다고 판단하지 못할 수 있다.
그 결과 Realtime과 Storage 관련 코드도 함께 포함될 수 있다.
이 사례는 사용하지 않는 메서드가 존재한다는 사실과 해당 메서드의 코드가 최종 Bundle에서 제거된다는 사실이 다르다는 점을 보여준다.
저자가 검토한 네 가지 해결 방법
첫 번째 방법은 인증 클라이언트를 Lazy Loading하는 것이다.
예를 들어 사용자가 실제로 계정 메뉴를 열거나 페이지가 Idle 상태에 들어갔을 때 인증 클라이언트를 로딩할 수 있다.
이렇게 하면 첫 화면에서 약 69KB의 JavaScript 다운로드를 줄일 가능성이 있다.
하지만 로그인한 사용자는 처음에 로그인 버튼을 볼 수 있다.
인증 상태가 확인된 이후에야 계정 메뉴가 표시되므로 UI가 잠깐 변경될 수 있다.
두 번째 방법은 Supabase의 전체 브라우저 클라이언트를 사용하지 않고 인증 REST API만 직접 호출하는 것이다.
이렇게 하면 필요한 기능만 구현할 수 있다.
하지만 다음 책임을 애플리케이션이 직접 관리해야 한다.
- Access Token 갱신
- Token 만료 처리
- 인증 정보 저장
- 여러 브라우저 탭 사이의 Session 동기화
저자는 단순한 Header 때문에 이러한 책임을 직접 구현하는 것은 적절하지 않을 수 있다고 판단했다.
세 번째 방법은 콘텐츠 페이지를 동적 렌더링으로 변경하는 것이다.
서버가 Cookie를 확인하고 로그인 상태에 맞는 Header를 생성하면 브라우저에서 인증 클라이언트를 실행할 필요가 줄어들 수 있다.
하지만 이 경우 기존의 461개 정적 페이지를 요청별 서버 렌더링으로 변경해야 한다.
정적 캐싱의 이점을 포기해야 할 수 있으므로 저자는 적합하지 않다고 판단했다.
네 번째 방법은 현재 구조를 유지하는 것이다.
JavaScript Chunk는 캐싱할 수 있으므로 여러 페이지를 방문하는 사용자에게는 중복 다운로드 비용이 줄어들 수 있다.
하지만 저자는 실제 콘텐츠 페이지의 방문 패턴을 고려했다.
검색 엔진에서 특정 질문의 답을 찾기 위해 방문한 사용자는 하나의 페이지를 읽고 사이트를 떠날 수 있다.
이러한 경우에는 두 번째 페이지에서 Cache를 재사용한다는 장점이 크지 않을 수 있다.
RSC Payload의 중복
저자는 React Server Components의 Payload도 분석했다.
HTML에는 실제 화면의 콘텐츠가 이미 존재한다.
그런데 Inline Script에는 같은 콘텐츠를 React가 처리할 수 있는 형식으로 직렬화한 정보가 포함돼 있었다.
해당 Payload의 크기는 22,821바이트였다.
화면에 실제로 표시되는 텍스트가 2,512바이트라는 점을 생각하면 큰 차이다.
하지만 이 데이터는 압축 과정에서 중복되는 문자열이 효과적으로 압축될 수 있다.
따라서 원본 Payload의 크기만큼 실제 네트워크 비용이 추가된다고 볼 수는 없다.
저자도 이를 새로운 버그라고 주장하지 않는다.
App Router의 렌더링 방식에서 발생하는 비용으로 구분한다.
측정 결과의 한계
이번 글의 JavaScript 크기는 실제 공개 웹사이트의 파일을 대상으로 측정한 결과다.
하지만 다음 수치는 제공되지 않았다.
- Lazy Loading 이후 실제 Bundle 크기
- 변경 전후의 LCP
- 변경 전후의 INP
- 실제 사용자의 네트워크 환경별 다운로드 시간
즉 약 69KB를 줄일 수 있다는 것은 현재 Bundle의 구성에서 도출한 개선 가능성이며, 실제 코드를 변경한 뒤 측정한 결과는 아니다.
또한 해당 페이지의 Chunk 구성이 다른 Next.js 프로젝트에서도 동일하게 나타난다는 의미는 아니다.
이번 글은 정적 페이지 하나에 어떤 JavaScript가 포함되는지 확인하려면 개별 페이지의 코드뿐 아니라 Root Layout과 공통 컴포넌트의 Import 관계까지 분석해야 한다는 점을 구체적인 크기와 경로로 보여준다.
Astro の記事に製品名と料金を直書きさせない: 差し込みの印と、ビルドを止める 3 つの検査
- 저자: Hotori / インサイドセールス株式会社
- 매체: Zenn
- 게시일: 2026-10-31
- 기술: Astro, TypeScript, Markdown, remark, mdast, Zod
- 원문: https://zenn.dev/hotori/articles/9b899354c350b3
Astro로 운영하는 실제 기업 웹사이트에서 제품 이름과 요금 정보를 여러 페이지에 직접 작성하지 못하도록 제한하고, Markdown 콘텐츠에 오류가 있으면 배포 전에 빌드를 실패시키는 구조를 구현한 사례다.
이번 글은 단순히 상수를 하나의 파일로 분리하는 방법을 설명하지 않는다.
저자는 실제 콘텐츠 작성 과정에서 발생할 수 있는 문제를 세 종류로 나누었다.
그리고 각 문제를 발견하기 위해 서로 다른 빌드 검사를 추가했다.
특히 Astro의 콘텐츠 로딩 과정에서 오류가 기록됐는데도 빌드가 계속 진행돼 본문이 비어 있는 페이지가 생성된 문제를 직접 발견하고 해결했다는 점이 흥미롭다.
제품 정보의 단일 원천
저자가 운영하는 서비스는 Hotori다.
서비스 소개 페이지는 Astro로 개발했다.
운영 정책상 다음 정보를 한 곳에서만 관리하도록 했다.
- 제품 이름
- 제품 도메인
- 요금
- 플랜 이름
공통 설정 파일은 다음과 같다.
src/config/brand.ts
예를 들어 월 요금이 변경됐다고 가정해 보자.
서비스 소개 페이지와 가격 비교 페이지, 기술 블로그에 각각 가격을 직접 작성했다면 모든 페이지를 찾아 수정해야 한다.
일부 페이지의 가격을 변경하지 못하면 서로 다른 가격이 표시될 수 있다.
저자는 이러한 문제를 방지하기 위해 모든 콘텐츠가 공통 설정을 참조하도록 구성했다.
하지만 Markdown 문서에서는 문제가 발생했다.
일반적인 Astro 컴포넌트에서는 TypeScript 설정값을 Import할 수 있다.
반면 콘텐츠 작성자가 Markdown 본문에 제품 이름과 가격을 자연스럽게 작성하려면 별도의 방법이 필요했다.
Placeholder 기반 콘텐츠 작성
저자는 Markdown에 실제 제품 이름 대신 Placeholder를 작성하도록 했다.
대표적인 형태는 다음과 같다.
{{name}}{{std.month}}
{{name}}은 제품 이름을 나타낸다.
{{std.month}}는 Standard 플랜의 월 요금을 나타낸다.
실제 값은 다음 파일에서 가져온다.
src/config/placeholders.ts
이 파일은 brand.ts에 정의된 정보를 기반으로 Placeholder와 값을 연결한다.
예를 들어 제품의 가격이 변경됐다면 brand.ts만 수정하면 된다.
이후 Markdown을 빌드하는 과정에서 변경된 가격이 반영된다.
따라서 콘텐츠 작성자는 실제 요금 숫자를 직접 입력할 필요가 없다.
remark와 mdast
Markdown 본문의 Placeholder는 remark Plugin에서 처리한다.
remark는 Markdown을 분석하고 변환하기 위한 도구다.
mdast는 Markdown 문서의 구조를 트리 형태로 표현한 자료구조다.
예를 들어 Markdown 문서에는 다음 Node가 존재할 수 있다.
- 문단
- 텍스트
- 링크
- 이미지
- Inline Code
- Fenced Code Block
저자는 mdast를 순회하면서 문자열에 해당하는 Placeholder를 실제 값으로 변경했다.
검사 대상에는 다음이 포함된다.
- 일반 텍스트
- Inline Code
- HTML
- 링크 URL
- 이미지 Alt Text
- Title
반면 Fenced Code Block은 변환하지 않았다.
코드 블록 안의 {{name}}은 실제 코드의 일부일 수 있기 때문이다.
이렇게 하면 코드 예제를 잘못 변경하는 문제를 줄일 수 있다.
Frontmatter는 별도로 처리
Astro의 Markdown Frontmatter에는 다음 정보가 포함될 수 있다.
titledescriptioncategory
문제는 이러한 값이 일반적인 Markdown 본문과 같은 위치에 존재하지 않는다는 것이다.
따라서 본문의 mdast를 변환하는 remark Plugin만으로는 Frontmatter의 모든 값을 처리할 수 없다.
저자는 이를 해결하기 위해 별도의 함수를 만들었다.
articleText()
해당 함수는 Frontmatter의 텍스트에서 Placeholder를 변경한다.
이 함수는 다음 기능에서 공통으로 사용된다.
- 기사 목록
- 개별 기사 페이지
- 사이트맵 관련 콘텐츠 처리
llms.txt
이렇게 하면 같은 문서의 제목이 페이지마다 다르게 표시되는 문제를 줄일 수 있다.
첫 번째 검사: 존재하지 않는 Placeholder 차단
저자는 다음 정규식으로 Placeholder를 찾는다.
/\{\{\s*([\w.]+)\s*\}\}/g
이 정규식은 다음과 같은 이름을 처리한다.
namestd.monthbiz.month
Placeholder의 이름에 해당하는 값을 찾지 못하면 예외를 발생시킨다.
예를 들어 콘텐츠 작성자가 다음과 같이 잘못 입력했다고 가정해 보자.
{{nmae}}
실제 설정에는 nmae가 존재하지 않는다.
이때 빈 문자열로 변경하거나 원래 텍스트를 그대로 남겨 두지 않는다.
대신 오류를 발생시킨다.
또한 어떤 Markdown 파일에서 오류가 발생했는지 메시지에 포함한다.
이렇게 하면 잘못된 제품 정보가 포함된 페이지가 배포되는 것을 방지할 수 있다.
하지만 이 검사에는 한계가 존재했다.
두 번째 검사: 본문이 비어 있는 페이지 차단
저자는 2026년 10월 28일에 Astro 5.18.2로 직접 테스트를 수행했다.
Markdown 본문에 잘못된 Placeholder를 작성했다.
remark Plugin은 정상적으로 오류를 발생시켰다.
하지만 예상하지 못한 결과가 나타났다.
Astro의 Glob Loader는 로그에 다음 오류를 기록했다.
[glob-loader] ERROR
그런데 빌드 전체는 실패하지 않았다.
대신 본문이 비어 있는 페이지가 생성됐다.
즉 개발 도구가 오류를 출력했다는 사실과 실제 빌드가 실패했다는 사실이 달랐던 것이다.
이러한 상황은 운영 환경에서 문제가 될 수 있다.
CI가 빌드 성공 여부만 확인한다면 빈 콘텐츠 페이지가 그대로 배포될 수 있기 때문이다.
저자는 이를 해결하기 위해 문서의 최종 렌더링 결과를 검사했다.
대표적인 조건은 다음과 같다.
!rendered.html?.trim()
본문이 존재해야 하는 기사에서 렌더링 결과가 비어 있다면 직접 오류를 발생시키도록 했다.
또한 오류 메시지에 관련 파일 경로를 포함했다.
이렇게 하면 Astro의 Glob Loader가 오류를 출력한 뒤 계속 진행하더라도 최종 빌드는 실패하도록 만들 수 있다.
세 번째 검사: 최종 HTML에서 Placeholder 누락 확인
첫 번째 Placeholder 정규식에도 문제가 존재했다.
다음 표현은 정상적인 Placeholder로 처리할 수 있다.
{{name}}
하지만 다음 표현은 다르다.
{{名前}}
정규식의 \w는 이 일본어 문자열을 같은 방식으로 처리하지 못한다.
따라서 Placeholder를 변경하지도 않고 오류를 발생시키지도 않을 수 있다.
그 결과 최종 HTML에 다음 문자열이 그대로 남을 수 있다.
{{名前}}
저자는 이를 방지하기 위해 실제 빌드 결과물인 dist/를 검사하도록 했다.
대표적인 정규식은 다음과 같다.
/\{\{[^}]*\}\}/
이 검사는 앞선 Placeholder 처리보다 넓은 범위의 문자열을 찾는다.
따라서 정확한 Placeholder 규격에 맞지 않아 변환되지 않은 표현도 발견할 수 있다.
이러한 구조에서는 세 가지 검사가 서로 다른 문제를 처리한다.
| 검사 | 목적 |
|---|---|
| Placeholder 처리 중 예외 | 존재하지 않는 Placeholder 사용 방지 |
| 렌더링된 HTML 검사 | 본문 생성 실패 후 빈 페이지 배포 방지 |
최종 dist/ 검사 | 변환되지 않은 Placeholder 잔존 방지 |
제품명 직접 작성 금지
저자는 Placeholder 외에 다른 검사도 사용한다.
대표적인 파일은 다음과 같다.
scripts/check-copy.mjs
이 스크립트는 src/ 아래의 파일을 검사한다.
제품 이름과 가격이 실제 콘텐츠에 직접 작성됐는지 확인한다.
제품 이름이 발견되면 빌드를 실패시킨다.
허용된 위치는 공통 설정 파일이다.
이 과정에서는 Unicode 정규화도 수행한다.
저자는 다음 방식을 사용했다.
normalize("NFKC")
이렇게 하면 일부 전각 영문자나 다른 형태로 작성된 문자열을 정규화해 비교할 수 있다.
예를 들어 제품의 이전 이름이 다른 문자 형태로 남아 있는 상황을 발견하는 데 도움이 될 수 있다.
근거 없는 마케팅 문구 검사
이번 글에는 기술적인 데이터 일관성뿐 아니라 콘텐츠 정책과 관련된 검사도 포함됐다.
저자는 다음과 같은 표현을 제한했다.
- 객관적인 근거가 없는 순위 주장
- 최초라는 표현
- 검증되지 않은 가동률
- 실제로 획득하지 않은 인증
- 확인되지 않은 제휴 및 도입 실적
이러한 문자열을 단순히 검색하면 정상적인 문서도 잘못 차단될 수 있다.
예를 들어 다음 표현을 생각할 수 있다.
해당 기업과 제휴하지 않았습니다.
이는 제휴 사실을 주장하는 문장이 아니다.
하지만 단순히 제휴라는 문자열만 검사하면 차단될 수 있다.
저자는 이러한 문제를 줄이기 위해 일부 표현을 허용 목록으로 관리했다.
또한 코드 주석에 작성한 금지 문구 때문에 빌드가 실패하지 않도록 처리했다.
의도적으로 오류를 발생시켜 검사 검증
저자는 세 가지 검사를 작성한 뒤 정상적인 빌드가 성공하는지만 확인하지 않았다.
실제로 잘못된 콘텐츠를 만들었다.
예를 들어 다음과 같은 상황이다.
- 존재하지 않는 Placeholder 작성
- 본문 렌더링 실패 유발
- 변환되지 않는 Placeholder 작성
- 제품 이름 직접 입력
- 금지된 표현 작성
이후 npm run build를 실행했다.
그리고 각 상황에서 실제로 빌드가 실패하는지 확인했다.
이는 테스트의 중요한 원칙과 연결된다.
정상적인 입력을 통과시키는 검사만으로는 해당 검사가 실제 문제를 발견할 수 있는지 알기 어렵다.
오류가 발생해야 하는 상황을 의도적으로 만든 뒤 실제로 실패하는지 확인해야 한다.
설계상의 한계
이번 시스템에는 여러 제한사항이 존재한다.
첫 번째는 정규식 기반 검사다.
문구의 실제 의미를 완벽하게 이해하지 못하므로 오탐과 누락이 발생할 수 있다.
두 번째는 콘텐츠 로딩 구조에 대한 의존성이다.
Astro의 Glob Loader나 Markdown 처리 방식이 변경되면 검증 로직도 영향을 받을 수 있다.
세 번째는 Placeholder 관리 비용이다.
사용할 수 있는 변수가 증가하면 Placeholder 목록과 관련 문서를 관리해야 한다.
또한 최종 HTML에 잘못된 문구가 존재하지 않는다는 사실만으로 모든 콘텐츠가 사실에 맞는다는 보장은 없다.
이번 글은 별도의 성능 Benchmark를 제공하지 않는다.
대신 Astro의 콘텐츠 파이프라인에서 검증의 책임을 빌드 전, Markdown 변환 중, 최종 출력 이후로 나누어 배포 오류를 방지한 실제 구현 사례다.
Your analytics tag is slowing your site down
- 저자: Kunj Hirapara
- 매체: 개인 기술 블로그
- 게시일: 2026-11-01 — 정확한 게시 시각 미확인
- 기술: JavaScript, Google Analytics 4, Google Tag Manager, Lighthouse, Next.js
- 원문: https://kunjdeveloper.com/blog/analytics-tag-slowing-your-site
실제 웹사이트에서 사용하는 Google Analytics Script가 브라우저의 메인 스레드에 얼마나 많은 작업을 발생시키는지 측정하고, Script 로딩을 지연했을 때 Lighthouse 결과가 어떻게 달라지는지 비교한 기술 글이다.
이번 글의 특징은 Google Analytics를 무조건 제거해야 한다고 주장하지 않는다는 점이다.
저자는 웹사이트 운영에 Analytics가 필요하다는 사실을 인정한다.
하지만 방문자를 측정하기 위한 Script가 사용자에게 처음 화면을 표시하고 입력을 처리하는 작업보다 먼저 실행돼야 하는지는 별개의 문제라고 설명한다.
특히 async로 Script를 다운로드하더라도 해당 Script가 실행될 때는 메인 스레드의 작업을 차단할 수 있다는 점을 실험으로 보여준다.
실제 사이트에서 발견한 문제
저자는 자신의 웹사이트에서 Lighthouse를 실행했다.
그 결과 Google Analytics의 gtag.js가 많은 CPU 시간을 사용하고 있었다.
실제 사이트에서는 다음 수치를 관찰했다.
gtag.js전송 크기: 약 176KB- 실행 시간: 1초 이상
문제는 Script가 페이지의 Hydration 직후 실행됐다는 점이다.
React 애플리케이션에서는 Hydration을 통해 서버가 생성한 HTML에 이벤트와 상태를 연결할 수 있다.
그런데 Hydration이 끝난 직후 외부 Script가 긴 JavaScript 작업을 수행하면 사용자의 클릭이나 키보드 입력이 지연될 수 있다.
예를 들어 사용자가 화면에 표시된 버튼을 눌렀다고 가정해 보자.
메인 스레드가 Analytics Script를 실행하고 있다면 해당 입력을 즉시 처리하지 못할 수 있다.
저자는 자신의 사이트에서 Analytics Script의 로딩 시점을 변경했다.
하지만 그와 동시에 다른 최적화 작업도 수행했다.
따라서 실제 사이트의 개선 효과를 Analytics 하나의 변경으로 분리해 측정할 수 없었다.
저자는 이를 해결하기 위해 별도의 실험 페이지를 만들었다.
세 가지 조건 비교
실험 페이지는 일반적인 기사 페이지였다.
페이지 자체에는 별도의 JavaScript 기능이 존재하지 않았다.
저자는 세 가지 조건을 비교했다.
첫 번째는 Analytics를 포함하지 않은 페이지다.
두 번째는 Google Analytics의 일반적인 Script를 HTML의 <head>에 포함한 페이지다.
세 번째는 Analytics 로딩을 지연한 페이지다.
실험에는 Lighthouse 13.6의 모바일 프로필을 사용했다.
각 조건은 다섯 번씩 실행했다.
또한 조건별 실행을 번갈아 수행했다.
이렇게 하면 특정 조건을 먼저 실행했기 때문에 발생할 수 있는 일부 환경 차이를 줄일 수 있다.
실제 측정 결과
| 조건 | Lighthouse Performance | Total Blocking Time |
|---|---|---|
| Analytics 없음 | 100 | 0ms |
| 일반적인 GA4 Script | 중앙값 90 | 중앙값 424ms |
| GA4 지연 로딩 | 100 | 0ms |
일반적인 GA4 Script를 사용한 경우 성능 점수는 88~91 사이였다.
TBT는 391~469ms 사이였다.
중앙값은 424ms였다.
해당 테스트에서 gtag.js의 크기는 다음과 같았다.
- 네트워크 전송: 157KB
- 압축 해제 후: 446KB
Lighthouse가 시뮬레이션한 모바일 환경에서는 Script 실행에 약 540ms가 사용됐다.
즉 별도의 애플리케이션 JavaScript가 없는 페이지에서도 Analytics Script 하나가 상당한 메인 스레드 작업을 발생시킬 수 있었다.
하지만 이 결과는 저자가 만든 단순한 테스트 페이지에 관한 것이다.
모든 웹사이트에서 GA4를 추가하면 정확히 424ms의 Blocking Time이 발생한다는 의미는 아니다.
실제 크기와 실행 시간은 Analytics 설정과 추가 Tag, 기기 성능에 따라 달라질 수 있다.
async의 한계
일반적인 Google Analytics 설치 코드에는 다음 속성이 포함될 수 있다.
async
이 속성은 Script를 다운로드하는 동안 HTML 파싱을 계속 진행할 수 있도록 한다.
하지만 다운로드한 Script를 실제로 실행하는 작업은 별개다.
브라우저의 메인 스레드에서는 다음 작업이 이루어진다.
- JavaScript 실행
- 사용자 입력 처리
- 일부 Layout 및 Style 계산
- React의 렌더링 작업
- DOM 변경
따라서 Script를 비동기로 다운로드했다고 해서 실제 실행 비용이 사라지지는 않는다.
gtag.js가 다운로드를 완료한 뒤 500ms 이상의 JavaScript 작업을 실행한다면 그 시간 동안 메인 스레드의 다른 작업이 지연될 수 있다.
저자는 이러한 이유로 Script 다운로드의 비동기성과 JavaScript 실행의 비차단성을 구분해야 한다고 설명한다.
TBT와 INP는 다른 지표
Total Blocking Time은 Lighthouse에서 측정하는 실험실 기반 성능 지표다.
일반적으로 50ms를 초과하는 긴 Task가 사용자 입력을 차단할 수 있는 시간을 계산한다.
하지만 TBT 자체가 Core Web Vital은 아니다.
실제 사용자 경험에서는 INP(Interaction to Next Paint)도 중요하다.
INP는 사용자가 실제로 상호작용한 이후 화면이 다음 상태로 갱신되기까지의 반응성을 측정한다.
긴 JavaScript Task가 사용자 입력과 겹치면 INP가 나빠질 수 있다.
하지만 이번 실험에서 실제 사용자들의 INP가 얼마나 변경됐는지는 측정하지 않았다.
따라서 Lighthouse의 TBT가 424ms 증가했다는 사실과 실제 사용자 INP가 동일하게 424ms 증가한다는 사실은 다르다.
Analytics 지연 로딩
저자는 Analytics를 처음부터 다운로드하지 않도록 변경했다.
대신 다음 조건 가운데 하나가 발생하면 Script를 로딩한다.
- 사용자가 화면을 터치
- 사용자가 키보드 입력
- 사용자가 스크롤
- 페이지 로딩 이후 4초 경과
이러한 방식에서는 사용자가 페이지를 처음 열었을 때 중요한 콘텐츠를 먼저 표시할 수 있다.
이후 사용자가 실제로 상호작용하거나 일정 시간이 지나면 Analytics를 실행한다.
저자는 다음 이벤트를 사용했다.
pointerdownkeydownscrolltouchstart
이벤트에는 once 옵션을 사용했다.
따라서 같은 이벤트를 반복해서 처리하지 않도록 했다.
또한 Script를 여러 번 삽입하지 않도록 내부 상태를 관리했다.
이벤트 Queue 유지
Analytics가 아직 로딩되지 않은 상태에서도 이벤트가 발생할 수 있다.
예를 들어 사용자가 페이지에 접근하자마자 버튼을 눌렀다고 가정해 보자.
Analytics Script는 아직 다운로드되지 않았다.
하지만 클릭 이벤트를 기록하고 싶을 수 있다.
저자는 기존 dataLayer 배열을 사용했다.
초기 gtag() 함수는 실제 Analytics API를 즉시 호출하지 않는다.
대신 전달받은 데이터를 dataLayer에 추가한다.
이후 실제 gtag.js가 로딩되면 이전에 저장한 이벤트를 처리할 수 있다.
저자의 실험에서는 페이지 조회 이벤트와 Script 로딩 전에 발생시킨 이벤트가 나중에 정상적으로 전달되는 것을 확인했다.
하지만 이러한 구조가 모든 상황에서 이벤트 유실을 방지한다는 의미는 아니다.
예를 들어 사용자가 Script가 로딩되기 전에 페이지를 완전히 종료한다면 기록이 전송되지 않을 수 있다.
지연 로딩의 결과를 해석할 때 주의할 점
이번 실험에서 GA4를 지연 로딩한 조건의 TBT는 0ms였다.
하지만 저자는 이 결과에 중요한 제한사항을 명시했다.
Analytics Script의 실제 실행 비용이 사라진 것은 아니다.
단지 실행 시점을 뒤로 이동했을 뿐이다.
Lighthouse의 측정 구간이 종료된 이후 Script가 실행됐다면 해당 비용은 TBT 측정 결과에 포함되지 않을 수 있다.
따라서 지연 로딩 이후 Lighthouse 점수가 100으로 변경됐다는 사실만으로 사용자 경험이 모든 상황에서 완벽하게 개선됐다고 판단해서는 안 된다.
예를 들어 사용자가 첫 번째 버튼을 누른 직후 Analytics Script가 로딩된다면 두 번째 입력이 지연될 수 있다.
실제 사용자들의 INP와 입력 지연도 함께 확인해야 한다.
방문 통계의 정확성과 트레이드오프
Analytics를 지연 로딩하면 일부 방문을 놓칠 수 있다.
예를 들어 사용자가 검색 결과에서 페이지를 열었다고 가정해 보자.
사용자는 2초 동안 내용을 확인한다.
스크롤하지 않는다.
아무런 버튼도 누르지 않는다.
이후 다른 사이트로 이동한다.
이 경우 4초 뒤에 로딩하도록 설정한 Analytics는 실행되지 않을 수 있다.
따라서 실제 방문이 있었더라도 Analytics에는 기록되지 않을 가능성이 있다.
저자는 이러한 차이를 마케팅 및 데이터 분석 담당자와 협의해야 한다고 설명한다.
즉 성능 개선을 위해 방문 통계의 일부 정확성을 포기하는 선택이 될 수 있다.
또한 Analytics 로딩 시점과 별개로 개인정보 동의 및 관련 정책도 충족해야 한다.
불필요한 Tag 제거
저자는 Script를 지연 로딩하기 전에 실제 Tag 목록을 검토하도록 설명한다.
예를 들어 다음 상황이 존재할 수 있다.
- 사용하지 않는 Universal Analytics Tag
- Google Tag Manager와 직접 Script 삽입을 통한 GA4 중복 등록
- 종료된 광고 캠페인의 추적 Script
- 모든 페이지에서 실행되는 채팅 Widget
- 필요 이상으로 실행되는 Session Recording
이러한 Script가 여러 개 존재한다면 단순히 실행 시점을 뒤로 이동하는 것보다 불필요한 Script를 제거하는 편이 더 근본적인 개선이 될 수 있다.
특히 Google Analytics를 GTM과 직접 Script 방식으로 중복 설치하면 불필요한 다운로드와 중복 이벤트 전송이 발생할 수 있다.
Next.js에서의 적용
Next.js에서는 다음 기능을 사용할 수 있다.
@next/third-partiesnext/scriptstrategy="lazyOnload"
이러한 기능은 외부 Script를 일반적인 HTML Script Tag보다 유연하게 관리하도록 돕는다.
하지만 저자는 자신의 사이트에서 Hydration 이후 Analytics를 로딩하는 것만으로 충분하지 않았다고 설명한다.
따라서 실제 사용자 상호작용이나 일정한 지연 시간 이후에 Analytics 컴포넌트를 생성하는 방식을 선택했다.
또한 Partytown과 서버 측 Google Tag Manager도 대안으로 소개한다.
하지만 두 방식 모두 새로운 비용이 존재한다.
Partytown은 일부 외부 Script를 Web Worker에서 실행할 수 있지만 DOM에 동기적으로 접근해야 하는 Script와 충돌할 수 있다.
서버 측 GTM은 브라우저의 작업량을 줄일 수 있지만 별도의 서버 운영과 비용이 필요하다.
측정상의 한계
이번 실험은 저자가 직접 구성한 페이지에서 수행한 자체 Benchmark다.
정량적인 결과는 존재하지만 독립적인 제3자의 재현 실험은 아니다.
또한 실제 운영 사이트에서는 Analytics 이외의 다른 최적화도 함께 수행했으므로 GA4 변경만의 개선 효과를 분리하지 못했다.
이번 글은 브라우저에서 외부 Script의 실제 CPU 비용을 측정하고, 실행 시점을 변경할 때 성능과 데이터 수집 정확성 사이에 어떤 트레이드오프가 존재하는지를 구체적인 실험으로 설명한다.
How I Built an Offline-First Party Game with Next.js and Capacitor (and Shipped It to Google Play)
- 저자: Syed Ahmed Ali
- 매체: DEV Community
- 게시일: 2026-11-01 — 정확한 게시 시각 미확인
- 기술: Next.js 16, React 19, TypeScript, Capacitor 8, Dexie, IndexedDB, Service Worker, Gemini
- 원문: https://dev.to/syedahmedali_dev/how-i-built-an-offline-first-party-game-with-nextjs-and-capacitor-and-shipped-it-to-google-play-2i9a
Next.js로 개발한 웹 애플리케이션을 인터넷 연결이 없는 환경에서도 정상적으로 동작하도록 설계하고, 같은 코드베이스를 Capacitor로 Android 애플리케이션까지 배포한 실제 개발 기록이다.
저자가 만든 게임은 Imposter Word다.
여러 명의 사용자가 하나의 스마트폰을 돌려 가며 사용하는 파티 게임이다.
게임의 단어와 힌트는 AI 모델로 생성할 수 있다.
하지만 인터넷이 연결되지 않은 환경에서도 게임을 시작할 수 있어야 한다.
이 요구사항 때문에 일반적인 AI 기반 웹 애플리케이션과 다른 설계가 필요했다.
이번 글에서는 다음 세 가지를 구체적으로 설명한다.
첫 번째는 AI API에 의존하지 않는 게임 데이터 구조다.
두 번째는 Next.js App Router의 오프라인 Navigation 문제다.
세 번째는 같은 Next.js 코드로 서버 기반 웹 애플리케이션과 정적 Android 애플리케이션을 함께 배포하는 방법이다.
처음부터 정한 세 가지 조건
저자는 프로젝트를 시작하면서 다음 조건을 정했다.
- 인터넷 연결이 없어도 게임을 진행할 수 있어야 한다.
- 사용자 계정을 만들 필요가 없어야 한다.
- 사용자 설정과 통계는 기기에 저장해야 한다.
이러한 조건은 실제 사용 환경과 연결된다.
예를 들어 여러 사용자가 자동차나 기차에서 게임을 한다고 가정해 보자.
네트워크 연결이 불안정할 수 있다.
이때 새로운 게임을 시작할 때마다 AI API의 응답을 기다려야 한다면 사용자 경험이 나빠질 수 있다.
또한 AI 서비스에 장애가 발생하면 게임 자체를 시작하지 못할 수 있다.
저자는 이를 방지하기 위해 AI 기능을 필수 기능이 아니라 추가적인 기능으로 설계했다.
세 단계의 단어 제공 구조
게임은 매 Round마다 단어와 힌트를 가져와야 한다.
저자는 이 기능을 하나의 인터페이스로 분리했다.
WordProvider
해당 인터페이스에는 다음 메서드가 존재한다.
generateRoundContent()
실제 구현은 세 가지다.
| 순서 | Provider | 데이터 출처 |
|---|---|---|
| 1 | AI Provider | Gemini API |
| 2 | Cache Provider | IndexedDB에 저장한 기존 AI 결과 |
| 3 | Fallback Provider | 애플리케이션에 포함된 정적 단어 목록 |
처음에는 AI Provider를 시도한다.
AI API가 정상적으로 응답하면 새로운 단어와 힌트를 사용한다.
그리고 해당 결과를 IndexedDB에 저장한다.
이후 네트워크가 연결되지 않은 상태에서는 이전에 생성한 단어를 재사용할 수 있다.
만약 저장된 단어도 없다면 애플리케이션에 포함된 정적 단어 목록을 사용한다.
이렇게 하면 AI API와 IndexedDB 중 어느 한쪽에서 문제가 발생하더라도 게임을 시작할 수 있다.
AI 호출 실패와 사용자 경험 분리
저자는 AI 요청이 실패했을 때 사용자에게 별도의 오류 화면을 표시하지 않도록 했다.
대신 다음 Provider로 이동한다.
예를 들어 인터넷 연결이 끊긴 상태에서 게임을 시작했다고 가정해 보자.
AI Provider가 실패한다.
이후 Cache Provider를 확인한다.
저장된 단어가 존재한다면 게임을 시작한다.
사용자는 AI 요청이 실패했다는 사실을 반드시 알 필요가 없다.
게임에 필요한 데이터가 정상적으로 준비됐기 때문이다.
이러한 구조는 외부 서비스의 장애와 실제 사용자 기능의 실패를 분리한다.
하지만 모든 종류의 오류를 무조건 무시하는 것은 아니다.
사용자가 게임 준비를 취소한 경우에는 AbortSignal을 확인해 실제 취소로 처리한다.
즉 외부 API의 실패와 사용자의 명시적인 취소를 구분한다.
Gemini API의 입력과 출력 검증
AI 단어 생성에는 다음 모델을 사용했다.
gemini-2.5-flash-lite
모델 요청에는 JSON 응답 형식을 지정했다.
responseMimeType: "application/json"
또한 이미 사용한 단어의 목록을 전달해 같은 단어가 반복되는 문제를 줄였다.
생성 다양성을 높이기 위해 Temperature를 1.2로 설정했다.
하지만 모델이 반환한 JSON이 형식적으로 올바르다고 해서 게임에 사용할 수 있는 데이터라는 의미는 아니다.
예를 들어 힌트 안에 실제 정답 단어가 포함될 수 있다.
이 경우 게임의 규칙이 무너진다.
저자는 이를 방지하기 위해 다음 조건을 검사했다.
- 정답 단어가 비어 있지 않은지
- 힌트 길이가 제한을 초과하지 않는지
- 힌트에 정답 단어가 직접 포함되지 않았는지
검증은 서버와 클라이언트에서 모두 수행한다.
서버는 잘못된 AI 결과를 사용자에게 반환하지 않도록 한다.
클라이언트는 실제 게임 상태에 데이터를 적용하기 전에 다시 확인한다.
API Key의 보관
Gemini API Key는 서버에서만 사용한다.
Next.js에서는 다음 Route Handler를 사용했다.
/api/round/generate
브라우저는 필요한 정보를 이 Endpoint에 전달한다.
서버는 실제 Gemini API를 호출한다.
이렇게 하면 브라우저의 JavaScript Bundle에 API Key를 포함하지 않아도 된다.
또한 저자는 IP 기반의 간단한 Rate Limiter를 추가했다.
하지만 해당 Rate Limiter는 메모리 기반이다.
따라서 서버리스 인스턴스가 여러 개 존재하면 각 인스턴스가 서로 다른 사용량을 기록할 수 있다.
또한 Cold Start 이후에는 기존 기록이 사라질 수 있다.
저자는 이를 엄격한 전역 요청 제한이 아니라 기본적인 남용 방지 장치로 설명한다.
실제 사용량이 증가하면 Redis 등의 공유 저장소가 필요할 수 있다.
Service Worker로 모든 화면을 미리 저장
오프라인 웹 애플리케이션에서는 Service Worker를 사용할 수 있다.
Service Worker는 브라우저에서 네트워크 요청을 가로채거나 Cache를 관리하는 데 사용할 수 있는 실행 환경이다.
저자는 모든 화면을 처음부터 Cache에 저장하도록 구성했다.
대상은 총 13개 Route다.
대표적으로 다음 화면이 포함된다.
//setup/players/round/game/voting/results/settings/statistics
이렇게 하면 사용자가 한 번도 열어 보지 않은 화면도 오프라인에서 사용할 수 있다.
예를 들어 게임을 설치한 직후 인터넷 연결이 끊겼다고 가정해 보자.
사용자는 이전에 열어 보지 않은 투표 화면으로 이동할 수 있어야 한다.
해당 화면이 Service Worker Cache에 존재하지 않는다면 오프라인에서 열리지 않을 수 있다.
저자는 이를 방지하기 위해 모든 필수 화면을 설치 과정에서 미리 준비했다.
Route 목록의 중복 관리 문제
Service Worker에는 Cache에 저장할 Route 목록이 필요하다.
애플리케이션에서도 실제로 사용할 수 있는 Route 목록이 필요하다.
문제는 두 목록을 별도로 관리해야 했다는 점이다.
저자는 실제로 두 목록이 서로 다른 상태가 되는 문제를 경험했다.
예를 들어 새로운 화면을 추가했다고 가정해 보자.
애플리케이션의 Route 목록에는 새 경로를 추가했다.
하지만 Service Worker의 Precache 목록에는 추가하지 않았다.
이 경우 온라인에서는 정상적으로 동작하지만 오프라인에서는 해당 화면이 열리지 않을 수 있다.
저자는 이를 해결하기 위해 빌드 스크립트에서 두 목록을 비교했다.
서로 일치하지 않으면 빌드를 실패시킨다.
이는 단순히 잘 작성한 문서로 실수를 방지하는 것과 다르다.
오프라인 지원에 필요한 Route 정의가 실제로 일치하는지 CI에서 검증하도록 만든 것이다.
Next.js App Router의 오프라인 Navigation 문제
이번 글에서 가장 중요한 프론트엔드 문제 가운데 하나는 Next.js App Router의 Navigation 방식이다.
일반적으로 Next.js에서는 다음 기능을 이용해 페이지를 이동할 수 있다.
<Link>router.push()
이러한 방식에서는 브라우저가 전체 HTML 문서를 다시 로딩하지 않고 필요한 React Server Components Payload를 가져올 수 있다.
하지만 오프라인 환경에서는 문제가 발생할 수 있다.
예를 들어 다음 상황이다.
- 사용자가 게임의 시작 화면을 열었다.
- 인터넷 연결이 끊어졌다.
- 사용자가 다음 게임 화면으로 이동한다.
- Next.js Router가 RSC Payload를 가져오려고 시도한다.
- 네트워크 요청이 실패한다.
- Navigation이 정상적으로 완료되지 않는다.
문제는 실제 HTML 페이지가 Service Worker Cache에 존재하더라도 클라이언트 Router가 해당 HTML로 자동 전환하지 않을 수 있다는 점이다.
저자는 이를 해결하기 위해 오프라인 상태에서는 일반적인 문서 Navigation을 사용하도록 했다.
대표적인 API는 다음과 같다.
window.location.assign()window.location.replace()
이렇게 하면 Service Worker가 Cache에 저장한 HTML을 반환할 수 있다.
반면 온라인 상태에서는 기존 Next.js Router를 그대로 사용한다.
Hard Navigation의 부작용
전체 문서를 다시 로딩하는 방식에도 단점이 존재한다.
React 애플리케이션의 메모리 상태가 초기화될 수 있기 때문이다.
저자의 게임에서는 배경 음악을 재생하고 있었다.
그런데 Hard Navigation이 발생하면 현재 오디오 실행 상태가 사라질 수 있었다.
저자는 이를 해결하기 위해 Navigation 이전에 오디오 상태를 별도로 기록했다.
이후 새로운 페이지에서 해당 상태를 복원했다.
이렇게 하면 사용자가 매번 페이지를 이동할 때마다 음악이 처음부터 다시 시작되는 문제를 줄일 수 있다.
즉 오프라인 Navigation을 안정적으로 만들기 위해 클라이언트 상태의 보존 방식까지 변경해야 했던 것이다.
Android WebView의 네트워크 상태
저자는 동일한 웹 애플리케이션을 Capacitor로 Android 앱에 포함했다.
하지만 이 과정에서 또 다른 문제가 발견됐다.
Android WebView에서는 다음 값이 실제 네트워크 상태와 일치하지 않을 수 있었다.
navigator.onLine
저자의 테스트에서는 Wi-Fi와 모바일 데이터를 모두 비활성화했는데도 온라인 상태로 표시되는 경우가 있었다.
따라서 Android 앱에서는 Capacitor Network Plugin을 사용하도록 변경했다.
이 Plugin은 Android의 네트워크 연결 상태를 가져온다.
반면 일반적인 웹 버전에서는 기존 브라우저의 네트워크 상태 이벤트를 사용한다.
하지만 네트워크 연결이 존재한다는 사실이 실제 Gemini API에 접근할 수 있다는 보장은 아니다.
따라서 AI 호출은 여전히 실패할 수 있는 작업으로 취급하고 Fallback을 유지한다.
하나의 코드베이스로 두 종류의 배포
저자는 Next.js 코드베이스를 두 방식으로 배포했다.
첫 번째는 일반적인 웹 애플리케이션이다.
이 경우 Next.js 서버가 실행된다.
따라서 /api/round/generate 같은 서버 Route Handler를 사용할 수 있다.
두 번째는 Android 애플리케이션이다.
이 경우 다음 설정으로 정적 파일을 생성한다.
output: "export"
하지만 정적 Export에는 서버 Route Handler를 포함할 수 없다.
따라서 저자는 Android 빌드 과정에서 app/api 디렉터리를 일시적으로 다른 위치로 이동했다.
이후 Next.js의 정적 빌드를 실행했다.
빌드가 성공하거나 실패하더라도 finally에서 원래 디렉터리를 복구하도록 했다.
이렇게 하면 Android 빌드 실패 때문에 다음 웹 배포에서 API Route가 사라지는 문제를 방지할 수 있다.
Android 앱은 별도의 서버를 내장하지 않는다.
AI 단어가 필요한 경우 실제 배포된 웹 API를 호출한다.
이때 Capacitor의 Origin과 API 서버의 Origin이 다르므로 CORS 설정도 필요했다.
Dexie와 IndexedDB
사용자의 개인 데이터는 IndexedDB에 저장했다.
이를 관리하기 위해 Dexie를 사용했다.
저자는 다음 데이터를 별도로 관리한다.
- 게임 설정
- 통계
- Achievement
- AI로 생성한 단어의 Cache
- 사용자가 직접 작성한 단어
특히 AI Cache와 사용자 작성 단어는 서로 다른 Table에 저장했다.
이유는 데이터의 보존 정책이 다르기 때문이다.
AI Cache는 필요하다면 오래된 데이터를 삭제할 수 있다.
반면 사용자가 직접 작성한 단어는 임의로 삭제해서는 안 된다.
또한 Cache에는 다음 정보를 저장한다.
usageCountlastUsedAt
이렇게 하면 자주 사용하지 않은 단어를 우선 선택할 수 있다.
저자는 데이터베이스 Schema가 변경될 때 Dexie의 Versioned Migration을 사용했다.
기존 데이터가 새로운 구조에서도 정상적으로 읽히도록 변환하는 과정이 필요했기 때문이다.
실제 배포에서 발견한 문제
저자는 Google Play 출시 과정에서 발생한 문제도 공개했다.
대표적으로 다음과 같다.
- Release Signing 설정이 누락됐는데도 Gradle Build가 성공
- Keystore 파일 경로를 잘못 설정
- JDK 버전과 Gradle의 호환성 문제
- Release Keystore를 Git Repository에 잘못 Commit
- Google Play의 Closed Testing 절차
- 개인정보 처리방침과 Data Safety 정보 준비
특히 Keystore가 공개 Repository에 포함된 사고는 중요한 보안 문제였다.
저자는 해당 파일을 Git History에서 제거했다.
그리고 새로운 Signing Key를 생성했다.
하지만 이미 공개된 비밀키를 Git History에서 제거했다는 사실만으로 그 키가 다시 안전해지는 것은 아니다.
실제 키를 폐기하고 교체해야 한다.
저자는 앱 출시 이전에 문제를 발견했기 때문에 새로운 Key를 사용할 수 있었다.
테스트와 한계
게임의 핵심 규칙은 일반적인 TypeScript 함수로 분리했다.
이후 Vitest로 다음 작업을 검증했다.
- 역할 배정
- 투표
- 탈락 처리
- 동점 처리
- 최종 결과 계산
브라우저 테스트에는 Playwright를 사용했다.
특히 AI API 요청을 의도적으로 실패시키고 정적 단어 목록으로 실제 게임을 시작할 수 있는지 확인했다.
하지만 이번 글에는 오프라인 처리의 성능을 다른 프레임워크와 비교한 Benchmark는 포함되지 않았다.
또한 모든 모바일 네트워크 상황에서 같은 복구 시간이 보장되는 것은 아니다.
이번 글은 Next.js의 서버 렌더링과 클라이언트 Navigation, Service Worker, Android WebView, 로컬 데이터베이스를 하나의 애플리케이션에서 연결할 때 발생하는 실제 문제를 해결한 개발 기록이다.
How to Test an MCP Server: Vitest, In-Memory Transport, Streamable HTTP and the Inspector CLI
- 저자: Waseem Anjum
- 매체: 개인 기술 블로그
- 게시일: 2026-10-31
- 기술: TypeScript, Node.js, Vitest, MCP, Streamable HTTP, Zod
- 테스트 환경: MCP SDK 1.32.2, Vitest 5.0.5, Node.js 22.20
- 원문: https://fabwaseem.com/blog/test-mcp-server
Model Context Protocol(MCP) 서버를 구현할 때 AI 모델과 실제 대화를 수행하지 않고도 Tool의 동작과 Schema, 오류 처리, HTTP Transport를 검증하는 방법을 설명한 기술 글이다.
이번 글의 특징은 MCP 서버를 일반적인 백엔드 애플리케이션과 비슷한 방식으로 테스트할 수 있다는 점이다.
MCP 서버는 AI 에이전트에게 도구를 제공한다.
예를 들어 다음과 같은 도구를 생각할 수 있다.
- 문서 검색
- 파일 조회
- JSON 변환
- 외부 API 요청
하지만 Tool이 정상적으로 등록됐다는 사실과 실제 클라이언트가 해당 Tool을 올바르게 사용할 수 있다는 사실은 다르다.
예를 들어 Tool 이름은 존재하지만 Input Schema가 잘못 정의됐을 수 있다.
또는 실제 HTTP 요청에서 필요한 Header가 누락되면 연결이 실패할 수 있다.
저자는 이러한 문제를 세 종류의 테스트로 분리했다.
세 단계의 테스트
| 단계 | 검증 대상 | 사용 기술 |
|---|---|---|
| 1단계 | Tool의 실제 비즈니스 로직 | Vitest |
| 2단계 | MCP Tool의 Schema와 호출 계약 | InMemoryTransport, SDK Client |
| 3단계 | 실제 HTTP 프로토콜과 응답 | Streamable HTTP |
각 단계는 서로 다른 문제를 발견한다.
예를 들어 첫 번째 단계에서는 문자열 변환 함수의 결과가 올바른지 검사한다.
두 번째 단계에서는 MCP 클라이언트가 해당 Tool을 발견하고 호출할 수 있는지 검사한다.
세 번째 단계에서는 실제 HTTP 요청과 응답이 MCP의 전송 규칙을 충족하는지 확인한다.
Tool의 실제 로직을 독립 함수로 분리
저자는 두 가지 Tool을 예제로 사용했다.
첫 번째는 다음과 같다.
slugify
문자열을 URL Slug로 변환하는 기능이다.
예를 들어 다음 문자열을 전달한다.
Hello, MCP World!
기대 결과는 다음과 같다.
hello-mcp-world
두 번째는 다음 Tool이다.
json_format
JSON 문자열을 읽기 쉬운 형태로 정리하는 기능이다.
예를 들어 한 줄로 작성된 JSON을 여러 줄로 포맷팅할 수 있다.
저자는 실제 문자열 변환 로직을 MCP Handler 안에 직접 작성하지 않았다.
대신 일반적인 TypeScript 함수로 분리했다.
MCP Handler는 입력값을 받아 해당 함수를 호출한다.
그리고 결과를 MCP 응답 형식으로 반환한다.
이렇게 하면 핵심 로직을 MCP 서버와 무관하게 테스트할 수 있다.
첫 번째 단계: 순수 함수 테스트
저자는 slugify()를 일반적인 Vitest 테스트로 검증한다.
대표적인 입력은 다음과 같다.
- 대문자와 소문자
- 공백
- 특수문자
- 악센트가 포함된 문자열
예를 들어 다음 문자열을 검사한다.
Crème Brûlée
기대 결과는 다음과 같다.
creme-brulee
내부에서는 Unicode 정규화를 사용한다.
normalize("NFKD")
이후 일부 결합 문자를 제거한다.
그리고 영문 소문자와 숫자를 제외한 문자를 구분자로 변경한다.
이러한 검사는 MCP 서버를 실행하지 않아도 수행할 수 있다.
따라서 Tool 자체의 비즈니스 로직과 MCP 전송 프로토콜의 문제를 분리해 조사할 수 있다.
두 번째 단계: InMemoryTransport
MCP 공식 TypeScript SDK는 다음 기능을 제공한다.
InMemoryTransport.createLinkedPair()
이 함수는 서로 연결된 두 Transport를 생성한다.
하나는 클라이언트에서 사용한다.
다른 하나는 서버에서 사용한다.
두 Transport는 같은 프로세스 안에서 통신한다.
따라서 실제 HTTP Port를 열거나 별도의 서버 프로세스를 시작할 필요가 없다.
저자는 이를 이용해 실제 MCP SDK Client를 생성했다.
그리고 MCP 서버에 연결했다.
이후 다음 API를 호출했다.
listTools()callTool()
이러한 방식은 실제 MCP 프로토콜을 사용하면서도 네트워크 실행 환경의 영향을 줄일 수 있다.
Tool 목록 검증
저자는 tools/list 결과를 검사한다.
단순히 Tool이 하나 이상 존재하는지만 확인하지 않았다.
다음 항목도 검증했다.
- Tool 이름의 형식
- Tool 이름의 중복 여부
- Description의 길이
- Input Schema의 구조
- Tool Annotation
예를 들어 Tool 이름은 일정한 패턴을 따라야 한다.
또한 Tool의 Input Schema는 Object 형태여야 한다.
저자는 설명이 지나치게 짧거나 비어 있는 Tool도 발견하도록 했다.
이는 실제 MCP 사용에서 중요하다.
AI 모델은 Tool의 이름과 설명을 바탕으로 어떤 도구를 사용할지 판단할 수 있기 때문이다.
따라서 Tool이 실제로 정상 동작하더라도 설명이 부정확하면 모델이 적절하지 않은 상황에서 호출할 수 있다.
readOnlyHint 검증
저자는 Tool Annotation도 검사했다.
대표적인 값은 다음과 같다.
readOnlyHint: true
이 값은 해당 Tool이 읽기 전용이라는 정보를 제공한다.
예를 들어 문자열을 변환하는 Tool은 일반적으로 외부 시스템의 상태를 변경하지 않는다.
따라서 읽기 전용 Tool로 표시할 수 있다.
하지만 중요한 제한사항이 있다.
readOnlyHint는 보안 권한을 강제하는 기능이 아니다.
실제 코드가 데이터베이스를 변경하더라도 Annotation에 읽기 전용이라고 작성할 수 있다.
따라서 Annotation은 Tool의 의도를 설명하는 Metadata로 이해해야 한다.
실제 API의 접근 권한은 서버에서 별도로 검증해야 한다.
Input Schema와 Output Schema
저자는 Zod를 이용해 Tool의 입력 구조를 정의했다.
예를 들어 slugify는 비어 있지 않은 문자열을 요구한다.
또한 json_format은 JSON 문자열과 들여쓰기 크기를 전달받는다.
입력값이 Schema를 충족하는지 확인할 수 있다.
출력에서도 다음 데이터를 검증한다.
structuredContent
예를 들어 slugify의 결과가 다음 형태인지 확인한다.
{ slug: "hello-mcp" }
이렇게 하면 Tool의 실제 결과가 선언된 Output Schema와 일치하는지 검증할 수 있다.
하지만 Output Schema가 존재한다는 사실만으로 실제 결과가 올바른 것은 아니다.
실제 테스트에서 반환값의 형태와 값을 함께 확인해야 한다.
잘못된 입력의 오류 형태
이번 글에서 가장 중요한 발견 가운데 하나는 MCP SDK 버전에 따라 오류가 전달되는 방식이 달라질 수 있다는 사실이다.
저자는 다음 두 상황을 테스트했다.
첫 번째는 Input Schema를 충족하지 않는 값이다.
예를 들어 비어 있는 문자열을 slugify에 전달한다.
두 번째는 존재하지 않는 Tool을 호출하는 상황이다.
예를 들어 다음 이름을 전달한다.
nope
저자는 처음에 이러한 호출이 예외를 발생시킬 수 있다고 예상했다.
하지만 실제 SDK 1.32.2에서는 다른 결과가 나타났다.
Promise가 정상적으로 완료됐다.
그리고 반환된 MCP Tool Result에 다음 값이 포함됐다.
isError: true
즉 호출 자체에서 JavaScript Exception이 발생한 것이 아니라 Tool 실행 결과가 오류 상태로 반환된 것이다.
이러한 차이는 실제 MCP Client 구현에서 중요하다.
예를 들어 클라이언트가 Promise의 catch만 사용해 오류를 처리한다면 isError: true인 결과를 정상적인 데이터로 취급할 수 있다.
따라서 MCP Tool Result의 오류 상태도 확인해야 한다.
저자는 다른 SDK 버전에서는 예외로 전달될 가능성을 고려해 두 형태를 모두 처리하는 테스트를 작성했다.
세 번째 단계: Streamable HTTP
두 번째 단계의 InMemoryTransport는 실제 네트워크를 사용하지 않는다.
따라서 HTTP 관련 문제를 모두 발견할 수는 없다.
예를 들어 다음 문제가 존재할 수 있다.
- 실제 MCP Endpoint의 경로가 잘못됨
- 필요한 HTTP Header가 누락됨
- Proxy가 요청을 변경함
- 잘못된 Content Type 반환
- Session 처리 오류
저자는 이를 검증하기 위해 실제 Streamable HTTP 서버를 실행했다.
그리고 MCP SDK Client를 연결했다.
테스트 대상은 Stateful Session을 사용하지 않는 Stateless 서버다.
따라서 요청마다 새로운 MCP 서버와 Transport를 생성하는 구조를 사용했다.
실제 HTTP 응답 검사
저자가 확인한 주요 결과는 다음과 같다.
| 요청 또는 조건 | 기대 결과 |
|---|---|
| 정상적인 MCP 초기화 | HTTP 200 |
| Stateless Session | mcp-session-id Header 없음 |
부적절한 Accept Header | HTTP 406 |
| 지원하지 않는 GET 요청 | HTTP 405 |
특히 Accept Header가 중요하다.
Streamable HTTP를 사용하는 MCP 클라이언트는 다음 콘텐츠 형식을 수신할 수 있어야 한다.
application/jsontext/event-stream
저자는 실제 테스트에서 다음과 같은 Header를 사용했다.
Accept: application/json, text/event-stream
반면 application/json만 허용하도록 요청하면 HTTP 406이 반환됐다.
이는 이번 구현에서 올바른 HTTP Header를 사용하지 않으면 MCP 연결이 실패할 수 있다는 사실을 보여준다.
또한 이번 Stateless 서버에서 GET 요청은 HTTP 405를 반환했다.
하지만 모든 MCP 서버가 GET 요청에 반드시 405를 반환해야 한다는 의미는 아니다.
실제 서버가 SSE Stream을 지원하는지에 따라 동작이 달라질 수 있다.
이번 테스트는 저자가 구현한 Stateless 서버의 계약을 검사한다.
배포된 서버까지 검증
저자는 로컬 테스트만으로 충분하다고 판단하지 않았다.
실제 배포된 MCP 서버에서도 같은 테스트를 실행할 수 있도록 했다.
대표적인 환경 변수는 다음과 같다.
MCP_URL
이 값에 실제 서버의 URL을 지정하면 HTTP 테스트를 해당 서버에서 수행할 수 있다.
예를 들어 다음 문제를 발견할 수 있다.
- 배포된 서버에
/mcpRoute가 존재하지 않음 - Reverse Proxy가 HTTP Header를 제거
- 실제 서버의 CORS 또는 인증 설정 문제
- 배포 이후 MCP 연결 실패
이러한 문제는 순수 함수의 Unit Test로 발견하기 어렵다.
따라서 배포된 서버에 대한 Smoke Test가 필요하다.
MCP Inspector CLI
저자는 MCP Inspector도 사용했다.
대표적인 명령은 다음과 같다.
npx @modelcontextprotocol/inspector --cli <url> --transport http --method tools/list
이를 이용하면 실제 MCP 클라이언트 애플리케이션을 실행하지 않고도 서버가 제공하는 Tool 목록을 확인할 수 있다.
또한 tools/call을 이용해 특정 Tool을 직접 호출할 수 있다.
예를 들어 slugify에 문자열을 전달하고 실제 MCP 응답이 올바른지 확인할 수 있다.
이는 Tool을 호출하는 AI 모델의 판단 문제와 서버의 실제 프로토콜 문제를 구분하는 데 도움이 된다.
테스트 실행 시간
저자는 다음 구성을 사용했다.
- 테스트 파일 3개
- 테스트 13개
- Vitest 실행
공개한 실행 결과는 다음과 같다.
전체 13개 테스트 약 295ms
이는 저자가 직접 실행한 결과다.
다른 머신이나 네트워크 환경에서 동일한 시간이 걸린다는 의미는 아니다.
또한 이 수치는 MCP 서버의 최대 요청 처리량을 나타내는 Benchmark가 아니다.
테스트 Suite의 실행 시간이다.
보안 및 설계상의 한계
이번 글의 테스트는 Tool의 기능과 MCP 프로토콜을 확인하는 데 초점을 맞춘다.
하지만 실제 운영 환경에서는 추가적인 검증이 필요할 수 있다.
예를 들어 다음 항목이다.
- 인증 및 인가
- OAuth Token 검증
- 외부 API 접근 범위
- 요청량 제한
- 민감한 데이터의 로그 노출
- Prompt Injection 대응
- Tool의 실제 Side Effect
특히 Tool에 readOnlyHint가 존재한다는 사실만으로 실제로 읽기 전용 작업만 수행한다고 보장할 수 없다.
또한 이번 테스트에서 사용한 MCP SDK는 1.32.2다.
다른 SDK 버전에서는 오류 형태나 HTTP 처리 방식이 변경될 수 있다.
따라서 실제 운영 프로젝트에서는 사용 중인 SDK의 버전을 고정하고 변경 시 테스트를 다시 실행해야 한다.
이번 글은 MCP 서버를 AI 모델의 자연어 응답으로만 평가하지 않고, 일반적인 백엔드 서비스처럼 실제 입력과 출력, Schema, HTTP 응답을 검증하는 방법을 구체적인 테스트 코드와 실행 결과로 설명한다.