ListRow에 열 수 있었던 prop을 왜 열지 않았을까
이 결정을 누가, 어디서, 몇 번 하는지로 갈랐다
앞 글은 Leading을 descriptor로, Trailing과 동작을 상호작용 계약으로 받았다. 이제 반대편을 본다. 같은 Recipe에서 아예 받지 않은 값들은 어떤 기준으로 props 밖에 남았을까.
정보 구조가 다른 묶음은 Recipe로 나누고, 같은 정보 구조 안에서 달라지는 승인된 표현은 각 Recipe의 variant 축에 남겼다. 그 어느 쪽에도 속하지 않는 조합은 공개 API로 표현할 수 없게 뒀다.
그렇게 나누고 나서도 남는 질문이 있었다. 각 Recipe가 받는 값 중에 여기서 정하면 안 되는 것은 무엇인가.
가장 복잡한 행 하나를 끝까지 따라가보면 그 답이 한 자리에 모인다.
로고는 시스템의 자산이 아니라 제품의 콘텐츠였다
여러 제공사의 상품을 나란히 놓고 지표를 비교하는 화면에서 쓰던 행이다. 네 영역을 모두 사용하는 유일한 행이기도 했다.
- Leading → 제공사 로고
- Content → 위쪽에 제공사명, 아래쪽에 상품명
- Trailing → 화살표. 누르면 상품 상세로 간다
- Bottom → 주요 지표 두 개, 그 아래 상품 태그 배지 여러 개
Leading부터 갈렸다.
제공사 로고는 아이콘처럼 보이지만 디자인 시스템의 자산이 아니었다. 아이콘은 시스템이 만들어 배포하는 유한한 목록이다. 제휴사는 계약이 늘 때마다 늘고, 로고 하나가 추가될 때마다 아이콘 세트를 다시 배포할 수는 없다.
같은 자리에 놓이지만 소유자가 달랐다. 그래서 로고는 'icon'이 아니라 'image'로 받았고, 이 Recipe에서는 'icon' 브랜치가 아예 선택지가 아니다. Leading이 비어 있는 형태도 없다.
type ListRowMaxData = {
// 로고는 시스템 자산이 아니라 제품의 콘텐츠다.
// 그래서 icon 브랜치는 선택지가 아니고, 비어 있을 수도 없다.
leading: { type: 'image'; src: string };
title: string;
subtitle: string;
// Bottom은 이 Recipe에서 optional이 아니다.
supporting: SupportingContent;
disabled?: boolean;
};
type SupportingContent = {
metrics: readonly [ListMetric, ListMetric];
badges: readonly ListBadge[];
};
type ListMetric = { label: string; value: string };
type ListBadge = { label: string; tone: 'accent' | 'neutral' };앞 글의 LeadingSpec은 'image' 브랜치에서 alt를 요구했다. 이 Recipe는 그 자리를 좁혀서 아예 받지 않는다.
이 Recipe에서는 로고가 가리키는 대상이 바로 옆의 title이다. 이미지가 이름을 한 번 더 만들면 링크의 접근 가능한 이름에 제공사명이 두 번 들어간다. Recipe는 alt=""로 고정해 이미지를 접근성 트리에서 빼고, 이름은 title이 만든다.
로고를 정보로 읽을지 장식으로 처리할지는 이 Recipe의 정보 구조가 정하는 문제였다. 호출부에 alt를 열어두면 같은 대상에 이름이 하나인 행과 둘인 행이 섞인다.
ListMetric에 label을 남긴 것도 같은 종류의 결정이었다.
화면에는 값만 보인다. 그래서 ['5.08%', '300만원'] 형태로 받아도 렌더링은 된다. 하지만 그렇게 받으면 두 값의 순서를 바꿔 넣어도 코드만 봐서는 드러나지 않고, 라벨을 노출하는 디자인 변경이 들어오면 데이터 구조부터 다시 만들어야 한다.
화면에 보이는 것과 데이터가 알고 있어야 하는 것은 같지 않았다.
네 번째 영역을 만들고 싶지 않았다
Leading · Content · Trailing 세 영역으로 끝내고 싶었다.
지표와 태그는 결국 Content가 길어지는 것 아닌가. 영역을 하나 더 만들면 Figma의 anatomy도, 그것을 따르는 세 구현의 API도 함께 늘어난다.
영역을 나누게 만든 것은 정보 구조가 아니라 세로 정렬이었다.
로고와 화살표는 카드 전체 높이의 중앙이 아니라 제공사명·상품명 블록의 중앙에 맞춰야 했다. 지표와 태그가 몇 줄이 되든 두 요소의 세로 위치는 움직이면 안 됐다.
지표와 태그를 Content 안에 함께 넣으면 기준선 자체가 내려간다. 첫 블록의 높이를 CSS 값으로 고정하는 우회로도 있지만, 제목이 두 줄로 감기는 순간 그 값이 틀린 값이 된다. 코드에 적어둔 높이는 텍스트가 몇 줄로 감길지 모른다.
행을 두 줄로 나누면 브라우저가 1행의 실제 높이를 재서 정렬한다.
여기서 이 영역의 정체가 드러났다. Bottom은 정보 계층이 달라서 생긴 영역이 아니었다. Leading과 Trailing의 정렬 기준이 되는 블록과, 그 기준에 영향을 주면 안 되는 블록의 경계였다.
그렇다면 이 경계를 Content 안쪽에 숨길 수도 있었다. content: { title, subtitle, supporting } 형태로 두면 영역은 계속 세 개이고, 렌더링되는 DOM도 같다.
그렇게 하지 않은 이유는 이름 때문이었다.
Content가 단지 길어지는 것뿐이라는 인식이 바로 이 경계가 필요한 이유였다. 경계에 이름이 없으면 다음 사람이 Content에 한 줄을 더 넣을 때 정렬이 깨지는데, 왜 깨졌는지 알려줄 근거가 코드에 남지 않는다. Bottom이라는 이름은 여기부터 다른 줄이라는 규칙을 API에 남기는 장치였다.
그리고 이건 디자인 정책의 결과이기도 했다. 로고와 화살표를 행 전체 높이 기준으로 가운데 정렬했다면 네 번째 영역은 필요 없었다.
영역이 하나 늘어난 것은 정보 구조의 요구가 아니라 정렬 정책의 요구였다. 공개 API의 형태는 타입 설계만으로 정해지지 않았다.
네 영역을 하나의 동작 영역으로 묶자 Bottom도 조작 요소를 가질 수 없었다
Bottom을 별도 영역으로 둔 결정은 레이아웃에서 끝나지 않았다.
이 행은 전체가 하나의 동작 영역이다. 누르면 상품 상세로 간다. 그러면 네 영역이 모두 같은 영역 안에 들어간다.
<li>
<a href="/products/123" class="rowMax">
<img class="leading" src="..." alt="" />
<span class="content">
<span class="title">제공사 A</span>
<span class="subtitle">상품명</span>
</span>
<svg class="trailing" aria-hidden="true">...</svg>
<span class="bottom">
<span class="metrics">...</span>
<span class="badges">...</span>
</span>
</a>
</li>동작 영역 안에 다른 조작 요소를 중첩할 수는 없다. 링크 안의 버튼은 유효한 HTML이 아니고, 보조기기에서도 무엇이 실행되는지 모호해진다.
그래서 Bottom의 지표와 배지는 전부 비조작 요소여야 했다. 배지를 필터 토글로 쓰고 싶다는 요구가 들어와도 이 Recipe에서는 받을 수 없다.
행 우측에 별도 버튼을 두는 형태는 열지 않았다.
구조 자체가 막힌 것은 아니었다. 앞 글의 rowAndTrailing처럼 행의 주 동작과 우측 보조 동작을 중첩하지 않고 나란히 두면서 Bottom을 주 동작 안에 넣는 DOM은 만들 수 있다. 정렬 구현이 복잡해질 뿐이다.
열지 않은 이유는 승인된 조작 구조에 그 형태가 없었다는 것뿐이다. 네 영역을 하나로 묶는 단일 동작 영역과, 주 동작과 보조 동작을 나누는 형태는 DOM 구조가 다르다. 아직 없는 요구를 위해 미리 공개하지 않았고, 요구가 생긴다면 기존 Recipe의 variant 축인지 별도 Recipe인지 세 번째 질문으로 다시 분류해야 한다.
그래서 이 Recipe는 pressTarget을 공개하지 않았다. 네 영역을 감싸는 행 전체가 동작 영역이라는 결정은 Recipe가 이미 끝낸 값이다. 호출부가 넘기는 것은 그 영역에서 실행할 rowAction뿐이다.
type MaxInteraction = {
rowAction: RowAction;
};pressTarget: 'row'는 Recipe 구현이 내부 ListRowPrimitive에 고정해서 넘긴다.
function ListRowMax(props: ListRowMaxProps) {
return (
<ListRowPrimitive pressTarget="row" rowAction={props.rowAction}>
...
</ListRowPrimitive>
);
}화살표는 이 Recipe에서 고정이고, 화살표가 있으면 동작이 있어야 한다는 앞 글의 계약이 그대로 걸린다. 그래서 rowAction은 선택적이 될 자리가 없다.
라우터는 열고 아이콘은 제한했다
rowAction은 두 종류였다. 이동과 명령이다.
type RowAction =
| { actionType: 'navigation'; href: string; onPress?: never }
| { actionType: 'command'; onPress: () => void; href?: never };이 행에서 둘 다 쓰였다. 평소에는 상품 상세로 가지만, 점검 중이거나 신청 가능한 시간대가 아닌 상품은 같은 행이 안내 토스트를 띄우는 command가 된다. 화살표는 그대로 있고 눌렀을 때 일어나는 일만 달라진다.
계약을 정리하면서 이 구분을 없앨지 검토했다. 핸들러 하나면 충분해 보였기 때문이다. 어디로 갈지는 사용처가 결정하는 일이고, 디자인 시스템이 경로를 알아야 할 이유는 없다.
없앨 수 없었던 이유는 렌더링되는 HTML 요소가 달라서였다. 핸들러만 받으면 행 표면은 <button>이 된다. 상품 상세는 공유되고 북마크되는 URL이었고, 여러 상품을 새 탭으로 열어 나란히 비교하는 것도 실제 사용 방식이었다. <button>에는 그 동작이 없다.
문제는 그다음이었다. <a href>를 그대로 렌더하면 이번에는 프레임워크 라우터를 지나치게 된다. Next.js App Router에서 평범한 <a>를 쓰면 <Link>가 제공하는 prefetch와 클라이언트 전환 경로를 지나지 않고, 같은 출처로의 이동도 브라우저의 문서 이동으로 처리된다.
그렇다고 디자인 시스템이 특정 프레임워크의 <Link>를 직접 가져다 쓸 수는 없었다. 이 디자인 시스템을 쓰는 앱이 Next.js뿐이 아니었다.
그래서 라우터 구현을 앱 진입점에서 한 번 등록받았다.
type LinkComponentProps = Omit<
ComponentPropsWithRef<'a'>,
'href'
> & { href: string };
type LinkComponent = ComponentType<LinkComponentProps>;href와 children만 받는 형태로 시작했다가 <a> 호환으로 넓혔다. 앞 글에서 행의 링크에 aria-label을 붙이고 비활성 상태에서 aria-disabled를 노출하며 실행 경로를 직접 막았는데, 좁은 props로는 그 값들이 어댑터를 통과하지 못한다.
이 타입이 정하는 것은 어댑터가 받아야 하는 입력까지다. 등록된 컴포넌트가 실제로 <a>를 렌더하고 href와 접근성 속성과 핸들러와 ref를 최종 <a>까지 넘기는지는 ComponentType만으로 증명되지 않는다. 그 부분은 등록하는 쪽이 지켜야 할 런타임 계약으로 남았다.
<DesignSystemProvider linkComponent={NextLink}>
<App />
</DesignSystemProvider>href를 받는 라우터는 그대로 등록되고, to를 받는 라우터는 어댑터를 한 번 거친다.
const link: LinkComponent = ({ href, ...rest }) => (
<RouterLink to={href} {...rest} />
);navigation은 등록된 LinkComponent로, command는 <button>으로 렌더한다.
여기서 앞선 결정 하나와 충돌하는 것처럼 보인다.
Leading은 컴포넌트를 받지 않기로 했다. 그런데 LinkComponent는 컴포넌트 참조다.
앞 글은 세 값을 갈라뒀다. 행마다 달라지는 아이콘 이름은 제품이 알고 descriptor prop으로 왔다. 그 이름을 실제로 그리는 컴포넌트 구현은 디자인 시스템이 갖고 공개하지 않았다. 라우터는 소유자가 반대쪽이다. 어떤 Link 구현을 쓸지는 제품 앱이 안다.
다만 제품이 아는 값이라는 사실만으로 받는 자리가 정해지지는 않았다. 아이콘 이름은 행마다 달라져 각 Recipe의 prop으로 받아야 했고, Link 구현은 같은 하위 트리의 모든 행이 같은 것을 쓴다. 매 행에 같은 값을 반복해 넘기는 대신 앱 진입점에서 한 번 주입했다.
앱 전역 값이면 언제나 Context로 보내야 한다는 규칙은 아니다. 모듈 설정으로 두거나 상위에서 prop으로 내려도 된다. 여기서 Provider를 고른 근거는 반복 전달 비용과 공유 범위였고, 테스트에서 다른 구현으로 바꿔 끼우기 쉽다는 것도 함께 봤다.
같은 자리에 prefetch도 섰다. 어떤 링크를 미리 불러올지는 그 앱의 라우팅 구조와 데이터 로딩 정책이 정한다. List가 답을 갖고 있지 않은 값이라 행별 prop으로 열지 않았고, 대신 고정하지도 않았다. 이 구현에서는 앱이 등록한 어댑터의 정책을 따르게 했고, 링크마다 다른 입력이 필요해진다면 어댑터 계약을 다시 봐야 했다. 컴포넌트가 자신이 책임질 수 없는 정책을 소유하지 않는다는 것이 여기서도 같은 기준이었다.
마지막 구멍은 className이었다
여기까지 제한하고 나서도 한 줄이면 전부 되돌릴 수 있었다.
<ListRowMax
{...data}
rowAction={{ actionType: 'navigation', href: '/products/1' }}
className="py-0 text-sm opacity-40"
/>이 한 줄이면 여백도, 폰트 크기도, 비활성 표현도 호출부가 다시 정한다. 영역을 나누고 조작 범위를 좁히고 동작의 종류를 가른 결정이 문자열 prop 하나로 열린다.
그리고 이 결정은 화면마다 반복된다. 앞의 기준을 그대로 대면 className은 열 수 없는 자리다. 라우터처럼 한 번 등록되고 끝나는 값이 아니라, 행이 그려지는 자리마다 다시 내려지는 결정이기 때문이다.
주석으로 className을 받지 않는다고 적는 것과 타입으로 막는 것은 다르다.
type NoStyleEscape = {
className?: never;
style?: never;
};
type ListRowMaxProps = ListRowMaxData & NoStyleEscape & MaxInteraction;행 사이 간격이나 목록을 감싸는 여백처럼 정당한 요구는 List 바깥에서 처리한다. 행 내부의 여백과 크기는 Recipe가 소유한다.
이 자리를 막아두면 예외 요구가 들어왔을 때 분류를 건너뛸 수 없다. variant 축으로 받을지, 새 Recipe로 만들지, 제품 로컬 구현으로 보낼지 정해야 한다. 탈출구가 열려 있으면 그 분류를 거치지 않고도 화면이 완성된다.
공개 API가 표현할 수 있는 범위가 곧 공통 List로 만들 수 있는 UI였다
행 하나의 props를 정하는 동안 실제로 정한 것은 각 값이 어디에 놓이는가였다.
1막의 첫 번째 질문은 이 결정에 필요한 정보를 누가 갖고 있는가였다. 그 질문이 소유자를 정하고 나면 한 단계가 더 남는다. 제품이 소유한 값이라도 그것이 행마다 달라지는지 앱 전체에서 한 번 정해지는지에 따라 받는 자리가 달라졌다. 새 판단 기준이 아니라 소유자를 정한 다음의 실행 단계다.
| 결정 | 소유자 | 결정이 달라지는 단위 | 공개 경계 |
|---|---|---|---|
| 아이콘 이름 | 제품 | 행 | descriptor prop |
| 아이콘 renderer | 디자인 시스템 | 라이브러리 배포 | 비공개 |
로고 src |
제품 | 행 | Recipe 데이터 |
| 로고의 대체 텍스트 정책 | 디자인 시스템 | Recipe | 내부 고정 |
| 로고와 화살표의 정렬 기준 | 디자인 시스템 | Recipe | 영역 구조로 고정 |
Link 구현 |
제품 앱 | 앱 또는 하위 트리 | 상위 경계에서 주입 |
prefetch 적용 방식 |
제품 앱의 라우터 어댑터 | 어댑터 또는 라우팅 규칙 | 어댑터 내부 |
| 행 내부 여백과 크기 | 디자인 시스템 | Recipe | 비공개 |
이 결정들은 서로 독립적이지 않았다. 행을 두 줄로 나눈 것은 Bottom의 정렬 경계였고, 조작 범위를 제한한 것은 네 영역을 하나의 동작 영역으로 승인한 결정이었다. 두 결정이 겹치는 자리에서 이 Recipe의 공개 상호작용 계약이 함께 좁아졌다.
그래서 조각마다 prop을 하나씩 여는 대신 조합 자체에 이름을 붙여 공개했다.
공개 API가 표현할 수 있는 범위가 곧 제품이 공통 List로 만들 수 있는 UI의 범위였다.