model.constant.ts

이 파일 하나가 비즈니스 객체 하나의 모양을 정합니다. 저장 스키마, 생성되는 CRUD, 폼 상태, API 계약, 관리자 explorer, AI 에이전트가 읽는 스키마가 모두 여기서 나오므로, 모듈의 다른 파일은 필드를 다시 적지 않습니다.
필드를 추가·변경·삭제할 때, 그리고 모델에 표시나 판정 로직이 필요할 때 이 파일을 엽니다.
이 페이지에서 쓰는 말
document
티켓 하나처럼, 저장된 모델 레코드 한 건입니다. 이 페이지에서는 도큐먼트라고 부릅니다.
relation
타입이 다른 모델(예: File)인 필드로, 이 페이지에서는 관계 필드라고 부릅니다. id를 저장하고 읽을 때 모델을 불러옵니다.
scalar
lib/__scalar/ 아래에 선언한 값 객체(스칼라)입니다. 따로 행을 두지 않고 도큐먼트 안에 저장됩니다.
hydrate
가져온 평범한 데이터를 메서드와 Dayjs 날짜를 갖춘 모델 인스턴스로 되살리는 일입니다.
projection
{ secret: true }처럼, 기본으로는 읽지 않는 필드를 더 읽어 오라는 읽기 옵션입니다.
agent
AI 호출자입니다. 인페이지 에이전트와 MCP 클라이언트를 함께 이릅니다.
클래스 다섯 개, 항상 이 순서로
비어 있는 것이 있어도 다섯 개를 모두 쓰고, 각각 via()로 만듭니다. 모듈의 다른 파일은 이 클래스들을 이름으로 가져다 씁니다.
TicketInput
사용자가 모델을 만들거나 고칠 때 채우는 필드입니다.
TicketObject
Input에 시스템이나 service가 관리하는 저장 필드를 더한 것입니다.
LightTicket
목록, 관계 필드, 카드가 돌려주는 일부 필드입니다. 서버와 클라이언트가 모두 들고 있습니다.
Ticket
Object와 Light를 합친 전체 모델입니다. 목록 단위 헬퍼는 여기에 static으로 둡니다.
TicketInsight
대시보드용 카운터입니다. count는 늘 들어 있고, 비어 있어도 클래스는 씁니다.
문의 티켓 모델의 파일 전체입니다:
apps/koyo/lib/ticket/ticket.constant.ts
  • as const 두 개가 실제로 일을 합니다. enumOf 배열에 붙은 것은 값을 string[]이 아닌 union 타입으로 만들고, Light 튜플에 붙은 것은 Light에 어떤 key가 있는지 via()에 알려 줍니다.
  • TypeScript enum 키워드는 쓰지 않습니다. 열거형은 enumOf로 만듭니다. 값 union은 TicketStatus["value"], 값 목록은 TicketStatus.values입니다.
  • 비즈니스 의미가 뻔하지 않은 필드에만 짧은 꼬리 주석을 답니다. 위의 due가 그 예입니다. 이 주석은 필드 옆에 둡니다. abstract는 필드 목록이 아니라 불변식을 담는 곳입니다.

필드 옵션

field(Type, options)는 저장 필드 하나를 선언합니다. 먼저 타입을, 그다음 옵션 객체 하나를 받으며, 옵션 객체는 생략할 수 있습니다.
타입
StringBooleanDate
JavaScript 전역이라 import가 필요 없습니다. Date 필드는 읽을 때 Dayjs로 나옵니다.
IntFloat
akanjs/base의 정수와 소수입니다. Number를 필드 타입으로 쓰면 타입 검사에서 막힙니다.
ID
다른 도큐먼트의 id입니다. 가리키는 모델은 ref 옵션으로 적습니다.
Any
형식이 자유로운 값입니다. 내용이 정말로 정해져 있지 않을 때만 씁니다.
TicketStatus
enumOf 클래스입니다. 저장되는 값은 그 목록 중 하나여야 합니다.
[T]
이 표에 있는 타입의 배열입니다. 기본값은 []입니다.
Map
문자열 key의 Map입니다. 값 타입은 of 옵션으로 반드시 적습니다.
Coordinate
스칼라 클래스입니다. 도큐먼트 안에 들어가는 값 객체입니다.
File
모델 클래스이므로 관계 필드가 됩니다. id를 저장합니다.
BinaryUpload
모델 필드가 될 수 없습니다. 바이트는 File 모델을 참조해 저장합니다.
값과 참조
defaultT | (doc) => T기본값 배열이면 [], 아니면 null
단순한 값은 리터럴로, 새로 만들어야 하는 값은 () => dayjs() 같은 thunk로 씁니다.
refstring
관계 필드 대신 id를 저장할 때, 그 ID 필드가 가리키는 모델입니다.
refPathstring
소유자가 여러 모델 중 하나일 때, 그 모델 이름을 담은 옆 필드입니다. 보통 enumOf이고 removeWithAny일 때만 String입니다.
of스칼라나 모델 클래스
Map 필드의 값 타입입니다. Map에는 반드시 적습니다.
refType"child" | "parent" | "relation"
관계의 종류를 스키마 문서에 표시하는 라벨입니다. 동작은 바꾸지 않습니다.
검색, cascade, 에이전트
text"title" | "desc" | "tag" | "thumb" | "filter"
이 역할로 필드를 전문 검색 인덱스에 넣습니다. 아래 텍스트 검색 필드에서 다룹니다.
cascade"removeRef" | "removeWith" | "removeWithAny"
관계의 어느 쪽이 다른 쪽과 함께 삭제되는지 정합니다. 아래 cascade 삭제 필드에서 다룹니다.
visualboolean기본값 false
페이지는 그리지만 에이전트는 보지 못하는 필드입니다. 줄여 쓰면 field.visual(T)입니다.
검증
validate(value, doc) => boolean
도큐먼트를 만들거나 저장할 때 실행되고, false면 쓰기를 거부합니다. null·undefined는 건너뜁니다.
immutableboolean기본값 false
도큐먼트 저장으로 값을 바꾸면 예외가 납니다. 쿼리 단위 쓰기는 검사하지 않습니다.
minnumber
스키마 문서와 sampleOf()가 쓰는 하한입니다. 실제로 막으려면 validate를 씁니다.
maxnumber
상한이며, 쓰이는 방식은 같습니다.
minlengthnumber
스키마 문서에 표시되는 길이 하한입니다. 배열 필드라면 store가 보내기 전에 항목 수를 검사합니다.
maxlengthnumber
길이 상한이며, 다루는 방식은 같습니다.
샘플과 카운터
exampleT
스키마 문서와 API explorer의 예시 요청·응답에 쓰이는 샘플 값입니다.
type"email" | "password" | "url"
sampleOf()가 그럴듯한 이메일, 비밀번호, URL을 만들게 합니다. 값을 검증하지는 않습니다.
accumulate쿼리 객체
Insight 필드에만 씁니다. 이 카운터가 셀 조건이며, {}는 매치 전부를 셉니다.
옵션 객체에 없는 것
  • .optional()은 옵션이 아니라 체인 메서드입니다. 저장 타입뿐 아니라 선언된 타입까지 T | null로 넓히기 때문입니다.
  • .meta()도 체인 메서드입니다. 필드에 메타데이터를 붙입니다. summary 카운터는 getQueryMeta(…)를 넘겨, 대시보드 타일을 누르면 목록이 그 조건으로 걸러지게 합니다.
  • 나머지는 호출 방식이 정합니다. nullable, select, enum, 필드 종류는 .optional(), field.hidden / field.secret, enumOf 타입에서 옵니다. 빈 문자열 기본값인 default: ""도 nullable을 켭니다.

hidden, secret, visual

field()의 변형 세 가지가 값을 누가 받는지 정합니다. hidden과 secret은 비밀 때문에 쓰며, 값이 서버를 떠나지 않습니다. visual은 비용 때문에 쓰며, 페이지는 값을 받지만 AI 에이전트는 받지 않습니다.
선언
서버 기본 읽기
페이지
AI 에이전트
일반
field(T)
✓
✓
✓
평범한 저장 필드입니다. 모든 쪽이 값을 받습니다.
비밀: 값이 서버에만 남습니다
field.hidden(T)
✓
서버가 저장하고 읽지만 클라이언트로는 보내지 않습니다. 항상 nullable입니다.
field.secret(T)
hidden과 같고, 서버의 기본 읽기에서도 빠집니다. projection으로 요청해야 읽힙니다.
비용: 에이전트만 받지 않습니다
field.visual(T)
✓
✓
페이지에는 평소대로 가고, 인페이지 에이전트의 읽기, MCP 결과와 MCP 스키마에서는 빠집니다.
✓값을 받음값이 빠짐
  • hidden은 내부 상태에 씁니다. 관리자 메모나 파일의 mimetype처럼, 도큐먼트는 들고 있지만 어떤 화면도 보여 주지 않는 값입니다.
  • secret은 인증 정보와 개인정보에 씁니다. 비밀번호 해시, 전화번호, 토큰이 그 대상입니다. 값은 pickById(id, { secret: true }) 같은 projection으로만 다시 읽으며, projection은 서버의 읽기만 넓힐 뿐 응답을 넓히지 않습니다.
  • visual은 AI 모델에게 쓸모없는 큰 데이터에 씁니다. blur placeholder, 렌더링된 HTML 본문, 직렬화된 도형처럼 레코드마다 수백 토큰을 먹는 값입니다. 저장, 검색, 폼, 페이지 응답은 그대로이고, visual 때문에 요청이 거부되는 일은 없습니다.
  • 화면이 그 값을 필요로 하면 hidden도 secret도 아닙니다. AI 모델에게 부담만 주지 않으면 되는 값이라면 visual입니다.
공용 File 모델은 hidden과 visual을 함께 씁니다:
libs/shared/lib/file/file.constant.ts
공용 User 모델에서 계정 정보를 secret으로 둔 부분입니다:
libs/shared/lib/user/user.constant.ts

인스턴스와 로직

표시와 판정 로직은 Light 클래스의 메서드로 둡니다. 서버와 클라이언트가 모두 Light를 들고 있으므로, 거기 쓴 메서드 하나를 페이지, 카드, store action, service에서 똑같이 부를 수 있습니다.
Light<Model>
레코드 하나에 대한 메서드입니다. 화면에 보일 문구와 판정 로직을 둡니다.
<Model> static
레코드 목록에 대한 헬퍼입니다.
<Scalar> static
값을 저장한 쪽이 아니라 값 자체에 속하는 계산입니다.
board 모델 파일 하나에 앞의 두 가지가 모두 들어 있습니다:
apps/koyo/lib/board/board.constant.ts
  • Light 메서드는 Light의 key만 읽습니다. isPrivate()와 canWrite()가 policy와 roles를 쓰므로 둘 다 튜플에 들어 있습니다.
  • 가장 자주 놓치는 규칙입니다. 이 규칙을 건너뛰면 ticketIsOverdue(ticket) 같은 함수로 가득한 util 모듈이 생겨납니다.
  • 스칼라도 같은 방식으로 나눕니다. libs/util의 Coordinate는 거리와 범위 계산을 static으로 들고 있습니다. 그 계산은 값을 저장한 쪽이 아니라 값 자체에 속하기 때문입니다.
인스턴스 복사하기
hydrate된 인스턴스의 Date 필드는 인스턴스 자신의 속성(own property)이 아니라 prototype에 있는 접근자(accessor)입니다. 인스턴스는 native Date를 symbol 아래에 두었다가, 처음 읽을 때 타입이 약속한 Dayjs를 만듭니다.
Date 필드가 빠집니다
Object.keys(user) · { ...user }
own property만 읽으므로 날짜가 빠집니다.
Date 필드가 들어갑니다
"createdAt" in user · for...inJSON.stringify(user)plainFieldsOf · immerify · deepObjectify
prototype까지 훑으므로 날짜가 들어 있습니다.

텍스트 검색 필드

필드에 text 역할을 주면 전문 검색 인덱스에 들어가며, 설정은 그 선언이 전부입니다. 결과 순위를 매길 때 역할마다 가중치가 다르므로, 값의 성격에 맞는 역할을 고릅니다.
역할가중치받는 타입
↳ 담는 값
"title"10String
사람이 눈으로 훑는 한 줄입니다. 이름이나 제목이 여기에 해당합니다.
"tag"3String
카테고리나 라벨 같은 키워드 목록입니다.
"desc"1String
본문이나 설명 같은 줄글입니다.
"filter"0String, ID, 관계 필드
상태, 역할, 소유자처럼 범위를 좁히는 값입니다. 매치는 되지만 제목 매치를 앞지르지 못합니다.
"thumb"—String, ID, 관계 필드
검색 결과를 그릴 수 있게 함께 저장됩니다. 인덱스에 들어가지 않으므로 매치되지 않습니다.
공용 Banner 모델이 다섯 역할을 모두 씁니다. 나머지 필드는 생략했습니다:
libs/shared/lib/banner/banner.constant.ts
  • 배열, 문자열 enum, 내장 스칼라도 됩니다. 문자열 배열은 그대로 인덱스에 들어가고, 스칼라 객체 배열은 leaf key 기준으로 들어갑니다. leaf가 배열이어도 됩니다.
  • Map과 중첩 배열에는 text 역할을 붙일 수 없고, Map 값 안의 필드도 인덱스에 들어가지 않습니다. 값을 읽어 올 고정된 경로가 없기 때문입니다.
  • 가중치는 기본값일 뿐입니다. 쿼리마다 q.search()에 weights를 넘기거나 columns를 좁힐 수 있습니다.
  • 검색은 모든 데이터베이스 모드에서 동작합니다. 같은 텍스트라면 SQLite와 Postgres가 같은 도큐먼트를 찾고, Postgres에서는 순서만 다를 수 있습니다.

cascade 삭제 필드

cascade는 관계의 어느 쪽이 다른 쪽과 함께 삭제되는지 정합니다. 두 방향 모두 같은 모양의 필드에 붙을 수 있어서, 값을 바꿔 적으면 눈에 띄는 버그가 아니라 데이터 손실이 됩니다.
값붙이는 곳
↳ 뜻
removeRef소유자 자신의 관계 필드
이 도큐먼트가 삭제되면, 필드가 가리키는 대상도 함께 삭제됩니다.
removeWith자식이 소유자를 가리키는 필드
소유자가 삭제되면, 이 도큐먼트도 함께 삭제됩니다.
removeWithAny자식 쪽 필드, 소유자가 어떤 모델이든 될 수 있을 때
소유자가 어떤 모델이든, 소유자가 삭제되면 이 도큐먼트도 함께 삭제됩니다.
removeRef: 소유자 쪽에 붙입니다
Story가 이미지를 소유합니다
Story 삭제
가리키던 File도함께 삭제
소유자가 들고 있는 관계 필드에 붙이며, 배열도 됩니다:
apps/koyo/lib/story/story.constant.ts
  • 관계 필드에만 붙습니다. String, ID, 스칼라는 삭제할 도큐먼트를 가리키지 않습니다.
  • 대상을 혼자 소유한다는 선언입니다. 다른 도큐먼트가 아직 대상을 참조하는지는 검사하지 않습니다. 특히 File은 origin으로 중복을 없애므로, 부모 둘이 한 행을 공유할 수 있습니다.
removeWith: 자식 쪽에 붙입니다
세션을 지우면 채팅도 함께 지워집니다
AgentSession 삭제
이를 가리키는 SessionChat도함께 삭제
자식이 자기 소유자를 가리키는 필드에 붙입니다. 여기서는 ref를 단 ID입니다:
apps/koyo/lib/sessionChat/sessionChat.constant.ts
소유자가 여러 모델 중 하나일 수 있다면, 그 모델 이름을 나열한 enumOf 필드를 refPath로 가리킵니다. 자유 문자열로는 소유자 후보를 미리 알 수 없으므로 반드시 enum이어야 합니다:
apps/koyo/lib/reaction/reaction.constant.ts
  • 소유자는 자식의 존재를 몰라도 됩니다. 그래서 lib을 건드리지 않고도 앱 모델이 lib 모델과 함께 삭제되게 만들 수 있습니다.
  • 받는 모양은 세 가지입니다. 관계 필드, ref를 단 ID, refPath를 단 ID입니다. 배열, Map, ref와 refPath를 함께 쓴 필드는 안 됩니다.
removeWithAny: 소유자가 어떤 모델이든
소유자가 앱의 어떤 모델이든 될 수 있다면, refPath는 소유자의 모델 이름을 담는 평범한 String 필드를 가리킵니다:
apps/koyo/lib/comment/comment.constant.ts
  • 대가가 있습니다. 앱의 모든 삭제가 이 필드를 인덱스 조회로 한 번씩 확인하고, 앱 전체의 cascade가 쿼리 한 번이 아니라 도큐먼트 하나씩 삭제하게 됩니다.
  • 소유자 후보를 안다면 enumOf와 removeWith를 씁니다. removeWithAny에는 enumOf 타입 필드를 쓸 수 없습니다.
모든 cascade에 공통인 점
  • 대상의 _postRemove도 실행됩니다. cascade는 대상의 service를 거쳐 삭제하므로, File을 삭제하면 저장소의 파일까지 지워집니다.
  • 쿼리 단위 삭제는 hook을 거치지 않으므로 cascade도 돌지 않습니다. remove<Filter>, removeMany, removeById가 그렇습니다. cascade가 걸린 도큐먼트는 하나씩 삭제합니다.

resolve 필드

어떤 값은 레코드와 그것을 보는 사람 둘 다에 속합니다. 이 사용자가 story에 좋아요를 눌렀는지, 몇 번 읽었는지, 수정할 수 있는지 같은 값입니다. 이런 값을 도큐먼트에 저장하면 보는 사람마다 행이 하나씩 필요합니다.
constant가 이름과 타입을 정합니다
like: resolve(Int)
Light나 전체 모델의 resolve 콜백 안에 선언합니다.
internal signal이 계산합니다
like: resolveField(Int).with(Self)
요청마다 실행되며, 필요한 호출자 정보를 받아 계산합니다.
story의 Light가 resolve 필드 두 개를 선언합니다:
apps/koyo/lib/story/story.constant.ts
story의 internal signal은 요청한 사람을 기준으로 like를 계산합니다. view도 같은 방식으로 씁니다:
apps/koyo/lib/story/story.signal.ts
  • 도큐먼트가 먼저 옵니다. exec는 story를 받고, 이어서 .with() 값을 순서대로 받습니다.
  • self는 null일 수 있습니다. 로그인하지 않은 방문자에게는 Self가 없으므로 0 같은 기본값으로 답합니다.
  • .with(srv.actionLog)로 다른 service를 가져옵니다. this.actionLogService로 쓰며, countByTarget은 그 모델의 byTarget 필터에서 생성된 count 메서드입니다.
  • text 역할은 붙일 수 없습니다. 저장된 값이 없으니 검색용 사본에 옮길 것도 없습니다.

라이브러리 모델 확장하기

라이브러리 모델을 가져다 쓰는 앱은 그 모델을 다시 선언하지 않고 확장합니다. 각 via() 호출 끝에 라이브러리의 클래스를 spread하면, 앱 자신의 필드가 물려받은 필드 옆에 나란히 놓입니다:
apps/koyo/lib/user/user.constant.ts
  • user는 앱의 생성 파일 lib/__lib/lib.constant.ts에서 옵니다. 라이브러리 모듈과 이름이 같은 앱 모듈마다 이런 export가 하나씩 생기며, 라이브러리의 클래스를 inputs, objects, lights, models, insights 배열 다섯 개에 나눠 담고 있습니다.
  • 앱이 더하는 것만 선언합니다. 여기서는 favoriteFlavor이고, 라이브러리의 필드는 모두 따라옵니다.
  • Light key와 메서드는 합쳐집니다. ["roles"]는 라이브러리 Light의 key에 더해지고, 라이브러리의 Light·모델 메서드도 그대로 쓸 수 있습니다.

실전 규칙

constant 파일을 커밋하기 전에 확인할 것들입니다.
  • 클래스 다섯 개를 순서대로 씁니다. 비어 있는 Insight까지 쓰고, 모든 enumOf 배열과 Light 튜플에 as const를 붙입니다.
  • 로직은 모델에 둡니다. 표시·판정 로직은 Light<Model>에, 목록 헬퍼는 전체 모델의 static에 두고, util 모듈에는 두지 않습니다.
  • non-null 단언은 쓰지 않습니다. ?., early return, type predicate로 좁히고, hidden·secret 값은 undefined가 아니라 null임을 기억합니다.
  • 주석은 비즈니스 의미에만 답니다. 의미가 뻔하지 않은 필드에 짧은 꼬리 주석을 달고, 그 밖에는 달지 않습니다.
  • 다른 constant는 파일 경로로 import합니다. barrel이 아니라 ../file/file.constant처럼 씁니다. deep import 금지 규칙의 공식 예외입니다.
자주 하는 실수
field(Number)
field(Int)나 field(Float)로 씁니다. Number는 타입 검사를 통과하지 못합니다.
enum TicketStatus { … }
enumOf("ticketStatus", [...] as const)로 씁니다. TypeScript enum은 필드 타입이 될 수 없습니다.
default: dayjs()
default: () => dayjs()로 씁니다. 그냥 dayjs()는 한 번만 실행되어 모든 행이 그 순간을 공유합니다.
ticketIsOverdue(ticket)
서버와 클라이언트가 함께 들고 있는 LightTicket에 두고 ticket.isOverdue()로 부릅니다.
{ ...user }
new cnst.User().set(user)로 씁니다. spread는 Date 필드를 전부 빠뜨립니다.
field(Binary)
field(File)로 씁니다. 바이트는 도큐먼트에 저장할 수 없고, File은 됩니다.
user.phone === undefined
user.phone ?? ""로 씁니다. hidden·secret 값은 undefined가 아니라 null로 옵니다.

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

내 AI에 이 문서 연결하기

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