설계 원칙 | CoUI
본문으로 건너뛰기
LogoCoUI

설계 원칙

CoUI 가 왜 이렇게 생겼는지 — 그리고 그것이 당신의 코드에 만드는 차이

목차

이 페이지는 CoUI 가 왜 이렇게 생겼는지만 답한다. 각 컴포넌트가 실제로 어떤 파라미터를 받는지는 그 컴포넌트 문서에 있다.

여기 적힌 것은 취향이 아니라 하나의 목표에서 나온다 — 같은 코드가 Flutter 와 Web 에서 같게 보인다. 아래 규칙들은 그 목표를 지키려고 포기한 것들의 목록이다.

같은 줄이 양쪽에서 컴파일된다#

컴포넌트 호출은 두 플랫폼에서 글자 그대로 같다. 다른 것은 import 줄뿐이다.

Card(
  variant: CoreCardVariant.outlined,
  header: Text('Product Name').titleMedium.semiBold,
  body: Text('Description').bodyMedium.onSurfaceVariant,
  footer: Button(
    variant: CoreButtonVariant.primary,
    onPressed: () {},
    child: Text('Buy Now'),
  ),
)

이게 성립하려면 레이아웃도 컴포넌트여야 한다. raw Container · Row · Stack · SizedBox 는 Flutter 에만 있고, 그 타입들(Alignment · StackFit · Clip)은 coui_web 에 존재하지 않는다.

Flutter 습관양쪽에서 쓰는 것
Container · Row · Column · Padding Frame
Stack / Positioned Stacks / Position
SizedBox spacerGap

이들은 전부 플랫폼 무관 Core* 타입만 받는다 — CoreAlignment · CoreClipBehavior 처럼. 그래서 양쪽에서 컴파일된다.

정렬 이름이 읽기 방향을 따른다 — CoreAlignment.topStart 이지 topLeft 가 아니다. 물리 좌우가 아니라 텍스트가 시작하는 쪽이다.

우회했을 때 무엇이 깨지나: Flutter 에서는 아무 문제 없이 돌아간다. 그 파일을 Web 빌드에 넣을 때 비로소 컴파일이 안 되고, 그때는 이미 그 패턴이 여러 파일에 퍼진 뒤다.

값을 넣을 자리는 세 개다#

"이 값 어디에 넣지?"의 답은 셋으로 끝난다.

무엇어디에
무엇인가 · 어떻게 동작하나 (variant · enabled · onTap · 슬롯 위젯) 생성자 파라미터
어떻게 보이나 (색 · 여백 · 반경 · 그림자 · 타이포 · duration) xxxStyle 슬롯
앱 전체에 한 번 (모든 Card 의 반경)CoreComponentTheme

값은 아래로 갈수록 이깁니다(요약):

CoreCardStyle.defaultX          디자인 시스템 기본값
  → CoreComponentTheme 의 card 슬롯     프로젝트 전역
  → 그 variant 별 스타일
  → widget.cardStyle               인스턴스 — 가장 셈

이 체인은 Flutter 와 Web 이 같은 CoreCardStyle 을 각자 읽어서 만든다. 그래서 프로젝트 전역에 한 줄을 넣으면 양쪽에 동시에 도달한다.

우회했을 때 무엇이 깨지나 — 조용히. 급해서 그 자리에서 값을 만들면 (Container(color: Color(0xFF1E293B)), css: {'background-color': '#1e293b'}, Duration(milliseconds: 200)) 컴파일도 되고 애널라이저도 조용하다. 리터럴 색은 CSS 변수를 거치지 않고, 테마 전환은 그 변수를 갈아끼우는 방식이라 라이트에서는 정확히 의도대로 보이고 다크에서만 틀린다. 리터럴 Duration 도 같다 — 동작 줄이기 설정이 토큰을 거친 값에만 닿는다.

테마를 실제로 뒤집어보기 전까지 아무 신호가 없다.

이름은 Flutter 를 따른다#

웹 관습 이름을 찾으면 없다.

찾게 되는 이름실제 이름
CodeBlockCodeSnippet
ListListing
GridResponsiveGrid
HeroHeroSection
Stack / Positioned Stacks / Position

왜 이 방향인가: Flutter 쪽에는 이미 이 라이브러리를 쓰는 프로젝트가 있고 Web 쪽에는 없다. 이름을 맞추는 비용이 한 방향으로만 흐른다. 사용자에게 실질적 효과는 어느 플랫폼 문서를 봐도 같은 이름이라는 것이다.

컴포넌트 이름에 접두사가 없는 것(Button · Icon · Card)도 같은 이유다 — Flutter 코드와 시각적으로 1:1 로 읽히게. 그 대가로 프레임워크 배럴을 따로 import 하면 이름이 충돌한다. 시작하기의 "단일 import 규칙" 참고.

텍스트는 chain 으로 쓴다#

Text('x').bodyMedium 이 style: 보다 권장되는 이유는 세 가지다.

style: 은 플랫폼마다 다른 타입을 받는다. Flutter 는 SDK TextStyle(Color · FontWeight 객체), Web 은 CoUI 자체 TextStyle(문자열 토큰). 그 줄은 이식되지 않는다.

같은 숫자가 두 플랫폼에서 다른 뜻이 된다. style: TextStyle(letterSpacing: 0.5) 는 Flutter 에서 0.5 논리픽셀, Web 에서 0.5em — 16배 차이다. chain 은 자간을 em 으로 쌓아두고 크기가 확정된 뒤 한 번만 환산해 양쪽 시각을 맞춘다.

chain 은 렌더 시점에 테마를 읽는다. .onSurface 는 굳은 값이 아니라 그릴 때 현재 스킴을 조회한다. 그래서 테마 override 와 다크 모드가 자동으로 따라온다.

Flutter 의 Text 와 Web 의 Text 는 같은 클래스가 아니다 — 같은 줄로 읽힐 뿐이다. 그래서 chain 결과를 공유 타입 변수에 담을 수 없고, 슬롯에 바로 넣을 때만 양쪽이 같은 줄이 된다.

체인 메서드 전체 목록은 Text 컴포넌트, 어느 역할을 고를지는 타이포그래피에 있다.

일부러 하지 않는 것#

chrome 을 생성자 파라미터로 받지 않는다#

Card 에는 padding: 도 color: 도 borderRadius: 도 없다. 전부 cardStyle: 하나로 들어간다.

왜 — 두 곳이면 우선순위를 외워야 한다. 같은 값을 두 경로로 받으면 둘 다 준 사용자가 어느 쪽이 이기는지 알 방법이 없다. 이건 가정이 아니다 — 이 킷에서 flat chrome 과 Style 슬롯을 둘 다 가진 유일한 컴포넌트(Frame, Figma Inspect 1:1 매핑을 위한 의도적 예외)에서는 필드마다 우선순위 규칙이 다르다.

슬롯이 하나라고 매번 전체 Style 을 재구성하지는 않는다 — withStyle 이 기존 슬롯에 patch 를 merge 한다:

Badge(child: Text('New')).withStyle(
  CoreBadgeStyle(borderRadius: CoreBorderRadius.all(CoreRadius.radius4)),
)

순수 레이아웃·동작 컴포넌트(Gap · Stacks · Group 등)는 Style 슬롯 자체가 없다. 그릴 chrome 이 없기 때문이다.

Web 의 classes / css 로 색을 넣지 않는다#

Web 컴포넌트가 통과시키는 표면은 셋이고 취급이 다르다.

표면통과
정체성 · 시맨틱 (id · role · aria-* · attributes) 자유 — Flutter 의 key/Semantics 에 대응
동작 (이벤트 핸들러)자유 — Flutter 콜백에 대응
스타일 (classes · css) 구조 레이아웃만 — 색·배경·그림자·반경은 ❌

왜 스타일만 다른가: 앞의 둘은 Flutter 에 대응물이 있다. classes/css 는 Web 전용 표면이라 Flutter 가 미러할 대상이 없다. 여기로 색을 넣으면 Flutter 가 따라올 수 없는 두 번째 출처가 된다.

두 가지가 다르게 깨진다:

  • classes 는 비결정적이다. HTML class 속성의 순서는 CSS 우선순위를 정하지 않는다 — 스타일시트 안의 규칙 순서가 정한다. 그래서 이길 때도 있고 질 때도 있다.
  • css 는 확실히 먹혀서 더 나쁘다. 인라인 스타일은 항상 이긴다. 정확히 의도대로 그려진다 — 라이트 모드에서만.
// ❌
Card(css: Styles(raw: {'background-color': '#1e293b'}))

// ✅ — Flutter 에도 같은 줄이 있고, 테마·다크모드가 따라온다
Card(cardStyle: CoreCardStyle(
  backgroundColor: CoreColor.token(CoreColors.surfaceContainer),
))

// ✅ classes 가 정당한 경우 — 구조 레이아웃
Card(classes: 'flex-1 min-w-0')

무엇이 막혀 있고 무엇이 규율인가#

적어두지 않으면 없는 보장을 믿게 된다.

막혀 있는 것 — 컴포넌트 이름 충돌은 컴파일러가 잡는다(배럴이 겹치는 프레임워크 이름을 가린다). 플랫폼 무관 타입만 받는 레이아웃 컴포넌트도 마찬가지다 — raw Alignment 를 Web 코드에 쓰면 컴파일이 안 된다.

규율인 것 — 나머지 대부분이다:

  • Flutter 에서 raw Stack/Positioned 는 막혀 있지 않다. 정상 컴파일된다. 깨지는 시점은 그 파일을 Web 빌드에 넣을 때다.
  • classes/css 에 리터럴 색을 넣는 것을 컴파일러도 CI 도 막지 않는다.
  • 리터럴 Duration·Color 를 컴포넌트 바깥에서 쓰는 것도 마찬가지다.

이 셋은 전부 테마를 뒤집거나 반대편 플랫폼을 빌드하기 전까지 신호가 없다. 그게 이 페이지가 존재하는 이유다.

사용자가 만지지 않는 것#

배럴에 resolveXxx · ResolvedXxx 같은 이름이 보일 수 있다. 숨겨져 있지는 않지만 참조 대상이 아니다 — 두 플랫폼이 의도적으로 다른 모양이고(Web 은 클래스명 + 인라인 CSS 번들, Flutter 는 평면 paint 값), 렌더 엔진별 내부 산출물이다.

사용자 API 는 둘뿐이다 — 생성자 파라미터와 CoreXxxStyle / CoreComponentTheme.