사람함께에이전트▾
사람 — 직접 정하고 책임지는 비즈니스 규칙과 흐름. 직접 읽어보세요.
함께 — 개념은 알아두고, 세부 규칙은 에이전트가 따릅니다.
에이전트 — 에이전트가 따르는 규칙과 레퍼런스. 필요할 때 찾아보세요.
앱 & 라이브러리▾
도메인▾
스칼라▾
model.store.ts
<model>.store.ts는 module 하나의 클라이언트 상태와, 그 상태를 바꾸는 액션을 담습니다. page와 컴포넌트는 store에서 상태를 읽고 액션을 호출할 뿐, fetch 호출을 직접 조율하지 않습니다.모델 store는 상태와 CRUD 액션을 자동으로 만들기 때문에, 대부분의 store는 거의 비어 있습니다. 직접 액션을 쓰는 경우는 다음 셋뿐입니다:
토스트
msg.success("ticket.openTicketSuccess")커스텀 endpoint 호출 전후의 로딩, 성공 메시지입니다.
낙관적 업데이트
void fetch.x(…)클라이언트 모델을 먼저 바꾸고, 요청은 기다리지 않고 보낸 뒤 반영합니다.
여러 필드 동시 쓰기
this.set({ a, b, c })함께 바뀌어야 하는 여러 상태 key를 한 번에 씁니다.
store가 맡는 일
할 일
store
*.store.ts
다른 레이어
constant · document · service · signal
UI 흐름 조율
fetch.*
✓
서버를 호출하고 그 로딩 상태를 관리합니다.
msg.*
✓
호출 전후의 토스트 메시지(로딩, 성공, 오류)입니다.
모달 · 선택
✓
어떤 모달이 열려 있고 어떤 행이 선택됐는지입니다.
폼 · 목록
✓
폼 값과 불러온 목록입니다. 모델 store는 둘 다 자동으로 만듭니다.
router.push
✓
액션이 성공한 뒤의 클라이언트 페이지 이동입니다.
비즈니스 규칙
도메인 규칙
✓
검증과 상태 전이는 constant, document, service에 둡니다.
권한 확인
✓
누가 endpoint를 부를 수 있는지는 signal의 guard가 정합니다.
✓여기에 둡니다여기에 두지 않습니다
이 페이지에서 쓰는 말
용어설명
state
store가 들고 있는 값입니다. 컴포넌트는 자신이 읽는 key가 바뀌면 다시 렌더링됩니다.
action
store 클래스의 method입니다. 컴포넌트는
st.do.<action>()으로 호출합니다.st
모든 module store를 합친 앱의 root store입니다.
@apps/<app>/client에서 import합니다.slice
<model>.signal.ts에 선언한 목록 쿼리입니다. slice마다 목록 상태와 액션이 따로 생깁니다.DataList
slice 상태가 쓰는, id로 색인된 목록 타입입니다.
list.set(x).save()로 갱신합니다.store 클래스 구조
store는
store(…)를 상속하는 클래스입니다. 첫 번째 인자에 따라 두 종류로 나뉩니다:모델 store
store(sig.ticket, () => ({ … }))모델의 signal에 묶입니다. 모델, 폼, 목록 상태와 CRUD 액션을 받습니다.
service store
store("myapp" as const, () => ({ … }))signal이 없습니다. 직접 쓴 상태와 액션만 가집니다.
인자설명
sig.<model>
모델에 묶고 상태와 액션을 자동 생성합니다. service store는
"<name>" as const를 넘깁니다.() => ({ … })
필수입니다. 상태 factory이며, 기본값은 store 인스턴스마다 새로 만들어집니다.
({ search, computed }) => ({ … })
선택입니다. 읽기 전용 파생 상태로, 아래 '쓰기 상태와 파생 상태'에서 다룹니다.
...<model>.stores
선택입니다. 같은 모델의 라이브러리 store로, 가장 먼저 합쳐집니다.
커스텀 액션 하나를 가진 모델 store입니다. import까지 모두 담았습니다:
apps/koyo/lib/ticket/ticket.store.ts
- import는 module 자신의 barrel에서 가져옵니다.
fetch,msg,sig는../useClient에서, 모델 클래스는../cnst에서 가져옵니다. - 액션 본문은 세 줄 정도입니다.
await fetch.x(),this.setTicket()같은 자동 생성 setter, 그리고 토스트 순서입니다. msg는 사전 key를 받습니다. 같은key옵션을 주면 성공 토스트가 로딩 토스트를 대신합니다.
service store도 같은 뼈대에서 시작합니다. 비어 있어도
// state, // action 표시는 남겨 둡니다:apps/myapp/lib/_myapp/myapp.store.ts
라이브러리 store 확장하기
라이브러리에도 있는 module을 앱이 가질 때(예:
libs/shared의 user), 앱 store는 라이브러리 store를 확장합니다. 상태 factory 뒤에 라이브러리 store를 나열합니다:apps/koyo/lib/user/user.store.ts
../__lib/lib.store가 목록을 만들어 둡니다. 사용하는 라이브러리에도 store가 있는 모델마다<model>.stores를 export합니다.- 라이브러리가 먼저입니다. 라이브러리의 상태와 액션이 먼저 합쳐지고, 그 위에 앱의 상태와 액션이 더해집니다.
- 파생 상태 factory는 그 앞에 둡니다. 순서는
store(sig.user, state, derived, ...user.stores)입니다.
쓰기 상태와 파생 상태
대부분의 상태는 평범한 값입니다. 상태 factory는 값을 브라우저 저장소에 두는
persist, session도 제공하고, 선택 사항인 세 번째 인자에서는 search, computed로 읽기 전용 상태를 선언합니다:apps/koyo/lib/ticket/ticket.store.ts
선언설명
menuOpen: false
메모리에 있는 평범한 값으로, store와 함께 초기화됩니다. 일반적인 UI 상태에 씁니다.
persist(Type, options?)
localStorage에 보관합니다. 새로고침 후에도 남아야 하는 값에 씁니다.session(Type, options?)
sessionStorage에 보관합니다. 현재 브라우저 세션 동안만 필요한 값에 씁니다.search(paramKey, Type, options?)
읽기 전용이며 URL 쿼리 문자열에서 읽습니다. 링크로 공유돼야 하는 필터와 탭에 씁니다.
computed(deps, selector, options?)
읽기 전용이며,
deps의 쓰기 상태가 바뀔 때 다시 계산됩니다.빌더 옵션
defaultT | () => Tpersistsessionsearch
시작 값입니다. 없으면 배열은
[], enum은 첫 값, 그 밖에는 타입의 기본값을 씁니다.nullableboolean기본값 falsepersistsessionsearch
null을 허용하고, 기본값이 없으면 null로 시작합니다.keystring기본값 상태 keypersistsession
브라우저 저장소에서 쓸 이름입니다.
equals(a, b) => boolean기본값 Object.iscomputed
다시 계산한 값을 변경으로 볼지 판단합니다.
computed는 쓰기 상태만 읽습니다.deps의 이름은 모두 같은 store의 쓰기 상태 key여야 합니다.ticketForm같은 자동 생성 key도 되지만, 다른search나computedkey는 안 됩니다.search는 기본값으로 돌아갑니다. 파라미터가 없거나 비었거나 해석할 수 없으면 기본값을 읽고, 서버 렌더링에서도 항상 기본값입니다.


파생 상태는 읽기 전용입니다.
search나 computed key에 this.set({ … })을 하면 에러가 나고, set<Key> setter도 생기지 않습니다. 대신 URL이나, 그 값이 읽는 쓰기 상태를 바꾸세요.상태 읽고 쓰기
액션 안에서는
this의 method 세 개로 모든 읽기와 쓰기를 합니다. 다음 줄이 그 값 없이는 동작할 수 없다면 pick을 씁니다:메서드설명
get()
현재 상태를 돌려줍니다. 값이
null일 수 있을 때 씁니다.pick(...keys)
반드시 있어야 하는 key를 돌려줍니다.
null, undefined, ""이면 에러를 던집니다.set(state)
상태를 씁니다. 객체는 얕게 병합하고, 함수를 넘기면 immer 사본을 직접 고칩니다.


pick은 값이 없으면 에러를 던집니다. null도 처리해야 할 정상 분기라면 get으로 읽고 일찍 return하세요.기본 모델 API
sig.<model>에 묶인 store는 아래 상태와 액션을 직접 쓰지 않아도 받습니다. 이름에는 모델 이름이 들어갑니다. ticket이라면 <model>Form은 ticketForm, create<Model>은 createTicket입니다.기본 상태
필드설명
<model>: Full | null
지금 열린 full 모델입니다(예:
ticket). 불러오기 전에는 null입니다.<model>Loading: string | boolean
처음엔
true, 요청 중에는 true나 레코드 id, 끝나면 false입니다.<model>Form: DefaultOf<Full>
레코드 하나를 만들거나 고칠 때 쓰는 폼 값입니다.
<model>FormLoading: string | boolean
폼이 열리기 전엔
true, edit<Model>이 불러오는 동안 id, 준비되면 false입니다.<model>Submit: Submit
제출 버튼용
{ disabled, loading, times }입니다.<model>ViewAt: Date
<model>을 마지막으로 불러오거나 저장한 시각입니다.<model>Modal: string | null
열린 모달입니다.
"edit", "view", 직접 정한 key, 또는 null입니다.<model>FormDraft: DraftState | null
열린 폼의 저장되지 않은 임시본(draft)입니다.
Load.Edit, Model.EditModal, Model.New가 관리합니다.기본 액션
메서드설명
create<Model>InForm(options?)
<model>Form으로 생성하고, 폼을 초기값으로 되돌린 뒤 목록에 행을 추가합니다.update<Model>InForm(options?)
<model>Form을 해당 레코드에 저장하고, 폼을 초기값으로 되돌린 뒤 목록을 갱신합니다.create<Model>(data, options?)
data로 생성합니다. 폼은 건드리지 않습니다.update<Model>(id, data, options?)
data로 레코드 하나를 수정합니다. 폼은 건드리지 않습니다.remove<Model>(id, options?)
레코드를 삭제하고 불러온 모든 목록에서 뺍니다.
check<Model>Submitable(disabled?)
폼이 유효한지에 따라
<model>Submit.disabled를 설정합니다.submit<Model>(options?)
폼에 id가 있으면
update<Model>InForm, 없으면 create<Model>InForm을 부릅니다.new<Model>(partial?, options?)
새 레코드용으로 폼을 채우고
"edit" 모달을 엽니다.edit<Model>(modelOrId, options?)
레코드를 폼에 불러오고
"edit" 모달을 엽니다.merge<Model>(modelOrId, data, options?)
update endpoint로
data를 저장하고, 캐시된 사본을 갱신합니다.view<Model>(modelOrId, options?)
레코드를
<model>에 불러오고 "view" 모달을 엽니다.set<Model>(...models)
받은 모델을
<model>과, 그 행을 가진 목록에 씁니다.reset<Model>(model?)
<model>을 비우거나 넘긴 모델로 바꾸고, 폼을 초기화하고, 모달을 닫습니다.load<Model>FormDraftrestore<Model>FormDraftdiscard<Model>FormDraft
폼 임시본을 다룹니다.
Load.Edit, Model.EditModal, Model.New가 대신 호출합니다.create<Model>…, update<Model>…, submit<Model>은 같은 옵션을 받습니다:onSuccess(model) => void | Promise<void>
저장된 모델을 받아 저장 후 실행됩니다. 페이지 이동 등에 씁니다.
onError(error: string) => void
요청이 실패하면 실행됩니다.
modalstring기본값 null
저장 후 보여 줄 모달입니다. 생략하면 모달이 닫힙니다.
sliceNamestring기본값 <model>
생성된 행을 받을 slice입니다(예:
ticketInProject).pathstring
저장된 모델을 이 상태 key에도 씁니다.
폼 setter
모델의 필드마다
<model>Form에 쓰는 setter도 생깁니다. 배열과 File 항목은 그 타입의 필드에만 생깁니다:메서드설명
set<Field>On<Model>(value)
<model>Form의 필드 하나를 씁니다. onChange에 참조로 넘깁니다.add<Field>On<Model>(value, { idx?, limit? })
배열 필드: 항목을
idx 위치에 넣습니다. 기본은 맨 끝입니다.sub<Field>On<Model>(idx)
배열 필드:
idx 위치의 항목, 또는 배열로 준 모든 위치의 항목을 뺍니다.addOrSub<Field>On<Model>(value)
배열 필드: 값이 없으면 넣고, 있으면 뺍니다.
upload<Field>On<Model>(fileList, idx?)
File 필드: 업로드한 뒤 파일이 uploading 상태를 벗어날 때까지 확인합니다.writeOn<Model>(path, value)
"payments.3.name" 같은 중첩 경로에 씁니다.필드가 바뀔 때 반응하려면 store에
_postSet<Field>를 선언합니다. 그 필드에 값이 쓰일 때마다 쓰기 직후에 실행됩니다:apps/koyo/lib/ticket/ticket.store.ts
- setter는 참조로 넘깁니다.
onChange={st.do.setTitleOnTicket}여야 컨트롤이 그 필드를 에이전트와 테스트에 공개합니다. 인라인 화살표 함수는 공개하지 못합니다. _postSet<Field>는 누가 쓰든 실행됩니다. 사람의 컨트롤, 에이전트의 툴,fill<Model>Form모두 해당하므로 어느 화면에서나 규칙이 지켜집니다.
slice 자동 생성 API
<model>.signal.ts에 선언한 slice마다 페이지, 정렬, 선택, 개수를 다루는 목록 상태와 액션이 따로 생깁니다. 다음 slice의 이름은 inProject입니다:apps/koyo/lib/ticket/ticket.signal.ts
slice 이름은 접미사가 됩니다. 모든 모델에 있는 root slice는 아무것도 붙이지 않고,
inProject는 InProject를 붙입니다:패턴설명
<model>List<Suffix>
root slice는
ticketList, inProject는 ticketListInProject입니다.init<Model><Suffix>
root slice는
initTicket, inProject는 initTicketInProject입니다.pageOf<Model><Suffix>
root slice는
pageOfTicket, inProject는 pageOfTicketInProject입니다.slice 상태
필드설명
<model>List<Suffix>: DataList<Light>
화면에 보이는 행입니다. init, refresh, 페이지 이동으로 불러옵니다.
<model>ListLoading<Suffix>: boolean
목록을 불러오는 중인지 나타냅니다.
<model>InitList<Suffix>: DataList<Light>
마지막 init 또는 refresh가 받은 행입니다.
<model>InitAt<Suffix>: Date
목록을 마지막으로 초기화한 시각입니다.
<model>Selection<Suffix>: DataList<Light>
사용자가 선택한 행입니다.
<model>Insight<Suffix>: Insight
count 같은 쿼리 집계값입니다.default<Model><Suffix>: DefaultOf<Full>
이 slice에서 여는 새 폼의 시작 값입니다.
pageOf<Model><Suffix>: number
현재 페이지이며 1부터 시작합니다.
lastPageOf<Model><Suffix>: number
count와 limit으로 계산한 전체 페이지 수입니다.limitOf<Model><Suffix>: number
페이지당 행 수이며 기본값은 20입니다.
hasMoreOf<Model><Suffix>: boolean
불러온 행 뒤에 서버에 행이 더 있는지입니다. count 대신 이 값을 읽습니다.
isCumulativeOf<Model><Suffix>: boolean
loadMoreOf… 뒤에는 true입니다. 목록이 페이지를 바꾸지 않고 쌓입니다.queryArgsOf<Model><Suffix>: Args
현재 쿼리 인자입니다.
sortOf<Model><Suffix>: Sort
현재 정렬 key이며 기본값은
"latest"입니다.slice 액션
메서드설명
init<Model><Suffix>(...args, initForm?)
이 쿼리 인자로 목록을 불러옵니다. 같은 쿼리가 이미 있으면 요청하지 않습니다.
refresh<Model><Suffix>(initForm?)
현재 목록을 서버에서 다시 불러옵니다.
select<Model><Suffix>(light | light[], { refresh?, remove? })
선택에 추가합니다.
refresh는 선택을 교체하고, remove는 뺍니다.setPageOf<Model><Suffix>(page, options?)
목록을 해당 페이지로 바꿉니다.
loadMoreOf<Model><Suffix>(options?)
이미 불러온 행 다음의 행을 이어 붙입니다. 페이지 번호는 받지 않습니다.
setLimitOf<Model><Suffix>(limit, options?)
페이지당 행 수를 바꾸고 다시 불러옵니다.
setQueryArgsOf<Model><Suffix>(...args)
쿼리 인자를 바꾸고 다시 불러옵니다.
(prev) => next 함수도 받습니다.setSortOf<Model><Suffix>(sort, options?)
정렬을 바꾸고 다시 불러옵니다.
init<Model><Suffix>와 refresh<Model><Suffix>는 마지막 인자로 initForm을 받을 수 있습니다:pagenumber기본값 현재 값, 처음엔 1
불러올 페이지입니다.
limitnumber기본값 현재 값, 처음엔 20
페이지당 행 수입니다.
sortstring기본값 현재 값, 처음엔 "latest"
filter가 선언한 정렬 key입니다.
insightboolean기본값 true
false면 count 쿼리를 생략하고, 불러온 행 수를 count로 씁니다.defaultPartial<DefaultOf<Input>>
new<Model>이 새 폼에 채울 시작 값입니다.invalidatebooleaninit: falserefresh: true
true면 항상 다시 불러오고, false면 이미 불러온 같은 쿼리를 재사용합니다.queryArgsArgsrefresh
앞쪽 쿼리 인자를 바꿉니다. 나머지 인자는 현재 값을 유지합니다.
사용 패턴
store 액션 안에서는
this의 get, pick, set과 자동 생성 액션으로 상태를 다루고, 서버는 fetch로 호출합니다:apps/koyo/lib/ticket/ticket.store.ts
- 자동 생성 액션은
this에도 있습니다.this.selectTicketInProject([], { refresh: true })는st.do로 부를 때와 똑같이 선택을 비웁니다. DataList는 배열처럼 map할 수 있습니다.ticketSelectionInProject.map(…)은 일반 배열을 돌려주며, 여기서는 id 배열입니다.
컴포넌트에서는
st.use.<key>()로 읽고 st.do.<action>()으로 액션을 호출합니다:apps/koyo/lib/ticket/Ticket.Util.tsx
st.use.<key>()는 key 하나를 구독합니다. store의 다른 값이 바뀌어도 버튼은 그대로이고,ticket이 바뀔 때만 다시 렌더링됩니다.void는 기다리지 않는 호출이라는 표시입니다.st.do액션은 promise를 돌려주며, 던져진Err는 이미 토스트로 표시됩니다.
자동 생성 setter
일반 상태 key마다
st.do.set<Key>(value)도 생깁니다. 다음 두 줄은 같은 일을 합니다:apps/koyo/lib/ticket/Ticket.Util.tsx
- 같은 이름의 액션이 우선합니다.
setTicket은 자동 생성된set<Model>액션이고setPageOfTicket은 slice 액션이라, 둘 다 단순 setter가 아닙니다. - 파생 key에는 setter가 없습니다.
search와computed상태는 읽기 전용입니다.
규칙과 흔한 실수
store를 커밋하기 전에 다음 규칙을 확인하세요:
- 액션은 값을 반환하지 않습니다. 모든 액션은
st.do를 거쳐 실행되므로 반환값에 닿을 수 없습니다. 결과는this.set()으로 상태에 씁니다. 값 없는return;guard는 괜찮습니다. - 목록은 DataList API로 갱신합니다. 배열 spread 대신
this.set({ ticketList: ticketList.set(ticket).save() })처럼 씁니다. - mutation 뒤에는 자동 생성 액션을 씁니다.
this.setTicket(await fetch.x())한 줄이 열린 모델과, 그 행을 가진 불러온 목록을 모두 갱신합니다. - 액션에는
try/catch를 쓰지 않습니다. 던져진Err는 자동으로 토스트로 표시됩니다. 클라이언트 쪽 검사가 실패하면 throw 대신msg.error("<key>")를 부르고 일찍 return합니다. - 반드시 있어야 하는 상태는
pick,null도 정상인 분기는get으로 읽습니다. '상태 읽고 쓰기'를 참고하세요. - 내 상태를 더하기 전에 라이브러리 store부터 확장합니다.
store()에...<model>.stores를 넘긴 뒤 앱 전용 상태와 액션을 더합니다.
다른 store에 닿기
다른 store의 액션을 부르거나 그 상태를 다룰 때는
this를 RootStore 타입으로 봅니다.libs/shared/lib/user/user.store.ts
- 캐스팅은 이미 있는 것을 타입에 알려 줄 뿐입니다. 실행 중에는 모든 store가 하나의 root로 합쳐지므로,
(this as unknown as RootStore)로 어느 액션이든 부르고.set({ … })/.get()으로 어느 상태든 다룹니다. import type으로만 가져옵니다.st.ts가 모든 store를 import하므로, 값으로 import하면 순환이 생깁니다.