- 요약
- 3개의 층 — 의존성 규칙
- 코드 위치 규칙
- 공유 패키지 사용 규칙
- Feature 설계 원칙 — 독립성, 조합, 인터페이스
- 컴포넌트화 원칙 — 복붙 금지
- 네이밍 규칙
- 기능 토글 규칙
- 브랜치/머지 운영
- TASK 처리 기준
- 새 Feature 추가 — 실전 예시
- 이것만은 하지 않는다
- PR 전 자가 점검표
apps/ → 조립만 한다. 비즈니스 코드 넣지 않는다.
features/ → 기능 하나 = 폴더 하나 = 패키지 하나. 서로 import 안 한다.
packages/ → 모두가 공유하는 도구. UI, API, 인증, 아이콘, 타입 등.
파일명 → kebab-case (content-area.tsx)
공통 UI → 반드시 @xgen/ui 사용 (새로 만들지 않는다)
API 호출 → 반드시 @xgen/api-client 사용 (fetch 직접 안 쓴다)
환경 변수 → 반드시 @xgen/config 사용 (process.env 직접 안 읽는다)
다국어 → 반드시 @xgen/i18n의 useTranslation() 사용
인증 → 반드시 @xgen/auth-provider의 useAuth() 사용
아이콘 → 반드시 @xgen/icons에서 import
브랜치 → refactor/figma-layout에서 따고, 완료 후 다시 머지
apps → features → packages
(조립) (기능) (인프라)
⭕ app이 feature를 import
⭕ feature가 package를 import
❌ feature가 다른 feature를 import ← 가장 중요한 규칙
❌ package가 feature를 import
❌ feature가 app을 import
⭕ 올바름:
features.ts에서 import 한 줄 주석 → 기능만 사라짐 → 나머지 정상
❌ 잘못됨:
chat-history를 삭제했더니 chat-new에서 에러 → Feature 간 의존이 있다는 뜻
자가 점검: "내 Feature 폴더를 통째로 삭제해도 다른 Feature가 깨지는가?"
→ 특정 화면/기능의 UI나 로직? → features/{해당-feature}/src/
→ 3개 이상 Feature가 공유하는 범용? → packages/{해당-package}/
→ Next.js 라우팅/레이아웃만? → apps/{해당-app}/src/
→ 모르겠다? → 팀 리드에게 묻는다
⭕ apps/web/src/features.ts → import + register만
⭕ apps/web/src/components/ → XgenSidebar, Layout 같은 구조 컴포넌트만
❌ apps/web/src/components/ChatMessageList.tsx → Feature에 넣는다
❌ apps/web/src/utils/chatFormatter.ts → Feature나 Package에 넣는다
Feature에서 직접 구현하지 않는다. 반드시 공유 패키지를 통해 사용한다.
// ⭕
import { ContentArea } from '@xgen/ui';
// ❌ 비슷한 래퍼를 Feature 안에서 새로 만드는 것
function MyContentWrapper({ children }) {
return <div className="content-area">{children}</div>;
}새 UI를 만들기 전 반드시 확인한다:
@xgen/ui에 비슷한 컴포넌트가 있는가? → 있으면 그것을 쓴다- JIRA 하위 티켓에 공통컴포넌트 작업이 연결되어 있는가? → 있으면 그것을 기다리거나 먼저 작업한다
- 다른 Feature에서도 같은 컴포넌트가 필요한가? →
@xgen/ui에 공통으로 만든다 - 이 Feature에서만 쓸 특수 UI인가? → 그때만 Feature 내부에 작성한다
// ⭕
import { createApiClient } from '@xgen/api-client';
const api = createApiClient();
const data = await api.get<UserList>('/users');
// ❌ fetch 직접 호출 — 인증 토큰 누락, 에러 처리 누락, base URL 하드코딩
const res = await fetch('http://localhost:8000/users', {
headers: { Authorization: `Bearer ${token}` }
});// ⭕
import { getBackendUrl } from '@xgen/config';
const coreUrl = getBackendUrl('core');
// ❌ process.env 직접 읽기
const url = process.env.NEXT_PUBLIC_CORE_BASE_URL;// ⭕
import { useAuth } from '@xgen/auth-provider';
const { user, isAuthenticated, hasAccessToSection } = useAuth();
// ❌ 쿠키를 직접 파싱
const token = document.cookie.match(/xgen_access_token=([^;]+)/)?.[1];// ⭕
import { useTranslation } from '@xgen/i18n';
const { t } = useTranslation();
return <h1>{t('sidebar.workspace.mainDashboard.title')}</h1>;
// ❌ 한국어 하드코딩
return <h1>메인 대시보드</h1>;// ⭕
import { IconSidebarChat, FiSettings } from '@xgen/icons';
// ❌ react-icons를 Feature에서 직접 설치/import
import { FiSettings } from 'react-icons/fi';Feature는 독립적으로 존재할 수 있는 기능 한 덩어리다. 독립적이라는 건 이런 뜻이다:
- 자기만의 npm 패키지다 (자체
package.json, 자체src/) - 다른 Feature를 import하지 않는다
- 혼자 지워도, 혼자 추가해도 앱이 깨지지 않는다
모든 Feature는 @xgen/types에 정의된 인터페이스를 구현한 객체를 default export 한다.
앱은 이 객체를 Registry에 등록해서 사용한다.
Feature는 앱이 자기를 어떻게 렌더링하는지 모른다. 인터페이스 계약만 지키면 끝이다.
여기가 핵심이다.
상황: 복잡한 화면 하나를 만들어야 한다. 그 화면에는 여러 기능이 들어간다. 예를 들어 "에디터 화면"을 만든다고 하자. 이 화면에는:
- 메인 편집 영역
- 사이드 패널 (속성, 히스토리, 검색 등)
- 상단 툴바
- 하단 상태 바
이것들을 하나의 거대한 Feature로 만들면 안 된다. 왜?
- 하나가 바뀌면 전부 다시 빌드해야 한다
- 사이드 패널 하나 끄고 싶은데 전체를 건드려야 한다
- 세 명이 동시에 수정하면 충돌이 난다
그래서 이렇게 한다:
features/
editor-core/ ← 메인 편집 영역 (Feature A)
editor-sidebar/ ← 사이드 패널 (Feature B)
editor-toolbar/ ← 상단 툴바 (Feature C)
editor-statusbar/ ← 하단 상태 바 (Feature D)
각각은 독립적인 Feature다. 서로 import하지 않는다. 서로의 존재를 모른다.
그런데 이것들은 분명히 "에디터 화면"이라는 하나의 맥락에서 함께 동작해야 한다. 이 모순을 어떻게 해결하는가?
→ 인터페이스가 해결한다.
절차를 따라가 보자.
@xgen/types에 이런 인터페이스를 만든다:
// "에디터 화면에 끼워지는 플러그인"의 계약
interface EditorPlugin {
id: string;
name: string;
toolbarButtons?: ToolbarButton[]; // 툴바에 버튼을 추가할 수 있다
sidePanels?: SidePanel[]; // 사이드 패널을 추가할 수 있다
statusBarItems?: StatusBarItem[]; // 상태 바에 항목을 추가할 수 있다
overlayComponents?: ComponentType[]; // 오버레이를 추가할 수 있다
}이 인터페이스는 "에디터 화면이 수용할 수 있는 확장 지점(slot)"의 목록이다.
모든 필드가 선택(?)이다. 각 Feature는 자기가 채울 수 있는 것만 채우면 된다.
// features/editor-toolbar/src/index.ts
export const editorToolbar: EditorPlugin = {
id: 'editor-toolbar',
name: 'Editor Toolbar',
toolbarButtons: [
{ id: 'save', label: '저장', onClick: handleSave },
{ id: 'undo', label: '실행취소', onClick: handleUndo },
],
// sidePanels, statusBarItems → 안 쓰니까 안 넣는다
};
export default editorToolbar;// features/editor-sidebar/src/index.ts
export const editorSidebar: EditorPlugin = {
id: 'editor-sidebar',
name: 'Editor Sidebar',
sidePanels: [
{ id: 'properties', label: '속성', component: PropertiesPanel },
{ id: 'history', label: '히스토리', component: HistoryPanel },
],
};
export default editorSidebar;두 Feature는 서로의 존재를 모른다. 같은 인터페이스를 구현했을 뿐이다.
// @xgen/types
class FeatureRegistry {
private editorPlugins: Map<string, EditorPlugin> = new Map();
registerEditorPlugin(plugin: EditorPlugin): void {
this.editorPlugins.set(plugin.id, plugin);
}
getEditorPlugins(): EditorPlugin[] {
return Array.from(this.editorPlugins.values());
}
}import editorToolbar from '@xgen/feature-editor-toolbar';
import editorSidebar from '@xgen/feature-editor-sidebar';
registry.registerEditorPlugin(editorToolbar);
registry.registerEditorPlugin(editorSidebar);// 에디터 화면 — 이것도 하나의 Feature이거나 앱 레벨 코드
const EditorPage: React.FC = () => {
const plugins = registry.getEditorPlugins();
// 모든 플러그인에서 toolbarButtons를 모아서 렌더링
const allButtons = plugins.flatMap(p => p.toolbarButtons ?? []);
// 모든 플러그인에서 sidePanels를 모아서 렌더링
const allPanels = plugins.flatMap(p => p.sidePanels ?? []);
return (
<div>
<Toolbar buttons={allButtons} />
<main>{/* 편집 영역 */}</main>
<Sidebar panels={allPanels} />
</div>
);
};이게 전부다. 에디터 화면은 "누가 등록되었는지" 모른다. 그냥 Registry에서 인터페이스 계약에 맞는 데이터를 꺼내서 렌더링할 뿐이다.
| 효과 | 설명 |
|---|---|
| 독립 배포 | editor-sidebar만 수정해도 다른 Feature는 빌드할 필요 없다 |
| 기능 토글 | features.ts에서 import 한 줄 주석처리 → 그 플러그인만 사라진다 |
| 병렬 개발 | A가 toolbar, B가 sidebar를 동시에 만들어도 충돌 없다 |
| 앱별 조합 | web 앱은 4개 다 등록, web_jeju 앱은 toolbar만 등록 — 같은 Feature, 다른 조합 |
| 확장 용이 | 새로운 Feature가 같은 인터페이스를 구현하면 에디터 화면 코드 수정 없이 끼워진다 |
-
"이 화면에 뭐가 끼워질 수 있는가?"를 먼저 정리한다
- 예: "이 화면에는 탭, 필터, 액션 버튼이 끼워질 수 있다"
-
그 확장 지점을 필드로 가진 인터페이스를
@xgen/types에 정의한다- 모든 필드는 선택(
?)으로 만든다 — 각 Feature가 필요한 것만 채우도록
- 모든 필드는 선택(
-
FeatureRegistry에 해당 인터페이스용 등록/조회 메서드를 추가한다registerXxx(plugin: XxxPlugin): voidgetXxxPlugins(): XxxPlugin[]
-
각 Feature는 이 인터페이스를 구현하고 default export 한다
-
소비하는 측(화면)은 Registry에서
getXxxPlugins()로 꺼내 렌더링한다
이 절차는 어떤 화면이든 동일하다. 에디터든, 대시보드든, 설정 화면이든.
어떤 인터페이스를 구현하든 아래는 모든 Feature에 공통이다.
'use client'; // ← 반드시 선언
import React from 'react';
import type { 인터페이스 } from '@xgen/types';
const MyComponent: React.FC = () => { /* UI */ };
export const myFeature: 인터페이스 = {
id: 'my-Feature', // ← 디렉토리명과 반드시 동일
name: 'My Feature',
// ... 인터페이스가 요구하는 필드들
};
export default myFeature; // ← 반드시 default export| 규칙 | 이유 |
|---|---|
'use client' 선언 |
Next.js App Router — 클라이언트 컴포넌트 선언 |
id = 디렉토리명 |
Feature 추적, 디버깅, 빌드 시 식별 |
default export |
features.ts에서 import xxx from '...'로 가져오는 규약 |
@xgen/types의 인터페이스 사용 |
타입 안전성 + Registry 호환성 |
한 Feature가 단독 기능이면서 동시에 다른 화면의 플러그인이기도 할 때:
// default export = 주된 역할 (Registry 메서드 A로 등록)
export const myFeature: FeatureModule = { /* 사이드바 메뉴 */ };
// named export = 부가 역할 (Registry 메서드 B로 등록)
export const myTab: DocumentTabConfig = { /* 탭 설정 */ };
export default myFeature;features.ts에서:
import myFeature, { myTab } from '@xgen/feature-my-Feature';
registry.register(myFeature); // 주된 역할로 등록
registry.registerDocumentTab(myTab); // 부가 역할로도 등록@xgen/types의FeatureRegistry클래스를 연다register로 시작하는 메서드 목록을 본다- 각 메서드의 파라미터 타입 = 그게 사용할 인터페이스다
- 그래도 모르겠으면 팀 리드에게 묻는다
페이지 내에서 재사용 가능한 영역(섹션/패널/카드/툴바 등)은 컴포넌트로 분리한다. "복붙"으로 코드가 누적되는 것을 허용하지 않는다.
| 상황 | 판단 |
|---|---|
| 같은 카드 UI가 페이지에 3번 나온다 | 분리한다 — 한 곳 수정 → 3곳 반영 |
| 다른 Feature에서도 쓸 수 있는 범용 테이블 | @xgen/ui에 넣는다 |
| 이 Feature 안에서만 쓰는 특수 차트 | Feature 내부에서 분리한다 |
이미 @xgen/ui에 비슷한 컴포넌트가 있다 |
새로 만들지 않는다. 그것을 쓴다 |
function AdminPage() {
return (
<div>
{/* 사용자 섹션 */}
<div className="bg-white rounded-lg p-6 shadow-sm">
<div className="flex justify-between items-center mb-4">
<h2 className="text-lg font-bold">사용자 관리</h2>
<button className="px-4 py-2 bg-blue-500 text-white rounded">추가</button>
</div>
<table>{/* ... */}</table>
</div>
{/* 로그 섹션 — 위와 거의 동일한 구조를 복붙 */}
<div className="bg-white rounded-lg p-6 shadow-sm">
<div className="flex justify-between items-center mb-4">
<h2 className="text-lg font-bold">로그 관리</h2>
<button className="px-4 py-2 bg-blue-500 text-white rounded">필터</button>
</div>
<table>{/* ... */}</table>
</div>
</div>
);
}function SectionPanel({ title, actionLabel, onAction, children }) {
return (
<div className="bg-white rounded-lg p-6 shadow-sm">
<div className="flex justify-between items-center mb-4">
<h2 className="text-lg font-bold">{title}</h2>
<button className="px-4 py-2 bg-blue-500 text-white rounded" onClick={onAction}>
{actionLabel}
</button>
</div>
{children}
</div>
);
}
function AdminPage() {
return (
<div>
<SectionPanel title="사용자 관리" actionLabel="추가" onAction={handleAdd}>
<UserTable />
</SectionPanel>
<SectionPanel title="로그 관리" actionLabel="필터" onAction={handleFilter}>
<LogTable />
</SectionPanel>
</div>
);
}디자인 변경 시 SectionPanel 하나만 수정 → 모든 곳에 반영된다.
이미 새 디자인으로 적용된 페이지가 존재하더라도, 페이지 단위로만 작업되어 컴포넌트화가 미진한 경우 해당 페이지에 대해 컴포넌트화를 추가로 진행한다.
"이 페이지 이미 끝났는데?" → 컴포넌트 분리가 안 되어 있으면 끝난 게 아니다.
⭕ content-area.tsx
⭕ tool-storage-panel.tsx
⭕ workflow-list-item.tsx
❌ ContentArea.tsx (PascalCase)
❌ contentArea.tsx (camelCase)
❌ content_area.tsx (snake_case)
| 접두사 | 의미 |
|---|---|
main- |
일반 사용자 기능 |
canvas- |
캔버스 플러그인 |
admin- |
관리자 기능 |
auth- |
인증 화면 |
mypage- |
마이페이지 |
support- |
고객 지원 |
| 접미사 | 의미 |
|---|---|
-Storage |
내가 저장/관리하는 곳 (CRUD) |
-Store |
탐색/다운로드 마켓 |
-Introduction |
인트로/랜딩 페이지 |
-Monitor / -Monitoring |
실시간 모니터링 |
-Management |
관리 기능 |
// 디렉토리: features/main-CurrentChat/
id: 'main-CurrentChat' // ⭕
// 디렉토리: features/admin-Users/
id: 'admin-Users' // ⭕
id: 'current-chat' // ❌ 디렉토리명과 다름features/main-Dashboard/ → "name": "@xgen/feature-main-Dashboard"
features/canvas-core/ → "name": "@xgen/feature-canvas-core"
packages/api-client/ → "name": "@xgen/api-client"
기능 ON/OFF는 features.ts에서 import 한 줄로 제어한다.
// ⭕ 기능 ON — import 활성
import canvasAutoWorkflow from '@xgen/feature-canvas-auto-workflow';
// ⭕ 기능 OFF — import 주석
// import canvasAutoWorkflow from '@xgen/feature-canvas-auto-workflow'; // 제외
// ❌ if문으로 분기하거나 환경변수로 토글하지 않는다import를 주석 처리하면: Registry에 등록되지 않는다 → 사이드바에 나타나지 않는다 → 번들에서 제외된다.
main ──────────────────────── (운영)
│
└── refactor/figma-layout ─ (작업 기준 브랜치)
│
├── feature/my-task-1 ─ (내 작업 브랜치)
│ └── 완료 후 → refactor/figma-layout에 머지
│
└── feature/my-task-2
└── 완료 후 → refactor/figma-layout에 머지
| 규칙 | 설명 |
|---|---|
| 데일리 머지 | main → refactor/figma-layout 매일 머지하여 최신 유지 |
| 브랜치 생성 | refactor/figma-layout에서 새 브랜치를 따서 작업 |
| 머지 방향 | 작업 완료 → refactor/figma-layout에 머지 |
# 작업 시작
git checkout refactor/figma-layout
git pull origin refactor/figma-layout
git checkout -b feature/my-task-123
# 작업 완료 후 머지
git checkout refactor/figma-layout
git pull origin refactor/figma-layout
git merge feature/my-task-123
git push origin refactor/figma-layout| 상황 | 처리 |
|---|---|
| JIRA에 할당된 UI/컴포넌트 작업이 이미 완료됨 | 해결됨(완료) 처리 |
| 페이지는 있지만 컴포넌트화가 미진 | 컴포넌트화 추가 진행 후 완료 처리 |
| 하위 티켓에 공통컴포넌트 작업이 연결됨 | 해당 공통 컴포넌트를 우선 적용 |
| 비슷한 UI가 이미 공통 컴포넌트로 존재 | 새로 만들지 않고 가져다 사용 |
"알림 센터" 기능을 추가한다고 가정한다.
사이드바에 메뉴가 필요? → YES → FeatureModule
캔버스 내부 플러그인? → NO
관리자 페이지? → NO
features/main-NotificationCenter/
├── package.json
├── tsconfig.json
└── src/
└── index.ts
package.json:
{
"name": "@xgen/feature-main-NotificationCenter",
"version": "0.0.0",
"private": true,
"main": "src/index.ts",
"types": "src/index.ts",
"dependencies": {
"@xgen/types": "workspace:*",
"@xgen/ui": "workspace:*",
"@xgen/i18n": "workspace:*",
"@xgen/api-client": "workspace:*",
"react": "^19.0.0"
}
}tsconfig.json:
{
"extends": "../../tsconfig.base.json",
"compilerOptions": { "outDir": "dist" },
"include": ["src"]
}src/index.ts:
'use client';
import React from 'react';
import type { FeatureModule, RouteComponentProps } from '@xgen/types';
import { ContentArea } from '@xgen/ui';
import { useTranslation } from '@xgen/i18n';
const NotificationCenterPage: React.FC<RouteComponentProps> = () => {
const { t } = useTranslation();
return (
<ContentArea>
<h2 className="text-lg font-semibold mb-4">
{t('workspace.notificationCenter.title')}
</h2>
</ContentArea>
);
};
export const notificationCenterFeature: FeatureModule = {
id: 'main-NotificationCenter',
name: 'Notification Center',
sidebarSection: 'workspace',
sidebarItems: [{
id: 'notification-center',
titleKey: 'workspace.notificationCenter.title',
descriptionKey: 'workspace.notificationCenter.description',
}],
routes: { 'notification-center': NotificationCenterPage },
};
export default notificationCenterFeature;// apps/web/src/features.ts
import notificationCenter from '@xgen/feature-main-NotificationCenter';
// features 배열에 추가
const features = [ ..., notificationCenter ];
features.forEach((f) => registry.register(f));// packages/i18n/src/locales/ko.json
{ "workspace": { "notificationCenter": { "title": "알림 센터", "description": "알림을 확인합니다" } } }
// packages/i18n/src/locales/en.json
{ "workspace": { "notificationCenter": { "title": "Notification Center", "description": "Check your notifications" } } }pnpm install
pnpm dev:web사이드바에 "알림 센터" 메뉴가 자동으로 나타난다. import 한 줄 주석처리로 기능 OFF.
| ❌ 금지 | ⭕ 올바른 방법 | 이유 |
|---|---|---|
| Feature에서 다른 Feature를 import | 공유 로직은 packages/로 추출 |
Feature 독립성 유지 |
apps/ 안에 비즈니스 컴포넌트 작성 |
features/에 Feature로 생성 |
App은 조립만 |
@xgen/ui에 있는 것과 비슷한 UI를 새로 만든다 |
@xgen/ui에서 import |
UI 일관성 |
PascalCase 파일명: ChatHeader.tsx |
chat-header.tsx |
네이밍 규칙 |
Feature id와 디렉토리명이 다름 |
반드시 동일하게 | 추적 가능성 |
fetch() 직접 호출 |
@xgen/api-client 사용 |
인증/에러/URL 중앙화 |
process.env.XXX 직접 읽기 |
@xgen/config 사용 |
환경 설정 중앙화 |
| 쿠키를 직접 파싱해서 인증 확인 | @xgen/auth-provider의 useAuth() |
인증 중앙화 |
| 한국어 문자열 하드코딩 | @xgen/i18n의 useTranslation() |
다국어 지원 |
| 한 페이지에 같은 UI 패턴을 복붙 | 컴포넌트로 분리 | 유지보수 비용 감소 |
react-icons를 Feature에서 직접 설치 |
@xgen/icons에서 import |
아이콘 일관성 |
- 이 코드가
features/,packages/,apps/중 올바른 곳에 있는가? - Feature를 삭제해도 다른 Feature가 정상 동작하는가?
- Feature 간 직접 import가 없는가?
- App에 비즈니스 로직을 넣지 않았는가?
- 파일명이 kebab-case인가?
- Feature
id가 디렉토리명과 일치하는가? -
package.json의name이@xgen/feature-{name}형식인가?
- 2회 이상 반복되는 UI 패턴이 컴포넌트로 분리되어 있는가?
-
@xgen/ui에 비슷한 컴포넌트가 이미 있지 않은가? - 이미 완성된 페이지라도 컴포넌트화가 되어 있는가?
- API 호출이
@xgen/api-client를 통하는가? - 환경 변수가
@xgen/config를 통해 읽히는가? - 인증이
@xgen/auth-provider의useAuth()를 통하는가? - 아이콘이
@xgen/icons에서 import되는가? - 다국어가
@xgen/i18n의useTranslation()을 통하는가?
- FeatureModule / CanvasSubModule / AdminSubModule / DocumentTabConfig 중 올바른 타입인가?
-
default export가 있는가? -
'use client'선언이 있는가? -
routes컴포넌트의 props가RouteComponentProps를 따르는가?
- UI 텍스트가 i18n 키를 사용하는가? (하드코딩 문자열 없음)
-
ko.json과en.json에 모두 추가했는가?