같은 List를 만들었는데 왜 플랫폼 구현은 달라야 했을까

세 플랫폼이 공유할 의미와 각 플랫폼이 소유할 실현을 나누기

앞의 세 막에서 정한 계약은 한 플랫폼 안에서 만들어진 것이 아니었다. 같은 목록을 iOS와 Android와 Web이 함께 만들었고, 셋이 보는 디자인 파일은 하나였다.

그런데 그 파일에서 나온 결정 중 상당수는 공통 명세에 한 줄도 없었다. 행의 동작을 <a>로 그릴지 <button>으로 그릴지, 목록을 <ul>로 묶을지, 선택 목록을 네이티브 radio로 만들지. 셋 다 Web 팀이 혼자 정했고, iOS와 Android는 그 결정을 알지 못한 채로 같은 화면을 만들었다.

처음에는 그것이 빈틈으로 보였다. 공통 명세가 요소까지 정하면 세 구현이 더 가까워지지 않을까. 그 제안을 실제로 따라가 보면 어디에서 멈추는지가 이 글이다.

앞 글이 선택형 목록의 그룹 이름을 필요성만 정하고 요소는 비워둔 채 넘긴 것도 같은 문제였다. 세 플랫폼이 같은 의미를 공유하면서도 그것을 같은 요소로 만들 수는 없었다.

미리 밝혀둘 것이 하나 있다. 여기서 구현이 달라야 했다는 것은 결과를 다르게 만들었다는 뜻이 아니다. 화면에 보이는 것은 같아야 했고 실제로 같았다. 달라야 했던 것은 그 결과를 만드는 방식이고, 더 정확히는 그 방식을 누가 계속 책임지는가다.

누르면 이동한다는 요구가 세 플랫폼에서 같지 않았다

첫 사례는 가장 흔한 행이다. 왼쪽에 아이콘이 있고, 제목과 설명이 있고, 오른쪽에 화살표가 있고, 누르면 상세 화면으로 간다.

당시 셋이 이 화면을 각자 만들었고 결과물은 나란히 놓아도 구별되지 않았다. 아이콘의 크기도 같고 제목과 설명의 간격도 같고 화살표의 위치도 같았다. 같은 값을 세 번 옮겨 적었기 때문이다.

그 자리에서 나오는 제안이 있다. 세 플랫폼이 결국 같은 것을 만드는데 왜 같은 결정을 세 번 하느냐. 그러면 공통 명세에 이렇게 적으면 될 것 같다. 이 행은 누르면 이동한다.

의미로는 맞는 문장이다. 문제는 그 다음이다. 이 문장을 코드로 만들면 이동이라는 동작이 무엇에 의해 바뀌는지가 플랫폼마다 다르다.

앱에서 이동이 바뀌는 이유는 navigation stack의 모양, 화면 전환 애니메이션, 딥링크로 그 화면에 바로 들어온 경우의 처리다. 웹에서 이동이 바뀌는 이유는 브라우저 히스토리, 라우터가 무엇을 미리 받아두는가, 새 탭으로 열렸을 때의 동작이다.

목록이 어느 쪽도 정하지 않는다. 그런데 이동한다는 한 문장을 공통 계약에 적어두면, 그 문장을 지키는 코드는 어딘가에 있어야 한다.

요구가 같다는 것과 책임이 같다는 것은 다른 말이다. 세 팀에게 물어보면 행을 누르면 상세로 간다는 답이 똑같이 나온다. 그런데 그 문장을 지키기 위해 각 팀이 실제로 관리하는 것을 펼쳐보면 겹치는 것이 거의 없다.

그래서 물어야 할 것은 웹과 앱이 다른지가 아니었다. 다르다는 것은 이미 안다. 두 환경의 이동이 같은 책임과 같은 변경 이유를 가지는가가 판정에 필요한 질문이다. 2막에서 prop 하나를 열지 말지 정할 때도 변경 이유를 물었다. 다만 그때 이어서 물은 것은 그 값을 누가 아는가였고, 여기서는 그 의미를 누가 실현하는가로 이어진다. 물음의 앞부분이 같을 뿐 뒷부분이 다르고, 뒤에서 그 차이를 다시 꺼낸다.

공통 계약이 이동을 소유하면 무엇이 따라오나

첫 번째 후보는 공통 계약이 이동을 descriptor로 소유하는 것이다.

typescript
type RowActionSpec = {
  type: 'navigation';
  target: string;
};

호출부가 세 플랫폼에서 글자 단위로 같아진다. 디자인 시스템이 이동을 알고 있으니 각 팀은 목적지만 넘기면 된다. 매력적인 그림이다.

그런데 이 계약을 실제로 지키려면 target을 해석하는 쪽이 있어야 하고, 그 쪽에 질문이 계속 붙는다. 어떤 라우터가 이 문자열을 푸는가. 이동이 히스토리를 쌓는가 현재 항목을 대체하는가. 목적지를 미리 받아둘 것인가. 전환 애니메이션은 누가 그리는가. 이동이 분석 이벤트를 남기는 시점은 언제인가.

웹만 놓고 봐도 이건 가정이 아니다. Next.js의 Linkhref 외에 prefetch, replace, scroll, onNavigate, transitionTypes를 받는다. prefetch 하나만 해도 autotruefalse가 각각 다른 것을 미리 받아둔다.

이 다섯을 전부 공통 행의 prop으로 받으면 어떻게 되는지는 2막이 이미 답해뒀다. 컴포넌트는 자신이 책임질 수 없는 정책을 소유하지 않는다. 웹의 prefetch 전략이 바뀔 때마다 세 플랫폼이 공유하는 명세를 고쳐야 하고, 변경 이유가 하나뿐인 것을 셋이 함께 들게 된다.

그런데 여기서 멈추면 안 된다. 정책을 공통 행이 받지 않고도 공통 목적지를 둘 수 있는 길이 있다.

2막이 그 길을 이미 한 번 걸었다. 웹 안에서도 라우터마다 Link 컴포넌트가 다른데, 그 차이를 행의 prop으로 올리지 않고 앱이 등록한 어댑터가 흡수하게 했다.

tsx
<DesignSystemProvider linkComponent={NextLink}>
  <App />
</DesignSystemProvider>

prefetch도 그 자리에 섰다. 어떤 링크를 미리 불러올지는 앱의 route 구조가 정하므로 행별 prop으로 열지 않고 등록된 어댑터의 정책을 따르게 했다.

같은 구조를 플랫폼 축으로 한 단 올리면 후보가 하나 더 생긴다. 공통은 목적지를 가리키는 값 하나만 정하고, 그 값을 해석하는 일과 히스토리와 prefetch와 전환은 각 플랫폼의 어댑터가 자기 안에서 처리한다. 정책이 공통으로 올라오지 않으면서 목적지는 공통이 된다.

그래서 실제로 놓인 후보는 셋이었다.

공통이 소유하는 것 플랫폼이 소유하는 것
A1 공통 행이 정책까지 받는다 목적지와 prefetch·replace 같은 정책 실행
A2 공통 목적지 + 플랫폼 어댑터 공통 route 식별자와 동작의 의미 route 해석, 히스토리, prefetch, 전환
B 플랫폼별 목적지 타입 동작의 의미와 목적지가 필요하다는 요구 목적지의 타입과 해석과 실행 정책

A1만 기각하고 A2를 지나가면 이 글의 판정은 서지 않는다. A2는 실재하는 답이고 2막이 그 축소판을 웹 안에서 이미 쓰고 있다.

다만 A2가 서려면 앞에 조건이 하나 붙는다. 세 플랫폼이 공유하는 route 식별자가 먼저 있어야 한다. 웹의 /orders/1과 iOS의 라우트 값과 Android의 destination을 하나의 타입으로 묶으려면 그 대응을 누군가 소유해야 하고, 그것은 목록의 계약이 아니라 제품 전체의 라우팅 계약이다.

우리 제품에는 그 계약이 없었다. 그러면 남는 선택은 둘이다. 목록을 위해 그것을 새로 만들거나, 목적지를 플랫폼에 남기거나.

그런 계약이 이미 있는 조직이라면 A2가 더 나은 답이다. 목적지가 하나로 모이고 정책은 여전히 어댑터 안에 있다. B를 고른 이유는 A2가 틀려서가 아니라 목록이 그 계약을 새로 소유할 자리가 아니어서다.

의미만 공통으로 두고 실현은 플랫폼에 남겼다

B를 골랐다. 공통 계약은 의미까지만 적고, 목적지의 타입과 그 의미를 무엇으로 실현할지는 각 플랫폼이 정한다.

공통이 적는 것은 셋이다. 이 행은 이동하는 동작을 가진다. 행 전체가 그 동작의 대상이다. 우측 요소는 독립된 별개의 동작이다.

2막에서 이미 그 형태로 써뒀다.

typescript
type RowAction =
  | { actionType: 'navigation'; href: string;
      onPress?: never }
  | { actionType: 'command'; onPress: () => void;
      href?: never };

이건 Web 쪽 선언이다. 공통으로 올라가는 것은 actionType: 'navigation'이라는 의미와 목적지를 가리키는 값이 하나 필요하다는 요구까지이고, 그 값의 타입은 여기 포함되지 않는다. href: string은 Web이 자기 언어로 그 요구를 받은 결과다.

tsx
<a href={action.href}>{children}</a>

같은 요구를 API 수준에서 대응시키면 iOS 쪽은 SwiftUI의 NavigationLink가, Android 쪽은 navigation 그래프의 destination이 그 자리에 선다. 셋은 같은 문장을 세 언어로 옮긴 것이 아니다. 각자가 자기 환경에서 이동이 무엇인지 아는 지식을 공통 명세로 끌어올리지 않은 것이다.

같은 동작을 표현한다고 해서 같은 이동 구현을 공유해야 하는 것은 아니었다.

공통 계약이 정한다
actionType: 'navigation'
행 전체가 이 동작의 대상이다목적지를 가리키는 값이 하나 필요하다
Web이 실현한다
<a href={href}>
이것이 바뀌는 이유
브라우저 히스토리
라우터의 prefetch 전략
새 탭으로 열렸을 때
iOS이 실현한다
NavigationLink(value:)
이것이 바뀌는 이유
navigation stack의 모양
화면 전환 애니메이션
딥링크 진입
Android이 실현한다
navigate(destination)
이것이 바뀌는 이유
백스택 정책
화면 전환 애니메이션
딥링크 진입
위쪽 한 줄은 셋이 같고, 아래쪽에서 실현과 그 실현이 바뀌는 이유가 갈린다

내준 것도 있다. 세 플랫폼의 호출부가 글자 단위로 같아지지 않는다. Web은 문자열 href를 넘기고 iOS는 라우트 값을 넘긴다. 이동을 정의하는 코드가 세 벌 존재하고, 하나를 고쳐도 나머지 둘은 그대로다. 완전히 같은 API 경험은 포기했다.

여기까지 오면 무엇으로 그릴지를 누가 정하는가라는 물음에 답이 셋 있다는 것이 보인다. 공통 계약이 정하거나, 플랫폼 구현이 정하거나, 호출부가 정하거나다.

세 번째도 실재하는 답이다. Radix Primitives의 asChild는 기본 DOM 요소를 렌더하지 않고 자식을 복제해 필요한 props와 동작을 넘긴다. 그러면 무엇으로 그릴지를 호출부가 고르고, 접근성과 동작이 유지되는지도 호출부의 책임이 된다.

1막에서 정리한 대로 그건 공급자와 사용자가 분리되지 않은 조건에서 잘 작동하는 답이다. 이 시리즈의 공개 Recipe는 제품 코드에서 요소 선택권을 다시 열지 않으려고 지금까지 왔다. 그래서 요소를 옮긴 곳은 호출부가 아니라 플랫폼 구현의 경계였다.

공통이 정하는 것과 플랫폼이 정하는 것을 갈랐다

이 판정을 한 번만 하고 끝낼 수는 없었다. 계약의 거의 모든 줄에서 같은 질문이 다시 나온다. 그래서 목록으로 만들었다.

공통이 정한다 플랫폼이 정한다
Recipe 목록과 각 Recipe의 정보 구조 어떤 요소로 그리는가
Variant 축과 값 native semantics로 갈지 ARIA를 얹을지
동작의 의미 (이동인가 명령인가) 그 의미를 어떤 요소가 지는가
동작 영역의 구조 (pressTarget) 그 구조를 어떤 요소로 만드는가
disabled의 의미 조작 불가를 무엇으로 표현하는가
접근 가능한 이름에 무엇이 들어가는가 그 이름을 어디에 붙이는가
그룹으로서의 이름이 필요하다는 사실 그 그룹을 무엇으로 묶는가
조작 대상이 충분히 커야 한다는 요구 그 크기를 몇으로 두는가

pressTarget이 왼쪽에 있는 것이 이 표의 요점이다. 행 전체가 하나의 조작 대상인지, 행과 우측 버튼이 각각인지는 Web만의 갈래가 아니다. 보조기기를 쓰는 사람이 이 행에서 조작할 수 있는 것이 몇 개인지는 세 플랫폼 모두 알아야 하고, 읽어주는 방식만 다르다.

다만 플랫폼이 의미에 대해 아무것도 모른다는 뜻은 아니다. 포커스가 지금 어디 있는지, 컨테이너 폭이 얼마인지, 입력이 터치인지 포인터인지, 자기가 무엇을 표현할 수 있는지는 그 플랫폼만 관찰할 수 있다. 1막의 첫 번째 질문도 그런 것들을 이미 내부 규칙으로 분류해뒀다. 정확히 말하면 이렇다. 플랫폼은 제품 의미의 출처가 아니다. 제품이 정한 의미를 자기 capability와 제약에 맞게 실현한다.

그리고 이 갈래는 1막이 세운 질문들과 다른 층에 있다. 1막의 첫 번째 질문이 재는 것은 이 결정에 필요한 정보가 어디에 있는가다. 여기서 생긴 것은 이미 정해진 의미를 각 플랫폼의 primitive로 번역하는 책임을 누가 계속 들고 있는가다.

둘은 관련되어 있지만 같은 값이 아니다. 나란히 놓으면 그렇게 보인다.

결정 의미를 아는 쪽 공통 계약이 하는 일 번역을 소유하는 쪽
이 행이 이동하는 동작인가 제품 이동이라는 의미를 보존한다 각 플랫폼
이 행이 조작 불가인가 제품 disabled의 의미를 보존한다 각 플랫폼
그룹에 어떤 이름이 필요한가 제품과 화면 이름이 필수라는 것과 그 출처를 보존한다 각 플랫폼
Web에서 <a>를 쓸지 라우터 Link를 쓸지 Web 앱과 어댑터 정하지 않는다 Web
어느 목적지를 미리 받아둘지 Web 라우터 정하지 않는다 Web 어댑터

왼쪽 두 열이 같아도 오른쪽 두 열은 갈린다. 누가 아는가라는 질문을 아무리 넓게 읽어도 아래 두 줄은 공통 계약이 답할 자리가 아니고, 위 세 줄은 플랫폼이 답할 자리가 아니다. 한 플랫폼만 있을 때는 둘이 겹쳐 보여서 나눌 일이 없었다.

그래서 이 글이 하는 일을 한 줄로 쓰면 이렇게 된다.

제품과 화면이 의미를 결정하고, 공통 계약이 보존할 의미를 규정하고, 각 플랫폼이 자기 capability로 그것을 번역한다.

질문이 하나 늘어난 것이 아니라 축이 하나 늘어났다.

제품과 화면
의미를 결정한다
공통 계약
보존할 의미와 허용 조합을 규정한다
플랫폼 구현
자기 capability와 제약으로 번역한다
같은 경로를 지나는 두 사례
행을 누르면 이동한다
actionType: 'navigation'
<a href>
NavigationLink
destination
이 제목이 그룹의 이름이다
header.title이 그룹 이름의 출처다
fieldset + legend
접근성 컨테이너
semantics
의미가 결정되는 자리와 그것이 번역되는 자리는 다르다. 아래 두 예가 같은 경로를 지난다

Web에는 공통 계약이 알 수 없는 제약이 있었다

축을 갈라두면 무엇이 좋은지는 곧바로 확인됐다.

pressTarget: 'rowAndTrailing'은 셋이 함께 합의한 의미다. 행 전체를 누르면 상세로 가고, 우측 버튼을 누르면 그 자리에서 다른 동작이 실행된다.

Web이 그것을 DOM으로 만들려니 걸리는 것이 있었다. HTML 명세<a> 요소의 content modeltransparent로 두면서, interactive content 자손과 <a> 자손과 tabindex가 붙은 자손이 있어서는 안 된다고 적는다. 즉 링크 안에 버튼을 둘 수 없다.

html
<a href="/orders/1">
  <span>주문 내역</span>
  <button type="button">취소</button>
</a>

2막에서 그래서 두 동작을 형제로 갈라뒀다.

tsx
<div className="row">
  <a href="/orders/1" className="primaryAction">
    <Content />
  </a>

  <button
    type="button"
    className="secondaryAction"
    onClick={onTrailingPress}
  >
    취소
  </button>
</div>

행 전체가 눌리는 것처럼 보이는 것은 링크의 동작 영역을 행 크기까지 늘려서 만든다. 디자인이 요구한 결과는 그대로이고 DOM은 형제 둘이다.

공통 계약이 정한다
pressTarget: 'rowAndTrailing'
행과 우측 요소가 각각 독립된 조작 대상이다
중첩명세가 막는다
<a href="/orders/1"> <span>주문 내역</span> <button>취소</button></a>
a 요소에는 interactive content 자손이 올 수 없다
중첩 없음Web의 실현 하나
<div class="row"> <a href="/orders/1">…</a> <button>취소</button></div>
링크의 동작 영역을 행 크기까지 늘린다
위 구조는 명세가 허용하지 않는다. 아래는 두 조작 대상을 중첩하지 않고 만든 실현 하나다

HTML의 이 content model이 iOS와 Android에 그대로 적용되지는 않는다. 두 플랫폼에 같은 무게의 제약이 있는지까지는 확인하지 않았고, 이 절이 기대는 것은 그것이 아니다.

여기서 확인되는 것은 앞 절의 판정이 실제로 한 일이다. 공통 계약이 동작 영역의 구조를 요소 구조와 분리해뒀기 때문에, Web은 HTML 제약을 자기 구현 안에서 해결할 수 있었다. 공통 계약은 행과 우측 요소가 각각 독립된 조작 대상이라는 문장까지이고 한 글자도 바뀌지 않았다.

이건 Web이 부족하다는 이야기가 아니다. 플랫폼마다 실현의 제약이 다르고, 그 제약을 공통 계약이 몰라도 되게 만든 것이 축을 가른 대가로 얻은 것이다.

공통 계약은 플랫폼 API의 교집합이 아니었다

두 번째 사례는 목록의 제목이다. 3막에서 ListHeadertitlecount와 동작 세 형태로 정리했고, 디자인 관점에서 그것은 목록 위에 놓인 한 줄이다.

그 한 줄이 화면에서 제목 역할을 한다는 것을 보조기기에 알리려면 무엇이 필요한지 세 플랫폼에 물어봤더니 답이 갈렸다.

찾아보면 답이 갈린다. 아래는 문서가 표현할 수 있다고 적은 범위이고, 보조기기가 실제로 그렇게 읽어주는지는 별개다. 뒤에서 그 구분을 다시 꺼낸다.

Web에는 h1부터 h6까지가 있고 aria-level도 있다. 위계가 숫자로 표현된다. iOS는 SwiftUI의 accessibilityHeading(_:).h1부터 .h6까지와 .unspecified를 받는다. 같은 iOS라도 UIKit에서 요소에 거는 것은 UIAccessibilityTraits.header이고 켜거나 끄는 값이다. 위계는 요소가 아니라 attributed string의 텍스트 속성으로 따로 있어서, 요소 단위로 위계를 거는 자리와는 층이 다르다. Android의 Compose는 Modifier.semantics { heading() }이고 인자를 받지 않는다.

여기서 흔히 두 방향으로 간다.

  • 최소 공통분모로 내린다. 전부 지원하는 것만 공통에 둔다. heading인지 아닌지를 켜고 끄는 boolean 하나만 남고 Web과 SwiftUI의 표현력을 버린다
  • 지원하는 쪽에 맞춰 올린다. 둘이 되니까 headingLevel을 공통 API에 넣는다. 소비할 수 없는 플랫폼이 값을 받아 무시한다

둘 다 플랫폼 capability에서 출발했다는 점이 같다. 공통 계약은 거기서 나오지 않는다.

공통 계약은 플랫폼 API의 교집합이 아니라, 제품과 디자인 시스템이 플랫폼을 넘어 보존하기로 한 의미와 정책의 모델이다. 각 플랫폼은 자기 capability만큼 그것을 번역한다.

그러면 질문의 순서가 바뀐다. 플랫폼 조사가 아니라 제품 쪽 조사에서 답이 나온다.

  1. 이 제품의 정보 구조에 heading 위계라는 의미가 실재하는가
  2. 있으면 공통 계약에 넣는다. 어느 플랫폼이 지원하는지와 무관하게
  3. 각 플랫폼이 capability만큼 번역한다. 켜고 끄는 값만 있는 쪽은 heading이라는 사실까지 소비하고 위계는 버린다
  4. 없으면 넣지 않는다. Web이 지원하더라도
가정 · 이 의미를 공통 계약에 넣는다면
이 제목은 heading이고, 위계는 2단계다
각 문서가 표현한다고 적는 범위
Webh2 · aria-level
heading이다
위계는 2단계다
iOS · SwiftUIaccessibilityHeading(.h2)
heading이다
위계는 2단계다전달 미확인
iOS · UIKit (요소)UIAccessibilityTraits.header
heading이다
위계는 여기서 표현하지 않는다
Android · Composesemantics { heading() }
heading이다
위계는 여기서 표현하지 않는다
UIKit은 요소에 거는 트레이트 기준이다. 위계는 attributed string의 텍스트 속성으로 따로 있다
같은 의미를 네 환경이 어디까지 번역하는지. 두 곳은 위계까지 가고 두 곳은 그 앞에서 멈춘다

확인하지 못한 것이 있다. SwiftUI에서 위계가 VoiceOver에 실제로 전달되려면 .accessibilityAddTraits(.isHeader)를 함께 걸어야 한다는 보고가 있고, UIKit의 텍스트 속성 쪽도 동작 보고가 엇갈린다. 어느 쪽도 직접 재보지 않았다. 그래서 위 그림은 어느 API가 그 값을 표현하도록 문서화되어 있는지까지이고, 실제 낭독 결과가 아니다.

만약 실전에서 위계가 전달되는 것이 Web 하나뿐이더라도 위 네 줄은 그대로 선다. 순서가 플랫폼이 아니라 제품에서 시작하기 때문이다. 바뀌는 것은 3번에서 위계를 버리는 플랫폼이 몇이냐뿐이다.

시각적으로 하나였던 제목이 네 가지 역할을 갖고 있었다

그런데 위계를 묻기 전에 먼저 걸린 것이 있었다. 그 제목이 애초에 무엇인가.

디자인 파일에는 최근 거래 한 줄이 있다. Web에서 그 한 줄은 최소 네 가지 역할을 가질 수 있다.

  • 문서 구조상의 heading인가. 그렇다면 <h2> 같은 요소로 그린다
  • 목록의 접근 가능한 이름의 출처인가. 그렇다면 aria-labelledby로 목록에 연결한다
  • 선택형 목록에서 그룹 이름의 출처인가. 그렇다면 <legend>로 그린다
  • 아무 시맨틱도 지지 않는 시각 라벨인가

넷은 서로 배타적이지 않다. 앞 글도 이 셋을 두고 서로 다른 것이고 하나를 고른다고 나머지가 따라오지 않는다고 적었지, 동시에 가질 수 없다고 하지 않았다. 실제로 한 제목이 문서 heading이면서 동시에 목록의 이름일 수 있다.

html
<h2 id="recent">최근 거래</h2>
<ul aria-labelledby="recent">

그래서 물을 것은 넷 중 무엇으로 그릴지가 아니다. 이 제목에 어떤 의미 관계를 부여하고, 그 관계를 각각 무엇으로 실현할지가 물음이다. 역할마다 따로 판정한다.

역할이 갈리면 스크린리더에서 읽히는 것이 달라진다. heading이면 보조기기의 heading 탐색 대상으로 노출되고, 목록의 이름이면 목록에 들어갈 때 함께 읽히고, 그룹의 이름이면 이 선택지들이 하나의 질문에 대한 답이라는 것이 전달되고, 아무 역할도 없으면 그냥 지나가는 텍스트다.

무엇을 부여하든 적용할 CSS는 같다. 글꼴 크기도 굵기도 여백도 하나의 값에서 나온다. 그래서 시각 시안을 비교하는 것만으로는 이 차이가 구별되지 않는다. 구현을 나란히 놓고 화면을 봐도 같다. 갈라지는 자리는 화면이 아니라 접근성 트리다.

디자인 파일에 있는 것
최근 거래
역할마다 따로 답한다 · 하나를 고른다고 나머지가 정해지지 않는다
문서 구조상의 heading인가<h2>이 목록이 페이지의 섹션인가
목록 이름의 출처인가aria-labelledby이 제목이 목록을 가리키는가
선택 그룹 이름의 출처인가<legend>이 제목이 곧 질문인가
아무 역할도 지지 않는가없음나머지 전부
한 제목이 문서 heading이면서 동시에 목록 이름의 출처일 수 있다
한 제목에 붙을 수 있는 네 역할. 배타적인 갈래가 아니라 각각 따로 판정하는 관계다

어떤 역할을 지는지는 컴포넌트가 판정할 수 없었다

컴포넌트가 스스로 판정할 수 있을지 후보를 대봤다. 전부 컴포넌트 밖의 정보를 필요로 했다.

헤더가 있으면 <h2>로 그린다. 이 목록이 페이지의 섹션인지, 이미 제목을 가진 카드 안에 들어 있는지, 위에 몇 단계가 있는지를 컴포넌트가 알 방법이 없다. 카드가 놓인 문맥에 따라 <h2>가 문서 위계를 어긋나게 만들 수 있다.

선택형이면 <legend>로 그린다. 목록이 선택형인 것과 그 제목이 그룹의 이름인 것은 다른 사실이다. 헤더에 최근 거래가 있고 실제 질문은 어느 계좌로 받을까요일 수 있다.

항상 aria-labelledby로만 연결한다. 안전하지만 문서 위계를 표현할 방법이 사라진다.

공통점은 하나다. 이 목록이 화면에서 무엇인지는 화면이 안다. 컴포넌트가 스스로 아는 것은 자기가 받은 입력까지이고, 자기가 어느 문서의 어느 자리에 놓였는지는 그 안에 없다.

3막에서 비슷한 모양을 한 번 봤고, 그때는 답이 달랐다. 행 하나는 자기가 첫 번째인지 마지막인지 몰랐는데 목록은 알았다. 배열을 가진 쪽이 형제를 볼 수 있었기 때문이고, 그래서 계층을 하나 올리는 것으로 답이 나왔다.

여기서는 그 방법이 통하지 않는다. 판정에 필요한 것이 목록 바깥에 있다. 목록을 감싼 카드가 이미 제목을 갖고 있는지, 이 페이지에 다른 섹션이 몇 개 있는지는 목록 계층이 독립적으로 판정할 수 없다. 화면 문맥을 아는 쪽이 명시적으로 넘겨줘야 끝난다.

그래서 경계를 이렇게 뒀다. 컴포넌트는 어떻게 보이는가를 제공하고, 화면은 무엇을 의미하는가를 정한다.

그리고 이건 Web만의 문제가 아니다. iOS도 Android도 같은 것을 묻는다. 이 제목이 heading인지 그룹 이름인지 그냥 라벨인지는 표현 수단과 무관하게 제품이 답해야 한다. 의미의 출처는 플랫폼이 아니라 제품이다를 여기서 두 번째로 확인한다.

heading 위계는 공개하지 않기로 했다

그러면 공개 API에 무엇을 두어야 하는지가 남는다. 후보 셋을 놓고 봤다.

화면이 heading을 통째로 소유한다. ListHeader는 시각 라벨만 그리고, 문서 heading이 필요한 화면은 목록 밖에 자기 <h2>를 둔다. 공개 API가 늘지 않는다. 대신 3막에서 제목을 목록 안으로 가져온 근거였던 것들, 제목과 목록 사이의 간격과 정렬이 다시 화면으로 새어 나간다.

headingLevel을 연다. 자리도 유지하고 위계도 표현된다. 같은 목록이 다른 화면에 놓이면 값이 달라지는데, 그건 그 자체로 비용이 아니다. 문서 위계가 화면 문맥에서 정해진다면 그 값을 화면이 공급하는 것이 오히려 맞는 배치다.

이름 연결만 연다. 목록에 접근 가능한 이름을 붙이는 것까지만 하고 문서 heading은 만들지 않는다.

기각 근거는 하나로 좁혔다. 당시 확인한 승인 화면과 ListHeader 용례에서는 목록 안의 제목이 문서 위계를 소유하는 경우가 없었다. 목록 위의 제목은 대부분 카드 안의 라벨이었고, 페이지 구조를 이루는 섹션 제목은 목록 밖에 따로 있었다. 앞 절의 네 줄을 그대로 적용하면 1번에서 아니오가 나온다. 그러니 headingLevel을 열지 않은 이유는 Android가 못 써서가 아니라 그 입력을 필요로 하는 승인된 의미가 아직 없어서다.

이건 구조적으로 영원히 필요 없다는 결론이 아니다. 목록 자체가 섹션의 heading을 소유하는 화면이 승인되면 같은 절차로 그때 열면 된다. 열게 되더라도 그것은 Variant가 아니다. 시각 결과를 하나도 바꾸지 않으므로 1막이 말한 승인된 표현 선택이 아니다. 화면의 문서 구조를 전달하는 입력이라 기존 분류 안에서 다룰지 종류를 하나 늘릴지는 그 사례가 생길 때 다시 판단한다.

앞 글이 비워둔 그룹 이름 자리를 채웠다

열지 않은 것과 별개로, 앞 글이 형태를 정하지 못하고 남겨둔 자리가 하나 있었다. 선택형 목록의 그룹 이름이다. 이 선택지들이 하나의 질문에 대한 답이라는 것을 알려야 한다는 책임까지는 정했고, 무엇으로 실현할지는 제목의 시맨틱 역할이 정해져야 답이 나온다고 적었다.

이제 답이 나온다. Web에서는 <fieldset>으로 묶고 <legend>에 이름을 둔다.

html
<fieldset>
  <legend>어느 계좌로 받을까요</legend>
  <ul>
    <li><label><input type="radio" ...></label></li>
  </ul>
</fieldset>

공개 API에는 무엇이 붙나. 여기서도 후보가 셋이다.

groupLabel: string을 따로 받는다. ListHeader와 두 벌이 된다. 같은 문장이 화면에 두 번 그려지거나, 하나를 시각적으로 숨겨야 한다.

항상 ListHeader의 제목을 <legend>로 쓴다. 제목이 그룹 이름이 아닌 경우를 표현할 수 없다. 앞 절에서 본 최근 거래어느 계좌로 받을까요의 차이가 그대로 걸린다.

제목이 그룹 이름 역할을 하는지를 받는다. 이것을 골랐다.

typescript
type GroupLabel =
  | { from: 'headerTitle' }
  | { from: 'text'; text: string };

출처는 헤더 전체가 아니라 header.title 하나다. ListHeaderSpec에는 숫자가 붙는 형태도 있고 버튼이 붙는 형태도 있는데, 그 숫자와 버튼까지 그룹의 이름이 되면 안 된다. countaction은 이름에 들어가지 않고, <legend> 안으로 헤더를 통째로 옮긴다는 뜻도 아니다.

from: 'headerTitle'header가 있어야 성립하므로 둘을 한 브랜치에 묶었다.

typescript
type SelectableListProps<
  C extends ListRowComponent<any, any>,
> = {
  itemComponent: C;
  items: readonly ListItem<RowInputOf<C>>[];
  empty: ListEmptySpec;
  divider?: 'none' | 'inset' | 'full';
  surface?: 'plain' | 'card';
} & ListSelection &
  (
    | { groupLabel: { from: 'headerTitle' };
        header: ListHeaderSpec }
    | { groupLabel: { from: 'text'; text: string };
        header?: ListHeaderSpec }
  );

groupLabel은 필수다. 그룹 이름 없는 선택형 목록은 표현할 수 없다. 앞 글이 multiple을 독립적인 스위치 여러 개가 아니라 하나의 질문 아래 여러 값을 고르는 형태로 좁혀뒀으므로, 이름 없는 그룹은 이 Recipe가 표현하는 형태가 아니다.

from: 'text'에는 대가가 있고 그걸 적어둬야 한다. 그 문자열은 화면에 보이지 않으면서 보조기기 사용자에게는 읽히는 제품 문구가 된다. 번역 대상이고, 보이는 헤더와 다른 값을 넣으면 두 사용자 집단이 서로 다른 질문을 받게 되고, 값이 잘못돼도 시각 QA로는 걸리지 않는다. 그래서 보이는 질문을 두는 쪽이 기본이고 text는 그럴 수 없는 승인된 경우의 명시적 예외로 뒀다.

여기서 SelectableList가 새로 받은 것은 요소가 아니라 이 제목이 그룹의 이름이라는 의미 하나다. <fieldset><legend>도 공개 API에 나타나지 않는다. Web은 그 의미를 그 두 요소로 실현하고, iOS와 Android에서 그 자리에 대응하는 것은 각각 접근성 컨테이너와 semantics다. 두 플랫폼의 실현까지 직접 만들어보지는 않았다.

이 자리가 앞의 판정이 실제로 무엇을 했는지 보여준다. 앞 글이 자리를 비워둔 이유는 요소를 못 정해서가 아니었다. 제목이 무엇을 의미하는지를 안 정해서였다. 의미가 정해지자 요소는 플랫폼마다 따라왔다.

그 공통 계약은 실제로 어디에 있었나

여기까지 공통 계약이라는 말을 계속 썼다. 그 왼쪽 열은 실제로 어디에 존재하는가.

이 자리에서 흔히 하나의 선택처럼 다루는 것이 실은 넷이다. 계약의 정본이 어디에 있는가, 그것을 어떤 형식으로 표현하는가, 각 플랫폼이 어떻게 구현하는가, 세 구현이 같은 의미를 지키는지 어떻게 확인하는가. 넷은 따로 고를 수 있다.

우리가 고른 조합은 이렇다.

결정 고른 것
정본 의미와 근거를 담은 문서 한 벌
표현 형식 산문. 기계가 읽는 스키마를 두지 않았다
구현 각 플랫폼이 자기 언어로 다시 선언한다. RowActionpressTarget이 그 Web 쪽 선언이다
확인 사람이 본다

정본을 문서로 두었다고 해서 형식과 확인까지 따라오는 것은 아니다. 기계로 표현할 수 있는 부분만 작은 스키마로 떼어낼 수도 있고, 세 구현이 같은 의미를 지키는지 공통 시나리오로 확인할 수도 있다. 반대로 스키마를 정본으로 두더라도 prefetch 같은 플랫폼 정책까지 그 스키마가 소유해야 하는 것은 아니다. 앞 절의 A2가 정확히 그 조합이다.

스키마가 담기 어려운 것도 좁혀서 말해야 한다. groupLabel이 필수라는 것은 스키마가 충분히 표현한다. 담기 어려운 것은 왜 그것이 필수인지, 어떤 제품 의미를 보존하려는 것인지 같은 근거와 해석 규칙이다.

그러면 문서로만 있는 것을 계약이라고 부를 수 있나. 부를 수 있다. 규범이 있고 바꾸는 절차가 있으면 계약이다. 각 플랫폼의 타입은 그 구현 안에서 계약을 실제로 강제하기도 한다. 다만 정확히 말하면 이렇다.

계약은 규범으로 존재하지만, 세 선언이 같은 의미를 유지하는지를 빌드가 증명하지는 않는다.

그러니 잃은 것은 확인 전체가 아니라 중앙 스키마에 의한 정적 동일성 검사다. 얻은 것은 플랫폼 구현을 서로 건드리지 않고 바꿀 수 있다는 것이고, 남은 위험은 세 선언이 조용히 어긋나는 것이다. 그 어긋남을 어떻게 운영할지는 이 글이 정하는 범위 밖이다. 이 글이 정한 것은 실현의 소유권까지다.

그리고 이 글의 코드는 한쪽만 실물이다. Web의 실현은 실제로 쓴 코드이고, iOS와 Android는 어떤 API가 그 자리에 대응하는지까지만 적는다. 세 구현을 나란히 놓고 비교하는 글이 아니라 무엇을 공유하고 무엇을 나눌지 정하는 글이라, 그 선에서 멈춘다.

플랫폼 차이는 제거 대상이 아니었다

이 글에서 한 일을 모으면 같은 판정을 세 번 했다.

사례 공통이 정한 것 플랫폼이 정한 것
행을 누르면 이동한다 동작의 의미와 목적지가 필요하다는 요구 목적지의 타입과 그것을 해석하고 실행하는 방법
행과 우측 요소가 각각 조작 대상이다 동작 영역의 구조 중첩 없는 구조를 만드는 방법
이 제목이 그룹의 이름이다 의미 하나 <fieldset><legend>, 그리고 두 플랫폼에서 그 자리에 대응하는 API

세 번 다 같은 것을 했다. 의미를 결정하는 소유자와 그것을 실현하는 소유자를 갈랐다.

그렇다고 이 글이 일관성을 포기한 것은 아니다. 무엇을 같게 만들었는지 적어두는 편이 정확하다.

세 플랫폼에서 같게 만든 것 플랫폼에 남긴 것
쓸 수 있는 Recipe와 Variant의 목록 무엇으로 그리는가
각 Recipe의 정보 구조와 허용 조합 목적지 값의 타입과 해석
이동인가 명령인가라는 동작의 의미 라우터와 히스토리 정책
조작 대상의 개수와 관계 (pressTarget) 그 관계를 만드는 구조
disabled의 제품 의미 조작 불가의 표현
선택 그룹에 이름이 필요하다는 것과 그 출처 이름을 접근성 트리에 올리는 API

일관성을 요소와 API 철자의 동일성으로 정의하지 않았다. 같게 만든 것은 제품 의미와 허용 조합과 조작 대상의 관계이고, 그것을 각자의 primitive로 번역하는 방법은 플랫폼에 남겼다.

세 구현의 호출부를 최대한 가깝게 만드는 것이 목표였다면 A1이나 A2를 골랐을 것이다. 다만 A1은 호출부 표면만 같아지고 실행 구현의 차이는 그대로 남는다. 그 표면을 얻으려고 세 플랫폼이 하나의 정책 집합을 함께 들게 된다.

그래서 나눈 이유는 차이를 없애기 위해서가 아니라 각 책임이 독립적으로 변할 수 있게 하기 위해서다. Web의 prefetch 전략이 바뀌어도 iOS 코드는 그대로다. iOS가 화면 전환을 바꿔도 공통 명세는 그대로다. 같은 변경 이유를 가진 책임만 공유했다.

하나의 결정이 다음 경계를 만들었다

그런데 실현을 플랫폼이 소유하기로 하면 그 플랫폼의 제약도 함께 들어온다. 이 글에서 이미 한 번 봤다. 링크 안에 버튼을 못 넣는다는 제약이 그것이고, 그건 계약을 고치지 않고 넘어갔다.

Web에는 그런 제약이 더 있다. 화면 폭이 실행 중에 바뀐다는 것, 첫 렌더가 서버에서 일어난다는 것, 어떤 코드는 경계를 넘지 못한다는 것. 앞의 세 막이 만든 계약은 그 경계들까지 미리 담아두지는 않았다.

이 막의 나머지 다섯 편이 그 계약을 다른 경계에 하나씩 대본다. 이 글에서 세운 축이 거기서도 그대로 쓰인다. 무엇이 의미이고 무엇이 실현인지를 가르는 일이 플랫폼 사이에서만 필요한 것이 아니기 때문이다.

다음 글의 경계는 폭이다. 같은 목록이 본문 한가운데 놓일 때와 좁은 사이드바에 놓일 때 다르게 보여야 했다. 1막은 그 변화를 이미 분류해뒀다. 제품도 컴포넌트도 모르고 환경만 아는 값이라 내부 규칙이다. 아직 정하지 않은 것은 그 환경값을 플랫폼 안의 어느 계층이 관찰하고, 목록의 계약을 건드리지 않으면서 어떻게 실현하는가다.