본문으로 건너뛰기

폼이 내 디자인이 아닙니다 - 웹뷰는 브라우저가 아니다 ep.03

디자이너가 준 시안에는 드롭다운이 있다. 화면에는 OS가 그린 피커가 뜬다. CSS는 한 줄도 먹지 않는다. 이 컨트롤은 처음부터 내 것이 아니었다.

폼 컨트롤만 유독 다른 이유

<div>는 내가 그립니다. <p>도 내가 그립니다. 그런데 <select>는 내가 그리지 않습니다.

폼 컨트롤은 HTML에서 유일하게 선언과 렌더링이 분리된 요소입니다. 웹은 “여기에 선택지가 있다”는 의미만 선언하고, 실제 픽셀은 엔진이 플랫폼에 위임합니다. MDN은 <select>가 기본 상태에서 “브라우저 창 밖으로 렌더되고 모바일 운영체제 내장 컴포넌트를 띄운다”고 적습니다. 창 밖이라는 표현이 정확합니다. 그 UI는 내 문서 트리 안에 없습니다.

이 위임이 브라우저에서는 잘 보이지 않습니다. 데스크톱 브라우저의 드롭다운은 어차피 문서 위에 겹쳐 뜨고, 모양도 크게 튀지 않습니다. 웹뷰에서는 다릅니다. 앱 전체가 하나의 디자인 언어로 통일되어 있는데, 폼 컨트롤만 OS 기본 스타일로 튀어나옵니다. 그래서 “폼이 내 디자인이 아닙니다”라는 제보가 유독 앱에서 올라옵니다.

여기서 두 번째 위임이 겹칩니다. 파일 선택과 자동완성은 엔진이 아니라 호스트 앱에 위임됩니다. 브라우저에서는 벤더가 이미 채워둔 자리인데, 웹뷰에서는 앱이 채워야 합니다. 채우지 않으면 눌러도 아무 일이 없습니다.

폼 컨트롤이 웹뷰에서 네이티브 컴포넌트로 치환되는 경로를 일곱 행으로 정리한 다이어그램. 세로 세 개의 열이 있고 열 제목은 왼쪽부터 '웹이 선언한 것', '웹뷰 경계', '사용자가 실제로 만나는 것'이다. 각 행은 왼쪽에서 오른쪽으로 굵은 화살표로 이어진다. 상자 색은 세 가지로 구분되며 범례가 하단에 있다. 파랑은 '웹이 통제', 회색은 'OS가 통제', 주황은 '앱이 구현해야 통제 가능'을 뜻한다. 1행 select: 왼쪽 파란 상자에 'select 요소 + CSS 규칙', 가운데 회색 관문에 '엔진이 브라우저 창 밖으로 렌더', 오른쪽 회색 상자에 'OS 제공 피커'와 주석 '앱 개입 없음, 엔진 버전이 결과를 결정'. 이 행에는 아래로 갈라지는 조건 분기 화살표가 하나 더 있고 라벨은 'appearance base-select 지원 엔진일 때'이며 파란 상자 '웹이 그린 드롭다운, 접근성 유지'로 이어진다. 2행 날짜 입력: 왼쪽 파란 상자에 'input type=date', 가운데 회색 관문에 '엔진이 type을 아는가', 오른쪽에 회색 상자 'OS 날짜 피커, 표시 형식은 로케일'과 그 아래 파란 상자 '모르면 텍스트 입력 상태로 처리'가 두 갈래로 놓인다. 오른쪽 여백에 'value는 어느 쪽이든 항상 yyyy-mm-dd'라는 주석이 붙는다. 3행 파일 선택: 왼쪽 파란 상자에 'input type=file + accept', 가운데 관문이 좌우로 갈라져 위쪽은 iOS 경로, 아래쪽은 안드로이드 경로다. iOS 경로는 회색 상자 '기본값 켜짐, 델리게이트 없이도 파일 선택 동작'으로 이어지고 옆에 주황 주석 '앱이 runOpenPanelWith를 구현하면 accept 정보가 사라짐'. 안드로이드 경로는 주황 상자 'WebChromeClient.onShowFileChooser'로 이어지고, 미구현일 때를 나타내는 점선 화살표가 X 표시가 붙은 회색 상자 '아무 반응 없음'으로 향한다. 4행 자동완성: 왼쪽 파란 상자에 'autocomplete 속성', 가운데 회색 관문에 'OS 자격증명 저장소 조회', 오른쪽 회색 상자 'iOS QuickType 바 / 안드로이드 자동완성 오버레이'. 안드로이드 쪽에는 주황 조건 라벨 'Chrome 또는 System WebView 61 이상 필요'가 붙는다. 5행 패스키: 왼쪽 파란 상자에 'navigator.credentials 호출', 가운데 주황 관문에 '앱과 도메인이 묶여 있는가', 오른쪽에 회색 상자 'OS 인증 시트'와 점선으로 이어진 회색 상자 '호출이 거절됨'이 두 갈래로 놓인다. 아래에 주황 조건 라벨 'Android는 앱이 opt-in, iOS는 Associated Domains'가 붙는다. 6행 인증번호: 왼쪽 파란 상자에 'autocomplete=one-time-code', 가운데 관문이 다시 두 갈래로 갈라진다. 위쪽은 회색 상자 'iOS: SMS에서 코드 파싱해 QuickType 바에 제안'. 아래쪽은 회색 상자 'Android WebView: WebOTP 미지원'이고 여기서 주황 상자 '앱이 SMS를 받아 웹에 전달하는 경로를 만들어야 함'으로 화살표가 이어진다. 7행 포커스: 왼쪽 파란 상자에 'el.focus() 호출', 가운데 주황 관문에 '웹뷰 자체가 포커스를 갖고 있는가', 오른쪽에 회색 상자 '소프트 키보드 표시'와 점선으로 이어진 회색 상자 '호출이 무시됨'이 두 갈래로 놓인다. 다이어그램 맨 아래에는 가로로 긴 띠가 있고 '왼쪽 열만 웹의 코드다. 가운데와 오른쪽은 요청하거나 조건을 확인하는 영역이다'라는 문장이 적혀 있다.

이 편은 이 그림의 각 행을 하나씩 따라갑니다. 되찾을 수 있는 것과 요청해야 하는 것, 그리고 포기하고 다르게 설계해야 하는 것을 가릅니다.


select은 스타일링이 안 된다는 말은 이제 조건부입니다

오래된 정설이 하나 있습니다. “<select>는 CSS로 스타일링이 안 되니 <div>로 새로 만들어라.” 2026년 8월 기준으로 이 문장은 절반만 맞습니다.

전통적인 select에서 할 수 있는 것

MDN은 <select>에 대해 “역사적으로 CSS로 효과적으로 스타일링하기 어려웠다”고 적고, 가능한 범위를 “박스 모델, 표시되는 폰트 등”으로 한정합니다. 테두리, 배경, 폰트, 패딩까지는 대체로 먹습니다. 열렸을 때 뜨는 목록은 손댈 수 없습니다. 그 목록은 문서가 아니라 OS 컴포넌트이기 때문입니다.

/* 여기까지는 대체로 반영됩니다 */
select {
  appearance: none; /* 기본 화살표 제거 */
  border: 1px solid var(--line);
  border-radius: 8px;
  padding: 12px 36px 12px 14px;
  font: inherit;
  background: url("./chevron.svg") no-repeat right 14px center;
}

appearance: none은 닫힌 상태의 껍데기만 벗깁니다. 열었을 때 뜨는 피커는 그대로입니다.

appearance: base-select

MDN에 따르면 base-select 값은 “<select>가 브라우저 창 밖으로 렌더되거나 내장 모바일 운영체제 컴포넌트를 띄우지 않게 만들고, 가장 넓은 <option> 너비를 기준으로 크기가 정해지지도 않게” 합니다. 즉 드롭다운 전체가 문서 안으로 들어옵니다. 그때부터 ::picker(select), ::picker-icon, ::checkmark 같은 의사 요소로 목록까지 직접 그릴 수 있습니다.

지원 현황은 이렇습니다.

엔진지원 버전조건
Chrome / Edge (데스크톱)135없음
Chrome for Android135없음
Android System WebView135기기에 설치된 WebView 버전을 따름
Safari272026년 8월 기준 베타
Safari iOS / iOS 웹뷰272026년 8월 기준 베타
Firefox149플래그 뒤에 있음

여기가 웹뷰 특유의 지점입니다. 안드로이드 웹뷰의 렌더링 엔진은 Android System WebView라는 별도 컴포넌트라서 기기마다 버전이 제각각입니다. 같은 안드로이드 15라도 어떤 기기는 WebView 140이고 어떤 기기는 128입니다. “내 화면이 지금 몇 번 엔진 위에 있는가”를 확인하는 절차는 ep.01에서 다룹니다.

그리고 Apple 쪽은 아직 정식 출시 전입니다. WebKit이 WWDC26에서 발표한 Safari 27 베타 항목이고, 이 시점에 이미 운영 중인 서비스의 기본 경로로 삼을 수는 없습니다.

그래서 어떻게 쓰는가

opt-in에는 방향 제약이 하나 있습니다. <select>에만 appearance: base-select를 걸고 피커는 OS 기본 스타일로 남기는 조합은 됩니다. 반대로 ::picker(select)만 켜는 것은 안 됩니다. 피커를 켜려면 <select>를 먼저 켜야 합니다. MDN도 대부분의 경우 양쪽 다 거는 편을 권합니다.

/* 미지원 엔진에서는 이 블록이 통째로 무시됩니다 */
@supports (appearance: base-select) {
  select,
  ::picker(select) {
    appearance: base-select;
  }

  ::picker(select) {
    border: 1px solid var(--line);
    border-radius: 12px;
    padding: 6px;
  }

  option:checked::checkmark {
    color: var(--accent);
  }
}

@supports로 감싸는 것은 취향이 아니라 WebKit이 명시한 권고입니다. 미지원 엔진에서는 커스터마이즈용 마크업이 통째로 무시되기 때문입니다.

골든 룰: option 안에 텍스트를 남깁니다

커스터마이즈 가능한 <select>에서는 <option> 안에 아이콘이나 색 스와치 같은 요소를 넣을 수 있습니다. 그리고 미지원 엔진에서는 그 비텍스트 내용이 텍스트 노드로만 남습니다. 아이콘만 넣어두면 그 항목은 빈 줄로 보입니다.

WebKit이 이걸 골든 룰로 정리했습니다. “언제나 option 요소에 텍스트 내용이나 접근 가능한 텍스트 속성을 제공하라.” 시각 장식은 텍스트를 대체하는 것이 아니라 보완해야 합니다.

<select id="payment">
  <option value="card">
    <img src="/icon/card.svg" alt="" />
    <span>신용카드</span>
  </option>
  <option value="mobile">
    <img src="/icon/mobile.svg" alt="" />
    <span>휴대폰 결제</span>
  </option>
</select>

미지원 엔진은 <img>를 버리고 “신용카드”라는 텍스트만 남깁니다. OS 피커에도 그 텍스트가 뜹니다. 지원 엔진은 아이콘과 텍스트를 함께 그립니다. 양쪽 다 작동합니다.


div로 만든 드롭다운의 청구서

디자인 시안이 확정된 채로 내려오는 화면에서 실제로 쓰이는 드롭다운은 대부분 세 번째 선택지입니다. <select>를 아예 버리고 <div><ul>로 새로 만든 컴포넌트입니다. 다중 선택, 검색, 그룹 헤더, 두 줄짜리 항목 같은 요구는 표준 <select>로 표현되지 않으니 어느 시점엔가 이 선택을 하게 됩니다.

이 선택 자체가 잘못은 아닙니다. 다만 표준 요소가 공짜로 주던 것을 전부 직접 만들어야 한다는 청구서가 따라옵니다.

W3C ARIA Authoring Practices Guide의 combobox 패턴이 요구하는 최소 항목은 이렇습니다.

항목요구 사항
입력 요소 역할role="combobox"
팝업 연결aria-controls가 팝업 요소를 가리킬 것
팝업 역할listbox, tree, grid, dialog 중 하나
열림 상태닫힘 aria-expanded="false" / 열림 "true"
활성 항목팝업 항목에 포커스가 가면 aria-activedescendant로 지목
선택 항목aria-selected="true"
자동완성 방식aria-autocompletenone, list, both 중 하나
listbox가 아닌 팝업aria-haspopup 추가

키보드 조작도 함께 옵니다. 콤보박스에서 아래 화살표로 팝업에 진입하고, Esc로 닫습니다. listbox 팝업 안에서는 Enter로 확정하고 닫으며, Esc는 닫고 콤보박스로 포커스를 되돌립니다. 화살표로 항목을 이동하고, Home과 End로 처음과 마지막으로 갑니다.

골자만 마크업으로 옮기면 이렇습니다.

<label id="country-label" for="country-input">거주 국가</label>

<input
  id="country-input"
  role="combobox"
  aria-controls="country-listbox"
  aria-expanded="false"
  aria-autocomplete="list"
  aria-labelledby="country-label"
  readonly
/>

<ul id="country-listbox" role="listbox" aria-labelledby="country-label" hidden>
  <li id="country-opt-kr" role="option" aria-selected="false">대한민국</li>
  <li id="country-opt-jp" role="option" aria-selected="true">일본</li>
</ul>

열고 닫을 때 갱신해야 하는 상태는 세 개입니다.

function open() {
  input.setAttribute('aria-expanded', 'true');
  listbox.hidden = false;
  // 포커스는 input에 두고, 활성 항목만 id로 가리킵니다
  input.setAttribute('aria-activedescendant', activeOption.id);
}

function close(returnFocus) {
  input.setAttribute('aria-expanded', 'false');
  input.removeAttribute('aria-activedescendant');
  listbox.hidden = true;
  if (returnFocus) input.focus();
}

포커스를 <li>로 옮기지 않고 aria-activedescendant로 가리키는 방식이 APG 패턴의 핵심입니다. 실제 포커스는 계속 콤보박스에 남아 있어야 키보드 입력이 한곳에서 처리됩니다.

여기까지 만들었다면 접근성은 지켰습니다. 그리고 다음 절부터 나오는 문제들은 이 컴포넌트로도 해결되지 않습니다. 자동완성과 OS 자동입력은 표준 요소에만 붙기 때문입니다.


날짜와 키보드는 type이 결정합니다

input type=“date”

날짜 입력은 웹뷰에서 OS 날짜 피커로 치환됩니다. MDN은 두 가지를 명시합니다. 첫째, “날짜 피커 입력 UI의 외형은 브라우저와 운영체제에 따라 다르다.” 둘째, 그리고 이게 실무에서 더 자주 사고를 냅니다.

표시되는 날짜 형식은 실제 value와 다릅니다. 표시는 사용자 브라우저의 로케일에 따라 형식이 정해지지만, 파싱된 value는 언제나 yyyy-mm-dd 형식입니다.

즉 화면에 “2026. 08. 25.”로 보여도 input.value"2026-08-25"입니다. 서버로 보낼 때는 이 값을 그대로 쓰면 되고, 화면에 다시 그릴 때는 직접 포맷해야 합니다. 표시 문자열을 파싱해서 쓰려는 시도는 로케일이 바뀌는 순간 깨집니다.

스타일링 제약도 있습니다. MDN에 따르면 <input type="date">size 같은 폼 크기 속성을 지원하지 않고, 크롬에서는 생성된 내용이 폼 컨트롤 안쪽에 놓여 효과적으로 스타일링하거나 표시할 수 없습니다.

지원 범위는 넓습니다. Chrome 20, Android WebView 4.4, Safari 14.1, Safari iOS와 iOS 웹뷰 5, Firefox 57입니다. 여기서 크롬 버전을 웹뷰 버전으로 직역하면 안 됩니다. 호환성 데이터에서 안드로이드 웹뷰 값은 크롬 값을 미러링하되 Blink로 전환한 시점인 WebView 4.4에 매핑됩니다. 그래서 크롬은 20인데 웹뷰는 4.4입니다. Safari 14.1도 데스크톱만의 값입니다. iOS 계열은 그보다 훨씬 이른 iOS 5부터 지원하므로, 폴백 달력 컴포넌트를 iOS 때문에 넣을 이유는 없습니다.

미지원일 때의 동작은 스펙에 있습니다. HTML 표준은 type 속성에 대해 “속성의 missing value default와 invalid value default는 둘 다 Text 상태”라고 정의합니다. 엔진이 date를 모르면 그냥 텍스트 입력이 됩니다. 검증도 피커도 없습니다.

이 판정을 코드로 하려면 요소를 하나 만들어 type을 되읽으면 됩니다.

const probe = document.createElement('input');
probe.setAttribute('type', 'date');
// 엔진이 date를 모르면 Text 상태로 떨어지므로 'text'가 돌아옵니다
const supportsDate = probe.type === 'date';

if (!supportsDate) {
  // 자체 달력 컴포넌트를 붙이거나, 형식 안내와 마스크를 제공합니다
}

inputmode

키보드 배열은 type만으로 정해지지 않습니다. inputmode가 더 세밀합니다. MDN이 정리한 값은 이렇습니다.

키보드
none가상 키보드 없음. 페이지가 자체 입력 컨트롤을 구현할 때
text사용자 로케일의 기본 키보드 (기본값)
decimal숫자와 로케일 소수 구분자. 마이너스 키는 기기에 따라 있을 수도 없을 수도 있음
numeric0부터 9까지의 숫자만 요구. 마이너스 키는 기기에 따라 다름
tel전화 키패드. 숫자와 별표, 우물정 포함
search / email / url각 용도에 최적화된 키보드

주의할 문장이 하나 있습니다. MDN은 “inputmode 속성은 입력에 어떤 유효성 요구도 강제하지 않는다”고 명시합니다. 키보드 배열만 바꿉니다. 검증은 별개로 해야 합니다.

지원 버전은 Chrome 66, Android WebView 66, Safari 12.1, iOS Safari 12.2입니다. 조건이 하나 붙습니다. iOS 13 이전에는 inputmode="none"이 아무 효과가 없었습니다. 자체 숫자 키패드를 그려놓고 시스템 키보드를 막으려 했다면 그 버전대에서는 둘 다 뜹니다.

type=“number”를 쓰면 안 되는 자리

type="number"의 진짜 함정은 증감 화살표가 아닙니다. 값이 사라지는 것입니다.

HTML 표준의 값 정제 알고리즘은 이렇습니다.

요소의 값이 유효한 부동소수점 수가 아니면, 대신 빈 문자열로 설정한다.

사용자가 1234-5678을 입력했다면 input.value""입니다. 무엇을 입력했는지조차 알 수 없습니다. 검증 메시지에 “입력하신 값”을 되비춰 줄 수도 없습니다.

스펙 자체가 용도를 제한합니다. “신용카드 번호나 미국 우편번호처럼 본질적으로 수치가 아닌 숫자는 이 타입을 쓰면 안 된다.” MDN도 같은 취지로 반복하고, 접근성 문제를 두 가지 더 덧붙입니다. 다른 조작을 하려다 실수로 값이 증감될 위험이 있고, 숫자가 아닌 것을 입력했을 때 무엇이 잘못됐는지 알려주는 명시적 피드백이 없습니다.

MDN이 제시하는 대안은 이렇습니다.

<!-- 카드번호, 계좌번호, 우편번호, 인증번호에는 number를 쓰지 않습니다 -->
<input
  type="text"
  inputmode="numeric"
  pattern="\d*"
  autocomplete="cc-number"
  maxlength="19"
/>

전화번호는 type="tel"이 맞습니다. 스펙이 “URL이나 Email 타입과 달리 Telephone 타입은 특정 문법을 강제하지 않는다. 이것은 의도된 것이다”라고 적고 있어서, 하이픈이나 국가번호가 섞여도 값이 날아가지 않습니다.


파일 선택은 두 플랫폼의 기본값이 정반대입니다

첨부 기능은 웹뷰에서 가장 자주 “눌러도 아무 일이 없다”는 제보로 올라옵니다. 그리고 원인이 플랫폼마다 반대입니다.

iOS (WKWebView)Android (WebView)
앱이 아무것도 안 했을 때파일 업로드가 켜져 있음targetSdk 21 이상이면 무반응
앱이 개입하는 지점WKUIDelegaterunOpenPanelWith (iOS 18.4+)WebChromeClient.onShowFileChooser (API 21+)
개입했을 때 accept 전달전달되지 않음getAcceptTypes()로 전달됨
capture 전달전달되지 않음isCaptureEnabled()로 전달됨
다중 선택 정보allowsMultipleSelectiongetMode()MODE_OPEN_MULTIPLE

안드로이드: 구현하지 않으면 조용히 끝납니다

WebChromeClient.onShowFileChooser는 안드로이드 5.0(API 21)에서 처음 공개 API가 되었습니다. AOSP의 javadoc은 이렇게 설명합니다.

클라이언트에게 파일 선택기를 표시하라고 알린다. 사용자가 “파일 선택” 버튼을 누른 것에 대한 응답으로, file 입력 타입을 가진 HTML 폼을 처리하기 위해 호출된다.

기본 구현은 false를 반환합니다. 그리고 Chromium의 WebView 글루 코드에서 그 뒤가 갈립니다. WebChromeClient가 아예 등록되지 않았으면 콜백이 즉시 null로 종료됩니다. 등록됐지만 onShowFileChooserfalse를 반환했고 앱의 targetSdk가 21 이상이면, 거기서 그대로 중단됩니다. targetSdk가 그보다 낮을 때만 레거시 경로로 넘어갑니다.

요청 문장을 정확히 쓰면 이렇게 됩니다. targetSdk가 21 이상인 앱에서 onShowFileChooser를 구현하지 않으면 파일 선택 버튼은 눌려도 아무 반응이 없습니다. 오류도 없고 콘솔에도 안 남습니다. 웹 코드를 아무리 고쳐도 바뀌지 않습니다.

앱에는 FileChooserParams가 넘어갑니다. getAcceptTypes()가 MIME 타입 배열을, isCaptureEnabled()가 카메라·마이크 선호를, getMode()가 단일/다중 여부를 담습니다. 다만 앱이 그 정보를 실제로 Intent에 반영할지는 앱의 구현에 달려 있습니다. 웹은 전달만 합니다.

iOS: 개입하는 순간 정보가 줄어듭니다

Apple 문서의 서술이 명확합니다.

업로드 패널을 커스터마이즈하려면 이 메서드를 구현하십시오. 파일 업로드를 비활성화하려면 이 메서드가 nil을 반환하도록 구현하십시오. macOS에서는 기본적으로, 이 메서드를 구현하지 않으면 파일 업로드가 비활성화됩니다. iOS에서는 기본적으로, 이 메서드를 구현하지 않으면 파일 업로드가 활성화됩니다.

그래서 iOS 웹뷰에서는 첨부가 대개 그냥 됩니다. 문제는 반대 방향입니다. 앱이 파일 선택 화면을 자기 디자인으로 바꾸려고 이 델리게이트를 구현하는 순간, WKOpenPanelParameters가 전달하는 정보는 allowsMultipleSelectionallowsDirectories 두 개뿐입니다. MIME 타입 필드가 없습니다. accept="image/*"를 걸어둬도 앱은 그것을 알 수 없습니다.

이 델리게이트 메서드의 iOS 가용성은 18.4 이상입니다. 그 이전 iOS에서는 앱이 파일 선택 UI에 개입할 공개 수단 자체가 없었습니다.

accept는 검증이 아닙니다

플랫폼 문제와 별개로, accept를 둘러싼 오해가 하나 더 있습니다. MDN이 명시적으로 경고합니다.

accept 속성은 선택된 파일의 타입을 검증하지 않습니다. 브라우저가 사용자를 올바른 파일 타입으로 유도하도록 힌트를 제공할 뿐입니다. 대부분의 경우 사용자가 파일 선택기에서 옵션을 바꿔 이를 무시하는 것이 여전히 가능합니다. (중략) accept 속성이 적절한 서버 측 검증으로 뒷받침되도록 해야 합니다.

웹뷰에서는 여기에 층이 하나 더 쌓입니다. iOS는 앱이 개입하면 accept를 알지 못하고, 안드로이드는 앱이 그 정보를 무시할 수 있습니다. 파일 타입 제한은 클라이언트에서 세 번 새어나갈 수 있는 구조입니다. 서버 검증은 선택이 아닙니다.

파일 입력에는 그 밖에도 웹 개발자가 알아야 할 제약이 몇 개 붙습니다. 스크립트로 파일 선택기의 값을 설정할 수 없고, value는 보안상 C:\fakepath\ 접두어가 붙은 문자열이며, 암시적 ARIA role이 없어 역할을 부여할 수 없습니다.

한편 첨부의 반대 방향, 즉 파일을 내려받는 쪽이 웹뷰에서 사라지는 문제는 내비게이션 결정 지점의 문제라 ep.02에 적어두었습니다.


자동완성은 웹의 기능이 아니라 OS의 기능입니다

주소를 채워 넣는 것도, 저장된 비밀번호를 꺼내오는 것도 브라우저가 하는 일처럼 보이지만 모바일에서는 OS가 합니다. 웹은 autocomplete 속성으로 이 필드가 무엇인지 이름표를 붙일 뿐입니다.

Apple 문서의 제목이 이 편의 논점을 그대로 담고 있습니다. “HTML input 요소에서 Password AutoFill 활성화하기”, 그리고 초록의 첫 문장이 “웹뷰나 웹페이지가 올바른 AutoFill 제안을 제공하도록 하십시오”입니다. 대상에 웹뷰가 명시되어 있습니다.

지정 방법은 하나뿐입니다.

<form action="/login" method="post">
  <input type="text" autocomplete="username" name="userId" />
  <input type="password" autocomplete="current-password" name="password" />
</form>

<!-- 비밀번호 변경 화면 -->
<input type="password" autocomplete="current-password" />
<input type="password" autocomplete="new-password" />

애플이 정리한 대응은 사용자명이 username, 기존 비밀번호가 current-password, 새 비밀번호가 new-password, 일회용 코드가 one-time-code입니다. 그리고 문서는 “기본적으로 시스템은 입력 요소의 autocomplete 값에 따라 키보드를 선택하지만, typeautocomplete 값을 섞어 원하는 키보드를 명시적으로 정의할 수 있다”고 덧붙입니다. 즉 이 속성은 자동완성만이 아니라 키보드에도 영향을 줍니다.

iOS에서 제안이 나타나는 자리는 QuickType 바입니다. Apple 문서는 “사용자가 지원되는 입력 뷰나 HTML 입력 요소를 선택하면 Password AutoFill이 키보드 위에 QuickType 바를 표시하고 해당 필드에 맞는 옵션을 채운다”고 설명하고, 조건도 명시합니다. 다만 이 조건은 사용자명·비밀번호 제안에 한정됩니다. 그 항목에서는 기기에 저장된 비밀번호가 하나 이상 있고 키체인 AutoFill 설정이 켜져 있어야만 QuickType 바가 나타납니다. 로그인 폼을 검증하는 기기에 아무것도 저장돼 있지 않으면 마크업이 맞아도 아무 일이 없습니다. 반대로 인증번호 제안에는 이 조건이 걸리지 않습니다. 같은 문서의 보안 코드 항목은 저장된 자격증명을 조건으로 걸지 않습니다.

안드로이드는 조건이 두 겹입니다. 자동완성 프레임워크 자체가 안드로이드 8.0(API 26) 이상에서만 제공됩니다. 그리고 안드로이드 개발자 블로그가 명시한 추가 조건이 있습니다.

기기에 Chrome 61 이상이 설치되어 있는 한 자동완성은 WebView에서 동작합니다.

여기서도 엔진 버전이 다시 나옵니다. 안드로이드 웹뷰에서 자동완성이 안 붙는다는 제보를 받으면 OS 버전이 아니라 설치된 System WebView 버전을 먼저 물어야 합니다.


패스키는 앱과 도메인이 묶여야 열립니다

패스키를 함께 보려고 합니다. 사용자가 보는 화면에서 둘은 구분되지 않습니다. 저장된 비밀번호가 뜨던 그 QuickType 바에 패스키도 함께 뜹니다. 웹이 이름표만 붙이고 실제 UI는 OS가 그린다는 구조도 같습니다.

다른 것은 조건 하나입니다. 자동완성은 기기에 저장된 자격증명만 있으면 되지만, 패스키는 앱과 도메인이 묶여 있어야 열립니다. 웹뷰가 어느 앱 안에 있는지를 따지는 단계가 하나 더 붙는 셈입니다. 그래서 “비밀번호는 자동으로 채워지는데 패스키만 안 뜹니다”라는 제보가 들어옵니다.

안드로이드는 앱이 켜야 합니다

androidx.webkit의 기능 플래그로 앱이 명시적으로 켜야 합니다.

// 앱에 켜달라고 요청하는 지점입니다. 구현은 앱 개발자 영역입니다.
if (WebViewFeature.isFeatureSupported(WebViewFeature.WEB_AUTHENTICATION)) {
    WebSettingsCompat.setWebAuthenticationSupport(
        webView.settings,
        WebSettingsCompat.WEB_AUTHENTICATION_SUPPORT_FOR_APP,
    )
}

이 경로에는 조건이 붙습니다. androidx.webkit 버전 1.12.0 이상이 필요하고, 앱과 웹사이트가 디지털 에셋 링크로 연결돼 있어야 하며, 문서가 명시한 제약으로 조건부 미디에이션 요청은 지원되지 않습니다. 조건부 UI를 전제로 로그인 화면을 설계했다면 웹뷰에서는 다르게 가야 합니다.

iOS는 켜는 방식 자체가 다릅니다. WKWebView에서 패스키는 iOS 16부터 동작하고, 앱이 호출할 API는 없습니다. 조건은 하나입니다. 그 relying party 도메인이 앱의 Associated Domains에 webcredentials 서비스 타입으로 등록돼 있어야 합니다. 등록돼 있으면 표준 navigator.credentials가 그대로 동작하고, 등록돼 있지 않으면 거절됩니다.

다만 iOS의 이 내용은 Apple 공식 문서가 아니라 Apple Developer Forums에 Apple Staff 배지가 붙은 엔지니어 답변입니다. 같은 취지의 답변이 여러 스레드에 반복됩니다. Associated Domains 자체는 정식 문서가 있고, entitlement 키는 com.apple.developer.associated-domains입니다.

두 플랫폼을 나란히 놓으면 요청할 내용이 갈립니다.

AndroidiOS
앱이 호출할 APIsetWebAuthenticationSupport 필요없음
앱과 도메인 연결디지털 에셋 링크Associated Domains webcredentials
기본 상태꺼짐연결돼 있으면 켜짐
조건부 미디에이션문서가 미지원이라고 명시공식 진술 없음

그래서 앱에 요청할 말이 다릅니다. 안드로이드는 “이 메서드를 호출해달라”이고, iOS는 “entitlement에 우리 도메인이 들어 있는지 확인해달라”입니다. iOS 쪽 제보를 받고 API 호출을 요청하면 앱 개발자가 부를 메서드가 없어 대화가 겉돕니다.

자사 도메인이 아니면 웹뷰 밖으로 나가야 합니다

이 조건에서 바로 따라오는 제약이 있습니다. 외부 ID 공급자(IdP)로 넘기는 연합 로그인은 앱 웹뷰 안에서 패스키를 쓸 수 없습니다. 앱이 소유하지 않은 도메인을 Associated Domains에 넣을 수 없기 때문입니다. FIDO Alliance가 운영하는 개발자 레퍼런스도 임베디드 웹뷰는 연결된 도메인의 패스키만 다룰 수 있으니 자사 서비스가 로그인을 처리할 때만 쓰라고 적습니다.

대안은 웹뷰를 벗어나는 것입니다. ASWebAuthenticationSession은 시스템 브라우저 컨텍스트라 이 제약을 받지 않습니다. OAuth 흐름에 이미 쓰이는 그 클래스이고, 로그인만 거기서 끝내고 결과를 콜백으로 받아오는 구조입니다. 대신 앱이 세션 내부를 들여다볼 수 없으니, 웹 입장에서는 로그인 화면이 별도 컨텍스트로 뜬다는 전제로 설계해야 합니다.

어느 그릇에 띄우느냐로 정리하면 이렇게 갈립니다.

환경조건2026년 8월 기준 상태
iOS WKWebViewiOS 16 이상, 앱이 해당 RP ID에 대해 Associated Domains를 설정자사 도메인이면 가능. 외부 IdP나 타사 RP는 불가
iOS ASWebAuthenticationSessioniOS 12.0 이상시스템 브라우저 컨텍스트라 제약 없음. 대신 앱이 세션 내부를 볼 수 없음
iOS SFSafariViewControlleriOS 9.0 이상제약 없음. 앱이 내용·자동완성·기록·데이터에 접근 불가
Android WebViewandroidx.webkit 1.12.0 이상, 앱이 명시적으로 켜야 함기본값이 꺼짐. Digital Asset Links로 앱과 도메인을 묶어야 함
Android Chrome없음정상 동작

마지막 두 줄이 실무에서 가장 자주 만나는 형태입니다. “안드로이드 크롬에서는 되는데 앱에서만 안 됩니다”의 정체가 이것입니다. 같은 엔진 위에서 도는데 앱이 opt-in하지 않았을 뿐입니다.

패스키 버튼을 그릴지 판단하는 법

가용성 판정을 잘못 잡으면 되는 환경에서도 버튼이 사라집니다. isUserVerifyingPlatformAuthenticatorAvailable()을 게이트로 쓰지 않는 편이 안전합니다. 이 값이 WKWebView에서만 항상 거짓을 돌려주던 구간이 실제로 있었습니다.

// 실패해도 되는 판단입니다. 확신이 없으면 버튼을 보여주는 쪽으로 기울입니다
async function passkeyCapability() {
  if (!window.PublicKeyCredential) return { platform: false, conditional: false };

  if (typeof PublicKeyCredential.getClientCapabilities === 'function') {
    try {
      const c = await PublicKeyCredential.getClientCapabilities();
      return { platform: c.passkeyPlatformAuthenticator ?? true, conditional: c.conditionalGet === true };
    } catch {
      // 아래 폴백으로 내려갑니다
    }
  }

  const platform = await PublicKeyCredential
    .isUserVerifyingPlatformAuthenticatorAvailable?.()
    .catch(() => true) ?? true;
  return { platform, conditional: false };
}

기본값을 참으로 두는 것이 핵심입니다. 거짓 음성의 대가는 패스키를 아예 못 쓰는 것이고, 거짓 양성의 대가는 눌렀을 때 실패 한 번입니다. 사용자는 잠시 의아해하다가 다른 선택을 하겠죠. 아무튼 로그인은 되니깐 차라리 뒤쪽이 낫습니다.

조건부 미디에이션은 양쪽 다 기대하지 않는 편이 낫습니다. 안드로이드는 문서가 미지원이라고 명시했고, iOS는 지원한다는 근거를 찾지 못했습니다. 아이디 입력창에 패스키가 떠오르는 UX를 전제로 설계했다면 명시적 버튼 경로를 반드시 함께 둡니다.

담당별로 무엇을 요청할지 정리한 목록은 ep.09에 있습니다. 로그인 세션이 웹뷰와 브라우저 사이에서 어떻게 갈라지는지는 저장소와 오리진의 문제라 ep.07에서 다룹니다.


인증번호 자동입력이 갈리는 지점

문자 인증을 쓰는 화면에서 가장 많이 받는 질문입니다. “브라우저에서는 인증번호가 자동으로 채워지는데 앱에서는 안 됩니다.”

정확한 답은 “웹뷰에서는 안 됩니다”가 아닙니다. 경로가 두 개인데 그중 하나만 웹뷰에서 살아 있고, 나머지 하나는 아예 웹의 영역이 아닙니다.

경로 1 - autocomplete=“one-time-code” (WebKit 계열)

사파리 12 릴리스 노트에 세 줄이 나란히 있습니다.

autocomplete 속성의 one-time-code 값으로 텍스트 필드를 태깅해 Security Code AutoFill이 제공되도록 하는 지원을 추가했습니다. SMS로 전달된 보안 코드 채우기 지원을 추가했습니다. “Your secure login code is: 123456.”처럼 “code”라는 단어 근처에 숫자 시퀀스가 있는 문자 메시지의 보안 코드 사용 지원을 추가했습니다.

이건 자바스크립트 API가 아닙니다. OS 기능입니다. Apple 문서는 “iOS는 SMS 메시지에 일회용 코드가 포함된 것을 감지하고 앱과 웹사이트의 일회용 코드 필드에 AutoFill로 그 코드를 제공한다”고 설명합니다. 그리고 Password AutoFill 워크플로 문서에 시간 조건이 있습니다.

시스템이 SMS 메시지에서 보안 코드를 파싱할 수 있으면 QuickType 바는 수신 후 최대 3분 동안 그 코드를 표시합니다. 텍스트 입력 뷰가 선택된 상태에서 보안 코드가 도착하면 시스템이 들어오는 코드를 QuickType 바로 밀어 넣습니다.

즉 사용자가 입력 필드를 이미 눌러둔 상태에서 문자가 오면 바로 뜨고, 문자를 먼저 받았다면 3분 안에 필드를 눌러야 합니다. 이 시간 창은 화면 설계에 영향을 줍니다. 인증번호 화면에 진입하자마자 입력 필드에 포커스가 가 있어야 유리합니다.

iOS 14와 macOS Big Sur부터는 도메인 바인딩 코드가 추가됐습니다. SMS 마지막 줄에 구조화된 형식을 넣으면 그 도메인에서만 코드가 제안됩니다.

[OO은행] 인증번호 [123456]을 입력해 주세요.

@bank.example.com #123456

Apple 문서는 “메시지 시작 부분에는 어떤 형식으로든 어떤 정보든 넣을 수 있고, 구조화된 도메인 바인딩 형식이 필요한 것은 마지막 줄뿐”이라고 적습니다. 로그인 화면이 다른 도메인의 iframe일 때는 %iframe-auth.example.org 같은 세 번째 토큰을 덧붙이는 형식도 있습니다.

다만 Apple 문서는 도메인 판정 기준을 두 갈래로만 서술합니다. 앱에서는 “메시지의 바인딩된 도메인이 앱의 연관 도메인 중 하나와 일치하면”, 사파리에서는 “브라우징 중인 웹사이트와 도메인이 일치해야” 합니다. WKWebView가 이 중 어느 쪽으로 판정되는지는 문서에 없습니다. 실기기 검증 없이 단정할 수 없는 항목입니다.

경로 2 - WebOTP API (Chromium 계열)

크로미움은 다른 길을 갑니다. OTPCredential을 통해 자바스크립트가 직접 SMS 코드를 받아옵니다.

// Android Chrome 84 이상, HTTPS 필수
if ('OTPCredential' in window) {
  const ac = new AbortController();
  navigator.credentials
    .get({ otp: { transport: ['sms'] }, signal: ac.signal })
    .then((otp) => {
      input.value = otp.code;
      form.submit();
    })
    .catch(() => {
      /* 사용자가 거부했거나 타임아웃 */
    });
}

그리고 이 편의 핵심 사실이 여기 있습니다. WebOTP는 Android WebView에서 지원되지 않습니다. MDN 호환성 데이터에 안드로이드 웹뷰 항목이 명시적으로 미지원으로 기록돼 있습니다. Chrome for Android 84에서는 되고, 같은 기기의 웹뷰에서는 안 됩니다.

사파리 계열은 WebOTP를 구현하지 않습니다. 크롬 공식 문서가 그 사정을 함께 정리합니다. “크로미움 외의 브라우저 엔진은 WebOTP API를 구현하지 않지만, 사파리는 input[autocomplete=one-time-code] 지원에서 같은 SMS 형식을 공유한다.” 그리고 “크로미움과 WebKit이 SMS 문자 메시지 형식에 합의했다”고 적습니다.

정리하면 이렇게 갈립니다

환경표준 자동입력 경로
iOS Safariautocomplete="one-time-code" (Safari 12+)
iOS 웹뷰Apple 문서가 대상에 웹뷰를 포함해 서술. 도메인 바인딩 판정 기준은 문서 미기재
Chrome for AndroidWebOTP (Chrome 84+, HTTPS 필수)
Android 웹뷰없음. WebOTP 미지원

안드로이드 웹뷰에서 인증번호 자동입력을 요구받았다면 표준 경로가 없습니다. 남는 것은 앱이 SMS를 수신해 웹에 값을 전달하는 구조인데, 이건 웹 표준이 아니라 프로젝트 합의 사항입니다. 구글이 제공하는 SMS Retriever API는 “추가 권한 없이 안드로이드 앱에서 SMS 기반 사용자 확인을 자동화한다”고 설명하는 네이티브 앱 전용 경로이고, 웹뷰의 웹 폼에 값이 자동으로 들어가는 경로가 아닙니다. 앱이 받아서 브리지로 넘겨주는 설계를 앱 개발자와 합의해야 합니다.

그래서 웹은 무엇을 하는가

세 가지입니다. 세 가지 다 지금 할 수 있습니다.

첫째, 마크업을 표준대로 씁니다. one-time-code는 WHATWG HTML 표준의 정식 autofill 필드명입니다. 의미는 “사용자 신원 확인에 쓰이는 일회용 코드”입니다. 표준이 정한 정규 형식은 줄바꿈 없는 자유 형식 텍스트이고, 표에 실린 예시가 123456입니다. 즉 표준은 숫자만 허용하지 않습니다. 영숫자가 섞인 코드를 쓰는 서비스도 이 필드명을 그대로 쓰면 됩니다.

<!-- type은 number가 아니라 text입니다 -->
<input
  type="text"
  inputmode="numeric"
  autocomplete="one-time-code"
  maxlength="6"
  aria-label="인증번호 6자리"
/>

여기서 inputmode="numeric"maxlength="6"은 표준이 요구하는 값이 아닙니다. 국내 인증번호가 숫자 여섯 자리라는 전제에서 나온 실무 선택입니다. 코드 형식이 다르면 이 두 값은 서비스에 맞춰 바꿔야 합니다.

web.dev가 type="number"를 피하라고 권하는 이유가 앞 절에서 본 것과 같습니다. 증감 버튼과 선행 0 제거 같은 예기치 않은 동작 때문입니다. 인증번호는 012345처럼 0으로 시작할 수 있고, type="number"에서는 그 값이 온전히 남지 않습니다.

둘째, 여섯 칸으로 쪼개지 않습니다. 한 칸에 한 글자씩 들어가는 디자인은 흔하지만, OS 자동입력은 코드 전체를 한 필드에 넣습니다. 쪼개면 자동입력이 붙을 자리가 없어집니다. 시각적으로 칸을 나누고 싶다면 입력 요소는 하나로 두고 CSS로 표현하는 편이 낫습니다.

셋째, 문자 발송 담당에게 형식을 요청합니다. 이게 이 절에서 가장 실행 가능한 항목입니다. 마지막 줄의 @도메인 공백 #코드 형식은 크로미움과 WebKit이 합의한 동일 포맷이라, 한 번 바꾸면 양 진영에 동시에 효과가 있습니다. 프론트엔드 코드로는 만들 수 없고, 문자 템플릿을 고쳐야만 생깁니다.


focus()를 불렀는데 키보드가 안 올라옵니다

화면에 들어가자마자 입력 필드에 커서를 두는 요구는 흔합니다. 특히 방금 본 인증번호 화면에서 그렇습니다. 그리고 웹뷰에서는 종종 커서만 가고 키보드가 안 올라옵니다.

안드로이드 공식 문서가 조건을 명시합니다.

뷰는 이미 소프트웨어 키보드에 연결되어 있어야 하며, 이를 위해서는 윈도가 포커스를 갖고 에디터 뷰가 View.requestFocus()로 포커스를 요청해야 합니다.

그리고 이어서 이렇게 적습니다. “특정 시나리오에서는 시스템이 뷰가 소프트웨어 키보드에 연결된 것으로 간주하지 않아 showSoftInput() 호출을 무시하고, 소프트 키보드가 사용자에게 보이지 않습니다.”

이 문장의 실무적 함의는 이렇습니다. 웹뷰라는 뷰 자체가 포커스를 받지 못한 상태라면, 페이지 안에서 el.focus()를 아무리 불러도 키보드는 올라오지 않습니다. 이건 웹 코드로 해결되는 문제가 아닙니다. 앱 개발자에게 “화면 진입 시점에 WebView가 포커스를 갖고 있는지” 확인을 요청할 근거가 됩니다.

iOS 쪽은 서술을 좁혀야 합니다. WKWebViewConfiguration의 공개 프로퍼티를 전부 확인하면 사용자 제스처를 요구하는 것은 미디어 재생용 항목 하나뿐입니다. 정확한 문장은 iOS 웹뷰에는 키보드 자동 표시를 켜고 끄는 공개 설정이 없습니다까지입니다. 그 이상의 정책이 있는지는 Apple 문서로 확인되지 않으므로 단정하지 않겠습니다.

웹 쪽에서 확실히 할 수 있는 것은 두 가지입니다.

// 1) 사용자 제스처 안에서 부릅니다. 비동기 콜백 밖으로 빠지지 않게 합니다.
button.addEventListener('click', () => {
  input.focus(); // 같은 실행 컨텍스트 안에서 호출
});

// 2) mousedown에서 부를 때는 기본 동작을 막습니다.
label.addEventListener('mousedown', (event) => {
  event.preventDefault(); // 막지 않으면 포커스가 다시 빠져나갑니다
  input.focus();
});

두 번째는 MDN이 명시한 규칙입니다. “mousedown 이벤트 핸들러에서 HTMLElement.focus()를 호출한다면, 포커스가 요소를 떠나지 않도록 event.preventDefault()를 호출해야 합니다.”

포커스 시 스크롤이 튀는 것을 막는 옵션도 있습니다. focus()preventScroll 기본값은 false이고, 이 값이면 브라우저가 포커스 후 요소를 보이도록 스크롤합니다.

input.focus({ preventScroll: true });

키보드가 올라온 뒤 화면이 잘리거나 하단 버튼이 가려지는 문제는 뷰포트 소관이라 ep.05에서 다룹니다.


그래서 무엇을 하면 되나

이 편에서 다룬 것을 담당별로 가릅니다.

웹 개발자가 할 일

  • type="number"를 검색해 걷어냅니다. 카드번호·계좌번호·우편번호·인증번호는 type="text"inputmode="numeric"pattern="\d*"을 겁니다. 값이 빈 문자열로 소실되는 문제를 먼저 막습니다.
  • 인증번호 필드를 한 칸으로 되돌립니다. 여섯 칸으로 쪼갠 입력은 OS 자동입력이 붙을 자리를 없앱니다. autocomplete="one-time-code", inputmode="numeric", maxlength를 한 요소에 겁니다.
  • 로그인 폼에 autocomplete 이름표를 붙입니다. username, current-password, new-password입니다. 이게 없으면 비밀번호 매니저가 붙을 근거가 없습니다.
  • 날짜는 value가 항상 yyyy-mm-dd라는 전제로 다룹니다. 표시 문자열을 파싱하지 않습니다. 미지원 대비 판정은 요소를 만들어 type을 되읽는 방식으로 합니다.
  • accept를 검증으로 취급하지 않습니다. 서버 검증을 반드시 둡니다. 웹뷰에서는 앱 구현에 따라 accept 자체가 전달되지 않을 수 있습니다.
  • <div> 드롭다운을 쓴다면 APG combobox 패턴 전부를 구현합니다. role, aria-expanded, aria-activedescendant, aria-selected, 그리고 화살표·Enter·Escape·Home·End 키 조작입니다. 절반만 구현한 컴포넌트는 표준 <select>보다 나쁩니다.
  • appearance: base-select@supports로 감싸고, <option> 안에 텍스트를 남깁니다. 미지원 엔진에서 OS 피커로 떨어졌을 때 빈 항목이 되지 않게 합니다.
  • focus()는 사용자 제스처 안에서 부르고, mousedown에서 부를 때는 preventDefault()를 함께 호출합니다.

앱에 요청할 일

이름을 정확히 지목하면 대화가 짧아집니다.

  • 안드로이드 파일 선택: WebChromeClient.onShowFileChooser를 구현해 주세요. 그리고 FileChooserParams.getAcceptTypes()isCaptureEnabled(), getMode()의 값을 실제 Intent에 반영해 주세요.
  • iOS 파일 선택: WKUIDelegaterunOpenPanelWith 델리게이트를 구현하실 계획이면 미리 알려주세요. 구현하는 순간 accept 정보가 전달되지 않으므로 파일 타입 안내를 웹 화면에서 별도로 처리해야 합니다. 구현하지 않으시면 iOS 기본값으로 업로드가 동작합니다.
  • 안드로이드 엔진 버전: 자동완성은 기기에 설치된 Chrome 또는 Android System WebView가 61 이상일 때 동작합니다. 이슈 리포트에 엔진 버전을 함께 담아주세요.
  • 안드로이드 패스키: WebViewFeature.WEB_AUTHENTICATION을 확인하고 WebSettingsCompat.setWebAuthenticationSupport로 켜주세요. 조건부 미디에이션은 지원되지 않으니 웹 쪽 설계를 그에 맞춥니다.
  • iOS 패스키: 호출할 API는 없습니다. 대신 Associated Domains에 webcredentials 서비스 타입으로 우리 도메인이 들어 있는지 확인해 주세요. 외부 IdP로 넘기는 로그인이라면 그 도메인은 넣을 수 없으니 ASWebAuthenticationSession 경로를 함께 검토해야 합니다.
  • iOS 자동완성 연결: Associated Domains 엔타이틀먼트가 설정되어 있는지 확인해 주세요.
  • 안드로이드 인증번호: WebOTP가 웹뷰에서 미지원이라 표준 경로가 없습니다. 앱이 SMS를 수신해 웹으로 전달하는 구조가 필요하며, 이건 프로젝트 차원의 합의 항목입니다.
  • 키보드: 화면 진입 시 웹뷰가 포커스를 갖고 있는지 확인해 주세요. 뷰가 포커스를 못 받으면 페이지 안의 focus() 호출이 무시됩니다.

문자 발송 담당에게 요청할 일

이 항목은 자주 잊힙니다. 그런데 효과가 가장 큽니다.

인증번호 SMS의 마지막 줄을 도메인 바인딩 형식으로 바꿔달라고 요청합니다. 크로미움과 WebKit이 합의한 동일 포맷이라 한 번의 변경이 양쪽에 적용됩니다.

[OO은행] 인증번호 [123456]

@bank.example.com #123456

앞부분은 자유 형식이어도 됩니다. 마지막 줄만 이 구조를 지키면 됩니다.

남는 것

여기까지 해도 남는 항목이 있습니다. <select>가 열렸을 때의 OS 피커 모양은 지원 엔진이 아닌 한 통제할 수 없습니다. 날짜 피커의 외형도 마찬가지입니다. 이건 켤 수 있는 것이 아니라 엔진 버전을 기다려야 하는 항목입니다.

그래서 판단은 이렇게 갈립니다. 디자인 일치가 접근성보다 우선하는 화면인지 물어보는 것입니다. 아니라면 표준 요소를 쓰고 OS 피커를 받아들이는 편이, 절반만 구현된 커스텀 컴포넌트보다 대체로 낫습니다.

담당별 매핑을 시리즈 전체 범위에서 정리한 표는 ep.09에 있습니다.


참고 자료


다음 편 예고

ep.04 - 한글이 덜 조합된 채로 넘어갑니다

폼 컨트롤을 되찾고 나면 그 안에 들어가는 글자가 문제가 됩니다. 한글에는 조합 중이라는 상태가 있고, 이 상태를 다루지 않으면 전송 버튼을 눌러도 마지막 글자가 남습니다.