Phase 2: WebView Runtime — 외부 모듈 실행 환경
외부 사업체가 웹앱으로 개발한 모듈을 homb 앱 내 WebView에서 안전하게 실행할 수 있는 런타임 환경을 구축한다.
1. 목표
- WebView 모듈 실행 컨테이너 (Flutter InAppWebView)
- JS Bridge SDK (
homb.js) — 모듈 ↔ homb 앱 간 통신 - Module Runtime API (membloc-app-engine) — 모듈 데이터 CRUD
- 권한 동의 플로우 UI
- 데이터 샌드박싱 (모듈별 격리)
- 샘플 WebView 모듈 1개 개발 (dogfooding)
2. 아키텍처
┌──────────────────────────────────────────────────┐
│ membloc-app (Flutter) │
│ │
│ ┌─────────────────────────────────────────────┐ │
│ │ ModuleWebViewScreen │ │
│ │ ┌───────────────────────────────────────┐ │ │
│ │ │ InAppWebView │ │ │
│ │ │ │ │ │
│ │ │ 3rd-party Web App │ │ │
│ │ │ ┌─────────────────────────────────┐ │ │ │
│ │ │ │ <script src="membloc-sdk.js"> │ │ │ │
│ │ │ │ │ │ │ │
│ │ │ │ const user = await homb.auth │ │ │ │
│ │ │ │ .getUserInfo(); │ │ │ │
│ │ │ │ const items = await homb.data │ │ │ │
│ │ │ │ .list("expenses"); │ │ │ │
│ │ │ └─────────────────────────────────┘ │ │ │
│ │ └──────────────┬────────────────────────┘ │ │
│ │ │ postMessage / handler │ │
│ │ ┌──────────────▼────────────────────────┐ │ │
│ │ │ JsBridgeController │ │ │
│ │ │ - Permission checker │ │ │
│ │ │ - Request router │ │ │
│ │ │ - Response serializer │ │ │
│ │ └──────────────┬────────────────────────┘ │ │
│ └─────────────────┼───────────────────────────┘ │
│ │ │
│ ┌─────────────────▼───────────────────────────┐ │
│ │ ModuleRuntimeService │ │
│ │ - auth: scoped token 발급 │ │
│ │ - family: 가족 정보 (읽기 전용) │ │
│ │ - data: module_data CRUD (REST) │ │
│ │ - storage: module storage upload/download │ │
│ │ - activity: 피드 이벤트 발행 │ │
│ │ - notifications: 푸시 알림 전송 │ │
│ └──────────────────┬──────────────────────────┘ │
│ │ HTTP │
└─────────────────────┼────────────────────────────┘
▼
┌────────────────────────┐
│ membloc-app-engine │
│ /api/runtime/* │
│ │
│ - module_data CRUD │
│ - activity write │
│ - notification relay │
│ - permission enforce │
└────────────────────────┘
3. JS Bridge SDK (membloc-sdk.js)
외부 모듈 개발자가 <script> 로 포함하는 클라이언트 SDK.
3.1 통신 프로토콜
모듈 (JS) → postMessage → Flutter (Dart handler)
→ 권한 체크
→ API 호출 or 로컬 데이터
→ 결과
Flutter (Dart) → evaluateJavascript → 모듈 (JS callback)
메시지 포맷:
// 요청
interface BridgeRequest {
id: string; // UUID, 응답 매칭용
method: string; // "auth.getUserInfo", "data.list" 등
params: Record<string, any>;
}
// 응답
interface BridgeResponse {
id: string; // 요청 id와 매칭
success: boolean;
data?: any;
error?: { code: string; message: string };
}
3.2 SDK API 상세
// membloc-sdk.js
class HombSDK {
// === 인증 ===
auth: {
// 현재 사용자 정보 (permission: 없음 — 항상 허용)
getUserInfo(): Promise<{
uid: string;
displayName: string;
photoUrl: string | null;
}>;
// 모듈용 scoped access token (백엔드 API 호출용)
getAccessToken(): Promise<string>;
};
// === 가족 정보 ===
// permission: family.read
family: {
getCurrent(): Promise<{
id: string;
name: string;
memberCount: number;
}>;
// permission: members.read
getMembers(): Promise<Array<{
uid: string;
displayName: string;
photoUrl: string | null;
role: string;
}>>;
};
// === 모듈 데이터 (샌드박스) ===
// permission: 없음 — 자기 모듈 데이터는 항상 접근 가능
data: {
get(key: string): Promise<any>;
set(key: string, value: any): Promise<void>;
delete(key: string): Promise<void>;
list(prefix?: string, options?: {
limit?: number;
offset?: number;
orderBy?: string;
}): Promise<{ items: DataEntry[]; total: number }>;
};
// === 파일 스토리지 ===
// permission: storage.write
storage: {
upload(file: File | Blob, path: string): Promise<{
url: string;
path: string;
size: number;
}>;
getUrl(path: string): Promise<string>;
delete(path: string): Promise<void>;
list(prefix?: string): Promise<StorageItem[]>;
};
// === 알림 ===
// permission: notifications.send
notifications: {
send(options: {
title: string;
body: string;
targetUids?: string[]; // 비면 전체 가족
data?: Record<string, string>;
}): Promise<void>;
};
// === 액티비티 피드 ===
// permission: activity.write
activity: {
post(event: {
eventType: string;
title: string;
summary: string;
entityRef?: { entityType: string; entityId: string };
payload?: Record<string, any>;
}): Promise<void>;
};
// === UI 연동 ===
// permission: 없음
ui: {
close(): void;
setTitle(title: string): void;
showToast(message: string, type?: 'info' | 'success' | 'error'): void;
showConfirm(options: {
title: string;
message: string;
confirmText?: string;
cancelText?: string;
}): Promise<boolean>;
// permission: channel.share
shareToChannel(data: {
text?: string;
imageUrl?: string;
linkUrl?: string;
linkTitle?: string;
}): Promise<void>;
};
// === 설정 ===
// permission: 없음 — 자기 모듈 설정만
settings: {
get(): Promise<Record<string, any>>;
update(partial: Record<string, any>): Promise<void>;
};
// === 이벤트 리스너 ===
on(event: 'resume' | 'pause' | 'themeChange', callback: Function): void;
off(event: string, callback: Function): void;
}
// 글로벌에 자동 노출
declare const membloc: MemblocSDK;
declare const homb: MemblocSDK; // legacy alias
3.3 SDK 배포
Primary artifact: `membloc-sdk.js`
Legacy alias: `membloc-sdk.js`
npm primary: `@membloc/module-sdk`
npm legacy alias: `@homb/module-sdk`
4. Flutter 구현
4.1 ModuleWebViewScreen
// features/modules/module_webview_screen.dart
class ModuleWebViewScreen extends StatefulWidget {
final String moduleKey;
final String entryUrl;
final String familyId;
final List<String> grantedPermissions;
}
핵심 동작:
- InAppWebView로
entryUrl로드 - 페이지 로드 완료 시 bridge 스크립트 주입 (네이티브 측 핸들러)
- JS → Dart
postMessage수신 →JsBridgeController라우팅 - 결과를
evaluateJavascript로 콜백
4.2 JsBridgeController
// core/modules/js_bridge_controller.dart
class JsBridgeController {
final String moduleKey;
final String familyId;
final List<String> grantedPermissions;
final ModuleRuntimeService runtimeService;
// postMessage 수신 시 호출
Future<BridgeResponse> handleRequest(BridgeRequest request) async {
// 1. permission 체크
final requiredPermission = _permissionFor(request.method);
if (requiredPermission != null &&
!grantedPermissions.contains(requiredPermission)) {
return BridgeResponse.error('PERMISSION_DENIED', '...');
}
// 2. 메서드 라우팅
return switch (request.method) {
'auth.getUserInfo' => _handleGetUserInfo(),
'auth.getAccessToken'=> _handleGetAccessToken(),
'family.getCurrent' => _handleGetFamily(),
'family.getMembers' => _handleGetMembers(),
'data.get' => _handleDataGet(request.params),
'data.set' => _handleDataSet(request.params),
'data.delete' => _handleDataDelete(request.params),
'data.list' => _handleDataList(request.params),
'ui.close' => _handleClose(),
'ui.setTitle' => _handleSetTitle(request.params),
'ui.showToast' => _handleShowToast(request.params),
'ui.shareToChannel' => _handleShareToChannel(request.params),
'activity.post' => _handleActivityPost(request.params),
'notifications.send' => _handleNotificationSend(request.params),
_ => BridgeResponse.error('UNKNOWN_METHOD', '...'),
};
}
String? _permissionFor(String method) {
return switch (method) {
'family.getCurrent' => 'family.read',
'family.getMembers' => 'members.read',
'storage.upload' => 'storage.write',
'storage.delete' => 'storage.write',
'notifications.send'=> 'notifications.send',
'activity.post' => 'activity.write',
'ui.shareToChannel' => 'channel.share',
_ => null, // auth, data, settings, ui.basic — 항상 허용
};
}
}
4.3 권한 동의 다이얼로그
// features/modules/permission_consent_dialog.dart
class PermissionConsentDialog extends StatelessWidget {
final RemoteModuleDescriptor module;
final List<String> requestedPermissions;
final VoidCallback onAccept;
final VoidCallback onDecline;
}
표시 형태:
┌─────────────────────────────────────┐
│ [아이콘] 가계부 │
│ Example Corp │
│ │
│ 이 모듈은 다음 권한을 요청합니다: │
│ │
│ ✅ 가족 정보 읽기 │
│ ✅ 가족 멤버 목록 읽기 │
│ ✅ 알림 전송 │
│ ✅ 채널에 공유 │
│ │
│ 데이터는 이 모듈 내에서만 │
│ 사용되며, 다른 모듈과 공유되지 │
│ 않습니다. │
│ │
│ [거절] [동의 및 설치] │
└─────────────────────────────────────┘
권한 설명 매핑:
const permissionLabels = {
'family.read': '가족 정보 읽기',
'members.read': '가족 멤버 목록 읽기',
'members.profile': '멤버 프로필 상세 보기',
'storage.read': '파일 읽기',
'storage.write': '파일 업로드 및 삭제',
'notifications.send': '알림 전송',
'activity.write': '활동 피드에 게시',
'calendar.read': '캘린더 일정 읽기',
'calendar.write': '캘린더 일정 추가/수정',
'channel.share': '채널에 콘텐츠 공유',
};
5. 백엔드 — Module Runtime API
5.1 새로운 파일 구조
internal/
├── handler/
│ └── runtime.go # [신규] 모듈 런타임 API
├── model/
│ └── module_data.go # [신규] 모듈 데이터 모델
├── repository/
│ └── module_data.go # [신규] module_data CRUD
├── service/
│ └── module_runtime.go # [신규] 런타임 비즈니스 로직
├── auth/
│ └── module_token.go # [신규] 모듈용 scoped token 발급/검증
5.2 데이터베이스 마이그레이션
migrations/004_module_data.up.sql
-- 모듈 데이터 (sandboxed key-value store)
CREATE TABLE module_data (
id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
module_key TEXT NOT NULL,
family_id TEXT NOT NULL,
data_key TEXT NOT NULL,
data_value JSONB NOT NULL DEFAULT '{}',
created_by TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE(module_key, family_id, data_key)
);
CREATE INDEX idx_module_data_lookup ON module_data(module_key, family_id);
CREATE INDEX idx_module_data_key_prefix ON module_data(module_key, family_id, data_key text_pattern_ops);
-- 모듈 API 키 (Server Module용, Phase 3에서 본격 사용)
CREATE TABLE module_api_keys (
id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
module_key TEXT NOT NULL,
key_hash TEXT NOT NULL,
name TEXT NOT NULL DEFAULT 'default',
permissions TEXT[] NOT NULL DEFAULT '{}',
last_used_at TIMESTAMPTZ,
expires_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_api_keys_module ON module_api_keys(module_key);
CREATE INDEX idx_api_keys_hash ON module_api_keys(key_hash);
5.3 Runtime API 엔드포인트
# 인증: Bearer <module-scoped-token>
# module-scoped-token: Firebase UID + moduleKey + familyId + permissions 포함
# 모듈 데이터
GET /api/runtime/:moduleKey/families/:fid/data
?prefix=expenses&limit=50&offset=0&orderBy=created_at
POST /api/runtime/:moduleKey/families/:fid/data
Body: { "key": "expense_001", "value": { ... } }
GET /api/runtime/:moduleKey/families/:fid/data/:key
PUT /api/runtime/:moduleKey/families/:fid/data/:key
Body: { "value": { ... } }
DELETE /api/runtime/:moduleKey/families/:fid/data/:key
# 액티비티
POST /api/runtime/:moduleKey/families/:fid/activity
Body: { "eventType": "...", "title": "...", ... }
# 알림
POST /api/runtime/:moduleKey/families/:fid/notifications
Body: { "title": "...", "body": "...", "targetUids": [...] }
5.4 Module Scoped Token
// internal/auth/module_token.go
// Flutter 앱이 WebView 모듈에 발급하는 단기 토큰
// 모듈은 이 토큰으로 Runtime API를 호출
type ModuleTokenClaims struct {
UID string `json:"uid"`
ModuleKey string `json:"moduleKey"`
FamilyID string `json:"familyId"`
Permissions []string `json:"permissions"`
jwt.RegisteredClaims
}
// 발급: Flutter → membloc-app-engine /api/auth/module-token
// 유효기간: 1시간
// 갱신: homb.auth.getAccessToken() 호출 시 Flutter가 재발급
5.5 Runtime 미들웨어
// 모든 /api/runtime/* 요청에 적용
func ModuleRuntimeMiddleware() echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
// 1. module-scoped-token 검증
// 2. URL의 :moduleKey와 토큰의 moduleKey 일치 확인
// 3. URL의 :fid와 토큰의 familyId 일치 확인
// 4. 요청에 필요한 permission이 토큰에 포함되어 있는지 확인
// → 통과하면 context에 claims 저장
}
}
}
6. 보안
6.1 WebView 격리
| 항목 | 대책 |
|---|---|
| URL 제한 | manifest의 entry_url 도메인만 로드 허용 |
| JS Bridge | window.membloc 객체를 우선 사용하고, window.homb는 legacy alias로 유지 |
| 쿠키/스토리지 | 모듈별 독립 (WebView 인스턴스 분리) |
| CSP 헤더 | 모듈 HTML에 Content-Security-Policy 권장 |
| 외부 요청 | 모듈 자체 서버 + homb Runtime API만 허용 |
6.2 데이터 격리
module_data 테이블:
- WHERE module_key = :moduleKey AND family_id = :familyId
- 다른 모듈의 데이터 접근 불가 (토큰에 moduleKey 바인딩)
Storage:
- /modules/{moduleKey}/families/{familyId}/ 경로로 격리
- 다른 모듈 경로 접근 시 403
6.3 Rate Limiting
// 모듈별 API 호출 한도
var rateLimits = map[string]int{
"free": 1000, // 1000 calls/day
"paid": 10000, // 10000 calls/day
"premium": 100000, // 100000 calls/day
}
// Redis 또는 인메모리 카운터로 관리
// 한도 초과 시 429 Too Many Requests
7. 샘플 WebView 모듈 — 가계부
dogfooding 목적으로 직접 개발. 실제 외부 모듈과 동일한 방식으로 구현.
7.1 스펙
{
"module_key": "com.homb.budget-sample",
"display_name": "가계부 (샘플)",
"type": "webview",
"entry_url": "https://modules.homb.app/budget-sample/",
"permissions": ["family.read", "members.read", "notifications.send"],
"supported_entity_types": ["transaction"]
}
7.2 기능
- 지출/수입 기록 (homb.data API로 저장)
- 가족 멤버별 지출 현황
- 월별 요약 → 액티비티 피드에 자동 게시
- 예산 초과 시 → 알림 전송
7.3 기술 스택
React (or Vue) + TypeScript
@membloc/module-sdk
Vite 빌드 → 정적 호스팅 (Cloudflare Pages 또는 homb CDN)
7.4 프로젝트 구조
homb-module-budget-sample/
├── src/
│ ├── App.tsx
│ ├── api/homb.ts # SDK 래퍼
│ ├── pages/
│ │ ├── Dashboard.tsx # 월별 요약
│ │ ├── AddTransaction.tsx
│ │ └── History.tsx
│ └── components/
├── public/
│ └── index.html
├── homb-module.json # manifest
├── package.json
└── vite.config.ts
8. 작업 목록
8.1 JS Bridge SDK
| # | 작업 | 예상 |
|---|---|---|
| S1 | membloc-sdk.js 코어 구현 (postMessage 프로토콜) | 1d |
| S2 | auth, family API 구현 | 0.5d |
| S3 | data API 구현 | 1d |
| S4 | storage API 구현 | 1d |
| S5 | ui API 구현 (close, toast, confirm, share) | 0.5d |
| S6 | activity, notifications API 구현 | 0.5d |
| S7 | TypeScript 타입 정의 + npm 패키지화 | 0.5d |
| S8 | SDK 문서 (README + 예제 코드) | 0.5d |
8.2 Flutter
| # | 작업 | 의존성 | 예상 |
|---|---|---|---|
| F1 | flutter_inappwebview 패키지 추가 + 기본 설정 | - | 0.5d |
| F2 | ModuleWebViewScreen — WebView 컨테이너 | F1 | 1d |
| F3 | JsBridgeController — postMessage 핸들러 | F2, S1 | 2d |
| F4 | Bridge 네이티브 주입 (homb-bridge.js inject) | F3 | 0.5d |
| F5 | PermissionConsentDialog | - | 0.5d |
| F6 | ModuleLauncher 확장 — webview 타입 분기 | F2, F5 | 0.5d |
| F7 | Module scoped token 요청 로직 | - | 0.5d |
| F8 | WebView ↔ 네이티브 UI 연동 (setTitle, close, toast) | F3 | 0.5d |
| F9 | shareToChannel 브릿지 연동 | F3 | 0.5d |
8.3 백엔드
| # | 작업 | 의존성 | 예상 |
|---|---|---|---|
| B1 | 004_module_data.up.sql 마이그레이션 | - | 0.5d |
| B2 | model/module_data.go | - | 0.5d |
| B3 | repository/module_data.go — CRUD + prefix 검색 | B1 | 1d |
| B4 | auth/module_token.go — scoped token 발급/검증 | - | 1d |
| B5 | service/module_runtime.go — 런타임 비즈니스 로직 | B3, B4 | 1d |
| B6 | handler/runtime.go — REST 엔드포인트 | B5 | 1d |
| B7 | Runtime 미들웨어 (토큰 검증 + 권한 체크) | B4 | 0.5d |
| B8 | Rate limiting (인메모리 → 향후 Redis) | - | 0.5d |
| B9 | 액티비티 발행 → Firestore 동기화 | B5 | 0.5d |
| B10 | 알림 발행 → Firebase Cloud Messaging 연동 | B5 | 1d |
| B11 | /api/auth/module-token 엔드포인트 | B4 | 0.5d |
8.4 샘플 모듈
| # | 작업 | 의존성 | 예상 |
|---|---|---|---|
| M1 | 프로젝트 scaffolding (React + Vite + TypeScript) | S7 | 0.5d |
| M2 | Dashboard (월별 요약) | M1 | 1d |
| M3 | AddTransaction (지출/수입 기록) | M1 | 1d |
| M4 | History (내역 조회) | M1 | 0.5d |
| M5 | 알림 연동 (예산 초과) | M1 | 0.5d |
| M6 | 빌드 + 정적 호스팅 배포 | M1 | 0.5d |
| M7 | manifest 등록 + 마켓플레이스에 publish | M6 | 0.5d |
9. 완료 기준
- WebView 모듈이 homb 앱 내에서 정상 로드/실행
- JS Bridge를 통해
getUserInfo,getCurrent,data.*동작 - 모듈 데이터가
module_data테이블에 격리 저장 - 권한 동의 다이얼로그 표시 후 설치
- 허가되지 않은 API 호출 시
PERMISSION_DENIED반환 - 샘플 가계부 모듈이 지출 기록 → 조회 → 알림 전체 플로우 동작
- Rate limit 초과 시 429 응답
- 다른 모듈의 데이터 접근 시도 시 차단 확인