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

사용자 지정 엔티티 공급자

원문 보기 위키 갱신

사용자 지정 엔티티 공급자 (Custom entity providers)

카탈로그의 가장자리에 위치해 원격 시스템과 동기화하는 나만의 엔티티 공급자를 만드는 방법을 설명하는 워크스루 문서예요.

출처: 문서

본문

엔티티 공급자는 카탈로그의 가장 맨 가장자리에 위치해요. 이들은 처리 트리의 루트를 형성하는 엔티티의 원래 소스예요. 동적 location 저장소 API와 app-config.yaml에서 지정하는 정적 location은 내장 공급자의 두 가지 예시예요.

엔티티 공급자의 몇 가지 정의적인 특징은 다음과 같아요.

  • 백엔드의 코드로 인스턴스화하고 카탈로그 빌더에 전달해요. 흔히 원격 시스템당 하나의 공급자 인스턴스가 있어요.

  • 이들을 적극적으로 실행하는 책임이 있을 수 있어요. 일부 공급자는 주기적으로 트리거해야 하며, 다른 것들은 웹훅이나 pub/sub 이벤트에 반응해요.

  • 그들의 타이밍은 처리 루프와 분리되어 있어요. 한 공급자는 30초마다 실행될 수 있고, 다른 공급자는 들어오는 모든 웹훅 호출마다 실행될 수 있어요.

  • 자신의 엔티티 집합에 대해 세부적인 업데이트를 수행할 수 있어요. 전체 집합을 교체하거나 개별 추가·제거를 발행할 수 있어요.

  • 그들의 출력은 처리되지 않은(unprocessed) 엔티티 집합이며, 이는 최종적으로 매끄럽게 결합된(stitched) 엔티티가 되기 전에 처리 루프를 거쳐요.

  • 엔티티를 제거하면 그 루트 아래에 있는 프로세서가 생성한 엔티티의 전체 하위 트리(subtree)도 함께 제거돼요.

엔티티 공급자 만들기

시작하는 가장 빠른 방법은 Backstage CLI를 사용하는 것이에요. CLI가 공급자 클래스, 구성 파싱, 스케줄링, 테스트가 포함된 완전한 백엔드 모듈을 스캐폴딩해요.

yarn new --select catalog-provider-module

CLI는 모듈 ID(예: frobs)를 프롬프트로 물어봐요. 그러면 plugins 폴더에 다음 구조의 백엔드 모듈 패키지가 생성돼요.

plugins/catalog-backend-module-frobs-provider/├── config.d.ts├── package.json├── src/│   ├── index.ts│   ├── module.ts│   └── provider/│       ├── FrobsProvider.ts│       ├── FrobsProvider.test.ts│       └── readProviderConfigs.ts

공급자 클래스

생성된 공급자 클래스는 EntityProvider 인터페이스를 구현하며 스케줄링, 연결 관리, 변경(mutation)을 처리해요. 핵심 구조는 다음과 같아요(모듈 ID frobs를 예시로 사용).

plugins/catalog-backend-module-frobs-provider/src/provider/FrobsProvider.ts

import { Config } from '@backstage/config';import {  DeferredEntity,  EntityProvider,  EntityProviderConnection,} from '@backstage/plugin-catalog-node';import { randomUUID } from 'node:crypto';import { readProviderConfigs } from './readProviderConfigs';import {  LoggerService,  SchedulerService,  SchedulerServiceTaskRunner,} from '@backstage/backend-plugin-api';export class FrobsProvider implements EntityProvider {  static fromConfig(    configRoot: Config,    options: { logger: LoggerService; scheduler: SchedulerService },  ): FrobsProvider[] {    return readProviderConfigs(configRoot).map(providerConfig => {      return new FrobsProvider({        id: providerConfig.id,        target: providerConfig.target,        logger: options.logger,        taskRunner: options.scheduler.createScheduledTaskRunner(          providerConfig.schedule,        ),      });    });  }  readonly #id: string;  readonly #target: string;  readonly #logger: LoggerService;  readonly #taskRunner: SchedulerServiceTaskRunner;  constructor(options: {    id: string;    target: string;    logger: LoggerService;    taskRunner: SchedulerServiceTaskRunner;  }) {    this.#id = options.id;    this.#target = options.target;    this.#logger = options.logger;    this.#taskRunner = options.taskRunner;  }  getProviderName() {    return `FrobsProvider:${this.#id}`;  }  async connect(connection: EntityProviderConnection) {    const id = `${this.getProviderName()}:refresh`;    await this.#taskRunner.run({      id,      fn: async () => {        const logger = this.#logger.child({          taskId: id,          taskInstanceId: randomUUID(),        });        try {          const entities = await this.read({ logger });          logger.info(`Read ${entities.length} entities`);          await connection.applyMutation({            type: 'full',            entities,          });        } catch (error) {          logger.error(`Refresh failed`, error);        }      },    });  }  async read(options: { logger: LoggerService }): Promise<DeferredEntity[]> {    const { logger } = options;    logger.info(`Reading Frobs from ${this.#target}`);    // Fetch and return entities from the remote system    return [];  }}

fromConfig 정적 메서드는 app-config.yaml의 모든 구성된 공급자 인스턴스를 읽고 구성 블록당 하나의 공급자를 생성하며, 각각 자체 스케줄을 가져요. getProviderName 메서드는 모든 공급자 사이에서 고유하고 시간이 지나도 안정적이어야 하는 이름을 반환해요 — 카탈로그는 이 이름을 사용해 어떤 "버킷(bucket)"의 엔티티가 이 공급자에 속하는지 식별해요.

카탈로그 엔진이 시작되면 등록된 모든 공급자에서 connect를 호출해요. 생성된 코드는 이 훅을 사용해 read 메서드를 호출하는 순환 작업을 예약해요. read 메서드는 외부 시스템에서 데이터를 가져와 DeferredEntity 객체로 반환하는 로직을 추가하는 곳이에요.

각 DeferredEntity는 backstage.io/managed-by-location과 backstage.io/managed-by-origin-location 어노테이션을 포함해야 해요. 이들이 없으면 엔티티는 카탈로그에 나타나지 않고 경고 로그를 생성해요. 어떤 값을 사용해야 하는지에 대한 지침은 잘 알려진 어노테이션 문서를 참고하세요.

모듈 등록

생성된 module.ts는 백엔드 모듈 시스템을 사용해 공급자를 카탈로그에 연결해요.

plugins/catalog-backend-module-frobs-provider/src/module.ts

import {  coreServices,  createBackendModule,} from '@backstage/backend-plugin-api';import { catalogProcessingExtensionPoint } from '@backstage/plugin-catalog-node';import { FrobsProvider } from './provider/FrobsProvider';export const catalogModuleFrobs = createBackendModule({  moduleId: 'frobs-provider',  pluginId: 'catalog',  register({ registerInit }) {    registerInit({      deps: {        logger: coreServices.logger,        config: coreServices.rootConfig,        scheduler: coreServices.scheduler,        processing: catalogProcessingExtensionPoint,      },      async init({ logger, scheduler, config, processing }) {        processing.addEntityProvider(          FrobsProvider.fromConfig(config, {            logger,            scheduler,          }),        );      },    });  },});

CLI 템플릿은 이 모든 것을 생성하며, 백엔드에서 모듈을 등록하는 것도 포함해요.

packages/backend/src/index.ts

const backend = createBackend();backend.add(import('@backstage/plugin-catalog-backend'));backend.add(import('./plugins/catalog-backend-module-frobs-provider'));backend.start();

구성 (Configuration)

생성된 readProviderConfigs.ts는 app-config.yaml에서 구성을 파싱해요. 템플릿은 단일 공급자 인스턴스와 여러 명명된 인스턴스를 모두 지원해요.

app-config.yaml

catalog:  providers:    frobsProvider:      target: https://frobs.example.com/api/v2      schedule:        frequency: { minutes: 30 }        timeout: { minutes: 3 }

서로 다른 환경을 가리키는 여러 인스턴스의 경우:

app-config.yaml

catalog:  providers:    frobsProvider:      production:        target: https://frobs.example.com/api/v2        schedule:          frequency: { minutes: 30 }          timeout: { minutes: 3 }      staging:        target: https://frobs-staging.example.com/api/v2        schedule:          frequency: { hours: 1 }          timeout: { minutes: 3 }

스케줄을 지정하지 않으면 공급자는 3분 타임아웃으로 30분마다 실행되는 것으로 기본 설정돼요. 생성된 config.d.ts 파일을 사용해 구성에 스키마를 추가할 수도 있어요.

공급자 변경 (Provider mutations)

각 공급자 인스턴스는 getProviderName이 반환하는 안정적인 이름으로 식별되는 자신만의 엔티티 "버킷"을 가져요. 공급자가 "변경(mutation)"을 발행할 때마다 그 버킷의 내용이 변경돼요. 버킷 밖의 것은 접근할 수 없어요.

변경에는 두 가지 유형이 있어요.

전체 변경(Full mutation) — 버킷 전체 내용을 교체해요. 카탈로그는 내부적으로 이를 효율적인 델타로 구현하는데, 실행 사이의 차이가 일반적으로 작기 때문이에요. 이것은 생성된 템플릿의 기본 전략이며, 원격 소스에서 모든 엔티티를 배치로 가져올 수 있을 때 잘 동작해요.

await connection.applyMutation({  type: 'full',  entities: entities.map(entity => ({    entity,    locationKey: `frobs-provider:${this.#id}`,  })),});

델타 변경(Delta mutation) — 버킷에서 특정 엔티티를 upsert하거나 삭제해요. 전체 스냅샷이 아니라 개별 변경 알림을 받는 이벤트 기반 공급자에 더 잘 맞아요.

await connection.applyMutation({  type: 'delta',  added: newEntities.map(entity => ({    entity,    locationKey: `frobs-provider:${this.#id}`,  })),  removed: removedEntities.map(entity => ({    entity,    locationKey: `frobs-provider:${this.#id}`,  })),});

두 경우 모두 카탈로그는 엔티티를 처리되지 않은 것으로 취급해요. 데이터베이스에 들어간 후 등록된 프로세서가 이를 최종적이고 처리되며 결합된 엔티티로 변환해요.

Location 키

공급자가 내보내는 모든 엔티티는 locationKey를 가질 수 있어요. 이는 충돌 해결 키로, 엔티티가 발생할 수 있는 각 location에 대해 고유해야 하는 불투명한 문자열이에요. 공급자와 그 인스턴스 속성을 명확히 식별하는 문자열로 설정하세요.

두 엔티티 정의가 동일한 엔티티 참조(kind, namespace, name)를 공유할 때 충돌이 발생해요. location 키는 다음 규칙을 사용해 충돌을 해결해요.

  • 기존 엔티티에 location 키가 없으면 새 엔티티가 이겨요.

  • 기존 엔티티에 location 키가 있으면 location 키가 일치할 때만 새 엔티티가 이겨요.

  • 엔티티가 아직 존재하지 않으면 카탈로그가 제공된 location 키로 삽입해요.

이것은 다른 공급자에 속한 엔티티의 "불량(rogue)" 탈취를 방지해요.

예시: User 엔티티 공급자

이 예시는 HR 시스템에서 user 엔티티를 동기화하고 Slack 프로필 링크로 보강하는 공급자를 보여줘요.

전체 user 엔티티 공급자 예시

import {  ANNOTATION_LOCATION,  ANNOTATION_ORIGIN_LOCATION,  UserEntity,} from '@backstage/catalog-model';import {  EntityProvider,  EntityProviderConnection,} from '@backstage/plugin-catalog-node';import { kebabCase } from 'lodash';interface Staff {  displayName: string;  slackUserId: string;  jobTitle: string;  photoUrl: string;  address: string;  email: string;}export class UserEntityProvider implements EntityProvider {  private readonly getStaffUrl: string;  private readonly slackTeam: string;  private connection?: EntityProviderConnection;  constructor(options: { getStaffUrl: string; slackTeam: string }) {    this.getStaffUrl = options.getStaffUrl;    this.slackTeam = options.slackTeam;  }  getProviderName(): string {    return 'user-entity-provider';  }  async connect(connection: EntityProviderConnection): Promise<void> {    this.connection = connection;  }  async run(): Promise<void> {    if (!this.connection) {      throw new Error('Not initialized');    }    const response = await fetch(this.getStaffUrl);    const staff: Staff[] = await response.json();    const userResources: UserEntity[] = staff.map(user => {      const links =        user.slackUserId && user.slackUserId.length > 0          ? [              {                url: `slack://user?team=${this.slackTeam}&id=${user.slackUserId}`,                title: 'Slack',                icon: 'message',              },            ]          : undefined;      return {        kind: 'User',        apiVersion: 'backstage.io/v1alpha1',        metadata: {          annotations: {            [ANNOTATION_LOCATION]: `hr-user:${this.getStaffUrl}`,            [ANNOTATION_ORIGIN_LOCATION]: `hr-user:${this.getStaffUrl}`,          },          links,          name: kebabCase(user.displayName),          title: user.displayName,        },        spec: {          profile: {            displayName: user.displayName,            email: user.email,            picture: user.photoUrl,          },          memberOf: [],        },      };    });    await this.connection.applyMutation({      type: 'full',      entities: userResources.map(entity => ({        entity,        locationKey: `hr-user:${this.getStaffUrl}`,      })),    });  }}

더 알아보기 (Learn more)