PHP 8.2+ / MariaDB 10.11+ / 멀티테넌트 SaaS 패키지
최종 업데이트: 2025
| 항목 | 내용 |
|---|---|
| 솔루션명 | BrandOS |
| 접속 방식 | https://myoc.kr/{tenant_slug} (파라미터형 멀티테넌트) |
| 주요 타겟 | 정치지망생, 퇴직 공무원/공기업인, 1인 기업/브랜드 |
| 개발 스택 | PHP 8.2+, MariaDB 10.11+, Redis(캐시/세션), Nginx |
"홈페이지 만드는 도구가 아니라, 찍으면 알아서 퍼지는 나만의 발행 시스템"
사용자가 하는 일: 사진 선택 → 한 줄 메모 → 올리기 버튼 하나
솔루션이 하는 일:
| 경쟁사 | 한계 |
|---|---|
| 윅스/아임웹 | 만들고 나면 끝. 운영·발행·SNS 연동은 사용자 몫 → 방치 |
| AI 뚝딱 홈페이지 | 생성은 쉬우나 콘텐츠가 살아 움직이지 않음 → 예쁜 명함 |
| SNS 직접 운영 | 플랫폼 알고리즘 종속, 계정 정지 시 전부 소멸, 플랫폼별 반복 노동 |
① Zero Friction 입력 설계
② SEO 자동 누적
③ SNS는 "복사"가 아닌 "연장" (플랫폼 독립 전략)
④ 타겟 특화 기능
⑤ 운영 부담 제로
중앙에서 고퀄리티 영상/정보성 게시물을 올리면 전체 테넌트 사이트에 노출.
테넌트별 URL로 공유 시 테넌트 브랜딩이 유지된 채로 전달됨.
공통 게시물 원본: https://myoc.kr/_common/health/123
홍길동 테넌트 공유 URL: https://myoc.kr/hong/health/123
→ 카카오톡 수신자가 링크 열면 홍길동 브랜딩으로 공통 콘텐츠 표시
→ 자연스럽게 홍길동 팬/지지자 유입으로 연결효과: 공통 콘텐츠 1건 → 수만 테넌트가 각자 팔로워에게 자연 확산
Single Database / Shared Schema 방식
→ 모든 테이블에 tenant_id FK를 통한 데이터 분리
→ 스키마 복잡도 대비 유지보수/백업 효율 최우선
→ 테넌트별 테이블 분리 절대 금지 (수만 테넌트 × 수십 게시판 = DB 붕괴)Request: https://myoc.kr/hong/board/notice
└─ Nginx → index.php
└─ Router::boot()
└─ TenantResolver::resolve('hong') ← slug → tenant_id 확정
└─ TenantContext::set($tenant) ← 전역 컨텍스트 싱글턴 주입
└─ 이후 모든 DB 쿼리에 tenant_id 자동 바인딩class TenantMiddleware
{
public function handle(Request $req, Closure $next): Response
{
$slug = $req->segment(1);
$tenant = TenantRepository::findBySlug($slug);
if (!$tenant || !$tenant->isActive()) {
return Response::abort(404);
}
TenantContext::set($tenant); // 싱글턴 전역 주입
View::share('tenant', $tenant);
SEOMeta::applyTenant($tenant); // <head> SEO 자동 주입
return $next($req);
}
}abstract class BaseModel
{
protected function applyTenantScope(string $sql, array $params): array
{
$tenant = TenantContext::get();
if ($tenant && $this->hasTenantScope) {
$sql .= ' AND tenant_id = :__tid';
$params[':__tid'] = $tenant->id;
}
return [$sql, $params];
}
}
// 모든 Model이 BaseModel 상속 → 테넌트 월담 구조적 차단CREATE TABLE tenants (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
slug VARCHAR(50) NOT NULL UNIQUE,
display_name VARCHAR(100) NOT NULL,
owner_user_id INT UNSIGNED NOT NULL,
plan ENUM('free','basic','pro') DEFAULT 'free',
status ENUM('active','suspended','deleted') DEFAULT 'active',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
INDEX idx_slug (slug)
) ENGINE=InnoDB;CREATE TABLE tenant_profiles (
tenant_id INT UNSIGNED PRIMARY KEY,
real_name VARCHAR(100),
tagline VARCHAR(200),
bio TEXT,
avatar_path VARCHAR(300),
cover_path VARCHAR(300),
meta_title VARCHAR(100), -- <title> 커스텀
meta_desc VARCHAR(300), -- <meta name="description">
og_image_path VARCHAR(300), -- Open Graph 이미지
canonical_url VARCHAR(300),
keywords VARCHAR(500),
schema_type ENUM('Person','Organization','LocalBusiness') DEFAULT 'Person',
FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE
) ENGINE=InnoDB;CREATE TABLE tenant_sns_settings (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
tenant_id INT UNSIGNED NOT NULL,
provider ENUM('kakao_story','instagram','facebook','twitter','threads','naver_blog'),
access_token TEXT, -- AES-256 암호화 저장
refresh_token TEXT,
token_expires DATETIME,
account_id VARCHAR(200),
is_active TINYINT(1) DEFAULT 1,
UNIQUE KEY uq_tenant_provider (tenant_id, provider),
FOREIGN KEY (tenant_id) REFERENCES tenants(id)
) ENGINE=InnoDB;-- tenant_id = NULL 이면 공통 게시판
CREATE TABLE boards (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
tenant_id INT UNSIGNED DEFAULT NULL,
board_key VARCHAR(50) NOT NULL,
board_name VARCHAR(100) NOT NULL,
skin ENUM('list','webzine','gallery') DEFAULT 'list',
use_sns_share TINYINT(1) DEFAULT 0,
use_comment TINYINT(1) DEFAULT 1,
use_like TINYINT(1) DEFAULT 1,
use_file TINYINT(1) DEFAULT 1,
sort_order SMALLINT DEFAULT 0,
is_active TINYINT(1) DEFAULT 1,
UNIQUE KEY uq_tenant_key (tenant_id, board_key),
FOREIGN KEY (tenant_id) REFERENCES tenants(id)
) ENGINE=InnoDB;
-- 공통 게시판 초기 데이터
INSERT INTO boards (tenant_id, board_key, board_name, skin)
VALUES (NULL, 'health', '건강정보', 'webzine'),
(NULL, 'life', '생활정보', 'webzine'),
(NULL, 'issue', '이슈', 'webzine');CREATE TABLE posts (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
tenant_id INT UNSIGNED DEFAULT NULL, -- NULL = 공통 게시물
board_id INT UNSIGNED NOT NULL,
user_id INT UNSIGNED NOT NULL,
post_type ENUM('tenant','common') DEFAULT 'tenant',
title VARCHAR(300) NOT NULL,
content MEDIUMTEXT,
thumb_path VARCHAR(300),
media_type ENUM('none','image','video') DEFAULT 'none',
video_url VARCHAR(500), -- YouTube/Vimeo 또는 직접 URL
location VARCHAR(200), -- 활동 장소
event_at DATETIME, -- 활동 일시
view_count INT UNSIGNED DEFAULT 0,
share_count INT UNSIGNED DEFAULT 0, -- 카카오톡 등 공유 누적
status ENUM('draft','published','hidden','deleted') DEFAULT 'published',
published_at DATETIME,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_tenant_board (tenant_id, board_id, status, published_at),
INDEX idx_common (post_type, board_id, status, published_at),
FOREIGN KEY (board_id) REFERENCES boards(id)
) ENGINE=InnoDB;
/*
구조 요약:
tenant_id = 123, post_type = 'tenant' → 홍길동 전용 게시물
tenant_id = NULL, post_type = 'common' → 공통 게시물 (한 건 저장, 수만 테넌트가 읽기만)
*/CREATE TABLE tenant_common_boards (
tenant_id INT UNSIGNED NOT NULL,
board_id INT UNSIGNED NOT NULL, -- 공통 board id
is_visible TINYINT(1) DEFAULT 1,
sort_order SMALLINT DEFAULT 0,
PRIMARY KEY (tenant_id, board_id),
FOREIGN KEY (tenant_id) REFERENCES tenants(id),
FOREIGN KEY (board_id) REFERENCES boards(id)
) ENGINE=InnoDB;CREATE TABLE sns_publish_logs (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
post_id INT UNSIGNED NOT NULL,
tenant_id INT UNSIGNED NOT NULL,
provider VARCHAR(30) NOT NULL,
status ENUM('pending','success','failed') DEFAULT 'pending',
sns_post_id VARCHAR(200),
error_msg TEXT,
published_at DATETIME,
INDEX idx_post (post_id),
FOREIGN KEY (post_id) REFERENCES posts(id)
) ENGINE=InnoDB;CREATE TABLE post_share_logs (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
post_id INT UNSIGNED NOT NULL,
tenant_id INT UNSIGNED DEFAULT NULL, -- 어느 테넌트 페이지에서 공유됐는지
share_channel ENUM('kakao','kakao_story','url_copy','other') DEFAULT 'kakao',
shared_at DATETIME DEFAULT CURRENT_TIMESTAMP,
INDEX idx_post (post_id),
INDEX idx_tenant (tenant_id, shared_at)
) ENGINE=InnoDB;CREATE TABLE members (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
tenant_id INT UNSIGNED NOT NULL,
email VARCHAR(200) NOT NULL,
password_hash VARCHAR(255) NOT NULL,
nickname VARCHAR(100),
point INT DEFAULT 0,
role ENUM('owner','manager','member') DEFAULT 'member',
status ENUM('active','banned','withdrawn') DEFAULT 'active',
joined_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uq_tenant_email (tenant_id, email),
FOREIGN KEY (tenant_id) REFERENCES tenants(id)
) ENGINE=InnoDB;CREATE TABLE menus (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
tenant_id INT UNSIGNED NOT NULL,
parent_id INT UNSIGNED DEFAULT NULL,
label VARCHAR(100) NOT NULL,
link_type ENUM('board','url','page') DEFAULT 'url',
link_value VARCHAR(300),
sort_order SMALLINT DEFAULT 0,
is_visible TINYINT(1) DEFAULT 1,
FOREIGN KEY (tenant_id) REFERENCES tenants(id)
) ENGINE=InnoDB;-- 템플릿 마스터 (시스템 제공 + AI 생성 통합)
CREATE TABLE templates (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(100) NOT NULL,
preview_path VARCHAR(300),
category ENUM('minimal','bold','elegant','warm','official','trendy'),
target_persona VARCHAR(100), -- "정치인", "강사", "작가" 등
css_tokens JSON NOT NULL, -- 디자인 토큰 전체
layout_preset VARCHAR(50) DEFAULT 'standard',
source ENUM('human','ai') DEFAULT 'human',
quality_score TINYINT UNSIGNED DEFAULT 0,
is_public TINYINT(1) DEFAULT 0, -- 검수 통과 후 공개
used_count INT UNSIGNED DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB;
-- 테넌트 적용 템플릿
CREATE TABLE tenant_templates (
tenant_id INT UNSIGNED PRIMARY KEY,
template_id INT UNSIGNED NOT NULL,
custom_tokens JSON DEFAULT NULL, -- 테넌트 미세조정 오버라이드
applied_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (tenant_id) REFERENCES tenants(id),
FOREIGN KEY (template_id) REFERENCES templates(id)
) ENGINE=InnoDB;/brandos
├── public/ ← DocumentRoot (Nginx)
│ ├── index.php ← 단일 진입점
│ ├── assets/ ← 전역 CSS/JS
│ └── uploads/
│ └── {tenant_id}/ ← 테넌트별 업로드 완전 분리
│
├── app/
│ ├── Core/
│ │ ├── Router.php
│ │ ├── Request.php
│ │ ├── Response.php
│ │ └── TenantContext.php ← 전역 테넌트 싱글턴
│ │
│ ├── Middleware/
│ │ ├── TenantMiddleware.php
│ │ ├── AuthMiddleware.php
│ │ └── CsrfMiddleware.php
│ │
│ ├── Models/
│ │ ├── BaseModel.php ← tenant_id 자동 바인딩
│ │ ├── Tenant.php
│ │ ├── Post.php
│ │ ├── Board.php
│ │ └── Member.php
│ │
│ ├── Services/
│ │ ├── SnsPublisher/
│ │ │ ├── SnsPublisherInterface.php
│ │ │ ├── KakaoStoryPublisher.php
│ │ │ ├── InstagramPublisher.php
│ │ │ └── SnsPublishJob.php ← 큐 처리
│ │ ├── SeoService.php
│ │ ├── MediaService.php ← 이미지 리사이즈/EXIF 처리
│ │ ├── TemplateService.php ← CSS 토큰 주입
│ │ ├── AiTemplateGenerator.php ← Claude API 연동
│ │ └── PointService.php
│ │
│ ├── Controllers/
│ │ ├── Admin/ ← 솔루션 최고관리자
│ │ ├── Tenant/ ← 테넌트 관리자
│ │ └── Front/ ← 공개 프론트
│ │
│ └── Views/
│ ├── _layout/
│ │ ├── base.html.php
│ │ ├── presets/
│ │ │ ├── standard.html.php
│ │ │ ├── sidebar-left.html.php
│ │ │ ├── fullhero.html.php
│ │ │ ├── minimal-top.html.php
│ │ │ └── magazine.html.php
│ │ └── tenant_sub.html.php
│ ├── skins/
│ │ ├── list/
│ │ ├── webzine/
│ │ └── gallery/
│ └── admin/
│
├── config/
│ ├── database.php
│ ├── app.php
│ └── sns.php ← SNS API 앱 키 (서버 전역)
│
├── storage/
│ ├── cache/
│ ├── logs/
│ └── queue/ ← SNS 발행 큐 파일
│
└── cron/
└── sns_queue_worker.php ← 큐 처리 크론잡 (1분 주기)class SeoService
{
public function renderHead(Tenant $t, ?Post $post = null): string
{
$profile = $t->profile;
$title = ($post?->title ? $post->title . ' | ' : '') . ($profile->meta_title ?? $t->display_name);
$desc = $post?->excerpt(120) ?? $profile->meta_desc;
$ogImage = $post?->thumb_url ?? $profile->og_image_url;
$canonical = $post?->canonical_url ?? "https://myoc.kr/{$t->slug}";
// JSON-LD 구조화 데이터 자동 생성
$schema = [
'@context' => 'https://schema.org',
'@type' => $profile->schema_type, // Person / Organization
'name' => $profile->real_name,
'url' => $canonical,
'description' => $desc,
'image' => $ogImage,
];
return $this->renderMetaTags($title, $desc, $ogImage, $canonical, $schema);
}
}/{slug}/sitemap.xmllastmod = updated_at, changefreq = 게시판 업데이트 빈도 기반class PostRepository
{
// 메인 피드: 테넌트 게시물 + 공통 게시물 혼합
public function getFeedForTenant(int $tenantId, int $limit = 20): array
{
$sql = "
(
SELECT p.*, b.board_name, 'tenant' AS source
FROM posts p
JOIN boards b ON b.id = p.board_id
WHERE p.tenant_id = :tid
AND p.status = 'published'
)
UNION ALL
(
SELECT p.*, b.board_name, 'common' AS source
FROM posts p
JOIN boards b ON b.id = p.board_id
JOIN tenant_common_boards tcb
ON tcb.board_id = p.board_id AND tcb.tenant_id = :tid2
WHERE p.post_type = 'common'
AND p.status = 'published'
AND tcb.is_visible = 1
)
ORDER BY published_at DESC
LIMIT :limit
";
return $this->db->query($sql, ['tid' => $tenantId, 'tid2' => $tenantId, 'limit' => $limit]);
}
}class PostController
{
public function view(string $slug, string $boardKey, int $postId): Response
{
$tenant = TenantContext::get();
$post = PostRepository::findForTenant($tenant->id, $postId);
// 공통 게시물(post->tenant_id === null)이든 테넌트 게시물이든 동일 렌더링
// 공통 게시물도 현재 테넌트 브랜딩을 씌워서 표시
// 공유 URL = 현재 테넌트 기준 (SNS/카카오톡 공유 시 이 URL 전달)
$shareUrl = "https://myoc.kr/{$slug}/{$boardKey}/{$postId}";
return View::render('front/post_view', compact('post', 'shareUrl', 'tenant'));
}
}발행 흐름
글쓰기 폼 제출
→ PostController::store()
→ Post 저장 (DB)
→ MediaService: 이미지 처리
→ 선택된 SNS → SnsPublishJob::dispatch()
→ sns_publish_logs에 'pending' INSERT
→ queue 파일에 job 적재
→ 사용자에게 즉시 응답 (비동기)
[크론잡 1분 주기] sns_queue_worker.php
→ pending 잡 조회
→ SnsPublisherInterface::publish($job)
→ 결과를 sns_publish_logs 업데이트주의: SNS 발행을 동기 처리하면 API 지연/토큰 만료 재시도 시 UX가 망가짐.
반드시 비동기 큐 방식으로 처리할 것.
Instagram Graph API 발행
class InstagramPublisher implements SnsPublisherInterface
{
public function publish(SnsPublishJob $job): PublishResult
{
$setting = $job->tenant->snsSetting('instagram');
$post = $job->post;
// Step 1: 미디어 컨테이너 생성
$container = $this->api->createMediaContainer(
token: $setting->access_token,
imageUrl: $post->thumb_absolute_url,
caption: $this->buildCaption($post),
);
// Step 2: 게시
$result = $this->api->publishMedia(
token: $setting->access_token,
containerId: $container->id,
);
return new PublishResult(success: true, snsPostId: $result->id);
}
private function buildCaption(Post $post): string
{
$parts = [$post->title];
if ($post->event_at) $parts[] = '📅 ' . $post->event_at->format('Y.m.d');
if ($post->location) $parts[] = '📍 ' . $post->location;
if ($post->content) $parts[] = "\n" . strip_tags(mb_substr($post->content, 0, 200));
return implode("\n", $parts);
}
}카카오스토리 발행
POST /v1/api/story/post/photostoryMediaIds 배열로 게시Facebook/Instagram은 동일 Graph API 기반 → 묶어서 개발
네이버 블로그 API는 안정성 낮음 → 우선순위 후순위 권장
class MediaService
{
public function processUpload(UploadedFile $file, int $tenantId): MediaResult
{
$image = imagecreatefromstring(file_get_contents($file->tmpPath));
$exif = exif_read_data($file->tmpPath);
// EXIF 회전 보정 (스마트폰 사진 필수)
$image = $this->autoRotate($image, $exif);
// 리사이즈 3종 생성
$this->saveResized($image, $tenantId, $file->hash, [
'original' => null, // 원본
'medium' => 800, // 중간
'thumb' => 400, // 썸네일
]);
// EXIF에서 촬영 일시·GPS 추출 → 활동소식 자동 채움
return new MediaResult(
path: "uploads/{$tenantId}/{$file->hash}",
takenAt: $exif['DateTimeOriginal'] ?? null,
gpsLat: $this->parseGps($exif['GPSLatitude'] ?? null),
gpsLng: $this->parseGps($exif['GPSLongitude'] ?? null),
);
}
}| 스킨 | 용도 | 레이아웃 |
|---|---|---|
list | 공지/텍스트 중심 | 제목+날짜+요약 테이블형 |
webzine | 활동소식, 공통 콘텐츠 | 카드형, 본문 미리보기+썸네일 |
gallery | 사진 갤러리 | Masonry/Grid, 이미지 우선 |
/app/Views/skins/{skin}/ 디렉토리로 분리skin.config.php로 스킨별 옵션 정의 (컬럼 수, 페이지당 수 등)Layer 1. 데이터 레이어 (DB) → posts, tenant_profiles
Layer 2. 구조 레이어 (HTML) → 레이아웃 프리셋 (5~7종 고정)
Layer 3. 스타일 레이어 (CSS 토큰) → AI가 생성하는 영역핵심: AI는 HTML을 건드리지 않고 CSS 토큰만 생성.
깨지는 레이아웃이 구조적으로 불가능하고, 검수도 색상 대비 체크 수준으로 단순화.
:root {
/* 색상 */
--color-primary: #1a56db;
--color-secondary: #7e3af2;
--color-accent: #ff5a1f;
--color-bg: #ffffff;
--color-bg-subtle: #f9fafb;
--color-text: #111928;
--color-text-muted: #6b7280;
--color-border: #e5e7eb;
/* 타이포그래피 */
--font-display: 'Pretendard', sans-serif;
--font-body: 'Pretendard', sans-serif;
--font-size-base: 16px;
--font-weight-heading: 700;
--line-height-base: 1.6;
/* 형태 */
--radius-sm: 4px;
--radius-md: 8px;
--radius-lg: 16px;
/* 헤더 */
--header-height: 64px;
--header-bg: var(--color-primary);
--header-text: #ffffff;
--header-style: 'solid'; /* solid | transparent | blur */
/* 카드 */
--card-shadow: 0 1px 3px rgba(0,0,0,.1);
/* 히어로 */
--hero-style: 'gradient'; /* gradient | image | split | minimal */
--hero-height: 480px;
}CSS 토큰 JSON (DB 저장 예시)
{
"color_primary": "#1a56db",
"color_bg": "#ffffff",
"font_display": "Noto Serif KR",
"font_body": "Pretendard",
"radius_md": "2px",
"header_style": "transparent",
"hero_style": "split",
"card_shadow": "none",
"font_weight_heading": "800"
}테넌트 페이지 head에 동적 주입
$tokens = $tenant->template->mergedTokens(); // template + custom_tokens 오버라이드 병합
echo "<style>:root {\n";
foreach ($tokens as $key => $value) {
echo " --" . str_replace('_', '-', $key) . ": {$value};\n";
}
echo "}</style>";| preset | 구조 | 적합 대상 |
|---|---|---|
standard | 상단 헤더 + 히어로 + 본문 | 범용 |
sidebar-left | 좌측 프로필 고정 + 우측 피드 | 개인 브랜드 |
fullhero | 전면 커버 이미지 + 스크롤 본문 | 정치인, 강사 |
minimal-top | 로고+메뉴만, 히어로 없음 | 기업/전문직 |
magazine | 2단 그리드 메인 | 콘텐츠 중심 |
프리셋은 5~7종 고정. AI는 프리셋 중 하나를 선택하고 토큰만 생성.
HTML은 사람이 한 번 만들면 영구 재사용.
[1단계] 관리자 요청
예: "트렌디한 20대 여성 강사 스타일 10종"
↓
[2단계] AI 프롬프트 생성 → Claude API 호출
→ CSS 토큰 JSON 배열만 반환하도록 구조화 프롬프트
↓
[3단계] 자동 렌더 검증
→ 생성 토큰을 실제 템플릿에 주입
→ 헤드리스 브라우저로 스크린샷 자동 촬영
→ 색상 대비비 자동 체크 (WCAG AA 기준, 최소 4.5:1)
↓
[4단계] 관리자 검수 큐
→ 스크린샷 + 토큰 나란히 표시
→ 승인 / 수정 / 반려
↓
[5단계] is_public = 1 → 테넌트 선택 화면에 노출AI 호출 프롬프트 구조
$prompt = "
당신은 웹 디자인 전문가입니다.
아래 JSON 스키마를 반드시 지키는 CSS 토큰 세트를 {$count}종 생성하세요.
페르소나: {$persona}
분위기: {$mood}
레이아웃 프리셋: {$preset} (변경 불가)
제약:
- 배경색과 텍스트 대비비 WCAG AA 기준(4.5:1) 이상 필수
- 흰 글씨에 흰 배경 금지
- 한국 웹 환경에 적합한 폰트 사용
반드시 아래 JSON 배열만 반환, 다른 텍스트 일절 없음:
[{ \"name\": \"...\", \"layout_preset\": \"...\", \"tokens\": { ...토큰... } }, ...]
토큰 스키마:
" . json_encode($tokenSchema, JSON_UNESCAPED_UNICODE);템플릿 선택 화면
→ 카테고리 필터: 미니멀 / 강렬 / 우아한 / 공식적 / 따뜻한
→ 페르소나 필터: 정치인 / 강사 / 작가 / 의료인 / 기업인
→ 썸네일 그리드 (실제 내 콘텐츠가 채워진 라이브 미리보기)
↑ 핵심 차별점: 샘플이 아닌 내 프로필·게시물로 미리보기
→ 클릭 → 즉시 적용 (새로고침 없이 CSS 토큰 교체)
→ 미세조정: 메인 컬러 1개만 조정 가능 (컬러피커)| 항목 | 윅스/아임웹 | BrandOS |
|---|---|---|
| 스타일 변경 | 블럭 재배치 필요 | 클릭 한 번 |
| 새 템플릿 제작 | 디자이너 필요 | AI 토큰 생성 파이프라인 |
| 미리보기 | 샘플 콘텐츠 | 내 실제 콘텐츠 |
| 무한 확장 | 불가 | AI 파이프라인으로 지속 추가 |
| 커스텀 | 자유롭지만 복잡 | 컬러 하나만 (단순·명확) |
/admin)/{slug}/manage)| 메뉴 | 주요 기능 |
|---|---|
| 대시보드 | 방문자 수, 최근 게시물, SNS 발행 현황, 공유 통계 |
| 프로필 관리 | 인물 정보, 사진, 소개, SEO 메타 설정 |
| 메뉴 관리 | 드래그앤드롭 메뉴 편집기 |
| 게시판 관리 | 게시판 생성/수정, 스킨 선택, SNS 연동 여부, 공통게시판 표시 설정 |
| 게시물 관리 | 전체 게시물 목록/수정/삭제, 예약 발행 |
| 회원 관리 | 회원 목록, 포인트 지급/차감, 등급 조정 |
| SNS 설정 | 플랫폼별 OAuth 연동, 발행 로그 확인 |
| 템플릿 | 레이아웃 프리셋 선택, 스타일 선택, 컬러 미세조정, 배너 관리 |
| SEO 설정 | 타이틀, 설명, OG 이미지, 사이트맵, schema.org 타입 |
| 항목 | 적용 방안 |
|---|---|
| SNS 토큰 | AES-256-CBC 암호화 저장, 복호화 키는 .env 관리 (절대 DB에 평문 저장 금지) |
| CSRF | 모든 폼 제출에 CSRF 토큰 검증 |
| XSS | 출력 시 htmlspecialchars() 전처리, 에디터는 허용 태그 화이트리스트 |
| SQL Injection | PDO prepared statements 100% 적용 |
| 파일 업로드 | MIME 실제 검사, 업로드 디렉토리 PHP 실행 차단 (Nginx deny) |
| 테넌트 월담 | 모든 쿼리에 WHERE tenant_id = ? 강제 바인딩 (BaseModel 레벨) |
| 레이트 리밋 | 로그인/SNS 발행 API에 Redis 기반 Rate Limit |
| 비밀번호 | password_hash() bcrypt, 평문 저장 절대 금지 |
| 항목 | 방안 |
|---|---|
| 테넌트 메타 캐싱 | Redis tenant:{slug} 키, TTL 10분. 설정 변경 시 즉시 무효화 |
| 게시물 목록 캐싱 | 게시물 작성/수정 시 해당 테넌트 게시판 캐시 자동 무효화 |
| 이미지 최적화 | Lazy Load, WebP 자동 변환, 3종 리사이즈 (원본/800/400) |
| DB 인덱스 필수 | (tenant_id, status, published_at), (post_type, board_id, status) 복합 인덱스 |
| SNS 발행 | 동기 처리 절대 금지 → 크론 큐 비동기 처리 |
| CSS 토큰 | 인라인 <style> 주입, 별도 HTTP 요청 없음 |
| Phase | 기간 | 주요 작업 |
|---|---|---|
| P1. 기반 | 2주 | 디렉토리 구조, Router, TenantMiddleware, BaseModel, DB 스키마 전체 생성 |
| P2. 코어 | 3주 | 테넌트 생성/관리, 회원가입/로그인, 게시판/게시물 CRUD, 공통 게시물 피드 |
| P3. SEO | 1주 | SeoService, JSON-LD, Sitemap 자동 생성, OG 태그 자동화 |
| P4. SNS | 2주 | 카카오스토리·Instagram Publisher, 큐 시스템, 발행 로그, 공유 URL |
| P5. 스킨/템플릿 | 2주 | 3종 게시판 스킨, 레이아웃 프리셋 5종, CSS 토큰 주입, 배너 관리 |
| P6. AI 템플릿 | 1주 | AiTemplateGenerator, 검수 큐, 라이브 미리보기, 컬러 미세조정 |
| P7. 마감 | 1주 | 보안 점검, 성능 튜닝(인덱스/캐시), 관리자 대시보드 완성 |
| P8. 패키지 | 1주 | 설치 마법사(install.php), 라이선스 시스템, 문서화 |
총 예상 기간: 약 13주 (약 3개월)
install.php : DB 자동 생성, 최고관리자 계정 설정 마법사.env.example : DB 접속, AES 암호화 키, SNS 앱 ID 샘플migrations/ : 버전별 DB 마이그레이션 스크립트demo_data.sql : 샘플 테넌트/게시물/템플릿 포함BrandOS Development Guide v1.0 — 본 문서를 기반으로 전체 개발이 가능하도록 설계되었습니다.