테마
호스트 계약 — 포탈과 BP 사이의 경계
포탈은 BP의 빌드 산출물만 적재한다. 포탈 소스에는 BP의 이름도, 경로도, 심볼도 없다. 그래서 둘 사이에 성립하는 것은 아래 네 가지뿐이며, 이 문서의 내용을 어기면 대부분 예외가 아니라 침묵으로 나타난다.
1. 진입점은 register(host) 하나
BP 백엔드 번들이 export하는 공개 표면은 이것뿐이다. 포탈은 이 함수만 호출하고 나머지 export는 알지 않는다.
ts
export function register(host: BpHostRuntime): BpRegistration | Promise<BpRegistration>;이름이 다르거나 없으면 그 BP만 조용히 건너뛴다 — 포탈은 계속 뜬다.
데코레이터는 쓰지 않는다. 포탈이 매니페스트 파일의 정체성과 register()가 돌려준 능력 표면을 합쳐 런타임에 클래스를 합성한다. 덕분에 BP 번들은 reflect-metadata도 SDK 데코레이터도 담을 필요가 없다.
2. BpHostRuntime — 포탈이 나에게 주는 전부
ts
interface BpHostRuntime {
readonly businessPackageId: string;
checkAppAccess(req: unknown, domainId: string, appId: string, action: "read" | "update"): Promise<boolean>;
currentUsername(req: unknown): Promise<string | null>;
currentUserNo(req: unknown): number | null;
readonly database: { url: string; provider: "sqlite" | "postgresql" };
readonly dataDir: string;
deriveSecret(purpose: string): string;
readonly logger: BpLogger;
}| 멤버 | 쓰임과 주의 |
|---|---|
checkAppAccess | 권한 판정은 전부 포탈이 한다. BP가 자체 권한 테이블을 만들어 판정하면 관리자 화면에서 부여한 권한과 어긋난다. req가 unknown인 이유는 BP를 포탈의 HTTP 프레임워크 타입에 묶지 않기 위해서다 |
currentUsername | 비동기다 — 세션에는 사용자 번호만 있고 이름은 포탈 사용자 레코드에 있다. BP가 포탈 테이블을 직접 읽지 않는다는 원칙의 귀결이다 |
currentUserNo | 세션에 이미 있어 동기다 |
database | 자기 스키마만 소유한다. 이 접속 정보로 포탈이나 다른 BP의 테이블을 읽는 것은 계약 위반이다. 물리적으로 막지는 않지만, 그 테이블은 예고 없이 바뀐다 |
dataDir | 이 BP 전용 파일 저장 경로. 포탈이 만들어서 넘긴다 |
deriveSecret(purpose) | 이 BP·이 용도 전용 파생키(hex). 같은 (BP, purpose)에는 항상 같은 값이 나온다 — 저장한 암호문을 다음 부팅에도 열 수 있어야 하므로. 포탈 마스터키 원본은 절대 넘어오지 않고, BP끼리 서로의 키를 유도할 수도 없다 |
logger | 포탈 로그 체계에 실린다. businessPackageId는 자동으로 붙는다 |
여기 없는 것은 쓸 수 없다. 포탈의 사용자 목록, 메뉴 트리, 다른 BP의 데이터에 접근하는 통로는 의도적으로 열려 있지 않다.
3. 호스트 제공 모듈 — 번들에 담으면 안 되는 것
일부 모듈은 포탈과 같은 인스턴스를 공유해야 한다. 사본이 둘이면 오류가 아니라 조용한 오작동으로 나타난다.
| 모듈 | 사본이 둘이면 |
|---|---|
@nestjs/common, @nestjs/core | DI 토큰이 서로 다른 클래스 객체가 되어 주입이 안 붙는다 |
reflect-metadata | 데코레이터 메타데이터가 다른 저장소에 쌓여 컨트롤러가 등록되지 않는다 |
@prisma/client, @prisma/adapter-* | 생성 클라이언트가 런타임을 찾지 못한다 |
better-sqlite3, pg | 네이티브 애드온이라 애초에 번들 불가 |
정확한 목록은 SDK가 소유한다. 직접 적지 말고 import한다 — 목록이 늘어도 따라온다.
js
import { HOST_PROVIDED_MODULES } from "@starj-portal/bp-backend-sdk";
await esbuild.build({ /* … */ external: [...HOST_PROVIDED_MODULES] });버전은 캐럿 없이 고정한다
이 모듈들은 번들에 안 들어가고 포탈 것이 쓰인다. 그래서 BP가 빌드 시점에 본 버전과 포탈이 제공하는 버전이 다르면 그 차이가 런타임에 드러난다. 실제로 겪은 사례 — BP가 Prisma 7.9.1로 생성 클라이언트를 만들고 포탈이 7.8.0을 제공하자 쿼리 프로토콜이 달라 적재가 실패했다. 양쪽 모두 ^7.8.0이었는데 서로 다르게 해석된 결과다.
jsonc
// BP의 package.json — ^를 붙이면 안 된다
"dependencies": { "@prisma/client": "7.8.0", "@nestjs/common": "11.1.28" }정확한 값은 SDK가 공표한다.
bash
node -e "import('@starj-portal/bp-backend-sdk').then(m => console.log(m.HOST_PROVIDED_VERSIONS))"4. 버전 게이트
BP는 자신이 요구하는 계약 범위를 bp-manifest.yaml에 선언하고, 포탈이 부팅 시 자신의 HOST_CONTRACT_VERSION과 대조한다.
yaml
runtime:
hostContract: "^1.0.0"안 맞으면 그 BP만 적재되지 않는다. 포탈은 정상적으로 뜨고, 그 BP의 화면만 없다. 그래서 "배포했는데 아무 일도 안 일어난다"가 이 게이트의 전형적인 증상이다 — troubleshooting.md 참고.
검증 시점이 BP의 빌드가 아니라 포탈의 부팅이라는 점이 중요하다. 배포 전에 확인하려면 대상 포탈이 제공하는 계약 버전을 먼저 확인하고, 그 버전에 맞는 SDK major로 빌드한다.
함께 볼 것
- bp-manifest.md — 선언 파일의 정확한 스키마
- build-and-package.md — 위 규칙을 실제 빌드 설정으로 옮기는 법