model.signal.ts

model.signal.ts는 모듈의 출입문입니다. 클라이언트가 무엇을 호출할 수 있는지, 페이지가 어떤 목록을 불러올지, 서버가 스스로 무엇을 실행할지를 이 파일이 정합니다.
페이지에 새 호출이나 목록이 필요할 때, 또는 서버에 예약 작업이 필요할 때 이 파일을 엽니다. 로직은 service에 두고, 여기 handler는 service를 호출하기만 합니다.
StoryInternalinternal()
서버가 스스로 하는 일입니다. 계산 필드, 예약 작업, 시작·종료 hook, queue job이 여기 있습니다.
StorySliceslice()
inRoot처럼 페이지가 불러오는 목록입니다. 각각 fetch 메서드와 store 상태가 됩니다.
StoryEndpointendpoint()
클라이언트가 하는 호출입니다. query, mutation, websocket message, pubsub room이 있습니다.
이 페이지에서 쓰는 말
guard
호출을 실행해도 되는지 정하는 클래스입니다. Public, Every(로그인한 모든 계정), Admin 등이 있습니다.
internal argument
호출자가 아니라 서버가 채워 주는 값입니다. .with(Self)로 받는 로그인 사용자가 대표적입니다.
refName
story처럼 camelCase로 쓴 모델 이름입니다. 경로와 fetch 메서드 이름이 여기서 만들어집니다.
MCP
AI 에이전트가 endpoint를 호출할 때 쓰는 프로토콜입니다. Akan은 /mcp에서 제공합니다.
기본 뼈대
모든 signal 파일은 세 클래스를 이 순서로 선언합니다. 비어 있어도 마찬가지입니다:
apps/blog/lib/story/story.signal.ts
  • this에 service가 있습니다. exec 안의 this.storyService가 이 모듈의 service이고, srv.story.with(srv.actionLog)로 this.actionLogService를 더합니다.
  • exec는 한 줄입니다. service 메서드 하나를 불러 결과를 돌려줄 뿐이고, 조회와 판단은 service에서 합니다.
  • barrel은 값으로 import합니다. signal은 * as cnst와 * as srv를 import type이 아닌 일반 import로 가져옵니다.

라이브러리 모델 확장하기

앱은 libs/shared의 user 모듈 위에 자기 user 모듈을 얹을 수 있습니다. 라이브러리 클래스를 마지막 인자로 넘기고, 앱이 더할 것만 적습니다.
../__lib/lib.signal이 모델별로 라이브러리 클래스를 모아 export합니다:
apps/blog/lib/user/user.signal.ts
  • 마지막에 spread합니다. internal(), slice(), endpoint()는 빌더 함수 뒤에 라이브러리 클래스를 몇 개든 받습니다.
  • 이름이 겹치면 라이브러리가 이깁니다. 라이브러리에 이미 있는 키를 적어도 교체되지 않으니, 새 이름을 붙이세요.
  • service도 함께 옵니다. 라이브러리의 service도 내 service와 나란히 this에서 쓸 수 있습니다.

internal 작업 정의하기

internal()에는 클라이언트가 호출하지 않는 일을 둡니다. 서버가 일정에 맞춰, 시작·종료할 때, queue에 job이 들어올 때, 계산 필드를 읽을 때 실행합니다.
resolveField(Type)
constant의 resolve 필드 값을 계산합니다. exec는 첫 인자로 부모 document를 받습니다.
interval(ms)
ms 밀리초마다 실행합니다.
cron(expression)
매일 자정처럼 cron 표현식이 정한 일정에 실행합니다.
timeout(ms)
서버가 시작되고 ms 밀리초 뒤에 한 번 실행합니다.
initialize(options?)destroy(options?)
서버 프로세스가 시작하거나 종료할 때 실행합니다.
process(Type)
백그라운드 queue job입니다. .msg()로 payload를 선언하고, service가 queue에 넣습니다.
좋아요 수를 계산하는 필드와 매일 밤 도는 정리 작업은 이렇게 씁니다:
apps/blog/lib/story/story.signal.ts
  • 필드는 constant에 선언합니다. like는 모델의 via(…, (resolve) => ({ like: resolve(Int) }))에 있어야 합니다.
  • 예약 작업은 값을 반환하지 않습니다. 값을 반환하는 handler는 resolveField와 process뿐이고, 나머지는 void입니다.
  • process job은 service가 queue에 넣습니다. storySignal: signal<sig.Story>()를 주입하고 this.storySignal.archive(storyId)를 호출합니다.
예약 작업 옵션
resolveField를 뺀 모든 빌더는 마지막 인자로 이 옵션을 받습니다:
serverMode"federation" | "batch" | "all"기본값 "all"
어느 역할의 서버가 실행할지 정합니다. "batch"는 batch와 "all" 서버에서 실행되고 federation 서버에서는 실행되지 않습니다.
operationMode("cloud" | "edge" | "local")[]기본값 모든 모드
AKAN_PUBLIC_OPERATION_MODE가 목록에 있을 때만 실행합니다. 예: ["cloud"].
lockboolean기본값 true
이전 실행이 이 프로세스에서 아직 도는 중이면 interval과 cron은 이번 실행을 건너뜁니다.
enabledboolean기본값 true
false면 코드를 지우지 않고 작업을 끕니다.

endpoint()로 API 정의하기

endpoint()에는 클라이언트가 호출할 수 있는 것을 둡니다. 호출이 하는 일에 맞춰 종류를 고르고, 인자는 빌더로 하나씩 적습니다.
네 가지 종류
종류
HTTP
WebSocket
요청과 응답
query(Type, options?)
✓
GET으로 데이터를 읽습니다. 클라이언트는 응답을 await합니다.
mutation(Type, options?)
✓
POST로 데이터를 쓰거나 비즈니스 동작을 실행합니다.
실시간
message(Type, options?)
✓
클라이언트가 소켓으로 보내는 메시지 하나입니다. .msg()로 필드를 선언합니다.
pubsub(Type, options?)
✓
클라이언트가 구독하고 서버가 publish하는 room입니다. .room()으로 room을 정합니다.
✓이 통로로 전달해당 없음
인자 빌더
빌더는 인자 하나가 어디서 오는지 정합니다. exec는 선언한 순서대로 인자를 받고, 그 뒤에 .with() 값을 받습니다:
.param(name, Type)
필수 URL 경로 구간입니다. scalar나 enumOf 하나만 받고, model이나 배열은 받지 않습니다.
.search(name, Type)
query string 값입니다. 항상 선택 인자라서 exec가 undefined를 받을 수 있습니다.
.body(name, Type, options?)
mutation의 요청 body 값입니다. query는 body 없이 보내므로 query에는 .search()를 씁니다. { nullable: true }면 선택 인자가 됩니다.
.msg(name, Type, options?)
message나 process job의 payload 필드입니다.
.room(name, Type)
클라이언트가 들어갈 pubsub room을 정하는 키입니다.
.with(InternalArg, options?)
서버가 넣어 주는 값입니다. Self, Me, Req, Res, Ws, Ip나 직접 만든 값을 쓰고, 값이 없으면 401입니다.
선택 인자는 맨 뒤에 둡니다. 필수인 .param, .msg, .room은 .search나 nullable 인자 뒤에 올 수 없습니다.
query와 mutation
누구나 할 수 있는 읽기와, 로그인한 계정만 할 수 있는 쓰기입니다:
apps/blog/lib/story/story.signal.ts
  • 생성되는 API와 겹치지 않는 이름을 고릅니다. story 모듈에는 이미 story와 createStory가 있으니, custom endpoint는 storyBySlug처럼 자기 이름을 씁니다.
  • 호출자는 .with(Self)로 받습니다. 클라이언트가 보낸 사용자 id는 믿지 않고, service에서 소유권을 한 번 더 확인합니다.
message와 pubsub
websocket 메시지 하나와, 새 채팅을 room 안의 모두에게 알리는 pubsub입니다:
apps/blog/lib/chatRoom/chatRoom.signal.ts
  • publish는 service가 합니다. chatRoomSignal: signal<sig.ChatRoom>()를 주입하고 this.chatRoomSignal.chatAdded(roomId, chat)를 호출합니다. pubsub의 exec는 클라이언트가 구독할 때 실행됩니다.
  • guard는 endpoint에 직접 답니다. slice의 guards map은 message와 pubsub에 닿지 않으므로, 자기 guards가 없으면 누구나 보내고 구독할 수 있습니다.
  • room은 다시 검사됩니다. message는 보낼 때마다 guard를 실행합니다. 구독 중인 room은 소켓의 인증 정보가 바뀌면 guard를 다시 실행하고, 통과하지 못하면 구독을 끊습니다.
고정 경로로 제공하기
/sitemap.xml처럼 정해진 주소에 있어야 하는 파일이 있습니다. path, prefix, globalPrefix 옵션으로 endpoint를 그 주소로 옮깁니다:
apps/blog/lib/story/story.signal.ts
  • 도착하는 주소. prefix: false가 /story 구간을, globalPrefix: false가 API prefix를 빼므로 /sitemap.xml에서 응답합니다.
  • Response를 반환하면 body와 header를 직접 정할 수 있습니다. 그대로 전송됩니다.
클라이언트에서 호출하기
endpoint마다 키 이름을 딴 fetch 메서드가 생깁니다. page는 query를 바로 await하고, 브라우저에서는 store action에서 호출합니다.
storyBySlug: query(…)
Story 값을 받습니다.
publishStory: mutation(…)
발행된 Story를 받습니다. .with(Self)는 클라이언트가 넘기는 인자가 아닙니다.
readChat: message(…)
보내기만 하고 반환값은 없습니다. 응답은 fetch.listenReadChat(fn)으로 받습니다.
chatAdded: pubsub(…)
구독을 끊는 함수를 받습니다. publish될 때마다 fn이 실행됩니다.

옵션 객체

query, mutation, message, pubsub의 두 번째 인자는 모두 같은 옵션 객체입니다. 대부분은 handler가 실행되기 전에 일어날 일을 정합니다.
exec 앞에서 도는 것
fetch.publishStory(storyId)
Logging에러, debug면 모든 호출
Timeoutendpoint가 선언한 timeout ms
AccountMiddleware앱이 등록한 middleware도
guards선언 순서대로
internal 인자.with(Self) · .with(Me)
캐시 조회internal 인자가 없는 query만
exec() handler
resolveReturnhidden·secret 필드 마스킹
403 Forbidden
gatewayTimeouthandler는 계속 실행됩니다
저장된 결과guard를 통과한 뒤에만
  • 선언하기 전에는 비용이 없습니다. Logging과 Timeout은 항상 등록되어 있지만, timeout을 선언하지 않은 endpoint에서 Timeout은 비켜서고, cache를 선언하지 않으면 캐시 조회도 건너뜁니다.
  • timeout은 응답할 뿐, 취소하지 않습니다. 호출자는 base.error.gatewayTimeout을 받지만, handler는 끝까지 실행됩니다.
접근과 캐시
guardsGuardCls[]기본값 없음
모든 middleware 뒤에 선언 순서대로 실행되고, 처음 거절한 guard가 403으로 응답합니다. 없으면 아무것도 검사하지 않습니다.
mcpboolean기본값 true
false면 AI 에이전트 목록에서만 빠집니다. guard와 HTTP 제공은 그대로입니다.
timeoutnumber (ms)기본값 클라이언트 기본 30초
시간이 지나면 호출자는 base.error.gatewayTimeout을 받습니다. 클라이언트도 같은 시간만큼 기다립니다.
cachenumber (ms)기본값 캐시 안 함query
이 시간 동안 응답을 재사용합니다. .with()가 없는 query만 쓸 수 있고, guard를 통과한 뒤에 조회합니다.
nullableboolean기본값 false
null 반환을 허용합니다. 없으면 null을 반환한 handler는 에러가 됩니다.
middlewaresMiddlewareCls[]기본값 없음
이 endpoint에만 붙는 middleware입니다. 등록된 체인 뒤에 실행됩니다.
경로와 전송
method"POST" | "PATCH" | "PUT" | "DELETE"기본값 "POST"mutation
mutation의 HTTP 메서드입니다. 외부 프로토콜이 다른 메서드를 요구할 때만 바꿉니다.
pathstring기본값 endpoint 키
키 대신 쓰는 고정 경로입니다. 끝의 *는 나머지 경로 전체와 맞습니다.
prefixfalse | string기본값 모델 refName
경로 앞의 모델 구간을 다른 문자열로 바꾸거나, false로 없앱니다.
globalPrefixfalse기본값 API prefix
false면 API prefix도 뺍니다. prefix: false와 함께 쓰면 사이트 루트에 경로가 생깁니다.
fileUploadboolean기본값 falsemutation
생성된 업로드 action이 호출할 mutation임을 표시합니다. shared의 file 모듈에 이미 있습니다.
backpressure"coalesce" | "queue"기본값 "coalesce"pubsub(Binary)
구독자가 따라오지 못할 때 최신 frame만 남길지, 모든 frame을 쌓을지 정합니다.

인자로 쓸 수 있는 타입

모든 인자 빌더는 같은 네 종류의 타입을 받습니다:
종류예
↳ 참고
scalarID · String · Int · Float · Boolean · Date
akanjs/base에서 import합니다. String, Boolean, Date는 JS 전역을 그대로 씁니다.
모델cnst.StoryInput
모듈 constant의 클래스입니다. 보통 Input을 씁니다.
enumOfcnst.StoryStatus
목록에 없는 값은 거절됩니다.
배열[ID] · [cnst.StoryInput]
위의 것을 [ ]로 감쌉니다. .param에는 쓸 수 없습니다.
실수 세 가지는 미리 알아 두세요. 그중 둘은 타입 에러로 잡히지 않습니다:
Number가 아니라 Int나 Float
.body("count", Int)
개수는 Int, 가격은 Float입니다. Number는 타입 검사를 통과하지 못합니다.
Upload는 body이지 필드가 아닙니다
.body("files", [Upload])
Upload body가 있으면 요청이 multipart로 바뀌고, 업로드를 맡은 mutation은 fileUpload: true를 선언합니다. 모델은 대신 File 모델을 참조합니다.
바이트는 Any가 아니라 Binary
.body("frame", Binary)
Binary는 양쪽 모두 Uint8Array이고 base64도 받으므로 JSON과 websocket frame에 모두 맞습니다. Any는 Buffer를 되돌릴 수 없는 { type, data } 객체로 바꿔 크기가 3.6배가 되고, 첫 바이트를 읽을 때에야 깨집니다.

자동으로 생기는 모델 API

모든 database 모듈은 endpoint를 쓰지 않아도 아래 fetch 메서드를 받습니다. custom endpoint는 비즈니스 동작에 고유한 이름이 필요할 때만 씁니다.
이 메서드들은 slice의 guards map이 지킵니다. 읽기는 get이, 쓰기는 cru가 맡습니다.
생성되는 메서드
get
cru
읽기
<model>(id)
✓
full 모델을 불러옵니다.
light<Model>(id)
✓
Light 모델을 불러옵니다.
view<Model>(id)
✓
상세 화면용 데이터입니다. 구조 분해하면 필드별 promise, await하면 한꺼번에 받습니다.
edit<Model>(id)
✓
수정 폼용 데이터로, view<Model>과 같은 handle 형태입니다. create·update·remove guard가 있을 때만 생깁니다.
쓰기
create<Model>(data)
✓
input으로 하나를 만듭니다.
update<Model>(id, data)
✓
id로 하나를 수정합니다.
merge<Model>(modelOrId, data)
✓
넘긴 필드만으로 update<Model>을 호출합니다. 모델이나 id를 받습니다.
remove<Model>(id)
✓
하나를 삭제합니다. 삭제는 항상 soft delete입니다.
✓이 키의 guard가 적용해당 없음
상세 페이지는 await하지 않은 view를 Zone에 넘깁니다:
apps/blog/page/story/[storyId]/_index.tsx
  • 구조 분해하면 스트리밍, await하면 기다림. fetch.viewStory(id)는 story와 storyView를 따로 된 promise로 주고, await하면 둘을 한 번에 줍니다.
  • merge는 일부만 고칩니다. store action에서 await fetch.mergeStory(story, { title })는 title만 보냅니다.
  • create, update, remove로 쓰기 하나만 바꿉니다. 각 키는 그 메서드 하나에 대해 cru를 대신합니다. libs/shared의 user가 create: Admin을 쓰는 방식입니다.

slice: 페이지가 불러오는 목록

slice()에는 페이지가 보여 줄 목록을 선언합니다. 각 항목은 init()으로 시작해 endpoint처럼 .param(), .search(), .with() 인자를 받고, service query를 반환합니다. slice의 .body()는 지원이 중단되었습니다. 목록은 요청 body 없이 불러오므로 그 값이 전달되지 않습니다.
root 하나에 속한 story 목록이며, 누구나 읽을 수 있습니다:
apps/blog/lib/story/story.signal.ts
  • root는 항상 Admin입니다. root slice인 init<Model>(queryKey, args)를 지키는데, 이것은 모델이 선언한 어떤 filter든 실행할 수 있습니다.
  • 이름 있는 slice는 자기 guard를 init({ guards: [...] })에 적습니다. map의 get과 cru는 여기에 닿지 않습니다.
  • query를 반환만 하고 다듬지 않습니다. exec가 반환하는 query에는 .sort()나 .limit()를 붙일 수 없고, 정렬과 페이지 크기는 fetch 옵션으로 정합니다.
생성되는 fetch 메서드
slice 키가 이 메서드들의 Suffix가 됩니다. inRoot에서 storyListInRoot, initStoryInRoot 등이 생깁니다:
<model>List<Suffix>(...args, skip, limit, sort)
목록의 한 페이지를 불러옵니다.
<model>Insight<Suffix>(...args)
같은 query의 집계 값을 불러옵니다.
init<Model><Suffix>(...args, option?)
목록과 insight를 필드별 promise로 한 번에 받습니다. storyInitInRoot는 Zone에 넘깁니다.
get<Model>Init<Suffix>(...args, option?)
같은 init 데이터를 await한 객체 하나로 받습니다.
init<Model>(queryKey?, args?)
root slice입니다. queryKey는 모델의 filter 이름(없으면 any), args는 그 인자입니다.
정렬과 페이지 크기는 마지막 옵션에 넣습니다: fetch.initStoryInRoot(rootId, { sort: "latest", limit: 20 }).
page에서 쓰기
page는 두 query를 함께 시작하고, 각 결과를 필요한 곳에 넘깁니다:
apps/blog/page/root/[rootId]/_index.tsx
  • storyInitInRoot는 Zone에 넘깁니다. Zone이 이것으로 store를 채웁니다.
  • storyListInRoot는 서버에 둡니다. 모델 인스턴스를 담고 있어 클라이언트 컴포넌트의 prop이 될 수 없으므로, 서버 컴포넌트나 Load.Stream에서 읽습니다.
  • 아무것도 await하지 않습니다. 두 query가 동시에 시작되고, 각 섹션은 자기 promise가 도착하는 대로 그려집니다.

꼭 기억할 규칙

signal 파일에서 하는 실수는 대부분 이 네 가지 규칙으로 막을 수 있습니다:
  • 복사하지 말고 확장합니다. 라이브러리에 이미 있는 모델이면 다시 선언하지 말고 ...user.internals, ...user.slices, ...user.endpoints를 spread합니다.
  • 다른 service는 .with()로 가져옵니다. srv.story.with(srv.actionLog)면 모든 handler에서 this.actionLogService를 쓸 수 있습니다.
  • AI 에이전트에게 보일지는 guard가 정합니다. 따로 켜는 옵션은 없습니다. mcp: false는 guard가 있는 endpoint를 빼는 용도이고, slice의 mcp: { cru: false }는 root slice와 생성된 CRUD에 한해 guards map과 같은 키로 적습니다.
  • prompt는 page에 둡니다. endpoint()에는 prompt 빌더가 없고, 화면은 page().prompt(name, description)로 MCP prompt가 됩니다.
AI 에이전트에게 닿는 것
선언한 모습
클라이언트
AI 에이전트
에이전트에게 공개
query · guards: [Public]
✓
✓
Public도 guard이므로, 읽기는 공개됩니다.
mutation · guards: [Every]
✓
✓
실질 guard가 있는 쓰기는 공개됩니다.
제공되지만 에이전트에게는 숨김
guard 없음
✓
누구나 호출할 수 있고, 에이전트는 볼 수 없습니다.
mutation · guards: [Public]
✓
쓰기에 Public만 달면 guard가 없는 것으로 봅니다.
mcp: false
✓
일부러 에이전트 목록에서 뺀 것입니다. guard는 그대로입니다.
guards: [Every, Person]
✓
Person은 사람만 할 수 있는 동작으로 묶습니다.
message · pubsub
✓
websocket으로 동작하므로, MCP 호출에는 쓸 수 없습니다.
Any · Binary · Upload
✓
Any나 Binary 반환, 파일 업로드는 모델에게 설명할 수 없습니다.
✓호출할 수 있음보이지 않음

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

내 AI에 이 문서 연결하기

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