사람함께에이전트▾
사람 — 직접 정하고 책임지는 비즈니스 규칙과 흐름. 직접 읽어보세요.
함께 — 개념은 알아두고, 세부 규칙은 에이전트가 따릅니다.
에이전트 — 에이전트가 따르는 규칙과 레퍼런스. 필요할 때 찾아보세요.
소개▾
튜토리얼▾
핵심 개념▾
시스템 아키텍처▾

비즈니스 서비스 아키텍처

고객이 키오스크에서 주문 버튼을 누르면, 그 버튼 하나가 네 가지 일을 해야 합니다. 망고가 떨어졌으면 주문을 거절하고, 주문을 기록하고, 지금 영수증을 돌려주고, 고객이 돌아서기 전에 주방 화면에 티켓을 띄우는 일입니다.
한 번의 탭, 네 가지 일
키오스크의 탭 한 번이 비즈니스 서비스에 닿고, 서비스는 재고를 확인하고, 주문을 저장하고, 영수증을 돌려주고, 주방 화면에 티켓을 띄웁니다.
어느 것도 화면을 그리는 일이 아닙니다. 이 일들을 합쳐 비즈니스 서비스라고 부릅니다. 요청이 부탁하는 액션, 그 뒤의 비즈니스 규칙, 액션이 시작하는 백그라운드 작업, 그리고 다른 화면에 알려야 하는 변경입니다.
모든 모듈은 이 일을 같은 파일 세 개로 나누고, 나누는 기준은 바뀌지 않습니다. 요청은 API 포트에 도착해 세 파일을 차례로 지나갑니다:
모듈 하나, 위에서 아래로
브라우저 · 모바일 앱 · agent
API 포트8282/api
icecreamOrder.signal.tsendpoint · slice· internal
icecreamOrder.service.ts규칙 · 다른 service· external APIs
icecreamOrder.document.tsschema · filter· chain methods
저장된 데이터
파일마다 답하는 질문이 하나씩 있고, 그 질문에만 답합니다:
signal.ts전화 상담원
이 호출자가 물어봐도 되는가?
연락을 받고, 현장까지 가서는 안 될 요청을 돌려보내고, 유효한 일을 알맞은 service에 넘깁니다.
service.ts업무 담당자
무엇이 일어나야 하는가?
재고 규칙, 결제 상태, 예약 충돌, 외부 API 연동이 여기서 하나의 의미 있는 액션으로 합쳐집니다.
document.ts문서고와 그 처리 규칙
기록은 어떻게 저장되고, 어떤 상태 전이를 받아들이는가?
저장 형태, query filter, 그리고 레코드가 받아들일 상태 전이를 정합니다. chain method는 값을 바꾸고 this를 돌려주며, 저장은 호출한 쪽이 합니다.
이 페이지에서 쓰는 말
guard
누가 이 호출을 해도 되는지 판단하는 클래스입니다. endpoint마다 signal 파일에서 직접 붙입니다.
generated CRUD
모든 모델이 이미 가진 생성, 조회, 수정, 삭제 endpoint입니다. 직접 쓰지 않습니다.
chain method
document에 두는 상태 전이 method입니다. 검사하고, 값을 바꾸고, this를 돌려줍니다. 저장은 호출한 쪽이 합니다.
adaptor
POS 단말기 같은 외부 시스템을 감싸는 싱글턴입니다. service에 plug로 꽂아 씁니다.
room
pubsub 채널입니다. 그곳을 구독한 화면은 서버가 publish하는 것을 모두 받습니다.

두 액션, 처음부터 끝까지

카운터에서 일어나는 실제 액션 두 개를 세 파일에 걸쳐 따라가 봅니다:
고객이 주문을 넣습니다
주문 생성은 생성된 CRUD라 endpoint를 따로 쓰지 않습니다. 직접 쓰는 것은 규칙입니다. 주문은 오늘 재고를 차감합니다.
_preCreate
직원이 주문을 다음 상태로 옮깁니다
이것은 생성되지 않습니다. 직접 선언하는 mutation이고, admin만 호출할 수 있습니다.
processIcecreamOrder
apps/koyo/lib/icecreamOrder/icecreamOrder.signal.ts
  • IcecreamOrderSlice.byStatuses는 화면이 불러오는 주문 목록이고, 상태로 거릅니다.
  • processIcecreamOrder는 위임 한 줄입니다. 판단은 endpoint의 몫이 아니기 때문입니다.
판단은 service에 있습니다. 주문이 두 번째 모듈인 inventory와 만나는 자리이고, 두 document를 모두 불러온 뒤에야 저장하는 자리입니다:
apps/koyo/lib/icecreamOrder/icecreamOrder.service.ts
  • _preCreate는 생성된 create가 주문을 저장하기 전에 돕니다. 재고 차감은 inventoryService에 맡깁니다.
  • processIcecreamOrder는 주문을 불러오고, chain method process()를 부른 뒤 저장합니다.
관리자의 몫은 거울상입니다. 같은 파일 세 개, 다른 guard, 그리고 상태 기계가 없습니다. 오늘 재고 보충은 admin이 요청하면 언제든 허용되기 때문입니다. 그 signal은 아래 '호출에 경계 두기'에 나옵니다.

Endpoint, Slice, Internal

모델의 signal 파일은 클래스 셋을 내보내고, 둘이 비어 있더라도 모든 모듈이 셋을 모두 선언합니다. 어느 것을 쓸지는 하나로 정해집니다. 누가 그 호출을 시작하는가입니다.
클래스누가 부르나
↳ 무엇을 담나
endpoint사용자가 fetch.*로
query / mutation, 또는 열린 연결의 message / pubsub
slice화면이
라우트의 fetch.initXInY(...)가 store를 채우고, st.do.initXInY()가 다시 불러옵니다
internal서버 자신이
cron 같은 예약 작업, 생명주기 hook, queue에 들어가는 process 작업
화면에 필요한 것으로 고르기
클래스 목록이 아니라 제품 동작에서 시작하세요. 사용자가 지금 답을 받아야 하는지, 열린 연결로 대화해야 하는지, 여러 화면에 알려야 하는지, 나중에 끝나는 작업인지에 따라 고릅니다.
Signal 형태 선택
화면이 무엇을 필요로 하는가?
지금 답이 필요
열려 있는 동안 계속 대화
여러 화면에 알림
나중에 마무리
query 또는 mutation
message
pubsub
process 또는 schedule
query / mutation
화면이 한 번 묻고 한 번의 결과를 기대합니다. 목록 불러오기, 폼 저장, 요청 승인, 재고 추가입니다.
message
열린 화면이 websocket 대화를 이어갑니다. 장비 제어, 실시간 운영 패널, 단계형 작업 흐름입니다.
pubsub
하나의 비즈니스 변경을 여러 화면, 대시보드, 장비, 사용자가 구독하는 room으로 밀어줍니다.
process / cron / interval
작업이 queue에 들어가거나, 예약되거나, 반복되거나, 호출자가 아니라 서버 생명주기에 묶입니다.

호출에 경계 두기

모든 endpoint는 option 객체를 받고, guards는 그중 첫 번째 필드일 뿐입니다. 나머지 필드는 호출이 어떻게 동작할지 정합니다. 얼마나 오래 걸려도 되는지, 응답을 재사용해도 되는지, 에이전트에게 보이는지입니다.
아래는 관리자의 몫입니다:
apps/koyo/lib/inventory/inventory.signal.ts
  • getTodaysInventory는 가게 전체가 공유하는 읽기입니다. Public이고, 응답을 1초 동안 재사용합니다(cache: 1000).
  • refillTodaysInventory는 admin만 할 수 있는 쓰기이고, 재고 공급처 사정으로 느려질 수 있어 1분을 줍니다(timeout: 60_000).
guardsGuardCls[]
누가 호출할 수 있는지입니다. 아무것도 적지 않으면 검사가 없으며, 기본 정책은 없습니다.
timeoutnumber기본값 30000 (client)
이 호출이 쓸 수 있는 밀리초입니다. Timeout middleware와 클라이언트가 함께 강제합니다.
cachenumber
응답을 재사용할 수 있는 밀리초입니다. internal argument가 없는 query만 가질 수 있습니다.
mcpboolean기본값 true
false는 guard를 건드리지 않고 endpoint를 MCP 카탈로그에서 내립니다.
method"POST" | "PATCH" | "PUT" | "DELETE"기본값 "POST"
mutation이 응답할 HTTP verb입니다. 외부 와이어 프로토콜이 verb를 강제할 때 씁니다.

요청보다 오래 사는 작업

호출자가 기다리지 않는 작업도 있습니다. 아이스크림이 녹으라고 버튼을 누르는 사람도, 지난밤 주문을 정리해 달라고 요청하는 사람도 없습니다. 이런 작업은 Internal 클래스에 두고, 서버가 스스로 시작합니다:
  • interval(10000): 내어준 아이스크림은 자기 일정대로 녹으므로, 서버가 10초마다 경고합니다.
  • cron("0 4 * * *", …): 아무도 찾아가지 않은 주문을 밤사이 새벽 4시에 정리합니다.
둘 다 internal signal이고, 이를 실제로 돌리는 것은 백그라운드 작업을 맡는 서버 프로세스인 batch replica입니다.
같은 파일에 pubsub room도 하나 생깁니다. 아무도 호출하지 않는 endpoint입니다. 그곳에 publish하는 것은 서버이기 때문입니다:
apps/koyo/lib/icecreamOrder/icecreamOrder.signal.ts
이미 열린 화면에 알리기
주문이 processing으로 넘어갈 때 주방 화면은 이미 열려 있고, 아무도 새로고침을 누르지 않습니다. 그래서 service가 저장된 주문을 새 상태 이름의 room으로 publish하고, 그 상태를 구독하는 화면마다 티켓이 덧붙습니다.
publish 한 번, 열린 화면 모두에
비즈니스 서비스가 처리된 주문을 그 상태의 room에 한 번 publish하면, 그 room을 구독한 주방 화면 모두가 티켓을 받습니다.
service는 주입받은 필드로 자기 signal에 닿고, 저장 바로 뒤에 publish합니다:
apps/koyo/lib/icecreamOrder/icecreamOrder.service.ts
무거운 작업은 셋을 한꺼번에 씁니다:
  1. mutation이 월간 정산 리포트를 시작하고, queue에 올라간 레코드를 즉시 돌려줍니다.
  2. 주입된 signal을 통해 service가 부르는 internal process가 파일을 만듭니다.
  3. slice는 레코드가 바뀌는 동안 화면이 진행률, 상태, 다운로드 결과를 읽게 합니다.

Service가 건네받는 것

service는 필요한 것을 직접 만들지 않습니다. serve()의 builder 인자에 적어 두면, handler가 하나라도 돌기 전에 컨테이너가 하나씩 건네줍니다.
덕분에 결제 제공자, 캐시 백엔드, 옆 모듈 전체를 그것을 쓰는 업무 method를 고치지 않고 교체할 수 있습니다.
만들지 않고 건네받습니다
다른 모듈의 service, adaptor, 환경 값, 공유 메모리가 각각 바깥에서 service로 건네집니다. service는 그중 어느 것도 직접 만들지 않습니다.
service<T extends Service>() => T
다른 모듈의 service입니다. 필드 이름은 Service로 끝나야 하며, inventoryService는 inventory로 풀립니다.
plug(adaptor: AdaptorCls) => Adaptor
adaptor 싱글턴입니다. adapt() 클래스, 또는 option.applyAdaptor가 구현을 정하는 role입니다.
signal<S>() => S
그 모듈의 signal입니다. pubsub room에 publish하거나 process를 큐에 넣습니다. 이름은 Signal로 끝납니다.
env(fn: (env) => T) => T
배선 시점에 백엔드 환경에서 읽는 값입니다.
memory(ref, opts?) => Store
cache adaptor에 보관되어 모든 replica가 보는 런타임 상태입니다. 스칼라나 모델 클래스를 받습니다.
use<T>() => T
legacy입니다. lib/option.ts의 생성자 방식 싱글턴을 가져옵니다. 새 adapter는 adapt()로 씁니다.
adaptor 작성하기
plug의 단위는 adaptor입니다. adapt()로 만든 클래스이고, 주어진 이름으로 스스로 등록합니다.
service와 같은 주입기를 받되 service와 signal은 빠집니다. 그래서 adaptor는 설정, 다른 adaptor, 공유 상태를 가질 수 있지만 비즈니스 로직은 갖지 않습니다:
apps/koyo/srvkit/posTerminal.ts
데이터베이스 기반 service는 자기 모델을 this.<refName>Model로 아무것도 선언하지 않고 받습니다. serve(db.inventory, …)가 붙여주기 때문입니다. 주입기별 상세한 사용법과 role을 묶는 방법은 의존성 주입에 있습니다.

에러가 놓일 자리

망고가 없어서 주문을 거절하는 것과 로그인하지 않은 고객의 주문을 거절하는 것은 같은 거절이 아니고, 같은 파일에 쓰지도 않습니다. 각 계층은 자기만 알 수 있는 것을 던집니다:
  • 로그인하지 않은 고객: 다른 무엇보다 먼저 signal.ts의 guard가 거절합니다.
  • 망고가 떨어짐: 다른 document가 막는 일이므로 service.ts가 거절합니다.
  • active가 아닌 주문은 처리할 수 없음: 레코드 자신의 상태가 막는 일이므로 document.ts가 거절합니다.
어느 계층이 거절하는가
호출 도착
signal.ts guards이 호출자가 이 일을 해도 되는가?
401 또는 403 · guard가 false 반환
service.ts다른 document가 막는가?
document.ts이 레코드가 허용하는 상태인가?
chain 메서드가 변경 · 호출자가 save
dictionary .error key호출자에게 번역됨
chain method가 이 규칙의 가장 작은 형태입니다. 검증하고, 값을 바꾸고, this를 돌려줍니다. 저장은 하지 않습니다. 그래야 chain이 이어 붙고, 쓰기 시점은 호출한 쪽이 정합니다:
apps/koyo/lib/icecreamOrder/icecreamOrder.document.ts
실패가 에러가 아닐 때
최선을 다하는 정도의 코드는 아예 던지지 않습니다. 평범한 값을 돌려주고, 그것이 에러인지는 호출한 쪽이 정합니다:
  • 제공처에 닿지 못한 adaptor는 로그를 남기고 null을 돌려줍니다.
  • 레코드를 불러오지 못한 guard는 warn을 남기고 false를 돌려줍니다.
이 스택 어디에도 Result 래퍼는 없습니다.

같은 Endpoint, 에이전트에게는

모든 signal은 기본 마운트되는 POST /mcp에서 MCP 서버로 AI 에이전트에게도 제공됩니다. signal 파일에 따로 적을 것도 없고, endpoint마다 켜야 하는 스위치도 없습니다.
대신 노출은 guard를 따릅니다. guard가 이미 권한 부여 결정이고, 스위치를 하나 더 두면 나중에 추가되는 endpoint가 누군가 기억해 낼 때까지 보이지 않게 될 뿐이기 때문입니다.
Endpoint
게시
제외
guard가 정하는 것
guards: [Admin]
✓
실질 guard를 선언한 endpoint는 에이전트에게 게시됩니다.
guards 없음
✓
거부됩니다. guards 배열을 빠뜨리면 권한뿐 아니라 노출까지 잃습니다.
mutation · [Public]
✓
guard가 Public뿐인 mutation도 거부됩니다.
형태가 정하는 것
pubsub · message
✓
거부됩니다.
fileUpload: true
✓
파일 업로드는 거부됩니다.
Any · Binary
✓
Any나 Binary를 반환하는 endpoint는 거부됩니다.
직접 고르는 것
mcp: false
✓
guard를 건드리지 않고 endpoint를 선반에서만 내립니다. guard는 완벽하지만 모델이 건드릴 일은 아닌, UI가 이끄는 상태 기계의 한 단계에 맞는 답입니다.
✓에이전트가 받는 결과해당 없음
그래서 이미 작성한 비즈니스 서비스가 곧 에이전트 표면입니다. 같은 guard, 같은 Err, 같은 service method입니다. 달라지는 것은 카탈로그의 비용과 반대편에 누가 있을 수 있는가입니다. 와이어, OAuth 메타데이터, rate limit, Person guard 이야기는 MCP 서버에 있습니다.

MIT 라이선스 하에 배포되었습니다.

내 AI에 이 문서 연결하기

MCPhttps://akanjs.com/mcp
Copyright © 2026 Akan.js 모든 권리 보유.시스템 관리자bassman