AD3 단백질 구조 분석 플랫폼: 3D 시각화 고도화와 번들 80% 감축
배경
이 제품(AD3, 실제 서비스 중)은 단백질–리간드 상호작용을 연구하는 전산생물학자와 의약화학자를 대상으로 합니다. 연구자들은 브라우저에서 가설을 탐색하고 검증하기 위해 3D 분자 뷰어, 구조화학 라이브러리, 인터랙티브 차트 같은 무거운 도메인 도구에 의존합니다. 이 작업을 담당하는 전문 라이브러리는 두 가지입니다:
- Molstar — RCSB Protein Data Bank 자체에서도 사용하는 오픈소스 3D 거대분자 뷰어
- RDKit.js — 업계 표준 RDKit 케모인포매틱스 툴킷의 WebAssembly 포팅 버전
둘 다 용량이 큰데(각각 수 MB), 같은 Molstar + RDKit 조합은 같은 뷰어 스택으로 만들어진 관련 제품인 ADC 케이스 스터디에도 쓰입니다.
문제
메인 JavaScript 번들 용량이 32MB였고, 프로덕션 빌드 기준 Lighthouse 성능 점수는 29였습니다. rollup-plugin-visualizer로 트리맵을 뽑아보니 RDKit과 Molstar가 실제로 사용되지 않는 라우트에서도 번들의 50% 이상을 차지하고 있었습니다 — 사용자가 그 라우트에서 결코 만지지 않을 과학 라이브러리 비용을 모든 페이지가 그대로 떠안고 있었던 겁니다. 표준적인 해법인 라우트 단위 dynamic() import로 깔끔하게 해결할 수 있었지만, 그러려면 어떤 라우트가 어떤 라이브러리를 필요로 하는지 정확히 파악하고 있어야 했습니다.
진행한 작업
1. 측정
프로덕션 빌드에 rollup-plugin-visualizer를 돌려 가장 큰 원인을 짚었습니다. 트리맵은 로드 타임 프로파일링에서 짐작했던 바를 그대로 확인해줬습니다: 현재 라우트와 무관하게 RDKit(구조화학)과 Molstar(3D 분자 뷰어)가 번들을 지배하고 있었습니다.
2. 대안 검토
전역 압축(brotli / gzip)은 전송 크기는 줄여주지만 브라우저가 파싱·실행해야 하는 32MB 자체는 바뀌지 않습니다 — 압축은 증상만 완화할 뿐 로드 비용 자체를 해결하지 못합니다. 무거운 import마다 React.lazy를 무분별하게 적용하는 것도 비용을 옮길 뿐, 과학 라이브러리를 관련 여부와 무관하게 모든 라우트에 심어두는 구조 자체는 그대로 남습니다. 이 코드베이스에는 두 방법 모두 충분하지 않았고, 구조적인 해결이 필요했습니다.
3. 라우트 단위 Molstar/RDKit 청크 분리
3D 뷰나 구조화학 화면을 실제로 렌더링하는 라우트에서만 Molstar와 RDKit이 로드되도록 번들링 전략을 재구성했습니다. 테이블 위주·폼 위주·대시보드 화면은 더 이상 메인 청크에 이 라이브러리들을 포함하지 않습니다.
4. RDKit 선택적 import
RDKit은 C++ 케모인포매틱스 라이브러리를 통째로 JavaScript로 포팅한 형태로 배포됩니다. 전체 라이브러리 import를 각 라우트가 실제로 필요로 하는 분자 연산만 가져오는 tree-shakeable entry point로 교체했습니다.
5. react-plotly.js 지연 로딩
인터랙티브 플로팅 라이브러리가 모든 라우트에 즉시(eager) 번들링되고 있었습니다. 라우트 단위 dynamic import로 지연시켜, 실제로 사용하는 분석 화면에서만 로드되게 했습니다.
6. Compound Designer — 가장 까다로운 경로
Compound Designer 라우트는 분자 뷰어·속성 테이블·필터 패널·유사도 검색 결과·병렬 좌표 플롯 등 150개 이상의 React 노드로 구성된 인터랙티브 워크스페이스를 렌더링합니다. 병목 지점은 다음과 같았습니다:
- 뷰포트 로딩: 현재 뷰포트에 보이는 노드만 렌더링. 사용자가 스크롤·팬 하면 나머지 노드가 필요 시점에 하이드레이션됨.
- 병렬 렌더링: 독립적인 노드 트리들을 순차가 아니라 동시에 렌더링.
useEffect의존성 배열 수정: 의존성 배열 누락·오래된 값 때문에 렌더링마다 불필요하게 재실행되던 이펙트들이 있었음. 이를 고쳐서 상호작용 1회당 발생하던 수천 건의 불필요한 리렌더를 제거.- 메모이제이션: 지문(fingerprint) 생성, 유사도 스코어링, 속성 계산 같은 비용이 큰 연산을 안정적인 입력값 기준
useMemo로 캐싱.
7. CLS(레이아웃 밀림) 회귀
배포 과정에서 Cumulative Layout Shift 회귀가 감지됐습니다: 동적 콘텐츠 로딩이 Compound Designer 라우트에서 레이아웃이 튀는 현상을 유발했습니다. 원인은 스타일이 없는 로딩 컨테이너가 높이 0으로 붕괴하는 것이었고, 프로덕션 반영 전에 원인을 찾아 해결했습니다.
8. 라우트-라이브러리 매핑
애플리케이션의 모든 라우트를 시각화 요구사항 기준으로 매핑했습니다:
| 라이브러리 | 필요한 라우트 | 불필요한 라우트 |
|---|---|---|
| Molstar (3D 뷰어) | 구조 분석, 도킹 결과 | 대시보드, 검색, 관리자 |
| RDKit.js (2D 화학) | 화합물 디자이너, 속성 테이블 | 대시보드, 관리자, 설정 |
두 라이브러리 모두 최상위 import에서 라우트 단위 dynamic() 호출로 마이그레이션했습니다:
// 이전: 모든 라우트가 비용을 그대로 부담
import { MolstarViewer } from '@molstar/viewer'
// 이후: 3D를 렌더링하는 라우트만 Molstar를 로드
const MolstarViewer = dynamic(() => import('@/components/3d/MolstarViewer'))
// RDKit.js는 화합물 관련 라우트에서만 로드
const RdkitDepiction = dynamic(() => import('@/components/chem/RdkitDepiction'))
각 무거운 컴포넌트는 최종 크기와 일치하는 스켈레톤 플레이스홀더를 받아서, 지연 로드 중 레이아웃 밀림(CLS)을 방지합니다 — 위 7번 항목의 CLS 회귀 대응과 같은 원칙입니다.
9. 라이브 데모: 이 사이트에 적용한 같은 기법
아래 패널은 두 라이브러리가 온디맨드로 로드되는 걸 보여줍니다. DevTools → Network를 열고 "rdkit" 또는 "molstar"로 필터링하면 각 라이브러리가 이 페이지에서만 로드되는 걸 확인할 수 있습니다. Molstar 패널에는 작은 preset 전환 버튼(Cartoon / Ball & stick / Surface)도 있는데, 커스텀 StructureRepresentationPresetProvider 3개로 구현했습니다 — 실제 플랫폼에서 다른 표현 스타일을 만들 때 쓰는 것과 같은 커스터마이징 메커니즘입니다.
두 라이브러리 모두 흉내만 낸 게 아니라 실제로 번들링·코드 스플리팅되어 있습니다: RDKit.js(WASM 바이너리 — gzip 전송 기준 ~2MB, 압축 전 6.9MB)는 이 사이트가 직접 서빙하는 /rdkit/ 경로에서 로드되고, Molstar는 molstar npm 패키지에서 React.lazy()를 통해 로드됩니다 — AD3 자체가 뷰어 컴포넌트에 쓰는 것과 같은 패턴입니다.
왜 next/dynamic 대신 Molstar에 React.lazy()를 썼나? next/dynamic은 단일 React 컴포넌트를 지연 로드하기 위한 것인데, Mol*의 프로덕션 통합 방식은 명령형입니다(createPluginUI가 DOM 컨테이너에 직접 마운트되지, Next가 알아서 코드 스플리팅할 수 있는 컴포넌트 트리를 통하지 않습니다). 모듈 전체를 React.lazy(() => import('./MolstarViewer')) + Suspense로 감싸면 이 컴포넌트가 렌더링될 때까지 molstar 청크 전체를 지연시킬 수 있습니다.
RDKit.js는 왜 번들링도 CDN도 아닌 자체 서빙인가? RDKit.js는 어떻게 import하든 런타임에 fetch·컴파일해야 하는 WebAssembly 바이너리입니다. 그래서 고정 URL을 가리키는 <script> 태그로 싣는 게 가장 정직한 방식입니다 — 요청이 DevTools에 읽을 수 있는 이름으로 그대로 찍히므로, 라우트 단위 로딩이 주장이 아니라 검증 가능한 사실이 됩니다. 다만 그 URL은 CDN이 아니라 이 사이트 자신입니다. /rdkit/에서 직접 서빙하면 페이지의 크리티컬 패스에서 서드파티가 빠지고, script-src·connect-src에 허용할 출처가 하나 줄며, 어떤 빌드를 쓰는지가 URL이 아니라 package.json에 고정됩니다. AD3가 하는 방식이기도 합니다 — AD3도 자체 /rdkit/ location에서 RDKit을 서빙하고, 그 nginx 블록에 보안 헤더를 반복해 둡니다. 빌드 단계에서 @rdkit/rdkit 패키지의 파일 두 개를 public/으로 복사할 뿐, 코드에서 패키지를 import하지는 않습니다. import하는 순간 RDKit이 다시 번들에 들어가 이 글이 말하는 분리가 무너지기 때문입니다.
결과
| 항목 | 이전 | 이후 | 변화 |
|---|---|---|---|
| 메인 번들 | 32MB | 6.5MB | −80% |
| Lighthouse 성능 점수 | 29 | 78 | +49 |
| Molstar (3D 뷰어) | 모든 라우트에서 즉시 import | 온디맨드 (이 라우트에서만, gzip 약 0.9MB 청크) | 지연 로딩 (React.lazy()) |
| RDKit.js (WASM) | 즉시 번들링 | 자체 서빙 온디맨드 | 지연 로딩 (<script> 삽입) |
동일한 테스트 환경에서 전후 프로덕션 빌드를 대상으로 Chrome DevTools Lighthouse로 측정. Molstar 청크 용량은 이 사이트 자체의 프로덕션 빌드(next build)에서 이 라우트 기준으로 실측했습니다.
6.5MB로 줄어든 메인 번들은 일반 사무실 네트워크 환경에서 3초 이내에 로드됩니다 (프로덕션 빌드, Chrome DevTools Fast 3G 스로틀링 기준 측정). 이후 라우트 전환에서는 해당 라우트 전용 청크만 추가로 로드됩니다.
비식별화된 기술 도식
단순화한 재구성 이미지이며, 실제 프로덕션 스크린샷이 아닙니다.
이 다이어그램은 대표적인 라우트 일부를 보여줍니다. 변경 전에는 모든 라우트가 전체 과학 라이브러리 청크를 포함했고, 변경 후에는 각 라우트가 필요한 부분집합만 로드합니다.
같은 기법을 이 사이트 자체에도 적용
이 케이스 스터디 사이트 자체도 동일한 라우트 단위 코드 스플리팅과 standalone 빌드 전략을 사용합니다. 이를 직접 증명하기 위해, 전용 perf/before-optimization 브랜치를 만들어 AD3에서 있었던 안티패턴을 이 사이트에 그대로 재현했습니다:
- 즉시(eager) import:
three.js(99 KB)가layout.tsx를 통해 모든 라우트에서 로드됨 - standalone 빌드 미적용:
next.config.ts에서output: 'standalone'제거
아래 대시보드는 최적화 전 브랜치와 현재 최적화된 상태를 — AD3의 비공개 코드베이스가 아니라 — 바로 이 사이트에서 직접 비교합니다.
This site’s live metrics
Measured from the production build of this case study site. ApexCharts itself is loaded route-scoped — the library adds zero bytes to any other page.
Before & After: same techniques on this site
The “before” state recreates the AD3 anti-pattern: an eager 3D library import on every route plus a non-standalone Docker build. Measured from the perf/before-optimization branch.
Docker production image (runner stage)
Measured separately from the bundle metrics above — different unit (MB), so it has its own axis. Image sizes were measured 2026-08-30; bundle numbers reflect the 2026-10-03 re-measurement.
Main bundle
gzipped · 7 chunks
452.5 KB uncompressed
Docker image (runner stage)
78% smaller vs non-standalone
vs ~492 MB · measured 2026-08-30
Route-scoped loading in action
// ApexCharts loaded ONLY on this page:
const Chart = dynamic(() => import('react-apexcharts'), { ssr: false })
// In the "before" branch, three.js was eagerly loaded in layout.tsx:
import EagerThreeInit from "@/components/EagerThreeInit" // +508.8 KB every route
Compare branches: git diff perf/before-optimization..HEAD
이 주장들을 직접 검증하는 방법
- 이 저장소를 클론:
git clone https://github.com/Lajancia/case-study - 현재 빌드(최적화 후):
git checkout main && npm run build && node scripts/measure-bundle.mjs - 최적화 전 빌드:
git checkout perf/before-optimization && npm run build && node scripts/measure-before.mjs - 차이 비교:
git diff perf/before-optimization..main -- app/layout.tsx next.config.ts - 라우트 단위 청크 확인: DevTools → Network 탭을 열고 이 페이지로 이동해서,
apexcharts가 이 라우트에서만 별도 청크로 로드되는지 확인.
관련 케이스 스터디
같은 Molstar/RDKit 뷰어 조합은 ADC 케이스 스터디에서도 쓰입니다 — 신규 제품에서 처음부터 라우트 단위로 설계한 사례입니다.
이 케이스 스터디가 흥미로우셨나요?
가벼운 커피챗도 언제든 환영합니다. 편하게 연락 주세요.