컴포저빌리티 시스템
컴포저빌리티 시스템 (Composability System)
레거시 문서
출처: 문서
본문
레거시 문서
이 페이지는 createRoutableExtension, createComponentExtension, RouteRef, ExternalRouteRef, 컴포넌트 데이터를 포함한 이전 프론트엔드 시스템의 컴포저빌리티 시스템을 설명해요. 새 프론트엔드 시스템은 Extensions, Extension Blueprints, Routes를 참고하세요.
요약
이 페이지는 많은 플러그인의 콘텐츠를 하나의 Backstage 애플리케이션으로 모으는 데 도움이 되는 컴포저빌리티 시스템을 설명해요.
컴포저빌리티 시스템의 핵심 원칙은 플러그인이 명확한 경계와 연결을 가져야 한다는 것이에요. 플러그인 내부의 충돌을 격리하되, 그 사이의 탐색은 허용해야 해요. 플러그인이 필요할 때만 로드되도록 하고, 다른 플러그인이 구축할 확장 포인트를 플러그인이 제공할 수 있게 해야 해요. 컴포저빌리티 시스템은 또한 앱 우선(app-first) 사고방식으로 구축되어, 플러그인과 코어 API보다 앱의 단순성과 명확성을 우선시해요.
컴포저빌리티 시스템은 단일 API 표면이 아니에요. 패턴, 프리미티브, API의 모음이에요. 핵심에는 앱에서 사용하기 위해 플러그인이 내보내는 확장(extension)이라는 개념이 있어요. 컴포넌트 데이터(component data)라는 프리미티브도 있는데, 앱의 구조를 더 선언적으로 유지하는 데 도움이 돼요. 또한 서로 다른 오픈소스 플러그인을 모을 때 특히 중요한, 플렉서블한 방식으로 페이지 사이를 라우팅하는 데 도움이 되는 RouteRef도 있어요.
개념
이 섹션은 컴포저빌리티 시스템을 지원하는 모든 개념을 간략히 살펴봐요.
컴포넌트 데이터
컴포넌트 데이터는 React 컴포넌트에 새로운 데이터 차원을 제공하는 방식으로 도입된 컴포저빌리티 프리미티브예요. 데이터는 키를 사용해 React 컴포넌트에 연결되며, 다음 예시에서 보듯 같은 키를 사용해 그 컴포넌트들로 만들어진 모든 JSX 요소에서 읽을 수 있어요.
const MyComponent = () => <h1>This is my component</h1>;attachComponentData(MyComponent, 'my.data', 5);const element = <MyComponent />;const myData = getComponentData(element, 'my.data');// myData === 5
컴포넌트 데이터의 목적은 요소를 렌더링하기 전에 검사할 수 있는 데이터를 임베드하는 메서드를 제공하는 것이에요. 요소 검사는 React 라이브러리들 사이에서 꽤 흔한 패턴이며, 예를 들어 react-router와 material-ui가 렌더링 전에 자식 요소의 속성을 발견하는 데 사용해요. 그러나 그 라이브러리들에서는 보통 요소 타입과 props만 검사되는 반면, 우리의 컴포넌트 데이터는 한 번에 한 데이터의 여러 다른 버전을 사용하고 해석할 수 있게 허용해 더 구조화된 접근을 추가하고 진화를 단순화해요.
컴포넌트 데이터의 사용 사례 중 하나는 앱의 요소를 통한 라우트 및 플러그인 발견을 지원하는 것이에요. 이를 통해 앱의 React 요소 트리가 어떤 플러그인이 사용되는지와 앱의 모든 최상위 플러그인 라우트 모두에 대한 진실의 원천이 되도록 허용해요. 그러나 컴포넌트 데이터의 사용은 이러한 사용 사례에만 국한되지 않으며, 새 추상화를 만드는 프리미티브로도 사용될 수 있어요.
확장 (Extensions)
확장은 플러그인이 앱에서 사용하기 위해 내보내는 것이에요. 가장 전형적으로는 React 컴포넌트이지만, 실제로는 어떤 종류의 JavaScript 값이든 될 수 있어요. create*Extension 함수로 만들어지며, 실제 내보낸 확장을 만들기 위해 plugin.provide()로 감싸져요.
확장 타입은 단순해요.
export type Extension<T> = { expose(plugin: BackstagePlugin): T;};
확장의 힘은 다양한 행위자가 그것의 사용에 연결할 수 있는 능력에서 나와요. 생성과 플러그인 감싸기는 생성 함수를 소유한 사람이 제어하고, Backstage 코어는 플러그인 밖으로 확장을 노출하는 과정에 연결할 수 있으며, 마지막으로 앱이 확장의 사용을 제어해요.
Backstage 코어 API는 현재 두 가지 유형의 확장 생성자를 제공해요: createComponentExtension과 createRoutableExtension이에요. 컴포넌트 확장은 특별한 요구 사항이 없는 평범한 React 컴포넌트로, 예를 들어 엔티티 개요 페이지용 카드예요. 컴포넌트는 거의 그대로 내보내지지만, 오류 경계, 지연 로딩, 플러그인 컨텍스트 같은 것을 제공하도록 감싸져요.
라우터블 확장은 컴포넌트 확장 위에 구축되며, 최상위 페이지나 엔티티 페이지 탭 콘텐츠 같은 특정 라우트 경로에서 렌더링되어야 하는 모든 컴포넌트에 사용돼요. 라우터블 확장을 만들 때는 RouteRef를 mountPoint로 제공해야 해요. 마운트 포인트는 외부 세계를 위한 컴포넌트의 핸들이 되며, 라우터블 컴포넌트에 연결하고 싶은 다른 컴포넌트와 플러그인이 사용해요.
현재 코어 라이브러리에는 두 개의 확장 생성 함수만 있지만, 미래에는 더 추가될 수 있어요. 또한 자체 확장을 통해 기능을 확장하는 방법을 제공하는 플러그인도 몇 가지 있어요. 예를 들어 @backstage/plugin-scaffolder의 createScaffolderFieldExtension이에요. 확장은 React에 묶이지도 않으며, 일반 JS 개념을 모델링하는 데 사용될 수 있고 React가 아닌 렌더링 라이브러리와 웹 프레임워크로의 브리지가 될 수도 있어요.
플러그인 관점의 확장
확장은 플러그인 경계를 넘나드는 주요 방법 중 하나이며, 플러그인이 앱 안에서 사용하기 위한 구체적인 콘텐츠를 제공하는 방식이에요. 엔티티 개요 페이지에 표시하기 위한 Router나 *Card 같은 기존의 컴포넌트 내보내기 개념을 대체해요.
내보낸 확장을 최상위 plugin.ts 파일이나 전용 extensions.ts(또는 .tsx) 파일에 만드는 것을 권장해요. 그러나 그 파일이 구현의 대부분을 담아서는 안 되며, 실제로 확장이 React 컴포넌트라면 실제 컴포넌트를 지연 로드하는 것을 권장해요. 컴포넌트 확장은 lazy 컴포넌트 선언을 사용해 기본으로 지연 로딩을 지원해요. 예를 들어:
export const EntityFooCard = plugin.provide( createComponentExtension({ component: { lazy: () => import('./components/FooCard').then(m => m.FooCard), }, }),);
라우터블 확장은 컴포넌트를 제공하는 유일한 방법이기 때문에 지연 로딩을 강제해요.
export const FooPage = plugin.provide( createRoutableExtension({ name: 'FooPage', component: () => import('./components/FooPage').then(m => m.FooPage), mountPoint: fooPageRouteRef, }),);
앱에서 확장 사용
지금은 모든 확장이 React 컴포넌트로 모델링돼요. 이 확장의 사용은 중요한 차이점 하나와 함께 일반 React 컴포넌트를 사용하는 것과 같아요. 확장은 모두 루트 AppProvider에서 뻗어 나오는 단일 React 요소 트리의 일부여야 해요.
예를 들어 다음 앱 코드는 작동하지 않아요.
const AppRoutes = () => ( <Routes> <Route path="/foo" element={<FooPage />} /> <Route path="/bar" element={<BarPage />} /> </Routes>);const App = () => ( <AppProvider> <AppRouter> <Root> <AppRoutes /> </Root> </AppRouter> </AppProvider>);
그러나 이 경우 고치기 쉽습니다! 앱에서 중간 컴포넌트를 만들지 않도록 주의하면 돼요. 예를 들어 이렇게요.
const appRoutes = ( <Routes> <Route path="/foo" element={<FooPage />} /> <Route path="/bar" element={<BarPage />} /> </Routes>);const App = () => ( <AppProvider> <AppRouter> <Root>{appRoutes}</Root> </AppRouter> </AppProvider>);
명명 패턴
플러그인을 만들 때 따라야 할 몇 가지 명명 패턴이 있으며, 이는 내보내기의 의도와 사용을 명확히 하는 데 도움이 돼요.
| | Description | Pattern | Examples |
| | Top-level Pages | *Page | CatalogIndexPage, SettingsPage, LighthousePage |
| | Entity Tab Content | Entity*Content | EntityJenkinsContent, EntityKubernetesContent |
| | Entity Overview Card | Entity*Card | EntitySentryCard, EntityPagerDutyCard |
| | Entity Conditional | is*Available | isPagerDutyAvailable, isJenkinsAvailable |
| | Plugin Instance | *Plugin | jenkinsPlugin, catalogPlugin |
| | Utility API Reference | *ApiRef | configApiRef, catalogApiRef |
라우팅 시스템
Backstage의 라우팅 시스템은 컴포저빌리티 시스템에 크게 의존해요. 앱의 라우팅 대상을 나타내는 데 RouteRef를 사용하며, 런타임에 구체적인 path에 바인딩되지만 서로 라우팅하는 방법을 모르는 서로 다른 플러그인을 섞는 데 도움이 되는 추상화 수준을 제공해요.
각 RouteRef의 구체적인 path는 앱의 요소 트리를 기반으로 발견돼요. 다음 예시를 고려해 보겠어요.
const appRoutes = ( <Routes> <Route path="/foo" element={<FooPage />} /> <Route path="/bar" element={<BarPage />} /> </Routes>);
FooPage와 BarPage가 각각 fooPlugin과 barPlugin이 내보내는 라우터블 확장이라고 가정할게요. FooPage는 라우터블 확장이므로 마운트 포인트로 할당된 RouteRef가 있으며, 이를 fooPageRouteRef라고 부를게요.
위 예시가 주어지면 fooPageRouteRef는 '/foo' 라우트와 연결될 거예요. FooPage로 라우팅하고 싶다면 useRouteRef 훅을 사용해 페이지에 대한 구체적인 링크를 만들 수 있어요. useRouteRef 훅은 단일 RouteRef를 유일한 파라미터로 받아 URL을 만드는 데 호출되는 함수를 반환해요. 예를 들어 이렇게요.
const MyComponent = () => { const fooRoute = useRouteRef(fooPageRouteRef); return <a href={fooRoute()}>Link to Foo</a>;};
이제 BarPage에서 FooPage로 연결하고 싶다고 가정해 보겠어요. barPlugin에서 fooPageRouteRef를 직접 참조하고 싶지 않아요. 그것은 fooPlugin에 불필요한 의존성을 만들 것이기 때문이에요. 또한 앱이 플러그인을 묶도록 허용하는 유연성을 거의 제공하지 않고, 링크가 플러그인 자체에 의해 결정될 것이기 때문이에요. 이를 해결하기 위해 ExternalRouteRef를 사용해요. 일반 라우트 참조와 마찬가지로 useRouteRef에 전달해 구체적인 URL을 만들 수 있지만, 라우터블 컴포넌트에서 마운트 포인트로 사용될 수는 없고 대신 앱에서 라우트 바인딩을 사용해 대상 라우트와 연결되어야 해요.
barPlugin 안에, 연결할 특정 플러그인 페이지보다는 플러그인에서의 역할을 설명하는 중립적인 이름을 사용해 새 ExternalRouteRef를 만들어요. 그러면 앱이 최종 대상을 결정할 수 있어요. 예를 들어 BarPage가 헤더의 외부 페이지에 연결하고 싶다면 이와 비슷한 ExternalRouteRef를 선언할 수 있어요.
const headerLinkRouteRef = createExternalRouteRef({ id: 'header-link' });
앱에서 외부 라우트 바인딩
외부 라우트의 연결은 앱이 제어해요. 플러그인의 각 ExternalRouteRef는 보통 다른 플러그인의 실제 RouteRef에 바인딩되어야 해요. 바인딩 과정은 앱 시작 시 한 번 발생하며, 그 후 앱의 수명 동안 구체적인 라우트 경로를 해석하는 데 사용돼요.
BarPage가 FooPage에 연결하는 위 예시를 사용하면, 앱에서 이렇게 할 수 있어요.
createApp({ bindRoutes({ bind }) { bind(barPlugin.externalRoutes, { headerLink: fooPlugin.routes.root, }); },});
위 바인딩이 주어지면 barPlugin 안에서 useRouteRef(headerLinkRouteRef)를 사용하면 FooPage가 마운트된 어떤 경로든 링크를 만들 수 있게 해줘요.
앱에서 RouteRef를 직접 import하고 사용하는 것이 아니라 플러그인 인스턴스에 의존해 플러그인의 라우트에 접근한다는 점을 주목하세요. 이는 라우트의 더 나은 네임스페이싱과 발견 가능성을 제공하고 각 플러그인 패키지에서 별도의 내보내기 수를 줄이기 위해 도입된 새 관례예요. 라우트 참조는 createPlugin에 이렇게 공급돼요.
// In foo-pluginexport const fooPlugin = createPlugin({ routes: { root: fooPageRouteRef, }, ...})// In bar-pluginexport const barPlugin = createPlugin({ externalRoutes: { headerLink: headerLinkRouteRef, }, ...})
또한 라우트 참조 자체는 플러그인 인스턴스를 만드는 파일과 다른 파일, 예를 들어 최상위 routes.ts에서 만드는 것이 거의 항상 좋다는 점도 주목하세요. 이는 같은 플러그인의 다른 부분에서 라우트 참조를 사용할 때 순환 import를 피하기 위해서예요.
또 한 가지 주목할 점은 라우팅의 이런 추상화가 특히 어떻게 통합될지에 유연성을 남겨야 하는 오픈소스 플러그인에 유용하다는 것이에요. 자체 Backstage 애플리케이션을 위해 내부적으로 만드는 플러그인에 대해서는 직접 import 경로를 가거나 구체적인 라우트를 직접 사용할 수도 있어요. 내부 플러그인에서조차 전체 라우팅 시스템을 사용하는 것이 이점이 될 수 있지만요. 라우트를 구조화하는 데 도움이 되고, 아래에서 보겠지만 라우트 파라미터를 관리하는 데도 도움이 돼요.
정적 구성을 사용해 라우트를 바인딩할 수도 있으며, 앱 코드를 변경할 필요가 없어져요. 그러나 라우트를 바인딩할 때 타입 안전성과 바인딩의 컴파일 타임 검증을 얻지 못한다는 것을 의미해요. 라우트 바인딩의 정적 구성은 app-config.yaml의 app.routes.bindings 키 아래에서 이루어져요. 새 프론트엔드 시스템의 라우트 바인딩과 같은 방식으로 작동해요. 예를 들어:
app: routes: bindings: bar.headerLink: foo.root
외부 라우트 참조의 기본 대상
Backstage 1.28 릴리스 이후 외부 라우트 참조에 대한 기본 대상을 정의할 수 있어요. 새 프론트엔드 시스템의 기본 대상과 같은 방식으로 작동해요. 예를 들어:
export const createComponentExternalRouteRef = createExternalRouteRef({ defaultTarget: 'scaffolder.createComponent',});
선택적 외부 라우트
ExternalRouteRef를 만들 때 선택적(optional)으로 표시할 수 있어요.
const headerLinkRouteRef = createExternalRouteRef({ id: 'header-link', optional: true,});
선택적으로 표시된 외부 라우트는 앱에서 바인딩될 필요가 없으며, 특정 링크를 표시할지 또는 액션을 취할지에 대한 스위치로 사용될 수 있어요.
선택적 외부 라우트로 useRouteRef를 호출하면 반환 서명이 RouteFunc | undefined로 변경되어 이런 로직이 가능해요.
const MyComponent = () => { const headerLink = useRouteRef(headerLinkRouteRef); return ( <header> My Header {headerLink && <a href={headerLink()}>External Link</a>} </header> );};
파라미터화된 라우트
RouteRef의 기능 중 하나는 이름 있고 타입이 지정된 파라미터를 추가하는 가능성이에요. 파라미터는 생성 시 선언되며, 앱의 경로에 파라미터의 존재를 강제하고 useRouteRef를 사용할 때 파라미터로 요구해요.
다음은 파라미터화된 라우트의 생성과 사용 예시예요.
// Creation of a parameterized routeconst myRouteRef = createRouteRef({ id: 'myroute', params: ['name']})// In the app, where MyPage is a routable extension with myRouteRef set as mountPoint<Route path='/my-page/:name' element={<MyPage />}/>// Usage within a componentconst myRoute = useRouteRef(myRouteRef)return ( <div> <a href={myRoute({name: 'a'})}>A</a> <a href={myRoute({name: 'b'})}>B</a> </div>)
현재 파라미터화된 ExternalRouteRef를 갖거나 외부 라우트를 파라미터화된 라우트에 바인딩하는 것은 불가능하지만, 필요하다면 미래에 추가될 수 있어요.
서브라우트
만들 수 있는 마지막 종류의 라우트 참조는 SubRouteRef이며, 절대 RouteRef에 상대적인 고정 경로를 가진 라우트 참조를 만드는 데 사용할 수 있어요. 페이지가 내부적으로 라우터블 확장 컴포넌트의 하위 라우트에 마운트되어 있고, 다른 플러그인이 그 페이지로 라우팅할 수 있게 하고 싶다면 유용해요.
예를 들어:
// routes.tsconst rootRouteRef = createRouteRef({ id: 'root' });const detailsRouteRef = createSubRouteRef({ id: 'root-sub', parent: rootRouteRef, path: '/details',});// plugin.tsexport const myPlugin = createPlugin({ routes: { root: rootRouteRef, details: detailsRouteRef, },});export const MyPage = myPlugin.provide( createRoutableExtension({ name: 'MyPage', component: () => import('./components/MyPage').then(m => m.MyPage), mountPoint: rootRouteRef, }),);// components/MyPage.tsxconst MyPage = () => ( <Routes> {/* myPlugin.routes.root will take the user to this page */} <Route path="/" element={<IndexPage />} /> {/* myPlugin.routes.details will take the user to this page */} <Route path="/details" element={<DetailsPage />} /> </Routes>);
카탈로그 컴포넌트
앱에서 카탈로그 엔티티 페이지를 구조화하고 다양한 시나리오에서 렌더링할 콘텐츠를 선택하는 데 도움이 되도록 @backstage/catalog 플러그인은 EntitySwitch 컴포넌트를 제공해요. EntitySwitch.Case 자식 목록을 사용해 최대 하나의 요소를 렌더링하도록 선택해 작동해요.
예를 들어 kind가 "Template"인 모든 엔티티를 MyTemplate 컴포넌트로 렌더링하고 다른 모든 엔티티를 MyOther 컴포넌트로 렌더링하고 싶다면 다음을 수행해요.
<EntitySwitch> <EntitySwitch.Case if={isKind('template')}> <MyTemplate /> </EntitySwitch.Case> <EntitySwitch.Case> <MyOther /> </EntitySwitch.Case></EntitySwitch>// Shorter form if desired:<EntitySwitch> <EntitySwitch.Case if={isKind('template')} children={<MyTemplate />}/> <EntitySwitch.Case children={<MyOther />}/></EntitySwitch>
EntitySwitch 컴포넌트는 선택된 엔티티가 if prop의 함수에 전달될 때 true를 반환하는 첫 번째 EntitySwitch.Case의 자식을 렌더링해요. 어떤 케이스도 일치하지 않으면 자식이 렌더링되지 않고, 케이스가 if 필터 함수를 지정하지 않으면 항상 일치해요. if 속성은 단순히 (entity: Entity) => boolean 타입의 함수예요. 예를 들어 isKind는 이렇게 구현할 수 있어요.
function isKind(kind: string) { return (entity: Entity) => entity.kind.toLowerCase() === kind.toLowerCase();}
@backstage/catalog 플러그인은 isKind, isComponentType, isResourceType, isEntityWith, isNamespace 같은 몇 가지 내장 조건을 제공해요.
EntitySwitch 컴포넌트 외에도 catalog 플러그인은 새 EntityLayout 컴포넌트를 내보내요. 이는 EntityPageLayout 컴포넌트의 조정된 버전이자 대체품이며, 아래 앱 마이그레이션 섹션에서 더 깊이 소개돼요.
참고: 이 문서의 나머지는 기존 애플리케이션을 위에서 설명한 새 컴포저빌리티 시스템으로 마이그레이션하는 방법을 다뤄요.
기존 플러그인 포팅
기존 플러그인을 새 컴포저빌리티 시스템으로 포팅하는 데 몇 가지 높은 수준의 단계가 있어요.
createPlugin안의router.addRoute또는router.registerRoute사용을 제거하고, 페이지 컴포넌트를 라우터블 확장으로 내보내기.- 모든
Router내보내기를 라우터블 확장으로 전환. - 카탈로그 개요 카드 같은 평범한 컴포넌트 내보내기를 컴포넌트 확장으로 변경.
RouteRef내보내기를 중단하고 대신createPlugin에 전달.- props로
RouteRef를 받거나 다른 플러그인에서 import하는 것을 중단하고, 대신ExternalRouteRef를 만들어createPlugin에 전달. - 아래 명명 패턴 표에 따라 다른 내보내진 심볼의 이름을 바꾸기.
기존 내보내기와 구성을 제거하는 것은 모든 플러그인의 중단 변경(breaking change)이라는 점을 주목하세요. 하위 호환성이 필요하다면 새 추가 사항을 만드는 동안 기존 코드를 deprecated 처리한 다음 나중에 제거해야 해요.
명명 패턴
import 별칭을 피하고 의도를 명확히 하기 위해 많은 export 명명 패턴이 변경됐어요. 새 이름을 만들려면 다음 표를 참고하세요.
| | Description | Existing Pattern | New Pattern | Examples |
| | Top-level Pages | Router | *Page | CatalogIndexPage, SettingsPage, LighthousePage |
| | Entity Tab Content | Router | Entity*Content | EntityJenkinsContent, EntityKubernetesContent |
| | Entity Overview Card | *Card | Entity*Card | EntitySentryCard, EntityPagerDutyCard |
| | Entity Conditional | isPluginApplicableToEntity | is*Available | isPagerDutyAvailable, isJenkinsAvailable |
| | Plugin Instance | plugin | *Plugin | jenkinsPlugin, catalogPlugin |