generated from ttipo/project-base
docs(rules): lock 의 npm 버그·성능 단언은 계산량으로·새 공용 부품
- testing: 성능 단언은 벽시계가 아니라 계산량(CPU 시간·비용)으로, 비동기는 고정 대기 대신 조건 대기 - docker·nestjs 스킬: npm 11.6.2 lock 버그의 원인과 지킬 것(unrs-resolver 1.12.x 로 올리지 않기, 인자 없는 npm install 확인), CPU 시간 도우미 - vue3 스킬: 공용 ModalDialog·나가기 전 확인·읽기 화면 본문 안내·displayedHtmlBody·notifyApiError, 테스트 시간 한도 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -328,7 +328,17 @@ node --env-file=.env.development mail/scripts/setup.mjs
|
||||
`@emnapi/core`·`@emnapi/runtime` 과 중첩 `chokidar` 가 빠져 `npm ci` 가 위와 같은 오류로 실패했다.
|
||||
그 패키지는 이미 lock 에 같은 판으로 있었으므로(sanitize-html 의 의존성) npm 이 루트 `dependencies` 에 더한 한 줄만 원래 lock 에 옮기고,
|
||||
임시 복사본에서 `npm ci`·`npm ci --omit=dev` 와 `require` 로 확인했다 — **lock 에 없던 패키지에는 쓸 수 없는 방법**이다.
|
||||
새 패키지를 들이기 전에 npm 11.6.2 의 `npm install` 뒤에도 `npm ci` 가 통과하도록 lock 을 바로잡아야 한다 (release-ops 몫, 아직 원인 미확인).
|
||||
- **원인과 해결 (2026-10-27)**: npm 11.6.1~11.6.2(arborist 9.1.5~9.1.6)의 버그다. `npm ci` 는 lock 의 의존 표시를 그대로 믿지만
|
||||
`npm install` 은 늘 다시 계산하는데, 들어오는 peer 간선이 하나라도 있는 패키지를 일반 의존성이어도 `peer` 로 남기고 `peer`+`optional` 인 패키지를 모두 지운다.
|
||||
jest 가 쓰는 `unrs-resolver@1.12.x` 가 그 모양이다 — 선택 설치인 wasm32 바인딩의 일반 의존성 `@emnapi/core`·`@emnapi/runtime` 이
|
||||
`@napi-rs/wasm-runtime@^1.1.4` 의 필수 peer 이기도 하다. 그래서 backend lock 의 `unrs-resolver` 를 **1.11.1** 로 내렸다
|
||||
(바인딩이 peer 없는 `@napi-rs/wasm-runtime@0.2.12` 를 쓴다. 개발 전용, `npm audit` 결과 같음).
|
||||
- **npm 이 11.6.2 인 동안 `unrs-resolver` 를 1.12.x 로 올리지 않는다** — `npm update` 로 범위 안 최신을 받아도 1.12.2 가 되어 다시 깨진다.
|
||||
npm 을 11.6.3 이상으로 올리면 원인이 사라진다(그때 lock 의 `peer` 표시가 한 번 크게 바뀌지만 `npm ci` 는 통과한다).
|
||||
- 중첩 `chokidar@4.0.3`(@angular-devkit 의 optional peer — 루트는 chokidar 5)은 npm 이 판과 무관하게 일부러 지우는 항목이라 lock 에 없다
|
||||
(`npm ls` 의 chokidar invalid·readdirp extraneous 표시는 정상).
|
||||
- lock 을 바꾼 뒤에는 임시 복사본에서 `npm ci` 와 함께 **인자 없는 `npm install` 이 lock 을 바이트 그대로 두는지**도 본다.
|
||||
frontend lock 은 같은 문제가 없다(2026-10-27 확인).
|
||||
- **dev backend 의 `/app/dist` 는 익명 볼륨이다.** 호스트에서 `npm run build` 를 하면 바인드 마운트된 `backend/dist` 가 지워졌다
|
||||
다시 생기면서 컨테이너의 Nest watch 가 끊겨 앱이 옛 코드로 계속 돈다 — 그래서 산출물을 호스트와 나눴다.
|
||||
`nest-cli.json` 의 `deleteOutDir` 는 마운트 지점을 지우려다 EBUSY 로 죽으므로, dev `command` 는 기동 때 볼륨 내용만 비우고
|
||||
|
||||
@@ -319,7 +319,8 @@ dc exec backend npm uninstall <패키지>
|
||||
- 변경 후 호스트의 `package.json`·`package-lock.json` 이 갱신됐는지 확인하고 **함께 커밋**한다 (bind mount 라 호스트에 반영된다).
|
||||
- 호스트에서 실행해버렸다면 `git checkout backend/package-lock.json` 으로 되돌린 뒤 위 방법으로 다시 한다.
|
||||
- 커밋 전 `docker compose ... build backend` 로 `npm ci` 가 통과하는지 확인한다. CI 도 같은 검사를 한다.
|
||||
⚠️ 지금 backend lock 은 컨테이너의 `npm install <패키지>` 로도 `npm ci` 가 깨진다 — [[docker]] "의존성은 컨테이너 안에서" 의 2026-10-27 항목을 먼저 본다.
|
||||
⚠️ npm 이 11.6.2 인 동안 jest 의 `unrs-resolver` 를 1.12.x 로 올리지 않는다(`npm update` 포함) — 올리면 컨테이너의 `npm install` 이 lock 을 깨뜨린다.
|
||||
원인과 확인 방법은 [[docker]] "의존성은 컨테이너 안에서" 의 2026-10-27 항목.
|
||||
- **운영 의존성이 실제로 들어가는지**도 본다 — 임시 복사본에서 `npm ci --omit=dev` 후 새 운영 의존성을 `require` 해 본다.
|
||||
lock 의 `dev` 표시가 틀리면 운영 이미지에서만 패키지가 빠져 앱이 뜨지 않는다 (2026-10-27 htmlparser2 사례).
|
||||
- **ESM 전용 패키지**가 들어와 Jest 가 `Cannot use import statement outside a module` 로 실패하면, jest 설정 두 곳
|
||||
@@ -342,6 +343,7 @@ dc exec backend npm uninstall <패키지>
|
||||
- [ ] **Swagger**: 새 엔드포인트에 `@ApiTags`/`@ApiOperation` + `@ApiStandardResponse`/`@ApiErrorResponse`.
|
||||
- [ ] **경로 alias**: `@/` 사용 (`tsconfig.json` paths + jest `moduleNameMapper` 설정 완료). 깊은 상대경로 금지.
|
||||
- [ ] **lint/test**: 커밋 전 `npm run lint` (`--max-warnings 0`) + `npm run test` 통과. 자동 수정은 `npm run lint:fix`.
|
||||
성능 단언은 `backend/test/cpu-time.ts` 의 `startCpuTimer()`(프로세스 CPU 시간)로 잰다 — 벽시계 한도는 부하에서 흔들린다 ([[testing]]).
|
||||
- [ ] **의존성 변경 시**: 호스트가 아니라 **컨테이너 안에서** `npm install/uninstall` (위 주의 항목 참조). 변경 후 Docker 빌드 확인.
|
||||
- [ ] **에러 던지기**: `HttpException` 직접 생성은 린트로 차단된다 — `BusinessException(code)` 사용.
|
||||
- [ ] **새 필수 env 추가 시**: `shared/config/env.validation.ts` 에 검증 규칙과 테스트를 함께 추가 ([[env-structure]]).
|
||||
|
||||
@@ -21,7 +21,7 @@ description: Vue 3 프론트엔드(frontend/) 개발 규칙 — Composition API
|
||||
| 세션 만료 처리 | `useApi.ts` 의 `refreshSession()` · 응답 인터셉터 | 로그인 상태에서 401 → `/auth/refresh` 한 번 시도 후 원 요청 재시도. 실패하면 `AUTH_002` 승격 + `authStore.expireSession()` → `components/feedback/SessionExpiredDialog.vue` (흐름: `docs/mail/api-contract.md` 1절). 갱신은 시작할 때 세션 세대(`authStore.generation`)를 잡고, 끝났을 때 바뀌었으면 성공이어도 버린다. 로그인은 진행 중인 갱신이 끝난 뒤(`settleRefresh()`)에 보낸다 |
|
||||
| 이동 전 세션 확인 | `useApi.ts` 의 `confirmSession()` | `<a href>` 다운로드처럼 axios 를 거치지 않는 이동 직전에 부른다 — me → refresh 1회 → 실패하면 세션 만료 |
|
||||
| 탭 간 로그아웃 | `src/composables/useLogoutBroadcast.ts` (`App.vue` 의 `useLogoutSync()`) | 로그아웃은 요청 **전에** BroadcastChannel 로 다른 탭에 알린다 — 받은 탭은 로그아웃으로 다루고 로그인 화면으로. 로그아웃 뒤 늦게 온 `/auth/refresh` 성공은 버리고 `/auth/logout` 을 한 번 더 부른다 (계약 1절) |
|
||||
| 요청 단위 알림 끄기 | `useApi.ts` 의 `silent` 요청 옵션 | `true` 또는 코드 배열 (`api.get(url, { silent: ['AUTH_001'] })`). 세션 만료 대화상자는 끄지 않는다 |
|
||||
| 요청 단위 알림 끄기 | `useApi.ts` 의 `silent` 요청 옵션 | `true` 또는 코드 배열 (`api.get(url, { silent: ['AUTH_001'] })`). 세션 만료 대화상자는 끄지 않는다. 끈 코드를 나중에 알림으로 알려야 하면(그 칸이 사라진 뒤 등) `notifyApiError()` — 같은 `showErrorUI()` 를 거친다 |
|
||||
| 페이지네이션 타입 | `src/shared/types/pagination.ts` | 목록 응답 공유 계약 ([[response-format]]) |
|
||||
| 도메인 Composable 예제 | `src/composables/useUser.ts` · `useMail.ts` | 새 도메인 API 패턴 참고 (`useMail` 이 실제 모듈 예) |
|
||||
| 인증 Composable | `src/composables/useAuth.ts` | 로그인·로그아웃·`restoreSession()`(me 가 AUTH_001 이면 refresh 1회, 알림 없음) — 쿠키 기반 |
|
||||
@@ -33,7 +33,8 @@ description: Vue 3 프론트엔드(frontend/) 개발 규칙 — Composition API
|
||||
| 디자인 토큰·글꼴 | `src/styles/tokens.css` · `src/styles/base.css` | 색·간격·글자 크기 변수. 글꼴은 npm `pretendard` 자체 호스팅(`main.ts` 에서 import — CSP `font-src 'self'`) |
|
||||
| 브랜드 자산 | `src/assets/brand/` · `src/shared/constants/brand.ts` | 흰 로고(전체·ARA 마크). 원본은 `docs/brand/` |
|
||||
| 토스트 | `src/stores/toast.store.ts` · `src/components/feedback/ToastHost.vue` | 오류는 `useApi.ts` 의 `showErrorUI()`, 성공 안내는 `composables/useToast.ts` |
|
||||
| 외부 HTML 격리 렌더링 | `src/components/mail/MailBodyFrame.vue` · `src/shared/utils/mail-html.ts` | 메일 본문 등 외부 HTML 은 이 컴포넌트로만 그린다 (sandbox·내부 CSP 근거는 파일 머리말) |
|
||||
| 모달 대화상자 | `src/components/feedback/ModalDialog.vue` | 네이티브 `<dialog>`(초점·Esc·닫힘 처리)와 모양. 세션 만료(`SessionExpiredDialog`)·나가기 확인(`components/mail/ComposeLeaveDialog.vue`)이 쓴다 — 새 대화상자도 이것으로 만든다 |
|
||||
| 외부 HTML 격리 렌더링 | `src/components/mail/MailBodyFrame.vue` · `src/shared/utils/mail-html.ts` | 메일 본문 등 외부 HTML 은 이 컴포넌트로만 그린다 (sandbox·내부 CSP 근거는 파일 머리말). 보여 줄 HTML 고르기는 `displayedHtmlBody()` 하나다 — null 이거나 공백뿐이면 textBody (읽기 화면·인용 공용, 서버의 `isBodyTruncated` 와 같은 규칙) |
|
||||
| 메일 모듈 | `src/pages/mail/*` · `src/components/mail/*` | API 계약 `docs/mail/api-contract.md`, 디자인 원본 `docs/design/webmail/` |
|
||||
| 콘텐츠 열 토큰 | `src/styles/tokens.css` 의 `--content-*` · `--content-toolbar-start` | 읽기·쓰기 화면이 같은 최대 폭·여백·32px 시작선을 쓴다 (새 화면도 이 토큰으로) |
|
||||
| 밀도 예외 크기 | `src/styles/tokens.css` 의 `--size-compact-control` · `--size-segment-item` · `--size-editor-tool` · `--size-editor-menu-item` · `--size-editor-swatch-cell` · `--size-editor-table-cell` (주석에 "밀도 예외") | 클릭 영역 44px 기준의 의도된 예외(목록 표의 별·보기 전환, 편집기 툴바 버튼 40×44·메뉴 항목 40·색 견본 40·표 고르기 칸 22)에만 쓴다 — 사용자 확정. 그 밖의 `--size-editor-*`(툴바 높이·글꼴 단추·본문 최소 높이 등)는 예외가 아니라 보통 크기다 |
|
||||
@@ -42,6 +43,8 @@ description: Vue 3 프론트엔드(frontend/) 개발 규칙 — Composition API
|
||||
| 발송 키 규칙 | `src/shared/utils/idempotency-key.ts` 의 `shouldRenewSendKey()` | `Idempotency-Key` 는 성공과 `MAIL_005` 때만 바꾼다 — 그 밖의 실패 뒤 다시 보내기는 같은 키 (계약 2절 "발송 본문") |
|
||||
| 첨부 사전 검사 | `src/shared/utils/mail-attachment.ts` 의 `precheckAttachment()` | 형식·파일당·합계 한도(`mail.store` 의 `attachmentMaxBytes`·`attachmentTotalMaxBytes`, 서버 값이 없으면 대체값). 넘는 파일은 올리지 않는다 |
|
||||
| 메일 본문 편집기 | `src/components/mail/editor/` — `MailEditor.vue` (쓰기 화면이 `defineAsyncComponent` 로 불러온다) | TipTap core (MIT 무료 패키지만, `@tiptap/*` 는 모두 같은 버전으로 고정). 툴바 배치·좁은 폭에서 접는 순서·단축키는 `docs/design/webmail/README.md` "메일 쓰기". 보낼 모양은 `mail-serialize.ts` — 서식 본문 `bodyHtml`(기본 글꼴 div, 인라인 style) + 텍스트 대체 본문 `body` (계약 2절 "서식 본문"). 답장·전달 초안 한도는 `fit-draft.ts`(처음 맞출 때 내 글 몫 5%를 남기고, 보내기 직전 `getContentForSend()` 가 다시 재서 인용을 더 줄인다 — 내 글만으로 넘으면 보내지 않고 칸 아래에 알린다), 붙여 넣거나 끌어다 놓은 파일은 첨부로(`shared/utils/editor-files.ts`, 화면 어디에 놓아도 — `composables/useWindowFileDrop.ts`). **읽어 들이는 style 값은 서버 정화와 같은 형식만** 받는다 — 규칙 `shared/utils/outgoing-html-rules.ts`(짝: backend `sanitize-outgoing-html.ts`, 같은 표본 표 `test-utils/outgoing-style-samples.ts` — 한쪽을 고치면 둘 다), 적용 `editor/mail-format-extensions.ts`. 편집기 청크를 못 받으면 `ComposeEditorLoadError.vue`("다시 시도"), 단축키 표기는 플랫폼별(`shared/utils/keyboard-shortcut.ts`) |
|
||||
| 나가기 전 확인 | `src/composables/useLeaveConfirm.ts` · `src/shared/utils/compose-changes.ts` | 화면을 연 뒤 바꾼 것이 있으면 앱 안 이동(라우트 떠남·바뀜)은 확인 대화상자, 탭 닫기·새로고침은 `beforeunload`. 바꿈 여부는 연 직후의 스냅숏과 비교한다(편집기는 `isChanged()`). 보낸 뒤·보내는 중·세션이 끝났을 때는 묻지 않는다 — 보내는 중에 떠나면 결과는 토스트로만 알리고 떠난 양식·새 양식을 건드리지 않는다(양식 세대로 늦게 온 결과를 버린다, 디자인 README "나가기 전 확인") |
|
||||
| 읽기 화면 본문 안내 | `src/shared/utils/mail-body-notices.ts` · `shared/constants/mail.ts` 의 `MAIL_BODY_NOTICES` | 서버의 `isHtmlBodyOmitted`·`isBodyTruncated`(계약 2절 "읽기 한도")를 외부 이미지 차단과 같은 안내 줄로 보인다(`.reader__notice[data-notice]`) |
|
||||
| 쓰기 칸 포커스 | `src/styles/base.css` 의 `.compose-field` | 글자 입력 행(받는 사람·참조·숨은참조·제목·본문)은 아래 선 하나로 포커스를 보인다. 새 입력 행에도 이 클래스를 붙인다 — 칸마다 포커스 규칙을 따로 만들지 않는다 |
|
||||
| env 타입 선언 | `frontend/env.d.ts` | `VITE_API_BASE_URL` |
|
||||
| dev API 프록시 | `vite.config.ts` 의 `server.proxy` | `/api` → 백엔드. 없으면 vite 가 SPA 폴백으로 **HTML 을 200 으로** 돌려줘 조용히 실패한다 |
|
||||
@@ -244,6 +247,9 @@ router.beforeEach((to) => {
|
||||
편집기로 읽어 들이는 서식은 **서버가 남기는 형식만** 받는다 (`outgoing-html-rules.ts`) — 서버만 지우면 편집기에서 숨은 글(`font-size:0` 등)이
|
||||
받는 사람에게 보이는 식으로 화면과 보낸 메일이 달라진다 (2026-10-27 리뷰).
|
||||
- [ ] **lint/test**: 커밋 전 `npm run lint` (`--max-warnings 0`) + `npm run type-check` + `npm run test:unit` 통과. 자동 수정은 `npm run lint:fix`.
|
||||
테스트 시간 한도는 전역 20초(`vitest.config.ts` — jsdom·편집기 테스트가 부하에서 5~13초)이고, 긴 초안을 맞추는 테스트는 `src/test-utils/timeouts.ts` 의 한도를 쓴다.
|
||||
"몇 ms 안에 끝난다"를 검사하는 성능 단언은 벽시계 대신 계산량(CPU 시간·비용)으로 잰다 — 벽시계는 다른 작업이 많으면 몇 배로 늘어
|
||||
CI 에서 흔들린다 (2026-10-27 사례, backend 는 `backend/test/cpu-time.ts`).
|
||||
- [ ] **린트로 강제되는 항목**: Options API·인라인 스타일·`console`·`any` 는 빌드가 실패한다.
|
||||
- [ ] **env**: `VITE_API_BASE_URL` 은 운영 빌드에서 `/api` 로 고정된다 (같은 주소 배포 — 운영은 프론트 nginx, 개발은 vite 프록시가 `/api` 를 백엔드로 넘긴다).
|
||||
`frontend/.env` 에 별도 작성 불필요 — 다른 주소를 넣으면 쿠키(Path=/api)·CSP `connect-src 'self'`·출처 검사가 맞지 않는다 ([[docker]] "같은 주소 배포").
|
||||
|
||||
Reference in New Issue
Block a user