테마
백엔드 가이드
업무패키지(Business Package, 이하 BP)의 서버 측 구현 — API, 권한, 홈 카드, 데이터베이스, 라이프사이클, 오류 처리, 그리고 계약 1.1.0에서 열리고 1.2.0에서 경계가 좁혀졌으며 1.3.0에서 서버가 만든 파일의 저장 통로와 사용자 토큰 차단 사유가 더해진 포탈 자원 접근 표면(§13~§19).
정본 예제: examples/task-bp/backend/src/. 이 문서는 코드를 옮겨 적지 않고 규칙과 그 근거를 기술하며, 완결된 코드는 그 디렉터리를 가리킨다.
1. register() — 유일한 진입점
정본: examples/task-bp/backend/src/register.ts.
ts
export function register(host: BpHostRuntime): BpRegistration {
const runtime = { host, repo: createRepo(host) }; // 컨트롤러가 주입받을 값
return {
manifest: { /* 능력 표면 — §2·§4·§5 */ },
controllers: [TaskController],
providers: [{ provide: TASK_RUNTIME, useValue: runtime }],
hooks: { async onInstall(ctx) { /* §8 */ } },
};
}register()는 async로 선언해도 된다.providers배열의 내용을 포탈이 해석하지 않고 그대로 NestJS에 전달한다. 주입 토큰은 BP가 정한다.- 컨트롤러가
host를 얻는 경로는 이providers하나뿐이다. 컨트롤러 안에서 주입받은 값의 이름과providers에 선언한 값의 형태는 반드시 대응해야 한다 —{ host }로 감싸 주입했다면 컨트롤러에서는this.rt.host.checkAppAccess(...)이고,host를 직접 주입했다면this.host.checkAppAccess(...)다. - 번들링 과정에서 컨트롤러 클래스의
name이 변경될 수 있다(esbuild가 이름 충돌을 해소한다). 클래스 이름으로 컨트롤러를 찾는 코드를 작성하지 않는다.
2. 업무 화면(App)과 메뉴는 분리해 선언한다
| 목록 | 기술 내용 |
|---|---|
apps[] | 실제 업무 화면 — id / name / uri / moduleCode / dispYn. 필드별 규칙은 bp-manifest.md §apps |
menu[] | 트리 위치 — id / parentId / appId 또는 name(그룹) / sortNo |
분리한 이유는 같은 App을 여러 메뉴 위치에 배치할 수 있어야 하고, 관리자가 메뉴를 재배치해도 App 선언이 변경되어서는 안 되기 때문이다.
apps[].id와menu[].id는 upsert 기준값이다. 한 번 배포한 뒤 변경하면 기존 레코드와 연결되지 않고 새 레코드가 생성된다.- 관리자가 이미 이동시킨 메뉴 노드는 다음 기동 시 상위 노드와 정렬 순서를 덮어쓰지 않는다. 새로 추가된 노드만 삽입된다.
- 매니페스트에서 App이 제거되면 그 App과 이를 참조하는 메뉴는 숨김 처리되며 삭제되지는 않는다. 선언을 복원하면 다시 표시된다.
App을 어떤 기준으로 나누는가
App은 권한 부여와 메뉴 배치의 단위이지 "화면 하나"의 단위가 아니다.
- 권한을 다르게 부여해야 하는 단위로만 App을 나눈다.
- 한 App 안의 하위 화면 전환은 URL 파라미터로 표현한다.
- App 사이의 이동은 위젯의
host.actions.navigateToApp()으로 가능하다(frontend-guide.md §7). 다만 그것은 App 경계를 넘는 이동이므로, 같은 업무 흐름 안의 목록↔상세 전환에 사용할 수단이 아니다.
3. API 경로 — 라우트 접두어는 BP가 직접 선언한다
포탈은 BP 컨트롤러에 경로 접두어를 자동으로 추가하지 않는다. 포탈에 전역 접두어 설정이 없고, BP 컨트롤러는 포탈 코어 컨트롤러와 같은 애플리케이션의 라우트 루트에 그대로 등록된다.
따라서 @Controller() 경로에 bp/{businessPackageId}/를 직접 써야 한다.
ts
@Controller("bp/task-bp/tasks") // → /bp/task-bp/tasks
export class TaskController { /* … */ }| 선언 | 실제 라우트 | 결과 |
|---|---|---|
@Controller("bp/task-bp/tasks") | /bp/task-bp/tasks | 정상. 프론트엔드의 host.apiBasePath(/bp/task-bp)와 대응한다 |
@Controller("tasks") | /tasks | 포탈 코어의 라우트 이름 공간에 등록된다. 코어 라우트와 이름이 겹치면 어느 쪽이 우선하는지 보장되지 않으며, 실패는 예외가 아니라 잘못된 응답으로 나타난다 |
실제로 운영 중인 업무패키지 하나가 @Controller("health")로 선언되어 포탈 코어의 /health와 겹쳐 있다. 이것은 지켜지지 않았을 때 무슨 일이 벌어지는지를 보여주는 사례이지 따라야 할 예가 아니다.
프론트엔드에서는 이 경로를 하드코딩하지 않고 host.apiBasePath 뒤에 자기 경로만 연결한다 — frontend-guide.md §2.
정본:
examples/task-bp/backend/src/ids.ts가API_PREFIX =bp/${BP_ID}``를 한 곳에서 합성하고, 컨트롤러와 카드의dataPath가 모두 그 상수에서 파생된다. 접두어를 파일마다 손으로 적으면 언젠가 한 곳만 어긋난다. 예제의preflight.mjs는 모든@Controller경로가bp/{businessPackageId}로 시작하는지 검사한다.
라우트에 포탈이 자동으로 적용하는 판정은 하나뿐이다
BP 라우트에 포탈이 자동으로 적용하는 것은 세션 인증뿐이다. 미인증 요청은 컨트롤러에 도달하기 전에 401로 거부된다. 그 뒤의 두 판정은 자동으로 이루어지지 않는다.
| 판정 | 누가 수행하는가 |
|---|---|
| 인증(로그인 여부) | 포탈. 전역 가드가 모든 라우트에 적용된다. 세션 쿠키뿐 아니라 Authorization: Bearer 토큰도 이 가드를 통과한다(§9-1) |
| 이 사용자가 이 App에 권한을 갖는가 | BP. 핸들러마다 host.checkAppAccess()를 호출한다(§4) |
| 이 도메인에서 이 BP가 활성인가 | 어느 쪽도 수행하지 않는다. App 화면 진입(/apps/{domainId}/{appUri})에는 이 판정이 적용되지만 /bp/{id}/… REST 경로에는 적용되지 않는다 |
그 결과 두 가지를 BP가 책임진다.
- 모든 핸들러에서 권한을 판정한다. 판정하지 않은 핸들러는 그 포탈에 로그인한 사용자 전원에게 열려 있다 — 메뉴에 표시되지 않아도 경로를 아는 사용자는 호출할 수 있다.
- 비활성 도메인에서도 BP의 REST는 응답한다. 화면 진입은 차단되지만 API는 차단되지 않으며,
checkAppAccess()도 권한 부여만 판정할 뿐 그 도메인에서 BP가 활성인지는 판정하지 않는다. 비활성 상태에서 응답이 달라야 하는 업무 규칙이 있다면 BP가 자기 데이터로 표현한다.
4. 권한 — 판정은 전부 포탈이 수행한다
BP는 권한을 선언하고, 판정은 host.checkAppAccess()에 질의한다. 자체 권한 테이블을 만들어 판정하면 관리자 화면에서 부여한 권한과 어긋난다.
ts
if (!(await this.rt.host.checkAppAccess(req, domainId, APP_LIST, "read"))) {
throw new ForbiddenException();
}판정에 대해 두 가지를 함께 알아 둔다.
- 전역 소속 권한은 모든 도메인에서 통과한다. 도메인이 지정되지 않은(전역) Authority를 부여받은 사용자는 어느
domainId로 질의해도 그 권한이 적용된다. "도메인마다 다르게 판정된다"는 전제 위에 업무 규칙을 세우지 않는다. req가 계약이 기대하는 객체가 아니어도 예외가 발생하지 않고false가 반환된다. 다른 값을 전달한 실수가 "권한 없음"으로만 나타나 원인이 지시되지 않는다. 컨트롤러가 전달받은 요청 객체를 그대로 넘긴다.
rw는 비트마스크다
defaultAuthorities[].apps[].rw는 정수 비트마스크다. bit0 = 읽기(1), bit1 = 쓰기(2).
rw | checkAppAccess(…, "read") | checkAppAccess(…, "update") | 메뉴·App 노출 | 사용 |
|---|---|---|---|---|
0 | false | false | 표시되지 않음 | 권한 없음 |
1 | true | false | 표시됨 | 읽기 전용 역할 |
2 | true | true | 표시됨 | 사용하지 않는다(아래) |
3 | true | true | 표시됨 | 읽기+쓰기 역할 |
2(쓰기 비트만)를 사용하지 않는 이유는 그것이 동작하지 않아서가 아니라, 동작은 3과 같은데 관리 화면과 감사 산출물이 다른 사실을 표시하기 때문이다.
- 실행 시 권한 판정은 쓰기 비트만으로도 읽기를 부여하므로
rw: 2는 인가상rw: 3과 완전히 같다. - 그런데 관리자 권한 그리드는 저장된 원시 값을 비트로 표시하므로 "읽기 없음, 쓰기만"이라는 사실과 다른 상태를 표시한다. Excel 내보내기도 같은 값을 출력한다.
- 그 화면에서 저장을 눌러도 체크박스 상태가 그대로 되쓰이므로 열어서 저장하는 것만으로는 값이 교정되지 않는다.
따라서 신규 BP는 0 / 1 / 3만 사용한다. 포탈은 rw 값의 범위를 검증하지 않으며 SDK 타입도 rw: number이므로, 잘못된 값을 넣어도 빌드·타입체크가 전부 통과한다. 예제는 이 값을 상수로 고정하고(examples/task-bp/backend/src/ids.ts) 배포 전 점검이 범위를 다시 검사한다(scripts/preflight.mjs).
defaultAuthorities가 하는 일과 하지 않는 일
defaultAuthorities[]는 설치 시 자동 생성되는 권한 템플릿이다. 기동할 때마다 매니페스트 기준으로 재동기화되므로, App을 새로 추가하면 기존 권한 보유자도 그 화면에 접근할 수 있게 된다. 관리자가 변경한 권한 표시 이름은 덮어쓰지 않는다.
자동화 범위는 "권한 정의"까지다. 사용자에게 실제로 부여하는 것은 관리자의 담당 범위다.
permissions[](권한 스코프 키)는 설명용이며 자동 프로비저닝에 사용되지 않는다.
5. 홈 인사이트 카드
Portal Shell(포탈 셸) 홈 화면에 배치되는 지표 카드다. 책임 분리가 이 기능의 핵심이다.
- BP는 형태(
kind)와 데이터 경로(dataPath)만 선언한다. 색·차트 라이브러리·레이아웃은 지정하지 않는다. - 카드를 렌더링하는 주체는 위젯이 아니라 Portal Shell이다. Portal Shell이 카드마다 독립적으로 BP의 엔드포인트를 호출한다. 한 카드의 지연이나 실패는 다른 카드로 확산되지 않는다.
- 수치는 GraphQL로 전달되지 않는다. 카드 선언(틀)만 GraphQL로 내려오고 수치는 아래 REST 엔드포인트로만 전달된다.
5-1. cards[] 필드
| 필드 | 필수 | 기본값 | 내용 |
|---|---|---|---|
id | ✅ | 레지스트리 upsert 기준이자 Portal Shell이 보는 cardId. {businessPackageId}:{키} 형식이어야 한다 — 아래 5-5 참고 | |
appId | ✅ | 같은 매니페스트의 apps[].id. 이 값이 두 가지를 동시에 결정한다 — 카드의 가시성 권한(그 App에 읽기 권한이 없으면 카드가 표시되지 않는다)과 카드 메뉴의 "앱 열기" 목적지. 참조가 유효하지 않으면 그 카드는 등록 자체를 건너뛴다(부팅 로그에 경고만 남고 부팅은 계속된다) | |
name | ✅ | 카드 제목 | |
kind | ✅ | KPI | LINE | DONUT | BAR. 이 네 값 이외는 등록 시점에 데이터베이스 쓰기 오류가 된다 | |
dataPath | ✅ | BP API 진입점 기준 상대 경로. 포탈이 앞에 /bp/{businessPackageId}를 결합한다 | |
defaultCols | 4 | 12칸 격자의 칸 수. 넓은 화면 구간에서만 그대로 사용되고 중간 구간에서는 6칸 또는 12칸으로 조정된다 | |
defaultRows | 1 | 픽셀이 아니라 최소 높이 전환용이다. 1보다 크면 카드 최소 높이가 커진다 | |
minCols | 2 | 어떤 화면 구간에서도 밑돌지 않는 하한 | |
ranges | 없음 | 선택 가능한 기간 키 목록. 생략하거나 비우면 Portal Shell이 기간 변경 메뉴를 노출하지 않는다 | |
defaultRange | ranges[0] | ranges가 있을 때만 저장된다 | |
sortNo | 1 | 카탈로그 정렬 | |
dispYn | true | 카탈로그 노출 |
두 가지를 알고 있어야 한다.
- 카드 카탈로그는 부팅할 때마다 매니페스트 기준으로 덮어쓰인다. 메뉴 배치와 달리 관리자 수정분을 보존하지 않는다.
- 선언이 사라진 카드는 삭제되지 않고 비활성 상태로 전환된다. 사용자 배치 레코드가 남으므로 선언을 복원하면 카드도 홈에 복원된다.
기간 키는 Portal Shell이 라벨을 번역해 제공하는 8개 중에서 고르는 것을 권장한다 — all, 7d, 30d, 90d, 365d, 12w, 26w, 52w. 다른 키를 쓰면 키 문자열이 그대로 화면에 표시된다. 카드 이름은 포탈의 번역 체계에서 키 card.{cardId}로 번역할 수 있고, 값이 없으면 매니페스트의 name이 사용된다.
5-2. 데이터 엔드포인트 계약
dataPath: "/api/cards/open-tickets"를 선언하고 BP id가 task-bp라면 Portal Shell이 호출하는 최종 URL은 이렇다.
GET /bp/task-bp/api/cards/open-tickets?domainId={도메인}[&range={기간키}][&locale={로케일}]- 컨트롤러 경로에
bp/task-bp/를 직접 선언해야 한다(§3). domainId는 항상 포함된다.range는 사용자가 선택한 값 또는defaultRange다. 기간이 없는 카드에는 포함되지 않는다. 선언에 없는range값에는 400을 반환하는 것이 권장 구현이다.locale은 소문자다(ko,en).- 인증은 same-origin 세션 쿠키다. 커스텀 헤더는 추가되지 않는다. 엔드포인트 안에서
checkAppAccess(req, domainId, appId, "read")로 인가를 다시 판정한다.
5-3. 응답 형태 — 타입을 SDK에서 import한다
payload 형태와 규모 상한은 SDK가 소유한다. 계약 1.1.0부터 @starj-portal/bp-backend-sdk가 카드 타입 전체와 상한 상수를 export하므로, BP가 이 계약을 자기 소스에 옮겨 적지 않는다. 옮겨 적으면 Portal Shell(포탈 셸) 쪽에서 값이 변경되어도 BP가 알 방법이 없고, 어긋난 결과는 카드가 "표시할 수 없음" 상태가 되는 형태로만 나타난다.
ts
import type { CardDataResponse, KpiCardPayload, DonutCardPayload } from "@starj-portal/bp-backend-sdk";
import { CARD_LIMITS, checkCardLimits } from "@starj-portal/bp-backend-sdk";kind | payload 타입 |
|---|---|
KPI | KpiCardPayload |
LINE | LineCardPayload |
DONUT | DonutCardPayload |
BAR | BarCardPayload |
공통 봉투는 CardDataResponse다 — kind / updatedAt(ISO 8601 문자열) / payload 세 필드이며, updatedAt은 현재 화면에 표시되지 않지만 타입상 필수다. 필드 목록은 타입 선언이 단일 원천이므로 여기 옮겨 적지 않는다.
bash
grep -n "interface\|:" node_modules/@starj-portal/bp-backend-sdk/dist/interfaces/card-payloads.d.ts타입이 표현하지 못하는 규칙은 아래 네 가지다.
KpiCardPayload.value의null과0은 다르다.null은 "표시할 값이 없음"이고0은 유효한 값이다. 없는 사실을0으로 내려보내면 화면이 그것을 단정한다.delta.sentiment가 색을 결정한다. 증감의 방향과 좋고 나쁨이 반대인 지표(처리시간 증가, 미해결 건수 감소 등)를 BP가 직접 표현한다. Portal Shell은 이 값을 자신의 시맨틱 색으로 옮기기만 한다.LineCardPayload의x는 Portal Shell이 파싱하지 않는 불투명한 라벨 문자열이다(날짜 형식일 필요가 없다).y는number | null이며, 계열이 2개 이상이면 범례가 자동으로 추가된다.DonutCardPayload.segments의 순서는 받은 그대로 렌더링된다(재정렬되지 않는다).- payload 안의 문자열은 BP가 번역한다. 세그먼트 라벨·단위·중앙 문구는 요청의
locale로 BP가 직접 번역한다. 포탈이 번역하는 것은 카드 이름뿐이다.
빈 상태 판정: LINE은 모든 계열의 points가 0개, DONUT은 segments가 0개, BAR은 bars가 0개, KPI는 value === null일 때다.
Portal Shell은 payload의 내부 구조를 검증하지 않는다. 필수 필드를 누락한 응답을 반환하면 렌더링 단계에서 오류가 발생할 수 있다.
5-4. 규모 상한 — 초과분을 잘라 주지 않는다
상한을 넘으면 Portal Shell이 초과분을 잘라 내지 않고 그 카드를 실패 상태로 표시한다. 앞의 N개만 남기면 "최근 값이 사라진 추이선"처럼 오류 메시지 없이 거짓인 그림이 산출되고, 무엇을 버려 요약할지는 데이터의 의미를 아는 BP만 판단할 수 있기 때문이다. 응답의 kind가 매니페스트 선언과 다를 때도 마찬가지로 실패 상태가 된다.
상한 값을 문서나 BP 소스에 옮겨 적지 않는다. SDK의 CARD_LIMITS를 import해 집계 단계에서 먼저 줄이고, 응답을 만들기 직전에 checkCardLimits(kind, payload)로 스스로 확인한다 — 위반 사유 문자열을 반환하고 문제가 없으면 null을 반환한다.
bash
node -e "import('@starj-portal/bp-backend-sdk').then(m => console.log(m.CARD_LIMITS))"5-5. 카드 id 명명 규약은 필수 규칙이다
cards[].id는 {businessPackageId}:{키} 형식이어야 한다. Portal Shell이 위젯에 전달하는 호스트 액션 세 가지(isCardOnHome / addCardToHome / removeCardFromHome)는 인자로 받은 cardId가 자기 BP id로 시작하지 않으면 예외를 발생시킨다.
접두어가 없는 id를 쓰면 카탈로그 화면으로는 카드를 배치할 수 있지만 위젯이 그 카드를 호스트 액션으로 다룰 수 없는 비대칭 상태가 된다. 서버 측 뮤테이션에는 이 접두어 검사가 없으므로 규약을 지키는 것은 BP의 책임이다.
그 밖에 알고 있어야 하는 것.
- 한 사용자의 홈에 배치할 수 있는 카드는 최대 24개다.
isCardOnHome은 매 호출마다 서버에 질의한다. 연속 호출을 피한다.
6. 데이터베이스 — 접속과 트랜잭션과 이름 공간
6-1. 클라이언트는 BP가 직접 생성한다
포탈이 제공하는 것은 접속 URL 문자열, 방언 이름, 데이터 이름공간 이름뿐이다(host.database = { url, provider, namespace }, namespace는 호스트 계약 2.0.0부터). PrismaClient도 트랜잭션 핸들도 전달되지 않는다.
PostgreSQL 클라이언트에는 namespace를 반드시 넘긴다(계약 2.0.0). 2.0.0 포탈은 BP 테이블을 BP 전용 스키마에 둔다. PostgreSQL 어댑터는 생성 쿼리에 스키마 이름을 붙이고, schema 옵션이 없으면 public을 쓴다. 옵션이 누락되면 테이블을 찾지 못하거나 권한 오류로 실패한다. SQLite에서는 namespace가 null이며 넘길 것이 없다.
ts
if (host.database.provider === "postgresql") {
const adapter = new PrismaPg(
{ connectionString: host.database.url, max: 5 },
host.database.namespace ? { schema: host.database.namespace } : undefined,
);
return new PgClient({ adapter });
}정본: examples/task-bp/backend/src/db.ts. 그 파일이 다루는 두 가지가 핵심이다.
- 방언마다 클라이언트를 별도로 생성해야 한다. 쿼리 컴파일러가 다르므로 하나의 클라이언트로 양쪽을 지원할 수 없다. 실행 중인 방언은
host.database.provider로 판별한다. - 두 생성 클라이언트는 구조가 같아도 TypeScript 상 별개 타입이다. 유니온으로 받아 delegate를 호출하면
TS2349: This expression is not callable로 막힌다. 저장소 인터페이스(TaskRepo)를 하나 정의하고 타입 좁히기를 팩토리 한 지점에만 두는 방식으로 해결한다.
커넥션 풀은 BP가 스스로 제한해야 한다. 포탈은 풀 크기를 설정하지 않고, BP 클라이언트를 등록·집계·종료하는 지점도 없다. BP가 N개면 한 프로세스 안에 클라이언트가 N+1개 이상 존재하고 총 커넥션 상한을 관리하는 코드가 어디에도 없다. PostgreSQL이라면 어댑터에 넘기는 풀 설정의 max로 상한을 정한다(위 예제의 max: 5, 정본은 examples/task-bp/backend/src/db.ts의 PG_POOL_MAX).
접속 URL의 ?connection_limit=N으로는 제한되지 않는다. 그 매개변수는 Prisma 내장 쿼리 엔진이 읽는 값이고, 드라이버 어댑터(@prisma/adapter-pg)가 쓰는 pg 드라이버의 풀은 읽지 않는다. 이 매개변수는 쓰지 않는다.
포탈이 BP 클라이언트를 종료시키지 않는다. 정리가 필요하면 onDeactivate에서 $disconnect()를 직접 호출한다. 프로세스 종료 시점에 호출되는 훅은 계약에 없다.
6-2. 트랜잭션 — 포탈 트랜잭션에 참여할 수 없다
- 포탈은 요청 단위 트랜잭션을 열지 않는다. 요청 범위 트랜잭션을 전파하는 장치(인터셉터·요청 스코프 저장소)가 포탈에 없다.
- BP가 포탈 트랜잭션에 참여할 방법이 없다. 트랜잭션 핸들이 호스트 계약에 없고, 포탈의 데이터 접근 계층도 노출되지 않는다.
- 따라서 BP 쓰기와 포탈 쓰기는 서로 다른 클라이언트·서로 다른 커넥션이며, 하나의 트랜잭션으로 묶을 수 없다. 계약 2.0.0에서는 BP 데이터가 다른 DB 서버에 있을 수도 있다(§6-3).
- 원자성은 BP 자신의 클라이언트가 제공하는
$transaction으로 자기 테이블 범위 안에서만 얻는다.
포탈 데이터와 BP 데이터를 함께 변경해야 하는 업무가 있다면, 그 업무를 하나의 트랜잭션으로 만들 수는 없다. 실패 시 보정(compensation)을 BP가 설계한다.
6-3. 데이터 이름공간과 테이블 접두어
계약 2.0.0부터 BP 데이터는 BP 전용 이름공간에 있다. 포탈은 BP마다 전용 이름공간과 그 이름공간에만 권한을 가진 전용 DB 계정을 만들고, host.database.url로 그 계정의 접속 주소를 넘긴다. 코어 계정의 자격증명은 BP에 오지 않는다.
| 방언 | 이름공간 | host.database.namespace |
|---|---|---|
| PostgreSQL | BP 전용 스키마(운영자 설정에 따라 BP 전용 데이터베이스) | 스키마 이름(예: bp_hr). 데이터베이스 방식이면 null |
| SQLite(개발 모드) | BP별 파일 | null |
- 전용 계정으로는
core_테이블과 다른 BP의 테이블을 읽을 수도 쓸 수도 없다. 사용자·도메인 정보는 호스트 계약으로 받고, 식별자를 값으로만 저장한다. 코어 테이블을 가리키는 외래 키는 만들지 않는다. - 저장 위치는 운영자가 설치 때 고른다. 포탈 기본 DB일 수도 있고 다른 DB 서버일 수도 있다. BP 코드는 저장 위치를 알 필요가 없고 알아낼 수단도 없다. 받은 접속 정보만 쓴다.
- 마이그레이션 이력은 BP 이름공간 안에 기록된다(§7-4).
1.x 포탈에서는 BP 테이블이 포탈 코어 테이블과 같은 데이터베이스, 같은 스키마에 있고 url은 코어 계정의 접속 주소다. 그 접속으로 코어 테이블에 닿을 수는 있지만 사용하면 계약 위반이며, 2.0.0으로 옮기면 실패한다.
| 이름 | 소유자 |
|---|---|
core_ 접두어를 가진 모든 테이블 | 포탈 코어(예약). BP가 사용해서는 안 된다. 2.0.0에서는 BP 전용 계정으로 조회할 수 없다 |
_prisma_migrations | Prisma CLI. BP 마이그레이션은 여기에 기록되지 않는다(§7-4) |
_dev_applied_migrations | 포탈 |
BP의 모든 테이블에 bp_{businessPackageId}_ 접두어를 추가한다. Prisma에서는 @@map으로 지정한다.
prisma
model Employee {
// …
@@map("bp_hr_employee")
}businessPackageId에 하이픈이 있으면 밑줄로 바꾼다 — task-bp → bp_task_bp_. 정본 예제가 이 형태를 사용한다.
물리 테이블 이름은 접두어를 포함해 50자 이내로 유지한다. PostgreSQL 식별자 한계가 63바이트이고, Prisma가 인덱스와 제약에 테이블 이름을 포함한 더 긴 이름을 자동으로 생성하기 때문이다.
테이블 접두어는 계약 2.0.0에서도 유지한다. 이름공간이 나뉘어도 DBA 도구에서 소속을 알아보는 용도와 반입 검증(V8)의 근거로 쓰인다.
패키지 아카이브로 설치하면 포탈이 접두어를 검사한다(검증 항목 V8). 접두어 밖의 테이블을 만들거나, 다른 설치 패키지와 접두어가 같거나 한쪽이 다른 쪽의 접두어이면 반입 단계에서 거부된다. 개발 모드의 소스 폴더(PORTAL_BP_DIR) 경로는 이 검사를 하지 않는다. 그 경로에서 두 BP가 같은 테이블 이름을 쓰면 데이터베이스가 거부해서 드러난다.
| 방언 | 개발 소스 폴더에서 두 BP가 같은 테이블 이름을 쓰면 |
|---|---|
| PostgreSQL | 두 번째 BP의 CREATE TABLE이 실패해 그 BP의 미적용 마이그레이션 전체가 취소되고, 그 BP만 POSTGRESQL_MIGRATION_FAILED로 건너뛰어진다 |
| SQLite | 같은 방식으로 취소되고 그 BP만 SQLITE_MIGRATION_FAILED로 건너뛰어진다 |
어느 방언이든 마이그레이션에 IF NOT EXISTS가 있으면 이 실패가 일어나지 않고 두 BP가 같은 물리 테이블을 공유하게 된다(§7-4).
마이그레이션 이력은 BP id와 마이그레이션 디렉터리 이름의 쌍으로 기록된다. 그래서 디렉터리 이름은 한 BP 안에서만 유일하면 된다. 계약 2.0.0에서는 BP마다 이름공간이 다르므로 개발 소스 폴더에서도 두 BP의 테이블 이름이 서로 충돌하지 않는다. 위 표는 1.x 포탈의 동작이다.
6-4. 도메인 격리는 BP의 책임이다
포탈의 모든 권한이 도메인별이므로 BP 테이블도 도메인 컬럼을 보유해야 한다. 포탈이 BP의 쿼리에 도메인 조건을 추가해 주지 않는다.
- 모든 조회 조건에
domainId를 포함한다. - 갱신·삭제 조건에도 포함한다. 기본키만으로 갱신하면 다른 도메인의 레코드를 변경할 수 있다. 정본 예제가
update대신updateMany+ 재조회를 쓰는 이유가 이것이다.
7. 마이그레이션
7-1. 산출물 배치
<schemaDir>/postgresql/schema.prisma 필수
<schemaDir>/postgresql/migrations/<타임스탬프>_<이름>/migration.sql
<schemaDir>/postgresql/migrations/migration_lock.toml
<schemaDir>/sqlite/… sqlite를 지원하면 동일 구조- 방언 디렉터리 이름은
sqlite/postgresql리터럴이다. - 적용 순서는 디렉터리 이름의 사전순이다. 타임스탬프 접두 명명이 사실상 필수다.
schemaDir을 생략하면 포탈은 아무 마이그레이션도 적용하지 않는다.
7-2. prisma.config.ts는 BP 산출물에 포함하지 않는다
Prisma 7부터 schema.prisma의 datasource에 url을 쓸 수 없고(P1012), 마이그레이션 명령은 config 파일의 datasource.url을 요구한다. 그 접속 정보는 포탈이 소유한다.
- 포탈에 고정 config 파일이 하나 있고, BP마다 스키마·마이그레이션 경로만 환경변수로 전달해 그 파일을 재사용한다.
- BP 설치 디렉터리에는
node_modules가 없고 읽기 전용으로 마운트되므로 그 위치에서 config를 해석할 수 없다. - 역할 분담은 이렇다 — "무엇을 적용할지"(스키마·마이그레이션 경로)는 BP가, "어디에 적용할지"(접속 URL)는 포탈이 제공한다.
BP 저장소에도 config 파일이 필요하다. 다만 그것은 로컬에서 마이그레이션 SQL을 생성할 때만 사용하고 dist/에 포함하지 않는다. 정본: examples/task-bp/prisma-config/{sqlite,postgresql}.config.ts.
7-3. 마이그레이션 SQL을 만드는 방법
정본: examples/task-bp/scripts/make-migration.mjs. 핵심은 세 가지다.
prisma migrate dev가 아니라prisma migrate diff를 쓴다.migrate dev는 개발용 데이터베이스에 실제로 접속해 shadow database를 만든다. BP는 두 방언 모두의 SQL을 만들어야 하는데 PostgreSQL 개발 데이터베이스를 항상 구동해 둘 수는 없다.migrate diff는 접속 없이 스키마만 비교해 SQL을 산출한다.--config를 반드시 전달한다. Prisma 7의 마이그레이션 명령은 config의datasource.url을 요구한다.- 결과가 비어 있으면 명시적으로 중단시킨다.
prisma migrate diff는 인자가 누락되면 stderr 출력도 없이 exit 0에 빈 출력을 산출한다(내부 엔진의 실패를 CLI가 흡수한다). 그대로 진행하면 0바이트migration.sql이 산출물에 포함되고, 포탈에서 테이블이 생성되지 않은 채 BP가 적재된다.
bash
DEBUG='prisma:*' prisma migrate diff --config prisma-config/sqlite.config.ts \
--from-empty --to-schema backend/prisma/sqlite/schema.prisma --script원인을 확인할 때만 DEBUG='prisma:*'를 사용한다. 그렇지 않으면 실패가 표면에 나타나지 않는다.
두 번째 마이그레이션부터는 스냅샷과 비교한다. 스크립트는 마이그레이션을 쓸 때마다 그 시점의 정본 스키마를 backend/prisma-src/snapshot/schema.prisma에 함께 남긴다. 다음 실행은 두 방언 모두 스냅샷에서 파생한 방언 스키마를 이전 상태로 써서 --from-schema <이전> --to-schema <지금>으로 차이분을 만든다. 접속이 필요 없다. --from-migrations는 쓰지 않는다. 그 옵션은 쌓인 마이그레이션을 shadow database에 재생하는데, PostgreSQL은 실제 서버가 필요해 두 번째 마이그레이션부터 datasource.shadowDatabaseUrl을 요구하며 실패한다.
- 스냅샷은 손으로 고치지 않고 커밋한다.
backend/prisma밖에 있으므로dist/에는 들어가지 않는다. - 스냅샷이 마이그레이션과 맞는지 매번 먼저 확인한다. SQLite 마이그레이션을 재생한 상태(SQLite는 접속 없이 재생된다)가 스냅샷과 다르면 아무것도 쓰지 않고 멈춘다. 생성된 SQL을 손으로 고칠 때는 결과 상태가 스키마와 같게 고친다. 열 이름을 바꿀 때 생성된
DROP COLUMN·ADD COLUMN(SQLite는 재정의 블록)을ALTER TABLE … RENAME COLUMN "a" TO "b"로 바꾸는 것이 그 예다. 그래야 데이터가 남는다. - 두 방언을 함께 만들거나 하나도 만들지 않는다. 두 방언의 SQL을 모두 만든 뒤에 파일을 쓰고, 쓰는 중에 실패하면 이번 실행에서 만든 폴더를 지운다. 시작할 때 방언별 마이그레이션 폴더 이름이 서로 같은지도 확인한다. 한 방언에만 마이그레이션이 늘어난 상태는 최종 테이블 집합이 같으면 반입 검증(V8)도 통과하므로, 그 방언의 포탈에서 실행 중에야 드러난다.
- 스냅샷 없이 만든 기존 저장소는 SQLite 마이그레이션의 상태가 지금 정본과 같을 때 한 번 실행하면 스냅샷이 만들어진다. 정본을 이미 고쳤다면 되돌리고 실행해 스냅샷을 만든 뒤 다시 고친다.
마이그레이션과 스냅샷은 산출물이 아니라 소스다. 저장소에 커밋한다.
7-4. 포탈은 마이그레이션을 이력으로 한 번만 적용한다
포탈은 적용한 마이그레이션의 이름과 SHA-256 체크섬을 기록하고, 아직 적용하지 않은 것만 적용한다. 이 사실이 마이그레이션 SQL 작성 방식을 결정한다. 적용 주체는 설치 경로와 방언에 따라 다음과 같다.
| 설치 경로 | 방언 | 언제 적용되는가 | 이력 | 실패하면 |
|---|---|---|---|---|
패키지 아카이브(운영 포탈, starj package add) | 두 방언 모두 | 승인 뒤 적용 단계에서, 새 코드가 기동하기 전에 포탈의 마이그레이션 러너가 migration.sql을 그대로 실행한다(prisma CLI를 쓰지 않는다) | 포탈의 패키지 마이그레이션 이력 표(계약 2.0.0에서는 BP 이름공간 안의 이력 표). 미적용분만 적용하고, 이미 적용된 파일의 내용이 바뀌면 거부한다 | BP 하나의 미적용분 전체가 한 트랜잭션이다. 하나라도 실패하면 전부 취소되고 적용이 실패로 끝난다 |
개발 모드의 소스 폴더(PORTAL_BP_DIR) | 두 방언 모두 | 포탈 부팅 중, 그 BP를 적재하기 직전에 위와 같은 러너가 실행한다 | 위와 같은 표(BP id 기준). 미적용분만 적용하고, 바뀐 파일은 거부한다 | 미적용분 전체가 취소되고 그 BP만 건너뛴다(SQLITE_MIGRATION_FAILED·POSTGRESQL_MIGRATION_FAILED). 부팅은 계속된다 |
계약 2.0.0에서 마이그레이션은 BP 전용 계정으로 BP 이름공간 안에서 실행된다. 마이그레이션 SQL에 스키마 이름을 쓰지 않는다. 러너가 접속의 기본 이름공간을 BP 이름공간으로 정하므로 prisma migrate diff가 만든 한정자 없는 SQL이 그대로 그 이름공간에 적용된다. Prisma가 PostgreSQL 첫 마이그레이션에 넣는 CREATE SCHEMA IF NOT EXISTS "public";는 러너가 건너뛴다. 그 문장을 지울 필요는 없다.
마이그레이션 SQL은 prisma migrate diff가 만든 그대로 둔다. 두 방언 모두 후처리하지 않는다. 이미 적용한 마이그레이션은 다시 실행되지 않으므로 재실행에 대비할 필요가 없다. ALTER TABLE … ADD COLUMN, 데이터 이행 문장(INSERT·UPDATE·DELETE), Prisma가 열 타입 변경에 만드는 테이블 재정의 블록도 한 번만 실행되므로 그대로 쓸 수 있다. 정본: examples/task-bp/scripts/make-migration.mjs.
CREATE TABLE IF NOT EXISTS·CREATE INDEX IF NOT EXISTS를 쓰지 않는다. 이력이 없는데 같은 이름의 테이블이 이미 있다면 그것은 드러나야 하는 사실이다. 다른 BP의 테이블일 수도 있고, 손으로 만들었거나 이전 방식으로 설치하고 남은 테이블일 수도 있다. IF NOT EXISTS가 없으면 CREATE TABLE이 실패해 적용이 취소된다. 있으면 오류 없이 지나가 기존 테이블을 그대로 쓰므로, 열 구성이 달라도 실행 중에야 드러난다. 포탈의 반입 검증(V8)은 이 구문을 경고(IF_NOT_EXISTS_PRESENT)로 알리고, 정본 예제의 배포 전 점검(preflight.mjs)은 실패로 본다.
이전 판 개발 키트의
make-migration.mjs는 SQLite SQL에IF NOT EXISTS를 덧붙였다. 당시 개발 소스 폴더의 SQLite 적용기가 이력 없이 부팅마다 전체를 다시 실행했기 때문이다. 그 스크립트를 복사해 쓰고 있다면 후처리(toReRunnable())를 지우고, 이미 만든 SQLitemigration.sql에서도 그 구문을 지운다. 파일 내용이 바뀌므로 그 마이그레이션을 이미 적용한 개발 DB는 아래 방법으로 초기화한다.
이미 적용한 migration.sql은 고치지 않는다. 체크섬이 달라지면 러너가 "이미 적용된 마이그레이션이 변경되었습니다"(PACKAGE_MIGRATION_FAILED)로 적용을 거부한다. 개발 소스 폴더에서는 그 BP가 SQLITE_MIGRATION_FAILED 또는 POSTGRESQL_MIGRATION_FAILED로 건너뛰어진다. 바꿀 내용은 pnpm run migrate:new <이름>으로 새 마이그레이션을 만들어 반영한다.
개발 중에 이미 적용한 파일을 고쳤거나, 이력 없이 테이블만 남은 개발 DB라면 포탈 저장소에서 pnpm bp:dev-reset <BP id>를 실행한다. 그 BP의 bp_<id>_ 접두어 테이블과 마이그레이션 이력을 지우므로(계약 2.0.0에서는 그 BP의 이름공간 전체를 지운다), 다음 기동에서 마이그레이션이 처음부터 적용된다. 테이블의 데이터도 함께 지워진다. 개발 DB에서만 쓴다.
패키지 아카이브로 설치하는 마이그레이션은 허용 목록의 문장만 쓸 수 있다(검증 항목 V8). CREATE VIEW·CREATE TRIGGER·CREATE FUNCTION, 그 밖의 PRAGMA, 접두어 밖의 테이블에 쓰는 문장이 있으면 반입 단계에서 거부된다. 목록은 build-and-package.md §7-6에 있다. 정본 예제의 preflight.mjs는 포탈과 같은 검사기를 starj CLI로 실행해 이 판정을 미리 받는다.
두 설치 경로의 이력은 두 방언 모두에서 이어진다. 개발 소스 폴더와 패키지 아카이브가 같은 이력 표를 쓰기 때문이다. 개발 소스 폴더로 적용한 DB에 같은 BP를 아카이브로 설치하면, 이름과 체크섬이 같은 마이그레이션은 건너뛰고 나머지만 적용된다. 예전 개발 도구(prisma migrate deploy)가 _prisma_migrations에 남긴 BP 이력은 이어받지 않는다. 그 도구로 테이블을 만든 개발 DB는 pnpm bp:dev-reset <BP id>로 초기화한다.
적용했다는 사실은 실제로 적용해 보고 확인한다. 아카이브 경로는 examples/task-bp/scripts/verify-package.mjs가 러너와 같은 방식(한 트랜잭션)으로 빈 SQLite에 적용하고, 만든 테이블이 아카이브 매니페스트의 database.tables와 같은지 본다. examples/task-bp/scripts/verify-deploy.mjs는 BP가 쓰는 Prisma가 dist/의 구성을 인식하는지 prisma migrate deploy로 확인하고, 마이그레이션을 전부 적용한 결과가 schema.prisma와 같은지(스키마만 고치고 migrate:new를 빠뜨리지 않았는지) 확인한다. 포탈은 이 명령을 쓰지 않는다.
방언 판정은 허용 목록으로 한다 — 접속 URL이 file:로 시작하면 sqlite, postgres://·postgresql://로 시작하면 postgresql이다. 그 밖의 URL(MySQL·MariaDB·Oracle 등)이면 포탈이 기동하지 않는다. BP는 host.database.provider만 보면 된다.
7-5. 업그레이드 중에도 이전 코드가 동작한다 — 확장·정리 마이그레이션 (계약 2.1.0)
포탈은 새 코드가 기동하기 전에 마이그레이션을 적용한다(§7-4). 그래서 다음 세 경우에 이전 버전 코드가 새 스키마에서 동작한다.
- 포탈 인스턴스가 여럿이면 한 대씩 재시작한다. 그동안 아직 재시작하지 않은 인스턴스에서 이전 코드가 동작한다.
- 새 버전이 상태 확인을 통과하지 못하면 포탈이 이전 버전 코드로 자동으로 되돌린다. 마이그레이션은 되돌리지 않으므로 이전 코드가 새 스키마에서 계속 동작한다.
- BP가 직접 등록한 타이머와 다른 BP의
host.services호출은 업데이트 중에도 이전 코드로 실행된다(아래 "업데이트 중에 막히는 것과 막히지 않는 것").
포탈은 업데이트 중 그 BP의 HTTP 요청을 거부해 이 위험을 줄인다. 다만 되돌린 뒤에도 거부를 풀려면 이전 코드가 새 스키마에서 동작한다는 보장이 필요하다. 그 보장을 BP가 선언하는 키가 backend.database.backwardCompatible이고, 이전 코드를 깨뜨리는 변경을 뒤로 미루는 곳이 backend.database.contractDir이다(bp-manifest.md §backend).
| 묶음 | 위치 | 언제 실행되는가 | 넣는 것 |
|---|---|---|---|
| 확장 마이그레이션 | schemaDir | 승인 뒤 적용 단계, 새 코드가 기동하기 전 | 이전 코드가 아는 테이블을 깨뜨리지 않는 변경 |
| 정리 마이그레이션 | contractDir | 모든 포탈 인스턴스가 새 버전을 적재한 뒤 포탈이 실행 | 이전 코드를 깨뜨리는 변경. 모든 문장을 쓸 수 있다 |
backwardCompatible: true이면 포탈이 확장 마이그레이션을 문장 단위로 검사한다. 반입할 때 한 번(검증 V8), 적용하기 직전에 한 번 한다. 검사 대상은 아직 적용하지 않은 확장 마이그레이션이 이미 있던 테이블에 하는 변경이다. 이미 있던 테이블이란 지금까지 적용한 마이그레이션을 재생해 얻은 테이블, 즉 이전 코드가 아는 테이블이다. 이번 릴리스에서 처음 만드는 테이블은 이전 코드가 쓰지 않으므로 검사하지 않는다. 그래서 첫 설치는 항상 통과한다.
| 문장 | 확장 | 정리 | 이유 |
|---|---|---|---|
| 테이블 추가, 비유일 인덱스 추가 | 허용 | 허용 | 이전 코드가 모른다 |
널 허용 열 추가, DEFAULT가 있는 열 추가 | 허용 | 허용 | 이전 코드의 삽입이 그대로 성공한다 |
NOT NULL인데 DEFAULT가 없는 열 추가 | 거부 | 허용 | 이전 코드의 삽입이 실패한다 |
| 열 삭제, 테이블 삭제, 인덱스 삭제, 제약 삭제, 열거형 삭제 | 거부 | 허용 | 이전 코드의 읽기·쓰기·업서트가 실패한다 |
| 테이블·열·제약·열거형 이름 변경 | 거부 | 허용 | 이전 코드가 옛 이름을 찾는다 |
| 열 자료형 변경(넓히는 변경 포함) | 거부 | 허용 | 해석 규칙이 방언마다 다르다 |
SET NOT NULL, DROP DEFAULT | 거부 | 허용 | 이전 코드가 널·기본값에 기대고 있을 수 있다 |
기존 테이블에 UNIQUE·CHECK·PRIMARY KEY·FOREIGN KEY 추가(인라인, 제약 추가, 유일 인덱스 포함) | 거부 | 허용 | 이전 코드의 쓰기가 제약 위반으로 실패한다 |
DROP NOT NULL, SET DEFAULT, 열거형 값 추가 | 허용 | 허용 | 이전 코드의 동작을 좁히지 않는다 |
INSERT, UPDATE | 허용 | 허용 | 값의 형식·의미를 바꾸는 UPDATE는 검사하지 못한다(아래) |
DELETE | 거부 | 허용 | 이전 코드가 있다고 가정한 행이 사라진다 |
SQLite에서 Prisma는 열 변경 대부분을 "새 테이블 new_X를 만들고 데이터를 옮긴 뒤 이름을 바꾸는" 블록으로 만든다. 포탈은 이 블록을 문장으로 보지 않고 변경 전후의 열 구성(열 이름, 자료형, NOT NULL, 기본값, 제약)을 비교해 같은 표로 판정한다. 널 허용 열 추가는 이렇게 만들어져도 허용이다. 블록 뒤에 Prisma가 다시 만드는 같은 열 조합의 유일 인덱스는 새 제약으로 보지 않는다.
위반이면 반입이 거부되고 메시지에 <방언>/<마이그레이션> 문장 <n>: <규칙> <설명>. 정리 마이그레이션(contractDir)으로 옮기세요가 나온다. 그 문장을 정리 마이그레이션으로 옮긴다. backwardCompatible이 없거나 false이면 검사하지 않고 지금처럼 경고만 낸다.
포탈이 검사하지 못하는 것은 BP 개발자가 확인한다.
- 값의 의미를 바꾸는
UPDATE. 예를 들어 상태 코드 값을 다른 체계로 바꾸면 이전 코드가 새 값을 이해하지 못한다. - 새 코드만 쓰는 열거형 값을 이전 코드가 읽었을 때의 동작. 값 추가는 허용이지만, 이전 코드가 모르는 값을 만나 예외를 던지는지는 검사하지 않는다.
- 트리거·함수 본문. BP 마이그레이션의 허용 목록에는 이 문장들이 없지만 같은 이유로 검사 대상도 아니다.
- 인덱스를 지우지 않아도 쿼리 계획이 바뀌어 느려지는 성능 영향.
한 번의 변경을 확장과 정리로 나누는 방법
확장은 승인 때, 정리는 새 버전이 모든 인스턴스에 적재된 뒤에 실행되므로 아래 변경은 모두 한 릴리스에 넣을 수 있다. 조건은 두 가지다. 새 코드는 정리 마이그레이션이 지울 것을 더 이상 쓰지 않는다. 정리가 실행된 뒤에는 그 릴리스 이전 버전으로 되돌릴 수 없다.
| 하려는 변경 | 확장 마이그레이션 | 새 코드 | 정리 마이그레이션 |
|---|---|---|---|
| 열 삭제 | 없음 | 그 열을 읽지도 쓰지도 않는다 | DROP COLUMN |
| 열 이름 변경 | 새 이름의 열 추가(널 허용)와 UPDATE로 값 복사 | 새 열에 쓰고, 이전 코드와 함께 동작하는 동안 옛 열에도 함께 쓴다. 새 열이 비어 있으면 옛 열에서 읽는다 | 옛 열 삭제 |
| 자료형 변경 | 새 자료형의 열 추가와 값 복사 | 이름 변경과 같다 | 옛 열 삭제 |
| 테이블 이름 변경 | 새 테이블 추가와 데이터 복사 | 이름 변경과 같다 | 옛 테이블 삭제 |
NOT NULL 추가 | 널 허용 열 추가(또는 DEFAULT와 함께 추가) | 항상 값을 쓴다 | 비어 있는 행을 UPDATE로 채운 뒤 SET NOT NULL(같은 트랜잭션) |
UNIQUE·CHECK·외래 키 추가 | 없음 | 제약을 어기는 쓰기를 하지 않는다 | 위반 행을 정리한 뒤 제약 추가 |
| 테이블·행 데이터 삭제 | 없음 | 그 데이터를 읽지 않는다 | DELETE, DROP TABLE |
| 열거형 값 이름 변경·삭제 | 새 값 추가 | 새 값만 쓴다. 이전 코드가 새 값을 읽을 때 문제가 없는지 확인한다 | 열거형 정리 |
이름을 바꾸는 동안 새 코드는 옛 열과 새 열에 함께 쓴다. 인스턴스를 한 대씩 재시작하는 동안 이전 코드와 새 코드가 같은 테이블을 동시에 쓴다. 새 코드가 새 열에만 쓰면, 이전 코드가 옛 열에만 쓴 행과 새 코드가 새 열에만 쓴 행이 섞인다. 그래서 새 코드는 두 열에 모두 쓰고, 읽을 때 새 열이 비어 있으면 옛 열을 읽는다.
Prisma 스키마에는 그 릴리스의 새 코드가 쓰는 열을 모두 적는다. Prisma 클라이언트는 스키마에 있는 열을 모두 읽고 쓰므로, 정리 마이그레이션이 지운 열이 스키마에 남아 있으면 정리 뒤 새 코드의 조회가 실패한다. 이 때문에 변경은 두 경우로 구분된다.
- 열 삭제, 데이터 삭제,
NOT NULL·제약 추가처럼 새 코드가 지울 대상을 쓰지 않는 변경은 위 표대로 한 릴리스에 넣는다. 지울 열은 그 릴리스의 스키마에서 뺀다. - 열·테이블 이름 변경과 자료형 변경은 새 코드가 옛 열에도 써야 하므로 옛 열이 스키마에 남는다. 옛 열을 지우는 정리 마이그레이션은 옛 열에 쓰는 코드를 지운 다음 릴리스에 넣는다. 같은 릴리스에 넣으면 정리 직후 새 코드가 없는 열에 쓰려다 실패한다.
정리 마이그레이션이 실행되는 조건
- 그 버전(또는 더 새 버전)이 확정되었고 확인 중인 새 버전이 없다.
- 살아 있는 모든 포탈 인스턴스가 그 버전을 적재했다. 그래서 인스턴스를 한 대씩 재시작하는 동안에는 실행되지 않는다.
- BP가 활성 상태다. 비활성이면 보류한다.
- 포탈 정책이 자동 실행을 허용하거나 운영자가 실행을 지시했다. 운영자가 되돌릴 수 있는 기간을 두려면 자동 실행을 비활성화한다.
정리가 실행된 뒤에는 그보다 낮은 버전으로 되돌릴 수 없다. 포탈이 되돌림 요청을 거부한다. 정리 전에는 되돌릴 수 있다.
정리가 실패해도 서비스에는 영향이 없다. 한 트랜잭션이므로 전부 취소되고 스키마는 확장된 상태로 남는다. 포탈은 정해진 횟수만 자동으로 다시 시도하고, 그 뒤는 운영자의 재시도를 기다린다. 정리 문장이 사용자 요청을 오래 막지 않도록 잠금 대기 시간에 상한이 있다. 큰 테이블의 제약 추가처럼 오래 잠그는 문장은 상한에 걸려 실패할 수 있다.
같은 BP의 정리가 대기 중이거나 실행 중이면 다음 버전의 적용은 정리가 끝날 때까지 기다린다. 다음 버전의 확장 마이그레이션이 정리 전 상태를 가정하지 않게 하기 위해서다. 정리가 실패한 상태이면 기다리지 않고, 다음 버전이 적용되면 아직 실행하지 않은 정리 마이그레이션 전부가 그 버전의 정리 대상이 된다.
정리 마이그레이션 작성 규칙
- 구조는
schemaDir과 같다:<contractDir>/<방언>/migrations/<폴더 이름>/migration.sql. 선언한 방언마다 두고, 방언끼리 폴더 이름이 같아야 한다. - 폴더 이름은 같은 릴리스의 확장 마이그레이션보다 뒤에 정렬되어야 한다. 포탈은 확장과 정리를 이름 순 하나의 시간선으로 재생해 "이전 코드가 아는 테이블"을 계산한다. 타임스탬프 접두어를 쓰고, 정리 마이그레이션은 그 릴리스의 확장 마이그레이션을 만든 뒤에 만든다.
- 확장 마이그레이션과 같은 폴더 이름을 쓰지 않는다. 반입이 거부된다.
- 확장 마이그레이션처럼 모든 버전의 아카이브에 누적해서 넣고, 이미 적용한 파일은 고치지 않는다. 이력에는
contract/<폴더 이름>으로 남는다. - 허용 목록(검증 V8)과 테이블 접두어 규칙은 확장 마이그레이션과 같다.
- 개발 모드의 소스 폴더(
PORTAL_BP_DIR)에서는 이전 코드가 함께 동작하지 않으므로 포탈이 확장 직후 같은 부팅에서 정리를 실행한다. - 2.0.0 포탈은
contractDir을 읽지 않으므로 정리 마이그레이션을 실행하지 않는다. 스키마는 확장된 상태로 남는다.
정리 마이그레이션 만들기. 정본 예제(examples/task-bp)의 스크립트는 contractDir을 다룬다. 이 스크립트를 복사해 쓰는 BP는 아래 여섯 파일을 함께 가져간다. 이전에 복사한 스크립트는 정리 마이그레이션이 생기면 스냅샷 확인과 verify-deploy.mjs에서 멈춘다.
scripts/gen-manifest.ts의backend.database에backwardCompatible: true와contractDir(예:"backend/prisma-contract")을 적고pnpm run manifest를 실행한다. 두 키의 타입은 개발 키트 2.1.0부터 있다.- 정본 스키마에 이전 코드를 깨뜨리지 않는 변경만 하고
pnpm run migrate:new <이름>으로 확장 마이그레이션을 만든다. - 정본 스키마에서 지울 것을 지우고
pnpm run migrate:new <이름> --contract로 정리 마이그레이션을 만든다. 스크립트가 같은 차이를<contractDir>/<방언>/migrations/<타임스탬프>_<이름>/에 쓰고, 새 폴더 이름이 기존 확장·정리 폴더보다 뒤에 정렬되는지 확인한다.
| 스크립트 | 정리 마이그레이션을 어떻게 다루는가 |
|---|---|
scripts/lib/migrations.mjs | 확장과 정리를 폴더 이름 순 하나의 시간선으로 합친다. 같은 폴더 이름이 두 묶음에 있으면 멈춘다 |
scripts/make-migration.mjs | --contract로 정리 마이그레이션을 쓴다. 스냅샷 확인은 합친 시간선을 재생해서 한다 |
scripts/build.mjs | 방언별 정리 폴더가 있고 이름이 같은지, 확장과 겹치지 않는지 확인한 뒤 contractDir을 dist/의 같은 상대 경로에 복사한다 |
scripts/verify-deploy.mjs | 합친 시간선을 prisma migrate deploy로 적용하고, 결과가 schema.prisma와 같은지 확인한다 |
scripts/verify-package.mjs, scripts/preflight.mjs | 확장과 정리를 묶음마다 한 트랜잭션으로 적용해 database.tables와 비교한다. 파생 매니페스트에 backwardCompatible·contractDir이 옮겨졌는지 확인한다. 폴더 구성과 허용 목록 판정은 starj CLI에 맡긴다 |
contractDir을 매니페스트에 옮기는 것은 starj CLI(starj package pack)다. 이 키를 모르는 CLI(0.2.0 이하)로 묶으면 파생 매니페스트에서 빠지고 포탈이 V2로 거부한다. verify-package.mjs가 이 상태를 실패로 알린다. contractDir을 선언한 BP는 starj CLI 0.3.0 이상으로 묶는다. 정본 예제는 CLI 0.2.0을 쓰므로 contractDir을 선언하지 않는다.
업데이트 중에 막히는 것과 막히지 않는 것
포탈은 이미 사용 중인 BP를 서버 코드 교체가 필요한 새 버전으로 바꿀 때 마이그레이션 적용부터 모든 인스턴스가 새 버전을 적재할 때까지 그 BP의 HTTP 요청을 거부한다(503 BP_UPDATING, host-contract.md §4). BP 코드는 그동안 호출되지 않는다.
| 경로 | 업데이트 중 |
|---|---|
그 BP의 REST 경로(/bp/<id>/…)와 위젯 번들 | 거부한다. Portal Shell(포탈 셸)은 화면 위치에 업데이트 안내를 표시한다 |
| 외부 스케줄러가 그 BP의 REST를 호출 | 거부한다. 호출하는 쪽은 Retry-After(초)가 지난 뒤 다시 호출한다 |
BP가 register()에서 직접 등록한 타이머(§9) | 막지 않는다. 이전 코드가 마이그레이션 중인 스키마에서 계속 실행된다 |
다른 BP가 host.services로 이 BP의 서비스를 호출(§17) | 막지 않는다. 이전 코드가 계속 응답한다 |
| 다른 BP, 포탈 화면, 첫 설치, UI 번들만 바뀐 업그레이드 | 막지 않는다 |
막히지 않는 두 경로는 이전 코드가 확장 마이그레이션 중이거나 직후의 스키마에서 실행되는 경로다. 이 경로가 있는 BP일수록 위 규칙을 지켜 backwardCompatible: true를 선언한다. 타이머 콜백은 실패해도 다음 주기에 다시 실행되도록 작성한다.
backwardCompatible이 true가 아닌 BP는 되돌림 뒤에도 거부가 풀리지 않는다. 운영자가 직접 풀 때까지 그 BP는 사용할 수 없다(install-to-portal.md §5).
8. 라이프사이클 훅
ts
hooks: {
async onInstall(ctx) {}, // §8-1
async onActivate(domainId) {}, // §8-2
async onDeactivate(domainId) {}, // §8-2
async onUninstall() {}, // §8-3
}8-1. onInstall — "최초 1회"가 아니다
onInstall은 매니페스트의 version 문자열이 그 BP에 대해 처음 등장할 때마다 실행된다. 판정은 semver 비교가 아니라 문자열 정확 일치다. 그 결과는 이렇다.
- 버전을 내려도(
2.2.0→2.1.0) 그 문자열 기록이 없으면 "신규"로 판정되어 실행된다. - 한 번 설치했던 버전으로 되돌아오면(
2.1.0→2.2.0→2.1.0) 실행되지 않는다. - 마이그레이션은 이 판정과 완전히 별개 경로다.
onInstall이 실행되지 않아도 마이그레이션은 적용되며 그 반대도 성립한다. 훅 안에서 DDL을 실행하지 않는다.
실패하면 다음 기동에서 다시 실행된다.
onInstall이 예외를 발생시켜도 포탈 부팅은 계속된다. 그 BP는 적재된 채로 남고, 포탈 애플리케이션 로그에 BP id·버전과 예외 메시지가 남는다.- 포탈은 훅이 성공한 뒤에만 설치 버전을 기록한다. 그래서 다음 기동에서 같은 버전의
onInstall이 다시 실행되고, 성공할 때까지 기동마다 반복된다. - 그동안 BP는 시딩이 끝나지 않은 채로 요청을 받는다. 훅이 만들지 못한 데이터를 전제하는 요청은 실패한다.
따라서 onInstall은 다음 세 가지를 지킨다.
upsert계열만 사용한다. 훅은 버전을 올릴 때마다, 그리고 실패한 뒤에는 기동할 때마다 다시 실행된다. 앞선 실행이 중간까지 만든 데이터 위에서 다시 실행되어도 결과가 같아야 한다.create를 쓰면 두 번째 실행에서 중복 키로 실패한다. 정본 예제의 배포 전 점검은onInstall을 두 번 실행해 결과가 같은지 확인한다.- 끝내지 못한 시딩은 예외로 알린다. 예외를 발생시키면 다음 기동에서 다시 실행된다. 훅 안에서 예외를 삼키면 포탈이 성공으로 보고 버전을 기록하므로, 그 버전에서는 다시 실행되지 않는다. 없어도 BP가 동작하는 단계만 훅 안에서 처리하고
host.logger.error로 기록한다. - 다중 인스턴스 배포에서는 첫 부팅이 경합할 수 있다. 리더 선출도 잠금도 없으므로 여러 인스턴스가 동시에 "신규 버전"으로 판정할 수 있다.
onInstall이 전달받는 ctx는 제한된 설치 컨텍스트다 — 포탈의 데이터 저장소 전체가 아니라 필요한 기능만 노출한다. 현재는 공통코드 등록(ctx.codes.upsertDefine / ctx.codes.upsertCode)만 제공한다. 파일 저장(files)은 없다 — 훅에는 요청 세션이 없어 파일을 만든 사용자를 정할 수 없다(§19).
관리자가 고친 공통코드를 지키려면 onExisting: "keep"을 지정한다(계약 1.3.0). 훅은 버전을 올릴 때마다 다시 실행되는데, 기본값 "overwrite"는 같은 키가 있어도 입력 값으로 덮어쓴다. 그러면 관리자가 공통코드 화면에서 시드 코드의 이름을 바꾸거나 비활성화한 것이 버전을 올릴 때 시드 값으로 되돌아간다.
ts
await ctx.codes.upsertDefine({ define: "HR_DEPT", defineName: "부서", useYn: true, sysYn: false, onExisting: "keep" });
await ctx.codes.upsertCode({ define: "HR_DEPT", code: "D100", codeName: "인사팀", sortNo: 1, useYn: true, sysYn: false, onExisting: "keep" });onExisting | 같은 키가 이미 있을 때 | 없을 때 |
|---|---|---|
"overwrite"(생략 시) | 이름·정렬·사용 여부·시스템 여부를 입력 값으로 덮어쓴다 | 만든다 |
"keep" | 아무것도 바꾸지 않는다 | 만든다 |
- 키는 그룹이
define, 코드가define+code다. 코드를 쓰기 전에 그 그룹이 있어야 한다(없으면 거부된다). - 소유권 판정은 두 값 모두 같다. 소유자가 없는 기존 그룹은 이 BP가 인수하고, 다른 업무패키지 소유이거나 포탈이 예약한 그룹이면
BP_CODE_FORBIDDEN으로 거부된다."keep"이 건너뛰는 것은 그 뒤의 값 쓰기뿐이다. "keep"을 쓰면runtime.hostContract를^1.3.0으로 선언한다. 1.2.0 포탈은 이 필드를 오류 없이 무시하고 덮어쓰므로, 게이트가 아니면 그 차이를 알 수 없다."keep"은 시드 값을 고친 새 버전도 반영하지 않는다. 시드 코드의 이름을 일부러 바꿔 배포하려면 그 코드만"overwrite"로 쓴다.
ctx에는 도메인도 데이터베이스도 없다. 데이터베이스는 BP가 register() 시점에 생성한 자기 클라이언트를 클로저로 사용하면 되지만, 도메인은 그렇게 얻을 수 없다. BP의 모든 테이블이 domain_id를 보유하는 것이 규약인데(§6-4) 설치 시점에는 어느 도메인에서 이 BP가 활성화될지 정해지지 않았다. 그래서 도메인별 기준정보는 onInstall에서 생성할 수 없다. onActivate(domainId)는 도메인을 전달받지만 부팅 훅이 아니고 다중 인스턴스에서 한 대에만 도달하므로(§8-2) 대안이 되지 않는다.
성립하는 방법은 첫 사용 시점 시딩이다.
- 그 도메인의 요청을 처음 처리할 때 기준정보의 존재를 확인하고, 없으면 생성한다.
- 완료 여부를 BP 자기 테이블의 행 하나로 표시하고, 그 판정을 프로세스 안에서 캐시해 매 요청의 왕복을 없앤다.
upsert로 작성해 여러 인스턴스가 동시에 실행해도 결과가 같게 만든다.- 대가는 그 도메인의 첫 사용자가 시딩 비용을 부담한다는 것이다.
8-2. onActivate / onDeactivate — 부팅 훅이 아니다
두 훅은 관리자가 활성화·비활성화를 조작했을 때만 호출된다. 포탈 부팅 시에는 호출되지 않는다. 초기화 목적으로 사용하면 동작하지 않는다.
그리고 호출 범위가 "그 관리자 요청을 처리한 인스턴스 1대에서 1회" 다. 조율 장치(잠금·리더 선출·데이터베이스 플래그)가 없고 설치 목록이 프로세스 지역 상태이므로, 다중 인스턴스 배포에서는 나머지 인스턴스에 훅이 도달하지 않는다. 이 훅에서 프로세스 내 상태를 변경하면 인스턴스 간 상태가 구분된다.
상태 전이와 감사 기록은 훅보다 먼저 커밋된다. 훅이 예외를 발생시켜도 전이는 되돌아가지 않는다. 활성화한 BP는 활성 상태로, 비활성화한 BP는 비활성 상태로 남는다. 포탈은 실패를 다음과 같이 다룬다.
- 관리자 조작은 오류로 끝나지 않는다. 포탈이 그 도메인×BP의 활성화 상태에 실패 시각과 오류 이름(
error.name)을 기록하고, 관리 화면이 그 칸에 "훅 실패"를 표시한다. - 관리자가 관리 화면에서 재시도한다. 포탈은 현재 상태에 맞는 훅(활성이면
onActivate, 비활성이면onDeactivate)을 다시 호출한다. 성공하면 표시가 사라진다. 실패 중에도 활성화·비활성화를 다시 할 수 있으므로,onActivate가 중간에 멈췄다면 비활성화해onDeactivate로 정리할 수도 있다. - 함께 활성화·비활성화에서는 한 BP의 훅이 실패해도 나머지 BP의 훅이 계속 실행된다. 실패한 BP마다 따로 기록된다.
- 메뉴·권한·API 노출은 상태를 따른다. 실패 기록은 판정에 쓰이지 않는다. 훅이 마치지 못한 준비 작업이 있어도 사용자 요청은 그 도메인에 들어온다.
- 포탈은 예외 메시지와 스택을 관리 화면·감사로그에 싣지 않는다. 관리자가 보는 것은 오류 이름과 시각뿐이다. 포탈의 애플리케이션 로그에는 BP id·도메인 id·훅 이름과 예외 메시지가 남지만 스택은 남지 않는다. 원인을 추적하려면 BP가 훅 안에서
host.logger.error로 무엇을 하다 실패했는지(도메인 id, 단계, 대상 식별자)를 직접 남긴다(§11). 메시지에 비밀값을 넣지 않는다.
활성화·비활성화는 도메인별로 독립적으로 적용된다. 비활성화해도 권한 부여 기록은 보존되며 재활성화 시 복원된다.
훅 반복 안전 규약
onActivate와 onDeactivate는 같은 도메인에서 여러 번 실행될 수 있다. 다음 경우가 모두 그렇다.
- 관리자가 실패한 훅을 재시도한다.
- 이미 활성인 도메인에 API로 같은 활성화를 다시 요청한다(비활성화도 같다).
- 비활성화한 뒤 다시 활성화한다.
- 두 관리자가 같은 칸을 거의 동시에 조작한다.
따라서 두 훅은 여러 번 실행해도 결과가 같게 작성한다. onUninstall(§8-3)과 같은 요구다. 앞선 실행이 중간에 예외로 멈췄더라도 다음 실행이 이어서 완료할 수 있어야 한다. 방법은 §8-1의 시딩 규칙과 같다.
upsert계열만 사용한다.create는 두 번째 실행에서 중복 키로 실패한다. 삭제는 대상이 이미 없어도 실패하지 않는deleteMany로 쓴다.- 이미 한 일은 BP 자기 테이블의 완료 표시 행으로 건너뛴다. 단계가 여럿이면 단계마다 완료를 표시하고, 다음 실행은 표시가 없는 단계부터 진행한다.
- 중간에 멈춰도 다시 실행할 수 있는 순서로 쓴다. 완료 표시는 그 단계의 작업이 끝난 뒤에 쓴다. 표시를 먼저 쓰면 작업이 실패해도 다음 실행이 그 단계를 건너뛴다.
- 외부 시스템 호출에는 중복 방지 키를 추가한다. 도메인 id와 작업 종류로 만든 고유 요청 식별자를 보내, 같은 요청이 두 번 도달해도 상대 시스템이 한 번만 처리하게 한다. 상대가 이를 지원하지 않으면 호출 전에 완료 표시 행으로 이미 보냈는지 확인한다.
이 훅에서 프로세스 내 상태를 변경하지 않는다는 앞의 규칙도 그대로다. 훅은 요청을 처리한 인스턴스 1대에서만 실행되므로, 재시도도 다른 인스턴스에서 실행될 수 있다.
8-3. onUninstall — 관리자가 제거를 확정할 때 한 번
관리자가 관리 화면에서 BP 제거를 확정하면 포탈이 onUninstall을 한 번 호출한다. 포탈 부팅이나 프로세스 종료 시에는 호출되지 않는다. 호출 범위는 onActivate와 같이 그 관리자 요청을 처리한 인스턴스 1대다(§8-2). 인자는 없다. 도메인별로 호출되지 않으므로 이 훅은 모든 도메인의 데이터를 한꺼번에 정리한다.
관리자는 다음 조건을 모두 만족할 때만 제거할 수 있다. 조건을 어기면 포탈이 제거를 거부하고 훅도 호출하지 않는다.
- 필수(mandatory) BP가 아니다.
- 모든 도메인에서 비활성 상태다.
- 이 BP를 필수 의존(
dependsOn의required: true)으로 선언한 다른 BP가 어느 도메인에서도 활성 상태가 아니다.
훅이 호출되지 않는 경우가 있다. 아래 두 경우에는 BP 코드가 포탈 프로세스에 적재되어 있지 않으므로 포탈이 훅 없이 정리한다. 이때 BP 자체 테이블은 그대로 남는다.
- 외부 등록(
installExternal)으로 등록된 BP - 설치 폴더에서 소스가 사라져 소실 상태로 표시된 BP(관리 화면에는 "정리" 버튼으로 표시된다)
훅이 예외를 발생시키면 제거 전체가 취소되고 아무것도 바뀌지 않는다. 정리를 끝내지 못했다면 예외를 발생시켜 제거를 멈추는 편이 맞다. 반대로 훅이 성공한 뒤 포탈의 정리 단계가 실패할 수도 있다. 이때 훅이 지운 데이터는 되돌아오지 않지만 BP는 카탈로그에 남아 있으므로 관리자가 제거를 다시 시도할 수 있다. 그래서 이 훅은 두 번 실행해도 결과가 같게 작성한다. 이미 지운 행을 다시 지우려 할 때 실패하지 않아야 한다.
포탈이 정리하는 것과 남기는 것은 다음과 같다.
| 구분 | 대상 |
|---|---|
| 포탈이 지운다 | 이 BP의 App, 그 App의 메뉴 배치·즐겨찾기·최근 사용 기록·권한 매핑, 이 BP의 카드와 사용자 홈 배치, 활성화 레코드, 버전 기록, 이 BP가 선언한 의존 관계, BP 등록 정보 |
| 포탈이 남긴다 | Role 정의와 사용자 부여, 감사로그, 활성화 이력, 알림 카테고리(비활성으로 바뀐다) |
| 포탈이 손대지 않는다 | BP가 만든 테이블(bp_{id}_ 접두어 테이블)과 그 데이터, dataDir 아래 파일 |
BP가 만든 테이블의 데이터는 이 훅에서 BP가 직접 정리한다. 포탈은 BP 테이블의 구조를 알지 못하므로 지워 주지 않는다. register() 시점에 생성한 자기 클라이언트(아래 예의 db)로 행을 삭제한다.
ts
async onUninstall() {
// 자식 테이블부터 지운다. 이미 비어 있어도 실패하지 않는다.
await db.$transaction([
db.taskAttachment.deleteMany({}),
db.task.deleteMany({}),
]);
host.logger.info("uninstall: BP 데이터를 정리했습니다");
}테이블 자체를 삭제(DROP TABLE)하지 않는다. 포탈은 마이그레이션 적용 이력을 두 방언 모두에서 남기므로(§7-4), 같은 BP를 다시 설치해도 이미 적용된 마이그레이션은 다시 실행되지 않는다. 그러면 테이블이 없는 채로 BP가 기동한다. 행만 지우고 테이블은 남겨 둔다.
제거가 끝나면 관리 화면이 운영자에게 설치 폴더에서 BP 소스를 지우라고 안내한다.
제거 뒤에 소스가 남아 있으면 BP가 다시 설치된다. 관리자가 BP를 제거하면 포탈은 onUninstall을 호출한 뒤 카탈로그 기록(버전 기록 포함)을 지운다. 그래서 설치 폴더에 소스가 남아 있으면 다음 기동에서 포탈이 그 BP를 신규 설치로 판정해 다시 등록하고, onInstall도 다시 실행한다(§8-1). 이 시점의 BP 테이블에는 onUninstall이 정리하고 남은 상태가 그대로 있다. 따라서 onInstall은 제거 뒤 재설치에서도 안전해야 한다. upsert 계열로 작성하면 이 요구도 함께 충족된다.
9. 주기 작업 — 제공하지 않는다
포탈은 BP에게 스케줄 등록 수단을 제공하지 않는다. 호스트 런타임 객체에 스케줄러·타이머·큐·이벤트버스가 없고, 포탈 자신도 작업 스케줄러 라이브러리를 사용하지 않는다. "프로세스 기동 시" 훅도 없다 — onActivate는 부팅 훅이 아니다(§8-2).
대안은 두 가지다.
| 방식 | 내용 |
|---|---|
| 사용자 조작 | BP 화면에 실행 버튼을 두고 사용자가 실행한다. 실행 이력은 BP 자기 테이블에 기록한다 |
| 외부 스케줄러 | 운영 환경의 스케줄러(cron·워크플로 엔진 등)가 BP의 REST 엔드포인트를 호출한다. 인증 수단은 계약에 있다 — 아래 §9-1 |
BP가 register() 안에서 직접 타이머를 등록하는 것이 물리적으로 막혀 있지는 않다. 그러나 그렇게 하면 아래를 전부 BP가 책임져야 한다.
- 다중 인스턴스에서의 중복 실행. 포탈은 리더 선출을 제공하지 않는다.
- 정리 시점. 포탈이 타이머의 수명을 관리하지 않고, 프로세스 종료 훅도 없다.
- 떠도는 promise. 타이머 콜백에서 처리되지 않은 promise 거부가 발생하면 포탈 프로세스 전체가 중단된다(host-contract.md §6). 콜백 전체를
try/catch로 감싼다. - 업데이트 중의 실행. 포탈이 이 BP를 업데이트하는 동안 HTTP 요청은 거부하지만 타이머는 막지 않는다. 이전 코드의 타이머가 마이그레이션 중이거나 직후의 스키마에서 실행된다(§7-5).
9-1. 기계 대 기계 인증 — 외부 스케줄러가 BP를 호출하는 방법
외부 스케줄러의 호출에는 사람의 세션이 없다. 포탈은 그런 호출을 위해 OAuth2 Client Credentials(RFC 6749 §4.4) 트랙을 제공하며, 그 토큰으로 인증한 요청은 BP 라우트에도 도달한다.
bash
# 1) 토큰 발급 — client_id / client_secret은 포탈 운영자가 발급해 인계한다
curl -s -u "<client_id>:<client_secret>" \
-d grant_type=client_credentials \
https://<포탈 주소>/api/oauth/token
# → { "access_token": "…", "token_type": "Bearer", "expires_in": 3600 }
# 2) BP 엔드포인트 호출
curl -s -H "Authorization: Bearer <access_token>" \
"https://<포탈 주소>/bp/<businessPackageId>/sync?domainId=<도메인 id>"그러나 그 요청의 주체는 사람이 아니다. 사람 세션을 전제로 한 표면이 전부 다르게 동작하며, 그것을 모르고 구현하면 엔드포인트가 언제나 403으로 응답하고 그 원인이 지시되지 않는다.
| 표면 | 기계 자격증명 요청에서의 동작 |
|---|---|
host.currentUserNo(req) | null |
host.currentUsername(req) · host.users.current(req) | null |
host.checkAppAccess(req, …) | 항상 false — 판정 근거인 사용자 번호가 없다 |
host.audit.record({ …, req }) | 기록은 남지만 주체 없는(시스템) 기록이 된다 |
host.destinations.fetch(…, { req }) | 호출 사용자의 신원을 전파하는 계열의 Destination(PRINCIPAL_PROPAGATION, 사용자 출처 Token Exchange)은 사용할 수 없다. 403 + principal-required로 막힌다(§16) |
따라서 기계 호출용 엔드포인트는 사람의 권한 판정에 의존하지 않는 별도 경로로 설계한다.
- 사람과 기계가 같은 핸들러를 공유하지 않는다. 공유하면 사람 경로에서 권한 판정이 사라지거나 기계 경로가 항상 403이 된다.
domainId를 요청 파라미터로 명시적으로 전달받는다. 기계 요청에는 "지금 조회 중인 도메인"이 없다.- 호출한 클라이언트를 식별하는 표면이 계약에 없다. 유효한 토큰을 보유한 클라이언트라면 어느 것이든 이 엔드포인트를 호출할 수 있으며, 포탈에 등록된 모든 API 클라이언트가 여기 해당한다 — 용도가 전혀 다른 클라이언트도 포함된다. 클라이언트에 부여되는 캐파빌리티는 포탈 코어의 판정 축이라 BP의 App 권한과 무관하다. 그러므로 기계 엔드포인트에는 되돌릴 수 없는 조작을 두지 않고, 처리 범위를 그 엔드포인트 자신이 제한하며, 실행 결과를
host.audit.record()와host.logger에 남긴다. - 자격증명 발급은 운영자의 담당 범위다. 필요한 클라이언트와 그 용도를 인계 문서에 적는다(install-to-portal.md §1).
이 경로가 있어도 §9의 판정은 변하지 않는다 — 포탈은 여전히 스케줄을 등록해 주지 않는다. 실행 주기, 다중 실행 방지, 실패 시 재시도는 전부 그 외부 스케줄러의 책임이다.
10. 시크릿·설정값·파일
ts
const key = host.deriveSecret("field-encryption"); // 이 BP·이 용도 전용 (hex 문자열)
const dir = host.dataDir; // 이 BP 전용 로컬 디렉터리 — 캐시·작업 파일 전용dataDir은 그 프로세스의 로컬 디스크다. 캐시·작업 파일처럼 없어져도 되는 것에만 쓴다. 업무 첨부와 BP 서버가 만든 파일(발급 문서 등)은 host.files로 포탈 저장소에 둔다(§19) — 포탈을 여러 대로 늘리면 한 대의 dataDir에 저장한 파일을 다른 대가 읽지 못한다.
포탈 마스터키 원본은 전달되지 않는다. 같은 (BP, purpose) 조합에는 항상 같은 값이 반환되므로 저장한 암호문을 다음 기동 시에도 복호화할 수 있다 — 반대로 purpose 문자열을 변경하면 기존 암호문을 복호화할 수 없다. 반환 문자열의 길이는 계약에 규정되어 있지 않으므로, 고정 길이 키가 필요하면 BP가 해시 등으로 정규화한다.
포탈에는 BP의 일반 설정값을 보관하는 저장소가 없다. 업무 파라미터 같은 설정값은 BP가 자기 테이블에 보관하고, 자격증명만 deriveSecret 파생키로 암호화한다.
외부 시스템 접속 정보는 예외다. 계약 1.1.0부터 그것은 운영자가 포탈에 등록한 Destination(외부 연결처)으로 다루며, BP는 주소·자격증명을 자기 테이블에 복제하지 않고 host.destinations로 호출한다(§16). 설치 안내(install-to-portal.md §1)의 "필요한 외부 연결"은 운영자가 등록해야 하는 Destination의 이름과 요건을 인계하는 항목이다.
BP가 fetch·axios로 외부를 직접 호출하지 않는다. 물리적으로 막혀 있지는 않으나, 그렇게 하면 운영자가 주소를 변경할 수 없고 사내망 대상의 터널 경로를 사용할 수 없으며 경로 허용목록과 감사 기록이 우회된다 — 근거와 사용법은 §16에 있다.
11. 로깅과 감사
ts
host.logger.info("일감 생성", { domainId, taskId }); // info / warn / error 세 메서드가 전부다host.logger의 목적지는 포탈의 애플리케이션 로그(stdout)다. businessPackageId가 자동으로 함께 기록된다. console.log로 출력한 내용은 포탈의 로그 조회 화면에 표시되지 않는다.
여기에 남긴 것은 포탈 감사 화면에 표시되지 않는다. 애플리케이션 로그와 감사로그는 다른 저장소이고 성격도 다르다(감사로그는 추가 전용으로 영구 보존되고, 런타임 로그는 보존 상한을 두고 삭제된다).
BP가 포탈 감사로그에 기록하는 경로 — 계약 1.1.0에서 열렸다
host.audit.record()가 그 경로다. 승인·반려처럼 나중에 누가 무엇을 했는지 따져야 하는 업무 조작은 이쪽에 남긴다. host.logger에 감사 필드를 추가하는 방식은 목적지가 달라 동작하지 않는다. 예외를 발생시키지 않고 기록 성공 여부를 boolean으로 반환하며, false는 업무 실패가 아니다 — 사용법과 유의사항은 §18에 있다.
BP가 자기 테이블(예: bp_{id}_audit_log)에 별도로 이력을 남기는 것은 여전히 유효한 선택이다. 두 저장소의 목적이 다르다 — 포탈 감사로그는 조직 전체의 감사 화면에서 함께 조회되어야 하는 조작을, BP 자기 테이블은 그 업무 화면에서만 의미를 갖는 상세 이력을 담당한다.
BP 요청은 포탈 접근로그에도 남지 않는다
포탈의 접근로그는 App 화면을 여는 한 지점에서만 기록된다. /bp/{id}/… REST 호출은 그 경로를 통과하지 않는다. 모든 HTTP 요청에 자동으로 남는 기록은 stdout 성능 로그뿐이며 데이터베이스에 저장되지 않는다.
예외적으로 BP의 활성화·비활성화는 감사에 기록된다. 그것은 포탈이 자기 관리 조작을 기록하는 것이며 BP의 실행 동작이 아니다.
12. 오류 처리
BP 컨트롤러의 예외는 포탈의 전역 예외 필터가 처리한다. 무엇을 발생시켰는지에 따라 클라이언트가 받는 것이 달라진다.
| BP가 발생시킨 것 | HTTP 상태 | 응답 JSON |
|---|---|---|
HttpException 계열(ForbiddenException·NotFoundException·BadRequestException 등) | 그 예외의 상태 | NestJS의 표준 응답. 인자 없는 형태는 { statusCode, message, error } |
new HttpException("메시지", 400) | 400 | { statusCode, message } |
그 밖의 모든 것(평범한 Error 포함) | 500 | { statusCode: 500, message: "내부 오류가 발생했습니다" } — BP가 지정한 메시지는 클라이언트에 전달되지 않고 로그에만 기록된다 |
따라서 사용자에게 표시되어야 하는 메시지는 반드시 HttpException 계열로 발생시킨다. throw new Error("id가 정수가 아니다")는 사용자에게 "내부 오류가 발생했습니다"로만 표시된다.
- 스택과 원인 체인은 구조화 로그에만 기록된다.
- 요청 단위 격리는 이루어지지만 프로세스 단위 격리는 이루어지지 않는다. 컨트롤러 핸들러 밖에서 발생한 처리되지 않은 promise 거부는 포탈 프로세스 전체를 중단시킨다(host-contract.md §6).
- 라이프사이클 훅은 컨트롤러와 달리 이 필터의 보호를 받지 않는다(§8).
12-1. 요청 본문 크기 상한
JSON 본문의 상한은 1MB다. 포탈이 HTTP 서버의 기본값을 변경하지 않으므로 그 값이 그대로 적용되며, 초과한 요청은 BP 컨트롤러에 도달하기 전에 413으로 거부된다. 이 값은 계약에 노출되지 않으므로 BP가 런타임에 조회할 수 없다.
- 대량 데이터를 한 요청에 포함하지 않는다. 일괄 처리와 붙여넣기 입력은 위젯이 나누어 전송하고 BP가 결합한다.
- 파일은 이 경로로 전송하지 않는다. 첨부 파일은 §19의 경로를 사용하며 그쪽 상한은 별도다(포탈 저장소 대상의 설정값).
- 설치마다 다를 수 있다. 큰 본문을 다루는 기능이 있으면 실제 크기로 설치 후 한 번 확인한다.
- 이 상한은 JSON과 폼 인코딩 본문에만 적용된다. 그 밖의 content-type은 포탈이 파싱하지 않고 스트림을 그대로 전달하므로 크기 상한이 적용되지 않고,
@Body()에 파싱된 객체가 아니라 스트림이 전달된다. 임의의 content-type을 받는 엔드포인트를 만든다면 BP가 직접 읽고 크기를 제한한다.
13. 알림 발행 — host.notifications
ts
await host.notifications.publish({ domainId, category, recipients, content, deepLink });계약 1.1.0에서 열린 표면이다. 그 전에는 BP가 사람에게 알리려면 포탈을 외부 시스템처럼 취급해 자기 포탈의 REST를 호출하는 우회밖에 없었다.
할 수 있는 것 — 발행 하나다. 수신자는 userNos(포탈 사용자 번호 목록) / authorityKey / authorityKeys / broadcastDomain 중 하나만 지정한다. authorityKey는 매니페스트 defaultAuthorities[].authId이며, 포탈이 실제 Authority 식별자로 해석하므로 BP가 그 합성 규칙을 알 필요가 없다.
수신자의 경계는 계약 1.2.0에서 좁혀졌다. 두 가지가 함께 적용된다.
- 해석되는
authorityKey는 이 BP가 매니페스트defaultAuthorities[]에 선언한 것뿐이다. 그 밖의 값 — 포탈 코어의auth-portal-basis나 다른 BP의 Authority 식별자 — 은 해석되지 않고 수신자에서 제외된다. 1.1.0에는auth-로 시작하는 값을 그대로 통과시키는 경로가 있어 BP가 작성한 제목·본문을 포탈 관리자 전원에게 전달할 수 있었다. - 수신자와
domainId는 이 BP가 활성화된 도메인 안으로 제한된다. 활성화되지 않은 도메인을domainId로 지정하면 발행되지 않고null이 반환된다. 수신자는userNos로 지정했든authorityKey·authorityKeys로 해석했든 같은 규칙으로 좁혀진다. 남는 사람은 이 BP가 활성화된 도메인에 소속된 사용자와 전역 소속 사용자다. 필수 업무패키지는 모든 도메인에서 활성으로 본다. 활성화되지 않은 도메인의 사용자에게는 이 BP의 화면 자체가 없어서deepLink를 열 수 없고, 다른 도메인에 업무 내용이 새는 경로가 된다.
수신자의 도메인 판정과 domainId는 따로 움직인다. 수신자는 각자의 소속 도메인으로 판정한다. domainId는 알림이 속하는 도메인일 뿐이며, 수신자를 그 도메인 하나로 더 좁히지 않는다. 예를 들어 D01과 D02에서 활성화된 BP가 domainId: "D01"로 역할 수신자에게 발행하면, D02 소속 보유자도 받는다. D01 소속에게만 보내려면 broadcastDomain을 쓴다.
포탈이 수신자로 고르는 사용자는 지정 방식마다 다르다. 어느 방식이든 비활성 사용자는 빠지며, 그 사용자에게는 인앱 알림도 만들지 않는다.
| 지정 방식 | 포탈이 고르는 사용자 |
|---|---|
userNos | 지정한 번호 중 명부에 있는 활성 사용자다. 명부에 없는 번호는 버린다. 위의 도메인 제한이 적용되며, 발행의 domainId와 소속 도메인이 같을 필요는 없다 |
authorityKey / authorityKeys | 그 역할을 부여받은 활성 사용자다. 여러 키면 합집합이며, 여러 역할을 가진 사람도 한 번만 받는다. 위의 도메인 제한이 userNos와 똑같이 적용된다. 운영자가 사용하지 않도록(useYn=false) 바꾼 Authority의 보유자는 받지 않는다. 포탈의 권한 판정도 그 Authority를 무시하므로, 알림이 가더라도 보유자가 그 화면을 열 수 없기 때문이다 |
broadcastDomain | domainId에 소속된 활성 사용자다. 전역 소속 사용자(소속 도메인이 없는 관리자 계정 등)는 받지 않는다. 그 사용자에게도 보내려면 userNos나 authorityKey로 지정한다 |
일부 수신자만 규칙에 걸려 빠지면 남은 사람에게 발행된다. 남은 사람이 없으면 null이다.
userNos의 길이 상한은 이 계약에 없다. 포탈 REST 발행 API의 userIds 1000개 상한은 이 표면에 적용되지 않는다. 포탈은 수신자의 사용자 행을 일괄 조회 한 번으로 읽고, 활성 판정은 도메인마다 한 번 한다.
할 수 없는 것 — 발행한 알림의 조회·취소·재발송, 수신 설정 변경. 사용자의 수신 여부는 포탈의 알림 설정 화면이 담당한다.
반드시 확인할 유의사항.
null이 반환되어도 오류가 아니다. 그리고null의 사유는 아홉 가지다. 반환값은 사유를 구분해 주지 않으므로 BP는 이것을 실패로 처리하지 않고host.logger.warn으로만 기록한다.null이 반환되는 경우조치할 주체 포탈의 알림 기능이 전체 비활성 상태다 운영자 그 category가 포탈 알림 카테고리 목록에서 비활성 상태다운영자 수신자 해석 결과가 0명이다(그 역할을 부여받은 사용자가 아직 없거나, 해석된 사용자가 모두 비활성이다) 운영자 지정한 역할의 Authority를 운영자가 사용하지 않도록 바꿨다 운영자 해석된 수신자 전원이 그 카테고리의 수신을 비활성화했다 사용자 각자 broadcastDomain수신자인데domainId를 전달하지 않았다BP 개발자 domainId가 이 BP가 활성화되지 않은 도메인이다(계약 1.2.0)BP 개발자 또는 운영자(활성화) authorityKey가 이 BP가 선언하지 않은 값이다(계약 1.2.0)BP 개발자 userNos로 지정했거나 역할로 해석한 수신자 전원이 이 BP가 활성화된 도메인 밖에 소속되어 있거나 명부에 없다(계약 1.2.0)BP 개발자 또는 운영자(활성화) 계약 1.2.0에서 추가된 세 가지는 포탈 애플리케이션 로그에 경고가 함께 남는다. 나머지와 달리 BP가 고쳐야 하는 사용 오류일 가능성이 높기 때문이다. 일부 수신자만 도메인 밖이라 제외된 경우에도 요청 수와 제외 수가 경고로 남는다. 사용자 번호와 이름은 로그에 싣지 않는다. 역할로 해석한 보유자가 처음부터 0명이어도 같은 경고가 남는다.
알림은 인앱 알림함과 전자우편 두 채널로 전달된다. 인앱 저장은 이 호출이 완료될 때 이미 커밋되어 있고, 전자우편 발송은 그 뒤 백그라운드로 진행되어 실패해도 이 호출의 결과에 반영되지 않는다. 수신자의 전자우편이 미등록이거나 미인증이면 그 사용자에게는 전자우편이 발송되지 않는다.
content가 전자우편 본문으로도 발송되므로 개인정보·비밀값을 넣지 않는다.content.title과content.body의 길이 상한은 계약에도 포탈 스키마에도 규정되어 있지 않다. 잘리지 않는 대신 알림 목록과 전자우편 제목에 그대로 실리므로, BP가 표시 가능한 길이로 스스로 제한한다.이 호출은 업무 트랜잭션에 참여하지 않는다(§6-2와 같은 이유). 업무 데이터를 커밋한 뒤에 호출하고, 실패해도 업무 처리를 되돌리지 않는다 — 알림이 전달되지 않았다고 승인이 취소되면 안 된다.
category는 매니페스트notificationCategories[]에 선언한 키를 사용한다. 선언하지 않은 키로 발행해도 포탈이 거부하지는 않으나, 사용자의 수신 설정 화면에 나타나지 않아 사용자가 수신을 비활성화할 수 없는 알림이 된다 — bp-manifest.md §notificationCategories.deepLink는 도메인 상대 App 경로다(예:eaccounting/approvals?id=123). 포탈이/apps/{domainId}/{deepLink}로 전개하므로 앞에 슬래시나 도메인을 붙이지 않는다.broadcastDomain수신자를 사용할 때는domainId가 필수다.
14. 공통코드 읽기 — host.codes
ts
const entries = await host.codes.list(define); // sortNo 오름차순
const one = await host.codes.get(define, code); // 없으면 null할 수 있는 것 — 읽기뿐이다. 쓰기는 onInstall의 설치 컨텍스트(ctx.codes)에만 있다(§8-1). 런타임에 코드 체계를 변경하는 것은 관리자의 조작이지 BP의 조작이기 때문이다.
유의사항.
list()는useYn: false항목도 함께 반환한다. 과거 데이터가 참조하는 코드의 이름을 표시해야 하기 때문이다. 화면에 선택지로 제시할 때는 BP가useYn으로 걸러 사용한다.- BP 스키마에 코드 표를 복제하지 않는다. 복제하면 관리자가 관리 화면에서 코드명을 변경해도 BP 화면에 반영되지 않는다. 계약 1.0.0에서 읽기 표면이 없어 자리잡았던 패턴이며, 지금은 해소되었다.
BpCodeEntry는code/codeName/sortNo/useYn네 필드다. 코드에 딸린 부가 속성(통화별 소수 자리수 등)을 실을 자리가 읽기·쓰기 어느 쪽에도 없다. 그런 값은 BP의 상수로 보유하며, 관리자가 코드를 추가해도 그 값은 BP를 재배포해야 추가된다는 점을 설계에 반영한다.list(define)에 도메인 인자가 없다. 공통코드는 포탈 전역이며 도메인마다 다른 코드 체계는 표현할 수 없다.- 행마다
get()을 호출하지 않는다. 목록 화면에서 왕복이 급증한다.define당list()한 번으로 조회해 메모리에 적재해 사용한다.
15. 사용자 프로필 조회 — host.users
ts
const me = await host.users.current(req); // 미인증이면 null
const u1 = await host.users.byUserNo(userNo);
const u2 = await host.users.byUsername(username);할 수 있는 것 — 단건 조회뿐이다. 반환 필드는 userNo / username / displayName / email / domainId이며, 여기 없는 필드는 BP가 알 수 없다.
할 수 없는 것 — 목록과 검색이 없다. 포탈 사용자 명부 전체를 BP에게 제공하면 경계(책임 분리선)가 의미를 잃기 때문이며, 이것은 누락이 아니라 결정이다. 따라서 "결재선을 지정하기 위해 사용자를 검색하는 화면"은 이 표면으로 만들 수 없다 — 조직 정보를 소유한 BP가 있으면 그 BP가 공개한 서비스를 §17의 BP 간 연계로 호출하고, 없으면 조직·조직장 정보를 자기 업무 데이터로 관리한다.
유의사항.
displayName을 BP가 직접 조합하지 않는다. 성과 이름의 순서가 로케일마다 다르므로 조각을 받아 합치면 반드시 어긋난다. 포탈이 로케일에 맞춰 조합한 값을 그대로 사용한다.- 이름 조각이 하나도 없는 계정이면
displayName에username이 들어온다. 빈 문자열이나null이 되지 않으므로 BP가 폴백을 준비할 필요가 없다. current(req)는currentUsername·currentUserNo를 각각 호출하는 대신 한 번에 조회하는 수단이다. 기존 두 메서드도 그대로 유효하다.
16. 외부 시스템 호출 — host.destinations
ts
const res = await host.destinations.fetch(destinationName, { method, path, headers, body, req, domainId });Destination(외부 연결처) 은 운영자가 포탈에 등록한 외부 시스템 접속 정의다. 주소·자격증명·인증 방식·허용 경로가 그 정의에 포함되며, BP는 이름만 알고 호출한다.
destinationName은 운영자가 등록한 Destination의 이름이다. 외부 BP 매니페스트(ExternalBusinessPackageManifest)의 폐기 예정 필드 destinationName과는 관계가 없다. 이 표면은 포탈 프로세스 안에서 실행되는 BP만 쓴다. 외부 BP(transport: "external")는 host를 받지 않고, 포탈은 그 BP로 가는 요청에 언제나 BP id와 같은 이름의 Destination을 쓴다. 외부 매니페스트의 destinationName을 BP id와 다르게 적으면 포탈이 BP_MANIFEST_INVALID로 부팅을 멈추므로, 이 필드는 생략한다. 외부 BP의 iframe 화면과 브라우저 경유 프록시 규칙(하위 요청의 domainId, 비활성 도메인의 404, 접근할 수 없는 도메인의 403)은 bp-manifest.md "iframe 화면의 하위 요청에는 domainId를 추가한다" 절을 참고한다. 그 비활성 404 규칙은 이 서버측 호출에는 적용되지 않는다.
Destination 이름은 ^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$를 따른다(포탈 ADR-097). 영문·숫자로 시작하고 영문·숫자·.·_·-만 쓰며 최대 64자이고, 대소문자를 구분한다. 포탈은 Destination을 새로 만들거나 이름을 바꿀 때 이 규칙을 검사한다. 규칙 도입 전에 저장된 이름은 그대로 호출된다. BP가 문서나 설치 안내에서 운영자에게 특정 Destination 이름을 등록하라고 요구한다면 그 이름도 이 규칙 안에서 정한다. 규칙을 벗어난 이름은 운영자가 등록할 수 없다. 외부 BP의 id가 그대로 Destination 이름이 되는 규칙은 bp-manifest.md §transport에 있다.
BP가 직접 외부를 호출하지 않는 이유는 세 가지다. (1) 주소·자격증명이 BP 산출물이나 BP 테이블에 고정되면 운영자가 변경할 수 없다. (2) 사내망 대상은 터널 경유가 필요한데 그 경로는 포탈만 안다. (3) 경로별·메서드별 허용목록과 감사 기록이 우회된다.
호출할 수 있는 Destination은 이 BP의 것과 공용뿐이다(계약 1.2.0). 소유 업무패키지가 지정된 Destination은 그 업무패키지의 호출만 통과하고, 소유가 지정되지 않은 Destination은 공용으로 취급되어 어느 BP나 호출한다. 다른 BP가 소유한 Destination은 이름을 알아도 403 + not-owned로 막힌다 — 나가는 요청에는 그 Destination에 저장된 자격증명이 실리므로, 이름을 아는 것이 곧 남의 자격증명으로 대상 시스템에 접근하는 것이 되어서는 안 되기 때문이다. 다만 공용 Destination으로는 조회만 가능하다 — 쓰기 권한 판정의 근거가 되는 소유 업무패키지의 App이 없으므로 GET·HEAD 외의 메서드는 403으로 거부된다. 따라서 쓰기가 필요한 외부 연결은 반드시 소유 업무패키지를 이 BP로 지정해 등록하도록 요청한다. 인계 문서에 필요한 외부 연결을 적을 때 소유 업무패키지를 이 BP로 지정할 것인지, 조회 전용의 공용으로 등록할 것인지를 함께 적는다(install-to-portal.md §1).
읽기에는 req가 필요하지 않다 — 의도된 범위다. 배치·후처리처럼 사람의 요청이 아닌 맥락에서도 조회가 필요하기 때문이며, BP 코드는 포탈과 같은 프로세스에서 실행되는 신뢰 코드라는 전제 위에 있다. 다만 쓰기(GET·HEAD 외)는 req 없이 통과하지 못한다 — 소유 업무패키지 App에 대한 쓰기 권한을 가진 사용자여야 하고, 신원이 없으면 그 판정이 성립하지 않으므로 거부된다. 조회 결과를 사용자에게 표시할 때 "누가 볼 수 있는가"는 여전히 BP가 checkAppAccess로 판정한다.
유의사항 네 가지.
- 헤더가 전부 전달되지는 않는다. Destination에 운영자가 설정한 요청 헤더 허용목록을 통과한 것만 실제로 전송되고, 허용되지 않은 헤더는 오류 없이 제외된다. 대상 시스템이 요구하는 헤더가 있으면 운영자에게 허용목록 등록을 요청한다. 헤더 이름의 대소문자는 구분되지 않는다 — 포탈이 소문자로 정규화한 뒤 허용목록과 대조한다(계약 1.2.0에서 정정되었다. 그 전에는
Content-Type처럼 대문자가 섞인 이름이 오류 없이 무시되었다). - 어떤 경우에도 예외가 발생하지 않는다. 등록되지 않은 Destination 이름, 허용목록 위반,
req누락, 사용자 토큰 교환 실패 같은 설정·사용 오류도 전부 응답 객체로 반환된다. 호출부가try/catch와 status 검사를 이중으로 수행하지 않게 하기 위한 규정이다. - 사용자 신원을 대상에 전달하는 Destination에는
req를 전달해야 한다.PRINCIPAL_PROPAGATION계열과 사용자 출처 Token Exchange(계약 1.3.0, §16-2)가 여기 해당한다.req없이 호출하면 조회라도 403 +principal-required로 거부된다. - 응답 본문은 원문 문자열이다. JSON 파싱은 BP가 한다 — 대상 시스템이 항상 JSON을 반환하지는 않는다.
16-1. 차단 응답 — 포탈이 막았는지 대상이 거부했는지 구분한다
차단은 예외가 아니라 status 401·403·404·502 응답으로 반환되므로, 그것만으로는 포탈의 차단인지 대상 시스템 자신의 거부인지 알 수 없다. 둘은 조치가 다르다. 전자는 운영자가 Destination 설정을 변경하거나 사용자가 다시 로그인해야 하고, 후자는 대상 시스템의 자격증명·권한 문제다. 그래서 포탈이 막은 경우에만 응답 헤더 x-portal-destination-blocked에 사유가 실린다.
| status | x-portal-destination-blocked | 뜻 | 누가 조치하나 |
|---|---|---|---|
| 404 | not-found | 그 이름의 Destination이 등록되어 있지 않다 | 운영자 |
| 404 | not-callable | 그 Destination이 서버측 호출 대상이 아니다 | 운영자 |
| 403 | allowed-paths | 경로×메서드가 허용목록에 없다 | 운영자 |
| 403 | principal-required | 사용자 신원이 필요한 Destination(PRINCIPAL_PROPAGATION 계열, 사용자 출처 Token Exchange)인데 req를 전달하지 않았다 | BP 개발자 |
| 403 | not-owned | 그 Destination이 다른 업무패키지의 소유다(계약 1.2.0) | 운영자(이 BP 소유 또는 공용으로 등록) |
| 403 | user-token-unavailable | 이 사용자의 로그인 방식에는 전달할 사용자 토큰이 없다(계약 1.3.0) | 사용자 — 외부 IdP(OIDC)로 로그인 |
| 401 | user-token-expired | 사용자 토큰이 만료되었다(계약 1.3.0) | 사용자 — 다시 로그인 |
| 403 | token-exchange-denied | 토큰 발급자가 이 사용자의 토큰 교환을 거부했다(계약 1.3.0) | 관리자 |
| 502 | token-exchange-unavailable | 토큰 발급자에 연결하지 못했다(계약 1.3.0) | 잠시 후 재시도, 계속되면 운영자 |
| 502 | (없음) | 대상에 도달하지 못했다(DNS 실패·연결 거부·시간 초과) | 운영자 |
| 그 밖 | (없음) | 대상 시스템의 응답이다 | 대상 시스템 소관 |
헤더가 없다고 해서 대상 시스템의 응답이라고 단정할 수는 없다. 포탈이 막았으면서 이 헤더를 붙이지 않는 경로가 있다 — 아래 넷은 요청이 대상 시스템에 도달하기 전에 포탈이 거부한 것이다.
| status | 포탈이 거부한 이유 | 조치 |
|---|---|---|
| 403 | 쓰기 요청인데 req를 전달하지 않았다 | BP 개발자 — 쓰기에는 신원이 필요하다 |
| 403 | 소유 업무패키지가 지정되지 않았거나 그 App이 없어 쓰기 권한을 판정할 수 없다 | 운영자 — 소유 업무패키지를 지정해 등록한다 |
| 403 | 호출한 사용자에게 소유 업무패키지 App의 쓰기 권한이 없다 | 운영자 — 권한을 부여한다 |
| 400 · 500 | 메서드 오버라이드 헤더를 사용했다 · 그 인증 방식의 어댑터가 설치되어 있지 않다 | BP 개발자 · 운영자 |
따라서 진단은 "헤더가 있으면 확실히 포탈의 차단"까지만 단정하고, 헤더가 없는 403·400·500은 응답 본문과 함께 운영자에게 전달해 판정을 요청한다. 값의 타입은 BpDestinationBlockedReason이고 헤더 이름은 BP_DESTINATION_BLOCKED_HEADER다. 둘 다 SDK에서 import한다. 문자열을 옮겨 적지 않는다.
ts
import { BP_DESTINATION_BLOCKED_HEADER, type BpDestinationBlockedReason } from "@starj-portal/bp-backend-sdk";
const res = await host.destinations.fetch("hr-api", { path: "/api/v1/employees", req });
const blocked = res.headers[BP_DESTINATION_BLOCKED_HEADER] as BpDestinationBlockedReason | undefined;
if (blocked === "principal-required") { /* BP가 고칠 문제다 */ }사유를 switch로 나눌 때는 default 분기를 둔다. 이 목록은 계약의 minor가 오를 때 늘어날 수 있다(1.2.0의 not-owned, 1.3.0의 사용자 토큰 사유 넷). default가 운영자 문의로 안내하면 새 사유가 와도 화면이 깨지지 않는다.
차단 응답의 본문을 화면에 그대로 옮기지 않는다. 포탈이 막은 응답의 본문은 포탈이 쓴 고정 문구이고 토큰이나 대상의 응답은 담기지 않지만, 문구는 포탈 버전에 따라 바뀔 수 있다. 헤더가 없는 응답의 본문은 대상 시스템이 쓴 것이므로 내부 정보가 담겨 있을 수 있다. 사용자에게는 사유에 맞춰 BP가 정한 문구를 보여 주고, 로그에는 사유와 status만 남긴다.
대상 시스템이 이 헤더를 위조해 보내면 포탈이 제거한다. 대상이 "포탈이 막았다"고 주장할 수 있으면 이 구분 자체가 무의미해지기 때문이다.
16-2. 사용자 출처 Token Exchange — 사용자 본인의 신원으로 호출한다 (계약 1.3.0)
운영자는 Destination을 사용자 출처 Token Exchange로 등록할 수 있다. 이 방식은 고정 자격증명을 쓰지 않는다. 포탈이 호출한 사용자가 외부 IdP(OIDC)로 로그인할 때 받은 토큰을 토큰 발급자에게 교환해, 그 사용자 본인의 토큰을 대상에 보낸다. 그래서 대상 시스템은 "누가 조회했는가"를 자기 권한으로 판정한다. BP 코드는 다른 Destination과 같다. 인증 방식은 운영자가 정하고, BP는 이름으로 호출한다.
BP가 알아야 할 것은 세 가지다.
- 조회에도
req가 필요하다. 신원 없는 호출을 고정 토큰으로 대신하지 않는다. 배치처럼 사람의 요청이 없는 맥락에서는 이 Destination을 쓸 수 없다. 그런 용도가 있으면 고정 자격증명을 쓰는 Destination을 따로 등록하도록 인계한다. - 사용자마다 결과가 다르다. 같은 요청도 로그인 방식과 토큰 상태에 따라 성공하거나 막힌다. 막히면 아래 네 사유 중 하나가 헤더로 온다.
- 사유마다 사용자에게 안내할 조치가 다르다.
| 사유 | status | 사용자에게 보여 줄 안내의 예 | BP가 돌려줄 status의 예 |
|---|---|---|---|
user-token-expired | 401 | "로그인이 만료되었습니다. 다시 로그인하세요." | 401 |
user-token-unavailable | 403 | "이 기능은 회사 계정으로 로그인해야 쓸 수 있습니다." | 403 |
token-exchange-denied | 403 | "접근할 수 없습니다. 관리자에게 문의하세요." | 403 |
token-exchange-unavailable | 502 | "잠시 연결할 수 없습니다. 잠시 후 다시 시도하세요." | 503 |
네 값은 BpDestinationUserTokenBlockedReason 타입으로 묶여 있다. 정본 예제 examples/task-bp/backend/src/tracker.controller.ts가 이 분기를 구현하고, scripts/smoke.mjs가 각 사유의 안내 문구와 본문 미전달을 단언한다.
주의 사항
- 헤더 없는 401은 다시 로그인 안내가 아니다. 대상 시스템 자신의 거부이며 사용자의 포탈 로그인과 관계가 없다. 헤더로만
user-token-expired를 판정한다. user-token-expired는 BP가 곧바로 재시도해도 결과가 같다. 사용자가 다시 로그인해야 새 토큰이 생긴다.token-exchange-unavailable의 502와 도달 실패의 502는 헤더로 구분한다. 헤더가 있으면 토큰 교환 단계에서 멈춘 것이고, 헤더가 없으면 대상 시스템에 닿지 못한 것이다.runtime.hostContract를 올릴 필요는 없다. 이 사유를 처리하는 코드는 1.2.0 이하 포탈에서도 해가 없다. 그 포탈에는 사용자 출처 Token Exchange Destination이 없어 이 사유가 오지 않을 뿐이다.
path는 Destination의 기준 주소 뒤에 연결되는 상대 경로이며 앞에 슬래시를 추가한다(예: /api/v1/employees). 인계 절차는 install-to-portal.md §1에 있다.
17. BP 간 연계 — host.services
ts
// 제공 측 — register() 안에서. 구현이 아니라 팩토리이며, 계약 버전을 함께 공표한다.
host.services.provide("approval", (caller) => impl, { contractVersion: "1.2.0" });
// 소비 측 — 요청 처리 시점에. 요구하는 버전 범위를 함께 전달한다.
const svc = host.services.require<T>(businessPackageId, "approval", { versionRange: "^1.0.0" });
const why = host.services.status(businessPackageId, "approval", { versionRange: "^1.0.0" });HTTP가 아니라 in-process 객체다. BP들의 컨트롤러·프로바이더는 이미 포탈의 같은 애플리케이션에 합쳐지지만 의존성 주입 토큰이 BP마다 지역 Symbol이라 서로 참조할 수 없었고, 그래서 두 BP를 연결하려면 브라우저를 한 번 왕복해 사용자 권한으로 상대 REST를 호출하는 방법밖에 없었다. 그 방식은 서버측 후처리(승인 시 자동 상신 등)에서는 성립하지 않는다.
경계는 dependsOn이 정한다. require()는 호출자의 bp-manifest.yaml dependsOn[]에 선언한 businessPackageId에 대해서만 성공한다. 선언하지 않은 BP의 서비스는 존재해도 null이 반환된다 — 의존이 매니페스트에 드러나야 운영자가 비활성화 순서를 판단할 수 있기 때문이다.
유의사항 세 가지.
register()실행 시점에는require()가null을 반환할 수 있다. 제공 측 BP가 아직 적재되지 않았을 수 있고 적재 순서는 보장되지 않는다.register()에서 미리 받아 보관하지 말고 요청 처리 시점에 매번 호출한다 — 그 시점에는 모든 BP의register()가 끝나 있다.- 제공 측은 인자를 검증하고 스스로 권한을 판정한다.
provide()로 전달한 객체의 메서드는 그 자체가 공개 API이며 호출자가 다른 회사의 BP일 수 있다. in-process 호출에는 HTTP 계층의 가드가 걸리지 않으므로, 권한 판정이 필요하면 제공 측이 스스로host.checkAppAccess를 호출한다. provide()는 구현 객체가 아니라 팩토리를 받는다. 팩토리는require()가 호출될 때 포탈이 채워 전달하는 호출자 정보(caller.businessPackageId)를 인자로 받는다. 호출자를 인자로 받는 방식이었다면 그 값은 호출자의 자기 신고여서, 다른 BP가 남의 이름을 대고 그 BP의 데이터를 조작하는 것을 계약 수준에서 막을 수 없다. 호출자를 구분할 필요가 없는 서비스라면 팩토리가 매번 같은 객체를 반환하면 된다.- 같은 이름을 두 번 등록하면 나중 것이 적용된다(로더가 경고를 남긴다).
- 상대 BP가 업데이트 중이어도
host.services호출은 거부되지 않는다. 포탈은 업데이트 중인 BP의 HTTP 요청만 거부한다. 제공 측은 이전 코드로 계속 응답하므로, 제공 측 BP는 확장 마이그레이션 직후의 스키마에서도 서비스가 동작하도록 §7-5의 규칙을 지킨다. - 자기 자신의
businessPackageId를 전달하면dependsOn선언 없이 자기가provide()한 서비스가 반환된다. 자기 자신에게 의존을 선언하는 BP는 없기 때문이며, 컨트롤러와 서비스 표면이 같은 구현을 공유하는 구조에서 사용한다.
null의 이유는 status()로 판정한다. require()의 null 하나로는 원인을 알 수 없고 조치도 다르다. 판정은 아래 순서로 이루어진다.
| 판정 | 뜻 | 누가 조치하나 |
|---|---|---|
not-declared | 매니페스트 dependsOn[]에 그 BP가 없다 | BP 개발자 |
not-loaded | 상대 BP가 적재되지 않았다 | 운영자. 사유는 GET /bp/runtime-info의 skipped[]에 있다 |
not-provided | 적재는 됐으나 그 이름의 서비스가 없다 | BP 개발자(이름 오탈자) |
contract-mismatch | 그 이름의 서비스는 있으나 제공 측이 공표한 계약 버전이 요구한 범위와 맞지 않는다 | 양쪽 BP의 버전을 맞춘다. 어느 쪽을 올릴지는 운영 판단이다 |
ok | 사용할 수 있다 | — |
사용자에게 표시하는 문장을 이 판정에 따라 다르게 만든다 — "결재 기능을 사용할 수 없습니다"와 "workflow 업무패키지가 설치되지 않았습니다"는 다른 문장이다.
17-1. 타입은 계약이 옮겨 주지 않는다
require<T>의 T는 호출자가 손으로 선언한 기대일 뿐 제공 측의 실제 타입이 아니다. 두 BP는 서로의 소스를 참조하지 않으므로, 상대의 시그니처가 변경되어도 타입체크는 통과하고 어긋남은 운영 중에만 드러난다.
실제로 확인된 사례 — 소비 측 BP가 제공 측의 설계 문서를 보고 서비스 타입 7개를 추론해 옮겨 적었더니 시그니처 5곳·반환 타입 4곳·권한 요구 1곳이 어긋났고, 그중 셋은 첫 요청부터 전부 실패하는 차단 결함이었는데도 타입체크는 끝까지 통과했다.
따라서 네 가지를 함께 수행한다.
- 제공 측은 서비스 표면의 타입 선언을 산출물과 함께 배포한다. 소비 측이 문서를 보고 옮겨 적지 않게 하는 것이 유일하게 확실한 방법이다.
- 제공 측은
contractVersion을 공표하고, 시그니처나 반환 타입을 변경할 때 major를 올린다. 소비 측은versionRange를 요구한다. 그래야 어긋남이 운영 중이 아니라 적재 시점에 드러난다. 제공 측이contractVersion을 공표하지 않았는데 소비 측이versionRange를 요구하면 맞지 않는 것으로 판정된다 — 공표하지 않은 버전에 대해 "맞다"고 단정할 근거가 없기 때문이다. - 제공 측은 자기 서비스의 테스트 더블도 산출물과 함께 배포한다. 소비 측이 만든 더블은 실물보다 관대해지기 쉽다 — 실증에서 소비 측이 작성한 더블 아홉 군데가 실물보다 관대했고(열람 권한 검사 누락, 반환 필드 누락, 예외 문구 상이), 그 결과 소비 측의 단독 검증이 통과하는데도 실물에서는 실패했다. 검증 규칙을 아는 쪽이 더블을 소유하는 것이 옳다.
- 양쪽을 함께 적재해 실제로 호출하는 통합 검증을 수행한다. 각 BP가 자기 안에서만 수행하는 검증은 이 어긋남을 발견하지 못한다 — 그 검증의 상대편은 자기가 만든 대역이기 때문이다. 테스트 더블(§20)은 자기 BP가 계약을 준수하는지를 확인하는 도구이며, 두 BP의 표면이 서로 맞는지는 확인하지 못한다.
17-2. 소유 관계와 역방향 통지 — 완결 지점을 하나로 고정한다
provide() 팩토리가 전달받는 caller.businessPackageId는 포탈이 채운 값이므로 신뢰할 수 있다. 제공 측이 그 값을 자기 데이터에 기록하면 "이 자원의 주인이 어느 BP인가"라는 사실이 남는다. 그런데 그 사실로부터 따라 나오는 규정이 계약에 규정되어 있지 않아 실증에서 차단 결함이 발생했다.
무엇이 발생했는가. 범용 결재 BP가 자기 결재함 화면에서 1차 승인을 처리했다. 그 결재 인스턴스는 다른 BP(경비정산)가 소유한 것이었다. 결재 BP의 상태는 전이되었으나 소유 BP의 상태 사본·감사 기록·알림은 하나도 남지 않았다. 권한이 정당한 사용자의 정상 조작이었고, 담당자의 처리 대기 목록에서 그 건이 조회되지 않았으며, 운영자가 분기를 발견해 보정하기 전까지 복구되지 않았다. 보정해도 감사와 알림은 복원되지 않는다.
원인은 계약에 역방향 통지 통로가 없다는 것이다. require()는 dependsOn을 선언한 방향으로만 통한다. 제공 측은 소비 측이 자기를 호출한다는 사실을 알지만, 소비 측에게 "그쪽이 소유한 자원의 상태가 변경되었다"고 되돌려 알릴 수단이 없다. 아래 네 가지는 전부 성립하지 않는다.
| 시도 | 성립하지 않는 이유 |
|---|---|
제공 측이 소비 측을 dependsOn으로 선언한다 | 순환 의존이 된다. 적재 순서가 보장되지 않고, 범용 BP가 자기 소비자를 매니페스트에 선언해야 한다는 것 자체가 범용성을 없앤다 |
| 콜백 함수를 인자로 전달받아 보관한다 | 함수는 직렬화되지 않아 포탈 재기동 후 사라진다. 다중 인스턴스에서는 어느 인스턴스에 등록되었는지도 정해지지 않는다 |
| 소비 측이 주기적으로 대조한다 | 주기 실행 수단이 계약에 없다(§9). 조회에 편승시키면 아무도 그 화면을 열지 않는 동안 대조되지 않는다 |
| 제공 측이 소비 측의 REST를 호출한다 | 그 경로를 호출할 세션이 제공 측에 없다. §9-1의 기계 자격증명은 외부 호출자의 것이지 BP가 보유하는 것이 아니다 |
그러므로 두 규정을 지킨다.
- 완결 지점을 소유 BP 하나로 고정한다. 제공 측의 화면과 REST 경로는 남이 소유한 자원의 업무 전이를 실행하지 않는다. 목록 표시와 원문으로의 이동까지만 담당하고, 전이는 소유 BP의 화면에서 그 BP의 코드가 실행한다. 대가는 명확하다 — 여러 BP의 결재를 한 화면에서 완결하는 경험은 지금 계약에서 성립하지 않는다.
- 소비 측은 통지가 아니라 조회로 상태를 확인한다. 자기 테이블의 상태를 사본으로 취급하고, 상세 화면에 진입하는 시점에
require()로 원본을 다시 조회해 보정한다. 관리 화면에 일괄 보정 수단을 함께 둔다.
이 두 규정이 설계에 반영되었는지는 화면 목록에서 확인한다 — 제공 측 BP의 화면 중 남이 소유한 자원의 상태를 변경하는 버튼이 있는지 확인한다.
18. 포탈 감사로그 — host.audit
ts
const recorded: boolean = await host.audit.record({ action, targetType, targetId, domainId, detail, req });host.logger와 목적지가 다르다. logger는 애플리케이션 로그(stdout)로 전송되어 감사 화면에 표시되지 않는다. 승인·반려처럼 나중에 누가 무엇을 했는지 따져야 하는 조작은 이쪽에 기록한다.
이벤트 타입은 포탈 코어와 이름 공간이 분리된다(계약 1.2.0). 감사로그에 남는 값은 bp:{businessPackageId}:{action}이며 포탈이 접두어를 추가한다. BP가 무엇을 적든 포탈 코어의 이벤트 타입과 충돌하지 않는다 — 1.1.0에서는 action이 그대로 이벤트 타입이 되어, BP가 LOGIN_SUCCESS를 기록하면 그 행이 포탈의 로그인 이벤트와 구분되지 않은 채 월 활성 사용자 집계에 합산되었다. BP가 접두어를 직접 추가하지 않는다(추가하면 접두어가 두 번 적용된다).
유의사항.
- 실패해도 예외를 발생시키지 않는다. 감사 기록이 되지 않았다고 업무 처리가 실패하면 안 되기 때문이며, 실패는 포탈 애플리케이션 로그에도 남는다. 따라서 이 호출의 성공을 업무 처리의 조건으로 삼지 않는다.
domainId는 이 BP가 활성화된 도메인이어야 한다(계약 1.2.0). 그렇지 않으면 기록하지 않고false를 반환한다.- 기록 성공 여부는 반환값
boolean으로 전달된다. 예외도 없고 반환값도 없으면 BP는 기록 여부를 알 수 없는 상태에서 자기 업무 이력에 "감사에 기록함"을 남기게 되고, 그 값이 사실이 아닐 수 있다.false를 받으면 BP는 자기 이력에 그 사실을 정확히 남기거나 재시도를 판단할 수 있다. false를 업무 실패로 취급하지 않는다. 기록 계통의 장애가 업무 장애로 확산된다. 정본 예제는false를 애플리케이션 로그에 경고로 남기고 업무 처리를 그대로 완료한다 —examples/task-bp/backend/src/task.controller.ts의recordAudit().detail에 개인정보·비밀값을 포함하지 않는다. 감사로그는 장기 보존된다.req를 생략하면 주체 없는(시스템) 기록이 된다. 사용자 조작의 결과라면 요청 객체를 전달한다.action은 BP 안에서 안정적인 문자열을 사용한다(예:EXPENSE_APPROVE). 값이 변경되면 과거 기록과 이어지지 않는다.
19. 업무 첨부 파일 — host.files
ts
const info = await host.files.stat(fileUuid, fileSeq); // 없으면 null
const ok = await host.files.claim(fileUuid, req); // 요청 객체를 그대로 전달한다(계약 1.2.0)
const buf = await host.files.read(fileUuid, fileSeq); // claim한 파일만
const done = await host.files.delete(fileUuid); // claim한 파일만
// 서버가 만든 파일(계약 1.3.0) — 처음부터 이 BP의 소유다
const { fileUuid, fileSeqs } = await host.files.create(
{ files: [{ originName: "certificate.pdf", mime: "application/pdf", data: pdfBytes }] },
req,
);dataDir에 직접 저장하지 않는 이유는 dataDir이 그 프로세스의 로컬 파일 시스템이기 때문이다. 포탈 인스턴스를 여러 대로 늘리면 A에 저장한 파일을 B가 읽지 못한다. dataDir은 캐시·작업 파일처럼 없어져도 되는 것에만 사용한다.
업로드는 브라우저가 한다. 위젯이 host.actions.uploadFile()로 파일을 포탈에 업로드해 fileUuid를 받고, 그 값만 BP API로 전송한다 — 큰 바이너리를 BP 컨트롤러가 중계하지 않게 하기 위함이다. 위젯 쪽 사용법과 유의사항은 frontend-guide.md §6 "파일 업로드"에 있다. BP 서버가 담당하는 것은 그 fileUuid를 claim해 자기 업무 데이터에 연결하고, 나중에 권한을 판정한 뒤 내용을 읽어 반환하는 것이다.
유의사항.
claim은 보안 장치다. claim된 파일은 포탈의 범용 다운로드 경로에서 더 이상 제공되지 않는다 — 그 경로에는 "이 파일을 열람할 수 있는 사용자인가"를 판정할 근거가 없기 때문이다. claim 이후의 열람 권한 판정은 소유 BP의 책임이다. BP가 자기 엔드포인트에서
checkAppAccess로 판정한 뒤read()로 내려준다.claim()의 두 번째 인자는 요청 객체다(계약 1.2.0에서 변경되었다). 포탈이 그 요청의 세션에서 로그인 사용자를 판정해 파일의 업로드 주체와 대조하며, 세션이 없는 호출은 거부된다. BP는 자기 컨트롤러가 받은req를 그대로 전달한다.1.1.0의
claim(fileUuid, uploaderUserNo)가 성립하지 않았던 이유는 판정의 근거를 호출자가 실어 보냈기 때문이다. 같은 계약의stat()이 그 번호를 알려 주었으므로claim(uuid, (await stat(uuid))!.uploadedBy!)한 줄이면 가드가 항상 통과했고, 그 결과 로그인한 사용자 누구나 타인이 올린 파일을 자기 업무 데이터에 연결하고 내용을 읽고 원본까지 삭제할 수 있었다. 호출자가 실어 보낸 값은 그 값의 출처로 검증할 수 없다 —services.provide()가 호출자 신원을 인자로 받지 않는 것과 같은 원칙이다(§17).stat()이 제공하는 범위도 함께 좁혀졌다. 결속하지 않은 파일과 다른 BP가 결속한 파일에 대해서는uploadedBy가 항상null이다. 이름·MIME·크기는 claim 전에 무엇이 올라왔는지 확인해야 하므로 그대로 열려 있다.read()·delete()는 이 BP가 claim한 파일에만 동작한다. claim하지 않은 파일을read()하면null이 반환된다.delete()의false가 "아무것도 삭제되지 않았다"를 뜻하지는 않는다. 이 BP의 파일이 아니면 아무 것도 하지 않고false지만, 저장소 객체 삭제 중 일부가 실패한 경우에도false다. 후자에서도 메타데이터는 삭제되므로 BP 관점에서 그 파일은 더 이상 존재하지 않는다. 남은 저장소 객체의 정리는 운영자의 책임이며, BP는 재시도하지 않고 자기 로그에 기록한다.저장소 유형의 기능이 설치되지 않았으면
create()·read()·delete()가 reject된다(계약 1.4.0). S3 계열·SFTP 같은 외부 저장소 유형은 기능 패키지가 있어야 쓸 수 있다.create()는 기본 저장소 대상의 유형으로,read()·delete()는 저장할 때 기록된 대상의 유형으로 판정한다.delete()는 이때 아무것도 지우지 않는다. 1.3.0까지는 메타데이터 행만 지우고false를 돌려주었으므로,delete()를 try/catch 없이 부르던 코드는 고친다. 판별과 처리 방법은 §21에 있다.read()와delete()는 파일을 저장할 때 기록된 저장소 대상을 찾아간다. 관리자가 나중에 기본 대상을 바꿔도 이미 저장된 파일의 위치는 바뀌지 않는다. 대상의 상태에 따라 다음과 같이 동작한다.저장할 때 기록된 대상 read()delete()활성 대상 그 대상에서 읽는다 그 대상에서 지운다 비활성 대상 그대로 읽는다. 비활성화는 새 저장만 막는다 그대로 지운다 삭제된 대상 null이 아니라 reject된다객체가 이미 없는 것으로 보고 메타데이터만 지운 뒤 true를 반환한다기록 없음(등록된 대상이 없을 때 로컬 폴백에 저장된 파일) 로컬 폴백에서 먼저 찾고, 거기 없을 때만 현재 활성 기본 대상에서 찾는다 같은 순서로 찾아 지운다 유형의 기능이 설치되지 않은 대상(계약 1.4.0) 미설치 거부로 reject된다 아무것도 지우지 않고 미설치 거부로 reject된다. 묶음의 대상 중 하나라도 해당하면 전체를 지우지 않는다 read()의null은 "없는 파일이거나 이 BP의 파일이 아니다"라는 뜻으로만 쓰인다. 메타데이터는 있는데 저장소에서 객체를 읽지 못한 경우(대상 삭제, 객체 유실, 저장소 연결 실패)는 Promise가 reject된다. BP 엔드포인트는 이 reject를 "파일 없음"으로 바꾸지 말고 서버 오류로 응답한다.claim 이전의 파일은 그 포탈에 로그인한 사용자면 누구나 내려받을 수 있다. 포탈의 범용 파일 경로는 claim된 파일만 거부하기 때문이다. 업로드와 claim 사이의 구간을 짧게 유지하고, 사용자가 첨부만 하고 저장하지 않은 파일을 정리하는 방법을 BP가 준비한다.
이미 이 BP가 결속한 파일이면
claim()은 요청한 사용자가 누구든true다. 재시도 경로에서 분기하지 않게 하려는 규정이지만, 그 결과true는 "이 요청의 사용자가 올린 파일이다"를 뜻하지 않는다. 사용자가 보낸fileUuid를 새 업무 데이터에 매는 API는claim()전에stat()의ownedByThisBp가true인 파일을 거부한다 — 그러지 않으면 다른 사용자의 업무 데이터에 이미 매인 파일을 자기 데이터에 다시 매고 그 경로로 내용을 읽을 수 있다. 같은fileUuid를 두 요청이 동시에 매는 경합은 이 검사로 막히지 않으므로, BP 쪽 첨부 표의fileUuid에 고유 제약을 둔다.용량은
stat()의fileSize로 판정한다 — 포탈이 실제로 수신한 바이트 수다. 위젯이 함께 전송한 값은 판정 근거로 삼지 않는다. 포탈이 적용하는 용량 상한은 설치의 저장소 설정값이며 계약에 노출되지 않으므로, BP가 더 좁은 제한을 요구한다면 자기 기준으로 다시 판정한다.stat()의mime은 서버가 검사한 값이 아니다. 업로드 요청의 파트 헤더에 브라우저가 적어 보낸 값을 그대로 보관한 것이며, 포탈의 허용 형식 판정도 같은 값을 사용한다. 형식이 업무 규칙상 중요하다면read()한 내용의 시그니처를 BP가 직접 확인한다.fileUuid를 claim 이전에 화면·URL·로그·오류 메시지에 노출하지 않는다. 그 구간의 유일한 방어가 값의 추측 불가능성이다.한 번의 업로드가 파일 여러 개를 만들 수 있다. 같은
fileUuid아래fileSeq가 1부터 부여되며,claim()과delete()는fileUuid단위로 묶음 전체에 적용되고stat()과read()만fileSeq단위다(생략하면 1이다).크기 필드의 이름이 양쪽에서 다르다 — 위젯이 받는 값은
size,stat()이 반환하는 값은fileSize다.read()는 파일 전체를 메모리에 적재해 반환한다. 스트리밍 표면이 계약에 없으므로, 큰 파일을 동시에 여러 건 반환하는 화면은 메모리 사용량을 고려해 설계한다.
19-1. 서버가 만든 파일 — files.create() (계약 1.3.0)
발급 문서처럼 BP 서버가 만든 파일도 dataDir이 아니라 포탈 저장소에 둔다. 이 오퍼레이션이 없을 때는 서버가 만든 파일을 dataDir과 자체 파일 표에 두는 수밖에 없었고, 포탈을 여러 대로 늘리면 한 대에 저장한 파일을 다른 대가 읽지 못했다.
| 항목 | 규정 |
|---|---|
| 입력 | files는 { originName, mime, data: Uint8Array }의 배열이며 1개 이상 10개 이하다. 0개이거나 10개를 넘으면 거부된다. data는 Uint8Array(Node의 Buffer 포함)여야 한다 |
| 결과 | { fileUuid, fileSeqs }. 포탈이 새 fileUuid를 만들고 fileSeq를 배열 순서대로 1부터 매긴다. fileSeqs[i]가 files[i]의 번호다 |
| 소유 | 만든 BP가 이미 결속한 상태로 시작한다. claim()을 부르지 않는다. stat()의 ownedByThisBp는 처음부터 true이고, read()·delete()가 바로 동작하며, 포탈의 범용 다운로드 경로에서는 처음부터 제공되지 않는다 |
| 저장한 사용자 | 포탈이 req의 세션에서 판정한다. 사용자 번호를 인자로 받지 않으며, 세션이 없으면 거부된다 |
| 저장 위치 | 브라우저 업로드와 같은 규칙이다. 포탈이 관리자가 지정한 기본 저장소 대상에 둔다. 저장소 대상이 하나도 없으면 로컬 폴백에 두고, 대상은 있는데 활성 기본 대상이 없으면 거부된다 |
| 크기 상한 | 파일당 저장소 대상의 설정값이며, 설정이 없으면 20MB다. 하나라도 넘으면 전체가 거부된다 |
| 실패 | 거부와 저장 실패는 Promise reject다. 일부만 저장된 상태를 남기지 않는다 — 쓰다가 실패하면 포탈이 이미 쓴 것을 지운다. reject된 호출에는 fileUuid가 없으므로 BP가 정리할 것도 없다 |
| 감사 | 포탈 감사로그에 남지 않는다. 문서 발급의 감사가 필요하면 BP가 host.audit.record()로 남긴다(§18) |
runtime.hostContract를^1.3.0으로 선언한다. 1.2.0 포탈에는 이 함수가 없다.- 업무 데이터와 함께 저장하는 순서를 정해 둔다. 포탈 저장소와 BP 데이터베이스는 한 트랜잭션에 묶이지 않는다.
create()가 성공한 뒤 BP 쪽 행 저장이 실패하면 받은fileUuid를delete()로 지운다. - 세션이 없는 경로(정기 배치, 기계 자격증명 요청, 설치 훅)에서는 파일을 만들 수 없다. 그런 요구가 생기면 계약 공백으로 보고한다.
dataDir에 둔 기존 파일은 사용자 요청을 처리할 때 한 건씩 옮긴다. 설치 훅에서 일괄로 옮길 수 없다(§8-1). 그 파일을 읽는 요청에서 권한 판정을 마친 뒤create(…, req)로 옮기고, 업무 데이터의fileUuid를 조건부 갱신으로 바꾼 다음 로컬 파일을 지운다. 갱신이 0행이면 동시 요청이 먼저 옮긴 것이므로 방금 만든 파일을delete()로 지운다.mime은 BP가 넘긴 값이 그대로 보관된다.
20. 테스트 더블 — 포탈 없이 BP를 실행한다
BpHostRuntime 구현체는 포탈이 소유하므로, BP가 자기 로직을 단독으로 실행하려면 가상 호스트가 필요하다. SDK가 서브패스로 제공한다 — BP마다 손으로 만들면 계약이 확장될 때마다 각자 고쳐야 하고, 무엇보다 각자의 대역이 계약을 조금씩 다르게 흉내낸다. 대역이 실물보다 관대하면 단독 스모크는 통과하는데 포탈에서는 동작하지 않는 BP가 만들어진다.
ts
import { createFakeBpHost } from "@starj-portal/bp-backend-sdk/testing";
const host = createFakeBpHost(); // 인자 없이도 모든 멤버가 동작한다
const reg = await register(host);이 서브패스는 BP 산출물에 포함되지 않는다. 단독 스모크와 단위 테스트에서만 import하며, 번들에 포함되면 포탈 런타임에 불필요한 코드가 실린다.
20-1. 기본값
인자 없이 호출해도 모든 멤버가 동작한다 — 권한은 전부 허용, 사용자 1명이 로그인한 상태, 공통코드·파일·서비스는 비어 있다. BP가 사용하지 않는 표면까지 채울 필요가 없다. 실제로 사용하는 표면만 옵션으로 지정한다.
| 옵션 | 지정하는 것 |
|---|---|
businessPackageId | 이 계약이 발급된 BP(기본값 "test-bp"). dataDir·deriveSecret·파일 소유권의 스코프 근거다 |
database / dataDir / deriveSecret | 접속 정보와 파일 경로. dataDir은 포탈과 동일하게 없으면 생성된다 |
access | 권한 판정. boolean / rw 비트마스크(1=읽기, 3=읽기+쓰기, 0=권한 없음) / App별 비트마스크 / 함수 |
users · currentUser | 포탈 사용자 명부와 로그인 사용자. currentUser: null은 미인증 상태다. currentUser에 명부에 없는 번호를 지정하면 그 번호의 최소 프로필이 명부에 추가된다. 사용자마다 useYn: false를 지정하면 비활성 사용자가 된다. 비활성 사용자는 알림 수신자에서 항상 제외되고(경고 없음), users.* 조회는 프로필을 그대로 반환한다 |
strictUsers | true면 명부(users)에 없는 userNo를 포탈처럼 알림 수신자에서 제외한다. userNos, authorityKey·authorityKeys의 보유자(roleMembers), broadcastRecipients가 모두 대상이다. 제외가 있으면 logger.warn에 요청 수(requested)와 제외 수(excluded)만 남긴다. 기본값 false는 포탈보다 관대하다 — 명부에 없는 번호도 수신자로 남긴다. 새로 작성하는 시험에서는 true로 지정하고, 수신자로 쓰는 userNo를 모두 users에 시드한다 |
codes | 공통코드 초기 상태. onInstall이 심는 경우에는 installContext를 사용한다 |
roleMembers | 알림 authorityKey 수신자 해소 표. 목록에 없는 키는 수신자 0명이다 |
declaredAuthorities | 매니페스트 defaultAuthorities[].authId 목록. 여기 없는 authorityKey는 해석되지 않는다(계약 1.2.0). 지정하지 않으면 짧은 authId와 auth-{businessPackageId}-로 시작하는 값만 이 BP의 것으로 본다 |
disabledAuthorities | 운영자가 사용하지 않도록(useYn=false) 바꾼 Authority. 짧은 authId나 합성 id로 적는다. 그 역할의 보유자는 알림 수신자가 아니다 |
activatedDomains | 이 BP가 활성화된 도메인. 알림·감사 기록의 도메인 경계다. 알림 수신자는 userNos든 authorityKey로 해석한 보유자든 이 목록의 도메인 소속과 전역 소속으로 좁혀진다. 지정하지 않으면 모든 도메인이 활성화된 것으로 본다 — 도메인 경계의 거부를 확인하려면 지정한다 |
dependsOn · otherPackages | BP 간 연계의 경계와 상대 BP가 제공하는 서비스. 서비스 값에 { factory, contractVersion }을 지정하면 제공 측이 계약 버전을 공표한 상태가 되고, 팩토리만 지정하면 공표하지 않은 상태가 된다 |
destinations | 등록된 Destination과 그 허용목록·인증 방식·응답. ownedBy를 지정하면 소유 업무패키지가 되고, 생략하면 공용이다(계약 1.2.0). userTokenExchange를 지정하면 사용자 출처 Token Exchange Destination이 되어 req 없는 호출이 principal-required로 막히고, { failWith }로 사용자 토큰 차단 사유를 흉내낸다(계약 1.3.0) |
files | 포탈 파일 저장소의 초기 상태 |
fileMaxSizeBytes | files.create()의 파일당 크기 상한. 기본값은 포탈과 같은 20MB다. 작게 지정하면 상한 초과의 거부를 작은 데이터로 확인할 수 있다 |
fileWriteFails | (file, index) => boolean. true를 반환하면 그 파일의 저장 실패를 흉내낸다. 그 create()는 reject되고 아무것도 남지 않는다 |
noActiveStorageTarget | true면 저장소 대상은 있으나 활성 기본 대상이 없는 설치를 흉내낸다. files.create()가 reject된다 |
installedFeatures | 이 배포에 설치된 기능 식별자 목록(계약 1.4.0). features.has()·list()가 이 목록으로 답한다. 생략하면 BP_FEATURE_KEYS의 식별자가 전부 설치된 것으로 본다(스프레드시트 작성, 이미지 처리, 외부 저장소 유형 넷). []이면 기능 패키지가 없는 배포다 |
fileStorageFeature | 기본 저장소 대상의 유형이 요구하는 기능 식별자(계약 1.4.0). 예: BP_FEATURE_KEYS.storageProvider("S3"). 생략하면 기능을 요구하지 않는다. 이 값이 installedFeatures에 없으면 files.create()·read()·delete()가 미설치 거부로 reject된다. 시드 파일과 create()로 만든 파일은 모두 이 대상에 저장된 것으로 본다 |
rentalFailure | 대여(계약 1.5.0)의 대기열 초과·시간 초과를 흉내낸다. { workbook: "busy" }, { images: "timeout" }처럼 종류마다 지정한다. 기능 미설치와 입력 검증을 통과한 호출만 이 값으로 거부된다. 문자열이면 매번 거부하고, { reason: "busy", times: 2 }처럼 객체로 주면 처음 times번만 거부한 뒤 성공시킨다(times는 1 이상의 정수, 아니면 TypeError). 미설치·검증으로 거부된 호출은 횟수에 들지 않는다. 도중에 바꾸려면 setRentalFailure를 쓴다. BP가 503으로 응답해 다시 시도를 안내하는지, 다시 시도하면 성공하는지 확인할 때 쓴다(§22-3) |
codeGroupOwners · reservedCodeGroups | 공통코드 그룹의 소유 업무패키지와 포탈 예약 그룹. 설치 컨텍스트가 여기에 쓰면 BP_CODE_FORBIDDEN으로 거부된다(onExisting과 무관하다) |
auditResult | audit.record()의 반환값. false로 감사 기록 실패를 흉내낸다 |
onInstall에 전달할 설치 컨텍스트도 함께 제공된다. installContext.codes에 기록한 공통코드를 host.codes가 그대로 읽는다 — 포탈에서 두 표면이 같은 저장소를 참조하는 것과 같다.
ts
await reg.hooks.onInstall(host.installContext);
const statuses = await host.codes.list("TASK_STATUS"); // onInstall이 심은 값이 조회된다20-2. 일어난 일을 단언한다
단독 스모크의 목적은 "표면을 호출했는가"가 아니라 무엇이 어디로 갔는가다. 알림이 잘못된 수신자에게 전달되거나 감사 기록이 누락되어도 화면에는 아무 변화가 없으므로, 호출 기록을 단언하지 않으면 확인할 방법이 없다.
ts
const note = host.calls.notifications.at(-1);
note.category; // 발행 입력이 그대로 펼쳐져 있다
note.recipientUserNos; // 포탈이 해소한 수신자 — [43]
note.result; // 수신자가 0명이면 null
host.calls.audit; // 감사 기록 이력(recorded 포함)
host.calls.destinations; // 외부 호출 이력(status, blockedByPortal)
host.calls.serviceRequires; // 어느 BP의 어느 서비스를 요구했고 판정이 무엇이었나
host.calls.access; // checkAppAccess 판정 이력
host.calls.logs; // host.logger로 전송된 BP 로그
host.calls.workbook; // workbook.write 이력 — 넘긴 시트, 행·셀 합계, 거부 사유(계약 1.5.0)
host.calls.images; // images.normalizePhoto 이력 — 입력 크기, 적용한 옵션, 거부 사유(계약 1.5.0)기록은 배열이므로 구간을 나누어 확인할 때는 length = 0으로 비우거나 host.resetCalls()를 호출한다. 상태(사용자·공통코드·파일)는 유지된다.
20-3. 실패 경로를 확인한다
계약의 어려운 부분은 정상 경로가 아니라 실패 경로다. 아래 경로들을 확인하지 않으면 BP가 계약을 준수했는지 알 수 없다.
| 확인할 것 | 만드는 방법 |
|---|---|
알림 수신자 0명(publish가 null) | roleMembers에서 그 키를 빈 배열로 두거나 지정하지 않는다 |
services.require()의 not-declared | dependsOn에 그 BP를 넣지 않는다 |
not-loaded | dependsOn에는 넣고 otherPackages에서 제외한다 |
not-provided | otherPackages에는 넣고 그 이름의 서비스를 제외한다 |
contract-mismatch (범위 불일치) | otherPackages의 서비스에 contractVersion을 지정하고, 맞지 않는 versionRange로 require()한다 |
contract-mismatch (미공표) | 서비스를 팩토리만으로 지정하고 versionRange를 요구한다 |
files.claim()이 false | 업로더가 다른 사용자인 파일을 req의 세션 사용자로 결속하려 하거나, claimedBy를 다른 BP로 지정한다. currentUser: null이면 세션 없는 호출이 된다 |
files.create()가 reject | currentUser: null로 두고 세션 없는 req로 호출하거나, 파일을 0개·11개 넘기거나, fileMaxSizeBytes보다 큰 파일을 넘기거나, Uint8Array가 아닌 data를 넘기거나, noActiveStorageTarget: true로 두거나, fileWriteFails로 저장 실패를 지정한다. 거부 이유는 calls.files의 rejectedBecause로 단언한다 |
| 기능이 설치되지 않은 배포에서 화면 요소를 감추는지 | installedFeatures: []처럼 그 기능을 뺀 목록을 지정하고, BP가 features.has()의 false에 맞게 동작하는지 단언한다 |
files.create()·read()·delete()의 미설치 거부(계약 1.4.0) | installedFeatures: []와 fileStorageFeature: BP_FEATURE_KEYS.storageProvider("S3")를 함께 지정한다. 오류는 isBpCapabilityNotInstalled(e)로 판별하고, calls.files의 rejectedBecause가 "capability-not-installed"인지 단언한다. delete()가 거부된 뒤 stat()이 그 파일을 그대로 돌려주는지, BP가 업무 데이터의 첨부 참조를 지우지 않았는지 함께 단언한다 |
| 대여의 미설치 거부(계약 1.5.0) | installedFeatures: []로 지정한다. workbook.write()·images.normalizePhoto()가 입력을 보기 전에 CAPABILITY_NOT_INSTALLED로 reject된다. BP가 501로 응답하는지 단언한다 |
| 대여의 상한 위반·형식 오류(계약 1.5.0) | 상한을 넘는 입력을 그대로 넘긴다. 더블이 포탈과 같은 reason·limit으로 거부한다. BP가 미리 나누거나 줄였다면 이 거부가 나지 않아야 한다 |
대여의 busy·timeout(계약 1.5.0) | rentalFailure: { workbook: "busy" }처럼 지정한다. BP가 503으로 응답하는지 단언한다 |
| 대여를 다시 시도해 성공하는 경로(계약 1.5.0) | host.setRentalFailure({ workbook: { reason: "busy", times: 1 } })로 한 번만 거부하게 한다. 첫 호출이 503, 같은 요청을 다시 보내면 성공하는지 단언한다. setRentalFailure는 주어진 종류만 바꾸고 남은 횟수를 0부터 다시 센다. 값이 null이면 그 종류는 거부하지 않고, 키를 생략한 종류는 그대로 둔다 |
| 이미지로 읽을 수 없는 입력(계약 1.5.0) | PNG·JPEG·GIF·WebP 머리 바이트가 없는 바이트나 빈 바이트를 넘긴다. isBpImageNotDecodable(e)가 true다 |
설치 컨텍스트의 BP_CODE_FORBIDDEN | codeGroupOwners에 그 그룹을 다른 BP 소유로 지정하거나 reservedCodeGroups에 넣는다 |
onExisting: "keep"이 관리자 수정을 지키는지 | onInstall을 한 번 실행하고 updateCode()로 관리자의 수정을 흉내낸 뒤 다시 실행해, 값이 그대로인지와 calls.codeWrites의 outcome이 "kept"인지 단언한다 |
| 알림 수신자가 남의 Authority로 지정되어 거부 | roleMembers에 auth-portal-basis 같은 키를 두고 그 키로 발행한다(계약 1.2.0에서 해석되지 않는다) |
| 알림·감사가 도메인 경계에서 거부 | activatedDomains를 지정하고 그 밖의 domainId로 호출한다 |
| 알림 수신자가 명부에 없어 제외 | strictUsers: true로 지정하고 users에 넣지 않은 userNo를 수신자로 지정한다. 남는 수신자가 0명이면 publish가 null이다 |
| 알림 수신자가 비활성 사용자라 제외 | users에 그 사용자를 useYn: false로 시드한다 |
destinations.fetch()가 403 + not-owned | Destination에 ownedBy: "다른-bp"를 지정한다 |
destinations.fetch()가 403 | allowedPaths에서 제외하거나 requiresPrincipal: true로 지정하고 req 없이 호출한다 |
destinations.fetch()의 사용자 토큰 차단(401·403·502, 계약 1.3.0) | userTokenExchange: { failWith: "user-token-expired" }처럼 사유를 지정한다. 요청마다 바꾸려면 failWith에 함수를 주고 null을 돌려주면 교환이 성공한다. userTokenExchange: true로 두고 req 없이 호출하면 principal-required다. 차단 응답의 본문은 비어 있지 않으므로, BP가 그 본문을 화면에 옮기지 않는지 함께 단언한다 |
destinations.fetch()가 404 | 등록하지 않은 이름으로 호출하거나(not-found), notCallable: true로 지정한다 |
destinations.fetch()가 502 | unreachable: true |
audit.record()가 false | auditResult: false |
정본 예제 examples/task-bp/scripts/smoke.mjs가 이 방식으로 알림 수신자 0명과 감사 기록 실패를 확인한다.
20-4. 더블이 준수하는 규정
대역이 실물보다 관대하면 스모크의 의미가 사라지므로, 더블은 계약의 규정을 그대로 준수한다.
notifications.publish()는 수신자가 0명이면null이며 예외를 발생시키지 않는다.broadcastDomain은BpUserProfile.domainId가 일치하는 사용자를 대상으로 한다- 비활성 사용자는
users에useYn: false로 시드한다. 그 사용자는strictUsers값과 무관하게 네 수신자 경로(userNos,authorityKey,authorityKeys,broadcastDomain) 모두에서 제외되고, 포탈과 같이 경고 로그를 남기지 않는다.users.byUserNo()·byUsername()·current()는 비활성 사용자의 프로필도 반환한다. 프로필에는 활성 여부 필드가 없다 - 명부에 없는
userNo는strictUsers: true로 지정하면 포탈처럼 네 수신자 경로 모두에서 제외되고,logger.warn에 요청 수와 제외 수만 남는다(사용자 번호는 남기지 않는다). 기본값(strictUsers생략)은 아직 관대하다 — 명부에 없는 번호도 수신자로 남기므로, 그 상태로 통과한 시험이 포탈에서는 수신자 0명(null)을 만날 수 있다. 새로 작성하는 시험에는strictUsers: true를 권한다. 기본값은 다음 호스트 계약 major에서true로 바꿀 계획이다 codes.list()는useYn=false를 포함하고sortNo오름차순이다. 쓰기는installContext에만 있다users는 단건 조회만 제공하며,current()의null은 미인증과 사용자 레코드 없음을 함께 뜻한다services의 판정 순서는not-declared→not-loaded→not-provided→contract-mismatch→ok이며, 자기 자신의 id는dependsOn선언 없이 통한다destinations.fetch()는 예외를 발생시키지 않는다. 차단은x-portal-destination-blocked헤더가 붙은 403/404(사용자 토큰 차단은 401·403·502), 도달 실패는 헤더 없는 502이며, 대상 시스템이 그 헤더를 위조해 보내면 제거한다. 판정 순서는 포탈과 같아서 허용목록(allowed-paths)을 사용자 신원(principal-required)과 토큰 교환보다 먼저 본다(계약 1.3.0에서 맞췄다)files.claim()은 요청의 로그인 사용자가 업로더일 때만 성공하고(세션이 없으면 거부), 이 BP가 이미 claim했으면 요청한 사용자와 무관하게true다.stat()은 결속하지 않은 파일의uploadedBy를null로 감춘다.read()/delete()는 claim한 파일에만 동작하고,stat()/read()의fileSeq는 생략하면 1이다files.create()(계약 1.3.0)는 세션이 없거나, 파일이 0개 또는 10개를 넘거나,data가Uint8Array가 아니거나, 한 파일이라도 크기 상한을 넘거나, 활성 기본 저장소 대상이 없으면 reject된다. 성공하면fileSeq가 1부터 매겨지고 처음부터 이 BP의 소유다(ownedByThisBp: true, 업로더는 세션 사용자). 저장 실패를 흉내내면 아무것도 남지 않는다features(계약 1.4.0)의has()는installedFeatures에 없는 값과 카탈로그에 없는 값에false로 답하고,list()는 정렬한 사본을 돌려준다. 돌려받은 배열을 바꿔도 다음 답은 같다- 저장소 유형의 기능이 설치되지 않았으면(계약 1.4.0)
files.create()·read()·delete()가 포탈과 같은 형태(errorCode: "CAPABILITY_NOT_INSTALLED",details.feature)로 reject된다. 판정 순서도 포탈과 같다.create()는 세션·파일 수·data형식 검사 뒤, 크기 검사 전에 거부한다.read()는 없는 파일과 남의 파일에 먼저null을,delete()는 남의 파일에 먼저false를 돌려준 뒤 거부한다.delete()는 거부할 때 아무것도 지우지 않는다.stat()·claim()은 거부하지 않는다 workbook.write()·images.normalizePhoto()(계약 1.5.0)는 포탈과 같은 순서로 거부한다. 기능 미설치(입력을 보기 전) → 입력 검증(포탈과 같은 사례표, 같은reason·limit) →rentalFailure→ 성공. 이미지는 그다음에 머리 바이트를 보고, PNG·JPEG·GIF·WebP가 아니면IMAGE_NOT_DECODABLE이다. 빈 바이트는 검증 단계의IMAGE_NOT_DECODABLE이다. 성공 결과는 실제 파일이 아니다. 워크북은PK로 시작하는 고정 바이트라 스프레드시트 프로그램으로 열리지 않고, 시트 내용은calls.workbook[n].sheets로 확인한다. 이미지는 입력 바이트의 사본을 원본·썸네일로 돌려주고mime은 입력 형식 그대로다(축소하지 않는다). 동시 처리 칸과 실제 시간은 흉내내지 않는다- 설치 컨텍스트의
onExisting(계약 1.3.0)은 생략하면"overwrite"이고,"keep"이면 같은 키의 값을 바꾸지 않는다. 그룹이 없는upsertCode와 다른 BP 소유·예약 그룹에 대한 쓰기는"keep"에서도 거부된다. 판정 순서는 포탈과 같아서, 이 BP 소유 그룹은 예약 표시와 무관하게 쓸 수 있다 audit.record()가 남기는 이벤트 타입은bp:{businessPackageId}:{action}이며, 호출 기록의eventType으로 확인한다- 선언하지 않은
authorityKey와 소유가 다른 Destination은 거부된다(계약 1.2.0) audit.record()는 예외를 발생시키지 않고boolean을 반환한다checkAppAccess의action은read/update둘뿐이다(그 밖의 값은 예외가 된다 — 단독 스모크는.mjs여서 타입 검사가 없다)
이 규정들이 실제로 지켜지는지는 SDK 자체 테스트가 확인한다 — packages/bp-backend-sdk/src/testing/fake-bp-host.test.ts, fake-bp-host.rental.test.ts, semver-range.test.ts가 단일 원천이다. 대여의 검증 사례는 포탈이 같은 표로 시험한다.
더블이 확인해 주지 못하는 것이 하나 있다. BP 간 연계의 상대편은 이 더블 안에서는 자기가 지정한 대역이다. 즉 otherPackages에 적은 서비스 타입이 상대 BP의 실제 표면과 같은지는 이 도구로 알 수 없다 — §17-1의 통합 검증이 그 역할을 대신한다.
21. 기능이 설치되어 있지 않을 때 — host.features (계약 1.4.0)
스프레드시트 작성(export.workbook), 이미지 처리(image.processing), 외부 저장소 유형(storage.provider.<유형>)은 배포마다 선택해 설치하는 기능 패키지다. 기능 패키지가 설치되지 않은 배포에서도 BP는 적재되고 동작해야 한다. 그러려면 BP가 두 가지를 할 수 있어야 한다. 무엇이 설치되어 있는지 확인하는 것과, 설치되지 않은 기능 때문에 거부된 호출을 다른 실패와 구분하는 것이다.
ts
import {
BP_FEATURE_KEYS,
bpCapabilityNotInstalledFeature,
isBpCapabilityNotInstalled,
} from "@starj-portal/bp-backend-sdk";
host.features.has(BP_FEATURE_KEYS.EXPORT_WORKBOOK); // true 또는 false
host.features.has(BP_FEATURE_KEYS.storageProvider("S3")); // "storage.provider.S3"
host.features.list(); // 설치된 식별자 전체(정렬한 사본)| 규정 | 내용 |
|---|---|
| 호출 시점 | 요청 처리 중에만 호출한다. register() 안에서 부르면 포탈 코어가 연결되지 않았다는 오류가 난다 |
| 동기 | has()·list()는 동기다. 포탈은 기동할 때 기능 패키지를 조립하며, 한 프로세스 안에서는 답이 바뀌지 않는다 |
| 알 수 없는 값 | 카탈로그에 없는 식별자를 넘기면 false다. 오타도 false이므로 문자열을 직접 쓰지 말고 BP_FEATURE_KEYS를 쓴다 |
| 전달하지 않는 것 | 기능 패키지의 이름·버전·구성. 기능 식별자만 전달한다 |
| 요구 범위 | features를 쓰는 BP는 runtime.hostContract를 ^1.4.0으로 선언한다. 1.3.0 이하 포탈에는 이 멤버가 없어 호출하면 실행 오류가 난다 |
21-1. 미리 확인하는 것은 편의이고, 통제는 서버의 거부다
설치 여부는 화면 요소를 감추거나 운영자에게 설치를 안내하는 데만 쓴다. 권한 판정의 근거로 쓰지 않는다. 설치되지 않은 기능의 연산은 포탈이 스스로 거부하므로, BP가 미리 확인했더라도 호출의 거부는 처리해야 한다.
업무 첨부(host.files)는 미리 확인하지 않는다. 저장할 수 있는지는 관리자가 정한 기본 저장소 대상의 유형에 달려 있고, 관리자는 그 대상을 언제든 바꿀 수 있다. 미리 물어 얻은 답은 호출하는 시점에 이미 틀렸을 수 있다. 그래서 files에는 사전 질의가 없고, 호출 자체가 미설치 거부로 실패한다. BP는 그 실패를 받아 처리한다.
21-2. 거부를 판별한다
포탈이 던지는 미설치 거부는 errorCode가 "CAPABILITY_NOT_INSTALLED"이고 details.feature에 빠진 기능 식별자가 있는 Error다. 판별은 SDK의 함수로 한다.
| 함수 | 반환 |
|---|---|
isBpHostError(e) | 포탈이 던진 거부 형태(errorCode 문자열을 가진 Error)인가 |
isBpCapabilityNotInstalled(e) | 미설치 거부인가. 코드와 details.feature(문자열)를 함께 본다 |
bpCapabilityNotInstalledFeature(e) | 미설치 거부이면 빠진 기능 식별자, 아니면 null |
- 오류 문구로 분기하지 않는다. 문구는 포탈이 바꿀 수 있고, 바뀌면 분기가 오류 메시지 없이 끊긴다.
instanceof로 판별하지 않는다. 포탈이 던지는 오류 클래스는 포탈 번들의 것이며, BP 번들 안에서 같은 이름의 클래스를 만들어도instanceof가 성립하지 않는다. SDK가 오류 클래스를 내보내지 않는 이유다.
21-3. 첨부 삭제의 거부를 처리한다
계약 1.4.0부터 files.delete()는 기능이 설치되지 않은 대상의 파일을 지우지 않고 거부한다. 포탈 쪽 구현이 바뀐 것이므로 이 거부는 BP가 선언한 runtime.hostContract 범위와 무관하게 지금 포탈에 적재된 모든 BP에 적용된다. try/catch로 감싸고, 미설치 거부이면 운영자에게 그 기능의 설치를 안내한다. 거부되면 파일이 남아 있으므로 업무 데이터의 첨부 참조도 지우지 않는다. 기능을 설치한 뒤 다시 호출하면 지워진다.
ts
try {
await host.files.delete(fileUuid);
} catch (e) {
const feature = bpCapabilityNotInstalledFeature(e);
if (feature !== null) {
host.logger.warn("첨부를 지우지 못했습니다. 저장소 기능이 설치되지 않았습니다", { fileUuid, feature });
throw capabilityNotInstalledResponse(e); // §21-4
}
throw e;
}21-4. HTTP 응답으로 내보낸다
BP 엔드포인트가 이 거부를 화면에 전달할 때는 포탈의 REST 거부와 같은 형식을 쓴다. 상태 코드는 501이고, 본문은 { statusCode, errorCode, feature, message }다. 화면은 errorCode와 feature로 "이 배포에서는 쓸 수 없는 기능입니다" 안내를 고른다. 직접 501로 바꾸거나, 감싸지 않고 다시 던지면 포탈이 같은 형식으로 응답한다. 원래 오류를 다른 Error로 감싸 던지면 §12의 규칙에 따라 500이 되어 다른 실패와 구분되지 않는다.
ts
import { HttpException } from "@nestjs/common";
import { BP_CAPABILITY_NOT_INSTALLED, bpCapabilityNotInstalledFeature } from "@starj-portal/bp-backend-sdk";
/** 미설치 거부를 포탈 REST 거부와 같은 501 응답으로 바꾼다. 미설치 거부가 아니면 그대로 돌려준다. */
export function capabilityNotInstalledResponse(e: unknown): unknown {
const feature = bpCapabilityNotInstalledFeature(e);
if (feature === null) return e;
return new HttpException(
{ statusCode: 501, errorCode: BP_CAPABILITY_NOT_INSTALLED, feature, message: "이 배포에서는 쓸 수 없는 기능입니다." },
501,
);
}create()·read()의 거부도 같은 방법으로 내보낸다. 첨부 저장이 501로 실패할 때 운영자가 확인할 것은 troubleshooting.md의 "첨부 저장이 501로 실패한다"에 있다.
22. 스프레드시트·이미지를 빌려 쓰기 — host.workbook·host.images (계약 1.5.0)
표를 스프레드시트(xlsx) 파일로 만드는 일과 사진을 정규화하는 일은 포탈에 설치된 기능을 빌려 쓴다. Excel 라이브러리나 이미지 라이브러리를 BP 번들에 넣지 않는다. 넣으면 그 기능이 설치되지 않은 배포에서도 BP만 따로 그 기능을 갖게 되고, 네이티브 바이너리를 쓰는 이미지 라이브러리는 번들에 넣을 수도 없다.
ts
import { BP_RENTAL_LIMITS } from "@starj-portal/bp-backend-sdk";
// 표 → xlsx 바이트
const book = await host.workbook.write({
sheets: [
{
name: "일감",
columns: [{ header: "번호", width: 8 }, { header: "제목", width: 40 }],
rows: [[1, "첫 일감"], [2, "둘째 일감"]],
},
],
});
book.mime; // "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
book.extension; // "xlsx"
// 이미지 한 장 → 방향 보정·메타데이터 제거·긴 변 축소 + 정사각 썸네일
const photo = await host.images.normalizePhoto({ data: uploadedBytes, maxEdge: 1024, thumbnailSize: 128 });
photo.original.mime; // 출력 형식은 포탈이 정한다
photo.thumbnail.data; // Uint8Array| 규정 | 내용 |
|---|---|
| 호출 시점 | 요청 처리 중에만 호출한다. register() 안에서 부르면 포탈 코어가 연결되지 않았다는 오류가 난다 |
| 요청 객체 | 받지 않는다. 사용자나 세션이 판정 근거가 되지 않는 변환이다. 권한 확인은 BP가 먼저 한다 — checkAppAccess를 통과한 요청만 포탈의 처리 칸을 쓰게 한다 |
| 서식 | 머리글 굵게, 열 너비, 머리글 행 고정만 있다. 수식·날짜 서식·병합·스타일은 없다. 문자열 셀은 =로 시작해도 수식이 아니라 문자열로 쓰인다. 날짜는 문자열(예: ISO 8601)로 넣는다 |
| 이미지 처리 | 사진 정규화 하나다. 임의의 크기 조정이나 형식 변환은 없다. 출력 형식은 포탈이 정하며 BP가 고를 수 없다 |
| 결과 | 호출할 때마다 새로 만든 Uint8Array다. 포탈은 결과를 보관하지 않는다. 응답으로 내려보내거나 files.create로 저장한다 |
| 감사 | 포탈은 대여를 감사로그에 남기지 않는다. 업무상 남겨야 하면 BP가 audit.record로 남긴다 |
| 요구 범위 | 쓰는 BP는 runtime.hostContract를 ^1.5.0으로 선언한다. 1.4.0 이하 포탈에는 이 멤버가 없어 호출하면 실행 오류가 난다 |
22-1. 응답으로 내려보내기
만든 파일을 바로 내려받게 하려면 StreamableFile로 돌려준다. 위젯은 그 응답을 Blob으로 받아 저장한다(frontend-guide.md §6 "파일 내려받기").
ts
import { Get, Query, Req, StreamableFile } from "@nestjs/common";
@Get("export")
async exportList(@Req() req: unknown, @Query("domainId") domainId: string): Promise<StreamableFile> {
await this.assert(req, domainId, APP_LIST, "read"); // 권한이 먼저다
const sheet = await this.buildSheet(domainId); // where에 domainId를 넣는다(§6-4)
let result;
try {
result = await this.host.workbook.write({ sheets: [sheet] });
} catch (e) {
throw rentalFailureResponse(e); // §22-3
}
return new StreamableFile(Buffer.from(result.data), {
type: result.mime,
disposition: `attachment; filename="tasks.${result.extension}"`,
});
}GET /tasks/export처럼 고정 경로를 쓰면 @Get(":id")보다 먼저 선언한다(§3). 정본 예제 examples/task-bp/backend/src/task.controller.ts의 exportList가 이 형태다.
내려받기는 데이터를 바꾸지 않으므로 포탈이 감사 기록을 요구하지 않는다. 내보내는 데이터가 민감하면 host.audit.record로 누가 언제 내려받았는지 남길지 업무패키지가 정한다.
22-2. files.create로 저장하기
보관해야 하는 문서(월말 보고서 등)나 정규화한 사진은 files.create로 포탈 저장소에 저장한다(§19-1). 저장한 사용자는 포탈이 req의 세션으로 판정한다.
ts
const photo = await host.images.normalizePhoto({ data });
const saved = await host.files.create(
{
files: [
{ originName: "photo", mime: photo.original.mime, data: photo.original.data },
{ originName: "photo-thumb", mime: photo.thumbnail.mime, data: photo.thumbnail.data },
],
},
req,
);
// saved.fileUuid를 업무 데이터에 남긴다.대여와 저장은 서로 다른 기능에 걸린다. 사진 정규화는 image.processing이, 저장은 기본 저장소 대상의 유형 기능이 있어야 한다. 둘 다 미설치 거부를 처리한다.
22-3. 거부 처리
호출 순서는 기능 설치 판정 → 입력 검증 → 동시 처리·대기열·시간 → 처리다. 앞 단계에서 거부되면 뒤 단계는 시작하지 않는다. 사유는 오류 문구가 아니라 값으로 가른다.
| 거부 | 판별 | 다시 시도 | BP가 할 일 | 권장 응답 |
|---|---|---|---|---|
| 기능 미설치 | isBpCapabilityNotInstalled(e), feature는 export.workbook 또는 image.processing | 안 된다 | 운영자에게 그 기능 패키지의 설치를 안내한다 | 501 { statusCode, errorCode, message, feature }(§21-4) |
| 상한 위반 | bpRentalRejectReason(e) === "limit-exceeded", 항목은 bpRentalRejectedLimit(e) | 안 된다 | 입력을 나누거나 줄인다(§22-4) | 400. 무엇을 줄일지 BP 문장으로 안내한다 |
| 형식 오류 | "invalid-input" | 안 된다 | BP 코드의 결함이다. 입력을 고친다 | 다시 던진다(포탈이 400으로 응답한다) |
| 대기열 초과 | "busy" | 된다 | 잠시 뒤 다시 시도하라고 안내한다 | 503 |
| 시간 초과 | "timeout" | 된다 | 다시 시도하거나 입력을 줄이라고 안내한다 | 503 |
| 이미지로 읽을 수 없음(빈 바이트 포함) | isBpImageNotDecodable(e) | 안 된다 | 사용자에게 다른 이미지를 올리라고 안내한다 | 400 |
ts
import { HttpException } from "@nestjs/common";
import { BP_REQUEST_REJECTED, bpRentalRejectReason } from "@starj-portal/bp-backend-sdk";
/** 대여 거부를 HTTP 응답으로 바꾼다. 해당하지 않으면 그대로 돌려준다. */
export function rentalFailureResponse(e: unknown): unknown {
const notInstalled = capabilityNotInstalledResponse(e); // §21-4
if (notInstalled !== e) return notInstalled;
const reason = bpRentalRejectReason(e);
if (reason === "busy" || reason === "timeout") {
return new HttpException(
{ statusCode: 503, errorCode: BP_REQUEST_REJECTED, message: "처리 중인 작업이 많습니다. 잠시 뒤 다시 시도하세요." },
503,
);
}
return e;
}- 감싸지 않고 다시 던지면 포탈이 응답을 만든다. 미설치는 501,
busy·timeout은 503{ statusCode, errorCode, message }, 그 밖의REQUEST_REJECTED는 400이다. 다른Error로 감싸 던지면 §12의 규칙에 따라 500이 되어 구분되지 않는다. busy·timeout은 BP가 막을 수 없다. 처리 칸은 종류별로 프로세스에 하나씩 있고 코어와 다른 BP의 작업이 함께 쓴다. 진행 중 2건, 대기열 8건이 차 있으면 새 호출은 기다리지 않고 바로busy로 거부된다. 시간은 호출한 시각부터 60초다(대기열에서 기다린 시간 포함). BP 안에서 곧바로 반복 호출하지 말고 사용자에게 다시 시도를 맡긴다. 반복 호출은 칸을 더 오래 붙잡는다.- 오류 문구에는 위치(시트 번호, 행 번호)만 있고 셀 값·시트 이름·이미지 내용은 없다. 문구를 사용자에게 그대로 보여 주지 말고 BP 문장으로 안내한다.
22-4. 상한을 미리 지킨다
상한을 넘는 입력은 포탈이 처리를 시작하기 전에 limit-exceeded로 거부한다. 다시 시도해도 결과가 같으므로 보내기 전에 BP가 확인한다. 값은 BP_RENTAL_LIMITS에 있다. 숫자를 코드에 옮겨 적지 않는다.
| 상수 | 값 | 위반하면 limit |
|---|---|---|
WORKBOOK_MAX_SHEETS | 10 | sheets |
WORKBOOK_MAX_COLUMNS | 50(시트당) | columns |
WORKBOOK_MAX_ROWS_TOTAL | 50,000(모든 시트의 합, 머리글 행 제외) | rows |
WORKBOOK_MAX_CELLS_TOTAL | 500,000. 시트마다 (행 수 + 1) × 열 수의 합 | cells |
SHEET_NAME_MAX_LENGTH | 31. [ ] : * ? / \ 불가, 대소문자 무시하고 중복 불가 | (invalid-input) |
CELL_TEXT_MAX_LENGTH | 32,767(머리글 포함) | cellText |
COLUMN_WIDTH_MIN·COLUMN_WIDTH_MAX | 1·255 | columnWidth |
IMAGE_MAX_BYTES | 20MiB(같은 값은 통과) | imageBytes |
MAX_EDGE_MIN·MAX_EDGE_MAX·MAX_EDGE_DEFAULT | 64·4096·512 | maxEdge |
THUMBNAIL_MIN·THUMBNAIL_MAX·THUMBNAIL_DEFAULT | 16·512·128 | thumbnailSize |
TIMEOUT_MS · CONCURRENCY_PER_KIND · QUEUE_PER_KIND | 60,000 · 2 · 8 | (timeout·busy) |
ts
const cells = (rows.length + 1) * columns.length;
if (rows.length > BP_RENTAL_LIMITS.WORKBOOK_MAX_ROWS_TOTAL || cells > BP_RENTAL_LIMITS.WORKBOOK_MAX_CELLS_TOTAL) {
throw new BadRequestException("내보낼 행이 너무 많습니다. 조회 범위를 줄이세요.");
}
const clip = (text: string) => text.slice(0, BP_RENTAL_LIMITS.CELL_TEXT_MAX_LENGTH);- 열이 많으면 행 상한보다 셀 상한이 먼저 걸린다. 50열 시트는 행 9,999개까지다.
- 상한을 넘는 표는 시트를 나누어도 합계 상한에 걸린다. 조회 범위(기간·상태)를 줄이게 한다.
- 행마다 길이가 열 수와 같아야 하고, 셀은 문자열·유한한 숫자·불리언·
null만 된다.undefined·객체·NaN·bigint는invalid-input이다.
22-5. 테스트 더블로 확인한다
createFakeBpHost()의 workbook·images는 포탈과 같은 순서와 같은 사례표로 거부한다(§20-4). 설치된 경우와 설치되지 않은 경우를 둘 다 확인한다. 설치된 경우만 보면 기능 패키지가 없는 배포에서 500으로 실패하는 상태를 통과시킨다.
ts
const installed = createFakeBpHost();
const lite = createFakeBpHost({ installedFeatures: [] });
const busy = createFakeBpHost({ rentalFailure: { workbook: "busy" } });
const once = createFakeBpHost({ rentalFailure: { workbook: { reason: "busy", times: 1 } } }); // 한 번 거부 뒤 성공
installed.setRentalFailure({ images: "timeout" }); // 도중에 바꾼다. 주어진 종류만 바뀐다
installed.calls.workbook.at(-1)?.sheets; // 넘긴 시트(호출 시점의 사본)더블의 결과는 실제 파일이 아니다. 워크북은 PK로 시작하는 고정 바이트라 열리지 않고, 이미지는 입력 사본이다. 파일이 실제로 열리는지, 사진이 실제로 줄었는지는 포탈에서만 확인할 수 있다. 정본 예제 examples/task-bp/scripts/smoke.mjs가 설치·미설치·busy·timeout 네 경우를 확인한다.
함께 볼 것
- host-contract.md — 포탈과 BP의 책임 분리선(경계)에서 지켜야 하는 규정
- bp-manifest.md — 능력 표면의 필드 스키마
- build-and-package.md — 위 구현을 번들로 만드는 방법
- frontend-guide.md §11 — 위젯을 시험할 때 사용하는 Portal Shell(포탈 셸) 테스트 더블
- frontend-guide.md §3 — 설치된 기능에 따라 위젯의 버튼을 감추는 방법