# mobile-app-ux — 모바일 앱 수준 UX 표준 데스크톱 웹앱을 폰에서 "앱처럼" 쓸 수 있게 만들 때 읽는다. 브리프에 "모바일 앱 수준", "iOS/Android 둘 다", "PWA" 같은 말이 있으면 **구현 전에 이 문서를 먼저 읽는다.** 기준은 취향이 아니라 플랫폼 가이드라인(HIG·M3)과 실측 사고다. ## UX 변경 전 조사 규칙 **UX 패턴을 바꾸기 전에 반드시 근거를 찾는다.** "내 생각엔 이게 낫겠다"로 내비 구조·뒤로가기·시트 같은 OS 레벨 관습을 바꾸면 사용자는 앱을 익히는 순간 배움이 무효가 된다. 순서: 1. 공식 가이드(HIG·M3·NN/g)에서 해당 패턴의 규범을 찾는다 2. 규범이 없으면 실제 앱 2~3개의 동작을 확인한다 3. 그래도 없으면 브리프에 물어서 정한다 — **기본값으로 OS 관습을 어기지 않는다** 실측 사고: 데스크톱 중앙 모달을 그대로 폰에 두면 하단 콘텐츠가 손닿지 않는 영역에 있고, 뷰 전환마다 히스토리가 쌓이지 않아 하드웨어 뒤로가기가 앱을 **통째로 종료**했다. 둘 다 "웹에서는 그랬다"는 관습을 모바일에 그대로 옮긴 실패다. ## 5축 요약 — iOS(HIG) vs Android(M3) vs 웹 구현 | 축 | iOS (HIG) | Android (M3) | 웹 구현 | |---|---|---|---| | 하단 내비 목적지 | 탭바 **5~6 이하** | 바텀내비 **3~5** | 초과분은 "더보기" 시트로 | | 터치 타깃 | 44×44pt | 48×48dp | coarse 포인터에서 44px 최소, 인라인 링크는 24px | | safe area | 노치·홈 인디케이터 회피 | 제스처 바 회피 | `viewport-fit=cover` + `env(safe-area-inset-*)` | | 입력 확대 | 포커스 시 16px 미만이면 확대 | — | `@media (max-width:680px)` 입력 16px | | 뒤로가기 | 스와이프 백 = 히스토리 | **하드웨어 백 = 히스토리** | `pushState`/`popstate` (아래 패턴) | 두 OS가 같은 것: 하단 시트는 그래버가 있고 썸존(화면 하단 35%)에서 열린다. 제스처 내비 예측 영역(가장자리)에 컨트롤을 두지 않는다. ## 구현 패턴 ### safe area ```html ``` ```css :root { --safe-top: env(safe-area-inset-top, 0px); /* 하·좌·우 동일 */ } .bottom-nav { padding-bottom: max(8px, var(--safe-bottom)); } .appbar { padding-top: max(0px, var(--safe-top)); } ``` `env()` 는 `viewport-fit=cover` 없이는 0으로 굳는다. 패딩에 더할 때는 `max(기존값, safe)` — safe가 0인 기기에서 기존 리듬이 살아야 한다. **주의: 토큰 블록(`:root {}`) 안에 넣어야 한다.** 블록 밖에 부착하는 실수가 있었다(무시되고 조용히 죽는다). ### iOS 입력 확대 방지 포커스된 입력이 16px 미만이면 iOS 사파리가 자동 확대한다. 확대는 컨트롤러가 아니라 뷰포트를 밀어 레이아웃을 흔든다. ```css @media (max-width: 680px) { input, select, textarea, #q, .note-form input { font-size: 16px; } } ``` **`maximum-scale=1` 로 잡지 마라.** 확대를 막는 건 WCAG 1.4.4 위반이다. 폰트 크기로만 잡는다. ID 선택자가 폰트를 `font: inherit`으로 정의한 경우 같은 명시도를 파일 뒤에 붙여 이겨야 한다(실측: `#q`가 미디어쿼리를 이겨 확대가 살아있었다). ### 하단 내비 + 더보기 시트 목적지가 5개 이하면 바텀내비에 전부 노출한다. 넘으면: **4개 고정 + 더보기**로 나누고, 나머지는 바텀 시트에 그룹 헤더를 달아 노출한다. - 항목: `min-height: 52px` 이상, 라벨+아이콘 또는 라벨만, 활성 표시는 색+`aria-current="page"` - 더보기 소속 뷰가 떠 있을 때는 **더보기 탭이 활성**이어야 한다(사용자가 자기 위치를 안다) - 시트: 그래버(36×4px), `max-height: 92dvh`, `border-radius`는 위쪽만, 등장은 `sheet-up` 모션(≤ 감사 도구 대기시간 — audit-gate 하니스 규칙 10) - 시트 항목도 `min-height: 48px` ### 뷰 전환은 스크롤을 리셋하고, 헤더는 하나다 **탭을 바꾸면 새 화면은 맨 위에서 시작한다.** 뷰 전환 함수 마지막에 `window.scrollTo(0, 0)` 을 넣어라 — `focus({ preventScroll: true })` 만으로는 부족하다. 실측 사고: 이전 뷰의 스크롤이 남은 채 탭을 바꾸면, 새 뷰가 짧을 때 브라우저가 스크롤을 클램프해 헤더가 **반쯤 잘린 채** 뜨고, 길면 화면 중간에 떨어진다. 뷰마다 크롬 위치가 달라져 "상단 바가 왔다갔다한다"는 보고로 나타났다. **모바일 헤더는 한 줄이다.** 데스크톱의 브랜드 바(사이드바 상단)와 화면 크럼 바를 좁은 화면에 그대로 쌓으면 90px+ 크롬이 콘텐츠를 옥에 끼운다. 하나를 고른다(크럼이 브랜드명을 이미 담고 있다면 브랜드 줄을 숨긴다) — 고른 헤더는 `position: sticky; top: 0` + safe-top 패딩으로 스크롤 내내 상단에 남는다. 검증은 E2E 로: 300px+ 스크롤 상태에서 탭 전환 → `scrollY === 0`·헤더 `top === 0`. ### 하드웨어 뒤로가기 = 앱 내 히스토리 (핵심 패턴) 안드로이드 백 키와 iOS 스와이프 백이 **앱을 종료하지 않고 앱 내 뒤로** 작동해야 한다. SPA 뷰 전환마다 `history.pushState` 로 스택을 쌓고 `popstate` 로 소비한다. 다이얼로그도 같은 스택에 태운다: ```js const DIALOGS = ["stu-dialog", "as-dialog", "more-dialog"]; let suppressPop = false; // close 이벤트가 history.back() 유발 → popstate 무시 let popClosing = false; // popstate가 다이얼로그를 닫는 중 → close의 back() 무시 const openDialog = (dlg) => { if (dlg.open) return; history.pushState({ v: currentView(), d: dlg.id }, ""); dlg.showModal(); }; DIALOGS.forEach((id) => { const dlg = document.getElementById(id); dlg.addEventListener("close", () => { if (popClosing) return; if (history.state && history.state.d === dlg.id) { suppressPop = true; history.back(); } }); }); let pendingMoreSelect = null; // 더보기 시트: 선택은 back() 후 push로 이어진다 window.addEventListener("popstate", () => { if (suppressPop) { suppressPop = false; return; } const s = history.state || {}; popClosing = true; DIALOGS.forEach((id) => { const d = document.getElementById(id); if (d.open && s.d !== id) d.close(); }); popClosing = false; if (s.v) setCurrentView(s.v); if (pendingMoreSelect) { const v = pendingMoreSelect; pendingMoreSelect = null; gotoView(v, true); } }); ``` - 닫기(Esc·backdrop·close 버튼)는 `dlg.close()` 대신 `history.back()` 경로로 — 스택이 남지 않게 - 더보기 시트에서 항목 선택: `pendingMoreSelect = view; history.back();` → popstate가 시트를 닫고 뷰 전환까지 마무리 - 진입 시 `history.replaceState({ v: initialView }, "")` 로 스택 바닥을 명시한다 - **검증은 진짜 백 키로**: 에뮬레이터 `adb shell input keyevent 4` 후 화면 상태를 텍스트 근거로 단언 (아래 "실기기 검증") ### 터치·스크롤 위생 ```css html { -webkit-tap-highlight-color: transparent; } body { overscroll-behavior-y: none; } /* 페이지 전체가 통째로 튕기는 것 방지 */ :is(button, a, input, select, textarea) { touch-action: manipulation; } /* 더블탭 줌 지연 제거 */ ``` ## PWA 최소 세트 "앱처럼"의 하한線: `manifest.webmanifest`(name·short_name·display: standalone·theme_color·아이콘 192/512) + `apple-touch-icon` + `mobile-web-app-capable` 메타 2종. 아이콘은 SVG에서 sharp로 PNG를 뽑는다(브랜드 문양 — 장부 문항, 시간표 블록 같은 **도메인 은유**). 홈 화면에 설치하면 아이콘이 곧 브랜드다 — 파비콘 재사용으로 땜빵하지 않는다. ## 실기기 검증 (안드로이드 에뮬레이터) 시뮬레이터가 없는 OS(Windows 등)에서 iOS는 WebKit 엔진(L4)으로 검증하고 문서로 남긴다. 안드로이드는 에뮬레이터에서 진짜 Chrome로: 1. `astro preview --host 127.0.0.1` — **바인딩 주의**: 기본 `localhost` 바인딩은 adb reverse가 닿지 않아 `ERR_EMPTY_RESPONSE`가 뜬다(실측 사고) 2. `adb reverse tcp:PORT tcp:PORT` 후 `am start -a android.intent.action.VIEW -d "http://127.0.0.1:PORT/…" com.android.chrome` 3. 조작은 `input tap x y`·`input keyevent 4`(백) — 좌표는 `uiautomator dump` 의 bounds 중심으로 4. **판정은 텍스트 근거로**: `uiautomator dump` 의 `text="…"` 에서 화면 제목·활성 탭을 읽는다. 비전(모델) 검수는 "백 직후 전환 중 프레임"을 잡는 등 타이밍 오탐이 있다 — 여부 판정은 계측, 비전은 레이아웃 평가에만(audit-gate 하니스 규칙 5) 5. Git Bash(Windows)에서 `/sdcard/...` 인자는 MSYS 경로 변환으로 깨진다 — `export MSYS_NO_PATHCONV=1` 6. 물리 폰이 adb에 붙어 있을 수 있다 — **항상 `-s emulator-XXXX` 로 대상을 한정한다** 실측 검증 세트(전부 텍스트 단언): 렌더(제목·콘텐츠) → 탭 전환(제목·활성 상태) → 백 키(이전 뷰 복귀) → 더보기 시트(그룹·항목 노출) → 시트 선택(대상 뷰) → 백 키(복귀). ## E2E 시나리오 (L6) 와의 연결 이 문서의 패턴들은 시나리오로 검증한다 — `audit-gate.md` 의 L6 계층 참조. 모바일 UX 검증 시나리오 최소 세트: - **백 키/스와이프 백**: 뷰 전환 N번 → 백 N번이 정확히 역순으로 복귀 (양방향) - **시트 수명주기**: 열림 → 선택 → 대상 뷰 → 백 → 시트를 연 뷰 - **터치타깃 스윕**: 전 상호작용 요소 44px(coarse) — 요소별·인라인 별도 기준 - **매트릭스**: 320/390/768 × 앱 전 뷰 — 오버플로·h1·브랜드 ## 출처 - Apple Human Interface Guidelines — Tab bars, Sheets, Layout & safe areas (developer.apple.com/design) - Google Material 3 — Bottom app bar / Navigation bar, 48dp touch targets (m3.material.io) - Nielsen Norman Group — Bottom Sheets UX, Touchscreen Touch Targets (nngroup.com/articles) - MDN — `env(safe-area-inset-*)`, `viewport-fit`, `overscroll-behavior`, `touch-action` - WebAIM/WCAG 1.4.4 — 확대 금지(`maximum-scale=1`)가 접근성 위반인 이유