테마
프론트엔드 가이드 — csr-widget
BP 화면을 포탈 셸 안에 iframe 없이 직접 마운트하는 방식이다. 셸과 React 인스턴스를 공유하지 않으므로, 셸이 어떤 프레임워크·버전을 쓰든 BP의 선택과 무관하다.
1. mount 계약
번들은 window.__BP_WIDGETS__[businessPackageId]에 mount 함수를 등록하는 IIFE여야 한다. 셸은 컨테이너 div와 호스트 컨텍스트를 넘기고, 위젯은 거기에 자기 React root를 만든다.
tsx
import type { BpHostContext, BpMountFn } from "@starj-portal/bp-frontend-sdk";
import { createRoot } from "react-dom/client";
import css from "./widget.css?inline"; // 번들러 설정에 따라 다름
const mount: BpMountFn = (container, host) => {
const shadow = container.attachShadow({ mode: "open" }); // §3 스타일 격리
const style = document.createElement("style");
style.textContent = css;
shadow.append(style);
const el = document.createElement("div");
shadow.append(el);
const root = createRoot(el);
root.render(<App host={host} />);
return () => root.unmount(); // 정리 함수(선택). 없으면 셸이 container를 비운다
};
window.__BP_WIDGETS__ = { ...window.__BP_WIDGETS__, "hello-bp": { mount } };키는 bp-manifest.yaml의 id와 정확히 같아야 한다. 다르면 셸이 위젯을 찾지 못하고 "등록되지 않았습니다" 오류를 낸다.
2. 호스트 컨텍스트
ts
interface BpHostContext {
domainId: string; // 필수 — 모든 API 호출에 포함해야 한다
apiBasePath: string; // 이 BP로 향하는 API의 base path (same-origin)
appUri?: string; // 지금 그려야 할 App의 uri
locale?: string; // BCP-47 (예: "ko")
theme?: "light" | "dark";
actions?: BpHostActions; // 셸 오퍼레이션 호출 통로
}API 호출 규칙
ts
const res = await fetch(`${host.apiBasePath}/items?domainId=${encodeURIComponent(host.domainId)}`);두 가지가 계약이다.
- 경로는
apiBasePath뒤에 이어붙인다. 값은 결합 방식에 따라 다르고 그 차이는 셸이 흡수한다 — 직접/bp/...를 하드코딩하면 다른 결합 방식에서 깨진다. domainId를 쿼리 파라미터로 반드시 포함한다. 프록시가 쿼리스트링에서 읽으므로 누락하면 403이다. 실제로 자주 밟는 함정이다.
인증 토큰은 넘어오지 않는다. 호출이 same-origin이라 브라우저 세션 쿠키가 자연히 실려간다.
App이 여러 개인 BP
번들은 businessPackageId 하나로 등록되므로, App이 둘 이상이면 host.appUri로 화면을 고른다. App이 하나뿐이면 무시해도 된다.
tsx
function App({ host }: { host: BpHostContext }) {
if (host.appUri === "hello/settings") return <Settings host={host} />;
return <Main host={host} />;
}3. 스타일 격리 — Shadow DOM은 선택이 아니다
iframe을 버려 얻은 것(하나의 문서, 하나의 스크롤, 테마 자동 전파)의 대가는 셸과 위젯이 하나의 CSS 캐스케이드를 공유한다는 것이다. 위젯이 셸과 같은 유틸리티 프레임워크(Tailwind 등)를 쓰면 동명 클래스가 같은 레이어에 두 번 선언되고 나중에 삽입된 쪽이 이긴다 — 실제로 위젯의 .hidden이 셸 상단 내비게이션의 md:block을 이겨 BP 화면을 열 때마다 셸의 GNB가 통째로 사라졌다.
그래서 경계를 만드는 책임은 위젯 쪽이다. 셸은 계속 평범한 div를 넘긴다.
| 규칙 | 이유 |
|---|---|
container.attachShadow()로 경계를 만들고 CSS를 그 안에만 심는다 | 규칙이 경계에서 막힌다 |
셸의 테마 토큰(--color-* 등)은 재정의하지 않는다 | 커스텀 프로퍼티라 경계를 넘어 상속된다 — 라이트/다크 전환이 위젯 안에서도 그대로 동작한다 |
| 모달·드롭다운도 shadow 안에서 렌더링한다 | document.body로 포탈을 만들면 그 노드는 경계 밖이라 스타일을 잃는다 |
리셋(preflight)을 포함한다면 :host { font-family: inherit; font-size: inherit; line-height: inherit }을 레이어 밖에 둔다 | preflight의 :host 규칙이 기본 서체를 박아 셸 서체 상속을 끊는다 |
접두사 없는 평문 CSS만 쓰는 위젯은 충돌이 없어 경계 없이도 동작하지만, 프레임워크 선택과 무관하게 이 규칙을 따르는 편이 안전하다.
4. 셸 오퍼레이션 호출 — host.actions
actions를 제외한 컨텍스트의 모든 값은 읽기 전용이다. 셸의 상태를 바꾸는 유일한 통로가 actions다.
ts
if (host.actions) {
const on = await host.actions.isCardOnHome("hello:open-count");
await host.actions.addCardToHome("hello:open-count");
await host.actions.removeCardFromHome("hello:open-count");
}- 작용 범위는 항상 현재 로그인 사용자·현재 도메인이다 — 다른 사용자를 지정하는 파라미터 자체가 없다.
- 대상은 그 BP가 소유한 카드로 제한된다.
- 위젯이 기억해 둔 상태를 쓰지 말고 매번
isCardOnHome으로 물어야 한다. 다른 기기·다른 탭에서 이미 바뀌었을 수 있다. - 구버전 셸을 위해 optional이므로 존재 확인이 필요하다.
5. URL 상태 동기화
목록에서 무엇을 골랐는지를 컴포넌트 state가 아니라 URL에 둔다 — 새로고침·뒤로가기·링크 공유가 모두 살아난다.
tsx
import { useUrlParam } from "@starj-portal/bp-frontend-sdk";
const [ticketId, setTicketId] = useUrlParam("ticket");
setTicketId("T-1024");
setTicketId(null); // 파라미터 제거React 밖(이벤트 핸들러 등)에서는 getUrlParam / setUrlParam을 쓴다.
6. loadAndMountCsrWidget은 누가 쓰나
셸(호스트)이 쓴다. 위젯 스크립트를 중복 주입 없이 로드해 mount를 호출하고 정리 함수를 돌려주는 헬퍼다. BP가 부를 일은 없다 — SDK에 함께 들어 있는 이유는 셸과 위젯이 같은 계약 정의를 보게 하기 위해서다.
함께 볼 것
- build-and-package.md — 위젯 번들을 어떻게 만드나
- troubleshooting.md — 화면이 안 뜨거나 스타일이 깨질 때