본문 바로가기

카테고리 없음

[2편] 3D 웹게임 렌더링 최적화: 초기 로드 시간 및 커버리지 개선

들어가며

[1편] 3D 웹게임 렌더링 최적화에 이은 초기 로드 속도 및 커버리지 개선 방법을 이어 공유하고자 합니다. 1편에서 정리한 대로, scripting 시간은 물리연산 최적화, LOD 적용으로 개선할 수 있었지만, 여전히 사이트 접속 시 초기 로드 시간이 다소 오래걸리는 문제는 해결되지 못했습니다. 이를 해결하기 위해서 어떻게 진단을 했고, 어떤 최적화 작업들을 수행했으며 결과는 얼마나 개선되었는지를 공유하고자 합니다. 저와 같은 문제에 부딪힌 분들께 도움이 되었으면 좋겠습니다.

 

성능 저하 원인 파악

 

초기 성능 분석 결과, 다음과 같은 문제점들이 발견되었습니다:

  • LCP(Largest Contentful Paint)가 6초로 매우 느린 로딩 시간
  • 코드 커버리지가 약 65%로, 실제 사용되지 않는 코드가 상당수 포함

왜 이렇게 불필요한 코드가 많았을까요? 정답은 바로 라우팅 처리와 vite 번들링에 있었습니다. 최적화 전에는 vite의 청크 분할이 설정되어있지 않아서 모든 코드가 단일 번들로 생성되었습니다. 대규모 라이브러리들(rapier, r3f 등)이 초기 로드 시점에 모두 포함되어 있다보니 큰 시간이 걸렸던 것이죠.

 

그리고 처음 프로젝트를 설계할 때는 다음과 같은 이유로 라우팅 처리를 적용하지 않았습니다:

  1. 단순한 게임 플로우
    • 게임 시작과 종료로 이어지는 단순한 스테이지 구조
    • 복잡한 페이지 전환이나 상태 관리가 필요하지 않음
  2. 개발 복잡도 고려
    • 라우팅 도입 시 추가되는 게임 상태 관리 로직 부담
    • 간단한 스낵 게임에 대한 과도한 아키텍처 적용 우려

그러나 초기 로그인 페이지에서 게임 실행에서만 필요한 대규모 라이브러리가 로드되는 문제가 발생하다보니, 각 선택에 대한 기회비용을 비교해야 했고 저는 라우팅처리를 하는 방식을 택했습니다. 그 이유는 [1] 각 화면에 필요한 리소스만 선택적으로 로드시에 LCP가 크게 개선될 수 있을거라 생각했고, 로그인 페이지가 경량화되기에 [2] 사용자 경험 또한 크게 개선될 것이라 생각했습니다. 그리고 향후 [3] 새로운 게임 스테이지가 추가된다고 하더라도 유연한 대응을 할 수 있기도하구요. 그래서 위에서 고려했던 라우팅 도입에 따른 개발 복잡도 증가라는 단점을 상쇄할 수 있다고 판단했습니다.

 

최적화를 위한 작업 내용

최종적으로 위의 문제 원인을 해결하기 위해 아래의 최적화 작업들을 적용했습니다.

1. Vite 설정을 통한 효율적인 청크 분할

Chrome Performance 탭에서 확인된 긴 scripting 시간의 주요 원인이 되는 큰 번들 파일들을 분석해본 결과, 각 라이브러리별로 청크를 분할할 필요가 있었습니다. 특히 가장 큰 비중을 차지하는 Three.js 관련 라이브러리들(three, rapier, fiber, drei)과 React 관련 코어 라이브러리들을 개별 청크로 분리하여 초기 로딩 시 필요한 코드만 불러올 수 있도록 했습니다.

 

위 번들 분석 이미지를 보면, 각 라이브러리별로 청크가 깔끔하게 분리된 것을 확인할 수 있습니다. 특히 three-stdlib, rapier 등 게임 실행에 필수적이지만 초기 로딩에는 필요하지 않은 무거운 라이브러리들이 별도의 청크로 분리되어 있습니다. 이렇게 청크 분할을 구현하기 위해 Vite 설정을 다음과 같이 구성했습니다.

import { defineConfig } from 'vite';
import { visualizer } from 'rollup-plugin-visualizer';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [
    react(),
    visualizer({
      open: true, // 빌드 후 자동으로 브라우저에서 열기
      filename: 'stats.html',
      template: 'treemap', // treemap, sunburst, network중 treemap 선택
      gzipSize: true,
      brotliSize: true,
    }),
  ],
  css: {
    postcss: './postcss.config.js',
  },
  build: {
    minify: 'esbuild',
    target: 'esnext',
    rollupOptions: {
      output: {
        manualChunks: {
          three: ['three'],
          rapier: ['@react-three/rapier'],
          fiber: ['@react-three/fiber'],
          drei: ['@react-three/drei'],
          // React 코어
          react: ['react', 'react-dom', 'react-error-boundary'],
          // 상태관리/데이터
          state: ['jotai', '@tanstack/react-query'],
          // 애니메이션/UI
          animation: ['gsap'],
          ui: ['react-toastify', 'leva'],
          // 네트워킹
          networking: ['socket.io-client'],
        },
        // 청크 파일명 패턴 설정
        chunkFileNames: 'assets/js/[name]-[hash].js',
        entryFileNames: 'assets/js/[name]-[hash].js',
        assetFileNames: 'assets/[ext]/[name]-[hash].[ext]',
      },
    },
    sourcemap: false, // 프로덕션 빌드시 소스맵 비활성화
  },
});

 

chunkFileNames로 청크 파일명 패턴을 설정한 이유는 캐싱 최적화를 위해서입니다. 파일명에 해시를 포함시켜서 컨텐츠가 변경될 때만 새로운 파일을 다운로드할 수 있도록 했는데, 변경되지 않은 청크는 브라우저 캐시를 활용해서 재사용이 가능해지기 때문입니다. 반복해서 사이트에 방문한다면 로딩 성능이 향상될 수 있습니다.

 

assetsFileNames 설정은 js, css, images와 같이 파일 유형별로 디렉토리 구조화를 해줄 수 있습니다. CDN 설정이나 캐시 정책이 용이해집니다. Nginx에서 /assets/js/ 는 expires 만료를 따로 설정할 수도 있고, CDN같은 경우 특정 유형의 파일들에 대해 다른 CDN 설정을 할 수 있기 때문입니다. 예를 들어, 이미지는 이미지에 특화된 cloudinary같은 CDN을 사용할 수 있고, JS/CSS는 또 다른 CDN을 사용할 수 있습니다.

저는 vercel을 이용해서 배포했기때문에 vercel 에서 제공하는 자체 기본 CDN을 사용하고 있습니다. 그리고 몇몇의 이미지는 Cloudinary를 통해서 이미지를 가져오고 있기에 이미 자체적으로 CDN이 적용이 되어있긴합니다. 그럼에도 assetsFileNames 설정을 적용해준 이유는 프로젝트 구조가 명확해지고, 캐시 관리에도 용이하기 때문입니다.

 

마지막으로 entryFileNames는 배포할 때마다 파일 해시가 변경되서 클라이언트가 최신 버전을 받을 수 있게하는 설정입니다. 

 

2. React Router를 활용한 코드 스플리팅과 Dynamic Import를 통한 지연 로딩

기존의 코드 구조는 위의 성능 저하 원인 파악에서 설명했던 이유들로 단순한 조건부 렌더링을 사용하고 있었습니다:

function App() {
 const [gameScreen] = useAtom(gameScreenAtom);

 return (
   <>
     {gameScreen === GameScreen.LOGIN && <LoginPage />}
     {gameScreen === GameScreen.HOME && <HomePage />}
     {gameScreen === GameScreen.GAME && <GamePage />}
     {gameScreen === GameScreen.GAME_OVER && <GameOverPage />}
   </>
 );
}

 

이 방식의 문제점은 초기 로드 시점에 모든 페이지의 코드가 하나의 번들로 포함되어 다운로드된다는 것이었습니다. 로그인 페이지에서는 필요하지 않은 Three.js나 물리 엔진 관련 코드까지 모두 불러와지고 있었죠. 이를 개선하기 위해 React Router와 React.lazy()를 활용한 코드 스플리팅을 적용했습니다.

const LoginPage = lazy(() => import('../pages/LoginPage'));
const HomePage = lazy(() => import('../pages/HomePage'));
const GamePage = lazy(() => import('../pages/GamePage'));
const GameOverPage = lazy(() => import('../pages/GameOverPage'));

const AuthRouter = () => {
  return (
    <Routes>
      <Route path="/login" element={<LoginPage />} />
      <Route element={<ProtectedRoute />}>
        <Route path="/home" element={<HomePage />} />
        <Route path="/game" element={<GamePage />} />
        <Route path="/game-over" element={<GameOverPage />} />
      </Route>
    </Routes>
  );
};

 

이렇게 변경함으로써 초기 접속시에는 로그인 페이지 관련 코드만 다운로드하고, 게임 페이지로 이동할 때 필요한 Three.js 관련 코드를 다운로드할 수 있었습니다. 그리고 결과 페이지도 게임이 끝난 후에 로드될 수 있도록 해서, 각 페이지별로 필요한 코드만 그때그때 불러올 수 있도록 최적화했습니다.

function App() {
  const { reset } = useQueryErrorResetBoundary();
  const isMobile = useIsMobile();

  return (
    <>
      {isMobile ? (
        <PlatformWarningModal />
      ) : (
        <KeyboardControls map={keyboardMap}>
          <ReactQueryClientProvider>
            <ErrorBoundary FallbackComponent={RenderErrorPage} onReset={reset}>
              <Suspense fallback={<LoadingPage />}>
                <SoundControlHeader />
            <BrowserRouter>
                <AuthRouter />
            </BrowserRouter>
              </Suspense>
            </ErrorBoundary>
          </ReactQueryClientProvider>
          <ToastContainer
            position="top-center"
            autoClose={1000}
            hideProgressBar={true}
            closeOnClick
            theme="light"
            transition={Flip}
          />
        </KeyboardControls>
      )}
    </>
  );
}
export default App;

 

그리고 Suspense를 활용해서 각 페이지의 코드가 로드되는 동안 로딩 UI를 보여줄 수 있도록 처리했습니다. 이러한 코드 스플리팅은 vite.config.js의 청크 설정과 시너지를 이루어, 각 페이지별로 필요한 라이브러리만 함께 로드되도록 만들었습니다. 결과적으로 사용자는 필요한 코드만 다운로드받게 되어 더 나은 초기 로딩 경험을 얻을 수 있게 되었습니다.

 

3. Tree Shaking을 통한 번들 크기 최적화

Tree Shaking은 JavaScript 모듈에서 실제로 사용되는 코드만 포함하고 사용하지 않는 코드는 제거하는 최적화 기법입니다. 저희 프로젝트에서는 Vite가 제공하는 Tree Shaking 기능을 활용했습니다.

// 변경 전: 전체 라이브러리 import
import * as THREE from 'three';
import { Canvas } from '@react-three/fiber';

 

위와 같이 전체 라이브러리를 Import하면 사용하지 않는 코드까지 모두 번들에 포함되게 되는데요. 협업 중에 저렇게 쓰인 코드들을 모두 제거하고 필요한 모듈만 import 하도록 변경해주었습니다.

// 변경 후: 필요한 모듈만 import
import { WebGLRenderer, Scene, PerspectiveCamera } from 'three';
import { Canvas } from '@react-three/fiber';

 

Vite는 이러한 구체적인 import 구문을 분석하여 실제 사용되는 코드만 번들에 포함시킵니다. 특히 프로젝트에서 Three.js와 관련 라이브러리들의 크기가 상당했기 때문에, Tree Shaking을 통한 최적화 효과가 컸습니다.

 

최적화 결과 

 

앞서 설명한 세 가지 최적화 작업(청크 분할, 코드 스플리팅, Tree Shaking)을 적용한 결과, 성능이 크게 개선되었습니다. 우선 가장 두드러진 변화는 사용되지 않는 코드의 비율이 35%에서 4.8%로 크게 감소했다는 점입니다. 특히 Three.js와 관련 라이브러리들의 미사용 코드가 대폭 줄어들었죠. 이는 자연스럽게 초기 로딩 성능 개선으로 이어졌습니다.

 

기존에 6초나 걸리던 LCP(Largest Contentful Paint)가 2초로 크게 단축되었습니다. 실제 사용자들이 체감하는 성능 또한 크게 개선되었는데요. 특히 로그인 페이지는 Three.js나 물리엔진 관련 코드가 제외되어 거의 즉시 로드될 정도로 가벼워졌습니다. 라우팅 기반 코드 스플리팅의 효과도 컸습니다. 각 페이지별로 필요한 리소스만 로드하게 되어 페이지 전환도 더욱 빨라졌고, 캐시를 효율적으로 활용할 수 있게 되었습니다.  특히 이러한 최적화는 느린 네트워크 상황에서 더 큰 효과를 발휘했습니다. 초기 번들 크기가 작아지고 필요한 코드만 로드하게 되어, 다양한 네트워크 환경에서도 안정적인 성능을 제공할 수 있게 되었습니다.

 

다음 최적화는?

위와 같은 최적화 작업들을 통해 초기 로딩 성능과 코드 커버리지를 크게 개선할 수 있었습니다. 하지만 여전히 아쉬운 점도 남아있습니다. 특히 다른 플레이어의 움직임을 제 캐릭터만큼 부드럽게 처리하는 것이 가장 큰 숙제로 남았습니다. 여러 방식을 시도해봤는데요. lerp를 거리에 따라 다르게 적용해보기도 하고, 3단계로 나눠서 적용해보기도 했지만, 결국 기본값인 0.1 정도의 보간이 가장 자연스러운 결과를 보여줬습니다. 아마도 네트워크 지연과 물리엔진의 상호작용 때문에 더 복잡한 접근이 필요할 것 같습니다. 다음에는 꼭 이 부분을 개선해보고 싶습니다. 멀티플레이어 게임에서 다른 플레이어의 움직임을 자연스럽게 표현하는 것은 게임의 몰입도에 큰 영향을 미치는 요소이니까요. 아마도 예측 시스템이나 더 발전된 보간 방식을 연구해봐야 할 것 같습니다. 그럼 이번 최적화 과정은 여기서 마무리하도록 하겠습니다! 글이 같은 고민을 하고 계시는 분들께 도움이 되었으면 좋겠네요! 감사합니다!

반응형