Million.js 완벽 가이드 — React 성능 최적화(블록 가상 DOM·For·Next.js)
이 글의 핵심
Million.js는 React 위에서 동작하는 최적화 컴파일러이자 “블록(block)” 단위의 가벼운 가상 DOM을 제공합니다. 이 글에서는 React의 렌더링·조정 모델과의 관계, block()과 블록 가상 DOM, 리스트용 For, 측정·프로파일링, Next.js 통합 시의 SSR·하이드레이션 주의, UI 라이브러리·후크·비결정적 반환 등 제약, 마지막으로 대규모 리스트에서의 실전 패턴까지 한 번에 정리합니다.
이 글의 핵심
Million.js는 React 애플리케이션의 업데이트 경로를 더 가볍게 만들기 위한 최적화 컴파일러와, 이를 뒷받침하는 블록 가상 DOM(block virtual DOM) 모델을 제공합니다. React가 상태 변화마다 컴포넌트 스냅샷을 만들고 전체 트리를 비교(diff) 하는 비용이 커질수록, Million이 말하는 이득은 커집니다. 다만 이는 “모든 프로젝트에 무조건 이득”이 아니라, 컴파일러 규칙·블록 제약·SSR 환경을 이해하고 적용했을 때 발현되는 조건부 이득에 가깝습니다.
이 글에서는 핵심 개념부터 block()과 블록 가상 DOM, 리스트를 위한 For, 측정·프로파일링, Next.js 통합, 주의사항·제약, 그리고 대규모 리스트 실전까지를 한 흐름으로 묶습니다. 공식 문서와 README에서 강조하는 바에 따라, 버전·프레임워크 조합에 따라 동작 차이가 있을 수 있으므로 적용 전 소규모 스파이크와 벤치를 전제로 읽어 주시기 바랍니다.
Million.js의 핵심 개념
React 업데이트: 렌더링과 조정
React에서 상태가 바뀌면 함수 컴포넌트가 다시 호출되어 새로운 엘리먼트 트리(스냅샷) 가 만들어집니다. 이후 조정자(reconciler)는 이전 트리와 새 트리를 비교하며 무엇을 DOM에 반영할지 결정합니다. 엘리먼트 수가 많아질수록 비교 연산은 누적되고, 이는 체감 지연·프레임 드랍으로 이어질 수 있습니다.
Million.js는 이 과정에서 비교 범위를 줄이거나 우회하여, 특정 UI 갱신을 더 직접적인 DOM 연산에 가깝게 매핑하는 것을 목표로 합니다. 문서화된 비유에 따르면, 단순한 예에서조차 텍스트 한 줄 갱신에 여러 단계의 비교가 필요할 수 있지만, Million이 생성하는 경로는 변경된 노드에 더 빨리 도달하도록 설계됩니다. 이는 벤치마크(js-framework-benchmark 등)와 데모에서 동일한 React 컴포넌트 모델을 유지하면서도 체감 성능을 끌어올릴 수 있는 이유이기도 합니다.
컴파일러와 수동 모드(Manual Mode)
Million은 컴파일러를 전제로 할 때 기능과 성능 이점이 크다고 안내합니다. 컴파일러 없이도 동작은 가능하나, 지원 범위가 제한되고 권장되지 않습니다. 실무에서는 Vite·Webpack·Next 등 번들러 설정에 Million 컴파일러를 연결하고, 수동 모드에서는 block·For 등 API로 최적화 대상을 명시적으로 지정합니다.
“블록”이란 무엇인가
Million에서 블록(block) 은 일반 React 컴포넌트를 감싼 고차 컴포넌트(HOC) 로, 렌더링 경로가 특정 규칙을 만족할 때 초고속 경로로 처리될 수 있게 합니다. 내부적으로는 blockdom 등에서 영감을 받은 블록 기반 가상 DOM 아이디어와 연결되며, “전체 트리 diff” 대신 템플릿에 가까운 안정 구조를 활용하려는 쪽에 가깝습니다.
block()과 블록 가상 DOM
block() 기본 사용법
block()은 million/react에서 가져옵니다. 컴포넌트 함수를 감싸 변수 선언으로 이름을 붙인 뒤 export하는 패턴이 문서에서 권장됩니다.
import { block } from "million/react";
const LionBlock = block(function Lion() {
return <img src="https://million.dev/lion.svg" alt="Million" />;
});
export default LionBlock;
위 패턴의 요지는 컴파일러가 블록 정의를 정적으로 분석할 수 있게 하는 데 있습니다. export default block(() => …)처럼 한 줄로 export 하거나, block(<Component />)처럼 엘리먼트를 넘기는 형태는 문서에서 잘못된 예로 들립니다. 블록은 함수 참조를 받아야 합니다.
커스텀 태그(as)
기본적으로 블록의 루트 태그 동작은 옵션으로 조정할 수 있습니다. 예를 들어 as로 루트 요소 이름을 지정할 수 있습니다(문서 예: as: 'div').
import { block } from "million/react";
const CardBlock = block(
function Card() {
return <section className="card">내용</section>;
},
{ as: "div" }
);
export default CardBlock;
프로젝트의 HTML 시맨틱·레이아웃 요구에 맞춰 루트 요소를 고정하면, 스타일·접근성 측면에서 예측 가능성이 높아집니다.
SSR과 ssr: false
서버에서 Million 경로와 클라이언트 경로가 달라 하이드레이션 불일치가 날 수 있습니다. 문서에서는 block의 옵션으로 ssr: false 를 주어 해당 블록의 서버 렌더링을 끄는 패턴을 제시합니다. Math.random()처럼 서버와 클라이언트가 다를 수밖에 없는 UI에 특히 유효합니다.
import { block } from "million/react";
const NoSSRBlock = block(
function NoSSR() {
return <div>{Math.random()}</div>;
},
{ ssr: false }
);
export default NoSSRBlock;
프로덕션에서는 무작위 표시 자체가 캐싱·재현성 측면에서 위험할 수 있으므로, 실제로는 시드가 고정된 난수나 클라이언트 전용 마운트 후 표시 등과 함께 설계하는 것이 안전합니다.
블록 규칙: 결정적 반환(deterministic returns)
문서는 블록 내부에서 조기 반환이나 조건에 따라 완전히 다른 트리를 반환하는 패턴이 성능 저하나 경고로 이어질 수 있음을 설명합니다. 이상적인 형태는 단일 return으로 안정적인 트리 구조를 유지하는 것입니다. React 훅 규칙과 겹치면 “후크 개수 불일치” 같은 오류로도 나타날 수 있어, 블록 경계에서는 조건부 훅 호출·조건부 초기 return을 특히 경계해야 합니다.
스프레드와 동적 children
스프레드 속성·children은 “안전하게 변하지 않는 경우”와 그렇지 않은 경우에 따라 제약이 있습니다. 바인딩이 바뀌며 트리 형태가 비결정적으로 변하면 최적화가 깨지거나 경고가 날 수 있습니다. 핫 경로에서는 명시적 props와 고정 자식 구조를 선호하는 것이 좋습니다.
For와 리스트 최적화
블록 안에서는 map 대신 For
문서는 블록 내부에서 배열 .map으로 리스트를 그리는 패턴을 피하고, 대신 <For> 를 사용하라고 안내합니다. 리스트가 컴포넌트의 주된 비용이라면, 리스트를 담당하는 부분을 별도 블록으로 분리하거나, 상위를 일반 React로 두고 하위만 For로 구성하는 식의 경계 설계가 자주 쓰입니다.
import { For } from "million/react";
type Item = { id: string; label: string };
export function ItemList({ items }: { items: Item[] }) {
return (
<For each={items}>
{(item) => (
<div key={item.id} className="row">
{item.label}
</div>
)}
</For>
);
}
each에 넘기는 배열의 참조·항목 정체성이 바뀌는 방식은 성능과 동작 모두에 영향을 줍니다. 안정적인 key(문서 예시에서는 문자열 키)와 불필요한 배열 재생성 방지는 React에서와 같이 중요하며, Million에서는 추가로 블록 경계와의 상호작용까지 고려해야 합니다.
as로 태그 지정
For 문서에서는 as prop으로 태그 이름을 지정할 수 있다고 하며, 기본값은 slot입니다. 스타일·접근성·레이아웃에 맞게 루트 요소를 고정하면 디버깅이 쉬워집니다.
측정과 프로파일링
React DevTools Profiler
Million 도입 전후는 동일 시나리오에서 비교해야 합니다. React DevTools Profiler로 커밋별 렌더 시간, 어떤 컴포넌트가 자주 렌더되는지 확인합니다. Million은 블록 내부의 세부 갱신이 React의 “컴포넌트 리렌더” 관점과 다르게 보일 수 있으므로, 프레임 예산(16ms 등) 과 INP(상호작용 지연) 를 함께 보는 것이 좋습니다.
Performance 패널과 롱 태스크
Chrome Performance 녹화로 롱 태스크(Long Task) 와 메인 스레드 점유를 확인합니다. 리스트 스크롤·입력 필드처럼 연속 이벤트가 많은 UI에서는 작은 비용도 누적됩니다. Million 적용 후에도 데이터 처리·레이아웃 스래싱이 병목이면, 블록만으로는 한계가 있음을 분리해 인지해야 합니다.
벤치마크 해석 시 주의
공식 README는 js-framework-benchmark 등을 참고로 제시합니다. 벤치는 합성 워크로드이므로, 실제 앱의 네트워크·상태 관리·서드파티 스크립트까지 포함한 체감과는 다를 수 있습니다. 스파이크 페이지에서 Profiler + Performance + Lighthouse를 함께 쓰는 것을 권장합니다.
Next.js 통합
next.config에 Million 래퍼
커뮤니티 예시와 문서 흐름에 따르면, Next 설정을 million.next(...) 로 감싸 사용하는 패턴이 일반적입니다. 정확한 import 경로·옵션은 프로젝트의 Million 버전에 맞춰 공식 설치 가이드를 따르십시오.
// next.config.mjs (개념 예시 — 버전에 맞게 조정)
import million from "million/compiler";
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
};
export default million.next(nextConfig);
App Router를 쓰는 경우 서버 컴포넌트와 클라이언트 컴포넌트 경계가 React만 쓸 때보다 더 중요합니다. Million 블록을 쓰는 파일은 대개 "use client" 가 필요하며, 서버 전용 데이터 페칭과 섞일 때는 클라이언트 하위 트리로 분리하는 편이 안전합니다.
하이드레이션 이슈
GitHub 이슈에는 Next 14+와 Million 3+ 조합에서 하이드레이션 오류가 보고된 바 있습니다. 원인은 JSX 변환·엄격 모드·서버/클라이언트 렌더 차이 등 복합적일 수 있으므로, 최소 재현 저장소를 만들고 버전을 고정해 조사하는 것이 좋습니다. 의심스러운 블록은 ssr: false 로 범위를 좁혀 원인 분리를 시도할 수 있습니다.
주의사항과 제약
import 경로
block 등은 million/react 에서 가져와야 합니다. million 루트에서 가져오면 지원되지 않는 import로 경고가 날 수 있습니다.
UI 라이브러리와의 궁합
문서는 Material UI·Chakra 등 복합 컴포넌트가 블록 최적화에 불리할 수 있음을 명시합니다. 이상적인 핫 경로는 div·p 등 네이티브 DOM으로 펼치고, 디자인 시스템 컴포넌트는 상위 레이어에 두는 식으로 관심사를 분리하는 전략이 필요합니다.
점진적 저하(progressive degradation)
Million은 지원하지 않는 패턴에서 기본 React 렌더링으로 우아하게 떨어지는 쪽을 지향합니다. 즉 앱이 “깨지지”는 않을 수 있어도, 기대한 속도 이점은 사라질 수 있습니다. 콘솔 경고를 성능 회귀 신호로 읽는 습관이 중요합니다.
훅과 블록 경계
블록은 React 훅을 사용할 수 있지만, 앞서 말한 결정적 반환·조건부 훅 제약과 충돌하지 않게 설계해야 합니다. 팀 차원에서 블록 스타일 가이드(조건 분기 위치, early return 금지, 훅 호출 순서 고정)를 두면 유지보수에 유리합니다.
실전: 대규모 리스트 최적화
1) 가상 스크롤과 Million의 조합
수천·수만 행을 한 번에 DOM에 올리면 Million 여부와 관계없이 메모리·레이아웃 비용이 병목이 됩니다. 윈도잉(가상 스크롤) 으로 DOM 노드 수를 상한으로 두고, 보이는 구간만 each 배열에 담는 패턴이 일반적입니다. 이때 스크롤 위치에 따라 아이템 배열이 자주 바뀌므로, 불필요한 객체 생성을 줄이고 행 컴포넌트를 메모이즈하는 등의 미세 최적화와 병행합니다.
2) 안정적인 키와 데이터 모델
가상 스크롤에서 인덱스만 key로 쓰면 데이터가 밀릴 때 잘못된 재사용이 생깁니다. 도메인 ID를 키로 쓰고, 페이지네이션·무한 스크롤이라도 행 정체성이 유지되게 모델링합니다.
3) 리스트 행을 block으로 쪼개기
행 하나가 무거우면 행 블록으로 분리해 갱신 범위를 줄입니다. 다만 행 내부에서 비결정적 트리·스프레드 남발이 있으면 이득이 줄어듭니다. 행 = 얇은 DOM + 명시적 props를 목표로 잡습니다.
4) 상태 상향과 이벤트 비용
리스트 전체가 리렌더되지 않게 상태를 최소 단위로 분리하고, 검색·정렬 같은 무거운 연산은 useMemo·워커·서버 쿼리로 이동합니다. Million은 조정을 돕지만 알고리즘 복잡도 자체를 없애지는 않습니다.
5) 측정 루프
변경 후에는 Profiler → Performance → 필드 테스트 순으로 검증하고, 회귀가 있으면 해당 블록만 Million 비적용으로 되돌려 A/B하는 것이 안전합니다.
정리
Million.js는 React 생태계를 유지한 채 조정 비용을 줄이려는 도구입니다. block으로 핫 스폿을 명시하고, 리스트는 For로 표현하며, Next.js에서는 컴파일러 설정·클라이언트 경계·SSR 옵션을 함께 설계해야 합니다. UI 라이브러리·비결정적 반환·스프레드 등 제약을 이해하지 못한 채 도입하면 이득이 작거나 디버깅 비용만 커질 수 있습니다. 마지막으로 대규모 리스트는 Million과 별개로 가상 스크롤·데이터 구조·상태 분리가 기본 선이며, 이들과 함께 쓸 때 비로소 체감 성능이 안정적으로 개선됩니다.
참고
- Million.js 공식 문서·설치 가이드: https://million.dev
- 블록(
block) 수동 모드 설명(아카이브 문서): https://old.million.dev/docs/manual-mode/block - 블록 DOM 아이디어 배경: Virtual DOM: Back in Block (공식 블로그)
- 벤치마크 참고: js-framework-benchmark
배포 전에 git add·git commit·git push 후 npm run deploy를 실행하십시오.