Platform Docs

Phase 1: Foundation - 동적 모듈 레지스트리

/platform/phase1-foundation

Phase 1: Foundation - 동적 모듈 레지스트리

Note: This document reflects an earlier planning snapshot. Legacy names such as memble or homb may appear in historical context. Current Membloc work should use membloc naming.

하드코딩된 모듈 레지스트리를 백엔드 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)

#작업의존성예상
B1002_marketplace.up.sql 마이그레이션 작성-0.5d
B2003_seed_modules.up.sql 시드 데이터 작성B10.5d
B3model/module.go, model/publisher.go 작성-0.5d
B4repository/module.go - 모듈 CRUD, 검색, 페이징B1, B31d
B5service/marketplace.go - 목록, 검색, 카테고리B40.5d
B6handler/marketplace.go - GET 엔드포인트B50.5d
B7repository/module_installation.go - 설치 CRUDB1, B30.5d
B8service/module_install.go - 설치 로직 + Firestore 동기화B71d
B9handler/module_install.go - POST 엔드포인트B80.5d
B10main.go 라우트 등록B6, B90.5d
B11Firebase Admin SDK 연동 (Firestore 쓰기용)-1d
B12테스트 작성B101d

5.2 Flutter (membloc-app)

#작업의존성예상
F1MarketplaceApi HTTP 클라이언트B6 완료0.5d
F2RemoteModuleDescriptor 모델-0.5d
F3RemoteModuleRegistry - fetch + 캐시F1, F21d
F4MergedModuleRegistry - native + remote mergeF30.5d
F5ModuleHubScreen 탭 분리 (내 모듈 / 마켓플레이스)F41d
F6ModuleDetailScreen 신규F11d
F7설치 플로우 (기존 Cloud Functions → 백엔드 API 전환)F1, B91d
F8오프라인 캐시 및 에러 처리F30.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 없음)

SDK assets

Static SDK files

JavaScript runtime and TypeScript definitions are served from this docs app.
membloc-sdk.jsmembloc-sdk.d.ts