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

증분 엔티티 제공자

원문 보기 위키 갱신

페이징을 지원하지만 메모리에 모두 담기에는 너무 큰 데이터 소스가 있다면, 증분 엔티티 제공자(incremental entity provider)가 데이터를 여러 "버스트(burst)"에 걸쳐 페이지 단위로 수집해 줍니다.

출처: 문서

본문

페이징을 지원하지만 메모리에 모두 담기에는 너무 큰 데이터 소스가 있다면, 증분 엔티티 제공자가 데이터를 여러 "버스트"에 걸쳐 페이지 단위로 수집해요. 이 방식은 전체 데이터셋을 한 번에 메모리에 보관하지 않고도 삭제와 업데이트를 처리해서, 소스 크기와 관계없이 메모리 사용량을 예측 가능하게 유지해 줍니다.

증분 엔티티 제공자를 왜 사용할까요?

기본 엔티티 제공자는 delta와 full 두 종류의 변형(mutation)을 제공해요. 둘 다 매우 큰 데이터 소스(10만 개 이상 레코드)를 다룰 때 한계가 있습니다.

  • full 변형은 diff를 계산하기 위해 모든 엔티티를 메모리에 올려야 해요. 규모가 커지면 프로세스 메모리가 고갈될 수 있어요.
  • delta 변형은 메모리 문제를 피하지만, 이벤트를 절대 놓치지 않는다는 보장은 없어요. DELETE 이벤트가 도착했을 때 Backstage 인스턴스가 내려가 있으면 카탈로그가 일관되지 않은 상태가 될 수 있어요.
  • delta 변형을 쓰더라도 초기 엔티티 목록을 만드는 방법은 여전히 필요해요. 예를 들어 웹훅으로 GitHub의 모든 리포지토리를 수집한다면 시작 집합은 여전히 필요하죠.
  • full 변형으로 한 번에 엄청난 수의 엔티티를 커밋하면 처리 큐가 넘쳐서 다른 제공자의 처리를 지연시켜요.

증분 엔티티 제공자는 delta 변형과 mark-and-sweep 메커니즘을 조합해 이 모든 문제를 해결합니다.

동작 방식

증분 엔티티 제공자는 단일 full 변형 대신 일련의 짧은 버스트를 수행해요. 각 버스트가 끝날 때마다:

  • 받은 각 엔티티를 데이터베이스에 표시(mark)해요.
  • 엔티티를 delta 변형으로 커밋해요.

제공자는 다음 버스트로 진행하기 전에 설정된 간격만큼 기다립니다.

소스에 더 이상 결과가 없으면, 제공자는 이전에 커밋한 모든 엔티티를 현재 수집 주기 동안 표시된 엔티티와 비교해요. 표시되지 않은 엔티티는 삭제됩니다. 그런 다음 제공자는 설정된 간격만큼 휴식한 뒤 새 주기를 시작해요.

이 접근 방식에는 여러 장점이 있습니다.

  • 수집 지연 시간 감소 — 각 버스트가 커밋한 엔티티는 전체 데이터셋을 읽기 전부터 카탈로그가 처리할 수 있어요.
  • 처리 파이프라인에 안정적인 부하 — 버스트 사이의 휴지(pause)가 파이프라인에 압박을 주지 않고 안정되게 만듭니다.
  • 내장된 재시도와 백오프(back-off) — 실패한 버스트는 설정 가능한 백오프 간격으로 자동 재시도됩니다.
  • 고아(orphan) 방지 — 삭제된 엔티티는 메모리 사용량이 낮은 mark-and-sweep 메커니즘을 통해 제거됩니다.

요구 사항

증분 엔티티 제공자는 페이징된 결과를 제공하는 데이터 소스를 위해 설계되었어요. 각 버스트는 하나 이상의 페이지를 처리하려고 시도합니다. 플러그인은 설정된 버스트 길이 안에서 가능한 한 많은 페이지를 가져오며, 매 반복마다 다음 페이지의 커서를 받을 것으로 기대합니다.

각 반복은 서로 다른 레플리카에서 일어날 수 있으며, 여기에 몇 가지 영향이 따릅니다.

  • 커서는 JSON으로 직렬화 가능해야 해요(대부분의 RESTful 또는 GraphQL 기반 API에서는 문제되지 않아요).
  • 클라이언트는 상태가 없어야(stateless) 합니다 — 반복마다 클라이언트를 처음부터 만들어 여러 레플리카에 처리를 분산할 수 있게요.
  • 추가 데이터를 처리할 수 있도록 Postgres에 충분한 저장 공간이 있어야 해요.

설치

Backstage 루트 디렉터리에서 패키지를 설치해요.

yarn --cwd packages/backend add @backstage/plugin-catalog-backend-module-incremental-ingestion

백엔드에 모듈을 추가해요.

packages/backend/src/index.ts

const backend = createBackend();backend.add(  import('@backstage/plugin-catalog-backend-module-incremental-ingestion'),);backend.start();

증분 엔티티 제공자 작성

증분 엔티티 제공자에는 세 가지 메서드가 필요합니다.

  • getProviderName — 다른 제공자와 충돌하지 않도록 하는 고유한 이름.
  • around — 반복 과정을 감싸서 설정과 정리(예: API 클라이언트 생성)를 처리.
  • next — 커서를 전진시켜 특정 페이지의 엔티티를 가져옴.

전체 인터페이스는 다음과 같습니다.

interface IncrementalEntityProvider<TCursor, TContext> {  getProviderName(): string;  next(    context: TContext,    cursor?: TCursor,  ): Promise<EntityIteratorResult<TCursor>>;  around(burst: (context: TContext) => Promise<void>): Promise<void>;}

이 워크스루는 가상의 페이징 API와 통신하는 증분 엔티티 제공자를 만듭니다.

타입 정의

API 클라이언트, 그 응답, 그리고 페이지네이션 상태를 추적하는 커서에 대한 타입부터 시작해요.

interface MyApiClient {  getServices(page: number): MyPaginatedResults<Service>;}interface MyPaginatedResults<T> {  items: T[];  totalPages: number;}interface Service {  name: string;}

클래스 설정

커서와 컨텍스트 타입으로 제공자 클래스를 만들어요. 커서는 페이지네이션 상태를 담고, 컨텍스트는 반복 중 필요한 모든 것(예: API 클라이언트)을 전달합니다.

import { IncrementalEntityProvider } from '@backstage/plugin-catalog-backend-module-incremental-ingestion';interface Cursor {  page: number;}interface Context {  apiClient: MyApiClient;}export class MyIncrementalEntityProvider  implements IncrementalEntityProvider<Cursor, Context>{  private readonly token: string;  private readonly mySource: string;  constructor(token: string, mySource: string) {    this.token = token;    this.mySource = mySource;  }  getProviderName() {    return `MyIncrementalEntityProvider`;  }}

around 구현

around 메서드는 페이지 반복 주기의 전후에 실행됩니다. 설정(클라이언트 생성, 연결 획득)과 정리에 사용해요.

async around(burst: (context: Context) => Promise<void>): Promise<void> {  const apiClient = new MyApiClient(this.token);  await burst({ apiClient });  // Teardown logic goes here if needed}

next 구현

next 메서드는 커서를 사용해 데이터의 한 페이지를 가져오고, 결과를 엔티티로 변환한 뒤, 다음 커서 위치와 done 플래그를 반환합니다.

import {  ANNOTATION_LOCATION,  ANNOTATION_ORIGIN_LOCATION,} from '@backstage/catalog-model';async next(  context: Context,  cursor: Cursor = { page: 1 },): Promise<EntityIteratorResult<Cursor>> {  const { apiClient } = context;  const location = `${this.getProviderName()}:${this.mySource}`;  const data = await apiClient.getServices(cursor.page);  const nextPage = cursor.page + 1;  const done = nextPage > data.totalPages;  const entities = data.items.map(item => ({    entity: {      apiVersion: 'backstage.io/v1beta1',      kind: 'Component',      metadata: {        name: item.name,        annotations: {          [ANNOTATION_LOCATION]: location,          [ANNOTATION_ORIGIN_LOCATION]: location,        },      },      spec: {        type: 'service',        lifecycle: 'production',        owner: 'unknown',      },    },  }));  return {    done,    entities,    cursor: { page: nextPage },  };}

증분 엔티티 제공자 설치

설치 단계를 마친 뒤 제공자용 백엔드 모듈을 만들어요. 이 예시는 packages/backend/src/extensions/catalogCustomIncrementalIngestion.ts에 둡니다.

packages/backend/src/extensions/catalogCustomIncrementalIngestion.ts

import {  coreServices,  createBackendModule,} from '@backstage/backend-plugin-api';import { incrementalIngestionProvidersExtensionPoint } from '@backstage/plugin-catalog-backend-module-incremental-ingestion';export const catalogModuleCustomIncrementalIngestionProvider =  createBackendModule({    pluginId: 'catalog',    moduleId: 'custom-incremental-ingestion-provider',    register(env) {      env.registerInit({        deps: {          incrementalBuilder: incrementalIngestionProvidersExtensionPoint,          config: coreServices.rootConfig,        },        async init({ incrementalBuilder, config }) {          const token = config.getString('myApiClient.token');          const myEntityProvider = new MyIncrementalEntityProvider(            token,            'production',          );          incrementalBuilder.addProvider({            provider: myEntityProvider,            options: {              burstLength: { seconds: 3 },              burstInterval: { seconds: 3 },              restLength: { days: 1 },              backoff: [                { seconds: 5 },                { seconds: 30 },                { minutes: 10 },                { hours: 3 },              ],              rejectRemovalsAbovePercentage: 5,              rejectEmptySourceCollections: true,            },          });        },      });    },  });

options 객체는 증분 수집 엔진이 어떻게 동작하는지 제어합니다.

  • burstLength — 단일 버스트로 페이지 읽기를 얼마나 오래 실행할 수 있는지. 짧게 유지해야 해요.
  • burstInterval — 버스트 사이의 휴지.
  • restLength — 처음부터 다시 수집하기 전에 얼마나 기다릴지.
  • backoff — 오류 후 재시도 지연을 순서대로 적용.
  • rejectRemovalsAbovePercentage — 한 주기에서 주어진 비율보다 많은 엔티티를 제거하지 못하게 막아요. 부분 결과를 반환하는 불안정한 업스트림 소스로부터 보호해 줍니다.
  • rejectEmptySourceCollections — 엔티티가 0개인 성공 응답을 거부해서, 이 소스의 모든 카탈로그 항목이 실수로 삭제되는 것을 방지합니다.

모듈을 packages/backend/src/index.ts에 추가해요.

packages/backend/src/index.ts

import { catalogModuleCustomIncrementalIngestionProvider } from './extensions/catalogCustomIncrementalIngestion';const backend = createBackend();backend.add(  import('@backstage/plugin-catalog-backend-module-incremental-ingestion'),);backend.add(catalogModuleCustomIncrementalIngestionProvider);backend.start();

관리 라우트

증분 수집 플러그인은 런타임에 제공자를 관리하기 위한 REST 엔드포인트를 노출합니다.

| | Method | Path | Permission | Description | GET | /api/catalog/incremental/health | catalog.ingestion.read | Check the health of all incremental providers. | GET | /api/catalog/incremental/providers | catalog.ingestion.read | List all known incremental entity providers. | GET | /api/catalog/incremental/providers/:provider | catalog.ingestion.read | Check the status of a specific provider (resting, interstitial, etc.). | POST | /api/catalog/incremental/providers/:provider/trigger | catalog.ingestion.manage | Trigger the provider's next action immediately. | POST | /api/catalog/incremental/providers/:provider/start | catalog.ingestion.manage | Stop the current ingestion cycle and start a new one immediately. | POST | /api/catalog/incremental/providers/:provider/cancel | catalog.ingestion.manage | Stop the current ingestion cycle and start a new one in 24 hours. | DELETE | /api/catalog/incremental/providers/:provider | catalog.ingestion.manage | Remove all records for the provider and restart it in 24 hours. | GET | /api/catalog/incremental/providers/:provider/marks | catalog.ingestion.read | Retrieve ingestion marks for the current cycle. | DELETE | /api/catalog/incremental/providers/:provider/marks | catalog.ingestion.manage | Remove all ingestion marks for the current cycle. | POST | /api/catalog/incremental/cleanup | catalog.ingestion.manage | Remove all records for all providers and restart them in 24 hours. |

모든 경우에 :provider는 getProviderName이 반환하는 이름입니다.

@backstage/plugin-catalog-common/alpha 패키지는 권한 정책에서 쓸 수 있도록 catalogIngestionReadPermission과 catalogIngestionManagePermission을 export해요. 카탈로그 백엔드는 이러한 권한을 자동으로 등록합니다. Backstage는 기본적으로 기본 권한을 허용하므로, 이러한 작업을 제한하려면 정책을 구성해야 합니다.

주의

cleanup 엔드포인트는 모든 제공자의 레코드를 제거합니다. 주의해서 사용하세요 — 제공자가 신속하게 다시 수집하지 않으면 고아 엔티티가 생길 수 있어요.

오류 처리

around나 next 메서드가 오류를 던지면 증분 엔티티 제공자가 오류를 기록하고 다음 백오프 간격 후에 재시도합니다. 설정된 백오프 단계를 계속 반복하며 재시도하다가 모든 재시도를 소진하면 현재 수집 주기를 취소하고 처음부터 다시 시작합니다. 직접 재시도 로직을 구현할 필요는 없어요.

더 자세한 기술 내용은 증분 수집 플러그인 README을 참조하세요.

더 알아보기 (Learn more)