10년차도 속았다! '죽은 코드'에 발목 잡힌 성능 최적화 대반전 스토리
2026. 8. 19.
10년차도 속았다! '죽은 코드'에 발목 잡힌 성능 최적화 대반전 스토리
안녕하세요! 10년 차 IT 실무자이자 테크 블로거, 이번에는 제가 직접 겪은 성능 최적화 삽질기를 풀어보고자 합니다. 제 웹사이트는 방문자나 클라이언트가 실제로 접속하는 유일한 페이지인데, 모바일에서 유독 버벅거린다는 불만이 많았죠. 처음 파일을 열기도 전에 저는 이미 해결책을 머릿속에 그렸습니다. 다섯 개의 캔버스, 수많은 애니메이션 루프들… 이걸 하나로 합치면 되겠구나!
하지만 제 노트 대신 실제 파일을 읽기 시작하면서 모든 것이 뒤집혔습니다.
// index.html:3824
const WORLD_CANVAS_ENABLED = false;
제가 병합하려던 네 개의 영구적인 애니메이션 루프 중 두 개는 아예 실행조차 되지 않았습니다. 이 WORLD_CANVAS_ENABLED 플래그가 :7103 라인에서 이들을 시작시키는 호출 자체를 막고 있었던 거죠.
이 플래그는 오직 그 부분만 가로막았습니다. 왜 이리 정확하게 언급하느냐고요? 처음에는 이 플래그가 2D 컨텍스트 생성도 막는다고 생각했거든요. 그런데 실제로 배포된 파일을 확인해보니 제 작업 복사본과 달랐습니다.
// index.html:3832, :3834, :3875 — 어떠한 보호 장치도 없음
const ctx = canvas.getContext('2d');
const vctx = visitorCanvas.getContext('2d');
const visitorPreviewCtx = visitorPreview.getContext('2d');
결론적으로, 아무것도 그려지지 않는 캔버스 세 개를 위해 프로덕션 환경에서는 여전히 2D 컨텍스트가 생성되고 있었습니다. 애니메이션 루프는 죽었지만, 메모리 부담은 그대로 남아있던 셈이죠. 이 세 라인을 보호하는 건 제 작업 트리에는 있지만 아직 배포되지 않은 별개의 수정 사항이라, 이번 글의 내용에는 포함하지 않겠습니다.
세 번째 루프인 커스텀 커서 역시 터치 디바이스에서는 즉시 종료됩니다.
// index.html:6656
(function primaCursor() {
if (window.matchMedia('(hover: none)').matches) return;
이 페이지는 모바일 사용자에게 중요한 만큼, 모바일 성능 측정이 핵심이었습니다. 결과적으로, 제가 최적화하려던 디바이스에서는 페이지에 단 하나의 영구적인 JS 루프만 존재했던 겁니다. 제가 처음에 구상했던 최적화는 사실상 '합칠' 대상이 거의 없었던 거죠. 만약 이대로 코드를 배포하고 성능을 측정했다면, 아무런 변화를 감지하지 못했을 테고, 문제 해결에 실패한 건지 아니면 애초에 해결할 문제가 없었던 건지조차 알 수 없었을 겁니다. 제가 실무에서 이런 종류의 헛수고를 줄이고자 늘 강조하는 게 바로 '가설-검증-측정' 사이클인데, 이번에는 저 스스로 그 원칙을 어긴 셈이었습니다. 제가 직접 작성한 문서와 제가 직접 코딩한 파일을 두고 말이죠.
버그 수정인가, 성능 개선인가?
진짜 버그를 찾다
기억에 의존하는 대신 코드를 꼼꼼히 읽기 시작하자, 드디어 진짜 문제점을 발견했습니다.
// index.html:3188
function canRunWebGLHero() {
return window.matchMedia('(hover: hover)').matches; // ← 항상 여기서 리턴됩니다.
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return false;
if (window.matchMedia('(max-width: 820px)').matches) return false;
if ((navigator.hardwareConcurrency || 4) < 4) return false;
if (navigator.deviceMemory && navigator.deviceMemory < 4) return false;
try {
const c = document.createElement('canvas');
return !!(c.getContext('webgl2') || c.getContext('webgl'));
} catch (error) {
return false;
}
}
무조건적인 return 문 아래에 다섯 개의 중요한 보호 장치들이 숨어 있었습니다. 제가 감사(audit)한 배포 빌드에서는 이 중 단 하나도 실행되지 않았습니다. 심지어 이 빌드는 현재 라이브 서비스 중인 버전입니다. 과거의 모든 배포를 감사한 것은 아니기에 언제부터 이런 상태였는지는 단정할 수 없습니다. 이 버그의 패치는 그 무의미한 return 문을 삭제하는 것입니다. 방문자가 로드하는 파일에서 해당 라인이 사라지기 전까지는 과거형으로 "고쳤다"고 말하지 않겠습니다.
이 return 문 때문에 조용히 꺼져버린 기능들은 다음과 같습니다:
| 보호 장치 (Guard) | 원래 의도 | 현재 (Actual) |
|---|---|---|
prefers-reduced-motion | WebGL 히어로 비활성화 | 무시됨 |
max-width: 820px | 작은 화면 스킵 | 무시됨 |
hardwareConcurrency < 4 | 저사양 CPU 스킵 | 무시됨 |
deviceMemory < 4 | 저용량 RAM 기기 스킵 | 무시됨 |
webgl2 / webgl 탐지 | 미지원 기기 스킵 | 무시됨 |
이 중 prefers-reduced-motion 부분이 가장 마음에 걸립니다. 사용자가 운영체제 수준에서 설정한 개인적인 선호도를, 제 페이지가 배포된 이래 줄곧 무시하고 있었다는 사실이었습니다. 제가 그들의 선택에 동의하지 않아서가 아니라, return 문이 잘못된 줄에 있었기 때문입니다. 이처럼 섬세한 부분까지 놓치고 있었다는 게 10년 차 개발자로서 정말 부끄러웠습니다.
특히 WebGL 기능 탐지 부분은 비용이 큽니다. hover 기능이 있는 기기(데스크톱)에서 WebGL을 지원하지 않아도, Three.js를 다운로드하고 컴파일하려 시도합니다. 그리고 실패하면 .catch(err => console.warn(...)) 블록으로 빠지는데, 아무도 그 경고를 보지 못하죠. 실행할 수 없는 기능에 대해 온전한 비용을 지불하고 있었던 겁니다.
코드 들여다보기
변경된 두 파일의 정확한 diff는 다음 링크에서 확인하실 수 있습니다:
gist.github.com/keniel13-ui/3c05e11202d048ad78a11ce2de215e8d
— 배포된 06a768bb 파일에 대한 두 개의 통합 diff와 각 덩어리가 어떤 역할을 하는지, 그리고 의도적으로 제외된 사항들을 설명하는 README가 포함되어 있습니다. 아직 프로덕션 환경에 반영되지 않았으므로, 지금 웹사이트를 로드하면 이 diff의 '이전' 상태를 보시게 될 겁니다.
세 개의 스크립트, 그리고 제가 오해했던 하나
이 페이지는 모든 로드 시 세 개의 서드파티 스크립트를 선언합니다:
<!-- index.html:129-131, 라이브 페이지에서 그대로 가져옴. 첫 번째 스크립트에 빠진 것을 주목하세요. -->
<script src="https://cdn.jsdelivr.net/npm/@supabase/supabase-js@2"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/gsap.min.js" defer></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/ScrollTrigger.min.js" defer
onload="window.dispatchEvent(new Event('gsap-ready'))"></script>
첫 번째 <script> 태그에는 defer 속성이 없습니다. 두 개의 GSAP 태그에는 있죠. 즉, Supabase는 <head> 섹션에 일반 스크립트로 존재합니다. 브라우저는 DOM 빌드를 멈추고, Supabase를 가져와 파싱하고 실행한 후에야 작업을 계속합니다. 파서 블로킹(parser-blocking) 방식인데, 이 라이브러리의 모든 호출 지점은 꺼져 있는 플래그 뒤에 숨겨져 있었습니다.
이 글의 초기 버전에는 저 supabase-js 라인에 defer를 제가 직접 추가했었습니다. 제 작업 브랜치에서는 이미 defer가 있었거든요. 배포된 코드를 인용할 때는 반드시 배포된 바이트를 그대로 인용해야 한다는 것을 다시 한번 깨달았습니다.
이번 감사 시 프로덕션 페이지가 사용한 URL에서 측정했습니다. GSAP는 3.12.5 버전에 고정되어 있습니다. @supabase/supabase-js@2는 버전 고정이 아닙니다. jsDelivr는 @2를 메이저 버전 별칭으로 취급하며, 측정 당시 2.112.3 버전으로 확인되었습니다(x-jsd-version: 2.112.3). 나중에 다시 측정했을 때 Supabase 버전이 다르다면 그 때문입니다.
| gzip 전송량 | 압축 해제된 JS 소스 | |
|---|---|---|
supabase-js@2 | 54,607 B | 212,199 B |
gsap.min.js | 28,200 B | 72,214 B |
ScrollTrigger.min.js | 17,681 B | 43,380 B |
| 총합 | 100,488 B | 327,793 B |
세 단위는 서로 다릅니다. 327,793 B는 압축 해제된 JavaScript 소스입니다. 이는 321,920 바이트짜리 HTML 문서 전체보다 약간 더 많은 소스 코드입니다. 100,488 B는 감사 당시 측정된 gzip 전송량입니다. 이 글을 마무리하면서 동일한 세 URL을 다시 가져왔을 때 100,148 B가 측정되었는데, 압축 해제된 소스는 변함이 없었고 압축된 전송량만 변동했습니다. 이는 위에 언급된 @2 별칭과 CDN 재압축의 영향이며, 이것이 제가 별칭을 고정 버전으로 표기하지 않은 이유입니다. 이 수치들은 모두 콜드 로드(cold-load) 기준이며, 캐시가 따뜻한 재방문자는 다시 전송하지 않습니다.
솔직한 헤드라인은 소스 코드 크기입니다. 이 세 라이브러리가 장식하는 문서 전체보다 더 많은 JavaScript 소스를 담고 있었다는 점이죠.
Supabase는 간단합니다. loadWorldState, loadVisitorAvatars, subscribeToVisitorAvatars 등 모든 호출 지점은 if (WORLD_CANVAS_ENABLED) 블록 내에서만 호출됩니다. 이 플래그는 false입니다. 제가 감사한 배포 빌드에서는 이 호출 지점들에 도달할 수 없습니다. 과거의 모든 배포를 감사한 것은 아니기에 사이트 역사상 단 한 번도 실행되지 않았다고 주장할 수는 없지만, 어쨌든 라이브러리는 모든 콜드 로드 시마다 여전히 가져와서 파싱되고 있었습니다.
GSAP는 제가 틀렸던 부분이며, 이 부분이 이 글의 주요 내용이 될 뻔했기에 기록으로 남기고자 합니다.
index.html 파일에서 gsap를 검색했지만 스크립트 태그 외에는 아무것도 찾지 못했습니다. 그래서 작업 파일에 "이 라이브러리들은 호출 지점이 전혀 없으며, gsap-ready 이벤트에는 리스너가 없다"는 주석을 남겼죠. 두 문장 모두 거짓이었습니다. 저는 여러 파일이 있는 사이트에서 하나의 파일만 검색했던 겁니다.
// prima-visual.js (live, dc78c46d…):226
const { gsap, ScrollTrigger } = window;
gsap.registerPlugin(ScrollTrigger);
gsap.to({ progress: 0 }, {
progress: 1,
ease: 'none',
scrollTrigger: { trigger: document.documentElement, start: 'top top',
end: 'bottom bottom', scrub: 1.2,
onUpdate: (self) => { scrollTarget = self.progress; } },
});
// prima-visual.js:248
window.addEventListener('gsap-ready', wireGSAP, { once: true });
248번 라인은 제가 존재하지 않는다고 말했던 그 리스너입니다. 그리고 scrub: 1.2는 실제로 중요한 작업을 하고 있었습니다. 바로 스크롤 시 배경이 손가락을 즉시 따라가는 대신 부드럽게 뒤따라오게 하는 '무게감'을 담당하고 있었죠. 이 태그들을 삭제하는 것은 죽은 코드를 제거하는 것이 아니라, 성능 점수로는 절대 알 수 없는 기능을 조용히 제거하는 것이었습니다.
따라서 이 최적화의 정직한 버전은 "아무도 호출하지 않는 라이브러리를 삭제했다"가 아닙니다. "115,594 바이트에 달하는 라이브러리 소스 코드에서 하나의 동작을 사용하고 있었고, 그 하나의 동작을 직접 구현하여 대체했다"는 것이죠.
렌더 루프를 살펴보니 대체 구현이 예상보다 작았습니다. 그 절반은 이미 존재하고 있었으니까요.
bgUniforms.uScroll.value += (scrollTarget - bgUniforms.uScroll.value) * 0.04;
항상 두 단계의 이징(easing)이 있었습니다. ScrollTrigger의 scrub가 첫 번째 단계였고, 위 라인이 두 번째 단계로, 제가 직접 작성한 코드였습니다. GSAP 태그를 제거한 것은 오직 첫 번째 단계만 제거한 것이었습니다. 페이지는 이징이 전혀 없어지지 않았고, 단순히 '지연'만 사라진 것이었죠.
// gsap + ScrollTrigger를 대체합니다. 이는 '수정 후' 파일에 있는 내용이며, 깔끔하게 정리된 내용이 아닙니다.
const SCRUB_SECONDS = 1.2; // 이름은 거짓말입니다. catch-up이 아닌 시간 상수입니다.
let scrollRaw = 0, scrollEased = 0;
const readScroll = () => {
const max = document.documentElement.scrollHeight - window.innerHeight;
scrollRaw = max > 0 ? Math.min(1, Math.max(0, window.scrollY / max)) : 0;
};
readScroll();
window.addEventListener('scroll', readScroll, { passive: true });
window.addEventListener('resize', readScroll, { passive: true });
// 기존 rAF 루프 내부:
const dt = Math.min(0.1, (now - lastFrame) / 1000); // 탭 전환 간격을 위해 클램핑
scrollEased += (scrollRaw - scrollEased) * (1 - Math.exp(-dt / SCRUB_SECONDS));
GSAP의 scrub: 1.2는 1.2초 안에 따라잡는다는 의미입니다. 제가 구현한 것은 지수 시간 상수(exponential time constant)입니다. τ = 1.2일 때 1.2초에 63%에 도달하고, 95%에 도달하려면 약 3.6초가 필요합니다. 만약 "따라잡았다"는 것을 ~95% 정착으로 정의한다면 (이는 GSAP가 제공하는 정의가 아니라 제 정의입니다), τ ≈ 0.4일 때 정착 시간이 1.2초 근처가 됩니다. 이는 경험적인 매핑이지, 동등한 재현은 아닙니다. 저는 라이브러리를 재현했다고 주장하는 이름 아래에 틀린 숫자를 파일에 남겨두었는데, 실제 사용감을 확인하는 유일한 테스트는 휴대폰으로 직접 스크롤하는 것뿐이기 때문입니다. 아직 그 테스트는 하지 못했습니다. 이 코드는 scrub를 근사하는 것이지, 완벽히 재현하는 것은 아닙니다. 제가 실무에서 이 부분을 테스트해 봤을 때, 단순히 수치를 맞추는 것보다 실제 디바이스에서 '손맛'을 느끼는 것이 훨씬 중요했습니다.
검토 후 수정된 한 가지 속성 주장이 있습니다. 이 문장의 첫 번째 버전은 틀렸었거든요. 새로운 첫 번째 단계는 프레임 기반의 고정 lerp가 아닌 시간 기반입니다. 하지만 두 번째 단계, 즉 위의 * 0.04 라인은 여전히 프레임 기반입니다. 따라서 결합된 시각적 반응은 새로고침 빈도에 독립적이지 않습니다. 저는 그렇게 주장하지 않습니다. 제가 대체한 단계만 그렇습니다.
한 가지 더 삭제된 것이 있는데, 제가 공을 가로채지 않도록 명시합니다. 배포된 페이지는 로드 시 WebGL 인트로(index.html:6576, fireIntro)를 실행합니다. 저는 이미 소유자로서 이 동작을 거부했습니다. 수정 후 빌드에서는 이를 호출하지 않습니다. "Clear the Lineup" 챌린지는 삭제를 최적화로 평가하지 않으므로, 제가 공개할 어떤 델타에도 포함되지 않습니다.
제가 개선한 것들
아무도 세지 않았던 고정 비용
제가 처음 작성했던 최적화 계약서는 캔버스와 requestAnimationFrame 루프만 세고 있었습니다. CSS는 세지 않았죠.
Gemini(AI)가 "15개 이상의 무한 애니메이션"이라고 세기에, 확인도 안 하고 초고에 복사했습니다. 그러다가 이 글 전체의 주제가 된 '직접 확인하기'를 실행했습니다.
배포된 파일에는 15개의 infinite 애니메이션 선언이 있었습니다. 14개는 CSS 규칙으로, 하나는 JavaScript에서 주입된 것이었습니다. 14개의 CSS 규칙 중 6개는 DOM에 전혀 나타나지 않고 JavaScript에 의해 주입되지도 않는 클래스를 대상으로 하고 있었습니다: .feed-pulse-dot, .feed-line, .sig-dot, .hero-scan, .hero-console, .scroll-cue. 마지막 .scroll-cue는 .prima-scroll-cue가 히어로 영역에 살아있기 때문에 놓치기 쉽지만, 다른 선택자입니다. id="scroll-cue"도 없었습니다. 스크롤 핸들러가 찾으려 해도 아무것도 찾지 못하는 죽은 규칙들이었습니다. 런타임에는 아무것도 애니메이션할 대상이 없으므로 비용도 발생하지 않았습니다.
따라서 실제 살아있는 애니메이션은 **8개의 CSS 규칙과 ambientDrift**였습니다. 이 중 seedBreathe (생명의 씨앗) 하나는 유지됩니다. 나머지는 모두 눈에 보이든 안 보이든 실행되는 장식용이었습니다.
8개의 라이브 CSS 대상은 모두 <main> 태그 안에 있으므로, main > section[id] 선택자로 모두 도달할 수 있습니다. 저는 이 역시 가정 대신 직접 확인했습니다. 왜냐하면 이 선택자는 가상 요소(pseudo-elements)를 매치하지 않으며, 저는 이미 한 번 선택자가 무엇을 커버하는지에 대해 잘못 생각한 적이 있었기 때문입니다.
이것은 이 페이지에서만 세 번째로 '선언'의 개수를 '실제로 발생하는 일'의 개수로 보고하는 실수를 저지른 사례입니다. 네 개의 루프 중 두 개는 시작조차 하지 않았고, 열네 개의 CSS 무한 규칙 중 여섯 개는 아무것도 대상으로 하지 않았으며, 제 스스로는 이런 실수를 하지 말라고 계약서에 명시했음에도 두 번이나 반복했죠.
페이지에는 이미 IntersectionObserver가 있었고, 저는 이 공을 가로챌 뻔했습니다. 하지만 이것은 브레이크 역할을 하지 않았습니다.
obs.unobserve(entry.target); // 한 번만 카드 노출, 그 후에는 해제
이 IntersectionObserver는 카드를 한 번 노출한 후 연결을 해제하며, prefers-reduced-motion 설정에 따라 전체 블록이 일찍 종료됩니다. 그래서 실제 '일시 정지' 기능을 수행할 두 번째 옵저버를 추가했습니다.
[data-ctl-offscreen], [data-ctl-offscreen] * { animation-play-state: paused !important; }
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-play-state: paused !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
const obs = new IntersectionObserver(entries => {
entries.forEach(e => e.isIntersecting
? e.target.removeAttribute('data-ctl-offscreen')
: e.target.setAttribute('data-ctl-offscreen', ''));
}, { rootMargin: '200px 0px 200px 0px', threshold: 0 });
document.querySelectorAll('main > section[id]').forEach(s => obs.observe(s));
document.addEventListener('visibilitychange', () => { /* 전체 탭 일시 정지 */ });
이러한 전반적인 비용을 제어하기 전에 한 가지 더 확인했습니다. [data-ctl-offscreen] * 선택자는 가상 요소를 매치하지 않으므로, ::before나 ::after에 적용된 무한 애니메이션은 화면 밖에서도 계속 실행될 수 있습니다. 확인해 본 결과, 이 파일의 무한 애니메이션 중 가상 요소에 적용된 것은 하나도 없었습니다. 따라서 이 선택자가 모든 것을 커버합니다. 만약 여러분의 코드에 가상 요소 애니메이션이 있다면 ::before와 ::after도 추가해야 합니다.
15번째 애니메이션인 ambientDrift는 스타일시트에 작성된 것이 아니라 JavaScript에서 주입된 것이기에 CSS 검색으로는 찾을 수 없었습니다. 하지만 이 요소들은 옵저버가 이미 감시하고 있는 섹션 내부에 추가되므로, 자손 선택자가 이를 포착합니다.
삭제된 것은 없습니다. 이전에도 15개의 무한 애니메이션 선언이 있었고, 수정 후에도 15개입니다. 다만, 재생 상태만 제어되고, 눈에 보이지 않는 곳에서만 일시 정지됩니다. 선택자는 main > section[id]입니다. #world를 포함한 11개의 섹션이 해당됩니다. 화면 밖에서는 '생명의 씨앗'이 쉬고, 화면 안에서는 여전히 숨 쉬고 있습니다. 이처럼 섬세한 접근은 단순히 성능을 넘어 사용자 경험을 존중하는 개발자의 자세라고 생각합니다.
수치 분석
두 버전 모두 동일한 Vercel 프로젝트 내에서 프리뷰로 배포되었으며, 하나의 세션에서 A/C를 번갈아 가며 lighthouse 13.4.1, 모바일, 기본 시뮬레이션 스로틀로 측정했습니다. 이 부분이 중요한데, Vercel은 프리뷰에 vercel.live/feedback.js를 주입하고 프로덕션에는 주입하지 않으므로, 프로덕션을 프리뷰와 비교하는 것은 두 개의 다른 페이지를 비교하는 셈이 됩니다. 여기서는 두 버전 모두 해당 스크립트를 포함하고 있었습니다.
보고할 수 있는 것
총 전송량은 CPU 측정치가 아닙니다. 이는 응답 크기의 합계이므로 호스트 속도에 따라 변하지 않습니다.
| A — 이전 | C — 이후 | |
|---|---|---|
| 총 바이트 용량, 3회 중앙값 | 568,605 B | 467,962 B |
| 실행 간 편차 | 1,616 B | 952 B |
| 리소스 | 10 | 10 |
차이: 100,643 B — 98.3 KB, 이전 버전의 17.7% 감소.
최악의 A 버전도 최상의 C 버전보다 98,993 B 더 많은 용량을 전송했습니다. 모든 A 버전 실행이 모든 C 버전 실행보다 더 많은 데이터를 전송했으며, 겹치는 부분이 없었습니다. 그리고 이는 세 개의 CDN URL을 직접 curl로 확인했을 때 얻은 수치(100,148–100,488 B)와 독립적으로 일치합니다. 두 가지 다른 방법으로 같은 결과가 나왔기에, 저는 이 수치들을 신뢰합니다.
아직 보고할 수 없는 것
CPU 의존적인 수치는 없습니다. 제 자체 게이트가 여섯 번의 모든 실행을 거부했습니다.
| 실행 | benchmarkIndex | 판정 |
|---|---|---|
| A-01 / A-02 / A-03 | 833 · 827 · 783 | 기준 미달 |
| C-01 / C-02 / C-03 | 623 · 214 · 660 | 기준 미달 |
기준치는 1500입니다. 30분 전 한 번의 보정 실행은 1711점을 기록했지만, 그 후 8GB 노트북에서 Lighthouse를 10번 연속 실행하자 호스트 성능이 자체 기준치 아래로 떨어졌습니다. 그래서 저는 이 여섯 번의 실행에서 LCP, TBT 및 메인 스레드 수치를 가지고 있지만, 제가 직접 정한 규칙에 따라 이 글에 포함하지 않을 것입니다.
이것이 제출물에 어떤 영향을 미치는지 정확히 설명하고 싶습니다. 여기서 핵심적인 성능 주장은 17.7%의 전송량 감소와 실제 버그 수정이지, Lighthouse 점수가 아닙니다. CPU 수치를 측정하려면 측정 도구를 실행하는 기기가 아닌 별도의 기기가 필요한데, 오늘은 준비되지 않았습니다.
어쨌든 폐기된 모든 실행 데이터는 Gist(measurements.md)에 benchmarkIndex와 함께 게시되어 있으므로, 제 말을 믿기보다는 폐기된 데이터를 직접 감사할 수 있습니다.
이전 버전 수치가 제가 가진 최악의 수치가 아닌 이유.
이 페이지에 대한 첫 번째 Lighthouse 실행(2026-08-17)은 27점을 기록했으며, LCP 10.4초, TBT 13,310ms였습니다. 이는 가장 매력적인 기준선이지만, 저는 사용하지 않고 있습니다. 동일한 URL, 동일한 도구 버전(lighthouse 13.4.1), 동일한 스로틀 설정으로 7번의 추가 실행에서 LCP 3.67–5.48초, TBT 216–709ms를 기록했습니다. 첫 번째 실행 결과는 한 번도 재현되지 않았습니다. 이를 기준선으로 사용하면 어떤 개선도 재현 가능한 기준선보다 훨씬 극적으로 보일 것입니다. 아래 표가 완성되기 전까지는 그렇게 과장하지 않겠습니다.
또한, 저는 조용한 호스트에서 측정된 수치와 죽어가는 호스트에서 측정된 수치를 비교할 수 없습니다. 모든 Lighthouse 보고서에는 environment.benchmarkIndex라는 호스트 속도 수치가 포함되어 있습니다. 2026년 8월 17일에 제 실행 중 한 세트는 302–1113을 기록했습니다. 같은 날 다른 모든 프로덕션 세트는 1705–2342를 기록했습니다. 겹치는 부분이 없었죠. 그 느린 실행들은 노트북 메모리가 부족해지면서 측정되었으며, 27분 후에 세션이 종료되었습니다. 도구는 그 와중에도 깔끔하게 종료되고 유효한 JSON을 계속 작성했습니다.
그래서 제 포함 규칙은 다음과 같습니다. 1500이라는 임계값은 제가 선택한 것이며, Lighthouse 유효성 경계가 아닙니다. 동일 세션, 양측 모두, 모든 실행에서 benchmarkIndex ≥ 1500을 충족하지 않으면 수치를 포함하지 않습니다.
제가 주장하지 않는 것들
- 루프 병합이 주된 승리는 아닙니다. 모바일에는 단 하나의 루프만 있었고, 제가 그 변화를 설명할 수 없었기에 그대로 두었습니다.
- 321,920 바이트 HTML 문서 내의 173,280 바이트 인라인 JavaScript는 그대로 유지되었습니다. 이는 남은 LCP(Largest Contentful Paint) 비용 중 가장 큰 부분이며, 이를 분리하는 것은 최적화보다는 구조 조정에 가깝습니다. Gemini는 인라인 JS를 "168 KB"라고 불렀지만, 배포된 파일 기준으로 측정하면 173,280 바이트입니다.
- Three.js는 여전히 페이지에 있습니다.
prima-visual.js(8,773 바이트, 라이브)는 첫 페인트 이후 이를 가져옵니다. hover 기능이 있는 데스크톱에서는hero-webgl.js(12,852 바이트, 라이브)가 동일한 모듈을 가져옵니다. 한 번 다운로드되어 두 곳에서 사용되는 것이죠. Lighthouse는 이 중 151KB가 사용되지 않는다고 표시합니다. 모바일에서 시각적 레이어를 게이팅하면 페이지가 평평해지므로, 더 나은 대안을 찾을 때까지는 유지됩니다. prefers-reduced-motion보호 장치는 현재 작동하지 않습니다. 그 초기return이 사라져야 작동할 겁니다. 방문자의 브라우저가 실제로 다른 경로를 택하기 전까지는 이 문장을 과거형으로 쓸 수 없습니다.fireIntro제거는 소유자의 지시에 따른 삭제입니다. 이는 공개되었지만, 최적화는 아닙니다.- 이 모든 변경 사항은 아직
primaworkflows.com에 라이브 배포되지 않았습니다. 위에서 언급된 해시들은 '이전' 버전입니다. '이후' 버전은 아직 로컬 프리뷰 후보 단계에 있습니다.
이제 막 시작하는 분들께 드리고 싶은 말
저는 제 사이트에 대해, 제 기억에 의존하여 꼼꼼하게 명세서를 작성했지만, 세 군데에서 오류를 범했습니다. 두 개의 루프는 아예 실행되지 않았고, 제가 세지 않았던 애니메이션 클래스가 통째로 있었으며, 죽었다고 생각했던 라이브러리가 실제로는 스크롤 처리를 담당하고 있었습니다. 제가 실행한 모든 검사는 정확했지만, 제가 그 검사 결과로 내린 주장은 실제보다 훨씬 광범위했습니다. 제가 이 경험을 통해 얻은 가장 큰 교훈은, 아무리 작은 변경이라도 '내 코드'라고 맹신하지 말고 항상 실제 코드를 보며 검증해야 한다는 겁니다.
코드의 버그는 return 문 아래에 다섯 개의 보호 장치가 숨어 있었다는 것이었습니다. 저 자신에게 있었던 버그는 이와 똑같은 형태였지만 한 단계 더 높은 수준에 있었습니다. 즉, 실제 결과를 바탕으로 했지만, 그 결과가 커버하는 범위보다 더 넓게 주장했다는 것이죠.
Google AI의 가장 좋은 활용 사례
저는 Gemini에게 최적화 전의 고정된 상태를 제공하고, 제가 어떤 것을 구현하기 전에 문제점을 진단해달라고 요청했습니다. 그래야 진단 내용이 수정된 결과에 맞춰 거꾸로 작성되지 않을 테니까요. 원본 출력은 타임스탬프가 찍혀 있으며, 저는 Gemini가 말한 내용을 편집할 수 없었습니다.
Gemini는 제가 놓쳤던 부분을 정확히 짚어냈습니다. 바로 경쟁하는 무한 CSS 키프레임 애니메이션이었죠. 제 계약서는 캔버스 및 rAF만 세고 있었기에 이 부분은 아예 포함되지 않았습니다. 또한, 두 가지 가설을 분리했습니다. 인라인 파서 블로킹 JS (173,280 바이트, Gemini가 출력한 168 KB가 아님)가 콜드 로드 및 LCP 비용에 더 큰 영향을 미칠 것이라는 가설과, 영구적인 애니메이션이 메인 스레드 비용의 후보라는 가설입니다. 저는 이 구분을 측정하지 않았으므로, 이는 가설일 뿐 실제 원인은 아닙니다. 하지만 이 구분 덕분에 제가 무엇을 측정해야 할지가 달라졌습니다. 루프를 병합한 다음 LCP를 기준으로 결과를 판단했다면, 실제 수정 사항이 실패로 보였을 수도 있었으니까요.
Gemini는 또한 페이지에 IntersectionObserver가 없다고 말했습니다. 하지만 배포된 HTML에는 6639번째 줄에 하나가 존재합니다. hero-webgl.js에도 또 다른 옵저버가 있습니다. 제가 Gemini의 말을 믿기 전에 직접 확인했기 때문에, 이 오류는 이 글의 한 단락이 아닌 각주로 남게 되었습니다.
만약 Gemini가 아무런 추가적인 통찰력을 제공하지 않았다면, 저는 이 카테고리 자체를 삭제했을 겁니다. 하지만 CSS 애니메이션 개수를 짚어준 것만으로도 이 섹션을 유지할 가치가 충분했습니다. 그렇다고 해서 AI가 이 글 전체를 쓸 만큼 충분했던 것은 아니었죠.
원문: https://dev.to/kenielzep97/i-was-about-to-optimize-five-canvases-two-of-them-werent-running-34ii 수집일: 2026-08-19 00:30:13