← Notes

모바일 뷰포트에 게임 욱여넣기

Phaser 캔버스 게임 burger-stack과 웹캠 모션인식 게임 cham3, PC 화면 기준으로 짜둔 두 프로젝트를 모바일로 옮기면서 똑같은 착각을 반복했다. 100dvh 한 줄이면 뷰포트 문제가 끝날 줄 알았는데, 두 프로젝트는 서로 다른 얼굴로 다시 터졌다.

100vh는 브라우저 주소창이 접혀 있든 펼쳐져 있든 그 순간의 뷰포트 높이를 그대로 100%로 굳힌다. 문제는 주소창이 펼쳐진 상태에서 100vh를 계산해두면, 주소창이 다시 접혀도 레이아웃이 그 굳은 값에 묶여 화면 일부가 잘려나간다는 것이다. dvh(dynamic viewport height)는 이 값을 주소창 상태 변화에 맞춰 매번 다시 계산해주는 단위다. 즉, 100vh가 스냅샷이라면 100dvh는 실시간 갱신값이라는 차이다.

burger-stack: 누가 캔버스 크기를 재는가

Phaser 기반 캐주얼 게임으로, 고정 캔버스에 화면 비율을 유지한 채 맞추는 스케일 모드를 쓴다. PC 전용으로 짜여 있던 걸 모바일로 옮기는 1차 작업에서 다음을 한 번에 정리했다.

  • 뷰포트·높이 폴백: viewport-fit=cover 추가, height: 100vh 다음 줄에 100dvh를 폴백으로 병기. iOS 전용 폴백 height: -webkit-fill-available;body가 아니라 반드시 **html**에 걸어야 한다.
  • 스크롤·터치 차단: overflow: hidden + position: fixed만으로는 iOS 바운스 스크롤이 안 막혀 overscroll-behavior: none을 같이 걸었고, touch-action: none·user-select: none으로 터치 기본 동작을 지웠다.
  • 회전·스케일 범위: 세로 전용 게임이라 가로 회전 시 안내 오버레이를 띄우고, 캔버스 스케일에 최소·최대 해상도 범위를 지정해 극단적으로 작거나 큰 기기에서 비율이 깨지지 않게 했다.

검증 리뷰에서 지적이 하나 나왔다. safe-area 여백을 화면 전체 body의 padding으로 주면, Phaser 엔진은 그 padding이 빠진 영역을 기준으로 캔버스 크기를 잰다는 것이다. 재봉사가 옷을 입은 상태로 몸 치수를 재면 실측과 다른 숫자가 나오는 것과 닮았다 — 누가 무엇을 기준으로 재느냐에 따라 같은 화면도 다른 크기로 계산된다. 검증사는 이렇게 못박았다.

“SafeArea + Phaser Scale 조합 — 실기기 테스트 필수… Phaser 버전 및 브라우저 렌더링 환경에 따라 측정 방식이 다를 수 있음.”

이 시점 검증은 로컬과 DevTools 반응형 모드까지였고, 배포 후 실기기 검증은 “필수 아님”으로 넘어갔다.

1차 대응 후에도 실사용 가능 높이가 100dvh보다 작은 문제가 남았다. 원인은 iOS Safari의 URL 바와 Android Chrome의 주소창이 100dvh 계산에 여전히 포함돼 있었다는 것 — 스케일 모드가 원본 비율을 지키려다 콘텐츠 일부를 화면 밖으로 밀어냈다. “모바일 브라우저 UI 점유 — 실제 사용 가능 높이 < 100dvh”. dvh라는 이름과 달리, “동적”이 브라우저 UI 점유분까지 포함해버리는 경우가 있었던 셈이다.

해법은 측정 기준 자체를 옮기는 것이었다. window 전체가 아니라 별도 컨테이너를 Phaser의 측정 대상으로 지정하고, 그 컨테이너의 최대 높이에서 주소창 점유분을 미리 빼뒀다.

#game-container {
  width: 100%;
  height: 100dvh;
  max-height: calc(100dvh - 60px);
}

Phaser 설정에서 부모 엘리먼트를 이 컨테이너로 지정하면, 엔진은 window 크기 대신 컨테이너 크기를 인식해 원본 비율을 유지한 채 자동으로 축소한다. 컨테이너가 최악의 경우 예상보다 훨씬 낮아질 수 있어 스케일 최소 높이값도 함께 낮춰야 했다.

cham3: CSS flex가 실기기에서 어긋난 사건

cham3(참참참)는 웹캠으로 고개·손 방향을 인식해 컴퓨터와 겨루는 Svelte 게임이다. 캔버스 엔진 없이 순수 CSS flex로 레이아웃을 짠다는 점이 burger-stack과 갈리는 지점이다.

레이아웃: 9종 깨짐과 실기기 재조정

사용자가 제공한 모바일 스크린샷 한 장으로 전면 재점검이 시작됐고, HUD 가로 넘침·타이틀과 HUD 겹침·카메라 토글 카드 잘림 등 총 9종의 깨짐을 식별했다. 그중 하나가 safe-area·dvh 전무였다 — 기존 레이아웃 최상위 컨테이너가 주소창을 포함한 영역에 고정돼 있어, 하단은 주소창 뒤로 가려지고 상단 노치는 HUD를 밀어내고 있었다. 이 컨테이너 한 곳에 100dvh와 safe-area 여백을 함께 적용하고, breakpoint도 하나로 통일했다.

  • HUD: 2행 분리로 시작했으나 실기기에서 다시 1행으로 되돌리고 칩 패딩·폰트만 줄였다.
  • 입력 버튼 영역: “라벨만 숨기는” 컴팩트화(Plan A)로 시작했지만 세로 공간 압박이 실기기에서도 남아 완전히 숨기는 방식(Plan B)으로 넘어갔다.
  • 출렁임 방지: 상태 문구가 바뀔 때 높이가 같이 바뀌며 카메라 영역이 출렁이던 걸, 문구 영역 높이를 고정값으로 못박아 없앴다.

DevTools 검증은 통과했지만 실기기에 연결하자 목업과 어긋난 지점이 이렇게 다시 나왔다.

블러 버그: 게이트는 열렸는데 그림이 안 그려진다

레이아웃과는 별개로, 배경 블러 토글이 모바일에서만 안 먹힌다는 제보가 들어왔다. 블러 활성 조건은 여러 변수의 조합이었는데, 그중 딱 하나가 모바일과 데스크톱을 가르는 변수였다. 적응형 프레임 레이트 로직이 추론 소요시간만으로 단계를 올리는데, 모바일은 기준 추론 속도가 느려 사실상 항상 최저 단계에 고정돼 있었다 — 저사양 폴백이 모바일에서 상시 발동해 블러를 막고 있었던 것이다.

1차 수정으로 matchMedia('(pointer: coarse)')를 이용해 터치 기기를 판별하고, 이 판별값으로 저사양 게이트를 우회시켰다. 그런데 실기기에서 진단용 표시를 붙여 확인하니 블러 활성 조건은 이미 참이었는데도 화면엔 여전히 블러가 없었다. 가설을 고쳤는데 증상은 그대로였다는 뜻이고, 원인은 다른 곳에 있었다.

진짜 원인은 Canvas 2D의 필터 속성이 iOS Safari에서 속성 자체는 받아들이지만 이미지 그리기 렌더링에는 적용되지 않는다는 것이었다. 온도조절기 다이얼을 원하는 온도로 돌렸는데 정작 히터는 안 켜지는 상황과 닮았다 — 설정값은 정상 수용됐는데 그 값이 실제 출력으로 이어지지 않는 조용한 실패다. 해결은 캔버스 필터를 버리고 <video> 엘리먼트에 CSS 필터를 직접 거는 방식으로 바꾸는 것이었다.

.feed.blurred {
  filter: blur(12px);
}

캔버스는 인물 컷아웃만 그리도록 단순화했고, Canvas 2D 필터는 완전히 폐기했다. 원인 확정에 쓴 진단 표시는 확정 후 전량 제거했다.

체크리스트: 모바일 뷰포트 대응 재사용 목록

burger-stack과 cham3는 서로 다른 실패 모드(엔진 스케일링 vs CSS 필터 렌더링)를 겪었지만, 겹치는 결론은 하나로 묶인다.

  1. 100dvh는 시작점이지 종점이 아니다. 주소창 점유분이나 화면 고정 레이아웃의 large-viewport 문제는 dvh만으로 안 풀린다. burger는 컨테이너 최대 높이로 점유분을 미리 빼는 방식으로, cham3는 최상위 컨테이너에 safe-area 여백을 얹는 방식으로 각자 풀었다.
  2. viewport-fit=cover는 safe-area의 전제조건이다. 순서를 바꾸면 safe-area 변수 자체가 안 잡힌다.
  3. 엔진·레이아웃 방식에 따라 스케일링 주체가 다르다. 캔버스 엔진은 “누가 크기를 재는가”가 safe-area 여백과 충돌할 수 있다. 순수 CSS flex라면 정렬 순서와 비율값으로 직접 통제할 수 있지만, breakpoint마다 비중을 다시 맞춰야 한다.
  4. iOS Safari는 매번 다른 방식으로 뒤통수를 친다. burger에서는 높이 폴백을 걸 엘리먼트가 미묘했고, cham3에서는 Canvas 필터가 속성은 받고 렌더는 무시하는 조용한 실패였다. 둘 다 코드만 보면 맞아 보이는 상태였다.
  5. DevTools 반응형 모드는 필요조건이지 충분조건이 아니다. cham3는 목업·DevTools 기준을 다 통과하고도 실기기 라운드에서 HUD 행수·입력 버튼 표시 여부·블러 렌더링까지 다시 손봐야 했다. burger는 실기기 검증을 필수로 두지 않았다는 차이가 있다 — 좋고 나쁘고의 문제라기보다, 실측 리스크가 이미 지목된 영역이면 실기기를 건너뛰지 말라는 신호로 남는다.
  6. 디버깅이 막히면 화면에 값을 직접 찍어라. cham3의 블러 버그는 임시 진단 표시로 “게이트는 열렸는데 왜 안 보이나”를 실기기에서 관측했고, 그 값으로 가설을 갈아치웠다.

즉, 모바일 뷰포트 대응은 100dvh 한 줄로 끝나는 단발성 작업이 아니라, 단위 교체 → 전제조건 확인 → 실기기 검증 세 단계를 다 거쳐야 완결되는 절차라는 게 두 프로젝트가 독립적으로 도달한 같은 결론이다.

목차