앱 마이그레이션하기
앱 마이그레이션하기 (Migrating Apps)
이 섹션은 기존 Backstage 앱 패키지를 새 프론트엔드 시스템을 사용하도록 마이그레이션하는 방법을 설명해요. 앱 패키지는 일반적으로 프로젝트의 packages/app에 있으며 Backstage 프론트엔드 애플리케이션을 서로 연결하는 책임을 져요.
출처: 문서
본문
개요 (Overview)
이 섹션은 기존 Backstage 앱 패키지를 새 프론트엔드 시스템을 사용하도록 마이그레이션하는 방법을 설명해요. 앱 패키지는 일반적으로 프로젝트의 packages/app에 있으며 Backstage 프론트엔드 애플리케이션을 서로 연결하는 책임을 져요.
이 문서는 누구를 위한 것인가요?
이 가이드는 레거시 프론트엔드 시스템에서 새 확장 기반 아키텍처로 업그레이드하려는 Backstage 앱 패키지(packages/app)의 관리자를 위한 것이에요.
사전 요구 사항:
-
앱의 현재 구조와 구성에 대한 이해
-
Yarn 워크스페이스와 모노레포 설정
-
yarn명령을 실행하고 의존성을 업데이트할 수 있는 접근 권한
마이그레이션 (Migration)
원활하고 관리 가능한 전환을 보장하기 위해 두 단계의 마이그레이션 프로세스를 권장해요.
-
1단계: 하이브리드 구성을 위한 최소 변경 이 단계에서는 앱을 하이브리드 모드로 실행할 수 있게 하는 가장 작은 변경 세트를 만들어요. 이렇게 하면 호환성 헬퍼와 레거시 코드에 계속 의존하면서도 새 프론트엔드 시스템 사용을 시작할 수 있어요. 목표는 전체 재작성 없이 새 시스템의 이점을 누릴 수 있도록 마이그레이션을 신속하게 돌파하는 것이에요.
-
2단계: 새 프론트엔드 시스템으로의 완전한 전환 앱이 하이브리드 모드로 실행되면 레거시 코드와 호환성 헬퍼를 제거하도록 코드베이스를 점진적으로 리팩터링할 수 있어요. 이 단계는 새 프론트엔드 아키텍처를 완전히 수용해 코드베이스를 깨끗하고 유지 관리 가능하게 유지하고 새 기능을 최대한 활용하는 데 초점을 맞춰요.
경고
하이브리드 모드에 너무 오래 머무르는 것은 권장하지 않아요. 레거시 버전과 호환성 헬퍼 지원은 향후 중단될 예정이므로, 가능한 한 빨리 코드베이스를 완전히 마이그레이션할 계획을 세울 것을 권장해요.
체크리스트 (Checklist)
시작하기 전에 이 체크리스트를 검토해 진행 상황을 추적하세요.
-
하이브리드 구성을 위한 최소 변경 완료(1단계)
-
앱이 하이브리드 모드에서 시작하고 작동
-
레거시 코드와 헬퍼를 점진적으로 마이그레이션하고 제거(2단계)
-
교체 마이그레이션 후 레거시 플러그인 라우트와 import 제거
-
앱이 새 프론트엔드 시스템에서 완전히 실행
참고
문제가 발생하면 GitHub 이슈를 확인하거나 Discord에서 물어보세요.
1단계: 하이브리드 구성을 위한 최소 변경
하이브리드 모드에서 새 프론트엔드 시스템을 실험해 보려면 앱을 최소한으로 변경하는 5단계가 있어요.
이 단계를 완료한 뒤 앱을 시작해 여전히 작동하는지 확인할 수 있어야 해요.
1) createApp 교체하기
시작하려면 새 @backstage/frontend-defaults 패키지를 추가해야 해요.
yarn --cwd packages/app add @backstage/frontend-defaults
다음 단계는 createApp 함수를 @backstage/frontend-defaults의 새 것으로 교체하는 것이에요.
in packages/app/src/App.tsx
import { createApp } from '@backstage/app-defaults';import { createApp } from '@backstage/frontend-defaults';
이 즉각적인 교체는 많은 중단을 일으키며 다음 단계에서 수정될 거예요.
2) createApp 옵션 변환하기
대부분의 레거시 createApp 옵션은 bindRoutes를 제외하고 새 프론트엔드 시스템과 호환되는 기능으로 변환할 수 있어요. 이는 convertLegacyAppOptions 헬퍼로 수행되며, 새 아키텍처로 점진적으로 마이그레이션하면서 기존 구성을 계속 사용할 수 있게 해줘요.
이렇게 하려면 먼저 앱 패키지에 이 의존성을 추가하세요.
yarn --cwd packages/app add @backstage/core-compat-api
애플리케이션이 만들어진 파일을 여세요. 현재 다음과 같은 작업을 하고 있을 거예요.
in packages/app/src/App.tsx
const app = createApp({ apis, icons: { // Custom icon example alert: AlarmIcon, }, featureFlags: [ { name: 'scaffolder-next-preview', description: 'Preview the new Scaffolder Next', pluginId: '', }, ], components: { SignInPage: props => { return ( <SignInPage {...props} providers={['guest', 'custom', ...providers]} title="Select a sign-in method" align="center" /> ); }, },});
다음으로 마이그레이션하세요.
in packages/app/src/App.tsx
import { createApp } from '@backstage/frontend-defaults';import { convertLegacyAppOptions } from '@backstage/core-compat-api';const convertedOptionsModule = convertLegacyAppOptions({ /* legacy options such as apis, icons, plugins, components, themes and featureFlags */ apis, icons: { // Custom icon example alert: AlarmIcon, }, featureFlags: [ { name: 'scaffolder-next-preview', description: 'Preview the new Scaffolder Next', pluginId: '', }, ], components: { SignInPage: props => { return ( <SignInPage {...props} providers={['guest', 'custom', ...providers]} title="Select a sign-in method" align="center" /> ); }, },}});const app = createApp({ features: [ // ... convertedOptionsModule, ],});
3) app.createRoot 호출 수정하기
app.createRoot(...)는 더 이상 인자를 받지 않아요. 이는 새 프론트엔드 시스템이 도입하는 근본적인 변화를 나타내요. 이전 시스템에서 app.createRoot(...)에 전달한 앱 요소 트리는 앱에서 플러그인과 기능을 설치하고 구성하는 주요 방법이었어요. 새 시스템에서는 대신 확장 트리로 서로 연결되는 확장으로 교체됐어요. 이제 훨씬 더 많은 책임이 플러그인으로 이동했어요. 예를 들어 각 플러그인 페이지의 라우트 경로를 수동으로 제공할 필요가 없고, 기본값을 재정의하고 싶을 때만 구성하면 돼요. 새 시스템이 어떻게 작동하는지에 대한 자세한 내용은 아키텍처 섹션을 참고하세요.
앱 요소 트리가 앱을 구성하는 대부분이므로, 이는 아마도 마이그레이션 노력의 대부분일 거예요. 마이그레이션을 최대한 원활하게 하기 위해 기존 앱 요소 트리를 새 앱에 설치할 수 있는 플러그인으로 변환하는 헬퍼를 제공했어요. 이를 통해 전체 앱 구조를 한 번에 마이그레이션할 필요 없이 개별 플러그인을 점진적으로 마이그레이션할 수 있어요.
헬퍼 이름은 convertLegacyAppRoot이며 @backstage/core-compat-api 패키지에서 export돼요. 설치한 뒤 convertLegacyAppRoot를 import하세요. 앱이 현재 다음과 같다면:
in packages/app/src/App.tsx
const app = createApp({ /* All legacy options except route bindings */});export default app.createRoot( <> <AlertDisplay transientTimeoutMs={2500} /> <OAuthRequestDialog /> <AppRouter> <Root>{routes}</Root> </AppRouter> </>,);
다음으로 마이그레이션하세요.
in packages/app/src/App.tsx
import { // ... convertLegacyAppRoot,} from '@backstage/core-compat-api';const convertedRootFeatures = convertLegacyAppRoot( <> <AlertDisplay /> <OAuthRequestDialog /> <AppRouter> <Root>{routes}</Root> </AppRouter> </>,);const app = createApp({ features: [ // ... ...convertedRootFeatures, ],});export default app.createRoot();
이전에 app.createRoot(...)에 전달했던 모든 요소를 가져와 대신 convertLegacyAppRoot(...)에 전달했어요. 그런 다음 convertLegacyAppRoot가 반환한 기능을 새 createApp의 features 옵션으로 전달해요.
4) 앱 렌더링 조정하기
계속 진행하기 전에 처리해야 할 세부 사항이 하나 더 있어요. app.createRoot() 함수는 이제 컴포넌트가 아닌 React 요소를 반환하므로, 앱 index.tsx를 다음과 같이 업데이트해야 해요.
in packages/app/src/index.tsx
import '@backstage/cli/asset-types';import ReactDOM from 'react-dom/client';import App from './App';import app from './App';ReactDOM.createRoot(document.getElementById('root')!).render(<App />);ReactDOM.createRoot(document.getElementById('root')!).render(app);
5) 앱 테스트 파일 업데이트하기
App.test.tsx 파일에도 유사한 변경을 해야 해요.
import { render, waitFor } from '@testing-library/react';import App from './App';import app from './App';describe('App', () => { it('should render', async () => { process.env = { NODE_ENV: 'test', APP_CONFIG: [ { data: { app: { title: 'Test' }, backend: { baseUrl: 'http://localhost:7007' }, }, context: 'test', }, ] as any, }; const rendered = render(<App />); const rendered = render(app); await waitFor(() => { expect(rendered.baseElement).toBeInTheDocument(); }); });});
2단계: 새 프론트엔드 시스템으로의 완전한 전환
앱이 하이브리드 모드에서 시작하고 작동한다면 2단계를 시작할 준비가 된 거예요. 그렇지 않다면 오류 메시지를 검토하고 GitHub 이슈를 확인하거나 커뮤니티 Discord에서 도움을 요청하세요.
이 시점에서 앱의 내용은 초기 마이그레이션 단계를 넘어섰을 거예요. 레거시 코드와 헬퍼를 점진적으로 제거해 새 시스템을 완전히 수용해 보겠어요.
커스터마이즈 메커니즘 선택하기
2단계 동안 필요를 충족하는 가장 덜 침습적인 메커니즘을 선호하세요. 이렇게 하면 앱이 기본 프론트엔드 시스템에 더 가깝게 유지되고 유지해야 하는 마이그레이션 코드 양이 줄어들어요.
| 필요할 때... | 사용할 것 |
|---|---|
| 확장이 이미 설정으로 노출하는 동작을 변경. | 확장의 구성. |
| 새 페이지, 앱 루트 요소, 테마, API 또는 기타 독립 확장 추가. | 확장 블루프린트와 프론트엔드 모듈. |
| 구성과 입력으로 지원하지 않는 방식으로 기존 확장 변경. | 확장 재정의. 재정의는 누락된 커스터마이즈에 집중하도록 유지. |
| 마이그레이션하는 동안 기존 레거시 앱 코드 보존. | 1단계의 호환성 헬퍼, 그런 다음 해당 코드가 마이그레이션되면 제거. |
사용 가능한 블루프린트의 개요를 보려면 관련 프론트엔드 패키지에서 *Blueprint export를 찾아보세요. 재정의를 추가하기 전에 확장의 구성과 입력을 확인하세요. 이러한 메커니즘은 공통 커스터마이즈를 위한 것이며 확장 동작을 교체하는 것을 피해야 해요.
createApp 옵션 마이그레이션하기
많은 createApp 옵션이 확장을 사용하도록 마이그레이션됐어요. 각각은 커스텀 확장을 만드는 데 사용하는 자체 확장 블루프린트를 가져요. 이러한 독립형 확장을 앱에 추가하려면 이를 createFrontendModule에 전달해야 하며, 이는 앱에 설치할 수 있는 기능으로 묶어요. 자세한 내용은 프론트엔드 모듈 섹션을 참고하세요.
예를 들어 앱에 추가하려는 lightTheme 확장이 있다고 가정하면 다음을 사용할 수 있어요.
먼저 @backstage/frontend-plugin-api 패키지를 추가해요.
yarn --cwd packages/app add @backstage/frontend-plugin-api
그런 다음 이렇게 사용할 수 있어요.
import { createFrontendModule } from '@backstage/frontend-plugin-api';const app = createApp({ features: [ // ... createFrontendModule({ pluginId: 'app', extensions: [lightTheme], }), ],});
이 마이그레이션의 일부로 만들어야 할 추가 확장도 extensions 배열에 추가할 수 있어요.
apis
Utility API 팩토리는 이제 대신 확장으로 설치돼요. 기존 팩토리를 ApiBlueprint에 전달하고 앱에 설치하세요. 자세한 내용은 Utility API 구성 섹션을 참고하세요.
예를 들어 다음 apis 구성:
const app = createApp({ apis: [ createApiFactory({ api: scmIntegrationsApiRef, deps: { configApi: configApiRef }, factory: ({ configApi }) => ScmIntegrationsApi.fromConfig(configApi), }), ],});
다음 확장으로 변환할 수 있어요.
import { ApiBlueprint } from '@backstage/frontend-plugin-api';const scmIntegrationsApi = ApiBlueprint.make({ name: 'scm-integrations', params: defineParams => defineParams({ api: scmIntegrationsApiRef, deps: { configApi: configApiRef }, factory: ({ configApi }) => ScmIntegrationsApi.fromConfig(configApi), }),});
그런 다음 'createApp 옵션 마이그레이션' 섹션에서 lightTheme로 했던 것처럼 scmIntegrationsApi를 extension으로 추가하면 돼요.
plugins
플러그인은 이제 대신 features 옵션을 통해 전달돼요.
예를 들어 다음 plugins 구성:
import { homePlugin } from '@backstage/plugin-home';createApp({ // ... plugins: [homePlugin], // ...});
다음 features 구성으로 변환할 수 있어요.
// plugins are now default exported via alpha subpathimport homePlugin from '@backstage/plugin-home/alpha';createApp({ // ... features: [homePlugin], // ...});
기능 검색이 활성화되어 있으면 플러그인은 패키지를 설치한 뒤 수동으로 import할 필요조차 없어요.
in app-config.yaml
app: # Enabling plugin and override features discovery packages: all # ✨
레거시 라우트 제거하기
플러그인 페이지를 새 프론트엔드 시스템으로 마이그레이션한 뒤, 새 확장이 설치되고 올바르게 작동하는지 검증하세요. 그런 다음 앱에서 플러그인의 레거시 라우트, 페이지 import, 관련 라우트 연결을 제거할 수 있어요.
라우트 참조나 외부 라우트 바인딩을 제거하기 전에 다른 플러그인이나 앱 컴포넌트가 여전히 사용하지 않는지 확인하세요.
featureFlags
앱에서 기능 플래그를 선언하는 것은 더 이상 지원되지 않아요. 이 선언들을 해당 플러그인으로 옮기세요.
예를 들어 다음 앱 기능 플래그 구성:
createApp({ // ... featureFlags: [ { pluginId: '', name: 'tech-radar', description: 'Enables the tech radar plugin', }, ], // ...});
다음 플러그인 구성으로 변환할 수 있어요.
import { createFrontendPlugin } from '@backstage/frontend-plugin-api';createFrontendPlugin({ pluginId: 'tech-radar', // ... featureFlags: [{ name: 'tech-radar' }], // ...});
이것은 createApp 옵션의 일부로 features 배열에 추가됩니다.
components
많은 앱 컴포넌트가 이제 대신 createComponentExtension을 사용해 확장으로 설치돼요. 자세한 내용은 앱 컴포넌트 구성 섹션을 참고하세요.
Router 컴포넌트는 이제 createRouterExtension을 사용해 재정의할 수 있는 내장 확장이에요.
로그인 페이지는 이제 SignInPageBlueprint를 사용해 만든 확장으로 설치돼요.
예를 들어 다음 로그인 페이지 구성:
const app = createApp({ components: { SignInPage: props => ( <SignInPage {...props} provider={{ id: 'github-auth-provider', title: 'GitHub', message: 'Sign in using GitHub', apiRef: githubAuthApiRef, }} /> ), },});
다음 확장으로 변환할 수 있어요.
import { SignInPageBlueprint } from '@backstage/plugin-app-react';const signInPage = SignInPageBlueprint.make({ params: { loader: async () => props => ( <SignInPage {...props} provider={{ id: 'github-auth-provider', title: 'GitHub', message: 'Sign in using GitHub', apiRef: githubAuthApiRef, }} /> ), },});
그런 다음 'createApp 옵션 마이그레이션' 섹션에서 lightTheme로 했던 것처럼 signInPage를 extension으로 추가하면 돼요.
themes
테마는 이제 ThemeBlueprint를 사용해 만든 확장으로 설치돼요.
예를 들어 다음 테마 구성:
const app = createApp({ themes: [ { id: 'custom-light', title: 'Light', variant: 'light', Provider: ({ children }) => ( <UnifiedThemeProvider theme={customLightTheme}> {children} </UnifiedThemeProvider> ), }, ],});
다음 확장으로 변환할 수 있어요.
import { ThemeBlueprint } from '@backstage/plugin-app-react';const customLightThemeExtension = ThemeBlueprint.make({ name: 'custom-light', params: { theme: { id: 'custom-light', title: 'Light Theme', variant: 'light', icon: <LightIcon />, Provider: ({ children }) => ( <UnifiedThemeProvider theme={customLightTheme} children={children} /> ), }, },});
그런 다음 'createApp 옵션 마이그레이션' 섹션에서 lightTheme로 했던 것처럼 customLightThemeExtension을 extension으로 추가하면 돼요.
configLoader
구성 로더 API가 약간 변경됐어요. AppConfig 객체 배열에 대한 promise를 반환하는 대신 이제 ConfigApi를 직접 반환해야 해요.
import { ConfigReader } from '@backstage/core-app-api';const app = createApp({ async configLoader() { const appConfigs = await loadAppConfigs(); return appConfigs; return { config: ConfigReader.fromConfigs(appConfigs) }; },});
icons
아이콘은 이제 IconBundleBlueprint를 사용해 새 인스턴스를 만들어 앱에 추가할 수 있는 확장으로 설치돼요.
import { IconBundleBlueprint } from '@backstage/plugin-app-react';const exampleIconBundle = IconBundleBlueprint.make({ name: 'example-bundle', params: { icons: { user: MyOwnUserIcon, }, },});const app = createApp({ features: [ createFrontendModule({ pluginId: 'app', extensions: [exampleIconBundle], }), ],});
bindRoutes
라우트 바인딩은 이 옵션으로 계속 할 수 있지만, 이제 정적 구성을 사용해 라우트를 바인딩하는 기능도 있어요. 자세한 내용은 라우트 바인딩 섹션을 참고하세요.
__experimentalTranslations
번역은 이제 TranslationBlueprint를 사용해 만든 확장으로 설치돼요.
예를 들어 다음 번역 구성:
import { catalogTranslationRef } from '@backstage/plugin-catalog/alpha';createApp({ // ... __experimentalTranslations: { resources: [ createTranslationMessages({ ref: catalogTranslationRef, catalog_page_create_button_title: 'Create Software', }), ], }, // ...});
다음 확장으로 변환할 수 있어요.
import { catalogTranslationRef } from '@backstage/plugin-catalog/alpha';import { createTranslationMessages } from '@backstage/frontend-plugin-api';import { TranslationBlueprint } from '@backstage/plugin-app-react';const catalogTranslations = TranslationBlueprint.make({ name: 'catalog-overrides', params: { resource: createTranslationMessages({ ref: catalogTranslationRef, catalog_page_create_button_title: 'Create Software', }), },});
그런 다음 'createApp 옵션 마이그레이션' 섹션에서 lightTheme로 했던 것처럼 catalogTranslations을 extension으로 추가하면 돼요.
createRoot 컴포넌트 마이그레이션하기
이렇게 하면 convertLegacyAppRoot가 설치한 많은 확장 재정의가 제거되고 앱의 셸이 새 시스템으로 전환돼요. 여기에는 앱의 루트 레이아웃과 요소, 라우터, 사이드바가 포함돼요. 앱이 이전과 같아 보이지 않을 가능성이 크며, 이를 마이그레이션하는 방법에 대한 정보는 아래 사이드바, 앱 루트 요소, 앱 루트 래퍼 섹션을 참고해야 해요.
이 단계가 완료되면 남은 작업은 아직 새 시스템을 지원하지 않는 플러그인을 포함해 앱의 모든 라우트와 엔터티 페이지를 마이그레이션하는 것이에요. 자체 내부 플러그인을 마이그레이션하는 방법은 플러그인 마이그레이션 가이드를 참고하세요. 외부 플러그인의 경우 각 플러그인의 마이그레이션 상태를 확인하고 필요하면 기여해야 해요.
이 마이그레이션이 완료되면 이제 제거할 수 있는 빈 convertLegacyAppRoot(...) 호출이 남게 되고, 앱이 새 시스템으로 완전히 마이그레이션되어야 해요! 🎉
앱 루트 요소 (App Root Elements)
앱 루트 요소는 현재 Root 컴포넌트에 인접하게 렌더링되는 React 요소예요. 예를 들어 이 스니펫에서 AlertDisplay, OAuthRequestDialog, VisitListener는 모두 앱 루트 요소예요.
in packages/app/src/App.tsx
const convertedRootFeatures = convertLegacyAppRoot( <> <AlertDisplay transientTimeoutMs={2500} /> <OAuthRequestDialog /> <AppRouter> <VisitListener /> <Root>{routes}</Root> </AppRouter> </>,);
AlertDisplay와 OAuthRequestDialog는 이미 내장 확장으로 제공되며 VisitListener도 그럴 것이므로, 주변 요소를 모두 제거하고 routes만 유지할 수 있어요.
in packages/app/src/App.tsx
const convertedRootFeatures = convertLegacyAppRoot(routes);
하지만 자체 커스텀 루트 요소가 있다면 이를 대신 앱에 설치하는 확장으로 마이그레이션해야 해요. @backstage/frontend-plugin-api의 AppRootElementBlueprint를 사용해(예: AppRootElementBlueprint.make({ ... })) 그러한 확장을 만들고 앱에 설치하세요.
요소가 이전에 AppRouter의 자식으로 렌더링되었는지 여부는 중요하지 않아요. 모든 새 루트 앱 요소는 앱 라우터의 자식으로 렌더링될 거예요.
앱 루트 래퍼 (App Root Wrappers)
앱 루트 래퍼는 현재 Root 요소의 부모로 렌더링되는 React 요소예요. 예를 들어 이 스니펫에서 CustomAppBarrier는 앱 루트 래퍼예요.
const convertedRootFeatures = convertLegacyAppRoot( <> <AlertDisplay transientTimeoutMs={2500} /> <OAuthRequestDialog /> <AppRouter> <CustomAppBarrier> <Root>{routes}</Root> </CustomAppBarrier> </AppRouter> </>,);
모든 앱 루트 래퍼는 @backstage/plugin-app-react의 AppRootWrapperBlueprint를 사용해 만든 확장으로 마이그레이션해야 해요. 래퍼가 여러 개라면 서로 완전히 독립적이어야 한다는 점에 유의하세요. 즉 React 트리에서 나타나는 순서가 중요하지 않아야 해요. 그렇지 않다면 단일 래퍼로 묶어야 해요.
다음은 CustomAppBarrier를 확장으로 변환하는 예시예요.
createApp({ // ... features: [ createFrontendModule({ pluginId: 'app', extensions: [ AppRootWrapperBlueprint.make({ name: 'custom-app-barrier', params: { Component: CustomAppBarrier, }, }), ], }), ], // ...});
앱 루트 사이드바 (App Root Sidebar)
새 앱은 src/modules/nav/Sidebar.tsx의 NavContentBlueprint를 사용해 만든 내장 사이드바 확장을 갖춰요. 이 블루프린트의 사이드바 기본 구현은 일부 항목을 다른 그룹으로 명시적으로 렌더링하고 나머지 항목은 렌더링해요. Nav 항목은 페이지 구성이나 플러그인 기본값의 메타데이터와 함께 app/routes 아래에 등록된 페이지 확장에서 자동으로 발견돼요.
기존 사이드바를 마이그레이션하려면 app/nav 확장에 대한 재정의를 만들어야 해요. src/modules/nav/ 폴더를 두는 표준 방식을 복사해, module 형태로 app에 설치할 수 있는 확장을 포함시킬 수 있어요.
in packages/app/src/modules/nav/index.ts
import { createFrontendModule } from '@backstage/frontend-plugin-api';import { SidebarContent } from './Sidebar';export const navModule = createFrontendModule({ pluginId: 'app', extensions: [SidebarContent],});
그런 다음 SidebarContent 확장의 실제 구현에서 전체 Sidebar 컴포넌트를 구현하는 다음과 같은 것을 제공할 수 있어요.
컴포넌트는 특정 항목을 커스텀 위치에 배치하기 위한 take(id)와 rest() 메서드가 있는 navItems prop을 받아요. 권장 접근 방식은 navItems.withComponent(...)를 사용해 각 nav 항목을 렌더링할 컴포넌트를 정의한 다음, 반환된 take(id)와 rest() 메서드를 사용해 미리 렌더링된 요소를 직접 가져오는 것이에요. 렌더러에서 가져온 항목은 메인 목록에서도 제거돼요. rest()를 통해 렌더링할 때 키가 자동으로 할당돼요.
in packages/app/src/modules/nav/Sidebar.tsx
import { NavContentBlueprint } from '@backstage/plugin-app-react';export const SidebarContent = NavContentBlueprint.make({ params: { component: ({ navItems }) => { const nav = navItems.withComponent(item => ( <SidebarItem icon={() => item.icon} to={item.href} text={item.title} /> )); return ( <Sidebar> <SidebarLogo /> <SidebarGroup label="Search" icon={<SearchIcon />} to="/search"> <SidebarSearchModal /> </SidebarGroup> <SidebarDivider /> <SidebarGroup label="Menu" icon={<MenuIcon />}> {nav.take('page:catalog')} {nav.take('page:scaffolder')} <SidebarDivider /> <SidebarScrollWrapper> {nav.rest({ sortBy: 'title' })} </SidebarScrollWrapper> </SidebarGroup> </Sidebar> ); }, },});
폐기된 items prop(<SidebarItem {...item} />과 호환되는 평면 목록)은 하위 호환성을 위해 계속 지원돼요. 목록을 자동으로 채우고 싶지 않다면 해당 SidebarGroup의 렌더링을 제거하기만 하면 돼요.
플러그인용 추가 고정 아이콘(예: 전용 그룹의 Search)을 렌더링할 때, 그 페이지가 nav.rest()에도 포함되므로 중복될 수 있다는 것을 알 수 있을 거예요. 남은 목록에서 항목을 제외하려면 nav.rest()를 호출하기 전에 nav.take('page:search')를 호출하세요. 반환 값을 버려도 돼요. 가져온 항목은 rest()에 나타나지 않아요.
페이지 자체를 비활성화하지 않고 사이드바에서 페이지를 숨기려면, 커스텀 사이드바 구현에서 nav.rest()를 호출하기 전에 nav.take('page:...')를 사용하거나, config에서 page:<plugin-id>: false로 페이지 확장을 비활성화하세요.
앱 루트 라우트 (App Root Routes)
최상위 라우트는 AppRouter 컴포넌트 바로 아래 있는 <FlatRoutes> 요소의 라우트예요. 작은 앱에서는 대략 다음과 같을 수 있어요.
in packages/app/src/App.tsx
const routes = ( <FlatRoutes> <Route path="/catalog" element={<CatalogIndexPage />} /> <Route path="/catalog/:namespace/:kind/:name" element={<CatalogEntityPage />} > {entityPage} </Route> <Route path="/create" element={<ScaffolderPage />} /> <Route path="/tech-radar" element={<TechRadarPage width={1500} height={800} />} /> </FlatRoutes>);
이 각 라우트는 새 시스템으로 마이그레이션해야 해요. 원하는 만큼 점진적으로 할 수 있지만, 한 가지 제약은 단일 플러그인의 모든 라우트를 한 번에 마이그레이션해야 한다는 것이에요. 이는 이러한 레거시 라우트에서 발견된 플러그인이 앱에 설치된 플러그인을 재정의하기 때문이에요. 예를 들어 플러그인이 정의한 두 라우트 중 하나만 마이그레이션하면 다른 라우트는 남아 같은 ID의 플러그인을 계속 재정의하며, 부분적이고 대부분 깨진 플러그인이 남게 돼요.
라우트를 마이그레이션하려면 라우트 목록에서 제거하고 대신 앱에 플러그인의 새 버전을 설치해야 해요. 이렇게 하기 전에 플러그인이 새 시스템을 지원하는지 확인해야 해요. 예시로 scaffolder 라우트를 제거해 보겠어요.
in packages/app/src/App.tsx
const routes = ( <FlatRoutes> <Route path="/catalog" element={<CatalogIndexPage />} /> <Route path="/catalog/:namespace/:kind/:name" element={<CatalogEntityPage />} > {entityPage} </Route> <Route path="/create" element={<ScaffolderPage />} /> <Route path="/tech-radar" element={<TechRadarPage width={1500} height={800} />} /> </FlatRoutes>);
앱 기능 검색을 사용한다면 설치 단계는 간단해요. 이미 끝났어요! scaffolder 플러그인의 새 버전이 이미 발견되어 앱에 있었고, 레거시 라우트에서 만든 플러그인이 우선순위가 더 높아 단순히 비활성화되어 있었을 뿐이에요. 기능 검색을 사용하지 않는다면 createApp의 features 옵션을 통해 앱에 새 scaffolder 플러그인을 수동으로 설치해야 해요.
모든 레거시 라우트를 마이그레이션할 때까지 각 라우트에 대해 이 과정을 계속하세요. Route의 자식으로 추가 확장이 설치된 플러그인의 경우 자세한 지침은 플러그인 README를 참고하세요. 엔터티 페이지는 별도 섹션을 참고하세요.
<Redirect> 라우트 마이그레이션하기
이전 라우트에 사용자를 한 경로에서 다른 경로로 전달하는 <Redirect> 요소가 포함되어 있다면, app/routes 확장의 내장 리다이렉트 구성으로 교체할 수 있어요.
app-config.yaml
app: extensions: - app/routes: config: redirects: - from: /old-path to: /new-path
자세한 내용은 app/routes 내장 확장 문서를 참고하세요.
핵심, 내부 및 서드파티 플러그인 마이그레이션하기
Catalog 플러그인의 엔터티 페이지 같은 특정 핵심 플러그인의 경우, 복잡성 때문에 더 점진적인 접근 방식이 필요한 경우가 많으므로 전용 단계별 마이그레이션 가이드를 제공해요.
내부 플러그인 마이그레이션 지침은 플러그인 마이그레이션 가이드를 참고하세요. 외부 플러그인의 경우 마이그레이션 상태를 확인하고 필요하면 기여하세요.
Catalog 엔터티 페이지 (Catalog Entity Page)
엔터티 페이지는 일반적으로 packages/app/src/components/catalog에 정의되고 /catalog/:namespace/:kind/:name 라우트의 자식으로 렌더링돼요. 엔터티 페이지는 일반적으로 꽤 크고 아주 많은 다른 플러그인의 콘텐츠를 가져와요. 엔터티 페이지를 점진적으로 마이그레이션하기 위해 convertLegacyAppRoot 헬퍼에 entityPage 옵션을 제공해요. 이 옵션을 사용하면 convertLegacyAppRoot가 반환하는 기능에 추가되는 확장으로 변환될 엔터티 페이지 앱 요소 트리를 전달할 수 있어요.
엔터티 페이지의 점진적 마이그레이션을 시작하려면 entityPages를 convertLegacyAppRoot 호출에 추가하세요.
in packages/app/src/App.tsx
const convertedRootFeatures = convertLegacyAppRoot(routes);const convertedRootFeatures = convertLegacyAppRoot(routes, { entityPage });
다음으로 catalog 플러그인 자체를 완전히 마이그레이션해야 해요. 앱에는 플러그인의 단일 버전만 설치할 수 있기 때문이에요. 따라서 catalog 플러그인의 새 버전을 사용하려면 이전 버전의 모든 사용을 제거해야 해요. 여기에는 라우트와 엔터티 페이지가 모두 포함돼요. EntityLayout과 EntitySwitch 같은 엔터티 페이지의 구조적 헬퍼는 유지해야 하지만, <CatalogIndexPage/>와 <EntityAboutCard/>, <EntityOrphanWarning/> 같은 엔터티 카드와 콘텐츠 확장은 제거해야 해요.
다음 라우트를 제거하세요.
in packages/app/src/App.tsx
const routes = ( <FlatRoutes> ... <Route path="/catalog" element={<CatalogIndexPage />} /> <Route path="/catalog/:namespace/:kind/:name" element={<CatalogEntityPage />} > {entityPage} </Route> ... </FlatRoutes>);
그리고 변환된 레거시 기능 전에 catalog 플러그인을 명시적으로 설치하세요.
in packages/app/src/App.tsx
import { default as catalogPlugin } from '@backstage/plugin-catalog/alpha';const app = createApp({ features: [convertedOptionsModule, ...convertedRootFeatures], features: [catalogPlugin, convertedOptionsModule, ...convertedRootFeatures],});
기본 <CatalogIndexPage />를 사용하지 않는다면 지금은 커스텀 catalog 페이지를 재정의로 설치하고, 나중에 새 시스템으로 완전히 마이그레이션할 수 있어요.
in packages/app/src/App.tsx
const catalogPluginOverride = catalogPlugin.withOverrides({ extensions: [ catalogPlugin.getExtension('page:catalog').override({ params: { loader: async () => ( <CatalogIndexPage pagination={{ mode: 'offset', limit: 20 }} filters={<>{/* ... */}</>} /> ), }, }), ],});const app = createApp({ features: [ catalogPlugin, catalogPluginOverride, convertedOptionsModule, ...convertedRootFeatures, ],});
이 시점에서 앱을 실행해 catalog 플러그인의 새 버전을 사용하지 않고 있음을 확인할 수 있을 거예요. 엔터티 페이지로 이동하면 페이지 하단에 중복 콘텐츠가 많이 보일 거예요. 이들은 앞서 언급한 catalog 플러그인 자체가 제공하는 엔터티 카드의 중복으로, 제거해야 해요. <EntityAboutCard/>와 <EntityOrphanWarning/> 같은 catalog 플러그인의 카드와 콘텐츠를 제거해 엔터티 페이지를 정리하세요.
정리가 완료되면 이전과 새 프론트엔드 시스템의 혼합으로 구축된 깨끗한 엔터티 페이지가 남게 돼요. 이 시점부터 엔터티 페이지에 콘텐츠를 제공하는 플러그인을 계속 점진적으로 마이그레이션해, 모든 플러그인이 새 시스템으로 완전히 이동하고 entityPage 옵션을 제거할 수 있을 때까지 진행할 수 있어요.
엔터티 페이지 탭에서의 마이그레이션은 탭 콘텐츠를 제공하는 각 플러그인의 EntityLayout.Route를 제거하고, 탭이 자동으로 감지되어 앱에 추가될 플러그인 자체가 만든 EntityContent 확장에서 비롯되도록 하는 것만큼 간단해야 해요.
커스텀 카드와 탭 추가하기
엔터티 페이지에 새 콘텐츠를 추가하려면 개요 카드에는 EntityCardBlueprint를, 탭 콘텐츠에는 EntityContentBlueprint를 사용하세요. 이러한 확장은 catalog 플러그인이 발견하며 레거시 EntityPage 트리에 새 컴포넌트를 추가하는 것을 피해요. EntityContentBlueprint는 group 구성을 통한 탭 콘텐츠 그룹화도 지원해요. 그룹 자체를 정의하는 방법은 그룹, 제목, 아이콘 구성하기를 참고하세요. 엔터티 콘텐츠 예시는 플러그인별 확장을, 카드, 콘텐츠, 탭 구성 세부 사항은 공통 확장 블루프린트를 참고하세요.
convertLegacyAppRoot 폐기하기
convertLegacyAppRoot의 제거를 2단계의 이정표로 취급하세요. 호출이 더 이상 레거시 루트 요소, 라우트 또는 entityPage 옵션을 받지 않을 때 제거할 수 있어요. 호출, import, features 배열의 convertedRootFeatures 항목을 제거하세요. 그런 다음 앱을 시작해 호환성 헬퍼 없이 새 프론트엔드 시스템이 기대하는 라우트, 탐색, 페이지 콘텐츠를 제공하는지 검증하세요.
yarn new에 새 템플릿 활성화하기
새 프론트엔트 시스템으로 전환한 뒤, 만드는 새 플러그인이 새 프론트엔드 시스템을 사용하도록 권장돼요. 이렇게 하면 결국 마이그레이션이 필요할 레거시 플러그인을 즉시 만들지 않게 돼요.
이 관행은 초기에도 꽤 중요해요. 새 프론트엔드 시스템의 관행에 익숙해지는 데 도움이 되기 때문이에요.
yarn new 명령은 이제 프론트엔드 플러그인에 대해 기본적으로 새 프론트엔드 시스템 템플릿을 사용해요. 이 변경 이전에 만들어진 이전 앱이 있다면 @backstage/cli-module-new 패키지를 업데이트해 새 템플릿에 접근할 수 있어요.
문제 해결 (Troubleshooting)
App Visualizer 플러그인 사용하기
문제 해결에 도움이 되도록 app-visualizer 플러그인을 설치할 것을 권장해요.
설치 지침은 App Visualizer를 읽어 주세요.
마이그레이션 중 App Visualizer 사용하기
마이그레이션 중 트리 뷰는 다음에 특히 유용해요.
-
플러그인 감지 검증 —
convertLegacyAppOptions와convertLegacyAppRoot로 앱을 변환한 뒤 트리 뷰를 열어 모든 예상 플러그인이 나타나는지 확인하세요. 변환된 레거시 플러그인은 새 시스템 플러그인과 함께 나타나요. -
중복 확장 발견 — 같은 플러그인이나 페이지에 대한 중복 항목이 보이면, 플러그인이 레거시 변환 헬퍼와 새 시스템을 통해 동시에 로드되고 있을 가능성이 높아요. 아래 중복 카드 문제 해결 섹션을 참고하세요.
-
디버그 출력 공유 — 이슈를 제기할 때 텍스트 뷰를 사용해 확장 트리를 복사해 버그 보고서에 포함하세요. 이렇게 하면 관리자가 앱 구조를 즉시 볼 수 있어요.
페이지 헤더의 "Copy tree as JSON" 버튼을 사용해 전체 확장 트리를 JSON 구조로 내보낼 수도 있으며, 더 깊은 분석을 위해 이슈 보고서에 첨부할 수 있어요.
엔터티 페이지에 중복 카드가 보임
convertLegacyAppRoot와 함께 entityPage 옵션을 사용하면 엔터티 페이지에 중복 카드가 나타나는 것을 볼 수 있어요. 이는 마이그레이션 헬퍼가 기존 엔터티 페이지 컴포넌트에서 카드를 자동으로 추출해 새 시스템에 추가하는 동시에, 새 엔터티 페이지 시스템도 packages/app 패키지에 설치된 모든 플러그인의 카드를 자동으로 포함하기 때문이에요. 결과적으로 같은 카드가 두 번 나타나요. 레거시 컴포넌트에서 한 번, 플러그인에서 한 번이죠.
이를 해결하려면 기존 엔터티 페이지 컴포넌트에서 카드 정의를 제거하기만 하면 돼요. 새 시스템이 설치된 플러그인을 통해 이 카드들을 자동으로 제공하므로 수동 정의가 더 이상 필요하지 않아요.
Error: Invalid element inside FlatRoutes, expected Route but found element of type ...
이는 FlatRoutes 안의 Routes가 Route 요소가 아닌 다른 것을 포함한다는 뜻이에요. 예를 들어 FeatureFlag나 RequirePermissions 요소일 수 있어요. 이들은 현재 새 프론트엔드 시스템에서 지원되지 않아요. 해결 방법으로 이 로직을 App.tsx 라우트에서 플러그인 자체로 내려 보내는 것이 포함되는데, 시스템이 앱에서 사용 가능한 플러그인과 라우트를 순회하고 수집할 수 있으려면 이러한 요소가 더 이상 App.tsx에 존재할 필요가 없기 때문이에요.
이것이 필요한 사용 사례가 있다면 버그 보고서나 커뮤니티 Discord를 통해 연락해 주세요.
다음 단계 (Next Steps)
-
새 프론트엔드 시스템을 사용하는 Backstage 데모 저장소를 탐색해 완전한 앱 참조를 확인하세요.
-
새 시스템에 대한 자세한 내용은 아키텍처 문서를 참고하세요.
-
문제가 발생하면 GitHub 이슈를 확인하거나 Discord에서 물어보세요.