테마
호스트 계약 — 포탈과 업무패키지 사이의 경계
호스트 계약(연계 규약) 은 포탈이 업무패키지(Business Package, 이하 BP)에 제공하는 것과 BP가 지켜야 하는 것을 함께 규정한 인터페이스 규약이다. 경계(책임 분리선) 는 그 규약이 정한 책임의 구분선을 말한다 — 예를 들어 권한 판정은 포탈이 담당하고, 업무 데이터는 BP가 담당한다.
포탈은 BP의 빌드 산출물만 적재한다. 포탈 소스에는 BP의 이름도, 경로도, 심볼도 존재하지 않는다. 따라서 양쪽 사이에 성립하는 것은 아래에 적힌 것뿐이며, 이 문서의 규정을 위반하면 대부분 예외가 아니라 오류 메시지 없는 오작동으로 나타난다.
1. 진입점은 register(host) 하나
BP 백엔드 번들이 export하는 공개 인터페이스는 이것뿐이다. 포탈은 이 함수만 호출하고 나머지 export는 참조하지 않는다.
ts
export function register(host: BpHostRuntime): BpRegistration | Promise<BpRegistration>;함수 이름이 다르거나 존재하지 않으면 해당 BP만 오류 메시지 없이 적재 대상에서 제외된다 — 포탈은 정상 기동한다.
데코레이터에 대한 정확한 규정
혼동하기 쉬운 지점이라 분리해 적는다.
| 대상 | 사용 여부 |
|---|---|
SDK의 BP 선언 데코레이터(@PortalBusinessPackage) | BP는 사용하지 않는다. 포탈 로더가 매니페스트 파일의 식별 정보와 register() 반환값을 결합해 실행 시점에 클래스를 합성할 때 쓰는 내부 도구다. SDK가 이 심볼을 export하지만 3rd-party BP의 사용 대상이 아니다 |
NestJS 컨트롤러 데코레이터(@Controller·@Get·@Inject 등) | 정상적으로 사용한다. BP 백엔드는 NestJS 컨트롤러로 작성한다. 이 때문에 빌드에 emitDecoratorMetadata가 필요하다 — build-and-package.md §2 |
2. BpHostRuntime — 포탈이 제공하는 전부
ts
interface BpHostRuntime {
readonly businessPackageId: string;
checkAppAccess(req: unknown, domainId: string, appId: string, action: "read" | "update"): Promise<boolean>;
currentUsername(req: unknown): Promise<string | null>;
currentUserNo(req: unknown): number | null;
readonly database: { url: string; provider: "sqlite" | "postgresql"; namespace: string | null }; // namespace는 2.0.0
readonly dataDir: string;
deriveSecret(purpose: string): string;
readonly logger: BpLogger; // info / warn / error — 세 메서드가 전부다
// ── 계약 1.1.0에서 추가된 포탈 자원 접근 표면 ──
readonly notifications: BpNotifications; // 알림 발행
readonly codes: BpCodes; // 포탈 공통코드 읽기
readonly users: BpUsers; // 포탈 사용자 프로필 단건 조회
readonly destinations: BpDestinations; // 등록된 Destination 경유 외부 호출
readonly services: BpServices; // BP 간 in-process 연계
readonly audit: BpAudit; // 포탈 감사로그 기록
readonly files: BpFiles; // 업무 첨부 파일(1.3.0에서 create 추가)
// ── 계약 1.4.0에서 추가된 표면 ──
readonly features: BpFeatures; // 이 배포에 설치된 기능의 확인
// ── 계약 1.5.0에서 추가된 표면 ──
readonly workbook: BpWorkbook; // 표 → 스프레드시트(xlsx) 파일
readonly images: BpImages; // 이미지 사진 정규화(축소본 + 썸네일)
}이 목록의 단일 원천은 SDK 타입이다. 설치한 SDK에서 직접 확인한다.
bash
grep -n "readonly" node_modules/@starj-portal/bp-backend-sdk/dist/interfaces/bp-host-runtime.d.ts| 멤버 | 용도와 주의사항 |
|---|---|
checkAppAccess | 권한 판정은 전부 포탈이 수행한다. BP가 자체 권한 테이블을 만들어 판정하면 관리자 화면에서 부여한 권한과 어긋난다. action은 "read"와 "update" 둘뿐이며, create/delete는 이 함수로 질의할 수 없다. rw 값과의 대응은 backend-guide.md §4 |
currentUsername | 비동기 함수다 — 세션에는 사용자 번호만 있고 이름은 포탈 사용자 레코드에 있다. BP가 포탈 테이블을 직접 조회하지 않는다는 원칙에 따른 결과다 |
currentUserNo | 세션에 값이 있으므로 동기 함수다 |
database | 접속 URL 문자열만 제공된다. 클라이언트 객체가 아니다. BP가 자기 클라이언트를 직접 생성한다 — backend-guide.md §6. 계약 2.0.0부터 url은 포탈이 이 BP에 배정한 전용 DB 계정의 접속 주소이고, 그 계정은 이 BP의 데이터 이름공간에만 권한을 가진다. namespace는 그 이름공간 이름이며(PostgreSQL은 스키마 이름, 그 밖의 방언은 null), PostgreSQL 클라이언트를 만들 때 반드시 넘긴다. 1.x 포탈에서는 BP 테이블이 포탈 코어와 같은 데이터베이스·같은 스키마에 있다. 어느 쪽이든 테이블 이름 규약을 지킨다(backend-guide.md §6-3) |
dataDir | 이 BP 전용 로컬 디렉터리. 포탈이 생성해 전달한다. 그 프로세스의 로컬 디스크이므로 캐시·작업 파일처럼 없어져도 되는 것에만 쓴다. 업무 파일은 files로 포탈 저장소에 둔다 — 포탈을 여러 대로 늘리면 한 대의 dataDir에 저장한 파일을 다른 대가 읽지 못한다 |
deriveSecret(purpose) | 이 BP·이 용도 전용 파생키(hex 문자열). 같은 (BP, purpose) 조합에는 항상 같은 값이 반환된다 — 저장한 암호문을 다음 기동 시에도 복호화할 수 있어야 하기 때문이다. 포탈 마스터키 원본은 전달되지 않으며, BP가 다른 BP의 키를 유도할 수도 없다. 반환 문자열의 길이는 계약에 규정되어 있지 않다 — 고정 길이 키가 필요하면 BP가 해시 등으로 정규화한다 |
logger | info / warn / error 세 메서드. 목적지는 포탈의 애플리케이션 로그(stdout) 이며 감사 화면에는 표시되지 않는다 — backend-guide.md §11 |
notifications | 포탈 알림 발행. 수신자가 0명이면 오류가 아니라 null이 반환되고, 업무 트랜잭션에 참여하지 않는다. 계약 1.2.0부터 수신자는 이 BP가 선언한 authority와 이 BP가 활성화된 도메인 안으로 제한된다 — backend-guide.md §13 |
codes | 포탈 공통코드 읽기 전용. 쓰기는 onInstall의 설치 컨텍스트에만 있다 — backend-guide.md §14 |
users | 포탈 사용자 프로필 단건 조회만. 목록·검색 표면은 열려 있지 않다 — backend-guide.md §15 |
destinations | 등록된 Destination(외부 연결처)을 경유한 서버측 외부 호출. 경로 허용목록과 헤더 허용목록을 통과한 것만 실제로 전송된다. 계약 1.2.0부터 이 BP 소유이거나 소유가 지정되지 않은 공용 Destination만 호출할 수 있다. 계약 1.3.0부터 사용자 출처 Token Exchange Destination의 토큰 교환 실패가 차단 사유 넷(401·403·502)으로 온다 — backend-guide.md §16 |
services | BP 간 in-process 연계. require()는 매니페스트 dependsOn[]에 선언한 BP에만 통하며, register() 실행 시점에는 null일 수 있다 — backend-guide.md §17 |
audit | 포탈 감사로그 기록. logger(애플리케이션 로그)와 목적지가 다르며 실패해도 예외를 발생시키지 않는다. 계약 1.2.0부터 이벤트 타입에 bp:{businessPackageId}: 접두어가 추가되어 포탈 코어 이벤트와 이름 공간이 분리된다 — backend-guide.md §18 |
files | 업무 첨부 파일. 브라우저가 올린 파일은 BP 서버가 claim한 뒤 조회·삭제한다. 계약 1.2.0에서 claim(fileUuid, req)로 시그니처가 바뀌었다(포탈이 세션에서 업로더를 판정한다). BP 서버가 만든 파일은 계약 1.3.0의 create(input, req)로 저장하며 처음부터 이 BP의 소유다. 계약 1.4.0부터 저장소 유형의 기능이 설치되지 않았으면 create·read·delete가 미설치 거부로 reject되며, delete는 그때 아무것도 지우지 않는다 — backend-guide.md §19, §21 |
features | 이 배포에 설치된 기능의 확인(계약 1.4.0). has(feature)는 그 기능 식별자가 설치되어 있는지, list()는 설치된 식별자 전체를 정렬한 사본으로 돌려준다. 둘 다 동기이고, 카탈로그에 없는 값에는 false로 답한다. 식별자 상수는 BP_FEATURE_KEYS(export.workbook, image.processing, storage.provider.<유형>)다. 화면 요소를 감추거나 운영자에게 설치를 안내하는 데만 쓴다. 권한 판정의 근거가 아니며, 설치되지 않은 기능의 연산은 포탈이 스스로 거부한다. 그 거부는 isBpCapabilityNotInstalled(e)로 판별한다 — backend-guide.md §21 |
workbook | 표를 스프레드시트(xlsx) 파일로 만든다(계약 1.5.0). write({ sheets })가 바이트와 mime·extension을 돌려준다. 포탈에 설치된 기능(export.workbook)을 빌려 쓰는 것이며, BP 번들에 Excel 라이브러리를 넣지 않는다. 서식은 머리글 굵게·열 너비·머리글 행 고정뿐이다. 결과는 포탈이 보관하지 않으므로 응답으로 내려보내거나 files.create로 저장한다 — backend-guide.md §22 |
images | 이미지 한 장을 사진용으로 정규화한다(계약 1.5.0). normalizePhoto({ data, maxEdge?, thumbnailSize? })가 방향을 반영하고 메타데이터를 지운 축소본과 정사각 썸네일을 돌려준다. 기능 image.processing을 빌려 쓴다. 출력 형식은 포탈이 정한다 — backend-guide.md §22 |
workbook·images의 거부는 값으로 가른다. 기능이 없으면 입력을 읽기 전에 CAPABILITY_NOT_INSTALLED, 상한 위반·형식 오류·대기열 초과·시간 초과는 REQUEST_REJECTED이며 details.reason(limit-exceeded·invalid-input·busy·timeout)으로 구분하고, 읽을 수 없거나 빈 이미지는 IMAGE_NOT_DECODABLE이다. 판별 함수는 isBpCapabilityNotInstalled·bpRentalRejectReason·bpRentalRejectedLimit·isBpImageNotDecodable이다. 다시 시도할 수 있는 것은 busy·timeout뿐이다. 상한 위반은 보내기 전에 막는다. 상한 값은 BP_RENTAL_LIMITS에 있고(시트 10개, 시트당 열 50개, 행 합계 50,000, 셀 합계 500,000, 이미지 20MiB 등), BP가 이 값으로 입력을 나누거나 줄이면 limit-exceeded는 나지 않는다. busy·timeout은 코어와 다른 BP의 작업이 같은 처리 칸을 쓰므로 BP가 막을 수 없다 — backend-guide.md §22-3·§22-4
checkAppAccess는 BP가 호출해야 적용된다. 포탈이 BP 라우트에 자동으로 적용하는 판정은 세션 인증뿐이며, 권한 판정과 도메인 활성 판정은 그 경로에서 이루어지지 않는다 — backend-guide.md §3.
계약 1.1.0에서 추가된 여덟 번째 줄 이후의 일곱 멤버, 1.4.0의 features, 1.5.0의 workbook·images는 요청 처리 시점에만 호출한다. register() 실행 중에는 포탈 코어가 아직 조립되지 않았을 수 있고, services.require()가 대상으로 삼는 BP도 아직 적재되지 않았을 수 있다.
이 인터페이스에 여전히 없는 것
여기 없는 기능은 사용할 수 없다. 계약 1.1.0에서 포탈 자원 접근 표면 7종이 열렸으나, 열린 것은 오퍼레이션뿐이며 포탈 서비스 객체나 데이터 저장소 핸들이 아니다. 아래는 자주 탐색되지만 지금도 존재하지 않는 것들이다.
| 여전히 없는 것 | 왜 없는가 / 무엇으로 대체하는가 |
|---|---|
| 주기 작업(스케줄러) 등록 | 포탈에 작업 스케줄러가 없고 리더 선출도 제공하지 않는다. 사용자 조작 또는 외부 스케줄러가 BP의 REST를 호출한다 — backend-guide.md §9 |
| 포탈 트랜잭션 참여, 트랜잭션 핸들 | 포탈이 요청 단위 트랜잭션을 열지 않으며 전파 장치도 없다. 원자성은 BP 자신의 클라이언트 범위 안에서만 얻는다 — backend-guide.md §6-2 |
| 포탈이 보관하는 BP 설정값 저장소 | 포탈에 BP 설정값을 보관하는 표가 없다. 설정값은 BP가 자기 테이블에 보관하고 비밀값만 deriveSecret 파생키로 암호화한다 — backend-guide.md §10. 다만 외부 시스템 접속 정보는 예외다 — 그것은 운영자가 등록한 Destination으로 다루며 BP가 복제하지 않는다(§16) |
| 포탈 사용자 목록·검색 | 포탈 사용자 명부 전체를 BP에게 제공하면 경계(책임 분리선)가 의미를 잃는다. users는 BP가 자기 업무 데이터에 기록해 둔 userNo·username으로 하는 단건 조회만 제공한다. 결재선 선택처럼 사람을 찾는 화면이 필요하면 조직 정보를 소유한 BP의 서비스를 services로 호출한다 |
| 포탈 메뉴 트리 조회 | 메뉴 배치는 관리자의 조작 결과이며 BP가 읽어야 할 근거가 없다. 다른 App으로의 이동은 위젯의 navigateToApp이 담당한다 — frontend-guide.md §7 |
| 다른 BP의 데이터 직접 조회 | 상대 BP의 테이블을 읽는 것은 지금도 계약 위반이다. 열린 것은 상대 BP가 services.provide()로 스스로 공개한 오퍼레이션뿐이며, 그것도 dependsOn[]에 선언한 경우에만 해석된다 — backend-guide.md §17 |
| 포탈 공통코드 쓰기(런타임) | 런타임에 코드 체계를 변경하는 것은 관리자의 조작이지 BP의 조작이 아니다. 쓰기는 onInstall의 설치 컨텍스트에만 있다 — backend-guide.md §14 |
| 사용자 없이 파일 만들기(정기 배치, 설치 훅) | files.create는 저장한 사용자를 요청 세션에서 판정하므로 세션이 없으면 거부된다. 설치 컨텍스트에도 files가 없다 — 훅은 세션이 없어 파일을 만든 사용자를 정할 수 없다. 기존 파일의 이행은 사용자 요청을 처리할 때 한 건씩 옮긴다 — backend-guide.md §19 |
| BP가 claim한 파일의 포탈 범용 다운로드 경로 | 그 경로에는 "이 파일을 열람할 수 있는 사용자인가"를 판정할 근거가 없다. claim 이후의 열람 권한 판정은 소유 BP의 책임이며, BP가 자기 엔드포인트에서 checkAppAccess로 판정한 뒤 내려준다 — backend-guide.md §19 |
| BP가 남긴 감사 기록의 조회 | audit는 기록만 제공한다. 조회는 포탈 감사 화면의 담당 범위다 — backend-guide.md §18 |
| 발행한 알림의 전달 상태 조회 | notifications는 발행 하나다. 발행 시점의 반환값 외에는 확인 수단이 없다 — backend-guide.md §13 |
| 제공 측 BP가 소비 측에 상태 변화를 통지하는 통로 | services는 dependsOn을 선언한 방향으로만 해석된다. 역방향 통지가 없으므로 업무의 완결 지점을 소유 BP 하나로 고정한다 — backend-guide.md §17-2 |
| 기능 패키지의 이름·버전·구성 | features는 기능 식별자만 전달한다. 어떤 패키지가 그 기능을 냈는지는 배포의 운영 정보이며 BP의 동작을 바꿀 근거가 아니다 |
| 기능 패키지의 포트 객체·라이브러리 | 열린 것은 workbook.write와 images.normalizePhoto 두 오퍼레이션뿐이다. 수식·날짜 서식·병합·스타일, 임의의 크기 조정·형식 변환은 열리지 않았다. 필요하면 포탈 쪽 설계가 먼저 바뀌어야 한다 |
| 대여 결과의 보관 | 포탈은 workbook·images의 결과를 저장하지 않는다. 보관이 필요하면 BP가 files.create로 저장한다 — backend-guide.md §22-2 |
| 대여의 처리 칸 예약·우선순위 | 처리 칸은 종류별로 프로세스에 하나씩 있고 코어와 모든 BP가 함께 쓴다. BP가 칸을 예약하거나 대기 순서를 앞당길 수 없다. busy·timeout은 다시 시도로 처리한다 |
| 설치 여부를 권한 판정의 근거로 쓰는 것 | features의 답은 편의다. 통제는 포탈의 거부이며, BP는 미리 확인했더라도 호출의 거부를 처리한다 — backend-guide.md §21 |
| 업무 첨부를 저장할 수 있는지 미리 묻는 질의 | 답이 관리자가 언제든 바꿀 수 있는 기본 저장소 대상에 달려 있어, 질의와 호출 사이에 답이 바뀔 수 있다. files는 호출 자체가 판정이며, BP는 그 거부를 받아 처리한다 — backend-guide.md §21 |
| 프로세스 종료 시 호출되는 훅 | 없다. onUninstall은 관리자가 관리 화면에서 BP 제거를 확정할 때 한 번 호출되며, 프로세스 종료와 무관하다. 예외를 발생시키면 제거 전체가 취소된다. 포탈은 BP 자체 테이블을 지우지 않으므로 그 정리는 이 훅에서 BP가 직접 한다 — backend-guide.md §8-3 |
포탈의 의존성 주입 토큰은 지역 Symbol로 선언되어 있고 @starj-portal/portal-core는 호스트 제공 모듈 목록에 없으므로, 의존성 주입을 우회해 포탈 내부 서비스를 획득하는 경로도 존재하지 않는다.
3. 호스트 제공 모듈 — 번들에 포함하면 안 되는 것
일부 모듈은 포탈과 같은 인스턴스를 공유해야 한다. 사본이 두 개 존재하면 예외가 아니라 오류 메시지 없는 오작동으로 나타난다.
| 모듈 | 사본이 두 개일 때의 증상 |
|---|---|
@nestjs/common, @nestjs/core | 의존성 주입 토큰이 서로 다른 클래스 객체가 되어 주입이 이루어지지 않는다 |
reflect-metadata | 데코레이터 메타데이터가 다른 저장소에 쌓여 컨트롤러가 등록되지 않는다 |
@prisma/client, @prisma/adapter-* | 생성 클라이언트가 실행 모듈을 해석하지 못한다 |
better-sqlite3, pg | 네이티브 애드온이므로 번들 대상이 될 수 없다 |
정확한 목록은 SDK가 소유한다. 직접 열거하지 말고 import해서 사용한다 — 목록이 늘어나도 자동으로 반영된다.
js
import { HOST_PROVIDED_MODULES } from "@starj-portal/bp-backend-sdk";
await esbuild.build({ /* … */ external: [...HOST_PROVIDED_MODULES] });버전은 캐럿 없이 고정한다
이 모듈들은 번들에 포함되지 않고 포탈이 제공하는 것이 사용된다. 따라서 BP가 빌드 시점에 참조한 버전과 포탈이 제공하는 버전이 다르면 그 차이가 실행 시점에 드러난다. 실제 발생 사례 — BP가 Prisma 7.9.1로 클라이언트를 생성하고 포탈이 7.8.0을 제공하자 쿼리 프로토콜이 달라 적재가 실패했다. 양쪽 모두 ^7.8.0으로 선언했으나 서로 다르게 해석된 결과다.
jsonc
// BP의 package.json — 캐럿을 사용하지 않는다
"dependencies": { "@prisma/client": "7.8.0", "@nestjs/common": "11.1.28" }정확한 값은 SDK가 공표한다.
bash
node -e "import('@starj-portal/bp-backend-sdk').then(m => console.log(m.HOST_PROVIDED_VERSIONS))"HOST_PROVIDED_MODULES(8개)와 HOST_PROVIDED_VERSIONS(6개)의 목록은 일치하지 않는다. better-sqlite3와 pg는 고정할 버전이 공표되어 있지 않다. 두 모듈은 BP가 직접 import하는 대상이 아니라 Prisma 어댑터가 사용하는 것이므로, BP의 dependencies가 아니라 로컬 실행용 devDependencies에 둔다. 이 두 모듈을 dependencies에 넣고 어댑터가 요구하는 것과 다른 버전을 설치하면 사본이 둘이 되어 Could not locate the bindings file이 발생한다.
정합 검사는 문서에 붙여 넣는 조각이 아니라 빌드가 수행한다 — 정본은 examples/task-bp/scripts/build.mjs의 0단계이며, HOST_PROVIDED_VERSIONS의 각 항목이 dependencies에 존재하고 값이 정확히 같은지를 확인한다(누락도 실패로 처리한다).
4. 버전 게이트
BP는 자신이 요구하는 계약 범위를 bp-manifest.yaml에 선언하고, 포탈이 자신의 HOST_CONTRACT_VERSION과 대조한다. 대조는 두 번 한다. 패키지 아카이브를 반입할 때 검증 항목 V5로 한 번(범위 밖이면 승인할 수 없다), 부팅해 적재할 때 한 번이다(install-to-portal.md §2-4, §3). starj package pack은 이 범위를 아카이브 매니페스트의 hostContract로 옮긴다.
yaml
runtime:
hostContract: "^1.3.0"요구 범위는 실제로 사용하는 표면에 맞춘다. 계약 1.1.0은 1.0.0에 멤버를 추가한 minor 상승이므로, ^1.0.0을 요구하는 기존 BP는 1.1.0 포탈에 그대로 적재된다. 반대로 1.1.0에서 추가된 표면(notifications·codes·users·destinations·services·audit·files, 매니페스트의 notificationCategories[])을 사용하면서 ^1.0.0을 선언하면, 1.0.0만 제공하는 포탈에도 적재되어 그 표면을 호출하는 시점에 실행 오류가 발생한다. 그런 BP는 ^1.1.0을 선언해 게이트가 미리 걸러내게 한다.
계약 1.3.0의 사용자 토큰 차단 사유를 처리하기만 하는 BP는 요구 범위를 올리지 않아도 된다. 그 변경은 x-portal-destination-blocked의 값을 늘렸을 뿐이고, 1.2.0 이하 포탈에는 사용자 출처 Token Exchange Destination이 없어 그 값이 오지 않는다. 처리 코드는 오지 않는 값을 기다릴 뿐 해가 없다.
계약 2.1.0의 업데이트 알림을 쓰는 BP도 요구 범위를 ^2.0.0 그대로 둔다. 2.1.0에서 호스트에 더해진 것은 위젯 쪽 선택 멤버 actions.reportUpdating 하나다. 위젯은 이 멤버를 직접 부르지 않고 프런트 SDK의 bpFetch·reportBpUpdating을 쓴다. 두 함수는 SDK 안의 코드라 2.0.0 포탈에서도 동작하고, 멤버가 없으면 알리지 않을 뿐이다. Portal Shell(포탈 셸)은 알림을 받지 못해도 업데이트 상태를 주기적으로 확인해 같은 안내를 표시한다. 매니페스트의 backwardCompatible·contractDir도 2.0.0 포탈이 읽지 않을 뿐 거부하지 않는다. 다만 2.0.0 포탈은 정리 마이그레이션을 실행하지 않으므로 contractDir의 변경은 적용되지 않은 채 남는다.
업데이트 중에는 포탈이 그 BP의 요청을 거부한다(계약 2.1.0 포탈). 포탈은 이미 설치되어 사용 중인 BP를 새 버전으로 바꾸면서 서버 코드 교체가 필요할 때, 마이그레이션 적용부터 모든 포탈 인스턴스가 새 버전을 적재할 때까지 그 BP의 서버 경로와 위젯 번들 요청을 거부한다. 응답은 다음과 같다. 다른 BP와 포탈 화면은 그대로 동작한다.
| 항목 | 값 |
|---|---|
| 상태 | 503 |
| 헤더 | x-portal-bp-updating: <BP id>, Retry-After: <초>, Cache-Control: no-store |
| 본문 | { "statusCode": 503, "errorCode": "BP_UPDATING", "message": "...", "details": { "businessPackageId": "...", "retryAfterSeconds": 5 } } |
BP 코드는 이 동안 호출되지 않는다. 위젯은 이 응답을 오류로 표시하지 않고 bpFetch(또는 reportBpUpdating)로 Portal Shell에 알린다(frontend-guide.md §8-1). 판별은 본문이 아니라 상태와 x-portal-bp-updating 헤더로 한다. 이 헤더가 없는 503은 업데이트 차단이 아니다. BP가 직접 만든 주기 작업과 다른 BP가 host.services로 하는 호출은 거부 대상이 아니다(backend-guide.md §7-5).
1.5.0은 따로 게시하지 않았다. 1.5.0에서 더한 표면(
workbook·images·위젯의features)은 게시된 개발 키트 2.0.0에 함께 들어 있다. 레지스트리의 개발 키트는 1.4.0 다음이 2.0.0이고, 포탈 0.2.0이 알리는 호스트 계약 버전(GET /bp/runtime-info의hostContractVersion)도 2.0.0이다. 2.0.0은 major 상승이라^1.5.0을 선언한 BP는 2.0.0 포탈에 적재되지 않는다. 지금 이 표면을 쓰는 BP는 아래 2.0.0 행의 조치에 따라^2.0.0을 선언한다.
host.workbook·host.images나 위젯의 features를 사용하는 BP는 ^1.5.0을 선언한다. 1.4.0 이하 포탈에서 workbook·images는 없는 멤버이므로 호출하는 시점에 실행 오류(TypeError)가 난다. 미리 막는 방법은 게이트뿐이다. ^1.5.0을 선언하면 1.4.0 이하 포탈은 그 BP를 반입 단계와 적재 단계에서 걸러낸다. 위젯의 features는 구버전 셸에서 없는 필드라 hasHostFeature가 false로 답하고 버튼이 감춰질 뿐 실행 오류는 나지 않는다. 상한 상수(BP_RENTAL_LIMITS)와 판별 함수만 쓰는 BP는 범위를 올리지 않아도 된다. 그 값들은 SDK에 들어 있고 포탈 멤버가 아니다.
host.features를 사용하는 BP는 ^1.4.0을 선언한다. 1.3.0 이하 포탈에서 features는 없는 멤버이므로 호출하면 실행 오류가 난다. 게이트가 유일한 방어다. 미설치 거부의 판별 함수(isBpCapabilityNotInstalled 등)만 쓰는 BP는 요구 범위를 올리지 않아도 된다. 1.3.0 포탈도 files.create·read의 미설치 거부를 같은 형태로 던진다.
host.files.create()나 설치 컨텍스트의 onExisting을 사용하는 BP는 ^1.3.0을 선언한다. 1.2.0 포탈에서 files.create는 없는 함수이고, onExisting은 오류 없이 무시되어 덮어쓰기로 동작한다. 후자는 실행 오류도 나지 않으므로 게이트가 유일한 방어다.
host.files.claim()을 사용하는 BP는 ^1.2.0을 선언한다. 1.2.0에서 그 오퍼레이션의 두 번째 인자가 업로더 번호에서 요청 객체로 바뀌었고, 그 차이는 게이트가 아니면 드러나지 않는다 — ^1.1.0을 선언한 채 1.1.0 포탈에 설치하면 요청 객체가 사용자 번호 자리에 들어가 결속이 항상 실패하며, 오류 없이 "첨부가 저장되지 않는" 형태로만 나타난다.
변경 이력
| 계약 | 무엇이 바뀌었나 | 기존 BP에 필요한 조치 |
|---|---|---|
| 2.1.0 | 업데이트 중 안내를 위한 표면을 더했다. 모두 추가뿐이다. (1) 프런트 SDK의 BpHostActions에 선택 멤버 reportUpdating?(input?: { retryAfterSeconds?: number }) 추가. 위젯이 BP_UPDATING 응답을 받았음을 Portal Shell에 알리고, 셸은 위젯을 정리해 업데이트 안내로 바꾼 뒤 끝나면 새 번들로 다시 마운트한다. (2) 프런트 SDK에 BP_UPDATING_ERROR_CODE·BP_UPDATING_HEADER·isBpUpdatingResponse·BpUpdatingError·reportBpUpdating·bpFetch 추가. (3) loadAndMountCsrWidget에 다섯째 인자 options?: { reload?: boolean } 추가. reload: true이면 그 BP의 기존 스크립트 요소와 window.__BP_WIDGETS__[id]를 지우고 새로 적재한다. 셸이 쓴다. (4) bp-manifest.yaml의 backend.database에 backwardCompatible(기본 false)과 contractDir(선택) 추가. (5) 프런트 테스트 더블에 actions.reportUpdating, 옵션 reportUpdating, 호출 기록 calls.reportUpdating, makeBpUpdatingResponse() 추가(기본 동작은 그대로다). 백엔드 BpHostRuntime은 바뀌지 않았다. 결정은 포탈 저장소의 ADR-197·198·199다 | 없다. runtime.hostContract는 ^2.0.0 그대로 둔다. 위젯의 서버 요청을 bpFetch로 바꾸고 BpUpdatingError는 화면에 표시하지 않는다(frontend-guide.md §8-1). 위젯 번들이 같은 페이지에서 다시 실행되어도 안전한지 확인한다(customElements.define과 전역 변수 등록). 마이그레이션에서 열·테이블 삭제나 이름 변경을 하는 BP는 확장·정리 두 단계로 나누는 것을 검토한다(backend-guide.md §7-5) |
| 2.0.0 | BP 데이터가 전용 이름공간과 전용 DB 계정으로 분리된다. (1) database.namespace 추가. (2) database.url이 코어 계정이 아니라 이 BP 전용 계정의 접속 주소로 바뀐다. 그 계정으로는 core_ 테이블과 다른 BP의 테이블을 읽을 수도 쓸 수도 없다. (3) 마이그레이션이 코어 테이블을 가리키는 외래 키를 쓰면 반입 검증 V8에서 거부된다. (4) 마이그레이션 이력이 BP 이름공간 안에 기록된다. 결정은 포탈 저장소의 ADR-173이다 | PostgreSQL 클라이언트를 만들 때 namespace를 넘긴다(backend-guide.md §6-1). 코어 테이블을 가리키는 외래 키와 코어 테이블을 읽는 SQL을 지운다. runtime.hostContract를 ^2.0.0으로 올려 다시 빌드한다. 설치된 BP는 데이터를 삭제한 뒤 다시 설치한다 |
| 1.5.0 | 따로 게시하지 않았고 2.0.0에 함께 들어 있다. (1) workbook(write({ sheets }))과 images(normalizePhoto({ data, maxEdge?, thumbnailSize? })) 추가. 포탈에 설치된 스프레드시트·이미지 기능을 빌려 쓴다. 요청 처리 시점에만 호출하고 req를 받지 않으며, 결과는 포탈이 보관하지 않는다. (2) 거부 순서는 기능 미설치 → 입력 검증 → 동시 처리·대기열·시간 → 처리다. 미설치는 CAPABILITY_NOT_INSTALLED, 상한 위반·형식 오류·대기열 초과·시간 초과는 REQUEST_REJECTED의 details.reason(limit-exceeded·invalid-input·busy·timeout)과 details.limit, 읽을 수 없는 이미지는 IMAGE_NOT_DECODABLE이다. 새 오류 코드는 없다. 포탈은 감싸지 않고 다시 던진 busy·timeout을 503으로 응답한다. (3) 상한 상수 BP_RENTAL_LIMITS, 오류 코드 상수 BP_REQUEST_REJECTED·BP_IMAGE_NOT_DECODABLE, 판별 함수 bpRentalRejectReason·bpRentalRejectedLimit·isBpImageNotDecodable 추가. (4) 프런트 SDK의 BpHostContext에 선택 필드 features(has(feature))와 BP_FEATURE_KEYS·hasHostFeature 추가. 셸은 이 필드를 항상 채우고, has는 부르는 시점의 설치 목록으로 답한다. (5) 테스트 더블에 workbook·images, 옵션 rentalFailure, 호출 기록 calls.workbook·calls.images 추가. 프런트 더블에 옵션 features 추가(기본 동작은 그대로다) | 없다(추가뿐이다). workbook·images나 위젯의 features를 쓰면 ^1.5.0을 선언한다. BP 번들에 Excel·이미지 라이브러리를 넣어 같은 일을 하던 BP는 대여로 옮기고 그 의존을 뺀다. 그때 기능이 설치되지 않은 배포의 501 응답과 버튼 감추기를 함께 넣는다 |
| 1.4.0 | (1) features(has·list) 추가와 기능 식별자 상수 BP_FEATURE_KEYS 추가. (2) 미설치 거부의 형태(errorCode: "CAPABILITY_NOT_INSTALLED", details.feature)를 계약으로 선언하고, 판별 함수 isBpHostError·isBpCapabilityNotInstalled·bpCapabilityNotInstalledFeature와 형식 BpHostError를 추가. 오류 클래스는 내보내지 않는다. (3) files.delete 변경. 계약 1.4.0에서 files.delete는 기록된 저장소 대상의 유형이 요구하는 기능이 설치되어 있지 않으면 아무것도 지우지 않고 CAPABILITY_NOT_INSTALLED로 reject한다. 1.3.0 포탈은 같은 상황에서 객체를 남긴 채 메타데이터 행만 지우고 false를 반환했고, 포탈 쪽 구현이 바뀐 것이므로 이 거부는 BP가 선언한 runtime.hostContract 범위와 무관하게 지금 포탈에 적재된 모든 BP에 적용된다. delete를 호출하는 BP는 ^1.4.0으로 올리는 것과 별개로, 호출을 try/catch로 감싸고 isBpCapabilityNotInstalled이면 행을 지우지 않고 운영자에게 안내한다. (4) create와 read는 1.3.0 포탈도 같은 형태로 거부했다. 달라진 것은 이를 계약으로 선언한 것뿐이다. 예외로, 묶음의 항목이 모두 로컬 폴백에 저장돼 있고 일부가 폴백에 없어 현재 기본 대상으로 넘어가는 경우의 delete는 개별 실패를 세어 false를 반환하고 부분 삭제가 남을 수 있다. (5) 테스트 더블에 installedFeatures·fileStorageFeature 옵션 추가(기본 동작은 그대로다) | files.delete 호출을 try/catch로 감싸고, isBpCapabilityNotInstalled(e)이면 업무 데이터의 행을 지우지 않고 운영자에게 그 기능(bpCapabilityNotInstalledFeature(e))의 설치를 안내한다. 이 조치는 요구 범위와 무관하게 지금 포탈에 적재된 모든 BP에 필요하다. features를 사용하면 ^1.4.0을 선언한다. 1.3.0 이하 포탈에서는 없는 멤버이므로 게이트가 유일한 방어다. 오류 문구로 미설치를 판별하던 코드는 판별 함수로 바꾼다 |
| 1.3.0 | (1) files.create(input, req) 추가 — BP 서버가 만든 파일을 포탈 저장소에 저장하며 만든 BP가 결속한 상태로 시작한다. 설치 컨텍스트에는 files를 더하지 않았다. (2) 설치 컨텍스트 codes.upsertDefine/upsertCode에 선택 필드 onExisting?: "overwrite" | "keep" 추가(기본값은 지금 동작). (3) Destination 차단 사유에 사용자 출처 Token Exchange의 실패 넷 추가 — user-token-unavailable(403), user-token-expired(401), token-exchange-denied(403), token-exchange-unavailable(502). principal-required(403)가 그 Destination을 req 없이 호출할 때도 난다(조회 포함). 넷을 묶은 타입 BpDestinationUserTokenBlockedReason 추가. (4) 테스트 더블 보완 — 사용자 시드의 useYn: false(비활성 수신자 제외)와 strictUsers(명부에 없는 수신자 제외), Destination 시드의 userTokenExchange 추가. 더블의 판정 순서를 포탈과 맞춰 allowed-paths를 principal-required보다 먼저 본다 | 없다(추가뿐이다). files.create나 onExisting을 사용하면 ^1.3.0을 선언한다. dataDir에 업무 파일을 두던 BP는 files.create로 옮긴다. 사용자 토큰 차단 사유를 처리하기만 하면 요구 범위를 올리지 않아도 된다. 차단 사유를 switch로 빠짐없이 나눈 BP는 타입 검사가 실패할 수 있으므로 default 분기를 둔다. 사용자 토큰을 쓰는 Destination을 호출하는 BP는 조회에도 req를 넘기고, user-token-expired이면 다시 로그인하라고 안내한다. 알림 수신자를 시험하는 BP는 strictUsers: true를 권한다 |
| 1.2.0 | 자원 접근 표면 4종의 경계가 좁혀졌다. files.claim(fileUuid, req)로 시그니처 변경, files.stat()은 결속하지 않은 파일의 업로더를 감춤, 감사 이벤트에 bp:{businessPackageId}: 접두어, 알림 수신자를 선언한 authority·활성 도메인으로 제한, Destination 소유권 대조(차단 사유 not-owned 추가) | claim() 호출부를 고치고 runtime.hostContract를 ^1.2.0으로 올린다. 알림을 역할로 보내는 BP는 그 authority가 매니페스트 defaultAuthorities[]에 선언되어 있는지 확인한다. 외부 연결처를 호출하는 BP는 그 Destination의 소유 업무패키지가 자기 것인지(또는 공용인지) 운영자와 확인한다 |
| 1.1.0 | 포탈 자원 접근 표면 7종 추가(notifications·codes·users·destinations·services·audit·files), 매니페스트 notificationCategories[] 추가 | 없다(멤버 추가). 새 표면을 사용하면 ^1.1.0을 선언한다 |
2.0.0이 major인 이유는 멤버를 더했을 뿐 아니라 같은 database.url의 의미가 바뀌기 때문이다. 1.x로 빌드한 BP는 namespace를 모르므로 PostgreSQL 스키마 방식에서 클라이언트가 public 스키마로 쿼리해 실패한다. 데이터베이스 방식에서는 쿼리가 통하더라도 코어 테이블을 가리키는 외래 키나 코어 테이블을 읽는 SQL이 있으면 실패한다. 주 버전을 올려야 그 BP가 런타임이 아니라 적재 단계에서 거부된다.
1.2.0이 major가 아닌 이유는 멤버가 없어지지 않았고 새 규정이 전부 "지금까지 통과하던 잘못된 호출을 막는" 방향이기 때문이다. 관통하는 원칙은 하나다 — 판정의 근거가 되는 값은 포탈이 채운다. 호출자가 인자로 실어 보낸 값은 그 값의 출처로 검증할 수 없다(1.1.0의 claim이 그런 형태였고, 같은 계약의 stat()이 그 근거를 알려 주어 검사가 항상 통과했다).
범위가 맞지 않으면 해당 BP만 적재되지 않는다. 포탈은 정상 기동하고 그 BP의 화면만 제공되지 않는다. 따라서 "배포했으나 아무 변화가 없다"가 이 검증의 전형적인 증상이다 — troubleshooting.md 참고.
검증 시점이 BP의 빌드가 아니라 포탈의 반입과 기동이라는 점이 중요하다. 개발 키트의 오프라인 검증(starj package verify)은 이 항목을 판정하지 않는다. 반입 때의 결과는 검증 결과에, 기동 때의 결과는 부팅 로그에 남고, 런타임 정보 API로도 조회할 수 있다(§5).
5. 계약 버전을 확인하는 방법
확인 대상이 두 가지이고 확인 수단도 두 가지다. 둘을 혼동하지 않는다.
| 확인 대상 | 수단 | 누가 |
|---|---|---|
| 대상 포탈이 제공하는 계약 버전, 운영 DB 방언, 적재된 BP와 건너뛴 BP의 사유 | 포탈의 런타임 정보 API GET /bp/runtime-info | 업무패키지 관리 권한을 가진 계정. BP 개발자에게 그 권한이 없으면 운영자에게 조회를 요청한다 |
| 내가 빌드 대상으로 삼은 계약 버전 | 설치한 SDK가 공표하는 상수 | BP 개발자 |
bash
# 내가 설치한 SDK가 공표하는 값 — 대상 포탈의 값이 아니다
node -e "import('@starj-portal/bp-backend-sdk').then(m => console.log(m.HOST_CONTRACT_VERSION))"GET /bp/runtime-info
포탈 원점 기준 경로다(https://<포탈 주소>/bp/runtime-info). 업무패키지 관리 권한을 요구한다 — 미인증 요청은 401, 권한이 없는 계정은 403으로 거부된다. 설치된 업무패키지의 이름과 버전 목록은 배포 인벤토리이므로 모든 로그인 사용자에게 열지 않는다. 권한이 있는 계정이라면 브라우저에서 포탈에 로그인한 뒤 이 주소를 직접 열어도 된다.
응답에 포함되는 것은 다섯 가지다.
| 필드 | 내용 |
|---|---|
hostContractVersion | 이 포탈이 지금 제공하는 호스트 계약 버전. bp-manifest.yaml의 runtime.hostContract 범위가 이 값을 만족해야 적재된다 |
databaseProvider | 운영 중인 DB 방언(sqlite | postgresql). backend.database.providers에 이 값이 있어야 적재된다 |
loaded[] | 적재에 성공한 BP — id / name / version / hostContractRange(그 BP가 선언한 요구 범위) |
skipped[] | 건너뛴 BP와 그 사유 — id / code(분류) / reason(설명). 내 BP가 여기 있으면 code가 어느 판정에서 걸렸는지 지시한다 |
unresolvedDependencies[] | dependsOn[]에 선언했으나 이번 부팅에 적재되지 않은 대상 — businessPackageId(선언한 쪽) / dependsOn(대상) / required / versionRange. 내 BP가 여기 있으면 services.require()가 그 대상에 대해 not-loaded를 반환하며, required: true인 항목은 그 BP를 새로 활성화하려는 관리자 조작을 차단하지만, 이미 활성화된 도메인의 상태를 되돌리지는 않는다 — 그 도메인에서는 화면이 계속 열리고 require()만 null이 되므로, 소비 측 BP가 그 상태를 사용자에게 표시하는 경로를 준비한다 |
skipped[].reason에는 호스트의 파일 경로나 스택트레이스가 포함되지 않는다. 원본 예외 메시지는 포탈 서버 로그에만 남으므로, reason만으로 원인이 좁혀지지 않으면 그 지점부터는 운영자에게 로그 조회를 요청한다.
bash
curl -s -b <세션 쿠키> https://<포탈 주소>/bp/runtime-info배포 전 대조는 이 API 하나로 끝난다 — hostContractVersion이 내 runtime.hostContract 범위를 만족하는지, databaseProvider가 내 backend.database.providers에 있는지 두 가지를 확인한다. 배포 후에는 내 BP의 id가 loaded[]에 있는지 확인하고, skipped[]에 있다면 code와 reason을 troubleshooting.md의 대응표와 대조한다. 다른 BP의 서비스에 의존하는 BP는 unresolvedDependencies[]도 함께 확인한다 — 자기 BP는 loaded[]에 있는데 상대가 적재되지 않은 상태가 여기서만 조회된다.
6. 실패의 격리 범위 — 어디까지 격리되는가
포탈이 호출하는 진입점(register(), 라이프사이클 훅, 컨트롤러 핸들러)의 예외는 각자의 범위 안에서 처리된다. 격리되지 않는 것은 BP가 스스로 시작하고 아무도 기다리지 않는 비동기 작업이다.
| BP 진입점 | 예외가 발생하면 |
|---|---|
register(host) | 그 BP만 적재 대상에서 제외되고 포탈은 정상 기동한다 |
onInstall | 포탈 부팅은 계속되고 BP는 적재된 채로 남는다. 포탈은 훅이 성공한 뒤에만 설치 버전을 기록하므로 다음 기동에서 같은 버전의 훅이 다시 실행된다. 그래서 이 훅도 여러 번 실행해도 결과가 같게 작성한다 — backend-guide.md §8-1 |
onActivate / onDeactivate | 관리자 조작은 오류로 끝나지 않는다. 상태 전이는 훅보다 먼저 커밋되므로 BP는 전이된 상태로 남고, 포탈이 실패를 기록해 관리 화면에 "훅 실패"로 표시한다. 관리자가 재시도하므로 두 훅은 여러 번 실행해도 결과가 같게 작성한다 — backend-guide.md §8-2 "훅 반복 안전 규약" |
| 컨트롤러 핸들러 | 전역 예외 필터가 처리한다 — 요청 단위로 격리된다(backend-guide.md §12) |
| 어디에도 속하지 않는 비동기 작업 | 포탈 프로세스 전체가 중단된다. 처리되지 않은 promise 거부를 잡는 핸들러가 포탈에 없다 |
따라서 BP는 떠도는 promise를 만들지 않는다. 백그라운드 작업·타이머 콜백·이벤트 핸들러의 비동기 함수는 반드시 자체 try/catch로 감싼다.
계약 1.1.0에서 추가된 표면은 이 표를 변경하지 않는다. 다만 그 표면들 자신의 실패 방식은 서로 다르다 — 알림 발행은 수신자가 0명이면 null을 반환하고, 감사 기록은 실패해도 예외를 발생시키지 않으며, Destination 호출은 어떤 경우에도 예외를 발생시키지 않고 차단을 x-portal-destination-blocked 헤더가 붙은 403/404 응답(사용자 토큰 차단은 401·403·502, 계약 1.3.0)으로 돌려주며, BP 간 연계는 계약 버전이 맞지 않으면 contract-mismatch로 판정된다. 계약 1.2.0에서 좁혀진 경계도 같은 방식으로 나타난다 — 거부는 예외가 아니라 null(알림)·false(감사·결속)·403 응답(Destination)이다. 각 표면의 실패 방식은 backend-guide.md §13~§19에 있다.
7. 공개 CSS 토큰 — 화면이 참조하는 호스트 표면
register(host)가 서버 쪽 진입점이라면, 화면 쪽에서 포탈이 BP에 건네는 값은 CSS 커스텀 프로퍼티다. 위젯은 Shadow DOM 안에 있어도 Portal Shell :root의 커스텀 프로퍼티를 상속받는다(frontend-guide.md §4·§5). 그 가운데 아래 두 토큰은 포탈이 BP의 참조 대상으로 공개 선언한 값이며, 호스트 계약(연계 규약)의 일부로 취급한다.
| 토큰 | 뜻 | 포탈이 제공하는 값 | BP가 둘 대체값 |
|---|---|---|---|
--button-ghost-border | 테두리 없는 버튼(고스트 버튼·아이콘 버튼)의 평소 테두리색 | 마우스 올림을 지원하고 정밀 포인터가 주 입력이며 폭이 640px 이상인 환경에서만 transparent다. 그 밖(터치, 굵은 포인터, Mobile 폭)에서는 일반 테두리색(--color-border)이다 | BP 자신의 일반 테두리색 |
--density-icon-button | 아이콘 버튼 정사각형의 한 변 | 1.75rem, Mobile 폭(639px 이하)에서는 2.5rem | 1.75rem |
왜 이 두 값을 공개했는가
포탈 화면은 테두리 없는 버튼을 이렇게 다룬다. 정밀 포인터 환경에서는 평소 테두리를 숨기고 마우스 올림·키보드 포커스에서 표시한다. 터치 환경과 Mobile 폭에서는 항상 표시한다. 이 규칙은 포탈 디자인 시스템의 버튼 컴포넌트가 가지고 있고, BP는 그 컴포넌트를 import할 수 없다. 그래서 BP마다 @media (hover: hover) and (pointer: fine) 같은 미디어 조건을 복사해 두는 일이 생겼다. 복사본은 포탈이 기준을 바꾸면 따라가지 못한다.
토큰 값이 이미 입력 환경에 따라 바뀌므로, BP는 미디어 조건을 복사하지 않고 토큰을 참조하기만 한다. 포탈이 기준을 바꾸면 BP는 재빌드 없이 따라간다.
사용 방법
css
.xx-ghost,
.xx-icon-button {
/* 테두리 자리는 항상 확보한다. 색만 토큰이 정한다 */
border: 1px solid var(--button-ghost-border, var(--xx-border));
}
.xx-ghost:hover,
.xx-ghost:focus-visible,
.xx-icon-button:hover,
.xx-icon-button:focus-visible {
border-color: var(--xx-border);
}
.xx-icon-button {
width: var(--density-icon-button, 1.75rem);
height: var(--density-icon-button, 1.75rem);
}--xx-border는 BP가 정한 일반 테두리색이다. 보통 var(--color-border, #d4d4d8)처럼 포탈 색 토큰에 다시 대체값을 붙인 값이다.
규칙
- 대체값은 반드시 둔다. 토큰 이름이 틀리거나 값을 받지 못하면 오류 없이 대체값이 적용된다. 대체값이 없으면 테두리가 사라지거나 버튼 크기가 0이 된다.
- iframe으로 렌더링되는 BP(
frontendDeliveryMode: "iframe")는 이 토큰을 받지 못한다. 그 BP에서는 대체값이 실제 값이므로,--button-ghost-border의 대체값을 일반 테두리색으로 둔다. 호버로 숨기는 위계는 얻지 못하지만 테두리가 항상 표시되는 쪽이라 조작 요소를 알아보는 데 문제가 없다. - 선택 상태를 가진 탭 목록은 이 규칙에서 빠진다. 한 묶음에서 한 항목만 선택되고, 마우스 올림 배경 강조와 선택 표시(밑줄이나 배경)가 있어야 한다. 두 조건을 채우지 못한 버튼은 예외가 아니다.
- 아이콘 버튼은 접근 가능한 이름(
aria-label),type="button", 아이콘의aria-hidden을 함께 갖춘다. 지역IconButton컴포넌트를 BP 안에 두어도 된다. SDK는 UI 컴포넌트를 내보내지 않는다.
계약 버전과의 관계
- 이 토큰을 참조해도
runtime.hostContract범위를 올리지 않는다. 대체값을 둔 BP는 토큰이 없는 포탈에서도 같은 화면을 렌더링한다.HOST_CONTRACT_VERSION도 이 선언으로 바뀌지 않았다. 새 값을 만든 것이 아니라 이미 Portal Shell에 있던 값을 공개 대상으로 정한 것이기 때문이다. - 이름을 바꾸거나 없애는 것은 호환성을 깨는 변경이다. BP의 참조는 CSS 문자열이라 컴파일 오류로 드러나지 않고, 대체값으로 넘어가 오류 메시지 없이 화면만 달라진다. 포탈은 이 두 이름을 안정된 표면으로 취급한다.
- 위 두 토큰과 frontend-guide.md §5의 테마 토큰 밖의 커스텀 프로퍼티는 보장되지 않는다.
함께 볼 것
- frontend-guide.md — 위젯의 스타일 격리와 테마 토큰
- bp-manifest.md — 선언 파일의 정확한 스키마
- build-and-package.md — 위 규칙을 실제 빌드 설정으로 반영하는 방법
- backend-guide.md — 위 계약 위에서 서버 로직을 구현하는 방법