List를 화면이 아니라 구조로 나누자 Recipe의 기준이 생겼다

화면 사례를 anatomy로 나누고 Recipe와 Variant의 경계를 정한 세 가지 질문

앞 글에서는 제품 개발자가 내려야 할 결정과 디자인 시스템이 소유해야 할 결정을 어떻게 구분할 것인가를 질문으로 세웠고, 그 경계가 조건에 따라 달라진다고 답했다. 쓰일 제품과 팀을 알고 있고 반복해서 사용할 조합이 정해진 자리라면, 화면만 아는 값과 동작은 제품 코드가 정하되 이미 내려진 디자인 결정은 디자인 시스템 안에 남겨야 한다.

경계를 어디에 둘지 정했다고 제품 코드가 사용할 공개 API가 저절로 만들어지지는 않는다. 무엇을 제한하려면 먼저 List를 어떤 단위로 나눌지 정해야 한다. 같은 Figma를 보더라도 개발자마다 화면을 다르게 나누면, 허용 범위를 타입으로 옮기기 전에 컴포넌트의 경계부터 달라진다.

이 글은 다음 질문에 답한다.

완성된 화면을 어떤 구조로 나눠야 제품 코드에 공개할 Recipe의 경계를 정할 수 있을까?

당시 실제 대상에는 더 많은 행 형태와 예외가 있었다. 여기서는 판단 과정이 드러나는 세 가지 행 형태만 남겼다.

같은 Figma도 코드에서는 다르게 나뉜다

디자인팀은 구현에 앞서 Figma에 유즈케이스와 Do/Don’t를 정리해둔 상태였다. 어떤 표현을 쓰고 쓰지 않는지도 시각적으로는 분명했다.

그러나 완성된 화면이 정리돼 있다는 것과 구현 계약이 정리돼 있다는 것은 다르다. 개발자는 화면을 코드의 단위로 다시 나눠야 한다.

  • 왼쪽 아이콘은 다른 List에서도 쓸 수 있는가?
  • 제목 옆 count는 어느 형태에서 사용할 수 있는가?
  • 우측 화살표와 버튼은 같은 자리에 있으니 서로 바꿔도 되는가?
  • description이 없는 행은 새로운 형태인가, 같은 형태의 다른 표현인가?
  • 하단 정보는 Content의 일부인가, 별도 영역인가?
  • 디자인이 승인하지 않은 조합은 어느 계약에서 막아야 하는가?

Figma에는 각 질문의 결과가 보이지만, 그 결과를 코드에서 어떻게 나눌지는 적혀 있지 않았다. 같은 화면을 보고도 한 개발자는 count를 선택적 prop으로 만들고, 다른 개발자는 별도 컴포넌트로 만들 수 있다.

화면 하나만 구현할 때는 어느 쪽도 크게 문제처럼 보이지 않는다. 비용은 서로 다르게 나눈 구현을 하나의 공통 컴포넌트로 합칠 때 나타난다. 수십 개 화면을 옮기는 상황에서는 처음의 작은 해석 차이가 나중의 마이그레이션 비용이 된다.

화면이 아니라 자리부터 분류했다

이때 한 Android 동료가 화면별 사례를 그대로 나열하기보다, 각 행을 구성하는 자리부터 분류해달라고 제안했다. 완성된 화면을 기준으로 List를 나누는 대신 Leading / Content / Trailing이라는 구조로 Figma를 다시 보자는 요청이었다.

처음에는 Figma를 보기 좋게 정리해달라는 뜻으로 이해했다. 다시 생각해보니 분류의 기준점을 완성된 화면에서 컴포넌트의 anatomy로, 그러니까 컴포넌트를 이름 붙은 영역으로 나눈 구조로 옮기자는 제안이었다.

기준점이 바뀌면 재사용의 단위가 바뀐다. 완성된 화면을 기준으로 삼으면 새 화면이 생길 때마다 기존 사례 중 가장 비슷한 것을 찾아 고치게 된다. 이름 붙은 영역과 각 영역이 받을 수 있는 값을 기준으로 삼으면, 새 화면이 기존 구조 중 어디에 해당하는지 판단하는 일이 된다.

기준을 바꾸자 여러 화면에 반복되는 관계가 보였다.

  • Leading에는 정해진 크기의 아이콘이나 이미지가 들어갔다.
  • Content는 전달하는 정보 구조에 따라 몇 가지 형태로 나뉘었다.
  • Trailing에는 화살표, 버튼, 텍스트 버튼, 선택 표시, 배지 또는 아무것도 없는 형태가 있었다.
  • 일부 행에는 주요 정보와 구분되는 하단 정보가 필요했다.

디자인팀은 각 형태의 이름을 정했고, 개발에서도 가능한 한 같은 이름을 사용했다. 디자인과 개발, QA가 같은 단어를 쓰면 Figma의 형태와 코드의 계약을 다시 번역하는 비용이 줄어든다. 디자인에서 하나로 부르는 형태를 개발에서 둘로 나누거나 그 반대로 만들면, 둘 사이의 대응 관계가 문서나 사람의 기억에 따로 남는다.

화면 기준
전체 알림
서비스 이용약관
변경사항을 확인해 주세요
확인
제공사명
5.08% · 300만원
추천
자리 기준
Leading
Content
Trailing
달라 보이는 화면도 자리로 나누면 반복되는 구조가 보인다

세 자리만으로 설명할 수 없는 관계가 있었다

행 하나는 먼저 Leading / Content / Trailing으로 나눴다.

영역 하나를 추가하면 그 자리에 들어갈 수 있는 값만 늘어나는 것이 아니다. 어느 행 형태에서 필수이고 어디에서 금지되는지, 다른 영역과 어떤 조합으로 나타날 수 있는지까지 정해야 한다. 디자인과 개발, QA가 함께 지켜야 할 계약이 그만큼 커진다. 그래서 처음부터 영역을 많이 만들지는 않았다.

한동안 세 자리로 충분했다. label 아래에 description이 여러 줄 붙어도 세로 쌓기는 Content 안에서 일어났다. 행 전체는 여전히 Leading / Content / Trailing이 가로로 놓인 한 줄이었다.

하단 정보가 필요해졌을 때도 먼저 Content 안에 넣는 방식을 검토했다. 기술적으로는 그렇게 구현할 수 있었다. 다만 당시 요구에는 Content 하나만으로 감당하기 어려운 관계가 있었다.

  • 하단 정보는 Content의 폭에 갇히지 않고, Content가 시작하는 지점부터 Trailing이 끝나는 지점까지 차지한다.
  • Leading과 Trailing은 하단 정보를 포함한 행 전체가 아니라 주요 정보가 놓인 첫 줄을 기준으로 정렬된다.

정렬 요구가 먼저 걸렸다. Leading을 Content에 맞추라는 규칙을 그대로 두고 하단 정보를 Content 안에 넣으면, Leading은 하단 정보까지 포함한 높이의 가운데로 내려간다. 아이콘이 첫 줄에서 벗어나 행 중앙에 놓인다.

정렬 기준을 Content의 첫 블록으로 고쳐 쓸 수는 있다. 그러면 행이 Content 내부를 들여다봐야 정렬을 지킬 수 있고, Content의 내부 구조가 행의 정렬 계약이 된다.

폭 요구도 같은 자리를 가리킨다. 하단 정보의 시작선과 종료선은 Content 안이 아니라 Content와 Trailing의 관계에서 정해진다. Content 안에서 같은 폭을 그릴 수는 있지만, 그렇게 두면 Content가 자기 바깥의 열 구조에 기대게 된다.

왼쪽 괄호가 Leading과 Trailing이 맞추는 범위다.
1세 영역만 있을 때
Leading
Content
제공사명
Trailing
정렬 기준 한 줄 전체하단 폭 하단 정보 없음
2하단 정보를 Content 안에 넣으면
Leading
Content
제공사명
하단 정보
5.08% · 300만원
Trailing
정렬 기준 하단 정보까지 포함하단 폭 Content 열에서 끝
3Bottom을 별도 영역으로 두면
Leading
Content
제공사명
Trailing
Bottom
5.08% · 300만원
정렬 기준 첫 줄하단 폭 Trailing 열 끝까지
Content 안에서도 같은 모양을 그릴 수 있지만, 행의 정렬과 폭 관계를 Content가 대신 소유하게 된다

따라서 Bottom이 필요했던 이유는 Content로 그릴 수 없어서가 아니었다. 하단 정보를 Content에 넣는 순간 Leading, Content, Trailing 사이에 이미 있던 정렬과 폭의 관계가 Content 내부 구현에 매이기 때문이었다.

지금 다시 보면 다른 선택지도 있었다. 호출부가 Leading과 Trailing의 정렬을 직접 고르게 하거나, 하단 정보를 사용하는 Max 구현 안에 배치를 숨길 수도 있었다. 전자는 같은 정렬 판단을 화면마다 반복하게 만든다. 후자는 비용이 가장 싸지만, 하단 정보의 시작선과 종료선, 정렬 기준이 Max 구현의 세부로만 남는다. 그러면 행 형태의 계약에 그 자리를 필수라고도 금지라고도 적을 수 없다.

하단 정보는 당시 Max에서만 사용했지만, 그 규칙은 디자이너가 형태를 설명할 때, 개발이 구현할 때, QA가 폭과 정렬을 확인할 때 이미 세 번 참조되고 있었다. 그래서 별도로 이름 붙일 가치가 있다고 판단했고 Bottom을 추가했다.

Bottom이 생기면서 행의 구조도 달라졌다. 이전까지 행은 가로 한 줄이었고 세로 쌓기는 Content 내부의 일이었다. 이제 행은 주요 정보가 놓이는 첫 줄과, Content에서 시작해 Trailing 끝까지 이어지는 하단 영역을 함께 소유하게 됐다. Leading과 Trailing은 첫 줄을 기준으로 정렬된다.

SecondarySupporting처럼 의미를 이름에 담는 안도 검토했다. 최종적으로는 나머지 세 영역과 마찬가지로 위치를 나타내는 Bottom을 사용하고, 그 안에 들어가는 값에 Supporting Content라는 의미를 남겼다.

Leading
Content
제공사명
상품명
Trailing
Bottom
5.08%300만원
연 이자지원중도상환 없음
1행 Leading · Content · Trailing
2행 Bottom · Content 열에서 시작해 Trailing 열 끝까지
Leading과 Trailing은 첫 줄에 맞춰 정렬되고, Bottom은 Content에서 시작해 Trailing 끝까지 이어진다

각 영역의 역할은 다음과 같이 정리했다.

영역 가능한 값 결정하는 것
Leading 아이콘 · 이미지 · 없음 왼쪽 시각 요소의 종류
Content label · description · count · 주요 지표 행이 전달하는 정보의 중심
Trailing 화살표 · 버튼 · 텍스트 버튼 · 선택 표시 · 배지 · 없음 오른쪽에 표시되는 정보와 조작 요소
Bottom Supporting Content · 없음 주요 정보와 구분되는 하단 정보

영역은 자유롭게 조합하는 슬롯이 아니었다

네 영역을 나열하면 각 자리에 무엇이든 넣을 수 있는 슬롯처럼 보인다. 실제 계약은 그렇지 않았다.

Content의 정보 구조가 정해지면 Trailing이 받을 수 있는 값과 Bottom의 유무도 함께 달라졌다. 예를 들어 BasicRegular는 여러 종류의 Trailing을 받을 수 있었지만 Max의 Trailing은 화살표로 한정됐다. Bottom도 모든 행이 선택적으로 받는 영역이 아니라 Max에만 필요한 영역이었다.

따라서 Basic / Regular / Max는 Content만 분류한 이름이 아니다. 무엇을 보고 구분하는지는 Content의 정보 구조지만, 구분한 결과는 Trailing과 Bottom을 포함한 행 전체의 계약을 정한다.

여기서 정보 구조는 값의 TypeScript 타입이 아니라, 어떤 역할의 정보가 어느 영역에 몇 계층으로 놓이는가를 뜻한다.

행 형태 정보 구조 예시
Basic label과 부가 정보 하나 제목과 설명, 또는 제목과 count
Regular labeldescription 1~4줄 여러 줄 안내가 붙는 항목
Max 식별 정보, 여러 주요 지표, Bottom 비교 결과 형태

Leading은 BasicRegular 모두 받을 수 있었다. 세 형태를 가른 것은 Leading의 유무가 아니라 Content가 담는 정보의 구성이다.

선택 표시는 Trailing에 놓일 수 있었지만, 선택 정책까지 Content의 종류로 보지는 않았다. 무엇이 어디에 보이는지와 행이 어떻게 동작하는지는 같은 분류가 아니기 때문이다. 선택과 상호작용의 책임은 목록 전체와 DOM 구조를 다루는 뒤 편에서 다시 검토한다.

이 글에서 anatomy로 정한 것은 어느 영역에 무엇이 놓이고 어떤 조합이 함께 존재할 수 있는지까지다. 각 영역이 눌리는 방식이나 Bottom이 행의 주 동작에 포함되는지는 영역 이름만으로 결정하지 않는다.

정보 구조 하나를 공개 Recipe 하나로 옮겼다

형태별 조합을 표로 만들자 같은 anatomy를 사용하면서도 서로 다른 행 계약이 보였다. 어떤 영역이 필수이고 금지되는지, 어떤 역할의 정보가 몇 계층으로 들어오는지가 달랐다.

이 차이를 공개 컴포넌트의 경계로 삼았다.

공개 컴포넌트 정보 구조
ListRowBasic label과 선택적 description, 또는 labelcount
ListRowRegular labeldescription 1~4줄
ListRowMax 식별 정보, 여러 주요 지표, Bottom
ListRowBasicvariant 2개
전체 알림
variant: 'default'
읽지 않음12
variant: 'count'
label(굵게) 단독, 또는 label(얇게) + count(강조 색) · Trailing은 표의 값을 모두 받는다 · Bottom 없음
ListRowRegularlabel + description 1~4줄
서비스 이용약관이 변경돼요
주요 변경사항을 확인해 주세요
2026년 9월 1일부터 적용됩니다
description은 최대 4줄까지
label(굵게) + description 1~4줄(연한 색, 기호 없음) · Trailing은 표의 값을 모두 받는다 · Bottom 없음
ListRowMaxBottom 필수, Trailing은 화살표만
제공사명
상품명
5.08%300만원
연 이자지원중도상환 없음
식별 정보, 여러 주요 지표, Bottom을 함께 표현 · Bottom 필수 · Trailing은 화살표 하나로 좁혀진다
세 Recipe는 같은 anatomy를 사용하지만 Content의 정보 구조, Trailing의 범위, Bottom의 유무가 다르다

제품 코드에서 ListRowBasic을 선택하면 그 Recipe가 지원하는 prop만 사용할 수 있다. ListRowMax를 선택하면 Max가 소유한 정보 구조와 영역 관계가 함께 따라온다. 이 글에서는 이렇게 정보 구조 하나를 구현하고 공개 props 계약으로 제공하는 컴포넌트를 Recipe라고 부른다.

anatomy를 나누고 Recipe 셋을 얻었지만, 이것만으로 공개 API가 모두 결정된 것은 아니었다. 각 Recipe가 어떤 입력을 받아야 하는지, 그 입력이 데이터인지 상태인지, 새로운 Recipe와 기존 Recipe의 Variant를 무엇으로 구분할지 정해야 했다.

지금 다시 정리하면 세 질문이 필요하다.

질문 1: 이 결정을 내릴 정보는 어디에 있는가

첫 번째 질문은 해당 결정을 제품 코드에 맡길지, 컴포넌트 안에 둘지 가른다.

필요한 정보가 있는 곳 결정이 놓이는 자리
제품의 의미나 요구사항을 알아야 결정할 수 있다 공개 입력
컴포넌트가 받은 값으로 계산할 수 있거나 환경이 관찰할 수 있다 내부 규칙
렌더링과 성능을 위한 구현 세부다 내부 정책

여기서 공개 입력은 제품 코드가 값을 넘기는 자리를 뜻한다. 그 입력들이 모여 제품 코드가 사용할 공개 API가 된다.

처음에는 제품 개발자가 판단해야 하는지를 물었다. 이렇게 물으면 편의를 위해 prop을 제공하는 쪽으로 판단이 기울기 쉽다. 중요한 것은 누가 값을 넘길 수 있느냐가 아니라, 올바른 결정을 내릴 정보가 어디에 있느냐다.

disabled, selected, value는 런타임에 바뀌지만 제품의 의미에서 결정되므로 공개 입력이 될 수 있다. 반면 빈 목록인지 여부는 목록 컴포넌트가 받은 배열에서 계산할 수 있다. 값이 런타임에 정해지는지와 그 값을 누가 알아야 하는지는 다른 문제다.

다만 여기서는 selectedvalue를 최종적으로 Row가 받을지 List가 계산해 내려줄지 정하지 않는다. 행 하나만 보고 있기 때문이다. 이 글에서 확인하는 것은 제품의 의미가 필요한 값이라는 점까지다.

내부 규칙에는 다음과 같은 판단이 들어갈 수 있다.

  • 목록의 첫 번째와 마지막 항목을 구분하는 일
  • 마지막 행 아래의 구분선을 제거하는 일
  • 목록이 비어 있는지 판단하는 일
  • description 유무에 따라 label의 정렬을 바꾸는 일
  • 컨테이너 폭에 따라 레이아웃을 바꾸는 일

앞의 넷은 컴포넌트가 이미 받은 값에서 계산된다. 마지막 하나는 컴포넌트도 제품도 모르고 환경만 안다. 둘 다 제품에 물어볼 것이 없다는 점에서는 같다.

목록이 비어 있는지 판단하는 일과, 비었을 때 무엇을 보여줄지는 다시 나눠야 한다. 전자는 받은 배열로 계산할 수 있지만 후자는 해당 화면의 의미를 알아야 정할 수 있다. 하나의 요구처럼 보이더라도 판단과 표현에 필요한 정보가 다르면 API의 위치도 달라진다.

내부 정책은 화면에 보이는 결과도 계약도 바꾸지 않는, 구현만 소유하는 값이다. 이 글에는 해당하는 예가 거의 없다. 뒤에서 가상화를 다룰 때 처음 실체가 생긴다.

앞 글의 countColor="red"leadingSize={40}이 제품 코드에 둘 필요가 없었던 이유도 같다. 제품은 count 값을 알지만, 그 값에 어떤 색을 적용할지는 디자인 시스템이 이미 알고 있었다.

질문 2: 공개 입력은 어떤 의미를 갖는가

공개하기로 한 prop을 모두 Recipe나 Variant로 분류할 필요는 없다. disabled를 Variant라고 부르면 표현 선택지에 상태가 섞이고, 별도 Recipe로 만들면 비활성 행이 별도 컴포넌트가 된다. disabled는 상태로 남으면 된다.

그래서 공개 입력이 어떤 의미를 갖는지 먼저 구분했다.

입력의 의미
데이터 label, count: 12, 아이콘 식별자
상태 disabled, selected
행동 rowAction, onValueChange
정책 selectionMode
정해진 표현 선택 variant: 'count', divider: 'inset', surface: 'card'

이 분류는 서로 다른 개념에 같은 이름을 붙이는 실수를 줄인다. label은 데이터이고, disabled는 상태이며, rowAction은 행동이다. 이 글에서는 의미만 구분한다. 각 행동이 어떤 DOM을 가져야 하는지, 선택 정책을 Row와 List 중 누가 소유해야 하는지는 뒤 편에서 다룬다.

의미와 구조는 다른 렌즈다

공개 입력을 분류하는 축은 하나가 아니다. 질문 2는 그 입력이 무엇을 뜻하는지 묻고, 다음 절의 질문 3은 정해진 형태를 컴포넌트 모델의 어느 단위로 표현할지 묻는다. 두 축을 하나의 분류 트리로 이어 붙이면 매번 둘 중 한 답을 버리게 된다.

API 입력의 의미 컴포넌트 모델에서의 역할
label="전체 알림" 데이터 일반 prop
count={12} 데이터 variant: 'count'가 소비하는 값
disabled 상태 일반 prop
rowAction 행동 상호작용 계약의 입력
ListRowBasic을 선택한다 질문 2의 대상이 아님 Recipe 선택
variant="count" 정해진 표현 선택 Variant 판별자
divider="inset" 정해진 표현 선택 List의 Variant
selectionMode="multiple" 정책 선택형 목록을 가르는 판별자 후보

count={12}는 의미로는 데이터지만 구조에서는 variant: 'count'가 요구하는 값이다. selectionMode="multiple"은 의미로는 정책이지만, 나중에 선택형 목록을 모델링할 때는 계약을 가르는 판별자가 될 수 있다. 두 분류는 충돌하지 않는다. 같은 입력을 의미와 구조라는 서로 다른 관점에서 본 결과다.

ListRowBasic을 선택하는 일만 성격이 다르다. prop을 넘기는 것이 아니라 공개 컴포넌트를 고르는 일이므로 질문 2의 대상이 아니다.

질문 3: 새 정보 구조가 필요한가

세 번째 질문은 정해진 형태를 컴포넌트 모델의 어느 단위로 표현할지 가른다. 새로운 형태를 검토할 때는 이렇게 묻는다.

새 Recipe가 필요한가, 기존 Recipe의 Variant 축으로 충분한가?

구조의 변화 컴포넌트 모델
필요한 영역과 정보 계층이 달라진다 새 Recipe
기존 Recipe의 정보 구조 안에서 정해진 표현을 고른다 기존 Recipe의 Variant

Recipe와 Variant는 대등한 두 분류가 아니다. 하나의 Recipe가 하나 이상의 Variant 축을 가질 수 있다. 예를 들어 ListRowBasic이라는 Recipe 안에 variant: 'default' | 'count'가 들어간다.

새 Recipe를 가르는 기준은 단순히 prop 계약이 달라지는지가 아니다. Variant도 선택한 값에 따라 받을 수 있는 prop을 좁힐 수 있다. 구분해야 할 것은 새로운 영역이나 정보 계층이 필요한지, 아니면 Recipe가 이미 소유한 정보 구조 안에서 표현만 달라지는지다.

질문 1
정보가 어디에 있는가
컴포넌트·환경이 안다
내부 규칙
전체 알림
설명이 있다
전체 알림
description 있음 / 없음
행 1
행 2
행 3
첫 행의 위쪽 모서리
구현 튜닝이다
내부 정책
제품만 안다
공개 입력
같은 입력 · 두 렌즈
질문 2 · 의미
공개 입력은 어떤 의미인가
데이터label
상태disabled
행동rowAction
정책selectionMode
정해진 표현 선택variant
질문 3 · 구조
새 정보 구조가 필요한가
정보 구조가 달라진다
새 Recipe
기존 정보 구조 안의 분기
기존 Recipe의 Variant
읽지 않음12
variant: 'count'
질문 1이 공개 여부를 가르고, 공개된 입력에는 의미와 구조라는 두 렌즈가 나란히 걸린다

Recipe와 Variant는 빌려온 말이고, 범위는 넓혔다

Recipe와 Variant는 스타일링 도구에서 가져온 말이다. Panda CSS, vanilla-extract, Chakra UI의 Recipe는 기본 스타일과 여러 Variant 축을 묶는다. CVA도 이름 붙은 Variant 축과 값을 조합한다. Figma의 Variant 역시 컴포넌트 세트 안에서 속성과 값으로 비슷한 표현을 묶는다.

이 도구들에서 확인한 핵심 관계는 Recipe가 Variant를 포함한다는 점이다. 다만 정보 구조 하나를 공개 컴포넌트 하나에 대응시킨 것은 도구가 요구한 규칙이 아니라 이 글에서 선택한 모델이다.

스타일링 도구의 Recipe는 주로 스타일을 묶는다. 여기서 Recipe는 스타일뿐 아니라 하나의 정보 구조와 공개 props 계약까지 함께 고정한다. DOM과 상호작용을 어디에서 소유할지는 아직 포함하지 않는다.

Variant도 스타일 값만 바꾸는 축보다 넓게 사용한다. 같은 정보 구조 안에서 표현과 데이터 계약이 함께 달라지는 분기도 Variant로 본다. variant: 'count'를 선택하면 count가 필수가 되고 description은 사용할 수 없다.

한 가지는 분명히 해둔다. Variant의 조건은 값들이 서로 배타적이라는 것이 아니다. dividersurface는 배타 관계 없이 각각 독립적으로 골라도 되지만 둘 다 Variant다. 조건은 선택지가 정해진 목록으로 한정돼 있고 그 결과를 컴포넌트가 소유한다는 것이다.

정리하면 이 글에서 Recipe는 하나의 정보 구조를 구현해 제품 코드에 제공하는 컴포넌트이고, Variant는 같은 Recipe 안에서 정해진 표현 차이를 선택하는 축이다. label, count, description 같은 값은 Recipe와 Variant가 사용하는 데이터다.

count는 데이터이고, count 형태는 Variant다

ListRowBasic 안에는 두 가지 표현이 있었다. default 형태는 label과 선택적인 description을 보여줬다. count 형태는 labelcount를 보여주고, label의 굵기와 count의 강조 색도 달랐다.

각 차이를 선택적 prop으로 제공하면 다음과 같은 API가 된다.

typescript
type ListRowBasicProps = {
  label: string;
  description?: string;
  count?: number;
  bold?: boolean;
  countColor?: string;
};

이 타입은 디자인에서 사용하지 않는 조합도 허용한다.

tsx
<ListRowBasic
  label="신청 내역"
  description="설명"
  count={3}
  bold
  countColor="red"
/>

count={3}3은 데이터다. 숫자 3과 4가 서로 다른 Variant인 것은 아니다. Variant는 count를 사용하는 Content 표현과 그에 따라 달라지는 계약이다.

typescript
type ListRowBasicProps =
  | {
      variant: 'default';
      label: string;
      description?: string;
      count?: never;
    }
  | {
      variant: 'count';
      label: string;
      count: number;
      description?: never;
    };

TypeScript의 discriminated unionvariant처럼 모든 분기가 공통으로 가진 리터럴 필드를 기준으로 union을 좁힌다. ?: never는 해당 분기에서 prop에 구체적인 값을 전달하지 못하게 한다. 다만 count={undefined}처럼 undefined를 명시한 경우까지 막으려면 exactOptionalPropertyTypes가 필요하다.

variant: 'count'를 선택하면 description을 사용할 수 없고, count가 필수가 되며, label의 타이포그래피와 count의 색상 토큰도 Recipe가 정한다. 제품 코드가 선택하는 것은 count 형태 하나지만 그 선택에 맞는 계약 전체가 따라온다.

Variant는 데이터의 값이 아니라, 같은 정보 구조 안에서 데이터의 배치와 표현을 정하는 계약의 분기다.

지원하는 조합은 다음과 같다.

tsx
<ListRowBasic variant="count" label="신청 내역" count={3} />

지원하지 않는 조합은 타입 검사에서 실패한다.

tsx
<ListRowBasic variant="default" label="신청 내역" count={3} />
// default variant에서는 count를 받을 수 없다

정렬은 왜 내부 규칙인가

ListRowBasicdefault Variant에서 description이 있으면 labeldescription이 위아래로 놓인다. description이 없으면 label 하나가 행 높이의 가운데에 놓인다. 보이는 결과가 달라지므로 정렬도 prop으로 제공해야 할 것처럼 보일 수 있다.

질문 1을 적용하면 판단은 간단하다. Recipe는 description을 이미 받았고, 그 값의 유무로 정렬을 계산할 수 있다. 제품만 아는 정보가 필요하지 않다.

align="center" | "top"을 별도 prop으로 제공하면 description이 있는데도 가운데 정렬하는 조합을 만들 수 있다. 우리에게는 description 유무와 정렬 사이의 대응이 하나로 정해져 있었으므로 선택지를 추가할 이유가 없었다.

다른 조건에서는 같은 결론이 나오지 않는다. 토스 디자인 시스템의 ListRow는 왼쪽과 오른쪽 영역을 ReactNode로 받고, 각각 top | center 정렬을 선택할 수 있는 prop을 제공한다.

토스 앱 화면 하나에서 ListRow 하나가 맡는 행 여덟 개를 점선으로 표시한 자료. 아이콘 유무와 줄 수, 우측 요소가 다른 행들이 모두 같은 컴포넌트로 표현돼 있다.
하나의 ListRow가 넓은 형태를 맡기 때문에 좌우 정렬도 공개 prop으로 제공한다

한 화면만 봐도 ListRow 하나가 맡는 범위가 보인다. 아이콘이 있는 행과 없는 행, 한 줄과 두 줄, 우측에 버튼과 화살표와 스위치가 각각 붙은 행이 전부 같은 컴포넌트다. 우리가 ListRowBasicListRowRegular로 갈라둔 범위가 하나 안에 들어 있다.

같은 질문 1을 대면 반대 답이 나온다. 우리 Recipe는 description을 값으로 받았으니 정렬을 계산할 수 있었다. 좌우 영역을 ReactNode로 받는 컴포넌트는 그 안에 무엇이 들었는지 모른다. 자기가 지금 어떤 행인지가 넘겨받은 값에서 나오지 않으므로, 정렬을 정할 정보는 호출부에만 있다.

공개된 ListRow 문서에서 확인할 수 있는 범위는 다음과 같다.

prop 기본값
left - React.ReactNode
leftAlignment center top · center
contents - React.ReactNode
right - React.ReactNode
rightAlignment center top · center

이 선택이 옳거나 틀렸다고 단정할 수는 없다. 그 시스템은 앱인토스처럼 바깥 팀이 만드는 제품까지 사용하는 자리에 있다. 앞 글의 축으로 말하면 쓸 제품을 미리 알 수 없는 쪽이다. 어떤 화면에 어떤 조합으로 놓일지 모르면 정렬 규칙을 하나로 고정할 근거가 없다. 다만 이는 공개된 문서와 화면을 바탕으로 한 해석이며, 실제 설계자의 판단 근거를 확인한 것은 아니다.

우리의 조건은 달랐다. 쓸 화면과 조합이 정해져 있었고, description 유무와 정렬의 관계도 하나였다. 같은 정렬 prop이 한쪽에서는 필요한 입력이 되고 다른 쪽에서는 불필요한 선택지가 되는 이유다.

보이는 결과가 달라진다는 것과 그 결과를 정하는 데 제품만 아는 정보가 필요하다는 것은 다른 말이다. 질문 1이 재는 것은 후자다.

count 형태는 왜 새 Recipe가 아닌가

count 형태는 별도 Recipe 후보처럼 보였다. label의 굵기가 달라지고 count에 강조 색이 적용되며 description도 사용할 수 없었다.

하지만 질문 3의 기준에서 보면 Basic과 정보 구조가 같았다. 둘 다 label과 부가 정보 하나를 Content에 배치한다. descriptionstring이고 countnumber라는 데이터 타입의 차이는 새로운 정보 구조를 만들지 않는다. 달라지는 것은 부가 정보의 종류와 그에 따른 표현이다.

새로운 영역이 필요하거나 정보 계층이 늘어나는 것도 아니었다. 그래서 count 형태를 새 Recipe로 만들지 않고 ListRowBasic 안의 Variant로 두었다.

요청 A
label 옆에 count를 붙인 형태
전체 알림
기존: Basic default
읽지 않음12
요청: Basic count
필요한 영역Leading · Content · Trailing 그대로
정보 계층label + 부가 정보 하나 그대로
달라진 것부가 정보의 종류와 표현
기존 Recipe의 Variant
요청 B
주요 지표와 하단 정보가 붙은 형태
제공사명
상품명
기존: Regular
제공사명
상품명
5.08%300만원
요청: Max
필요한 영역Bottom이 새로 필요하다
정보 계층주요 지표 여러 개가 늘어난다
달라진 것정렬 기준과 하단 폭 규칙
새 Recipe
두 요청 모두 받을 수 있는 prop이 달라진다. 가른 것은 계약이 달라지는지가 아니라 새로운 영역과 정보 계층이 필요한지다.
같은 기준을 대면 count는 기존 Recipe의 Variant로, Max는 새 Recipe로 떨어진다

새 형태 요청을 검토할 때도 같은 질문을 사용할 수 있다.

  1. 기존 Recipe의 정보 구조 안에서 표현할 수 있다면 Variant 후보로 본다.
  2. 필요한 영역이나 정보 계층이 달라진다면 새 Recipe 후보로 본다.
  3. 디자인 시스템이 지원하기로 한 형태는 내부 조합 수단으로 구현한 뒤 공개 API에 추가한다.

이 기준만으로 운영 절차까지 결정되지는 않는다. 새로운 형태를 검토하고 배포하는 속도가 느리면 제한된 공개 API가 병목이 될 수 있다. 그 비용은 운영 경계를 다루는 뒤 편에서 다시 살펴본다.

화면을 구조로 나누자 공개할 단위가 보였다

처음에는 Figma에 정리된 화면을 어떻게 공통 컴포넌트로 옮길지가 문제였다. Android 동료의 제안에 따라 완성된 화면 대신 Leading / Content / Trailing / Bottom이라는 anatomy로 다시 보자, 여러 화면에 반복되던 관계와 행 전체의 정보 구조가 드러났다.

그 결과 Basic / Regular / Max를 행 전체의 세 정보 구조로 구분하고, 각각을 ListRowBasic / ListRowRegular / ListRowMax라는 공개 Recipe에 대응시켰다. 영역은 자유 조합 슬롯이 아니었다. Recipe를 선택하면 Content뿐 아니라 Trailing의 범위와 Bottom의 유무까지 함께 정해졌다.

공개 API를 판단할 때는 세 질문을 사용했다.

  1. 이 결정을 내릴 정보는 어디에 있는가. 제품의 의미가 필요하면 공개 입력, 컴포넌트가 계산하거나 환경이 관찰할 수 있으면 내부 규칙, 구현 세부라면 내부 정책으로 둔다.
  2. 공개 입력은 어떤 의미를 갖는가. 데이터, 상태, 행동, 정책, 정해진 표현 선택을 구분한다.
  3. 새 정보 구조가 필요한가. 필요하면 새 Recipe, 기존 정보 구조 안의 표현 차이라면 그 Recipe의 Variant로 둔다.

질문 1은 공개 여부를 가르는 첫 단계다. 뒤의 둘은 이어지는 다음 단계가 아니라, 같은 입력을 의미와 구조에서 각각 보는 두 렌즈다.

제품 코드가 넘긴다
공개 입력 · 제품의 의미를 알아야 정할 수 있다
데이터label · count
무엇을 보여줄지는 화면이 안다
상태disabled · selected
어느 행을 잠글지는 제품의 의미다
행동rowAction
눌렀을 때 어디로 갈지는 화면이 정한다
표현 선택variant: 'count'
정해진 선택지 중 하나를 고른다
Recipe 선택ListRowBasic · Regular · Max
prop이 아니라 컴포넌트를 고르는 일이다
디자인 시스템이 정한다
내부 규칙 · 받은 값으로 계산하거나 환경이 관찰한다
label의 세로 정렬
받은 description의 유무로 계산한다
마지막 행의 divider
목록이 순서를 알고 있다
빈 목록 판정
받은 배열의 길이로 계산한다
좁은 폭의 레이아웃
제품도 컴포넌트도 모르고 환경만 안다
내부 정책
이 글에는 예가 없다. 가상화를 붙일 때 처음 생긴다
열 수 있었지만 열지 않았다
제품 코드가 값을 넘길 수는 있다. 그 값을 정할 정보가 제품에 없을 뿐이다
countColor
색은 Figma가 이미 정했다
leadingSize
아이콘 크기도 하나로 정해져 있었다
align
description 유무와 대응이 하나였다
bold
variant가 typography까지 가져간다
세 질문을 다 돌리고 나면 이 글이 내린 판정이 경계 하나로 모인다

이 기준은 아직 행 하나에만 적용했다. 남은 편들이 하는 일의 상당 부분은 같은 기준을 더 큰 대상에 대보는 것이고, 실제로 두 번은 고치게 된다. 어디서 왜 고쳤는지는 그 편들에서 밝힌다.

다음 글에서는 세 Recipe를 실제 공개 API로 표현하는 방식을 비교한다. 제품 코드에는 정해진 조합만 제공하면서, 디자인 시스템 내부에는 새로운 조합을 만들 수단을 어떻게 남길지가 다음 질문이다.