사람함께에이전트▾
사람 — 직접 정하고 책임지는 비즈니스 규칙과 흐름. 직접 읽어보세요.
함께 — 개념은 알아두고, 세부 규칙은 에이전트가 따릅니다.
에이전트 — 에이전트가 따르는 규칙과 레퍼런스. 필요할 때 찾아보세요.
앱 & 라이브러리▾
도메인▾
스칼라▾
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은 둘 다 합니다.클래스는 셋이 아니라 둘
모델 모듈의 시그널은 클래스를 세 개 선언합니다. 서비스 모듈은 슬라이스를 앞에 둘 테이블이 없어서 두 개만 선언합니다:
클래스
모델 모듈
lib/<model>
서비스 모듈
lib/_<service>
파일에 적는 순서대로
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



가드를 하나도 적지 않은 엔드포인트는 아무 검사도 하지 않습니다. 가드가 없으면 누구나 앱의 키로 아무 입력이나 암호화할 수 있어서
encrypt가 암호화 오라클이 됩니다. libs/shared의 Admin에 닿지 못하는 라이브러리는 [None]으로 엔드포인트를 닫고, mcp: false로 MCP에서도 뺍니다.엔드포인트마다 가드를 적는다
모델 모듈에서는 슬라이스의 guards 맵이 생성된 CRUD 엔드포인트를 덮어 줍니다. 서비스 모듈에는 슬라이스가 없어서 물려받을 기본값이 없습니다. 엔드포인트마다 자기
guards 배열을 바로 옆에 적습니다.이 배열은 AI 에이전트가 MCP로 그 엔드포인트를 볼 수 있는지도 함께 정합니다:
엔드포인트에 적은 것
호출자 검사
에이전트에 공개
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를 던져, 에이전트가 없는 것을 물었다는 사실을 알게 합니다.


mutation에 붙은
[Public]은 가드가 없다는 말을 적어 놓은 것과 같습니다. MCP는 가드가 Public 하나뿐인 mutation을 가드가 아예 없는 것과 똑같이 거절합니다. 어떤 가드를 언제 쓰는지는 인증과 권한 치트시트에 정리되어 있습니다.프로토콜이 정한 경로
대부분의 엔드포인트는 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 클래스가 비어 있습니다. 첫 예약 작업이 생기기 전까지는 그 모습입니다.


lock은 서버 사이를 조율하지 않습니다. 한 프로세스 안에서 겹치는 실행만 건너뜁니다. 역할이 맞는 서버는 각자 자기 사본을 실행하므로, 한 번만 돌아야 하는 작업은 serverMode: "batch"로 두고 batch 워커를 하나만 띄웁니다.모델 없이 실시간
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를 보지 못합니다.


pubsub과 message는 자기 guards를 적기 전까지 열려 있습니다. 위쪽의 무엇도 덮어 주지 않고, 모델 모듈의 슬라이스 기본값도 닿지 않습니다. 위 두 엔드포인트가 [Public]인 것은 minimal이 예시가 아니라 벤치마크 앱이기 때문입니다. room의 가드는 소켓의 자격 증명이 바뀔 때마다 다시 실행됩니다.