Next.js에서 장식용 WebGL을 첫 화면 밖으로 미루는 설계
전체 화면 WebGL 배경을 제거하지 않고도 초기 렌더링을 우선하는 방법을 정리한다. 정적 폴백, 기기 능력 검사, idle 로딩, 가시성·접근성 처리, 정책 테스트를 실제 포트폴리오 코드 기준으로 설명한다.
전체 화면 WebGL 배경은 포트폴리오의 인상을 만들 수 있습니다. 하지만 장식용 효과가 첫 화면의 콘텐츠보다 먼저 브라우저의 네트워크·CPU·GPU 예산을 가져가면 우선순위가 뒤집힙니다.
이 저장소의 유체 배경은 그래서 처음부터 렌더링되는 기능이 아니라 정적 화면 위에 나중에 얹는 선택적 enhancement로 다룹니다. 중요한 것은 WebGL을 무조건 제거하는 것이 아닙니다. 콘텐츠가 먼저 보이고, 효과는 조건이 맞을 때만 안전하게 시작되도록 경계를 만드는 것입니다.
이 글은 특정 수치의 Lighthouse 개선이나 사용자 성과를 주장하는 보고서가 아닙니다. 실제 코드와 테스트에 남아 있는 설계 결정, 그리고 그 결정을 다른 Next.js 사이트에 적용할 때 필요한 트레이드오프를 설명합니다. 구현의 출발점은 cold-load 렌더링을 우선한 커밋과 WebGL enhancement를 인터랙션 이후로 미룬 커밋입니다.
문제를 “WebGL이 빠른가”로 정의하지 않는다
장식용 배경의 첫 질문은 평균 프레임 레이트가 아닙니다.
이 효과가 준비되기 전에도 사용자는 페이지의 핵심 콘텐츠를 읽고 조작할 수 있는가?
web.dev의 LCP 최적화 가이드는 관련 없는 JavaScript의 파싱·실행이나 긴 작업이 주요 콘텐츠의 렌더링을 지연시킬 수 있다고 설명합니다. 이 원칙을 포트폴리오에 적용하면 판단은 간단해집니다.
- 제목, 소개, 탐색, 프로젝트 링크는 critical content다.
- 유체 배경은 의미를 전달하지 않는 decorative enhancement다.
- enhancement가 실패해도 완전한 정적 폴백이 남아야 한다.
이렇게 분류하면 “배경을 어느 브라우저에서 60fps로 돌릴까?”보다 먼저 “배경이 없어도 페이지가 완성되어 보이는가?”를 검토하게 됩니다. 성능 최적화의 목표가 효과의 존재를 증명하는 것이 아니라, 효과가 콘텐츠의 준비를 방해하지 않도록 하는 것이기 때문입니다.
첫 렌더링은 정적 폴백으로 시작한다
HomeLayoutWrapper는 배경 로더를 페이지 구조에 포함하지만, 로더는 처음부터 WebGL 컴포넌트를 렌더링하지 않습니다. 항상 다음과 같은 정적 레이어를 먼저 반환합니다.
return (
<>
<div
aria-hidden="true"
className="liquid-background-fallback fixed inset-0 -z-10 pointer-events-none"
/>
{LiquidBackground ? <LiquidBackground /> : null}
</>
);
이 구조에는 두 가지 의미가 있습니다.
- 서버와 초기 hydration은 브라우저 전용 WebGL API에 의존하지 않는다.
- 동적 import가 늦어지거나 실패해도 사용자에게 빈 배경이 아니라 의도한 정적 화면을 보여준다.
Next.js의 Lazy Loading 가이드는 Client Component와 라이브러리를 동적 import로 분리해 필요한 시점에 불러올 수 있다고 설명합니다. 다만 코드 스플리팅만으로는 충분하지 않습니다. 컴포넌트를 페이지 렌더링 시점에 바로 동적으로 불러오면, 파일은 분리되어도 초기 경로에서 로드가 시작될 수 있습니다. 이 구현은 컴포넌트를 처음 렌더링하는 대신, 로더 안에서 import("@/components/LiquidBackground")를 나중에 호출합니다.
로딩 여부를 기기·사용자 조건으로 결정한다
효과를 늦게 불러오는 것과 효과를 아예 불러오지 않는 것은 다른 결정입니다. 로더의 shouldEnableLiquidBackground 정책은 다음 조건에서는 정적 폴백을 유지합니다.
prefers-reduced-motion이 활성화되어 있다.- 사용자가
Save-Data를 요청했다. - 연결 정보가
2g또는slow-2g다. - WebGL을 사용할 수 없거나 소프트웨어 렌더러로 판정됐다.
- 보고된
deviceMemory가 4GB 이하이거나hardwareConcurrency가 2 이하이다.
effectiveType과 saveData는 MDN NetworkInformation 문서에 설명된 선택적 브라우저 힌트입니다. 이 API들은 모든 환경에서 제공된다고 가정할 수 없으므로, 구현은 값이 없을 때를 별도 실패로 취급하지 않습니다. 반대로 값이 있다고 해서 실제 GPU 성능을 측정했다고 말할 수도 없습니다. 이 숫자들은 벤치마크 결과가 아니라 “장식 효과를 켜지 않을 보수적인 정책”입니다.
prefers-reduced-motion은 단순한 최적화 플래그가 아닙니다. MDN의 접근성 가이드가 설명하듯 사용자가 비필수 움직임을 줄이도록 설정한 신호입니다. 따라서 reduced-motion에서는 배경을 투명하게 숨기는 데 그치지 않고, 애니메이션 루프를 만들지 않으며 정적 프레임만 허용합니다.
useEffect 안에서도 바로 시작하지 않는다
클라이언트 컴포넌트라고 해서 브라우저 자원을 즉시 사용할 이유는 없습니다. 이 로더는 useEffect 안에서 초기 조건을 확인한 뒤에도 다음 순서를 지킵니다.
- 페이지의
load이후를 기다린다. - 6초의 초기 지연을 둔다.
requestIdleCallback이 있으면 idle 구간에 import를 예약한다.- idle 콜백이 지나치게 늦어지지 않도록 1.5초 timeout을 둔다.
- API가 없는 브라우저에서는
setTimeout경로로 import한다.
6초라는 값은 “최적의 지연 시간”이라는 측정 결과가 아닙니다. 현재 사이트에서 장식 효과를 초기 상호작용 이후로 밀어내기 위해 둔 명시적인 정책값이며 테스트로 보호됩니다.
const LIQUID_BACKGROUND_INITIAL_DELAY_MS = 6000;
const IDLE_CALLBACK_TIMEOUT_MS = 1500;
window.setTimeout(() => {
if (window.requestIdleCallback) {
window.requestIdleCallback(importBackground, {
timeout: IDLE_CALLBACK_TIMEOUT_MS,
});
return;
}
importBackground();
}, LIQUID_BACKGROUND_INITIAL_DELAY_MS);
MDN의 requestIdleCallback() 문서는 이 API를 애니메이션·입력 같은 지연에 민감한 작업을 방해하지 않도록 낮은 우선순위 작업을 idle 구간에 예약하는 수단으로 설명합니다. 동시에 이 API는 Baseline이 아닌 제한적 지원 기능이므로, 지원 여부를 검사하고 fallback을 남겨야 합니다. timeout을 지정하지 않으면 콜백이 오래 기다릴 수 있다는 점도 공식 문서가 명시합니다.
핵심은 setTimeout과 requestIdleCallback 중 하나를 고르는 것이 아닙니다. 먼저 충분한 지연으로 enhancement를 초기 경로 밖으로 보내고, 그 다음 브라우저가 여유로운 순간을 선택하게 하며, 마지막으로 무기한 대기를 막는 것입니다.
생명주기를 상태 머신으로 고정한다
지연 로더는 타이머 하나로 끝나지 않습니다. 탭이 숨겨질 수 있고, React Strict Mode에서 effect가 정리됐다가 다시 실행될 수 있으며, dynamic import는 시작된 뒤 취소할 수 없습니다.
그래서 로더는 다음 상태를 명시적으로 가집니다.
type LiquidBackgroundLoadState =
| "idle"
| "scheduled"
| "loading"
| "loaded"
| "failed";
idle에서는 한 번만 예약한다.scheduled에서는 아직 시작하지 않은 timer와 idle callback만 취소한다.loading에서는 이미 시작한 Promise를 보존하고 Strict Mode 재실행 시 재사용한다.loaded와failed에서는 다시 예약하지 않는다.
이 구분은 Page Visibility API의 사용 목적과도 맞습니다. MDN은 문서가 숨겨졌을 때 불필요한 작업을 피해 자원을 절약할 수 있다고 설명합니다. 이 구현은 예약 단계에서 탭이 숨겨지면 작업을 취소하지만, 이미 import가 시작된 뒤에는 Promise를 억지로 취소하지 않습니다. 취소할 수 없는 작업을 취소한 척하지 않는 것이 상태 모델의 중요한 부분입니다.
렌더러 안에서도 예산을 제한한다
모듈을 늦게 불러오는 것만으로 GPU 비용이 사라지지는 않습니다. 컴포넌트가 로드된 뒤에는 별도의 렌더링 정책을 적용합니다.
- 캔버스 drawing buffer를 viewport의 0.65 배로 설정한다.
- 프레임 간격을 계산해 목표 30fps를 넘는 draw를 건너뛴다.
- 탭이 숨겨지면
requestAnimationFrame루프를 중지한다. - reduced-motion에서는 루프 대신 보이는 정적 프레임만 그린다.
- WebGL context 손실 시 애니메이션을 멈추고 복구 이벤트에서 초기화한다.
- 임시 capability probe에서 소프트웨어 renderer를 확인하면 enhancement를 사용하지 않는다.
여기서도 “30fps가 모든 기기에서 충분하다”거나 “0.65가 최적의 scale”이라고 주장하면 안 됩니다. 이 값들은 현재 구현의 상한과 기본값입니다. 실제 서비스에서는 대표 기기와 네트워크 조건에서 Chrome DevTools, Lighthouse, 현장 데이터 등을 사용해 별도로 측정해야 합니다. 측정 없이 숫자를 성과로 포장하지 않는 것이 더 중요합니다.
정책을 테스트 가능한 순수 함수로 분리한다
브라우저 API를 직접 호출하는 컴포넌트 안에 모든 조건을 넣으면 테스트가 어렵고, 새로운 예외가 생길 때 회귀를 놓치기 쉽습니다. 이 저장소는 판단을 liquid-background-policy.ts의 순수 함수로 분리하고, 테스트에서 경계 조건을 고정합니다.
현재 정책 테스트는 다음을 확인합니다.
- reduced-motion, Save-Data, WebGL 미지원, 저사양 장치, 느린 연결이 정적 폴백을 선택하는지
- 선택적 기기 힌트가 없어도 정상적으로 enhancement를 허용하는지
- SwiftShader와 llvmpipe 같은 알려진 소프트웨어 renderer를 거부하는지
- 캔버스 크기가 0이 되지 않고 기본 scale이 적용되는지
- timestamp 간격이 목표 프레임 예산보다 짧으면 draw를 건너뛰는지
- 가시성 전환 뒤
scheduled작업만 취소되고,loading·loaded상태가 다시 예약되지 않는지 - 선택한 초기 지연값이 초기 interactive window 밖에 있는지
예를 들어 상태 전이는 다음처럼 브라우저 없이 검증할 수 있습니다.
let state: LiquidBackgroundLoadState = "idle";
state = transitionLiquidBackgroundLoad(state, "schedule");
state = transitionLiquidBackgroundLoad(state, "beginImport");
state = transitionLiquidBackgroundLoad(state, "importSucceeded");
// loaded 상태에서는 다시 schedule되지 않는다.
assert.equal(
transitionLiquidBackgroundLoad(state, "schedule"),
"loaded"
);
이 테스트들은 실제 사용자 환경의 FPS나 LCP를 측정하지 않습니다. 대신 “정적 폴백을 유지해야 하는 조건”과 “한 번만 로드해야 하는 생명주기”가 코드 변경으로 조용히 사라지지 않게 합니다. 성능 측정과 정책 회귀 테스트를 서로 다른 증거로 취급해야 하는 이유입니다.
이 패턴의 비용과 한계
정적 우선 설계는 공짜가 아닙니다.
- 장식 효과가 6초 이후에 나타나므로 일부 방문자는 배경 변화를 늦게 본다.
- 제한적인 브라우저 힌트에 의존하면 실제 하드웨어를 완벽하게 분류할 수 없다.
- 낮은 기기 메모리나 CPU 코어 수를 기준으로 삼는 것은 보수적인 휴리스틱이지 성능 보증이 아니다.
- WebGL context 손실, 브라우저별 shader 동작, 모바일 GPU 발열은 자동화 테스트만으로 충분히 검증할 수 없다.
- 장식 효과가 제품의 의미나 조작 가능성을 전달한다면 이 전략을 그대로 적용할 수 없다. 그 경우에는 기능 자체의 접근성 계약과 의미 있는 정적 대체 UI를 함께 설계해야 한다.
따라서 이 방법의 약속은 “항상 빠르다”가 아닙니다. 더 좁고 검증 가능한 약속입니다.
효과가 없어도 콘텐츠는 완성되어야 하고, 효과가 시작되더라도 사용자가 선택한 환경과 페이지 가시성을 존중해야 한다.
다른 Next.js 사이트에 적용하는 체크리스트
- Critical path를 먼저 분류한다. 제목·본문·탐색과 장식용 효과를 같은 우선순위로 두지 않는다.
- 완전한 정적 폴백을 먼저 렌더링한다. 빈 컨테이너나 opacity 0 상태를 기본 화면으로 삼지 않는다.
- 동적 import의 시점도 설계한다. 코드 분리만 하지 말고
load·지연·idle 정책을 명시한다. - 접근성과 자원 힌트를 거부 조건에 포함한다. reduced-motion과 Save-Data를 기능 저하가 아니라 사용자 설정으로 취급한다.
- 가시성에 따라 예약과 렌더링을 분리한다. 시작 전 작업은 취소하고, 이미 시작한 Promise는 상태에 맞게 처리한다.
- GPU 예산을 코드로 제한한다. drawing buffer, FPS, context loss, hidden tab을 각각 다룬다.
- 정책을 순수 함수와 상태 전이로 추출한다. 브라우저가 없어도 중요한 경계 조건을 테스트한다.
- 효과의 지연을 성과로 착각하지 않는다. 실제 LCP·INP·배터리·GPU 사용량은 대표 환경에서 별도로 측정한다.
결론: 배경은 기능이 아니라 승인된 enhancement다
WebGL 배경을 유지할지 제거할지는 미적 취향만의 문제가 아닙니다. 초기 렌더링, 접근성, 장치 자원, 브라우저 생명주기 사이의 우선순위를 정하는 아키텍처 문제입니다.
이 저장소에서 얻은 가장 재사용 가능한 결론은 단순합니다. 정적 화면을 제품의 완성된 기본값으로 만들고, WebGL은 조건을 통과한 뒤에만 추가되는 enhancement로 취급하라. 그러면 효과가 늦게 로드되거나 실패해도 핵심 콘텐츠는 독립적으로 동작합니다.