⚠️

본 콘텐츠의 이미지 및 내용을 무단으로 복제, 배포, 수정하여 사용할 경우 저작권법에 의해 법적 제재를 받을 수 있습니다.

이미지 로딩 중...

브라우저 환경 최적화 완벽 가이드 - 슬라이드 1/7
A

AI Generated

2025. 11. 27. · 2 Views

브라우저 환경 최적화 완벽 가이드

웹 브라우저에서 AI 모델을 효율적으로 실행하기 위한 최적화 기법을 다룹니다. 호환성 확인부터 Service Worker 캐싱까지, 실무에서 바로 적용할 수 있는 핵심 전략을 배워봅니다.


목차

  1. 브라우저 호환성 확인
  2. 모델 로딩 전략
  3. 프로그레스 표시
  4. 오프라인 지원
  5. Service Worker 캐싱
  6. 번들 크기 최적화

1. 브라우저 호환성 확인

김개발 씨는 회사에서 웹 기반 AI 서비스를 개발하게 되었습니다. 열심히 코드를 작성하고 테스트를 마친 뒤 배포했는데, 고객센터에 문의가 쏟아지기 시작했습니다.

"저는 화면이 하얗게만 나와요." 알고 보니 특정 브라우저에서 WebGL이 지원되지 않아서 생긴 문제였습니다.

브라우저 호환성 확인은 사용자의 브라우저가 우리 서비스에 필요한 기능을 지원하는지 미리 검사하는 것입니다. 마치 놀이공원에서 놀이기구를 타기 전에 키를 재는 것과 같습니다.

이 과정을 거치면 지원되지 않는 환경의 사용자에게 적절한 안내 메시지를 보여줄 수 있습니다.

다음 코드를 살펴봅시다.

// 브라우저 호환성을 검사하는 유틸리티 함수
async function checkBrowserCompatibility() {
  const requirements = {
    webgl: !!document.createElement('canvas').getContext('webgl2'),
    webgpu: 'gpu' in navigator,
    wasm: typeof WebAssembly === 'object',
    indexedDB: 'indexedDB' in window,
    serviceWorker: 'serviceWorker' in navigator
  };

  // 필수 요구사항 확인
  const missing = Object.entries(requirements)
    .filter(([key, supported]) => !supported)
    .map(([key]) => key);

  return { supported: missing.length === 0, missing, requirements };
}

김개발 씨는 입사 6개월 차 프론트엔드 개발자입니다. 최근 회사에서 브라우저 기반 AI 이미지 편집 서비스를 런칭했는데, 예상치 못한 문제가 터졌습니다.

일부 사용자들이 서비스가 전혀 작동하지 않는다고 불만을 토로한 것입니다. 선배 개발자 박시니어 씨가 로그를 분석해보니 원인이 명확했습니다.

"김개발 씨, 이 사용자들 브라우저를 보세요. WebGL2를 지원하지 않는 구형 브라우저네요.

호환성 검사를 넣었어야 했어요." 그렇다면 브라우저 호환성 확인이란 정확히 무엇일까요? 쉽게 비유하자면, 이것은 마치 요리를 시작하기 전에 재료를 확인하는 것과 같습니다.

파스타를 만들려면 면, 소스, 올리브유가 필요합니다. 재료가 없다면 요리를 시작하기 전에 "재료가 부족합니다"라고 알려주는 것이 훨씬 나은 경험이겠죠.

브라우저 호환성 확인도 마찬가지입니다. 서비스 실행에 필요한 기능들이 브라우저에 있는지 미리 확인합니다.

호환성 확인이 없던 시절에는 어땠을까요? 개발자들은 사용자가 직접 에러를 경험한 뒤에야 문제를 알 수 있었습니다.

콘솔에 빨간 에러 메시지가 가득하고, 화면은 멈춰버리고, 사용자는 "이 서비스 왜 이래?"라며 떠나버립니다. 더 큰 문제는 개발 환경에서는 잘 되는데 특정 사용자 환경에서만 문제가 생기는 경우였습니다.

재현조차 어려운 버그가 되어버리는 것이죠. 바로 이런 문제를 해결하기 위해 사전 호환성 검사가 필수가 되었습니다.

호환성 검사를 도입하면 서비스 진입 전에 문제를 파악할 수 있습니다. 지원되지 않는 브라우저 사용자에게는 "Chrome 최신 버전을 사용해주세요"라는 친절한 안내를 보여줄 수 있습니다.

무엇보다 개발팀이 지원할 브라우저 범위를 명확히 정의하고 관리할 수 있게 됩니다. 위의 코드를 한 줄씩 살펴보겠습니다.

먼저 requirements 객체에서 각 기능의 지원 여부를 불리언 값으로 저장합니다. webgl2는 캔버스 요소를 생성해서 컨텍스트를 얻을 수 있는지 확인합니다.

webgpu는 navigator 객체에 gpu 속성이 있는지 검사합니다. 이런 식으로 필요한 모든 기능을 한 번에 체크합니다.

그다음 filtermap을 사용해서 지원되지 않는 기능만 추려냅니다. 결과적으로 missing 배열이 비어있다면 모든 기능이 지원되는 것입니다.

실제 현업에서는 어떻게 활용할까요? 예를 들어 AI 기반 화상 회의 서비스를 개발한다고 가정해봅시다.

서비스 시작 시 호환성을 검사하고, WebRTC나 MediaDevices API가 지원되지 않으면 "이 브라우저에서는 화상 회의 기능을 사용할 수 없습니다"라는 모달을 보여줍니다. 사용자는 당황하지 않고 다른 브라우저를 사용하거나, 모바일 앱을 설치하는 등의 대안을 선택할 수 있습니다.

하지만 주의할 점도 있습니다. 초보 개발자들이 흔히 하는 실수 중 하나는 너무 엄격한 검사입니다.

WebGPU가 없어도 WebGL로 대체 가능한데, 무조건 WebGPU를 요구하면 많은 사용자를 잃게 됩니다. 따라서 필수 기능과 선택적 기능을 구분하고, 대체 방안(fallback)을 함께 고려해야 합니다.

다시 김개발 씨의 이야기로 돌아가 봅시다. 박시니어 씨의 조언을 듣고 호환성 검사 로직을 추가한 김개발 씨는 한숨을 돌렸습니다.

"이제 지원 안 되는 브라우저에서는 친절한 안내 페이지가 나오겠네요!" 브라우저 호환성 확인을 제대로 구현하면 사용자 경험이 크게 향상됩니다. 에러 대신 안내를, 좌절 대신 대안을 제공할 수 있으니까요.

실전 팁

💡 - 호환성 검사는 앱 초기화 단계에서 가장 먼저 실행하세요

  • 필수 기능과 선택적 기능을 구분하여 유연하게 대응하세요
  • caniuse.com을 참고하여 지원 브라우저 범위를 결정하세요

2. 모델 로딩 전략

김개발 씨가 개발한 AI 서비스가 드디어 정식 출시되었습니다. 그런데 사용자 리뷰에 공통된 불만이 올라왔습니다.

"앱 켜는 데 30초나 걸려요. 뭐가 이렇게 느린 거예요?" AI 모델 파일이 너무 커서 생긴 문제였습니다.

이제 모델을 어떻게 효율적으로 로딩할지 고민해야 합니다.

모델 로딩 전략은 대용량 AI 모델을 사용자에게 최소한의 대기 시간으로 전달하는 방법입니다. 마치 이사할 때 꼭 필요한 짐부터 먼저 옮기는 것처럼, 핵심 기능에 필요한 모델만 우선 로딩하고 나머지는 나중에 불러오는 전략입니다.

이를 통해 체감 로딩 속도를 크게 개선할 수 있습니다.

다음 코드를 살펴봅시다.

// 지연 로딩과 우선순위를 적용한 모델 로더
class ModelLoader {
  constructor() {
    this.cache = new Map();
    this.loadQueue = [];
  }

  // 우선순위에 따른 모델 로딩
  async loadModel(modelId, priority = 'normal') {
    if (this.cache.has(modelId)) {
      return this.cache.get(modelId);
    }

    const model = await this.fetchWithPriority(modelId, priority);
    this.cache.set(modelId, model);
    return model;
  }

  async fetchWithPriority(modelId, priority) {
    const url = `/models/${modelId}.onnx`;
    const fetchOptions = priority === 'high'
      ? { priority: 'high' }
      : { priority: 'low' };

    const response = await fetch(url, fetchOptions);
    return await response.arrayBuffer();
  }
}

김개발 씨는 요즘 고민이 많습니다. 서비스는 잘 만들었는데, 첫 로딩이 너무 느리다는 피드백이 계속 들어옵니다.

AI 모델 파일 하나가 50MB가 넘으니 당연한 결과였죠. 박시니어 씨가 커피를 건네며 말했습니다.

"모델을 한 번에 다 불러올 필요가 있을까요? 사용자가 당장 필요로 하는 것부터 먼저 보여주면 어떨까요?" 그렇다면 모델 로딩 전략이란 정확히 무엇일까요?

쉽게 비유하자면, 이것은 마치 레스토랑에서 식사하는 것과 같습니다. 손님이 앉자마자 전채부터 디저트까지 한꺼번에 내오면 어떨까요?

테이블이 가득 차고, 음식은 식어버리겠죠. 대신 물과 빵을 먼저 내오고, 전채, 메인, 디저트 순으로 적절한 타이밍에 서빙하는 것이 훨씬 좋은 경험입니다.

모델 로딩도 마찬가지입니다. 모델 로딩 전략이 없던 시절에는 어땠을까요?

페이지에 접속하면 수백 MB의 모델이 다 로딩될 때까지 사용자는 하얀 화면만 바라봐야 했습니다. 3초가 지나면 사용자의 53%가 이탈한다는 연구 결과가 있습니다.

30초를 기다리게 한다면? 대부분의 사용자는 떠나버립니다.

더 큰 문제는 모바일 환경이었습니다. 데이터 요금제를 사용하는 사용자에게 불필요한 대용량 다운로드는 불쾌한 경험이 됩니다.

바로 이런 문제를 해결하기 위해 체계적인 로딩 전략이 필요합니다. **지연 로딩(Lazy Loading)**을 사용하면 화면에 보이는 기능에 필요한 모델만 먼저 불러옵니다.

우선순위 기반 로딩으로 핵심 기능은 빠르게, 부가 기능은 백그라운드에서 로딩합니다. 캐싱을 적용하면 한 번 불러온 모델을 재사용할 수 있어 두 번째 방문부터는 거의 즉시 로딩됩니다.

위의 코드를 자세히 살펴보겠습니다. ModelLoader 클래스는 모델 로딩을 관리하는 중앙 컨트롤러 역할을 합니다.

cache라는 Map 객체에 이미 로딩된 모델을 저장해두고, 같은 모델을 다시 요청하면 네트워크 통신 없이 캐시에서 바로 반환합니다. loadModel 메서드는 먼저 캐시를 확인합니다.

캐시에 있으면 즉시 반환하고, 없으면 fetchWithPriority를 호출합니다. priority 파라미터로 'high'를 전달하면 브라우저가 해당 요청을 우선적으로 처리합니다.

실제 현업에서는 어떻게 활용할까요? 예를 들어 AI 사진 편집 앱을 만든다고 가정해봅시다.

앱이 시작되면 가장 많이 쓰는 "배경 제거" 모델만 먼저 로딩합니다. 사용자가 다른 기능 탭을 클릭하면 그때 해당 모델을 불러옵니다.

이런 방식으로 초기 로딩 시간을 5초 이내로 줄일 수 있습니다. 하지만 주의할 점도 있습니다.

초보 개발자들이 흔히 하는 실수 중 하나는 너무 잘게 쪼개는 것입니다. 모델을 100개로 나누면 관리가 복잡해지고, 오히려 HTTP 요청 오버헤드가 증가할 수 있습니다.

적절한 단위로 분리하고, 자주 함께 쓰이는 모델은 묶어서 관리하는 것이 좋습니다. 다시 김개발 씨의 이야기로 돌아가 봅시다.

모델 로딩 전략을 적용한 뒤, 초기 로딩 시간이 30초에서 4초로 줄었습니다. 사용자 리뷰에도 "빨라졌어요!"라는 긍정적인 반응이 올라오기 시작했습니다.

모델 로딩 전략을 제대로 구현하면 같은 기능이라도 체감 속도가 완전히 달라집니다. 사용자는 기다림 없이 서비스를 즐길 수 있게 됩니다.

실전 팁

💡 - 가장 자주 사용되는 기능의 모델을 우선 로딩하세요

  • IndexedDB를 활용해 모델을 로컬에 캐시하면 재방문 시 즉시 로딩됩니다
  • 네트워크 상태에 따라 모델 품질을 조절하는 적응형 로딩도 고려해보세요

3. 프로그레스 표시

김개발 씨가 모델 로딩 전략을 적용했지만, 여전히 사용자 불만이 있었습니다. "로딩 중인 건 알겠는데, 얼마나 기다려야 하는지 모르겠어요." 진행 상황이 보이지 않으니 사용자들이 불안해했던 것입니다.

이제 로딩 진행률을 보여주는 방법을 알아봐야 합니다.

프로그레스 표시는 시간이 걸리는 작업의 진행 상황을 사용자에게 시각적으로 보여주는 것입니다. 마치 배달 앱에서 "조리 중 - 배달 중 - 도착 예정"을 보여주는 것처럼, 사용자에게 현재 상태와 예상 시간을 알려줍니다.

이를 통해 사용자의 체감 대기 시간을 크게 줄일 수 있습니다.

다음 코드를 살펴봅시다.

// 상세한 진행률을 제공하는 프로그레스 트래커
class ProgressTracker {
  constructor(onProgress) {
    this.onProgress = onProgress;
    this.stages = [];
    this.currentStage = 0;
  }

  setStages(stages) {
    // 각 스테이지: { name: '모델 다운로드', weight: 0.6 }
    this.stages = stages;
    this.currentStage = 0;
  }

  updateProgress(stageProgress) {
    const completed = this.stages
      .slice(0, this.currentStage)
      .reduce((sum, s) => sum + s.weight, 0);

    const currentWeight = this.stages[this.currentStage]?.weight || 0;
    const total = completed + (currentWeight * stageProgress);

    this.onProgress({
      stage: this.stages[this.currentStage]?.name,
      stageProgress,
      totalProgress: Math.round(total * 100),
    });
  }

  nextStage() {
    this.currentStage++;
  }
}

김개발 씨는 심리학 관련 아티클을 읽다가 흥미로운 내용을 발견했습니다. "사람은 진행 상황을 알 때 같은 시간도 더 짧게 느낀다." 그래서 병원 대기실에 번호표 시스템이 있고, 엘리베이터에 층수 표시가 있는 것이었습니다.

박시니어 씨도 공감했습니다. "맞아요.

로딩 바 하나만 잘 넣어도 사용자 경험이 확 달라져요. 진짜 중요한 건 실제 속도가 아니라 체감 속도거든요." 그렇다면 프로그레스 표시란 정확히 무엇일까요?

쉽게 비유하자면, 이것은 마치 마라톤에서 이정표를 보는 것과 같습니다. "현재 25km 지점, 완주까지 17km 남음"이라는 표지판이 없다면 러너는 끝이 어딘지 몰라 불안해질 것입니다.

반면 이정표가 있으면 "조금만 더 가면 돼"라는 희망이 생깁니다. 프로그레스 표시도 사용자에게 같은 심리적 안정감을 줍니다.

프로그레스 표시가 없던 시절에는 어땠을까요? 화면에 스피너 하나만 빙글빙글 돌아갑니다.

1분이 지나도 똑같이 돌아갑니다. 사용자는 생각합니다.

"이거 멈춘 거 아냐? 새로고침할까?" 그리고 새로고침을 누르면 처음부터 다시 로딩이 시작됩니다.

이런 악순환이 반복되면서 사용자는 결국 떠나버립니다. 바로 이런 문제를 해결하기 위해 체계적인 프로그레스 표시가 필요합니다.

프로그레스 표시를 도입하면 사용자는 현재 무엇이 진행 중인지 알 수 있습니다. "모델 다운로드 중...

45%"라는 메시지를 보면 작업이 진행되고 있다는 확신이 생깁니다. 예상 잔여 시간까지 보여주면 더욱 좋습니다.

"약 30초 남음"이라는 정보는 사용자가 기다릴지 말지 판단하는 데 도움이 됩니다. 위의 코드를 자세히 살펴보겠습니다.

ProgressTracker 클래스는 여러 단계로 이루어진 작업의 진행률을 계산합니다. stages 배열에 각 단계의 이름과 가중치(weight)를 설정합니다.

예를 들어 모델 다운로드가 전체의 60%, 초기화가 40%라면 각각 0.6과 0.4를 할당합니다. updateProgress 메서드가 핵심입니다.

현재 단계의 진행률(0~1)을 받아서 전체 진행률을 계산합니다. 이미 완료된 단계의 가중치를 모두 더하고, 현재 단계의 진행률에 가중치를 곱해 더합니다.

결과적으로 전체 진행률이 0~100 사이의 숫자로 나옵니다. 실제 현업에서는 어떻게 활용할까요?

예를 들어 AI 번역 서비스를 개발한다고 가정해봅시다. 모델 로딩 과정을 "모델 다운로드 - 모델 파싱 - 웜업 실행" 세 단계로 나눕니다.

각 단계마다 진행률을 업데이트하고, UI에는 "번역 엔진을 준비하고 있습니다... 67%"와 같이 표시합니다.

사용자는 무슨 일이 일어나는지 이해하고 편안하게 기다릴 수 있습니다. 하지만 주의할 점도 있습니다.

초보 개발자들이 흔히 하는 실수 중 하나는 가짜 프로그레스입니다. 실제 진행 상황과 무관하게 프로그레스 바가 움직이면, 99%에서 한참을 멈추는 현상이 생깁니다.

이러면 오히려 신뢰를 잃습니다. 실제 진행 상황을 정확히 반영하되, 측정이 어려운 구간은 불확정 상태(indeterminate)로 표시하는 것이 좋습니다.

다시 김개발 씨의 이야기로 돌아가 봅시다. 프로그레스 표시를 적용한 뒤, 흥미로운 일이 벌어졌습니다.

실제 로딩 시간은 똑같은데, 사용자 불만이 크게 줄었습니다. "기다리는 동안 뭐가 되는지 보이니까 답답하지 않아요"라는 피드백이 들어왔습니다.

프로그레스 표시를 제대로 구현하면 같은 대기 시간도 훨씬 짧게 느껴집니다. 사용자 심리를 이해하는 개발자가 좋은 서비스를 만듭니다.

실전 팁

💡 - 단계별 메시지를 함께 보여주면 사용자가 현재 상황을 더 잘 이해합니다

  • 프로그레스 바가 99%에서 멈추지 않도록 가중치를 현실적으로 설정하세요
  • 예상 잔여 시간은 과대평가해서 보여주는 것이 더 좋은 경험을 줍니다

4. 오프라인 지원

김개발 씨가 지하철에서 자신의 서비스를 테스트하다가 당황했습니다. 터널 구간에 들어가자 화면에 공룡이 나타났습니다.

"아, 오프라인이면 아무것도 안 되는구나..." 인터넷 연결이 끊겨도 기본 기능은 동작해야 한다는 사실을 깨달은 순간이었습니다.

오프라인 지원은 인터넷 연결이 없어도 애플리케이션의 핵심 기능이 동작하도록 만드는 것입니다. 마치 비행기 모드에서도 다운로드한 음악을 들을 수 있는 것처럼, 필요한 자원을 로컬에 저장해두고 오프라인에서도 사용할 수 있게 합니다.

이를 통해 어떤 네트워크 환경에서도 일관된 서비스를 제공할 수 있습니다.

다음 코드를 살펴봅시다.

// IndexedDB를 활용한 오프라인 모델 저장소
class OfflineModelStore {
  constructor(dbName = 'ai-models') {
    this.dbName = dbName;
    this.db = null;
  }

  async init() {
    return new Promise((resolve, reject) => {
      const request = indexedDB.open(this.dbName, 1);

      request.onupgradeneeded = (e) => {
        const db = e.target.result;
        db.createObjectStore('models', { keyPath: 'id' });
      };

      request.onsuccess = (e) => {
        this.db = e.target.result;
        resolve(this.db);
      };
      request.onerror = () => reject(request.error);
    });
  }

  async saveModel(id, data, metadata = {}) {
    const tx = this.db.transaction('models', 'readwrite');
    const store = tx.objectStore('models');
    await store.put({ id, data, metadata, savedAt: Date.now() });
  }

  async getModel(id) {
    const tx = this.db.transaction('models', 'readonly');
    const store = tx.objectStore('models');
    return store.get(id);
  }
}

김개발 씨는 출퇴근 시간에 지하철을 탑니다. 서울 지하철은 터널이 많아서 인터넷이 끊기는 구간이 꽤 있습니다.

그때마다 자신의 서비스가 먹통이 되는 것을 보면서 문제의식을 느꼈습니다. 박시니어 씨에게 고민을 털어놓자, 그가 말했습니다.

"PWA라고 들어봤어요? 오프라인에서도 동작하는 웹 앱을 만들 수 있어요.

AI 모델을 로컬에 저장해두면 인터넷 없이도 추론을 실행할 수 있죠." 그렇다면 오프라인 지원이란 정확히 무엇일까요? 쉽게 비유하자면, 이것은 마치 도시락을 싸가는 것과 같습니다.

점심시간에 식당에 갈 수 없는 상황이라도, 도시락이 있으면 밥을 먹을 수 있습니다. 오프라인 지원도 마찬가지입니다.

서버에 연결할 수 없는 상황에서도, 로컬에 저장된 자원으로 서비스를 제공할 수 있습니다. 오프라인 지원이 없던 시절에는 어땠을까요?

인터넷이 끊기면 웹 앱은 그냥 멈춰버렸습니다. "인터넷에 연결할 수 없습니다" 메시지와 함께 아무것도 할 수 없었죠.

특히 AI 서비스는 모델을 매번 서버에서 불러와야 해서 오프라인에서는 완전히 무용지물이었습니다. 바로 이런 문제를 해결하기 위해 오프라인 지원 기술이 발전했습니다.

IndexedDB를 사용하면 브라우저에 대용량 데이터를 저장할 수 있습니다. AI 모델 파일도 저장할 수 있어서, 한 번 다운로드한 모델은 오프라인에서도 사용 가능합니다.

네트워크 상태 감지와 결합하면 온라인일 때는 최신 모델을, 오프라인일 때는 저장된 모델을 사용하는 영리한 전환이 가능합니다. 위의 코드를 자세히 살펴보겠습니다.

OfflineModelStore 클래스는 IndexedDB를 래핑한 저장소입니다. init 메서드에서 데이터베이스를 열고, 'models'라는 오브젝트 스토어를 생성합니다.

IndexedDB는 비동기 API라서 Promise로 감싸서 사용합니다. saveModel 메서드는 모델 ID, 실제 데이터, 그리고 메타데이터를 함께 저장합니다.

savedAt 타임스탬프를 추가해서 나중에 캐시 무효화에 활용할 수 있습니다. getModel 메서드는 저장된 모델을 ID로 조회합니다.

실제 현업에서는 어떻게 활용할까요? 예를 들어 음성 인식 메모 앱을 만든다고 가정해봅시다.

사용자가 앱을 처음 실행하면 음성 인식 모델을 다운로드하고 IndexedDB에 저장합니다. 이후에는 비행기 안에서도, 터널 속에서도, 산속에서도 음성 메모를 할 수 있습니다.

인터넷이 연결되면 메모를 클라우드에 동기화하면 됩니다. 하지만 주의할 점도 있습니다.

초보 개발자들이 흔히 하는 실수 중 하나는 저장 용량을 고려하지 않는 것입니다. IndexedDB도 용량 제한이 있습니다.

보통 디스크 용량의 50% 정도까지 사용할 수 있지만, 너무 많은 모델을 저장하면 문제가 생깁니다. 오래된 모델은 자동으로 삭제하는 LRU 캐시 정책을 적용하는 것이 좋습니다.

다시 김개발 씨의 이야기로 돌아가 봅시다. 오프라인 지원을 구현한 뒤, 지하철 터널에서도 서비스가 잘 동작했습니다.

"이제 어디서든 쓸 수 있겠네!" 김개발 씨의 얼굴에 미소가 번졌습니다. 오프라인 지원을 제대로 구현하면 사용자는 네트워크 상태를 신경 쓰지 않아도 됩니다.

언제 어디서나 동작하는 서비스, 그것이 진정한 사용자 중심 설계입니다.

실전 팁

💡 - navigator.onLine으로 네트워크 상태를 감지하고 UI에 반영하세요

  • 저장된 모델의 버전을 관리해서 업데이트가 필요할 때 알 수 있게 하세요
  • 오프라인에서 수행한 작업은 큐에 저장했다가 온라인 복귀 시 동기화하세요

5. Service Worker 캐싱

김개발 씨가 오프라인 지원을 구현했지만, 한 가지 아쉬운 점이 있었습니다. IndexedDB에 모델은 저장했는데, HTML, CSS, JavaScript 파일은 여전히 매번 서버에서 받아오고 있었습니다.

박시니어 씨가 말했습니다. "Service Worker를 써보세요.

네트워크 요청을 가로채서 캐시할 수 있어요."

Service Worker 캐싱은 브라우저와 서버 사이에서 네트워크 요청을 가로채고 관리하는 기술입니다. 마치 택배 기사가 물건을 바로 전달하는 대신 아파트 경비실에 맡기면, 다음부터는 경비실에서 바로 받을 수 있는 것과 같습니다.

이를 통해 로딩 속도를 획기적으로 개선하고 오프라인 지원도 강화할 수 있습니다.

다음 코드를 살펴봅시다.

// Service Worker 등록 및 캐시 전략 설정
// sw.js (Service Worker 파일)
const CACHE_NAME = 'ai-app-v1';
const MODEL_CACHE = 'ai-models-v1';

const STATIC_ASSETS = ['/', '/index.html', '/app.js', '/styles.css'];

self.addEventListener('install', (event) => {
  event.waitUntil(
    caches.open(CACHE_NAME).then((cache) => cache.addAll(STATIC_ASSETS))
  );
});

self.addEventListener('fetch', (event) => {
  const url = new URL(event.request.url);

  // 모델 파일은 Cache First 전략
  if (url.pathname.startsWith('/models/')) {
    event.respondWith(
      caches.open(MODEL_CACHE).then(async (cache) => {
        const cached = await cache.match(event.request);
        if (cached) return cached;

        const response = await fetch(event.request);
        cache.put(event.request, response.clone());
        return response;
      })
    );
  }
});

김개발 씨는 브라우저 개발자 도구의 네트워크 탭을 유심히 살펴보고 있었습니다. 페이지를 새로고침할 때마다 같은 파일들이 반복해서 다운로드되고 있었습니다.

"이거 낭비 아닌가?" 박시니어 씨가 화면을 보더니 말했습니다. "Service Worker를 도입하면 저 요청들을 캐시에서 바로 처리할 수 있어요.

네트워크를 거치지 않으니 거의 즉시 로딩되죠." 그렇다면 Service Worker란 정확히 무엇일까요? 쉽게 비유하자면, Service Worker는 마치 개인 비서와 같습니다.

여러분이 뭔가를 요청하면, 비서가 먼저 확인합니다. "이거 이미 갖고 있는 거예요, 바로 드릴게요." 없으면 직접 가져오고, 나중을 위해 복사본을 만들어둡니다.

이렇게 Service Worker는 브라우저와 서버 사이에서 똑똑하게 중개 역할을 합니다. Service Worker가 없던 시절에는 어땠을까요?

매번 페이지를 열 때마다 모든 파일을 서버에서 다시 받아왔습니다. HTTP 캐시가 있긴 했지만 제어가 어렵고, 오프라인에서는 무용지물이었습니다.

사용자가 새로고침할 때마다 몇 초씩 기다려야 했고, 인터넷이 끊기면 아무것도 할 수 없었습니다. 바로 이런 문제를 해결하기 위해 Service Worker가 등장했습니다.

Service Worker를 사용하면 캐시 전략을 직접 제어할 수 있습니다. 자주 변하지 않는 파일은 캐시 우선으로, 자주 변하는 데이터는 네트워크 우선으로 설정할 수 있습니다.

무엇보다 오프라인에서도 캐시된 자원으로 앱이 동작합니다. 위의 코드를 자세히 살펴보겠습니다.

install 이벤트는 Service Worker가 처음 설치될 때 발생합니다. 이때 STATIC_ASSETS 배열에 정의된 핵심 파일들을 미리 캐시합니다.

이것을 **프리캐싱(Pre-caching)**이라고 합니다. fetch 이벤트가 핵심입니다.

브라우저가 네트워크 요청을 할 때마다 이 이벤트가 발생합니다. 코드에서는 모델 파일 요청을 감지해서 Cache First 전략을 적용합니다.

캐시에 있으면 바로 반환하고, 없으면 네트워크에서 가져온 뒤 캐시에 저장합니다. 실제 현업에서는 어떻게 활용할까요?

예를 들어 AI 문서 스캐너 앱을 만든다고 가정해봅시다. Service Worker로 앱 셸(HTML, CSS, JS)과 OCR 모델을 캐싱합니다.

사용자가 앱 아이콘을 누르면 거의 즉시 화면이 나타납니다. 오프라인에서도 문서를 스캔하고 텍스트를 추출할 수 있습니다.

마치 네이티브 앱처럼 동작하는 웹 앱이 완성됩니다. 하지만 주의할 점도 있습니다.

초보 개발자들이 흔히 하는 실수 중 하나는 캐시 무효화를 잊는 것입니다. 버그를 수정해서 새 버전을 배포했는데, 사용자는 계속 캐시된 옛날 버전을 보게 됩니다.

따라서 CACHE_NAME에 버전 번호를 넣고, 새 버전 배포 시 이전 캐시를 삭제하는 로직을 반드시 추가해야 합니다. 다시 김개발 씨의 이야기로 돌아가 봅시다.

Service Worker를 도입한 뒤, 페이지 로딩 속도가 눈에 띄게 빨라졌습니다. 네트워크 탭을 보니 대부분의 요청이 "(ServiceWorker)"라고 표시되어 있었습니다.

"와, 서버에 안 가고 바로 캐시에서 오는 거네요!" Service Worker 캐싱을 제대로 구현하면 웹 앱도 네이티브 앱 못지않은 속도를 낼 수 있습니다. 사용자는 빠른 반응 속도에 만족하고, 서버 비용도 절감됩니다.

실전 팁

💡 - 개발 중에는 Service Worker를 비활성화하거나, 매번 업데이트하도록 설정하세요

  • 캐시 버전 관리를 통해 새 배포가 제대로 반영되도록 하세요
  • Workbox 라이브러리를 사용하면 복잡한 캐시 전략도 쉽게 구현할 수 있습니다

6. 번들 크기 최적화

김개발 씨의 서비스가 점점 인기를 얻으면서 기능도 늘어났습니다. 그런데 어느 날 빌드 결과를 보고 깜짝 놀랐습니다.

번들 크기가 무려 5MB가 넘었습니다. 모바일 사용자들은 이 거대한 파일을 다운로드하느라 데이터를 낭비하고 있었습니다.

이제 다이어트가 필요한 시점입니다.

번들 크기 최적화는 웹 애플리케이션의 JavaScript 파일 크기를 줄여서 로딩 속도를 개선하는 것입니다. 마치 여행 가방을 쌀 때 꼭 필요한 것만 챙기고, 압축 팩으로 부피를 줄이는 것과 같습니다.

이를 통해 초기 로딩 시간을 단축하고 사용자 경험을 향상시킬 수 있습니다.

다음 코드를 살펴봅시다.

// webpack.config.js - 번들 최적화 설정
module.exports = {
  mode: 'production',
  optimization: {
    splitChunks: {
      chunks: 'all',
      cacheGroups: {
        // AI 관련 라이브러리는 별도 청크로 분리
        ai: {
          test: /[\\/]node_modules[\\/](onnxruntime-web|@tensorflow)[\\/]/,
          name: 'ai-vendors',
          priority: 10,
        },
        vendors: {
          test: /[\\/]node_modules[\\/]/,
          name: 'vendors',
          priority: -10,
        },
      },
    },
    usedExports: true, // Tree Shaking 활성화
  },
  experiments: {
    asyncWebAssembly: true, // WASM 지연 로딩
  },
};

김개발 씨는 Lighthouse 성능 점수를 확인하다가 충격을 받았습니다. 점수가 30점밖에 되지 않았습니다.

가장 큰 문제로 지적된 것은 "JavaScript 실행 시간 줄이기"였습니다. 5MB가 넘는 번들을 파싱하고 실행하느라 브라우저가 버벅거리고 있었던 것입니다.

박시니어 씨가 webpack-bundle-analyzer 결과를 보여주며 말했습니다. "여기 보세요.

ONNX Runtime이 2MB, TensorFlow.js가 1.5MB네요. 이걸 메인 번들에 다 넣으면 안 돼요.

분리해야 합니다." 그렇다면 번들 크기 최적화란 정확히 무엇일까요? 쉽게 비유하자면, 이것은 마치 이삿짐 정리와 같습니다.

새 집에 이사할 때 모든 짐을 한 트럭에 싣고 한 번에 옮기면 편할 것 같지만, 실제로는 비효율적입니다. 당장 필요한 침구와 세면도구는 먼저 옮기고, 계절 옷이나 책은 나중에 옮기는 것이 합리적입니다.

번들 최적화도 마찬가지로, 당장 필요한 코드만 먼저 전송합니다. 번들 최적화가 없던 시절에는 어땠을까요?

모든 JavaScript 코드가 하나의 거대한 파일로 묶였습니다. 사용자가 첫 페이지만 보려고 해도 전체 앱의 코드를 다 다운로드해야 했습니다.

AI 모델 추론 코드, 관리자 페이지 코드, 거의 안 쓰는 기능의 코드까지 모두 포함되어 있었습니다. 결과적으로 첫 화면이 나타나기까지 수십 초가 걸리기도 했습니다.

바로 이런 문제를 해결하기 위해 번들 최적화 기법들이 발전했습니다. **코드 분할(Code Splitting)**을 사용하면 코드를 여러 청크로 나눌 수 있습니다.

**트리 쉐이킹(Tree Shaking)**으로 사용하지 않는 코드를 제거합니다. **동적 임포트(Dynamic Import)**로 특정 기능이 필요할 때만 해당 코드를 불러옵니다.

이 세 가지를 조합하면 번들 크기를 획기적으로 줄일 수 있습니다. 위의 코드를 자세히 살펴보겠습니다.

splitChunks 설정이 핵심입니다. cacheGroups에서 특정 패턴에 맞는 모듈을 별도의 청크로 분리합니다.

AI 관련 라이브러리는 'ai-vendors'라는 이름으로 분리되어, 메인 번들과 별개로 관리됩니다. 이렇게 분리된 청크는 필요할 때만 로딩됩니다.

usedExports: true는 트리 쉐이킹을 활성화합니다. 예를 들어 lodash에서 map 함수만 사용한다면, 다른 수백 개의 함수는 최종 번들에 포함되지 않습니다.

asyncWebAssembly는 WebAssembly 모듈을 지연 로딩할 수 있게 해줍니다. 실제 현업에서는 어떻게 활용할까요?

예를 들어 AI 기반 사진 편집 앱을 만든다고 가정해봅시다. 앱을 시작하면 기본 UI만 담긴 100KB 정도의 메인 번들이 로딩됩니다.

사용자가 "배경 제거" 버튼을 누르면 그때 ai-vendors 청크가 로딩됩니다. "필터 적용"을 누르면 filters 청크가 로딩됩니다.

이런 식으로 필요한 시점에 필요한 코드만 가져옵니다. 하지만 주의할 점도 있습니다.

초보 개발자들이 흔히 하는 실수 중 하나는 너무 잘게 쪼개는 것입니다. 청크가 100개가 되면 각각의 HTTP 요청 오버헤드가 문제가 됩니다.

또한 자주 함께 쓰이는 코드를 분리하면 오히려 비효율적입니다. webpack-bundle-analyzer로 번들 구성을 시각화하고, 적절한 크기와 개수를 찾아야 합니다.

다시 김개발 씨의 이야기로 돌아가 봅시다. 번들 최적화를 적용한 뒤, 메인 번들이 5MB에서 200KB로 줄었습니다.

Lighthouse 점수도 30점에서 85점으로 껑충 뛰었습니다. "이제 모바일에서도 쌩쌩하게 돌아가네요!" 번들 크기 최적화를 제대로 구현하면 같은 기능을 제공하면서도 훨씬 빠른 서비스를 만들 수 있습니다.

사용자의 시간과 데이터를 아껴주는 것, 그것이 좋은 개발자의 덕목입니다.

실전 팁

💡 - webpack-bundle-analyzer로 번들 구성을 시각화하고 큰 의존성을 파악하세요

  • moment.js 대신 day.js, lodash 대신 lodash-es를 사용해 트리 쉐이킹 효과를 높이세요
  • 이미지와 폰트는 별도로 최적화하고, CDN에서 제공하는 것이 좋습니다

이상으로 학습을 마칩니다. 위 내용을 직접 코드로 작성해보면서 익혀보세요!

#JavaScript#BrowserOptimization#ServiceWorker#WebPerformance#OfflineSupport#AI,ML,JavaScript

댓글 (0)

댓글을 작성하려면 로그인이 필요합니다.