1. 문제 상황 분석

1.1 기본적인 Top-level await 이슈

최신 JavaScript에서는 모듈 최상위 레벨에서 await를 사용할 수 있지만, 이는 빌드 시 특별한 처리가 필요합니다.

특히 다음과 같은 코드에서 문제가 발생합니다

// services/modelLoader.js
const model = await loadModel('assets/character.glb');
const texture = await loadTexture('assets/skin.png');

export { model, texture };

이러한 코드는 개발 환경에서는 잘 작동하지만, 프로드덕션 빌드 시 다음과 같은 에러가 발생합니다.

ERROR: Top-level await is not available in the configured target environment

1.2 실제 프로젝트에서 발생하는 복잡한 시나리오

실제 프로젝트에서는 다음과 같은 복잡한 상황들이 발생할 수 있습니다.

  • 다중 모델 로딩
  • 조건부 모델 로딩
  • 동적 임포트와의 충돌
  • 청크 사이즈 최적화 필요성

2. 해결 방안

2.1 기본 설정

먼저 vite-plugin-top-level-await를 설치하고 기본 설정을 합니다.

pnpm install vite-plugin-top-level-await --save-dev
// vite.config.js
import { defineConfig } from 'vite';
import topLevelAwait from "vite-plugin-top-level-await";
import compression from 'vite-plugin-compression';

export default defineConfig({
  assetsInclude: ['**/*.hdr', '**/*.glb', '**/*.gltf'],
  plugins: [
    topLevelAwait({
      // 특정 파일 패턴만 처리
      include: ['src/models/**/*.js', 'src/assets/**/*.js'],
      // 특정 파일 제외
      exclude: ['node_modules/**']
    }),
    compression() // 추가적인 최적화
  ],
  build: {
    rollupOptions: {
      output: {
        manualChunks: {
          // 벤더 청크 분리
          vendor: ['three', '@react-three/fiber'],
          // 모델 청크 분리
          models: ['src/models/index.js']
        }
      }
    },
    chunkSizeWarningLimit: 1000
  }
});

2.2 최적화된 모델 로딩 구현

실제 프로젝트에서는 다음과 같이 최적화된 모델 로딩 시스템을 구현할 수 있습니다.

// src/utils/modelLoader.js
import { DRACOLoader } from 'three/examples/jsm/loaders/DRACOLoader';
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader';
import { LoadingManager } from 'three';

class ModelLoader {
  constructor() {
    this.loadingManager = new LoadingManager();
    this.dracoLoader = new DRACOLoader(this.loadingManager);
    this.gltfLoader = new GLTFLoader(this.loadingManager);
    
    // Draco 디코더 경로 설정
    this.dracoLoader.setDecoderPath('/draco/');
    this.gltfLoader.setDRACOLoader(this.dracoLoader);
    
    // 로딩 캐시 설정
    this.modelCache = new Map();
  }

  async loadModel(path, options = {}) {
    // 캐시 확인
    if (this.modelCache.has(path)) {
      return this.modelCache.get(path);
    }

    try {
      const model = await new Promise((resolve, reject) => {
        this.gltfLoader.load(
          path,
          (gltf) => {
            // 모델 후처리
            if (options.scale) {
              gltf.scene.scale.multiplyScalar(options.scale);
            }
            resolve(gltf);
          },
          (progress) => {
            if (options.onProgress) {
              options.onProgress(progress);
            }
          },
          reject
        );
      });

      // 캐시에 저장
      this.modelCache.set(path, model);
      return model;
    } catch (error) {
      console.error(`Failed to load model: ${path}`, error);
      throw error;
    }
  }

  // 메모리 관리
  clearCache() {
    this.modelCache.forEach((model) => {
      model.scene?.traverse((object) => {
        if (object.geometry) {
          object.geometry.dispose();
        }
        if (object.material) {
          if (Array.isArray(object.material)) {
            object.material.forEach(material => material.dispose());
          } else {
            object.material.dispose();
          }
        }
      });
    });
    this.modelCache.clear();
  }
}

export const modelLoader = new ModelLoader();

2.3 실제 사용 예시

// src/components/Scene.jsx
import { Suspense, useEffect, useState } from 'react';
import { modelLoader } from '../utils/modelLoader';

export const Scene = () => {
  const [model, setModel] = useState(null);
  const [loading, setLoading] = useState(true);
  const [progress, setProgress] = useState(0);

  useEffect(() => {
    const loadModel = async () => {
      try {
        const gltf = await modelLoader.loadModel('/models/character.glb', {
          scale: 1.5,
          onProgress: (progress) => {
            const percentage = (progress.loaded / progress.total) * 100;
            setProgress(Math.round(percentage));
          }
        });
        
        setModel(gltf.scene);
      } catch (error) {
        console.error('모델 로딩 실패:', error);
      } finally {
        setLoading(false);
      }
    };

    loadModel();

    // 컴포넌트 언마운트 시 메모리 정리
    return () => {
      if (model) {
        modelLoader.clearCache();
      }
    };
  }, []);

  if (loading) {
    return <div className="loading">Loading... {progress}%</div>;
  }

  return (
    <Suspense fallback={null}>
      {model && (
        <primitive object={model} position={[0, 0, 0]} />
      )}
    </Suspense>
  );
};

3. 성능 모니터링 및 최적화

3.1 청크 분석 도구 추가

// vite.config.js 추가 설정
import { visualizer } from 'rollup-plugin-visualizer';

export default defineConfig({
  // ... 기존 설정 ...
  plugins: [
    // ... 기존 플러그인 ...
    visualizer({
      filename: 'stats.html',
      open: true,
      gzipSize: true
    })
  ]
});

3.2 성능 모니터링 구현

// src/utils/performanceMonitor.js
class PerformanceMonitor {
  constructor() {
    this.metrics = {
      modelLoadTimes: new Map(),
      memoryUsage: new Map(),
      fps: []
    };
  }

  startModelLoadTimer(modelPath) {
    this.metrics.modelLoadTimes.set(modelPath, {
      startTime: performance.now(),
      endTime: null,
      duration: null
    });
  }

  endModelLoadTimer(modelPath) {
    const metric = this.metrics.modelLoadTimes.get(modelPath);
    if (metric) {
      metric.endTime = performance.now();
      metric.duration = metric.endTime - metric.startTime;
    }
  }

  trackMemoryUsage() {
    if (performance.memory) {
      this.metrics.memoryUsage.set(Date.now(), {
        usedJSHeapSize: performance.memory.usedJSHeapSize,
        totalJSHeapSize: performance.memory.totalJSHeapSize
      });
    }
  }

  getMetrics() {
    return this.metrics;
  }

  generateReport() {
    const report = {
      modelLoadTimes: {},
      averageMemoryUsage: null,
      averageFPS: null
    };

    // 모델 로딩 시간 분석
    this.metrics.modelLoadTimes.forEach((value, key) => {
      report.modelLoadTimes[key] = `${value.duration.toFixed(2)}ms`;
    });

    // 메모리 사용량 평균 계산
    if (this.metrics.memoryUsage.size > 0) {
      const totalMemory = Array.from(this.metrics.memoryUsage.values())
        .reduce((acc, curr) => acc + curr.usedJSHeapSize, 0);
      report.averageMemoryUsage = 
        `${((totalMemory / this.metrics.memoryUsage.size) / 1024 / 1024).toFixed(2)}MB`;
    }

    return report;
  }
}

export const performanceMonitor = new PerformanceMonitor();

4. 에러 처리 및 폴백 전략

// src/utils/errorBoundary.js
import React from 'react';

class ModelErrorBoundary extends React.Component {
  constructor(props) {
    super(props);
    this.state = { hasError: false, error: null };
  }

  static getDerivedStateFromError(error) {
    return { hasError: true, error };
  }

  componentDidCatch(error, errorInfo) {
    // 에러 로깅 서비스로 전송
    console.error('Model loading failed:', error, errorInfo);
  }

  render() {
    if (this.state.hasError) {
      return (
        <div className="error-container">
          <h2>모델을 불러오는데 실패했습니다</h2>
          <button onClick={() => window.location.reload()}>
            다시 시도
          </button>
        </div>
      );
    }

    return this.props.children;
  }
}

export default ModelErrorBoundary;

5. 결론

이러한 심화된 구현을 통해 다음과 같은 이점을 얻을 수 있습니다.

  • 효율적인 메모리 관리
  • 최적화된 모델 로딩
  • 상세한 성능 모니터링
  • 안정적인 에러 처리

특히 대규모 3D 애플리케이션에서는 이러한 최적화가 필수적이며, 사용자 경험을 크게 향상시킬 수 있습니다.

profile
에러가 나도 괜찮아 — 그건 내가 배우고 있다는 증거야.

0개의 댓글