ThreeUI Community는 유료 제품 threeui.com의 무료 부분만 통째로 떼어 공개한 저장소입니다. 안에는 두 가지가 같이 들어 있습니다.
@designcodeio/threeui.컴포넌트는 웹 배경·히어로 섹션·버튼·텍스트 애니메이션·랜딩페이지 같은 것들이고, 대부분 WebGL 셰이더나 Three.js 3D 장면으로 그려집니다. 만든 사람은 Meng To — 디자인 교육 사이트 Design+Code를 만든 사람입니다.
공개일이 2026년 8월 21일, 이 문서를 쓰는 8월 24일 기준 별 2,819개·포크 283개. 사흘 만입니다.
본점(비공개 저장소)에는 유료 메뉴와 무료 메뉴가 섞여 있습니다. 손님에게 공개하는 분점(이 저장소)에는 무료 메뉴만 있어야 합니다.
보통은 사람이 옮깁니다. 그러다 유료 레시피 한 줄이 딸려 나오면 끝입니다. ThreeUI는 여기를 기계에 맡겼습니다 — 본점이 바뀔 때마다 자동으로 잘라내 분점으로 보내고, 잘못 딸려 나온 게 없는지 사람 눈이 아니라 실패하는 테스트가 확인합니다. 이 저장소에서 배울 게 가장 많은 부분이 여기입니다.
사흘 만에 2.8k는 코드 품질만으로 나오는 숫자가 아닙니다. 세 가지가 겹쳤습니다.
| 비교 대상 | 성격 | ThreeUI와의 차이 |
|---|---|---|
| shadcn/ui | 복사·붙여넣기 컴포넌트 | shadcn은 접근성·구조가 중심인 평면 UI. ThreeUI는 GPU로 그리는 시각 효과가 중심이라 겹치지 않는다 |
| Magic UI · Aceternity UI | 화려한 모션 컴포넌트 | 가장 가까운 경쟁. 다만 그쪽은 CSS·Framer Motion 위주고 ThreeUI는 Three.js/WebGL 실물 3D가 많다 |
| React Three Fiber | Three.js의 React 바인딩 | R3F는 도구, ThreeUI는 완성품. 실제로 이 저장소는 R3F를 안 쓰고 Three.js를 직접 다룬다 |
| Spline · Rive | 3D/모션 저작 도구(SaaS) | 그쪽은 에디터에서 만들어 런타임으로 삽입. ThreeUI는 소스 코드가 곧 결과물이라 수정 한계가 없다 |
다만 개인적으로 가장 값어치 있는 부분은 컴포넌트가 아닙니다. 오픈코어를 코드로 강제하는 방식입니다. 비슷한 처지의 프로젝트(유료 제품 + 오픈소스 판)는 많은데, 그 경계 관리를 이렇게 노골적으로 공개한 사례는 드뭅니다.
scripts/ 안에 공개 경계 감사·빌드 감사·릴리스 등급 추론·설치 스모크 테스트가 전부 들어 있습니다. 합쳐서 1,639줄이고, 전부 의존성 없는 순수 Node 스크립트입니다. 내 프로젝트로 그대로 베껴 갈 수 있는 크기라는 게 핵심입니다.
| 항목 | 선택 | 눈여겨볼 점 |
|---|---|---|
| UI | React 19.2 (개발) / peer >=18 <20 | 패키지 사용자는 React 18이든 19든 된다. 저장소 자체는 19로 개발 |
| 언어 | TypeScript 5.9, strict: true | moduleResolution: "Bundler", isolatedModules. 사이트 빌드는 noEmit, 라이브러리 빌드만 .d.ts를 뽑는다 |
| 번들러 | Vite 7.3 | 설정 파일이 두 개다 — 사이트용·라이브러리용 (아래 4장) |
| 라우팅 | 없음. 직접 만든 src/routes.js | react-router 같은 의존성 0. history.pushState + popstate로 200줄 안에 끝냈다 |
| 스타일 | 순수 CSS 한 덩어리 (src/styles.css 91KB) | Tailwind도 CSS-in-JS도 없다. 테마는 <html>의 data-theme·data-scheme·data-palette 속성으로만 갈린다 |
| 상태 | React 훅 + localStorage | 전역 상태 라이브러리 없음. 테마·조회수 모두 브라우저에만 저장 |
보통 프로젝트는 Three.js를 한 버전 씁니다. 이 저장소는 세 개가 동시에 살아 있습니다.
// package.json
"dependencies": {
"three128": "npm:three@0.128.0", ← 별명 three128 = Three.js 0.128
"three165": "npm:three@0.165.0" ← 별명 three165 = Three.js 0.165
},
"peerDependencies": {
"three": ">=0.149 <1" ← 쓰는 쪽이 준비하는 '기본' 버전
}
이걸 npm alias라고 부릅니다. 별명: "npm:패키지@버전" 문법으로 같은 패키지의 다른 버전을 다른 이름으로 동시에 설치합니다. 그러면 컴포넌트마다 자기가 검증된 버전을 골라 import할 수 있습니다.
// src/shaders/warp-field/warpFieldRenderer.ts
import * as THREE from "three128"; ← 0.128에서 동작이 검증된 장면
Three.js는 버전마다 미묘하게 달라지는 것으로 악명이 높습니다. 재질(material)의 기본 색 공간이 바뀌거나, 지오메트리 API 이름이 바뀌거나, 렌더러 옵션이 사라집니다. 몇 년에 걸쳐 만든 시각 효과 100개를 최신 버전 하나로 통일하려면 전부 다시 손봐야 합니다.
그래서 고치는 대신 얼려 버렸습니다. 2021년에 만든 장면은 0.128을 계속 쓰고, 최근 것은 0.165를 씁니다. "언젠가 마이그레이션한다"를 무한정 미루는 대신 미루는 것 자체를 설계로 못 박은 셈입니다.
셰이더 코드(GLSL)는 .glsl 파일이 아니라 TypeScript 템플릿 문자열로 들어 있습니다. 별도 로더 플러그인이 필요 없어지고, 타입스크립트가 그대로 export할 수 있습니다.
// src/shaders/bell-field/bellFieldShaders.ts — 실제 코드 발췌
export const BELL_FIELD_FRAGMENT_SHADER = `
precision highp float;
uniform vec2 u_resolution;
uniform float u_time;
uniform vec2 u_mouse;
// 원형 진동 모드의 베셀 포락선을 대신하는 감쇠 코사인
float bess(float x) { return cos(x - 0.785398) / sqrt(1.0 + abs(x)); }
...
`;
| 항목 | 내용 |
|---|---|
| CI | GitHub Actions 3종 — ci.yml(빌드+설치 스모크) · community-sync-pr.yml(동기화 PR 자동 개설) · publish-npm.yml(npm 발행) |
| Node | 24 고정 (actions/setup-node@v7) |
| npm 발행 | Trusted Publishing + provenance. id-token: write 권한으로 OIDC 토큰을 받아 발행 — npm 토큰을 저장소 시크릿에 두지 않는다 |
| 발행 조건 | package.json·lock·CLI package.json이 바뀐 push만. 게다가 npm view로 같은 버전이 이미 있으면 건너뛴다 |
| 배포 패키지 | 2개 — @designcodeio/threeui(React 컴포넌트) · @designcodeio/threeui-cli(유료 소스 설치기) |
| 분석 | Google Analytics(gtag)가 index.html에 그대로 박혀 있음 — 포크할 때 지워야 한다 |
오른쪽 두 갈래가 이 설계의 핵심입니다. 무료 컴포넌트는 npm에 통째로 넣고, 유료 컴포넌트는 npm에 아예 올리지 않습니다. 유료 쪽은 CLI가 브라우저 로그인을 거쳐 요청할 때마다 서버가 권한을 확인하고 내려줍니다. npm에 올려 두고 라이선스 키로 잠그는 방식은 애초에 성립하지 않는다는 판단입니다 — npm에 올라간 코드는 누구나 받을 수 있으니까요.
이 저장소의 알맹이는 사람이 쓴 게 아니라 스크립트가 뱉은 것입니다.
// src/index.ts 첫 줄
// Generated by scripts/generate-library-entry.mjs. Do not edit by hand.
// src/data/mainCatalogSummary.ts
// Updated by scripts/sync-community-from-main.mjs.
export const FULL_THREEUI_COLLECTION_COUNT = 263;
generate-library-entry.mjs가 하는 일이 재밌습니다. 카탈로그 파일 shaders.tsx를 정규식으로 훑어 lazy(() => import("../shaders/...")) 패턴을 전부 뽑아낸 다음, 컴포넌트 하나당 파일 하나씩 src/package-components/*.ts 100개를 기존 것을 지우고 새로 씁니다. 그리고 이름이 겹치면 그 자리에서 에러를 던집니다.
// scripts/generate-library-entry.mjs 요지
const lazyPattern = /import\("(\.\.\/shaders\/[^"]+)"\)\.then\(...module\.(\w+)...\)/g;
const names = new Set();
for (const item of exports) {
if (names.has(item.name)) throw new Error(`Duplicate Community export: ${item.name}`);
names.add(item.name);
}
컴포넌트가 100개면 배럴 파일(export를 모아 놓은 index)을 손으로 유지하는 건 지는 싸움입니다. 하나 추가할 때마다 index.ts·subpath export·타입 선언 세 군데를 같이 고쳐야 하고, 언젠가 반드시 하나를 빠뜨립니다.
ThreeUI는 그 세 군데를 카탈로그 파일 하나에서 파생시킵니다. 카탈로그가 진실이고 나머지는 그림자입니다. 그림자를 손으로 고치면 다음 생성 때 조용히 사라집니다.
감사 스크립트가 두 겹입니다.
audit-public.mjs — 소스 트리 검사 (32줄). 저장소 전체를 걸어 다니며 다음이 하나라도 있으면 빌드를 실패시킵니다.
.env 계열 파일/Users/누구/ 형태의 개발자 로컬 경로 — 실명이 새는 흔한 경로-----BEGIN ... PRIVATE KEY-----)api_key = "20자 이상 문자열" 같은 비밀처럼 생긴 대입문audit-build.mjs — 빌드 결과물 검사 (43줄). 이쪽이 더 흥미롭습니다. dist/의 모든 텍스트 파일을 하나로 이어 붙인 뒤 금지 패턴 목록을 돌립니다.
// scripts/audit-build.mjs — 금지 시그니처 (일부)
/@supabase\// 인증 백엔드가 딸려 나왔나
/supabase\.auth/ /createClient\s*\(/
/AuthProvider/ /AccountButton/ /OAuthConsent/
/create-checkout-session/i /stripe-webhook/i 결제 런타임
/VITE_SUPABASE/ 환경변수 이름조차 금지
/threeui\.netlify\.app/ 비공개 호스트 주소
/sf-(?:bold|light|medium|regular|semibold)\.woff2/ 재배포 불가 폰트
+ 소스맵(.map) 한 개라도 있으면 실패
여기서 감사 대상이 "소스"가 아니라 "빌드 결과물"이라는 게 핵심입니다. 소스만 보면 조건부 import나 동적 참조로 딸려 들어온 코드를 놓칩니다. 번들러가 실제로 묶어 낸 것을 검사하면 그 구멍이 막힙니다.
세 번째 겹은 public-boundary.test.mjs(232줄)입니다. 이건 Vite를 테스트 안에서 직접 띄워(createServer + ssrLoadModule) 카탈로그 모듈을 실제로 불러온 뒤, 동기화 리포트의 숫자와 대조합니다.
// 카탈로그 실물과 리포트가 한 글자도 어긋나면 안 된다
assert.equal(visible.length, report.communityParents);
assert.equal(allRoutes.length, report.communityRoutes);
assert.ok(report.excludedProParents > 0); ← 걸러낸 게 0이면 그것도 이상
assert.ok(report.excludedBetaParents > 0);
유료 표식 필드가 남아 있으면 실패
assert.ok(allRoutes.every(s => s.status === undefined && s.access === undefined));
보통은 "나오면 안 되는 게 없는지"만 봅니다. 이 테스트는 "걸러낸 게 있기는 한지"도 확인합니다. 필터가 통째로 고장 나서 아무것도 안 걸러도, 앞의 검사만으로는 통과할 수 있기 때문입니다. 필터를 테스트할 땐 필터가 죽은 경우를 반드시 넣어야 합니다.
prepare-community-release.mjs는 이번 동기화가 patch냐 minor냐 major냐를 사람에게 묻지 않고 계산합니다. 방법은 단순합니다 — 공개된 것들을 문자열 토큰 집합으로 펼친 뒤, 이전 것과 차집합을 냅니다.
// 컴포넌트 하나가 이런 토큰들로 펼쳐진다
component:liquid-metal-button
variant:liquid-metal-button:circle
control:liquid-metal-button:diameter
variant-control:liquid-metal-button:circle:strokeWidth
// 그리고 판정
removed.length ? "major" 뭔가 사라졌다 = 깨는 변경
: added.length ? "minor" 뭔가 늘었다 = 기능 추가
: "patch"; 겉모습은 그대로 = 내부 수정
여기에 안전장치가 하나 더 있습니다. 사람이 --type patch로 추론값보다 낮은 등급을 강제하면 에러를 던집니다. 올려 잡는 건 되고 내려 잡는 건 안 됩니다.
1.4.2 형태에서 앞부터 major·minor·patch. major는 "쓰던 코드가 깨질 수 있음", minor는 "기능이 늘었지만 기존 건 그대로", patch는 "내부만 고침"입니다. 라이브러리 사용자는 이 숫자만 보고 업데이트 위험도를 판단하므로, 잘못 매기면 남의 빌드를 깹니다. ThreeUI는 이 판단을 사람 손에서 뺏어 왔습니다.컴포넌트를 화면에 그리는 방식이 두 갈래로 갈립니다.
경로 B는 처음 보면 이상해 보입니다. React 프로젝트에서 왜 통짜 HTML을 iframe에 넣을까요?
랜딩페이지처럼 자기만의 CSS 리셋·전역 스타일·스크롤 로직을 가진 완성품을 React 트리 안에 그냥 넣으면 카탈로그 사이트의 스타일과 서로 잡아먹습니다. 반대로 React용으로 다시 쓰면 "원본 그대로"가 아니게 됩니다.
iframe은 스타일과 스크립트가 격리된 상자입니다. 원본을 한 글자도 안 고치고 넣을 수 있습니다. 대신 바깥에서 값을 바꾸려면 postMessage로 말을 걸어야 하고, ThreeUI는 컴포넌트마다 그 다리(bridge) 스크립트를 문자열로 덧붙여 넣습니다.
// src/shaders/liquid-metal-button/LiquidMetalButton.tsx — 실제 방식
import source from "./liquid-metal-button.html?raw";
// 원본 HTML의 </body> 앞에 통신용 스크립트를 끼워 넣는다
source.replace("</body>", BRIDGE_SCRIPT + "</body>")
// 원형 변형은 CSS만 덧붙이고 body에 표식을 단다
.replace("<head>", CIRCLE_STYLE + "</head>")
.replace("<body>", '<body data-shape="circle">')
즉 변형(variant)을 만드는 방법이 "다시 구현"이 아니라 "문자열 치환"입니다. 원본이 언제나 정본으로 남는다는 게 장점이고, 원본의 한 줄만 바뀌어도 치환이 조용히 실패한다는 게 단점입니다.
사이트 빌드 (vite.config.js) | 라이브러리 빌드 (vite.lib.config.js) | |
|---|---|---|
| 목적 | 카탈로그 웹사이트 | npm 패키지 |
| 출력 | dist/ | lib-dist/ |
| 진입점 | index.html 1개 | index.ts + 컴포넌트별 100개 |
| 외부 처리 | 없음 (전부 번들) | react·react-dom·three·three128·three165 전부 external |
| 모듈 형태 | 청크로 합침 | preserveModules: true — 파일 구조를 그대로 유지 |
| 소스맵 | 끔 | 끔 (감사가 .map 존재를 실패로 처리) |
preserveModules와 컴포넌트별 진입점이 짝을 이루면 서브패스 import가 가능해집니다.
// 전체를 긁어오는 방식 — 개발 중 import 그래프가 커진다
import { AtTheHorizon } from "@designcodeio/threeui";
// 필요한 하나만 — 이쪽이 훨씬 가볍다
import { AtTheHorizon } from "@designcodeio/threeui/components/AtTheHorizon";
| 파일 | 크기 | 의미 |
|---|---|---|
public/source-code.json | 32.5MB | 컴포넌트 소스를 통째로 담은 단일 JSON. 사이트의 '소스 보기' 탭이 이걸 읽는다. 클론 용량의 최대 원인 |
src/data/shaders.tsx | 412KB | 15,661줄. 102개 경로의 메타·컨트롤·변형이 전부 여기 하드코딩 |
src/styles.css | 91KB | CSS 프레임워크 없이 순수 CSS로 카탈로그 전체를 감당 |
buildSkillMarkdown.js | 178KB | 컴포넌트 설명을 AI 에이전트용 마크다운으로 뽑는 파일. 사이트에 /mcp 문서 페이지가 따로 있다 |
README는 "커뮤니티 부모 컴포넌트 50개, 경로 111개, 무료 변형 141개 + 단독 23개 = 164개"라고 적고 있습니다. 그런데 같은 커밋의 public/community-sync-report.json은 43 / 102 / 138이라고 말합니다.
리포트가 맞습니다 — 그건 스크립트가 만든 것이고, README는 사람이 쓴 것이기 때문입니다. 실제 카탈로그를 세어 봐도 경로 102개, 그중 variantOf가 붙은 파생 항목이 59개입니다. 이 저장소를 읽을 땐 README보다 생성된 리포트를 믿는 게 안전합니다.
"three128": "npm:three@0.128.0" 하나면 끝입니다. 마이그레이션이 막힌 레거시 모듈이 있을 때, 전부 멈추고 올리는 대신 옛 버전에 별명을 붙여 격리하는 선택지가 생깁니다.
대가는 번들 크기입니다. Three.js는 압축해도 수백 KB이고, 세 버전을 동시에 쓰는 앱은 세 벌을 받습니다. "쓰는 컴포넌트가 어느 버전을 참조하는지"를 확인하고 고르는 습관이 필요합니다.
preserveModules: true + entry에 컴포넌트별 진입점 + package.json의 exports에 "./components/*" 와일드카드. 이 셋이 한 세트입니다. 컴포넌트가 많은 라이브러리를 만든다면 그대로 베껴 쓸 수 있는 구성입니다.
"sideEffects": ["**/*.css"]도 놓치기 쉬운 부분입니다 — 이게 없으면 번들러가 CSS import를 "안 쓰는 코드"로 보고 지워 버립니다.
srcDoc + postMessage 샌드박스외부에서 받은 완성 HTML을 내 스타일과 충돌 없이 화면에 넣어야 할 때 쓰는 방법입니다. 미리보기 기능, 이메일 템플릿 편집기, 사용자 제작 위젯 같은 데 그대로 적용됩니다.
ThreeUI가 잘한 점은 통신 브리지를 원본에 심지 않고 문자열로 덧붙인다는 것입니다. 원본 파일은 계속 "그냥 열면 동작하는 HTML"로 남습니다.
bellFieldShaders.ts는 입문용으로 좋은 크기입니다. 흐름이 늘 같습니다 — gl_FragCoord(현재 픽셀 좌표)를 화면 크기로 나눠 −1~1 범위로 정규화하고, 화면비를 보정하고, u_time으로 시간에 따라 흔들고, 최종 색을 gl_FragColor에 넣습니다.
주석도 배울 거리입니다 — "원형 진동 모드의 베셀 포락선을 대신하는 감쇠 코사인"처럼 수학적 근사의 의도를 남겨 두는 방식입니다.
warpFieldRenderer.ts의 워프 효과는 선 수천 개를 매 프레임 새로 만들지 않습니다. Float32Array 하나를 잡아 두고 z좌표만 밀다가, 카메라를 지나치면 뒤쪽으로 되돌려 놓습니다.
positions[i*6 + 2] += step;
if (positions[i*6 + 2] > RECYCLE_Z) { 카메라를 지나쳤으면
positions[i*6 + 2] = RESET_Z; 맨 뒤로 되돌린다
}
attribute.needsUpdate = true; GPU에 갱신 신호
객체 풀링(object pooling)의 교과서적 사례입니다. 자바스크립트에서 매 프레임 객체를 만들면 가비지 컬렉터가 주기적으로 프레임을 끊습니다.
index.html의 <head> 안, React가 로드되기 전에 인라인 스크립트가 localStorage를 읽어 <html>에 data-theme을 붙입니다. try/catch로 감싸 스토리지가 막힌 환경(시크릿 모드 등)에서도 기본값으로 떨어집니다.
다크 모드를 넣을 때 "새로고침하면 흰 화면이 번쩍한다"의 정석 해법입니다.
install.mjs의 safeRelativePath()는 서버가 준 파일 경로를 믿지 않습니다. 널 바이트·절대 경로·역슬래시·.·..·빈 세그먼트를 전부 거부하고, 그러고도 resolve() 결과가 목적지 밖을 가리키면 한 번 더 막습니다.
덮어쓰기도 기본 거부입니다 — 기존 파일이 있고 내용이 다르면 --force 없이는 진행하지 않습니다. 남의 작업 디렉토리에 파일을 쓰는 도구는 "요청받은 경로"를 절대 그대로 믿으면 안 됩니다.
oauth.mjs는 CLI 로그인의 표준 형태를 그대로 보여 줍니다. 임의 문자열(verifier)을 만들고 그 SHA-256 해시(challenge)를 인증 요청에 실은 뒤, 토큰을 받을 때 원본을 제시해 중간에 인증 코드를 가로챈 사람이 쓰지 못하게 합니다.
세션 파일 저장도 눈여겨볼 만합니다 — 디렉토리는 0700, 파일은 0600, 그리고 임시 파일에 쓴 뒤 rename으로 갈아끼웁니다(원자적 쓰기). 도중에 프로세스가 죽어도 반쯤 쓰인 세션 파일이 남지 않습니다.
| 항목 | 요구사항 |
|---|---|
| Node.js | 24 권장 (CI 기준). CLI는 engines: node >=20 |
| 디스크 | 클론 141MB (얕은 클론 기준). public/ 78MB + src/ 9.6MB. npm install 후 node_modules가 더 붙는다 |
| GPU | WebGL2를 지원하는 브라우저. 통합 그래픽으로도 대부분 돌지만 파티클 많은 장면은 프레임이 떨어진다 |
| 브라우저 | ES 모듈 + <iframe srcDoc>. 사실상 최신 Chrome/Safari/Firefox/Edge |
| 서버 | 불필요. 정적 사이트라 빌드 후 아무 데나 올리면 된다 |
| 모바일 | 돌긴 하는데 발열·배터리를 각오해야 한다. 셰이더 배경은 화면 전체를 매 프레임 다시 칠한다 |
사이트에는 PreviewFpsMeter라는 작은 컴포넌트가 붙어 있어 미리보기의 실제 프레임을 재고 등급을 매깁니다 — 55fps 이상 good · 45 이상 watch · 그 아래 slow. 컴포넌트를 고르기 전에 내 기기에서 몇 fps인지 확인할 수 있다는 뜻입니다.
랜딩페이지처럼 완성된 HTML 문서를 렌더하는 컴포넌트는, 그 안에서 이미지·폰트·Three.js 런타임을 루트 상대 경로로 찾습니다. npm 설치만 하고 쓰면 그 파일들이 없어서 화면이 비거나 깨집니다.
해결은 README에 적힌 대로 node_modules/@designcodeio/threeui/lib-dist/assets/에서 필요한 파일을 내 앱의 public 디렉토리로 복사하거나, 컴포넌트의 sourceUrl·assetBaseUrl prop을 넘겨 경로를 바꾸는 것입니다.
MIT는 애플리케이션 코드·커뮤니티 컴포넌트 코드·ThreeUI가 직접 만든 이미지까지입니다. 나머지는 다릅니다.
번들된 오픈 폰트는 SIL Open Font License 1.1이고, https://threeui.com에서 불러오는 썸네일·미리보기 이미지는 이 저장소가 재배포하지 않습니다 — 즉 그 이미지는 라이선스가 별개입니다. 상업 프로젝트에 쓸 거면 ASSET-LICENSES.md · FONT-LICENSES.md · THIRD_PARTY_NOTICES.md 세 파일을 먼저 읽으세요.
본체 카탈로그는 263개인데 여기 공개된 건 커뮤니티 102개 경로입니다. Pro 57묶음·Beta 25묶음은 코드가 아예 들어 있지 않습니다. 사이트의 Get Pro 링크는 유료 페이지로 나갑니다.
또한 포크해서 유료 부분을 되살릴 수는 없습니다 — 잘려 나간 게 아니라 처음부터 복사되지 않았기 때문입니다.
index.html 맨 위에 gtag 스크립트가 측정 ID G-5WP9FS3ECK와 함께 그대로 들어 있습니다. 지우지 않고 배포하면 내 사이트의 방문 기록이 원저자 계정으로 흘러갑니다. 포크 후 첫 작업으로 지우세요.
git clone --depth 1 https://github.com/MengTo/threeui.git
cd threeui && npm install && npm run dev
확인할 것 — 컴포넌트 하나를 골라 컨트롤 슬라이더를 움직였을 때 FPS 미터가 어떻게 변하는지, 테마를 5가지 팔레트로 돌렸을 때 <html> 태그의 속성이 어떻게 바뀌는지(개발자 도구 Elements 탭).
배우는 것 — 이 프로젝트가 CSS 변수와 데이터 속성만으로 테마를 갈아끼우는 방식.
npm install @designcodeio/threeui three react react-dom
import { BellFieldBackground } from "@designcodeio/threeui/components/BellFieldBackground";
import "@designcodeio/threeui/style.css";
부딪힐 것 — Next.js App Router라면 브라우저 API를 쓰므로 클라이언트 컴포넌트로 만들어야 합니다. 자산이 필요한 컴포넌트를 고르면 7장의 함정을 그대로 만납니다. 일부러 그런 컴포넌트를 하나 골라 깨뜨려 보는 게 이 과제의 목적입니다.
확인할 것 — 전체 import와 서브패스 import로 각각 빌드했을 때 번들 크기 차이.
src/shaders/bell-field/bellFieldShaders.ts에 uniform float u_warp;를 추가하고 좌표 계산에 섞어 넣습니다. 그리고 BellFieldBackground.tsx에서 prop → uniform으로 연결합니다.
주의 — src/data/shaders.tsx에 컨트롤을 추가하고 싶어질 텐데, 그 파일은 생성물입니다. 로컬 실험은 되지만 업스트림에 올릴 방법은 없습니다. "생성물 저장소에서는 기여 경로가 다르다"를 몸으로 배우는 과제이기도 합니다.
scripts/generate-library-entry.mjs(48줄)를 읽고, 내 프로젝트의 컴포넌트 디렉토리를 훑어 index.ts와 컴포넌트별 진입점을 생성하는 스크립트를 씁니다. 여기에 이름 중복 시 에러와 "Do not edit by hand" 헤더를 반드시 넣으세요.
확인할 것 — package.json의 exports에 와일드카드 서브패스를 열고, 실제로 import X from "내패키지/components/X"가 타입까지 붙어 동작하는지.
이 저장소에서 가장 값어치 있는 부분을 옮겨 오는 과제입니다. audit-public.mjs와 audit-build.mjs를 참고해, 내 프로젝트에서 절대 공개되면 안 되는 것들의 목록을 정규식으로 적고 CI에 겁니다.
/Users/이름/, C:\Users\이름\)핵심 — 소스가 아니라 빌드 결과물을 검사할 것. 그리고 public-boundary.test.mjs처럼 "걸러낸 개수가 0이면 실패"도 함께 넣을 것.
prepare-community-release.mjs의 classifyRelease()를 가져와, 내 라이브러리의 공개 API 표면(export 이름·함수 시그니처·설정 키)을 토큰 집합으로 뽑고 이전 릴리스와 비교합니다. 사라진 게 있으면 major, 늘어난 게 있으면 minor.
"버전을 몇 번째 자리에서 올릴까"를 사람 기억이 아니라 코드가 답하게 만드는 것 — 라이브러리를 오래 유지할수록 이 자동화의 값어치가 커집니다.
| 주차 | 주제 | 이 저장소에서 읽을 곳 | 바깥 교재 |
|---|---|---|---|
| 1주차 | WebGL·셰이더 기초 좌표 정규화 · uniform · 시간 애니메이션 |
bell-field/bellFieldShaders.ts → dot-matrix/ → ribbon-field/ 순으로 짧은 것부터 |
The Book of Shaders (한국어판 있음) |
| 2주차 | Three.js 장면 구성 지오메트리 · 재질 · 버퍼 · dispose |
warp-field/warpFieldRenderer.ts(22KB, 파티클 재활용) · landscape/ · globe/ |
Three.js Journey · 공식 매뉴얼의 "Memory 관리" 절 |
| 3주차 | 라이브러리 패키징 Vite lib 모드 · exports 필드 · .d.ts · peerDependencies |
vite.lib.config.js · tsconfig.lib.json · package.json의 exports · generate-library-entry.mjs |
Node.js 공식 Packages 문서의 exports 절 |
| 4주차 | 공급망·릴리스 자동화 경계 감사 · provenance · semver 추론 |
scripts/audit-*.mjs · public-boundary.test.mjs · prepare-community-release.mjs · .github/workflows/publish-npm.yml |
npm Provenance 문서 · semver.org 한국어 |
넷 중 하나만 고른다면 4주차를 권합니다. 1~3주차는 대체 교재가 많지만, "유료 제품에서 무료 판을 안전하게 떼어내는 실물 파이프라인"은 공개된 사례 자체가 드뭅니다.
| 용어 | 뜻 |
|---|---|
| 셰이더 / GLSL | GPU가 픽셀마다 돌리는 작은 프로그램과 그 언어. 웹에서는 WebGL을 통해 실행된다 |
| uniform | 셰이더 프로그램 전체가 공유하는 바깥에서 넣어 주는 값. 시간·해상도·마우스 위치 등이 여기로 들어간다 |
| Three.js | WebGL을 직접 다루기 어려워서 만든 3D 라이브러리. 버전 간 호환성 변화가 잦기로 유명하다 |
| npm alias | "별명": "npm:패키지@버전". 같은 패키지의 다른 버전을 다른 이름으로 동시에 설치하는 문법 |
| peerDependencies | "이건 내가 설치하지 않을 테니 쓰는 쪽에서 준비해 주세요"라는 선언. React처럼 앱에 하나만 있어야 하는 것에 쓴다 |
| 서브패스 export | 패키지/일부 형태로 필요한 부분만 import하게 열어 주는 package.json 설정 |
| preserveModules | 번들할 때 파일을 하나로 합치지 않고 원래 파일 구조를 유지하는 Rollup/Vite 옵션. 트리 셰이킹에 유리하다 |
| sideEffects | "이 파일들은 import만 해도 의미가 있으니 지우지 마라"고 번들러에 알리는 필드. CSS import가 대표적 |
| srcDoc | iframe에 URL 대신 HTML 문자열을 직접 넣는 속성. 스타일·스크립트가 격리된다 |
| postMessage | iframe 안팎처럼 격리된 문맥끼리 메시지를 주고받는 브라우저 API |
| FOUC | Flash of Unstyled Content. 스타일이 적용되기 전 화면이 잠깐 번쩍이는 현상 |
| 오픈코어 | 핵심 일부만 오픈소스로 공개하고 나머지는 유료로 파는 사업 방식 |
| semver | major.minor.patch 세 자리 버전 규칙. major=깨짐, minor=기능 추가, patch=내부 수정 |
| provenance | 패키지가 어느 저장소·커밋·CI에서 만들어졌는지 서명해 함께 공개하는 npm 기능 |
| Trusted Publishing | npm 토큰 없이 CI의 OIDC 신원만으로 패키지를 발행하는 방식 |
| PKCE | Proof Key for Code Exchange. 인증 코드를 가로채도 쓸 수 없게 만드는 OAuth 확장. CLI·모바일 앱의 표준 |
| 경로 순회 (path traversal) | ../../etc/passwd처럼 상위 디렉토리로 빠져나가 의도치 않은 파일에 접근하는 공격 |
| 객체 풀링 | 객체를 매번 새로 만들지 않고 다 쓴 것을 되돌려 재사용하는 기법. 가비지 컬렉션으로 인한 끊김을 줄인다 |
scripts/audit-build.mjs (43줄) — 가장 짧고 가장 배울 게 많다scripts/prepare-community-release.mjs (100줄) — semver 자동 추론scripts/public-boundary.test.mjs (232줄) — 테스트 안에서 Vite를 띄우는 방법packages/cli/src/install.mjs (87줄) — 경로 순회 방어의 실물public/community-sync-report.json — README보다 이쪽 숫자가 정확하다examples/가 사실상 최대 학습 자료exports 필드와 서브패스 정의