컴포넌트 API 설계: 불린의 함정

디자인 시스템을 쓰는 일과 설계하는 일은 다르다. 쓰는 쪽은 <Button>을 불러와 화면에 얹으면 끝이다. 설계하는 쪽은 그 <Button>이 어떤 props를 받을지, 어떤 조합을 허용하고 어떤 조합을 막을지를 정한다. 이 결정이 곧 API다. 컴포넌트의 props는 함수의 인자와 똑같은 지위를 가진다. 팀의 다른 디자이너와 개발자가 매일 호출하는 공개 인터페이스고, 한번 배포되면 바꾸기 어렵다.
그런데 이 API를 설계할 때 가장 흔하게, 가장 조용히 무너지는 지점이 있다. 불린(boolean) prop을 하나씩 늘려가는 습관이다.
이제부터 코드가 조금씩 나온다. 미리 말해두면 디자이너가 이 props를 직접 타이핑할 일은 없다. 코드의 모양만 눈으로 읽으면 된다. 그 모양이 곧 이 컴포넌트가 뭘 허용하고 뭘 막는지의 목록이고, 그건 디자이너가 시안에서 늘 다루는 것과 같은 것이기 때문이다.
하나의 비유를 깔고 가자. 카페 주문서를 떠올리면 이 글 전체가 쉬워진다. 음료 사이즈는 S, M, L 중 딱 하나를 고른다. 라디오 버튼처럼 하나를 누르면 나머지는 저절로 꺼진다. 그런데 주문서를 잘못 만들면, 사이즈를 하나의 선택이 아니라 작음? 큼? 같은 체크박스 여러 개로 쪼갤 수 있다. 그러면 작음과 큼을 동시에 체크한 주문서, 작은데 큰 커피가 열린다. 있을 수 없는 주문인데 주문서는 아무렇지 않게 받아준다. 이 글이 말하는 불린의 함정이 정확히 이 모양이다. 코드가 어려워 보이면 지금 이건 카페 주문서 이야기라고 되뇌면 된다.
불린은 어떻게 쌓이는가
처음엔 아무 문제가 없어 보인다. 버튼에 강조 스타일이 필요하다. primary 불린을 추가한다. 위험한 동작에 쓸 빨간 버튼이 필요하다. danger를 추가한다. 큰 버튼이 필요하다. large. 작은 버튼도. small. 비활성 상태도 있어야지. disabled. 로딩 스피너도. loading. 몇 주 뒤 API는 이렇게 생겼다.
interface ButtonProps {
primary?: boolean;
secondary?: boolean;
danger?: boolean;
large?: boolean;
small?: boolean;
disabled?: boolean;
loading?: boolean;
}
코드를 모르는 눈으로도 모양은 읽힌다. 줄마다 이름 하나에 boolean이 붙어 있다. boolean은 참이냐 거짓이냐, 곧 켜고 끄는 스위치라는 뜻이다. 이름 뒤의 물음표(?)는 안 적어도 된다는 표시, 곧 선택 항목이라는 뜻이다. 그러니 이 타입은 켜고 끌 수 있는 스위치 일곱 개가 나란히 놓인 주문서다. primary 스위치, danger 스위치, large 스위치, small 스위치, 각각을 서로 따로 켜고 끈다. 카페 주문서로 치면 사이즈를 하나 고르는 칸이 아니라, 큼? 작음?을 각각 체크하는 칸으로 쪼개 둔 셈이다.
각 줄은 지극히 합리적인 요청에서 나왔다. 문제는 줄 하나하나가 아니라 이들이 모여 만드는 공간이다. 불린이 일곱 개면 이 컴포넌트가 가질 수 있는 상태 조합은 2의 7제곱, 128가지다. 불린 하나를 더할 때마다 조합은 두 배가 된다. 여덟 개면 256가지, 열 개면 1024가지다. 변형이 선형으로 늘어난다고 느끼지만 실제로는 지수로 폭발한다.
이 128가지 중 대부분은 의미가 없거나, 애초에 있어선 안 되는 조합이다. 그래서 불린의 함정은 세 겹으로 온다.
함정의 세 겹
첫째, 모순 조합이 열린다. primary와 secondary는 서로를 배제하는 개념이다. 버튼이 동시에 주 버튼이면서 보조 버튼일 수는 없다. 그런데 위 타입은 둘 다 true인 상태를 아무렇지 않게 허용한다.
<Button primary secondary />
JSX에서 이렇게 이름만 나란히 적으면 그 스위치를 켠다는 뜻이다. 그러니 primary secondary는 두 스위치를 동시에 켠 것, 카페 주문서에 작음과 큼을 같이 체크한 것과 같다. 타입 검사기는 통과시킨다. 두 불린 다 boolean 타입이 맞으니까. 그럼 렌더링은 어떻게 될까. 컴포넌트 내부의 if (primary) ... else if (secondary) ... 순서에 따라 결정된다. 즉 어느 스타일이 이기는지가 구현 세부에 숨어버린다. API가 막았어야 할 실수를 런타임 우연에 떠넘긴 것이다. 이건 상태 하나가 여러 불린으로 쪼개져 서로 모순될 수 있는 문제, 즉 불가능한 상태가 표현 가능해지는 문제와 같은 뿌리다. 이번 글은 그걸 컴포넌트의 공개 API 표면에서 본다.
둘째, 변형이 문서와 테스트를 감당 불가능하게 만든다. 스토리북(Storybook)에 이 버튼의 모든 상태를 나열한다고 해보자. 이론상 128개의 스토리가 필요하다. 시각 회귀 테스트(visual regression test)로 스냅샷을 찍으려 해도 128장이다. 물론 실제로 의미 있는 조합만 골라 찍겠지만, 그 순간 이런 질문이 생긴다. large이면서 small인 스냅샷은 찍어야 하나. disabled이면서 loading인 건. 어떤 조합이 유효하고 어떤 게 무효인지가 타입에 안 적혀 있으니, 사람이 매번 머릿속으로 골라내야 한다. API가 스스로 설명하지 못하면 그 부담은 전부 사용하는 사람에게 넘어간다.
셋째, 호출부에서 의도가 안 보인다. JSX에서는 조금 낫지만, 같은 컴포넌트를 명령형으로 부르는 코드나 다른 언어의 함수 호출을 상상해보면 분명해진다.
createButton(true, false, false, true, false, true, false);
이 일곱 개의 불린이 각각 뭘 뜻하는지는 함수 시그니처를 열어봐야 안다. 이름표 없는 예/아니오 일곱 개를 순서대로 바리스타에게 들이미는 것과 같다. 두 번째 칸이 켜졌는데 그게 큼인지 뜨거움인지 주문서를 안 보면 모른다. JSX처럼 이름이 붙어 primary large로 쓰더라도, primary와 large가 서로 독립인지 배타인지는 여전히 이름만 봐선 모른다. 불린은 예와 아니오 두 값만 담기 때문에, 그 자리에 원래 어떤 선택지들이 있었는지를 지워버린다. 이게 아리아 히다야트(Ariya Hidayat)가 2011년 글에서 불린의 함정(boolean trap)이라 부른 문제의 핵심이다. 불린 인자의 이 문제 자체는 그전부터 API 설계 쪽에서 지적되던 것이고, 그가 이 이름을 붙여 널리 퍼뜨린 쪽에 가깝다.
처방: 상호배타는 하나의 열거형으로
세 겹의 함정은 사실 하나의 오진에서 나온다. 서로 배타적인 선택지를 여러 개의 독립 불린으로 모델링한 것이다. 처방도 하나다. 상호배타 옵션은 불린 여러 개가 아니라 하나의 열거형(enum), 타입스크립트라면 문자열 리터럴 유니온으로 표현한다.
type Variant = "primary" | "secondary" | "danger" | "ghost";
type Size = "sm" | "md" | "lg";
interface ButtonProps {
variant?: Variant;
size?: Size;
disabled?: boolean;
loading?: boolean;
}
이 코드가 처방의 핵심이니 기호부터 읽자. 가운데 세로 막대 |는 또는이라는 뜻이다. 그러니 첫 줄 type Variant = "primary" | "secondary" | "danger" | "ghost"는 variant가 primary이거나 secondary이거나 danger이거나 ghost, 넷 중 하나라고 읽으면 된다. 둘째 줄 Size도 sm, md, lg 셋 중 하나다. 스위치 여러 개를 따로 켜고 끄던 앞의 주문서와 결정적으로 다르다. 이건 사이즈 칸에서 S, M, L 중 하나만 눌리는 라디오 버튼이다. 하나를 누르면 나머지는 자동으로 꺼진다. 그 아래 interface는 이 버튼이 받는 주문 항목표다. variant를 고르는 칸 하나, size를 고르는 칸 하나, 그리고 아무 사이즈와도 같이 켤 수 있는 진짜 스위치인 disabled와 loading이 있다.
바뀐 게 뭔지 보자. variant는 네 값 중 정확히 하나만 가진다. primary이면서 danger인 상태는 이제 타입 레벨에서 표현 자체가 불가능하다. 렌더링 우선순위를 구현 안에 숨길 일도 없다. 애초에 두 값이 공존할 수 없으니까. 모순 조합을 런타임 방어 코드로 막는 게 아니라, 컴파일 시점에 존재하지 못하게 지운 것이다. 주문서에서 작음과 큼을 동시에 체크하는 칸 자체가 없어진 것이다.
조합 폭발도 정리된다. 아까 불린 다섯 개(primary, secondary, danger, large, small)가 만들던 32가지 조합은, 이제 variant 4개 곱하기 size 3개, 12가지로 줄었다. 그리고 이 12가지는 전부 유효하다. 무효 조합을 걸러낼 필요가 없다. 스토리북도 variant를 한 축, size를 다른 축으로 격자를 짜면 12칸이 정확히 다 채워진다. 버릴 칸이 없다.
여기서 판단 기준이 하나 선다. 언제 불린이고 언제 유니온인가. 시험은 간단하다. 이 상태가 다른 상태들과 자유롭게 공존할 수 있으면 불린, 서로 배타면 유니온이다. disabled는 어떤 variant, 어떤 size와도 함께 쓸 수 있다. primary이면서 disabled, danger이면서 disabled 다 말이 된다. 그러니 disabled는 진짜 독립적인 이진 상태고 불린이 맞다. loading도 마찬가지다. 반면 primary와 secondary는 공존이 모순이니 유니온의 두 값이어야 한다. 카페로 치면 뜨겁게/차갑게를 고르는 것과 사이즈를 고르는 것은 서로 상관없이 함께 정하지만, S냐 M이냐는 둘 중 하나여야 하는 것과 같다.
한 가지 함정이 더 있다. 지금 진짜 둘 중 하나뿐이라 불린으로 두더라도, 나중에 세 번째 선택지가 생기는 순간 문제가 된다. 예를 들어 outlined?: boolean으로 시작한 테두리 버튼에 나중에 점선 스타일이 필요해지면, 불린으로는 표현할 수 없어 variant로 갈아엎어야 한다. 이건 공개 API를 바꾸는 파괴적 변경이라, 그 버튼을 쓰던 모든 호출부를 건드려야 한다. 그래서 배타적 선택지가 셋 이상으로 자랄 여지가 조금이라도 보이는 축은 처음부터 유니온으로 여는 편이 낫다. 물론 반대 방향의 과설계도 경계해야 한다. 정말로 켜고 끄는 것밖에 없는 상태(fullWidth 같은)를 억지로 한 값짜리 유니온으로 만들 이유는 없다. 판단은 다시 앞의 시험으로 돌아간다. 배타적 선택지가 셋 이상으로 자랄 개념인가, 아니면 순수한 온오프인가.
타입으로 필수 조합까지 강제하기
유니온은 모순을 막는 첫걸음이고, 여기서 한 발 더 나갈 수 있다. 특정 조합을 아예 필수로 강제하는 것이다. 판별 유니온(discriminated union)을 쓰면 된다.
아이콘만 있는 버튼을 생각해보자. 텍스트가 없으니 스크린 리더가 읽을 게 없다. 접근성을 위해 라벨이 반드시 있어야 한다. 반면 텍스트 버튼은 라벨이 필요 없다. 자식으로 들어온 글자가 곧 라벨이니까. 이 규칙을 주석이나 문서가 아니라 타입으로 못박을 수 있다.
type ButtonProps =
| { kind: "text"; children: string; icon?: never }
| { kind: "icon"; icon: string; label: string; children?: never };
여기도 앞서 본 |가 나온다. 이 타입은 아래 두 줄 중 하나라고 읽으면 된다. 텍스트 버튼이거나, 아이콘 버튼이거나. 카페로 치면 주문서가 두 종류로 갈린 것이다. 글자를 새긴 컵과, 그림만 있는 컵. 각 줄 안의 중괄호 { ... }는 그 종류를 고르면 함께 따라오는 항목표다. never라는 낯선 단어는 여기 자리 자체가 없다, 넣지 마라는 뜻이다. 그림만 있는 컵은 겉만 봐서 뭔지 모르니 반드시 이름표(label)를 붙이게 하고, 대신 글자(children) 칸은 never로 막는다. 글자 컵은 반대로 글자 칸이 필수고 아이콘 칸이 막힌다.
kind가 판별자다. kind: "icon"을 고르면 label이 필수가 되고, children은 아예 넣을 수 없다(never). 반대로 kind: "text"면 children이 필수고 icon은 막힌다. 아이콘 버튼을 만들면서 라벨을 빠뜨리면 컴파일이 안 된다. 접근성 규칙이 코드 리뷰의 눈썰미에 기대지 않고 타입 검사기에 박히는 것이다. 그림 컵을 주문하면서 이름표를 안 붙이면 주문서가 아예 접수되지 않는 셈이다.
컴포넌트 내부에서도 이 판별자가 일한다.
function resolveLabel(p: ButtonProps): string {
if (p.kind === "icon") {
return p.label; // 여기서 p.label은 확실히 존재한다
}
return p.children; // kind === "text" 이므로 children이 확실히 존재
}
모양부터 보면, if는 조건이 맞으면 그 안으로 들어가고, return은 값을 하나 내놓는다는 뜻이다. 그러니 이 함수는 주문서(p)를 받아 화면에 읽어줄 이름을 돌려주는 일을 한다. 그림 컵이면 이름표(label)를, 글자 컵이면 새긴 글자(children)를 그대로 내놓는다. if (p.kind === "icon") 안에서 타입스크립트는 p를 아이콘 분기로 좁힌다(narrowing). 그 안에서는 p.label이 반드시 있다는 걸 알기에 옵셔널 체이닝이나 방어 코드가 필요 없다. 잘못된 상태가 타입에 존재하지 않으니 그걸 처리하는 코드도 존재하지 않아도 된다. 이게 잘못된 상태를 표현 불가능하게 만든다는 원칙이 API 설계로 넘어온 모습이다. 통계나 지각 실험에서 애초에 관측될 수 없는 값을 데이터 구조에서 배제하듯, 여기선 애초에 호출될 수 없는 조합을 타입에서 배제한다.
한 가지 정직하게 짚을 것이 있다. 이 안전망은 컴파일 시점에만 작동한다. props가 CMS나 JSON 설정처럼 런타임에 외부에서 흘러들어오면, 타입 시스템은 그걸 검사하지 못한다. kind가 아이콘인데 라벨이 빈 문자열인 데이터가 들어오면 타입은 통과하고 접근성은 깨진다. 타입은 개발자가 손으로 부르는 호출부를 지키지, 데이터 경계까지 지키지는 않는다. 그 경계에선 런타임 검증이 따로 필요하다.
합성으로 prop 폭발을 구조로 풀기
지금까지는 하나의 축(variant, size)이 폭발하는 걸 유니온으로 다스렸다. 그런데 다른 종류의 폭발이 있다. 컴포넌트가 담을 내용이 늘어나면서 prop이 끝없이 붙는 경우다. 카드 컴포넌트가 대표적이다.
<Card
icon={<UserIcon />}
title="김지훈"
subtitle="프로덕트 디자이너"
action={<FollowButton />}
footerText="가입 2년차"
badge="PRO"
/>
이 코드도 모양만 보면 된다. <Card ... /> 안에서 icon={...}, title="..."처럼 이름 하나에 값 하나를 채워 넣고 있다. 주문 항목을 칸마다 적어 넣는 것이다. 문제는 카드에 넣고 싶은 게 늘 때마다 이 칸이 하나씩 는다는 데 있다. 커피에 토핑을 추가할 때마다 주문서에 휘핑? 시럽? 펄? 칸을 무한히 새로 파는 것과 같다. 처음엔 title과 subtitle만 있었을 것이다. 그러다 아이콘을 넣고 싶은 화면이 생기고, 액션 버튼이 필요한 화면이 생기고, 배지가 필요한 화면이 생긴다. prop이 하나씩 붙는다. 여기서 배지를 액션 옆이 아니라 제목 위에 붙이고 싶으면 어떻게 하나. badgePosition prop을 또 만드나. 카드 하나가 세상의 모든 카드 레이아웃을 설정으로 감당하려 드는 순간, prop 목록은 앞서 본 불린 목록과 똑같은 방식으로 무너진다.
이 폭발은 유니온으로 못 푼다. 성질이 다른 폭발이기 때문이다. variant 폭발은 하나의 축에서 값이 배타적으로 늘어난 것이고, 카드 폭발은 여러 자리(슬롯)에 여러 내용이 조합되는 것이다. 후자는 설정(configuration)이 아니라 합성(composition)으로 푼다. 카드가 내용을 prop으로 받는 대신, 자식으로 받게 하는 것이다.
<Card>
<CardHeader>
<UserIcon />
<CardTitle>김지훈</CardTitle>
<CardSubtitle>프로덕트 디자이너</CardSubtitle>
</CardHeader>
<CardBody>가입 2년차</CardBody>
<CardFooter>
<FollowButton />
</CardFooter>
</Card>
바뀐 모양을 보자. 아까는 <Card ... /> 하나에 칸을 잔뜩 채웠는데, 이번엔 <Card>를 열고 그 안에 <CardHeader>, <CardBody>, <CardFooter> 같은 조각을 차곡차곡 끼워 넣는다. 칸을 채우는 게 아니라 부품을 조립하는 것이다. 주문서에 토핑 칸을 무한히 늘리는 대신, 컵에 원하는 토핑을 직접 얹어 조립해 건네는 쪽에 가깝다. 이제 카드는 어디에 뭘 놓을지를 설정으로 알 필요가 없다. 그저 자식이 흘러들어올 자리를 내어줄 뿐이다. 배지를 제목 위에 놓고 싶으면 CardHeader 안에서 순서를 바꾸면 된다. 새 레이아웃마다 prop을 추가할 일이 없다. 폭발하던 설정 공간을 구조로 옮겨 담은 것이다.
합성의 트레이드오프
여기서 멈추면 합성이 만능처럼 보이지만, 대가도 분명히 있다. 합성은 자유롭지만 일관성을 강제하는 힘이 약하다.
설정 방식의 카드는 답답한 대신 확실한 게 하나 있었다. 제목과 부제의 위치, 간격, 타이포가 언제나 똑같았다. 카드를 쓰는 사람이 그걸 어길 방법이 없었으니까. 합성으로 열어주는 순간 그 보장이 사라진다. 어떤 팀원은 CardTitle을 안 쓰고 그냥 <h3>을 박고, 어떤 팀원은 헤더 없이 바디부터 시작하고, 어떤 팀원은 푸터에 제목을 넣는다. 자유를 줬더니 카드가 화면마다 조금씩 달라진다. 디자인 시스템이 애초에 막으려던 그 편차가 슬그머니 돌아온다.
그래서 좋은 컴포넌트 설계는 두 방식을 섞는다. 흔한 경우는 설정으로 굳혀 일관성을 보장하고, 벗어나는 경우만 합성으로 열어준다. 예를 들어 카드 대부분이 아이콘, 제목, 부제 구조라면 그건 CardHeader가 icon, title, subtitle을 받는 형태로 굳혀 흔들리지 않게 하고, 특수한 레이아웃이 필요한 소수만 CardHeader의 자식 슬롯을 열어 직접 짜게 한다. 설정은 일관성을, 합성은 확장성을 맡는다. 어느 쪽으로 얼마나 여느냐가 시스템 설계자의 판단이고, 이건 자체 시스템을 처음부터 지을지 검증된 오픈소스를 가져다 쓸지의 결정만큼이나 시스템의 성격을 좌우한다.
네이밍이 곧 API다
마지막으로, 지금까지 미뤄둔 층이 있다. 이름이다. 유니온으로 값을 좁히고 합성으로 구조를 풀어도, 그 값과 슬롯의 이름을 잘못 지으면 API는 다시 새기 시작한다.
핵심 원칙은 하나다. 의도로 이름 짓고, 구현 세부로 짓지 않는다. variant="danger"는 좋은 이름이다. 이 버튼이 위험한 동작에 쓰인다는 의도를 담기 때문이다. 반대로 variant="red"는 나쁜 이름이다. 지금 danger 스타일이 빨강일 뿐, 빨강은 구현 세부다. 다크 모드에서 색이 바뀌거나 브랜드 리뉴얼로 위험색이 주황이 되는 순간 red라는 이름은 거짓말이 된다. 이름이 색이 아니라 의도를 가리키면, 구현이 바뀌어도 이름은 살아남는다. 이건 색 토큰을 red-500이 아니라 color-danger처럼 의미 계층으로 짓는 발상과 정확히 같은 정신이다. prop 이름은 컴포넌트 표면의 시맨틱 토큰인 셈이다.
불린 이름에는 다른 함정이 있다. 부정형이다. disabled는 관용적으로 굳은 이름이라 그대로 쓰는 게 맞지만, 여기서 어설프게 긍정형을 고집해 enabled로 뒤집으면 오히려 관례를 깨서 혼란을 준다. 진짜 피해야 할 건 이중 부정이다. isNotEnabled 같은 이름은 참일 때 비활성이라는 걸 머릿속에서 두 번 뒤집어야 읽힌다. disabled={!isActive} 같은 호출부는 부정 위에 부정이 얹혀 사고를 부른다. 이름 하나가 읽는 사람의 머릿속에 부정을 몇 번 쌓게 하는지가 API의 사용성을 가른다.
잘못 쓰기 어렵게
이 모든 처방을 관통하는 한 문장이 있다. 좋은 컴포넌트 API는 잘못 쓰기 어렵게(hard to misuse) 설계된다. 잘 쓰기 쉽게보다 잘못 쓰기 어렵게가 먼저다. 문서에 아무리 올바른 사용법을 적어도, API가 틀린 사용을 허용하면 누군가는 반드시 틀리게 쓴다. 반대로 틀린 조합이 타입에서 아예 불가능하면, 문서를 안 읽어도 틀릴 수가 없다. 작은데 큰 커피를 주문할 칸 자체가 없으면 아무도 그걸 주문할 수 없는 것과 같다.
정리하면 세 가지 지렛대다. 모순 조합은 유니온과 판별 유니온으로 타입 레벨에서 막는다. 필수 조합은 판별 유니온으로 강제한다. 흔한 경우는 기본값과 설정으로 굳혀 쉽게 만들고, 벗어나는 경우만 합성으로 연다. props를 하나 더 추가하고 싶어질 때마다 물어보면 된다. 이건 정말 독립된 이진 상태인가, 아니면 배타적 선택지를 불린으로 쪼갠 것인가. 이 컴포넌트가 담을 내용인가, 아니면 컴포넌트의 설정인가. 이 이름은 의도를 가리키나, 아니면 오늘의 구현을 가리키나.
컴포넌트를 쓰는 일은 여기서 끝나지만, 설계하는 일은 이 세 질문을 매 prop마다 다시 던지는 일이다. props는 API고, API는 한번 열면 되돌리기 어렵다. 그러니 처음 여는 그 순간에, 잘못 쓸 문을 아예 만들지 않는 편이 낫다.
이 글, 어떠셨어요?