테마
트러블슈팅
이 플랫폼의 실패는 대부분 예외가 아니라 오류 메시지 없이 나타난다. 어느 단계에서 걸려도 포탈은 정상 기동하고, 해당 업무패키지(Business Package, 이하 BP)만 적재되지 않는다. 그래서 "오류 메시지는 없는데 동작하지 않는다"를 기본 상황으로 두고 진단하는 것이 효율적이다.
먼저: 누가 무엇을 확인할 수 있는가
진단 수단이 두 곳에 나뉘어 있다. 어느 쪽인지 먼저 판단하면 불필요한 재작업을 줄일 수 있다.
| 확인 대상 | 누가 확인하는가 |
|---|---|
| 산출물의 형태·내용(매니페스트·번들·마이그레이션) | 개발자. examples/task-bp/scripts/preflight.mjs가 자동으로 검사한다 |
| 위젯 전역 등록·재마운트 | 개발자. examples/task-bp/scripts/check-widget.mjs |
| 마이그레이션이 실제로 적용되는지 | 개발자. examples/task-bp/scripts/verify-package.mjs가 포탈의 마이그레이션 러너와 같은 방식(한 트랜잭션)으로 적용해 본다. verify-deploy.mjs는 prisma migrate deploy로 적용하고 마이그레이션이 schema.prisma와 어긋나지 않는지 본다 |
| 아카이브의 구조·무결성·서명(V1~V4), 방언별 마이그레이션 폴더·SQL 허용 목록(V7·V8 정적 부분) | 개발자. starj package verify, examples/task-bp/scripts/preflight.mjs·verify-package.mjs |
| 반입 검증 결과(V5~V12)와 승인 대기 상태 | 패키지 관리 권한을 가진 계정. starj package list --candidates, starj package show <후보 id> |
register()부터 컨트롤러까지의 실행 | 개발자. examples/task-bp/scripts/smoke.mjs가 SDK의 테스트 더블(@starj-portal/bp-backend-sdk/testing)로 실행한다 |
| 건너뛴 BP와 그 사유 | 업무패키지 관리 권한을 가진 계정. GET /bp/runtime-info의 skipped[] |
| 포탈이 제공하는 호스트 계약 버전 | 같은 계정. 같은 API의 hostContractVersion |
| 운영 DB 방언 | 같은 계정. 같은 API의 databaseProvider |
| 건너뛴 사유의 원본 예외 메시지·스택트레이스 | 포탈 운영자만. 위 API의 reason은 호스트 경로·스택트레이스를 포함하지 않는다 |
| 도메인 활성화·권한 부여 상태 | 포탈 관리자만 |
GET /bp/runtime-info가 진단의 출발점이다. 업무패키지 관리 권한을 요구한다 — 설치된 업무패키지의 이름과 버전 목록은 배포 인벤토리이므로 모든 로그인 사용자에게 열려 있지 않다. 권한이 있는 계정이라면 브라우저에서 포탈에 로그인한 뒤 그 주소를 직접 열어도 되고, 없으면 아래 문장으로 운영자에게 조회를 요청한다. 응답 필드는 host-contract.md §5에 있다.
GET /bp/runtime-info의 응답에서hostContractVersion,databaseProvider, 그리고<BP id>의loaded[]·skipped[]항목을 알려 주십시오.
이 API로 좁혀지지 않을 때만 운영자에게 추가로 요청한다.
포탈 부팅 로그에서
<BP id>로 검색해 건너뛴 사유의 상세(detail필드)를 알려 주십시오./bp/runtime-info의skipped[]에는code와 짧은 설명만 포함됩니다.
BP가 나타나지 않는다
먼저 사유를 직접 조회한다. 추측으로 좁히지 않는다.
- 업무패키지 관리 권한이 있는 계정으로
GET /bp/runtime-info를 조회한다(권한이 없으면 운영자에게 요청한다). - 내 BP의
id가loaded[]에 있으면 적재는 성공한 것이다 — 원인은 도메인 활성화·권한 부여 쪽이다(이 문서 마지막 절). skipped[]에 있으면 그 항목의code를 아래 표와 대조한다.- 어느 쪽에도 없으면 아직 설치가 확정되지 않은 것이다. 반입 후보의 상태(검증 실패, 승인 대기, 적용 실패, 되돌림)를 운영자와 확인한다(아래 "패키지 반입이 거부된다"). 개발 모드의 소스 폴더라면 폴더 배치를 확인한다.
skipped[].code | 원인 | 조치 | 누가 |
|---|---|---|---|
MANIFEST_UNREADABLE | bp-manifest.yaml을 읽거나 파싱할 수 없다 | 파일 실재와 YAML 문법, 필수 필드(id/name/version/backend.entry) 확인 | 개발자 |
MANIFEST_API_VERSION_UNSUPPORTED | 포탈이 이 서술자 포맷을 인식하지 못한다 | apiVersion 값과 포탈 버전 확인 | 운영자 + 개발자 |
HOST_CONTRACT_MISMATCH | runtime.hostContract 범위 밖 | reason에 BP 요구 범위와 포탈 제공 버전이 함께 표시된다. 선언을 고쳐 재빌드한다 — bp-manifest.md §runtime | 개발자 |
DATABASE_PROVIDER_UNSUPPORTED | backend.database.providers에 운영 방언이 없다 | 같은 응답의 databaseProvider를 확인하고 그 방언을 지원하도록 재빌드한다 | 개발자 |
BACKEND_ENTRY_MISSING | backend.entry가 가리키는 파일이 산출물에 없다 | 배포 전 점검의 "backend.entry가 실재한다" 항목 | 개발자 |
SQLITE_MIGRATION_FAILED / POSTGRESQL_MIGRATION_FAILED | 개발 소스 폴더에서 마이그레이션 적용이 실패했다. 그 BP의 미적용분 전체가 취소되었다. 흔한 원인은 이미 적용한 migration.sql을 고친 것(체크섬 불일치)과, 이력 없이 같은 이름의 테이블이 남아 있는 것이다 | 고친 내용은 새 마이그레이션으로 옮긴다. 개발 DB라면 포탈 저장소에서 pnpm bp:dev-reset <BP id>로 그 BP의 테이블과 이력을 지운 뒤 다시 기동한다 — backend-guide.md §7-4 | 개발자 |
REGISTER_EXPORT_MISSING | 번들이 register를 export하지 않는다 | 배포 전 점검의 "register를 export한다" 항목 | 개발자 |
REGISTER_RETURNED_NO_MANIFEST | register()가 능력 표면을 반환하지 않았다 | 반환값의 manifest 확인 | 개발자 |
LOAD_ERROR | 번들 적재 또는 register() 실행 중 예외 | 흔한 원인은 ERR_MODULE_NOT_FOUND(호스트 제공 모듈 목록에 없는 모듈을 external로 제외했다)와 번들 자산 상대경로 오류다. 배포 전 점검의 "번들에 남은 import" 항목, build-and-package.md §2 "런타임 자산 경로" | 개발자 |
reason에는 호스트의 파일 경로나 스택트레이스가 포함되지 않는다. 원본 예외 메시지가 필요하면 그 지점부터 운영자에게 부팅 로그 조회를 요청한다.
패키지 반입이 거부된다
반입 후보가 VERIFY_FAILED이면 검증 항목별 결과에 실패 코드가 있다. 오프라인에서 재현할 수 있는 것(V1~V4)은 starj package verify <tgz> --trusted-key <공개 키>로 먼저 확인한다.
| 실패 | 원인 | 조치 |
|---|---|---|
V1 ARCHIVE_CORRUPT | GNU tar 형식으로 다시 묶었거나 아카이브가 잘렸다 | pnpm run package로 다시 만든다. 직접 묶었다면 tar --format=pax를 쓴다 — build-and-package.md §7-5 |
V1 ENTRY_TYPE_NOT_ALLOWED | 링크·장치 파일·GNU 긴 이름 항목이 있다 | 같다 |
| V2 | starj-package.json이 bp-manifest.yaml과 어긋난다 | bp-manifest.yaml을 고친 뒤 build와 package를 다시 실행한다. 아카이브 안의 파일을 손으로 고치지 않는다 |
| V3 | 파일 목록이나 해시가 맞지 않는다 | 아카이브를 푼 뒤 내용을 바꿔 다시 묶은 상태다. pnpm run package로 다시 만든다 |
V4 UNSIGNED | 운영 모드 포탈에 서명 없는 아카이브를 넣었다 | 서명 키로 다시 만든다 — build-and-package.md §7-3 |
V4 KEY_UNKNOWN | 포탈에 등록되지 않은 키로 서명했다 | 운영자에게 공개 키(.trusted-key.json)를 전달해 신뢰 키로 등록받는다 |
V4 KEY_REVOKED / KEY_EXPIRED | 폐기되었거나 만료된 키다 | 새 키로 서명하고 운영자에게 새 키 등록을 요청한다 |
V4 SIGNATURE_INVALID | 서명이 매니페스트와 맞지 않는다. 개발 모드에서도 실패한다 | 아카이브가 변조되었거나 매니페스트를 바꾼 뒤 서명을 갱신하지 않았다. 다시 만든다 |
| V5 | 포탈의 호스트 계약 버전이 runtime.hostContract 범위 밖이다 | host-contract.md §4 |
V7 DIALECT_MIGRATIONS_MISSING | 선언한 방언의 마이그레이션 폴더가 없다. 실행 중인 방언을 선언하지 않은 경우도 V7이다 | pnpm run migrate:new가 두 방언을 모두 만든다. preflight가 미리 판정한다 |
| V8 | 접두어 위반, 다른 패키지와 접두어 충돌, 허용 목록 밖의 마이그레이션 SQL(SQL_NOT_ALLOWED), 선언 테이블 불일치(DATABASE_DECLARATION_INVALID) | 메시지에 마이그레이션 이름과 문장 번호가 나온다. 접두어 충돌 밖의 항목은 preflight와 starj package pack이 미리 판정한다. 허용 목록은 build-and-package.md §7-6. 경고 IF_NOT_EXISTS_PRESENT는 설치를 막지 않지만 IF NOT EXISTS를 지운다(backend-guide.md §7-4) |
V2 CONTRACT_REQUIRES_BACKWARD_COMPATIBLE | contractDir을 선언했는데 backwardCompatible: true가 없다(호스트 계약 2.1.0) | 확장 마이그레이션이 하위 호환 규칙을 지키는지 확인하고 backwardCompatible: true를 선언한다 — bp-manifest.md §backend |
V8 BACKWARD_INCOMPATIBLE_SQL | backwardCompatible: true인데 확장 마이그레이션이 이미 있던 테이블에서 열 삭제·이름 변경·자료형 변경·제약 추가·DELETE 같은 문장을 쓴다(호스트 계약 2.1.0) | 메시지의 문장을 정리 마이그레이션(contractDir)으로 옮긴다. 인계 전에 starj package verify … --previous <직전 아카이브>(starj CLI 0.3.0 이상)로 미리 확인한다 — backend-guide.md §7-5 |
| V11 | 같은 이름·버전이 이미 설치되었는데 내용이 다르다 | version을 올린다 |
V11 DOWNGRADE_AFTER_CONTRACT | 정리 마이그레이션이 이미 실행된 버전보다 낮은 버전을 반입했다(호스트 계약 2.1.0) | 그 버전으로는 돌아갈 수 없다. 문제를 고친 더 높은 버전을 만든다 |
starj package verify가 서명된 아카이브에 SIGNATURE_NOT_CHECKED를 내면 --trusted-key를 주지 않은 것이다. 서명이 틀렸다는 뜻이 아니다.
적용 단계의 PACKAGE_MIGRATION_FAILED는 마이그레이션이 실행 중에 실패했다는 뜻이다. 그 BP의 미적용 마이그레이션 전체가 취소되었으므로 DB는 적용 전 상태다. 메시지가 "이미 적용된 마이그레이션이 변경되었습니다"이면 배포한 migration.sql을 고친 것이다. 고친 내용은 새 마이그레이션으로 옮긴다.
컨트롤러가 등록되지 않는다 (404인데 오류는 없다)
원인이 두 가지다. 둘을 순서대로 확인한다.
(1) 라우트 접두어를 선언하지 않았다
포탈은 BP 컨트롤러에 경로 접두어를 자동으로 추가하지 않는다. @Controller("tasks")로 선언하면 그 라우트는 /tasks이고, 프론트엔드가 호출하는 ${host.apiBasePath}/tasks(= /bp/task-bp/tasks)와 일치하지 않는다.
ts
@Controller("bp/task-bp/tasks") // ← BP가 직접 선언한다자세한 규칙은 backend-guide.md §3.
(2) 호스트 제공 모듈의 사본이 둘이다
@nestjs/common사본이 둘 → 의존성 주입 토큰이 서로 다른 객체가 되어 주입이 이루어지지 않는다reflect-metadata사본이 둘 → 데코레이터 메타데이터가 다른 저장소에 쌓인다
문자열 검색으로 판정하지 않는다. grep -c "class BadRequestException"은 reflect-metadata 누락을 검출하지 못하며 "합격"을 반환한다. 배포 전 점검의 호스트 제공 모듈 인라인 검사를 사용한다(examples/task-bp/scripts/preflight.mjs).
(3) peer 버전이 어긋났다
@nestjs/common을 캐럿 범위로 설치하면 포탈이 제공하는 것과 다른 버전이 해석될 수 있다. HOST_PROVIDED_VERSIONS의 값으로 캐럿 없이 고정한다 — host-contract.md §3.
의존성 주입이 undefined다
emitDecoratorMetadata 없이 번들했다. esbuild 단독으로는 이 옵션이 지원되지 않으므로 .ts를 ts.transpileModule로 먼저 변환해야 한다 — build-and-package.md §2.
컴파일은 통과하고 실행 시에만 드러나므로 빌드 성공을 신호로 삼지 않는다. 배포 전 점검의 design:paramtypes 항목이 이것을 검사한다.
this.host가 undefined인 또 다른 원인은 주입한 값의 형태와 사용하는 형태가 어긋난 경우다. providers에 { host } 객체를 주입했다면 컨트롤러에서는 this.rt.host....여야 한다.
빌드가 Cannot read properties of undefined (reading 'ES2023')으로 중단된다
TypeScript 7이 설치되었다. 빌드가 쓰는 ts.transpileModule / ts.ScriptTarget은 TypeScript 5.x의 API이며 7에는 존재하지 않는다.
bash
node -e "import('typescript').then(m => console.log(m.default.version, typeof m.default.transpileModule))"typescript를 ^5.9로 고정하고 다시 설치한다.
pnpm install이나 pnpm add가 실패한다
| 메시지 | 원인 | 조치 |
|---|---|---|
Invalid package manager specification in package.json | pnpm init이 생성한 devEngines.packageManager.version을 pnpm이 semver로 해석하지 못한다 | 그 필드를 삭제한다 |
ERR_PNPM_IGNORED_BUILDS | 네이티브 의존성의 설치 스크립트가 차단되었다. pnpm install이 비정상 종료 코드를 반환하고 이후 pnpm run <스크립트>가 전부 실패한다 | pnpm-workspace.yaml에 allowBuilds를 선언한다(getting-started.md §1-3) |
Could not locate the bindings file | better-sqlite3의 사본이 둘이다(Prisma 어댑터가 요구하는 버전과 다른 버전을 설치했다) | better-sqlite3를 dependencies에서 제거하고 로컬 실행용 devDependencies에만 둔다 |
타입체크가 TS2307로 중단된다
두 가지 중 하나다.
backend/generated/<provider>/client.js를 찾지 못한다 → 빌드를 먼저 실행한다. 이 파일은 빌드 2단계의prisma generate가 생성한다. 순서는build→typecheck다../widget.css를 찾지 못한다 → CSS 앰비언트 선언을 추가한다(getting-started.md §1-6). import 경로에?inline을 붙이지 않는다.
마이그레이션 SQL이 0바이트로 생성된다
prisma migrate diff가 exit 0과 빈 출력을 산출한 것이다. 대개 --config 누락으로 내부 엔진이 접속 정보 없이 종료한 경우이며, CLI가 그 실패를 표면에 노출하지 않는다.
bash
DEBUG='prisma:*' prisma migrate diff --config prisma-config/sqlite.config.ts \
--from-empty --to-schema backend/prisma/sqlite/schema.prisma --script마이그레이션 생성 스크립트는 결과가 비면 명시적으로 중단해야 한다 — backend-guide.md §7-3.
두 번째 마이그레이션부터 shadowDatabaseUrl을 요구하며 실패한다
You must set datasource.shadowDatabaseUrl … if you want to diff a migrations directory가 표시되고 SQLite 폴더만 생겼다면, 이전 판 make-migration.mjs를 쓰고 있는 것이다. 그 판은 --from-migrations로 이전 상태를 만들었고, PostgreSQL은 그 재생에 실제 서버가 필요하다.
- 한 방언에만 생긴 마이그레이션 폴더를 지운다.
- 정본 예제의
scripts/make-migration.mjs와scripts/lib/providers.mjs로 바꾼다. 두 번째 마이그레이션부터 스냅샷(backend/prisma-src/snapshot/schema.prisma)과 비교하므로 접속이 필요 없다. - 정본을 마지막 마이그레이션 상태로 되돌리고
pnpm run migrate:new를 한 번 실행해 스냅샷을 만든다. 그 뒤 변경을 다시 적용하고 실행한다.
절차의 근거는 backend-guide.md §7-3에 있다.
배포 전 점검이 new_bp_… 테이블을 접두어 위반으로 거부한다
이전 판 preflight.mjs의 오판이다. Prisma가 SQLite 열 추가·변경에 만드는 재정의 블록은 new_<테이블>을 만든 뒤 같은 마이그레이션에서 원래 이름으로 되돌리며, 포탈의 반입 검증(V8)은 이것을 받는다. 정본 예제의 scripts/preflight.mjs로 바꾼다. 지금 판은 접두어를 직접 판정하지 않고 V8 결과로 판정한다(build-and-package.md §6).
datasource property url is no longer supported (P1012)
Prisma 7은 schema.prisma의 datasource에 url을 쓰는 것을 금지한다. 접속 정보는 prisma.config.ts가 제공하며, 그 파일은 BP 산출물에 포함하지 않는다 — backend-guide.md §7-2.
마이그레이션이 적용되지 않는다
backend.database.schemaDir을 선언했는지 확인한다. 생략하면 포탈은 이 BP의 마이그레이션에 관여하지 않는다.schema.prisma와migrations/가 같은 폴더에 있는지 확인한다. 방언 폴더 이름이sqlite/postgresql리터럴인지도 확인한다.- 마이그레이션 디렉터리 이름이 타임스탬프로 시작하는지 확인한다. 적용 순서가 이름의 사전순이다.
- 이미 적용한
migration.sql을 고쳤다면 새 내용은 적용되지 않는다. 포탈은 이름과 체크섬으로 이력을 남기고, 바뀐 파일을 만나면 적용을 거부한다(패키지 아카이브는PACKAGE_MIGRATION_FAILED, 개발 소스 폴더는SQLITE_MIGRATION_FAILED·POSTGRESQL_MIGRATION_FAILED). 고친 내용은 새 마이그레이션으로 옮기고, 개발 DB는 포탈 저장소의pnpm bp:dev-reset <BP id>로 초기화한다 — backend-guide.md §7-4. - 마이그레이션은 적용되었다는데 열이 없다면 같은 이름의 테이블이 이미 있었고 마이그레이션의
IF NOT EXISTS가 그 사실을 감춘 것이다.IF NOT EXISTS를 지운다.
다른 BP와 테이블이 충돌한 것 같다
1.x 포탈에서는 BP 테이블이 포탈 코어와 같은 데이터베이스·같은 스키마를 공유한다. 테이블 이름에 bp_{businessPackageId}_ 접두어가 없으면 언젠가 충돌한다. 호스트 계약 2.0.0 포탈에서는 BP마다 이름공간이 달라 다른 BP와 충돌하지 않는다.
- 패키지 아카이브로 설치하면 포탈의 반입 검증(V8)이 접두어 위반과 다른 설치 패키지와의 접두어 충돌을 거부한다.
- 개발 소스 폴더에서는 두 번째 BP의
CREATE TABLE이 실패해 그 BP의 마이그레이션이 취소된다(POSTGRESQL_MIGRATION_FAILED또는SQLITE_MIGRATION_FAILED). - 마이그레이션에
IF NOT EXISTS가 있으면 이 실패가 일어나지 않고 두 BP가 같은 물리 테이블을 공유하게 된다. 컬럼 구성이 다르면 실행 시점에 정상 동작하지 않는다.
배포 전 점검의 "모든 테이블 이름이 bp_{businessPackageId}_로 시작한다" 항목이 이것을 검사한다 — backend-guide.md §6-3.
DB 접속은 되는데 쿼리가 실패한다
호스트 계약 2.0.0 포탈에서 relation "public.bp_…" does not exist 또는 권한 오류(permission denied)라면 원인은 둘 중 하나다.
- PostgreSQL 클라이언트를 만들 때
host.database.namespace를 어댑터의schema옵션으로 넘기지 않았다. 어댑터가public스키마로 쿼리한다. 고치는 방법은 backend-guide.md §6-1에 있다. - BP 코드나 마이그레이션이
core_테이블이나 다른 BP의 테이블을 읽는다. BP 전용 계정에는 그 권한이 없다. 필요한 정보는 호스트 계약으로 받는다.
Missing field 'format' 같은 프로토콜 오류라면, BP가 빌드 시점에 참조한 Prisma 버전과 포탈이 제공하는 버전이 다른 것이다. 실제 발생 사례다 — 양쪽 다 ^7.8.0이었는데 BP는 7.9.1로, 포탈은 7.8.0으로 해석되었다.
bash
node -e "import('@starj-portal/bp-backend-sdk').then(m=>console.log(m.HOST_PROVIDED_VERSIONS))"이 값으로 캐럿 없이 고정하고 다시 빌드한다.
화면이 표시되지 않는다 / 화면 영역이 비어 있다
| 확인 | 내용 |
|---|---|
window.__BP_WIDGETS__의 키 | bp-manifest.yaml의 id와 정확히 같은지 |
| 번들 포맷 | head -c 40 dist/frontend/bundle.js가 즉시 실행 함수 래퍼로 시작하는지(build-and-package.md §4) |
| 번들 파일 실재 | frontend.bundle이 가리키는 파일이 산출물에 있는지. 없으면 브라우저가 위젯 스크립트에서 404를 받는다 |
frontend 블록 없이 csr-widget을 선언했는지 | 화면이 없는 BP가 frontendDeliveryMode: "csr-widget"을 선언하면 경고 없이 적재되고 그 App을 열 때 영구히 비어 있는 영역이 된다. 화면이 없는 BP는 "ssr"을 선언한다(bp-manifest.md §frontendDeliveryMode) |
| 안내 패널이 표시된다 | frontendDeliveryMode가 ssr / external-proxy이거나 선언되지 않았다. 포탈 안에서 실행되는 BP가 화면을 제공하는 방식은 csr-widget 하나다. 외부 BP(transport: "external")는 transport만으로 iframe이 되지 않으므로 frontendDeliveryMode: "iframe"을 선언한다. iframe을 선언했는데도 안내 패널이 표시되면 서버가 그 App을 unbound로 내린 것이다. 그 도메인에서 쓸 수 있는 Destination 중 BP id와 같은 이름이고, 용도가 FRONTEND_IFRAME / BOTH이며, 소유 업무패키지가 이 BP인 것이 없다. 소유가 지정되지 않은 같은 이름의 Destination은 연결로 인정되지 않는다(bp-manifest.md §frontendDeliveryMode) |
| iframe 영역이나 그 안의 요청이 404를 받는다 | 외부 BP의 iframe App이다. 첫 화면은 열리는데 그 안의 XHR·fetch·스크립트·스타일 요청만 404라면, 그 요청 URL에 domainId가 빠져 프록시가 사용자의 소속 도메인으로 해석했고 그 도메인에서 BP가 비활성인 경우다. 다중 도메인 사용자에게만 나타난다. BP가 내는 모든 /portal/app/dest/{BP id}/… URL에 받은 domainId를 추가한다. 첫 화면부터 404라면 요청한 도메인에서 BP가 비활성이다. 브라우저 경유 프록시는 비활성 BP의 연결을 존재하지 않는 대상과 같은 404로 답한다(bp-manifest.md §iframe 화면의 하위 요청에는 domainId를 추가한다) |
| iframe 영역이나 그 안의 요청이 403을 받는다 | 요청 URL의 domainId가 사용자가 접근할 수 없는 도메인이다. 프록시는 Destination을 해석하기 전에 도메인 접근을 먼저 판정한다 |
외부 BP를 등록하자 포탈이 BP_MANIFEST_INVALID로 부팅하지 않는다 | 외부 매니페스트의 destinationName이 BP id와 다르다. 이 필드는 폐기 예정이며 포탈이 읽지 않는다. 지우거나 BP id와 같게 둔다 |
번들 적재 실패에 대해 Portal Shell(포탈 셸)은 오류 화면을 표시하지 않는다. 브라우저 콘솔의 처리되지 않은 promise 거부가 유일한 단서다.
화면을 다시 열면 내용이 두 번 표시되거나 비어 있다
위젯이 재마운트에 안전하지 않다.
container.attachShadow()를 조건 없이 호출하면 두 번째 마운트에서NotSupportedError가 발생한다.container.shadowRoot ?? container.attachShadow(...)형태로 방어한다.- 정리 함수를 반환하지 않았다면 Portal Shell의 대체 정리(
container.innerHTML = "")가 shadow root 안을 제거하지 못해 이전 내용이 남는다. 정리 함수 반환은 필수다.
frontend-guide.md §1 참고. examples/task-bp/scripts/check-widget.mjs가 이것을 자동으로 검사한다.
화면은 표시되는데 API가 403이다
domainId를 쿼리 파라미터로 전달하지 않았다. 가장 자주 발생하는 누락이다.
ts
fetch(`${host.apiBasePath}/tasks?domainId=${encodeURIComponent(host.domainId)}`)domainId를 전달했는데도 403이라면 권한 쪽이다. 아래 항목을 확인한다.
읽기는 되는데 저장만 동작하지 않는다
defaultAuthorities[].apps[].rw 값을 확인한다. rw는 비트마스크다 — 읽기 전용 1, 읽기+쓰기 3. 편집 역할에 1을 선언하면 checkAppAccess(…, "update")가 계속 false를 반환한다.
2는 사용하지 않는다. 실행 시 동작은 3과 같지만 관리자 권한 그리드와 Excel 내보내기가 "읽기 없음, 쓰기만"이라는 다른 사실을 표시하며, 그 화면에서 저장해도 값이 교정되지 않는다. 자세한 대응표는 backend-guide.md §4.
배포 전 점검이 rw 값의 범위를 검사한다.
홈 카드가 표시되지 않는다
| 원인 | 확인 |
|---|---|
cards[].appId가 존재하지 않는 App을 참조한다 | 그 카드는 등록 자체를 건너뛴다. 부팅 로그에 경고만 남는다(운영자 확인 필요) |
| 그 App에 읽기 권한이 없다 | 카드 가시성은 appId가 가리키는 App의 권한을 따른다 |
| BP가 그 도메인에서 활성화되지 않았다 | 관리자 확인 필요 |
dispYn: false | 매니페스트 확인 |
홈 카드가 "표시할 수 없음" 상태가 된다
| 원인 | 확인 |
|---|---|
응답의 kind가 매니페스트 선언과 다르다 | 엔드포인트 응답의 kind 확인 |
| 규모 상한 초과 | 초과분을 잘라 내지 않고 카드 전체가 실패 상태가 된다. 요약은 BP의 책임이다. 상한 값을 옮겨 적지 말고 SDK의 CARD_LIMITS를 import해 확인한다 — node -e "import('@starj-portal/bp-backend-sdk').then(m => console.log(m.CARD_LIMITS))". 응답을 만들기 직전에 checkCardLimits(kind, payload)를 호출하면 위반 사유가 반환된다 |
| 엔드포인트가 2xx가 아니다 | 컨트롤러 경로에 bp/{businessPackageId}/를 선언했는지 확인한다 |
| 무한히 로딩 상태로 남는다 | 카드 데이터 요청에 타임아웃이 없다. 엔드포인트가 응답하지 않는 상태다 |
응답 형태는 backend-guide.md §5-3.
host.actions가 예외를 발생시킨다
cardId가 {businessPackageId}:로 시작하지 않는다. 카드 id 명명 규약은 필수 규칙이다 — backend-guide.md §5-5.
알림을 발행했는데 아무에게도 전달되지 않았다
publish()의 반환값이 null인지 확인한다. null은 오류가 아니라 수신자가 0명이라는 뜻이다.
authorityKey/authorityKeys로 발행했다면 다음을 차례로 확인한다. 그 키가 매니페스트defaultAuthorities[]에 선언되어 있는가. 그 역할이 어느 사용자에게 부여되어 있는가. 운영자가 그 Authority를 사용하지 않도록 바꾸지 않았는가. 권한 부여와 Authority 사용 여부는 관리자의 담당 범위다.userNos로 발행했다면 그 사용자 번호가 실재하는지 확인한다. 명부에 없는 번호는 오류 없이 버려진다.- 수신자가 모두 이 BP가 활성화되지 않은 도메인 소속이면
null이다.userNos와 역할 수신자 모두 같다. broadcastDomain으로 발행했다면domainId를 함께 전달했는지 확인한다.- 비활성 사용자는 어느 방식으로 지정해도 수신자에서 빠진다. 해석된 사용자가 모두 비활성이면
null이다.
특정 사용자만 받지 못했다면 지정 방식의 규칙을 확인한다.
broadcastDomain은 그 도메인에 소속된 활성 사용자에게만 보낸다. 소속 도메인이 없는 전역 소속 사용자(관리자 계정 등)는 그 도메인에 접근할 수 있어도 받지 않는다. 그 사용자에게도 보내려면userNos나authorityKey로 지정한다.userNos로 지정한 사용자와authorityKey·authorityKeys로 해석한 보유자는 이 BP가 활성화된 도메인에 소속되었거나 전역 소속이어야 받는다. 판정 기준은 각자의 소속 도메인이며, 발행의domainId와 같은 도메인일 필요는 없다. 역할을 가졌는데 받지 못한 사용자가 있으면 그 사용자의 소속 도메인에서 이 BP가 활성화되어 있는지 확인한다.- 일부 수신자가 빠졌다면 포탈 애플리케이션 로그에 요청 수와 제외 수가 경고로 남는다. 사용자 번호는 로그에 남지 않는다.
- 규칙 전체는 backend-guide.md §13에 있다.
반환값이 null이 아닌데 사용자가 수신하지 못했다면 수신 설정 쪽이다. 매니페스트 notificationCategories[]에 선언하지 않은 category로 발행했다면 그 알림은 전달되지만 사용자의 수신 설정 화면에 나타나지 않으며, defaultSubscribed: false로 선언했다면 사용자가 별도로 설정하기 전까지 수신하지 않는다 — backend-guide.md §13.
services.require()가 null을 반환한다
원인이 두 가지다.
bp-manifest.yaml의dependsOn[]에 그 BP를 선언하지 않았다. 선언하지 않은 BP의 서비스는 존재해도null이 반환된다. 이것은 의도된 경계(책임 분리선)다.register()실행 시점에 호출했다. 그 시점에는 제공 측 BP가 아직 적재되지 않았을 수 있고 적재 순서는 보장되지 않는다.register()에서 미리 받아 보관하지 말고 요청 처리 시점에 매번 호출한다.
제공 측 BP가 실제로 적재되었는지는 GET /bp/runtime-info의 loaded[]로 확인한다. 상세는 backend-guide.md §17.
files.read()가 null을 반환한다
그 파일을 claim하지 않았다. read()와 delete()는 이 BP가 claim한 파일에만 동작한다.
- 업로드 직후
claim(fileUuid, req)를 호출했는지 확인한다. 계약 1.2.0에서 두 번째 인자가 요청 객체로 바뀌었다 — 컨트롤러가 받은req를 그대로 전달한다. claim()이false를 반환하는 경우는 세 가지다. (1) 이미 다른 BP가 claim한 파일이다. (2) 요청의 로그인 사용자가 그 파일의 업로드 주체가 아니다 — 다른 사용자가 올린 파일은 결속할 수 없다. (3) 요청에 세션이 없다(기계 자격증명으로 인증한 요청 포함).stat()의uploadedBy로 업로더를 확인한 뒤 그 값을 넘기는 방식은 이제 성립하지 않는다. 결속하지 않은 파일의uploadedBy는null이며, 애초에 그 값은 인자가 아니다.stat()의ownedByThisBp로 현재 소유 상태를 확인할 수 있다.- 요청 계약이 맞는데도 계속 실패한다면 포탈이 제공하는 계약 버전을 확인한다 — 1.1.0 포탈에
^1.1.0을 선언한 채 설치하면 요청 객체가 사용자 번호 자리에 들어가 결속이 항상 실패한다.runtime.hostContract를^1.2.0으로 선언하면 그 상태가 적재 게이트에서 걸린다.
claim한 파일은 포탈의 범용 다운로드 경로에서 더 이상 제공되지 않는다. 화면에서 첨부를 조회할 수 없다면 그것이 원인이며, 열람 권한 판정과 내려주기는 소유 BP가 자기 엔드포인트에서 담당한다 — backend-guide.md §19.
files.read()가 reject된다
null과 reject는 원인이 다르다. null은 없는 파일이거나 이 BP의 파일이 아니라는 뜻이다. reject는 메타데이터는 있는데 저장소에서 객체를 읽지 못했다는 뜻이다.
- 파일을 저장한 저장소 대상이 삭제되었다.
read()는 저장할 때 기록된 대상에서 읽으므로, 그 대상이 없어지면 읽을 곳이 없다. 대상을 비활성으로 바꾼 것은 원인이 아니다. 비활성 대상의 파일은 그대로 읽힌다. - 저장소에 객체가 없거나 저장소에 연결하지 못했다. 대상이 기록되지 않은 파일은 로컬 폴백에서 먼저 찾고, 거기 없을 때만 현재 활성 기본 대상에서 찾는다. 두 곳 모두에 없으면 reject된다.
- 어느 쪽이든 BP가 고칠 수 있는 상태가 아니다. 운영자에게 저장소 상태를 확인하도록 안내하고, BP 엔드포인트는 서버 오류로 응답한다.
- 대상이 삭제된 파일을
delete()하면 실패로 세지 않는다. 포탈이 메타데이터만 지우고true를 반환한다. 근거는 backend-guide.md §19.
다른 사용자의 첨부가 내 업무 데이터에 매였다 / claim()이 항상 true다
이미 이 BP가 결속한 파일이면 claim()은 요청한 사용자가 누구든 true다. 재시도 경로에서 분기하지 않게 하려는 규정이므로, true를 "이 요청의 사용자가 올린 파일이다"로 읽으면 다른 사용자의 업무 데이터에 이미 매인 fileUuid를 자기 데이터에 다시 매는 경로가 열린다.
- 사용자가 보낸
fileUuid를 새 업무 데이터에 매는 API는claim()전에stat()을 호출해ownedByThisBp가true이면 거부한다. - 같은
fileUuid를 두 요청이 동시에 매는 경합은 위 검사로 막히지 않는다. BP 쪽 첨부 표의fileUuid에 고유 제약을 둔다. - 근거는 backend-guide.md §19.
files.create()가 reject된다
거부 사유는 아래와 같다(계약 1.3.0). 규정 전체는 backend-guide.md §19-1에 있다.
- 요청에 세션이 없다. 정기 배치, 기계 자격증명으로 인증한 요청, 설치 훅에서는 파일을 만들 수 없다. 컨트롤러가 받은
req를 그대로 전달했는지 확인한다. - 파일이 0개이거나 10개를 넘는다. 10개를 넘으면 호출을 나눈다. 묶음이 달라지므로
fileUuid도 달라진다. data가Uint8Array가 아니다. 문자열이나 일반 배열이면Uint8Array(Node의Buffer포함)로 바꿔 넘긴다.- 파일 하나가 크기 상한을 넘는다. 상한은 포탈 저장소 대상의 설정값(설정이 없으면 20MB)이며 설치마다 다르다. 운영자에게 그 값을 확인한다.
- 저장소 대상은 등록되어 있는데 활성 기본 대상이 없다. 대상이 하나도 없을 때만 로컬 폴백이 쓰인다. 운영자에게 저장소 대상 중 하나를 활성 기본으로 지정하도록 요청한다.
- 저장소 쓰기가 실패했다. 포탈이 이미 쓴 것을 지웠으므로 BP가 정리할 것은 없다. 운영자에게 저장소 대상의 상태를 확인하도록 요청한다.
- 기본 저장소 대상의 유형에 필요한 기능 패키지가 설치되지 않았다. 오류의
errorCode가CAPABILITY_NOT_INSTALLED다. 아래 "첨부 저장이 501로 실패한다"를 본다.
TypeError: host.files.create is not a function이면 대상 포탈이 1.2.0 이하다. runtime.hostContract를 ^1.3.0으로 선언하면 그 상태가 적재 게이트에서 걸린다.
첨부 저장이 501로 실패한다
기본 저장소 대상의 유형에 필요한 기능 패키지가 이 배포에 설치되어 있지 않다. S3·S3 호환·BTP Object Store·SFTP 유형의 저장소 대상은 그 유형의 기능 패키지가 있어야 쓸 수 있다. 관리자가 그런 유형의 대상을 기본으로 지정했는데 기능 패키지가 없으면, files.create()가 쓰기 전에 거부한다(계약 1.4.0). 같은 이유로 그 대상에 저장된 파일의 read()와 delete()도 거부된다. delete()는 이때 아무것도 지우지 않는다.
- BP가 확인할 것: 오류를
isBpCapabilityNotInstalled(e)로 판별하고 있는지, 화면에 501과errorCode·feature를 전달하는지 확인한다(backend-guide.md §21-4). 문구로 분기하고 있으면 판별 함수로 바꾼다. 응답이 501이 아니라 500이면 BP가 거부를 평범한 오류로 다시 던지고 있다. - 운영자에게 요청할 것: 응답의
feature값(storage.provider.S3등)을 전달하고, 그 기능 패키지를 설치하거나 기능이 필요 없는 유형의 대상을 기본으로 지정하도록 요청한다. 이미 그 대상에 저장된 파일을 읽고 지우려면 기능 패키지를 설치해야 한다. - BP가 하지 않을 것:
features.has()로 미리 확인해 첨부 버튼을 감추는 것으로 대신하지 않는다. 기본 저장소 대상은 관리자가 언제든 바꿀 수 있으므로, 거부를 처리하는 코드가 반드시 있어야 한다.
내보내기·사진 처리가 503으로 실패한다 (busy·timeout)
host.workbook.write()나 host.images.normalizePhoto()가 REQUEST_REJECTED로 거부되고 bpRentalRejectReason(e)가 "busy" 또는 "timeout"이다(계약 1.5.0). 둘 다 입력이 틀린 것이 아니라 포탈의 처리 한도에 걸린 것이며, 다시 시도할 수 있다.
| 사유 | 증상 | 원인 |
|---|---|---|
busy | 호출이 기다리지 않고 바로 거부된다 | 처리 칸은 종류별로 포탈 프로세스에 하나씩 있고, 진행 중 2건과 대기열 8건이 이미 차 있다. 코어와 다른 BP의 작업도 같은 칸을 쓴다 |
timeout | 60초쯤 지나서 거부된다 | 호출한 시각부터 60초가 지났다. 대기열에서 기다린 시간도 포함된다. 결과는 버려진다 |
- BP가 확인할 것: 응답이 503인지 확인한다. 500이면 BP가 거부를 다른
Error로 감싸 던지고 있다(backend-guide.md §22-3). 사용자에게는 잠시 뒤 다시 시도하라고 안내한다. - BP가 하지 않을 것: 실패한 호출을 서버 안에서 곧바로 반복하지 않는다. 처리 칸을 더 오래 붙잡아 다른 요청까지
busy가 된다. 다시 시도는 사용자에게 맡긴다. timeout이 반복되면 입력이 큰 것이다. 내보내기의 행 수를 줄이거나(조회 범위를 좁힌다), 사진은 원본 크기를 줄여 올리게 한다. 이미 시작한 작업은 끝까지 돌고 그동안 칸을 차지하므로, 같은 입력의 반복은busy를 함께 부른다.busy가 자주 나면 운영자에게 알린다. 같은 시간대에 내보내기가 몰리는 것이다. 한도 값은 포탈이 정하며 BP가 바꿀 수 없다.
같은 호출이 501이면 기능 패키지가 설치되지 않은 것이다(isBpCapabilityNotInstalled(e), feature는 export.workbook 또는 image.processing). 운영자에게 그 기능 패키지의 설치를 요청하고, 위젯은 hasHostFeature로 버튼을 감춘다(frontend-guide.md §3). 400이고 사유가 limit-exceeded이면 입력이 상한을 넘은 것이다. bpRentalRejectedLimit(e)가 넘은 항목을 반환한다. 보내기 전에 BP_RENTAL_LIMITS로 확인한다(backend-guide.md §22-4).
TypeError: Cannot read properties of undefined (reading 'write')처럼 host.workbook이 없으면 대상 포탈이 1.4.0 이하다. runtime.hostContract를 ^1.5.0으로 선언하면 그 상태가 적재 게이트에서 걸린다.
내보내기 버튼이 표시되지 않는다
위젯이 hasHostFeature(host, BP_FEATURE_KEYS.EXPORT_WORKBOOK)로 버튼을 감추고 있다면 원인은 셋 중 하나다.
- 이 배포에 스프레드시트 기능 패키지가 없다. 운영자에게 설치 여부를 확인한다. 이 경우 버튼이 감춰지는 것이 맞다.
- Portal Shell이 1.5.0 이전이다. 구버전 셸은
features를 넘기지 않으므로 답이 항상false다.GET /bp/runtime-info의hostContractVersion을 확인한다. - 위젯이 답을 저장해 두고 있다. 셸은 기동 직후 설치 목록을 모를 수 있고 그동안 답은
false다. 마운트할 때 한 번 물은 값을 상태에 저장해 두면 목록이 알려진 뒤에도 새로고침 전까지 버튼이 나타나지 않는다. 렌더링할 때와 누를 때마다hasHostFeature를 부른다. 목록 변화를 알리는 이벤트는 없으므로 버튼은 위젯이 다음에 다시 렌더링될 때 나타난다.
버전을 올렸더니 관리자가 고친 공통코드가 시드 값으로 되돌아갔다
onInstall은 버전을 올릴 때마다 다시 실행되고, 설치 컨텍스트의 공통코드 쓰기는 기본값이 덮어쓰기("overwrite")다. 관리자가 고친 값을 지키려면 upsertDefine·upsertCode에 onExisting: "keep"을 지정하고 runtime.hostContract를 ^1.3.0으로 선언한다. 1.2.0 포탈은 이 필드를 오류 없이 무시한다 — "keep"을 지정했는데도 되돌아갔다면 대상 포탈의 계약 버전을 GET /bp/runtime-info로 확인한다. backend-guide.md §8-1.
Destination 호출이 403이다 / 헤더가 전달되지 않았다
host.destinations.fetch()는 어떤 경우에도 예외를 발생시키지 않는다. 차단도 응답으로 반환되므로, 먼저 응답 헤더x-portal-destination-blocked를 확인한다 — 이 헤더가 있으면 포탈이 막은 것이고 대상 시스템은 호출을 받지도 않았다. 값은not-found(그 이름의 Destination이 없다, 404) /not-callable(서버측 호출 대상이 아니다, 404) /allowed-paths(경로 허용목록 위반, 403) /principal-required(req누락, 403) /not-owned(다른 업무패키지 소유다, 403 — 계약 1.2.0)다. 헤더가 없는 403은 대상 시스템 자신의 거부이고, status 502는 대상에 도달하지 못한 것이다(DNS 실패·연결 거부·시간 초과). 각각 조치가 다르다 — 대응표는 backend-guide.md §16-1.- 요청 헤더는 허용목록을 통과한 것만 전송되고, 허용되지 않은 헤더는 오류 없이 제외된다. 대상 시스템이 요구하는 헤더가 있으면 운영자에게 허용목록 등록을 요청한다. 헤더 이름의 대소문자는 구분되지 않는다(계약 1.2.0에서 정정되었다 — 그 전에는
Content-Type처럼 대문자가 섞인 이름이 무시되었다). not-owned는 운영자가 조치한다. 그 Destination을 이 업무패키지 소유로 등록하거나, 여러 업무패키지가 함께 쓸 대상이면 소유 업무패키지를 지정하지 않은 공용으로 등록하도록 요청한다.PRINCIPAL_PROPAGATION계열 Destination에req를 전달하지 않으면 거부된다.
상세는 backend-guide.md §16.
Bearer 토큰으로 호출했는데 BP 엔드포인트가 401 또는 403이다
- 401이면 인증 자체가 실패한 것이다. 토큰 만료(발급 시점부터 1시간), 클라이언트 비활성화,
Authorization: Bearer접두어 누락을 확인한다. - 403이면 대부분 권한 판정이 원인이다. 기계 자격증명(OAuth2 Client Credentials)으로 인증한 요청에는 사용자 번호가 없으므로
host.checkAppAccess()가 항상false를 반환한다. 사람 세션을 전제로 작성한 핸들러는 그 요청에 대해 언제나 403이 된다 — 기계 호출용 엔드포인트를 별도로 두고 그 안에서는checkAppAccess()에 의존하지 않는다(backend-guide.md §9-1). - 같은 이유로
host.currentUserNo(req)·host.users.current(req)도null이며,host.audit.record()는 주체 없는 기록이 된다.
위젯이 파일을 업로드할 수 없다
host.actions?.uploadFile이undefined인 경우는 구버전 Portal Shell이다. 첨부 기능을 사용할 수 없다는 안내를 표시한다(frontend-guide.md §6).- 예외가 발생한 경우의 메시지는 사용자에게 그대로 표시해도 되는 문장이다. 용량 상한과 허용 형식은 포탈 저장소 대상의 설정값이라 설치마다 다르며 계약에 노출되지 않는다 — 위젯이 상한을 가정하지 않는다.
- 업로드는 성공했는데 BP에서 파일을 찾을 수 없는 경우는
claim()을 호출하지 않았거나stat()에 다른fileSeq를 전달한 것이다(생략하면 1이다). 업로드한 사용자와claim()을 호출한 요청의 사용자가 같아야 한다(계약 1.2.0) — 위젯이 업로드한 뒤 다른 사용자의 세션으로 결속을 시도하는 구조는 성립하지 않는다.
비활성화한 도메인에서 BP의 API가 계속 응답한다
현재 포탈의 동작이며, BP가 이것을 전제로 자기 판정을 갖춰야 한다. 도메인별 활성화 판정은 App 화면 진입에만 적용되고 /bp/{businessPackageId}/… REST 경로에는 적용되지 않는다. host.checkAppAccess()도 권한 부여만 판정할 뿐 활성 여부는 판정하지 않는다 — backend-guide.md §3.
비활성 상태에서 응답이 달라야 하는 업무 규칙이 있다면 BP가 자기 데이터로 표현한다.
감사로그에 기록이 남지 않는다
host.audit.record()는 실패해도 예외를 발생시키지 않는다. 감사 기록이 되지 않았다고 업무 처리가 실패하면 안 되기 때문이며, 실패는 포탈 애플리케이션 로그에만 남는다. 따라서 이 호출의 성공을 업무 처리의 조건으로 삼지 않는다.
host.logger에 감사 필드를 추가한 것이라면 목적지가 다르다 — logger는 애플리케이션 로그(stdout)이며 감사 화면에 표시되지 않는다. 두 통로의 구분은 backend-guide.md §11·§18에 있다.
host.actions의 토스트·확인·화면 이동이 동작하지 않는다
네 오퍼레이션(toast / confirm / navigateToApp / reportError)은 전부 optional이다. 구버전 Portal Shell(포탈 셸)에서는 전달되지 않으므로 존재를 확인하고 호출하며, 없을 때의 대체 동작을 준비한다 — frontend-guide.md §6.
포탈이 제공하는 계약 버전은 GET /bp/runtime-info의 hostContractVersion으로 확인한다. 값이 1.1.0 미만이면 이 오퍼레이션들이 제공되지 않는다.
위젯의 뒤로가기가 이전 선택으로 돌아가지 않는다
의도된 동작이다. setUrlParam / useUrlParam은 history.replaceState만 사용하므로 뒤로가기 스택에 항목을 추가하지 않으며, 뒤로가기는 그 화면을 벗어난다 — frontend-guide.md §9.
다크 모드에서 색이 맞지 않는다
- 테마 토큰 이름이 목록에 있는 것인지 확인한다. 이름이 틀리면 오류 없이 항상 대체값이 적용된다 — frontend-guide.md §5.
- 다크에서 실제로 바뀌는 토큰은 15개뿐이다.
--color-brand·상태 색·--radius·--shadow-card에는 다크 오버라이드가 없다. - 스크립트에서 모드를 판정한다면
[data-theme]이 아니라:root.dark와prefers-color-scheme을 확인한다.
Portal Shell의 상단 메뉴가 표시되지 않는다 / 위젯 스타일이 틀어진다
위젯 CSS가 Portal Shell과 같은 캐스케이드에서 충돌했다. Shadow DOM 경계를 만들어야 한다 — frontend-guide.md §4.
위젯 안의 모달만 스타일이 적용되지 않는다면 document.body에 포탈을 만든 것이다. shadow root 안에서 렌더링한다.
위젯 서체가 Portal Shell과 다르다면 리셋(preflight)의 :host 규칙이 상속을 차단한 것이다. :host { font-family: inherit; … }를 레이어 밖에 둔다.
포탈 프로세스가 중단된다
BP가 만든 처리되지 않은 promise 거부를 의심한다. 포탈에 그것을 잡는 핸들러가 없으므로 프로세스 전체가 중단된다.
- 타이머 콜백, 백그라운드 작업, 이벤트 핸들러의 비동기 함수를
try/catch로 감싼다. - 훅 안에서 시작한 타이머·백그라운드 작업의 거부는 훅의
try/catch밖이라 처리되지 않는다. 훅 자체의 예외는 포탈이 잡으므로 프로세스를 중단하지 않는다.
host-contract.md §6 참고.
관리 화면에 "훅 실패"가 표시된다
그 도메인에서 onActivate 또는 onDeactivate가 예외를 발생시켰다. 활성 상태인 칸이면 onActivate, 비활성 상태인 칸이면 onDeactivate다. 전이는 이미 끝났으므로 BP는 표시된 상태 그대로 동작한다. 다만 훅이 하려던 준비나 정리는 중간에 멈춰 있을 수 있다.
- 원인은 로그에서 찾는다. 관리 화면은 오류 이름과 시각만 보여 주고 예외 메시지는 보여 주지 않는다. 포탈 운영자에게 그 시각 전후의 애플리케이션 로그를 BP id와 도메인 id로 검색해 달라고 요청한다. 포탈이 남기는 것은 훅 이름과 예외 메시지뿐이고 스택은 없다. BP가 훅 안에서
host.logger.error로 남긴 단계별 기록이 없으면 원인을 좁히기 어려우므로, 먼저 그 기록을 추가한다. - 훅을 고친다. 원인을 고치면서 훅이 여러 번 실행해도 결과가 같은지 확인한다. 앞선 실행이 중간까지 만든 데이터 위에서 다시 실행되기 때문이다(backend-guide.md §8-2 "훅 반복 안전 규약").
- 고친 버전을 배포한 뒤 관리자가 그 칸에서 재시도한다. 성공하면 표시가 사라진다. 활성화 훅이 남긴 것을 되돌려야 하면 재시도 대신 비활성화해
onDeactivate가 정리하게 할 수도 있다.
반복 안전하지 않은 훅을 그대로 재시도하면 중복 키 오류로 다시 실패하거나, 외부 시스템에 같은 요청을 두 번 보낸다.
업그레이드했는데 onInstall이 실행되지 않는다
version 판정은 문자열 정확 일치다. 그 문자열이 이전에 설치된 적이 있으면 실행되지 않는다.
이전 배포에서 onInstall이 예외를 발생시켰다면 그 버전은 기록되지 않았으므로 다음 기동에서 다시 실행된다. 반대로 훅이 예외를 안에서 삼켰다면 포탈은 성공으로 보고 버전을 기록하므로, 그 버전에서는 다시 실행되지 않는다. 이 경우 version을 새 값으로 올려 다시 배포한다. 원인은 backend-guide.md §8-1에 있다.
업그레이드했는데 화면·권한이 새로 생성되었다
apps[].id 또는 menu[].id를 변경했다. 이 값들은 upsert 기준이라 변경하면 기존 레코드와 이어지지 않는다. 이전 id로 되돌린다.
설치는 되었는데 사용자에게 화면이 표시되지 않는다
설치·활성화·권한 부여는 별개다.
- 그 도메인에서 활성화되었는지 (관리자)
- 사용자에게 권한이 부여되었는지 (관리자) —
defaultAuthorities가 만드는 것은 권한의 정의까지다 apps[].dispYn을false로 선언하지 않았는지 (개발자)