Phase 1: Foundation — 동적 모듈 레지스트리
하드코딩된 모듈 레지스트리를 백엔드 API 기반 동적 레지스트리로 전환한다. 기존 모듈은 그대로 동작하면서, 마켓플레이스의 데이터 기반을 구축한다.
1. 목표
- 모듈 메타데이터를 PostgreSQL에서 관리
- membloc-app-engine에 Marketplace API 추가
- Flutter 앱이 백엔드에서 모듈 목록을 fetch하여 레지스트리 구성
- 기존 9개 모듈(Core 4 + Plugin 5)을 백엔드에도 등록
- ModuleHubScreen에 마켓플레이스 탐색 UI 추가
2. 데이터베이스 마이그레이션
migrations/002_marketplace.up.sql
-- 퍼블리셔 (모듈 개발 사업체)
CREATE TABLE publishers (
id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
name TEXT NOT NULL,
email TEXT NOT NULL UNIQUE,
website TEXT,
logo_url TEXT,
status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending','approved','suspended')),
firebase_uid TEXT NOT NULL UNIQUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- 모듈 레지스트리
CREATE TABLE modules (
id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
module_key TEXT NOT NULL UNIQUE,
publisher_id TEXT NOT NULL REFERENCES publishers(id),
display_name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
long_description TEXT,
category TEXT NOT NULL DEFAULT 'general',
type TEXT NOT NULL DEFAULT 'native'
CHECK (type IN ('native','webview','server')),
icon_url TEXT,
entry_url TEXT,
manifest JSONB NOT NULL DEFAULT '{}',
permissions TEXT[] NOT NULL DEFAULT '{}',
settings_schema JSONB NOT NULL DEFAULT '{}',
pricing_model TEXT NOT NULL DEFAULT 'free'
CHECK (pricing_model IN ('free','paid','freemium','subscription')),
status TEXT NOT NULL DEFAULT 'draft'
CHECK (status IN ('draft','review','published','suspended')),
min_homb_version TEXT DEFAULT '1.0.0',
install_count INTEGER NOT NULL DEFAULT 0,
rating_avg NUMERIC(2,1) NOT NULL DEFAULT 0,
rating_count INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_modules_category ON modules(category);
CREATE INDEX idx_modules_publisher ON modules(publisher_id);
CREATE INDEX idx_modules_status ON modules(status);
CREATE INDEX idx_modules_key ON modules(module_key);
-- 모듈 버전
CREATE TABLE module_versions (
id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
module_id TEXT NOT NULL REFERENCES modules(id) ON DELETE CASCADE,
version TEXT NOT NULL,
changelog TEXT DEFAULT '',
manifest JSONB NOT NULL DEFAULT '{}',
entry_url TEXT,
status TEXT NOT NULL DEFAULT 'approved'
CHECK (status IN ('review','approved','rejected')),
reviewed_by TEXT,
reviewed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE(module_id, version)
);
-- 모듈 설치 (가족 단위, PostgreSQL 측)
CREATE TABLE module_installations (
id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
family_id TEXT NOT NULL,
module_id TEXT NOT NULL REFERENCES modules(id),
module_key TEXT NOT NULL,
version TEXT NOT NULL DEFAULT '1.0.0',
status TEXT NOT NULL DEFAULT 'active'
CHECK (status IN ('active','disabled','uninstalled')),
settings JSONB NOT NULL DEFAULT '{}',
installed_by TEXT NOT NULL,
installed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE(family_id, module_key)
);
CREATE INDEX idx_installations_family ON module_installations(family_id);
CREATE INDEX idx_installations_module ON module_installations(module_id);
-- 모듈 카테고리 마스터
CREATE TABLE module_categories (
key TEXT PRIMARY KEY,
display_name TEXT NOT NULL,
icon_name TEXT,
sort_order INTEGER NOT NULL DEFAULT 0
);
-- 초기 카테고리 데이터
INSERT INTO module_categories (key, display_name, icon_name, sort_order) VALUES
('lifestyle', '생활', 'home', 1),
('communication','소통', 'chat', 2),
('finance', '가계/금융', 'account_balance', 3),
('health', '건강', 'favorite', 4),
('education', '교육', 'school', 5),
('entertainment','여가', 'sports_esports', 6),
('productivity', '생산성', 'task_alt', 7),
('religion', '종교/신앙', 'auto_stories', 8),
('general', '기타', 'extension', 99);
migrations/002_marketplace.down.sql
DROP TABLE IF EXISTS module_installations;
DROP TABLE IF EXISTS module_versions;
DROP TABLE IF EXISTS modules;
DROP TABLE IF EXISTS publishers;
DROP TABLE IF EXISTS module_categories;
기존 모듈 시드 데이터 (migrations/003_seed_modules.up.sql)
-- homb 공식 퍼블리셔
INSERT INTO publishers (id, name, email, website, status, firebase_uid)
VALUES ('homb-official', 'homb', 'homblabs@gmail.com', NULL, 'approved', 'system');
-- Core 모듈 (type=native, isCore 개념은 manifest에)
INSERT INTO modules (id, module_key, publisher_id, display_name, description, category, type, status, manifest) VALUES
('mod-family-core', 'family_core', 'homb-official', 'Family Core',
'그룹, 권한, 공유 같은 코어 기능', 'general', 'native', 'published',
'{"isCore": true, "navEnabledByDefault": false, "feedEnabledByDefault": false}'::jsonb),
('mod-channels', 'channels', 'homb-official', '채널',
'가족 대화와 소통', 'communication', 'native', 'published',
'{"isCore": true, "supportedEntityTypes": ["message"]}'::jsonb),
('mod-memories', 'memories', 'homb-official', '추억',
'사진과 기록 보관', 'lifestyle', 'native', 'published',
'{"isCore": true, "supportedEntityTypes": ["memory"], "supportsSharing": true}'::jsonb),
('mod-archives', 'archives', 'homb-official', '아카이브',
'가족 파일과 자료 보관', 'lifestyle', 'native', 'published',
'{"isCore": true, "supportedEntityTypes": ["archive"], "supportsSharing": true}'::jsonb),
-- Plugin 모듈
('mod-travel', 'travel', 'homb-official', '여행',
'가족 여행 일정과 준비물, 기록', 'lifestyle', 'native', 'published',
'{"supportedEntityTypes": ["trip"], "supportsSharing": true, "defaultInstallSettings": {"defaultStatus": "planning", "checklistEnabled": true}}'::jsonb),
('mod-chat', 'chat', 'homb-official', '대화방',
'채팅만 하는 독립 공간', 'communication', 'native', 'published',
'{"supportedEntityTypes": ["chat_room"], "defaultInstallSettings": {"channelType": "module", "purposeKey": "chat"}}'::jsonb),
('mod-religion', 'religion', 'homb-official', '종교',
'예배, 기도 제목, 가족 신앙 이벤트 관리', 'religion', 'native', 'published',
'{"supportedEntityTypes": ["service"], "defaultInstallSettings": {"serviceLabel": "예배", "prayerNotesEnabled": true}}'::jsonb),
('mod-shopping', 'shopping', 'homb-official', '장바구니',
'가족 장보기 목록 관리', 'lifestyle', 'native', 'published',
'{"supportedEntityTypes": ["shopping_list"]}'::jsonb),
('mod-chores', 'chores', 'homb-official', '집안일',
'가족 집안일 분담과 완료 추적', 'lifestyle', 'native', 'published',
'{"supportedEntityTypes": ["chore"]}'::jsonb);
-- 버전 정보
INSERT INTO module_versions (module_id, version, status) VALUES
('mod-family-core', '1.0.0', 'approved'),
('mod-channels', '1.0.0', 'approved'),
('mod-memories', '1.0.0', 'approved'),
('mod-archives', '1.0.0', 'approved'),
('mod-travel', '1.0.0', 'approved'),
('mod-chat', '1.0.0', 'approved'),
('mod-religion', '1.0.0', 'approved'),
('mod-shopping', '1.0.0', 'approved'),
('mod-chores', '1.0.0', 'approved');
3. 백엔드 구현 (membloc-app-engine)
3.1 새로운 파일 구조
internal/
├── auth/middleware.go # 기존
├── config/config.go # 기존
├── handler/
│ ├── health.go # 기존
│ ├── family.go # 기존
│ ├── marketplace.go # [신규] 마켓플레이스 조회 API
│ └── module_install.go # [신규] 모듈 설치/해제 API
├── model/
│ ├── family.go # 기존
│ ├── user.go # 기존
│ ├── module.go # [신규] Module, ModuleVersion
│ ├── publisher.go # [신규] Publisher
│ └── module_installation.go # [신규] ModuleInstallation
├── repository/
│ ├── family.go # 기존
│ ├── module.go # [신규] 모듈 CRUD
│ └── module_installation.go # [신규] 설치 CRUD
├── service/
│ ├── family.go # 기존
│ ├── marketplace.go # [신규] 마켓플레이스 비즈니스 로직
│ └── module_install.go # [신규] 설치 비즈니스 로직
3.2 Model 정의
// internal/model/module.go
package model
import "time"
type Module struct {
ID string `json:"id" db:"id"`
ModuleKey string `json:"moduleKey" db:"module_key"`
PublisherID string `json:"publisherId" db:"publisher_id"`
DisplayName string `json:"displayName" db:"display_name"`
Description string `json:"description" db:"description"`
LongDescription *string `json:"longDescription" db:"long_description"`
Category string `json:"category" db:"category"`
Type string `json:"type" db:"type"`
IconURL *string `json:"iconUrl" db:"icon_url"`
EntryURL *string `json:"entryUrl" db:"entry_url"`
Manifest map[string]any `json:"manifest" db:"manifest"`
Permissions []string `json:"permissions" db:"permissions"`
SettingsSchema map[string]any `json:"settingsSchema" db:"settings_schema"`
PricingModel string `json:"pricingModel" db:"pricing_model"`
Status string `json:"status" db:"status"`
MinHombVersion string `json:"minHombVersion" db:"min_homb_version"`
InstallCount int `json:"installCount" db:"install_count"`
RatingAvg float64 `json:"ratingAvg" db:"rating_avg"`
RatingCount int `json:"ratingCount" db:"rating_count"`
CreatedAt time.Time `json:"createdAt" db:"created_at"`
UpdatedAt time.Time `json:"updatedAt" db:"updated_at"`
}
type ModuleVersion struct {
ID string `json:"id" db:"id"`
ModuleID string `json:"moduleId" db:"module_id"`
Version string `json:"version" db:"version"`
Changelog string `json:"changelog" db:"changelog"`
EntryURL *string `json:"entryUrl" db:"entry_url"`
Status string `json:"status" db:"status"`
CreatedAt time.Time `json:"createdAt" db:"created_at"`
}
type ModuleCategory struct {
Key string `json:"key" db:"key"`
DisplayName string `json:"displayName" db:"display_name"`
IconName string `json:"iconName" db:"icon_name"`
SortOrder int `json:"sortOrder" db:"sort_order"`
}
// 앱이 필요로 하는 간소화된 응답
type ModuleSummary struct {
ModuleKey string `json:"moduleKey"`
DisplayName string `json:"displayName"`
Description string `json:"description"`
Category string `json:"category"`
Type string `json:"type"`
IconURL *string `json:"iconUrl"`
EntryURL *string `json:"entryUrl"`
Manifest map[string]any `json:"manifest"`
PricingModel string `json:"pricingModel"`
InstallCount int `json:"installCount"`
RatingAvg float64 `json:"ratingAvg"`
PublisherName string `json:"publisherName"`
}
3.3 API 엔드포인트
// cmd/server/main.go 에 추가할 라우트
// Marketplace (공개 — 인증 불필요)
e.GET("/api/marketplace/modules", marketplaceHandler.ListModules)
e.GET("/api/marketplace/modules/:key", marketplaceHandler.GetModule)
e.GET("/api/marketplace/categories", marketplaceHandler.ListCategories)
e.GET("/api/marketplace/featured", marketplaceHandler.GetFeatured)
// Module Install (인증 필요)
api.POST("/api/families/:familyId/modules/:key/install", moduleInstallHandler.Install)
api.POST("/api/families/:familyId/modules/:key/uninstall", moduleInstallHandler.Uninstall)
api.GET("/api/families/:familyId/modules", moduleInstallHandler.ListInstalled)
3.4 주요 핸들러 로직
handler/marketplace.go
GET /api/marketplace/modules?category=lifestyle&q=여행&page=1&limit=20
Response:
{
"modules": [ModuleSummary],
"total": 42,
"page": 1,
"hasMore": true
}
status = 'published'인 모듈만 반환- category, keyword(display_name ILIKE), pricing_model 필터
- install_count, rating_avg 기반 정렬 옵션
handler/module_install.go
POST /api/families/:familyId/modules/:key/install
Authorization: Bearer <firebase-token>
→ module_installations에 INSERT
→ modules.install_count += 1
→ Firestore families/{familyId}/modules/{key} 에도 동기화 (기존 앱 호환)
3.5 Firestore 동기화 전략
Phase 1에서는 양방향 동기화가 아닌, 백엔드 → Firestore 단방향 쓰기로 호환성 유지:
설치 요청 → membloc-app-engine (PostgreSQL에 저장)
→ Firestore families/{id}/modules/{key} 에도 write
→ Flutter 앱은 기존 Firestore stream 그대로 사용
모듈 목록 → Flutter 앱이 /api/marketplace/modules 호출
→ NativeModuleRegistry + RemoteModuleRegistry merge
4. Flutter 앱 변경
4.1 RemoteModuleRegistry
// core/modules/remote_module_registry.dart
class RemoteModuleDescriptor {
final String moduleKey;
final String displayName;
final String description;
final String category;
final String type; // native, webview, server
final String? iconUrl;
final String? entryUrl;
final Map<String, dynamic> manifest;
final String pricingModel;
final int installCount;
final double ratingAvg;
final String publisherName;
}
class RemoteModuleRegistry {
// GET /api/marketplace/modules 호출
// 결과를 로컬 캐시 (SharedPreferences 또는 Hive)
// 오프라인 시 캐시된 데이터 사용
Future<List<RemoteModuleDescriptor>> fetchAll();
Future<List<RemoteModuleDescriptor>> search(String query);
Future<List<RemoteModuleDescriptor>> byCategory(String category);
}
4.2 MergedModuleRegistry
// core/modules/merged_module_registry.dart
class MergedModuleRegistry {
final ModuleRegistry nativeRegistry; // 기존 (하드코딩)
final RemoteModuleRegistry remoteRegistry; // 신규 (백엔드)
// native 모듈이 우선, remote에서 추가 모듈 merge
// native에 없는 webview/server 타입 모듈이 여기서 추가됨
List<ModuleDescriptor> get all { ... }
}
4.3 ModuleHubScreen 확장
현재:
ModuleHubScreen
└── 하드코딩 모듈 카드 목록
변경:
ModuleHubScreen
├── Tab 1: 내 모듈 (설치됨) — 기존과 동일
└── Tab 2: 마켓플레이스 탐색
├── 검색바
├── 카테고리 필터 칩
├── 추천 모듈 배너
└── 모듈 카드 그리드
└── 탭 → ModuleDetailScreen
├── 스크린샷
├── 설명
├── 퍼블리셔 정보
├── 권한 목록
├── 리뷰 (Phase 4)
└── [설치] 버튼
4.4 API 클라이언트
// core/services/marketplace_api.dart
class MarketplaceApi {
final String baseUrl; // membloc-app-engine 주소
Future<List<RemoteModuleDescriptor>> listModules({
String? category,
String? query,
int page = 1,
});
Future<RemoteModuleDescriptor> getModule(String moduleKey);
Future<List<ModuleCategory>> listCategories();
Future<List<RemoteModuleDescriptor>> getFeatured();
Future<void> installModule(String familyId, String moduleKey);
Future<void> uninstallModule(String familyId, String moduleKey);
}
5. 작업 목록
5.1 백엔드 (membloc-app-engine)
| # | 작업 | 의존성 | 예상 |
|---|---|---|---|
| B1 | 002_marketplace.up.sql 마이그레이션 작성 | - | 0.5d |
| B2 | 003_seed_modules.up.sql 시드 데이터 작성 | B1 | 0.5d |
| B3 | model/module.go, model/publisher.go 작성 | - | 0.5d |
| B4 | repository/module.go — 모듈 CRUD, 검색, 페이징 | B1, B3 | 1d |
| B5 | service/marketplace.go — 목록, 검색, 카테고리 | B4 | 0.5d |
| B6 | handler/marketplace.go — GET 엔드포인트 | B5 | 0.5d |
| B7 | repository/module_installation.go — 설치 CRUD | B1, B3 | 0.5d |
| B8 | service/module_install.go — 설치 로직 + Firestore 동기화 | B7 | 1d |
| B9 | handler/module_install.go — POST 엔드포인트 | B8 | 0.5d |
| B10 | main.go 라우트 등록 | B6, B9 | 0.5d |
| B11 | Firebase Admin SDK 연동 (Firestore 쓰기용) | - | 1d |
| B12 | 테스트 작성 | B10 | 1d |
5.2 Flutter (membloc-app)
| # | 작업 | 의존성 | 예상 |
|---|---|---|---|
| F1 | MarketplaceApi HTTP 클라이언트 | B6 완료 | 0.5d |
| F2 | RemoteModuleDescriptor 모델 | - | 0.5d |
| F3 | RemoteModuleRegistry — fetch + 캐시 | F1, F2 | 1d |
| F4 | MergedModuleRegistry — native + remote merge | F3 | 0.5d |
| F5 | ModuleHubScreen 탭 분리 (내 모듈 / 마켓플레이스) | F4 | 1d |
| F6 | ModuleDetailScreen 신규 | F1 | 1d |
| F7 | 설치 플로우 (기존 Cloud Functions → 백엔드 API 전환) | F1, B9 | 1d |
| F8 | 오프라인 캐시 및 에러 처리 | F3 | 0.5d |
6. 호환성 전략
Firestore ↔ PostgreSQL 이중화
Phase 1에서는 두 곳 모두에 데이터를 유지한다:
[기존 흐름 — 유지]
Flutter → Firestore families/{id}/modules/{key} (실시간 stream)
[신규 흐름 — 추가]
Flutter → membloc-app-engine API → PostgreSQL (마켓플레이스 데이터)
→ Firestore (설치 시 동기화)
- 기존
ModuleService.installsStream()— Firestore 기반, 그대로 유지 - 마켓플레이스 탐색 — 백엔드 API 사용
- 설치 요청 — 백엔드 API → 백엔드가 Firestore에도 write
기존 모듈 동작
- Core 4개 + Plugin 5개는
default_module_registry.dart에 계속 존재 - 백엔드에도 동일 데이터 시드
MergedModuleRegistry에서 native 우선, 중복 제거
7. 완료 기준
-
/api/marketplace/modules호출 시 9개 기존 모듈 반환 -
/api/marketplace/modules?category=lifestyle필터 동작 -
/api/families/:id/modules/:key/install호출 시 PostgreSQL + Firestore 양쪽 저장 - Flutter 앱에서 마켓플레이스 탭에 모듈 목록 표시
- Flutter 앱에서 모듈 상세 화면 진입
- 오프라인 상태에서 캐시된 모듈 목록 표시
- 기존 모듈 설치/해제 플로우 정상 동작 (regression 없음)