본문 바로가기
WIKI 기술 지식 베이스

프론트엔드 확장 재정의

원문 보기 위키 갱신

프론트엔드 확장 재정의 (Frontend Extension Overrides)

프론트엔드 시스템의 중요한 커스터마이즈 지점은 기존 확장을 재정의하는 기능이에요. 확장 로직을 약간 조정하는 것부터 확장을 커스텀 구현으로 완전히 교체하는 것까지 다양한 용도로 사용할 수 있어요. 확장은 스스로 구성 가능하게 만드는 것이 권장되지만, 원하는 동작을 얻으려면 확장을 재정의해야 하는 상황도 많아요. 플러그인을 구축할 때 확장 재정의 기능을 염두에 두어야 하며, 플러그인의 많은 부분을 다시 구현할 필요 없이 더 깊은 커스터마이즈를 가능하게 하는 강력한 도구가 될 수 있어요.

출처: 문서

본문

소개 (Introduction)

프론트엔드 시스템의 중요한 커스터마이즈 지점은 기존 확장을 재정의하는 기능이에요. 확장 로직을 약간 조정하는 것부터 확장을 커스텀 구현으로 완전히 교체하는 것까지 다양한 용도로 사용할 수 있어요. 확장은 스스로 구성 가능하게 만드는 것이 권장되지만, 원하는 동작을 얻으려면 확장을 재정의해야 하는 상황도 많아요. 플러그인을 구축할 때 확장 재정의 기능을 염두에 두어야 하며, 플러그인의 많은 부분을 다시 구현할 필요 없이 더 깊은 커스터마이즈를 가능하게 하는 강력한 도구가 될 수 있어요.

일반적으로 대부분의 기능은 사용자가 공통 목표를 달성하기 위해 확장 재정의를 사용하지 않아도 되도록 좋은 수준의 커스터마이즈가 내장되어 있어야 해요. 잘 작성된 기능은 종종 구성 설정이 있거나, 적용 가능한 곳에서 확장성에 확장 입력(extension inputs)을 사용해요. 그 예시가 검색 플러그인이에요. 검색 플러그인은 결과가 어떻게 보이는지 조정하려고 결과 페이지를 통째로 교체하는 대신 결과 렌더러를 입력으로 제공할 수 있게 해줘요. 도입자(adopter)들은 확장 재정의의 필요성과 규모를 줄이기 위해 가능하면 이를 활용해야 해요.

확장 재정의는 기존 if predicate를 교체하거나 제거할 수도 있어요. 이는 .override(...)를 통한 직접 확장 재정의와 plugin.withOverrides(...)를 통한 플러그인 수준 재정의 모두에 적용돼요. 프론트엔드 모듈은 동일한 확장 재정의 메커니즘을 사용해 재정의된 확장의 조건을 조정하거나 지울 수 있어요.

확장 재정의하기 (Overriding an extension)

createExtension으로 만든 모든 확장에는 override 메서드가 있으며, 확장 블루프린트에서 만든 확장도 포함돼요. override 메서드는 새 확장을 만들며 기존 확장을 변경(mutate)하지 않아요. 이 새 확장은 기존 확장 옆에 설치되면 우선권을 가지며 기존 확장을 재정의하도록 만들어져요. override 메서드는 새 확장 인스턴스를 만들긴 하지만, 기본 템플릿에서 여러 새 확장을 만드는 방법으로 의도된 것은 아니에요. 그런 사용 사례에는 확장 블루프린트를 사용하고 싶을 거예요.

다음은 확장에서 .override(...) 메서드를 호출하는 예시예요.

const myOverrideExtension = myExtension.override({  factory(originalFactory) {    return originalFactory();  },});

이 재정의는 무작동(no-op)이에요. 확장의 동작을 바꾸지 않고 단순히 원본 확장 factory의 출력을 전달해요. 확장 블루프린트에 익숙하다면, 원본 factory 함수에 접근할 수 있는 이 factory 재정의 패턴을 알아볼 거예요. 사실 유일한 차이점은 원본 factory에 매개변수를 전달할 필요가 없다는 점이에요. 첫 번째 매개변수는 이제 선택적인 factory 컨텍스트 재정의가 되며, 이에 대해서는 다음 섹션에서 각 재정의 패턴을 자세히 다룰 거예요.

원본 factory 출력 재정의하기

확장을 재정의할 때 기존 출력을 전달하거나 자신의 것으로 교체할 수 있어요. 재정의 factory는 확장 factory가 선언된 각 출력에 대해 단일 값만 반환할 수 있다는 규칙에 대한 예외가 있어요. 대신 각 확장 데이터 참조에 대해 제공된 마지막 값을 항상 사용해요. 이렇게 하면 원본 factory의 출력을 전달하면서도 자신의 것을 제공할 수 있어요. 예를 들어:

const myOverrideExtension = myExtension.override({  factory(originalFactory) {    return [      ...originalFactory(),      coreExtensionData.reactElement(<h1>Hello Override</h1>),    ];  },});

출력을 장식하기 위해 원본 factory의 개별 데이터 값에 접근할 수도 있어요.

const myOverrideExtension = myExtension.override({  factory(originalFactory) {    const originalOutput = originalFactory();    const originalElement = originalOutput.get(coreExtensionData.reactElement);    return [      ...originalOutput,      coreExtensionData.reactElement(        <details>          <summary>Show original element</summary>          {originalElement}        </details>,      ),    ];  },});

확장 factory가 제너레이터 함수로 선언될 수 있는 것처럼 재정의 factory도 마찬가지예요. 제너레이터 함수를 사용하면 위의 첫 번째 예시를 다음과 같이 작성할 수 있어요.

const myOverrideExtension = myExtension.override({  *factory(originalFactory) {    yield* originalFactory();    yield coreExtensionData.reactElement(<h1>Hello Override</h1>);  },});

yield* 표현식에 주목하세요. 이는 제공된 iterable의 모든 값을 제너레이터로 전달하며, 여기서는 원본 factory 출력이에요.

블루프린트 매개변수 재정의하기

원래 확장 블루프린트에서 만든 확장을 재정의한다면, 원래 블루프린트에 제공된 매개변수를 재정의할 수 있어요. 이는 .override의 옵션으로 직접 하거나, 재정의 factory에서 원본 factory를 호출할 때 할 수 있어요. 제공된 매개변수 재정의는 블루프린트에서 확장을 만들 때 제공된 기존 매개변수와 병합돼요.

예를 들어 PageBlueprint에서 만든 다음 확장을 생각해 보세요.

const exampleExtension = PageBlueprint.make({  params: {    loader: () =>      import('./components/ExamplePage').then(m => <m.ExamplePage />),    path: '/example',  },});

params 옵션을 통해 즉시 매개변수를 재정의할 수 있어요.

const overrideExtension = exampleExtension.override({  params: {    loader: () =>      import('./components/OverridePage').then(m => <m.OverridePage />),  },});

재정의 factory에서 원본 factory를 호출할 때 매개변수 재정의를 전달하는 것도 가능해요.

const overrideExtension = exampleExtension.override({  factory(originalFactory) {    return originalFactory({      params: {        loader: () =>          import('./components/OverridePage').then(m => <m.OverridePage />),      },    });  },});

선언된 출력 재정의하기

확장을 재정의할 때 새 출력 선언을 제공할 수 있어요. 이는 기존 출력 선언을 교체하므로, 원본 출력 중 일부를 전달하려면 다시 선언해야 해요. 다음 예시는 확장을 재정의하고 출력 선언을 교체하는 방법을 보여줘요.

// Original extensionconst exampleExtension = createExtension({  name: 'example',  output: [coreExtensionData.reactElement],  factory: () => [coreExtensionData.reactElement(<h1>Example</h1>)],});// Override extension, with additional outputsconst overrideExtension = exampleExtension.override({  output: [coreExtensionData.reactElement, coreExtensionData.routePath],  factory(originalFactory) {    return [...originalFactory(), coreExtensionData.routePath('/example')];  },});

출력 선언을 재정의할 때 원본 출력을 포함할 필요는 없어요. 다만 이제는 원본 factory의 출력을 직접 전달할 수 없고, 확장이 부착된 입력의 계약(contract)을 여전히 준수해야 한다는 점을 기억하세요.

선언된 입력 재정의하기

확장을 재정의할 때 새 입력 선언도 제공할 수 있어요. 원하는 만큼 새 입력을 정의할 수 있지만, 원본 확장이 선언한 기존 입력은 재정의할 수 없어요. 새 입력은 기존 입력과 병합되어 재정의 factory가 둘 다에 접근할 수 있게 해줘요. 다음 예시는 확장을 재정의하고 새 입력 선언을 추가하는 방법을 보여줘요.

const myOverrideExtension = myExtension.override({  inputs: {    myOverrideInput: createExtensionInput([coreExtensionData.reactElement]),  },  factory(originalFactory, { inputs }) {    const originalOutput = originalFactory();    const originalElement = originalOutput.get(coreExtensionData.reactElement);    return [      ...originalOutput,      coreExtensionData.reactElement(        <div>          <h1>Original element</h1>          {originalElement}          <h1>Additional inputs</h1>          <ul>            {inputs.myOverrideInput.map(i => (              <li key={i.node.spec.id}>                {i.get(coreExtensionData.reactElement)}              </li>            ))}          </ul>        </div>,      ),    ];  },});

구성 스키마 재정의하기

구성 스키마 재정의는 선언된 입력 재정의와 매우 유사하게 작동해요. 기존 구성과 병합될 새 구성 필드를 정의할 수 있지만, 기존 필드를 다시 선언할 수는 없어요. 다음 예시는 확장을 재정의하고 새 구성 필드를 추가하는 방법을 보여줘요.

import { z } from 'zod';const exampleExtension = createExtension({  configSchema: {    foo: z.string(),  },  // ...});const overrideExtension = exampleExtension.override({  configSchema: {    bar: z.string(),  },  factory(originalFactory, { config }) {    //    console.log(`foo=${config.foo} bar=${config.bar}`);    return originalFactory();  },});

원본 factory 구성 컨텍스트 재정의하기

지금까지의 모든 예시에서 originalFactory 콜백을 인자 없이 호출했어요. 하지만 원본 factory의 첫 번째 매개변수를 사용해 원본 factory의 factory 컨텍스트 일부를 재정의할 수 있어요. 제공된 구성을 재정의하거나 입력을 어떤 방식으로든 바꾸고 싶을 때 유용해요. 블루프린트용 factory를 구현한다면 재정의 factory 컨텍스트는 원본 factory 함수의 두 번째 매개변수가 된다는 점에 유의하세요. 다음은 원본 factory의 구성을 재정의하는 예시예요.

import { z } from 'zod';const exampleExtension = createExtension({  name: 'example',  configSchema: {    layout: z.enum(['grid', 'list']).optional(),  },  output: [coreExtensionData.reactElement],  factory: ({ config }) => [    coreExtensionData.reactElement(      <MyExtension layout={config.layout ?? 'list'} />,    ),  ],});const overrideExtension = exampleExtension.override({  factory(originalFactory, { config }) {    return originalFactory({      config: {        // Switch default layout from 'list' to 'grid'        layout: config.layout ?? 'grid',      },    });  },});

위 예시에서 볼 수 있듯이 originalFactory 호출에서 config 속성을 사용해 새 구성 객체를 제공할 수 있어요. config 속성을 제공하면 원래 원본 factory에 제공됐을 원본 구성 객체를 완전히 재정의해요. 이 객체는 구성 스키마의 출력 유형을 준수해야 한다는 점에 유의하세요. 직관적이지 않을 수 있는데, 이 시점에는 구성이 이미 Zod로 처리되고 검증되었기 때문에 스키마의 기본값 같은 것들이 다시 적용되지 않기 때문이에요.

원본 factory 입력 컨텍스트 재정의하기

구성 외에도 원본 factory에 제공되는 입력을 재정의할 수 있어요. 구성 재정의와 마찬가지로 원본 입력을 새 것으로 완전히 교체하지만, 재정의 factory가 받는 입력은 전달할 수 있어요.

각 입력은 두 가지 방법 중 하나로 재정의할 수 있으며, 이 둘은 결합할 수 없어요. 원본 입력을 전달(또는 전달하지 않음)할 수 있고, 선택적으로 개별 항목을 필터링하거나 재정렬할 수 있어요. 또는 입력에 새 값을 제공해 원본 입력을 교체할 수 있어요. 새 값을 제공할 때는 모든 기존 입력을 전달해야 하고 입력을 재정렬할 수 없으며, 기존 입력을 전달할 때는 새 값을 제공할 수 없어요.

다음 예시는 각 입력 항목에 제공된 값을 재정의하는 방법을 보여줘요.

const exampleExtension = createExtension({  inputs: {    items: createExtensionInput([coreExtensionData.reactElement]),  },  // ...});const overrideExtension = exampleExtension.override({  factory(originalFactory, { inputs }) {    return originalFactory({      inputs: {        items: inputs.items.map(i => [          coreExtensionData.reactElement(            <ItemWrapper>{i.get(coreExtensionData.reactElement)}</ItemWrapper>,          ),        ]),      },    });  },});

대조적으로 다음 예시는 원본 입력을 다른 순서로 전달하는 방법을 보여줘요.

const exampleExtension = createExtension({  inputs: {    content: createExtensionInput([coreExtensionData.reactElement], {      singleton: true,      optional: true,    }),    items: createExtensionInput([coreExtensionData.reactElement]),  },  // ...});const overrideExtension = exampleExtension.override({  factory(originalFactory, { inputs }) {    return originalFactory({      inputs: {        // We can also skip forwarding the original input, if we want to remove it        content: inputs.content,        // Sort items input by their extension ID        items: inputs.items.toSorted((a, b) =>          a.node.spec.id.localeCompare(b.node.spec.id),        ),      },    });  },});

앱에 재정의 확장 설치하기

Backstage 앱에 확장 재정의를 설치할 때는 플러그인의 확장을 재정의하거나 추가할 때마다 plugin.withOverrides를 사용해야 해요. 플러그인 재정의에 대한 자세한 내용은 플러그인 재정의 섹션을 참고하세요.

이 두 옵션 중 하나를 사용할 때 반드시 확장 .override(...) 메서드를 사용해 재정의를 만들 필요는 없다는 점에 유의하세요. createExtension이나 블루프린트로 완전히 새로운 확장이거나, 같은 kind, namespace, name을 사용해 같은 확장 ID를 만들어 기존 확장을 재정의하는 새 확장을 만들 수도 있어요.

프론트엔드 모듈 만들기

다음 예시는 검색 플러그인의 검색 페이지를 재정의하는 프론트엔드 모듈을 만드는 방법을 보여줘요.

import {  createPageExtension,  createFrontendModule,} from '@backstage/frontend-plugin-api';const customSearchPage = PageBlueprint.make({  params: {    path: '/search',    loader: () =>      import('./CustomSearchPage').then(m => <m.CustomSearchPage />),  },});export default createFrontendModule({  pluginId: 'search',  extensions: [customSearchPage],});

위 코드가 @internal/search-page 패키지에 있다고 가정하면, 앱에서 다음과 같이 설치할 수 있어요.

packages/app/src/App.tsx

import { createApp } from '@backstage/frontend-defaults';import searchPageModule from '@internal/search-page';const app = createApp({  features: [searchPageModule],});export default app.createRoot();

프론트엔드 모듈을 만들 때는 pluginId를 정의해야 하며, 모듈이 로드되려면 해당 플러그인도 설치되어 있어야 해요.

프론트엔드 모듈은 if 옵션도 지원해요. 플러그인과 마찬가지로 해당 predicate는 모듈에서 오는 모든 확장에 적용되며, 논리적 AND로 확장 수준의 if predicate와 결합돼요. 기능 플래그나 권한을 기반으로 전체 재정의 패키지를 활성화하거나 비활성화하고 싶을 때 유용해요.

더 알아보기 (Learn more)