Million.js 가이드 — 블록 기반 React 최적화, block·For·Next.js 설정과 제약

이 글의 핵심

Million.js는 React 위에서 동작하는 컴파일러·런타임으로, 핫 경로를 블록 단위로 최적화해 업데이트 비용을 줄입니다. Virtual DOM과의 차이, block()/For, Next.js 설정, 마이그레이션 순서, 제약·우회까지 실무 관점으로 정리합니다.

들어가며

Million.js는 React 16 이상과 Node 18 이상을 전제로 하는 오픈소스 최적화 도구입니다. 공식 자료는 같은 UI 패턴에서 렌더링과 업데이트 시간이 크게 줄어드는 벤치마크를 내세우지만, 실제 이득은 리스트 길이, 상태 변경 빈도, 서드파티 컴포넌트 비중, SSR과 하이드레이션 여부에 따라 크게 달라집니다. 이 글은 특정 개선 수치보다 어떤 구조에서 이득이 생기고 어떤 구조에서는 생기지 않는지를 이해하는 데 초점을 둡니다.

React는 상태가 바뀌면 컴포넌트를 다시 실행해 새 Virtual DOM(이하 VDOM) 트리를 만들고, 이전 트리와 비교(reconciliation)해 바뀐 부분만 실제 DOM에 반영합니다. 대부분의 화면에서는 이 비용이 문제되지 않지만, 초당 여러 번 갱신되는 대시보드 셀이나 긴 리스트처럼 같은 구조의 UI가 반복해서 업데이트되는 곳에서는 비교 비용이 누적됩니다. Million.js는 이런 구간을 “블록(block)“으로 만들어, 트리 전체를 비교하는 대신 바뀔 수 있는 값만 DOM에 직접 반영하는 경로를 제공합니다.

도입을 검토한다면 프로젝트 저장소의 최근 릴리스와 이슈를 먼저 확인하는 것이 좋습니다. 컴파일러가 JSX를 변환하는 방식이라 React의 메이저 버전이나 번들러가 바뀔 때 호환성 문제가 생길 수 있고, 유지보수 상태에 따라 도입 판단이 달라질 수 있기 때문입니다.


Million.js의 핵심 개념

컴파일러와 런타임

Million.js는 million 패키지와 번들러/프레임워크용 컴파일러 플러그인의 조합으로 동작합니다. 컴파일러가 빌드 시점에 JSX를 분석해 블록으로 바꿀 수 있는 부분을 변환하고, 런타임에서는 million/react의 block, For 등이 최적화된 업데이트를 수행합니다.

컴파일러 없이 런타임만 쓰는 것도 가능하지만, 공식 문서는 기능 제한이 크다며 권장하지 않습니다. 실무에서는 빌드 파이프라인에 컴파일러를 넣는 것을 전제로 합니다.

설치

공식 문서는 npx million@latest CLI로 패키지 설치와 설정 파일 수정을 한 번에 하는 방법을 안내합니다. 빌드 구성이 특이해 CLI가 맞지 않으면 npm install million 후 번들러별 컴파일러 플러그인을 직접 연결합니다. Next.js, Vite, Webpack, Astro 등 주요 도구별 설정 예시가 문서에 있습니다.

자동 모드와 수동 모드

자동(automatic) 모드는 컴파일러가 코드를 분석해 최적화할 수 있는 컴포넌트를 스스로 골라 블록으로 바꿉니다. Next.js에서는 million.next(nextConfig, { auto: true })로 시작합니다. 진입 장벽이 낮은 대신 어떤 컴포넌트가 바뀌었는지 직접 통제하기 어렵습니다.

수동(manual) 모드는 개발자가 block()과 <For>를 명시해 최적화할 구간을 정합니다. 어느 부분이 블록인지 코드에 드러나므로 디버깅과 성능 측정이 쉽습니다.

자동 모드로 먼저 도입해 기능 회귀가 없는지 확인한 뒤, 프로파일링으로 찾은 병목 구간만 수동으로 블록화하는 점진적 방식이 흔합니다.

지원하지 않는 패턴은 React로 폴백

Million.js는 지원하지 않는 패턴을 만나면 경고를 낼 수 있지만, 앱을 깨뜨리는 대신 해당 부분을 일반 React 렌더링으로 처리하는 방향으로 설계되어 있습니다. 그래서 처음부터 모든 컴포넌트를 블록 규칙에 맞추지 않아도 앱은 동작합니다. 다만 성능 이점은 규칙을 지킨 블록에서만 생깁니다.


Virtual DOM 최적화 원리

React VDOM이 하는 일

React는 UI를 상태의 함수로 표현하며, 상태가 바뀌면 새 트리를 만들고 이전 트리와 비교해 필요한 DOM 변경을 계산합니다. 이 모델은 예측 가능하고 생산적이지만, 비교 비용은 바뀐 값의 수가 아니라 다시 렌더링한 트리의 크기에 비례합니다. 셀 하나의 값만 바뀌어도 그 셀이 속한 컴포넌트 트리 전체를 다시 만들고 비교합니다.

Million.js가 줄이려는 비용

블록은 컴파일 시점에 JSX에서 정적인 부분(태그 구조, 고정 속성)과 동적인 부분(props에서 오는 값)을 분리해 둡니다. 업데이트할 때는 트리를 다시 비교하지 않고 동적인 값들만 이전 값과 비교해 해당 DOM 노드를 직접 고칩니다. 그래서 비용이 트리 크기가 아니라 동적 값의 개수에 가까워지며, 구조는 그대로이고 값만 바뀌는 변경에서 이득이 큽니다.

Million.js가 React 모델을 버리는 것은 아닙니다. React 컴포넌트 경계 안에서 특정 서브트리만 다른 방식으로 갱신하므로, 상태 관리, 훅, 이벤트는 여전히 React 규칙을 따릅니다.

컴파일러가 필요한 이유

정적 부분과 동적 부분을 나누려면 JSX 구조를 빌드 시점에 분석할 수 있어야 합니다. 컴파일러는 JSX를 Million이 최적화할 수 있는 형태로 바꾸면서, 훅 호출 위치 같은 React 규칙과 충돌하지 않게 정리합니다. 컴파일러가 빌드에 포함되지 않았을 때 Invalid Hook Call 같은 오류가 보고되는 이유가 이것입니다.


block()과 <For>

block() 기본 형태

block은 컴포넌트를 받아 최적화된 컴포넌트를 돌려주는 고차 컴포넌트처럼 동작하며, million/react에서 import해야 합니다.

// LionBlock.tsx
import { block } from 'million/react';

const LionBlock = block(function Lion() {
  return <img src="https://million.dev/lion.svg" alt="Million" />;
});

export default LionBlock;

블록의 래퍼 태그를 바꾸고 싶다면 두 번째 인자로 { as: 'article' }처럼 as 옵션을 줍니다. 기본 래퍼 요소가 레이아웃이나 시맨틱 HTML과 맞지 않을 때 씁니다.

import { block } from 'million/react';

const CardBlock = block(
  function Card() {
    return <p>내용</p>;
  },
  { as: 'article' },
);

컴파일러가 블록을 분석하려면 변수 선언으로 정의해야 합니다. export default block(...)처럼 한 줄로 내보내지 말고 const X = block(...); export default X 형태로 씁니다. 인자로는 컴포넌트 함수를 넘겨야 하며, block(<Component />)처럼 이미 만든 JSX 요소를 넘기면 안 됩니다.

블록 안에서의 조건부 렌더링

문서는 블록이 항상 같은 구조를 반환하는 결정적(deterministic) 반환을 권장합니다. 여러 return 분기에서 서로 다른 구조의 트리를 반환하면 미리 분석해 둔 정적 구조가 맞지 않아 이점이 사라지고, 조건에 따라 훅 호출 수가 달라지면 React 훅 규칙도 어기게 됩니다. 분기는 블록 바깥의 부모 컴포넌트에서 처리하고, 블록 안은 하나의 return과 고정된 구조를 유지하도록 리팩터링합니다.

<For>로 리스트 처리

블록 안에서 .map()으로 리스트를 렌더링하는 것은 권장되지 않습니다. 대신 For 컴포넌트에 배열을 each prop으로 넘기고, 자식으로 각 아이템을 렌더링하는 함수를 줍니다.

import { For } from 'million/react';
import { useState } from 'react';

export function App() {
  const [items, setItems] = useState([1, 2, 3]);

  return (
    <>
      <button type="button" onClick={() => setItems([...items, items.length + 1])}>
        아이템 추가
      </button>
      <ul>
        <For each={items}>
          {(item) => <li>{item}</li>}
        </For>
      </ul>
    </>
  );
}

For도 as prop으로 래퍼 태그를 바꿀 수 있습니다. 위 예처럼 <ul> 안에 넣으면 래퍼 요소가 <ul>과 <li> 사이에 끼므로, DOM 구조나 CSS 선택자가 그 사이에 다른 요소가 없다고 가정하고 있다면 깨질 수 있습니다.

For의 memo prop

문서에 따르면 For는 예기치 않은 동작을 피하려고 기본적으로 블록 재사용을 제한합니다. 각 아이템이 오직 item 값에만 의존한다고 확신할 때 memo prop을 켜면 재사용으로 메모리와 생성 비용을 줄일 수 있습니다. 아이템 렌더링이 바깥의 상태나 컨텍스트에 의존한다면 그 상태가 바뀌어도 아이템이 갱신되지 않을 수 있으므로, 이런 셀에는 켜지 않는 편이 안전합니다.

SSR과 ssr: false

Million은 서버와 클라이언트에서 렌더링 경로가 달라, Math.random()이나 현재 시각처럼 렌더링마다 바뀌는 값이 블록에 들어가면 하이드레이션 불일치가 생길 수 있습니다. 이때 block의 옵션 ssr: false나 For의 ssr={false}로 해당 부분의 SSR을 끄는 방법이 문서에 나옵니다. SSR을 끈 부분은 초기 HTML에 내용이 들어가지 않으므로, 검색 노출이 중요한 콘텐츠에는 신중히 적용해야 합니다.


Next.js 통합

next.config에 컴파일러 연결

공식 문서의 예시는 다음과 같은 형태입니다.

// next.config.mjs
import million from 'million/compiler';

/** @type {import('next').NextConfig} */
const nextConfig = {
  reactStrictMode: true,
};

const millionConfig = {
  auto: true,
  // React Server Components를 쓰는 경우:
  // auto: { rsc: true },
};

export default million.next(nextConfig, millionConfig);

App Router에서는 'use client'가 붙은 클라이언트 컴포넌트가 최적화 대상입니다. 서버 컴포넌트는 브라우저에서 다시 렌더링되지 않으므로 블록으로 만들 이유도 없습니다. 서버 컴포넌트 비중이 큰 프로젝트에서는 최적화할 수 있는 범위가 클라이언트 경계 안쪽으로 좁아진다는 점을 염두에 둬야 합니다.

Pages Router와의 공존

문서는 Pages Router와 App Router를 모두 지원한다고 안내합니다. 기존 /pages 기반 코드에도 같은 방식으로 컴파일러를 연결할 수 있지만, 커스텀 Babel 설정이나 다른 JSX 변환 플러그인이 있으면 변환 순서가 충돌해 빌드 설정을 조정해야 할 수 있습니다.

도입 시 흔한 이슈

GitHub 이슈에서 자주 보고되는 유형은 컴파일러가 실제로 적용되지 않아 생기는 런타임 경고와 훅 오류, 서드파티 UI 컴포넌트와 블록 규칙의 충돌, SSR 하이드레이션 불일치입니다. 문제가 생기면 먼저 빌드 출력이나 경고 메시지로 컴파일러가 적용됐는지 확인하고, 문제 구간을 일반 React로 되돌려 재현 범위를 줄인 다음, 블록 안을 단순한 DOM 구조로 다시 구성하는 순서로 접근합니다.


마이그레이션 전략

단계적 도입

먼저 React DevTools Profiler, Chrome Performance 패널, Core Web Vitals(특히 INP)로 실제로 느린 화면을 고릅니다. 측정 없이 전체에 적용하면 이득이 있었는지 판단할 수 없습니다.

다음으로 auto: true로 컴파일러만 켜고 빌드, 테스트, 스테이징 배포를 거쳐 기능 회귀가 없는지 확인합니다. 그 뒤 대시보드 테이블, 채팅, 피드처럼 같은 행 UI가 반복되는 곳부터 For로 바꾸고, 행 컴포넌트를 조건부 return이 적은 단순한 구조로 리팩터링하면서 block으로 감쌉니다. SSR이 필요한 페이지는 ssr 옵션과 그 콘텐츠의 검색 노출 필요성을 함께 검토합니다.

롤백 경로도 미리 정해 두는 것이 좋습니다. 컴파일러 설정을 환경 변수로 켜고 끌 수 있게 해 두면, 문제가 생긴 배포를 코드 변경 없이 되돌리거나 특정 라우트의 블록만 제거해 비교할 수 있습니다.

리스트 마이그레이션 예시

블록 안에서 .map()을 쓰는, 문서가 권장하지 않는 형태입니다.

// 블록 안에서 map: 경고나 성능 저하가 생길 수 있음
const RowListBlock = block(function RowList({ items }: { items: string[] }) {
  return (
    <ul>
      {items.map((id) => (
        <li key={id}>{id}</li>
      ))}
    </ul>
  );
});

For로 바꾼 형태입니다.

import { block, For } from 'million/react';

const RowListBlock = block(function RowList({ items }: { items: string[] }) {
  return (
    <ul>
      <For each={items}>{(id) => <li>{id}</li>}</For>
    </ul>
  );
});

행을 더 세밀하게 최적화하려면 Row를 별도 block으로 분리하고, 부모는 데이터만 내려주는 얇은 컨테이너로 둡니다. 그러면 어느 단위에서 시간이 드는지 프로파일링 결과로 구분하기도 쉬워집니다.


성능 벤치마크 — 숫자를 올바르게 읽기

외부 벤치마크의 조건

Million.js를 소개하는 자료에는 기존 React보다 렌더링 시간이 크게 줄었다는 수치가 자주 인용됩니다. 하지만 벤치마크 결과는 리스트 길이와 key의 안정성, 상태가 어디에 있는지, 프로덕션 빌드인지 개발 모드인지, 브라우저와 CPU 성능, Strict Mode나 동시성 기능 사용 여부에 따라 크게 달라집니다. 외부 수치는 어떤 조건에서 이득이 나는지 보는 참고 자료로만 쓰고, 도입 여부는 자기 앱에서 측정한 결과로 판단해야 합니다.

직접 측정하는 방법

1,000행 리스트를 스크롤하거나 필터를 토글하는 것처럼 같은 시나리오를 고정하고, 프로덕션 번들로 측정합니다. 입력 반응성이 중요한 화면은 INP를 따로 봅니다.

React DevTools Profiler의 컴포넌트 렌더 횟수는 Million 블록 내부의 갱신을 React 컴포넌트 렌더로 표시하지 않으므로, 도입 후 렌더 횟수가 줄어 보이는 것 자체를 성과로 읽으면 안 됩니다. Chrome Performance 패널에서 같은 조작의 롱 태스크(50ms 이상)와 메인 스레드 점유 시간을 전후로 비교하는 것이 더 정확한 지표입니다. 회귀가 보이면 해당 블록만 Million을 끄고 비교해 원인을 좁힙니다.

대규모 리스트에서 Million보다 먼저 볼 것

리스트가 수천, 수만 행이면 병목은 대개 재조정이 아니라 DOM 노드 수 자체입니다. Million은 재조정 비용을 줄일 뿐 DOM을 줄이지 않으므로, 다음 순서로 접근하는 편이 효과가 큽니다.

  1. 가상 스크롤(윈도잉)로 화면에 보이는 행만 DOM에 올립니다. 이것만으로 대부분의 문제가 해결됩니다.
  2. key를 안정적으로 둡니다. 가상 스크롤에서 인덱스를 key로 쓰면 스크롤할 때 다른 데이터가 같은 DOM을 재사용해 입력 상태가 엉뚱한 행으로 옮겨 갑니다. 도메인 ID를 key로 씁니다.
  3. 상태 위치를 정리합니다. 검색어 하나 바뀔 때 리스트 전체가 다시 렌더링되지 않도록 상태를 필요한 컴포넌트까지만 내리고, 정렬과 필터 같은 무거운 연산은 useMemo나 서버 쿼리로 옮깁니다. Million은 알고리즘의 복잡도를 줄여 주지 않습니다.
  4. 그래도 행 갱신이 병목으로 측정되면 그때 행을 block으로 만듭니다. 행 안에 스프레드 props나 조건부 구조가 많으면 블록의 이점이 줄어드므로, 단순한 DOM과 명시적인 props로 정리한 뒤 적용합니다.

제약사항과 우회 방법

UI 컴포넌트 라이브러리

문서는 블록 안에서 MUI, Chakra 같은 추상 컴포넌트를 쓰면 성능이 오히려 떨어질 수 있다고 경고합니다. 이런 컴포넌트는 내부에서 다시 React 트리를 만들기 때문에 블록이 정적 구조를 미리 분석할 수 없습니다. 블록 안은 기본 DOM 요소와 클래스로 구성하고, 디자인 시스템 컴포넌트는 블록 바깥이나 자주 갱신되지 않는 레이어에 둡니다.

Spread props와 children

{...props}처럼 펼쳐 넣는 props는 어떤 속성이 들어올지 컴파일 시점에 알 수 없어 블록에서 제한됩니다. 필요한 props를 명시적으로 나열하고, children도 구조가 고정된 형태로 넘기는 편이 안전합니다.

<YourBlock>텍스트</YourBlock>처럼 블록에 임의의 자식을 넣는 것도 경고 대상이 될 수 있습니다. 블록은 props로만 제어되는 단순한 트리일 때 안정적이므로, 슬롯형 레이아웃이 필요하면 블록 바깥에서 합성하거나 슬롯 내용을 props로 넘기는 방식을 검토합니다.

조건부 return과 훅

블록 규칙과 React 훅 규칙이 겹치는 곳에서 디버깅하기 어려운 오류가 납니다. 블록을 작게 나누고, 조건 분기는 상위 컴포넌트에서 처리합니다.

SSR 하이드레이션

해당 블록만 ssr: false/ssr={false}로 클라이언트에서 렌더링하거나, 서버에서는 렌더링마다 같은 결과가 나오는 데이터만 내려보냅니다.

트러블슈팅 빠른 참조

증상점검할 것
[Million.js] Block needs to be defined as a variable declarationconst X = block(...) 후 export default X로 바꿨는지
Invalid Hook Call컴파일러가 빌드에 실제로 포함됐는지, React가 중복 설치되지 않았는지
Found unsupported import for blockimport { block } from 'million/react'인지
하이드레이션 불일치Math.random()·시간·로케일 등 렌더링마다 바뀌는 값, ssr: false 검토
성능이 기대만큼 안 나옴블록 안 추상 컴포넌트 비중, .map 잔존 여부, DOM 노드 수

같이 보면 좋은 글