React와 WebGL을 함께 쓸 때 프레임 드랍을 막는 렌더링 루프 분리법
TuBrief 편집팀
2026년 8월 7일
0
컴퓨터/소프트웨어원본 영상을 바탕으로 AI의 도움을 받아 작성했습니다. 원본 영상이 기준입니다.
커뮤니티의 다른 글
댓글 (0)
Log in to leave a comment
아직 작성된 글이 없습니다
원본 영상을 바탕으로 AI의 도움을 받아 작성했습니다. 원본 영상이 기준입니다.
Log in to leave a comment
아직 작성된 글이 없습니다
Next.js 대시보드에 WebGL 캔버스를 얹는 작업은 생각보다 까다롭습니다. 화려한 차트나 무거운 그래픽 요소를 붙이다 보면 화면이 뚝뚝 끊기기 일쑤죠. 마우스만 움직여도 프레임이 떨어지고, 기껏 만든 대시보드가 버벅이면 개발자 입장에선 답답할 수밖에 없습니다.
이 문제는 보통 React의 상태 관리 방식과 WebGL의 렌더링 방식이 충돌하면서 발생합니다. 두 엔진의 일하는 방식을 분리하지 않으면 아무리 좋은 라이브러리를 써도 화면은 계속 끊깁니다.
Three.js나 Deck.gl 같은 라이브러리는 내부에 수학 연산 엔진과 셰이더 컴파일러를 들고 있어서 덩치가 큽니다. 아무 생각 없이 상단에서 패키지를 불러오면 사용하지도 않는 코드까지 메인 번들에 다 섞여 들어갑니다.
Next.js 환경이라면 서브패스 직접 임포트와 dynamic load 구성을 조합해서 이 짐을 덜어내야 합니다. 서버 사이드 렌더링 중에 window is not defined 에러가 터지는 것도 같이 막을 수 있습니다.
// next.config.mjs
import withBundleAnalyzer from '@next/bundle-analyzer';
const bundleAnalyzer = withBundleAnalyzer({
enabled: process.env.ANALYZE === 'true',
});
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
experimental: {
optimizePackageImports: [
'@radix-ui/react-icons',
'lucide-react',
'three',
'deck.gl'
],
},
webpack: (config, { isServer }) => {
if (!isServer) {
config.resolve.fallback = {
...config.resolve.fallback,
fs: false,
path: false,
};
}
return config;
},
};
export default bundleAnalyzer(nextConfig);
이 설정을 거치면 초기 페이지 로드 때 그래픽 자원을 한 번에 다 받지 않고 필요한 시점에 나눠 받습니다. 번들 분석기로 확인해 보면 메인 JS 파일에 묶여 있던 캔버스 관련 코드가 독립된 덩어리로 떨어져 나간 것을 볼 수 있습니다.
클라이언트 컴포넌트 래퍼에서는 SSR을 끄고 가져옵니다.
// components/dashboard/CanvasDashboardWrapper.tsx
'use client';
import dynamic from 'next/dynamic';
import { Skeleton } from '@/components/ui/skeleton';
const DynamicCanvasRenderer = dynamic(
() => import('./CanvasRenderer').then((mod) => mod.CanvasRenderer),
{
ssr: false,
loading: () => (
<div className="w-full h-[500px] flex items-center justify-center bg-slate-900 rounded-lg">
<Skeleton className="w-11/12 h-5/6 bg-slate-800" />
</div>
),
}
);
export function CanvasDashboardWrapper() {
return (
<div className="relative w-full h-[500px] overflow-hidden rounded-lg border border-slate-800">
<DynamicCanvasRenderer />
</div>
);
}
이렇게 구성하면 세 가지 효과를 바로 얻습니다.
next.config.mjs에 패키지를 등록해 불필요한 모듈 생략을 유도합니다.next/dynamic에 { ssr: false }를 주어 서버 렌더링 시점의 런타임 에러를 차단합니다.Tailwind CSS를 쓴다면 포인터 이벤트도 체크해야 합니다. 캔버스 래퍼 전체에 pointer-events-none을 걸어 이벤트가 뒤쪽 DOM으로 빠지게 만들고, 실제 조작이 필요한 객체 레이어에만 pointer-events-auto를 다시 줍니다.
React는 상태(useState, useContext)가 바뀌면 가상 DOM을 다시 그립니다. 반면 WebGL은 requestAnimationFrame을 타고 초당 60번씩 화면을 고쳐 그립니다.
초당 60번 띄우는 루프 안에서 React 상태 변경 함수를 부르면 어떻게 될까요? 매 프레임마다 React의 Reconciliation 연산이 돌면서 메인 스레드가 굳어버립니다. 화면이 버벅이는 진짜 이유입니다.
변하는 값은 useRef에 담고, 화면을 그리는 작업은 requestAnimationFrame 단독 루프에 맡겨야 합니다.
// hooks/useAnimationFrame.ts
import { useEffect, useRef } from 'react';
type AnimationCallback = (deltaTime: number, timestamp: number) => void;
export const useAnimationFrame = (callback: AnimationCallback, isPaused: boolean = false) => {
const requestRef = useRef<number | null>(null);
const previousTimeRef = useRef<number | null>(null);
const callbackRef = useRef<AnimationCallback>(callback);
useEffect(() => {
callbackRef.current = callback;
}, [callback]);
useEffect(() => {
if (isPaused) {
if (requestRef.current !== null) {
cancelAnimationFrame(requestRef.current);
}
return;
}
const animate = (timestamp: number) => {
if (previousTimeRef.current !== null) {
const deltaTime = timestamp - previousTimeRef.current;
callbackRef.current(deltaTime, timestamp);
}
previousTimeRef.current = timestamp;
requestRef.current = requestAnimationFrame(animate);
};
requestRef.current = requestAnimationFrame(animate);
return () => {
if (requestRef.current !== null) {
cancelAnimationFrame(requestRef.current);
}
};
}, [isPaused]);
};
마우스 이동이나 드래그처럼 자주 발생하는 이벤트도 똑같습니다. 이벤트 핸들러에서 React 상태를 갱신하면 가비지 컬렉션이 짧은 시간에 몰려서 일어나고 렉이 발생합니다.
구조를 바꿉니다.
useRef의 current에 덮어씁니다.useAnimationFrame 훅으로 고유한 requestAnimationFrame 루프를 만듭니다.WICG에서 논의 중인 HTML in Canvas 규격처럼 drawElementImage 같은 메서드로 DOM을 캔버스 비트맵에 바로 그리려는 시도가 있습니다. 최신 크롬 플래그 환경에서는 잘 동작하지만, 실무에서 이걸 그대로 믿고 썼다간 구형 브라우저나 일부 모바일 기기에서 화면이 통째로 날아갑니다.
기능 지원 여부를 먼저 읽고, 에러 바운더리로 묶어서 예외 상황에 대비합니다.
// components/canvas/CanvasErrorBoundary.tsx
'use client';
import React, { Component, ErrorInfo, ReactNode } from 'react';
import { detectCanvasCapabilities } from '@/utils/canvasFeatureDetection';
interface Props {
children: ReactNode;
fallbackUI: ReactNode;
}
interface State {
hasError: boolean;
isSupported: boolean;
}
export class CanvasErrorBoundary extends Component<Props, State> {
public state: State = {
hasError: false,
isSupported: true,
};
public componentDidMount() {
const capabilities = detectCanvasCapabilities();
if (!capabilities.webgl2) {
this.setState({ isSupported: false });
}
}
public static getDerivedStateFromError(_: Error): Partial<State> {
return { hasError: true };
}
public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error('Canvas UI Rendering Engine Crashed:', error, errorInfo);
}
public render() {
if (this.state.hasError || !this.state.isSupported) {
return (
<div className="relative w-full h-full min-h-[400px] bg-slate-950 flex items-center justify-center">
<div className="absolute inset-0 z-10 pointer-events-auto">
{this.props.fallbackUI}
</div>
</div>
);
}
return (
<div className="relative w-full h-full min-h-[400px] overflow-hidden">
{this.props.children}
</div>
);
}
}
안전망을 치는 방법입니다.
min-h-[400px] 같은 최소 높이를 고정해 폴백 UI(일반 HTML/SVG 차트 등)로 전환될 때 화면 비율이 무너지는 현상을 막습니다.V8 엔진은 자바스크립트 힙에 들어있는 객체는 잘 치워줍니다. 하지만 WebGL 버퍼, 텍스처, 셰이더 프로그램처럼 GPU 메모리에 올려둔 데이터는 모릅니다.
페이지를 이동하거나 대시보드 탭을 넘길 때 명시적으로 이 자원들을 지워주지 않으면, GPU 메모리가 차오르다가 Context lost 오류와 함께 브라우저 탭이 튕깁니다.
// hooks/useWebGLCleanUp.ts
import { useEffect, useRef } from 'react';
export const useWebGLCleanUp = () => {
const glRef = useRef<WebGL2RenderingContext | null>(null);
const resourcesRef = useRef<{
buffers: WebGLBuffer[];
textures: WebGLTexture[];
programs: WebGLProgram[];
}>({
buffers: [],
textures: [],
programs: [],
});
const registerBuffer = (buffer: WebGLBuffer) => resourcesRef.current.buffers.push(buffer);
const registerTexture = (texture: WebGLTexture) => resourcesRef.current.textures.push(texture);
const registerProgram = (program: WebGLProgram) => resourcesRef.current.programs.push(program);
useEffect(() => {
return () => {
const gl = glRef.current;
if (!gl) return;
resourcesRef.current.buffers.forEach((buffer) => gl.deleteBuffer(buffer));
resourcesRef.current.textures.forEach((texture) => gl.deleteTexture(texture));
resourcesRef.current.programs.forEach((program) => {
const shaders = gl.getAttachedShaders(program);
if (shaders) {
shaders.forEach((shader) => {
gl.detachShader(program, shader);
gl.deleteShader(shader);
});
}
gl.deleteProgram(program);
});
const loseContextExt = gl.getExtension('WEBGL_lose_context');
if (loseContextExt) {
loseContextExt.loseContext();
}
resourcesRef.current = { buffers: [], textures: [], programs: [] };
glRef.current = null;
};
}, []);
return { glRef, registerBuffer, registerTexture, registerProgram };
};
메모리가 제대로 해제되는지 크롬 개발자 도구로 직접 확인하는 순서입니다.
Detached HTMLCanvasElement나 WebGLBuffer 개수가 남아있지 않고 0으로 떨어지는지 확인합니다.React와 WebGL을 같이 쓸 때는 두 라이브러리가 각자 잘하는 영역을 다치지 않게 가라앉혀 두는 게 핵심입니다. 번들을 쪼개고, 렌더링 루프에서 React 상태를 치우고, 컴포넌트가 사라질 때 GPU 메모리를 확실히 비워주는 것. 이 세 가지만 챙겨도 프레임 드랍 없이 깔끔하게 도는 대시보드를 만들 수 있습니다.