스폰서LobeHubLobeHub자세히 알아보기
dshfind

3과: 서비스 작성하기: Service의 세 가지 역할

한 문장 요약: 지난 과에서는 ctx.tools에 도구를 등록했습니다. 이번 과에서는 서비스를 작성합니다——하나의 능력을 「정의, 제공자, 소비자」 세 가지 역할로 나누어 ctx에 마운트합니다. 정의하는 쪽은 계약(그 능력이 어떤 모습인지)만 작성하고, 제공하는 쪽이 실제 작업을 담당하며(super(ctx, name) / ctx.provide로 구현을 등록), 소비하는 쪽은 「그것이 필요하다」고 선언만 합니다(inject 또는 ctx.get). 세 쪽은 이름만 알 뿐 서로를 import하지 않습니다. 제공자를 교체해도 소비자는 손댈 필요가 없습니다——이것이 2장에서 말한 「심(seam)」이며, 코이펙트(coeffect)가 DSH에서 일상적으로 나타나는 형태입니다.


1. 사용자 스토리: 플러그인 A가 「스토리지」를 제공하고, 플러그인 B가 사용하려 한다

꼬마 D는 두 개의 플러그인을 작성했습니다:

  • 플러그인 A「storage-sqlite」: 데이터베이스에 연결할 수 있고, 키-값 데이터를 저장할 수 있습니다.
  • 플러그인 B「todo-list」: 사용자의 할 일 목록을 기록해야 하므로, 그 목록을 영구 저장해야 합니다.

B는 A의 스토리지 능력을 사용하고 싶습니다. 전통적인 방식이라면 A의 구현 클래스를 직접 import하는 것인데——문제가 바로 드러납니다:

  1. B가 A의 구체적인 클래스 이름과 생성자 인자를 알아야 하므로, 두 플러그인이 강하게 결합됩니다.
  2. 스토리지 백엔드를 바꾸고 싶을 때(JSON 파일로 바꾸거나, 원격 데이터베이스로 바꾸거나), B의 코드를 다시 고쳐야 합니다.
  3. A가 설치되어 있지 않으면 B가 그대로 크래시되며, 피할 여지가 없습니다.

DSH에서는 어떻게 우아하게 협력할까요? 답은 한 문장입니다: B는 A를 import하지 않습니다. A는 「storage라는 이름의 서비스를 제공한다」고 말하고, B는 「storage 서비스가 필요하다」고 말할 뿐이며, 두 플러그인은 같은 ctx 위에서 이름을 통해 만납니다. 누가 구현했는지, 언제 구현했는지, 설치되어 있는지——B는 전혀 모릅니다.

공식 튜토리얼의 「서비스」 정의(출처: docs/user/develop/framework/service.zh.md):

서비스는 한 플러그인이 다른 플러그인에게 공개하는 능력입니다. inject는 플러그인이 어떤 서비스를 필요로 하는지 선언합니다. Harness에서 tools, llm, agents는 모두 서비스입니다——서비스는 ctx에 마운트된 이름 있는 능력입니다.

Cordis 입문 튜토리얼은 더 직설적으로 말합니다(출처: docs/cordis-tutorial/03-services.zh.md):

소비자는 'tools' 같은 능력만 지정할 뿐 그 제공자를 import하지 않으므로, 설정이 제공자를 선택할 수 있고 소비자를 수정할 필요가 없습니다.

이 패턴은 프로덕션 저장소에서 매일 사용됩니다. 능력 목록(출처: docs/capability-seams.zh.md)을 열어 볼 때, ctx.storage는 「비세션 스토리지 허브」 seam이며, 아래 표는 그 한 행을 발췌한 것입니다:

ctx 키역할소속 패키지구현직접 소비자설명
ctx.storageseamstoragestorage-json, storage-sqlitestorage-domain각 백엔드가 서로 다른 이름으로 나란히 등록됩니다. 데이터 형태(도메인 우선)는 허브에 마운트되고, 타입이 있는 연산은 불투명한 KV 유닛 프리미티브로 변환됩니다.

이해하셨나요? storage-jsonstorage-sqlite두 명의 제공자로, 각각 「스토리지」를 구현합니다. storage-domain소비자로, ctx.storage라는 이름만 알 뿐 뒤가 JSON 파일인지 SQLite인지는 전혀 신경 쓰지 않습니다——바로 꼬마 D가 원하던 디커플링입니다. 아래에서 하나씩 뜯어 보겠습니다.


2. 세 역할 분해: Definition이 계약을 정하고, Provider가 일하고, Consumer가 사용한다

먼저 세 역할의 위치를 기억할 그림을 세워 봅시다:

Service Definition能力长什么样(契约)Service Provider谁来干活(实现)Consumer谁在用(注入)实现服务消费服务按定义注入 / 获取

三种角色分离 → 换提供者不影响消费者,能力才可替换

공식 튜토리얼의 「서비스 작성하기」 완전한 시연——greeter 서비스(출처: docs/cordis-tutorial/03-services.zh.md):

import { Service, type Context } from 'cordis'

declare module 'cordis' {
  interface Context {
    greeter: GreeterService
  }
}

export class GreeterService extends Service {
  constructor(ctx: Context) {
    super(ctx, 'greeter')
  }

  greet(who: string) {
    return `Hello, ${who}!`
  }
}

export const name = 'greeter'

export function apply(ctx: Context) {
  ctx.plugin(GreeterService)
}

이 하나의 파일에는 사실 두 가지 역할이 동시에 등장합니다. 하나씩 분해해 봅시다:

2.1 Service Definition: 능력 계약——이 능력이 어떤 모습인가

「정의」는 세 가지 질문에 답합니다:

  1. 서비스의 이름이 무엇인가——greeter(super(ctx, 'greeter') 안의 그 이름).
  2. 어떤 공개 메서드를 제공하는가——greet(who: string).
  3. 소비자가 받는 타입은 무엇인가——declare module 'cordis'greeterContext 인터페이스에 추가하여, 이후 ctx.greeter에 타입이 생깁니다.

Definition은 「계약」만 정할 뿐 어떤 일도 하지 않습니다. 공식 튜토리얼의 원문(출처: docs/cordis-tutorial/03-services.zh.md):

컴파일 시: declare module 'cordis' 블록은 TypeScript 선언 병합을 사용하여 greeterContext 인터페이스에 추가함으로써, ctx.greeter가 어디서든 타입 검사를 통과하게 합니다. 코드를 생성하지 않습니다. 이 선언이 없어도 서비스는 런타임에 동작하지만, 소비자는 타입 안전성을 잃습니다.

2.2 Service Provider: 구현——누가 일하는가

GreeterService extends Service가 바로 제공자입니다: greet를 실제로 구현하는 코드가 여기에 있습니다. super(ctx, 'greeter')는 이 인스턴스를 ctx의 greeter 키에 등록합니다(등록 세부 사항은 3절에서 설명). Service 서브클래스는 그 자체가 플러그인(클래스 형태 플러그인)이므로, apply 안의 ctx.plugin(GreeterService)이 일반 플러그인처럼 마운트합니다. 공식 튜토리얼의 원문(출처: docs/cordis-tutorial/03-services.zh.md):

런타임: super(ctx, 'greeter')는 이름 greeter로 이 인스턴스를 등록합니다. 이후 어떤 플러그인이든 ctx.greeter를 통해 접근할 수 있습니다. 등록은 effect에 속하며, 제공자가 언로드될 때 이 서비스는 제거됩니다.

2.3 Consumer: 사용자——필요하다고 선언만 한다

소비자는 제공자를 전혀 import하지 않고, 단 두 줄만 작성합니다(출처: docs/cordis-tutorial/03-services.zh.md):

import type { Context } from 'cordis'

export const name = 'consumer'
export const inject = ['greeter']

export function apply(ctx: Context) {
  console.log(ctx.greeter.greet('world'))
}

export const inject = ['greeter']가 의존성 선언입니다——「greeter 서비스가 필요하다」. 프레임워크가 보장합니다(출처: docs/user/develop/framework/service.zh.md):

프레임워크 보장: apply가 실행될 때 inject에 선언된 서비스는 모두 준비 완료 상태입니다. 서비스가 아직 준비되지 않았다면, 플러그인은 실행되지 않고 기다립니다.

두 플러그인을 어셈블리 파일에 추가하면 실행됩니다:

- name: './greeter.ts'
- name: './consumer.ts'

Hello, world!가 출력됩니다. 두 줄의 순서를 바꿔서 다시 실행해도 출력은 동일합니다——플러그인이 언제 시작되는지를 결정하는 것은 파일 순서가 아니라 의존 관계입니다(출처: docs/cordis-tutorial/03-services.zh.md). ./greeter.ts를 삭제해 보세요: 소비자는 대기 상태(PENDING)를 유지하며, 크래시되지도 않고 절반만 실행되지도 않습니다.

💡 세 역할을 한 문장으로 기억하기: Definition은 계약서, Provider는 일하는 사람, Consumer는 일을 시키는 사람. 계약서가 가울데 놓여 있고, 일하는 사람과 일을 시키는 사람은 서로 만나지 않습니다.


3. ctx에 등록하는 과정: provide와 consume의 실제 작성법

2절의 그 super(ctx, 'greeter') 뒤에는 도대체 무엇이 있을까요? Cordis 코어 라이브러리는 세 가지 저수준 API를 제공합니다(출처: docs/cordis-api/context.zh.md):

API하는 일한 문장으로
ctx.provide(name, value)현재 fiber가 소유하는 서비스 구현을 등록제공자: 구현을 ctx에 마운트
ctx.get(name)주입 요구를 충족하지 않고 스토리지에서 서비스를 읽기소비자: 이름으로 가져오고, 없으면 undefined
ctx.set(name, value)이미 제공된 서비스의 값을 덮어쓰기제공자: 구현 교체(제공한 fiber만 set 가능)

Service 기반 클래스는 「제공」을 보기 좋은 형태로 감싼 것일 뿐입니다: super(ctx, 'greeter') 내부는 한 번의 ctx.provide('greeter', this)입니다. 코어 라이브러리의 ctx.provide 설명(출처: docs/cordis-api/context.zh.md):

현재 fiber가 소유하는 서비스 구현을 등록합니다. fiber가 활성화된 후에는 이 서비스가 같은 격리 스코프 내의 의존자들에게 보이게 됩니다. 반환된 리소스 해제 함수가 실행되거나 fiber가 언로드되면, 이 서비스는 등록 해제되고 의존자들이 깨어납니다.

마지막 문장에 주목하세요: provide는 취소 함수를 반환합니다. 제공자 플러그인이 언로드될 때 자동으로 실행되고, 서비스는 ctx에서 사라지며, 의존자들이 깨어나 다시 해결을 시도합니다——이것이 「등록은 effect이며 롤백 가능하다」의 정착점입니다. Service 기반 클래스를 사용하고 싶지 않다면, ctx.provide로 일반 객체를 직접 마운트할 수도 있습니다:

export function apply(ctx: Context) {
  // 제공: 구현을 ctx의 'storage' 키에 마운트하고, 취소 함수를 반환
  const dispose = ctx.provide('storage', {
    async get(key: string) { /* ... */ },
    async set(key: string, value: string) { /* ... */ },
  })
  // 플러그인 언로드 시 dispose()가 자동으로 호출됨(provide는 추적되는 effect이기 때문)
}

소비자가 서비스를 가져오는 방법에도 두 가지 자세가 있습니다: 필수 의존inject를 사용하고(준비될 때까지 PENDING 대기), 선택 의존은 inject를 걸러뛰고 사용 지점에서 ctx.get()으로 탐지합니다(출처: docs/user/develop/framework/service.zh.md):

export function apply(ctx: Context) {
  const metrics = ctx.get('metrics')
  metrics?.record('plugin_loaded', 1)
}

두 가지 소비 방식을 비교해 봅시다:

방식작성법동작
필수 의존export const inject = ['greeter']서비스가 준비되지 않으면 플러그인은 대기(PENDING) 상태 유지. 준비되어야 apply 실행
선택 의존inject를 쓰지 않고, ctx.get('greeter')서비스가 없으면 undefined를 받고, 플러그인은 정상 실행

그리고 의존 추적은 「로딩 이후」에도 계속 유효합니다(출처: docs/user/develop/framework/service.zh.md):

앱 실행 중에 필수 서비스가 사라지면(예: 그 제공자가 언로드됨): 1. 그에 의존하는 플러그인이 자동으로 dispose(리소스 해제)됩니다. 2. 서비스가 다시 나타나면 플러그인이 자동으로 다시 로드됩니다. 이를 통해 플러그인이 더 이상 존재하지 않는 서비스를 호출하는 것을 방지합니다.


4. 세 역할 분리 = 교체 가능한 seam, 본질은 코이펙트

4.1 왜 제공자를 교체해도 소비자에 영향이 없는가

세 역할 그림으로 돌아가 봅시다: 소비자는 「정의」——서비스 이름과 메서드 시그니처에만 의존하고, 「구현」에는 전혀 의존하지 않습니다. 그래서 제공자는 마음대로 교체할 수 있습니다:

  • 스토리지 백엔드 교체: storage-jsonstorage-sqlite로 바꿔도 소비자 storage-domain은 한 줄도 바뀌지 않습니다.
  • bash 실행기 교체: 샌드박스, 원격 또는 PowerShell 실행기가 bash-local을 대체할 수 있으며, 어떤 소비자도 수정할 필요가 없습니다(출처: docs/capability-seams.zh.mdctx.shell 행).

이것이 「심(seam)」입니다: 정의와 구현 사이의 그 틈이 바로 교체가 일어나는 곳입니다. 능력 목록 문서의 원문(출처: docs/capability-seams.zh.md):

서비스는 코어 백본 서비스가 될 수도 있고, 교체 가능한 능력 seam이 될 수도 있으며, 조합 패키지/조합 포인트가 될 수도 있습니다.

저장소 실전 매뉴얼의 패키지 조직에 대한 조언(출처: docs/cookbook/adding-a-package.md, 원문은 영어이며 여기에 번역):

교체 가능한 능력에 대해서는, Service Definition/Service provider/Consumer 역할이 독립적으로 발전해야 할 때 이들을 서로 다른 패키지로 분리하세요——bash 트리오가 템플릿입니다.

(영어 원문: For a swappable capability, separate Service Definition / Service provider / Consumer roles into packages when they evolve independently … the bash trio is the template.)

프로덕션 저장소에서 bash 패밀리는 이 템플릿의 살아 있는 표본입니다(출처: docs/capability-seams.zh.md):

역할패키지책임
Service Definitionshell/ctx.shell 정의: 명령을 실행하는 능력 계약
Service Providerbash-local/, bash-sandbox/, pwsh-local/각각 실행기를 구현
Consumertool-bash/, tool-pwsh/, hooks-claude-code/, hooks-codex/모델을 위한 셸 도구와 훅 브리지

4.2 왜 이 메커니즘은 태생적으로 「코이펙트」인가

2장 3.2에서 배운 코이펙트(coeffect)를 기억하나요?——이펙트는 「내가 무엇을 바꿨는가」를 묻고, 코이펙트는 「내가 무엇이 필요한가」를 묻습니다. 서비스 메커니즘은 이 두 방향을 모두 차지하고 있습니다:

  • 소비자 쪽은 코이펙트: export const inject = ['greeter']가 바로 「무엇이 필요한가」의 선언입니다. 시스템은 이 의존 표를 주시하며, 의존이 갖춰지면 자동으로 활성화하고, 의존이 사라지면 자동으로 언로드하고, 다시 나타나면 자동으로 재로드합니다——이것이 바로 2장 3.2에서 업그레이드된 「리액티브 코이펙트」입니다(2장 8과 「리액티브 코이펙트: 의존이 갖춰지면 자동 시작」과 호응).
  • 제공자 쪽은 이펙트: ctx.provide('greeter', this)(또는 super(ctx, 'greeter'))는 한 번의 롤백 가능한 이펙트입니다——등록 즉시 효력을 발휘하고, 언로드 시 자동으로 취소되어 ctx에서 깔끔하게 사라집니다(2장 6과 「롤백 가능한 이펙트」와 호응).

즉 「서비스를 작성한다」는 것은, 본질적으로 2장의 그 한 쌍의 개념을 실제 코드에서 한 번씩 사용하는 것입니다:

제공자: ctx.provide(...)   →  이펙트(내가 무엇을 바꿨는가: 서비스를 마운트, 롤백 가능)
소비자: inject / ctx.get   →  코이펙트(내가 무엇이 필요한가: 자동으로 연결, 갖춰져야 활성화)

💡 한 문장 기억법: 서비스 = 계약(정의) + 이펙트(제공) + 코이펙트(소비). 세 역할을 나누는 이유는, 「제공」과 「소비」가 항상 계약을 사이에 두고 마주 보게 하여 각각 독립적으로 교체할 수 있게 하기 위해서입니다.


핵심 요점 복습

  • 서비스는 한 플러그인이 다른 플러그인에게 공개하는 능력: ctx에 마운트된 이름 있는 능력(tools, llm, agents는 모두 서비스). 소비자는 이름만 지정할 뿐 제공자를 import하지 않습니다.
  • 세 역할: Service Definition(능력 계약: 이름, 메서드 시그니처, declare module 'cordis' 타입 선언), Service Provider(구현: Service 서브클래스 + super(ctx, 'greeter') 등록 + ctx.plugin(...) 마운트), Consumer(사용자: export const inject = ['greeter'], apply에서 ctx.greeter를 직접 사용).
  • ctx에 등록하는 실제 작성법: 저수준은 ctx.provide(name, value)(제공, 취소 함수 반환), ctx.get(name)(이름으로 가져오기, null 가능), ctx.set(name, value)(이미 제공된 값 덮어쓰기). super(ctx, name)provide의 래퍼입니다.
  • 필수 의존과 선택 의존: inject는 필수를 선언(준비되지 않으면 PENDING 대기). inject를 걸러뛰고 ctx.get()으로 선택 의존을 탐지. 서비스가 사라지면 자동으로 dispose, 다시 나타나면 자동으로 재로드.
  • 세 역할 분리 = 교체 가능한 seam: 제공자 교체(storage-jsonstorage-sqlite로, bash-localbash-sandbox로)가 어떤 소비자에게도 영향을 주지 않습니다. 저장소는 「Definition/Provider/Consumer를 독립 패키지로」 조직하며, bash 트리오가 템플릿입니다.
  • 본질은 코이펙트: 소비자의 「의존 선언」이 바로 2장 3.2의 코이펙트입니다——시스템이 자동으로 주입하고, 의존이 갖춰지면 자동으로 활성화. 제공자의 「서비스 등록」은 롤백 가능한 이펙트입니다——언로드하면 원상복구. 서비스 = 계약 + 이펙트 + 코이펙트.

🚀 서비스는 플러그인들이 「이름을 사이에 두고 협력」하게 합니다. 그렇다면 플러그인들이 「이벤트를 사이에 두고 협력」하게 하려면 어떻게 할까요? 다음은 4과 「이벤트 리스닝: 플러그인 간 느슨한 결합 통신」——ctx.on으로 보내면 서비스를 공유할 필요조차 없습니다.

자가 테스트 · 서비스 작성하기

답을 모두 선택한 후 「답안 제출」을 클릭하면 정답 여부와 해설을 확인할 수 있습니다.

1. Service 세 역할에 대해 올바른 설명은?
2. 왜 세 역할 분리가 능력을 「교체 가능」하게 만드는가?
3. 서비스 메커니즘과 2장에서 배운 코이펙트(coeffect)는 어떤 관계인가?
4. 필수 의존과 선택 의존에 대해 올바른 설명은?