백엔드 서비스
백엔드 서비스는 모든 백엔드 플러그인과 모듈에 사용 가능한 공유 기능을 제공해요.
출처: 문서
본문
백엔드 서비스는 모든 백엔드 플러그인과 모듈에 사용 가능한 공유 기능을 제공해요. 서비스 인터페이스를 나타내는 타입을 포함한 서비스 참조를 통해 제공되며, 이는 Backstage 프론트엔드 시스템의 Utility API가 작동하는 방식과 유사해요. 플러그인이나 모듈에서 서비스를 사용하려면 서비스 참조를 사용해 해당 서비스의 구현을 요청해요.
서비스를 둘러싼 시스템은 서비스 인터페이스와 그 구현 사이에 어느 정도의 간접화(indirection) 수준을 제공하기 위해 존재해요. 이것은 의존성 주입의 구현이며, 각 백엔드 인스턴스가 의존성 주입 컨테이너예요. 각 서비스의 구현은 각 서비스 인스턴스가 만들어지는 방식을 캡슐화하는 서비스 팩토리에서 제공돼요.
서비스 인터페이스
서비스 인터페이스는 어떤 TypeScript 타입이든 될 수 있지만, 여러 메서드를 가진 객체 인터페이스로 만드는 것이 가장 좋아요. 인터페이스 설계에 대한 일반 지침이 적용돼요: 적지만 강력한 메서드를 가진 단순하고 간결하게 유지하세요. 개별 메서드가 발전할 수 있는 방식을 제한하지 않도록 주의하세요. 종종 옵션 객체를 유일한 매개변수로 하는 메서드와 결과 객체를 반환하는 것이 좋아요. 메서드가 async여야 할지 확실하지 않은 이유가 있으면 항상 async로 만드세요. 예를 들어 최소 인터페이스는 종종 다음 패턴을 사용해야 해요:
export interface FooService { foo(options: FooOptions): Promise<FooResult>;}
서비스 참조
서비스 인터페이스를 정의한 후에는 createServiceRef 함수를 사용해 서비스 참조를 만들어야 해요. 이는 사용자가 서비스와 상호 작용할 수 있도록 내보내는 ServiceRef 인스턴스를 만들어요. 개념적으로 프론트엔드 시스템의 ApiRef와 매우 유사해요. 예를 들어:
import { createServiceRef } from '@backstage/backend-plugin-api';export interface FooService { foo(options: FooOptions): Promise<FooResult>;}export const fooServiceRef = createServiceRef<FooService>({ id: 'example.foo', // the owner of this service is in this case the 'example' plugin});
위에서 만든 fooServiceRef는 내보내야 하며, FooService 인터페이스에 대한 의존성을 선언하고 런타임에 그 구현을 받는 데 사용할 수 있어요.
서비스 참조를 만들 때 ID를 부여해야 해요. 이 ID는 전역적으로 고유해야 하며 일반적으로 '<pluginId>.<serviceName>' 형식이어야 해요. 서비스 관련 명명 패턴은 명명 패턴 페이지를 참고하세요.
명명에 관한 참고 사항: 프론트엔드와 백엔드 시스템은 상당히 유사한 개념에 대해 의도적으로 "APIs"와 "Services"라는 별도의 이름을 사용해요. 이는 문서와 토론에서, 그리고 코드에서도 두 개념 간의 혼동을 피하기 위해서예요. 두 시스템은 상당히 유사하지만 완전히 같지는 않으며 서로 바꿔 쓸 수 없어요.
서비스 팩토리
서비스 참조를 통해 서비스 인터페이스에 의존할 수 있으려면 물론 그 구체적인 구현을 만드는 방법도 필요해요. 그 로직을 캡슐화하기 위해 서비스 팩토리를 사용하며, 이는 서비스 인스턴스가 만들어지는 방식과 구현을 위해 어떤 다른 서비스에 의존하는지 둘 다 정의해요.
서비스 팩토리는 다양한 소스에서 올 수 있어요. 내장 서비스 팩토리, 다른 패키지에서 가져올 수 있는 외부 팩토리, 직접 만들 수도 있어요. 특정 서비스 팩토리는 의존성 주입 컨테이너 역할을 하는 각 백엔드 인스턴스 안에 설치돼요. 주어진 백엔드 인스턴스에 대해 각 서비스마다 지정된 서비스 팩토리가 하나만 있을 수 있어요.
서비스 팩토리를 정의하려면 createServiceFactory를 사용해요:
import { createServiceFactory } from '@backstage/backend-plugin-api';class DefaultFooService implements FooService { async foo(options: FooOptions): Promise<FooResult> { // ... }}export const fooServiceFactory = createServiceFactory({ service: fooServiceRef, deps: { bar: barServiceRef }, factory({ bar }) { return new DefaultFooService(bar); },});
서비스 팩토리를 만들려면 팩토리가 인스턴스를 만들 service에 대한 참조, 팩토리가 의존하는 다른 서비스를 나열하는 deps 객체, 서비스 인스턴스를 만들기 위해 호출될 factory 함수를 제공해야 해요. 백엔드 시스템은 deps 객체에 나열된 각 의존성의 서비스 인스턴스를 포함한 객체로 factory 함수를 호출해요. 서비스 구현이 다른 서비스에 의존하지 않으면 deps는 빈 객체({})로 남겨져요. factory 함수는 서비스 인터페이스를 구현하는 값을 반환해야 해요.
서비스 인스턴스 생성을 비동기로 해야 한다면 factory 함수를 async로 만들 수 있어요. 예를 들어:
export const fooServiceFactory = createServiceFactory({ service: fooServiceRef, deps: {}, async factory() { const foo = new DefaultFooService(); await foo.init(); return foo; },});
서비스 팩토리 간의 순환 의존성은 허용되지 않는다는 점에 유의하세요. 이는 런타임에 검증되며, 충돌을 감지하면 백엔드 인스턴스가 시작을 거부해요. 마찬가지로 서비스 팩토리가 등록된 어떤 서비스 팩토리도 제공하지 않는 서비스에 의존하면 백엔드도 시작에 실패해요.
핵심 서비스
백엔드 시스템은 백엔드의 주요 기능 구현을 돕는 여러 핵심 서비스 정의를 제공하면서, 로깅, 데이터베이스 접근, 작업 스케줄링 같은 공통 관심사에 대한 유틸리티 집합도 제공해요. 이러한 핵심 서비스는 createBackend로 만든 백엔드 인스턴스에 항상 존재하며, 필요하면 모두 커스텀 구현으로 재정의할 수 있어요.
모든 핵심 서비스의 서비스 참조는 @backstage/backend-plugin-api 패키지에서 사용 가능한 자체 coreServices 객체를 통해 내보내져요. 예를 들어 로깅 서비스는 coreServices.logger로 접근할 수 있어요.
어떤 핵심 서비스가 있고 어떻게 사용하는지에 대해 더 자세히 읽으려면 핵심 서비스 섹션을 참고하세요.
서비스 범위
기본적으로 서비스는 개별 플러그인 범위로 지정되며, 각 플러그인에 대해 별도의 서비스 인스턴스가 만들어진다는 뜻이에요. 예를 들어 위 fooFactory에서 서비스에 의존하는 모든 플러그인에 대해 DefaultFooService의 별도 인스턴스가 만들어져요. 이는 개별 플러그인에 맞게 서비스 구현을 조정할 수 있게 하고 플러그인 간에 어느 정도의 분리를 보장해요.
서비스 범위는 createServiceRef 호출 중에 정의되며, 플러그인 범위가 기본값이에요. 따라서 위 fooServiceRef의 정의는 다음과 동등해요:
export const fooServiceRef = createServiceRef<FooService>({ scope: 'plugin', id: 'example.foo',});
서비스에는 'plugin'과 'root' 두 가지 범위만 가능해요.
루트 범위 서비스
서비스가 루트 범위 서비스로 정의되면 팩토리가 만든 구현이 모든 플러그인과 서비스에 걸쳐 공유돼요. 루트 범위 서비스의 또 다른 차별점은 어떤 플러그인이 의존하는지와 무관하게 항상 초기화된다는 점이에요. 이는 개별 플러그인에 특정되지 않는 백엔드 전반의 관심사를 구현하는 데 적합하게 만들어요.
루트 범위 서비스 사용에는 제한이 있는데, 그 구현은 다른 루트 범위 서비스에만 의존할 수 있다는 점이에요. 반면 플러그인 범위 서비스는 루트 및 플러그인 범위 서비스 모두에 의존할 수 있어요. 이 제한 때문에 루트 범위 서비스를 정의하는 주요 이유 중 하나는 다른 루트 범위 서비스가 의존할 수 있게 하기 위해서예요.
이러한 제한과 루트 범위 서비스의 특정 사용 사례 때문에 루트 범위 서비스는 플러그인 범위 서비스보다 드문 경향이 있어요. 일반적으로 언급된 두 사용 사례 중 하나를 구현하는 경우가 아니라면 서비스를 플러그인 범위로 정의하는 것을 선호해야 해요.
일부 서비스는 플러그인과 루트 범위 서비스 정의 쌍으로 제공돼요. 예를 들어 rootLogger 서비스는 루트 범위 서비스이고 logger 서비스는 플러그인 범위 서비스예요. rootLogger 서비스는 주요 로깅 구현을 담고, logger 서비스는 단순히 rootLogger 위에 플러그인별 라벨을 추가해요. 이 분리는 다른 루트 범위 서비스도 로깅 서비스에 접근할 수 있도록 존재하지만, 분리를 피할 수 있다면 항상 선호돼요. 이 패턴을 구현하게 된다면 루트 범위 서비스에는 root 접두사를 붙여야 하며, 이는 플러그인 범위 서비스 사용을 권장하기 위해서예요.
플러그인 메타데이터
플러그인 범위 서비스는 백엔드 시스템이 제공하며 재정의할 수 없는 특수 서비스인 플러그인 메타데이터 서비스에 접근할 수 있어요. 플러그인 메타데이터 서비스는 서비스 인스턴스가 생성되고 있는 플러그인에 대한 정보를 제공해요. 그 자체가 플러그인 범위 서비스이며 coreServices.pluginMetadata 참조를 통해 다른 서비스처럼 의존할 수 있어요.
플러그인 메타데이터 서비스는 서비스에 대한 모든 플러그인별 사용자 지정의 기반이에요. 예를 들어 플러그인 범위 logger 서비스의 기본 구현은 플러그인 메타데이터 서비스를 사용해 모든 로그 메시지에 플러그인 ID를 필드로 추가해요:
export const loggerServiceFactory = createServiceFactory({ service: coreServices.logger, deps: { rootLogger: coreServices.rootLogger, pluginMetadata: coreServices.pluginMetadata, }, factory({ rootLogger, pluginMetadata }) { return rootLogger.child({ plugin: pluginMetadata.getId() }); },});
서비스 팩토리를 위한 루트 컨텍스트
일부 서비스는 서비스의 모든 인스턴스에 걸쳐 공유되는 컨텍스트를 가지는 것이 유용할 수 있어요. 이는 물론 루트 범위 서비스에는 적용되지 않는데, 루트 범위 서비스는 항상 단일 인스턴스만 가지기 때문이에요. 루트 컨텍스트는 예를 들어 데이터베이스 접근을 위한 공통 연결 풀, 개발용으로 생성된 시크릿, 또는 다른 종류의 공유 시설을 공유하는 데 사용할 수 있어요. 프로덕션에서 플러그인 간 상태를 공유하는 데 이것을 사용해서는 안 된다는 점에 유의하세요. 플러그인 격리 규칙을 위반하게 되기 때문이에요.
루트 컨텍스트는 createRootContext 옵션을 전달해 서비스 팩토리의 일부로 정의돼요:
export const fooServiceFactory = createServiceFactory({ service: fooServiceRef, deps: { rootLogger: coreServices.rootLogger, bar: barServiceRef }, createRootContext({ rootLogger }) { return new FooRootContext(rootLogger); } factory({ bar }, ctx) { return ctx.forPlugin(bar) },});
createRootContext 함수가 반환하는 whatever 값은 공유되고 각 factory 함수 호출의 두 번째 인자로 전달돼요. 이렇게 하면 각 플러그인 인스턴스 생성에 사용되는 공유 컨텍스트를 만들 수 있어요. factory 함수와 달리 createRootContext 함수는 루트 범위 서비스만 의존성으로 받지만, factory 함수처럼 async일 수도 있어요.
기본 서비스 팩토리
표준 Backstage 백엔드 인스턴스에는 기본적으로 설치되는 서비스가 많아요. 이러한 서비스는 항상 존재할 것으로 예상할 수 있으며, 사용 가능하게 만들기 위해 추가 단계를 취할 필요가 없어요. 이는 외부 패키지에서 가져오는 서비스에는 반드시 해당하지 않는데, 플러그인이나 모듈 사용자가 백엔드에 그 서비스의 팩토리를 설치하지 않았을 수 있기 때문이에요. 플러그인 통합자에게 의존하는 서비스의 팩토리를 설치하도록 요청하지 않기 위해 서비스에 기본 팩토리를 정의하는 것이 가능해요.
기본 서비스 팩토리는 createServiceRef에 defaultFactory 옵션을 전달해 서비스 참조의 일부로 정의돼요:
import { createServiceFactory, createServiceRef,} from '@backstage/backend-plugin-api';export const fooServiceRef = createServiceRef<FooService>({ id: 'example.foo', defaultFactory: async service => createServiceFactory({ service, deps: {}, factory() { return new DefaultFooService(); }, }),});
서비스 팩토리를 만들 때 fooServiceRef를 사용하지 않고 default factory 콜백의 service 매개변수를 사용한다는 점에 유의하세요. 이는 fooServiceRef를 직접 사용하려 하면 순환 참조가 발생하기 때문이에요.
서비스가 기본 팩토리를 정의하면 백엔드에 명시적 팩토리가 등록되어 있지 않을 때 그 팩토리가 사용돼요. 이렇게 하면 서비스 사용자가 설치 여부를 걱정하지 않고 서비스를 직접 가져와 사용할 수 있어요. 다른 플러그인이나 모듈에서 사용하기 위해 내보내는 모든 서비스에 항상 기본 팩토리를 정의하는 것이 권장돼요.
서비스에 기본 팩토리를 정의하면 런타임에 중복 구현이 생길 수 있어요. 이는 팩토리의 공유 루트 컨텍스트와 서비스의 플러그인별 인스턴스 모두에 적용돼요. 패키지 의존성 버전 범위가 정확히 일치하지 않아 같은 패키지가 중복 설치될 수 있기 때문이에요. 이는 같은 서비스를 사용하는 두 개의 서로 다른 플러그인에서뿐 아니라 플러그인과 그 모듈 간에도 발생할 수 있어요. 이 시나리오에서 서비스가 깨진다면 기본 팩토리를 정의하지 말고, 대신 서비스 사용자가 백엔드 인스턴스에 팩토리를 명시적으로 설치하도록 요구해야 해요.
서비스 팩토리 사용자 지정
서비스 팩토리를 선언할 때 구현 자체의 구성 요소(building blocks)를 내보내고 싶을 수도 있어요. 이는 정적 구성으로 가능한 것 이상으로 코드를 통해 서비스 구현을 추가로 사용자 지정할 수 있게 하되, 서비스를 처음부터 다시 구현할 필요 없게 하기 위해서예요. 예를 들어 예시 DefaultFooService 클래스를 내보내면서, 발전시키기 쉽도록 구성을 정적 create 팩토리 메서드로 옮길 수 있어요:
export class DefaultFooService { static create(options: { transform?: (foo: string) => string }) { return new DefaultFooService(options.transform ?? (foo => foo)); } private constructor(private readonly transform: (foo: string) => string) {} foo(foo: string): string { return this.transform(foo); }}
DefaultFooService를 내보냄으로써 이제 고급 사용자가 서비스 구현을 비교적 간단하게 사용자 지정할 수 있어요. 그러려면 제공된 구현을 사용하는 자체 서비스 팩토리를 정의할 수 있어요:
export const customFooServiceFactory = createServiceFactory({ service: fooServiceRef, deps: {}, factory() { return DefaultFooService.create({ transform: foo => foo.toUpperCase(), }); },});
이를 통해 정적 구성으로 표현할 수 없는 서비스 구현에 대한 더 고급 옵션을 제공할 수 있어요. 또한 서비스 구현 사용자에게 의존성 주입을 통해 다른 서비스에 접근할 수 있게 해주며, 이는 사용자 지정에 유용할 수 있어요.
멀티턴
기본적으로 서비스 참조는 서비스의 싱글턴 인스턴스를 가리켜요. 이는 새 서비스 팩토리가 이 참조를 사용하면 이전 것을 재정의함을 의미해요. 이것이 가장 흔한 사용 사례이지만, 어떤 경우에는 같은 서비스의 여러 인스턴스를 원할 수도 있어요.
일부 서비스의 경우 기능을 재정의하는 대신 확장하는 것이 바람직해요. 예를 들어 일부 서비스는 특정 이벤트를 처리하는 많은 핸들러를 가질 수 있고, 이전 것을 재정의하는 대신 새 핸들러를 추가하고 싶을 수 있어요. 이 경우 서비스 참조를 만들 때 multiton 옵션을 사용할 수 있어요:
// example-service-ref.tsimport { createServiceRef } from '@backstage/backend-plugin-api';export interface FooService { foo(options: FooOptions): Promise<FooResult>;}export const fooServiceRef = createServiceRef<FooService>({ id: 'example.foo', multiton: true, // this service ref will be an array of instances});
이 serviceRef를 팩토리에 의존성으로 추가하면 팩토리는 단일 인스턴스 대신 인스턴스 배열을 받아요:
deps: {fooServices: fooServiceRef}, factory(fooServices) { // fooServices is an array of instances return new Bar(fooServices); },
서비스 팩토리 옵션 패턴
참고
이 패턴은 권장되지 않으며 필요할 때만 사용하세요. 가능하면 정적 구성이나 재구현을 통해 서비스를 구성 가능하게 만드는 것을 선호해야 해요.
어떤 경우에는 서비스 팩토리 사용자가 서비스 구현이 아니라 팩토리 자체에 옵션을 전달할 수 있게 하는 것이 유용할 수 있어요. 이는 서비스 팩토리를 재구성된 팩토리를 반환하는 함수로도 정의함으로써 활성화할 수 있어요. 예를 들어:
const fooServiceFactoryWithOptions = (options?: { transform?: (foo: string) => string;}) => createServiceFactory<FooService>({ service: fooServiceRef, deps: {}, factory() { return DefaultFooService.create({ transform: options?.transform, }); }, });export const fooServiceFactory = Object.assign( fooServiceFactoryWithOptions, fooServiceFactoryWithOptions(),);
이를 통해 fooServiceFactory를 직접 사용할 수 있을 뿐 아니라 추가 옵션을 전달해 사용자 지정 팩토리를 만들 수도 있어요:
backend.add(fooServiceFactory);// ORbackend.add(fooServiceFactory({ transform: foo => foo.toLowerCase() }));
이 패턴은 의존성 주입을 통해 다른 서비스에 접근할 수 없기 때문에 권장되지 않아요. 그러나 서비스를 다시 구현하지 않고 옵션을 직접 전달할 수 있는 능력이 매우 편리한 Backstage 프레임워크의 몇몇 곳, 예를 들어 @backstage/backend-test-utils의 mockServices에서 사용돼요.