# 01 — 스택 선택과 셋업 > 기준 시점: **2026-08-20**. 이 문서의 모든 코드는 아래 "버전 스냅샷"에서 검증한 API를 기준으로 작성했다. > 버전 의존적인 API에는 `[r1XX+]` 표기를 붙였다. --- ## 0. 버전 스냅샷 | 패키지 | 버전 | 비고 | |---|---|---| | `three` | **0.185.1** | r185. r163부터 WebGL1 미지원(WebGL2 전용) | | `@react-three/fiber` | **9.7.0** | peer: `react >=19 <19.3`, `three >=0.156` | | `@react-three/drei` | **10.7.8** | peer: `react ^19`, `three >=0.159`, `@react-three/fiber ^9` | | `@react-three/postprocessing` | **3.0.5** | peer: `three >= 0.182`, `postprocessing ^6.36`, `fiber >=9.7` | | `postprocessing` | **6.39.4** | peer: `three >= 0.168.0 < 0.186.0` ← **상한 존재. three 0.186부터 깨짐** | | `gsap` | **3.15.0** | 3.13(2025-04)부터 ScrollTrigger/SplitText/MorphSVG 포함 **전 플러그인 무료** | | `lenis` | **1.3.26** | 스무스 스크롤. `respectReducedMotion` 기본 `true` | | `troika-three-text` | **0.52.5** | peer: `three >=0.125`. SDF 텍스트 | | `detect-gpu` | 5.x | GPU tier 0~3 판정 | | `vite-plugin-glsl` | 1.6.x | `.glsl/.vert/.frag` import + `#include` | ### 🔴 버전 조합 함정 (가장 흔한 사고) 1. **`postprocessing`의 three 상한**: `>= 0.168.0 < 0.186.0`. `three@0.186`으로 올리면 포스트프로세싱이 깨진다. 포스트프로세싱을 쓸 거면 **three를 0.185.x에 고정**한다. 2. **R3F v9는 React 19 전용**이고 `react >=19 <19.3`. React 19.2.x에서 내부 reconciler가 비호환 변경돼서 R3F가 reconciler를 번들에 포함시켰다. React 19.3+에서는 R3F v10을 기다려야 한다. 3. **package.json에 three를 한 번만**: `three`가 중복 설치되면 `instanceof` 검사가 전부 깨진다. 모노레포/pnpm에서는 `resolutions`/`overrides`로 단일화한다. ```json // package.json — 안전한 핀 { "dependencies": { "three": "0.185.1", "@react-three/fiber": "^9.7.0", "@react-three/drei": "^10.7.8", "@react-three/postprocessing": "^3.0.5", "postprocessing": "^6.39.4" }, "overrides": { "three": "0.185.1" }, "pnpm": { "overrides": { "three": "0.185.1" } } } ``` --- ## 1. Vanilla three.js vs React Three Fiber — 결정 기준 ### 한 줄 요약 **페이지가 React가 아니면 vanilla. React면 R3F.** 그 외 판단은 아래 표. | 상황 | 권장 | 이유 | |---|---|---| | 정적 랜딩페이지(HTML/Astro/Webflow 등), 히어로 배경 셰이더 1개 | **Vanilla** | React 런타임(~45KB gz) 불필요. three만 15~90KB gz | | 스크롤 전체를 지배하는 WebGL(카메라 이동, 섹션 전환) | **Vanilla + GSAP/Lenis** | 명령형 타임라인이 스크롤 연출과 궁합이 좋음 | | DOM 이미지 다수를 WebGL 플레인으로 치환 | 둘 다 가능 (Vanilla 약간 유리) | DOM ↔ WebGL 좌표 동기화는 어차피 수동 | | Next.js/React 앱 내부의 3D 섹션, 제품 컨피규레이터 | **R3F** | 상태-씬 바인딩, Suspense 로딩, drei 재사용 | | 3D 모델 + 환경광 + 그림자 + 포스트프로세싱 조합 | **R3F + drei** | ``, ``, `` 로 하루치 작업이 10줄 | | 팀에 React 개발자만 있음 | **R3F** | 유지보수 비용이 성능 손해보다 큼 | | 극한 성능(모바일 1st paint < 1.5s, 번들 < 150KB) | **Vanilla** | 트리셰이킹 통제권이 완전함 | ### 비용 비교 (gzip, 실측 근사) | 구성 | 크기 | |---|---| | three 코어만 (`WebGLRenderer` + `Scene` + `PlaneGeometry` + `ShaderMaterial`) | **~90 KB** (실제 트리셰이킹 후) | | three 전체 번들 import | ~170 KB | | + `@react-three/fiber` | +30 KB | | + `react` + `react-dom` | +45 KB | | + `@react-three/drei` (전체 import 시) | +100 KB 이상 → **반드시 named import** | | + `postprocessing` (Bloom만) | +25 KB | | + `gsap` core + ScrollTrigger | +40 KB | | + `lenis` | +5 KB | > **현실 체크**: three는 트리셰이킹이 완전하지 않다. `WebGLRenderer` 하나가 셰이더 청크 전체를 끌고 온다. > "three 쓰면 최소 90KB gz"를 예산의 바닥으로 잡아라. 이보다 작게 만들려면 three가 아니라 raw WebGL/OGL을 써야 한다. ### 혼합 전략 (추천 기본값) 랜딩페이지에서 가장 안전한 구조는 **"WebGL은 지연 로드되는 장식 레이어"**다. ``` DOM(SSR/정적) = 콘텐츠·SEO·LCP 담당 └ = 장식. dynamic import, IntersectionObserver로 첫 진입 시 로드 ``` 이렇게 하면 JS 번들이 초기 경로에서 빠지고, WebGL 실패/저사양/`prefers-reduced-motion`에서 DOM만 남아도 페이지가 성립한다. --- ## 2. 설치 ### Track A — Vanilla + Vite ```bash npm create vite@latest my-site -- --template vanilla cd my-site npm i three@0.185.1 npm i -D vite-plugin-glsl # 선택 npm i gsap lenis npm i troika-three-text npm i postprocessing@^6.39.4 npm i detect-gpu ``` ### Track B — R3F + Vite (React 19) ```bash npm create vite@latest my-site -- --template react-ts cd my-site npm i three@0.185.1 @react-three/fiber@^9.7.0 @react-three/drei@^10.7.8 npm i -D @types/three vite-plugin-glsl # 선택 npm i @react-three/postprocessing@^3.0.5 postprocessing@^6.39.4 npm i gsap lenis maath npm i -D r3f-perf leva ``` ### Track C — Next.js (App Router) + R3F ```bash npx create-next-app@latest my-site --typescript cd my-site npm i three@0.185.1 @react-three/fiber@^9.7.0 @react-three/drei@^10.7.8 npm i -D @types/three ``` Next.js에서는 Canvas를 **반드시 클라이언트 전용 동적 임포트**로 감싼다. ```tsx // components/SceneClient.tsx 'use client' import dynamic from 'next/dynamic' const Scene = dynamic(() => import('./Scene'), { ssr: false, loading: () =>