번들 확장 20개 전부에 AGENTS.md · README.md · docs/ 를 채우고, 코어가 들고 있던 확장 소유 문서 두 갈래를 그 확장으로 옮긴다. 번들 템플릿의 컴포넌트· 핸들러·레이아웃 상세와 확장이 구독하는 활동 로그 훅 목록이 그 대상이며, 코어에는 총계와 링크만 남아 확장이 기능을 늘릴 때 코어 문서를 고쳐야 하던 역방향 의존이 사라진다. 전수 완비를 확인하고 강제를 조인다 — 문서 동반 룰을 대상 목록 없는 error 로 승격하고, 검사 스크립트가 문서 미보유를 실패로 올리며, 미채움 마커 baseline 을 0 으로 기록한다. 한쪽만 조이면 "새 확장이 문서 없이 들어와도 초록" 인 상태가 남는데 그 결과는 이상 0건과 구분되지 않는다. 집필 과정에서 드러난 생성기 결함 셋을 함께 고친다. 스케줄 주기 열이 계약 키를 읽지 않아 모든 확장에서 '-' 였고, 네임스페이스를 붙인 핸들러 등록 키가 수집에서 통째로 빠졌으며, README 골격이 폐기된 히어로 배지를 계속 찍어내고 있었다. 셋 다 산출물이 아니라 원천이 틀린 것이라, 가드의 모집단에 생성기 출력 자체를 넣어 다음 확장이 같은 상태로 태어나는 경로를 막는다.
16 KiB
컴포넌트 타입별 개발 규칙
메인 문서: components.md 관련 문서: layout-json-components.md | sirsoft-admin_basic 컴포넌트
목차
- 핵심 원칙
- 기본 컴포넌트 (Basic Component)
- 집합 컴포넌트 (Composite Component)
- 집합 컴포넌트 재사용 가이드라인
- 레이아웃 컴포넌트 (Layout Component)
핵심 원칙
필수: 기본 컴포넌트 사용 (HTML 태그 직접 사용 금지)
필수: 기본 컴포넌트만 사용 (Div, Button, H2 등)
필수: 집합 컴포넌트 재사용 우선
1. 기본 컴포넌트 (Basic Component)
정의: HTML 기본 태그에 대응하는 최소 래핑 컴포넌트
타입: basic
예시: Button, Input, Div, Icon, H1, H2, P, Span, Form, Table, Ul, Li, Nav 등
특징:
- DOM 요소에 직접 매핑
- 최소한의 래핑만 수행
- props를 HTML 속성으로 전달
- 스타일링은 className으로 처리
패턴
// templates/[vendor-template]/src/components/basic/Button.tsx
import React from 'react';
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'danger' | 'success';
size?: 'sm' | 'md' | 'lg';
}
/**
* 기본 버튼 컴포넌트
*/
export const Button: React.FC<ButtonProps> = ({
children,
variant = 'primary',
size = 'md',
className = '',
...props
}) => {
return (
<button
className={className}
{...props}
>
{children}
</button>
);
};
Input 컴포넌트 IME 처리 (중요)
Input 컴포넌트는 한글 등 IME(Input Method Editor) 조합 입력을 올바르게 처리합니다.
핵심 동작:
- IME 조합 중(
compositionStart~compositionEnd)에는 외부onChange를 호출하지 않음 - 조합 완료 후
compositionEnd이벤트에서 최종 값으로onChange호출 - IME 조합 중에는
onKeyPress이벤트도 발생하지 않음 (Enter 키 등) - 내부 로컬 상태로 화면 표시를 유지하여 조합 중에도 입력이 보임
사용 시 주의사항:
✅ keypress 이벤트: IME 조합 완료 후에만 발생
✅ change 이벤트: IME 조합 완료 후에만 외부에 전달
한글 입력 후 Enter 검색: keypress + key: "Enter" 조합 사용 권장
검색 입력 필드 예시 (keypress 사용):
{
"id": "search_input",
"type": "basic",
"name": "Input",
"props": {
"type": "text",
"placeholder": "검색어 입력..."
},
"actions": [
{
"type": "change",
"handler": "setState",
"params": {
"target": "global",
"searchQuery": "{{$event.target.value}}"
}
},
{
"type": "keypress",
"key": "Enter",
"handler": "navigate",
"params": {
"path": "/search?q={{_global.searchQuery}}"
}
}
]
}
2. 집합 컴포넌트 (Composite Component) ⭐ 핵심
정의: 기본 컴포넌트를 조합하여 특정 UI 패턴을 캡슐화한 복합 컴포넌트
타입: composite
예시:
- UI 컴포넌트: Card, DataGrid, Modal, Dropdown, Pagination, SearchBar, StatusBadge, Toast, TagInput
- 관리자 컴포넌트: AdminSidebar, AdminHeader, AdminFooter, PageHeader, TemplateCard
핵심 원칙 (필수 준수)
필수: 기본 컴포넌트 사용 (HTML 태그 직접 사용 금지)
필수: 기본 컴포넌트만 사용 (Div, Button, H2 등)
필수: 집합 컴포넌트 재사용 우선
필수: props 기본값은 undefined로 설정 (배열/객체 리터럴 금지)
필수: 모듈 레벨 상수 사용 (const EMPTY: T[] = [])
- UI 패턴 캡슐화: 특정 UI 패턴(카드, 테이블, 모달 등)을 완성된 형태로 제공
- 간단한 Props 전달: 레이아웃 JSON에서 최소한의 props만 전달
- 기본 컴포넌트 조합: 내부적으로 Div, Button, Table 등 기본 컴포넌트만 사용
- HTML 태그 직접 사용 금지:
<div>,<button>등 HTML 태그 직접 사용 불가 - 집합 컴포넌트 재사용 우선: 새로운 집합 컴포넌트 개발 시 기존 집합 컴포넌트를 재사용할 수 있는지 우선 검토
- Props 기본값 참조 안정성 필수: 배열/객체 기본값은 모듈 레벨 상수 사용 (무한 렌더 루프 방지)
editorAttrs패스스루 필수 (레이아웃 편집기 nesting 대상): editor-specnesting.draggable에 등재된 composite/layout 컴포넌트는editorAttrs?: EditorAttrs를 받아 시각적 루트 요소에{...editorAttrs}로 spread 해야 한다 (§ "editorAttrs 패스스루" 참조)
editorAttrs 패스스루 (레이아웃 편집기 nesting 컴포넌트)
편집 모드에서 코어 DynamicRenderer 는 각 컴포넌트에 data-editor-* 표식(드롭 슬롯/선택/드래그 DOM 쿼리용)과 선택/hover 핸들러를 단일 editorAttrs 객체로 주입한다. composite/layout 컴포넌트는 도메인 prop 만 명시 구조분해하고 미명시 props 를 DOM 으로 흘리지 않으므로, editorAttrs 를 받아 루트에 spread 하지 않으면 그 노드가 편집기에서 누락된다(편집기는 [data-editor-path] DOM 쿼리로 동작 — 컨테이너 노드가 누락되면 자식을 컨테이너 밖으로 옮기는 드롭 슬롯이 생성되지 않음).
import type { EditorAttrs } from '../../types';
export interface FooProps {
/* ...도메인 prop 전부 명시 구조분해... */
editorAttrs?: EditorAttrs; // 편집기 주입 속성 (편집 모드 전용)
}
export const Foo = ({ /* 도메인 prop */, editorAttrs }: FooProps) => (
<Div className={...} {...editorAttrs}>{children}</Div> // 시각적 루트에 spread
);
| 구분 | editorAttrs 수신 | 이유 |
|---|---|---|
basic ({...props} 패스스루) |
불필요 | 코어가 주입한 개별 data-editor-* 키가 {...props} 로 DOM 도달 |
| composite / layout | 필수 | 도메인 prop 만 구조분해 → 개별 키 유실 → editorAttrs 명시 수신 + 루트 spread 필요 |
| 서드파티 모달 / Portal | 면제 | interface 주석에 editorAttrs 패스스루 — 모달 예외 명기 |
- 사용자 페이지(비편집)에서는
editorAttrs미주입 →{...editorAttrs}가 no-op → DOM 구조/속성 불변 (프리뷰 ↔ 사용자 페이지 패리티 유지). editorAttrs만 spread 하므로 도메인 prop 누출/HTML 동명 prop 타입 충돌이 없다. (React.HTMLAttributes상속 +{...rest}방식은 도메인 prop 누출 위험으로 채택 안 함.)- 자동 검출: 정적 검사가 nesting.draggable 의 composite/layout 컴포넌트가
editorAttrs를 받지 않으면 차단한다. 의도적 면제는 파일 헤더// editor-attrs:allow <사유>.
요소 id 패스스루 (코어 일괄 ID)
레이아웃 편집기 코어는 모든 draggable 컴포넌트의 [속성] 탭 최상단에 "요소 ID" 컨트롤을 일괄 제공한다(값 = 표준 node.props.id, 코어는 강제 DOM 주입 안 함). 따라서 draggable 컴포넌트는 editorAttrs 와 별개로 id prop 을 받아 시각적 루트에 전달해야 그 id 가 실제 DOM 에 닿는다.
export interface FooProps {
id?: string; // DOM id (코어 일괄 ID)
// ... 도메인 prop ...
editorAttrs?: EditorAttrs;
}
export const Foo = ({ id, /* 도메인 prop */, editorAttrs }: FooProps) => (
<Div id={id} className={...} {...editorAttrs}>{children}</Div> // id 를 루트에 명시 전달
);
| 구분 | id 수신/전달 | 비고 |
|---|---|---|
basic ({...props}/{...validProps} 패스스루) |
불필요 | id 가 props 스프레드로 DOM 도달 |
| composite / layout (도메인 prop 만 구조분해) | 필수 | id?: string 수신 + 루트에 id={id} 명시 전달 |
| 서드파티 모달 / Portal (인라인 루트 없음) | 면제 + opt-out | 인라인 DOM 이 없어 id 부착 불가 → 그 컴포넌트 capability 에 "coreProps": false 선언(코어 id 컨트롤 미노출). 예: ImageGallery(Lightbox) |
- 여러 렌더 분기(variant 별 root)가 있으면 모든 root 분기에
id={id}를 전달한다(누락 분기는 그 variant 에서 id 미반영). - id 값이 이미
{{바인딩}}이거나 컴포넌트/핸들러가 동적 id 를 쓰면, 코어 id 컨트롤은 "바인딩됨(코드 편집)" 디그레이드로 표시해 덮어쓰기를 차단한다(작성자 정적 값만 편집). - 코어 id 컨트롤은 HTML 안전 문자(영문자/숫자/
-/_/:/.)만 허용하고 한글·공백 등은 자동 제거한다(sanitizeElementId).
올바른 패턴
// ✅ 올바른 예: 기본 컴포넌트 재사용
import { Div } from '../basic/Div';
import { H2 } from '../basic/H2';
import { P } from '../basic/P';
import { Img } from '../basic/Img';
export interface CardProps {
title?: string;
content?: string;
imageUrl?: string;
onClick?: () => void;
}
/**
* Card 집합 컴포넌트
*/
export const Card: React.FC<CardProps> = ({
title,
content,
imageUrl,
onClick,
}) => {
return (
<Div className="card" onClick={onClick}>
{imageUrl && <Img src={imageUrl} />}
<Div className="card-body">
{title && <H2>{title}</H2>}
{content && <P>{content}</P>}
</Div>
</Div>
);
};
잘못된 패턴
// ❌ 잘못된 예: HTML 태그 직접 사용 (금지)
export const Card: React.FC<CardProps> = ({
title,
content,
imageUrl,
onClick,
}) => {
return (
<div className="card" onClick={onClick}>
{imageUrl && <img src={imageUrl} />}
<div className="card-body">
{title && <h2>{title}</h2>}
{content && <p>{content}</p>}
</div>
</div>
);
};
Props 기본값 참조 안정성
배열/객체 기본값을 destructuring에서 직접 사용하면 매 렌더마다 새 참조가 생성되어 useEffect 무한 루프를 유발합니다. 모달 내에서 사용 시 startTransition으로 래핑된 모달 닫기 렌더를 영구 차단하여 모달 전체가 작동 불능이 됩니다.
// ❌ 잘못된 예: 매 렌더마다 새 [] 참조 생성 → useEffect 무한 루프
export const FileUploader = forwardRef((
{ initialFiles = [], roleIds = [], ... },
ref
) => { ... });
// ✅ 올바른 예: 모듈 레벨 상수로 안정적인 참조
const EMPTY_FILES: Attachment[] = [];
const EMPTY_ROLE_IDS: number[] = [];
export const FileUploader = forwardRef((
{ initialFiles = EMPTY_FILES, roleIds = EMPTY_ROLE_IDS, ... },
ref
) => { ... });
트러블슈팅: composite 컴포넌트가 매 렌더마다 새 객체/배열 기본값을 만들면 자식 모달이 닫기 직후 재마운트되어 닫기 버튼이 동작하지 않는 회귀가 발생한다. 모듈 스코프 상수로 안정화한다.
객체 값 이벤트의 _changedKeys 규칙 (engine-v1.28.0+)
객체 형태의 value를 emit하는 composite 컴포넌트는
onChange 이벤트에 _changedKeys 메타데이터를 포함해야 합니다.
이는 엔진의 debounce와 상호작용 시 stale closure로 인한 키 유실을 방지합니다.
규칙:
onChange이벤트에서target.value가 객체(Record<string, any>)인 경우, 실제 사용자가 변경한 키 목록을_changedKeys: string[]로 포함_changedKeys미포함 시 엔진은 기존 동작 유지 (마지막 값 사용)
예시:
// Composite 컴포넌트 내부
onChange?.({
target: { name, value: { ko: "빨강", en: "blue" } },
_changedKeys: ["ko"], // 실제 변경된 키만 명시
});
대상 컴포넌트: MultilingualInput, MultilingualTagInput 등 객체 value를 emit하면서 debounce와 함께 사용될 수 있는 모든 composite 컴포넌트
3. 집합 컴포넌트 재사용 가이드라인 ⭐ 매우 중요
새로운 집합 컴포넌트 개발 전 다음 순서로 검토:
1. 기존 집합 컴포넌트 재사용 (최우선)
- 동일하거나 유사한 UI 패턴을 제공하는 기존 컴포넌트가 있는가?
- 기존 컴포넌트를 props로 커스터마이징하여 요구사항을 충족할 수 있는가?
- 예시: 퀵 액션 버튼 → ActionMenu 재사용, 브레드크럼 → Breadcrumb 재사용
2. 기존 컴포넌트 조합 (차선책)
- 여러 기존 컴포넌트를 조합하여 새로운 패턴을 만들 수 있는가?
- 예시: PageHeader = Breadcrumb + 제목 + 탭 + 액션 버튼 조합
3. 새 컴포넌트 개발 (최후의 수단)
- 위 두 방법으로 해결 불가능한 경우에만 신규 개발
- 개발 시 향후 재사용 가능성을 고려한 범용적 설계 필수
재사용 시 얻는 이점
- 코드 중복 제거 및 유지보수성 향상
- 일관된 UX 제공
- 기존 컴포넌트의 개선사항 자동 반영
- 인터페이스 통일로 학습 비용 감소
실제 재사용 사례
ActionMenu 재사용 예시:
// ❌ 잘못된 예: 커스텀 액션 메뉴 구현
export const TemplateCard = () => (
<Div>
{quickActions.map((action) => (
<Button onClick={action.onClick}>{action.label}</Button>
))}
</Div>
);
// ✅ 올바른 예: ActionMenu 컴포넌트 재사용
import { ActionMenu } from './ActionMenu';
import { IconName } from '../basic/Icon';
export const TemplateCard = () => (
<Div>
<ActionMenu
items={actions}
triggerIconName={IconName.EllipsisVertical}
position="left"
/>
</Div>
);
Breadcrumb 재사용 예시:
// ❌ 잘못된 예: 커스텀 브레드크럼 구현
export const PageHeader = ({ breadcrumbs }) => (
<Div>
{breadcrumbs.map((item, index) => (
<React.Fragment key={index}>
{item.url ? <A href={item.url}>{item.label}</A> : <Span>{item.label}</Span>}
{index < breadcrumbs.length - 1 && <Icon name={IconName.ChevronRight} />}
</React.Fragment>
))}
</Div>
);
// ✅ 올바른 예: Breadcrumb 컴포넌트 재사용
import { Breadcrumb, BreadcrumbItem } from './Breadcrumb';
export const PageHeader = ({ breadcrumbItems }) => (
<Div>
<Breadcrumb items={breadcrumbItems} />
</Div>
);
4. 레이아웃 컴포넌트 (Layout Component)
정의: 자식 요소를 배치하는 컨테이너 컴포넌트
타입: layout
예시: Container (flex, grid, stack), Grid, Flex, SectionLayout
특징:
- 자식 요소의 배치 및 정렬을 담당
- 레이아웃 관련 props 제공 (layout, direction, gap, justify, align 등)
- UI 로직 없이 순수하게 구조화만 담당
주의: Section은 basic 컴포넌트이며, SectionLayout은 layout 컴포넌트입니다.
Section(basic): HTML<section>태그를 래핑하는 최소 컴포넌트SectionLayout(layout): Section 컴포넌트를 활용하여 제목, 패딩, 배경색 등을 제공하는 레이아웃 컴포넌트
핵심 원칙 (필수 준수)
- 기본 컴포넌트 재사용: 내부적으로 Div, Section 등 기본 컴포넌트만 사용
- HTML 태그 직접 사용 금지:
<div>,<section>등 HTML 태그 직접 사용 불가 - 집합 컴포넌트와 동일한 패턴: 집합 컴포넌트와 동일한 개발 패턴 적용
개발 가이드라인
// ❌ 잘못된 예: HTML 태그 직접 사용
export const Container: React.FC<ContainerProps> = ({ children }) => (
<div className="container">
{children}
</div>
);
// ✅ 올바른 예: 기본 컴포넌트 재사용
import { Div } from '../basic/Div';
export const Container: React.FC<ContainerProps> = ({ children }) => (
<Div className="container">
{children}
</Div>
);
재사용 시 얻는 이점
- 집합 컴포넌트와 동일한 아키텍처 패턴 유지
- 일관된 코드베이스 구조
- 기본 컴포넌트 수정 시 자동 반영