Notice
Recent Posts
Recent Comments
반응형
관리 메뉴

HM소프트

PWA 풀스크린으로 태블릿 키오스크 만들기 — 주소창 없는 앱처럼 (2) 본문

외주 개발일지

PWA 풀스크린으로 태블릿 키오스크 만들기 — 주소창 없는 앱처럼 (2)

HM소프트 2026. 6. 13. 05:52
반응형

태블릿 키오스크를 PWA 풀스크린 앱처럼 만든 기록. manifest display:fullscreen과 display_override 폴백, 캐싱 없는 최소 Service Worker, beforeinstallprompt 3갈래 설치 유도, Fullscreen API 토글, clamp() 폰트 토큰과 nowrap·ellipsis 가독성, 고정 해상도를 버린 반응형 flex까지 다룬다.

1편에서 이어서 — 이제 "키오스크답게" 만들 차례

1편에서는 여러 추출구를 가진 커피머신의 상태를 WebSocket으로 실시간으로 받아 화면에 반영하는 일을 다뤘다. 기기가 보내는 이벤트를 종류별로 분배하고, 끊기면 재연결하고, 알람을 시각·청각적으로 띄우는 — 말하자면 "데이터가 살아 움직이게" 만드는 작업이었다.

그런데 화면이 살아 움직여도, 그 화면이 브라우저 안에 떠 있으면 키오스크가 아니다. 상단에 주소창이 보이고, 탭이 보이고, 뒤로가기 버튼이 보인다. 매장에 놓인 태블릿을 손님이나 직원이 만지다가 실수로 주소창을 누르면 엉뚱한 페이지로 빠져나간다. 키오스크는 "이 화면 말고 다른 데로 갈 수 없는" 닫힌 경험이어야 한다.

이번 편은 그 닫힌 경험을 만든 기록이다. 크게 두 갈래다. 하나는 PWA로 풀스크린 앱처럼 띄우는 것 — 주소창과 브라우저 UI를 없애는 일. 다른 하나는 태블릿 화면의 가독성을 잡는 것 — PC 웹 감각으로 만들면 10인치 화면에서 글자가 작고 답답해지는 문제를 토큰 단위로 푸는 일이다. 둘 다 "작아 보이지만 안 하면 키오스크가 아닌" 작업들이었다.

먼저 분명히 해 둘 게 하나 있다. 이 UI는 임베디드 기기에서 웹으로 서빙되는 태블릿 화면이고, 데모·검증 단계의 빌드다. 그래서 모든 결정의 기준은 "운영 환경의 완벽함"이 아니라 "태블릿에 띄웠을 때 진짜 앱처럼 보이는가"였다. 그 기준이 PWA 설계의 디테일을 거의 다 결정했다.

풀스크린은 왜 이렇게 어려운가 — 브라우저의 벽

처음에 가장 순진하게 생각했던 건 "페이지 로드되면 자동으로 풀스크린으로 만들면 되지 않나"였다. 안 된다. 브라우저는 보안상 사용자 제스처(탭·클릭) 없이 풀스크린 진입을 허용하지 않는다. 페이지가 로드되자마자 requestFullscreen()을 부르면 그냥 무시되거나 예외가 난다. 광고 사이트가 멋대로 전체화면을 점령하는 걸 막으려는 정책이라, 우회할 방법이 없다.

그래서 풀스크린을 얻는 경로를 두 갈래로 나눴다.

첫째 경로는 PWA 설치다. 한 번 홈 화면에 추가해 두면, 그 아이콘으로 들어올 때 브라우저가 manifest에 적힌 표시 모드를 보고 처음부터 풀스크린으로 띄운다. 사용자 제스처(아이콘 탭) 자체가 진입점이라 보안 정책에 걸리지 않는다. 이게 키오스크의 정공법이다 — 매장에 설치할 때 한 번만 "홈 화면에 추가"를 해 두면 그 뒤로는 영원히 앱처럼 뜬다.

둘째 경로는 브라우저로 직접 들어온 사람을 위한 차선책이다. 아직 설치하지 않았다면 상단바의 전체화면 토글 버튼을 한 번 누르게 한다. 이 "한 번의 탭"이 바로 브라우저가 요구하는 사용자 제스처라서, 그 순간 requestFullscreen()이 통한다.

이 두 경로를 머릿속에 그려 놓고 나니, 코드가 자연스럽게 세 조각으로 나뉘었다. manifest(설치 후 풀스크린을 결정), install.js(설치를 유도), fullscreen.js(설치 안 한 사람용 토글). 아래에서 하나씩 본다.

manifest — display는 standalone이 아니라 fullscreen

PWA를 처음 만들 때 보통 display: standalone을 쓴다. 주소창은 없애되 상단 상태바(시계·배터리)는 남기는 모드다. 일반 앱에는 그게 맞다. 하지만 키오스크는 상태바조차 없어야 한다. 손님 앞에 놓인 화면에 안드로이드 시계와 배터리 아이콘이 떠 있으면 그건 "남의 태블릿"처럼 보인다. 그래서 displayfullscreen으로 올렸다.

이 짧은 JSON에 의도가 여럿 박혀 있다.

display: fullscreen + display_override. fullscreen은 비교적 최근의 표시 모드라 일부 브라우저가 무시할 수 있다. 그래서 display_override["fullscreen", "standalone"]을 줘서 우선순위 폴백을 명시했다. "가능하면 fullscreen, 안 되면 standalone, 그것도 안 되면 일반"으로 단계적으로 떨어진다. 한 줄이 명시되어 있으면 풀스크린을 지원하는 기기에서는 최대치를, 아닌 기기에서는 차선을 자동으로 고른다.

start_url: ./main/. 설치된 아이콘으로 들어오면 루트가 아니라 메인 화면으로 바로 진입한다. 키오스크의 "기본 화면"이 곧 추출 모니터링 메인이기 때문이다. scope: ./ 로 앱의 영역을 사이트 전체로 묶어, 어느 하위 경로로 이동해도 PWA 프레임을 벗어나지 않게 했다.

orientation: landscape. 매장 태블릿은 가로로 거치된다. 세로로 돌아가면 5개 추출구 카드 레이아웃이 무너지므로, 방향을 가로로 고정했다.

상대 경로(./)로 적은 이유. start_urlscope/main/이 아니라 ./main/처럼 상대 경로로 적었다. 이 빌드는 호스팅 환경에 따라 경로 앞에 prefix가 붙을 수 있어서, 절대 경로로 박아 두면 prefix 환경에서 manifest가 엉뚱한 주소를 가리킨다. 상대 경로면 manifest 파일이 놓인 위치를 기준으로 해석되므로 어느 환경에 올려도 그대로 동작한다. 이건 뒤(3편 배포 편)에서 더 크게 부딪힐 prefix 문제의 첫 신호이기도 했다.

Service Worker — 캐싱은 안 깔았다, 일부러

PWA를 설치 가능하게 만들려면 manifest만으로는 부족하다. 브라우저(특히 안드로이드 Chrome)가 "설치 가능"으로 판정하려면 fetch 핸들러를 가진 Service Worker가 등록되어 있어야 한다. 그게 설치 가능성(installability) 판정의 한 조건이다.

그런데 Service Worker라고 하면 보통 "오프라인 캐싱"을 떠올린다. 정적 자원을 캐시에 담아 두고, 네트워크가 없을 때 캐시에서 꺼내 주는 그 역할. 나는 그걸 일부러 넣지 않았다.

fetch 핸들러는 있지만, 하는 일은 요청을 그대로 네트워크로 흘려보내는 것뿐이다. 캐시를 조회하지도, 저장하지도 않는다. 왜 이렇게 했나.

이 빌드는 데모·검증 단계라 자주 갱신된다. 캐싱을 깔면 가장 흔히 밟는 지뢰가 "옛날 화면이 계속 뜨는" stale-cache 문제다. 코드를 고쳐 올렸는데 태블릿에는 어제 캐시된 화면이 그대로 떠서, "왜 안 바뀌지?"로 한참을 헤맨다. Service Worker 캐시는 한 번 꼬이면 디버깅이 까다롭다. 검증 단계에서는 항상 최신 화면이 뜨는 것이 캐시로 얻는 오프라인 내성보다 훨씬 중요했다. 그래서 "설치 가능성을 위한 fetch 핸들러"라는 형식 요건만 충족하고, 실제 캐싱 정책은 비워 뒀다.

skipWaiting()clients.claim()도 같은 맥락이다. 기본 동작이라면 새 Service Worker는 기존 것이 제어하던 탭이 다 닫힐 때까지 "대기" 상태로 기다린다. 그러면 빌드를 갱신해도 다음 방문까지 반영이 안 된다. 이 둘을 넣어 새 SW가 곧바로 활성화되고 즉시 모든 탭을 넘겨받게 했다. 빌드 즉시 반영 — 검증 단계의 미덕이다. 주석에도 적어 뒀듯이, 운영에서 오프라인 지원이 필요해지면 그때 이 fetch 핸들러 안에 캐싱 전략을 채워 넣으면 된다. 자리는 비워 두되, 미래의 확장 지점은 명시해 둔 셈이다.

install.js — 설치를 "스스로 권하는" 배너

manifest와 SW를 갖췄으면 이제 사용자에게 "홈 화면에 추가하세요"를 권해야 한다. 그런데 이게 플랫폼마다 천차만별이다. 안드로이드 Chrome은 beforeinstallprompt라는 이벤트로 설치 프롬프트를 띄울 수 있지만, iOS Safari에는 그런 API가 아예 없다. 그래서 install.js는 세 갈래로 분기한다.

핵심 로직 몇 조각을 보자. 안드로이드의 beforeinstallprompt이벤트가 오는 순간 가로채서 보관해 둬야 한다. 브라우저 기본 미니 인포바를 그대로 두면 우리 디자인과 따로 노니까, preventDefault()로 막고 이벤트 객체를 변수에 저장한다. 그리고 사용자가 우리 배너의 버튼을 눌렀을 때 비로소 그 저장해 둔 객체로 프롬프트를 띄운다.

deferredPrompt를 한 번 prompt()하면 재사용할 수 없어서, 호출 후 null로 비운다. 사용자가 설치를 수락하면 appinstalled 이벤트가 따로 날아오므로 그때도 배너를 정리한다.

iOS는 이 이벤트가 영영 오지 않는다. 그래서 userAgent로 iOS를 감지하면 버튼 없이 안내 텍스트만 띄운다 — "공유 → 홈 화면에 추가를 누르세요." 클릭으로 설치를 트리거할 방법이 없으니 사람에게 수동 절차를 알려 주는 수밖에 없다.

안드로이드인데 beforeinstallprompt가 4초가 지나도 안 오는 경우(이미 설치됐거나, 설치 기준을 못 채웠거나)를 대비해 타임아웃 폴백도 뒀다. 4초 후에도 프롬프트가 없으면 generic 안내 배너를 띄운다. 이벤트 기반 로직은 "이벤트가 안 오는 경우"를 항상 같이 설계해 둬야 한다는 걸, 이 4초 타이머가 보여 준다.

// 안드로이드 — 이벤트 안 오는 케이스 대비 4초 폴백
setTimeout(function () {
  if (!deferredPrompt && !isStandalone() && !wasDismissed()) {
    showBanner('generic');
  }
}, 4000);

마지막 디테일 하나. 사용자가 배너를 닫으면 localStorage에 "닫음" 플래그를 남겨 다시 띄우지 않는다. 설치를 원치 않는 사람에게 매번 같은 배너를 들이미는 건 키오스크가 아니라 광고다. isStandalone()(이미 풀스크린인지)과 wasDismissed()(닫은 적 있는지) 두 가드를 배너를 띄우는 모든 경로 앞에 똑같이 걸어, "이미 앱처럼 쓰는 사람"과 "거절한 사람"에게는 절대 안 뜨게 했다.

배너의 표시 모드 감지는 CSS 미디어 쿼리를 JS로 읽는 방식이다.

function isStandalone() {
  return (window.matchMedia && (
    window.matchMedia('(display-mode: fullscreen)').matches ||
    window.matchMedia('(display-mode: standalone)').matches ||
    window.matchMedia('(display-mode: minimal-ui)').matches
  )) || window.navigator.standalone === true;  // iOS home-screen webapp
}

display-mode 미디어 쿼리는 manifest의 display와 짝을 이룬다. 풀스크린이든 standalone이든 minimal-ui든 "브라우저 UI가 줄어든 상태"면 전부 "이미 설치된 것"으로 간주한다. iOS는 표준 미디어 쿼리 대신 navigator.standalone이라는 비표준 속성을 쓰므로 그것도 OR로 묶었다. 한 함수 안에 세 플랫폼의 풀스크린 판정이 다 들어 있다.

fullscreen.js — 설치 안 한 사람을 위한 한 번의 탭

PWA를 설치하지 않고 그냥 브라우저로 들어온 사람에게는, 상단바 우측에 전체화면 토글(⛶) 버튼을 둔다. 앞서 말했듯 버튼 탭이라는 사용자 제스처가 있어야 requestFullscreen()이 통하기 때문이다.

// fullscreen.js — 진입 시 시스템 내비게이션까지 숨기기 시도
function enterFullscreen() {
  var el = document.documentElement;
  var req = el.requestFullscreen
    || el.webkitRequestFullscreen     // 구형 WebKit
    || el.msRequestFullscreen;        // 구형 Edge/IE
  if (req) {
    // navigationUI: hide — 안드로이드에서 시스템 내비바까지 숨기려는 시도
    try { req.call(el, { navigationUI: 'hide' }); }
    catch (e) { req.call(el); }       // 옵션 미지원 브라우저는 인자 없이 폴백
  }
}

두 가지를 챙겼다. 하나는 벤더 프리픽스 폴백이다. 태블릿 웹뷰는 최신 크롬만 있는 게 아니라서 requestFullscreen / webkitRequestFullscreen / msRequestFullscreen을 순서대로 시도한다. 임베디드 기기는 종종 구형 웹뷰를 쓰기 때문에 프리픽스를 빼면 풀스크린이 아예 안 되는 기기가 생긴다.

다른 하나는 navigationUI: 'hide' 옵션이다. 안드로이드는 풀스크린이어도 하단 시스템 내비게이션 바(뒤로·홈·최근)가 남는 경우가 있는데, 이 옵션으로 그것까지 숨기려 시도한다. 다만 모든 브라우저가 이 옵션을 받지는 않아서, try/catch로 감싸 옵션을 거부하면 인자 없이 다시 호출한다. "최대치를 시도하되 안 되면 조용히 차선으로"가 이 코드 전체의 패턴이다.

그리고 이미 PWA로 설치돼 풀스크린으로 실행 중이면, 이 토글 버튼 자체를 숨긴다. 이미 전체화면인데 "전체화면 버튼"이 떠 있으면 혼란스럽다. isStandalone()이 참이면 버튼을 hidden 처리하고 끝낸다 — install.js와 정확히 같은 판정 함수를 공유해서, "설치된 사용자"의 정의가 두 파일에서 어긋날 일이 없다.

function init() {
  var btn = document.getElementById('btn-fullscreen-top');
  if (!btn) return;
  if (isStandalone()) { btn.hidden = true; return; }  // PWA면 토글 무의미
  btn.hidden = false;
  btn.addEventListener('click', function () {
    if (getFsElement()) exitFullscreen();
    else enterFullscreen();
  });
  // ESC 등으로 빠져나갔을 때 아이콘(들어가기/나가기) 동기화
  ['fullscreenchange', 'webkitfullscreenchange', 'msfullscreenchange']
    .forEach(function (ev) {
      document.addEventListener(ev, function () { syncIcons(btn); });
    });
}

마지막 한 조각은 아이콘 동기화다. 사용자가 버튼이 아니라 ESC 키나 시스템 제스처로 풀스크린을 빠져나갈 수도 있다. 그러면 버튼 아이콘만 "나가기" 상태로 남아 실제와 어긋난다. 그래서 fullscreenchange 계열 이벤트를 듣고 있다가, 상태가 바뀔 때마다 아이콘(들어가기 ⛶ / 나가기)을 실제 풀스크린 여부에 맞춰 다시 그린다. 이것도 프리픽스 세 종류를 다 듣는다. 1편의 WebSocket에서 "기기 상태 → 화면 반응"을 1:1로 맞췄던 것과 같은 발상이다 — 실제 상태와 화면 표시를 절대 어긋나게 두지 않는다.

메타 태그 — 풀스크린의 마지막 1cm

manifest·SW·JS가 풀스크린의 골격이라면, 마지막 마감은 HTML <head>의 메타 태그들이다. 풀스크린 PWA에서 화면을 끝까지(노치·둥근 모서리 영역까지) 쓰려면 뷰포트 설정이 받쳐 줘야 한다.

<!-- head 메타 — 풀스크린/안전영역 대응 -->
<meta name="viewport"
      content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<link rel="manifest" href="/manifest.json">
<meta name="theme-color" content="#C5A059">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="추출 모니터">
<link rel="apple-touch-icon" href="/img/app-icon.svg">

viewport-fit=cover가 핵심이다. 이게 없으면 노치나 둥근 모서리가 있는 기기에서 화면 가장자리에 빈 여백이 생긴다. cover로 두면 콘텐츠가 안전영역 밖까지 꽉 차고, 필요한 곳에서는 CSS의 env(safe-area-inset-*)로 여백을 되돌릴 수 있다. iOS 계열은 표준 manifest를 일부만 따르므로 apple-mobile-web-app-* 메타를 따로 줘서, iOS에서 홈 화면에 추가했을 때도 풀스크린 웹앱으로 뜨고 상태바가 콘텐츠 위로 반투명하게 겹치게(black-translucent) 했다. theme-color는 풀스크린 진입 전 잠깐 보이는 브라우저 크롬 색을 앱 색(골드)과 맞춰, 전환 순간의 이질감을 줄인다.

표준 PWA(manifest)와 iOS 전용 메타를 둘 다 넣어야 한다는 게 핵심이다. 어느 한쪽만으로는 모든 태블릿을 못 덮는다.

태블릿 가독성 — 토큰 하나로 전부 키우기

여기서부터는 풀스크린과 별개의 싸움, 가독성이다. 태블릿 키오스크는 PC 모니터처럼 30cm 앞에서 보는 게 아니다. 손가락으로 만지고, 한두 걸음 떨어져서 본다. PC 웹 감각으로 14px·16px 글자를 깔면 10인치 화면에서는 작고 답답하다.

이걸 화면마다 일일이 키우는 건 지옥이다. 그래서 폰트 크기를 디자인 토큰(CSS 변수)으로 추상화하고, 그 토큰의 값만 태블릿용으로 올렸다. 각 화면은 px가 아니라 토큰을 참조하므로, 토큰 한 줄만 바꾸면 전부 같이 커진다.

clamp(최소, 선호, 최대)를 쓴 게 핵심이다. 단순히 px를 키우면 작은 화면에서 글자가 넘치고, vw(뷰포트 비례)만 쓰면 큰 화면에서 무한정 커진다. clamp는 이 둘을 묶는다 — 작은 화면에서는 최소값을 보장하고, 화면이 커질수록 0.8vw + 10px처럼 비례해서 커지다가, 일정 크기 이상에서는 최대값에서 멈춘다. 가운데 vw 항이 있어서 화면 크기에 따라 글자가 부드럽게 따라 자란다. 10인치 태블릿과 14인치 노트북에서 같은 화면을 띄워도 둘 다 "적당히 큰" 상태가 된다.

그리고 max 값을 의도적으로 3~4px 올렸다. PC 웹의 기본 본문이 보통 15~16px인데, 태블릿용 --fs-base의 상한을 19px까지 끌어올렸다. 멀찍이서 보는 화면에서 이 몇 px이 "읽힌다 / 답답하다"를 가른다.

--tap-min: 48px도 같은 가독성의 일부다. 손가락으로 누르는 화면에서 버튼이 작으면 오작동한다. 접근성 권고치(44px)에 안전 여유 4px을 더해 48px을 모든 버튼의 최소 높이로 못 박았다. 뒤에 나올 설치 배너의 버튼·닫기 영역이 전부 이 토큰을 참조한다.

좁은 폭에서 글자가 깨지는 것들 — nowrap과 ellipsis

토큰으로 전체를 키우고 나니, 이번에는 반대 방향의 문제가 드러났다. 글자를 키우니 좁은 영역에서 텍스트가 줄바꿈되거나 칸을 넘쳤다. 특히 두 군데가 문제였다.

하나는 상단바의 통계 라벨이다. "추출 24회 · 가동 6시간" 같은 짧은 통계가 폭이 좁아지면 어중간하게 줄바꿈돼 레이아웃이 깨졌다. 숫자와 단위가 두 줄로 찢어지면 한눈에 안 들어온다. 그래서 통계 묶음에 white-space: nowrap을 걸어 절대 줄바꿈되지 않게 했다.

/* theme — 상단바 통계는 한 줄 고정, 슬롯명은 말줄임 */
/* 상단바 통계 — 숫자/단위가 줄바꿈되지 않게 */
.tb-stat,
.tb-stat .tb-lbl,
.tb-stat .tb-val { white-space: nowrap; }

/* 즐겨찾기 슬롯의 레시피명 — 길면 '...'으로 자르기 */
.slot-recipe {
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

다른 하나는 즐겨찾기 슬롯의 레시피명이다. 추출구마다 자주 쓰는 레시피를 슬롯에 등록해 두는데, 레시피 이름이 길면 슬롯 카드 밖으로 삐져나왔다. 처음엔 "두 줄까지 보여 주고 자를까" 고민했지만, 슬롯 카드의 높이가 고정이라 결국 한 줄로 자르고 말줄임(...) 처리가 가장 깔끔했다. white-space: nowrap + overflow: hidden + text-overflow: ellipsis 세 줄의 고전적인 조합이다. 추출구 이름(.sctrl-name)에도 같은 처리를 해서, 긴 이름이 와도 레이아웃이 절대 밀리지 않게 했다.

여기서 배운 건, 가독성은 "크게"만이 아니라 "넘치지 않게"가 절반이라는 점이다. 키우는 작업(토큰 상향)과 가두는 작업(nowrap·ellipsis)을 같이 해야 비로소 좁은 태블릿 화면이 정돈된다.

고정 1024×768을 버리고 진짜 반응형으로

이 UI의 출발점은 사실 고정 해상도였다. 특정 태블릿의 1024×768에 픽셀을 맞춰 짠 화면. 그런데 매장마다 거치하는 태블릿 크기가 제각각이고, 데모는 노트북 브라우저에서도 봐야 했다. 고정 해상도는 그 순간 무너진다 — 더 큰 화면에서는 한쪽에 빈 공간이, 작은 화면에서는 잘림이 생긴다.

그래서 최상위 컨테이너를 전체 화면을 채우는 flex 레이아웃으로 갈아엎었다.

width: 100% + height: 100vh로 기기 화면을 꽉 채우되, max-width: 1600px로 상한을 둔 게 핵심이다. 키오스크는 보통 태블릿이지만 데모를 큰 모니터에서 띄울 때 콘텐츠가 가로로 끝없이 늘어지면 그것대로 보기 싫다. 그래서 1600px에서 멈추고 가운데 정렬한다. "작은 화면은 채우되, 큰 화면은 적당히"라는 양쪽 끝을 한 규칙으로 처리했다.

그 위에 단계별 미디어 쿼리를 얹어, 화면이 좁아질 때 그리드 열 수를 줄였다.

@media (max-width: 1366px) { .spout-grid { grid-template-columns: repeat(3, 1fr); } }
@media (max-width: 1024px) { .grid-3 { grid-template-columns: repeat(2, 1fr); } }
@media (max-width: 768px)  { .grid-2, .grid-3 { grid-template-columns: 1fr; }
                             .machine-status-bar { flex-wrap: wrap; } }

5개 추출구 카드는 넓은 화면에서는 한 줄에 펼쳐지다가, 폭이 줄면 3열·2열로 접힌다. 통계 바는 좁아지면 flex-wrap으로 다음 줄로 넘어간다. 고정 해상도를 버린 대가로 이런 분기들을 일일이 챙겨야 했지만, 그 덕에 "아무 태블릿에나 띄워도 깨지지 않는" 화면이 됐다.

정보가 한 화면에 안 들어갈 때 — 2탭으로 쪼개기

마지막 사례는 가독성의 또 다른 얼굴이다. 기기 사용 이력(history) 화면에 보여 줄 패널이 5개나 됐다. KPI 요약, 일별 추이, 레시피별 횟수, 요일·시간대 분포, 최근 추출 목록. 태블릿 한 화면에 이 다섯을 다 욱여넣으면 전부 작아져서 아무것도 안 읽힌다. 가독성을 위해 글자를 키웠더니 이번엔 화면이 모자라는 역설이다.

여기서 글자를 다시 줄이는 건 앞에서 한 일을 무르는 것이다. 대신 정보를 계층화했다. 화면을 "한눈에"와 "상세" 2개 탭으로 쪼갰다.

"한눈에" 탭에는 가장 중요한 KPI 4개와 일별 추이만 둔다. 매장 운영자가 화면을 켜자마자 "오늘 몇 잔 나갔나"를 스캐닝 한 번으로 파악할 수 있어야 하므로, 이 탭의 KPI 숫자는 거의 2배로 키웠다.

tabular-nums로 숫자 폭을 고정한 것도 작은 디테일이다. 비례 폰트는 1과 8의 폭이 달라서, 실시간으로 숫자가 바뀌면 KPI가 미세하게 떨린다. 고정폭 숫자로 두면 값이 바뀌어도 자리가 흔들리지 않아 안정적으로 읽힌다.

자세히 들여다볼 정보(레시피별 횟수, 요일·시간대 분포, 최근 추출 목록)는 "상세" 탭으로 미뤘다. 평소엔 안 보다가 필요할 때만 들어가는 정보다. 이 상세 탭은 넓은 화면에서는 좌우 2열로 패널을 배치하다가, 폭이 좁아지면 1열로 접힌다.

핵심은 "한 화면에 다 넣으려다 아무것도 안 보이는" 문제를, 글자를 줄이는 대신 정보를 나눠 풀었다는 점이다. 자주 보는 건 크게 앞에, 가끔 보는 건 한 단계 뒤로. 키오스크의 정보 설계는 "다 보여 주기"가 아니라 "지금 필요한 것만 크게 보여 주기"다.

정리하며

이번 편에서 한 일을 한 줄씩 정리하면 이렇다.

  • 풀스크린은 두 경로로. 브라우저는 자동 풀스크린을 막으므로, ① PWA 설치(아이콘 탭이 곧 제스처) ② 상단바 토글 버튼(탭이 곧 제스처) 두 길을 다 깔았다.
  • manifest는 display: fullscreen + display_override 폴백으로, 키오스크에서 상태바조차 없애되 미지원 기기에서는 standalone으로 우아하게 떨어지게 했다. 경로는 prefix 환경을 대비해 상대 경로로.
  • Service Worker는 캐싱 없이 fetch 패스스루만 — 설치 가능성 요건만 충족하고, 자주 갱신되는 검증 빌드의 stale-cache 함정을 피했다. skipWaiting+claim으로 즉시 반영.
  • install.js는 3갈래 분기(Android beforeinstallprompt / iOS 수동 안내 / generic 폴백)에 4초 타임아웃과 "닫음 기억"을 더해, 설치를 권하되 귀찮게 굴지 않게 했다.
  • fullscreen.js는 벤더 프리픽스 폴백 + navigationUI: hide + 아이콘 동기화로, 구형 웹뷰부터 ESC 이탈까지 화면과 실제 상태를 어긋나지 않게 묶었다.
  • 가독성은 토큰으로. clamp() 기반 폰트 토큰의 상한을 +3~4px 올려 전 화면을 한 번에 키우고, nowrap·ellipsis로 좁은 폭의 깨짐을 가뒀다. 터치 타겟은 48px로 통일.
  • 고정 해상도를 버리고 전체화면 flex(상한 1600px) + 단계별 미디어 쿼리로 10~14인치를 모두 덮었다.
  • 정보가 넘치면 쪼갰다. 이력 화면을 "한눈에 / 상세" 2탭으로 나눠, 자주 보는 KPI는 크게 앞에 두고 나머지는 뒤로 미뤘다.

돌이켜보면 이번 편의 작업들은 죄다 "안 하면 티는 안 나지만, 안 하면 키오스크가 아닌" 것들이었다. 주소창 하나, 몇 px의 글자 크기, 한 줄로 자르는 말줄임 — 각각은 사소하지만 합쳐지면 "브라우저에 띄운 웹페이지"와 "매장에 놓인 전용 기기"의 차이가 된다. 다음 마지막 3편에서는 이 PWA를 실제로 배포하면서 만난 CI·CDN 경로(prefix) 문제와 장애 우회 트러블슈팅을 다룬다. manifest를 상대 경로로 적게 만든 그 prefix가, 배포 단계에서 본격적으로 발목을 잡는 이야기다.

반응형