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

기준 시점: 2026-11-01 06:00 KST
조사 범위: 전일 2026-10-31 06:00 ~ 당일 2026-11-01 06:00 KST
추천 글 범위: 기준 시점 당시 최근 7일
1️⃣ 프론트엔드
Next.js 16.5.0-canary.35 공개 — use cache 오류의 Prerender 실패 문제와 다국어 라우팅 수정
- 발표일: 2026-10-31
- GitHub 공개 시각: 2026-10-31 08:01 UTC, 한국 시간 17:01
- 버전: Next.js 16.5.0-canary.35
- 안정화 단계: Canary Pre-release
- 적용 대상: App Router, Cache Components, Prerendering, Pages Router와 App Router의 혼합 구성, Turbopack
- 공식 원문: https://github.com/vercel/next.js/releases/tag/v16.5.0-canary.35
- 캐시 오류 수정: https://github.com/vercel/next.js/pull/100956
- 다국어 경로 수정: https://github.com/vercel/next.js/pull/98726
Next.js가 16.5 계열의 새로운 Canary 버전을 공개했다.
이번 릴리스는 새로운 React API나 대규모 프레임워크 기능을 추가하는 업데이트가 아니다. 대신 서버 렌더링 과정에서 이미 처리한 오류가 다시 빌드 실패로 이어질 수 있던 문제와 App Router의 다국어 경로 처리 문제를 수정했다.
특히 'use cache'와 Prerendering을 함께 사용하는 프로젝트에서 발생하는 오류 처리 문제가 중요하다.
Canary는 정식 안정 버전이 아니다. 따라서 이번 수정 사항이 Next.js의 모든 운영 환경에 이미 적용됐다고 해석해서는 안 된다.
1. use cache 함수의 오류가 페이지 빌드까지 실패시키는 문제
Next.js의 Cache Components에서는 다음 Directive를 사용할 수 있다.
'use cache'
이 Directive는 특정 함수나 컴포넌트의 결과를 캐싱하도록 하는 데 사용된다.
예를 들어 상품 정보를 조회하는 서버 함수를 생각할 수 있다.
해당 함수의 결과를 캐싱하면 동일한 데이터를 가져오기 위한 반복적인 계산이나 데이터 조회를 줄일 수 있다.
하지만 캐싱하는 함수에서도 오류가 발생할 수 있다.
예를 들어 외부 API가 일시적으로 실패한다고 가정해 보자.
애플리케이션은 오류를 처리하고 사용자에게 대체 콘텐츠를 표시할 수 있다.
일반적인 JavaScript 코드에서는 다음과 같은 흐름이다.
- 캐시 함수 실행
- 외부 데이터 조회 실패
- 함수에서 오류 발생
- 상위 코드에서
catch로 오류 처리 - Fallback 콘텐츠 반환
- 페이지 렌더링 계속 진행
이 구조에서 중요한 점은 오류가 이미 처리됐다는 사실이다.
개발자가 해당 오류를 예상하고 대체 결과를 반환했다면 전체 페이지를 반드시 실패 처리할 필요는 없다.
하지만 기존 Next.js 구현에서는 다른 결과가 발생할 수 있었다.
오류 기록과 Prerender 실패 판정이 같은 정보를 사용
Next.js는 서버 렌더링 중 발생한 오류를 기록한다.
이때 오류의 Digest와 관련 정보를 저장하는 내부 Lookup을 사용한다.
이 정보는 나중에 오류를 다시 확인하거나 로그를 출력하는 데 활용될 수 있다.
문제는 Prerendering에서도 같은 Lookup을 사용했다는 점이다.
Prerendering은 페이지를 요청받기 전에 미리 렌더링하는 작업이다.
예를 들어 빌드 시점에 정적 HTML이나 관련 결과물을 생성할 수 있다.
기존 구현에서는 'use cache' 함수에서 발생한 오류가 애플리케이션 코드에서 정상적으로 처리됐더라도 내부 오류 기록에 남을 수 있었다.
Prerendering은 해당 기록을 확인하고 실제 렌더링 실패가 발생했다고 판단할 수 있었다.
그 결과 다음 상황이 가능했다.
- 실제 애플리케이션 코드에서는 오류를 처리함
- Fallback 콘텐츠를 정상적으로 생성함
- 하지만 Next.js 내부에서는 실패한 렌더링으로 판단함
- 빌드 또는 On-demand Prerendering이 실패함
관련 Issue에는 정상적으로 처리한 'use cache' 오류 때문에 페이지가 빌드 오류 또는 HTTP 500으로 이어질 수 있다는 문제가 기록돼 있다.
이번 수정은 이러한 불일치를 해결한다.
실제 렌더링 실패와 기록용 오류 분리
새로운 구현은 다음 두 종류의 정보를 분리한다.
첫 번째는 로그와 진단을 위해 저장하는 오류 정보다.
두 번째는 실제로 Prerendering을 실패 처리해야 하는 렌더링 오류다.
Next.js는 React Server Components 렌더러에서 보고한 오류를 별도로 수집한다.
그리고 기존 오류 Lookup은 로깅을 위한 용도로 유지한다.
이렇게 하면 이미 처리된 오류가 단순히 기록에 존재한다는 이유만으로 전체 Prerendering을 실패시키는 문제를 줄일 수 있다.
공식 PR에서는 다음 두 환경을 대상으로 회귀 테스트를 추가했다.
- Build-time Prerendering
- On-demand Prerendering
또한 오류가 처리된 이후 반환한 Fallback 결과도 캐싱할 수 있는지 확인한다.
이는 캐시 함수 내부에서 오류가 발생했다는 사실과 실제 페이지를 생성할 수 없다는 사실을 구분하는 수정이다.
모든 오류를 무시하는 기능은 아니다
이번 변경을 이해할 때 주의해야 할 부분이 있다.
Next.js가 서버 렌더링에서 발생하는 모든 오류를 무시하도록 변경한 것은 아니다.
실제로 처리되지 않은 오류가 존재한다면 페이지 렌더링이나 빌드는 여전히 실패할 수 있다.
이번 수정의 대상은 애플리케이션이 이미 처리한 오류가 내부 오류 기록 때문에 다시 실패로 해석되는 상황이다.
따라서 정상적인 오류 처리와 실제 렌더링 실패를 구분하는 정확성이 개선된 것이다.
2. App Router와 Pages Router의 다국어 경로 처리
두 번째 주요 변경은 다음 항목이다.
Fix App Router locale path matching with Pages i18n
Next.js에서는 App Router와 Pages Router를 함께 사용하는 프로젝트가 존재할 수 있다.
예를 들어 오래된 페이지는 pages/ 디렉터리에 남아 있고, 새로운 화면은 app/ 디렉터리에서 구현할 수 있다.
이러한 구조에서 국제화(i18n) 설정까지 사용하면 요청 경로의 처리가 복잡해진다.
예를 들어 다음 경로를 생각할 수 있다.
/ko/products
/en/products
애플리케이션은 언어 Prefix를 확인해야 한다.
또한 해당 경로가 App Router와 Pages Router 가운데 어느 쪽에 속하는지 결정해야 한다.
기존 구현에서는 Pages Router의 i18n 설정과 App Router의 경로 매칭이 서로 영향을 줄 수 있었다.
이번 Canary에서는 이전에 되돌렸던 관련 수정 사항을 다시 적용했다.
공식 PR에는 다음 작업도 포함됐다.
- i18n Domain Routing 테스트 수정
- App Router의 Prerender Parameter Decoding 수정
따라서 다국어 경로를 사용하는 혼합 Router 프로젝트에서 경로 해석의 일관성을 높이려는 변경으로 볼 수 있다.
하지만 이번 수정이 모든 i18n 구성을 자동으로 지원한다는 의미는 아니다.
실제 적용 여부는 프로젝트의 Router 구성과 Locale 설정에 따라 달라진다.
3. 응답 취소와 Turbopack 처리 개선
이번 릴리스에는 다음 수정도 포함됐다.
Handle cancellation errors during response clone cleanup
이는 응답을 복제하고 정리하는 과정에서 발생하는 취소 오류를 처리하는 변경이다.
HTTP Response Body는 Stream을 이용할 수 있다.
일부 실행 흐름에서는 Response를 복제하거나 여러 소비자가 데이터를 읽는 구조가 존재할 수 있다.
이러한 작업 중 요청이 취소되면 정리 과정에서도 오류가 발생할 수 있다.
이번 변경은 해당 취소 상황을 처리하는 내부 로직과 관련된다.
Turbopack에도 다음 변경이 포함됐다.
- Pages Route가 존재하는 경우에만 Pages Client Entry 추가
- Tree Shaking으로 제거된 모듈의 AST 메모리 해제
AST(Abstract Syntax Tree)는 소스코드의 문법 구조를 표현하는 자료구조다.
빌드 도구는 코드를 분석하기 위해 AST를 생성한다.
하지만 Tree Shaking으로 사용하지 않는 모듈이 제거됐다면 해당 AST를 계속 메모리에 보관할 필요가 줄어든다.
이번 수정은 이러한 데이터를 더 적극적으로 정리하는 방향이다.
다만 공식 릴리스에는 메모리 사용량이나 빌드 시간이 얼마나 개선됐는지에 관한 정량적인 Benchmark가 포함되지 않았다.
안정화 단계와 적용 범위
이번 버전은 Canary다.
따라서 일반적인 운영 프로젝트에서 사용 중인 Next.js 16의 안정 버전과 구분해야 한다.
특히 다음 조건에 해당하는 프로젝트에서 관련 변경의 의미가 크다.
'use cache'함수의 오류를 상위 코드에서 처리- Static Prerendering 또는 On-demand Prerendering 사용
- App Router와 Pages Router 혼합 사용
- i18n 경로 및 Domain Routing 사용
이번 발표는 캐시 함수의 오류 처리와 실제 페이지 생성 실패를 분리하고, 다국어 라우팅 및 Turbopack 내부 처리의 정확성을 개선한 개발 버전 업데이트다.
이외에 React, Vue, Nuxt, Angular, Svelte, SvelteKit, Astro, Remix의 이번 조사 범위에 해당하는 주요 신규 안정 릴리스는 확인하지 못했습니다.
공식 릴리스 채널의 개발 버전과 패치 릴리스도 검토했지만, 중요한 신규 기능이 확인되지 않은 단순 유지보수 변경은 포함하지 않았다.
2️⃣ 웹 플랫폼·브라우저
이번 조사 범위에서 포함할 만한 주요 신규 발표를 확인하지 못했습니다.
W3C, WHATWG, TC39, MDN, Chrome·Chromium, Firefox·Mozilla, WebKit·Safari와 JavaScript·TypeScript·CSS, 웹 성능 및 접근성 관련 공식 정보를 검토했다.
이번 기간에 새롭게 정식 채택된 주요 웹 표준이나 안정화된 브라우저 API의 출시 발표는 확인하지 못했다.
최근 공개된 Firefox 159와 Chrome 158 Beta는 이전 브리핑에서 이미 다뤘으므로 반복하지 않았다.
또한 Chrome의 Canary·Beta 변경과 웹 표준 초안의 개정은 일반 사용자가 사용할 수 있는 안정적인 브라우저 기능의 출시와 구분했다.
이번 추천 글에서는 새로운 브라우저 기능의 발표 대신 실제 웹 플랫폼 기능을 활용하는 방법을 다룬다.
특히 CSS의 @custom-media를 활용해 반응형 디자인의 조건을 재사용하는 방법과 Angular의 브라우저 History API 처리 사례를 소개한다.
3️⃣ 백엔드·인프라
Cloudflare workerd v1.20261031.1 공개 — 유휴 Durable Object의 비동기 작업이 전체 연결을 중단시키던 문제 수정
- 발표일: 2026-10-31
- GitHub 릴리스 표시 시각: 2026-10-31 00:44 UTC, 한국 시간 09:44
- 버전: workerd v1.20261031.1
- 안정화 단계: 오픈소스 런타임 릴리스
- 적용 대상: workerd, Durable Objects, WebSocket Hibernation, IoContext
- 공식 릴리스: https://github.com/cloudflare/workerd/releases/tag/v1.20261031.1
- 주요 수정 PR: https://github.com/cloudflare/workerd/pull/7857
- Durable Object Facet 수정: https://github.com/cloudflare/workerd/pull/8097
Cloudflare가 Workers Runtime의 오픈소스 구현체인 workerd의 새로운 릴리스를 공개했다.
이번 버전에는 Durable Object의 비동기 실행 환경과 관련된 중요한 수정이 포함됐다.
특히 유휴 상태의 Durable Object에서 다른 실행 Context의 비동기 작업이 완료되거나 AbortSignal이 전달될 때 실행 Context 전체가 중단될 수 있던 문제를 수정했다.
이 문제는 WebSocket 연결을 유지하는 애플리케이션에서 의미가 크다.
다만 먼저 구분해야 할 부분이 있다.
workerd의 오픈소스 릴리스가 공개됐다는 사실이 Cloudflare의 전 세계 운영 Runtime에 해당 변경이 즉시 모두 적용됐다는 의미는 아니다.
이번 발표에서 직접 확인된 것은 workerd v1.20261031.1의 소스코드와 릴리스 내용이다.
실제 Cloudflare Workers 서비스의 배포 시점은 별도로 구분해야 한다.
Durable Object와 IoContext
Durable Object는 특정 ID와 연결된 실행 단위다.
예를 들어 하나의 채팅방을 하나의 Durable Object로 관리할 수 있다.
사용자는 해당 채팅방의 Durable Object에 WebSocket으로 연결한다.
Durable Object는 채팅방의 상태와 연결을 관리한다.
하지만 Durable Object가 항상 활성 HTTP 요청을 처리하는 것은 아니다.
예를 들어 사용자가 채팅방에 연결돼 있지만 새로운 메시지는 전송하지 않는 상황을 생각할 수 있다.
이때 Durable Object는 실제 요청을 처리하지 않는 유휴 상태일 수 있다.
workerd는 이러한 실행 환경을 관리하기 위해 IoContext를 사용한다.
IoContext는 실행 중인 비동기 작업과 관련된 상태를 관리하는 내부 구성요소다.
중요한 특징은 다음과 같다.
IoContext의 수명은 개별 HTTP 요청의 수명보다 길 수 있다.
따라서 현재 처리 중인 요청이 없어도 관련 비동기 작업의 결과가 나중에 도착할 수 있다.
다른 실행 Context의 Promise가 완료되는 상황
예를 들어 Durable Object A가 다른 실행 Context에 작업을 요청했다고 가정해 보자.
해당 작업은 비동기로 실행된다.
Durable Object A는 현재 요청을 완료한다.
그런데 나중에 다른 실행 Context에서 작업이 완료된다.
이때 Promise의 결과를 원래 Durable Object에 전달해야 할 수 있다.
기존 workerd에서는 이러한 비동기 작업을 Queue에 등록했다.
그리고 IoContext::run()을 이용해 처리했다.
문제는 현재 처리 중인 IncomingRequest가 없는 상태에서도 해당 작업이 실행될 수 있었다는 점이다.
IoContext::run()은 현재 요청이 존재한다는 전제에 의존하는 부분이 있었다.
따라서 현재 요청이 없으면 오류가 발생할 수 있었다.
기존 구현에서는 해당 오류로 인해 IoContext가 중단될 수 있었다.
그 결과 Durable Object 전체가 영향을 받을 수 있었다.
WebSocket 연결이 영향을 받을 수 있었던 이유
공식 PR은 Hibernatable WebSocket을 유지하는 유휴 Actor를 구체적인 사례로 제시한다.
예를 들어 하나의 채팅방에 사용자 100명이 연결돼 있다고 가정해 보자.
현재 새로운 HTTP 요청은 없다.
하지만 WebSocket 연결들은 유지되고 있다.
이때 다른 Context에서 전달한 비동기 작업이 완료된다.
해당 결과를 처리하려다가 IoContext가 중단되면 한 사용자와 관련된 작업의 실패가 Durable Object 전체에 영향을 줄 수 있다.
결과적으로 여러 WebSocket 연결이 함께 중단될 가능성이 있었다.
즉 문제는 단순히 하나의 Promise가 실패하는 상황이 아니었다.
특정 비동기 작업을 잘못 처리하면서 동일한 Durable Object에 연결된 다른 클라이언트까지 영향을 받을 수 있다는 점이 중요했다.
새로운 처리 방식: 다음 요청까지 작업 보류
이번 수정에서는 현재 IncomingRequest가 없는 경우 해당 작업을 무조건 실행하지 않는다.
대신 Queue에 남겨 둔다.
이후 새로운 IncomingRequest가 도착하면 대기 중인 작업을 처리한다.
전체 흐름은 다음과 같다.
- 다른 IoContext에서 Promise 완료 또는 AbortSignal 이벤트 발생
- Durable Object의 IoContext에 작업 등록
- 현재 IncomingRequest가 있는지 확인
- 요청이 존재하면 작업 실행
- 요청이 없으면 Queue에 보관
- 다음 요청이 도착하면 보관한 작업 실행
이러한 방식은 유휴 상태에서 작업을 실행하려다가 전체 IoContext가 중단되는 문제를 줄인다.
다만 모든 종류의 오류를 무시하도록 변경한 것은 아니다.
실제 작업 자체에서 오류가 발생한다면 기존의 오류 처리 정책이 적용될 수 있다.
이번 수정의 대상은 현재 IncomingRequest가 없어 작업을 실행하기에 적절하지 않은 상태다.
Lock 대기 중 요청이 종료되는 상황
두 번째 문제는 Lock을 기다리는 동안 발생할 수 있었다.
예를 들어 두 작업이 같은 내부 자원에 접근하려고 한다고 가정해 보자.
첫 번째 작업이 Lock을 가지고 있다.
두 번째 작업은 Lock이 해제되기를 기다린다.
그런데 두 번째 작업이 기다리는 동안 해당 요청이 종료될 수 있다.
이 경우 처음 작업을 등록했을 때는 IncomingRequest가 존재했지만 실제 Lock을 획득했을 때는 더 이상 요청이 존재하지 않을 수 있다.
이번 수정은 다음 두 시점에서 요청의 존재 여부를 확인한다.
IoContext::run()호출 시점- Lock을 획득한 이후
따라서 Lock을 획득했다고 해서 실제로 요청이 여전히 존재한다고 가정하지 않는다.
요청이 종료됐다면 작업을 실행하지 않고 필요한 오류 처리를 수행할 수 있다.
명확한 오류 코드
이번 변경에서는 IncomingRequest가 없는 상황을 나타내기 위해 다음 오류 식별자를 사용한다.
NO_CURRENT_INCOMING_REQUEST_DETAIL_ID
기존에는 해당 상황이 내부 오류로 처리될 수 있었다.
새로운 구현에서는 보다 명확한 JavaScript Error를 생성하도록 변경했다.
또한 반복적인 오류가 로그를 지나치게 많이 생성하지 않도록 경고 출력 빈도를 제한한다.
공식 PR은 이러한 경고를 최대 한 시간에 한 번 기록하도록 설명한다.
하지만 오류 식별자가 추가됐다는 사실이 해당 상황을 사용자 코드에서 모두 복구할 수 있다는 의미는 아니다.
실제 호출 구조에 따라 작업의 실행 여부와 상태 복구가 달라질 수 있다.
waitUntil과 작업의 실행 시간
보류된 작업은 새로운 요청이 도착했을 때 실행될 수 있다.
이때 해당 작업은 새로운 요청의 waitUntil 작업으로 처리된다.
waitUntil은 응답 이후에도 특정 비동기 작업을 계속 실행하도록 연결할 수 있는 기능이다.
하지만 waitUntil을 사용한다고 해서 해당 작업에 무제한의 CPU 시간과 메모리가 제공되는 것은 아니다.
실제 실행에는 해당 환경의 제한이 적용된다.
따라서 보류된 작업이 너무 많거나 실행 비용이 크다면 새로운 요청의 처리에도 영향을 줄 수 있다.
또한 Durable Object가 Hibernation 또는 종료되는 과정에서는 아직 처리하지 못한 일부 작업이 폐기될 수 있다.
공식 구현은 이러한 상황에서 경고를 남기도록 한다.
즉 이번 수정은 유휴 상태의 비동기 작업을 안전하게 보류하는 기능이지 모든 비동기 작업의 영속적인 실행을 보장하는 기능은 아니다.
중요한 작업의 결과를 반드시 보존해야 한다면 Durable Storage 등 별도의 지속 저장소를 고려해야 한다.
AbortSignal의 중복 등록 방지
이번 수정에는 AbortSignal 관련 개선도 포함됐다.
AbortSignal은 비동기 작업의 취소를 전달하는 데 사용하는 인터페이스다.
예를 들어 클라이언트가 요청을 취소하면 서버에서도 해당 작업을 중단하도록 신호를 전달할 수 있다.
하지만 서로 다른 Context 사이에서 AbortSignal을 전달하는 경우에는 이벤트 구독과 전달 요청이 추가로 발생할 수 있다.
기존 구조에서는 유휴 상태의 IoContext에 같은 종류의 작업이 반복해서 등록될 가능성이 있었다.
이번 변경에서는 특정 AbortSignal의 RPC 구독 요청을 중복으로 등록하지 않도록 제한했다.
또한 Abort Reason과 관련된 데이터의 메모리 사용량도 더 정확하게 관리하도록 개선했다.
이러한 변경은 비동기 이벤트가 많이 발생하는 환경에서 불필요한 Queue 증가를 줄이기 위한 것이다.
Durable Object Facet의 Snapshot 제한
이번 릴리스에는 Durable Object Facet과 관련된 변경도 포함됐다.
Facet은 Durable Object의 일부 기능을 분리해 제공하는 구조다.
하지만 Facet은 실제 Root Durable Object와 저장소를 공유할 수 있다.
따라서 특정 Facet이 전체 저장소 Snapshot을 생성하거나 복구한다면 같은 Root를 사용하는 다른 Facet에도 영향을 줄 가능성이 있다.
이번 릴리스에서는 이러한 위험을 줄이기 위해 Facet에서 다음 기능을 직접 사용하지 못하도록 제한했다.
storage.snapshot()onNextSessionRestore()
이러한 기능은 공유 저장소의 전체 상태와 연결되기 때문이다.
따라서 Facet 단위의 작업이 다른 Facet의 상태까지 변경하는 문제를 줄이려는 목적이다.
다만 이번 변경은 모든 Durable Object Storage 기능을 제거하는 것이 아니다.
Snapshot과 Restore처럼 공유 상태 전체에 영향을 줄 수 있는 기능의 사용 범위를 제한한 것이다.
Rust JSG와 메모리 관리
이번 릴리스에는 Rust JSG 관련 변경도 포함됐다.
JSG는 workerd에서 JavaScript와 네이티브 코드 사이의 인터페이스를 구성하는 데 사용하는 기술이다.
이번 릴리스에서는 Rust에서 비동기 Context Frame을 관리하는 기능이 추가됐다.
또한 Promise Reaction이 실행된 뒤 관련 데이터를 더 빨리 정리하도록 변경했다.
기존에는 특정 Promise Reaction 데이터가 Garbage Collection이 실행될 때까지 유지될 수 있었다.
새로운 방식에서는 Reaction이 완료된 이후 해당 데이터를 정리할 수 있다.
이는 불필요하게 유지되는 데이터의 수명을 줄이기 위한 변경이다.
하지만 이번 릴리스에는 메모리 사용량이 몇 퍼센트 감소했는지에 관한 정량적인 Benchmark가 제공되지 않았다.
따라서 모든 Worker에서 메모리 사용량이 감소한다고 단정할 수 없다.
안정화와 적용 범위
이번 릴리스는 workerd의 실제 오픈소스 릴리스다.
하지만 일부 변경은 내부 Feature Gate에 의해 제어될 수 있다.
따라서 해당 코드를 포함한 버전을 사용한다는 사실만으로 모든 변경이 모든 실행 환경에서 즉시 활성화된다고 볼 수 없다.
또한 Cloudflare Workers의 글로벌 운영 Runtime에 언제 적용되는지는 오픈소스 릴리스 시점과 구분해야 한다.
이번 업데이트는 유휴 Durable Object에서 전달된 비동기 작업이 전체 실행 Context를 중단시키는 문제를 해결하고, 저장소 공유 범위와 네이티브 비동기 데이터의 수명 관리를 개선한 런타임 수정이다.
이외에 Node.js, Deno, Bun과 Cloudflare·AWS·Google Cloud·Azure의 이번 조사 범위에서 포함할 만한 주요 신규 백엔드 및 인프라 발표는 확인하지 못했습니다.
공식 릴리스 노트와 변경 기록을 확인했으며, 조사 범위 이전에 게시된 발표와 이미 다룬 보안 업데이트는 반복하지 않았다.
4️⃣ 개발 도구 및 보안
이번 조사 범위에서 포함할 만한 주요 신규 발표를 확인하지 못했습니다.
Vite, webpack, Rollup, esbuild, Babel, Biome, ESLint, Prettier, npm, pnpm, Yarn, GitHub, GitLab 및 주요 테스트·배포 도구의 공식 릴리스 채널을 확인했다.
조사 기간에 소규모 패치와 개발 버전의 배포가 있었지만, 중요한 신규 기능이나 보안 수정으로 확인되지 않은 항목은 포함하지 않았다.
특히 Rollup 4.67.3은 10월 31일 공개된 패치로 확인했지만, 주요 내용이 특수한 Module Identifier에서 줄바꿈을 보존하는 버그 수정에 한정돼 있어 별도의 주요 뉴스로 선정하지 않았다.
보안 관련 공식 권고도 확인했으나, 이미 발표된 취약점을 반복 설명하는 자료와 실제로 새로운 수정 사항을 제공하는 발표를 구분했다.
이전 브리핑에서 다룬 Node.js와 PHP의 긴급 보안 업데이트, GitHub의 Code Scanning 및 공급망 보안 발표도 중복으로 포함하지 않았다.
다만 최신성이 중요한 보안 수정은 공식 발표일뿐 아니라 실제 패치가 적용된 날짜와 운영 환경의 배포 상태를 함께 확인해야 한다.
예를 들어 새로운 보안 공지가 공개됐더라도 취약점 자체는 이전에 수정됐을 수 있다.
반대로 패치가 공개됐더라도 실제 운영 중인 Container나 Runtime이 아직 이전 버전을 사용하고 있을 수 있다.
이번 추천 글에서는 새로운 릴리스 발표를 반복하지 않고, 실제 개발 도구의 성능과 호환성을 독립적으로 측정한 자료를 소개한다.
📚 추천 글
I Compared 6 TypeScript Validation Libraries. Here's What Surprised Me 🤔
- 저자: nyaomaru
- 매체: DEV Community
- 게시일: 2026-10-28
- 원문: https://dev.to/nyaomaru/i-compared-6-typescript-validation-libraries-heres-what-surprised-me-325g
- 실험 코드: https://github.com/nyaomaru/typescript-validator-comparison
TypeScript 애플리케이션에서 사용하는 여섯 가지 런타임 검증 라이브러리와 직접 작성한 Type Guard를 같은 조건으로 구현하고, 실제 번들 크기와 실행 시간을 비교한 기술 글이다.
이번 글의 특징은 단순히 어떤 라이브러리가 가장 빠른지 비교하는 데 그치지 않는다는 점이다.
저자는 먼저 모든 검증기가 실제로 같은 데이터를 같은 기준으로 검사하는지 확인했다.
그다음 번들 크기와 런타임 성능을 측정했다.
특히 같은 TypeScript 타입을 검증한다고 해도 각 라이브러리가 실제로 허용하는 JavaScript 값의 범위가 다를 수 있다는 점을 실험으로 확인했다.
비교 대상
저자는 다음 일곱 가지 방식을 비교했다.
| 방식 | 검증 구조 |
|---|---|
| 직접 작성한 Type Guard | 일반적인 JavaScript 조건문 |
| is-kit | 조합 가능한 Type Guard |
| Runtypes | Runtime Type 객체 |
| Valibot | 함수 기반 Schema |
| Zod | 객체 기반 Schema |
| ArkType | TypeScript와 유사한 타입 표현 |
| typia | 빌드 시 생성한 검증 함수 |
저자는 is-kit의 개발자이기도 하다.
따라서 해당 라이브러리의 설계와 활용 방법에 관해서는 직접적인 이해관계가 존재한다.
하지만 실제 비교는 공개된 테스트 코드와 측정 결과를 제공한다.
각 결과의 한계도 함께 설명한다.
동일한 User 데이터 검증
실험에서는 다음 필드가 존재하는 User 객체를 사용했다.
idnameageroletagsprofile
각 필드에는 구체적인 조건이 있다.
예를 들어 age는 18부터 120 사이의 정수여야 한다.
role은 admin 또는 user만 허용한다.
tags는 문자열 배열이어야 한다.
profile에는 bio와 active가 존재해야 한다.
또한 Root와 중첩 객체에서 정의하지 않은 추가 속성을 거부한다.
문자열을 숫자로 자동 변환하는 Coercion도 허용하지 않는다.
특히 이름의 길이는 Unicode 문자 수가 아니라 UTF-16 Code Unit 기준으로 1~100개로 정의했다.
이러한 기준은 중요하다.
JavaScript 문자열의 length는 사용자가 눈으로 인식하는 문자 수와 항상 같지 않기 때문이다.
예를 들어 일부 Emoji는 여러 Code Unit으로 표현될 수 있다.
따라서 문자열의 길이를 검사한다는 사실만으로 모든 라이브러리가 같은 의미의 길이를 검사한다고 볼 수 없다.
35개 Fixture로 검증 의미 통일
저자는 총 35개의 테스트 입력을 준비했다.
대표적으로 다음 사례가 포함됐다.
- 정상적인 User 객체
- 최소 및 최대 나이
- 이름 길이의 경계값
- Emoji가 포함된 이름
- 잘못된 Primitive Type
- 필수 필드 누락
- 허용되지 않은 Role
- 배열 안의 잘못된 타입
- Root의 추가 속성
- 중첩 객체의 추가 속성
일곱 검증기는 같은 Fixture를 처리했다.
결과는 다음과 같았다.
35개 Fixture × 7개 검증기 = 245개 검사 모두 통과
따라서 일반적인 JSON 형태의 입력에서는 같은 규칙을 구현했다고 볼 수 있다.
하지만 이는 JSON으로 표현할 수 있는 데이터에 한정된 결과다.
JavaScript에는 JSON에서 표현할 수 없는 값도 존재한다.
예를 들어 다음과 같은 값이다.
- Symbol Key
- Sparse Array
- Prototype을 통해 상속한 속성
undefined- Getter
- Non-enumerable Property
이러한 입력에서는 실제 차이가 나타났다.
Exact Object의 의미가 달랐다
저자는 정의하지 않은 추가 속성을 거부하는 조건을 구현했다.
하지만 실제로 어떤 속성을 추가 속성으로 취급할지는 라이브러리마다 달랐다.
예를 들어 다음과 같은 경우다.
{ ...user, extra: undefined }
일부 검증기는 이를 추가 속성으로 판단해 거부했다.
반면 typia는 허용했다.
또한 Symbol Key를 가진 객체에서도 결과가 달랐다.
| 입력 | 일부 검증 결과 |
|---|---|
| 배열에 빈 Slot 존재 | 허용하는 검증기와 거부하는 검증기가 존재 |
| Prototype에서 필수 필드 상속 | 일부는 허용, 일부는 거부 |
| 추가 Symbol 속성 | 일부는 허용, 일부는 거부 |
값이 undefined인 추가 속성 | 대부분 거부, 일부 허용 |
이러한 차이는 반드시 특정 라이브러리의 버그라는 의미는 아니다.
어떤 속성을 검증 대상으로 간주하는지에 대한 정의가 서로 다르기 때문이다.
예를 들어 Object.keys()는 일반적으로 Enumerable String Key를 반환한다.
Symbol Key나 Non-enumerable Property는 같은 방식으로 확인되지 않는다.
따라서 직접 작성한 Type Guard도 검사 방법에 따라 특정 속성을 놓칠 수 있다.
이번 글은 TypeScript의 타입 정의와 실제 JavaScript Runtime에서 검증해야 하는 데이터의 범위를 구분해야 한다는 점을 보여준다.
번들 크기 비교
저자는 각 검증기를 독립적인 Browser Bundle로 생성했다.
모든 Bundle에는 동일한 esbuild 설정을 사용했다.
주요 조건은 다음과 같다.
- Browser Target
- ESM
- ES2022
- Minification
- Tree Shaking
- Legal Comment 제거
그리고 각 Bundle의 Gzip 크기를 측정했다.
결과는 다음과 같았다.
| 검증 방식 | Gzip 크기 |
|---|---|
| 직접 작성한 Type Guard | 337B |
| typia | 403B |
| is-kit | 1,530B |
| Valibot | 1,853B |
| Runtypes | 5,188B |
| Zod | 26,212B |
| ArkType | 47,603B |
가장 작은 것은 직접 작성한 Type Guard였다.
typia도 매우 작은 결과를 생성했다.
반면 Zod와 ArkType은 상대적으로 큰 Bundle을 생성했다.
하지만 이 수치를 해석할 때는 중요한 제한사항이 있다.
첫 번째는 각 검증기의 독립적인 번들 크기를 측정했다는 점이다.
예를 들어 기존 애플리케이션에서 이미 Zod를 사용하고 있다면 새로운 Schema 하나를 추가할 때마다 26KB가 증가하는 것은 아니다.
두 번째는 일반 Zod를 측정했다는 점이다.
Zod Mini는 비교 대상에 포함되지 않았다.
따라서 이번 결과만으로 Valibot과 Zod Mini의 크기를 비교할 수 없다.
세 번째는 typia의 실행 방식이다.
typia는 TypeScript 타입에서 실제 검증 코드를 생성한다.
따라서 Runtime Bundle은 작지만 빌드 과정에 별도의 Transformer가 필요하다.
즉 실행 시점의 Bundle 크기와 개발 도구의 복잡성은 별도의 문제다.
런타임 속도 비교
저자는 검증기의 실행 시간도 측정했다.
각 검증기를 별도 Node.js Process에서 실행했다.
테스트 조건은 다음과 같다.
- 20,000회 Warm-up
- 9개 측정 Sample
- Sample당 50,000회 검증
- Schema 생성 시간은 측정에서 제외
정상적인 User 객체를 검사한 결과는 다음과 같았다.
| 검증 방식 | 정상 객체 검증 중앙값 |
|---|---|
| typia | 21.9ns |
| 직접 작성한 Type Guard | 79.9ns |
| is-kit | 232.0ns |
| ArkType | 291.5ns |
| Zod | 612.6ns |
| Valibot | 951.2ns |
| Runtypes | 5,804.0ns |
이번 실험에서는 typia가 가장 빠른 결과를 보였다.
하지만 저자는 이 결과만으로 typia가 모든 환경에서 가장 빠른 검증기라고 판단해서는 안 된다고 설명한다.
특히 일부 잘못된 입력에서는 약 2ns라는 매우 짧은 실행 시간이 측정됐다.
이는 동일한 형태의 객체를 수많은 횟수로 반복해서 검사하는 과정에서 V8이 강하게 최적화했을 가능성이 있다.
실제 웹 애플리케이션에서는 입력 객체의 구조가 매번 다를 수 있다.
따라서 이번 Microbenchmark의 결과를 일반적인 API 요청 처리 시간으로 직접 환산해서는 안 된다.
구조화된 오류와 데이터 변환
이번 글은 런타임 속도보다 검증 결과를 어떻게 사용하는지도 중요하다고 설명한다.
예를 들어 직접 작성한 Type Guard는 일반적으로 Boolean을 반환한다.
입력이 올바른지 아닌지는 확인할 수 있다.
하지만 어느 필드가 잘못됐는지 자세하게 설명하려면 추가 구현이 필요하다.
반면 Zod와 Valibot 같은 Schema 라이브러리는 구조화된 오류 정보를 제공할 수 있다.
예를 들어 다음과 같은 정보를 얻을 수 있다.
- 오류가 발생한 필드
- 기대한 타입
- 실제 입력값
- 검증에 실패한 이유
또한 일부 라이브러리는 Parsing이나 Transform, Schema Metadata, 다른 도구와의 통합을 제공한다.
따라서 런타임 실행 속도가 가장 빠른 검증기가 모든 프로젝트에 가장 적합한 것은 아니다.
예를 들어 복잡한 Form에서 사용자에게 필드별 오류 메시지를 표시해야 한다면 구조화된 오류 정보가 중요할 수 있다.
반면 단순히 외부 데이터의 특정 속성이 문자열인지 확인하는 작업이라면 작은 Type Guard가 충분할 수 있다.
실제 선택에서의 트레이드오프
이번 실험을 통해 구분할 수 있는 선택 기준은 다음과 같다.
직접 작성한 Type Guard는 단순한 데이터 경계에서 추가 의존성을 줄일 수 있다.
is-kit은 여러 작은 Guard를 조합하는 방식에 적합하다.
Valibot은 상대적으로 작은 Schema 기반 구현을 제공한다.
Zod는 풍부한 생태계와 여러 도구의 통합을 제공한다.
ArkType은 TypeScript와 비슷한 표현으로 검증 규칙을 작성할 수 있다.
typia는 빌드 시점에 검증 코드를 생성해 작은 Runtime Bundle을 제공할 수 있다.
그러나 typia의 경우 빌드 도구 통합이 필요하다.
또한 TypeScript Compiler와의 호환성을 관리해야 할 수 있다.
이번 글은 검증 라이브러리의 성능을 비교하기 전에 각 라이브러리가 실제로 같은 데이터 규칙을 검증하는지 확인해야 한다는 점을 공개된 Fixture와 Benchmark로 설명한다.
Zoneless Angular 22 in production: error reporting, the back button, and a service worker myth
- 저자: Andreas Mattes
- 매체: DEV Community
- 게시일: 2026-10-30
- 원문: https://dev.to/andimatt/zoneless-angular-22-in-production-error-reporting-the-back-button-and-a-service-worker-myth-1ngm
Angular 22의 Zoneless Change Detection을 사용하는 실제 PWA에서 오류 보고와 Service Worker 등록, 모바일 Back Gesture, 동적 컴포넌트 렌더링 과정에서 발견한 문제를 설명한 기술 글이다.
이번 글의 특징은 문제의 원인을 반드시 Zoneless에서 찾지 않는다는 점이다.
저자는 처음에 몇 가지 오류를 모두 Zoneless의 문제로 분류했다.
하지만 실제 Angular 소스코드를 확인하자 일부 원인은 다른 곳에 있었다.
특히 Service Worker 등록 방식에 관해 자신이 작성한 주석이 잘못됐다는 사실을 발견했다.
실제 애플리케이션 구조
저자는 음료 섭취량을 기록하는 PWA를 개발하고 있다.
사용한 기술은 다음과 같다.
- Angular 22
- Signals
- Zoneless Change Detection
- Angular Service Worker
- Sentry
Zoneless Change Detection은 Angular가 Zone.js에 의존하지 않고 변경 감지를 수행하도록 하는 방식이다.
기존에는 Zone.js가 여러 비동기 작업을 추적하는 데 사용됐다.
반면 Zoneless에서는 Signals와 명시적인 변경 알림 등을 이용해 UI를 갱신한다.
저자는 다음 Provider를 사용한다.
provideZonelessChangeDetection()
그러나 Zoneless를 활성화했다고 해서 브라우저의 모든 이벤트와 Promise가 Angular의 오류 처리 시스템에 자동으로 연결되는 것은 아니다.
처리되지 않은 HTTP Promise 오류
첫 번째 문제는 HTTP 요청이 실패했을 때 발생했다.
예를 들어 Angular의 HttpClient를 이용해 API를 호출한다고 가정해 보자.
요청이 HTTP 503을 반환한다.
하지만 해당 요청의 Promise Rejection을 코드에서 처리하지 않았다.
이 경우 브라우저에서는 다음 이벤트가 발생할 수 있다.
unhandledrejection
문제는 이러한 오류가 Angular의 ErrorHandler를 거치지 않을 수 있다는 점이었다.
저자의 기존 구성에서는 Sentry가 브라우저의 전역 오류 이벤트를 직접 처리하고 있었다.
하지만 HttpErrorResponse는 일반적인 JavaScript Error와 다른 구조를 가진다.
그 결과 Sentry에 다음과 비슷한 오류 제목이 기록됐다.
Object captured as promise rejection with keys...
이러한 메시지는 실제 HTTP 상태 코드와 요청 URL을 이해하기 어렵게 만든다.
따라서 같은 종류의 오류를 그룹화하거나 검색하기도 어려웠다.
Sentry beforeSend에서 오류 정규화
저자는 Sentry의 beforeSend를 이용해 오류 정보를 변경했다.
beforeSend는 Sentry에 실제 이벤트가 전송되기 전에 해당 정보를 검토하거나 수정할 수 있는 Hook이다.
저자는 원래 Exception을 확인한다.
그리고 HTTP 오류 형태인지 판단한다.
HTTP 오류라면 기존에 사용하는 오류 정규화 함수를 호출한다.
이후 다음 정보를 수정한다.
- 오류의 Type
- 오류 메시지
- 관련 Extra 정보
예를 들어 기존의 모호한 오류 제목을 다음과 같은 형태로 변경할 수 있다.
HttpError: Http failure response ... 503 Service Unavailable
이렇게 하면 실제 장애를 분석할 때 HTTP 오류의 종류를 더 쉽게 구분할 수 있다.
하지만 이러한 방식에는 추가적인 유지보수 비용이 존재한다.
Sentry의 이벤트 구조와 Angular의 오류 구조를 연결하는 로직을 직접 관리해야 하기 때문이다.
Angular에 이미 존재했던 대안
저자는 이후 Angular 소스코드를 확인하면서 중요한 사실을 발견했다.
Angular에는 이미 다음 기능이 존재했다.
provideBrowserGlobalErrorListeners()
이 기능은 Angular 20부터 제공됐다.
브라우저의 다음 전역 이벤트를 Angular의 ErrorHandler에 연결한다.
errorunhandledrejection
따라서 새 프로젝트라면 이 기능을 먼저 검토할 수 있다.
저자는 아직 자신의 프로젝트에서 해당 Provider로 완전히 변경하지 않았다고 설명한다.
이유는 Sentry의 Browser SDK도 전역 오류 Handler를 등록하기 때문이다.
두 시스템을 함께 사용하면 같은 오류가 중복 보고될 수 있는지 확인해야 한다.
따라서 실제 운영 프로젝트에서는 오류 처리 경로가 여러 개 존재하는지 검증할 필요가 있다.
Service Worker 등록에 관한 잘못된 가정
두 번째 문제는 Service Worker 등록 방식이었다.
저자는 기존에 다음 설정을 사용하고 있었다.
registrationStrategy: "registerImmediately"
이유는 다음과 같은 가정 때문이었다.
Zoneless 환경에서는 Angular의 안정화 상태가 발생하지 않는다.
따라서 registerWhenStable은 항상 제한 시간까지 기다린다.
그러나 실제 Angular 소스코드를 확인한 결과 이 가정은 잘못됐다.
Zoneless 환경에서도 Angular는 PendingTasks를 추적한다.
그리고 다음 메서드가 안정화 상태를 기다리는 데 사용될 수 있다.
appRef.whenStable()
따라서 Zoneless 환경이라고 해서 registerWhenStable이 무조건 제한 시간까지 기다리는 것은 아니다.
저자는 이전에 작성한 주석이 잘못됐다는 사실을 인정했다.
이는 프레임워크의 내부 동작을 추측해서 문서화하는 것보다 실제 구현을 확인하는 것이 중요하다는 사례다.
첫 화면 표시 이후 Service Worker 등록
저자는 registerImmediately도 최종적으로 사용하지 않았다.
Service Worker가 등록되면 오프라인 사용을 위한 파일을 다운로드할 수 있다.
문제는 첫 화면을 표시하는 과정과 오프라인 리소스 다운로드가 동시에 발생할 수 있다는 점이다.
예를 들어 사용자가 앱을 처음 설치했다고 가정해 보자.
브라우저는 HTML과 JavaScript를 가져와 화면을 표시해야 한다.
그런데 Service Worker도 오프라인 파일을 다운로드하기 시작한다.
이 경우 두 작업이 네트워크와 리소스를 경쟁할 수 있다.
저자는 이를 고려해 Custom Registration Strategy를 구현했다.
핵심 정책은 다음과 같다.
첫 화면이 렌더링된 이후 Service Worker를 등록하되, 최대 10초 안에는 등록을 시작한다.
Angular는 registrationStrategy에 Observable을 반환하는 함수를 사용할 수 있다.
저자는 이를 활용했다.
하지만 이 방식 역시 모든 PWA에 최적이라는 의미는 아니다.
오프라인 사용이 매우 중요한 서비스에서는 Service Worker를 가능한 한 빨리 등록하는 것이 더 적합할 수 있다.
반면 처음 화면을 빠르게 표시하는 것이 우선이라면 일부 다운로드를 지연하는 편이 유리할 수 있다.
동적으로 생성한 Bottom Sheet의 첫 렌더링
세 번째 문제는 Bottom Sheet였다.
Bottom Sheet는 화면 아래쪽에서 나타나는 UI다.
모바일 애플리케이션에서 메뉴나 선택 화면을 표시할 때 자주 사용된다.
저자는 Bottom Sheet를 동적으로 생성했다.
이를 위해 다음 작업을 수행한다.
createComponent()로 컴포넌트 생성ApplicationRef에 View 연결- 생성한 요소를
document.body에 추가 - 컴포넌트를 실제로 렌더링
- Open Signal 활성화
문제는 Open Animation이 내부 Template Element에 의존했다는 것이다.
예를 들어 #content로 참조하는 요소가 아직 생성되지 않았다면 Animation이 정상적으로 실행되지 않을 수 있다.
저자는 이를 해결하기 위해 다음 메서드를 사용했다.
changeDetectorRef.detectChanges()
먼저 한 번 렌더링해 필요한 Template Element를 생성한다.
그다음 Open Signal을 활성화한다.
이렇게 하면 Animation이 실제 DOM 요소를 찾을 수 있다.
하지만 저자는 이것이 Zoneless만의 문제인지는 확신하지 못한다고 설명한다.
동적으로 생성한 컴포넌트의 첫 렌더링 시점과 Effect 실행 순서에 관한 일반적인 문제일 수도 있기 때문이다.
모바일 Back Gesture 처리
이번 글에서 가장 복잡한 문제는 실제로 Zoneless와 관계없는 모바일 Navigation이었다.
사용자는 Android에서 시스템 Back Gesture를 사용할 수 있다.
예를 들어 화면 아래쪽에 Bottom Sheet가 열려 있다고 가정해 보자.
이때 사용자가 Back Gesture를 수행하면 기대하는 동작은 Bottom Sheet를 닫는 것이다.
하지만 브라우저의 기본 History가 동작하면 이전 페이지로 이동할 수 있다.
저자는 이를 해결하기 위해 Bottom Sheet를 열 때 History Entry를 추가했다.
대표적인 API는 다음과 같다.
history.pushState()
추가하는 Entry에는 해당 Bottom Sheet를 식별하는 Sentinel ID를 포함했다.
이후 popstate 이벤트가 발생하면 현재 History Entry와 Bottom Sheet의 ID를 비교한다.
적절한 상황에서는 페이지를 이동하는 대신 Bottom Sheet를 닫는다.
RouterLink와 History가 충돌한 문제
저자는 이 구현에서 세 가지 Edge Case를 발견했다.
첫 번째는 Bottom Sheet 안의 RouterLink다.
사용자가 링크를 클릭하면 새로운 페이지로 이동해야 한다.
동시에 현재 Bottom Sheet는 닫혀야 한다.
하지만 Bottom Sheet가 닫히면서 동기적으로 history.back()을 호출하면 문제가 발생할 수 있었다.
Router가 새로운 페이지로 이동하는 도중 이전 History Entry로 이동하는 이벤트가 발생하기 때문이다.
그 결과 정상적인 Forward Navigation이 취소될 수 있었다.
저자는 이를 해결하기 위해 새로운 Navigation이 완료되는 시점을 기다리도록 변경했다.
새로운 Route가 정상적으로 적용됐다면 Bottom Sheet의 Sentinel도 해당 History 흐름에서 더 이상 필요하지 않을 수 있다.
두 개의 Bottom Sheet가 교체되는 상황
두 번째 문제는 하나의 Bottom Sheet가 닫히면서 다른 Bottom Sheet가 열리는 상황이었다.
기존 Bottom Sheet는 history.back()을 호출한다.
하지만 새 Bottom Sheet가 자신의 History Entry를 추가하기 전에 Back Navigation이 발생할 수 있다.
이 경우 새로운 Bottom Sheet까지 즉시 닫힐 수 있었다.
저자는 이를 해결하기 위해 Back 작업을 Microtask로 지연했다.
이렇게 하면 새 Bottom Sheet가 History Entry를 먼저 추가할 기회를 얻는다.
이후 현재 History 상태를 다시 확인할 수 있다.
아직 해결하지 못한 문제
세 번째 문제는 두 개의 Bottom Sheet가 거의 동시에 닫히는 상황이었다.
history.back()은 비동기적으로 처리된다.
따라서 첫 번째 Bottom Sheet의 Back 작업이 완료되기 전에 두 번째 Bottom Sheet도 Back을 요청할 수 있다.
그 결과 History Stack에 불필요한 Entry가 남을 수 있었다.
사용자는 이후 Back Gesture를 수행했지만 화면에는 아무런 변화가 없는 상황을 경험할 수 있었다.
저자는 이 문제를 아직 해결하지 못했다고 명시했다.
향후 여러 Back 요청을 하나로 합쳐 다음 방식으로 처리하는 방법을 검토하고 있다.
history.go(-n)
이 부분은 실제 구현이 완료된 해결책이 아니라 앞으로 적용할 수 있는 설계 아이디어다.
운영상의 한계
이번 글은 실제 PWA에서 발견한 문제를 분석한 기록이다.
하지만 각 변경이 실제 렌더링 시간을 얼마나 개선했는지에 관한 정량적인 Benchmark는 제공하지 않는다.
또한 모든 문제를 Zoneless의 버그라고 판단하지 않는다.
실제로는 다음 세 문제가 서로 달랐다.
- 전역 Promise 오류의 보고 경로
- Service Worker의 등록 시점
- 모바일 History와 Overlay의 동시 상태 변경
이번 글은 프레임워크의 내부 동작을 잘못 이해하면 실제 문제의 원인을 다른 기술에 잘못 귀속시킬 수 있다는 점을 보여준다.
또한 Android의 Back Gesture를 웹 UI와 자연스럽게 연결하는 과정에서 브라우저 History API와 Router, 동적 Overlay 사이의 상호작용이 얼마나 복잡해질 수 있는지 구체적으로 설명한다.
Useful @custom-media Presets
- 저자: Daniel Schwarz
- 매체: Master.dev
- 게시일: 2026-10-28
- 원문: https://blog.master.dev/useful-custom-media-presets/
CSS의 @custom-media를 이용해 복잡한 Media Query를 이름으로 정의하고 재사용하는 방법을 설명한 기술 글이다.
저자는 일반적인 반응형 디자인의 한계에서 출발한다.
기존 웹 개발에서는 화면의 너비를 기준으로 모바일과 태블릿, 데스크톱을 구분하는 방식이 널리 사용됐다.
예를 들어 768px보다 작은 화면을 모바일로 취급하는 방식이다.
하지만 실제 기기에서는 화면 크기와 입력 방식이 항상 일치하지 않는다.
예를 들어 태블릿에 마우스와 키보드를 연결할 수 있다.
또한 터치스크린을 가진 노트북도 존재한다.
따라서 사용자의 실제 입력 방식에 따라 UI를 변경하려면 화면 크기 외의 조건도 고려해야 한다.
@custom-media의 구조
CSS의 Media Query는 다음과 같이 작성할 수 있다.
@media (hover: none)
이는 기본 입력 장치가 Hover를 지원하지 않는 환경을 나타낼 수 있다.
하지만 같은 조건을 여러 파일에서 사용한다면 동일한 Media Query를 반복해서 작성해야 한다.
@custom-media는 이러한 조건에 이름을 부여한다.
예를 들어 다음과 같다.
@custom-media --no-hover (hover: none);
이후 다음과 같이 사용할 수 있다.
@media (--no-hover)
이렇게 하면 복잡한 Media Query의 실제 조건을 하나의 위치에서 관리할 수 있다.
또한 여러 조건을 조합할 수 있다.
예를 들어 Hover가 불가능하고 화면이 가로 방향인 경우를 하나의 이름으로 정의할 수 있다.
하지만 중요한 제한사항이 있다.
이번 글이 게시된 시점에는 @custom-media가 모든 주요 브라우저에서 기본적으로 지원되지 않는다.
저자는 Firefox에서 관련 Flag를 활성화해야 사용할 수 있다고 설명한다.
Chrome과 Safari에서는 일반적인 안정 기능으로 사용할 수 없는 상태였다.
따라서 실제 운영 환경에서는 Fallback 또는 빌드 도구를 함께 사용해야 한다.
화면 크기와 입력 장치의 분리
저자는 다음 Media Feature를 조합한다.
hoverpointerany-hoverany-pointer
예를 들어 다음 조건을 생각할 수 있다.
(hover: hover) and (pointer: fine)
이 조건은 기본 입력 장치가 Hover와 정밀한 Pointer를 지원하는 상황을 나타낸다.
일반적인 마우스가 대표적인 사례다.
반면 다음 조건을 사용할 수 있다.
(hover: none) and (pointer: coarse)
이는 일반적인 터치 기반 입력 환경을 나타내는 데 사용할 수 있다.
저자는 이러한 조건에 다음 이름을 부여했다.
| 이름 | 대표적인 조건 |
|---|---|
--mouse | Hover 가능, 정밀한 Pointer |
--touch | Hover 불가, 거친 Pointer |
--stylus | Hover 불가, 정밀한 Pointer |
--air | Hover 가능, 거친 Pointer |
--dpad | Hover 불가, Pointer 없음 |
하지만 이 분류는 실제 장치의 종류를 완벽하게 식별하는 것은 아니다.
예를 들어 pointer: fine이라는 결과만으로 실제 장치가 반드시 마우스라고 판단할 수는 없다.
Stylus처럼 정밀한 입력을 제공하는 다른 장치도 존재하기 때문이다.
따라서 Media Query는 장치의 이름보다 실제 제공하는 입력 특성을 기준으로 해석해야 한다.
any-pointer의 함정
이번 글에서 중요한 부분은 any-pointer와 any-hover를 조합하는 방법이다.
예를 들어 태블릿에 마우스를 연결한 상황을 생각할 수 있다.
태블릿은 터치 입력을 제공한다.
추가로 마우스를 연결하면 Hover와 정밀한 Pointer도 사용할 수 있다.
이 경우 다음 조건을 사용할 수 있다.
(any-hover: hover) and (any-pointer: fine)
하지만 이 조건이 참이라는 사실만으로 동일한 하나의 입력 장치가 두 특성을 모두 제공한다고 보장할 수는 없다.
예를 들어 하나의 장치가 Hover를 지원하고 다른 장치가 정밀한 Pointer를 제공할 수도 있다.
any-hover와 any-pointer는 각각 전체 입력 장치 가운데 해당 기능이 존재하는지 확인하기 때문이다.
따라서 사용자가 지금 실제로 어떤 장치를 사용하고 있는지까지 알 수 있는 것은 아니다.
이러한 한계는 특히 마우스와 터치를 모두 지원하는 기기에서 중요하다.
Container Style Query를 이용한 대체 구현
현재 모든 브라우저가 @custom-media를 지원하지 않으므로 저자는 대체 방법을 제시한다.
핵심은 CSS Custom Property와 Container Style Query를 결합하는 것이다.
먼저 :root에서 일반적인 Media Query를 실행한다.
조건이 만족되면 특정 CSS Custom Property를 설정한다.
예를 들어 터치 환경이라면 다음 값을 지정할 수 있다.
--touch: true
이후 하위 요소에서는 다음과 같은 Style Query를 사용할 수 있다.
@container style(--touch)
이렇게 하면 실제 Media Query의 조건을 Custom Property에 저장하고, 하위 요소에서 해당 결과를 재사용할 수 있다.
하지만 이 방법에도 제한사항이 존재한다.
첫 번째는 :root 자체를 해당 Container Style Query로 스타일링할 수 없다는 점이다.
:root는 최상위 요소이므로 자신을 포함하는 상위 Container가 존재하지 않기 때문이다.
두 번째는 Container Query와 일반적인 Media Query의 조건을 하나의 표현식 안에서 자유롭게 조합할 수 없다는 점이다.
따라서 복잡한 조합에는 추가적인 Custom Property나 중첩된 규칙이 필요할 수 있다.
저자는 이러한 방식이 실제로 동작하더라도 지나치게 많은 CSS 변수를 관리하는 구조는 복잡해질 수 있다고 설명한다.
Breakpoint 재사용
저자는 화면 크기 관련 조건도 @custom-media로 정의한다.
예를 들어 다음과 같다.
@custom-media --from-760 (width >= 760px);
이후 다음처럼 사용할 수 있다.
@media (--from-760)
저자는 360px부터 1560px까지 약 100px 간격으로 여러 Breakpoint를 정의했다.
이러한 방식은 화면의 크기를 모바일·태블릿·데스크톱 세 구간으로 단순하게 나누는 방식과 다르다.
실제 Layout이 변경돼야 하는 시점을 더 세밀하게 표현할 수 있다.
하지만 모든 프로젝트에서 100px 간격의 Breakpoint가 필요하다는 의미는 아니다.
실제 콘텐츠가 어느 시점에서 줄바꿈되거나 Layout이 깨지는지 확인하는 것이 중요하다.
범위 조건의 재사용
저자는 최소값과 최대값을 별도로 정의하는 방식도 소개한다.
예를 들어 다음과 같다.
--from-360
--to-659
이후 두 조건을 조합해 특정 범위 안에서만 스타일을 적용할 수 있다.
이는 하나의 고정된 범위 이름을 정의하는 것보다 조합의 유연성이 높다.
반면 이름과 조건이 많아지면 실제 디자인 시스템에서 유지보수해야 할 설정도 증가한다.
따라서 단순히 Media Query의 문자열을 줄이기 위해 너무 많은 추상화를 도입하지 않도록 고려해야 한다.
HDR 디스플레이 감지
저자는 HDR 콘텐츠를 위한 Media Query도 제시한다.
HDR은 High Dynamic Range를 의미한다.
일반적인 SDR보다 더 넓은 밝기 범위를 표현할 수 있는 기술이다.
하지만 HDR 콘텐츠를 적절하게 표현하려면 디스플레이의 지원 범위를 확인해야 한다.
저자는 다음 조건을 조합한다.
(dynamic-range: high) and (color-gamut: p3)
그리고 이를 다음 이름으로 정의한다.
--true-hdr
이렇게 하면 HDR과 넓은 색 영역을 지원하는 환경에서 별도의 이미지나 스타일을 제공할 수 있다.
하지만 해당 조건이 참이라는 사실만으로 실제 이미지가 항상 의도한 밝기와 색상을 표현한다는 보장은 없다.
브라우저와 운영체제의 Color Management, 실제 미디어 파일의 형식도 영향을 준다.
브라우저 지원과 빌드 도구
현재는 @custom-media의 기본 지원 범위가 제한적이다.
따라서 저자는 PostCSS Plugin을 이용하는 방법도 소개한다.
빌드 과정에서 @custom-media 규칙을 일반적인 Media Query로 변환할 수 있다.
이렇게 하면 개발자는 재사용 가능한 이름으로 조건을 작성하면서 실제 배포 결과물에는 기존 브라우저가 이해할 수 있는 CSS를 생성할 수 있다.
하지만 빌드 도구가 이러한 변환을 수행한다면 해당 도구의 설정을 계속 관리해야 한다.
또한 새로운 CSS 문법을 지원하는 시점에는 실제 브라우저의 지원 범위를 다시 확인해야 한다.
이번 글은 반응형 디자인을 화면 너비 중심의 단순한 분류에서 벗어나 실제 입력 장치와 사용자 환경의 특성을 기준으로 구성하는 방법을 구체적인 CSS 예제로 설명한다.
특히 아직 모든 브라우저가 지원하지 않는 기능을 현재 프로젝트에서 어떻게 사용할 수 있는지와 그 과정에서 발생하는 유지보수상의 트레이드오프를 함께 다룬다.
Neon Realtime vs Supabase Realtime
- 저자: kuberdenis
- 매체: DEV Community
- 게시일: 2026-10-30
- 실제 테스트일: 2026-10-28
- 원문: https://dev.to/kubeden/neon-realtime-vs-supabase-realtime-410g
Neon Realtime과 Supabase Realtime을 비교하면서 PostgreSQL의 데이터가 변경된 뒤 브라우저가 새로운 결과를 확인하기까지 얼마나 시간이 걸리는지 직접 측정한 기술 글이다.
이번 글은 제품의 공식 성능 발표를 그대로 정리한 자료가 아니다.
저자가 직접 테스트 환경을 구성하고 데이터를 변경하면서 지연 시간을 측정했다.
또한 단순한 행 변경 이벤트와 Join, Aggregate, Top 10 Query의 실시간 결과 갱신을 구분했다.
특히 실시간 데이터베이스가 변경된 행만 전달하는 방식과 실제 SQL Query의 전체 결과를 유지하는 방식 사이에 어떤 차이가 있는지를 보여준다.
비교한 다섯 가지 방법
저자는 다음 다섯 가지 방식을 비교했다.
| 방식 | 데이터 갱신 방법 |
|---|---|
| Neon Realtime | SQL Query 결과 구독 |
| Supabase Broadcast | PostgreSQL Trigger에서 변경 이벤트 발행 |
| Supabase Postgres Changes | 테이블 변경 이벤트 구독 |
| PostgreSQL LISTEN/NOTIFY | 알림을 받은 뒤 Query 재실행 |
| 1초 Polling | 1초마다 Query 재실행 |
예를 들어 사용자가 실시간 순위표를 보는 상황을 생각해 보자.
데이터베이스에 플레이어의 점수가 저장돼 있다.
어떤 플레이어의 점수가 변경되면 브라우저의 순위표도 갱신돼야 한다.
단순한 구현에서는 1초마다 API를 다시 호출할 수 있다.
하지만 변경이 없어도 계속 요청이 발생한다.
반면 변경 이벤트를 전달하는 방식에서는 데이터가 실제로 변경됐을 때만 알림을 받을 수 있다.
그러나 변경 이벤트를 받았다고 해서 실제 순위표의 새로운 결과를 자동으로 알 수 있는 것은 아니다.
순위표는 여러 행의 데이터를 정렬하고 제한한 결과이기 때문이다.
따라서 일부 방식에서는 변경 이벤트를 받은 이후 SQL Query를 다시 실행해야 한다.
Neon Realtime의 SQL Query 구독
Neon Realtime에서는 SQL Query 자체를 구독할 수 있다.
예를 들어 다음과 같은 Query를 생각할 수 있다.
SELECT id, name, score FROM players WHERE team_id = $1
서버는 해당 Query를 승인한 뒤 Sealed Token을 생성한다.
브라우저는 해당 Token을 이용해 Realtime에 연결한다.
이후 Query 결과에 영향을 주는 데이터가 변경되면 새로운 결과를 전달받을 수 있다.
이 방식의 장점은 단순한 행 변경 이벤트뿐 아니라 Query 결과를 기준으로 동작한다는 점이다.
예를 들어 Join과 Aggregate, Top 10 같은 Query도 지원한다.
하지만 실제로 어떤 SQL 표현을 사용할 수 있는지는 제한된다.
이번 Beta에서는 읽기 전용이면서 결정적인 Query만 지원한다.
예를 들어 다음 함수는 허용되지 않는다.
now()gen_random_uuid()
이러한 함수는 실행 시점마다 결과가 달라질 수 있기 때문이다.
또한 모든 테이블에 Primary Key가 필요하다.
테스트 환경
저자는 다음 환경을 사용했다.
| 항목 | Neon | Supabase |
|---|---|---|
| 지역 | Frankfurt | Frankfurt |
| 데이터베이스 | PostgreSQL 18.6 | PostgreSQL 17.11 |
| 인스턴스 | 0.25 CU | Nano |
| 메모리 | 약 1GB | 약 0.5GB |
| 데이터 크기 | 1만 및 100만 행 | 1만 및 100만 행 |
클라이언트에서는 데이터베이스까지 약 40ms의 왕복 네트워크 지연이 존재했다.
따라서 측정 결과에는 네트워크 지연이 포함된다.
실제 성능은 동일한 지역에서 실행되는 서버와 연결하는 경우 달라질 수 있다.
또한 두 플랫폼의 인스턴스 사양이 동일하지 않다.
Supabase의 Nano는 Neon보다 메모리가 적고 공유 CPU를 사용한다.
따라서 Query를 다시 실행하는 작업의 성능 차이에는 인스턴스 사양의 차이도 영향을 줄 수 있다.
측정 방법
데이터베이스에는 1,000개의 Team을 구성했다.
그리고 Player의 Score를 변경했다.
테스트 대상 Query는 다음 네 종류다.
- 특정 Team의 Player 목록
- Player와 Team을 Join한 결과
- Team별 Aggregate
- 전체 Player의 Top 10
각 조합에서는 다섯 번의 Warm-up 이후 100번의 쓰기 작업을 수행했다.
각 쓰기 사이에는 250~750ms의 무작위 대기 시간을 두었다.
실제 측정한 구간은 다음과 같다.
데이터베이스에 UPDATE 요청 전송 → 구독자가 변경된 결과를 확인
따라서 단순한 WebSocket 이벤트 전달 시간만 측정한 것은 아니다.
Join이나 Aggregate에서 추가 Query가 필요하다면 해당 시간도 포함했다.
1만 행에서의 결과
저자가 측정한 P50 지연 시간은 다음과 같다.
| 방식 | Filter | Join | Aggregate | Top 10 |
|---|---|---|---|---|
| Neon Realtime | 40ms | 40ms | 44ms | 42ms |
| Supabase Broadcast | 58ms | 110ms | 125ms | 108ms |
| LISTEN/NOTIFY | 84ms | 83ms | 128ms | 85ms |
| Supabase Postgres Changes | 371ms | 397ms | 418ms | 368ms |
| 1초 Polling | 484ms | 534ms | 546ms | 498ms |
이번 실험에서는 Neon Realtime이 가장 낮은 지연 시간을 기록했다.
하지만 이 결과는 하나의 Writer와 하나의 Subscriber를 사용하는 환경에서 측정됐다.
많은 사용자가 동시에 같은 Query를 구독하는 상황까지 평가한 것은 아니다.
또한 모든 플랫폼의 리소스가 동일하지 않으므로 결과를 절대적인 제품 성능 순위로 해석해서는 안 된다.
Supabase Broadcast의 특징
Supabase Broadcast에서는 PostgreSQL Trigger가 변경 이벤트를 발행할 수 있다.
브라우저는 해당 이벤트를 수신한다.
단순한 행 변경이라면 이벤트에 포함된 데이터를 이용해 화면을 갱신할 수 있다.
하지만 Join이나 Aggregate 결과를 새로 계산해야 한다면 추가 Query가 필요할 수 있다.
저자의 실험에서는 Broadcast 이벤트가 약 57ms 뒤에 도착했다.
이후 REST Query를 실행하는 경우 추가적인 시간이 필요했다.
따라서 단순한 Filter보다 Join과 Aggregate에서 지연 시간이 증가했다.
이러한 구조에서는 실제로 어떤 Query를 다시 실행해야 하는지 클라이언트가 관리해야 한다.
반면 변경 이벤트를 이용해 로컬 상태를 직접 갱신할 수 있다면 전체 Query를 다시 실행하지 않아도 될 수 있다.
따라서 실시간 데이터의 구조와 클라이언트가 유지하는 상태에 따라 적합한 방법이 달라진다.
100만 행에서의 결과
데이터를 100만 행으로 늘리면 차이가 더 크게 나타났다.
| 방식 | Filter | Join | Aggregate | Top 10 |
|---|---|---|---|---|
| Neon Realtime | 44ms | 41ms | 179ms | 46ms |
| Supabase Broadcast | 60ms | 115ms | 793ms | 110ms |
| LISTEN/NOTIFY | 159ms | 145ms | 244ms | 70ms |
| Supabase Postgres Changes | 328ms | 418ms | 1,129ms | 434ms |
| 1초 Polling | 542ms | 495ms | 517ms | 414ms |
Neon Realtime은 대부분의 Query에서 비교적 낮은 지연 시간을 유지했다.
하지만 Aggregate에서는 지연 시간이 증가했다.
이유는 데이터 변경에 따라 갱신해야 하는 결과의 크기가 커졌기 때문이다.
100만 행 Aggregate의 네트워크 비용
100만 행의 Aggregate에서는 Neon Realtime이 전체 Aggregate 결과를 다시 전송했다.
결과에는 약 1,000개의 그룹이 포함돼 있었다.
실제 전송 크기는 약 111KB였다.
반면 작은 Filter와 Join Query에서는 변경된 행만 전달해 약 0.4KB 수준의 데이터를 사용했다.
따라서 Neon Realtime이 모든 상황에서 항상 최소한의 데이터만 전송한다는 의미는 아니다.
복잡한 Aggregate에서는 전체 결과를 다시 전송할 수 있다.
이러한 경우에는 실시간 지연 시간과 네트워크 전송량 사이에 트레이드오프가 존재한다.
예를 들어 변경이 자주 발생하는 대규모 Aggregate를 수많은 사용자가 구독한다면 전송량이 크게 증가할 수 있다.
따라서 단일 사용자 환경의 지연 시간이 낮다는 사실만으로 대규모 Fan-out에서도 비용이 낮다고 판단할 수는 없다.
Scale-to-zero 검증
Neon에는 일정 시간 사용하지 않는 Compute를 중단하는 Scale-to-zero 기능이 존재한다.
일반적으로 데이터베이스에 계속 Query를 보내면 Compute가 활성 상태로 유지될 수 있다.
따라서 Realtime Subscription이 존재하는 경우에도 Compute가 중단될 수 있는지 확인할 필요가 있다.
저자는 실제로 Realtime Subscription을 열어 둔 상태에서 테스트했다.
결과는 다음과 같았다.
- 구독 연결을 유지한 상태에서도 약 5분 뒤 Compute 중단
- 이후 10분 동안 관찰했지만 Compute가 불필요하게 다시 활성화되지 않음
- 다음 쓰기 요청에서 Compute 재시작
- 재시작 이후 Realtime Subscription으로 변경 결과 전달
반면 일반적인 LISTEN 연결은 Compute 중단 이후 종료됐다.
따라서 다음 알림을 받으려면 다시 연결해야 했다.
이 결과는 열린 브라우저 탭 하나가 항상 데이터베이스의 Compute 비용을 발생시키는 것은 아니라는 점을 보여준다.
하지만 Scale-to-zero 상태에서 다시 활성화할 때는 Cold Start에 따른 대기 시간이 발생할 수 있다.
또한 이번 실험은 한 번의 구독 연결을 대상으로 했다.
인증과 권한의 제한
Neon Realtime은 아직 Beta다.
공식 문서에 따르면 다음 제한사항이 존재한다.
- 읽기 전용의 결정적인 Query만 지원
- 모든 테이블에 Primary Key 필요
- Row Level Security 지원 미완료
- 제한된 지역에서 제공
- Beta 기간의 프로토콜 변경 가능성
특히 RLS를 아직 지원하지 않는다는 점이 중요하다.
서버가 Query를 Sealing할 때 실제 사용자의 권한을 검증해야 한다.
예를 들어 다른 회사의 데이터를 조회하는 Query를 사용자가 임의로 생성할 수 없도록 구성해야 한다.
Sealed Token의 유효 기간은 60초다.
필요한 경우 클라이언트는 만료 전에 새로운 Token을 가져와야 한다.
React Binding은 이러한 갱신을 처리하는 기능을 제공한다.
하지만 Token을 자동으로 갱신한다는 사실만으로 원래 Query의 권한이 안전하게 검증됐다고 볼 수는 없다.
실제 서버의 인가 로직이 필요하다.
이번 Benchmark의 한계
저자는 다음 제한사항을 명시했다.
- Writer 한 개
- Subscriber 한 개
- 초당 약 1~2회의 쓰기
- 설정별 한 번의 실험
- 하나의 지역
- 서로 다른 데이터베이스 인스턴스 사양
따라서 많은 사용자가 동시에 같은 데이터에 접근하는 상황과 높은 쓰기 부하에서의 성능은 검증하지 않았다.
또한 Neon Realtime은 Beta이며 실제 프로토콜이 변경될 수 있다.
이번 글은 실시간 기능을 선택할 때 단순히 WebSocket의 존재 여부보다 실제 Query의 결과를 어떤 방식으로 유지하는지와 변경 이벤트 이후 추가 조회가 필요한지를 고려해야 한다는 점을 보여준다.
Bun 1.4.4's bun fmt checks Prettier's own repository about 280 times faster than Prettier
- 저자: Alex Georgiev
- 매체: DEV Community
- 게시일: 2026-10-30
- 원문: https://dev.to/alexgeorgiev17/bun-144s-bun-fmt-checks-prettiers-own-repository-about-280-times-faster-than-prettier-gjj
Bun 1.4.4의 새로운 Formatter인 bun fmt가 실제로 Prettier와 얼마나 비슷하게 동작하는지, 그리고 얼마나 빠른지 독립적으로 측정한 기술 글이다.
이번 글은 Bun 1.4.4의 출시 소식을 반복하는 자료가 아니다.
저자는 이미 공개된 기능을 실제 Repository에 적용해 공식 성능 주장과 호환성 수준을 확인했다.
특히 테스트 도중 발생한 오류를 처음에는 Bun의 버그로 의심했지만 실제로는 자신의 실행 방법에 문제가 있었다는 사실도 공개했다.
테스트 대상: Prettier의 자체 Repository
저자는 Prettier의 실제 소스코드를 가져와 테스트했다.
이 선택에는 이유가 있다.
Prettier는 자신의 코드베이스에서도 Prettier를 사용한다.
따라서 정상적인 Repository에서는 Prettier가 검사하는 코드가 이미 해당 Formatter의 규칙을 충족하고 있어야 한다.
저자는 다음 두 명령을 비교했다.
bun fmt --checkprettier --check
두 도구 모두 파일을 실제로 변경하는 대신 현재 파일이 올바르게 포맷팅돼 있는지 검사한다.
이렇게 하면 동일한 Repository를 기준으로 다음 두 사항을 확인할 수 있다.
- 포맷팅 결과에 관한 판단이 일치하는가?
- 검사에 필요한 시간이 얼마나 다른가?
실제 테스트 규모
Bun은 대상 Repository에서 2,623개 파일을 발견했다.
저자는 해당 파일을 대상으로 두 Formatter를 실행했다.
실제 결과는 다음과 같았다.
| 도구 | 실행 시간 | 최대 RSS 메모리 |
|---|---|---|
| Bun 첫 번째 실행 | 0.10초 | 48.3MB |
| Bun 두 번째 실행 | 0.13초 | 48.5MB |
| Bun 세 번째 실행 | 0.15초 | 48.7MB |
| Prettier 한 번 실행 | 36.77초 | 519.6MB |
두 Formatter는 검사한 파일에서 변경이 필요하지 않다고 판단했다.
즉 이 테스트에서는 두 도구의 검사 결과가 일치했다.
실행 시간의 차이도 매우 컸다.
Bun의 0.13초와 Prettier의 36.77초를 비교하면 약 283배다.
하지만 중요한 제한사항이 있다.
Prettier는 한 번만 측정했다.
따라서 36.77초가 여러 실행의 평균이나 중앙값이라는 의미는 아니다.
Bun은 세 번 측정했지만 Prettier는 반복 측정하지 못했다.
따라서 283배라는 결과는 이번 실행 환경에서 관찰한 비율이지 모든 프로젝트에 적용할 수 있는 일반적인 성능 보장은 아니다.
메모리 사용량
메모리 사용량에서도 큰 차이가 나타났다.
Bun은 약 48MB를 사용했다.
반면 Prettier는 약 520MB를 사용했다.
차이는 약 10배다.
이러한 차이는 메모리 제한이 작은 CI Runner에서 영향을 줄 수 있다.
예를 들어 CI에서 여러 작업을 병렬로 실행하는 경우다.
각 작업의 메모리 사용량이 크면 전체 Runner의 메모리가 부족해질 수 있다.
따라서 Formatter의 메모리 사용량을 줄이는 것은 단순히 실행 속도를 높이는 것과 다른 이점을 제공할 수 있다.
하지만 이번 측정이 모든 Formatter 설정과 Plugin 조합에서 동일한 메모리 차이를 보장하는 것은 아니다.
실제 사용하는 파일 종류와 설정에 따라 결과가 달라질 수 있다.
처음에는 두 도구 모두 실패했다
저자는 처음에 Prettier Repository를 다른 Working Directory에서 검사했다.
그리고 대상 Repository의 경로만 인자로 전달했다.
이 경우 .prettierignore 파일이 기대한 위치에서 적용되지 않았다.
그 결과 Prettier의 자체 테스트를 위해 의도적으로 만든 잘못된 설정 파일까지 검사 대상에 포함됐다.
해당 파일은 정상적인 JavaScript 파일이 아니다.
Prettier가 잘못된 외부 설정을 처리할 때 어떤 오류를 반환하는지 테스트하기 위한 Fixture다.
저자의 첫 번째 실행에서는 Bun이 해당 파일을 읽고 오류를 발생시켰다.
저자는 처음에 Bun의 설정 검색 기능에 버그가 있다고 생각했다.
하지만 같은 방식으로 Prettier를 실행하자 Prettier도 같은 문제를 경험했다.
즉 문제는 Formatter 자체보다 실행 환경의 Working Directory와 Ignore 설정이었다.
저자는 이를 수정한 뒤 다시 테스트했다.
Bun에는 다음 옵션을 사용했다.
--cwd
Prettier에는 다음 옵션을 사용했다.
--ignore-path
이렇게 하면 실제 Repository의 설정과 Ignore 파일을 올바르게 적용할 수 있다.
플러그인 호환성 문제
저자는 Bun이 Prettier와 호환된다고 설명하는 부분도 검증했다.
예를 들어 Prettier에는 다양한 Plugin이 존재한다.
대표적으로 다음과 같은 Plugin이다.
- Tailwind Class 정렬
package.jsonKey 정렬- Svelte 파일 포맷팅
- Import 정리
Bun은 일부 인기 Plugin의 동작을 자체적으로 구현한다.
이 경우 실제 Prettier Plugin을 설치하지 않아도 해당 기능을 제공할 수 있다.
하지만 이 방식에는 중요한 차이가 있다.
같은 Plugin 이름을 인식하더라도 실제로 실행하는 코드는 원래 Plugin이 아니라 Bun의 자체 구현일 수 있다.
설치되지 않은 Plugin을 Bun이 처리한 사례
저자는 테스트 프로젝트에서 다음 Plugin을 설정했다.
prettier-plugin-organize-imports
하지만 해당 Plugin을 실제로 설치하지 않았다.
Prettier는 오류를 반환했다.
실제 Plugin을 찾을 수 없기 때문이다.
반면 Bun은 정상적으로 파일을 포맷팅했다.
그리고 사용하지 않는 Import를 제거했다.
저자는 bun fmt --doctor를 확인했다.
그 결과 해당 Plugin 이름이 Bun의 Built-in Plugin 목록에 포함돼 있었다.
즉 Bun은 실제 Plugin이 설치돼 있는지 확인하는 대신 자신의 구현을 사용했다.
이러한 동작은 설치해야 하는 패키지 수를 줄일 수 있다.
하지만 특정 버전의 Prettier Plugin에 의존하는 프로젝트에서는 주의가 필요하다.
Bun의 자체 구현과 실제 Plugin의 동작이 완전히 동일하다고 보장할 수 없기 때문이다.
지원하지 않는 Plugin은 거부
저자는 존재하지 않는 다른 Plugin 이름도 테스트했다.
예를 들어 다음과 같은 이름이다.
prettier-plugin-totally-fake-xyz
이 경우 Bun은 해당 Plugin을 지원하지 않는다는 오류를 반환했다.
또한 다음 옵션을 사용하면 지원하지 않는 Plugin을 무시하고 포맷팅할 수 있도록 했다.
--allow-unsupported
하지만 이 방식에서는 지원하지 않는 Plugin의 효과가 적용되지 않는다.
따라서 개발자는 실제 프로젝트의 설정을 확인해야 한다.
특히 bun fmt --doctor가 어떤 Plugin을 자체 구현으로 처리하는지 확인하는 것이 중요하다.
병렬 실행 성능
저자는 다른 Repository인 Vite의 소스코드도 테스트했다.
대상은 약 1,589개의 JavaScript 및 TypeScript 파일이었다.
Bun의 Thread 수를 변경해 실행했다.
결과는 다음과 같았다.
| 설정 | 실행 시간 |
|---|---|
| 4개 Thread | 0.09초 |
| 1개 Thread | 0.29초 |
이번 실험에서는 4개 Thread를 사용했을 때 약 3.2배 빠르게 처리했다.
이는 Bun이 실제로 여러 CPU Core를 이용해 Formatter 작업을 병렬 처리한다는 사실을 확인하는 데 도움이 된다.
하지만 실제 효과는 사용 가능한 CPU Core와 파일 수, 디스크 성능 등에 영향을 받는다.
포맷팅 검사의 실제 의미
이번 실험에서는 두 Formatter가 같은 Repository를 모두 정상으로 판단했다.
하지만 이것만으로 Bun이 Prettier의 모든 가능한 입력에 대해 정확히 같은 결과를 생성한다는 사실이 검증된 것은 아니다.
예를 들어 특정 Plugin이나 복잡한 문법 조합에서는 결과가 다를 수 있다.
또한 단순한 --check 결과가 일치했다고 해서 실제로 모든 파일을 다시 포맷팅했을 때 Byte 단위로 완전히 같은 출력이 생성된다는 의미도 아니다.
따라서 실제 프로젝트에서 교체하려면 다음 두 종류의 검증을 구분해야 한다.
첫 번째는 기존 파일에 대해 Formatter가 동일한 통과 여부를 반환하는지 확인하는 것이다.
두 번째는 실제로 포맷팅한 출력 결과가 기존 규칙과 일치하는지 확인하는 것이다.
특히 Plugin을 사용하는 프로젝트에서는 두 번째 검증이 중요할 수 있다.
이번 Benchmark의 한계
이번 글은 공식 성능 발표를 독립적으로 확인하려는 실험이다.
그러나 실제 환경에는 다음 제한사항이 존재했다.
- Prettier는 한 번만 측정
- Bun은 세 번 측정
- Prettier 자체 Repository를 주된 테스트 대상으로 사용
- 모든 Prettier Plugin을 검증하지 않음
- 실제 프로젝트에서 사용하는 다양한 설정을 모두 비교하지 않음
따라서 약 283배라는 결과는 특정 Repository의 검사 시간 비교에 관한 것이다.
또한 Bun의 Built-in Plugin이 실제 Prettier Plugin과 완전히 동일한 출력을 생성한다는 보장도 없다.
이번 글은 개발 도구의 성능이 크게 개선됐다는 주장을 실제 Repository에서 검증하면서, 실행 환경과 Plugin 호환성의 차이까지 함께 확인한 독립적인 Benchmark다.