DB 스키마 (schema.sql)
-- products: 제품 마스터
CREATE TABLE products (
id TEXT PRIMARY KEY, -- 'cms' | 'banking' | 'infosys' | 'connector'
name_kr TEXT NOT NULL,
name_en TEXT NOT NULL,
sort_order INTEGER NOT NULL DEFAULT 0
);
-- categories: 카테고리 마스터 (제품에 귀속)
CREATE TABLE categories (
id TEXT PRIMARY KEY,
product_id TEXT NOT NULL REFERENCES products(id),
name_kr TEXT NOT NULL,
name_en TEXT NOT NULL,
sort_order INTEGER NOT NULL DEFAULT 0
);
-- customers: 도입 고객 (실제 데이터 or 더미)
CREATE TABLE customers (
id TEXT PRIMARY KEY, -- UUID
product_id TEXT NOT NULL REFERENCES products(id),
category_id TEXT REFERENCES categories(id),
name TEXT NOT NULL,
joined_at TEXT NOT NULL, -- 'YYYY-MM-DD' 형식
created_at TEXT NOT NULL
);
-- 인덱스 (API 쿼리 성능)
CREATE INDEX idx_customers_product ON customers(product_id);
CREATE INDEX idx_customers_category ON customers(category_id);
CREATE INDEX idx_customers_joined ON customers(joined_at);
⚠️ is_new 컬럼 없음. customers 테이블에 is_new 컬럼은 존재하지 않습니다. API(customers.ts)에서 joined_at >= 당월 1일 조건으로 실시간 계산합니다.
카테고리 구성 (seed.sql 기준)
| product_id | category_id | 한국어명 | 영어명 |
cms | branchq | Branch Q | Branch Q |
cms | ihbq | IHB Q | IHB Q |
cms | serp | AI경리나라 | AI Kyungri |
cms | aigumgo | AI금고 | AI Vault |
cms | rerp | rERP Q | rERP Q |
banking | personal | 개인뱅킹 | Personal Banking |
banking | biz | 기업뱅킹 | Business Banking |
infosys | nl2sql | 자연어→SQL | NL to SQL |
infosys | instant | 즉시 분석 | Instant Analysis |
connector | finconn | 금융기관 연결 | Financial Connection |
connector | erplink | ERP 연계 | ERP Integration |
로컬 SQLite 파일 위치
.wrangler/state/v3/d1/miniflare-D1DatabaseObject/
├── cc9b6845...cdd.sqlite # Miniflare D1 인스턴스 A
└── 6f7bfe81...d95.sqlite # Miniflare D1 인스턴스 B (동일 내용)
# 두 파일 모두 항상 같은 데이터를 유지해야 함
# add-2026.mjs 등 스크립트는 두 파일 모두 업데이트함
주의: wrangler dev 서버가 실행 중이면 SQLite 파일에 직접 쓰기가 실패(WAL 잠금)할 수 있습니다. 데이터 추가 스크립트 실행 전 서버를 먼저 종료하세요.
모든 API는 ?product={product_id} 파라미터 필수. CORS 허용 (Access-Control-Allow-Origin: *). 인증 없음 (내부 전용).
GET /api/count
| 항목 | 내용 |
| 설명 | 제품의 누적 / 올해(YTD) / 이달(MTD) 고객 수 반환 |
| 파라미터 | product (필수) — 제품 ID |
| 응답 | { total: number, ytd: number, mtd: number } |
| YTD 기준 | 당해 1월 1일 이후 joined_at |
| MTD 기준 | 당월 1일 이후 joined_at |
# 요청 예시
GET /api/count?product=cms
# 응답
{ "total": 89, "ytd": 14, "mtd": 7 }
GET /api/breakdown
| 항목 | 내용 |
| 설명 | 제품 내 카테고리별 고객 수 (sort_order 기준 정렬) |
| 파라미터 | product (필수) |
| 응답 | { breakdown: [{ category_id, name_kr, name_en, cnt, cnt_ytd, cnt_mtd }] } |
| 특이사항 | 고객이 없는 카테고리도 포함 (LEFT JOIN). 프론트에서 breakdownCache에 저장해 고객현황 탭 stat 카드에 재활용 |
GET /api/trend
| 항목 | 내용 |
| 설명 | 최근 12개월 월별 신규 고객 수 |
| 파라미터 | product (필수) |
| 응답 | { months: [{ month: "YYYY-MM", cnt: number }] } |
| 특이사항 | 데이터 없는 월은 응답에 포함되지 않음. 프론트(showroom.js)에서 getLast12Months()로 빈 월을 0으로 채워 렌더링 |
GET /api/customers
| 항목 | 내용 |
| 설명 | 고객 목록 (페이징, 카테고리 필터) |
| 파라미터 | product (필수), page (기본 1), category (선택 — 없으면 전체) |
| 응답 | { total: number, page: number, records: [{ name, joined_at, is_new }] } |
| 페이지 크기 | 10건 고정 |
| is_new | 당월 1일 이후 joined_at이면 true (컬럼 없음, 쿼리 계산) |
| joined_at 형식 | DB는 YYYY-MM-DD, 응답은 YYYY-MM (앞 7자리만 반환) |
# 요청 예시 — 2페이지, branchq 카테고리만
GET /api/customers?product=cms&page=2&category=branchq
# 응답
{
"total": 23,
"page": 2,
"records": [
{ "name": "삼성전자", "joined_at": "2026-05", "is_new": true },
...
]
}
functions/api/count.ts
/// <reference types="@cloudflare/workers-types" />
interface Env { DB: D1Database; }
export const onRequestGet: PagesFunction<Env> = async ({ request, env }) => {
const url = new URL(request.url);
const product = url.searchParams.get('product');
if (!product) return json(400, { error: 'product required' });
const now = new Date().toISOString();
const yearStart = now.substring(0, 4) + '-01-01';
const monthStart = now.substring(0, 7) + '-01';
const result = await env.DB.prepare(`
SELECT
COUNT(*) AS total,
COUNT(CASE WHEN joined_at >= ? THEN 1 END) AS ytd,
COUNT(CASE WHEN joined_at >= ? THEN 1 END) AS mtd
FROM customers WHERE product_id = ?
`).bind(yearStart, monthStart, product).first();
return json(200, result);
};
functions/api/customers.ts — 핵심 쿼리
// is_new는 DB 컬럼이 아닌 쿼리 계산값
const monthStart = new Date().toISOString().substring(0, 7) + '-01';
const rows = await env.DB.prepare(`
SELECT
name,
joined_at,
CASE WHEN joined_at >= ? THEN 1 ELSE 0 END AS is_new
FROM customers
WHERE product_id = ?
[AND category_id = ?] -- category 파라미터 있을 때만
ORDER BY joined_at DESC
LIMIT 10 OFFSET ?
`).bind(monthStart, product, ...optionalCategory, offset).all();
// joined_at 응답 시 앞 7자리만 (YYYY-MM)
records: rows.results.map(r => ({
...r,
joined_at: r.joined_at.substring(0, 7),
is_new: r.is_new === 1,
}))
public/js/showroom.js — 프론트 핵심 구조
/* 전역 상태 */
let currentLang = 'kr'; // 'kr' | 'en'
let currentSection = 'main'; // 'main' | 'customers' | 'demo' | 'video'
let currentPage = 1;
let currentCategory = ''; // 빈 문자열 = 전체
let breakdownCache = []; // /api/breakdown 결과 캐시 → 고객현황 stat 카드 재활용
const PRODUCT = document.body.dataset.product ?? ''; // HTML body의 data-product 속성
/* 다국어: [data-kr] / [data-en] 속성으로 텍스트 전환 */
function applyLang() {
document.querySelectorAll('[data-kr]').forEach(el => {
el.textContent = currentLang === 'en'
? (el.dataset.en || el.dataset.kr)
: el.dataset.kr;
});
}
/* 고객현황 탭 stat 카드 — 별도 API 호출 없이 breakdownCache 재사용 */
function updateCustStats(categoryId) {
if (!breakdownCache.length) return;
let total, ytd, mtd;
if (!categoryId) { // 전체
total = breakdownCache.reduce((s, c) => s + c.cnt, 0);
ytd = breakdownCache.reduce((s, c) => s + c.cnt_ytd, 0);
mtd = breakdownCache.reduce((s, c) => s + c.cnt_mtd, 0);
} else { // 특정 카테고리
const cat = breakdownCache.find(c => c.category_id === categoryId);
if (!cat) return;
total = cat.cnt; ytd = cat.cnt_ytd; mtd = cat.cnt_mtd;
}
animateCounter('#cust-total', total);
animateCounter('#cust-ytd', ytd);
animateCounter('#cust-mtd', mtd);
}
CSS — 제품별 포인트 컬러 구조 (showroom.css)
/* :root — 기본값 (허브 페이지) */
:root {
--blue: #334155; /* 기본 슬레이트 */
--blue-rgb: 51,65,85;
}
/* body[data-product] — 제품 페이지 진입 시 덮어씀 */
body[data-product="cms"] { --blue: #2563eb; --blue-rgb: 37,99,235; }
body[data-product="banking"] { --blue: #4f46e5; --blue-rgb: 79,70,229; }
body[data-product="infosys"] { --blue: #7c3aed; --blue-rgb: 124,58,237; }
body[data-product="connector"] { --blue: #0f9981; --blue-rgb: 15,153,129; }
/* 이후 모든 컴포넌트는 var(--blue) / rgba(var(--blue-rgb), 0.x) 사용
→ HTML의 data-product만 바꾸면 전체 테마 자동 전환 */
로컬 개발 환경 설정
# 1. 의존성 설치
npm install
# 2. DB 스키마 초기화 (최초 1회)
npm run db:apply # → wrangler d1 execute showroom-db --file=schema.sql
# 3. 마스터 데이터 시드 (products / categories)
npm run db:seed # → wrangler d1 execute showroom-db --file=seed.sql
# 4. 더미 고객 데이터 추가 (선택)
node add-2026.mjs # 2026년 데이터 33건 — YTD/MTD 표시용
# 5. 개발 서버 시작
npm run dev # → http://localhost:8788
주의: 스크립트(add-2026.mjs 등)로 SQLite에 직접 쓸 때는 반드시 npm run dev를 먼저 종료하세요. 서버 실행 중에는 WAL 잠금으로 쓰기가 실패합니다.
Cloudflare 프로덕션 배포
# 1. wrangler.toml에 실제 D1 database_id 입력 (최초 1회)
# wrangler d1 create showroom-db 실행 후 출력된 ID 사용
npm run db:create
# 2. 원격 DB에 스키마 + 시드 적용
npm run db:apply:remote
npm run db:seed:remote
# 3. Pages 배포 (public/ 폴더를 통째로 배포)
npm run deploy # → wrangler pages deploy public
# Functions(functions/api/*.ts)는 배포 시 자동 포함됨
# 별도 빌드 스텝 없음
wrangler.toml 확인: database_id = "PLACEHOLDER"로 되어 있으면 반드시 실제 D1 ID로 교체해야 프로덕션에서 데이터가 동작합니다.
고객 데이터 추가 방법
| 방법 | 설명 | 사용 시기 |
| 스크립트 직접 작성 |
add-2026.mjs 참고해서 Node.js 스크립트 작성 후 실행. node:sqlite 모듈(Node.js v22+) 사용 |
대량 더미 데이터, 초기 구축 |
| seed.sql 추가 |
seed.sql에 INSERT 구문 추가 후 npm run db:seed 재실행 |
마스터 데이터(products/categories) 변경 |
| wrangler d1 execute |
wrangler d1 execute showroom-db --command "INSERT INTO customers ..." |
단건 수동 추가, 운영 중 긴급 수정 |
| 원격 DB 직접 |
위 명령에 --remote 플래그 추가 |
프로덕션 데이터 수정 |
새 제품 추가 시 체크리스트
# 1. seed.sql — products / categories INSERT 추가
INSERT INTO products VALUES ('newprod', '제품명', 'Product Name', ...);
INSERT INTO categories VALUES ('cat1', 'newprod', '카테고리명', ...);
# 2. public/newprod/index.html — 기존 제품 HTML 복사 후 수정
# - body data-product="newprod"
# - .category-tabs 버튼의 data-category 맞게 수정
# - .concept-diagram 내용 수정
# 3. public/css/showroom.css — 포인트 컬러 추가
body[data-product="newprod"] { --blue: #색상코드; --blue-rgb: R,G,B; }
# 4. public/index.html — 허브 카드 추가
# - .card[href="/newprod/"] 에 --card-accent 자동 적용됨
# 5. DB 적용
npm run db:seed # 로컬
npm run db:seed:remote # 프로덕션