Files
Gnuboard7/docs/frontend/components-types.md
T
HeuJung b505ac7ba7 fix(core,board,ecommerce,page,gdpr): 목록 컨텍스트 왕복·엔진 렌더 파이프 결함 일괄 수정 + 공개문서 정리
세 갈래의 결함을 한 브랜치에서 정리한다.

## 목록 컨텍스트 왕복 시 URL 상태 소실 ( @jiwonpapa 님께서 제보해주셨습니다.)

목록에서 상세·형제 상세(이전/다음)·작성/수정 폼에 다녀오면 보고 있던
page/search/category/filters 가 사라지던 문제를 전 도메인에서 수정했다.

- 엔진(engine-v1.54.2): `mergeQuery: true` 만 적고 `query` 를 생략하면 병합이
 통째로 건너뛰어지던 함정을 교정 — `ActionDispatcher.handleNavigate`/`handleReplaceUrl`.
- 게시판·이커머스·페이지·회원·마이페이지·gdpr 등 9개 확장 레이아웃의 왕복 leg 전수
 적용(mergeQuery: true). 의도적 리셋(검색/필터 초기화·탭 전환·프리셋)은 면제 주석으로 구분.
- 무한스크롤 목록(브랜드·상품 공통정보·고시정보)의 새로고침이 URL 검색·정렬을 떨구던
 결함 수정.
- 재발 차단: audit 룰 `layout-list-context-navigate-merge-query`(목록 클러스터 자동 도출,
 page/필터 URL 신호 4종) + `layout-navigate-path-absolute`(navigate path 동작 키워드 금지).

## cellChildren 등 반복 렌더에서 단일 바인딩 파이프 미적용 ( @glitter-gim 님께서 제보해주셨습니다.)

목록 표의 각 칸에 넣은 날짜·숫자 서식(`{{row.x | datetime(...)}}`)이 빈 값이 되거나
서식 없는 원본으로 나오던 문제를, 표현식 판정 로직이 엔진 전역에 복제되며 갈라진
구조적 결함으로 진단하고 판정 경로를 단일화했다(engine-v1.54.3).

- `RenderHelpers`(renderItemChildren·evaluateIfCondition)·`ConditionEvaluator`·
 `DataBindingEngine.resolveObject`·`DynamicRenderer` props 5곳에 단일 바인딩 파이프 분기 추가.
- 계획: `g7-scalable-lobster.md`(렌더 경로 비대칭 결함 일괄 수정).

## 공개 문서 내부 도구 귀속 제거

release 에 포함되는 공개 문서(`docs/**`)에서 내부 audit 룰 ID 귀속 서술을
도구 비귀속 표현("정적 검사")으로 정리. 재발 차단 룰 `public-no-internal-audit-reference` 신설.

전 계층 테스트(PHPUnit·Vitest·Playwright)·회귀 테스트 동반, 버전/CHANGELOG/활성 디렉토리 동기 완료.
2026-07-26 15:17:56 +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>
);

재사용 시 얻는 이점

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

관련 문서