월간 후론트
9월에 나온 큰 결정들은 하나같이 같은 근거를 댔다. 코딩 에이전트 덕분에 비용 계산이 달라졌다는 것. Shopify는 그 이유로 React Native를 버렸고 Linear는 그 이유로 CSS-in-JS를 버렸다. shadcn은 에이전트가 디자인 시스템을 이탈하는 걸 막겠다고 린터를 새로 만들었다. 기술 선택의 기준이 사람이 쓰기 좋은가에서 에이전트가 다루기 좋은가로 옮겨가는 중이다.
이번 호 표지는 그 전환에 가장 비싼 값을 치른 쪽이다.
Shopify, 6년 만의 네이티브 회귀
2020년 Shopify는 React Native에 올인했다. 그 결정을 발표한 사람이 Mustafa Ali다. 2025년 1월에는 "React Native의 미래는 밝다"는 글도 썼다. 그리고 2026년 9월, 같은 사람이 네이티브로 돌아간다고 발표했다.
먼저 짚어둘 게 있다. 이 글은 React Native가 느리다거나 결함이 있다고 말하지 않는다. 바뀐 건 프레임워크가 아니라 전제다. 크로스 플랫폼을 택한 원래 근거는 같은 기능을 두 번 만들면 비용이 두 배라는 계산이었는데 코딩 에이전트가 그 두 배를 충분히 깎았다는 게 Shopify의 판단이다. 전제가 사라지면 결론도 사라진다.
방식은 greenfield다. 점진 이식을 포기하고 전면 재작성을 택했다. Shop 앱은 엔지니어 6명이 PoC부터 앱스토어 배포까지 12주 만에 Swift·Kotlin으로 다시 만들었다. 다음 차례인 Shopify 앱은 300개 이상 화면에 홈·잠금화면 위젯, Apple Watch 앱, Siri Shortcuts까지 포함하며 연내 출시 예정이다.
여기서 재밌는 건 실패담이다. LLM에 RN 코드베이스를 통째로 던져 한 번에 네이티브로 변환하는 방식은 실패했다. 사양과 태스크 파일로 고정해도 유지보수 불가능한 코드가 대량으로 나왔다고 자체 평가했다.
그래서 만든 게 Helix다. 화면 하나를 지정하면 RN 코드를 읽고 수 분 내에 리뷰 가능한 작은 체크포인트 순서를 제안한다. 각 체크포인트는 네 개의 관문을 전부 통과해야 커밋된다. 테스트로 동작 증명, 실행 중인 앱과 비주얼 대조, 적대적 코드 리뷰어 2개 통과, 그리고 사람 승인. 리뷰 피드백은 누적 기억되어 진행할수록 자율성이 올라간다.
두 번째 축은 피드백 루프 속도였다. 시뮬레이터 제어가 접근성 트리와 스크린샷에 의존해 느리고 불안정한 게 병목이었다. 비즈니스 로직을 UI에서 완전히 떼어내 데스크톱에서 헤드리스로 돌리고 CLI로 에이전트에 노출했다. 분 단위가 밀리초 단위가 됐다.
불완전한 결과가 다음 단계로 넘어가지 못하게 막는 루프. 이게 Helix의 전부이고 모바일이 아닌 어떤 대규모 에이전트 리팩터링에도 그대로 옮겨진다.
오픈소스 쪽 정리도 함께 나왔다. React Native Skia는 2026년 말까지 Shopify가 후원하고 이후 William Candillon이 새 이름으로 포크해 배포하며 원본 저장소는 아카이브된다. 주당 약 200만 다운로드의 FlashList는 호환성을 깨는 치명적 이슈만 수정하며 장기 관리 주체를 찾는 중이다. Restyle은 2026년 말까지 동작을 보장한 뒤 아카이브된다. 이 셋을 쓰는 RN 프로젝트는 지금 의존성 정리 계획을 세워야 한다.
반론도 있다. r/reactnative 스레드에서 Expo 창업자 중 한 명은 이게 기술적이라기보다 정치적 결정이며 구 아키텍처 RN 앱과 새로 만든 네이티브 앱을 비교한 것이라 공정하지 않다고 반박했다. 일리가 있다. 개선 수치의 상당 부분은 재작성 효과일 수 있다.
그리고 Shopify의 전제도 대부분의 조직과 다르다. 대규모 인력, 자체 에이전트 인프라를 만들 역량, 앱 두 개를 플랫폼별로 동시 운영할 여력. 결론만 복사하면 다친다. 가져갈 건 결론이 아니라 질문이다. 우리가 그 기술을 택한 근거는 아직 유효한가.
이번 달을 아홉 개로 압축하면
| 숫자 | 무엇 |
|---|---|
| 12주 / 6명 | Shopify가 Shop 앱을 네이티브로 재작성한 기간과 인원 |
| 14.3s → 0.81s | oxc Rust React Compiler의 컴파일 단계 (1,036개 파일, 17.6배) |
| 3,085ms → 550ms | claude.ai 신규 로드 시간 (5.6배) |
| 80만 줄 | GitHub이 14.5주에 작성한 Copilot 런타임 Rust 코드 |
| 55% | GitHub이 CSS Modules 전환으로 줄인 Primer SSR 시간 |
| 약 32,000건 | 8월 한 달 관측된 Vite dev server 파일 읽기 시도 |
| 80.5% | Bun 1.4의 Node.js 테스트 스위트 통과율 |
| 88% | React Router 8.4 새 매처의 네비게이션 시간 단축 (라우트 1,000개) |
| 10.0 | vm2 샌드박스 탈출 CVSS 점수 |
표만 보고 닫아도 이번 달 할 일은 다 파악된다
| 판정 | 항목 | 한 줄 |
|---|---|---|
| 즉시 | Next.js 16.3.6 | next/og RCE. Critical |
| 즉시 | Vite 7.3.2 / 8.0.5 | 이미 대규모 스캐닝 중 |
| 즉시 | vm2 3.11.7 | CVSS 10.0. 패치해도 안심 못 함 |
| 즉시 | Fastify 5.12.2 | validation·auth 우회 포함 4건 |
| 즉시 | Node 26.8.2 / 24.21.0 | Undici 보안 릴리스 |
| 즉시 | Moment.js 2.31.0 | 경로 순회. 주간 2,500만 다운로드 |
| 즉시 | Express 4.22.3 계열 | hbs·multer·morgan 포함 6건 |
| 검토 | React 19.3 | View Transitions·Fragment Refs stable |
| 검토 | Vitest 5.0 | RC에서 정식으로. 최대 53% |
| 검토 | oxc React Compiler | Babel 대비 17.6배, 패턴 커버리지도 넓다 |
| 검토 | @shadcn/lint | 에이전트가 디자인 시스템 이탈하는 걸 막는다 |
| 검토 | Zod 4.6 | .validate()로 거절 경로 35배 |
| 검토 | React Router 8.4 | 플래그 없이 리렌더가 준다 |
| 검토 | Turbopack 청킹 옵션 | Next.js 16.3, 켜기만 하면 되는 둘 |
| 검토 | npm stage-only 토큰 | 2027년 1월 전에 옮겨야 한다 |
| 검토 | Node 26.10 util.debounce | lodash 하나 걷어낼 수 있다 |
| 검토 | Vite+ 1.0 | 베타였던 게 정식으로 |
| 검토 | Rslib 1.0 | 앱이 Rspack이면 설정을 공유한다 |
| 관망 | Remix 3 RC | React가 필요 없는 Remix |
| 관망 | TanStack Redact v0.1 | React 호환 런타임. 라이선스가 아직 없다 |
| 관망 | Vinext 1.0 | Vite 위에 올린 Next.js |
| 관망 | OJ | Rust dev server. Evan You가 벤치마크에 의문 |
| 관망 | htmx 4.0 | 2년 만의 메이저. fetch로 갈아탔다 |
| 관망 | node:ffi 기본 활성화 | 26.9부터. 보안 정책 재검토 대상 |
| 관망 | TinyJS / node:bench / Jotai 3.0 / Vidact | 지금 쓰면 안 되지만 재밌다 |
| 참고 | Anthropic 3배, GitHub CSS 탈출, Linear 두 편 외 | 아래 참조 |
이번 주 안에 끝내라. 미루면 그게 사고다
next/og RCE, 그리고 예고된 9건 더
9월 22일, 정기 릴리스 밖에서 긴급 패치가 나왔다. next/og의 Node.js ImageResponse 구현에서 Critical 등급 원격 코드 실행이 발견됐다. 영향 범위는 v16.2.0부터 v16.3.5까지이고 v16.3.6에서 수정됐다. Edge 런타임의 ImageResponse는 영향받지 않는다.
근본 원인은 Next.js 밖에 있다. SVG를 이미지로 바꾸는 업스트림 라이브러리 Satori의 출력 이스케이프 처리가 미흡했고 다른 업스트림 의존성 취약점과 결합하면서 RCE로 이어졌다. GHSA는 Next.js 쪽 GHSA-vcvr-r3jv-pc5j, Satori 쪽 GHSA-wx4j-mvgx-mqwp다.
npm install next@16.3.6 # 16.x npm install next@15.5.26 # 15.5.x (하드닝만, RCE 영향 없음)
OG 이미지 API가 외부 입력을 파라미터로 받는 구조라면 우선순위를 더 올려라. 그리고 Next.js가 후속 릴리스에서 9건을 추가로 수정하겠다고 예고했다. 이번 패치로 끝났다고 보면 안 된다.
패치 다음에 할 일은 포트 점검이다
F5 허니넷이 8월 한 달에만 Vite 대상 파일 읽기 시도 약 32,000건을 관측했다. 세션 기준 807건. 직전 3개월 합계가 1,732건이었으니 폭증이다.
CVE-2026-39364는 인증 없이 @fs 라우트로 server.fs.deny 차단 목록을 우회하는 파일 노출이다. 우회 방법이 어이없을 만큼 간단하다. ?raw, ?import&raw, ?import&url&inline 같은 쿼리 파라미터를 붙이면 경로 검증에서 쿼리 스트링이 잘리거나 오해석돼 deny 체크가 발동하지 않는다.
공격자가 노리는 건 앱이 아니다. 클라우드 계정이다. 워드리스트가 그걸 말해준다. .env.local·.env.production·.env.staging, /root/.aws/credentials와 /home/{ec2-user,ubuntu,node,www-data}/.aws/credentials, terraform.tfstate, serverless.yml, .azure/accessTokens.json. 특히 /proc/self/cwd/.env는 절대 경로를 몰라도 실행 중인 프로세스 기준으로 .env를 읽는 수법이다.
스캐너는 User-Agent를 Googlebot·ClaudeBot·GPTBot으로 위조하고 X-Forwarded-For도 조작한다. 봇 화이트리스트를 User-Agent 문자열로 판단하고 있다면 그게 곧 우회 경로다. 역방향 DNS 검증으로 바꿔라.
할 일은 넷이다.
--host, server.host, Docker 포트 매핑을 전수 점검해라. 5173 포트가 공인 IP에 물려 있는지 docker-compose·k8s ingress·보안그룹까지 본다.vite build + vite preview나 정적 서빙으로 바꾸는 게 낫다..env 값, AWS 키, Azure 토큰, Terraform state를 유출된 것으로 간주하고 전부 로테이션한다.한 가지 더. F5 리포트의 타깃 국가 통계에서 한국이 센서당 이벤트 1위다. 남 얘기가 아니다.
패치가 나왔는데도 안심할 수 없다
GitLab Threat Research Group이 vm2에서 CVSS 10.0 샌드박스 탈출을 찾았다. 공격 설정이 vm2 README의 Quick Examples 첫 번째 예제 그대로다. 설정 실수로 생긴 구멍이 아니다. 공식 문서를 따른 개발자가 기본으로 취약하다.
구조가 교묘하다. new NodeVM({ require: { external: true, root: './' } })에서 root는 방어선이 아니다. 일반적인 npm 프로젝트는 ./node_modules가 ./ 안에 있으니까. 그래서 require('./node_modules/vm2')가 검사를 통과하고 vm2 자체 로더를 건너뛰고 Node의 진짜 hostRequire()로 로드된다. 공격자는 그렇게 얻은 진짜 vm2 모듈로 자기 규칙을 가진 두 번째 NodeVM을 만든다. 바깥 샌드박스 규칙은 안쪽에 적용되지 않는다.
주당 약 125만 다운로드, 월 500만 이상. 3.11.7에서 이 경로는 막혔다. 그런데 수정이 생각보다 좁다. vm2 자체 lib/ 폴더와 메인 파일 require만 차단한다. 같은 README 설정을 다른 파일로 겨누면 3.11.7에서도 완전한 RCE가 난다. require.external: true에 root가 없으면 경고 한 줄만 찍고 코드는 그대로 실행된다.
플러그인 시스템, 코드 플레이그라운드, CI 러너, AI가 생성한 코드를 실행하는 도구가 전형적인 노출 지점이다. vm2 의존성부터 grep해봐라. 새 프로젝트라면 vm2 대신 컨테이너나 별도 프로세스 격리가 훨씬 튼튼하다.
한 줄씩 끝나는 것들
moment.locale()을 통한 경로 순회(CVE-2026-17495, CVSS 5.9). 2.29.2부터 2.30.1까지가 대상이고 2022년 CVE-2022-24785의 불완전한 수정이었다. 가드는 입력이 문자열이라 가정하는데 match()는 통과하고 toString()이 순회 경로를 반환하는 객체를 넘기면 내부 require()까지 도달한다. 주간 2,500만 다운로드가 아직 유지보수 모드로 돌고 있다.hbs·multer·morgan 세 패키지에 걸친 취약점 6건. Express 4 사용자는 4.22.3도 함께.올릴 값어치는 있는데 전부 조건이 붙는다
작년의 실험이 올해의 정식 API로
9월 9일 공개. 작년에 실험 API로 나왔던 View Transitions와 Fragment Refs가 stable이 됐다. 여기에 react-dom의 browser(), Trusted Types 지원, Server Component에서 Context를 직접 렌더링하는 기능이 붙었다.
<ViewTransition>은 Transition으로 마킹된 업데이트에서만 동작한다. startTransition, <Suspense> reveal, useDeferredValue가 트리거고 긴급 업데이트는 애니메이션되지 않는다. 애니메이션 종류는 트리 변화로 자동 결정된다. enter(추가), exit(제거), update(스타일·내용 변경), share(named ViewTransition이 다른 위치로 이동).
여기서 쓸모 있는 게 addTransitionType이다. state 업데이트와 함께 addTransitionType('next')를 호출하면 같은 업데이트라도 원인별로 다른 애니메이션을 지정할 수 있다. enter={{ 'next': 'from-right', 'previous': 'from-left' }} 형태다. 캐러셀이나 탭처럼 방향성 있는 전환이 정확히 이 케이스다. React가 이 타입을 브라우저 view transition type으로도 심어주니 CSS에서 :active-view-transition-type(...)으로 스코핑할 수 있다.
<ViewTransition> 안에 <img>나 @font-face <style>을 넣으면 해당 리소스가 로드될 때까지 Suspense가 걸린다. 이미지와 폰트가 제각각 튀어나오는 flicker를 막는 용도로 쓸 수 있다.
Fragment Refs는 래퍼 <div> 없이 여러 DOM 노드를 다루게 해준다. <Fragment ref={...}>로 FragmentInstance를 받으면 addEventListener·dispatchEvent(1단계 자식 대상), focus·focusLast·blur(깊이 우선), observeUsing·unobserveUsing(IntersectionObserver·ResizeObserver), getClientRects·scrollIntoView·compareDocumentPosition을 쓸 수 있다. 라이브러리 컴포넌트가 ref를 노출하지 않아 래퍼 div를 끼워 넣던 상황이 여기서 해소된다. 레이아웃을 건드리지 않고 뷰포트 감지를 붙일 수 있다.
**use(browser())**는 서버에서만 suspend한다. SSR HTML에는 가장 가까운 Suspense fallback이 들어가고 클라이언트 하이드레이션 이후에는 suspend하지 않는다. use 계열이라 조건문 안이나 early return 뒤에서도 호출 가능하다. localStorage나 타임존처럼 브라우저 전용 값을 위해 쓰던 mounted state + useEffect 패턴을 이걸로 대체할 수 있다. 조건부 호출이 되니까 initialData가 없을 때만 use(browser())를 부르는 useBrowserQuery 래퍼 패턴이 특히 쓸모 있다.
주의할 것 넷. <ViewTransition>은 현재 DOM 전용이고 React Native는 작업 중이다. Suspense와 조합할 때 기본값 그대로 두면 이미 로드된 캐시 UI까지 매번 애니메이션돼 앱이 굼떠 보인다. <ViewTransition update="auto" default="none">이 권장 패턴이다. Strict Mode에서 하이드레이션 중에도 Effect가 double invoke되도록 바뀌었으니 멱등하지 않은 Effect가 있으면 드러난다. 그리고 use(browser())를 쓰는 컴포넌트는 fallback이 초기 HTML에 들어가므로 SEO나 LCP가 중요한 영역에는 신중해야 한다.
Babel을 떠날 이유가 속도만은 아니다
1,036개 파일 규모 React Router 앱에서 컴파일 단계가 14.3초에서 0.81초로 줄었다. 약 17.6배. 다만 전체 빌드는 22.1초에서 9.3초로 약 2.4배에 그친다. 컴파일 외 작업이 대부분이라서다.
속도보다 중요한 건 패턴 커버리지다. Babel 기반 컴파일러 1.0이 건너뛰던 것들이 Rust 버전에서는 이미 처리된다. try/catch 블록 안의 조건 로직(1.0의 대표적 블로커), 구조분해된 prop을 재할당한 뒤 중첩 클로저에서 쓰는 경우, 계산된 객체 프로퍼티 키. 필자 앱에서는 이것만으로 컴파일 대상 함수가 7개 늘었다.
@vitejs/plugin-react v6.1.0부터 react({ compiler: true })로 켠다(Vite 8+ 필요). React Router framework 모드처럼 Vite React 플러그인을 못 쓰면 @acusti/vite-plugin-react-compiler를 쓴다. vite-plugin-babel, babel-plugin-react-compiler, @babel/preset-typescript 세 개를 걷어내고 plugins: [reactRouter(), reactCompiler()]로 끝난다.
함정 하나. Oxlint의 React Compiler 규칙을 쓰고 있다면 빌드 쪽도 같은 oxc-transform-react 버전으로 맞춰야 한다. 버전이 어긋나면 린트는 통과하는데 컴파일은 건너뛰는 조용한 구멍이 생긴다(필자가 실제로 겪고 오탐 이슈를 제출했다). 그리고 아직 미지원 패턴이 남아 있다. try 블록 안의 throw, 논리 할당 연산자(??=, &&=, ||=)는 컴포넌트나 훅을 통째로 건너뛰게 만든다. compiler: true는 아직 experimental 표기다.
에이전트에게 규칙을 말로 하지 말고 린터로 줘라
두 뉴스레터에 동시에 올라온 이번 달 최대 화제작. 출시 직후 GitHub 스타 2.2k를 넘겼다.
문제의식이 명확하다. 에이전트가 짠 UI는 bg-[var(--oh-border)], rounded-[6px], p-[3px], 생 hex 컬러 같은 일회성 arbitrary value로 디자인 시스템을 계속 이탈한다. shadcn이 택한 해법은 린터다. 에러 메시지 자체가 뭐가 잘못됐고 대신 뭘 쓰고 그게 어디 있는지를 프로젝트의 컴포넌트·variant·테마에서 뽑아 알려준다.
"p-4" is not allowed on <Button>: <Button> owns its spacing. Use a size (sm, lg), or margin here or gap on the parent...
TypeScript로 style prop을 좁히면 TS2353만 뜨고 끝난다. 차이가 여기 있다.
규칙은 6종이다. no-restyle(className으로 컴포넌트 재스타일링), no-raw-colors, no-arbitrary-values, no-inline-styles, no-unknown-classes, require-static-classes. 컴포넌트별 contract를 설정할 수 있다.
"shadcn/no-restyle": ["error", { "allow": ["layout"], "contracts": [ { "pattern": "^Button$", "allow": ["w-full", "mt-*", "mb-*"] }, { "pattern": "^CardTitle$", "allow": ["layout", "typography"], "deny": ["font-*"] } ] }]
Tailwind v4 프로젝트면 되고 shadcn/ui는 필요 없다. ESLint와 Oxlint 양쪽 지원. 공개된 eval은 150회 이상 태스크 런에서 Sonnet 5 8/8(위반 69→0), Haiku 4.5 8/8(66→0), Opus 5 8/8(42→0), GPT 5.6 Terra 8/8(117→0), GPT 5.6 Sol 6/8(98→0)이다. Claude 대조군에서 수정 비용이 규칙 문서만 줬을 때보다 10~48% 저렴했다.
도입 순서가 중요하다. no-restyle + allow: ["layout"] 하나로 시작하고 no-arbitrary-values, no-raw-colors를 순차로 켜라. require-static-classes는 런타임에 클래스를 조립하는 기존 코드를 대거 잡아내니 초기에는 끄고 시작하는 편이 현실적이다. 그리고 AGENTS.md에 "변경 후 npm run lint 실행하고 에러 전부 수정" 한 줄을 넣는 게 사실상 필수 마무리다.
Oxlint의 JS 플러그인 API가 아직 alpha라는 점, eval이 벤더 자체 수치라는 점은 감안해라.
지난 호 관망이던 게 정식으로
9월 4일 정식 릴리스. 이번 메이저의 초점은 성능이고 마이크로 벤치마크 대신 5파일 유틸 패키지부터 1,280 모듈 엔터프라이즈 모놀리스까지 생성한 레퍼런스 앱 세트로 측정했다.
수치(Apple M4 10코어, Node 24, 3회 중앙값, 4.1.10 → 5.0): deps-heavy vmThreads 1.59s → 0.74s(−53%), long-haul vmForks 5.43s → 4.06s(−25%), enterprise-monolith 7.24s → 5.83s(−19%), react-spa Browser Mode 2.40s → 2.01s(−16%). 반대로 forks+jsdom+isolation처럼 환경 셋업이 지배적인 조합은 ±3% 이내로 변화가 없다.
개선 원인은 sharedViteServer(인라인 프로젝트가 루트 Vite 서버 공유), fsModuleCache 정식 승격(변환 모듈을 디스크에 영속해 프로세스 간 재사용), vm 풀의 컴파일 코드 재사용과 모듈 그래프 프리웜이다.
Trace View가 이번 릴리스의 진짜 물건이다. browser.traceView: true로 켜면 모든 인터랙션·어서션·page.mark를 DOM 스냅샷으로 기록해 사후에 단계별로 되감아 볼 수 있다. 실패한 액션은 빨간색으로 강조되고 에디터 패널에서 소스 위치가 열린다. Playwright Traces와 달리 provider 비의존이고 별도 뷰어가 필요 없다. HTML 리포터의 singleFile 옵션과 조합하면 CI 아티팩트 하나만 받아 재생할 수 있다.
진단 기능도 좋아졌다. 리포터 duration이 environment 79%, import 13%, transform 6%, tests 1%, setup 1% 식으로 퍼센트를 보여준다. 새 vitest doctor는 대안 설정으로 실제 실행해 보고 더 빠른 옵션을 추천한다. 업그레이드 전에 이것부터 돌려봐라.
신규 vi.when은 인자별 mock 동작을 선언한다. vi.when(fn).calledWith(1).thenResolve(...) 형태고 deep equality 매칭에 expect.any() 같은 비대칭 매처를 지원한다. mockImplementation 안에 if를 쌓던 코드가 정리된다.
브레이킹이 많다. Vite ≥ 6.4.0, Node.js ≥ 22.12.0이 필수다. clearMocks가 기본 true가 되고 await 안 한 resolves/rejects/toMatchFileSnapshot은 경고로 끝나지 않고 실패한다. locator가 strict 기본이라 getByText('Item')이 더 이상 Item 1을 매칭하지 않는다. 마이그레이션에서 가장 시끄러운 지점이 여기다. 워커·풀 ID는 0-based에서 1-based로 바뀌었고 벤치마킹 API는 재작성이라 기존 .bench.ts를 전부 손봐야 한다.
거절 비용을 깎는 릴리스
9월 9일 공개. 헤드라인은 .validate()다. ZodError를 만들지 않고 유효한지만 답하는 불리언 검증 API인데, 핵심은 첫 번째 이슈에서 short-circuit한다는 점이다. 전체 ZodIssue[] 배열을 모으지 않으니 거절 비용이 극적으로 싸진다.
z.compile()과 함께 쓰면 invalid 입력 기준 .safeParse().success 대비 최대 35배 빠르다. 호출당 시간으로 보면 3개 객체 union 28ns(34.9배), 문자열 10개 배열 23ns(24.6배), 키 5개 객체 21ns(16.3배). 반환 타입이 타입 가드라서 if (Player.validate(data)) 안에서 narrowing이 그대로 된다.
CJS 환경이라면 업그레이드만으로 3배를 가져간다. 4.5의 CJS 엔트리포인트는 255개 export 중 252개가 getter였고 V8이 getter 뒤의 callee를 상수로 볼 수 없어 인라이닝이 불가능했다. 4.6은 plain property로 emit하고 exports 객체를 freeze한다. ESM 빌드는 변화 없다.
재귀 스키마 메모리 회귀도 고쳤다. 4.5는 마지막 파스의 input/output을 다음 파스까지 붙들고 있어 장수명 스키마가 만진 모든 객체를 pin했다. 4.6은 파스 상태를 weak하게 유지해 29k 노드 트리 1회 파스 기준 retain이 10.1MB에서 2.2MB로 줄었다(재귀 파스 성능은 약 6% 희생). 카테고리 트리나 AST를 다루다 4.5에서 OOM을 겪었다면 이번 릴리스가 바로 그 수정이다.
적용 지점은 명확하다. 검증 결과는 받되 에러 메시지는 안 쓰는 곳. API 게이트웨이의 요청 필터, 미들웨어 스키마 체크, 큐 컨슈머의 페이로드 선별, 서버 액션 초입 가드. 반대로 폼 검증처럼 필드별 에러를 보여줘야 하는 곳에는 맞지 않는다. short-circuit이 곧 어디가 왜 틀렸는지 모른다는 소리니까.
브레이킹은 아래 손대지 말 것에 따로 모아뒀다. 조용한 것들이 섞여 있다.
플래그 없이 얻는 것과 플래그가 필요한 것
9월 15일 릴리스. API 추가 없이 런타임 성능 두 가지를 손봤다.
첫째는 플래그 없이 적용된다. 내부 데이터 라우터 컨텍스트를 하나에서 4개로 분리했다. 현재 위치(useLocation, useSearchParams, useMatches), 진행 중 네비게이션(useNavigation, useRevalidation), loader·action 데이터, fetcher. useLocation만 쓰는 컴포넌트는 navigation.state가 loading으로 바뀌어도 리렌더되지 않는다. 네비게이션마다 트리 전체가 흔들리던 앱은 업그레이드만으로 체감이 다르다.
둘째는 opt-in이다. @remix-run/route-pattern 기반 새 매처가 Chromium 합성 벤치마크 기준 라우트 100개에서 19~38%, 1,000개에서 71~88% 단축을 낸다. future: { unstable_routePatternMatching: true }로 켜되 그 전에 unstable_preloadRoutePattern()을 호출해야 한다. 라우트가 100개 미만이면 이득이 작다.
unstable_validateParams도 들어왔다. 파라미터에 정규식 검증을 걸고 실패하면 매칭이 뒤쪽 라우트로 이어진다. path: "/:drink"에 { drink: /^(wines|whiskeys|sakes|beers)$/ }를 걸고 그다음에 /:food를 두는 식이다. loader 안에서 파라미터 검증 후 404를 던지던 패턴을 라우팅 레이어로 올릴 수 있다.
컨텍스트 분리는 UNSAFE_ export에 breaking change를 포함한다. 테스트 유틸이나 커스텀 라우터 래퍼가 이걸 쓰고 있다면 먼저 확인해라. 그리고 레거시 매칭 API(matchRoutes, matchPath, useMatch)는 여전히 기존 정규식 매처를 쓰므로 플래그를 켠 라우터와 결과가 미묘하게 달라질 수 있다.
켜기만 하면 되는 둘, 측정이 필요한 셋
Next.js 팀이 청킹 전략을 공개했다. 청킹은 요청 수를 줄이는 것과 코드 전송량을 줄이는 것이 정면으로 충돌하는 문제다. 전부 한 청크면 캐시 적중률은 최고지만 안 쓰는 코드까지 매번 받고 모듈마다 쪼개면 과전송은 없지만 수백 개 요청과 압축 효율 저하가 생긴다.
Turbopack은 같은 chunk group 안의 청크만 병합한다. 어차피 함께 로드되니 병합해도 과전송이 없다. 병합이 손해가 되는 지점은 소프트 네비게이션이다. A+B 병합 파일을 받은 뒤 A만 필요한 페이지로 이동하면 A를 다시 받는다. Turbopack은 세션의 2/3가 단일 페이지 방문이라고 가정하고 확률 가중으로 병합 여부를 정한다.
nextjs.org 실측(8단계 네비게이션 합계): 병합 없음 561.6 KiB / 96 요청, 기본값 554.8 KiB / 38 요청, 그룹당 1청크 610.0 KiB / 15 요청. 기본값이 요청을 절반 이하로 줄이면서 코드량도 조금 적다. 최대 병합은 요청은 더 줄지만 코드가 10% 더 나간다.
지금 바로 켤 만한 건 둘이다. experimental.turbopackSharedRuntime(페이지별 런타임 청크를 하나로 통합, 첫 로드 이후 매 네비게이션마다 블로킹 요청 1개와 약 10KB 절약)과 experimental.turbopackCjsTreeShaking. 둘 다 추후 기본값이 될 예정이라 미리 켜서 검증해 두는 게 낫다.
나머지는 측정이 필요하다. generateComponentChunks는 병합본과 비병합본을 함께 emit해 런타임이 더 싼 쪽을 고르게 한다(SPA성 이동이 잦으면 유효). firstPageLoadPriority(기본 0.67)는 자사 애널리틱스의 실제 이탈률로 맞춰라. clusters는 함께 방문되는 라우트를 정규식으로 묶는데 잘못 정의하면 역효과다. A만 쓰는 페이지와 A+B를 쓰는 페이지를 한 클러스터에 섞으면 오히려 병합이 줄어든다.
npm 토큰, Node 내장, 그리고 빌드 도구 둘
npm stage publish로 버전을 제출만 하고 실제 배포는 메인테이너가 2FA로 승인하는 구조다. npm이 2027년 1월부터 bypass-2FA 토큰의 직접 퍼블리시를 없앤다고 예고했다. 사내 라이브러리를 CI에서 자동 배포하는 팀은 지금 토큰 권한을 감사해라. 마이그레이션을 안 마치면 파이프라인이 갑자기 막힌다. 다만 stage-only 토큰도 dist-tag 이동이나 deprecate 같은 write 권한은 그대로 가지므로 일반 write 토큰처럼 보호해야 하고 더 근본적인 해결책은 OIDC 기반 trusted publishing이다.util.debounce()와 util.throttle()이 AbortSignal 지원과 함께 내장으로 들어왔다. .catch(() => {}) 땜질을 대체할 util.markPromiseAsHandled()도 생겼다. 빌드 스크립트나 SSR 로직에서 lodash 의존성 하나를 걷어낼 수 있다. 다만 Current 라인이라 프로덕션은 LTS 백포트를 기다리는 게 안전하고 무엇보다 이건 Node 런타임 API이지 브라우저에서 돌아가는 클라이언트 코드에는 쓸 수 없다. Flavio Copes가 정리한 12가지 Node 내장 대체재도 함께 볼 만하다. 각 대체재의 한계를 정직하게 짚어준다. fetch는 4xx/5xx에서 reject하지 않고 --env-file은 ${HOST} 변수 확장을 지원하지 않으며 타입 스트리핑은 enum·decorator를 지원하지 않는다.vp 단일 CLI 아래 create·install·dev·check·test·build·pack·run·env 9개 명령을 두고 vite.config.ts 하나로 전부 설정한다. 내부적으로 Vite 8, Vitest 5, Rolldown, Oxc, tsdown을 한 팀이 유지보수한다. vp migrate가 실행 전에 변경 계획을 먼저 보여주고 tsup 마이그레이션까지 처리한다. Node 런타임 관리까지 vp가 떠맡는 구조라 nvm·volta와 역할이 겹친다. 팀 컨벤션 정리가 먼저다.지금 쓰면 안 되지만 6개월 뒤에 "그거 왜 몰랐지" 소리 안 들으려면
Remix 3 RC: React 메타프레임워크였던 과거와 결별했다. 웹 프리미티브 위에 세운 독립 풀스택 프레임워크로, DB 관리·스키마 검증·타입 안전 라우터·번들 없는 에셋 서버·자체 UI 런타임까지 remix 단일 패키지에 담겼다. React Router v7을 쓰고 있다면 이건 업그레이드 경로가 아니다. 별개 프레임워크다. 의존성 하나로 풀스택이 갖춰져 npm 공급망 공격 표면이 줄고 에이전트 코딩에도 잘 맞는다는 게 팀의 주장이다. 조각 교체가 가능해서 @remix-run/data-schema 대신 Zod, @remix-run/data-table 대신 Drizzle을 끼울 수 있고 render 미들웨어를 React 기반으로 바꿀 수도 있다. 정식은 10월 2일 Remix Jam 예정이었다. React 생태계 자산을 그대로 가져올 수 없으니 기존 React 코드가 많으면 비용이 크다.
TanStack Redact v0.1: Tanner Linsley가 5월에 예고했던 React 호환 런타임이 나왔다. React 19.3 API를 지원하고 Vite 플러그인과 호환성 표까지 갖췄다. 런타임 크기는 React보다 훨씬 작지만 동시성 렌더링을 지원하지 않는다. 그리고 라이선스가 아직 없다. 실험 단계로 보는 게 맞다.
Vinext 1.0: Cloudflare가 Vite 기반으로 재구현한 Next.js가 1.0을 냈다. Pages Router와 App Router를 모두 지원하고 기존 앱 마이그레이션 경로도 제공한다.
OJ: Lovable이 하루에 Vite dev server를 백만 개씩 띄우는 부담을 줄이려고 Rust로 dev server를 새로 만들었다. 기존 vite.config.ts를 그대로 읽고 Vite 플러그인도 실행한다. 다만 Evan You가 벤치마크에 의문을 제기했다. AI 때문에 다들 자기만의 슬롭 포크를 유지보수하게 될 거라는 전망도 덧붙였다. 뒷담화 섹션 참고.
htmx 4.0: 2년 만의 메이저. 내부를 XMLHttpRequest에서 fetch()로 전면 교체했지만 사용자 관점에선 htmx 2와 거의 같다. 핵심 변경 셋은 속성 상속의 명시적 opt-in 전환(hx-confirm:inherited="..."), 이벤트명 표준화(htmx:beforeRequest → htmx:before:request), 히스토리 복원 시 localStorage 캐시 제거다. idiomorph 기반 morph swap이 내장됐고 <hx-partial>로 한 응답에서 여러 타깃을 갱신할 수 있다. npm latest는 2027년 초까지 2.x로 유지되고 4.0은 next 태그다. 기존 2.x를 쓰고 있다면 npx htmx.org@4.0.0 upgrade-check -- ./templates부터 돌려라. CSRF 헤더에 :inherited가 빠져 요청이 거부되는 사고를 막을 수 있다.
node:ffi 기본 활성화: Node 26.9.0부터 --experimental-ffi 플래그 없이 node:ffi를 쓸 수 있다. 플래그는 반대로 끄는 용도가 됐다. 컴파일된 addon이나 node-gyp 없이 네이티브 라이브러리 함수를 직접 호출한다. 기본값이 바뀌었으니 네이티브 코드 실행 표면도 그만큼 넓어졌다. 샌드박스나 서드파티 코드 실행 환경이라면 보안 정책을 재검토해야 한다.
나머지 구경거리: TinyJS(txiki.js 기반 10MB 미만 JS 데스크톱 앱, 서명·공증까지 지원). node:bench(James Snell의 실험적 벤치마크 러너가 main에 머지됐다. node:test를 본뜬 설계에 node --bench CLI와 JSON 리포터). Jotai 3.0(ESM 전용, React 18·TS 5.5 최소 요구, atomFamily는 jotai-family로 분리). Vidact(React 스타일 컴포넌트를 Solid처럼 직접적인 DOM 조작 코드로 컴파일, 베타). Expo SDK 58 beta(RN 0.88 RC 위, App Intents 지원).
이 목록만 알아도 하루는 번다
| 지뢰 | 왜 |
|---|---|
| Next.js 16.3.5 이하 (16.2.0+) | next/og RCE. 예고된 9건 추가 패치 |
| 외부 노출된 Vite dev server | 패치해도 노출 구조가 남으면 다음 우회에 또 당한다 |
| vm2 | 3.11.7의 수정이 좁다. 다른 파일로 겨누면 여전히 RCE |
| Zod 4.6의 에러 맵 | safeParse()가 lazy해져서 result.error를 읽을 때 실행된다 |
Zod 4.6의 z.emoji() | "123", "#", 단독 ZWJ가 이제 실패한다 |
Zod 4.6의 enum .options | 리버스 매핑 제거. [0,1,2]만 남는다. UI 셀렉트에 쓰고 있었다면 확인 |
| Vitest 5의 locator strict | getByText('Item')이 Item 1을 안 잡는다. 가장 시끄러운 지점 |
Vitest 5의 VITEST_WORKER_ID | 0-based에서 1-based로. 샤딩·포트 할당 오프바이원 |
| Node 27의 Intel Mac | 실험적으로 격하. 공식 x64 macOS 빌드가 사라진다 |
| GitHub Actions의 Node 20 | 러너에서 제거됐다. 액션 유지보수 중이면 runs.using을 node24로 |
| dotenv 18.0 | -r dotenv/config 프리로딩 제거. dotenv run --으로 대체 |
Zod 쪽을 조금 더 풀어야겠다. 조용히 위험한 게 섞여 있다.
에러 맵의 실행 시점이 바뀌었다. 4.6에서 safeParse()가 에러를 lazy하게 만들면서 에러 맵(global/locale/per-schema)이 파스 시점이 아닌 result.error를 처음 읽을 때 실행된다. 에러 맵에 사이드 이펙트를 넣어뒀다면 아무도 에러를 읽지 않을 때 그 코드가 아예 실행되지 않는다. 로깅이나 메트릭을 거기 걸어뒀다면 조용히 사라진다.
메타데이터 멤버 8종(.format, .minLength, .maxLength, .minValue, .maxValue, .isInt, .minDate, .maxDate)도 첫 읽기 시점에 materialize된다. Object.keys(schema)나 Object.assign({}, schema)에 의존하는 코드는 결과가 달라진다. 값 자체도 바뀔 수 있다. z.string().min(8).length(5).minLength가 4.5에서는 5였는데 4.6에서는 8이다.
그리고 z.toJSONSchema() 출력이 달라진다. 체인된 체크를 conjunction으로 접어서 z.number().min(0).max(23).int()가 올바른 {minimum: 0, maximum: 23}을 낸다. 런타임 파싱은 원래부터 맞게 동작했고 틀린 건 emit된 스키마뿐이었다. 스냅샷 테스트가 깨질 수 있다.
당장 코드를 바꾸진 않지만 다음 분기 의사결정에 쓰일 것들
location.reload() 한 줄이 하루 약 50만 건의 보이지 않는 리로드를 유발하고 있었다는 것. 일반적인 로드 지표로는 안 잡히는 문제였다. 교훈은 순서다. Valgrind로 CPU 명령어 수를, V8 커버리지로 호출 횟수를 재는 결정론적 지표 체계를 먼저 만들고 그 위에서 최적화를 반복했다. 다만 2주에 3,000건 이상을 병합하면서 무중단을 유지한 건 약 200개의 피처 플래그와 자동 코드 리뷰, 14개 뷰포트 통합 테스트가 있어서다. 안전장치 없이 같은 속도로 미는 건 위험하다.sx prop 제거를 위해 과도기용 @primer/styled-react 래퍼와 VS Code 플러그인·코드모드를 만들어 엔지니어 8명이 6개월간 약 6,400곳 이관, 이후 Copilot 에이전트를 투입해 남은 항목을 3주 만에 정리 (3) 테마 로직을 CSS로 이전. 2024년 12월 첫 성과 발표에서 2026년 6월 완료까지 수년이 걸린 프로젝트다.styled(Button) 같은 패턴이 컴포넌트를 바깥에서 열어젖히는 걸 당연하게 만들었고 리디자인 이후 UI 회귀의 긴 꼬리를 계속 쫓는 원인이 됐다. 에이전트가 코드베이스에 기여하는 비중이 커지면서 명시적 스타일 계약의 필요가 더 커졌다는 게 결정적이었다. 마이그레이션 순서도 참고할 만하다. 스타일 기반 정의 → leaf 노드부터 이동 → 공유 primitive는 마지막. 그리고 채택률을 끌어올린 건 기술이 아니었다. 가시화였다. dev 툴바의 잔여 styled-components 규칙 카운터, 컴포넌트별 스타일 시스템 하이라이터, 주간 진행 그래프, 새 styled-components를 PR에서 지적하는 봇.isolate:false로 모듈 상태를 공유하게 해 월간 비용 17%를 추가로 절감했다(단일 최대 개선폭). isolate:false는 정합성 리스크가 가장 큰 변경이니 fake timer나 공유 상태를 분리할 수 없는 파일은 격리 모드에 남겨야 한다.audit부터. 7개 Tier-0 체크로 AI-Readiness Score를 매기고 티어를 붙인다(AI-native ≥ 70, Invested ≥ 40, 그 아래 Emerging). CI에 smoke 프로파일을 물리면 prop 이름을 바꿨을 때 프로덕션보다 스위트가 먼저 깨진다. 셀당 대략 1.5달러에 생성 2~5분이고 스타 9개짜리 초기 프로젝트라는 점은 감안해라.UX.md나 Design.md를 두고 코딩 에이전트 컨텍스트에 물리는 것이다. 승인된 컴포넌트 목록, 금지 패턴, 접근성 체크리스트를 거기 명시하면 매번 리뷰에서 같은 지적을 반복할 필요가 없다. 반복해서 같은 문제가 생성물에 나타난다면 개별 산출물 말고 생성 규칙을 고쳐라. 다만 UX-context 파일은 방치되면 금방 낡아서 오히려 틀린 가이드를 주입하게 된다.Cannot access 'someArray' before initialization이 난다. Safari 27에서 모듈 로더를 바닥부터 다시 써 완전한 스펙 준수를 달성했고 구현도 self-hosted JavaScript builtin에서 네이티브 C++로 전면 전환했다. 검증 방식이 인상적이다. 같은 JavaScriptCore 기반이라 같은 문제를 물려받았던 Bun 팀이 실패 케이스를 제공했고 복잡한 모듈 그래프를 생성하는 퍼저를 만들어 다른 엔진 출력과 바이트 단위로 비교했다. 다만 수정은 Safari 27 이후에만 적용되고 iOS는 OS 업데이트와 묶여 있어 잔존 기간이 길다. 라이브러리 공개 진입점에 top-level await를 두는 건 아직 이르다.ToBoolean 변환 로직과 -0/-1 처리 방식, 그리고 true/false Oddball 객체가 서로 다른 캐시 라인에 위치한다는 점 때문에 CPU 캐시 접근 패턴으로 비밀 값을 유출할 수 있다. UBC 연구팀이 발견했고 이미 패치됐다. 토큰 비교나 서명 검증을 순수 JS로 상수 시간이라 믿고 구현했다면 재검토 대상이다. 분기가 없어도 상수 시간은 보장되지 않는다.await fetch()가 기다리는 것: 본문 전체를 기다리지 않는다. 헤더까지만 기다린다. 이 사실이 커넥션 관리, clone(), abort, 타임아웃 처리에 만드는 의외의 결과들을 파고든다.compositionstart/compositionend 또는 isComposing 체크로 별도 방어가 필요하다.radius: [24, 0, 64]는 슬라이더(기본값·최소·최대), visible: true는 토글, '#a78bfa'는 컬러 피커가 된다. type: 'spring'으로 Time 모드와 Physics 모드를 전환하고 type: 'easing'으로 큐빅 베지어 핸들을 직접 드래그한다. 스프링 애니메이션 튜닝처럼 숫자 바꾸고 새로고침을 수십 번 반복하던 작업이 드래그로 바뀐다. 프로덕션 빌드에서는 자동으로 숨겨지지만 productionEnabled를 실수로 켜두면 사용자에게 개발용 패널이 노출된다.<Measurer /> 하나만 얹으면 살아 있는 화면 위에서 요소를 선택하고 간격을 재고 가이드를 긋는다. 단축키로 Select/Guides/X-ray/Ruler 모드를 오간다. 개발 빌드에만 조건부 마운트하면 디자이너와 QA가 스테이징에서 직접 간격을 재고 이슈를 적어줄 수 있다. 비슷한 결로 Squints는 크롬 확장이라 React가 아닌 페이지에도 쓸 수 있다.이번 달 이적 시장과 뒷말
$()가 브라우저 전쟁을 어떻게 우회하게 해줬는지 되짚었다. 그보다 10년 전 Microsoft가 상표 문제를 피하려 JScript라는 이름으로 IE에 JavaScript를 실었다는 이야기도 함께.이전 시리즈 포스트
26년 09월