Files
Gnuboard7/docs/frontend/components-types.md
T
HeuJung 6c63536f81 docs(core,extensions): 확장 20개 개발자 문서 완비와 문서 소유 이관
번들 확장 20개 전부에 AGENTS.md · README.md · docs/ 를 채우고, 코어가 들고
있던 확장 소유 문서 두 갈래를 그 확장으로 옮긴다. 번들 템플릿의 컴포넌트·
핸들러·레이아웃 상세와 확장이 구독하는 활동 로그 훅 목록이 그 대상이며,
코어에는 총계와 링크만 남아 확장이 기능을 늘릴 때 코어 문서를 고쳐야 하던
역방향 의존이 사라진다.

전수 완비를 확인하고 강제를 조인다 — 문서 동반 룰을 대상 목록 없는 error 로
승격하고, 검사 스크립트가 문서 미보유를 실패로 올리며, 미채움 마커 baseline 을
0 으로 기록한다. 한쪽만 조이면 "새 확장이 문서 없이 들어와도 초록" 인 상태가
남는데 그 결과는 이상 0건과 구분되지 않는다.

집필 과정에서 드러난 생성기 결함 셋을 함께 고친다. 스케줄 주기 열이 계약 키를
읽지 않아 모든 확장에서 '-' 였고, 네임스페이스를 붙인 핸들러 등록 키가 수집에서
통째로 빠졌으며, README 골격이 폐기된 히어로 배지를 계속 찍어내고 있었다.
셋 다 산출물이 아니라 원천이 틀린 것이라, 가드의 모집단에 생성기 출력 자체를
넣어 다음 확장이 같은 상태로 태어나는 경로를 막는다.
2026-08-31 22:57:36 +09:00

16 KiB

컴포넌트 타입별 개발 규칙

메인 문서: components.md 관련 문서: layout-json-components.md | sirsoft-admin_basic 컴포넌트


목차

  1. 핵심 원칙
  2. 기본 컴포넌트 (Basic Component)
  3. 집합 컴포넌트 (Composite Component)
  4. 집합 컴포넌트 재사용 가이드라인
  5. 레이아웃 컴포넌트 (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[] = [])
  1. UI 패턴 캡슐화: 특정 UI 패턴(카드, 테이블, 모달 등)을 완성된 형태로 제공
  2. 간단한 Props 전달: 레이아웃 JSON에서 최소한의 props만 전달
  3. 기본 컴포넌트 조합: 내부적으로 Div, Button, Table 등 기본 컴포넌트만 사용
  4. HTML 태그 직접 사용 금지: <div>, <button> 등 HTML 태그 직접 사용 불가
  5. 집합 컴포넌트 재사용 우선: 새로운 집합 컴포넌트 개발 시 기존 집합 컴포넌트를 재사용할 수 있는지 우선 검토
  6. Props 기본값 참조 안정성 필수: 배열/객체 기본값은 모듈 레벨 상수 사용 (무한 렌더 루프 방지)
  7. editorAttrs 패스스루 필수 (레이아웃 편집기 nesting 대상): editor-spec nesting.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 컴포넌트를 활용하여 제목, 패딩, 배경색 등을 제공하는 레이아웃 컴포넌트

핵심 원칙 (필수 준수)

  1. 기본 컴포넌트 재사용: 내부적으로 Div, Section 등 기본 컴포넌트만 사용
  2. HTML 태그 직접 사용 금지: <div>, <section> 등 HTML 태그 직접 사용 불가
  3. 집합 컴포넌트와 동일한 패턴: 집합 컴포넌트와 동일한 개발 패턴 적용

개발 가이드라인

// ❌ 잘못된 예: 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>
);

재사용 시 얻는 이점

  • 집합 컴포넌트와 동일한 아키텍처 패턴 유지
  • 일관된 코드베이스 구조
  • 기본 컴포넌트 수정 시 자동 반영

관련 문서