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하는 것인데——문제가 바로 드러납니다:
- B가 A의 구체적인 클래스 이름과 생성자 인자를 알아야 하므로, 두 플러그인이 강하게 결합됩니다.
- 스토리지 백엔드를 바꾸고 싶을 때(JSON 파일로 바꾸거나, 원격 데이터베이스로 바꾸거나), B의 코드를 다시 고쳐야 합니다.
- 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.storage | seam | storage | storage-json, storage-sqlite | storage-domain | 각 백엔드가 서로 다른 이름으로 나란히 등록됩니다. 데이터 형태(도메인 우선)는 허브에 마운트되고, 타입이 있는 연산은 불투명한 KV 유닛 프리미티브로 변환됩니다. |
이해하셨나요? storage-json과 storage-sqlite는 두 명의 제공자로, 각각 「스토리지」를 구현합니다. storage-domain은 소비자로, ctx.storage라는 이름만 알 뿐 뒤가 JSON 파일인지 SQLite인지는 전혀 신경 쓰지 않습니다——바로 꼬마 D가 원하던 디커플링입니다. 아래에서 하나씩 뜯어 보겠습니다.
2. 세 역할 분해: Definition이 계약을 정하고, 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: 능력 계약——이 능력이 어떤 모습인가
「정의」는 세 가지 질문에 답합니다:
- 서비스의 이름이 무엇인가——
greeter(super(ctx, 'greeter')안의 그 이름). - 어떤 공개 메서드를 제공하는가——
greet(who: string). - 소비자가 받는 타입은 무엇인가——
declare module 'cordis'가greeter를Context인터페이스에 추가하여, 이후ctx.greeter에 타입이 생깁니다.
Definition은 「계약」만 정할 뿐 어떤 일도 하지 않습니다. 공식 튜토리얼의 원문(출처: docs/cordis-tutorial/03-services.zh.md):
컴파일 시:
declare module 'cordis'블록은 TypeScript 선언 병합을 사용하여greeter를Context인터페이스에 추가함으로써,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-json을storage-sqlite로 바꿔도 소비자storage-domain은 한 줄도 바뀌지 않습니다. - bash 실행기 교체: 샌드박스, 원격 또는 PowerShell 실행기가
bash-local을 대체할 수 있으며, 어떤 소비자도 수정할 필요가 없습니다(출처:docs/capability-seams.zh.md의ctx.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 Definition | shell/ | ctx.shell 정의: 명령을 실행하는 능력 계약 |
| Service Provider | bash-local/, bash-sandbox/, pwsh-local/ | 각각 실행기를 구현 |
| Consumer | tool-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-json을storage-sqlite로,bash-local을bash-sandbox로)가 어떤 소비자에게도 영향을 주지 않습니다. 저장소는 「Definition/Provider/Consumer를 독립 패키지로」 조직하며, bash 트리오가 템플릿입니다. - 본질은 코이펙트: 소비자의 「의존 선언」이 바로 2장 3.2의 코이펙트입니다——시스템이 자동으로 주입하고, 의존이 갖춰지면 자동으로 활성화. 제공자의 「서비스 등록」은 롤백 가능한 이펙트입니다——언로드하면 원상복구. 서비스 = 계약 + 이펙트 + 코이펙트.
🚀 서비스는 플러그인들이 「이름을 사이에 두고 협력」하게 합니다. 그렇다면 플러그인들이 「이벤트를 사이에 두고 협력」하게 하려면 어떻게 할까요? 다음은 4과 「이벤트 리스닝: 플러그인 간 느슨한 결합 통신」——
ctx.on으로 보내면 서비스를 공유할 필요조차 없습니다.
자가 테스트 · 서비스 작성하기
답을 모두 선택한 후 「답안 제출」을 클릭하면 정답 여부와 해설을 확인할 수 있습니다.
