Model.Util.tsx

Util 파일에는 모듈의 작은 클라이언트 컴포넌트를 둡니다. 삭제 버튼, 툴박스, 다이얼로그 트리거, 필터 컨트롤, 뒤로 가기 링크처럼 각자 동작 하나를 맡습니다.
클릭과 store 액션을 여기에 모으면 Unit과 View는 서버 렌더링으로 남고, Page, Zone, Template도 각자 역할에 집중합니다.
항상 클라이언트 파일
"use client";
"use client"는 파일 역할에 따라 항상 첫 줄에 둡니다. Util은 클릭, hook, store를 다루기 위한 파일입니다.
동작 이름으로 짓습니다
Project.Util.Remove
Remove, Resolve, SetOrg처럼 모델 이름 없이 동작 이름으로 짓습니다. 모델 이름은 네임스페이스가 붙여 줍니다.
모델이 아니라 id를 받습니다
projectId: string
모델 prop은 클래스 인스턴스째로 서버와 클라이언트의 경계를 넘게 됩니다. 나머지 값은 store에서 읽습니다.
호출만 하고 판단하지 않습니다
st.do.resolveReport(reportId)
store 액션이나 Model 래퍼를 호출할 뿐입니다. 누가 할 수 있고 무엇이 바뀌는지는 service와 document가 정합니다.
이 페이지에서 쓰는 말
Model.EditModel.Remove
akanjs/ui가 제공하는 컴포넌트로, 모듈의 자동 생성된 수정·삭제 흐름을 대신 실행합니다.
st.dost.use
클라이언트 store입니다. st.do.x()는 액션을 실행하고, st.use.x()는 키 하나를 읽어 값이 바뀌면 다시 그립니다.
fetch.slice.<name>
래퍼가 다룰 모델과 목록을 알려 주는 slice 메타데이터입니다. 요청을 보내지 않습니다.
query args
slice 목록을 불러올 때 쓴 인자입니다. 티켓 목록을 거르는 프로젝트 id 목록이 그 예입니다.

파일 규칙

Util 파일은 모두 같은 모양입니다. akan create-module product를 실행하면 Remove export 하나가 든 첫 파일을 만들어 줍니다:
apps/koyo/lib/product/Product.Util.tsx
  • fetch.slice.product는 요청을 보내지 않습니다. Model.Remove에게 어떤 모델을 지울지 알려 주는 slice 메타데이터입니다.
  • l("base.remove")는 공용 문구입니다. 모든 모듈이 함께 쓰는 문구는 base.*에, 모듈 고유의 문구는 <model>.*에 있습니다.
  • 직접 import할 수 있는 외부 패키지는 이름이 react로 시작하는 것뿐입니다. react-icons는 괜찮고, 다른 외부 패키지는 lib에서 re-export한 것을 가져옵니다.
파일에 담긴 규칙
lib/<model>/<Model>.Util.tsx
모듈의 다른 파일 옆에 둡니다. 서비스 모듈에도 둘 수 있고, 스칼라 모듈에는 둘 수 없습니다.
"use client"
항상 import 위 첫 줄에 둡니다. Template과 Zone도 같고, Unit과 View에는 절대 두지 않습니다.
RemoveToolboxSetOrgQueryMakerInSelfBackButton
named export만 씁니다. 호출하는 쪽이 Project.Util.Remove로 쓰므로 이름에 모델명을 반복하지 않습니다.
interface RemoveProps
컴포넌트 바로 위에 선언하고 컴포넌트 이름을 따릅니다. id와 단순한 값만 받습니다.
@apps/<app>/client
fetch, st, usePage는 import 한 줄로 가져옵니다. UI 조각은 akanjs/ui에서 가져옵니다.

Model 래퍼로 만드는 동작

Util 대부분은 Model 래퍼를 감싼 얇은 컨트롤입니다. 툴박스는 여러 래퍼를 한데 모아, 그것을 보여 주는 Unit이나 Zone을 작게 유지합니다.
Model.Edit
Edit 버튼을 그리고, 누르면 children으로 받은 Template을 수정 모달에 엽니다.
Model.Remove
children이 트리거가 됩니다. 확인을 받은 뒤 레코드를 삭제합니다.
Model.SureToRemove
레코드의 name을 보여 주는 더 엄격한 삭제입니다. typeNameToRemove를 주면 이름을 다시 입력해야 합니다.
드롭다운 메뉴 안의 프로젝트 툴박스입니다. 삭제 항목은 소유자에게만 보입니다:
apps/koyo/lib/project/Project.Util.tsx
  • 래퍼가 자동 생성된 액션을 호출합니다. Model.Edit는 st.do.editProject를, Model.SureToRemove는 st.do.removeProject를 실행하므로 Util이 핸들러를 따로 쓰지 않습니다.
  • 직접 만든 동작은 st.tool을 스스로 선언합니다. 보관 버튼은 id를 넘겨 그 툴의 callable을 호출하므로, 사용자의 클릭과 에이전트가 같은 핸들러를 실행합니다.
  • 소유자 전용 항목은 cond ? … : null로 씁니다. isOwner prop 덕분에 툴박스를 그리는 쪽에서 조건이 보입니다.

다이얼로그와 모달 동작

동작 전에 확인이나 작은 입력이 필요하면, 그 다이얼로그도 같은 Util에 둡니다. 먼저 열림 상태를 어디에 둘지 정합니다:
다이얼로그 안에
<Dialog> + useState
Dialog가 스스로 열고 닫습니다. 이 다이얼로그만 쓰는 임시 값은 useState에 둡니다.
store에
st.use.reportModal()
edit<Model>(id, { modal })가 <model>Modal 키에 값을 쓰므로, 어느 컴포넌트나 액션에서든 열고 닫을 수 있습니다.
로컬 상태: SetOrg
SetOrg는 다이얼로그에서 조직을 고른 뒤 사업자 등록증에 저장합니다:
apps/koyo/lib/bizLicense/BizLicense.Util.tsx
  • 여기서는 useState를 써도 됩니다. 고른 id는 이 다이얼로그에만 속한 임시 값입니다. 서버 데이터는 절대 useState에 두지 않습니다.
  • Field.ParentId는 연결할 레코드를 고릅니다. fetch.slice.orgInSelf에서 선택지를 불러오고, 고른 id를 onChange에 넘깁니다.
  • Dialog.Action은 모달 하단을 채웁니다. 조직을 고르기 전까지 저장 버튼은 비활성 상태입니다.
store 상태: Resolve
Resolve는 모달 키를 store에 두므로, store 액션이 모달을 엽니다:
apps/koyo/lib/report/Report.Util.tsx
  • editReport(id, { modal })는 레코드를 불러오고 모달 이름을 정합니다. reportForm을 채우고, 넘긴 이름을 reportModal에 씁니다.
  • 키에 id를 넣습니다. 목록이 Resolve 버튼을 여러 개 그려도 resolve-${reportId} 덕분에 행마다 모달이 따로 열립니다.
  • resetReport가 모달을 닫습니다. report, reportForm, reportModal을 비우며, onCancel에 그대로 넘기면 됩니다.

쿼리와 경로 도우미

필터 컨트롤과 현재 경로에 따라 달라지는 도우미도 Util입니다. store나 경로를 읽고, 자동 생성된 액션이나 router 도우미를 호출합니다.
st.use.queryArgsOf<Model><Suffix>()
slice 목록을 마지막으로 불러온 인자입니다. slice 인자 순서대로 담긴 배열입니다.
st.do.setQueryArgsOf<Model><Suffix>(...args)
slice 인자마다 값을 하나씩 받고, 목록과 insight를 1페이지부터 다시 불러옵니다.
st.use.path()
언어 접두사를 뺀 현재 경로입니다. /board/abc/post/1 같은 값입니다.
Link.Back
akanjs/ui의 래퍼로, 누르면 router.back()을 호출합니다.
필터 바꾸기
QueryMakerInSelf는 ticketInSelf 목록의 프로젝트 필터는 그대로 두고 담당자 필터만 비웁니다:
apps/koyo/lib/ticket/Ticket.Util.tsx
  • 인자는 slice 인자마다 하나씩 펼쳐 넘깁니다. 배열 하나로 감싸면 그 배열 전체가 첫 번째 인자로 들어갑니다.
  • 갱신 함수도 넘길 수 있습니다. setQueryArgsOfTicketInSelf((projectIds, userIds) => [projectIds, []])는 현재 인자로 다음 인자를 만듭니다.
경로 읽기
BackButton은 특정 게시판 아래의 페이지에서만 뒤로 가기 링크를 보여 줍니다:
apps/koyo/lib/board/Board.Util.tsx
  • { agent: false }는 이 키를 에이전트에게 공개하지 않습니다. 경로는 무엇을 그릴지 정하는 데만 쓰이므로 인페이지 에이전트가 읽을 이유가 없습니다.
  • 앞쪽의 return null은 가드 절입니다. 이렇게 일찍 빠져나올 때만 쓰고, 그 밖에서는 cond ? <X /> : null로 씁니다.

규칙과 흔한 실수

Util에 두는 것과, 나머지를 맡는 파일을 한눈에 정리했습니다:
할 일
Util
화면
폼
로직
Util의 일
onClick → st.do.*
✓
store 액션 하나를 실행하는 버튼입니다.
Model.Edit · Model.Remove
✓
자동 생성된 수정·삭제 흐름을 여는 래퍼입니다.
Dialog · Modal
✓
다이얼로그 트리거와, 그 다이얼로그만 쓰는 임시 값입니다.
setQueryArgsOf…
✓
slice 목록의 query args를 바꾸는 필터 컨트롤입니다.
st.use.path · Link.Back
✓
현재 경로를 읽어 무엇을 보여 줄지 정하는 도우미입니다.
다른 파일의 일
필드와 마크업
✓
행 하나는 Unit이, 레코드 하나의 상세는 View가 그립니다.
Field.* · <model>Form
✓
필드가 store에 묶인 폼입니다.
권한과 변경 내용
✓
업무 규칙은 서버의 service와 document에서 실행됩니다.
여러 단계의 비동기 흐름
✓
Util이 한 줄로 호출하는 store 액션입니다.
✓여기에 둡니다여기에 두지 않습니다
Util 작성 규칙
  • 문구는 l을 거칩니다. usePage()에서 꺼내 l("model.key")나 l.trans({ en, ko })로 쓰고, 동작 문구를 하드코딩하지 않습니다.
  • 호출만 하고 판단하지 않습니다. st.do 액션이나 Model 래퍼를 호출하고, 업무 규칙은 service와 document에 둡니다.
  • useState는 UI에만 필요한 값에 씁니다. 열린 다이얼로그, 고른 옵션, 입력 중인 값은 괜찮고 서버 데이터는 안 됩니다.
  • props는 명시적으로 둡니다. 동작이 어떤 id, slice, 역할, 이름에 기대는지 호출하는 쪽에서 보여야 합니다.
  • 큰 툴박스는 나눕니다. 큰 툴박스나 작업 흐름 모달은 한 컴포넌트에 몰아넣지 말고 named export로 나눕니다.
  • 직접 만든 버튼은 에이전트에게 공개합니다. Model 래퍼는 에이전트 툴을 스스로 선언하지만, 일반 버튼은 옆에 st.tool(…)을 선언하고 그 callable을 onClick에 넘기기 전까지 아무것도 공개하지 않습니다.
흔한 실수
실수
↳ 이렇게 고칩니다
{isOwner && <Remove />}
조건부 렌더링은 isOwner ? <Remove /> : null 형태로 씁니다.
onChange={(v) => st.do.setNameOnX(v)}
setter를 그대로 넘깁니다. 화살표 함수로 감싸면 에이전트가 필드를 못 보고 lint도 실패합니다.
useEffect(() => { … }, [])
데이터는 page에서 불러와 내려 줍니다. 마운트 시점 로드는 akan quality ssr이 경고합니다.
fetch.initTicketInSelf()
클라이언트 파일의 fetch.init*는 lint가 막습니다. st.do.initTicketInSelf()로 다시 불러옵니다.
<div>…markup only…</div>
클릭, hook, store가 없는 Util은 서버가 할 일입니다. Unit이나 View로 옮깁니다.
함께 볼 페이지

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

내 AI에 이 문서 연결하기

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