| type | guide | ||||||
|---|---|---|---|---|---|---|---|
| audience | ai-agent | ||||||
| tags |
|
||||||
| glossary |
|
이 문서는 AI 에이전트가 새 기능을 구현·리뷰·디버깅할 때 따르는 절차서다.
모든 결정은 §3 결정 규칙과 §9 Do/Don't으로 환원된다. 모호하면 그쪽으로 돌아간다. 새 기능의 비즈니스 로직은proxy.md나src/modules/<domain>/proxy.ts가 아니라src/lib/<domain>/use-case 모듈에 구현한다. 이 문서의 모든 샘플과 예시는src/modules/mock/src/lib/mock기준으로 읽는다. 프로젝트별 특화 도메인 구현은 작업 기준 샘플로 사용하지 않는다.
| 작업 유형 | 읽는 순서 |
|---|---|
| 새 기능 구현 | §1 → §2 → §3 → §4 → §9 |
| 기존 코드 리뷰 | §3 → §9 → §10 |
| 버그 수정 | §3 (위치 결정) → §6 (메서드) → §9 |
| 새 모델 추가 | §2-M → §9 |
| 개념 학습 | §10 → §5 → §7 |
| 빠른 룩업 | §11 (Task → Section) |
경로 표기 약속
<domain>: 도메인 이름 (예:mock,orders,tickets)<use-case>: use-case 파일/폴더 이름 (예:update-test-name)proxy.<domain>: manager-proxy 인스턴스
이 문서의 기본 샘플
- 모델 source of truth:
src/modules/mock/model.ts - 모델 골든 샘플:
src/modules/mock/model.ts의TestModel - manager-proxy 샘플:
src/modules/mock/proxy.ts - use-case 골든 샘플:
src/lib/mock/update-test-name.ts - spec 헤더 골든 샘플:
src/lib/mock/update-test-name.spec.ts
TestModel의 mockId + mock$, mockIds + mock$$, readonly $mock/$mocks 패턴은 이 문서의 기준 계약이다.
이 계약을 바꾸는 경우 model/views/transformer/proxy/spec/field-registry/guide를 함께 갱신해야 한다.
유저가 다음 3가지를 제공했는지 확인한다. 하나라도 없으면 구현을 시작하지 말고 유저에게 요청한다.
- 요구사항: 비즈니스 흐름의 단계별 기술
- Input: 요청 파라미터 타입 또는 인터페이스 이름
- Output: 응답 타입 또는 인터페이스 이름
STOP 조건: 위 3개가 모두 없을 때, "보통 이렇게 한다"로 추정해 시작하지 않는다.
| 순서 | 파일 | 확인할 것 |
|---|---|---|
| 1 | src/service/backend-proxy.ts |
사용 가능한 manager-proxy |
| 2 | src/modules/<domain>/proxy.ts |
기존 helper (있으면 재사용) |
| 3 | src/modules/<domain>/model.ts |
필드 정의 (source of truth) |
| 4 | src/lib/<domain>/ |
기존 use-case 목록 |
→ 객체 역할은 §4 핵심 객체 참고.
src/lib/<domain>/SPEC.md 또는 use-case 파일 상단 JSDoc에 작성한다.
- 기능 설명 1줄
- 비즈니스 STEP 목록 (§1 요구사항 기반)
- Input / Output 타입 명세
- 성공 시나리오 ≥ 1
- 실패 시나리오 (모델 없음, 권한 없음, validation 실패 등)
STOP 조건: SPEC.md 없이 코드 작성을 시작하지 않는다.
updateTestName의 경우 명세는 아래 정도면 충분하다.
- 기능 설명:
TestModel.name을 업데이트한다- 같은 요청에서
count를 항상 1 증가시킨다
- Input:
id: stringname: string
- Output:
- 저장 후 최종
TestModel
- 저장 후 최종
- 성공 시나리오:
- 기존 test-model이 있으면
name이 바뀌고count가 1 증가한다
- 기존 test-model이 있으면
- 실패 시나리오:
id없음name없음- 대상 model 없음
기본값은 use-case 모듈. 새 기능의 비즈니스 로직은 항상 use-case에서 시작한다.
proxy.md/proxy.ts는 전체 기능 흐름을 담는 곳이 아니라 use-case가 호출하는 atomic helper를 설명·구현하는 곳이다. 아래 표로만 예외를 결정한다.
| 판단 기준 | 위치 |
|---|---|
새 기능의 비즈니스 흐름 (validate → resolve → fetch → authorize → execute → return) |
src/lib/<domain>/<use-case>.ts |
여러 use-case에서 반복 호출되는 atomic helper (saveMeta, loadMeta) |
src/modules/<domain>/proxy.ts |
| ID 생성, counter 증가, 단일 model mutation | src/modules/<domain>/proxy.ts |
| 하나의 API 흐름 전체 (resolve → fetch → authorize → execute → return) | src/lib/<domain>/<use-case>.ts |
| 여러 manager를 순서 있게 조합 | src/lib/<domain>/<use-case>.ts |
→ 추가 결정 규칙은 §3 결정 규칙 참고.
유저 요구사항을 다음 6단계 틀에 맞춘다 (모든 단계가 항상 필요한 건 아님).
STEP.0 validate — 필수 입력값·전제 조건 확인
STEP.1 resolve — 요청 주체 및 의존 리소스 해소
STEP.2 fetch — 대상 조회 및 존재·유효성 검증
STEP.3 authorize — 권한·비즈니스 조건 검증
STEP.4 execute — 핵심 상태 변경 수행
STEP.5 return — 결과 가공 및 반환
→ 실제 코드 형태는 §7 use-case 예시 참고.
src/lib/<domain>/<use-case>/types.ts 또는 src/lib/<domain>/types.ts.
export interface <UseCaseName>Input { ... } // 유저 제공 Input
export interface <UseCaseName>Output { ... } // 유저 제공 Output
export interface <UseCaseName>Options { ... } // 실행 주입값만 ($owner, throwable, useSession 등)STOP 조건:
Options에 비즈니스 입력값을 넣지 않는다. 요청 파라미터는Input에 둔다.
단, 프론트엔드(React)와 공유해야 하는 API/use-case 타입은 src/lib/<domain>/types.ts가 아니라 src/modules/<domain>/views.ts에 둔다.
이렇게 해야 API와 frontend가 같은 domain view 계약을 import해서 타입 정보를 공유할 수 있다.
export interface UpdateTestNameInput {
id: string;
name: string;
}파일: src/lib/<domain>/<use-case>.ts 또는 <use-case>/execute.ts.
- 시그니처:
(proxy: BackendProxy, input: Input, options?: Options) => Promise<Output> - STEP 4의 순서를 주석으로 표시
- Storage 접근은
proxy.<domain>.*로만 - proxy helper가 있으면 그걸 쓴다. 없으면 §6의
get/set/inc조합 - 모델 정보를 업데이트할 때는 저장 직전에 항상
proxy.<model>.validateModel(updateSet, id)로 최종 점검한다.
| # | 행동 | 참고 |
|---|---|---|
| 6.1 | 멱등성 판단 → 메서드 선택 (get/set/inc) |
§6 |
| 6.2 | proxy helper 재사용 vs 신규 작성 결정 | §3 |
| 6.3 | §9 Do/Don't 위반 검사 | §9 |
| 6.4 | 위반 발견 시 해당 STEP으로 돌아가 수정 | — |
use-case에서 기존 모델 정보를 바꾸는 경우, 바로 set()에 body를 넘기지 않는다.
먼저 최종 업데이트 셋을 만들고, proxy validator로 참조 head와 모델 규칙을 마지막으로 보정한 뒤 저장한다.
// STEP.2 fetch
const test = await proxy.test.get(input.id, true);
// STEP.4 execute
const updateSet: TestModel = {
name: input.name,
mockId: input.mockId,
mockIds: input.mockIds,
};
const validated = await proxy.test.validateModel(updateSet, test.id);
const saved = await proxy.test.set(test.id, validated);이 패턴을 지키면 mockId, mockIds처럼 참조 id가 바뀌는 경우에도 proxy가 mock$, mock$$를 자동으로 다시 채운다.
validateModel() 전에는 use-case가 비즈니스 의도에 필요한 필드만 update set에 담고, head snapshot을 직접 조립하지 않는다.
execute.spec.ts 또는 <use-case>.spec.ts. 다음을 모두 커버한다.
- input validation 실패 (필수 필드 누락)
- 주요 성공 흐름 (SPEC.md 성공 시나리오)
- 핵심 실패 흐름 (모델 없음, 권한 없음 — SPEC.md 실패 시나리오)
- 저장 반영 여부 (
guardProxy()종료 후service.$<domain>.find/retrieve로 확인) - 헤더는 공통 spec 패턴 사용:
// eslint-disable-next-line @typescript-eslint/no-unused-vars
import { _it, describe, it, expect2, expect, GETERR } from '../../cores/commons.spec';
import * as $service from '../../service/backend-service.spec';
//* load target use-case function to verify.
import { myUseCase } from './my-use-case';→ 상세 규칙은 §8 테스트 규칙 참고.
| # | 행동 |
|---|---|
| 8.1 | src/lib/<domain>/index.ts에 use-case를 export 하기 |
| 8.2 | 여러 API에서 재사용 시 src/service/backend-proxy.ts에 <domain>으로 포함 |
| 8.3 | src/modules/<domain>/api-*.ts의 guardProxy() 안에서 use-case 호출 |
controller는 얇게 유지: request 정규화 + use-case 진입점만.
→ §9 자기검증 체크리스트를 모두 통과해야 완료. 위반 시 해당 STEP으로 복귀.
신규 모델 추가는 use-case 구현과 별개의 작업으로 본다.
모델의 source of truth는 항상 src/modules/<domain>/model.ts이며, API 입출력은 views.ts와 transformer.ts에서 명시적으로 변환한다.
아래 순서로 타입 기준을 먼저 고정한다.
| 파일 | 해야 할 일 |
|---|---|
src/modules/<domain>/types.ts |
ModelType에 새 모델 타입 추가, 필요한 *Stereo LUT와 타입 추가 |
src/modules/<domain>/model.ts |
<ModelName>Head, <ModelName>Model 정의 |
src/modules/<domain>/model.ts |
$HEAD, $FIELD에 새 모델 연결 |
Storage/model 계층에서는 boolean을 직접 쓰지 말고 BoolFlag를 사용한다.
export interface MyModel extends Model {
isActive?: BoolFlag;
}반대로 외부 view/body 계층은 사람이 쓰는 API 계약이므로 boolean으로 노출한다.
export interface MyView extends View, Omit<Partial<MyModel>, 'isActive'> {
isActive?: boolean;
}변환은 반드시 transformer에서 처리한다.
// model -> view
isActive: model?.isActive !== undefined ? Boolean(model.isActive) : undefined,
// body -> model
if (body?.isActive !== undefined) model.isActive = $T.BN(body.isActive);다른 모델에서 참조하거나 socket/view/head payload로 줄 가능성이 있으면 <ModelName>Head를 별도로 둔다.
stereo가 있는 모델은 head에도 stereo를 항상 포함한다.
export interface MockHead {
id?: string;
name?: string;
stereo?: MockStereo;
}
export interface MockModel extends Model, MockHead {
meta?: string;
}Head를 만들었으면 $HEAD.<model>에 등록하고 transformer에 asHead()를 둔다.
모델 간 참조를 저장할 때는 ID만 저장하지 말고, 대응되는 head snapshot을 함께 저장한다.
export interface TestModel extends Model, TestHead {
mockId?: string;
mock$?: MockHead;
mockIds?: string[];
mock$$?: MockHead[];
}ID 배열은 xxxIds: string[] 형태로 저장하고, 대응되는 head 배열은 xxx$$ 형태로 저장한다.
export interface TestModel extends Model, TestHead {
mockIds?: string[];
mock$$?: MockHead[];
}필요하면 전체 모델을 readonly $xxx 필드로 참조할 수 있다.
단, $로 시작하는 필드는 DB 저장 필드가 아니며 $FIELD 생성 시 제외된다.
export interface TestModel extends Model, TestHead {
readonly $mock?: MockModel;
readonly $mocks?: MockModel[];
}작성 규칙:
- 단일 참조:
mockId + mock$ - ID 배열:
mockIds - head 배열:
mock$$ - 전체 모델 참조:
readonly $mock,readonly $mocks xxxIds$$처럼 ID 배열에$$를 붙이지 않는다.xxx$또는xxx$$는 head snapshot이고,$xxx는 전체 모델 참조다.
골든 샘플은 src/modules/mock/model.ts의 TestModel이다.
export interface TestModel extends Model, TestHead {
mockId?: string;
mock$?: MockHead;
mockIds?: string[];
mock$$?: MockHead[];
readonly $mock?: MockModel;
readonly $mocks?: MockModel[];
}이 샘플은 src/modules/mock/transformer.ts와 src/modules/mock/proxy.ts에서도 같은 패턴으로 변환/검증된다.
views.ts에는 <ModelName>View, <ModelName>Body를 추가한다.
프론트엔드(React)와 공유되는 API/use-case 입력·출력 타입도 views.ts에 함께 둔다.
transformer.ts에는 <ModelName>Transformer를 만들고 $trans.<model>에 등록한다.
View에서 내부 객체 배열을 노출할 때는 model의 head 배열을 그대로 내보내지 말고, 대응되는 <ModelName>View[]로 변환한다.
export interface TestView extends View, Omit<Partial<TestModel>, 'mock$' | 'mock$$' | '$mock' | '$mocks'> {
/** linked mock resolved from `mockId` */
mock$?: MockView;
/** linked mocks resolved from `mockIds` */
mock$$?: MockView[];
}ID 배열 입력은 body에서 xxxIds로 받고, transformer에서 $T.SS(...).filter(Boolean)으로 정규화한다.
xxx$$는 resolved view 출력 전용으로 보고 body 입력/저장 대상으로 처리하지 않는다.
// model -> view
mockIds: model?.mockIds,
mock$$: model?.mock$$ ? model.mock$$.map(N => $trans.mock.modelAsView(N)) : undefined,
// body -> model
if (body?.mockIds !== undefined) model.mockIds = $T.SS(body.mockIds).filter(Boolean);Transformer에서는 모든 필드를 의식적으로 점검해야 한다.
modelAsView()에서 외부로 노출할 필드를 빠짐없이 지정한다.bodyToModel()에서 body 입력을 모델 타입으로 변환한다.- enum/stereo 값은
$T.asLut()로 검증한다. - 문자열은 의미에 따라
$T.S2()또는$T.S()를 선택한다. - 숫자는
$T.N(), boolean view 값은$T.BN()으로BoolFlag에 맞춘다. - core/internal 필드, large field, readonly field를 외부에 노출할지 명시적으로 판단한다.
- Head가 있으면
asHead()가$HEAD.<model>기준으로 동작하는지 확인한다. - 참조 필드는
id,$,$$, readonly$xxx각각의 출력/입력 변환을 의식적으로 점검한다.
공유 API/use-case 타입 예:
export interface UpdateTestNameInput {
id: string;
name: string;
}공유 타입은 특정 use-case 내부에서만 쓰는 private 타입이 아니라면 src/lib/<domain>/types.ts에 숨기지 않는다.
저장 가능한 모델이면 domain service와 proxy, top-level backend에 모두 연결한다.
| 파일 | 해야 할 일 |
|---|---|
src/modules/<domain>/service.ts |
<ModelName>ModelManager 추가, BackendService 인터페이스에 $<model> 추가 |
src/modules/<domain>/proxy.ts |
<ModelName>ManagerProxy 추가, domain BackendProxy 인터페이스에 <model> 추가 |
src/service/backend-service.ts |
$<model> manager 생성 |
src/service/backend-proxy.ts |
<model> proxy 생성 |
Manager와 Proxy의 책임은 분리한다.
service.ts의ModelManager.validateModel()은 필수값 등 최소 검증만 담당한다.proxy.ts의ManagerProxy.validateModel()은this.$mgr.validateModel(model, $org)를 먼저 호출한다.- proxy 검증에서는 API smoke/internal test용으로
name === '#', create 시name === '!'를 막는다. 단,name필드가 없는 모델에는 억지로 적용하지 않는다. - 단일 참조 id가 body/update에 들어오면 대응 head를 다시 읽어
xxx$를 갱신한다. - id 배열이 body/update에 들어오면 대응 모델들을
mget()으로 읽어xxx$$를 갱신한다. - 참조 id가 빈 값이면 head는
null, id 배열이 빈 값이면 head 배열은[]로 정규화한다.
표준 proxy 패턴:
public async validateModel<T extends TestModel>(model: T, modelId?: string): Promise<T> {
const $org = modelId ? await this.get(modelId, true) : null;
const isCreate = !$org;
const validated = await this.$mgr.validateModel(model, $org);
const errScope = `validate(${this.$mgr.type}/${modelId ?? ''})`;
if (validated?.name == '#') throw new Error(`.name[${validated.name}] is invalid - ${errScope}`);
if (isCreate && validated?.name == '!') throw new Error(`.name[${validated.name}] is invalid - ${errScope}`);
if (validated?.mockId !== undefined) {
const mock = validated.mockId ? await this.proxy.mock.get(validated.mockId, false) : null;
validated.mock$ = mock ? this.proxy.mock.trans.asHead(mock) : null;
}
if (validated?.mockIds !== undefined) {
const mocks = validated.mockIds?.length ? await this.proxy.mock.mget(validated.mockIds, false) : [];
validated.mock$$ = mocks?.filter(Boolean).map(N => this.proxy.mock.trans.asHead(N)) ?? [];
}
return validated as T;
}$HEAD와 $FIELD는 src/generated/field-registry.ts의 fieldKeys를 사용하므로 모델 추가 후 반드시 생성 파일을 갱신한다.
npm run fields:gen생성 후 아래를 확인한다.
fieldKeys.<model>Head()가 추가됐는가?fieldKeys.<model>Model()이 추가됐는가?fieldRegistryMeta.entryCount와checksum이 함께 갱신됐는가?
최소한 transformer spec에서 아래를 검증한다.
$FIELD.<model>에 저장 대상 필드가 들어오는가?bodyToModel()이 body 값을 모델 타입으로 변환하는가?modelAsView()가 모든 view 필드를 의도대로 출력하는가?BoolFlag필드는 view에서 boolean으로 보이는가?asHead()가 head 필드만 반환하는가?- 참조 규칙이 지켜지는가? 예:
mockId + mock$,mockIds + mock$$,readonly $mocks xxxIds$$같은 잘못된 ID 배열 필드명이 남아 있지 않은가?
Transformer spec은 mock-model, test-model 테스트처럼 모델별 구역을 분리한다.
//* test of `test-model`
it('should pass test-model', async () => {
const FIELD = $FIELD.test;
const trans = $trans.test;
const $model: TestModel = { ... };
//* immutable.
const $imune: TestModel = {
mock$: undefined,
mock$$: undefined,
$mocks: undefined,
};
expect2(() => checkAllKeys($model, FIELD)).toEqual([]);
expect2(() => trans.bodyToModel(onlyDefined(trans.modelAsView($model)))).toEqual({
...$model,
...$imune,
});
});Spec 작성 규칙:
- 여러 모델을 한
it()에 묶지 않는다. - 각 모델마다
FIELD,trans,$model,$imune를 둔다. - proxy/service spec에서
makeModel()을 사용할 때는makeModel<TestModel>()처럼 모델 generic을 명시한다. $imune에는 API body로 업데이트/저장하면 안 되는 필드를 명시한다.mock$,mock$$같은 head snapshot은$imune에 넣어bodyToModel()저장 대상에서 제외되는지 검증한다.readonly $mocks같은 전체 모델 참조도$imune에 넣어 저장 대상이 아님을 검증한다.
| 상황 | 위치 |
|---|---|
| 새 기능의 비즈니스 로직 | src/lib/<domain> use-case |
| 단순 조회 | service.$<domain> 또는 proxy.<domain>.get/find |
guardProxy() 바깥에서 최종 저장 결과 확인 |
service.$<domain>.find/retrieve |
| 단일 model 변경 | proxy.<domain>.set/inc |
| 단일 도메인 atomic helper | src/modules/<domain>/proxy.ts |
| 여러 manager를 순서 있게 조합 | src/lib/<domain> use-case |
| WebSocket agent처럼 한 곳에서만 호출 | use-case 직접 import 허용 |
| request/event parsing | src/modules/<domain>/api-*.ts 또는 event handler |
| 판단 | 행동 |
|---|---|
proxy.<domain>.<helper>() 시그니처가 이미 있다 |
그걸 쓴다. 재구현 금지 |
| 같은 atomic mutation이 이 use-case 이후에도 반복될 것 같다 | ManagerProxy에 helper 추출 후 사용 |
| 이 use-case에서만 쓰는 비즈니스 판단·분기·흐름이다 | use-case 파일 안에서 직접 구현 |
| 새 API 기능의 전체 흐름을 추가해야 한다 | ManagerProxy가 아니라 use-case를 만든다 |
mock 기준 예:
updateTestName에서만 필요한name 변경 + count 증가흐름은src/lib/mock/update-test-name.ts에 둔다- 만약 이후 여러 use-case가 반복해서
meta 저장을 쓴다면 기존proxy.test.saveMeta()같은 helper를 재사용한다
| 조건 | 선택 |
|---|---|
호출자가 이미 BackendProxy를 가짐 + 호출 지점 1~2곳 + 재사용 가능성 낮음 |
직접 import (예: 단일 migration, 내부 helper 진입점) |
절차에 막혔을 때만 펼쳐 본다. 일반 흐름에는 §2가 충분하다.
- 위치:
src/service/backend-service.ts - 책임: 도메인 manager 생성,
createProxy(context)로BackendProxy생성,guardProxy()제공 - AI 읽는 기준: 등록된
$<domain>목록 /guardProxy()바깥 결과 확인용service.$<domain>.find/retrieve - 이 문서의 샘플에서는
service.$test,service.$mock만 기준으로 본다
- 위치:
src/service/backend-proxy.ts - 책임: 요청 단위 실행 컨텍스트, manager-proxy 접근점
- AI 읽는 기준:
proxy.<domain>manager 목록 /guardProxy()안에서는 항상proxy.<domain>사용 (service.$<domain>금지) - 이 문서의 샘플에서는
proxy.test,proxy.mock만 기준으로 본다
- 위치:
src/modules/<domain>/proxy.ts - 책임: 단일 도메인 model의 atomic 동작,
get/set/inc공통 + 도메인 helper - 비책임: 새 기능의 전체 비즈니스 흐름, 여러 단계의 권한/존재 검증, 여러 manager 조합
- AI 읽는 기준: 새 helper 추가 전 기존 helper 존재 확인. 단일 model의 ID/조회/저장/counter 로직은 보통 여기. 기능 흐름은
src/lib/<domain>/use-case로 이동한다.
- 위치:
src/modules/<domain>/api-*.ts - 책임: path/query/body/event parsing, transformer 정규화, entrypoint 정책 해소,
guardProxy()시작, use-case 결과 반환 - AI 읽는 기준: 같은 도메인 기존
api-*.tsnaming/패턴을 따른다. 긴 business flow는 controller에 두지 않는다.
- 위치:
src/lib/<domain>/ - 책임: 여러
proxy.*호출 조합, 권한/존재 검증, mutation, view 변환 - AI 읽는 기준:
types.ts에서 계약 /execute.ts에서 STEP 순서 /index.ts에서 export 등록.
- 위치:
src/cores/abstract-services.ts(대개) - 시그니처:
guardProxy<T>(context: NextContext, callback: (proxy: Proxy) => Promise<T>): Promise<T> - 동작: proxy 생성 → callback 실행 → 종료 시
saveAllUpdates()자동 호출 - 규칙:
- 안에서 읽기/쓰기는
proxy.*로 통일 - 안에서 새
BackendService생성 금지 - 예외 시에도
saveAllUpdates()1회 시도 후 throw 가능 (서비스 구현 확인)
- 안에서 읽기/쓰기는
return this.service.guardProxy($ctx, async proxy => {
const model = await proxy.test.get('A00001', {});
const updateSet = { name: 'updated name' };
const validated = await proxy.test.validateModel(updateSet, 'A00001');
await proxy.test.set('A00001', validated);
return model;
});guardProxy()가NextContext로BackendProxy생성- callback 안에서
proxy.*로 조회/수정 - callback 종료 →
proxy.saveAllUpdates() - 누적 변경사항 Storage 반영
| 상황 | 사용 메서드 |
|---|---|
| 카운터·순번·tick 증가 (재시도 시 두 번 더해야 정상) | inc() |
| atomic claim (반환값 1로 선점 판별) | inc() + 반환값 검증 |
| upsert (없으면 기본값, 있으면 덮어쓰기) | get({}) → set() |
| 필드 덮어쓰기 (이미 조회된 모델 확정 상태) | set() |
| 반드시 있어야 하는 모델 조회 | get(true) |
| 없어도 되는 조회 | get(false) |
await proxy.test.get('A00001', true); // 없으면 throw
await proxy.test.get('A00001', false); // 없으면 null
await proxy.test.get('A00001', {}); // 없으면 기본 모델로 시작false로 받으면null일 수 있음. 바로 필드 대입 금지.{}로 받으면 read-modify-write 시작점으로 자연스럽게 이어짐.
const updateSet = { name: 'new name' };
const validated = await proxy.test.validateModel(updateSet, 'A00001');
await proxy.test.set('A00001', validated);- 부분 업데이트. 실제 Storage 반영은
guardProxy()종료 시점. - use-case에서 모델 정보를 업데이트하는 경우
set()직전에validateModel(updateSet, id)를 반드시 거친다.
const test = await proxy.test.inc('A00001', { count: 1 });- 숫자 누적이 의도일 때. atomic counter 성격 있을 때.
- 도메인 helper 있으면 helper 사용
- 없으면
get/set/inc조합 - 같은 mutation 반복 →
ManagerProxyhelper로 추출 - 여러 manager 묶는 흐름 → use-case로 분리
mock 기준 helper 예:
proxy.test.loadMeta(id)proxy.test.saveMeta(id, meta)proxy.mock.pushMockBody(body)proxy.mock.pullMockBody(id)
helper 탐색 시 읽을 파일:
src/modules/<domain>/proxy.tssrc/modules/<domain>/model.ts
import type { BackendProxy } from '../../service/backend-proxy';
export type UseCase<I = any, O = any, P = void> = (
proxy: BackendProxy,
input: I,
options?: P,
) => Promise<O>;규칙:
- 첫 인자는 항상
proxy - 둘째 인자는 business input
- 셋째 인자는 실행 옵션 (
$owner,useSession,throwable,current,validate,errScope) - Storage 접근은
proxy.<domain>으로만 - request body 도메인 값은
input, 실행 환경 값은options
export const updateTestName: UseCase<UpdateTestNameInput, UpdateTestNameOutput, UpdateTestNameOptions> = async (
proxy,
input,
) => {
const errScope = `updateTestName(${input?.id ?? ''})`;
// STEP.0 validate
if (!input?.id) throw new Error(`.id (string) is required - ${errScope}`);
if (!input?.name) throw new Error(`.name (string) is required - ${errScope}`);
// STEP.1 fetch
const model = await proxy.test.get(input.id, true);
// STEP.2 execute
const updateSet = { name: input.name };
const validated = await proxy.test.validateModel(updateSet, model.id);
await proxy.test.set(model.id, validated);
await proxy.test.inc(model.id, { count: 1 });
// STEP.3 return
return proxy.test.get(model.id, true);
};작성 기준:
- 파일 1개 = use-case 1개
- 단계는
STEP.1부터 순서대로. validation/normalization은STEP.0 - 에러 메시지에
errScope포함 → spec 회귀 검증 용이 - 흐름이 한눈에: validate → fetch → mutation → return
- side effect / non-goal은 파일 상단 JSDoc에 짧게
export * from './types';
export * from './update-test-name';src/modules/mock/model.ts // TestModel, MockModel source of truth
src/modules/mock/proxy.ts // MockManagerProxy, TestManagerProxy helper
src/lib/mock/types.ts // UpdateTestNameInput / Output / Options
src/lib/mock/update-test-name.ts // use-case 구현
src/lib/mock/update-test-name.spec.ts // 독립 spec
src/lib/mock/index.ts // export 정리import { updateTestName } from '../../lib/mock';
return this.service.guardProxy($ctx, async proxy => {
return updateTestName(proxy, { id: body.id, name: body.name });
});→ 허용 조건은 §3.3 참고.
return this.service.guardProxy($ctx, async proxy => {
return updateTestName(proxy, { id: body.id, name: body.name });
});- API/handler에 두는 것: parsing, normalization, entrypoint 정책,
guardProxy()시작, use-case 호출 - API/handler에 두지 않는 것: 긴 business flow, model mutation 상세, atomic helper, 반복 권한/존재 검증
새 코덱스가 바로 따라야 하는 기본 순서는 아래다.
src/modules/<domain>/model.ts에서 필드 확인src/modules/<domain>/proxy.ts에서 helper 존재 여부 확인src/lib/<domain>/types.ts에 input/output/options 정의src/lib/<domain>/<use-case>.ts에UseCase시그니처로 구현src/lib/<domain>/<use-case>.spec.ts에 공통 헤더 패턴으로 독립 spec 작성- API/controller에서는
guardProxy()안에서 use-case만 호출
mock 기준으로는 아래 두 파일을 먼저 복사 기준으로 삼으면 된다.
src/lib/mock/update-test-name.tssrc/lib/mock/update-test-name.spec.ts
- use-case마다
execute.spec.ts또는<use-case>.spec.ts - 커버: input validation, 주요 성공, 핵심 실패, 저장 반영
- 헤더는
commons.spec+backend-service.spec기반 공통 패턴을 유지 - error string은
GETERR같은 헬퍼로 고정 검증 guardProxy()안에서 변경한 내용은 callback 이후service.$<domain>.find/retrieve로 확인- 기존
BackendProxy메서드를 추출했다면 기존 통합 spec 유지 (회귀 방지)
mock/update-test-name.spec.ts가 보여주는 최소 검증 세트:
- 성공:
- 기존 model의
name이 변경된다 count가 1 증가한다service.$test.find()로 저장 반영을 확인한다
- 기존 model의
- 실패:
name누락- 대상 model 없음
| Do | Don't |
|---|---|
새 기능 비즈니스 로직은 src/lib/<domain> use-case로 구현 |
새 기능 흐름을 proxy.md/src/modules/<domain>/proxy.ts에 구현 |
guardProxy() 안 작업은 proxy.*로 |
guardProxy() 안에서 service.$<domain>로 중간 상태 확인 |
use-case는 (proxy, input, options?) 시그니처 |
use-case 안에서 새 BackendService 생성 |
model 필드는 modules/<domain>/model.ts 기준 |
request body를 model 필드로 가정 |
model boolean 저장 필드는 BoolFlag 사용 |
model에 boolean 직접 저장 |
view/body boolean 변환은 transformer.ts에서 처리 |
model과 view의 boolean 표현을 섞기 |
프론트엔드 공유 API/use-case 타입은 views.ts에 작성 |
공유 타입을 src/lib/<domain>/types.ts에 숨기기 |
| transformer에서 모든 필드 출력/입력 변환을 점검 | 새 필드를 model에만 추가하고 view 변환 누락 |
필요한 참조 payload는 <ModelName>Head로 분리 |
full model을 head/reference로 그대로 노출 |
참조 ID는 대응 head와 함께 저장 (mockId + mock$) |
참조 ID만 저장하고 head snapshot 누락 |
ID 배열은 xxxIds, head 배열은 xxx$$ 사용 |
xxxIds$$처럼 ID 배열에 $$ 붙이기 |
전체 모델 참조는 readonly $xxx 사용 |
$xxx 필드가 DB에 저장된다고 가정 |
head snapshot과 readonly 참조는 transformer $imune로 저장 불가 검증 |
bodyToModel()에서 stage$, note$$, $notes 저장 허용 |
stereo가 있는 모델은 head에도 stereo 포함 |
head에서 stereo 누락 |
모델 추가 후 npm run fields:gen 실행 |
fieldKeys를 수동 편집하거나 갱신 누락 |
여러 manager 조합은 src/lib/<domain> |
api-*.ts에 긴 business flow 작성 |
atomic helper는 ManagerProxy |
같은 mutation 로직을 use-case마다 복사 |
options에는 실행 주입값만 |
options에 business input 필드 섞기 |
- SPEC.md의 모든 시나리오가 spec에서 통과하는가?
- 새 기능 비즈니스 로직이
src/lib/<domain>use-case에 있는가? -
guardProxy()내부 작업을proxy.*로만 하고 있는가? - use-case 시그니처가
(proxy, input, options?)인가? - controller가 business flow를 직접 나열하지 않는가?
- atomic helper가
ManagerProxy에 있는가? -
options에 비즈니스 입력값이 섞여 있지 않은가? - 저장 결과를
guardProxy()종료 후service.$<domain>.find/retrieve로 확인했는가? - 프론트엔드와 공유하는 API/use-case 타입이
src/modules/<domain>/views.ts에 있는가? - 새 모델 boolean 저장 필드가
BoolFlag이고 view/body에서는 boolean으로 변환되는가? - 새 모델의
transformer.ts가 모든 필드의 출력과 입력 변환을 명시적으로 다루는가? - 새 모델에 필요한
<ModelName>Head와asHead()가 있는가? - 참조 필드는
id + $head, 배열은ids + $$heads, 전체 모델은 readonly$xxx패턴인가? - head snapshot과 readonly 참조가
bodyToModel()저장 대상에서 제외되고$imunespec으로 검증되는가? -
stereo가 있는 모델의 head에stereo가 포함되는가? - 새 모델 추가 후
npm run fields:gen으로fieldKeys가 갱신됐는가?
위반 항목이 있으면 해당 STEP으로 돌아가 수정 후 spec 재실행.
BackendService는 모델 manager를 소유한다.BackendProxy는 요청 컨텍스트와 함께 각 manager의 proxy를 묶는다.- 비즈니스 로직과 Storage 갱신은
guardProxy()내부에서 처리한다. guardProxy()종료 시 변경사항이 Storage에 반영된다.- 여러 manager를 조합하는 반복 업무는
src/lib/<domain>/use-case로 분리한다. - API/handler는 request 정규화와
guardProxy()시작만 담당한다.
| 하려는 일 | 보는 곳 |
|---|---|
| 새 모델 만들기 | §2-M |
| 새 use-case 만들기 | §2 STEP 4 |
get/set/inc 어떤 걸 쓸지 |
§6.1 |
| 코드 위치 결정 (proxy vs use-case) | §3.1, STEP 3 |
| 테스트 케이스 빠짐 검사 | §8, STEP 7 |
| 리뷰/PR 자기검증 | §9 체크리스트 |
| guardProxy 동작 원리 | §4 guardProxy(), §5 |
| 도메인 helper 어디서 찾나 | §6.5 |
src/
├── service/
│ ├── backend-service.ts # BackendService: manager 소유, guardProxy 제공
│ └── backend-proxy.ts # BackendProxy: 요청 단위 proxy
├── modules/
│ └── <domain>/
│ ├── api-<resource>.ts # API/controller
│ ├── model.ts # model 필드 source of truth
│ ├── proxy.ts # ManagerProxy
│ ├── service.ts # domain service 또는 helper
│ ├── transformer.ts # request/view 변환
│ ├── types.ts # ModelType, Stereo LUT
│ └── views.ts # 응답 view 기준
└── lib/
├── core/
│ └── index.ts # UseCase 공통 타입
└── <domain>/
├── README.md 또는 SPEC.md
├── index.ts
└── <use-case>.ts 또는 <use-case>/execute.ts평평한 구조 (간단한 use-case):
src/lib/mock/
├── README.md
├── index.ts
├── types.ts
├── update-test-name.ts
└── update-test-name.spec.ts폴더 구조 (use-case가 늘어날 때):
src/lib/mock/
├── SPEC.md
├── index.ts
└── update-test-name/
├── types.ts
├── execute.ts
└── execute.spec.ts| 패턴 | 위치 | 읽을 포인트 |
|---|---|---|
| 원본 proxy 실행 모델 | docs/backend-service-proxy.md |
BackendService, BackendProxy, ManagerProxy, guardProxy() 기본 |
| mock 골든 샘플 use-case | src/lib/mock/update-test-name.ts |
UseCase 시그니처, get/set/inc 조합, errScope 패턴 |
| mock 골든 샘플 spec | src/lib/mock/update-test-name.spec.ts |
공통 spec 헤더, $service.instance(), 성공/실패/저장 검증 |
- 새 기능이
guardProxy()실행 경계 안에서 동작 - 새 기능 비즈니스 로직이
src/lib/<domain>use-case 모듈에 구현됨 - Storage 접근은
proxy.<domain>으로 수행 - 반복 비즈니스 흐름은
src/lib/<domain>use-case로 분리 -
src/modules/<domain>/api-*.ts는 request 정규화 + use-case 호출 중심으로 얇음 - use-case input/output/options 타입이 가까운 파일에 존재
- 프론트엔드와 공유되는 API/use-case 타입은
src/modules/<domain>/views.ts에 존재 - 새 모델이면
types.ts,model.ts,views.ts,transformer.ts,service.ts,proxy.ts, top-level backend 등록이 일관됨 - 새 모델의 boolean 저장 필드는
BoolFlag, view/body 노출은 boolean, 변환은 transformer에 있음 - 새 모델의 참조 필드는
xxxId + xxx$,xxxIds + xxx$$, readonly$xxx규칙을 따름 -
stereo가 있는 모델은 head에도stereo를 포함함 - 새 모델의 모든 출력/입력 필드를 transformer spec으로 검증함
- 새 모델 추가 후
npm run fields:gen결과가 반영됨 - 단위 spec과 필요한 통합 spec 통과