Published on

Safari iOS WebGL 컨텍스트 손실 해결법

Authors

서버나 앱이 멀쩡한데 iOS Safari에서만 3D 화면이 갑자기 멈추거나 검게 변하고, 콘솔에 WebGL context lost가 찍히는 경우가 있습니다. 이 현상은 단순 버그라기보다 모바일 환경의 메모리/전력 정책GPU 리소스 관리 방식이 겹치며 발생하는 경우가 많습니다.

이 글에서는 iOS Safari에서 WebGL 컨텍스트 손실이 왜 자주 일어나는지, 그리고 감지, 복구, 재발 방지(메모리 절감)까지 실무적으로 적용 가능한 패턴을 정리합니다.

iOS Safari에서 컨텍스트 손실이 잦은 이유

1) 메모리 압박과 탭 생존 정책

iOS는 메모리가 부족하면 백그라운드 탭의 리소스를 공격적으로 회수합니다. WebGL은 텍스처, 버퍼, 프레임버퍼 등 GPU 메모리를 많이 쓰는데, Safari가 이를 회수하는 과정에서 컨텍스트가 손실될 수 있습니다.

특히 아래 조건이 겹치면 재현률이 올라갑니다.

  • 고해상도 텍스처 다수 사용(예: 4096x4096)
  • 후처리 체인(여러 FBO) 사용
  • 캔버스 해상도를 devicePixelRatio 그대로 따라가며 렌더링
  • 페이지가 백그라운드로 갔다가 다시 돌아옴

2) 백그라운드 전환, 화면 잠금, PWA 전환

visibilitychange로 백그라운드 진입, 화면 잠금, 앱 전환 등의 이벤트가 발생하면 iOS는 GPU 컨텍스트를 유지하지 않을 수 있습니다. 특히 WebGL 컨텍스트는 OS가 보장하는 영속 자원이 아닙니다.

3) 리소스 누수(특히 텍스처·버퍼·FBO)

프레임마다 생성되는 텍스처/버퍼를 제대로 deleteTexture, deleteBuffer, deleteFramebuffer로 해제하지 않으면 누수가 누적됩니다. 데스크톱에서는 오래 버티다가, iOS에서는 빠르게 임계점에 도달해 컨텍스트 손실로 이어질 수 있습니다.

4) 긴 프레임(롱태스크)과 이벤트 루프 정체

메인 스레드가 자주 막히면 렌더링이 지연되고, iOS에서 탭이 비활성 상태로 간주되거나 내부적으로 리소스 회수가 트리거되는 경우가 있습니다. 렌더링뿐 아니라 입력/스크롤/레이아웃도 함께 느려질 수 있어요.

프론트 성능 관점의 롱태스크 추적은 아래 글도 함께 참고하면 좋습니다.

1단계: 컨텍스트 손실 감지 및 기본 복구 루틴

WebGL은 컨텍스트 손실 이벤트를 제공합니다. 핵심은 두 가지입니다.

  • webglcontextlost: 기본 동작(자동 복구)을 막고, 애플리케이션이 복구를 주도
  • webglcontextrestored: GL 리소스를 전부 재생성하고 렌더 루프를 재시작

아래 예시는 순수 WebGL2 기준이며, Three.js 등 엔진을 쓰더라도 개념은 동일합니다.

const canvas = document.getElementById('c');
const gl = canvas.getContext('webgl2', {
  antialias: false,
  alpha: false,
  depth: true,
  stencil: false,
  preserveDrawingBuffer: false,
  powerPreference: 'high-performance'
});

let rafId = null;
let resources = null;

function startLoop() {
  if (rafId != null) return;
  const loop = () => {
    // draw()
    rafId = requestAnimationFrame(loop);
  };
  rafId = requestAnimationFrame(loop);
}

function stopLoop() {
  if (rafId != null) {
    cancelAnimationFrame(rafId);
    rafId = null;
  }
}

canvas.addEventListener('webglcontextlost', (e) => {
  // 중요: 기본 동작을 막아야 restored 이벤트를 받을 수 있는 케이스가 많습니다.
  e.preventDefault();
  stopLoop();

  // UI로 "그래픽 재시작 중" 표시 등을 해두면 UX가 좋아집니다.
  console.warn('WebGL context lost');
}, false);

canvas.addEventListener('webglcontextrestored', () => {
  console.warn('WebGL context restored');

  // 컨텍스트가 복구되면, 기존 GL 핸들은 무효입니다.
  // 셰이더/버퍼/텍스처/FBO 등 모든 리소스를 재생성해야 합니다.
  resources = initResources(gl);
  startLoop();
}, false);

function initResources(gl) {
  // 프로그램, 버퍼, 텍스처 등을 생성
  // 반환값으로 관리 객체를 만들어 두면 dispose와 restore가 쉬워집니다.
  return {
    program: createProgram(gl),
    vbo: createVbo(gl),
    tex: createTexture(gl)
  };
}

resources = initResources(gl);
startLoop();

복구 구현에서 자주 하는 실수

  • 손실 이전에 만들었던 텍스처/버퍼를 재사용하려고 시도
    • 컨텍스트가 바뀌면 GL 객체는 더 이상 유효하지 않습니다.
  • 복구 시점에 비동기 로딩(이미지, 모델)이 엉킨 상태로 재초기화
    • 복구 루틴은 idempotent하게(몇 번 호출돼도 동일 결과) 설계하는 것이 안전합니다.

2단계: iOS에서 재현률을 낮추는 핵심 체크리스트

복구 루틴만으로는 UX가 여전히 나쁩니다. 중요한 것은 컨텍스트 손실 자체를 덜 일어나게 만드는 것입니다.

1) 캔버스 해상도 제한: devicePixelRatio 상한 두기

iPhone Pro 계열처럼 devicePixelRatio가 3인 기기에서 화면 전체 캔버스를 그대로 쓰면 픽셀 수가 폭증합니다. 픽셀 수는 곧 렌더 타겟 메모리로 연결됩니다.

function resizeCanvasToDisplaySize(canvas, maxDpr = 2) {
  const dpr = Math.min(window.devicePixelRatio || 1, maxDpr);
  const width = Math.floor(canvas.clientWidth * dpr);
  const height = Math.floor(canvas.clientHeight * dpr);

  if (canvas.width !== width || canvas.height !== height) {
    canvas.width = width;
    canvas.height = height;
    return true;
  }
  return false;
}
  • 권장: iOS에서는 maxDpr1.5 또는 2로 제한하는 것을 먼저 시도
  • 후처리(FBO)까지 쓰는 경우 효과가 특히 큼

2) 텍스처 크기·포맷 최적화

  • 가능한 한 RGBA8 단일 포맷에 집착하지 말고, 용도에 맞게 줄이기
  • 거대한 텍스처를 여러 장 쓰는 대신, 해상도 단계별로 준비
  • 밉맵이 필요 없으면 생성하지 않기
gl.bindTexture(gl.TEXTURE_2D, tex);
// 밉맵이 필요 없으면:
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
// 래핑도 반복이 필요 없으면 clamp:
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);

3) FBO(렌더 타겟) 개수와 해상도 줄이기

후처리를 위해 여러 패스를 쌓으면 FBO 텍스처가 늘어납니다. iOS에서 컨텍스트 손실의 주요 원인 중 하나가 과도한 렌더 타겟 메모리 사용입니다.

  • 패스 수 줄이기
  • 렌더 타겟을 화면 해상도보다 낮게(예: 0.5 스케일)
  • 필요할 때만 생성하고, 씬 전환 시 즉시 삭제

4) 리소스 생명주기 관리: 생성과 해제의 짝을 강제

리소스 누수를 막으려면 패턴이 필요합니다. 가장 쉬운 방법은 “리소스 레지스트리”를 두고, 씬 종료나 페이지 전환에서 일괄 해제하는 것입니다.

function createResourceTracker(gl) {
  const textures = new Set();
  const buffers = new Set();
  const framebuffers = new Set();
  const programs = new Set();

  return {
    trackTexture(t) { textures.add(t); return t; },
    trackBuffer(b) { buffers.add(b); return b; },
    trackFramebuffer(f) { framebuffers.add(f); return f; },
    trackProgram(p) { programs.add(p); return p; },

    dispose() {
      framebuffers.forEach(f => gl.deleteFramebuffer(f));
      buffers.forEach(b => gl.deleteBuffer(b));
      textures.forEach(t => gl.deleteTexture(t));
      programs.forEach(p => gl.deleteProgram(p));

      framebuffers.clear();
      buffers.clear();
      textures.clear();
      programs.clear();
    }
  };
}

이 구조를 쓰면 컨텍스트 복구 시점에 disposeinitResources로 재생성하는 흐름이 단순해집니다.

5) 백그라운드 진입 시 렌더링 정지

백그라운드에서 계속 requestAnimationFrame을 돌리면 불필요한 작업이 쌓이고, iOS에서 리소스 회수 가능성이 커집니다.

document.addEventListener('visibilitychange', () => {
  if (document.visibilityState === 'hidden') {
    stopLoop();
  } else {
    startLoop();
  }
});

추가로 오디오, 비디오, 네트워크 폴링도 함께 줄이면 효과가 있습니다.

6) WebGL 컨텍스트 옵션 재검토

  • preserveDrawingBuffer: true는 메모리/성능 비용이 큽니다. 스크린샷 등 명확한 목적이 없다면 피하세요.
  • alpha: true는 합성 비용이 늘 수 있습니다. 배경이 불투명이라면 alpha: false 고려
  • antialias: true는 기기별 비용이 큽니다. MSAA가 꼭 필요하지 않다면 끄고 후처리로 대체

3단계: 강제 복구(최후의 수단)와 사용자 경험

컨텍스트가 손실되었는데 webglcontextrestored가 오지 않거나, 복구에 실패하는 케이스도 있습니다. 이때는 사용자에게 새로고침을 유도하거나, WebGL을 끄고 2D 대체 화면으로 전환하는 “서킷 브레이커”가 필요합니다.

복구 타임아웃과 폴백

let lostAt = null;

canvas.addEventListener('webglcontextlost', (e) => {
  e.preventDefault();
  lostAt = performance.now();
  stopLoop();

  // 3초 내 복구가 안 되면 폴백
  setTimeout(() => {
    if (lostAt && performance.now() - lostAt >= 3000) {
      showFallbackUI();
    }
  }, 3100);
});

canvas.addEventListener('webglcontextrestored', () => {
  lostAt = null;
  hideFallbackUI();
  resources = initResources(gl);
  startLoop();
});

폴백 UI는 단순히 “새로고침” 버튼만 있어도 현장에서는 큰 도움이 됩니다.

4단계: 디버깅 포인트(재현·원인 좁히기)

iOS Safari는 데스크톱 크롬처럼 풍부한 GPU 디버깅 도구가 부족합니다. 대신 아래 방식으로 원인을 좁히는 것이 실무적입니다.

1) 기능 플래그로 원인 격리

  • 후처리 on/off
  • 텍스처 해상도 단계 조절
  • maxDpr 단계 조절
  • 그림자, SSAO, Bloom 등 무거운 기능 개별 토글

이렇게 “무거운 기능을 끄면 안 터지는지”를 확인하면, 대부분 메모리/대역폭 병목인지 빠르게 판단할 수 있습니다.

2) 메모리 압박 신호를 로그로 남기기

Safari에서 명확한 GPU 메모리 지표를 얻기 어렵기 때문에, 간접 지표를 남겨두면 좋습니다.

  • 현재 캔버스 실제 렌더링 해상도
  • FBO 개수와 크기
  • 텍스처 개수, 대략적인 추정 메모리
  • 최근 씬 전환 횟수, dispose 호출 여부

서버 사이드 OOM을 추적하듯, 클라이언트도 “상태 스냅샷”이 중요합니다. 운영 환경에서 메모리 압박을 추적하는 사고방식은 아래 글과도 결이 비슷합니다.

3) 페이지 생명주기 이벤트까지 함께 보기

  • pagehide, pageshow (BFCache 관련)
  • visibilitychange
  • SPA 라우팅 시 캔버스 detach/attach 여부

특히 SPA에서 라우팅만 바뀌고 캔버스가 계속 남아 있으면, “이전 씬의 리소스”가 누적되기 쉽습니다.

Three.js를 쓴다면 적용 포인트

Three.js는 내부적으로 컨텍스트 손실을 다루는 편이지만, iOS에서는 여전히 손실이 발생할 수 있습니다. 아래를 점검하세요.

  • 렌더러 생성 옵션에서 preserveDrawingBuffer 사용 여부
  • renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)) 같은 상한 적용
  • 씬 전환 시 geometry.dispose(), material.dispose(), texture.dispose() 호출
  • 후처리(EffectComposer) 해상도 스케일 다운

또한 성능 최적화는 사용자 지표에 직접 반영되므로, Next.js 기반 서비스라면 전체 프론트 최적화 관점에서 아래 글도 같이 보면 좋습니다.

실전 적용 순서(권장)

  1. webglcontextlost / webglcontextrestored 이벤트 처리로 “복구 가능” 상태 만들기
  2. devicePixelRatio 상한 + 후처리 렌더 타겟 다운스케일로 메모리 급감시키기
  3. 텍스처 크기/밉맵 정책 정리
  4. 씬 전환마다 리소스 dispose를 강제하는 구조 도입
  5. 백그라운드 진입 시 렌더 루프 정지
  6. 복구 실패 타임아웃 폴백(새로고침 유도 또는 2D 대체)

마무리

iOS Safari의 WebGL 컨텍스트 손실은 “특정 코드 한 줄”로 완전히 사라지기보다, 메모리 사용량과 생명주기 관리를 정상화했을 때 급격히 줄어드는 유형의 문제입니다. 복구 이벤트를 제대로 처리해 사용자에게 검은 화면을 남기지 않으면서, 해상도 상한과 리소스 해제를 통해 손실 자체를 줄이는 접근이 가장 효과적입니다.

운영 환경에서는 손실 시점의 렌더링 상태(해상도, FBO, 텍스처 규모)를 로그로 남겨두면 재현이 어려운 iOS 이슈를 훨씬 빠르게 수습할 수 있습니다.