이 문서는 Building an LLM safe design system의 한글 번역입니다.
목차
목차 보기
핵심 요약
📌 TL;DR (클릭하여 펼치기)
주요 내용
- LLM은 CSS를 유창하게 작성하지만, 팀의 디자인 결정을 모른 채 임의의 값을 선택합니다. 수백 가지 컴포넌트가 쌓이면 UI가 미묘하게 제각각으로 흩어집니다.
- Polar는 이를 해결하기 위해 Orbit이라는 디자인 시스템을 만들었습니다. 핵심 원칙은 “잘못된 선택을 타입 오류로 만들어 CI에서 걸러낸다”는 것입니다.
- StyleX 기반
<Box />컴포넌트 하나가 모든 레이아웃의 진입점입니다. props는 미리 정의된 디자인 토큰만 받으며, Tailwind의 자유로운 문자열 클래스는 허용하지 않습니다. - 순수
<div>등 HTML 요소는 ESLint 규칙(polar/no-raw-html-layout)으로 CI에서 금지합니다. 시맨틱은<Box as="nav">형태로 보존합니다. - 다크 모드는 CSS 네이티브
light-dark()함수로 처리합니다. 토큰 자체가 라이트/다크 값을 함께 담고 있어, LLM이dark:변형을 빠뜨릴 수가 없습니다.
핵심 메시지
- 문서에 적힌 규칙은 확률이지만, CI에 박힌 규칙은 계약입니다. LLM 시대의 디자인 시스템은 “따르길 바라는 결정의 집합”이 아니라 “표현할 수 있는 유일한 결정의 집합”이어야 합니다.
원문 작성일: 2026년 6월 16일
작성자: Polar 팀
오늘날 Polar에서 출시되는 UI 코드는 대부분 LLM을 개발 루프에 두고 작성합니다. 속도에는 더없이 좋지만, 일관성에는 더 까다롭습니다. 디자인 시스템이 그에 맞게 설계되지 않았다면 말이죠.
저희는 Orbit이라는 새 시스템을 이제 막 시작한 터라, 아직 갈피를 잡아가는 중입니다. 어떤 건 맞고 어떤 건 틀렸을 겁니다. 이 글은 그 생각을 기록한 글로, 나중에 저희 스스로 반박해볼 수 있도록 생각이 생생할 때 적어두었습니다.
출발점은 단순합니다. 문제는 LLM이 CSS나 Tailwind 클래스를 못 쓴다는 게 아닙니다. 오히려 유창하게 씁니다. 문제는 그 이면의 결정을 모른 채 쓴다는 데 있습니다.
LLM에게 카드 UI를 만들어 달라고 하면 p-4, rounded-lg, bg-gray-100, dark:bg-zinc-900, text-gray-500 같은 값을 꺼냅니다. 하나하나는 합리적입니다. 하지만 그게 꼭 저희 팀의 값은 아닙니다. 이런 일이 수백 개의 컴포넌트와 수천 번의 생성에 걸쳐 쌓이면, UI는 서로 조금씩 다른 수천 가지 회색으로 서서히 흩어집니다. CLAUDE.md에 막으려고 써두어도 마찬가지입니다.
그래서 Orbit에 걸고 있는 승부수는 이겁니다. 애초에 브랜드를 벗어난 결정을 코드로 표현하기 어렵게 만드는 것. 이상적으로는 거의 불가능하게. 저희가 실제로 내린 결정이 아닌 값이라면, CI를 통과해서는 안 됩니다.
시작하기 전에
먼저 한 가지 명확히 해두고 싶습니다. 이 글은 Tailwind를 비판하는 게 아닙니다. Tailwind는 정말 뛰어납니다. 유틸리티 CSS 역사상 가장 편리한 도구이고, Polar 초기 개발에도 광범위하게 쓰였으며, 사람이 마크업 대부분을 직접 타이핑하는 프로젝트라면 지금도 최우선 선택지입니다. 그 개방성은 사람이 키보드를 잡고 있을 때는 진짜 장점입니다.
단점은 아주 좁고 특정한 지점에 있습니다. LLM이 타이핑을 맡았을 때는 그 개방성이 오히려 문제가 됩니다. Tailwind를 떠나는 건 나쁘기 때문이 아닙니다. 저자가 달라졌기 때문에 제약을 걸어야 하는 것입니다.
빠르게 움직이고 반복하고 싶다면 Tailwind가 최선이라고 생각합니다. 다만 이 글은, 팀이 커지고 에이전트 기반 LLM 시대에 접어든 상황에서 일관성을 지키고 코드베이스를 미래에 대비시키기 위해 저희가 어떤 변화를 택했는지를 다룹니다.
문자열의 문제
Tailwind 클래스는 문자열입니다. className="flex p-4 bg-blue-500" 같은 클래스는 컴파일러에 닿기 전까지는 그냥 텍스트입니다. 빠르게 쓸 수 있는 것도 바로 이 때문이고, LLM이 짠 코드에서는 위험해지는 것도 바로 이 때문입니다.
문자열 기반 API는 LLM이 살짝 틀릴 수 있는 공간을 무한히 열어줍니다.
p-4,p-5,p-[17px],px-4 py-3— 모두 유효하고, 모두 다른 간격bg-gray-100,bg-zinc-100,bg-neutral-100— 모두 유효하고, 어느 것도 표준이 아님dark:변형 — LLM이 기억해서 붙여야 하고, 절반은 틀림text-[#3b82f6]같은 임의 값 — 팔레트를 아예 우회함
이 중 어느 것도 문법 오류가 아닙니다. 모두 lint를 통과하고 렌더링됩니다. 다만 정적 분석이 잡을 수 없는 한 가지 방식으로 틀렸습니다. 바로 시스템을 벗어났다는 것입니다. LLM은 저희 팀의 회색이 oklch(0.96 0.003 264)이지 bg-gray-100이 아니라는 걸 알 방법이 없습니다. 타입 시스템이 그걸 알려주지 않으니까요.
문자열에 lint 규칙을 쓰는 건 복잡합니다. 정규식이 놓친 특수 케이스를 계속 쫓는 싸움이 됩니다. 반면 props는 그렇지 않습니다.
저희가 계속 돌아오게 되는 건 탈출구(escape hatch) 문제입니다. LLM이 날 것의 className이나 인라인 스타일로 내려갈 수 있는 순간, 그 주위에 쌓아둔 모든 보장이 약해집니다. LLM은 탈출구를 좋아합니다. 학습 데이터가 탈출구로 가득하기 때문입니다.
클래스는 값이지, 결정이 아니다
LLM 이야기를 잠깐 내려놓겠습니다. p-4나 --color-gray-100에는 더 근본적인 문제가 있는데, 이건 누가 타이핑하든 마찬가지입니다.
디자인 시스템은 값의 묶음이 아닙니다. 결정의 집합입니다. 이를테면 이런 결정들이죠. “카드는 이 배경면 위에 얹힌다”, “덜 강조하는 텍스트는 이 색을 쓴다”, “세로로 쌓이는 요소 사이의 간격은 이만큼이다”. 값은 그 결정이 낳은 결과일 뿐, 결정 자체가 아닙니다.
p-4는 값입니다. “16픽셀 패딩”을 뜻합니다. 왜 그 크기인지, 어디서 써야 하는지, 무엇과 맞춰야 하는지는 설명하지 않습니다. bg-gray-100도 값입니다. 특정 회색 하나, 그 회색이 카드인지 호버 상태인지 비활성 컨트롤인지 우연의 일치인지는 전혀 담겨 있지 않습니다. CSS 변수도 이를 해결하지 못합니다. --color-gray-100: #f3f4f6은 더 나은 이름을 가진 값일 뿐, 그 색이 무엇을 위한 것인지는 여전히 모릅니다.
결정이 아니라 값으로 코드를 쓰면, 정작 그 값을 쓰는 자리에서 결정은 증발해버립니다. 6개월 후에는 bg-gray-100을 쓰는 곳이 40군데가 되고, 그중 어느 게 “카드”를 의미했는지 알 수 없습니다. 카드 배경색 방침을 바꾸려면, 결정을 고치는 게 아니라 색을 grep 해야 합니다. 의도는 도구도, 팀원도, 모델도 다시 읽을 수 있는 어딘가에 한 번도 적힌 적이 없습니다.
그래서 Orbit의 토큰은 값이 아닌 의도로 이름 붙입니다. background-card는 결정입니다. 카드가 얹히는 배경면이 이것이라는 결정. 라이트/다크 모드에서 어떤 hex 값이 되는지는 이름 뒤에 숨은 구현 세부사항입니다. 간격도 마찬가지입니다. m, l, xl은 척도 위의 역할이지, 우연히 마음에 든 픽셀 수가 아닙니다. 둘 다 padding="l"을 쓰는 요소는 같은 결정을 내렸다고 선언하는 것이지, 16px을 우연히 둘 다 원했다는 게 아닙니다.
LLM에게 bg-gray-100을 주면, 수백 개의 그럴싸한 이웃 중에서 선반 위의 값을 하나 골라야 하고, 그러려면 감각이 필요합니다. LLM에게 background-card를 주면, 저희가 이미 내려둔 결정 목록에서 결정을 고릅니다. 감각을 요구하는 게 아닙니다. 무엇을 만들고 있는지 이름 붙이도록 요구하는 겁니다.
문서는 제안이고, CI는 계약이다
가장 먼저 떠오르는 방법은 규칙을 써두는 것입니다. CLAUDE.md에, 스타일 가이드에, 시스템 프롬프트에 “우리 gray를 써라, bg-gray-100 쓰지 마라”고 적는 것이죠. 저희도 그런 것들을 다 갖추고 있습니다. 효과가 없습니다.
문서에 적은 건 확률이지 보장이 아닙니다. LLM은 읽고, 컨텍스트 안의 다른 모든 것과 견줘서, 대부분의 경우 따릅니다. 대부분은 디자인 시스템이 아닙니다. 수천 번의 생성을 거치면 빗나간 것들이 쌓이고, 결국 모든 diff를 손으로 검토하며 이탈을 잡아야 하는 처지로 돌아옵니다.
그래서 더 단단한 선을 그었습니다. Orbit 전체가 이 선 위에 걸려 있습니다. 정말 중요한 규칙은 영어로 쓰지 않습니다. CI에서 실행되는 ESLint 규칙으로 명문화합니다. 이것이 하나의 결정론적 계약을 만듭니다. PR이 초록이면 머지해도 됩니다. 그리고 거기서 따라오는 결론도 감수하기로 했습니다. 무언가 잘못됐는데 어떤 규칙도 잡지 못했다면, 그건 규칙의 빈틈이지 작성자의 실수가 아닙니다.
규칙을 직접 만들든가, 아니면 그 결과를 그대로 안고 가든가 — 선택지는 둘뿐입니다. “하지만 가이드라인에서 하지 말라고 했잖아” 같은 변명은 통하지 않습니다.
이렇게 하면 조심해야 할 주체가 뒤바뀝니다. 사람이든 LLM이든 모든 작성자가 매번 프롬프트마다 저희 나름의 방침을 기억해주길 믿는 대신, 그 방침 자체를 잊을 수도, 건너뛸 수도, 말로 무를 수도 없는 검사 안에 박아 넣습니다. LLM은 원하는 건 뭐든 써도 됩니다. 저희는 그중 기꺼이 배포할 만한 것만 CI를 통과하도록 해둘 뿐입니다.
토큰을 유일한 어휘로 만들기
저희는 Tailwind 대신 Meta의 컴파일 타임 타입 안전 스타일링 라이브러리인 StyleX를 쓰고 있습니다. 하지만 StyleX는 수단이지 목적이 아닙니다. 목적은 그 위에 만들 수 있는 것 — 디자인 토큰을 타입이 있는 props로 받는 단 하나의 기본 요소, <Box />입니다.
저희 스타일링 API는 Shopify의 Restyle 시스템에서 크게 영감을 받았습니다.
Orbit 방식은 이렇습니다.
<Box
flexDirection="column"
gap="l"
padding="m"
backgroundColor="background-card"
borderRadius="m"
borderColor="border-primary"
boxShadow="m"
>
<Text variant="heading-xs" color="text-primary">
카드 제목
</Text>
<Text color="text-secondary">설명</Text>
</Box>
여기 있는 모든 값은 결정에서 나왔습니다. padding은 "16px"를 받지 않습니다. 미리 정의된 크기 집합을 받습니다. backgroundColor는 hex 코드를 받지 않습니다. 실제로 정의한 색상 이름을 받습니다. 타입은 토큰 정의에서 바로 나옵니다.
export const spacing = stylex.defineVars({
none: '0',
xs: '4px',
s: '8px',
m: '12px',
l: '16px',
xl: '24px',
'2xl': '32px',
'3xl': '48px',
'4xl': '64px',
'5xl': '96px',
})
export const backgroundColors = stylex.defineVars({
'background-primary': 'light-dark(hsl(233, 4%, 81%), hsl(233, 4%, 3.5%))',
'background-card': 'light-dark(hsl(240, 2.90%, 72.50%), hsl(233, 4%, 9.5%))',
// ...
})
이게 핵심 아이디어입니다. 디자인 결정은 한 곳에 살고, 그것만이 prop 타입이 허용하는 유일한 것입니다. Orbit 코드를 생성하는 LLM은 CSS 전체 공간에서 선택하는 게 아닙니다. 저희가 써놓은 짧은 메뉴에서 고릅니다. 자동완성이 유효한 토큰을 보여줍니다. 오타는 시각적 회귀가 아니라 타입 오류 — 3주 후 발견되는 게 아니라 그 자리에서 잡힙니다.
순수 <div>를 금지하는 이유
이 부분이 가장 오래 고민하고, 지금은 가장 확신하는 부분입니다.
Box의 props를 제약해봤자, 제약 없는 것이 바로 옆에 있으면 아무 의미가 없습니다. 날 것의 <div>는 어떤 className이든, 어떤 인라인 스타일이든, 어떤 어트리뷰트든 받습니다. 그것은 빈 캔버스입니다. 그 빈 캔버스가 바로 시스템을 벗어난 코드를 가능하게 합니다. 토큰은 빠져나갈 구멍이 없을 때만 제약이 됩니다. 그리고 <div>가 바로 그 구멍입니다.
그래서 이 승부수는 ‘props에 타입을 입히기’에 그치지 않습니다. ‘타입 없는 컨테이너를 아예 없애기’입니다. 이제 예전에 <div>에 손이 가던 바로 그 자리에서 Box에 손이 갑니다. 그 옆에 나란히 놓인, 키 한 번 덜 누르면 그만인 제약 없는 샛길 같은 건 없습니다.
이 점은 사람보다 LLM에게 더 크게 작용합니다. 사람은 기여 가이드를 한 번 읽고 “여기서는 날 것의 div를 안 쓴다”를 내면화합니다. 반면 LLM은 매번 프롬프트마다 코드베이스를 처음 보듯 다시 파악하고, 별수 없이 가장 손쉬운 길로 빠집니다.
<div className="...">를 쓸 수 있다면, 수십 년의 학습 데이터가 그렇게 훈련시켰으니 그쪽으로 갑니다. 기본값을 실제로 움직이는 유일한 방법은 날 것의 요소를 쓰기 어렵게 만드는 것이 아니라 아예 쓸 수 없게 만드는 것입니다.
명백한 반론은 시맨틱입니다. <div>, <section>, <nav>, <ul>을 금지하면 의미 있고 접근 가능한 HTML을 잃는 게 아닌가? 그렇지 않습니다. 이 트레이드오프가 받아들일 만한 이유가 바로 여기 있습니다. Box는 다형적(polymorphic)입니다. 금지하는 요소 목록이 정확히 Box가 as prop으로 렌더링할 수 있는 요소 목록입니다.
<Box as="nav" alignItems="center" columnGap="m">…</Box>
<Box as="ul" flexDirection="column" rowGap="s">
<Box as="li">아이템</Box>
</Box>
DOM에는 여전히 진짜 <nav>와 진짜 <ul>이 나오고, 올바른 DOM props가 타입 체크를 거쳐 전달됩니다. 잃는 건 아무 문자열이나 받아주던 열린 입구이지, 시맨틱이 아닙니다. as는 허용 요소의 닫힌 집합이고, 날 것의 <nav>는 열린 문입니다. 의미는 살리고, 문을 닫는 것입니다.
ESLint 규칙으로 강제합니다.
"polar/no-raw-html-layout": "error"
<div /> 대신 @polar-sh/orbit의 <Box />를 사용하세요.
이렇게 해야 Orbit 디자인 시스템을 따를 수 있습니다.
잘못된 것이 컴파일에서 실패하도록 만드는 것 — 지금까지 저희가 찾은, LLM의 새 컨텍스트 윈도우에서도 살아남는 유일한 지시 방식입니다.
디자인 시스템에 없는 값이 필요하다면, 시스템을 우회하는 신호가 아니라 토큰을 추가하라는 신호로 삼는 편이 낫습니다. 어디까지가 정당한 탈출구이고 어디부터가 게으름인지, 그 선은 아직 긋는 중입니다. 배워가면서 계속 옮겨갈 생각입니다.
LLM이 잊을 수 없는 다크 모드
토큰 값을 자세히 보세요. light-dark(hsl(233, 4%, 81%), hsl(233, 4%, 3.5%)). 각 색상에는 라이트 값과 다크 값이 함께 담겨 있고, 브라우저 네이티브 light-dark() CSS 함수가 이를 해석합니다.
즉 기억해야 할 dark: 변형이 없습니다. 스타일을 두 번 입히는 과정 자체가 없으니까요. backgroundColor="background-card"를 한 번 쓰면, 두 테마 모두에서 올바른 값이 렌더링됩니다. LLM은 라이트 모드에서는 멀쩡한데 다크 모드에서는 깨지는 컴포넌트를 내놓을 수 없습니다. 틀릴 만한 별도 다크 모드 코드가 아예 없으니까요. 가장 흔한 부류의 테마 버그는 애초에 표현할 수조차 없습니다.
지금까지 보이는 것
아직 초기라서, 방향 정도로 받아들이시길 바랍니다. 결론은 아닙니다. 하지만 리뷰가 벌써 달라지기 시작했습니다. 예전에는 생성된 UI에서 스타일 이탈을 검토했습니다. 잘못된 회색, 잘못된 간격, 빠뜨린 다크 모드, 걸러지지 않은 임의 값 같은 것들을요.
Polar의 모든 개발자가 디자인 시스템과 UX 기준에 익숙하지 않아도 됩니다. 원래 그럴 필요가 없었고, 지금도 마찬가지여야 합니다. 더 많은 부분이 구조적으로 올바르게 되어, 대화가 동작과 레이아웃으로 옮겨가고 있습니다.
LLM이 저희 팔레트를 안다고 믿고 맡기는 게 아닙니다. 그저 LLM에게, 입에 올릴 수 있는 말 대부분이 이미 정답인 어휘를 쥐여주는 것입니다.
아직 해결해야 할 진짜 문제들이 남아 있습니다. 닫힌 토큰 집합은 저희가 실제로 만드는 일부 UI를 담아내기엔 너무 작습니다. 매주 토큰을 추가하면서, 제약이 절약하는 것보다 비용이 더 드는 시점을 지켜보고 있습니다.
앱 대부분은 아직 레거시 Tailwind로 되어 있고, 코드를 건드릴 때마다 파일 단위로 마이그레이션하는 중입니다. 한 번에 전부 다시 쓰는 방식은 아닙니다. 허용한 탈출구는 하나하나가 그 보장에 난 금입니다. eslint-disable 줄을 일일이 점검하면서, 목록이 늘어나면 디자인 시스템의 버그로 간주합니다.
그래도 저희가 늘 다시 붙드는 생각은 하나입니다. 디자인 시스템은 사람들이 따라주길 바라는 결정의 집합이었습니다. 코드 대부분을 LLM이 쓰는 시대에는, 바람은 전략이 될 수 없습니다. 디자인 시스템은 표현할 수 있는 유일한 결정의 집합이 되어야 합니다.
그것이 바로 Orbit이 딛고 선 승부수입니다.
참고 자료
- Building an LLM safe design system - 원문
- Shopify Restyle — Orbit 스타일링 API의 영감 원천
- StyleX — Meta의 컴파일 타임 타입 안전 CSS 라이브러리