로그를 기반으로 작성하여 스크린샷은 모두 누락됨,,,홀홀
사내에서 피부과 상담 서비스를 만들고 있다. 환자가 촬영한 얼굴 사진을 3D로 보여주는 화면인데, 3D를 띄운다 부터 범상치 않았다.
서버에서 환자의 얼굴 메시 데이터(DWB 파일)와 얼굴 텍스처 이미지가 따로 내려온다. 프론트에서는 이 두 개를 조합해서 — 메시 위에 텍스처를 입혀서 — 3D로 렌더링한다. 상담사가 환자 얼굴을 이리저리 돌려보면서 피부 상태를 확인하는 화면이다.
3D를 도입한 건 좋았는데, 문제는 그 이후에 터졌다.
현장에서 "로딩이 너무 느려요"라는 피드백이 쏟아지기 시작한 것이다.
상담사분들이 브라우저로 결과 페이지를 열면, 환자 얼굴이 뜨기까지 한참을 기다려야 했다. 환자 앞에서 로딩 화면만 10초 가량 반복해서 보여지는 상황이니 불만이 나올 수밖에 없었다.
"대체 왜 이렇게 느린 거지?"
Chrome DevTools를 열고 파헤치기 시작했다. Performance 탭, Network waterfall, Lighthouse 리포트를 하나씩 뜯어보다가 원인을 찾았다. LCP(Largest Contentful Paint)가 16.7초. 화면에서 가장 큰 콘텐츠 — 즉 환자 얼굴 사진 — 이 렌더링되기까지 16.7초가 걸리고 있었다.
그리고 그 16.7초의 대부분은, 3D 이미지를 띄우기 위한 과정에서 발생하고 있었다.
이 글은 그 16.7초를 5.7초까지 끌어내린 과정을 정리한 기록이다.
LCP는 화면에서 가장 큰 콘텐츠 요소가 렌더링 완료되는 시점을 측정하는 Core Web Vitals 지표다. Google 권장 기준은 2.5초 이내이고, 4초를 넘어가면 "Poor" 등급을 받는다.
이 서비스에서 LCP 요소는 환자의 얼굴 텍스처 이미지였다. 3D 메시 위에 입히기 전에 2D로라도 먼저 보여줘야 하는 그 이미지. 결국 "환자 얼굴이 화면에 뜨기까지 걸리는 시간 = LCP"인 셈이었다.
문제는 하나가 아니었다. 직렬로 이어진 여러 병목이 겹겹이 쌓여 있었다.
HTML 파싱
→ JS 번들 다운로드/파싱
→ React hydration
→ 얼굴 분석 데이터 API + 3D 리소스 + 분석 스크립트 + 미사용 폰트 (전부 동시에 경쟁)
→ 분석 데이터 응답 대기 ← !!!여기서 전체 렌더 차단
→ 컴포넌트 렌더
→ 얼굴 이미지 다운로드 (3,506.3KB JPEG 원본, 기본 우선순위)
→ LCP 16.7초
핵심 병목은 크게 세 가지였다.
가장 심각한 구조적 문제였다.
if (!analysisData) {
return <LoadingSpinner />;
}
얼굴 분석 데이터(피부 진단 결과)가 응답할 때까지 얼굴 사진을 포함한 전체 페이지가 스피너만 표시했다. 얼굴 사진은 별도의 이미지 API로 이미 병렬 로딩이 가능한 상태였는데, 분석 데이터 게이트 때문에 DOM에 삽입조차 되지 않고 있었다.
LCP 이미지의 운명이 완전히 다른 API의 응답 속도에 종속된 구조였다.
초기 로딩 시점에 다음 리소스들이 모두 동시에 네트워크를 점유하고 있었다:
정작 LCP 요소인 얼굴 이미지는 기본 우선순위(fetchPriority 미설정)로 이 경쟁에서 밀리고 있었다.
여기서 프록시 이야기를 좀 해야 한다.
얼굴 이미지는 GCS(Google Cloud Storage)에 저장되어 있고, Signed URL로 접근한다. 근데 클라이언트에서 GCS Signed URL로 직접 접근하면 파일 다운로드가 불가능했다. 그래서 중간에 프록시 서버(Next.js API Route)를 태워서 가져오는 구조를 이미 쓰고 있었는데, 문제는 프록시가 이미지를 아무 가공 없이 그대로 패스스루만 하고 있었다는 것이다.
얼굴 이미지 원본 스펙:
화면에 546px로 표시할 이미지를 2888px 원본 그대로 다운로드하고 있었다. 픽셀 기준으로 (2888×3852) ÷ (546×728) ≈ 28배에 해당하는 불필요한 데이터를 전송하고 있었던 셈이다.
가장 먼저 한 것은 "불필요한 대기"를 끊는 것이었다.
// ✅ Home view는 분석 데이터 없이도 렌더 허용
if (isAnalysisLoading && currentView !== "home") {
return <LoadingSpinner />;
}
얼굴 사진을 보여주는 Home 뷰는 분석 데이터가 없어도 렌더할 수 있다. 해당 뷰에서만 게이트를 제거했다.
동시에 진행한 기본 최적화들:
| 항목 | 변경 | 이유 |
|---|---|---|
fetchPriority="high" | LCP 이미지에 추가 | 브라우저에게 "이 이미지가 가장 중요하다"고 명시 |
| 3D 리소스 지연 로딩 | 2D 모드에서 3D 메시 다운로드 차단 | 5.8MB 네트워크 경쟁 제거 |
ContentSquare lazyOnload | afterInteractive → lazyOnload | idle 시점으로 지연 |
| SUIT 폰트 preload | <link rel="preload"> 추가 | CSS 파싱 대기 없이 조기 다운로드 |
| 이미지 캐시 TTL | 1시간 → 7일 | 재방문 시 즉시 로드 |
이 단계에서 16.7초 → 14.2초로 개선되었다. 의미는 있지만, 여전히 목표(5초)와는 거리가 멀었다.
Tailwind CSS v4의 @import "tailwindcss"가 기본 --font-sans에 Geist 폰트를 포함시키고 있었다. 크리티컬 패스에서 2,827ms를 차지하던 폰트를 제거했다. LCP 수치 변화는 미미했지만, 전체 waterfall이 가벼워진 건 확인했다.
@theme {
--font-sans: "Pretendard", "SUIT Variable", "SUIT", system-ui, sans-serif;
}
여기서 뼈아픈 실수를 했다.
기존에 다른 서비스에서 사용하던 3D 스캔 로딩 오버레이를 그대로 가져다 붙였다. "3D 뷰가 기본이니까 로딩 화면을 보여주면 자연스럽겠지"라는 판단이었다.
결과는 참혹했다.
스캔 오버레이가 화면 전체를 덮으면서 LCP 이미지를 완전히 차단했다. 3D 메시 다운로드(~5.8MB) + 스캔 애니메이션(최소 7.5초)을 모두 기다린 후에야 실제 콘텐츠가 보였다.
LCP가 23.7초.
멋있어 보이는 로딩 화면 하나가 성능 최적화 노력을 한 방에 날려버린 것이다. 이 경험에서 확실히 배웠다 — "보기 좋은 UX"와 "실제 좋은 UX"는 다를 수 있다.
Phase 3의 실패에서 방향을 완전히 틀었다.
핵심 아이디어: 3D가 최종 목표지만, 초기에는 2D 이미지를 먼저 보여주고 3D가 준비되면 트랜지션으로 전환하자.
어차피 3D 렌더링을 위해서는 메시 파일과 텍스처 이미지가 둘 다 필요한데, 텍스처 이미지(얼굴 사진)는 그 자체로 의미 있는 콘텐츠다. 이걸 먼저 2D로 보여주면 상담사는 바로 환자 얼굴을 확인할 수 있고, 3D 전환은 "보너스"가 된다.
// 3D 준비 전에는 강제로 2D 표시
const effectiveFaceViewMode =
faceViewMode === "3D" && !is3DReady ? "2D" : faceViewMode;
로딩 흐름:
페이지 로드
→ 2D 얼굴 이미지 즉시 렌더 (fetchPriority="high")
→ LCP ⏱️ (7.9초)
→ 3D 리소스 백그라운드 로딩 (병렬)
→ 메시 준비 완료
→ 스캔 트랜지션 애니메이션
→ 3D 뷰로 전환
LCP가 23.7초 → 7.9초로 극적으로 개선되었다. 그리고 로딩 화면이 LCP를 차단하지 않으면서도, 3D 전환 시 멋진 스캔 애니메이션은 그대로 유지할 수 있었다. 오히려 이전보다 자연스러워졌다.
7.9초까지 왔지만, 여전히 가장 큰 병목이 남아 있었다. 3,506.3KB짜리 원본 이미지.
여기서 중요한 맥락이 하나 있다. 이 이미지는 단순히 태그에 표시되는 이미지가 아니라, Three.js에서 3D mesh에 래핑되는 텍스처 파일이다. 2D로 먼저 보여주는 건 LCP 최적화를 위한 임시 표시이고, 최종적으로는 3D mesh 위에 입혀져야 하는 이미지다.
이 제약 때문에 일반적인 이미지 최적화 방법들이 전부 막혔다.
Next.js Image 컴포넌트 (next/image)
next/image는 <img> 태그를 렌더링하는 React 컴포넌트다. Three.js 텍스처로 사용하려면 raw 이미지 URL이 필요한데, next/image는 URL을 내부적으로 /_next/image?url=...로 변환해버린다. Three.js의 TextureLoader에 이 컴포넌트를 넘길 수 없다.
클라이언트 사이드 Canvas 리사이즈
브라우저에서 이미지를 받은 후 Canvas API로 리사이즈하는 방법. 근데 이건 문제의 본질을 해결하지 못한다. 클라이언트가 3,506.3KB를 전부 다운로드한 다음에야 리사이즈가 가능하기 때문에, 네트워크 병목은 그대로다.
CDN 이미지 변환 서비스 (Cloudinary, imgix 등)
강력한 솔루션이지만, 별도 서비스 도입을 위해서는 백엔드 인프라 변경이 수반된다. 백엔드 팀에 로딩이 워낙 많았었고, 한창 인프라 변동이 잦았던 시기라,,, 프론트엔드 혼자서 해결할 수 있는 범위 내에서 답을 찾아야 했다.
결론적으로, Three.js 텍스처라는 기술 제약과 백엔드 변경이 어려운 환경 제약이 겹쳐서, 이미 존재하는 프록시 서버에서 서버 사이드로 변환하는 것이 유일하면서도 가장 효율적인 방법이었다.
sharp는 Node.js 환경에서 가장 널리 사용되는 고성능 이미지 처리 라이브러리다.
프록시에서 서버 사이드 변환을 해야 한다는 결론이 나왔으니, 어떤 라이브러리를 쓸 것인가의 문제였다. sharp를 선택한 이유는 명확했다:
한 줄로 요약하면, Three.js 텍스처라는 제약 때문에 프록시 서버 사이드 변환이 유일한 선택지였고, 그 위에 올릴 라이브러리로는 이미 있는 프록시에 sharp 한 줄 추가하면 끝이라는 점에서 가장 합리적이었다.
기존 프록시 라우트에 w(width)와 q(quality) 파라미터를 추가했다:
// proxy/route.ts
import sharp from "sharp";
const widthParam = searchParams.get("w");
if (widthParam && isImage) {
const width = parseInt(widthParam, 10);
const quality = parseInt(searchParams.get("q") || "80", 10);
const buffer = Buffer.from(await response.arrayBuffer());
const optimized = await sharp(buffer)
.resize(width, null, { fit: "inside", withoutEnlargement: true })
.webp({ quality })
.toBuffer();
return new NextResponse(optimized, {
headers: {
"Content-Type": "image/webp",
"Cache-Control": "public, max-age=604800, stale-while-revalidate=86400"
}
});
}
클라이언트에서는 프록시 URL에 원하는 크기만 명시하면 된다:
// 표시 크기 546px × 2 (Retina 대응) = 1092px
const optimizedUrl = `/api/proxy?url=${encodeURIComponent(rawUrl)}&w=1092&q=80`;
| 항목 | Before | After | 변화 |
|---|---|---|---|
| 해상도 | 2888 × 3852 | 1092 × 1456 | 2x Retina 대응 |
| 포맷 | JPEG | WebP | |
| 용량 | 3,506.3KB | 122.6KB | 96.5% 감소 |
| 캐시 | 1일 (GCS 기본) | 7일 (프록시) | 재방문 성능 향상 |
HTML → JS → hydration
→ 분석 데이터 API 대기(차단)
→ 렌더
→ 이미지 다운로드 (3,506.3KB JPEG, 낮은 우선순위)
→ LCP ⏱️ 16.7초
HTML → JS + 폰트 preload(병렬) → hydration
→ 얼굴 이미지 즉시 렌더 (분석 데이터 대기 없음, 2D 우선)
→ 프록시 WebP (122.6KB, fetchPriority="high") → LCP ⏱️ 5.7초
→ 분석 데이터 API (병렬, 비차단)
→ 3D 메시 리소스 (백그라운드, 비차단)
→ 메시 준비 → 스캔 트랜지션 → 3D 뷰 전환
→ 분석 스크립트 미로딩 (idle 대기)
이번 최적화에서 가장 크게 배운 건 "문제의 층위를 구분하는 것"이었다.
Phase 3에서 로딩 화면을 적용했다가 LCP가 23.7초로 악화된 경험도 잊을 수가 없다. 멋진 3D 스캔 애니메이션을 포기한 게 아니다. 부모가 자식을 "2D 모드"로 속여서 이미지를 먼저 그리게 하고, 3D가 준비되면 바꿔치기하는 구조로 3D 경험은 그대로 유지하면서 LCP만 끌어내린 것이다. 현장에서 들려오던 "느려요" 피드백이 사라진 게 뿌듯했다.
성능 최적화를 위해 몇 개의 depth를 파고든건지,, 렌더 구조, 네트워크 우선순위, 리소스 크기, 캐싱 전략이 복합적으로 작용한다. 각 층위를 하나씩 풀어가면서 16.7초 → 14.2초 → 7.9초 → 5.7초로 점진적으로 개선해 나간 과정 자체가 이번 작업의 핵심이었다고 생각한다.
그래도 아직 4초대를 못 파고 들어서 아쉬움이 남는다. 좀 더 연구하고 공부해봐야겠다...!