테마
시작하기 — 빈 디렉터리에서 포탈에 등록되는 업무패키지까지
이 문서에서 BP는 업무패키지(Business Package)를, Portal Shell(포탈 셸) 은 사용자가 로그인해 메뉴와 업무 화면을 이용하는 프론트엔드 애플리케이션을 뜻한다. 용어 전체는 index.md §용어 참고.
포탈 소스를 체크아웃하거나 포탈 인스턴스를 구동할 필요는 없다.
이 문서는 코드를 옮겨 적지 않는다. 완결된 코드는 SDK 저장소의 examples/task-bp/에 있고, 그 디렉터리는 빌드·타입체크·배포 전 점검·마이그레이션 실적용·단독 스모크가 전부 실행되는 상태로 유지된다. 아래 각 단계는 무엇을 왜 그렇게 하는지와 예제의 어느 파일이 정본인지를 지시한다.
bash
# 예제를 그대로 실행해 보는 것이 가장 빠른 시작이다
cd examples/task-bp
pnpm install # 배포 전 점검과 패키지 단계가 쓰는 starj CLI(@starj-portal/cli)도 함께 받는다
pnpm run verify # 빌드 → 타입체크 → 배포 전 점검 → 마이그레이션 실적용 → 패키지 아카이브 → 아카이브 검증 → 단독 스모크0. CLI로 시작하기
새 BP는 starj CLI(@starj-portal/cli)의 starj bp create로 만드는 것이 기본 경로다. 아래 §1~의 수동 단계(레지스트리, 빌드 허용 목록, 버전 고정, tsconfig.json, 빌드·점검 스크립트)를 CLI가 모두 처리한다. 실행부터 포탈에서 화면을 확인하기까지의 전체 절차는 업무패키지 생성 안내에 있다.
bash
npx --registry https://npm.mdpert.com/ @starj-portal/cli bp create| 질문 | 정해지는 것 |
|---|---|
| BP id | 매니페스트 id, 라우트 접두어 bp/{id}, 물리 테이블 접두어 bp_{id}_. 배포한 뒤에는 바꾸지 않는다 |
| BP 이름 | 매니페스트 name |
| App 키와 이름(여러 개) | apps[].id({id}:{키})와 apps[].uri({id}/{키}), 메뉴 항목, 화면 제목 |
| 메뉴 폴더 이름 | 메뉴 루트 항목 |
| 기본 권한 역할 | 조회자(rw: 1)와 편집자(rw: 3), 또는 편집자만. 모든 App이 역할에 들어간다 |
| 자체 테이블 사용 | 예시 테이블 bp_{id}_note와 두 방언의 첫 마이그레이션 |
만든 직후 pnpm install과 pnpm run verify를 실행한다. verify가 쓰는 starj CLI는 devDependencies로 함께 받는다. 통과하면 dist-package/<id>-<version>.tgz를 업무패키지 생성 안내 §5 또는 install-to-portal.md의 절차대로 설치하고, 관리자가 도메인 활성화와 권한 부여를 마치면 메뉴의 App에 제목과 로그인 사용자가 표시된다. 그 화면은 백엔드 GET /bp/{id}/hello를 불러 포탈의 App 권한 판정까지 거친 결과다.
생성된 프로젝트의 스크립트는 정본 예제의 것을 범용으로 옮긴 것이다. 카드·알림 카테고리·onInstall처럼 쓰지 않는 기능은 선언했을 때만 검사한다. 아래 §1부터는 CLI가 처리한 각 설정이 왜 필요한지 설명하므로, CLI로 시작했더라도 한 번은 읽는다.
1. 프로젝트 준비
1-1. 패키지 초기화
pnpm init이 만드는 package.json에는 devEngines.packageManager.version 필드가 들어가는데, pnpm 11은 그 값을 semver로 해석하지 못해 이후 pnpm add가 Invalid package manager specification으로 중단된다. 이 필드를 삭제한 뒤 진행한다. 예제의 package.json을 복사해 시작하면 이 단계를 건너뛸 수 있다.
1-2. 레지스트리
.npmrc에 레지스트리를 지정한다. 계정도 토큰도 필요 없다 — 이 스코프는 익명 설치로 열려 있다.
ini
@starj-portal:registry=https://npm.mdpert.com/@starj-portal 스코프만 이 레지스트리를 보고, 나머지 의존성은 npmjs에서 그대로 받는다.
1-3. 네이티브 의존성 빌드 허용
esbuild·better-sqlite3·prisma는 설치 스크립트를 실행해야 한다. pnpm 11은 이를 기본 차단하고 ERR_PNPM_IGNORED_BUILDS로 pnpm install을 exit 1로 종료시키며, 그러면 이후 pnpm run <스크립트>가 전부 실패한다. 워크스페이스 설정에 허용 목록을 선언한다 — 정본은 examples/task-bp/pnpm-workspace.yaml이다.
yaml
allowBuilds:
"@prisma/engines": true
better-sqlite3: true
esbuild: true
prisma: true1-4. 설치할 의존성
bash
# @starj-portal 스코프
pnpm add @starj-portal/bp-frontend-sdk # 화면이 있는 BP만
pnpm add @starj-portal/bp-ui-kit # 선택. 화면을 포탈 관리 화면과 같은 모양으로 그린다(프론트엔드 가이드 §12)
pnpm add -D @starj-portal/bp-backend-sdk
pnpm add -D @starj-portal/cli # 배포 전 점검과 패키지 아카이브를 만드는 starj CLI
# 포탈이 제공하는 모듈 — 캐럿 없이 정확히 고정한다(아래 설명)
pnpm add @nestjs/common@11.1.28 @nestjs/core@11.1.28 reflect-metadata@0.2.2
# DB를 쓰는 BP만
pnpm add @prisma/client@7.8.0 @prisma/adapter-better-sqlite3@7.8.0 @prisma/adapter-pg@7.8.0
pnpm add -D prisma@7.8.0 better-sqlite3
# 빌드 도구와 타입
pnpm add react@^19 react-dom@^19
pnpm add -D typescript@^5.9 esbuild @types/react @types/react-dom @types/node세 가지를 반드시 지킨다.
typescript는^5.9로 고정한다. 빌드가 쓰는ts.transpileModule/ts.ScriptTargetAPI는 TypeScript 5.x의 것이고 TypeScript 7에는 존재하지 않는다. 버전을 고정하지 않으면 오늘pnpm add -D typescript는 7.x를 설치하고, 빌드가Cannot read properties of undefined (reading 'ES2023')으로 중단된다. 이 오류 메시지는 원인을 지시하지 않는다.@nestjs/*·reflect-metadata·@prisma/*는 번들에 포함되지 않는데도 설치해야 한다. 빌드가 이 모듈들을 externals로 제외하고 실행 시에는 포탈 것이 사용되지만, (1) 타입체크가 이 모듈들의 타입 선언을 참조해야 하고, (2) 단독 스모크와 배포 전 점검이 번들을 실제로 적재해 컨트롤러를 실행하기 때문이다. 버전은 SDK의HOST_PROVIDED_VERSIONS와 캐럿 없이 일치해야 한다 — 이유는 host-contract.md §3.react는 19가 필요하다.@starj-portal/bp-frontend-sdk의 peer 범위가^19.0.0이다. 18로 고정하면 설치 시 경고만 표시되고 빌드는 통과하므로, 어긋난 것이 실행 시점까지 드러나지 않는다.
HOST_PROVIDED_MODULES에는 better-sqlite3와 pg도 있으나 HOST_PROVIDED_VERSIONS에는 두 모듈의 값이 공표되어 있지 않다. 이 둘은 BP가 직접 import하지 않고 Prisma 어댑터가 사용하므로, BP의 dependencies가 아니라 로컬 실행용 devDependencies에 둔다.
1-5. tsconfig.json
정본은 examples/task-bp/tsconfig.json이다. 반드시 들어가야 하는 두 줄은 이것이다.
jsonc
"experimentalDecorators": true,
"emitDecoratorMetadata": trueNestJS의 의존성 주입은 생성자 타입을 design:paramtypes 메타데이터로 읽는다. 이 두 줄이 없으면 컴파일은 통과하고 실행 시 주입만 오류 메시지 없이 실패한다. 같은 설정이 빌드 스크립트의 트랜스파일 옵션에도 있어야 한다 — 번들러는 tsconfig.json을 참조하지 않는다.
1-6. CSS 앰비언트 선언
프론트 번들은 CSS를 문자열로 받아 shadow root에 삽입한다. SDK가 이 앰비언트 선언을 배포하지 않으므로 BP가 직접 둔다(정본: examples/task-bp/types/css.d.ts). 없으면 타입체크가 TS2307로 중단된다.
ts
declare module "*.css" {
const css: string;
export default css;
}import 경로에 ?inline을 붙이지 않는다. ?inline은 Vite 계열 문법이고, 이 문서가 제시하는 esbuild 빌드에서는 widget.css?inline이라는 이름의 파일을 탐색하다가 해석에 실패한다.
2. 계약 버전과 제공 버전 확인
확인할 값이 두 가지이고 확인 수단도 두 가지다.
bash
# (1) 내가 설치한 SDK가 공표하는 값
node -e "import('@starj-portal/bp-backend-sdk').then(m => console.log(m.HOST_CONTRACT_VERSION, m.HOST_PROVIDED_VERSIONS))"# (2) 대상 포탈이 제공하는 값 — 포탈에 로그인한 상태로 조회한다
GET https://<포탈 주소>/bp/runtime-infoGET /bp/runtime-info는 업무패키지 관리 권한을 요구한다(권한이 없으면 운영자에게 조회를 요청한다). 응답에 포탈이 제공하는 hostContractVersion, 운영 중인 databaseProvider, 적재된 BP 목록(loaded[]), 건너뛴 BP와 그 사유(skipped[]) 가 포함된다. 배포 전에는 (1)과 (2)의 계약 버전을 대조하고, 배포 후에는 내 BP가 loaded[]에 있는지 확인한다 — 상세는 host-contract.md §5.
두 값이 어긋나면 그 BP만 부팅 시 적재 대상에서 제외되며, 그 사유는 위 API의 skipped[]에서 직접 확인할 수 있다.
HOST_PROVIDED_VERSIONS의 값들은 package.json의 dependencies에 캐럿 없이 고정한다(§1-4).
3. 백엔드 — register() 하나
정본: examples/task-bp/backend/src/register.ts.
register()가 반환하는 것은 네 가지다 — 능력 표면(manifest), 컨트롤러 목록, DI 프로바이더 목록, 라이프사이클 훅. 골격은 이렇다.
ts
export function register(host: BpHostRuntime): BpRegistration {
const runtime = { host, repo: createRepo(host) };
return {
manifest: { apps: [...], menu: [...], permissions: [...], defaultAuthorities: [...], frontendDeliveryMode: "csr-widget" },
controllers: [TaskController],
providers: [{ provide: TASK_RUNTIME, useValue: runtime }],
hooks: { async onInstall(ctx) { /* upsert 계열만 사용한다 */ } },
};
}여기서 확정되는 계약은 다섯 가지다. 상세는 backend-guide.md.
- 진입점은
register하나다. 데코레이터로 BP를 선언하지 않는다. - 권한 판정은
host.checkAppAccess()에 질의한다. BP가 자체적으로 판정하지 않는다. action은"read" | "update"둘뿐이다. - 업무 화면(
apps)과 메뉴 배치(menu)는 분리된 목록이다. defaultAuthorities[].apps[].rw는 비트마스크다. 읽기 전용1, 읽기+쓰기3.2는 사용하지 않는다 — 이유는 backend-guide.md §4.onInstall은 여러 번 실행해도 결과가 같아야 한다(실패하면 다음 기동에서 다시 실행된다) — 이유는 backend-guide.md §8.host에는 계약 1.1.0에서 추가된 포탈 자원 접근 표면 7종이 있다 — 알림(notifications) / 공통코드(codes) / 사용자 조회(users) / 외부 호출(destinations) / BP 간 연계(services) / 감사로그(audit) / 첨부 파일(files). 전부 요청 처리 시점에만 호출한다(register()실행 중에는 아직 사용할 수 없다). 각각 무엇을 할 수 있고 무엇은 할 수 없는지는 backend-guide.md §13~§19에 있다. 이 표면을 사용하면 매니페스트에runtime.hostContract: "^1.1.0"을 선언하고,files.claim()을 사용하면"^1.2.0"을 선언한다(1.2.0에서 그 오퍼레이션의 두 번째 인자가 요청 객체로 바뀌었다). 서버가 만든 파일을files.create()로 저장하면"^1.3.0"을 선언한다.
4. 라우트 접두어 — BP가 직접 선언한다
포탈은 BP 컨트롤러에 경로 접두어를 자동으로 추가하지 않는다. 포탈에 전역 접두어 설정(setGlobalPrefix)이 없고, BP 컨트롤러는 포탈과 같은 애플리케이션의 루트에 그대로 등록된다.
따라서 @Controller() 경로에 bp/{businessPackageId}/를 직접 써야 한다.
ts
@Controller("bp/task-bp/tasks") // ← /bp/task-bp/tasks
export class TaskController { /* … */ }@Controller("tasks")로 선언하면 그 라우트는 /tasks가 되어 포탈 코어의 라우트 이름 공간에 등록된다. 이름이 겹치면 어느 쪽이 우선하는지 보장되지 않고, 실패는 예외가 아니라 잘못된 응답으로 나타난다. 실제로 운영 중인 업무패키지 하나가 @Controller("health")로 선언되어 포탈의 /health와 겹쳐 있다.
프론트엔드는 이 경로를 하드코딩하지 않고 host.apiBasePath(값이 /bp/{businessPackageId}다) 뒤에 자기 경로만 연결한다 — frontend-guide.md §2.
정본:
examples/task-bp/backend/src/ids.ts가API_PREFIX =bp/${BP_ID}``를 한 곳에서 합성하고, 컨트롤러와 카드의dataPath가 모두 그 상수에서 파생된다. 접두어를 파일마다 손으로 적으면 언젠가 한 곳만 어긋난다. 예제의preflight.mjs는 모든@Controller경로가bp/{businessPackageId}로 시작하는지 검사한다.
5. 프론트엔드 — Portal Shell 안에 마운트되는 위젯
정본: examples/task-bp/frontend/src/index.tsx.
tsx
const mount: BpMountFn = (container, host) => {
// 같은 container 에 두 번 마운트될 수 있다. attachShadow 를 무조건 호출하면
// 두 번째 호출에서 NotSupportedError 가 발생하고 화면이 비어 있는 상태로 남는다.
const shadow = container.shadowRoot ?? container.attachShadow({ mode: "open" });
shadow.replaceChildren();
// … style, root 생성 …
return () => { root.unmount(); shadow.replaceChildren(); }; // 정리 함수는 필수다
};
window.__BP_WIDGETS__ = { ...window.__BP_WIDGETS__, "task-bp": { mount } };세 가지가 계약이다.
- 전역 키는
bp-manifest.yaml의id와 정확히 같아야 한다. - Shadow DOM 경계를 만든다 — frontend-guide.md §4.
- 정리 함수 반환은 필수다. Portal Shell의 대체 정리는
container.innerHTML = ""이고, 그것은 shadow root 안을 제거하지 못한다.
6. 매니페스트
정본: examples/task-bp/bp-manifest.yaml. 전체 필드는 bp-manifest.md.
runtime.hostContract는 실제로 사용하는 표면에 맞춘다. 1.0.0의 표면만 사용한다면 "^1.0.0"이고, 계약 1.1.0에서 추가된 표면(host.notifications / codes / users / destinations / services / audit / files, 매니페스트의 notificationCategories[], 위젯의 viewer / canWrite와 호스트 액션 4종)을 하나라도 사용한다면 "^1.1.0", host.files.claim()을 사용한다면 "^1.2.0", host.files.create()나 설치 컨텍스트의 onExisting을 사용한다면 "^1.3.0"이다 — bp-manifest.md §runtime. 아래 스니펫은 정본 예제의 매니페스트이며, 그 예제가 1.2.0에서 인자가 바뀐 files.claim()을 사용하므로 "^1.2.0"을 선언한다.
yaml
apiVersion: 1
id: task-bp
name: 일감 관리
version: 1.0.0
runtime:
hostContract: "^1.2.0"
backend:
entry: backend/index.mjs
database:
providers: [sqlite, postgresql]
schemaDir: backend/prisma
frontend:
mode: csr-widget
bundle: frontend/bundle.jsprisma.config.ts를 산출물에 포함하지 않는다. Prisma 7은 schema.prisma에 datasource.url을 쓰는 것을 금지하고 마이그레이션 명령이 config 파일의 datasource.url을 요구하는데, 그 접속 정보는 포탈이 소유한다. 포탈은 자기 config 파일 하나를 재사용하며 BP마다 스키마·마이그레이션 경로만 환경변수로 전달한다. BP 쪽의 config 파일은 로컬에서 마이그레이션 SQL을 생성할 때만 쓰고 dist/에 넣지 않는다 — backend-guide.md §7.
7. 빌드
빌드 스크립트 전문은 examples/task-bp/scripts/build.mjs다. 그 한 파일이 아래 7단계를 순서대로 수행한다.
| 단계 | 하는 일 | 왜 이 순서인가 |
|---|---|---|
| 0 | 호스트 제공 모듈 버전 정합 검사, TypeScript API 존재 확인 | 어긋난 채 진행하면 실행 시점에만 드러난다 |
| 1 | 정본 schema.prisma → 방언별 스키마 파생 | 스키마를 방언마다 수동 관리하면 반드시 어긋난다 |
| 2 | 방언별 스키마 검증 + Prisma 클라이언트 생성 | 백엔드 번들이 이 생성 산출물을 import한다 |
| 3 | 마이그레이션 존재·비어 있지 않음 확인 | 0바이트 migration.sql은 마이그레이션 생성의 무음 실패 흔적이다 |
| 4 | bp-manifest.yaml 생성(타입 검사됨) | 오탈자를 컴파일 단계에서 검출한다 |
| 5 | 백엔드 번들(ESM, externals = 호스트 제공 모듈) | |
| 6 | 프론트 번들(IIFE, 자기 React 포함) | |
| 7 | 매니페스트·방언별 스키마/마이그레이션을 dist/로 복사 | 매니페스트가 가리키는 경로와 일치해야 한다 |
bash
node scripts/build.mjs타입체크는 빌드보다 먼저 실행할 수 없다. BP 코드가 backend/generated/<provider>/client.js를 import하는데 그 파일은 빌드 2단계의 prisma generate가 생성한다. 새로 받은 저장소에서 타입체크를 먼저 실행하면 TS2307로 중단된다. 순서는 build → typecheck다.
결과:
dist/
bp-manifest.yaml
backend/index.mjs
backend/prisma/{sqlite,postgresql}/{schema.prisma, migrations/}
frontend/bundle.js8. 배포 전 자체 점검
포탈은 문제가 있는 산출물을 오류 메시지 없이 적재 대상에서 제외한다. 그래서 이 점검이 사실상 유일한 조기 경보다.
정본은 examples/task-bp/scripts/preflight.mjs다. 매니페스트 스키마, 매니페스트가 가리키는 경로의 실재, 방언별 스키마·마이그레이션 배치, 테이블 이름 접두어, 호스트 제공 모듈의 번들 혼입, design:paramtypes 메타데이터, register() 실행 결과의 능력 표면 정합, rw 값 범위, onInstall을 두 번 실행해도 결과가 같은지, 위젯 전역 등록과 재마운트까지 한 번에 검사한다. 포탈 반입 검증의 V7·V8(방언별 마이그레이션 폴더, 마이그레이션 SQL 허용 목록)은 starj CLI로 판정을 받는다.
bash
node scripts/preflight.mjs # 항목 수는 실행 결과 마지막 줄에 표시된다점검이 통과했다는 사실만으로는 아무것도 보증되지 않는다. 망가진 산출물도 함께 통과한다면 그 점검은 없는 것과 같다. 그래서 예제는 고의로 망가뜨린 산출물 4종을 만드는 스크립트를 함께 두고, 점검이 그것들을 실제로 검출하는지 확인한다.
bash
node scripts/build.mjs && node scripts/break-artifacts.mjs
for d in dist-broken/*; do node scripts/preflight.mjs "$d"; done # 전부 실패해야 정상이다이전 판의 문서가 제시하던 grep -c "class BadRequestException" dist/backend/index.mjs 한 줄은 위 고의 파손 4종 중 어느 것도 검출하지 못한다. 사용하지 않는다.
9. 포탈에 전달하기
포탈에는 dist/를 서명해 묶은 패키지 아카이브를 설치한다. 아카이브는 devDependencies로 받은 starj CLI(@starj-portal/cli)가 만든다. 다른 CLI 파일로 만들려면 그 경로를 STARJ_CLI에 지정한다(build-and-package.md §7-1).
bash
STARJ_SIGN_KEY=<개인 키 경로> pnpm run package # dist-package/task-bp-1.0.0.tgz
STARJ_TRUSTED_KEY=<공개 키 경로> pnpm run verify:package서명 키를 만들고 보관하는 방법, 키 없이 만드는 개발용 아카이브의 제약은 build-and-package.md §7에 있다. 이 파일을 포탈 운영자에게 전달하거나, 개발 포탈에는 pnpm exec starj package add --approve로 직접 설치한다. 설치 후 관리자가 도메인에서 활성화하고 사용자에게 권한을 부여해야 화면이 열린다 — install-to-portal.md.
다음에 읽을 것
| 목적 | 문서 |
|---|---|
| 포탈 없이 화면과 API를 함께 실행하며 사용자·권한별로 점검 | dev-shell.md |
| 포탈과 BP의 책임 분리선(경계)에서 지켜야 하는 규정 확인 | host-contract.md |
| DB 스키마·마이그레이션·트랜잭션·테이블 이름 규약 | backend-guide.md §6~§7 |
| 홈 인사이트 카드 선언과 데이터 응답 형태 | backend-guide.md §5 |
| 업무 화면 여러 개, 테마 토큰, Portal Shell 오퍼레이션 | frontend-guide.md |
| 동작하지 않으나 오류 메시지도 표시되지 않음 | troubleshooting.md |