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

기준 시점: 2026-11-04 06:00 KST
조사 범위: 전일 2026-11-03 06:00 ~ 당일 2026-11-04 06:00 KST
추천 글 범위: 기준 시점 당시 최근 7일
1️⃣ 프론트엔드
Next.js 16.5 Canary, 사전 Navigation API와 외부 React Runtime 실험 도입 — 페이지 이동 준비와 CSP 처리 방식 변경
- 발표일: 2026-11-03 KST 기준
- 릴리스:
16.5.0-canary.37,16.5.0-canary.38 - GitHub 표시 시각:
.37은 11월 2일 23:49,.38은 11월 3일 09:09 — 표시 시간대 미확인 - 안정화 단계: Canary Pre-release. 일부 기능은 별도의 Experimental Flag가 필요한 실험 단계
- 적용 대상: Next.js App Router, React Server Components, Turbopack, Cache 및 Navigation
- 원문: https://github.com/vercel/next.js/releases/tag/v16.5.0-canary.37
- 후속 릴리스: https://github.com/vercel/next.js/releases/tag/v16.5.0-canary.38
Next.js가 16.5 계열의 두 Canary 릴리스를 공개했다.
이번 변경에서 눈에 띄는 부분은 세 가지다.
첫 번째는 사용자가 실제로 페이지 이동을 확정하기 전에 Navigation을 준비할 수 있는 새로운 실험적 API다.
두 번째는 React Server Components의 데이터를 HTML 안에 전달하는 방식을 변경해 엄격한 Content Security Policy를 지원하려는 시도다.
세 번째는 캐시 갱신과 Turbopack의 Module Graph 관리에서 발생하던 문제를 수정한 것이다.
다만 이번 기능들은 Next.js 16.5의 정식 안정 버전으로 출시된 기능이 아니다.
특히 React의 External Browser Runtime은 Experimental React에 의존하므로 일반적인 운영 프로젝트에서 안정적인 기능으로 취급해서는 안 된다.
router.experimental_prepare()
새로운 API는 다음과 같다.
router.experimental_prepare()
이 API는 useRouter()로 얻은 Router에서 사용할 수 있도록 추가됐다.
기존 Next.js의 Navigation은 사용자가 링크를 클릭하거나 Router의 push()를 호출한 이후 실제 페이지 이동을 수행하는 방식이다.
물론 Next.js에는 Prefetch 기능도 존재한다.
하지만 Prefetch와 실제 Navigation 사이에는 차이가 있다.
특히 동적인 데이터를 가져와야 하는 페이지에서는 사용자가 이동을 확정한 이후 추가 요청이 필요할 수 있다.
새로운 experimental_prepare()는 이러한 작업을 더 일찍 시작하도록 설계됐다.
예를 들어 사용자가 특정 상품 카드 위에 마우스를 올렸다고 가정해 보자.
이후 마우스 버튼을 누른다.
하지만 아직 실제 클릭 이벤트가 완료되지는 않았다.
개발자는 이 시점에 다음 페이지의 데이터를 가져오기 시작할 수 있다.
실제 사용자가 클릭을 완료하면 이미 진행 중인 요청을 재사용해 Navigation을 수행할 수 있다.
공식 PR에서는 pointerdown에서 준비를 시작하고 click에서 이동을 확정하는 방식을 주요 사용 사례로 설명한다.
준비와 이동 확정의 분리
experimental_prepare(href)는 실제 Router 상태를 즉시 변경하지 않는다.
대신 일반적인 Navigation과 같은 준비 절차를 시작한다.
필요하다면 동적 요청도 수행한다.
이후 반환된 Handle에서 다음 메서드를 사용할 수 있다.
push()replace()
push()는 새로운 History Entry를 추가하는 방식으로 이동한다.
replace()는 현재 History Entry를 교체하는 방식으로 이동한다.
중요한 특징은 준비 단계에서 시작한 요청이 아직 완료되지 않았더라도 실제 이동 시점에 해당 요청을 재사용할 수 있다는 점이다.
따라서 사용자가 클릭한 뒤 새로운 요청을 처음부터 시작하는 비용을 줄일 가능성이 있다.
기존 Prefetch와 다른 캐시 동작
공식 구현은 준비한 데이터를 기존 Back/Forward Navigation에서 사용하는 로컬 캐시와 연결한다.
준비된 Navigation을 실제로 확정하면 해당 캐시의 데이터를 재사용할 수 있다.
그러나 여기에는 중요한 제한사항이 있다.
준비된 데이터는 자동으로 만료되지 않는다.
예를 들어 사용자가 어떤 링크의 Navigation을 준비했다고 가정해 보자.
하지만 실제로는 해당 링크를 클릭하지 않았다.
한참 뒤에 같은 Handle을 이용해 Navigation을 확정한다면 준비 당시의 데이터를 사용할 수 있다.
따라서 개발자는 Handle을 지나치게 오래 유지하지 않도록 관리해야 한다.
또한 다음 상황에서는 준비된 데이터가 무효화될 수 있다.
- Router Refresh
- Server Action 실행
- 관련 캐시의 무효화
Handle은 한 번만 확정할 수 있다.
이미 사용한 Handle에서 다시 push()나 replace()를 호출하면 새로운 Navigation을 수행하지 않는다.
개발 환경에서는 관련 경고가 표시될 수 있다.
이러한 제약은 새로운 API가 일반적인 Router 이동을 단순히 대체하는 기능이 아니라는 점을 보여준다.
정적 페이지와 ISR Navigation 수정
이번 Canary에는 정적인 ISR 페이지로 이동할 때 불필요하게 전체 페이지를 다시 렌더링할 수 있던 문제의 수정도 포함됐다.
ISR(Incremental Static Regeneration)은 정적으로 생성한 페이지를 일정한 정책에 따라 갱신할 수 있도록 하는 기능이다.
하지만 실제 Navigation 과정에서 이미 존재하는 정적인 결과를 효율적으로 재사용하지 못하면 불필요한 서버 작업이 발생할 수 있다.
이번 변경은 next start 환경에서 이러한 문제를 수정한다.
또한 Stale Time이 짧은 캐시의 Revalidation 처리도 개선됐다.
후속 Canary인 .38에서는 fetch()와 unstable_cache의 Blocking Revalidation 문제를 추가로 수정했다.
다만 이번 릴리스에는 실제 Navigation 지연이나 서버 렌더링 시간이 얼마나 감소했는지에 관한 정량적인 Benchmark가 포함되지 않았다.
React의 Inline Script를 줄이기 위한 실험
두 번째 주요 변경은 다음 Experimental Flag다.
experimental.externalBrowserRuntime
이 기능은 React가 서버 렌더링 결과를 브라우저로 전달하는 방식과 관련된다.
Next.js App Router는 React Server Components를 사용한다.
서버는 HTML뿐 아니라 React가 클라이언트에서 필요한 상태를 복원하는 데 사용할 데이터도 전달한다.
기존에는 일부 정보가 Inline Script로 전달됐다.
대표적으로 다음과 같은 구조다.
<script>self.__next_f.push(...)</script>
이러한 방식은 HTML과 JavaScript의 전달을 연결하기 쉽다.
하지만 보안 정책과 관련된 문제가 발생할 수 있다.
Content Security Policy와 unsafe-inline
CSP(Content Security Policy)는 웹페이지에서 실행할 수 있는 Script 등의 출처를 제한하는 보안 기능이다.
예를 들어 외부에서 삽입된 악성 JavaScript가 실행되는 위험을 줄이기 위해 사용할 수 있다.
엄격한 CSP에서는 Inline Script를 제한할 수 있다.
하지만 Next.js가 서버 렌더링 과정에서 필요한 JavaScript 명령을 Inline Script로 생성한다면 해당 정책과 충돌할 수 있다.
물론 Nonce를 이용해 특정 Inline Script만 허용하는 방법도 있다.
하지만 요청마다 새로운 Nonce를 생성해야 하는 구성은 정적 HTML 생성과 함께 사용하기 어려울 수 있다.
이번 실험은 이러한 문제를 줄이려는 목적이다.
External Browser Runtime
새로운 Experimental Flag를 활성화하면 React의 일부 서버 렌더링 명령을 Inline Script 대신 다음 형태의 요소로 표현한다.
<template data-rci>
이 요소는 직접 실행되는 JavaScript가 아니다.
별도로 로딩한 React Browser Runtime이 해당 데이터를 해석하고 필요한 작업을 수행한다.
따라서 서버가 HTML 안에 실행 가능한 Script를 반복해서 삽입하지 않아도 된다.
하지만 이러한 방식에도 비용이 존재한다.
별도의 Runtime JavaScript 파일이 필요하다.
브라우저는 해당 파일을 다운로드하고 실행해야 한다.
또한 현재 해당 Runtime은 React의 Experimental Channel에서 제공된다.
따라서 기능을 활성화하면 해당 App Router의 React 버전 선택에도 영향을 준다.
React Flight Payload의 전달 방식 변경
이번 Canary에는 React Flight Payload를 전달하는 방식의 변경도 포함됐다.
기존에는 다음 형태의 Inline Script를 사용했다.
<script>self.__next_f.push(...)</script>
실험적 외부 Runtime을 사용하는 경우에는 다음과 같은 Template 형태로 데이터를 전달한다.
<template data-next-flight="...">
이후 클라이언트 코드가 Template을 수집해 기존 Flight 데이터 처리 경로로 전달한다.
중요한 점은 Flight Payload의 실제 데이터 구조를 새롭게 변경한 것이 아니라 전달 형식을 바꿨다는 사실이다.
또한 모든 Inline Script가 완전히 제거됐다고 단정해서는 안 된다.
공식 PR에서는 외부 Runtime을 적용하는 작업이 엄격한 CSP를 지원하기 위한 여러 단계 가운데 하나라고 설명한다.
Pages Router는 기존 Inline Script 방식을 계속 사용한다.
실제 CSP가 어떤 수준까지 강화될 수 있는지는 사용하는 기능과 렌더링 경로에 따라 추가 검증이 필요하다.
Turbopack 내부 처리 개선
후속 Canary인 .38에는 Turbopack 관련 수정도 포함됐다.
대표적으로 다음 변경이 있다.
- Encryption Key 변경 시 불필요한 모듈별 분석 무효화 방지
- 취소 가능한 내부 데이터 읽기 지원
- UTF-16 기반 소스코드 위치 계산 개선
- 비동기 서버 Import 과정에서 Layout의 Client Chunk가 불필요하게 포함되는 문제 수정
create-next-app의 Tailwind CSS 버전 고정
이러한 변경은 빌드 과정의 정확성과 내부 계산 효율을 개선하려는 목적이다.
하지만 이번 릴리스에는 동일한 프로젝트의 빌드 시간이나 메모리 사용량을 비교한 공식 수치가 포함되지 않았다.
안정화 단계와 제한사항
이번 변경은 다음 세 수준으로 구분해야 한다.
| 변경 | 상태 |
|---|---|
| Next.js 16.5.0 Canary | 정식 안정 릴리스 이전의 개발 버전 |
router.experimental_prepare() | 실험적 Navigation API |
externalBrowserRuntime | Experimental Flag 및 React Experimental Channel 필요 |
| 캐시 및 Turbopack 수정 | Canary에 포함된 버그 수정 |
따라서 이번 발표는 새로운 기능이 일반 사용자에게 정식 제공됐다는 의미가 아니다.
Next.js가 Navigation 지연과 서버 렌더링의 CSP 문제를 해결하기 위해 새로운 실행 방식을 실험하고 있으며, 해당 작업의 일부가 Canary에 반영됐다는 발표로 이해해야 한다.
이번 조사 범위에서 React, Vue, Nuxt, Angular, Svelte, SvelteKit, Astro, Remix의 주요 신규 안정 릴리스는 확인하지 못했습니다.
개별 패치와 개발 버전의 변경은 검토했지만, 중요한 새로운 기능이나 보안 수정으로 확인되지 않은 일반적인 유지보수 변경은 포함하지 않았다.
2️⃣ 웹 플랫폼·브라우저
이번 조사 범위에서 포함할 만한 주요 신규 발표를 확인하지 못했습니다.
W3C, WHATWG, TC39, MDN, Chrome Developers·Chromium, Mozilla·Firefox, WebKit·Safari의 공식 발표와 JavaScript·TypeScript·CSS, 웹 성능 및 접근성 관련 자료를 검토했다.
이번 24시간에 해당하는 중요한 신규 웹 표준의 정식 채택이나 주요 브라우저 API의 안정화 발표는 확인하지 못했다.
WebKit에서는 개발 빌드가 계속 생성되고 있지만, 개별 개발 빌드가 만들어졌다는 사실을 새로운 웹 플랫폼 기능의 정식 출시로 취급하지 않았다.
또한 W3C의 회의 일정과 표준화 논의는 실제 규격 문서의 신규 공개 또는 안정화 발표와 구분했다.
이전 브리핑에서 소개한 Chrome 158 Beta와 Firefox 159의 정식 출시는 반복하지 않았다.
이번 추천 글에서는 새로운 브라우저 기능보다 실제 브라우저의 기존 기능을 활용하면서 발생하는 문제를 다룬다.
특히 CSS의 Forced Colors Mode에서 디자인 구조가 어떻게 변경되는지 실제 웹사이트의 DOM과 스타일을 분석한 자료를 선정했다.
또한 React의 <Activity>를 이용해 컴포넌트를 숨기면서 상태를 유지하는 방법과 브라우저의 pagehide 및 sendBeacon()으로 민감한 입력값을 수집하지 않고 이탈 지점을 분석하는 사례도 포함했다.
3️⃣ 백엔드·인프라
Cloudflare Agents SDK 0.33.0, Pi 1.5 도입 — 스트리밍 저장 작업 최대 27배 감소와 동시 실행 처리량 개선
- 발표일: 2026-11-03 — 정확한 게시 시각 미확인
- 버전: Agents SDK 0.33.0, Pi Harness 1.5
- 안정화 단계: 정식 SDK 릴리스. 기존 API 및 세션 데이터와의 호환성 변경 포함
- 적용 대상: Cloudflare Agents SDK, PiHarness, Durable Objects, SQLite 기반 세션 저장
- 발표 성능: 저장 Transaction 최대 약 27배 감소, 일부 테스트에서 CPU 사용량 약 25% 감소, 다중 세션 처리량 약 5.2배 증가
- 측정 주체: Cloudflare 자체 Benchmark
- 원문: https://developers.cloudflare.com/changelog/post/2026-11-03-pi-harness-pi-1-5/
Cloudflare가 Agents SDK 0.33.0에서 PiHarness를 Pi 1.5로 업데이트하면서 AI 에이전트의 스트리밍 결과를 저장하는 방식과 세션 실행 구조를 변경했다.
이번 업데이트는 새로운 AI 모델을 추가하는 릴리스가 아니다.
에이전트가 답변을 생성하고 도구를 호출하는 동안 발생하는 저장소 작업과 메모리 사용량을 줄이는 데 초점을 맞췄다.
특히 여러 AI 에이전트의 세션을 동시에 실행하는 서비스에서 중요한 변화다.
AI 에이전트의 스트리밍과 저장소 작업
일반적인 AI 채팅 서비스에서는 모델이 생성한 답변을 스트리밍으로 전달할 수 있다.
예를 들어 모델이 긴 답변을 생성한다고 가정해 보자.
클라이언트에는 다음과 같은 데이터가 순차적으로 전달된다.
- 첫 번째 텍스트 조각
- 두 번째 텍스트 조각
- 도구 호출 정보
- 도구 실행 결과
- 이후 생성한 답변
이러한 데이터를 저장하지 않는다면 실행 환경이 종료됐을 때 이전 진행 상황을 복구하기 어려울 수 있다.
따라서 Cloudflare의 Pi Harness는 세션의 실행 상태와 결과를 저장한다.
문제는 저장 빈도다.
기존 방식에서는 스트리밍 업데이트가 발생할 때마다 저장소에 변경 사항을 기록할 수 있었다.
답변이 길거나 도구 결과가 자주 도착한다면 저장 Transaction이 매우 많아질 수 있다.
특히 SQLite 기반 세션 저장에서는 이러한 작업이 CPU와 I/O 비용으로 이어질 수 있다.
스트리밍 데이터의 Batch 저장
Pi 1.5에서는 저장 방식을 변경했다.
스트리밍 결과를 매번 즉시 저장하는 대신 최대 약 1초 단위로 모아서 기록한다.
사용자에게 전달하는 스트리밍 결과는 기존처럼 즉시 표시할 수 있다.
하지만 지속 저장소에 기록하는 작업은 Batch로 처리한다.
예를 들어 1초 동안 100개의 텍스트 조각이 도착했다고 가정해 보자.
기존에는 각 조각에 대해 저장 작업이 발생할 수 있었다.
새로운 방식에서는 여러 조각을 하나의 Batch로 기록할 수 있다.
이렇게 하면 저장소 Transaction 수가 감소한다.
공식 성능 측정
Cloudflare가 공개한 결과는 다음과 같다.
| 측정 항목 | 발표 결과 |
|---|---|
| 긴 답변의 SQLite 저장 Transaction | 약 27배 감소 |
| 병렬 도구 호출 Turn의 저장 Transaction | 약 5.7배 감소 |
| 일부 작업의 CPU 사용량 | 약 25% 감소 |
| 2,048개 세션 환경의 실행 처리량 | 88 → 459 runs/s |
마지막 수치는 약 5.2배의 처리량 차이다.
이러한 결과는 저장 작업을 줄인 것뿐 아니라 내부 Scheduler가 세션을 처리하는 방식도 변경했기 때문에 나타났다.
기존에는 Commit이 발생할 때마다 많은 세션을 다시 확인할 수 있었다.
새로운 Scheduler는 이러한 반복 검사를 줄인다.
따라서 세션이 많아질수록 불필요한 관리 작업을 줄일 수 있다.
하지만 이 수치는 Cloudflare의 자체 Benchmark다.
다른 환경에서 독립적으로 재현한 결과는 아니다.
또한 실제 AI 모델의 추론 속도가 5.2배 빨라졌다는 의미도 아니다.
측정한 대상은 특정 SQLite 기반 Harness 환경에서의 에이전트 실행 처리량이다.
실제 서비스에서는 모델 응답 시간과 외부 API, 도구 실행 시간 등이 전체 처리량에 영향을 준다.
저장 지연의 트레이드오프
이번 최적화에는 명확한 트레이드오프가 존재한다.
스트리밍 결과를 즉시 화면에 표시하더라도 해당 결과가 지속 저장소에 즉시 기록되는 것은 아니다.
예를 들어 모델이 새로운 답변을 생성했다고 가정해 보자.
사용자는 화면에서 해당 답변을 보고 있다.
하지만 아직 저장 Batch가 실행되지 않았을 수 있다.
이때 실행 환경이 갑자기 종료되면 마지막 일부 결과가 저장되지 않을 가능성이 있다.
Cloudflare는 기본 설정에서 최대 약 1초의 스트리밍 진행 상황이 손실될 수 있다고 설명한다.
이는 화면에 표시된 내용과 실제 복구 가능한 저장 상태가 일시적으로 다를 수 있다는 의미다.
따라서 금융 거래나 외부 데이터 변경처럼 반드시 기록해야 하는 작업을 단순한 스트리밍 기록에만 의존해서는 안 된다.
실제 외부 작업의 완료 여부는 별도의 영속적인 작업 기록이나 Transaction으로 관리할 필요가 있다.
저장 간격은 Recovery 설정에서 조정할 수 있다.
하지만 간격을 짧게 설정하면 저장 작업이 증가할 수 있다.
반대로 길게 설정하면 장애 시 손실할 수 있는 진행 상황이 늘어날 수 있다.
큰 Tool Result의 메모리 사용량
이번 릴리스에서는 큰 도구 실행 결과를 처리하는 방식도 변경됐다.
예를 들어 에이전트가 로그 파일을 읽는다고 가정해 보자.
도구는 수백 KB의 텍스트를 반환할 수 있다.
기존 구현에서는 새로운 텍스트 조각이 도착할 때마다 기존 문자열을 복사해 새로운 문자열을 만드는 작업이 반복될 수 있었다.
이 경우 결과가 커질수록 복사 비용이 증가할 수 있다.
Cloudflare는 기존 구현에서 500KB의 Tool Result를 스트리밍하는 작업이 다음 리소스를 사용했다고 설명한다.
- CPU 약 20초
- Heap 약 100MB
Pi 1.5에서는 새로운 텍스트를 기존 결과에 추가하는 방식으로 변경했다.
따라서 반복적인 복사를 줄일 수 있다.
하지만 공식 발표에는 해당 500KB 작업의 새로운 정확한 CPU 시간과 Heap 사용량이 별도로 제공되지 않았다.
따라서 이번 변경 이후 모든 500KB 응답을 특정 시간 안에 처리한다고 단정할 수 없다.
session.watch()
세션의 실행 상태를 확인하는 API도 변경됐다.
기존에는 다음 API를 사용했다.
session.events()
새로운 API는 다음과 같다.
session.watch()
새로운 API는 다음 정보를 함께 제공한다.
- 최근 Transcript Entry
- 현재 스트리밍 중인 답변
- 실행 중인 도구
- 마지막 실행이 답변 없이 종료된 이유
따라서 단순한 이벤트 목록뿐 아니라 실제 세션의 현재 상태를 확인할 수 있다.
예를 들어 모델 오류가 발생했다고 가정해 보자.
애플리케이션은 세션이 정상적으로 완료됐는지 확인할 수 있다.
또한 도구가 실행 중인지 또는 모델 오류로 응답이 중단됐는지도 구분할 수 있다.
session.continue()
이번 버전에는 다음 API도 추가됐다.
session.continue()
이 API는 새로운 사용자 메시지를 추가하지 않고 기존 세션의 실행을 이어갈 수 있도록 한다.
예를 들어 모델 오류 때문에 세션이 답변을 완료하지 못했다고 가정해 보자.
애플리케이션은 기존 Transcript를 유지한 채 실행을 다시 시작할 수 있다.
하지만 이 API가 모든 실패 작업을 자동으로 반복하는 범용 Retry 기능은 아니다.
실제로 세션이 답변 없이 종료된 상태인지 확인해야 한다.
또한 현재 세션이 유휴 상태여야 한다.
이미 답변이 완료된 세션을 다시 실행하려고 하면 정상적인 새로운 답변이 생성되지 않을 수 있다.
Turn 제한과 실패 처리
새로운 Pi Harness에서는 실행할 수 있는 Turn 수를 제한할 수 있다.
대표적인 설정은 다음과 같다.
defaults.maxTurns
이 설정을 이용해 하나의 에이전트 실행에서 도구 호출과 모델 호출이 지나치게 반복되는 상황을 제한할 수 있다.
또한 반복적인 충돌이 발생한 작업은 더 이상 Parked 상태로 무기한 유지하지 않는다.
Restart Limit에 도달하면 작업을 종료한다.
이에 따라 다음 기능이 제거됐다.
retry()parked상태
기존에 해당 API와 상태를 이용하던 프로젝트에서는 코드 변경이 필요하다.
브라우저 도구 연동
Pi의 Code Mode에서는 Browser Tool도 사용할 수 있다.
대표적인 인터페이스는 다음과 같다.
browserToolimage()
에이전트는 Browser Tool을 이용해 웹페이지를 탐색할 수 있다.
또한 브라우저에서 생성한 Screenshot을 이미지 입력으로 전달할 수 있다.
이러한 구조는 웹 UI를 구현하는 코딩 에이전트에서 활용할 수 있다.
예를 들어 React 컴포넌트를 수정한 이후 실제 브라우저 화면을 확인하는 작업이다.
하지만 Browser Tool을 사용할 수 있다는 사실만으로 실제 UI의 접근성과 시각적 정확성을 자동으로 보장하는 것은 아니다.
실제 테스트와 검증이 필요하다.
Pi 1.4와의 호환성 문제
이번 업데이트에서 가장 중요한 제한사항은 세션 데이터의 하위 호환성이 없다는 점이다.
Cloudflare는 다음 사실을 명시했다.
Pi 1.5에서 실행한 세션은 Pi 1.4에서 읽을 수 없다.
따라서 Pi 1.5로 업데이트한 이후 문제가 발생했다고 해서 단순히 이전 버전인 Pi 1.4로 Rollback하는 것이 안전하지 않을 수 있다.
기존 Pi 1.4는 새로운 형식의 세션 데이터를 읽지 못할 수 있기 때문이다.
Cloudflare는 업데이트 이후 문제가 발생하면 이전 버전으로 되돌리기보다 새로운 버전에서 문제를 수정하는 방향을 안내한다.
따라서 이번 업데이트는 일반적인 패치처럼 취급해서는 안 된다.
저장 성능과 다중 세션 처리량을 개선하는 동시에 실제 세션 데이터 형식과 공개 API를 변경한 업그레이드다.
Cloudflare R2, 요청 지역과 HTTP 오류 코드별 사용량 분석 추가 — 읽기·쓰기·429 제한 오류를 구분
- 발표일: 2026-11-03 — 정확한 게시 시각 미확인
- 안정화 단계: 정식 Dashboard 기능
- 적용 대상: Cloudflare R2 Bucket Metrics
- 변경 유형: Observability 기능 확대
- 원문: https://developers.cloudflare.com/changelog/post/2026-11-03-r2-request-error-breakdowns/
Cloudflare가 R2 Bucket의 Metrics 화면에서 요청이 발생한 지역과 HTTP 오류 코드를 더 세밀하게 확인할 수 있도록 기능을 확장했다.
R2는 Cloudflare의 Object Storage 서비스다.
웹 애플리케이션에서는 다음 데이터의 저장소로 사용할 수 있다.
- 사용자 업로드 파일
- 이미지
- 동영상
- 문서
- 빌드 결과물
- 로그 및 백업 데이터
이러한 파일을 제공하는 과정에서는 단순히 전체 요청 수만 확인하는 것으로 충분하지 않을 수 있다.
예를 들어 전 세계 사용자가 이미지 파일을 다운로드하는 서비스가 있다고 가정해 보자.
한국에서는 정상적으로 다운로드되지만 다른 지역에서는 오류가 많이 발생할 수 있다.
전체 오류 수만 보면 어느 지역에서 문제가 발생하는지 파악하기 어렵다.
이번 기능은 이러한 차이를 Dashboard에서 확인할 수 있도록 한다.
Request Distribution by Region
첫 번째 기능은 다음과 같다.
Request Distribution by Region
이 화면에서는 지역별 요청을 비교할 수 있다.
또한 요청의 종류를 구분할 수 있다.
대표적인 항목은 다음과 같다.
- Read
- Write
- Error
- Rate-limited Error
Rate Limit으로 인해 발생한 요청 제한도 별도로 확인할 수 있다.
대표적인 HTTP 상태 코드는 다음과 같다.
429
예를 들어 특정 지역에서 이미지 다운로드 오류가 증가했다고 가정해 보자.
실제 네트워크나 파일 접근 문제인지, 요청량 제한 때문인지 구분할 수 있다.
이는 지역별 트래픽이 크게 다른 글로벌 서비스에서 유용하다.
Errors by Status Code
두 번째 기능은 다음과 같다.
Errors by Status Code
이 화면은 HTTP 상태 코드별 오류 수와 변화 추세를 표시한다.
예를 들어 다음 상태를 구분할 수 있다.
| HTTP 상태 코드 | 일반적인 의미 |
|---|---|
| 403 | 접근 거부 |
| 404 | 리소스를 찾을 수 없음 |
| 429 | 요청량 제한 |
| 500 계열 | 서버 측 오류 |
하지만 같은 상태 코드가 항상 같은 원인으로 발생하는 것은 아니다.
예를 들어 HTTP 403은 Bucket의 접근 정책뿐 아니라 인증 정보의 문제로 발생할 수도 있다.
따라서 실제 오류 원인은 해당 요청의 내용과 R2의 설정을 함께 확인해야 한다.
변경 범위와 제한사항
이번 업데이트는 R2의 실제 파일 저장 방식이나 Object API를 변경한 것이 아니다.
또한 요청 제한 정책이나 HTTP 오류 코드를 새롭게 정의한 것도 아니다.
기존에 발생하던 요청을 분석하는 Dashboard 기능이 추가된 것이다.
이번 발표에는 오류 분석 시간이 얼마나 감소했는지에 관한 정량적인 Benchmark가 포함되지 않았다.
R2의 운영 상태를 전체 요청 수뿐 아니라 요청 지역과 오류 종류로 구분해 확인할 수 있도록 관측 기능을 확장한 업데이트다.
4️⃣ 개발 도구 및 보안
Vercel CI, Vercel 프로젝트가 없는 GitHub Repository에서도 사용 가능 — 배포 서비스와 CI 설정 분리
- 발표일: 2026-11-03 — 정확한 게시 시각 미확인
- 발표 주체: Vercel
- 안정화 단계: Vercel CI Beta의 지원 범위 확대
- 적용 대상: GitHub Private Repository, Vercel CI
- 원문: https://vercel.com/changelog/vercel-ci-no-longer-requires-a-linked-vercel-project
Vercel이 별도의 Vercel 프로젝트를 생성하지 않아도 GitHub Repository에서 Vercel CI를 실행할 수 있도록 기능을 변경했다.
Vercel CI는 앞서 10월 28일 Beta로 공개됐다.
당시 발표에서는 Turborepo의 작업 정의와 Remote Cache를 이용해 Lint와 Test, Build 등의 작업을 처리하는 기능을 소개했다.
이번 발표는 그 기능 자체를 다시 출시한 것이 아니다.
CI를 사용하기 위해 반드시 Vercel Deployment 프로젝트를 연결해야 했던 제약을 제거한 것이다.
기존 구조의 제약
기존에는 Vercel CI를 사용하려면 연결된 Vercel 프로젝트가 필요했다.
예를 들어 다음과 같은 Repository를 생각해 보자.
- 공통 TypeScript 라이브러리
- 내부 ESLint 설정
- UI 컴포넌트 패키지
- CLI 도구
이러한 프로젝트는 Vercel에 웹 애플리케이션을 배포하지 않을 수 있다.
하지만 실제 코드 변경 시에는 테스트와 Lint를 실행해야 한다.
기존 방식에서는 CI를 사용하기 위해 빈 Vercel 프로젝트를 생성해야 했다.
이는 실제 배포하지 않는 Repository에도 배포 프로젝트라는 관리 단위를 만들어야 한다는 의미였다.
이번 변경에서는 이러한 과정이 필요하지 않다.
Repository에 직접 CI 연결
새로운 방식에서는 GitHub Repository에 직접 Vercel CI를 활성화할 수 있다.
대표적인 명령은 다음과 같다.
vercel ci enable
이 명령은 로컬에서 Clone한 Repository 안에서 실행한다.
CLI가 현재 선택한 Vercel Team에 CI를 활성화한다.
다른 Team을 지정하려면 다음 옵션을 사용할 수 있다.
--scope <team>
이후 다음 Git Push부터 Repository에 정의된 작업이 실행된다.
단, 실제로 실행할 작업은 Repository의 CI 설정에 따라 결정된다.
따라서 명령을 실행했다고 해서 Lint와 Test, Build가 자동으로 모두 구성되는 것은 아니다.
필요한 권한
새로운 기능을 사용하려면 다음 조건이 필요하다.
- GitHub Repository에 접근할 수 있는 Vercel GitHub App이 설치돼 있어야 한다.
- GitHub 계정이 Vercel 계정과 연결돼 있어야 한다.
- 해당 Repository에 Write 또는 Admin 권한이 있어야 한다.
이러한 조건은 CI가 실제 Repository의 코드와 이벤트에 접근하기 위해 필요하다.
예를 들어 GitHub App에 Repository 접근 권한을 부여하지 않았다면 Vercel CI가 해당 Repository의 변경을 정상적으로 확인할 수 없을 수 있다.
또한 계정 연결이 존재하더라도 Repository 권한이 부족하면 CI를 활성화할 수 없다.
현재 지원하지 않는 Repository
이번 기능에는 중요한 제한사항이 있다.
현재는 GitHub Private Repository만 지원한다.
다음 환경은 지원하지 않는다.
- 공개 GitHub Repository
- GitLab
- 다른 Git Provider
따라서 공개 오픈소스 Repository에서 별도의 Vercel 프로젝트 없이 CI를 실행하려는 경우에는 아직 사용할 수 없다.
또한 이번 변경은 GitHub Repository와 Vercel CI 사이의 연결에 관한 것이다.
GitHub Actions의 모든 기능을 자동으로 대체한다는 의미는 아니다.
예를 들어 다른 클라우드에 배포하거나 특정 Runner의 실행 환경이 필요한 작업은 별도의 CI 구성이 필요할 수 있다.
기존 Turborepo 기능과의 관계
이번 변경 이후에도 Vercel CI는 기존의 작업 실행 모델을 유지한다.
대표적인 기능은 다음과 같다.
- Repository의 작업 정의 사용
- 변경 영향을 받는 작업 실행
- Remote Cache 활용
- 의존성 설치 및 실행 환경 관리
따라서 Vercel에 애플리케이션을 배포하지 않는 Monorepo도 이러한 기능을 사용할 수 있다.
하지만 실제 효과는 Repository의 Task Graph와 Cache 설정에 영향을 받는다.
이번 발표에는 별도 프로젝트 연결을 제거한 이후 CI 실행 시간이 추가로 개선됐다는 Benchmark는 포함되지 않았다.
웹 애플리케이션을 Vercel에 배포하지 않는 코드베이스에서도 Vercel CI를 독립적으로 사용할 수 있도록 지원 범위를 확장한 변경이다.
GitHub Dependabot, Agent Package Manager 의존성 업데이트 정식 지원 — AI Skill·Prompt·Instruction도 버전 관리
- 발표일: 2026-11-03 — 정확한 게시 시각 미확인
- 안정화 단계: Generally Available
- 적용 대상: GitHub Dependabot, Agent Package Manager(APM)
- 지원 범위: Git 기반 APM 의존성의 Version Update
- 원문: https://github.blog/changelog/2026-11-03-dependabot-version-updates-support-agent-package-manager
GitHub가 AI 코딩 에이전트가 사용하는 Skill과 Prompt, Instruction의 의존성을 Dependabot으로 업데이트할 수 있도록 기능을 확장했다.
이번 업데이트는 일반적인 npm 패키지의 버전 관리와 비슷한 문제를 AI 에이전트의 설정 파일에 적용한 것이다.
예를 들어 개발자가 AI 코딩 에이전트에게 특정 프로젝트의 코드 스타일을 알려주기 위해 별도의 Skill을 설치했다고 가정해 보자.
Skill에는 다음 정보가 포함될 수 있다.
- 코드 작성 규칙
- 테스트 방법
- 배포 절차
- 특정 프레임워크의 사용 지침
이러한 Skill 역시 시간이 지나면서 변경될 수 있다.
예를 들어 기존 지침에 잘못된 API가 포함돼 있을 수 있다.
새로운 라이브러리 버전에 맞춰 사용 방법이 변경될 수도 있다.
따라서 Skill을 외부 Repository에서 가져와 사용한다면 버전을 관리할 필요가 있다.
Agent Package Manager
Agent Package Manager는 AI 에이전트의 Skill과 Prompt, Instruction을 의존성으로 관리하는 도구다.
일반적인 JavaScript 프로젝트에서는 package.json에 패키지 의존성을 정의한다.
APM에서는 다음 파일을 사용한다.
apm.yml
실제로 설치한 버전 정보는 다음 Lockfile에 기록할 수 있다.
apm.lock.yaml
이러한 구조에서는 외부 Git Repository에 저장된 Skill을 특정 버전으로 사용할 수 있다.
하지만 새로운 버전이 공개되면 해당 변경을 확인하고 업데이트해야 한다.
Dependabot은 이러한 작업을 자동화한다.
Dependabot의 업데이트 PR
GitHub는 apm.yml에 선언한 Git 기반 의존성을 확인한다.
지원되는 의존성의 새로운 버전이 존재하면 Dependabot이 PR을 생성한다.
또한 apm.lock.yaml이 존재한다면 관련 내용을 다시 생성한다.
예를 들어 기존 Skill이 오래된 Commit을 참조한다고 가정해 보자.
새로운 버전이 공개되면 Dependabot이 해당 참조를 변경하는 PR을 만들 수 있다.
개발자는 PR에서 실제 변경 내용을 확인한 뒤 병합할 수 있다.
하지만 AI Skill의 버전이 최신이라는 사실만으로 해당 지침이 현재 프로젝트에 적합하다는 보장은 없다.
실제로 어떤 지침이 변경됐는지 검토해야 한다.
Dependabot 설정
기존 Dependabot 설정 파일을 사용한다.
.github/dependabot.yml
여기에 다음 정보를 추가한다.
- Package Ecosystem:
apm apm.yml이 존재하는 디렉터리- 업데이트 Schedule
이후 Dependabot은 정해진 주기에 따라 APM 의존성을 확인한다.
따라서 별도의 AI Agent 업데이트 시스템을 직접 구현할 필요가 줄어든다.
Version Update와 Security Update의 차이
이번 발표에서 가장 중요한 제한사항은 다음과 같다.
현재 APM 지원은 Version Update만 제공한다. Security Update는 지원하지 않는다.
Version Update는 새로운 의존성 버전이 존재하는지 확인하는 기능이다.
Security Update는 알려진 취약점에 영향을 받는 의존성을 수정 버전으로 변경하는 기능이다.
두 기능은 다르다.
따라서 APM을 Dependabot에 등록했다고 해서 악성 Skill이나 Prompt Injection이 포함된 외부 지침을 자동으로 탐지하고 수정하는 것은 아니다.
또한 실제 Skill의 안전성은 버전 정보만으로 판단하기 어렵다.
예를 들어 외부 Skill에 악의적인 Shell 명령이 포함됐다면 이를 실행하기 전에 내용과 권한을 검토해야 한다.
지원 환경
현재 지원 범위는 다음과 같다.
- github.com
- 모든 GitHub 요금제
- 지원되는 Git 기반 APM 의존성
GitHub Enterprise Server에는 3.24 버전부터 제공될 예정이다.
따라서 모든 Self-hosted GitHub 환경에서 즉시 사용할 수 있는 것은 아니다.
이번 업데이트는 AI 에이전트의 지침과 Skill도 일반적인 소프트웨어 패키지처럼 버전과 의존성으로 관리할 수 있도록 Dependabot의 범위를 확장한 기능이다.
GitHub CodeQL 2.27.3 공개 — JSON Data Extension·AI Threat Model·Python 3.15 지원 및 SSRF 오탐 감소
- 발표일: 2026-11-03 — 정확한 게시 시각 미확인
- 버전: CodeQL 2.27.3
- 안정화 단계: 정식 릴리스
- 적용 대상: GitHub Code Scanning, CodeQL CLI, Custom Query
- 변경 유형: 언어 지원 확대, Data Flow 분석 개선, 일부 Breaking Change
- 원문: https://github.blog/changelog/2026-11-03-codeql-2-27-3-adds-python-3-15-features-and-json-data-extensions
GitHub가 CodeQL 2.27.3을 공개하면서 데이터 흐름 분석에 사용하는 Data Extension의 형식을 확장하고 AI 관련 입력을 별도의 Threat Model로 정의할 수 있도록 변경했다.
CodeQL은 소스코드의 구조와 데이터 흐름을 분석해 보안 문제를 탐지하는 도구다.
예를 들어 사용자의 HTTP 요청에 포함된 문자열이 데이터베이스 Query에 어떻게 전달되는지 분석할 수 있다.
또한 외부 URL을 가져오는 코드에서 신뢰할 수 없는 입력이 검증 없이 사용되는지 확인할 수 있다.
이번 릴리스는 새로운 보안 취약점의 발견이 아니다.
보안 취약점을 분석하는 도구의 기능과 정확성을 개선한 업데이트다.
Data Extension의 역할
CodeQL은 프로그램의 데이터 흐름을 분석한다.
이때 어떤 값이 신뢰할 수 없는 외부 입력인지 알아야 한다.
대표적으로 다음 데이터가 해당할 수 있다.
- HTTP Request Parameter
- Cookie
- Query String
- 외부 파일
- 외부 API 응답
하지만 CodeQL이 모든 프레임워크와 사용자 정의 함수를 자동으로 이해할 수는 없다.
예를 들어 자체 HTTP Framework를 만들었다고 가정해 보자.
외부에서 들어오는 요청 데이터를 특정 함수가 반환한다.
하지만 CodeQL이 해당 함수를 외부 입력의 출처로 인식하지 못한다면 관련 데이터 흐름을 분석하기 어려울 수 있다.
Data Extension은 이러한 정보를 추가하기 위한 기능이다.
개발자는 어떤 함수가 신뢰할 수 없는 데이터를 반환하는지 정의할 수 있다.
또한 어떤 함수가 위험한 데이터를 사용하는 위치인지도 모델링할 수 있다.
JSON Data Extension 지원
기존에는 Data Extension에 주로 YAML 형식이 사용됐다.
이번 릴리스에서는 JSON 형식도 지원한다.
확장자가 다음과 같다면 JSON으로 파싱한다.
.json
또한 JSON Data Extension에서는 다음 주석 형식도 사용할 수 있다.
///* ... */
단, 이는 CodeQL의 Data Extension Parser가 지원하는 문법이다.
일반적인 JSON 표준 자체가 이러한 주석을 허용한다는 의미는 아니다.
다른 JSON Parser에서도 동일하게 처리된다고 가정해서는 안 된다.
새로운 형식을 사용하더라도 실제 Data Extension의 구조는 기존 YAML 기반 정의와 동일한 개념을 사용한다.
따라서 JSON은 새로운 분석 기능이라기보다 사용자 정의 데이터 모델을 작성하는 방법의 확장이다.
AI Threat Model
이번 릴리스에서는 새로운 Threat Model도 추가됐다.
ai
이 모델은 LLM이 생성한 데이터와 관련된 위험을 분석하는 데 사용할 수 있다.
예를 들어 AI 모델이 사용자 질문에 대한 답변을 생성한다고 가정해 보자.
이후 애플리케이션은 해당 답변을 Shell 명령이나 외부 URL로 사용하려고 할 수 있다.
이때 AI 모델의 출력은 신뢰할 수 없는 데이터로 취급해야 할 수 있다.
실제 모델이 반환한 값이 올바른 형식을 가진다고 해도 보안상 안전하다는 보장은 없기 때문이다.
개발자는 새로운 ai Threat Model을 이용해 이러한 데이터가 어떤 함수에서 생성되는지 정의할 수 있다.
하지만 중요한 제한사항이 존재한다.
이번 ai Threat Model에는 기본으로 제공되는 Source Model이 없다.
즉 CodeQL을 업데이트하고 ai를 활성화했다고 해서 모든 LLM 출력이 자동으로 위험한 입력으로 분석되는 것은 아니다.
개발자가 Data Extension을 이용해 실제 AI 관련 데이터의 출처를 직접 정의해야 한다.
따라서 이번 기능을 GitHub의 AI Application Scanning이 모든 AI 취약점을 자동으로 탐지하게 됐다는 의미로 해석해서는 안 된다.
Python 3.15 지원
CodeQL의 Python Extractor도 업데이트됐다.
새로운 지원 범위는 다음과 같다.
- Python 3.15의 일부 새로운 문법
matchLiteral Pattern의 Unary Plus- Unicode 17 식별자
frozendictsentinel
특히 frozendict와 관련된 데이터 흐름도 모델링할 수 있도록 변경됐다.
정적 분석 도구에서는 새로운 문법을 파싱하는 것만으로 충분하지 않다.
실제 값이 함수와 객체를 통해 어떻게 전달되는지도 이해해야 하기 때문이다.
따라서 새로운 언어 버전의 지원에는 Parser와 Data Flow Model의 변경이 함께 필요할 수 있다.
Java와 Kotlin의 SSRF 분석 개선
이번 릴리스에서 웹 백엔드와 직접 연결되는 수정은 SSRF 분석이다.
대표적인 Query는 다음과 같다.
java/ssrfjava/unvalidated-url-redirection
SSRF(Server-Side Request Forgery)는 서버가 공격자가 지정한 URL로 요청을 보내도록 유도하는 공격이다.
예를 들어 서버가 사용자가 전달한 URL에서 이미지를 다운로드한다고 가정해 보자.
서버가 외부 URL을 적절하게 검증하지 않으면 내부 네트워크의 주소로 접근할 가능성이 있다.
하지만 실제 코드에서는 URL의 Hostname을 허용 목록과 비교할 수 있다.
예를 들어 다음과 같은 방식이다.
URI.getHost()
그리고 해당 Hostname이 허용된 도메인에 해당하는지 확인한다.
기존 CodeQL에서는 이러한 검증이 존재하더라도 일부 상황에서 위험한 데이터 흐름으로 판단할 수 있었다.
특히 다음 경우다.
- Hostname을 대소문자 구분 없이 비교
- Hostname을 지역 변수에 저장한 뒤 비교
새로운 Query는 이러한 조건을 더 정확하게 인식한다.
따라서 실제로 허용 목록 검증을 수행하는 코드에서 불필요한 경고가 줄어들 수 있다.
하지만 SSRF 검사의 정확성이 개선됐다는 사실이 모든 우회 경로를 탐지한다는 의미는 아니다.
실제 URL 검증에서는 Redirect와 DNS Rebinding, IP 주소 정규화 등도 중요하다.
Python의 데이터 출처 설정
Python 관련 Query도 변경됐다.
대표적인 Query는 다음과 같다.
- XXE
- XML Bomb
- NoSQL Injection
이번 업데이트에서는 사용자가 정의한 Threat Model에 따라 외부 입력의 출처를 선택하도록 개선됐다.
기본적으로는 기존 Remote Flow Source를 사용한다.
하지만 Data Extension으로 추가한 입력 출처도 분석에 반영할 수 있다.
따라서 자체 Framework나 특수한 데이터 입력 경로를 사용하는 프로젝트에서 분석 범위를 더 정확하게 지정할 수 있다.
GitHub Actions Custom Query의 Breaking Change
이번 릴리스에는 호환성 변경도 존재한다.
GitHub Actions의 Control Flow Graph(CFG)가 공통 CFG 라이브러리를 사용하도록 변경됐다.
CFG는 프로그램이나 Workflow가 어떤 실행 경로를 따라가는지 표현하는 자료구조다.
Custom CodeQL Query에서 기존 CFG의 Node와 Edge 구조에 직접 의존했다면 영향을 받을 수 있다.
특히 다음 기존 클래스가 제거됐다.
Completion
대신 새로운 CFG Edge Label을 사용해야 한다.
대표적인 값은 다음과 같다.
DirectSuccessorBooleanSuccessorReturnSuccessor
따라서 GitHub Actions를 분석하는 Custom Query를 유지하는 프로젝트에서는 실제 Query의 호환성을 확인해야 한다.
적용 범위
CodeQL 2.27.3은 github.com의 GitHub Code Scanning에 자동 배포된다.
GitHub Enterprise Server에는 3.24 버전부터 포함될 예정이다.
이전 GHES 버전에서는 필요한 경우 CodeQL을 직접 업데이트해야 할 수 있다.
이번 릴리스에는 탐지 속도나 전체 취약점 발견율이 얼마나 개선됐는지에 관한 정량적인 Benchmark가 포함되지 않았다.
AI 모델 출력의 데이터 흐름을 직접 정의할 수 있는 기반을 추가하고, 여러 언어의 분석 정확성과 호환성을 개선한 정적 분석 도구 업데이트다.
이번 조사 범위에서 Vite, webpack, Rollup, esbuild, Babel, Biome, ESLint, Prettier, npm, pnpm, Yarn의 중요한 신규 정식 기능이나 긴급 보안 수정은 확인하지 못했습니다.
공식 릴리스 및 변경 기록에서 소규모 패치와 개발 버전은 검토했지만, 중요한 변경 내용이 확인되지 않은 일반적인 유지보수 업데이트는 포함하지 않았다.
이전 브리핑에서 다룬 GitHub Actions Workflow Lockfile과 Dependabot Reachability Analysis, AI Code Scanning 등의 발표도 반복하지 않았다.
이번 조사에서 확인한 GitHub의 새로운 소식은 APM 의존성 업데이트 지원과 CodeQL 2.27.3의 변경 사항으로 구분했다.
📚 추천 글
Our stylesheet has no forced-colors rules, so Windows High Contrast deletes 61 gradients from our homepage
- 저자: Daniel Pertu
- 매체: DEV Community
- 게시일: 2026-11-03
- 기술: CSS, Forced Colors Mode, 접근성, Chrome DevTools
- 원문: https://dev.to/daniel_pertu/our-stylesheet-has-no-forced-colors-rules-so-windows-high-contrast-deletes-61-gradients-from-our-569l
실제 운영 중인 웹사이트에서 Windows High Contrast와 같은 Forced Colors 환경을 활성화했을 때 기존 디자인의 구조가 어떻게 변경되는지 DOM과 Computed Style을 분석한 글이다.
이번 글의 특징은 접근성을 일반적인 체크리스트로 설명하지 않는다는 점이다.
저자는 자신이 운영하는 웹사이트의 실제 CSS 파일과 HTML을 조사했다.
이후 Chrome DevTools에서 Forced Colors를 활성화했다.
그리고 실제로 몇 개의 Gradient가 사라지고 어떤 요소의 색상과 형태가 변경되는지 측정했다.
특히 색상의 차이만으로 구분하던 UI 구조가 Forced Colors Mode에서 완전히 사라질 수 있다는 점을 구체적인 수치로 보여준다.
실제 CSS 파일의 상태
저자가 조사한 웹사이트는 Pub Trivia다.
해당 사이트는 하나의 CSS 파일을 제공한다.
파일의 크기는 다음과 같다.
86,718바이트
파일에는 총 13개의 Media Query가 존재했다.
하지만 해당 Media Query는 모두 다음 조건과 관련돼 있었다.
- 최소 화면 너비
- Hover
저자는 접근성과 관련된 다음 표현도 검색했다.
forced-colorsforced-color-adjustprefers-contrastprefers-reduced-motion@media print
결과는 모두 0건이었다.
즉 사이트는 화면 크기와 입력 장치의 Hover 상태는 고려하고 있었지만 사용자가 직접 선택한 표시 환경에 관한 별도의 스타일은 정의하지 않고 있었다.
그렇다고 브라우저가 해당 페이지를 무조건 읽을 수 없게 만드는 것은 아니다.
Forced Colors Mode에서는 브라우저가 일부 색상을 시스템 색상으로 변경한다.
문제는 이 과정에서 개발자가 의도한 시각적인 구분이 달라질 수 있다는 점이다.
실제로 사라진 Gradient
저자는 홈페이지에서 다음 영역에 존재하는 요소를 조사했다.
- Header
- Main
- Footer
총 723개의 요소가 존재했다.
그중 61개 요소에는 Gradient Background가 적용돼 있었다.
하지만 Forced Colors를 활성화하자 해당 Gradient가 모두 제거됐다.
측정 결과는 다음과 같다.
| 페이지 | 일반 모드의 Gradient | Forced Colors |
|---|---|---|
| 홈페이지 | 61개 | 0개 |
| 가격 페이지 | 18개 | 0개 |
| 기능 소개 페이지 | 0개 | 0개 |
Chrome은 Forced Colors Mode에서 해당 Gradient를 제거했다.
문제는 이 Gradient가 단순한 장식으로만 사용되지 않았다는 사실이다.
일부 Gradient는 카드의 경계와 구분선을 표현하는 데 사용되고 있었다.
따라서 Gradient가 제거되면서 실제 레이아웃의 구조까지 달라 보였다.
배경색으로 만든 1px 구분선
저자는 다음과 같은 스타일을 사용하고 있었다.
grid gap-px border
이 방식에서는 Grid의 각 자식 사이에 1px의 간격을 만든다.
그리고 부모의 Background Color를 통해 해당 간격을 구분선처럼 표시한다.
예를 들어 부모의 배경색이 회색이라고 가정해 보자.
자식 요소의 배경색은 검은색이다.
두 요소 사이에 1px의 Gap이 있다면 부모의 회색 배경이 노출된다.
사용자는 이를 구분선처럼 인식한다.
문제는 Forced Colors에서 발생했다.
브라우저는 부모와 자식의 배경색을 시스템 색상으로 변경할 수 있다.
저자의 경우 두 배경색이 모두 검은색으로 변경됐다.
따라서 실제 1px Gap은 존재하지만 화면에서는 구분선이 보이지 않았다.
그 결과 여러 개의 카드로 구성된 영역이 하나의 큰 영역처럼 표시됐다.
실제 Border와 배경색의 차이
저자는 같은 컨테이너의 바깥쪽 Border는 정상적으로 남아 있다는 사실도 확인했다.
이유는 실제 CSS Border를 사용했기 때문이다.
Forced Colors Mode에서는 실제 Border Color가 시스템 색상으로 조정될 수 있다.
반면 배경색의 차이만으로 구현한 구분선은 구조적인 Border가 아니다.
따라서 브라우저가 해당 구분선을 유지해야 할 이유를 알기 어렵다.
이 사례는 디자인의 구조를 표현하는 요소와 단순한 장식 효과를 구분해야 한다는 점을 보여준다.
실제로 서로 다른 항목을 구분해야 한다면 배경색의 차이만 사용하는 것보다 Border를 활용하는 편이 적합할 수 있다.
버튼이 일반 링크처럼 보인 문제
저자는 홈페이지에 있는 11개의 링크도 조사했다.
일반 모드에서는 링크가 다음처럼 구분됐다.
- 주요 CTA 버튼
- 일반 문장 안의 링크
- 보조 링크
- 배경색이 반전된 링크
하지만 Forced Colors에서는 모든 링크의 글자색이 같은 노란색으로 변경됐다.
또한 주요 CTA의 배경색은 페이지의 배경색과 같은 검은색이 됐다.
그 결과 주요 CTA와 일반 링크의 차이가 크게 줄어들었다.
예를 들어 Get started 버튼을 생각할 수 있다.
일반 모드에서는 배경색과 글자색의 대비를 이용해 버튼처럼 표시한다.
하지만 Forced Colors에서는 단순한 노란색 텍스트로 보일 수 있다.
남아 있는 차이는 Padding 정도였다.
저자는 이를 해결하기 위해 주요 CTA에 실제 Border를 추가하는 방법을 제시했다.
이렇게 하면 배경색이 변경되더라도 버튼의 형태를 유지할 수 있다.
텍스트 계층의 단순화
기능 소개 페이지에서도 차이가 나타났다.
저자는 총 85개의 텍스트 요소를 확인했다.
일반 모드에서는 세 종류의 색상이 사용됐다.
| 색상 역할 | 요소 수 |
|---|---|
| 본문 및 제목 | 44개 |
| 보조 설명 | 26개 |
| 투명도를 이용한 텍스트 | 15개 |
Forced Colors에서는 이 구분이 크게 단순화됐다.
76개 요소는 흰색으로 표시됐다.
9개 링크는 노란색으로 표시됐다.
즉 보조 설명과 일반 본문을 구분하던 회색의 차이가 사라졌다.
하지만 저자는 이 결과를 무조건 수정해야 하는 오류라고 판단하지 않았다.
해당 텍스트는 여전히 읽을 수 있었다.
또한 글자 크기와 굵기 등 다른 요소로 일부 계층을 구분할 수 있었다.
사용자가 Forced Colors를 선택했다면 색상보다 높은 대비와 단순한 표시 방식을 원할 수 있기 때문이다.
따라서 원래 디자인의 모든 색상을 억지로 복원하는 것이 적절하지 않을 수 있다.
사라지지 않은 이미지
흥미로운 결과도 있었다.
Gradient는 모두 제거됐다.
하지만 배경 이미지로 사용한 WebP 파일은 그대로 표시됐다.
저자가 조사한 두 이미지의 크기는 다음과 같았다.
- 953,385바이트
- 915,548바이트
즉 시각적인 구조를 나타내는 작은 Gradient는 사라졌지만 장식용으로 사용하는 큰 이미지는 그대로 남았다.
저자는 이를 통해 Forced Colors가 모든 시각적 장식을 제거하는 기능이 아니라는 점을 설명한다.
브라우저가 CSS Gradient와 Bitmap Image를 서로 다르게 처리하기 때문이다.
저자가 제안한 수정
저자는 세 가지 변경을 검토했다.
첫 번째는 구분선이 필요한 Grid 자식에 실제 Border를 추가하는 것이다.
또는 Forced Colors에서만 Border를 표시할 수 있다.
두 번째는 주요 CTA에도 Border를 추가하는 것이다.
이렇게 하면 배경색이 변경돼도 버튼의 형태가 유지된다.
세 번째는 다음 속성의 사용을 신중하게 결정하는 것이다.
forced-color-adjust: none
이 속성은 특정 요소가 브라우저의 Forced Colors 조정에서 제외되도록 할 수 있다.
하지만 모든 요소에 무조건 적용하는 것은 적절하지 않다.
사용자가 의도적으로 선택한 높은 대비 설정을 무시하게 될 수 있기 때문이다.
저자는 기본적으로 해당 속성을 사용하지 않는 편이 적절하다고 설명한다.
실제 검증 범위와 한계
이번 결과는 공개된 웹사이트의 실제 CSS와 DOM을 분석한 자체 측정이다.
측정에는 Chrome DevTools의 Forced Colors 에뮬레이션과 Chrome DevTools Protocol이 사용됐다.
따라서 실제 모든 Windows 사용자 환경에서 완전히 동일한 결과가 발생한다고 단정할 수는 없다.
또한 저자는 변경 전후의 접근성 점수나 사용자 테스트 결과를 제공하지 않았다.
이번 글의 수치는 특정 표시 모드에서 실제 스타일이 어떻게 변경되는지에 관한 것이다.
CSS의 색상과 장식에만 의존해 UI 구조를 표현하면 사용자의 표시 환경에 따라 중요한 구분이 사라질 수 있다는 점을 실제 웹사이트 분석으로 보여주는 자료다.
Streaming Markdown in Vue: Rendering LLM Tokens Without Flicker
- 저자: Simon He
- 매체: DEV Community / markstream-vue 개발자
- 게시일: 2026-11-03
- 기술: Vue, Markdown Parser, SSE, Virtualization, SSR, Mermaid, KaTeX
- 원문: https://dev.to/simonhe95/streaming-markdown-in-vue-rendering-llm-tokens-without-flicker-4fne
- 관련 프로젝트: https://github.com/simon-he/markstream-vue
AI 채팅 인터페이스에서 모델이 생성하는 Markdown을 실시간으로 표시할 때 발생하는 깜빡임과 불필요한 재렌더링, 코드 블록 및 표의 불완전한 상태를 처리하는 방법을 설명한 글이다.
저자는 실제로 markstream-vue를 개발하고 있다.
따라서 이번 글은 라이브러리 개발자가 자신의 설계 선택을 설명한 자료다.
특정 라이브러리의 내부 구조를 이해하는 데 유용하지만 독립적인 제품 비교나 Benchmark와는 구분해야 한다.
일반적인 Markdown Parser의 입력 가정
일반적인 Markdown Parser는 완성된 문서를 입력받는다.
예를 들어 다음과 같은 문서다.
- 제목
- 문단
- 완성된 코드 블록
- 완성된 표
- 닫힌 수식 표현
대표적인 라이브러리는 다음과 같다.
- marked
- markdown-it
- react-markdown
이러한 라이브러리는 완성된 Markdown을 렌더링하는 데 적합하다.
하지만 AI 모델의 스트리밍 응답은 다르다.
모델은 전체 문장을 한 번에 반환하지 않는다.
대신 여러 Token과 Chunk를 순차적으로 전달한다.
불완전한 Markdown
예를 들어 모델이 코드 블록을 생성한다고 가정해 보자.
처음에는 코드 블록을 시작하는 기호가 도착한다.
이후 언어 이름이 도착한다.
그다음 코드의 일부가 도착한다.
하지만 아직 코드 블록을 닫는 기호가 도착하지 않았다.
이 시점의 Markdown은 완성된 문서가 아니다.
비슷한 문제가 다음 표현에서도 발생한다.
- 닫히지 않은 Bold
- 완성되지 않은 Table
- 닫히지 않은 수식
- 일부만 전달된 Mermaid Diagram
일반적인 Parser에 이러한 데이터를 전달하면 구조를 잘못 해석하거나 임시 Loading 상태를 반환할 수 있다.
이후 새로운 Chunk가 도착하면 전체 구조가 다시 변경된다.
그 결과 사용자는 다음 현상을 경험할 수 있다.
- 텍스트 깜빡임
- 스크롤 위치 변경
- 코드 블록의 반복 생성
- Diagram의 표시와 제거 반복
전체 문자열 재파싱의 비용
단순한 구현에서는 SSE로 새로운 Chunk를 받을 때마다 문자열을 누적할 수 있다.
대표적인 구조는 다음과 같다.
content += token
이후 Vue의 Computed Property에서 전체 Markdown을 다시 파싱한다.
예를 들어 다음과 같은 방식이다.
marked.parse(content)
문제가 되는 부분은 전체 문자열을 반복해서 처리한다는 점이다.
첫 번째 Chunk가 도착하면 짧은 문자열을 파싱한다.
하지만 100번째 Chunk가 도착했을 때는 이전 99개 Chunk를 포함한 전체 문자열을 다시 파싱할 수 있다.
문서가 길어질수록 한 번의 파싱에 필요한 작업량이 증가한다.
또한 Markdown AST와 실제 DOM의 변경도 반복해서 발생할 수 있다.
따라서 저자는 완성된 문서를 렌더링하는 방식과 스트리밍 중인 문서를 렌더링하는 방식을 구분한다.
두 가지 렌더링 모드
markstream-vue에서는 두 가지 모드를 제공한다.
| 모드 | 설정 | 목적 |
|---|---|---|
| Virtual Window | max-live-nodes="220" | 긴 문서와 채팅 기록의 DOM 개수 제한 |
| Incremental | max-live-nodes="0" | 현재 생성 중인 답변의 점진적 렌더링 |
Virtual Window는 실제 화면에 필요한 범위의 DOM Node만 유지하는 방식이다.
예를 들어 Markdown 문서에 수천 개의 Block이 존재한다고 가정해 보자.
모든 Block을 동시에 DOM에 유지하면 브라우저의 메모리와 Layout 처리 비용이 증가할 수 있다.
Virtual Window에서는 실제 필요한 일부 Block만 유지한다.
따라서 긴 문서를 표시하는 상황에 적합하다.
반면 현재 답변이 생성되고 있는 채팅 말풍선에서는 Incremental Mode가 적합할 수 있다.
이 모드에서는 새롭게 도착한 내용을 작은 Batch로 렌더링한다.
Batch 크기와 지연 시간
Incremental Mode에서는 다음 값을 사용할 수 있다.
render-batch-sizerender-batch-delaytypewriter
저자가 소개한 설정 예시는 다음과 같다.
render-batch-size="16"
render-batch-delay="8"
이 설정은 한 번에 처리하는 작업량과 Batch 사이의 간격을 제어한다.
예를 들어 모델이 큰 Chunk를 한 번에 반환했다고 가정해 보자.
해당 내용을 모두 즉시 렌더링하면 브라우저의 메인 스레드에서 긴 작업이 발생할 수 있다.
반면 여러 Batch로 나누면 한 번에 처리해야 하는 작업량을 줄일 수 있다.
하지만 Batch를 너무 작게 만들면 전체 렌더링이 완료되기까지 시간이 길어질 수 있다.
반대로 Batch를 크게 만들면 한 번의 작업에서 처리해야 하는 DOM 변경이 증가할 수 있다.
따라서 실제 서비스에서는 응답 길이와 사용자 경험에 맞게 설정해야 한다.
이번 글에는 Batch 크기에 따른 독립적인 성능 Benchmark가 포함되지 않았다.
final 상태
이번 설계에서 중요한 부분은 스트리밍의 완료 여부를 명시적으로 전달한다는 점이다.
대표적인 속성은 다음과 같다.
final
스트리밍 중에는 false다.
모델이 답변을 완료하면 true로 변경한다.
이렇게 하면 Parser가 현재 문서를 어떻게 해석해야 하는지 구분할 수 있다.
예를 들어 아직 닫히지 않은 코드 블록이 있다고 가정해 보자.
스트리밍 중에는 이후에 닫는 기호가 도착할 수 있다.
따라서 해당 상태를 불완전한 코드 블록으로 유지할 수 있다.
하지만 모델이 실제로 답변을 완료했다면 더 이상 새로운 데이터가 도착하지 않는다.
이 경우 Parser는 최종 문서로 다시 해석해야 한다.
저자의 구현에서는 final이 true로 변경되면 스트리밍 Cache를 무효화한다.
그리고 최종 Markdown 규칙으로 다시 파싱한다.
이렇게 하면 마지막 코드 블록이나 수식이 계속 Loading 상태로 남는 문제를 줄일 수 있다.
Mermaid와 KaTeX
복잡한 Markdown에는 Mermaid Diagram과 수식이 포함될 수 있다.
하지만 이러한 기능을 기본 Bundle에 모두 포함하면 다운로드 크기가 증가할 수 있다.
저자는 다음 기능을 선택적으로 제공한다.
- Mermaid
- KaTeX
stream-diffs
Mermaid는 Diagram을 렌더링하는 데 사용할 수 있다.
KaTeX는 수학 표현식을 렌더링하는 데 사용할 수 있다.
stream-diffs는 향상된 코드 표시와 Diff 기능에 사용된다.
필요한 기능을 설치하지 않았다면 더 단순한 표현으로 Fallback할 수 있다.
예를 들어 향상된 코드 렌더링을 사용하지 않는 경우 일반적인 <pre> 요소를 사용할 수 있다.
이러한 방식은 처음부터 모든 Markdown 기능을 포함하지 않도록 한다.
SSR과 Hydration
저자는 서버에서 Markdown을 먼저 파싱하는 방식도 제공한다.
대표적인 함수는 다음과 같다.
getMarkdown()parseMarkdownToStructure()
서버에서 Markdown을 구조화된 Node로 변환한다.
이후 클라이언트에서는 해당 Node를 전달받아 렌더링할 수 있다.
예를 들어 다음 속성을 사용할 수 있다.
:nodes
이렇게 하면 서버에서 이미 파싱한 문서를 클라이언트에서 다시 전체 파싱하지 않아도 되는 구조를 만들 수 있다.
하지만 서버와 클라이언트의 렌더링 결과가 서로 다르면 Hydration 문제가 발생할 수 있다.
따라서 동일한 Parser 설정과 필요한 컴포넌트 구성을 유지해야 한다.
Markdown 안에 Vue 컴포넌트 삽입
AI 응답에는 일반적인 Markdown으로 표현하기 어려운 콘텐츠가 포함될 수 있다.
예를 들어 다음과 같은 UI다.
- 경고 Callout
- 버튼
- 차트
- 사용자 정의 카드
저자는 다음 함수를 제공한다.
setCustomComponents()
이를 이용해 Markdown의 특정 Marker를 실제 Vue 컴포넌트로 연결할 수 있다.
예를 들어 CALLOUT이라는 Marker를 경고 컴포넌트에 연결할 수 있다.
이러한 구조에서는 AI 응답에 포함된 특정 표현을 실제 인터랙티브 UI로 변경할 수 있다.
하지만 신뢰할 수 없는 Markdown이 임의의 컴포넌트를 실행하도록 허용하는 것은 보안상 문제가 될 수 있다.
따라서 어떤 Marker와 컴포넌트를 허용할지 제한해야 한다.
저자가 명시한 한계
저자는 모든 Markdown 프로젝트에 스트리밍 전용 렌더러를 사용해야 한다고 주장하지 않는다.
예를 들어 다음 상황에서는 일반적인 Parser가 더 간단할 수 있다.
- 정적인 블로그 글
- 기술 문서
- 완성된 Changelog
- 매우 짧은 Markdown
또한 다양한 Remark Plugin과의 호환성이 중요한 프로젝트라면 기존 Unified 생태계가 더 적합할 수 있다.
이번 글에는 실제 CPU 사용량이나 Frame Time을 다른 라이브러리와 비교한 독립적인 Benchmark가 없다.
따라서 markstream-vue가 모든 Vue 프로젝트에서 가장 빠르다고 단정할 수 없다.
완성되지 않은 Markdown을 오류 상태가 아닌 정상적인 스트리밍 상태로 취급하고, DOM의 갱신 범위와 최종 파싱 시점을 명확하게 관리하는 설계를 설명한 자료다.
React Activity Component: The State Between Render and Remount
- 저자: Parsa Jiravand
- 매체: bestpractic.org
- 게시일: 2026-11-02
- 기술: React 19, Activity, State, Effect, Strict Mode, useSyncExternalStore
- 원문: https://bestpractic.org/blog/react-weekly-activity-component
React에서 컴포넌트를 화면에서 숨기는 것과 실제로 Unmount하는 것이 어떻게 다르며, <Activity>가 두 방식의 한계를 어떻게 해결하는지 자세하게 설명한 기술 글이다.
이번 글은 새로운 React 릴리스를 소개하는 발표가 아니다.
<Activity>는 React 19.2에서 이미 도입됐다.
저자는 기존 기능의 실제 실행 동작과 이후 React 19.3에서 수정된 문제를 분석한다.
특히 State와 Effect, DOM, Ref가 서로 다른 수명주기를 가진다는 점을 설명한다.
조건부 렌더링의 한계
예를 들어 대시보드에 세 개의 Tab이 있다고 가정해 보자.
- Sales
- Operations
- Support
각 Tab에는 입력 Form과 데이터를 표시하는 컴포넌트가 존재한다.
사용자는 Sales Tab에서 검색 조건을 입력한다.
이후 Operations Tab으로 이동한다.
다시 Sales Tab으로 돌아온다.
일반적인 조건부 렌더링에서는 다음과 같은 표현을 사용할 수 있다.
activeTab === "sales" && <ReportPanel />
이 방식에서는 조건이 거짓이 되면 기존 컴포넌트가 Unmount된다.
따라서 해당 컴포넌트의 내부 State도 사라진다.
사용자가 다시 Tab으로 돌아오면 새로운 컴포넌트 인스턴스가 생성된다.
그 결과 입력하던 검색어와 선택한 필터가 초기화될 수 있다.
CSS로만 숨기는 방법
이를 해결하기 위해 모든 Tab을 계속 Mount한 상태로 유지할 수 있다.
예를 들어 비활성 Tab에 다음 CSS를 적용한다.
display: none
이렇게 하면 해당 컴포넌트는 실제 React 트리에 남아 있다.
따라서 내부 State가 유지된다.
하지만 다른 문제가 발생한다.
컴포넌트의 Effect도 계속 실행될 수 있다.
예를 들어 Sales Tab에서 다음 작업을 수행한다고 가정해 보자.
- 1초마다 데이터 Polling
- WebSocket 구독
- Timer 실행
- 외부 이벤트 구독
사용자가 다른 Tab으로 이동해 화면에서 보이지 않더라도 이러한 작업이 계속 실행될 수 있다.
따라서 불필요한 네트워크 요청과 CPU 사용량이 발생할 수 있다.
즉 단순한 CSS 숨김은 State를 보존하지만 실제 비동기 작업까지 중단하지는 않는다.
<Activity>의 역할
React 19.2에서는 다음 컴포넌트를 사용할 수 있다.
<Activity>
대표적인 Mode는 다음과 같다.
visiblehidden
visible은 일반적으로 화면에 표시하는 상태다.
hidden은 해당 하위 트리를 숨기는 상태다.
하지만 기존 조건부 렌더링과 중요한 차이가 있다.
hidden 상태에서도 React의 State를 유지한다.
따라서 사용자가 입력한 Form 값이나 컴포넌트의 내부 State를 보존할 수 있다.
동시에 Effect는 정리된다.
예를 들어 WebSocket 구독을 관리하는 useEffect가 존재한다고 가정해 보자.
컴포넌트가 Hidden 상태로 변경되면 해당 Effect의 Cleanup이 실행된다.
이후 다시 Visible 상태가 되면 Effect를 다시 설정할 수 있다.
세 방식의 차이
| 항목 | CSS로 숨김 | Activity Hidden | Unmount |
|---|---|---|---|
| React State | 유지 | 유지 | 제거 |
| DOM | 유지하되 숨김 | 유지하되 숨김 | 제거 |
| Effect | 계속 실행 | Cleanup 후 재설정 | Cleanup 후 제거 |
| Ref | 유지 | 유지 | 정리 |
| 다시 표시 | 기존 컴포넌트 표시 | 상태 복원 및 Effect 재실행 | 새로운 인스턴스 생성 |
이 차이는 실제 서비스에서 중요하다.
예를 들어 사용자가 여러 단계의 Form을 작성한다고 가정해 보자.
다른 화면으로 잠시 이동했다가 돌아왔을 때 입력값은 유지하고 싶을 수 있다.
하지만 해당 화면의 불필요한 데이터 Polling은 중단하고 싶을 수 있다.
Activity는 이러한 요구사항에 적합하다.
Hidden 상태에서도 렌더링이 발생할 수 있다
이번 글에서 중요한 부분은 Hidden 상태가 React의 모든 작업을 완전히 중단한다는 의미는 아니라는 사실이다.
Hidden 상태에서도 Props나 Context가 변경될 수 있다.
React는 필요한 경우 해당 하위 트리를 다시 렌더링할 수 있다.
다만 Hidden 상태의 작업은 일반적인 화면 업데이트보다 낮은 우선순위로 처리될 수 있다.
따라서 다음 두 작업은 구분해야 한다.
- 화면에 표시하지 않는 것
- React의 계산을 전혀 수행하지 않는 것
Activity는 첫 번째를 제공한다.
두 번째를 무조건 보장하는 기능은 아니다.
따라서 매우 무거운 계산을 수행하는 컴포넌트라면 Activity를 사용하더라도 추가적인 최적화가 필요할 수 있다.
Effect Cleanup의 역할
예를 들어 다음 Effect가 존재한다고 가정해 보자.
컴포넌트가 Mount되면 Interval을 생성한다.
Interval은 1초마다 데이터를 갱신한다.
컴포넌트가 Unmount되면 Interval을 제거한다.
이러한 코드는 다음 작업을 포함한다.
- Timer 생성
- Cleanup에서 Timer 제거
Activity가 Hidden 상태로 변경되면 Effect Cleanup이 실행될 수 있다.
따라서 Timer가 계속 실행되는 문제를 줄일 수 있다.
하지만 Cleanup이 올바르게 작성되지 않았다면 문제가 발생한다.
예를 들어 Interval을 생성했지만 Cleanup에서 제거하지 않았다고 가정해 보자.
그 경우 Activity의 실행 모델을 사용하더라도 불필요한 작업이 남을 수 있다.
저자는 이러한 문제를 발견하기 위해 React Strict Mode를 활용하는 방법을 설명한다.
Strict Mode
React Strict Mode는 개발 환경에서 일부 실행 흐름을 추가로 검사한다.
특히 Effect의 설정과 정리 과정이 올바르게 구현됐는지 확인하는 데 도움이 된다.
예를 들어 Effect가 두 번 설정되면서 WebSocket 구독이 중복되는 문제가 있다면 개발 환경에서 관련 현상을 발견할 수 있다.
하지만 Strict Mode를 사용한다는 사실만으로 모든 Cleanup 문제가 자동으로 해결되는 것은 아니다.
실제 코드가 올바른 정리 로직을 제공해야 한다.
useSyncExternalStore 관련 문제
저자는 React 19.3에서 수정된 Activity 관련 문제도 설명한다.
대표적인 상황은 useSyncExternalStore를 사용하는 컴포넌트다.
이 Hook은 React 외부의 상태 저장소를 구독하는 데 사용할 수 있다.
예를 들어 외부 Store에서 데이터를 가져오는 컴포넌트를 생각할 수 있다.
컴포넌트가 Hidden 상태인 동안 외부 Store의 값이 변경될 수 있다.
이후 다시 Visible 상태가 되면 최신 값을 표시해야 한다.
하지만 Hidden 상태의 Effect와 구독 처리에는 복잡한 실행 순서가 존재한다.
따라서 외부 Store가 변경됐을 때 최신 상태를 정확하게 반영하는지 검증해야 한다.
저자는 React 19.3에서 이러한 상황과 관련된 수정이 있었다고 설명한다.
이는 Activity가 단순히 display: none을 적용하는 Wrapper가 아니라 React의 렌더링과 구독 수명주기에 직접 영향을 주는 기능이라는 점을 보여준다.
언제 사용하지 않아야 하는가
Activity는 모든 조건부 렌더링을 대체하는 기능이 아니다.
예를 들어 다음 상황에서는 일반적인 Unmount가 더 적합할 수 있다.
- 더 이상 사용하지 않는 화면
- 내부 State를 반드시 초기화해야 하는 Form
- 많은 메모리를 사용하는 컴포넌트
- 매번 새로운 인스턴스가 필요한 UI
Activity는 Hidden 상태에서도 DOM과 State를 유지한다.
따라서 메모리 비용이 완전히 사라지는 것은 아니다.
또한 여러 개의 큰 화면을 모두 Activity로 유지하면 브라우저에 많은 DOM과 State가 남을 수 있다.
실제 사용 패턴을 고려해야 한다.
설계상의 한계
이번 글은 React의 실행 모델을 설명하는 기술 자료다.
실제 서비스의 CPU 사용량이나 메모리 사용량을 비교한 독립적인 Benchmark는 제공하지 않는다.
따라서 Activity를 사용하면 모든 Tab UI의 성능이 특정 비율로 개선된다고 판단할 수 없다.
컴포넌트를 숨기거나 제거할 때 State와 Effect, DOM의 수명주기를 각각 어떻게 관리할 것인지 이해하는 데 필요한 React 내부 동작과 설계 선택을 설명하는 글이다.
Vitest vs Jest vs node:test in 2026: Benchmarked on a Real TypeScript Project
- 저자: Waseem Anjum
- 매체: 개인 기술 블로그 / DEV Community
- 게시일: 2026-11-03
- 기술: TypeScript, Node.js, Vitest, Jest, SWC, node:test
- 원문: https://dev.to/fabwaseem/vitest-vs-jest-vs-nodetest-in-2026-benchmarked-on-a-real-typescript-project-4bj5
실제 TypeScript 프로젝트를 이용해 Vitest와 Jest, Node.js 내장 테스트 러너의 실행 시간을 같은 조건에서 비교한 기술 글이다.
이번 글은 단순히 각 테스트 러너의 기능을 비교하지 않는다.
저자는 실제 코드와 테스트를 작성했다.
이후 모든 테스트 러너에서 동일한 테스트가 실행되도록 구성했다.
그리고 Cold Run과 Warm Run, 프로세스 격리, TypeScript 변환, 테스트 파일 수에 따른 차이를 측정했다.
특히 어떤 테스트 러너가 가장 빠른지보다 어떤 격리 설정을 사용하는지가 성능에 더 큰 영향을 줄 수 있다는 점을 실제 결과로 보여준다.
테스트 환경
저자는 다음 환경에서 측정했다.
| 항목 | 구성 |
|---|---|
| 운영체제 | Linux |
| CPU | Intel Xeon 8 vCPU |
| 메모리 | 16GB |
| Node.js | 24.22.1 |
| TypeScript | 6.0.3 |
| Vitest | 5.1.0 |
| Jest | 30.5.2 |
| 테스트 파일 | 39개 |
| 테스트 | 183개 |
테스트 대상은 단순한 계산 함수에만 한정되지 않았다.
다음 기능이 포함됐다.
- 금액 계산
- URL Slug 생성
- Cursor Pagination
- LRU Cache
- Rate Limiter
- Retry와 Backoff
- CSV Parser
- Event Bus
- Zod Schema
- 주문 상태 변경
- HTTP API
- MCP 서버
실제 HTTP 및 MCP 테스트에서는 서버를 임의의 Port에서 실행했다.
그리고 fetch()와 MCP SDK Client를 이용해 요청을 전달했다.
따라서 모든 테스트가 단순한 함수 호출만 수행하는 것은 아니었다.
테스트 러너별 API 차이 제거
Vitest와 Jest, node:test는 서로 다른 테스트 API를 제공한다.
예를 들어 테스트를 정의하는 함수와 Mocking 방식이 다를 수 있다.
이러한 차이가 실제 Benchmark 결과에 영향을 주지 않도록 저자는 공통 Shim을 만들었다.
각 테스트 파일은 다음 경로에서 테스트 API를 가져왔다.
#t
실제 테스트 러너의 종류에 따라 이 경로를 해당 API로 연결했다.
Assertion에는 다음 Node.js 기능을 사용했다.
node:assert/strict
또한 실제 테스트에서는 러너별 Mocking API에 의존하지 않았다.
필요한 Mock 객체는 직접 작성했다.
이렇게 하면 테스트 러너마다 다른 방식으로 구현한 코드 때문에 결과가 달라지는 문제를 줄일 수 있다.
Cold Run과 Warm Run
저자는 두 종류의 실행 시간을 측정했다.
첫 번째는 Cold Run이다.
기존 테스트 러너의 Cache를 제거한 상태에서 실행한다.
두 번째는 Warm Run이다.
Cache를 유지한 상태에서 실행한다.
각 구성은 10번씩 실행했다.
실행 순서는 섞어서 진행했다.
저자는 각 실행 시간의 중앙값을 사용했다.
다만 테스트 VM은 공유 환경이었다.
측정 당시 시스템 Load Average가 3~13 사이에서 변화했다.
따라서 결과의 소수점 차이는 환경의 영향을 받을 수 있다.
기본 설정 비교
첫 번째 결과는 다음과 같다.
| 테스트 러너 | Cold | Warm |
|---|---|---|
| Vitest 기본 설정 | 1.59초 | 1.51초 |
| Jest + SWC | 0.99초 | 0.81초 |
| node:test | 1.23초 | 1.24초 |
이 테스트에서는 Jest가 가장 빠른 결과를 보였다.
Vitest가 가장 느렸다.
하지만 이 결과만으로 Jest가 모든 프로젝트에서 Vitest보다 빠르다고 판단해서는 안 된다.
실제로 실행 설정을 변경하면 결과가 달라졌다.
프로세스 격리의 비용
Vitest의 기본 설정에서는 테스트 파일별로 별도의 프로세스를 사용할 수 있다.
저자의 측정에서는 39개의 Worker Process가 생성됐다.
이때 각 프로세스의 시작 비용이 누적됐다.
반면 Jest는 여러 테스트 파일을 처리하면서 Worker Process를 재사용했다.
또한 각 테스트 파일은 별도의 VM Context에서 실행할 수 있었다.
이러한 차이 때문에 실제 테스트 코드의 실행 시간뿐 아니라 실행 환경을 준비하는 비용이 달라졌다.
저자는 Vitest의 실행 방식을 변경했다.
대표적으로 다음 설정을 사용했다.
threadsvmThreads--no-isolate
결과는 다음과 같았다.
| Vitest 설정 | Warm Run |
|---|---|
| 기본 설정 | 1.51초 |
| Threads | 1.42초 |
| vmThreads | 0.91초 |
| No Isolation | 0.68초 |
격리를 제거한 설정에서는 기존 Jest보다 빠른 결과가 나타났다.
하지만 여기에는 중요한 트레이드오프가 존재한다.
격리를 제거하면 테스트 사이의 상태가 공유될 수 있다
테스트 격리는 단순한 성능 비용만 발생시키는 기능이 아니다.
각 테스트가 다른 테스트의 영향을 받지 않도록 하는 역할도 한다.
예를 들어 테스트 A가 전역 변수를 변경했다고 가정해 보자.
테스트 B는 해당 전역 변수가 초기 상태라고 기대할 수 있다.
하지만 두 테스트가 같은 실행 환경을 공유한다면 테스트 A의 변경이 테스트 B에 영향을 줄 수 있다.
그 결과 다음 문제가 발생할 수 있다.
- 실행 순서에 따라 테스트 성공 여부 변경
- 전역 Mock의 잔존
- Module Cache 오염
- 환경 변수 변경의 영향
- 종료되지 않은 Timer나 Subscription
따라서 --no-isolate가 빠르다는 사실만으로 모든 프로젝트에서 활성화해야 한다는 의미는 아니다.
실제 테스트의 독립성을 확인해야 한다.
vmThreads의 한계
vmThreads는 속도와 격리 사이의 절충안이 될 수 있다.
하지만 Node.js VM Context에는 다른 문제가 존재한다.
예를 들어 객체가 서로 다른 JavaScript Realm에서 생성됐다고 가정해 보자.
두 객체의 구조가 같더라도 Prototype이 서로 다를 수 있다.
이러한 차이는 instanceof나 특정 객체 비교에서 영향을 줄 수 있다.
저자는 Vitest의 VM 기반 실행에서 이러한 문제를 발견했다고 설명한다.
또한 관련 문서에서는 큰 테스트 Suite에서 메모리 사용량이 증가할 가능성을 안내한다.
따라서 작은 테스트 Suite에서 빠르게 실행됐다는 사실만으로 매우 큰 프로젝트에서도 같은 결과가 나온다고 판단할 수 없다.
Jest의 TypeScript 변환 비용
Jest에서는 TypeScript를 처리하는 방법도 성능에 영향을 줬다.
저자는 다음 두 방식을 비교했다.
@swc/jestts-jest
SWC는 TypeScript를 JavaScript로 빠르게 변환하는 데 사용할 수 있다.
하지만 일반적인 변환 과정에서 TypeScript의 전체 타입 검사를 수행하지는 않는다.
반면 ts-jest는 설정에 따라 타입 검사를 함께 수행할 수 있다.
결과는 다음과 같았다.
| Jest 설정 | Warm Run |
|---|---|
| SWC | 0.81초 |
| ts-jest 타입 검사 | 3.13초 |
ts-jest isolatedModules | 1.14초 |
타입 검사를 포함한 ts-jest는 상대적으로 느렸다.
하지만 타입 검사를 수행하는 것 자체가 불필요한 작업이라는 의미는 아니다.
저자는 타입 검사를 별도의 단계로 실행하는 방식도 측정했다.
tsc --noEmit
해당 작업은 약 1.5초가 걸렸다.
따라서 테스트 러너에서는 빠른 JavaScript 변환을 사용하고 타입 검사는 별도의 CI 단계에서 수행하는 구조를 검토할 수 있다.
하지만 테스트 실행과 타입 검사를 분리하면 실제 CI 구성과 오류 보고 경로가 달라질 수 있다.
node:test의 성능
Node.js의 내장 테스트 러너도 비교됐다.
기본 설정에서는 각 테스트 파일을 별도의 프로세스에서 실행했다.
하지만 다음 옵션을 사용하면 결과가 달라졌다.
--test-isolation=none
이 설정에서는 Warm Run이 약 0.51초로 감소했다.
실험한 구성 가운데 가장 빠른 결과였다.
하지만 Vitest와 마찬가지로 격리를 제거한 결과다.
따라서 테스트의 독립성과 전역 상태 관리에 대한 책임이 증가할 수 있다.
파일 수를 네 배로 늘린 실험
저자는 테스트 파일의 수를 네 배로 늘려 추가로 측정했다.
다만 실제 테스트 코드를 새롭게 작성한 것은 아니다.
기존 테스트 트리를 세 번 더 복사했다.
전체 규모는 다음과 같다.
- 테스트 파일 156개
- 테스트 732개
측정 결과는 다음과 같았다.
| 구성 | Warm Run |
|---|---|
| Vitest 기본 설정 | 5.03초 |
| Vitest vmThreads | 1.99초 |
| Vitest No Isolation | 1.08초 |
| Jest + SWC | 1.52초 |
| node:test 기본 설정 | 4.26초 |
| node:test No Isolation | 1.10초 |
이 결과에서는 테스트 파일별 실행 환경을 생성하는 비용이 더 명확하게 나타났다.
파일 수가 증가하자 기본 설정의 Vitest와 node:test는 상대적으로 더 많은 시간이 필요했다.
반면 격리를 제거한 구성은 실행 시간이 크게 증가하지 않았다.
하지만 이 실험은 같은 테스트를 반복 복사한 것이다.
실제 대규모 프로젝트처럼 서로 다른 라이브러리와 복잡한 의존성이 추가된 상황은 아니다.
따라서 실제 프로젝트의 규모가 네 배로 증가했을 때 같은 비율로 결과가 달라진다고 단정할 수 없다.
이번 Benchmark의 한계
이번 결과는 저자가 직접 수행한 자체 Benchmark다.
독립적인 제3자가 같은 환경에서 재현한 결과는 아니다.
또한 VM은 공유 환경이었고 실제 시스템 부하가 변화했다.
다음 조건에 따라서도 결과가 달라질 수 있다.
- 테스트 파일 수
- 실제 테스트 코드의 복잡도
- Mocking 방식
- 브라우저 환경 사용 여부
- TypeScript 변환 방식
- 병렬 실행 설정
- 테스트 격리 정책
따라서 단순히 가장 빠른 숫자를 기준으로 테스트 러너를 선택하는 것은 적절하지 않을 수 있다.
이번 글은 테스트 러너의 성능을 비교할 때 라이브러리 자체보다 실행 격리와 TypeScript 변환 방식, 프로세스 생성 비용을 함께 분석해야 한다는 점을 실제 Benchmark로 보여준다.
Finding where people quit a multi-step form, without cookies or recording what they typed
- 저자: Peter Hallander
- 매체: DEV Community / Vittnet
- 게시일: 2026-11-03
- 기술: JavaScript, pagehide, sendBeacon, Form Validation, Playwright
- 원문: https://dev.to/stackedboost/finding-where-people-quit-a-multi-step-form-without-cookies-or-recording-what-they-typed-2c49
실제 법률 문서 작성 서비스에서 사용자가 여러 단계의 Form을 작성하다가 어느 화면에서 이탈했는지 확인하면서도 입력한 개인정보를 별도로 저장하지 않도록 분석 시스템을 구현한 과정을 설명한 글이다.
이번 글의 특징은 일반적인 Analytics SDK나 Session Recording 도구를 사용하지 않았다는 점이다.
저자가 운영하는 서비스에서는 다음과 같은 민감한 정보를 입력할 수 있다.
- 이름
- 개인 식별 정보
- 법률 문서의 당사자
- 재산과 관련된 정보
따라서 사용자가 실제로 어떤 값을 입력했는지 제3자의 Session Recording 도구에 전달하는 것은 적절하지 않을 수 있다.
저자는 다음 세 가지 정보를 알고 싶었다.
첫 번째는 사용자가 Form을 실제로 작성하기 시작했는지다.
두 번째는 어느 단계에서 이탈했는지다.
세 번째는 입력값 검증 오류 때문에 진행하지 못했는지다.
하지만 실제 입력한 값은 필요하지 않았다.
기존 Analytics의 한계
저자의 기존 Analytics에서는 다음 이벤트만 기록하고 있었다.
Started form
이벤트가 존재하면 사용자가 Form을 시작했다는 사실은 확인할 수 있다.
하지만 다음 사실은 알 수 없다.
- 첫 번째 단계에서 이탈했는지
- 마지막 단계까지 진행했는지
- 검증 오류가 발생했는지
- 가격을 확인한 뒤 이탈했는지
일반적인 Session Recording 도구를 도입하면 이러한 행동을 더 자세히 분석할 수 있다.
하지만 실제 입력값이나 화면의 일부가 기록될 가능성이 있다.
저자는 이러한 위험을 피하기 위해 필요한 최소한의 이벤트만 기록하기로 결정했다.
수집하지 않는 데이터
저자가 설정한 기본 원칙은 다음과 같다.
- Cookie 사용하지 않음
- 사용자 ID 저장하지 않음
- IP 주소 저장하지 않음
- Form에 입력한 실제 값 저장하지 않음
- 이탈한 Form당 하나의 이벤트만 기록
이렇게 하면 여러 페이지의 사용자를 장기간 추적하는 방식보다 수집 데이터의 범위를 줄일 수 있다.
하지만 Cookie를 사용하지 않는다는 사실만으로 모든 개인정보 보호 의무가 자동으로 충족되는 것은 아니다.
실제로 어떤 데이터를 수집하고 얼마나 오래 저장하는지, 해당 지역의 법적 요구사항을 함께 고려해야 한다.
pagehide
사용자가 페이지를 떠나는 순간을 기록하려면 브라우저 이벤트를 사용해야 한다.
기존에는 다음 이벤트를 사용하는 코드가 존재했다.
beforeunloadunload
하지만 저자는 두 이벤트를 사용하지 않았다.
특히 모바일 환경에서는 페이지가 항상 일반적인 종료 절차를 거치지 않는다.
사용자가 다른 앱으로 전환하거나 브라우저를 백그라운드로 이동할 수 있기 때문이다.
또한 unload 이벤트는 Back/Forward Cache의 동작에도 영향을 줄 수 있다.
저자는 다음 이벤트를 사용했다.
pagehide
이 이벤트는 페이지가 Navigation이나 종료 등의 이유로 숨겨질 때 발생할 수 있다.
또한 Back/Forward Cache에 들어가는 상황과도 연결된다.
하지만 pagehide 역시 모든 운영체제 종료 상황에서 반드시 전달된다는 보장은 없다.
따라서 이를 이용한 분석 결과는 실제 이탈의 완전한 기록이 아니라 브라우저가 전달할 수 있었던 이벤트를 기반으로 한다.
navigator.sendBeacon()
페이지를 떠나는 시점에는 일반적인 fetch() 요청이 완료되지 않을 수 있다.
예를 들어 사용자가 브라우저 탭을 닫는다고 가정해 보자.
이때 JavaScript에서 HTTP 요청을 시작했다.
하지만 페이지가 종료되면서 해당 요청이 취소될 수 있다.
저자는 이를 해결하기 위해 다음 API를 사용했다.
navigator.sendBeacon()
이 API는 페이지 종료 시점의 작은 분석 이벤트를 전달하는 데 적합하다.
저자는 다음 URL로 데이터를 전송한다.
/api/t
전달할 데이터는 JSON으로 직렬화한다.
하지만 HTTP Request Body의 Content Type은 다음과 같이 설정했다.
text/plain
이는 브라우저와 요청 정책의 차이를 고려한 선택이다.
일반적인 JSON Content Type은 요청의 성격에 따라 추가적인 검증 절차를 발생시킬 수 있다.
저자는 서버에서 실제 문자열을 JSON으로 파싱하도록 구성했다.
fetch() Fallback
sendBeacon()을 사용할 수 없거나 요청을 Queue에 등록하지 못하는 상황도 고려했다.
이 경우 다음 설정으로 fetch()를 실행한다.
keepalive: true
이렇게 하면 페이지가 종료되는 상황에서도 요청을 완료할 가능성을 높일 수 있다.
하지만 keepalive를 사용했다고 해서 모든 요청이 반드시 전송되는 것은 아니다.
브라우저의 정책과 네트워크 연결 상태에 따라 실패할 수 있다.
따라서 이탈 분석 시스템은 일부 이벤트가 누락될 가능성을 고려해야 한다.
실제 Form을 작성한 사용자만 기록
저자는 페이지를 열었다가 아무런 행동 없이 떠나는 사용자를 Form 이탈로 취급하지 않았다.
이러한 방문자는 일반적인 페이지 조회 통계에서 확인할 수 있기 때문이다.
대신 다음 상태를 관리했다.
- Form을 시작했는지
- 실제 입력이나 클릭이 있었는지
- 결제 절차를 시작했는지
- 이미 이탈 이벤트를 전송했는지
예를 들어 사용자가 페이지를 열었다.
하지만 Form에는 아무런 값을 입력하지 않았다.
이후 페이지를 떠났다.
이 경우 이탈 이벤트를 전송하지 않는다.
반면 Form에 값을 입력한 뒤 페이지를 떠났다면 이탈 이벤트를 기록할 수 있다.
결제 Redirect는 이탈로 기록하지 않음
이번 글에서 중요한 예외 처리다.
사용자가 Form의 마지막 단계에서 결제 버튼을 눌렀다고 가정해 보자.
서비스는 외부 결제 제공업체로 Redirect한다.
이 경우 현재 페이지에서는 pagehide 이벤트가 발생할 수 있다.
하지만 사용자는 Form을 포기한 것이 아니다.
실제로는 결제 절차로 이동하고 있다.
따라서 저자는 다음 상태를 별도로 관리했다.
paying
결제 버튼을 누르면 먼저 해당 값을 설정한다.
이후 분석 이벤트를 전송하고 Redirect를 시작한다.
이렇게 하면 정상적인 결제 이동을 Form 이탈로 잘못 기록하는 문제를 줄일 수 있다.
조건부 Form 단계
이번 서비스에서는 이전 답변에 따라 이후 단계가 달라질 수 있다.
예를 들어 사용자가 특정 선택지를 선택하면 추가 질문이 나타날 수 있다.
반대로 다른 선택지를 선택하면 해당 질문이 생략될 수 있다.
따라서 단순히 DOM에 존재하는 단계의 Index를 사용하면 사용자가 실제로 본 단계와 다른 값이 기록될 수 있다.
저자는 현재 표시되는 단계만 계산한다.
그리고 다음 정보를 기록한다.
- 사용자가 실제로 본 현재 단계 번호
- 전체 표시 단계 수
- 현재 단계의 제목
예를 들어 다음과 같은 정보다.
step 2/5: Givare och mottagare
이렇게 하면 단순히 두 번째 단계에서 이탈했다는 사실보다 실제 어떤 질문을 처리하고 있었는지 이해하기 쉽다.
검증 오류의 필드 이름만 기록
Form의 입력값을 검증하는 과정에서는 오류가 발생할 수 있다.
예를 들어 다음 두 필드에서 오류가 발생했다고 가정해 보자.
- 이름
- 개인 식별 번호
저자는 실제 입력한 이름이나 번호를 기록하지 않는다.
대신 어떤 필드에서 오류가 발생했는지만 기록한다.
예를 들어 다음과 같은 값이다.
givare_namn
givare_pnr
또한 반복되는 필드의 Index는 제거한다.
예를 들어 여러 수신자의 정보를 입력하는 Form이 있다고 가정해 보자.
실제 필드 경로는 다음과 같을 수 있다.
mottagare.0.pnr
mottagare.3.pnr
저자는 이를 공통 필드 이름으로 변환한다.
mottagare.pnr
이렇게 하면 어떤 종류의 입력에서 오류가 발생했는지 알 수 있지만 실제로 몇 명의 정보를 입력했는지는 드러내지 않을 수 있다.
서버에서 다시 검증
브라우저에서 생성한 분석 이벤트는 신뢰할 수 있는 데이터가 아니다.
사용자는 직접 HTTP 요청을 생성할 수 있기 때문이다.
저자는 서버에서 다음 조건을 검사했다.
- 이탈 이벤트에서만 추가 Metadata 허용
- 문자열 길이 제한
- 제어 문자 제거
- HTML 관련 특수문자 제거
- 알려진 Bot 요청 제외
Metadata 길이는 최대 160자로 제한했다.
이렇게 하면 분석 이벤트가 지나치게 커지는 문제를 줄일 수 있다.
또한 이벤트 내용을 관리자 화면이나 HTML 알림에 표시할 때 발생할 수 있는 일부 Injection 문제를 줄일 수 있다.
하지만 문자열을 단순히 정리했다는 사실만으로 모든 출력 환경의 보안이 자동으로 보장되는 것은 아니다.
실제 HTML에 표시할 때는 적절한 Escaping이 필요하다.
Playwright에서 발견한 테스트 오류
저자는 자신의 분석 코드에 다음 조건을 추가했다.
navigator.webdriver
이 값은 브라우저가 자동화된 환경에서 실행되는지 나타낼 수 있다.
저자는 자동화된 Bot의 요청을 통계에서 제외하기 위해 해당 값이 true이면 이벤트를 전송하지 않도록 했다.
하지만 이 설정 때문에 새로운 문제가 발생했다.
Playwright 테스트에서도 navigator.webdriver가 true로 표시될 수 있기 때문이다.
테스트는 Form을 작성하고 페이지를 닫는 동작을 정상적으로 수행했다.
하지만 실제 분석 이벤트는 전송되지 않았다.
그런데 기존 테스트는 네트워크 요청이 실제로 발생했는지 확인하지 않았다.
따라서 테스트는 성공한 것처럼 보였다.
저자는 이를 해결하기 위해 Playwright의 addInitScript()를 사용했다.
테스트 환경에서 해당 속성을 의도적으로 변경한 뒤 실제 분석 이벤트가 발생하는지 확인했다.
이후 /api/t 요청을 관찰하고 Payload의 내용을 검사했다.
이 사례는 자동화 테스트가 실제 운영 코드의 Bot 차단 정책 때문에 검증하려는 기능을 아예 실행하지 않을 수 있다는 점을 보여준다.
실제 저장 데이터와 한계
저자는 이탈 이벤트마다 다음 정보를 저장한다.
- 이벤트 종류
- 현재 경로
- 문서 종류
- 기기 분류
- 브라우저 언어
- 대략적인 지역 정보
- 단계와 오류 필드 이름
IP 주소는 저장하지 않는다.
대략적인 지역 정보는 IP를 메모리에서 조회한 뒤 결과만 저장한다.
하지만 이러한 정보도 사용자의 행동에 관한 데이터다.
따라서 수집 목적과 보관 기간, 개인정보 처리방침을 고려해야 한다.
저자는 실제 서비스의 개인정보 처리방침도 수정했다.
이번 글에는 이벤트 전송 성공률이나 분석 시스템 도입 전후의 Form 전환율에 관한 정량적인 Benchmark가 제공되지 않았다.
따라서 이 시스템을 도입하면 Form 완료율이 특정 비율로 증가한다고 단정할 수 없다.
민감한 입력값을 수집하지 않고도 사용자가 어떤 단계와 검증 오류에서 이탈하는지 확인하도록 최소한의 브라우저 이벤트와 서버 분석을 연결한 실제 운영 사례다.