Grabbin은 단순한 링크 위젯 뿐만 아니라 이미지, 비디오, 지도 등 다양한 형태의 위젯을 제공한다. 여기에 더해 위젯별 preset, 위젯 전용 액션 등 다양한 커스터마이징을 제공한다. 또한 링크 위젯은 각 provider 별로 위젯의 배경색도 다르고, 위젯 액션의 문구도 다르다.
그렇기 때문에 단순히 "type, provider를 if 문으로 렌더링하면 되지 않을까?" 하는 생각은 매우 심플하게 접근할 수 있지만 이는 새로운 위젯이 추가될 때 마다 변경해야 하는 지점이 많기 때문에 확장에 불리하고 유지보수 비용도 높아진다고 생각했다.
그리드 시스템 설계도
위젯은 단순히 화면에 보이는 카드가 아니고, 다음 정보를 가진 객체다:
type GridItem = {
id: string;
type: ItemType;
data: ItemData;
style: ItemStyle;
layouts: {
wide: ItemLayout;
compact: ItemLayout;
};
};
type은 위젯의 종류를 나타내고, data는 위젯마다 다른 데이터를 가진다.
현재 제공하는 위젯은 다음과 같다:
| 위젯 | 주요 데이터 | 특성 |
|---|---|---|
| Text | 텍스트, 링크 | 편집 시 textarea, 정렬·수직 정렬 |
| Media | 이미지·동영상, crop, caption | 이미지/동영상 처리, crop 편집 |
| Map | 위도, 경도, zoom | Mapbox, 위치 편집, 지도 드래그 |
| Section | 제목 | full banner 전용, 섹션 구분 |
| Link | URL, metadata, providerData | provider별 스타일과 CTA |
같은 위젯이라도 preset에 따라 레이아웃 형태가 달라질 수 있다.
type PresetName =
| "fullBanner"
| "halfBanner"
| "squareSmall"
| "landscape"
| "squareLarge"
| "portrait";
예를 들어 Text와 Link는 작은 정사각형, 가로형, 세로형 등 여러 preset을 지원한다. 반면 Section은 전체 너비의 fullBanner만 지원한다. 또한 레이아웃은 화면 크기에 따라 독립적으로 저장된다.
layouts: {
wide: {
x: 0,
y: 2,
w: 2,
h: 2,
},
compact: {
x: 0,
y: 5,
w: 2,
h: 2,
},
}
따라서 그리드 시스템은 단순히 “아이템을 나열하는 컴포넌트”가 아니라 다음 세 가지를 함께 처리해야 했다.
- 어떤 위젯인가?
- 어떤 레이아웃과 기능을 지원하는가?
- 현재 breakpoint와 편집 상태에서 무엇을 보여줘야 하는가?
또 다른 다양성을 가지는 Link 위젯
Link 위젯은 URL 하나만 보여주는 위젯이 아니다. URL을 분석해 provider를 판단하고, provider에 따라 다음이 달라질 수 있다:
- 카드 배경색
- 아이콘(=favicon)
- CTA 메인 문구
- CTA 서브 문구 (e.g. follower, subscriber 또는 upvote count)
- CTA 색상
- CTA 스타일
- 콘텐츠 썸네일
- 또는 GitHub contribution graph
- 또는 Youtube 최근 영상 썸네일 4개
예를 들어 YouTube는 Watch, Spotify는 Play, GitHub는 Follow라는 CTA를 사용할 수 있다.
type LinkProviderPresentation = {
cardBackground: string;
actionBackground: string;
actionText: string;
actionLabel: string;
actionVariant: "solid" | "outline";
};
즉, 다양성은 두 단계로 존재한다.
그리드 아이템 타입
└── Link 위젯
├── YouTube
├── GitHub
├── Spotify
├── Instagram
└── Generic Web
그렇기 때문에, 이 구조를 고려하지 않고 구현하면 조건문이 빠르게 늘어나고 감당할 수 없게 된다고 판단했다.
가장 단순한 렌더링 방식
function GridItem({ item }) {
if (item.type === "text") {
return <TextItem item={item} />;
}
if (item.type === "media") {
return <MediaItem item={item} />;
}
if (item.type === "map") {
return <MapItem item={item} />;
}
if (item.type === "section") {
return <SectionItem item={item} />;
}
if (item.type === "link") {
if (isYoutube(item.data.url)) {
return <YoutubeLink item={item} />;
}
if (isGithub(item.data.url)) {
return <GithubLink item={item} />;
}
return <GenericLink item={item} />;
}
return null;
}
위 방식도 위젯이 적고 variation이 적다면 좋은 접근법이 될 수 있다.
하지만 기능이 늘어나면 하나의 컴포넌트가 다음 책임을 모두 가지게 된다.
- 위젯 타입 판별
- 데이터 렌더링
- 카드 공통 스타일
- drag 방지 영역
- 편집 컨트롤
- 삭제 버튼
- preset 변경
- provider 판별
- provider별 스타일
- provider별 특수 콘텐츠
결국 새로운 기능 하나를 추가할 때마다 거대한 컴포넌트를 수정해야 한다.
if (item.type === "link" && provider === "youtube") {
// 카드 스타일
// CTA
// 썸네일
// subscriber count
// preset별 분기
}
이 방식의 문제는 단순히 코드가 길어진다는 데 있지 않다.
변경 범위가 예측하기 어려워진다
새로운 위젯 하나를 추가했을 뿐인데 다음 코드들을 모두 확인해야 할 수 있다.
- 메인 렌더러
- 편집 컨트롤
- 삭제 처리
- preset 처리
- 서버 validation
- API mapping
- drag 영역
- responsive layout
- 스타일 조건문
공통 동작이 반복된다
모든 위젯이 카드 shell, hover UI, focus 처리, drag cancel, z-index를 필요로 한다면 위젯별 컴포넌트에 같은 코드가 반복된다.
Provider 조건문이 흩어진다
Provider 조건문이 Link renderer, 카드 shell, CTA 컴포넌트, metadata 처리부에 각각 존재하면 provider 하나를 추가할 때 여러 파일을 수정해야 한다.
프론트엔드와 백엔드의 계약이 어긋날 수 있다
프론트엔드는 새로운 위젯을 렌더링할 수 있지만 백엔드 validation이 모르면 저장에 실패한다. 반대로 백엔드는 새로운 타입을 허용하지만 프론트엔드 renderer가 없을 수도 있다.
그래서 선택한 모델은
다음 네 가지를 분리하는 모델이다.
공유 데이터 계약
↓
아이템 타입 Registry
↓
Capability 계산
↓
공통 Shell + 타입별 Renderer
↓
Provider Presentation Registry
핵심은 모든 위젯을 똑같이 만들려는 것이 아닌, 각 위젯의 차이를 인정하고 차이가 들어갈 수 있는 위치를 제한하는 것이다.
공유 타입 계약: Discriminated Union
먼저 위젯의 데이터 구조를 type으로 구분되는 union으로 정의한다.
type GridItem =
| {
type: "text";
data: {
text: string;
link?: string;
};
}
| {
type: "media";
data: {
objectKey: string;
mimeType: string;
crop?: MediaCrop;
};
}
| {
type: "map";
data: {
latitude: number;
longitude: number;
zoom?: number;
};
}
| {
type: "section";
data: {
title: string;
};
}
| {
type: "link";
data: {
url: string;
metadata?: LinkMetadata;
};
};
이 구조를 사용하면 item.type을 확인하는 순간 TypeScript가 해당 data의 타입을 좁혀준다.
function getItemTitle(item: GridItem) {
switch (item.type) {
case "text":
return item.data.text;
case "section":
return item.data.title;
case "link":
return item.data.metadata?.title ?? item.data.url;
}
}
타입별 Registry로 확장 지점을 모은다
타입별로 어떤 preset을 사용할 수 있는지는 Registry에 모아둔다.
const itemTypeRegistry = {
text: {
schema: textSchema,
allowedPresets: [
"halfBanner",
"squareSmall",
"landscape",
"squareLarge",
"portrait",
],
},
media: {
schema: mediaSchema,
allowedPresets: [
"squareSmall",
"landscape",
"squareLarge",
"portrait",
],
},
map: {
schema: mapSchema,
allowedPresets: [
"squareSmall",
"landscape",
"squareLarge",
"portrait",
],
},
section: {
schema: sectionSchema,
allowedPresets: ["fullBanner"],
},
link: {
schema: linkSchema,
allowedPresets: [
"halfBanner",
"squareSmall",
"landscape",
"squareLarge",
"portrait",
],
},
} satisfies Record<ItemType, ItemTypeDefinition>;
이 Registry는 단순한 매핑표가 아니다.
- 어떤 schema를 사용하는가
- 어떤 preset을 사용할 수 있는가
- 서버가 어떤 데이터를 허용하는가
를 하나의 타입별 계약으로 묶는다.
Renderer, Editor, Controls를 분리한다
위젯의 view를 Registry로 관리한다.
const itemViewRegistry = {
text: {
renderer: TextItemRenderer,
editor: null,
controls: ItemControls,
},
media: {
renderer: MediaItemRenderer,
editor: null,
controls: ItemControls,
},
map: {
renderer: MapItemRenderer,
editor: null,
controls: ItemControls,
},
section: {
renderer: SectionItemRenderer,
editor: null,
controls: ItemControls,
},
link: {
renderer: LinkItemRenderer,
editor: null,
controls: ItemControls,
},
};
그리드 전체는 특정 위젯을 직접 알지 않는다.
function ItemRenderer(props: ItemRendererProps) {
const capabilities = getItemCapabilities(props.item, {
breakpoint: props.breakpoint,
mode: props.mode,
});
if (!capabilities.canRender) {
return null;
}
const view = getItemViewRegistration(props.item);
return view.renderer(props);
}
새로운 위젯이 추가되어도 GridSection은 수정하지 않는다. Registry에 renderer를 등록하면 타입에 따라 적절한 view가 선택된다.
공통 Shell과 전용 Renderer를 분리한다
위젯마다 내용은 다르지만 카드 바깥의 동작은 대부분 공통이다.
- 카드 크기
- border radius
- shadow
- drag cancel selector
- hover 시 컨트롤 표시
- focus 상태
- 삭제 버튼
- z-index
- 진입·퇴장 애니메이션
그래서 이 부분은 GridItemShell이 담당한다.
<GridItemShell
item={item}
layout={layout}
capabilities={capabilities}
onCommand={onCommand}
>
<ItemRenderer
item={item}
breakpoint={breakpoint}
preset={preset}
mode={mode}
onCommand={onCommand}
/>
</GridItemShell>
구조를 단순화하면 다음과 같다.
GridSection
└── GridItemShell
├── 공통 카드 프레임
├── hover/focus controls
├── delete action
├── drag cancel boundary
└── ItemRenderer
├── TextItemRenderer
├── MediaItemRenderer
├── MapItemRenderer
├── SectionItemRenderer
└── LinkItemRenderer
Capability를 별도로 계산한다
"어떤 위젯인가?"와 "현재 상태에서 어떤 기능을 제공할 수 있는가?"는 서로 다르다.
예를 들어 Media는 crop 기능을 제공하지만 Text는 제공하지 않는다. Section은 preset이 하나뿐이므로 preset 선택 UI가 필요 없다. 이런 정책은 renderer가 판단하지 않고 capability 계산부에서 결정한다. (*render와 capability의 책임을 서로 분리)
function getItemCapabilities(item, context) {
const allowedPresets = getAllowedPresets(item.type);
const preset = inferPresetFromLayout(
item.type,
item.layouts[context.breakpoint],
context.breakpoint,
);
const controls = [];
if (
context.mode === "edit" &&
allowedPresets.length > 1
) {
controls.push(...createPresetControls(allowedPresets, preset));
}
if (
context.mode === "edit" &&
(item.type === "text" || item.type === "media")
) {
controls.push(createLinkControl(item));
}
if (
context.mode === "edit" &&
item.type === "media"
) {
controls.push(createCropControl());
}
if (context.mode === "edit") {
controls.push(createDeleteControl());
}
return {
allowedPresets,
controls,
canRender: preset !== null,
};
}
renderer는 "crop 버튼을 보여줘야 하나?"를 판단하지 않는다. 그저 이미 계산된 capability를 받아서 렌더링에 집중한다.
Provider 스타일은 별도의 Presentation Registry로 관리한다
Link provider의 핵심 정보와 UI 스타일을 분리한다. Provider의 URL 판별과 ID는 공유 영역에서 관리한다.
type LinkProviderDefinition = {
id: LinkProviderId;
label: string;
priority: number;
match: (url: URL) => boolean;
};
반면 카드 색상과 CTA 문구는 프론트엔드 전용 presentation registry에서 관리한다.
const linkProviderPresentation = {
youtube: {
cardBackground: "#fff2f5",
actionBackground: "#ff0033",
actionText: "#ffffff",
actionLabel: "Watch",
actionVariant: "solid",
},
spotify: {
cardBackground: "#f0fbf4",
actionBackground: "#1ED760",
actionText: "#ffffff",
actionLabel: "Play",
actionVariant: "solid",
},
github: {
cardBackground: "#ffffff",
actionBackground: "#f6f8fa",
actionText: "#000000",
actionLabel: "Follow",
actionVariant: "outline",
},
} satisfies Record<
ConfiguredLinkProviderId,
LinkProviderPresentation
>;
조금 더 쉽게 말하면 "이 링크가 어떤 서비스인지 판단하는 코드"와 "그 서비스를 어떻게 보여줄지 정하는 코드"를 분리한다는 것이다.
예를 들어 사용자가 다음 URL을 제출했다고 가정하면:
https://www.youtube.com/watch?v=123
먼저 URL을 보고 provider를 판단합니다.
const youtubeProvider = {
id: "youtube",
label: "YouTube",
priority: 0,
match: (url) =>
url.hostname.includes("youtube.com") ||
url.hostname === "youtu.be",
};
여기서 각 값은 다음 의미를 갖는다.
type LinkProviderDefinition = {
id: LinkProviderId; // 내부에서 사용할 고유 ID
label: string; // 사용자에게 보여줄 이름
priority: number; // 여러 규칙이 맞을 때 우선순위
match: (url: URL) => boolean; // 이 URL이 해당 provider인지 판단
};
즉 이 객체는 **“이 URL이 YouTube인가?”**만 판단한다.
const provider = resolveLinkProvider(
new URL("https://www.youtube.com/watch?v=123"),
);
provider.id;
// "youtube"
그 다음에야 UI 스타일을 찾는다. 이는 **“YouTube라면 카드와 버튼을 어떻게 보여줄까?”**를 정의한다.
const linkProviderPresentation = {
youtube: {
cardBackground: "#fff2f5",
actionBackground: "#ff0033",
actionText: "#ffffff",
actionLabel: "Watch",
actionVariant: "solid",
},
};
전체 흐름은 다음과 같다.
URL
↓
이 링크가 어떤 provider인지 판별
↓
"youtube"라는 provider ID 획득
↓
"youtube"에 해당하는 UI 스타일 조회
↓
YouTube 카드로 렌더링
이렇게 나누는 이유는 provider 판단과 스타일은 서로 다른 책임이기 때문이다. 예를 들어, YouTube URL 판별 규칙은 백엔드에서도 필요할 수 있다. 백엔드는 YouTube인지 판단해서 썸네일이나 구독자 수를 가져올 수 있다. 하지만, 카드 배경색과 버튼 모양은 프론트엔드 UI에서만 필요한 정보다.
따라서 구조는 이렇게 나눈다:
공유 영역
- YouTube URL인가?
- provider ID는 무엇인가?
- metadata를 어떻게 가져올 것인가?
프론트엔드 영역
- 카드 배경색은 무엇인가?
- CTA 문구는 무엇인가?
- 버튼은 solid인가 outline인가?
만약 이 둘을 한 곳에 섞으면 백엔드 코드에 UI 색상이 들어가거나, 프론트엔드 renderer 안에 URL 판별 조건문이 계속 늘어난다.
정리하면:
LinkProviderDefinition은 “무슨 서비스인가?”를 정의하고,LinkProviderPresentation은 “그 서비스를 어떻게 보여줄 것인가?”를 정의합니다.
다시 돌아와서, Link renderer는 provider마다 색상을 직접 조건문으로 작성하지 않는다.
const provider = getLinkProviderPresentation(item.data.url).id;
const presentation =
getConfiguredLinkProviderPresentation(provider);
return (
<LinkAction
label={presentation?.actionLabel ?? "Open"}
actionBackground={presentation?.actionBackground}
actionText={presentation?.actionText}
actionVariant={presentation?.actionVariant}
/>
);
새 provider의 스타일을 추가할 때는 provider 정의와 presentation 데이터를 추가하면 된다.
하지만, Provider 고유 데이터가 필요한 경우도 있다. YouTube thumbnail, GitHub contribution graph, Product Hunt upvote처럼 모든 provider에 공통되지 않는 정보가 그 예다. 이런 예외는 공통 스타일 시스템과 섞지 않고, provider metadata와 renderer 내부의 좁은 영역에서만 처리한다.
실제 렌더링 흐름
전체 흐름은 다음과 같다:
API 응답 수신
↓
공유 schema로 데이터 검증
↓
item.type에 따라 GridItem 타입 결정
↓
현재 breakpoint의 layout과 preset 계산
↓
getItemCapabilities 실행
↓
itemViewRegistry에서 view 조회
↓
GridItemShell이 공통 카드 UI 구성
↓
전용 Renderer가 위젯 콘텐츠 렌더링
↓
Link라면 provider presentation 적용
새로운 위젯을 추가하는 과정
예를 들어 VideoEmbed라는 새로운 위젯을 추가한다고 가정해 보자. 필요한 변경은 다음과 같이 제한된다.
1. 공유 API schema와 ItemType 추가
2. backend itemTypeRegistry에 schema와 preset 등록
3. VideoEmbedItemRenderer 작성
4. frontend itemViewRegistry에 renderer 등록
5. 필요한 경우 capability 정책 추가
그리드 레이아웃 엔진이나 다른 위젯 renderer를 수정할 필요는 없다.
const itemViewRegistry = {
// existing items...
videoEmbed: {
renderer: VideoEmbedItemRenderer,
editor: null,
controls: ItemControls,
},
};
이 구조의 장점
변경 범위가 작다
새로운 위젯은 자신의 schema, renderer, registry만 주로 수정한다. 공통 shell의 변경 없이도 새로운 UI를 추가할 수 있다.
공통 버그를 한 곳에서 수정할 수 있다
drag cancel, hover controls, 삭제 버튼, 카드 프레임 같은 기능은 GridItemShell에 모여 있기 때문에, 한 번 수정하면 모든 위젯에 적용된다.
타입이 확장 체크리스트가 된다
type union과 satisfies Record<...>를 사용하면 새로운 타입이나 provider를 추가했을 때 누락된 registry가 컴파일 단계에서 드러난다.
Provider의 스타일과 데이터 로직이 분리된다
Provider의 URL 판별, metadata enrichment, UI presentation이 서로 다른 책임을 가진다. 따라서, "Provider 추가로 renderer 전체가 복잡해지는 문제"를 줄일 수 있다.
예외를 숨기지 않고 격리한다
YouTube, GitHub 처럼 정말 고유한 UI가 필요한 provider까지 억지로 일반화하지 않는다. 대신 공통 영역과 예외 영역을 나눈다.
일반적인 provider 스타일 → Registry
provider별 metadata → Enrichment
고유한 시각화 → 해당 feature 영역
결론은…
Widget = 타입이 명확한 데이터 계약
Registry = 타입별 확장 지점
Capability = 현재 상태에서 가능한 기능
Shell = 모든 아이템의 공통 동작
Renderer = 위젯 고유 콘텐츠
Provider Registry = provider별 표현과 스타일
변경 비용을 예측 가능한 작은 범위에 가둔다. 새 위젯을 추가해도 그리드 전체를 수정하지 않고, 새 provider를 추가해도 모든 카드 스타일을 다시 작성하지 않게한다. 공통 동작은 한 곳에서 유지하고, 타입별 차이는 Registry와 전용 Renderer 안에서 관리한다.
실제 preset 별 위젯 UI의 차이점은…
| preset | |
|---|---|
| squareSmall | |
| halfBanner | |
| landscape | |
| squareLarge | |
| portrait | |
| youtube provider |