service.signal.ts

service.signal.ts는 서비스 모듈 앞에 달린 문입니다. 누가 어떤 인자로 호출할 수 있는지는 시그널이 정하고, 무슨 일이 일어나는지는 서비스가 정합니다. 엔드포인트나 예약 작업, 실시간 room을 추가할 때 이 파일을 엽니다.
이 페이지에서 쓰는 말
service module
_security, _oauth처럼 lib/_<name>에 있고 자기 테이블이 없는 모듈입니다.
guard
호출을 실행해도 되는지 정하는 클래스입니다. Public, Every, Admin 등이 있습니다.
internal argument
호출자가 아니라 서버가 채워 주는 값입니다. .with(...)로 받습니다.
MCP
AI 에이전트가 엔드포인트를 호출할 때 쓰는 프로토콜입니다. Akan은 /mcp에서 제공합니다.
serverMode
서버의 역할입니다. federation은 요청에 답하고, batch는 백그라운드 작업을 돌리고, all은 둘 다 합니다.
클래스는 셋이 아니라 둘
모델 모듈의 시그널은 클래스를 세 개 선언합니다. 서비스 모듈은 슬라이스를 앞에 둘 테이블이 없어서 두 개만 선언합니다:
클래스
모델 모듈
서비스 모듈
파일에 적는 순서대로
XInternal
✓
✓
런타임이 시작하는 일입니다. 예약 작업, queue job, 부팅과 종료 때의 작업을 둡니다. 비어 있어도 적습니다.
XSlice
✓
테이블을 페이지 단위로 보여 주는 창이고, 뒤에 insight 쿼리가 있습니다. 테이블이 없으면 슬라이스도 없습니다.
XEndpoint
✓
✓
호출자가 닿는 곳입니다. query, mutation, pubsub, message를 둡니다.
✓선언함선언하지 않음
엔드포인트 하나를 가진 영수증 모듈의 파일 전체입니다:
apps/koyo/lib/_receipt/receipt.signal.ts
  • Internal 클래스는 비어 있어도 남깁니다. 예약 작업이 들어갈 자리를 표시합니다.
  • exec은 한 줄입니다. 인자를 서비스에 넘기고, 서비스가 돌려준 값을 그대로 돌려줍니다.
  • 시그널은 명사를 다시 붙입니다. 서비스 메서드는 print, 엔드포인트는 printReceipt입니다. 그래서 st.do.printReceipt와 fetch.printReceipt가 같은 이름으로 읽힙니다.
흔한 실수: 가드 없는 엔드포인트
libs/util의 이 파일은 원래 피해야 할 모양이었습니다. 빨간 줄이 예전 모습이고, 초록 줄이 지금 들어가 있는 수정입니다:
libs/util/lib/_security/security.signal.ts

엔드포인트마다 가드를 적는다

모델 모듈에서는 슬라이스의 guards 맵이 생성된 CRUD 엔드포인트를 덮어 줍니다. 서비스 모듈에는 슬라이스가 없어서 물려받을 기본값이 없습니다. 엔드포인트마다 자기 guards 배열을 바로 옆에 적습니다.
이 배열은 AI 에이전트가 MCP로 그 엔드포인트를 볼 수 있는지도 함께 정합니다:
엔드포인트에 적은 것
호출자 검사
에이전트에 공개
실질 가드를 적은 경우
{ guards: [Every] }
✓
✓
공개됩니다. 에이전트의 호출도 다른 호출과 똑같이 검사합니다.
{ guards: [Every], mcp: false }
✓
HTTP는 그대로 제공하고, 에이전트 목록에서만 빠집니다.
{ guards: [Every, Person] }
✓
사람만 할 수 있는 동작입니다. 모델은 거절되고 목록에서도 보지 못합니다.
Public만 적었거나 아무것도 없는 경우
query(T, { guards: [Public] })
✓
일부러 열어 둔 읽기입니다. 아래 문서 도구처럼 공개됩니다.
mutation(T, { guards: [Public] })
HTTP로는 누구나 실행합니다. MCP는 가드가 없는 것으로 봅니다.
가드 없음
HTTP에서는 검사를 하나도 하지 않고, MCP는 거절합니다.
✓예아니요
열어 둔 엔드포인트도 그것이 결정이고, 결정으로 적혀 있다면 괜찮습니다. 이 문서 앱의 시그널이 바로 그렇게 합니다:
apps/akan/lib/_doc/doc.signal.ts
  • 여기서 [Public]은 결정입니다. 같은 마크다운을 /llms/pages에서 이미 누구에게나 제공하고 있습니다. 가드를 달아도 지키는 것은 없고, 이 도구가 존재하는 이유인 에이전트만 막게 됩니다.
  • 클래스 주석이 이유를 말합니다. 그럴듯한 대안을 왜 버렸는지는 이 코드베이스가 남기는 몇 안 되는 주석 종류 중 하나입니다.
  • 없는 페이지는 빈 값이 아니라 Err입니다. readDocPage는 doc.error.docPageNotFound를 던져, 에이전트가 없는 것을 물었다는 사실을 알게 합니다.

프로토콜이 정한 경로

대부분의 엔드포인트는 Akan이 만든 경로로 호출되고, 그 URL을 직접 칠 일은 없습니다. 프로토콜 엔드포인트는 다릅니다. RFC 8414는 메타데이터 문서 위치를 /.well-known/oauth-authorization-server로 정해 두었고, 클라이언트는 거기서 못 찾으면 달리 찾아볼 곳이 없습니다.
libs/shared는 OAuth 엔드포인트를 RFC가 정한 자리에 그대로 둡니다:
libs/shared/lib/_oauth/oauth.signal.ts
경로의 위치는 옵션 네 개가 정합니다. 공유 const protocolRoute 하나가 프로토콜 엔드포인트 다섯 개의 옵션을 한 가지로 맞춰 줍니다:
pathstring기본값 엔드포인트 이름
엔드포인트 이름과 .param()으로 만드는 경로 대신 쓸 고정 경로입니다.
prefixfalse | string기본값 모델 refName
경로 앞에 붙는 구간입니다. 모델 모듈은 refName을 붙이고, 서비스 모듈은 아무것도 붙이지 않습니다.
globalPrefixfalse기본값 API 접두사(/api)
false면 앱의 API 접두사를 떼어 내, 경로가 origin 루트에 놓입니다.
mcpboolean기본값 true
false면 누가 호출할 수 있는지는 그대로 두고, 에이전트 목록에서만 뺍니다.
  • 여기서도 [Public]은 결정입니다. 클라이언트는 아직 자격 증명이 없고, 그것을 받으러 온 것입니다.
  • prefix: false는 루트 자리임을 분명히 적은 것입니다. 서비스 모듈은 원래 접두사를 붙이지 않으니, 이 줄은 경로를 바꾸기보다 의도를 밝혀 둡니다.
서버가 채워 주는 값
.with(X)는 호출자가 보내지 않는 값을 선언한 인자들 뒤에 붙여 exec에 넘깁니다:
.with(Req)
원본 Request입니다. Akan이 대신 파싱하지 않는 form body나 헤더를 읽을 때 씁니다.
.with(Ip)
가장 가까운 프록시가 기록한 호출자 IP이고, 주소를 전혀 알 수 없을 때만 null입니다.
.with(Account)
검증된 호출자 계정입니다. @libs/shared/srvkit에서 가져옵니다.
  • nullable이 아니면, 값이 없을 때 거절됩니다. { nullable: true } 없이 값이 null이면 호출은 Unauthorized로 거절됩니다. authorizeOAuth와 registerOAuthClient는 처음 보는 상대에게도 답해야 해서 nullable을 적습니다.
  • IP를 소켓에서 직접 읽지 마세요. 게이트웨이 뒤에서는 모든 peer가 127.0.0.1입니다. 그래서 Ip는 프록시가 기록한 값을 읽습니다.
  • 행위자를 body 값으로 받지 마세요. 호출자가 위조할 수 없는 .with(Account), Self, Me로 읽습니다.
Response를 그대로 돌려주기
exec이 돌려준 Response는 직렬화 없이 그대로 전송됩니다. OAuth 엔드포인트는 이것으로 클라이언트가 기다리는 상태 코드와 헤더, 302 redirect를 정확히 돌려줍니다. localFile은 이것으로 파일을 복사 없이 흘려보냅니다:
libs/util/lib/_localFile/localFile.signal.ts
  • [Public]을 적어 익명 읽기를 명시된 결정으로 만듭니다. mcp: false로 파일 스트림을 MCP 목록에서 뺍니다.
  • *는 URL의 나머지 전부와 맞습니다. exec은 req.url에서 파일 경로를 다시 꺼내 읽습니다.
  • 파일은 Bun이 파일을 보내는 방식 그대로 나갑니다. 서비스가 Content-Type을 달지 않으므로 Bun이 저장된 이름으로 타입을 정하고 Range에는 206으로 답하며, 응답을 버퍼링하거나 압축하지 않습니다. PDF를 뺀 모든 응답에 nosniff와 sandbox Content-Security-Policy가 붙으므로, 업로드한 HTML이나 SVG가 API 오리진에서 실행되지 않습니다.

런타임이 시작하는 일

internal()에는 런타임이 스스로 시작하는 일을 둡니다. 예약 작업, queue job, 부팅이나 종료 때의 한 단계 같은 것입니다. 호출자가 런타임뿐이라 인가할 요청이 없고, 가드도 적지 않습니다.
cron(expression)
매일 자정처럼 cron 표현식이 정한 일정에 실행합니다.
interval(ms)
ms 밀리초마다 실행합니다.
timeout(ms)
서버가 시작되고 ms 밀리초 뒤에 한 번 실행합니다.
initialize()destroy()
프로세스가 시작할 때 한 번, 멈출 때 한 번 실행합니다.
process(Type)
백그라운드 queue job입니다. .msg()로 payload의 각 필드에 이름을 붙입니다.
resolveField(Type)
모델의 resolve 필드 값을 계산합니다. 서비스 모듈에는 모델이 없으니 쓸 일이 없습니다.
서버마다 한 번씩이 아니라 밤마다 딱 한 번 돌아야 하는 작업은 batch 워커를 지정합니다:
apps/koyo/lib/_receipt/receipt.signal.ts
resolveField를 뺀 모든 빌더는 마지막 인자로 이 옵션을 받습니다:
serverMode"federation" | "batch" | "all"기본값 "all"
어느 역할의 서버가 실행할지 정합니다. "batch"는 batch와 "all" 서버에서만 돌고 federation에서는 돌지 않습니다.
operationMode("cloud" | "edge" | "local")[]기본값 모든 모드
AKAN_PUBLIC_OPERATION_MODE가 목록에 있을 때만 실행합니다. 예: ["cloud"].
lockboolean기본값 true
cron과 interval에서, 같은 프로세스의 이전 실행이 아직 도는 중이면 이번 실행을 건너뜁니다.
enabledboolean기본값 true
false면 코드를 지우지 않고 작업을 끕니다.
  • 서비스의 serverMode와 맞춥니다. 서비스가 serverMode를 선언했다면 internal도 같은 값을 적어야 합니다. 그렇지 않으면 그 서비스가 꺼진 프로세스에 작업이 예약됩니다.
  • 비어 있는 것이 보통입니다. 이 워크스페이스의 서비스 모듈 여덟 개는 모두 아직 Internal 클래스가 비어 있습니다. 첫 예약 작업이 생기기 전까지는 그 모습입니다.

모델 없이 실시간

pubsub과 message에도 테이블은 필요 없습니다. 그래서 서비스 모듈만으로도 실시간 기능을 만들 수 있습니다. 둘 다 웹소켓으로 오갑니다:
pubsub
클라이언트가 구독하는 room입니다. room 인자와 payload 타입을 선언합니다.
pubsub(Any).room("roomId", String)
message
클라이언트가 보내는 frame 하나입니다. 필드마다 .msg()로 선언하고, exec이 답합니다.
message(Boolean).msg("seq", Int)
minimal 앱은 fan-out 벤치마크를 위해 둘을 하나씩 짝지어 둡니다:
apps/minimal/lib/_minimal/minimal.signal.ts
서비스는 signal<sig.Minimal>()로 주입받은 자기 시그널을 통해 room에 발행합니다:
apps/minimal/lib/_minimal/minimal.service.ts
  • 바이트라면 Binary로 선언합니다. pubsub(Binary)는 JSON 봉투를 건너뛰고, backpressure가 걸리면 가장 최신 frame만 남깁니다. frame을 하나도 빠짐없이 받아야 한다면 { backpressure: "queue" }를 더합니다.
  • 둘 다 MCP에는 나가지 않습니다. 가드를 어떻게 적든 에이전트는 pubsub과 message를 보지 못합니다.

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

내 AI에 이 문서 연결하기

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