디자인시스템으로 화면을 생성하려면, 모델보다 게이트를 먼저 만들어야 한다
이 글은 우리 플랫폼 팀이 THAKI Design System(@thaki/tds)으로 데스크톱 콘솔 UI를 자동
생성하는 모델을 만들면서 배운 것을 정리한 기록이다. 읽는 대상은 LLM으로 UI를 뽑아보려는
프론트엔드·ML 엔지니어다. 결론부터 말하면, 화면 생성의 품질은 모델 크기가 아니라 생성물을
실제로 컴파일하고 검증하는 게이트가 결정했다.
학습으로 문법을 가르치는 대신, 규칙으로 유효한 출력만 나오게 강제했습니다.
왜 코드를 직접 생성하지 않았나
스크린샷이나 요구사항에서 긴 JSX를 한 번에 생성하는 방식은 오래 알려진 실패 패턴이 있다. 요소가 빠지고, 계층이 뒤틀리고, 레이아웃이 평평해진다. 우리는 일반적인 표현은 보존하고 도메인 특화 부분만 강하게 학습한다는 원리를 UI에 옮겼다. 모델이 픽셀이나 코드를 직접 만들지 않고 두 단계의 중간표현만 만들게 했다. 먼저 디자인시스템과 무관한 UX-IR로 “이 화면이 무엇을 하는가”를 표현하고, 그다음 TDS-IR로 “그것을 어떤 TDS 컴포넌트로 실현하는가”를 표현한다. 실제 코드는 모델이 아니라 결정론적 컴파일러가 소유한다. TDS가 이미 패딩과 타이포와 상호작용을 구현하고 있으니, 모델은 스타일을 다시 배울 필요 없이 어떤 컴포넌트를 어떤 구조로 쓸지만 배우면 된다.
먼저 만든 것은 모델이 아니라 레지스트리와 게이트였다
tds_ssot 저장소를 분석해 61개 컴포넌트의 props와 slot, enum 값, 그리고 어떤 slot에 어떤
컴포넌트가 들어갈 수 있는지를 자동으로 추출해 Component Registry로 만들었다. Button의 variant가
여덟 가지 중 하나여야 한다는 것, FormField의 control slot에 들어갈 수 있는 컴포넌트가 정해져
있다는 것 같은 규칙이 전부 여기에 들어간다. 흥미로운 발견 하나는, @thaki/tds가 모바일
시스템이 아니라 Table과 Drawer와 Modal 중심의 데스크톱 콘솔 시스템이라는 점이었다. 데모가
/desktop 경로인 이유이기도 하다. 그래서 UX 역할을 TDS 컴포넌트로 실현하는 매핑도 콘솔 패턴에
맞춰 잡았다.
게이트는 코드가 소유하게 했다. 컴포넌트가 실제로 존재하는지, prop이 정의된 값인지, slot 호환이 맞는지, 누를 수 있는 요소에 동작이 붙어 있는지, primary 버튼이 중복되지 않는지를 결정론적으로 검사한다. 모델이 “잘 만들었습니다”라고 자기 보고하는 것은 검증이 아니다. 컴파일이 되고 스키마를 통과하는 것이 검증이다.
데이터는 100개면 충분한가
사용자 요청은 샘플 100개였다. 여기서 정직하게 짚어야 할 것이 있다. 100개로 27B 모델을 풀 파인튜닝하면 과적합과 mode collapse가 확정이다. 그래서 100개를 27B 학습셋이 아니라 파이프라인을 증명하는 데이터로 썼다. 확률 그래머로 실제 콘솔 화면 패턴을 합성하고, 게이트를 통과한 화면에서만 학습 예제를 파생했다. 화면 한 개에서 텍스트에서 UX-IR, 텍스트에서 TDS-IR, 편집 지시에서 JSON Patch, 잘못된 IR에서 복구, 그리고 good과 corrupt를 짝지은 선호쌍까지 여러 태스크를 뽑아냈다. 게이트를 통과한 시드 화면 22개 중 20개에서 100개의 SFT 예제와 20개의 선호쌍이 나왔다. 데이터의 양보다 실행 가능한 검증을 통과했는지가 중요하다는 원칙을 그대로 지켰다.
가장 중요한 숫자
우리가 이 프로젝트에서 얻은 가장 값진 결과는 학습 곡선이 아니라 지표들의 판별력 비교였다. 정답 IR에서 컴포넌트 하나를 지우고 prop의 enum 값을 망가뜨린 다음, 여러 지표가 이 손상을 얼마나 잡아내는지 쟀다.
| 지표 | 정상 | 손상 | 변화 |
|---|---|---|---|
| component F1 (유사도) | 1.00 | 0.81 | 거의 안 떨어짐 |
| valid-prop rate | 1.00 | 0.30 | 붕괴 |
| schema-valid rate | 0.93 | 0.23 | 붕괴 |
| compile-ok | 1.00 | 1.00 | 반응 없음 |
유사도 기반 지표는 관대했고, 컴파일 성공은 아예 반응하지 않았다. 컴파일러는 잘못된 prop도 그냥 그대로 뱉기 때문에 컴파일이 됐다고 품질이 좋은 것이 아니다. 반대로 스키마 검증과 prop 유효성은 손상에 정확히 반응해 절반 아래로 떨어졌다. 다시 말해, 화면 생성 품질을 유사도나 컴파일 성공만으로 정렬하면 거의 맞지만 틀린 출력을 걸러내지 못한다. 품질을 만드는 것은 컴파일과 스키마와 slot과 상호작용으로 루프를 닫는 게이트다.
학습을 붙여보고, 정직하게 실패했다
여기까지가 하네스 이야기라면, 다음은 실제 학습이다. 데이터 엔진을 돌려 게이트를 통과한 화면 1185개에서 5924개의 학습 예제를 만들고, Qwen3.6-27B에 LoRA를 붙여 사내 H200 클러스터에서 학습했다. 학습 자체는 잘 수렴했다. 손실은 0.07까지 떨어졌고 토큰 정확도는 96%였다. 그리고 학습한 어댑터를 실제로 서빙해 학습 전후를 비교했다.
결과는 정직하게 말하면 실패였다. 300스텝짜리 진단용 LoRA는 자동 생성 결과를 거의 바꾸지 못했다. 학습 전 모델과 학습 후 모델의 오류 프로파일이 사실상 같았다. 둘 다 FormField에 입력 속성을 직접 붙이고, Card에 없는 title을 붙였다. 실제 디자인시스템은 FormField가 입력을 슬롯으로 감싸는 중첩 구조인데, 두 모델 모두 더 납작한 구조를 상상하고 있었다.
원인을 파고들면 세 가지였다. 학습이 짧았고, 토큰 정확도가 일찌감치 포화해서 기울기 신호가 약했다. 더 중요한 건 데이터였다. 합성 데이터를 만든 realize 함수가 실제 중첩 구조를 보여주지 않았으니, 모델은 데이터에 없는 계약을 배울 수 없었다. 그리고 27B 모델의 기존 습관이 강했다.
여기서 두 번째 교훈이 나온다. 세 가지 대리 지표가 모두 진짜 생성 품질을 놓쳤다. 컴파일 성공도, teacher-forced 토큰 정확도도, LoRA를 붙였을 때의 토큰 정확도 변화도 전부 게이트 통과율과 어긋났다. 오직 완전한 레지스트리에 대한 실행 게이트만이 두 모델이 공유하는 진짜 간극을 드러냈다. 그 과정에서 우리 레지스트리 자체가 불완전해서 멀쩡한 출력을 떨구고 있었다는 것도 알게 됐고, 30개 컴포넌트의 속성을 전수 추출해 바로잡았다.
그래서 데이터를 고치고, 다시 학습하고, 결국 답을 찾았다
우리는 realize 함수를 실제 중첩 구조를 만들도록 다시 썼다. 이제 FormField는 입력을 슬롯으로 감싸고, 컴포넌트마다 실제 속성을 쓴다. 게이트를 완벽히 통과하는 화면이 2771개 나왔고, 여기서 학습 예제 1만 3855개를 만들었다. 그리고 이번에는 훨씬 오래, 최적화 업데이트를 1500번 돌렸다.
그런데도 학습된 모델은 여전히 게이트를 통과하지 못했다. 학습 전과 후가 또 거의 같았다. 이쯤에서 분명해졌다. 이 문제에서 27B 모델의 평탄한 습관은 우리가 감당할 만한 학습량으로는 뒤집히지 않는다는 것이다.
그래서 방향을 바꿨다. 모델을 설득하는 대신, 생성 자체를 강제하기로 했다. 레지스트리에서 JSON 스키마를 자동으로 만들었다. 컴포넌트 이름은 실재하는 것만, 속성은 그 컴포넌트가 실제로 가진 것만 허용하고, 중첩 구조는 재귀로 강제하는 스키마다. 이걸 디코딩 제약으로 걸었다. 결과는 분명했다. 같은 held-out 40건에서 학습 없이, 게이트 통과율이 0%에서 39건, 즉 97.5%로 올랐다 (95% 신뢰구간 86.8%에서 99.9%).
네 갈래 비교: 학습 없이 제약만으로 게이트 통과율이 39/40까지 올랐습니다. 측정값(n=40).
이 세 갈래 비교가 이 프로젝트의 결론이다. 아무 처치 없는 기본 모델도, 파인튜닝한 모델도 0/40이었고, 디코딩을 제약한 기본 모델만 39/40을 통과했다. 강한 습관을 가진 모델에게 구조화된 출력을 시킬 때는, 파인튜닝으로 습관을 지우려 애쓰는 것보다 실행 게이트와 같은 규칙으로 디코딩을 제약하는 편이 이 조건에서는 값싸고 확실했다.
여기서부터가 정직해야 하는 대목이다. 첫째, 97.5%이지 100%가 아니다. 통과하지 못한 한 건은 제약을 걸었는데도 JSON이 중간에 끊겨서 나왔다. 스키마 제약은 파싱 가능한 JSON조차 완전히 보장하지 못한다. 둘째, 이 승리는 타당성의 승리이지 품질의 승리가 아니다. 정답 화면과의 컴포넌트 유사도(F1)는 제약을 걸었을 때 오히려 소폭 낮아졌다(0.317에서 0.292로). 유효하다는 것과 요구한 의도에 부합한다는 것은 다르며, 이 간극이 다음 단계의 진짜 숙제다. 셋째, 통과율이 높은 이유를 오해하면 안 된다. 처음엔 이 100%가 스키마가 게이트를 대부분 포함하기 때문이라고 적었지만, 실제로 두 규칙을 적대적 케이스로 대조해 보니 반대였다. 게이트가 스키마보다 더 엄격했다. 스키마는 가장 흔한 두 오류 (존재하지 않는 컴포넌트, 없는 속성)만 막고, schemaVersion 누락이나 슬롯에 엉뚱한 컴포넌트를 넣는 것 같은 위반은 통과시켰다. 그런 것까지 잡는 것은 게이트였다. 그러니 제약이 값어치를 한 이유는 스키마가 지배적 실패 유형을 제거했고 남은 출력이 마침 게이트의 더 엄격한 규칙까지 만족했기 때문이다. 진짜 by-construction 위험은 스키마와 게이트가 같은 레지스트리에서 나온다는 점이고, 이건 레지스트리 밖의 검증기(React 렌더와 TypeScript 타입 검사)로만 배제할 수 있다.
유효한 화면이 좋은 화면은 아니었다
여기서 멈추면 “제약이 답이다”로 끝나지만 그건 절반의 진실이었다. 우리는 더 파봤다. held-out 정답 화면에서 요구사항 계약을 뽑아, 각 방법이 화면의 실제 내용을 얼마나 재현하는지 쟀다. 결과는 뼈아팠다. 유효성은 제약이 가장 높았지만 참조의 정보와 컴포넌트 재현은 제약이 가장 낮았다. 학습 없이 유효성만 산 셈이고, 내용 재현은 무처치 기본 모델보다도 낮았다. GPU 없이 기본 출력을 결정론적으로 고치는 repair도, 자유 계획을 먼저 세우고 제약하는 방식도 마찬가지였다. 둘 다 유효성을 내용과 맞바꿨다. 유효하다는 것과 요구에 맞다는 것은 다른 축이었다.
그래서 어디에 투자할지 먼저 쟀다
다음 본능은 모델을 더 키우거나 여러 개 뽑아 고르는 것이었다. 그 전에 물었다. 여덟 번 생성하면 그중 좋은 게 있기는 한가. 한 프롬프트에 여덟 개씩 제약 생성해 단일 샘플과 최선의 후보를 비교하니, 최선의 후보는 단일 샘플의 유사도를 크게 웃돌았다. 0.29 대 0.45였다. 좋은 화면은 이미 생성되고 있었다. 못 하는 건 그중 좋은 걸 고르는 일이었다. 병목은 생성이 아니라 선택이었다.
선택기는 심판이 아니라 요구사항이었다
그래서 정답을 모르는 선택기를 만들어 그 천장의 얼마를 실제로 잡는지 쟀다.
정답을 모르는 선택기 중 요구사항 predicate가 헤드룸의 27%를 회수했고, LLM 심판은 무선택보다 나빴습니다. 측정값(n=40).
구조가 풍부한 후보를
고르는 건 거의 소용없었다. 오히려 게이트를 통과한 후보를 고르면 나빠졌다. 유효성으로 고르면 내용이
떨어진다는 걸 여기서 또 봤다. 기본 모델에게 직접 이 중 뭐가 제일 낫냐 물으면 아무것도 안 고른
것보다 못했다. 통한 건 하나였다. 프롬프트에서 이 화면이 담아야 할 것을 먼저 뽑고, 그 요구를 가장
잘 채운 후보를 고르는 것. 이게 선택 헤드룸의 4분의 1을 잡았다. 심판이 아니라 요구사항이 선택기였다.
프로젝트를 정리하며 남는 교훈은 넷이다. 하나, 가장 먼저 투자할 곳은 더 큰 모델이 아니라 생성물을 실제로 실행하고 검증하는 골격이다. 둘, 그 골격이 정확해야 실패도 성공도 정확하게 보인다. 셋, 강한 습관과 구조화된 출력이 부딪혔을 때 적어도 이 문제에서는 학습을 더 밀어붙이기보다 제약을 거는 쪽이 값쌌다. 이것을 모든 경우로 일반화하지는 않는다. 넷, 유효성과 충실도는 다른 축이고, 남은 병목은 생성이 아니라 선택이며, 그 선택기는 심판이 아니라 요구사항 기반이어야 했다. 다음으로 증명할 것은 사람이 쓴 요구사항 계약으로 선택기와 의미 정확도를 함께 끌어올리는 것이다.