테마
빌드와 패키징
업무패키지(Business Package, 이하 BP) 저장소가 산출하는 것은 두 가지다. 빌드가 만드는 디렉터리 하나(dist/)와, 그 디렉터리를 서명해 묶은 패키지 아카이브 하나(.tgz)다. 포탈에는 아카이브를 설치한다. 포탈은 아카이브를 검증하고, 관리자가 승인한 뒤에만 코드를 적재한다(install-to-portal.md).
빌드 스크립트 전문은 examples/task-bp/scripts/build.mjs다. 이 문서는 그 스크립트를 옮겨 적지 않고 각 단계가 왜 필요한지와 어긋났을 때 무엇이 관측되는지를 기술한다. 새 BP는 그 파일을 복사해 시작하는 것을 권장한다.
1. 산출물 배치
dist/ ← 이 디렉터리 전체를 아카이브로 묶는다(§7)
bp-manifest.yaml
backend/index.mjs ← register()를 export하는 ESM 번들
backend/prisma/sqlite/{schema.prisma, migrations/}
backend/prisma/postgresql/{schema.prisma, migrations/}
frontend/bundle.js ← csr-widget 번들(화면이 있는 BP만)경로는 bp-manifest.yaml의 backend.entry / backend.database.schemaDir / frontend.bundle이 가리키는 값과 일치해야 한다.
여기에 포함하지 않는 것을 함께 확정해 둔다.
| 포함하지 않는 것 | 이유 |
|---|---|
prisma.config.ts | 접속 정보는 포탈이 소유한다. BP 저장소의 config 파일은 로컬 마이그레이션 생성 전용이다(backend-guide.md §7-2) |
backend/generated/<provider>/ | 생성된 Prisma 클라이언트는 백엔드 번들에 이미 포함되어 있다 |
node_modules/ | 호스트 제공 모듈은 포탈 것이 사용되고, 나머지는 번들에 포함된다 |
배포되는 schema.prisma의 generator 블록은 사용되지 않는다 — 포탈은 이 스키마로 마이그레이션만 적용한다. 블록이 산출물 밖 경로를 가리키더라도 그 자체로는 문제가 되지 않는다.
2. 백엔드 번들
번들러는 esbuild, 트랜스파일은 TypeScript
esbuild는 emitDecoratorMetadata를 지원하지 않는다(타입 정보가 필요하기 때문이다). 그런데 NestJS의 의존성 주입이 생성자 타입을 design:paramtypes 메타데이터로 읽는다. 누락하면 컴파일은 통과하고 실행 시 주입만 정상 동작하지 않는다.
그래서 .ts는 ts.transpileModule로 먼저 변환하고, 번들링만 esbuild에 위임한다. 핵심은 트랜스파일 옵션의 이 두 줄이다.
js
experimentalDecorators: true,
emitDecoratorMetadata: true, // ← 이 줄이 핵심typescript는 ^5.9로 고정한다. ts.transpileModule / ts.ScriptTarget / ts.ModuleKind는 TypeScript 5.x의 API이며 TypeScript 7에는 존재하지 않는다. 고정하지 않으면 오늘 pnpm add -D typescript는 7.x를 설치하고 빌드가 아래 오류로 중단된다.
✘ [ERROR] Cannot read properties of undefined (reading 'ES2023') [plugin ts-transpile]이 메시지는 원인을 지시하지 않는다. 그래서 예제의 빌드 스크립트는 0단계에서 API 존재 여부를 직접 확인하고, 없으면 원인과 조치를 함께 출력하며 중단한다.
js
if (typeof ts.transpileModule !== "function" || !ts.ScriptTarget) {
fail(`설치된 TypeScript(${ts.version}) 에 transpileModule/ScriptTarget 이 없다. …`);
}esbuild 설정에서 확정된 것
| 설정 | 값 | 이유 |
|---|---|---|
format | "esm" | backend.entry가 ESM 번들이어야 한다 |
platform / target | "node" / "node22" | |
external | [...HOST_PROVIDED_MODULES] | 직접 열거하지 않는다. 하나를 누락하면 그 모듈의 사본이 번들에 포함되고, 사본이 둘이면 예외가 아니라 오작동으로 나타난다 |
minify | false | 배포 전 점검이 esbuild가 남기는 // node_modules/… 주석으로 "호스트 제공 모듈이 번들에 포함되었는가"를 판정한다. minify하면 그 판정이 무력해진다 |
externals와 버전 고정
external에 넣은 모듈은 번들에 포함되지 않고 포탈 것이 사용된다. 그래서 그 모듈들은 HOST_PROVIDED_VERSIONS의 값으로 캐럿 없이 고정해야 한다. 자세한 이유는 host-contract.md §3.
jsonc
"dependencies": { "@prisma/client": "7.8.0", "@nestjs/common": "11.1.28" }정합 검사는 빌드의 0단계가 수행한다(정본: examples/task-bp/scripts/build.mjs). 검사 조건이 중요하다 — 값이 다른 경우뿐 아니라 dependencies에 선언 자체가 없는 경우도 실패로 처리해야 한다. "선언되어 있으면 값을 확인한다" 형태의 검사는 의존성이 누락된 상태를 통과시키는데, 그 상태가 바로 최소 설치만 수행했을 때의 상태다.
런타임 자산 경로 — 번들에서 어긋나는 지점
번들은 모든 소스를 한 파일로 합치므로 import.meta.dirname이 파일별 위치가 아니라 번들 위치 하나가 된다. 파일마다 자기 깊이로 상대경로를 계산해 두면 번들에서 전부 어긋나고, 결과는 부팅 시 ENOENT → 그 BP만 오류 메시지 없이 제외다.
경로 계산을 한 모듈에 모으고, 개발(backend/src → backend/{seed,assets})과 번들(dist/backend → dist/{seed,assets}) 양쪽에 같은 규칙("이 모듈 기준 ..")이 성립하도록 배치한다.
생성된 Prisma 클라이언트도 같은 성격의 코드를 포함한다. 초기화 시점에 globalThis.__dirname을 설정하므로, 두 방언의 클라이언트를 한 번들에 포함하면 양쪽이 모두 실행되어 나중에 초기화된 쪽의 값이 남는다. 방언별 산출물을 두 벌 만드는 BP는 이 사실을 알고 있어야 한다.
3. 방언별 Prisma 산출물
방언마다 쿼리 컴파일러가 다르므로 클라이언트 한 벌로 양쪽을 지원할 수 없다.
정본: examples/task-bp/scripts/lib/providers.mjs(파생)와 examples/task-bp/scripts/make-migration.mjs(마이그레이션 생성).
정본 스키마 하나에서 파생시킨다
스키마를 두 벌 수동 관리하면 반드시 어긋난다. 정본 schema.prisma 하나를 두고 두 값만 치환해 방언별 스키마를 생성한다.
datasource의providergenerator의output경로
backend/prisma-src/schema.prisma 정본 — 수동으로 수정하는 유일한 스키마
backend/prisma/<provider>/schema.prisma 파생 산출물(수정하지 않는다)
backend/generated/<provider>/ 생성된 클라이언트(dist에 넣지 않는다)Prisma 7의 제약
schema.prisma의 datasource에 url을 쓸 수 없다. 쓰면 P1012로 거부된다. 마이그레이션 명령이 요구하는 접속 정보는 prisma.config.ts가 제공하며, 그 파일은 BP 산출물에 포함하지 않는다 — backend-guide.md §7-2.
명령에도 --config를 전달한다. --schema= 방식은 Prisma 7에서 마이그레이션 명령에 필요한 접속 정보를 제공하지 못한다.
bash
prisma validate --config prisma-config/sqlite.config.ts
prisma generate --config prisma-config/sqlite.config.ts두 방언 모두를 검증한다. 방언마다 지원하는 기능이 다르므로(예: SQLite에는 enum이 없다) 한쪽만 확인하면 배포 대상 환경에서 P1012로 드러난다.
마이그레이션 생성의 무음 실패
prisma migrate diff는 인자가 하나라도 누락되면 stderr 출력 없이 exit 0과 빈 출력을 산출한다. 그대로 진행하면 0바이트 migration.sql이 산출물에 포함되고, 포탈에서 테이블이 생성되지 않은 채 BP가 적재된다.
따라서 마이그레이션 생성 스크립트는 결과가 비어 있으면 명시적으로 중단해야 한다. 원인 진단이 필요하면 DEBUG='prisma:*'를 사용한다. 상세한 절차는 backend-guide.md §7-3에 있다.
4. 프론트엔드 번들
자기 React를 포함하는 독립 IIFE로 만든다. Portal Shell(포탈 셸)과 React를 공유하지 않으므로 externals가 없다.
js
await esbuild.build({
entryPoints: ["frontend/src/index.tsx"],
outfile: "dist/frontend/bundle.js",
bundle: true,
format: "iife", // ← ESM이 아니다
target: "es2022",
minify: true,
loader: { ".css": "text" }, // CSS를 문자열로 받아 shadow root에 삽입한다
define: { "process.env.NODE_ENV": '"production"' },
});이 번들은 인증 없이 제공된다. 포탈이 /bp-widgets/{businessPackageId}/bundle.js를 로그인 여부와 무관하게 내려주므로, 번들에 포함한 것은 전부 공개된다고 전제한다 — 자격증명·내부 주소·비공개 업무 규칙을 번들에 포함하지 않는다. 화면에 표시할 실제 데이터는 위젯이 BP API를 호출할 때 세션 인증과 권한 판정을 거친다.
그리고 포탈은 이 파일을 첫 요청에 프로세스 메모리로 캐시한다. 캐시 키는 파일 경로다. 패키지로 설치하면 버전마다 설치 경로가 달라지므로 새 아카이브를 적용하면 새 번들이 제공된다. 반면 개발 모드의 소스 폴더(PORTAL_BP_DIR)에서 번들 파일만 교체하면 포탈을 재기동하기 전까지 이전 번들이 계속 제공된다. 개발 중 화면이 변경되지 않는다면 이것을 먼저 확인한다.
format을 "iife"로 두는 이유
Portal Shell은 이 번들을 type="module" 없는 클래식 스크립트로 적재한다. 따라서 format: "esm"으로 만들어도 전역 등록 코드 자체는 실행되며, 위젯을 찾지 못하는 상태가 되지는 않는다(esbuild의 esm 출력에 export가 없으면 클래식 스크립트로도 평가된다).
실제 차이는 즉시 실행 함수 래퍼의 유무다. IIFE 출력은 번들 전체가 함수 안에 갇히지만, ESM 출력에는 그 래퍼가 없어 번들 최상위에서 선언된 이름이 전역 이름 공간으로 유출된다. Portal Shell·다른 BP 위젯과 같은 문서를 공유하므로, 이름이 겹치면 서로의 값을 덮어쓰게 되고 그 실패는 예외가 아니라 오작동으로 나타난다.
loader: { ".css": "text" }를 전제로 CSS는 import css from "./widget.css"로 가져온다. ?inline 접미사를 붙이지 않는다 — 그것은 Vite 계열 문법이고 esbuild에서는 해석 대상 파일 이름이 달라진다.
정적으로 확인하는 방법
번들을 브라우저에서 적재하지 않고도 형식을 확인할 수 있다.
bash
head -c 40 dist/frontend/bundle.js- IIFE 출력은
"use strict";(()=>{처럼 즉시 실행 함수 래퍼로 시작한다. - ESM 출력은 최상위 변수 선언(
var …)으로 시작한다.
위젯이 실제로 전역에 등록되는지까지 확인하려면 jsdom으로 번들을 적재한다 — 정본: examples/task-bp/scripts/check-widget.mjs. 이 검사는 재마운트 안전성(frontend-guide.md §1)도 함께 확인한다.
5. 빌드 순서
한 명령으로 끝나야 하는 이유는 단계 사이에 순서 제약이 있기 때문이다.
| 단계 | 하는 일 |
|---|---|
| 0 | 호스트 제공 모듈 버전 정합 검사, TypeScript API 존재 확인 |
| 1 | 정본 스키마 → 방언별 스키마 파생 |
| 2 | 방언별 스키마 검증 + Prisma 클라이언트 생성 |
| 3 | 마이그레이션 존재·비어 있지 않음 확인 |
| 4 | bp-manifest.yaml 생성(타입 검사됨) |
| 5 | 백엔드 번들 |
| 6 | 프론트 번들 |
| 7 | 매니페스트·방언별 스키마와 마이그레이션을 dist/로 복사 |
타입체크는 빌드보다 먼저 실행할 수 없다. BP 코드가 backend/generated/<provider>/client.js를 import하는데 그 파일은 2단계의 prisma generate가 생성한다. 새로 받은 저장소에서 타입체크를 먼저 실행하면 TS2307로 중단된다. 순서는 build → typecheck → preflight → package → verify:package 다. 정본 예제의 pnpm run verify가 이 순서로 전부 실행한다.
6. 배포 전 자체 점검
포탈에 설치하기 전에 확인할 것들이다. 대부분의 실패가 오류 메시지 없이 나타나므로, 이 점검이 사실상 유일한 조기 경보다.
정본은 examples/task-bp/scripts/preflight.mjs다. 항목을 문서에 나열하지 않는다 — 목록이 문서와 코드로 나뉘면 어긋나고, 어긋난 사실을 아무도 인지하지 못한다. 검사 범위는 이렇다.
| 영역 | 검사하는 것 |
|---|---|
| 매니페스트 | apiVersion 값, semver 형식, hostContract 범위가 SDK의 계약 버전을 만족하는지 |
| 경로 실재 | entry / bundle / 방언별 schema.prisma·migrations/ |
| 스키마 | datasource provider가 폴더 이름과 같은지, datasource에 url이 없는지(Prisma 7) |
| 마이그레이션 | 1건 이상인지, 0바이트가 없는지, CREATE TABLE을 포함하는지, 물리 테이블명이 50자 이내인지, 테이블·인덱스를 IF NOT EXISTS로 만들지 않는지, migration_lock.toml이 방언과 일치하는지 |
| 포탈 반입 판정(V7·V8 정적 부분) | starj package pack으로 임시 아카이브를 만들고 starj package verify --json으로 방언별 결과를 받는다. 방언마다 마이그레이션 폴더가 있는지(V7), 마이그레이션 SQL이 허용 목록 안이고 접두어와 선언 테이블이 맞는지(V8). 포탈이 반입할 때 쓰는 검사기와 같은 라이브러리다. 테이블 접두어(bp_{businessPackageId}_)는 이 V8 결과로만 판정한다. V8은 마이그레이션을 이름 순으로 적용한 최종 테이블 집합을 보고, SQLite 재정의의 임시 테이블 new_<이름>은 같은 마이그레이션에서 RENAME TO "<이름>"으로 돌아가면 받는다. 같은 규칙을 스크립트에 다시 쓰면 어긋나므로 두지 않는다 |
| 산출물 위생 | 로컬 전용 config가 dist/에 포함되지 않았는지 |
| 백엔드 번들 | 호스트 제공 모듈이 인라인되지 않았는지, 남은 import가 전부 호스트 제공 모듈이거나 Node 내장인지, design:paramtypes가 남아 있는지, 번들을 실제로 적재할 수 있는지, register를 export하는지 |
| 능력 표면 | register()를 실제로 호출해 App id·uri 중복, menu[].parentId·appId 참조, dispYn 명시, frontendDeliveryMode 일치, rw 값이 1 또는 3인지, onInstall을 두 번 실행해도 결과가 같은지 |
| 프론트 번들 | 위젯 전역 등록 키가 매니페스트 id와 같은지, 재마운트에 안전한지 |
| 저장소 설정 | 호스트 제공 모듈 버전이 캐럿 없이 고정되었는지, typescript가 5.x인지 |
bash
node scripts/preflight.mjs # 항목 수는 실행 결과 마지막 줄에 표시된다배포 전 점검에도 starj CLI가 필요하다(§7-1). V7·V8 판정을 스크립트 안에 다시 구현하지 않고 CLI에 묻기 때문이다. CLI를 찾지 못하면 그 항목이 실패한다.
복사본은 자동으로 갱신되지 않는다
이 스크립트는 SDK가 게시하는 실행 파일이 아니라 예제의 소스다. 새 BP는 그것을 복사해서 시작하며, 복사한 뒤에는 정본의 수정이 반영되지 않는다. 검사 항목이 늘어나도 복사본은 그대로이고, 뒤처졌다는 사실이 어디에도 표시되지 않는다.
- 복사한 스크립트에 어느 시점의 정본에서 복사했는지 주석으로 남기고, SDK를 갱신할 때 정본과 대조한다.
- 계약 1.1.0의 표면을 사용한다면 아래 검사를 직접 추가한다. 정본 예제가 그 표면을 전부 사용하지는 않으므로 복사본에도 포함되어 있지 않다. 아래 항목은 전부 위반해도 빌드와 타입체크가 통과하며 운영 중에만 드러난다.
| 추가할 검사 | 위반했을 때 |
|---|---|
notificationCategories[].key가 {businessPackageId}.로 시작하고 서로 중복되지 않는다 | 다른 BP의 카테고리와 충돌하거나 사용자 수신 설정 화면에서 구분되지 않는다 |
소스에서 publish({ category: … })로 사용하는 문자열이 전부 매니페스트에 선언되어 있다 | 발행은 되지만 사용자가 수신을 비활성화할 수 없는 알림이 된다(backend-guide.md §13) |
recipients.authorityKey로 사용하는 문자열이 defaultAuthorities[].authId에 존재한다 | 수신자 해석 결과가 0명이 되어 publish()가 언제나 null을 반환한다 |
deepLink 문자열이 슬래시로 시작하지 않고 {businessPackageId}/로 시작한다 | 알림을 선택해도 대상 화면이 열리지 않는다 |
services.require(bpId, …)의 bpId가 매니페스트 dependsOn[]에 있다 | require()가 언제나 null을 반환한다(not-declared) |
카드 데이터 응답이 checkCardLimits()를 통과한다 | 홈 카드가 "표시할 수 없음" 상태가 된다(backend-guide.md §5-4) |
점검이 실제로 검출하는지 확인한다
"점검이 통과했다"는 사실 자체는 아무것도 보증하지 않는다. 망가진 산출물도 함께 통과한다면 그 점검은 없는 것과 같다.
그래서 예제는 고의로 망가뜨린 산출물을 만드는 스크립트를 함께 둔다.
bash
node scripts/build.mjs && node scripts/break-artifacts.mjs
for d in dist-broken/*; do node scripts/preflight.mjs "$d"; done # 전부 실패해야 정상이다
for d in dist-good/*; do node scripts/preflight.mjs "$d"; done # 전부 통과해야 정상이다dist-good/은 반대 방향을 확인한다. 정상 산출물을 거부하는 점검은 결국 꺼지므로 걸러내지 못하는 점검과 같다. Prisma가 열 추가·변경에 만드는 SQLite 재정의 블록(CREATE TABLE "new_bp_…" → DROP TABLE → RENAME TO)이 그 예다.
| 재현하는 실수 | 검출하는 점검 |
|---|---|
externals를 수동으로 열거하다 @nestjs/common을 누락 | 호스트 제공 모듈 인라인 검사 / 번들 적재 검사 |
같은 실수를 reflect-metadata에 대해 저지름 | 호스트 제공 모듈 인라인 검사 |
emitDecoratorMetadata: false | design:paramtypes 검사 |
프론트 번들을 format: "esm"으로 생성 | IIFE 래퍼 검사 |
SQLite 마이그레이션에 손으로 쓴 CREATE VIEW를 더함 | V7·V8 판정(starj package pack이 V8 SQL_NOT_ALLOWED로 거부) |
| PostgreSQL 마이그레이션 폴더를 뺌 | 경로 실재 검사 / V7·V8 판정(V7 DIALECT_MIGRATIONS_MISSING) |
SQLite CREATE에 IF NOT EXISTS를 붙임(이전 판 make-migration.mjs의 출력 형태) | IF NOT EXISTS 검사 |
| 접두어 없는 테이블을 만듦 | V7·V8 판정(V8 접두어 위반) |
SQLite 재정의의 임시 테이블 new_bp_…를 원래 이름으로 되돌리지 않음 | V7·V8 판정(V8 접두어 위반) |
시나리오 전체 목록과 각 시나리오가 걸리는 검사 이름은 examples/task-bp/README.md에 있다.
사용하지 않는 점검
이전 판의 문서가 제시하던 아래 두 가지는 판정 수단으로 사용하지 않는다.
| 폐기한 점검 | 이유 |
|---|---|
grep -c "class BadRequestException" dist/backend/index.mjs → 0 | 위 고의 파손 4종 중 어느 것도 검출하지 못한다. reflect-metadata 누락 산출물은 이 검사에서 "합격"을 반환하면서 컨트롤러가 등록되지 않는 상태다 |
grep -c "@nestjs/common" dist/backend/index.mjs → "0에 가까워야" | externals가 정상 동작하면 import 문이 반드시 남으므로 정의상 0이 될 수 없다. 판정 가능한 기준이 아니다 |
번들 혼입 여부는 문자열 검색이 아니라 esbuild가 남기는 인라인 모듈 주석 목록을 호스트 제공 모듈 목록과 대조하는 방식으로 판정한다(그래서 백엔드 번들을 minify하지 않는다).
7. 패키지 아카이브 만들기와 서명
포탈에 설치하는 단위는 dist/를 묶은 패키지 아카이브(.tgz)다. 아카이브는 포탈이 배포하는 starj CLI의 starj package pack으로 만든다. tar czf로 직접 묶은 파일은 포탈이 받지 않는다. 포탈은 아카이브 루트의 starj-package.json(파일 목록과 해시, 매니페스트)과 서명 파일을 요구하기 때문이다.
정본: examples/task-bp/scripts/package.mjs(묶기), examples/task-bp/scripts/verify-package.mjs(검증), examples/task-bp/scripts/lib/starj-cli.mjs(CLI 찾기).
bash
pnpm run package # dist/ → dist-package/<id>-<version>.tgz
pnpm run verify:package # 아카이브가 dist/와 같은지, starj package verify를 통과하는지 확인7-1. starj CLI 준비
starj CLI는 SDK 패키지와 같은 사설 레지스트리(https://npm.mdpert.com/)에 @starj-portal/cli로 게시되어 있다. BP 저장소의 devDependencies에 넣어 받는다. 정본 예제와 starj bp create로 만든 프로젝트에는 이미 들어 있으므로 pnpm install만 하면 된다. BP 저장소의 .npmrc가 @starj-portal 스코프를 그 레지스트리로 보내므로 레지스트리 설정을 더할 필요는 없다(Node.js 22 이상).
bash
pnpm add -D @starj-portal/cli@^0.1.0
pnpm exec starj --version정본 예제의 스크립트(preflight·package·verify:package)는 다음 순서로 CLI를 찾는다.
| 순서 | 위치 | 쓰는 경우 |
|---|---|---|
| 1 | 환경변수 STARJ_CLI가 가리키는 파일 | 의존성으로 받은 것과 다른 CLI로 검증하려는 경우. 포탈 릴리스 산출물의 starj.mjs, 포탈 저장소에서 빌드한 파일, CI가 고정한 파일이 여기에 해당한다 |
| 2 | node_modules/@starj-portal/cli/dist/starj.mjs, 없으면 node_modules/.bin/starj(Windows는 starj.cmd) | devDependencies로 받은 CLI. 기본 경로다 |
| 3 | PATH의 starj | 전역으로 설치한 경우 |
bash
# 다른 CLI 파일로 검증할 때만 지정한다. 지정하면 의존성으로 받은 CLI보다 먼저 쓴다
export STARJ_CLI=/path/to/starj.mjs
node "$STARJ_CLI" --versionSTARJ_CLI는 우선순위 1번으로 남는다. CI나 릴리스 검증에서 어느 CLI 파일로 검증했는지 고정하는 수단이기 때문이다. STARJ_CLI에 값이 있는데 파일이 없으면 다음 순서로 넘어가지 않고 실패한다. 잘못 적은 경로가 의존성의 다른 버전으로 대체되면 어느 CLI로 검증했는지 알 수 없기 때문이다. 세 곳 어디에도 없으면 preflight·package·verify:package가 실패한다. 검증을 건너뛴 채 통과시키지 않는다.
Windows에서도 2번 경로는 셸을 거치지 않는다. 스크립트가 패키지 안의 starj.mjs를 지금 실행 중인 node로 실행하기 때문이다. .bin/starj.cmd 래퍼와 3번의 PATH 탐색만 셸을 거치므로 환경에 따라 찾지 못할 수 있다.
갓 게시한 CLI 버전은 pnpm install이 받지 못할 수 있다. pnpm의 최소 공개 경과시간 정책이 최근 게시분을 거르기 때문이다. 정본 예제는 pnpm-workspace.yaml의 minimumReleaseAgeExclude에 그 버전을 넣어 둔다. SDK 패키지와 같은 방식이다.
복사한 스크립트는 정본의 수정을 받지 못한다. 새 BP가 위 세 파일을 복사해 쓰면, 정본이 바뀌어도 그 복사본은 그대로다(§6과 같다).
7-2. 서명 키 만들기와 보관
이 절부터 BP 저장소에서 직접 실행하는 CLI 명령은 pnpm exec starj …로 적는다. CLI가 devDependencies로 설치되어 PATH에 없기 때문이다. STARJ_CLI로 다른 CLI를 지정했다면 node "$STARJ_CLI" …로 실행한다. pnpm run package 같은 스크립트는 §7-1의 순서로 CLI를 스스로 찾는다.
운영 모드 포탈은 신뢰 키로 서명한 아카이브만 받는다. 키는 Ed25519 한 쌍이다.
bash
# 비밀번호를 환경변수에 두고, 명령에는 변수 이름만 준다(명령줄 인자로 받지 않는다)
export STARJ_SIGN_KEY_PASSPHRASE='<충분히 긴 무작위 문자열>'
pnpm exec starj package keygen --out ~/.starj-keys/task-bp --label "task-bp 배포 키" --passphrase-env STARJ_SIGN_KEY_PASSPHRASEkeygen은 세 파일을 만든다.
| 파일 | 비밀인가 | 용도 |
|---|---|---|
starj-signing.key.pem | 비밀 | 개인 키(권한 0600). 서명에만 쓴다 |
starj-signing.pub.pem | 아니다 | 공개 키. 검증할 때 --trusted-key로 전달한다 |
starj-signing.trusted-key.json | 아니다 | { label, publicKeyPem }. 포탈 운영자에게 신뢰 키 등록용으로 전달한다 |
키를 보관할 때 지키는 것:
- 개인 키는 저장소 밖에 둔다. 저장소 안에 두지 않는다. 정본 예제와 이 저장소의
.gitignore는*.key.pem과starj-signing.key.pem*을 막지만, 그것은 실수를 막는 마지막 장치일 뿐이다. - 비밀번호를 건다. 비밀번호 없이
keygen하면 경고가 출력된다. 암호화는 PKCS#8aes-256-cbc이고 키 유도 매개변수가 고정이므로, 비밀번호가 짧으면 보호도 약하다. 무작위로 만든 긴 문자열을 쓴다. - CI에서는 개인 키와 비밀번호를 각각 시크릿으로 둔다. 작업 중에 개인 키를 임시 파일로 쓰고
STARJ_SIGN_KEY에 그 경로를,STARJ_SIGN_KEY_PASSPHRASE에 비밀번호를 지정한다. 작업이 끝나면 임시 파일을 지운다. 비밀 관리자나 HSM이 뒷받침하는 시크릿 저장소를 권장한다. - 개인 키와 비밀번호를 문서·로그·이슈에 남기지 않는다. 노출되었으면 새 키를 만들고, 운영자에게 이전 키 폐기와 새 키 등록을 요청한다.
keygen은 이미 있는 파일을 덮어쓰지 않는다(종료 코드 4). 키를 바꾸려면 새 폴더에 만든다.
7-3. 묶기와 서명
bash
STARJ_SIGN_KEY=~/.starj-keys/task-bp/starj-signing.key.pem pnpm run package
# 또는
pnpm run package -- --sign-key ~/.starj-keys/task-bp/starj-signing.key.pempackage.mjs는 starj package pack dist --out dist-package [--sign-key …] [--passphrase-env STARJ_SIGN_KEY_PASSPHRASE]를 실행한다. 실행할 때마다 dist-package/를 비우므로 이전 아카이브가 남지 않는다.
서명 키를 주지 않으면 서명하지 않은 아카이브를 만들고 경고한다. 그 아카이브는 개발 모드(PORTAL_PACKAGE_MODE=development) 포탈에서만 경고와 함께 설치되고, 운영 포탈은 V4(UNSIGNED)로 거부한다. 로컬 개발에서만 쓴다.
pack이 하는 일:
| 항목 | 내용 |
|---|---|
| 매니페스트 파생 | bp-manifest.yaml에서 starj-package.json을 만든다. id→name, version, runtime.hostContract→hostContract, dependsOn[]→dependencies[](businessPackageId→name, versionRange→range, required는 그대로), 방언과 schemaDir, 진입점과 위젯 번들 경로. 코어 버전 범위는 *이고, 호환은 호스트 계약 범위로 판정한다 |
| 테이블 목록 계산 | 방언마다 마이그레이션 SQL을 순서대로 읽어 최종 테이블 집합을 계산하고 database.tables에 적는다. 접두어는 bp_<id>_(하이픈은 밑줄)다 |
| 파일 목록 | 모든 파일의 경로·크기·SHA-256을 적는다. .git/, .DS_Store, Thumbs.db는 항상 제외하고, 그 밖의 점 파일은 포함하되 경고한다 |
| 재현 가능한 묶기 | 경로 순 정렬, 수정 시각 0, 소유자 0, 권한 0644/0755로 고정한다. 같은 Node 주 버전과 POSIX 빌드 호스트에서는 같은 입력이 같은 해시를 낸다 |
| 서명 | --sign-key가 있으면 매니페스트 바이트에 서명해 starj-package.sig.json을 넣는다 |
이미 만든 아카이브의 서명을 바꾸거나 더할 때는 pnpm exec starj package sign <tgz> --sign-key <pem>을 쓴다. 키 교체 기간에는 두 키로 서명할 수 있다.
7-4. 검증
bash
STARJ_TRUSTED_KEY=~/.starj-keys/task-bp/starj-signing.pub.pem pnpm run verify:packageverify:package가 확인하는 것:
| 확인 | 실패하면 |
|---|---|
아카이브의 파일 집합과 해시가 **이번 빌드의 dist/**와 같다 | build 뒤에 package를 다시 실행하지 않아 이전 아카이브가 남은 상태다 |
파생 매니페스트의 이름·버전·종류·hostContract·방언·dependencies가 bp-manifest.yaml과 같다 | 포탈이 V2로 거부한다 |
sqlite 마이그레이션 전체를 한 트랜잭션으로 빈 DB에 적용할 수 있고, 만든 테이블이 database.tables와 같다 | 포탈의 패키지 마이그레이션 러너가 PACKAGE_MIGRATION_FAILED로 적용을 취소한다 |
starj package verify(V1~V4, V7·V8 정적 부분)를 통과한다 | 포탈도 같은 판정으로 거부한다 |
신뢰 키도 --allow-unsigned도 없이 검증하면 실패한다 | 이 검증이 조건 없이 통과하고 있다는 뜻이다 |
| 실행 중에 만든 임시 키로 서명하면 그 키로 통과하고 다른 키로는 실패한다 | 서명 왕복이 성립하지 않는다 |
tar --format=pax로 다시 묶은 아카이브는 통과하고 GNU 형식은 실패한다 | §7-5의 안내가 사실과 다르다 |
서명 판정은 아카이브와 환경변수에 따라 다음과 같이 구분된다.
| 아카이브 | 지정한 환경변수 | 판정 |
|---|---|---|
| 서명됨 | STARJ_TRUSTED_KEY(공개 키 .pem 또는 .trusted-key.json) | 그 키로 검증한다 |
| 서명됨 | STARJ_SIGN_KEY만 | 개인 키에서 공개 키를 계산해 검증한다. 서명이 그 키로 맞게 만들어졌는지만 확인한다. 포탈이 그 키를 신뢰하는지는 확인하지 않으며, 출력에도 그렇게 표시된다 |
| 서명됨 | 둘 다 없음 | 실패 |
| 서명 없음 | 없음 | --allow-unsigned로 검증하고 "개발용"으로 표시한다 |
pnpm exec starj package verify <tgz> …로 직접 실행할 때의 규칙:
--trusted-key가 없으면 서명을 확인할 수 없으므로, 서명된 아카이브는 V4SIGNATURE_NOT_CHECKED로 실패한다.--skip-signature를 주면 서명 구조와 해시만 확인하고 경고로 통과시킨다.- 서명이 없으면
UNSIGNED이고,--allow-unsigned가 있으면 경고, 없으면 실패다. --trusted-key로 준 키는 모든 종류를 허용하는 것으로 본다. 포탈의 허용 종류 제한과 폐기 확인은 오프라인 검증에 적용되지 않는다. 포탈 서버는 항상 자기 신뢰 키 목록으로 다시 검증한다.- 종료 코드:
0성공,1일반 오류(읽을 수 없는 키 파일 포함),2사용법 오류,3검증 실패,4빌드·파일 입출력 오류.
7-5. 손으로 다시 묶을 때는 pax 또는 ustar 형식
starj package pack이 만든 아카이브를 풀어 내용을 바꾸지 않고 다른 도구로 다시 묶어야 한다면, tar 형식을 **--format=pax 또는 --format=ustar**로 지정한다.
bash
tar --format=pax -czf task-bp-1.0.0.tgz -C <풀어 둔 폴더> .포탈의 아카이브 읽기기는 POSIX ustar 헤더(pax 확장 헤더 포함)만 받는다. GNU tar의 기본 형식(--format=gnu)은 V1 ARCHIVE_CORRUPT로 거부된다. macOS의 bsdtar에서는 --format=gnutar가 같은 형식이다. 링크, 장치 파일, pax 전역 헤더도 거부된다. macOS에서는 확장 속성 파일(._*)이 섞이지 않도록 환경변수 COPYFILE_DISABLE=1을 함께 지정한다. 섞이면 파일 목록과 어긋나 V3로 거부된다.
파일 내용을 바꿨다면 다시 묶지 말고 pnpm run package로 처음부터 만든다. starj-package.json의 해시와 서명이 더는 맞지 않기 때문이다.
7-6. 오프라인 검증이 확인하는 것과 확인하지 않는 것
starj package verify는 V1~V4(아카이브 구조, 매니페스트, 무결성, 서명)와 V7·V8의 정적 부분을 판정한다. starj package pack도 아카이브를 쓰기 전에 V7·V8 정적 부분을 판정하고, 실패하면 아카이브를 만들지 않고 종료 코드 3으로 끝난다. 결과는 방언마다 한 줄씩 나온다. 포탈은 반입할 때 모든 항목을 다시 판정한다.
| 항목 | 오프라인에서 판정하는 것 | 포탈만 판정하는 것 |
|---|---|---|
| V7 | 선언한 방언마다 <schemaDir>/<방언>/migrations/가 있는가. 없으면 DIALECT_MIGRATIONS_MISSING | 실행 중인 방언이 providers에 있는가 |
| V8 | 마이그레이션 SQL이 허용 목록의 문장만 쓰는가(SQL_NOT_ALLOWED), 접두어 규칙과 최종 테이블 집합이 선언과 맞는가(DATABASE_DECLARATION_INVALID). CREATE … IF NOT EXISTS가 있으면 경고 IF_NOT_EXISTS_PRESENT | 다른 설치 패키지와의 접두어 충돌 |
나머지는 포탈만 판정한다.
| 항목 | 포탈이 판정하는 것 | 미리 확인하는 방법 |
|---|---|---|
| V5 | 포탈의 호스트 계약(연계 규약) 버전이 hostContract 범위 안인가 | GET /bp/runtime-info의 hostContractVersion(host-contract.md §5) |
| V9 | dependencies의 대상이 설치되어 있는가 | 업무패키지 사이의 의존은 경고이며 설치를 막지 않는다. 판정은 도메인 활성화 때 한다 |
V8의 허용 목록은 다음과 같다. 목록 밖의 문장이 하나라도 있으면 그 아카이브는 설치되지 않는다.
- 허용:
CREATE TABLE,CREATE [UNIQUE] INDEX,ALTER TABLE,DROP TABLE,DROP INDEX,INSERT·UPDATE·DELETE. 모든 쓰기 대상(테이블, 인덱스,RENAME TO의 새 이름)이bp_<id>_접두어 안이어야 한다. PostgreSQL의 접두어 있는CREATE TYPE … AS ENUM·ALTER TYPE·DROP TYPE, SQLite의PRAGMA foreign_keys·PRAGMA defer_foreign_keys, Prisma가 PostgreSQL 첫 마이그레이션에 넣는CREATE SCHEMA IF NOT EXISTS "public";한 문장도 허용한다. ALTER TABLE동작은ADD COLUMN,DROP COLUMN,ALTER COLUMN,ADD CONSTRAINT,DROP CONSTRAINT,RENAME COLUMN, 접두어 이름으로의RENAME TO만 허용한다.- 거부:
CREATE FUNCTION·TRIGGER·VIEW·EXTENSION,GRANT,COPY,DO,MERGE, 최상위WITH(열 자료형의WITH TIME ZONE은 예외), 그 밖의PRAGMA,CONCURRENTLY,public·main밖의 스키마 한정자,INHERITS·PARTITION·TABLESPACE. - 1.x 포탈은 외래 키가 접두어 밖의 코어 테이블을 가리키는 것과 코어 테이블을 읽는 것을 허용한다. 검사 대상은 쓰기뿐이다. 호스트 계약 2.0.0 포탈은 업무패키지 마이그레이션의 코어 테이블 외래 키와 코어 테이블 읽기를 모두 거부한다. 마이그레이션은 BP 전용 계정으로 실행되어 코어 테이블에 접근할 수 없기 때문이다(backend-guide.md §6-3).
- 형식 규칙: 공백 문자는 스페이스·탭·LF·CRLF만 쓴다. ASCII가 아닌 문자는 따옴표 문자열·따옴표 식별자·주석 안에서만 쓴다. 한 방언에서만 유효한 구문(PostgreSQL 파일의 백틱 식별자 등)은 거부된다.
- SQLite의 테이블 재정의(Prisma가 만드는
new_<이름>임시 테이블)는 같은 파일 안에서ALTER TABLE "new_<이름>" RENAME TO "<이름>"이 뒤따를 때만 받는다.
정본 예제의 preflight.mjs는 starj CLI로 이 판정을 실행하므로, 목록 밖의 문장이 있으면 배포 전 점검에서 실패한다. prisma migrate diff가 만드는 일반적인 테이블·인덱스·열 변경은 목록 안에 있다. 손으로 쓴 SQL이나 데이터 이행 문장을 넣을 때 이 목록을 먼저 확인한다.
함께 볼 것
- install-to-portal.md — 만든 아카이브를 포탈에 설치하는 방법
- troubleshooting.md — 설치했는데 아무 변화가 없을 때