List API의 유연성은 어디에 두어야 했을까
같은 ListRow를 Flat, Compound, Recipe로 만들자 잘하는 일이 갈렸다
첫 글에서 구현을 두 층으로 나눴다고 적었다. 제품 코드에 공개하는 층에서는 디자인 시스템이 지원하는 조합만 고를 수 있게 하고, 안쪽에는 새 조합을 조립할 수 있는 층을 남긴다. 그리고 그 구조가 실제로 어떤 API 형태가 되는지, 왜 하나의 형태로는 둘 다 못 하는지는 답하지 않고 넘겼다.
앞 글은 그 사이에 재료를 만들었다. 행 하나가 Leading, Content, Trailing, Bottom 네 영역으로 나뉘었고, 정보 구조를 기준으로 ListRowBasic, ListRowRegular, ListRowMax 세 Recipe가 나왔고, 무엇을 공개할지 판정하는 질문 셋이 세워졌다.
이 글이 그 미뤄둔 답이다. 같은 행 하나를 세 가지 API로 만들어보고 비교한다. 비교에 쓸 행은 이것이다. Leading에 아이콘이 있고, label이 “전체 알림”이고, 그 옆에 count 12가 붙는다. 앞 글의 분류로는 ListRowBasic의 count variant에 해당하는 행이다.
하나의 Flat API로 모든 조합을 표현할 수 있을까
첫 후보는 행 전체를 하나의 컴포넌트로 만들고 모든 것을 prop으로 펴는 형태다. 비교 대상인 행을 이 형태로 쓰면 이렇다.
<ListRow
type="basic"
leadingIcon="bell"
label="전체 알림"
count={12}
/>제품 개발자가 쓰기에는 이 형태가 편하다. 필요한 값이 최상위 prop에 펼쳐져 있어 자동 완성이 잘 듣고, 코드를 읽을 때도 무엇이 들어가는지 한눈에 보인다. 행의 구조가 단순할 때는 이걸로 충분하다.
문제는 Leading, Trailing, Bottom, 선택, 상태처럼 서로 영향을 주는 축이 늘어날 때 나타난다.
<ListRow
type="basic"
leadingType="icon"
leadingIcon="bell"
label="전체 알림"
description="설명"
count={12}
trailingType="arrow"
bottom
bottomBadge
selectable
multiple
compact
/>이 목록에 첫 글에서 걷어낸 countColor나 leadingSize 같은 디자인 결정은 이미 없다. 전부 데이터이거나 구조의 선택이다. 그런데도 질문이 쏟아진다.
count와description을 함께 줄 수 있는가?type="basic"에서bottom을 쓸 수 있는가?trailingType="arrow"인데 눌렀을 때 실행할 것이 없으면 어떻게 되는가?multiple은selectable없이도 의미가 있는가?
prop의 개수 자체는 견딜 만했다. 걸린 것은 서로 독립적이지 않은 값들이 하나의 컴포넌트에 평평하게 놓여 있다는 점이었다. prop 열두 개가 만드는 상태는 열두 개가 아니다. 각 prop이 독립적으로 optional이면 서로 조합된 상태가 곱으로 늘어난다. 그중 디자인이 정한 것은 일부뿐인데, 타입에는 나머지 상태도 함께 존재한다.
물론 discriminated union을 쓰면 Flat API도 어느 정도 제한할 수 있다. 앞 글에서 count가 붙는 형태를 두 브랜치짜리 union으로 제한한 것이 정확히 그 방법이다.
type ListRowBasicProps =
| {
variant: 'default';
label: string;
description?: string;
count?: never;
}
| {
variant: 'count';
label: string;
count: number;
description?: never;
};|로 나열된 형태 하나가 브랜치다. 호출부는 그중 하나를 고르고, 어느 브랜치인지는 variant 값이 가른다. count?: never는 그 브랜치에서 count를 받지 않겠다는 표시다. 컴포넌트가 받는 props 타입 자체가 이 union이라, 아래에서 최상위 union이라고 쓰는 것이 이것이다.
그런데 같은 방법으로 하나의 최상위 계약 안에서 Leading, Content, Trailing, Bottom, 선택, 상태가 서로 거는 제약을 전부 정확하게 표현하려 하면, 독립적이지 않은 축의 조합이 브랜치로 펼쳐진다.
여기서 비대해진다는 것은 표현할 수 있는 조합의 수가 많다는 뜻이 아니다. 지원하는 조합의 수는 어떤 형태로 만들든 같다. 문제는 그 조합이 전부 최상위 union의 브랜치로 펼쳐진다는 것이다.
축들이 서로 제약하기 때문에 축별로 타입을 쪼갤 수 없고, 쪼갤 수 없으니 조합을 한 자리에 열거하게 된다. 그렇게 한 타입 안에 열거하면 축이 하나 늘 때마다 브랜치는 몇 개가 더 생기는 것이 아니라 몇 배가 된다. 위의 타입은 ListRowBasic 하나가 count 축만 담아 브랜치가 둘이다. 하나의 Flat API가 Bottom의 유무까지 담으면 넷이 되고, 선택 여부까지 담으면 여덟이 된다.
그 결과 새 요구가 들어왔을 때 어떤 브랜치를 고쳐야 하는지 찾기 어려워지고, 타입 오류도 여러 브랜치가 함께 후보로 잡히면서 길고 읽기 어려워지기 쉽다. 축별로 타입을 나누고 중첩해 구조화하는 방법도 있지만, 그건 값을 최상위 prop에 평평하게 펴둔다는 이 형태의 전제를 조금씩 허무는 방향이다.
한 컴포넌트가 모든 유즈케이스를 표현하도록 만들수록, 공개 API는 디자인팀이 정한 조합을 설명하기보다 가능한 모든 경우를 수용하는 설정 객체에 가까워졌다.
Compound는 구조를 드러냈지만 조합을 다시 호출부에 물었다
Flat의 반대편에 있던 후보가 Compound 형태다. 같은 행을 이렇게 쓴다.
<ListRow.Root>
<ListRow.Leading>
<ListRow.Icon name="bell" />
</ListRow.Leading>
<ListRow.Content>
<ListRow.Label>전체 알림</ListRow.Label>
<ListRow.Count>12</ListRow.Count>
</ListRow.Content>
</ListRow.Root>Compound는 구조를 JSX에 그대로 드러낸다. 앞 글에서 나눈 네 영역이 그대로 컴포넌트 이름이 되고, 무엇이 어느 영역에 들어가는지가 prop 접두사 대신 컴포넌트의 배치로 읽힌다. leadingType과 leadingIcon처럼 이름으로만 묶여 있던 관계가 중첩으로 묶인다.
조합을 만들기도 쉽다. 디자인 시스템 안에서 새로운 형태를 만들 때 네 영역을 다시 배치하면 된다. 처음에는 이 형태를 그대로 공개하면 구조와 확장성을 모두 얻을 수 있을 것처럼 보였다.
하지만 제품 코드에 Compound API를 그대로 열면 디자인에서 허용하지 않은 조합도 자연스러운 JSX로 작성할 수 있다.
<ListRow.Root>
<ListRow.Leading>
<CustomImage size={48} />
</ListRow.Leading>
<ListRow.Content>
<CustomMarkup />
</ListRow.Content>
<ListRow.Trailing>
<Switch />
<Badge />
</ListRow.Trailing>
<ListRow.Bottom>
<Button>임의 행동</Button>
</ListRow.Bottom>
</ListRow.Root>코드의 구조는 행처럼 보인다. Root 아래에 Leading, Content, Trailing, Bottom도 있다. 그러나 각 영역에 무엇을 넣을 수 있는지는 다시 호출부가 판단하고 있다.
Flat API에서는 허용되지 않은 prop 조합이 표현 가능했다. Compound API를 그대로 공개하면 그보다 더 넓은 임의 JSX가 들어올 수 있다. 구조는 공통화됐지만 디자인 정책은 공통화되지 않은 상태다.
children으로 들어오는 것을 타입으로 좁히면 되지 않느냐는 물음이 여기서 자연스럽게 따라온다. JSX로 받은 것을 타입으로 좁히려 할 때 무엇에 부딪히는지는 다음 글에서 따로 다룬다. 이 글에서는 그대로 공개하면 각 영역에 무엇이 올지를 호출부가 정하게 된다는 사실까지만 쓴다.
Flat과 Compound가 잘하는 일은 달랐다
두 형태를 비교하고 나서 전체 API의 정답을 하나 고르려던 생각을 접었다.
| Flat | Compound | |
|---|---|---|
| 입력을 좁히기 | 타입으로 조합을 열거해 좁힐 수 있다 | children으로 들어오는 것을 좁히기 어렵다 |
| 구조 표현 | prop 이름으로 간접적으로 드러난다 | JSX 배치로 직접 드러난다 |
| 새 조합 설계 | 축이 늘수록 prop과 union이 커진다 | 영역을 다시 배치하면 된다 |
| 호출부 코드량 | 비교적 적다 | 비교적 많다 |
| 디자인 정책의 위치 | 컴포넌트 타입에 둘 수 있다 | 그대로 공개하면 각 화면으로 퍼진다 |
Flat은 입력을 좁히는 데 유리했고, Compound는 구조를 조합하는 데 유리했다. 조합 표현력과 조합 제한은 다른 목표였고, 두 형태는 각각 한쪽에만 강했다. 그리고 우리에게는 둘 다 필요했다. 지원하는 형태는 앞으로도 늘어날 테니 새 조합을 조립할 능력이 있어야 하고, 목표가 디자인이 정한 조합만 표현 가능하게 만드는 것이니 조합을 제한할 능력도 있어야 한다.
두 성질이 서로를 배제할 필요는 없었다. 다만 필요한 위치가 달랐다.
Recipe는 둘 중 하나가 아니라 세 번째였다
새로운 정보 구조나 variant를 만들기 위해 조합의 자유가 필요한 쪽은 디자인 시스템 내부였다. 화면을 만드는 제품 개발자에게 필요한 일은 디자인에서 정한 조합을 골라 데이터를 넣는 것까지였다. 새로운 List 구조의 설계는 그 목록에 없다.
그래서 Flat과 Compound 중 하나를 고르는 대신 세 번째 답을 만들었다. 앞 글이 정보 구조로 가른 셋마다 그것을 구현한 공개 컴포넌트를 하나씩 둔다. ListRowBasic, ListRowRegular, ListRowMax. 앞 글이 스타일링 도구의 용어를 빌려 Recipe라고 부른 것이 이 컴포넌트들이다.
같은 행을 Recipe로 쓰면 이렇다.
<ListRowBasic
variant="count"
leading={<ListIcon name="bell" />}
label="전체 알림"
count={12}
/>호출부만 보면 Flat처럼 읽힌다. 필요한 값이 prop에 평평하게 놓이고, 자동 완성이 듣는다. 표현을 조정하던 prop이 사라지고 제품이 알아야 할 값이 표면에 남았을 뿐, 문법으로는 첫 후보와 다를 것이 없다.
그래서 정확히 해두면, Recipe는 Flat이나 Compound와 같은 축에 놓인 세 번째 문법이 아니다. Flat과 Compound가 API를 어떻게 표현하는가의 답이라면, Recipe는 정해진 계약을 어떤 공개 단위로 나누는가의 답이다. 하나의 Flat 컴포넌트가 모든 정보 구조를 받는 대신 정보 구조마다 공개 컴포넌트를 하나씩 나눴고, type="basic"이라는 prop 값 대신 ListRowBasic이라는 컴포넌트를 고르게 했다. 컴포넌트를 고르는 행위가 API의 일부가 된 것이다.
조합의 자유 쪽은 Compound가 맡는다. Compound는 사라지지 않고 Recipe의 안쪽으로 들어갔다. Recipe가 하는 일이 내부에서 Leading, Content, Trailing, Bottom을 조합해 정해진 구조 하나를 만드는 것이다. 첫 글이 예고만 하고 이름을 붙이지 않았던 두 층에 이제 이름이 생겼다. 바깥으로 공개하는 층이 Recipe이고, 안쪽 조립 층이 Compound Primitive다.
세 형태를 나란히 놓으면 이렇다.
한 타입이 감당하던 축을 컴포넌트가 나눠 가졌다
도해의 마지막 줄을 다시 본다. 축이 하나 늘었을 때 Flat은 최상위 union을 다시 짜야 하고, Recipe는 안쪽 층에서 만든다. 지금 다시 정리하면 그 차이는 한 타입이 혼자 감당해야 하는 축의 수에서 온다.
하나의 Flat API는 모든 축을 한 타입 안에 담아야 했다. 정보 구조도, variant도, Trailing이 받을 수 있는 값도, Bottom의 유무도 같은 계약 안에서 서로를 제약한다. Recipe는 그중 정보 구조라는 축을 컴포넌트 경계에서 먼저 가른다. ListRowBasic을 고른 순간 Bottom은 타입에 아예 존재하지 않고, ListRowMax를 고르면 Bottom이 필수가 된다.
컴포넌트를 고르고 나면 그 안에서 조합을 고르는 일로 남는 union은 그 Recipe가 소유한 정보 구조 안의 분기, 곧 variant 축까지 줄어든다. 앞 글에서 본 ListRowBasicProps가 두 브랜치로 끝난 이유가 이것이다.
앞에서 적은 대로 지원하는 조합의 수는 어떤 형태로 만들든 변하지 않는다. 컴포넌트가 늘어난 만큼 각 타입이 좁아졌을 뿐이다.
Recipe는 두 형태의 장점을 반씩 섞은 절충이 아니다. 최적화하는 대상이 다르다. 하나의 컴포넌트로 모든 조합을 표현하는 것도, 호출부가 조합을 설계할 수 있는 것도 포기했다. 대신 정해진 조합만 표현된다는 성질과, 새 조합을 만들 능력이 시스템 안에 남는다는 성질을 얻었다.
비용도 함께 왔다. 컴포넌트의 개수가 늘고, 무엇을 고를 수 있는지가 타입 하나에 모여 보이지 않게 됐다. 하나의 Flat API라면 union 정의가 곧 전체 목록인데, Recipe에서는 세 컴포넌트와 각각의 variant 축을 알아야 전체가 보인다.
공개 prop에서 디자인 결정을 걷어냈다
Recipe라는 답이 정해졌다고 각 Recipe가 받을 prop까지 정해진 것은 아니다. 표면을 확정하면서 prop 후보 하나하나에 앞 글의 첫 번째 질문을 물었다. 이 결정에 필요한 정보를 누가 갖고 있는가.
제품 코드가 전달할 값은 제품만 아는 값이었다.
- label과 description
- count
- 이미지나 아이콘을 식별하는 값
- 행의 현재 상태
- 행을 눌렀을 때 실행할 action
디자인 시스템이 소유할 값은 정해진 조합의 표현 규칙이었다.
- typography와 color token
- 아이콘 크기와 영역 사이의 spacing
- 각 정보 구조에서 쓸 수 있는 Leading과 Trailing의 범위
- variant에 따른 강조 방식
첫 글에서 countColor와 leadingSize를 Figma의 결정이 제품 코드로 새어나가는 통로라고 불렀다. 위의 두 목록은 그 판정을 prop 하나하나에 반복한 결과다. 통로가 될 뻔한 prop은 전부 아래 목록으로 내려가 Recipe 내부의 소유가 됐다.
남은 공개 prop을 앞 글의 두 번째 질문으로 부르면 데이터이거나 상태이거나 행동이다. 정해진 표현 선택은 각 Recipe의 variant 축으로 모였고, 표현 규칙 자체는 공개 prop에 남지 않았다.
내부 Compound는 새 Recipe를 만드는 도구로 남겼다
앞 글은 넷째 Recipe가 필요해질 때의 절차를 셋으로 세워두고, 마지막에 이렇게 적었다. 새 Recipe는 내부에 남겨둔 조합 도구로 만든다. 그 조합 도구가 방금 이름을 붙인 내부 Compound다.
새 형태가 디자인의 승인을 거치면 디자인 시스템 안에서 Primitive를 조합해 새 Recipe를 만들고, 공개 층에 하나 추가한다. 제품 코드가 겪는 변화는 고를 수 있는 컴포넌트가 하나 늘어나는 것뿐이다. 전체 구조를 그리면 이렇다.
이 구조에서 함께 정한 것이 하나 더 있다. 내부 Compound를 공개하지 않는다.
이 화면만 조금 다른 형태가 필요하다는 요청은 언젠가 온다. 그때 내부 Primitive를 열어주면 그 화면은 오늘 나갈 수 있다. 하지만 공개된 조합 도구는 그 화면에서만 쓰이지 않는다. Primitive 층에서는 description과 count를 같이 놓는 행도, Switch와 Badge를 나란히 놓는 행도 전부 조립된다. 한 번의 예외를 위해 연 문이 Recipe가 막고 있던 모든 조합의 우회로가 된다. 우회하는 비용이 0이 되는 순간, 제한을 지키는 것은 타입이 아니라 합의가 된다.
그래서 예외 요청은 내부 층을 여는 방식으로 받지 않기로 했다. 요청을 어떻게 받고 얼마나 빨리 답하는지, 그래도 필요한 탈출구를 어디에 둘지는 운영의 문제라 4막에서 다룬다.
경계에 대해서도 하나 적어둔다. 두 층은 한 패키지 안에 있고, 여기서 나눈 것은 공개 API의 표면이다. 타입과 API 표면에 그은 이 경계가 코드 접근 수준에서도 지켜지는지는 별개의 문제이고, 그 문제는 4막에서 다시 연다.
타입이 좁힌 것은 코드로 표현되는 조합까지였다
Recipe와 variant의 discriminated union으로, 제품 코드의 일반적인 사용 경로에서 디자인이 정하지 않은 정적 조합이 타입 오류가 됐다.
- count를 받을 수 없는 variant에 count를 전달한다.
- 필수 데이터가 없는 variant를 고른다.
- 고른 Recipe가 받지 않는 prop을 함께 전달한다.
- 디자인 시스템이 공개하지 않은 표현 값을 쓴다.
타입 검사가 빌드나 CI의 게이트에 포함돼 있다면 이런 조합은 화면에 렌더링되기 전에 실패한다. QA가 Figma와 대조해 찾아야 했던 문제 일부가 제품 코드를 작성하는 시점으로 이동한다.
하지만 이 보호 범위를 실제보다 넓게 말할 수는 없다. 여기서 막은 것은 정확히 다음까지다.
제품 코드가 디자인에서 정하지 않은 정적 조합을 공개 TypeScript API를 통해 표현하는 경우
any와 강제 assertion과 JavaScript 호출부는 이 검사를 그대로 우회한다. 서버나 CMS에서 들어오는 값도 타입 선언만으로 검증되지 않는다. 긴 텍스트가 좁은 영역에서 어떻게 보이는지, 실제 브라우저에서 초점이 어디에 표시되는지도 타입만으로는 확인할 수 없다.
타입 선언 안쪽에도 경계가 있다. 앞 글이 ?: never를 설명하면서 미뤄둔 것이 그것이다. 같은 union이라도 값을 명시적으로 비워 넘기는 경우까지 막으려면 컴파일러 옵션이 하나 더 필요하다. 타입 선언이 어디까지 막고 어디부터 못 막는지는 다음 글에서 실제 타입을 두고 따진다.
컴파일 단계의 제약은 디자인 검토와 UI 테스트를 대체하지 않는다. 역할이 다르다.
- 타입은 표현할 수 있는 정적 조합의 범위를 좁힌다.
- 런타임 검증은 외부에서 들어오는 값의 경계를 확인한다.
- UI 검증은 실제 화면에서만 드러나는 상태와 레이아웃을 확인한다.
이 글에서 고른 형태는 첫 번째 경계를 공개 API의 표면에 세우는 선택이었다.
유연성을 없앤 것이 아니라 위치를 바꿨다
비교를 시작할 때의 질문은 Flat과 Compound 중 무엇이 좋은 API 형태인가였다. 그런데 세 번째 답으로 넘어간 자리에서 실제로 판단한 것은 문법이 아니었다.
누가 새로운 List 조합을 만들 수 있어야 하는가?
유연성의 위치는 그 답을 정하고 나서 따라왔다.
제품 개발자에게 모든 슬롯을 공개하면 새 화면을 빠르게 만들 수 있다. 하지만 화면마다 디자인을 다시 해석하게 되고, 허용되지 않은 조합도 정상적인 코드로 표현된다. 반대로 모든 유즈케이스를 하나의 Flat API에 넣으면 optional prop과 union 브랜치가 계속 늘어난다. API는 커지는데 어떤 조합이 지원되는지는 오히려 읽기 어려워진다.
내부 Compound와 공개 Recipe를 나누면서 조합의 자유와 입력의 제약을 서로 다른 위치에 뒀다. 디자인 시스템 안에서는 네 영역을 조합해 새 Recipe를 만들 수 있다. 제품 코드에서는 지원되는 Recipe를 고르고, 그 Recipe가 요구하는 데이터를 전달한다.
설계와 구현에는 약 3개월이 걸렸고, 기준이 정리된 핵심 비즈니스 플로우 세 곳의 화면부터 공통 List로 옮겼다. 도입 직후라 전체 화면의 마이그레이션이나 장기적인 결함 감소까지 확인하지는 못했다. 다만 실제 화면을 교체하면서 Figma에 정리된 유즈케이스가 Recipe로 표현되고, 허용하지 않은 일부 조합이 일반적인 제품 코드에서 타입 오류로 차단되는 수준까지는 확인했다.
없앤 것은 유연성 자체가 아니라, 제품 코드가 합의 없이 새로운 UI 조합을 만들 수 있는 경로였다.
다만 이 글이 제한한 것은 조합의 층까지다. 방금 쓴 Recipe 호출부에는 leading={<ListIcon name="bell" />}이라는 slot이 하나 있었다. Recipe를 골라도 저 자리에는 여전히 아무 컴포넌트나 들어갈 수 있다. 그것이 왜 타입으로 잘 막히지 않는지, 그래서 컴포넌트 대신 무엇을 받기로 했는지는 다음 글에서 다룬다.