TuBrief
구독 채널
비디오
커뮤니티

React와 WebGL을 함께 쓸 때 프레임 드랍을 막는 렌더링 루프 분리법

TuBrief 편집팀
2026년 8월 7일
0
컴퓨터/소프트웨어

원본 영상을 바탕으로 AI의 도움을 받아 작성했습니다. 원본 영상이 기준입니다.

한국어EnglishEspañol中文हिन्दीDeutschFrançaisPortuguêsРусскийBahasa Indonesia日本語العربية

관련 영상

이 UI 컴포넌트 라이브러리 진짜 대박입니다 (Canvas UI)7:27

이 UI 컴포넌트 라이브러리 진짜 대박입니다 (Canvas UI)

Better Stack

커뮤니티의 다른 글

사내 시스템에 llm api 붙일 때 마주하는 현실적인 한계와 대응법

2026년 9월 13일

레거시 백엔드에 GPT-6 Astra 붙일 때 예산 승인과 보안 통과를 먼저 끝내는 법이 있습니다

2026년 9월 13일

에이전트끼리 대화하다 6천만 원 청구서가 나오는 이유

2026년 9월 13일

사내 RAG 벡터 검색에 Okta 권한 필터를 직접 거는 방법

2026년 9월 13일

브라우저 에이전트에게 내 구글 계정을 통째로 넘기면 안 되는 이유

2026년 9월 12일

Apple Won the AI Race

2026년 9월 12일

댓글 (0)

Log in to leave a comment

아직 작성된 글이 없습니다

© 2026 . All rights reserved.

TuBrief
구독 채널
비디오
커뮤니티
로그인

React와 WebGL을 함께 쓸 때 프레임 드랍을 막는 렌더링 루프 분리법

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>
  );
}

이렇게 구성하면 세 가지 효과를 바로 얻습니다.

  1. next.config.mjs에 패키지를 등록해 불필요한 모듈 생략을 유도합니다.
  2. next/dynamic에 { ssr: false }를 주어 서버 렌더링 시점의 런타임 에러를 차단합니다.
  3. 고정 높이 Skeleton을 띄워 데이터가 들어오기 전 레이아웃이 덜컹거리는 현상을 잡습니다.

Tailwind CSS를 쓴다면 포인터 이벤트도 체크해야 합니다. 캔버스 래퍼 전체에 pointer-events-none을 걸어 이벤트가 뒤쪽 DOM으로 빠지게 만들고, 실제 조작이 필요한 객체 레이어에만 pointer-events-auto를 다시 줍니다.

useState를 캔버스 루프 안에서 치우기

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 상태를 갱신하면 가비지 컬렉션이 짧은 시간에 몰려서 일어나고 렉이 발생합니다.

구조를 바꿉니다.

  1. 마우스 위치나 회전값처럼 자주 바뀌는 데이터는 useRef의 current에 덮어씁니다.
  2. useAnimationFrame 훅으로 고유한 requestAnimationFrame 루프를 만듭니다.
  3. 렌더링 함수는 이 Ref 값을 읽어와 캔버스에 그리기만 합니다. React 상태를 건드리지 않으니 리렌더링 자체가 안 일어납니다.

지원하지 않는 브라우저를 위한 폴백 처리

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>
    );
  }
}

안전망을 치는 방법입니다.

  1. 캔버스 컨텍스트 확인 로직으로 WebGL2 지원 상태를 점검합니다.
  2. React 클래스형 에러 바운더리로 렌더링 중 발생하는 런타임 오류를 낚아챕니다.
  3. 래퍼 박스에 min-h-[400px] 같은 최소 높이를 고정해 폴백 UI(일반 HTML/SVG 차트 등)로 전환될 때 화면 비율이 무너지는 현상을 막습니다.

GPU 메모리는 자바스크립트 가비지 컬렉터가 안 치웁니다

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 };
};

메모리가 제대로 해제되는지 크롬 개발자 도구로 직접 확인하는 순서입니다.

  1. 대시보드 컴포넌트를 열고 Memory 탭에서 첫 번째 힙 스냅샷(S1)을 땁니다.
  2. 컴포넌트 마운트와 언마운트(다른 탭 이동 등)를 10회 이상 반복합니다.
  3. 수동 쓰레기 수집(휴지통 아이콘)을 누른 뒤 두 번째 힙 스냅샷(S2)을 땁니다.
  4. S2 비교 대상을 S1으로 두고 Detached HTMLCanvasElement나 WebGLBuffer 개수가 남아있지 않고 0으로 떨어지는지 확인합니다.

React와 WebGL을 같이 쓸 때는 두 라이브러리가 각자 잘하는 영역을 다치지 않게 가라앉혀 두는 게 핵심입니다. 번들을 쪼개고, 렌더링 루프에서 React 상태를 치우고, 컴포넌트가 사라질 때 GPU 메모리를 확실히 비워주는 것. 이 세 가지만 챙겨도 프레임 드랍 없이 깔끔하게 도는 대시보드를 만들 수 있습니다.