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

커스텀 프로세서

원문 보기 위키 갱신

프로세서는 카탈로그의 처리 루프 중간에 자리 잡고 있습니다. 아직 처리되지 않은 엔티티가 스티칭(stitched) 엔티티가 되기까지 갱신하고 마무리하는 역할을 담당해요.

출처: 문서

본문

프로세서는 카탈로그의 처리 루프 중간에 자리 잡고 있어요. 처리되지 않은 엔티티가 스티칭 엔티티가 되기까지 갱신하고 마무리하는 역할을 담당하며, 그 과정에서 엔티티 트리의 가지를 이루는 새 엔티티를 내보낼 수도 있습니다.

프로세서의 가장 흔한 용도는 엔티티에 어노테이션(annotation)을 풍부하게 추가하거나, 커스텀 종류(kind)의 엔티티를 검증하는 것입니다. 외부 시스템의 엔티티를 수집해야 한다면 엔티티 제공자(entity provider)가 더 적합합니다.

프로세서의 몇 가지 특징입니다.

  • 백엔드의 코드로 인스턴스화해서 카탈로그 빌더(catalog builder)에 전달해요. 보통 각 타입마다 인스턴스 하나가 있어서 카탈로그의 모든 엔티티에 대해 반복 호출됩니다.
  • 호출은 고정된 처리 루프에 의해 구동됩니다. 모든 프로세서가 모든 엔티티에 대해 조건 없이 호출됩니다. 루프 빈도를 조절하는 것 외에는 이 동작을 제어할 수 없는데, 그 빈도도 모든 프로세서에 동일하게 적용됩니다.
  • 엔티티를 직접 삭제할 수는 없어요. 특정 자식 엔티티를 더 이상 내보내지 않으면 그 자식은 고아(orphan)로 표시됩니다.
  • 입력은 처리되지 않은 엔티티이고, 출력은 그 엔티티에 대한 수정 사항에 자식 엔티티를 포함한 선택적인 보조 데이터를 더한 것입니다.

프로세서와 처리 루프

카탈로그 백엔드는 처리 루프를 돌면서 주기적으로 모든 엔티티를 방문해 등록된 프로세서에 통과시킵니다. 각 엔티티는 방문할 때마다 preProcessEntity, validateEntityKind, postProcessEntity라는 프로세서 메서드 전체 체인을 거칩니다. 특정 엔티티에 프로세서를 맞추거나, 다른 프로세서와 독립적으로 실행 빈도를 제어할 수는 없어요.

엔티티가 방문되는 빈도는 processingInterval 구성으로 제어되며, 기본적으로 대략 100~150초마다입니다. 모든 프로세서는 같은 주기로 실행됩니다 — 어떤 프로세서에 다른 빈도를 부여할 방법은 없어요. 스케줄링을 제어해야 한다면 엔티티 제공자가 더 적합합니다.

엔티티가 수집, 처리, 스티칭을 거쳐 어떻게 이동하는지에 대한 전체 설명은 The Life of an Entity를 참조하세요.

프로세서 만들기

Backstage CLI는 프로세서 클래스, 모듈 등록, 테스트를 포함한 완전한 백엔드 모듈을 스캐폴딩해 줍니다.

yarn new --select catalog-processor-module

CLI가 모듈 ID(예: team-name)를 묻습니다. 그러면 plugins 폴더에 백엔드 모듈 패키지가 생성됩니다.

plugins/catalog-backend-module-team-name-processor/├── package.json├── src/│   ├── index.ts│   ├── module.ts│   └── processor/│       ├── TeamNameProcessor.ts│       └── TeamNameProcessor.test.ts

프로세서 클래스

생성된 클래스는 출발점으로 preProcessEntity 메서드를 가진 CatalogProcessor를 구현합니다. 다음은 생성된 구조입니다.

plugins/catalog-backend-module-team-name-processor/src/processor/TeamNameProcessor.ts

import { Config } from '@backstage/config';import { Entity } from '@backstage/catalog-model';import {  CatalogProcessor,  CatalogProcessorEmit,} from '@backstage/plugin-catalog-node';import { LocationSpec } from '@backstage/plugin-catalog-common';export class TeamNameProcessor implements CatalogProcessor {  static fromConfig(_config: Config): TeamNameProcessor {    return new TeamNameProcessor();  }  getProcessorName(): string {    return 'TeamNameProcessor';  }  async preProcessEntity(    entity: Entity,    _location: LocationSpec,    _emit: CatalogProcessorEmit,    _originLocation: LocationSpec,  ): Promise<Entity> {    // Add your enrichment logic here    return entity;  }}

모듈 등록

생성된 module.ts는 프로세서를 카탈로그에 연결합니다.

plugins/catalog-backend-module-team-name-processor/src/module.ts

import {  coreServices,  createBackendModule,} from '@backstage/backend-plugin-api';import { catalogProcessingExtensionPoint } from '@backstage/plugin-catalog-node';import { TeamNameProcessor } from './processor/TeamNameProcessor';export const catalogModuleTeamName = createBackendModule({  pluginId: 'catalog',  moduleId: 'team-name-processor',  register({ registerInit }) {    registerInit({      deps: {        config: coreServices.rootConfig,        catalog: catalogProcessingExtensionPoint,      },      async init({ catalog, config }) {        catalog.addProcessor(TeamNameProcessor.fromConfig(config));      },    });  },});

CLI 템플릿이 백엔드에 모듈을 등록하는 것을 포함해 이 모두를 생성해 줍니다.

packages/backend/src/index.ts

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

프로세서 메서드

CatalogProcessor 인터페이스에는 여러 선택적 메서드가 있습니다. 각각은 처리 파이프라인의 서로 다른 단계에서 호출됩니다. 가장 자주 사용하게 될 두 가지는 preProcessEntity와 validateEntityKind입니다.

preProcessEntity

엔티티가 내보내진 후 검증되기 전에 호출됩니다. 어노테이션 추가나 빠진 필드 채우기처럼 엔티티에 추가 데이터를 풍부하게 하는 데 사용해요. 이 시점에 엔티티는 여전히 불완전할 수 있어요.

async preProcessEntity(  entity: Entity,  location: LocationSpec,  emit: CatalogProcessorEmit,  originLocation: LocationSpec,  cache: CatalogProcessorCache,): Promise<Entity>;

구현하기 가장 흔한 메서드입니다. location 매개변수를 사용해 엔티티의 소스 URL에서 어노테이션을 유도하거나, 엔티티에 이미 있는 정보를 사용해 빠진 필드를 채울 수 있어요.

주의

프로세서는 모든 처리 주기마다 모든 엔티티에 대해 실행됩니다. 프로세서에서 외부 API 호출은 피하세요 — 느린 응답이 전체 처리 루프를 지연시킵니다. 외부 시스템에서 데이터를 가져와야 한다면, 스케줄을 직접 제어하고 오류를 독립적으로 처리할 수 있는 엔티티 제공자를 사용하세요.

preProcessEntity에서 잘 작동하는 몇 가지 패턴입니다.

  • 적용되지 않는 엔티티를 프로세서가 건너뛰도록 종류(kind)로 일찍 필터링해요.
  • 덮어쓰기 전에 어노테이션이나 필드에 이미 값이 있는지 확인해요. 그러면 사용자가 catalog-info.yaml에서 프로세서의 기본값을 재정의할 수 있어요.
  • 프로세서가 추가할 것이 없다면 엔티티를 변경하지 않고 그대로 반환하세요.

다음은 엔티티 이름에서 팀 영역을 추출해 company.com/team-area 레이블을 추가하는 예시입니다. 조직이 payments-checkout-service나 platform-auth-api 같은 네이밍 규칙을 쓴다면, 이 프로세서는 첫 번째 세그먼트를 팀 영역으로 뽑아냅니다.

import { Entity } from '@backstage/catalog-model';import { CatalogProcessor } from '@backstage/plugin-catalog-node';import { LocationSpec } from '@backstage/plugin-catalog-common';const TEAM_AREA_LABEL = 'company.com/team-area';export class TeamAreaProcessor implements CatalogProcessor {  getProcessorName(): string {    return 'TeamAreaProcessor';  }  async preProcessEntity(    entity: Entity,    _location: LocationSpec,  ): Promise<Entity> {    if (entity.kind !== 'Component') {      return entity;    }    if (entity.metadata.labels?.[TEAM_AREA_LABEL]) {      return entity;    }    const parts = entity.metadata.name.split('-');    if (parts.length < 2) {      return entity;    }    const teamArea = parts[0];    return {      ...entity,      metadata: {        ...entity.metadata,        labels: {          ...entity.metadata.labels,          [TEAM_AREA_LABEL]: teamArea,        },      },    };  }}

validateEntityKind

전처리와 기본 검증 후에 호출됩니다. 정의한 커스텀 종류의 엔티티를 검증하는 데 사용해요. 엔티티가 알려진 종류이고 유효하면 true를, 종류가 이 프로세서가 인식하지 못하면 false를 반환합니다. 종류는 인식되지만 엔티티가 유효하지 않으면 오류를 던집니다.

async validateEntityKind(entity: Entity): Promise<boolean>;

예를 들어 spec.type: 'website'인 모든 Component 엔티티가 링크를 하나 이상 포함하도록 강제할 수 있어요.

async validateEntityKind(entity: Entity): Promise<boolean> {  if (entity.kind !== 'Component') {    return false;  }  if (    entity.spec?.type === 'website' &&    (!entity.metadata.links || entity.metadata.links.length === 0)  ) {    throw new Error(      'Component entities with type "website" must include at least one link',    );  }  return true;}

postProcessEntity

엔티티가 검증을 통과한 후에 호출됩니다. 검증된 엔티티를 바탕으로 관계(relation)를 내보내거나, 추가 메타데이터를 붙이거나, 자식 엔티티를 만드는 데 사용해요.

async postProcessEntity(  entity: Entity,  location: LocationSpec,  emit: CatalogProcessorEmit,  cache: CatalogProcessorCache,): Promise<Entity>;

getProcessorName

프로세서의 고유 식별자를 반환합니다. 유일하게 필수인 메서드입니다.

getProcessorName(): string;

getPriority

프로세서가 실행되는 순서를 제어하는 숫자를 반환합니다. 기본 우선순위는 20이며, 값이 낮을수록 먼저 실행됩니다. 프로세서가 다른 프로세서의 수정 사항에 의존하거나, 다른 프로세서가 여러분의 프로세서에 의존할 때 사용해요.

코드를 바꾸지 않고 구성으로 프로세서의 우선순위를 재정의할 수도 있습니다. 자세한 내용은 프로세서 구성 문서를 참조하세요.

getPriority?(): number;

readLocation

로케이션의 내용을 읽고 그로부터 엔티티를 내보냅니다. 이 프로세서가 로케이션을 처리했다면 true를, 다른 프로세서로 넘겨야 한다면 false를 반환합니다.

async readLocation(  location: LocationSpec,  optional: boolean,  emit: CatalogProcessorEmit,  parser: CatalogProcessorParser,  cache: CatalogProcessorCache,): Promise<boolean>;

참고

대부분의 외부 통합에서는 readLocation보다 엔티티 제공자가 더 나은 선택입니다. 엔티티 제공자는 스케줄링, 델타 업데이트, 오류 처리를 완전히 제어하게 해 줍니다. readLocation 메서드는 주로 카탈로그의 내장 프로세서가 사용합니다.

처리 결과 캐싱

프로세서가 정말로 외부 시스템을 호출해야 하고 — 엔티티 제공자를 쓸 수 없다면 — 프로세서 캐시를 사용해 반복 호출을 피하세요. 많은 외부 시스템이 속도 제한에 걸리지 않고 변경 여부를 확인하는 데 쓰는 ETag를 지원하며, CatalogProcessorCache는 주기 사이에 ETag를 저장할 곳을 제공합니다.

이 예시는 프로세서에 ETag 기반 캐싱을 추가하는 방법을 보여줍니다.

import { Entity } from '@backstage/catalog-model';import {  CatalogProcessor,  CatalogProcessorCache,  CatalogProcessorEmit,} from '@backstage/plugin-catalog-node';import { LocationSpec } from '@backstage/plugin-catalog-common';const CACHE_KEY = 'v1';type CacheItem = {  etag: string;  team: string;};export class TeamAnnotationProcessor implements CatalogProcessor {  getProcessorName() {    return 'TeamAnnotationProcessor';  }  async preProcessEntity(    entity: Entity,    location: LocationSpec,    _emit: CatalogProcessorEmit,    _originLocation: LocationSpec,    cache: CatalogProcessorCache,  ): Promise<Entity> {    if (entity.kind !== 'Component' || location.type !== 'url') {      return entity;    }    const cacheItem = await cache.get<CacheItem>(CACHE_KEY);    try {      const response = await fetch('https://teams.example.com/api/lookup', {        headers: cacheItem?.etag ? { 'If-None-Match': cacheItem.etag } : {},      });      if (response.status === 304 && cacheItem) {        return this.applyTeam(entity, cacheItem.team);      }      const etag = response.headers.get('etag');      const { team } = await response.json();      if (etag && team) {        await cache.set<CacheItem>(CACHE_KEY, { etag, team });      }      return team ? this.applyTeam(entity, team) : entity;    } catch {      if (cacheItem) {        return this.applyTeam(entity, cacheItem.team);      }      return entity;    }  }  private applyTeam(entity: Entity, team: string): Entity {    return {      ...entity,      metadata: {        ...entity.metadata,        annotations: {          ...entity.metadata.annotations,          'company.com/team': team,        },      },    };  }}

프로세서 구현이나 CacheItem 타입을 바꾸면 CACHE_KEY 버전을 올리세요. 이렇게 하면 코드 변경 후 프로세서가 오래된 캐시 데이터를 쓰지 않게 됩니다.

다양한 메타데이터 파일 형식 지원

catalog-info.yaml이 아닌 형식의 기존 메타데이터 파일이 있다면, 그 파일을 즉석에서 Entity 형식으로 변환하는 커스텀 파서를 구현할 수 있어요. 이는 GithubEntityProvider 같은 내장 제공자와 통합되므로, 이런 파일을 위한 별도 제공자가 필요하지 않습니다.

파서를 담을 백엔드 모듈을 스캐폴딩하는 것으로 시작해요.

yarn new --select backend-plugin-module --option pluginId=catalog

이렇게 하면 모듈 보일러플레이트가 생깁니다. 그런 다음 파서 자체를 구현하고 catalogModelExtensionPoint로 등록하면 됩니다.

기존 형식이 이렇게 생겼다고 가정해 보아요.

id: my-servicetype: serviceauthor: [email protected]

이것을 유효한 Entity로 변환하는 파서가 필요합니다.

plugins/catalog-backend-module-custom-parser/src/customEntityDataParser.ts

import {  CatalogProcessorParser,  processingResult,  LocationSpec,} from '@backstage/plugin-catalog-node';import yaml from 'yaml';import {  Entity,  stringifyLocationRef,  ANNOTATION_ORIGIN_LOCATION,  ANNOTATION_LOCATION,} from '@backstage/catalog-model';const makeEntityFromCustomFormat = (  component: { id: string; type: string; author: string },  location: LocationSpec,): Entity => {  return {    apiVersion: 'backstage.io/v1alpha1',    kind: 'Component',    metadata: {      name: component.id,      namespace: 'default',      annotations: {        [ANNOTATION_LOCATION]: `${location.type}:${location.target}`,        [ANNOTATION_ORIGIN_LOCATION]: `${location.type}:${location.target}`,      },    },    spec: {      type: component.type,      owner: component.author,      lifecycle: 'experimental',    },  };};export const customEntityDataParser: CatalogProcessorParser = async function* ({  data,  location,}) {  let documents: yaml.Document.Parsed[];  try {    documents = yaml.parseAllDocuments(data.toString('utf8')).filter(d => d);  } catch (e) {    const loc = stringifyLocationRef(location);    yield processingResult.generalError(      location,      `Failed to parse YAML at ${loc}, ${e}`,    );    return;  }  for (const document of documents) {    if (document.errors?.length) {      const loc = stringifyLocationRef(location);      yield processingResult.generalError(        location,        `YAML error at ${loc}, ${document.errors[0]}`,      );    } else {      const json = document.toJSON();      if (json && typeof json === 'object') {        if (json.apiVersion) {          yield processingResult.entity(location, json as Entity);        } else {          yield processingResult.entity(            location,            makeEntityFromCustomFormat(json, location),          );        }      } else if (json !== null) {        yield processingResult.generalError(          location,          `Expected object at root, got ${typeof json}`,        );      }    }  }};

catalogModelExtensionPoint를 사용해 백엔드 모듈을 통해 파서를 등록합니다.

plugins/catalog-backend-module-custom-parser/src/module.ts

import { createBackendModule } from '@backstage/backend-plugin-api';import { catalogModelExtensionPoint } from '@backstage/plugin-catalog-node/alpha';import { customEntityDataParser } from './customEntityDataParser';export const catalogModuleCustomDataParser = createBackendModule({  pluginId: 'catalog',  moduleId: 'custom-data-parser',  register({ registerInit }) {    registerInit({      deps: {        catalogModel: catalogModelExtensionPoint,      },      async init({ catalogModel }) {        catalogModel.setEntityDataParser(customEntityDataParser);      },    });  },});

템플릿은 백엔드에 모듈을 등록하는 일도 대신 해 줍니다.

packages/backend/src/index.ts

backend.add(import('@backstage/plugin-catalog-backend'));backend.add(  import('@internal/backstage-plugin-catalog-backend-module-custom-parser'),);

더 알아보기 (Learn more)