한마디로
감정을 3D 행성으로 시각화하는 웰니스 웹 플랫폼입니다. 감정 레벨·이유·일기를 3단계로 기록하면, 달력과 차트로 감정 흐름을 확인합니다. AI를 통해 감정을 예측하고 기간별 분석을 제공합니다.
Emotion Recording
Data Visualization
AI-Powered Analysis
- GLSL 셰이더 간소화와 LOD Subdivision으로 삼각형 88% 감소, 29fps → 61fps를 달성했습니다.
- Barrel Export 구조 개선과 Dynamic Import로 First Load JS 58% 감소를 달성했습니다.
- pnpm 모노레포로 Next.js 웹앱과 Chrome Extension이 3D 행성, 시계, 테마를 공유하는 구조를 설계했습니다.
감정 행성이 서비스보다 무거웠습니다.
r3f-perf로 측정한 결과, 삼각형 408,040개, 프레임 레이트 29fps였습니다.
Zero Gravity는 새 탭을 열 때마다 감정 행성이 맞이하는 서비스입니다. 매일 여는 화면이 무겁다는 건 서비스 첫인상이 무겁다는 뜻이었습니다.
미리 구워두면 되지 않을까?
매 프레임 noise를 계산하는 대신, 미리 계산한 값을 텍스처에 저장해두고 꺼내 쓰면? VAT(Vertex Animation Texture)를 시도했습니다.
VAT는 실패했지만, 테스트하면서 기존 셰이더의 연산량이 과도하다는 걸 알게 되었습니다. "이 연산이 꼭 필요한가?" 근본 질문으로 전환했습니다.
불필요한 연산을 찾았습니다.
셰이더의 연산량을 수치로 확인해봤습니다.
본래 표면이 유기적으로 일렁이는 구체를 위해 설계된 셰이더였습니다. 표면을 변형하려면:
- 각 꼭짓점의 위치를 noise(자연스러운 패턴을 생성하는 수학 함수)로 흔든다
- noise 내부에서 더 동적인 패턴을 만들기 위해 4D noise를 2번 계산한다
- 위치가 바뀌면 빛 계산을 위한 법선도 다시 구해야 하므로, 이웃 꼭짓점 2개도 같이 계산 = 3번 호출
4D noise 2회 × 3번 호출 × 102,000개 꼭짓점 = 프레임당 약 61만 회 noise 호출
감정의 강도를 행성 표면의 일렁임으로 표현하려 했지만, 행성의 형태를 보면 목성이나 해왕성처럼 표면 위를 흐르는 색상 패턴이 핵심이었습니다. 표면 변형 없이도 감정은 충분히 전달되고 있었습니다.
불필요한 연산을 제거하고 색상 애니메이션만 남겼습니다.
per-vertex를 줄였으니, 이번엔 vertex 수.
꼭짓점당 noise를 줄였지만, 꼭짓점이 102,000개면 여전히 프레임당 약 20만 회 noise 호출입니다.
IcosahedronGeometry의 삼각형 수는 subdivision의 제곱에 비례합니다.
subdivision 100 → 삼각형 408,040개
메인 페이지에서 행성이 화면 가득 확대되기 때문에, subdivision이 낮으면 표면이 각져 보였습니다. 퀄리티를 위해 100까지 올렸지만, 성능과의 trade-off를 고려하지 못했습니다.
시각적 품질이 유지되는 최소 subdivision을 찾아 LOD(Level of Detail)를 적용했습니다. 작게 보이는 행성은 더 낮춰도 눈에 띄지 않고, 모바일은 화면 자체가 작으니까 더 줄일 수 있었습니다.
* 408,040은 shadow pass 포함 수치입니다. subdivision 100의 실제 삼각형은 약 204,000개지만, shadow pass가 같은 geometry를 한 번 더 그려 2배로 찍혔습니다. 바닥 없는 우주 공간에 불필요한 그림자였기에 제거했습니다.
4D noise 2회 × 1번 호출 × 24,000개 꼭짓점 = 프레임당 약 4.8만 회 noise 호출
29fps → 61fps


프레임당 61만 회의 noise 호출을 4.8만 회로 줄인 건, 새로운 기술을 도입한 게 아니라 필요 없는 연산을 찾아 제거한 결과였습니다. 최적화에 정답은 없지만, "정말 필요한가?"를 묻는 것이 가장 효과적인 출발점이었습니다.
차트 페이지에 Three.js가 왜 있지?
원인은 Barrel Export 구조였습니다.
@next/bundle-analyzer로 번들을 분석했습니다. Three.js가 712KB로 전체 번들의 약 35%를 차지하고 있었고, First Load JS는 Home 447KB, Record 514KB에 달했습니다.
Three.js가 원인이라 판단했습니다. 컴포넌트를 Dynamic Import로 분리하고, Custom Exports로 필요한 모듈만 뽑아보고, drei 개별 import도 시도했습니다. 전부 효과가 없었습니다.
그러다 한 가지 이상한 점을 발견했습니다. 3D를 전혀 사용하지 않는 차트 페이지에도 Three.js가 포함되어 있었습니다.
차트 페이지는 그래프의 색상 매핑(EMOTION_COLORS_MAP)과 라벨 표시(EMOTION_TYPES) 등 순수 상수만 import하고 있었지만, 같은 barrel에 3D 컴포넌트가 묶여 있어 Three.js 712KB가 통째로 딸려왔습니다.

712KB를 끊어낸 방법.
감정 관련 상수와 타입(EMOTION_STEPS, EMOTION_COLORS, EMOTION_TYPES 등)은 프로젝트 전반의 도메인 데이터 허브였습니다. 슬라이더, 폼, 캘린더, 차트, Chrome Extension까지 약 28개 파일이 이 데이터를 import하고 있었습니다.
문제는 이 도메인 데이터가 모노레포 shared 패키지에서 3D 행성 컴포넌트와 같은 barrel에 묶여 있었다는 것입니다. 모노레포에서 shared 패키지는 pre-built 상태로 소비되기 때문에, barrel의 단위가 곧 chunk의 단위이자 번들 최적화의 한계가 됩니다.
의존성 무게 기준으로 barrel을 나누는 것이 구조적 해결이었습니다. 감정의 "무엇"(이름, 색상, 값)과 "어떻게 보여줄지"(3D 메시, 셰이더)를 분리했습니다.
Three.js 컴포넌트는 React.lazy로 지연 로딩했습니다. 모노레포에서 web과 Chrome Extension이 shared를 공유하는 구조라, Next.js 전용인 next/dynamic 대신 프레임워크 독립적인 React.lazy를 선택했습니다.
번들 이후의 문제.
번들은 절반이 됐지만, 사용자 앞에는 여전히 2~3초의 로딩 화면이 있었습니다.
3D 씬은 JS가 도착한 뒤에도 WebGL 초기화, 환경맵 로딩, 셰이더 컴파일을 거쳐야 렌더링됩니다. 번들을 줄여도 이 과정은 줄어들지 않았습니다.
3D 씬이 로드되기 전에도 행성이 즉시 보이도록 구조를 바꿨습니다.
- Canvas만 lazy load 분리하고, Container는 즉시 렌더하여 레이아웃 공간 확보 → CLS 방지
- 감정별 정적 placeholder 이미지 21개(7감정 × 3사이즈) → 3D 로딩 중에도 행성이 즉시 보임
First Load JS -58%
원인을 모른 채 페이지를 하나씩 뜯어보고, 구조를 추적하고, 번들을 줄인 뒤에도 사용자 화면에 로딩이 남아 있으면 다시 파고들었습니다. 최적화는 "됐다" 싶을 때 한 번 더 보는 것이었습니다.
Chrome Extension에서 3D 행성을 보여주고 싶었습니다.
하지만 Chrome Extension은 SSR을 지원하지 않아 Next.js를 그대로 쓸 수 없었습니다.
매일 더 빠르게 감정을 기록하고 확인할 수 있도록, 새 탭을 열면 바로 감정 행성이 보이는 진입점을 만들고 싶었습니다. 웹과 Extension에서 같은 경험을 제공하고 싶었기에, 핵심 UI 컴포넌트를 두 환경에서 공유할 방법을 찾아야만 했습니다.
Next.js(SSR)와 Vite(Extension) 각각의 장점을 유지하면서 공유가 가능한 모노레포를 선택했습니다.
pnpm Workspace 3-패키지 구조
Vite library mode로 shared 패키지를 빌드하고, web과 extension 모두에서 import합니다. Chrome Cookies API로 NextAuth 세션 쿠키의 존재를 확인하고, NextAuth 세션 엔드포인트로 검증을 위임하여 웹에서 한 번 로그인하면 Extension에서도 자동 인증되도록 했습니다.
셰이더 코드를 분석하고, 번들을 추적하고, 모노레포를 설계한 건 전부 같은 이유였습니다. 새 탭을 열고 서비스를 사용할 때, 사용자가 빠르고 자연스럽게 감정 행성을 만날 수 있도록.