테마
백엔드 가이드
BP의 서버 쪽 — API, 데이터베이스, 라이프사이클.
1. register() — 유일한 진입점
ts
import type { BpHostRuntime, BpRegistration } from "@starj-portal/bp-backend-sdk";
export function register(host: BpHostRuntime): BpRegistration {
const runtime = { host }; // 컨트롤러가 주입받을 값
return {
manifest: {
apps: [{ id: "hello:main", name: "Hello", uri: "hello", moduleCode: "HELLO" }],
menu: [{ id: "hello:menu-main", appId: "hello:main" }],
permissions: ["hello:read"],
defaultAuthorities: [
{ authId: "HELLO_USER", authName: "Hello 사용자", apps: [{ appId: "hello:main", rw: 1 }] },
],
frontendDeliveryMode: "csr-widget",
},
controllers: [HelloController],
providers: [{ provide: HELLO_RUNTIME, useValue: runtime }],
hooks: {
async onInstall(ctx) { /* 초기 데이터 */ },
},
};
}register()는 async여도 된다. 포탈은 반환값을 자기 조립 경로에 그대로 투입한다.
2. 화면(App)과 메뉴는 분리 선언한다
| 목록 | 무엇을 기술하나 |
|---|---|
apps[] | 실제 화면 — id(안정 식별자) / name / uri(포탈 전역 고유) / moduleCode |
menu[] | 트리 위치 — id / parentId / appId 또는 name(그룹) / sortNo |
분리된 이유는 같은 App을 메뉴 여러 곳에 걸 수 있어야 하고, 관리자가 메뉴를 재배치해도 App 선언이 흔들리면 안 되기 때문이다.
apps[].id와menu[].id는 upsert 기준이다. 한 번 배포한 뒤 바꾸면 기존 레코드와 이어지지 않고 새로 생긴다.- 관리자가 이미 옮겨 놓은 메뉴 노드는 다음 부팅에서 부모/순서를 덮어쓰지 않는다. 새로 추가된 노드만 삽입된다.
- 매니페스트에서 App이 사라지면 그 App과 그것을 가리키는 메뉴는 숨김 처리되고 삭제되지는 않는다. 선언을 되돌리면 되살아난다.
3. 권한 — 판정은 전부 포탈이 한다
BP는 권한을 선언하고, 판정은 host.checkAppAccess()에 묻는다. 자체 권한 테이블을 만들어 판정하면 관리자 화면에서 부여한 권한과 어긋난다.
ts
@Get("items")
async list(@Req() req: unknown, @Query("domainId") domainId: string) {
if (!(await this.host.checkAppAccess(req, domainId, "hello:main", "read"))) {
throw new ForbiddenException();
}
// …
}defaultAuthorities[]는 설치 시 자동 생성되는 권한 템플릿이다. 매 부팅마다 매니페스트 기준으로 재동기화되므로, App을 새로 추가하면 기존 권한 보유자도 그 화면에 자동으로 접근할 수 있게 된다. 다만 자동화 범위는 "권한 정의"까지이고, 사용자에게 실제로 부여하는 것은 관리자의 몫이다. 관리자가 바꿔 둔 권한 표시 이름은 덮어쓰지 않는다.
4. API 경로
컨트롤러는 포탈의 앱에 편입되며, BP의 REST API는 /bp/{businessPackageId}/... 아래에 산다. 프론트엔드에서는 이 경로를 직접 쓰지 말고 host.apiBasePath를 쓴다.
5. 데이터베이스
BP는 자기 스키마만 소유한다. host.database로 받은 접속 정보로 포탈이나 다른 BP의 테이블을 읽는 것은 계약 위반이며, 그 테이블은 예고 없이 바뀐다.
방언별 산출물 두 벌
providers에 방언을 둘 이상 선언했다면 방언마다 클라이언트를 따로 생성해야 한다 — 쿼리 컴파일러가 다르므로 한 벌로는 양쪽을 커버할 수 없다. 런타임 선택은 host.database.provider(또는 접속 URL 스킴)로 한다.
backend/prisma/
sqlite/{schema.prisma, migrations/}
postgresql/{schema.prisma, migrations/}
backend/generated/
sqlite/ postgresql/정본 schema.prisma 하나를 두고 datasource provider와 생성 클라이언트 출력 경로만 치환해 파생시키는 방식을 권한다 — 스키마를 두 벌 손으로 관리하면 반드시 갈라진다.
마이그레이션
포탈이 자기 코어 스키마 → 코어 시드 → BP 스키마 순서로 적용한다. BP가 마지막인 이유는 BP 테이블이 코어 테이블을 참조할 수 있어서다(반대는 없다).
schemaDir을 생략하면 포탈은 이 BP의 마이그레이션에 손대지 않는다 — 내가 알아서 하겠다는 선언이다.
6. 라이프사이클 훅
ts
hooks: {
async onInstall(ctx) {}, // 최초 설치 시 1회(마이그레이션 이후)
async onActivate(domainId) {}, // 도메인에서 활성화될 때
async onDeactivate(domainId) {}, // 비활성화될 때
async onUninstall() {},
}onInstall이 받는 ctx는 좁혀진 설치 컨텍스트다 — 포탈의 데이터 저장소 전체가 아니라 꼭 필요한 것만 노출한다. 현재는 공통코드 등록만 열려 있다.
ts
async onInstall(ctx) {
await ctx.codes.upsertDefine({ define: "HELLO_STATUS", defineName: "처리 상태", useYn: true, sysYn: false });
await ctx.codes.upsertCode({ code: "OPEN", codeName: "열림", define: "HELLO_STATUS", sortNo: 1, sysYn: false, useYn: true });
}활성화/비활성화는 도메인별로 독립이다. 같은 BP가 도메인 A에서는 활성, B에서는 비활성일 수 있다. 비활성화해도 권한 부여 기록은 보존되며 재활성화 시 복원된다.
7. 시크릿과 파일
ts
const key = host.deriveSecret("field-encryption"); // 이 BP·이 용도 전용 (hex)
const dir = host.dataDir; // 이 BP 전용 파일 저장 경로포탈 마스터키 원본은 넘어오지 않는다. 같은 (BP, purpose)에는 항상 같은 값이 나오므로 저장한 암호문을 다음 부팅에도 열 수 있다 — 반대로 purpose 문자열을 바꾸면 기존 암호문을 복호화할 수 없다.
8. 로깅
ts
host.logger.info("주문 생성", { orderId });포탈 로그 체계에 실리고 businessPackageId는 자동으로 붙는다. console.log는 포탈의 로그 조회 화면에 나타나지 않는다.
함께 볼 것
- host-contract.md — 경계에서 지켜야 하는 것
- build-and-package.md — 이것을 어떻게 번들로 만드나