chore(docker): 메일 엔진·네트워크 분리·같은 주소 배포와 Node 24 고정 #1

Merged
ttipo merged 52 commits from feat/mail-module into main 2026-10-30 12:50:48 +09:00
8 changed files with 355 additions and 46 deletions
Showing only changes of commit d9ad6fc178 - Show all commits
+8 -2
View File
@@ -9,13 +9,19 @@
- [ ] 루트 `.env.{env}.example` → `.env.{env}` 복사 후 **placeholder 값 교체** (`change-me-*` 가 남아 있으면 안 된다, [[env-structure]])
운영은 `BACKEND_API_URL`·`CORS_ORIGIN` 을 **실제 도메인**으로 — `localhost`/`example.com` 이 남으면 기동 실패한다
운영은 `TRUST_PROXY`(앞단 프록시 홉 수 — 외부 리버스 프록시 뒤면 2, 없으면 1)도 필수다. 앱 네트워크 대역 `APP_NETWORK_SUBNET` 은
호스트의 다른 도커·사내 네트워크와 겹치지 않는 값으로 둔다 (메일 엔진이 이 대역만 신뢰한다 — [[docker]])
- [ ] `backend/.env.{env}.example` → `backend/.env.{env}` 복사 후 `JWT_SECRET` 등 고유 시크릿 작성 (compose `env_file` 로 로드 — 파일이 없으면 기동 실패)
- [ ] `flutter/.env.example` → `flutter/.env` 복사 후 `API_URL` 확인 (Android 에뮬레이터: `10.0.2.2`)
— **이 파일이 없으면 `flutter analyze` 가 asset 경고를 낸다**
- [ ] 프로젝트 식별 값 교체 (`PROJECT_NAME` 등 루트 env 키)
- [ ] 의존성 설치: `cd backend && npm ci`, `cd frontend && npm ci`, `cd flutter && flutter pub get`
- [ ] Docker 개발 환경 기동으로 동작 확인 — backend + db ([[docker]])
(Redis 와 프론트엔드 컨테이너는 기본 비활성이다 — 필요하면 루트 `.env` 에 `COMPOSE_PROFILES=redis,frontend`)
- [ ] Docker 개발 환경 기동으로 동작 확인 — backend + db + 메일 엔진(mail·mailpit) ([[docker]])
(예시 env 의 기본값이 `COMPOSE_PROFILES=mail` 이다. Redis 와 프론트엔드 컨테이너는 기본 비활성 — 필요하면 `COMPOSE_PROFILES=mail,redis,frontend`)
- [ ] 메일 엔진 초기 설정 — `node --env-file=.env.development mail/scripts/setup.mjs` (처음 한 번, env 를 바꾸면 다시 — 여러 번 실행해도 안전)
운영은 **처음 setup(bootstrap) 전에** 엔진 관리자 아이디 `MAIL_ADMIN_LOCAL_PART` 를 정한다 — 옮겨 올 실제 메일함(예: `admin@`)과 겹치지 않게.
bootstrap 뒤에는 바꿀 수 없다(setup 이 막는다). 저장소는 `sudo` 없이 클론한다 — `data/` 가 사용자 소유여야 setup 이 상태 파일을 쓴다
직원 로그인 = 메일 계정이라 이 단계 전에는 로그인할 수 없다. 개발 테스트 계정·관리 화면·Mailpit 은 [`mail/README.md`](../../mail/README.md)
- [ ] 프론트엔드 dev 서버는 **호스트에서** 기동 — `cd frontend && npm run dev` (→ <http://localhost:5173>)
컨테이너 vite 는 바인드 마운트 때문에 첫 서빙까지 수 분이 걸려 profile 뒤로 숨겨 두었다 ([[docker]])
+27 -5
View File
@@ -13,11 +13,21 @@ description: 환경변수 단일 진실 공급원(SSOT) 구조 — 루트 .env.{
│ (docker compose --env-file 로 로드)
├─▶ backend : env_file + environment 로 DB/Redis/CORS/포트 주입
│ (backend/.env.{APP_ENV} 는 "존재"만 하면 됨, 내용 최소)
├─▶ frontend : BACKEND_API_URL → build arg(VITE_API_BASE_URL) 로 주입
├─▶ frontend : 빌드 arg VITE_API_BASE_URL=/api 고정 (같은 주소 배포 — env 로 바꾸지 않는다)
│ 런타임 env MAIL_ATTACHMENT_MAX_SIZE_MB·UPLOAD_MAX_SIZE_MB → nginx 본문 한도,
│ NGINX_BACKEND_UPSTREAM=http://${PROJECT_NAME}-backend:${BACKEND_PORT} (frontend/nginx-env.envsh — 값이 틀리면 기동 중단)
│ (호스트에서 `npm run dev` 로 띄울 때는 compose 를 거치지 않으므로
│ vite.config.ts 가 이 파일을 `loadEnv` 로 직접 읽어 FRONTEND_PORT/BACKEND_PORT 사용)
├─▶ network : APP_NETWORK_SUBNET → app-network 서브넷 고정 (메일 엔진의 신뢰 대역 — 사설 대역 /24~/28, frontend 는 붙지 않는다)
│ EDGE_NETWORK_SUBNET → edge(frontend ↔ backend) 서브넷 고정 (TRUST_PROXY 주소 목록용)
├─▶ db : DB_USER/DB_PASSWORD/DB_NAME, DB_DATA_DIR 볼륨
└─▶ redis : REDIS_PASSWORD, REDIS_DATA_DIR 볼륨
├─▶ redis : REDIS_PASSWORD, REDIS_DATA_DIR 볼륨
├─▶ uploads : 운영은 named volume <PROJECT_NAME>_uploads (env 없음), 개발만 UPLOAD_DATA_DIR 바인드 마운트
├─▶ mail : MAIL_PUBLIC_URL·MAIL_RECOVERY_ADMIN → STALWART_* (backend 에는 MAIL_JMAP_URL·MAIL_DOMAIN·MAIL_GATEWAY_*·MAIL_ATTACHMENT_*·
│ MAIL_ADMIN_LOCAL_PART·MAIL_BLOCKED_ACCOUNTS)
│ 운영의 MAIL_RECOVERY_ADMIN 은 파일에 두지 않고 셸 일회성 override 로만 준다 (mail/README.md "운영 배포")
│ MAIL_ADMIN_LOCAL_PART 는 setup.mjs 와 backend 가 함께 읽는다 — bootstrap 전에만 정할 수 있다
└─▶ mail/scripts/setup.mjs : `node --env-file=.env.{APP_ENV}` 로 같은 파일을 직접 읽는다 (mail/README.md)
```
규칙:
@@ -33,18 +43,30 @@ description: 환경변수 단일 진실 공급원(SSOT) 구조 — 루트 .env.{
포트를 `frontend/.env` 나 `vite.config.ts` 에 중복 작성하지 말 것 — 루트가 SSOT 다.
(읽은 값은 dev 서버 설정에만 쓰고 클라이언트 번들에는 넣지 않는다 — `VITE_` 없는 값의 번들 노출 금지, [[security]])
- **Flutter 는 예외** — docker-compose 대상이 아니므로 `flutter/.env` 에서 `API_URL` 을 독립적으로 관리한다.
- **루트 env 를 Node 스크립트가 직접 읽을 때**(`node --env-file=…`) 두 가지를 주의한다.
- Node 는 **셸에 이미 있는 변수를 덮어쓰지 않는다** — 셸에 `APP_ENV=development` 가 남은 채 `.env.production` 을 주면 개발 값으로 돈다.
스크립트는 env 파일 값과 셸 값이 다르면 중단해야 한다 (`mail/scripts/setup.mjs` 의 `findShellOverrides()`).
- Node 는 따옴표 없는 값의 `#` 부터를 **공백이 없어도** 주석으로 자르고, compose 는 그대로 읽는다.
비밀번호 등은 영문·숫자로만 만든다 (`#`·따옴표·`$`·공백 금지).
- 모든 env 파일은 커밋 금지, `*.example` 만 커밋한다. (`.gitignore` 의 `!*.example` 재포함 규칙)
- **설정이 잘못되면 서버가 뜨지 않아야 한다(fail-fast).** 조용히 기본값으로 굴러가다 운영에서 터지는 것이 더 위험하다.
검증은 `backend/src/shared/config/env.validation.ts` 가 담당하며 `ConfigModule.forRoot({ validate })` 에 연결되어 있다.
- 숫자 항목은 형식 검사 (모든 환경)
- 운영 환경: `DB_HOST`/`DB_USER`/`DB_PASSWORD`/`DB_NAME` 필수
- 운영 환경 필수: `DB_HOST`/`DB_USER`/`DB_PASSWORD`/`DB_NAME`, `JWT_SECRET`, `MAIL_JMAP_URL`/`MAIL_DOMAIN`/`MAIL_GATEWAY_*`,
`CORS_ORIGIN`(화면의 공개 주소 — 상태 변경 요청의 출처 검사), `TRUST_PROXY`(백엔드 앞 프록시 홉 수 — 운영에서 `true`·`0`·`false` 는 거부)
- 운영 환경: `change-me`·`password` 같은 **예시 값이 그대로 배포되는 것을 차단**
- 운영 환경: `CORS_ORIGIN`·`BACKEND_API_URL` 에 **로컬 주소(`localhost`·`127.0.0.1` 등)나
예시 도메인(`example.com`)이 남아 있으면 차단** — 기동은 되는데 브라우저만 조용히 실패하는 조합을 막는다.
로컬에서 운영 이미지를 검증하느라 일부러 localhost 를 쓸 때만 `ALLOW_LOCALHOST_URL=true` 로 건너뛴다
(`BACKEND_API_URL` 은 앱이 쓰지 않지만 이 검사를 위해 compose 가 backend 에 주입한다)
(`BACKEND_API_URL` 은 앱도 프론트 빌드도 쓰지 않는다 — 같은 주소 배포라 "화면 주소 + /api" 이고, 이 검사를 위해서만 compose 가 backend 에 주입한다)
- 문제는 하나씩이 아니라 **한 번에 모아서** 보여준다 (재기동 반복 방지)
- 새 필수 설정을 추가하면 이 파일의 목록과 테스트도 함께 갱신한다
> 루트 env의 대표 키: `PROJECT_NAME`, `APP_ENV`, `BUILD_MODE`, 각종 `*_PORT`, `BACKEND_API_URL`, `CORS_ORIGIN`, `DB_*`/`DB_DATA_DIR`, `REDIS_*`/`REDIS_DATA_DIR`, `THROTTLE_TTL_MS`/`THROTTLE_LIMIT`. 실제 목록은 `.env.development.example` 참조.
> 루트 env의 대표 키: `PROJECT_NAME`, `APP_ENV`, `BUILD_MODE`, 각종 `*_PORT`, `APP_NETWORK_SUBNET`·`EDGE_NETWORK_SUBNET`, `BACKEND_API_URL`, `CORS_ORIGIN`, `TRUST_PROXY`,
> `DB_*`/`DB_DATA_DIR`, `REDIS_*`/`REDIS_DATA_DIR`, `THROTTLE_TTL_MS`/`THROTTLE_LIMIT`, `UPLOAD_MAX_SIZE_MB`(·개발 전용 `UPLOAD_DATA_DIR`),
> 메일 `MAIL_IMAGE`(패치까지 고정)·`MAIL_DOMAIN`·`MAIL_HOSTNAME`·`MAIL_PUBLIC_URL`·`MAIL_ADMIN_LOCAL_PART`(관리자 아이디, 기본 `admin` — setup·backend 공용, bootstrap 전에만)·
> `MAIL_ADMIN_PASSWORD`·`MAIL_RECOVERY_ADMIN`(운영은 일회성 override)·`MAIL_GATEWAY_USER`/`MAIL_GATEWAY_PASSWORD`·
> `MAIL_JMAP_URL`·`MAIL_ATTACHMENT_MAX_SIZE_MB`/`MAIL_ATTACHMENT_TOTAL_MAX_SIZE_MB`·`MAIL_TRUSTED_NETWORKS`(비우면 `APP_NETWORK_SUBNET`, 그보다 넓으면 setup 실패)·
> `MAIL_BLOCKED_ACCOUNTS`(선택 — 관리자 외에 웹메일 로그인·대리 접근을 막을 주소, 콤마. 비어 있어도 관리자는 항상 막힌다)
> (개발 전용 `MAIL_ADMIN_PORT`·`MAIL_DEV_USER_PASSWORD`·`MAILPIT_IMAGE`/`MAILPIT_UI_PORT`). 실제 목록은 `.env.development.example` 참조.
> 실행/배포 명령은 [[docker]], 보안 규칙은 [[security]] 참조.
+3 -1
View File
@@ -34,6 +34,7 @@ backend/src/common/errors/error-codes.ts ← 여기만 수정한다
| AUTH_001 | 401 | 미인증 상태로 보호 자원 접근 | "로그인이 필요한 서비스입니다. 로그인 후 이용해 주세요." |
| AUTH_002 | — | **클라이언트 전용** · 세션 만료 | "안전을 위해 로그아웃 되었습니다. 다시 로그인해 주세요." |
| AUTH_003 | 403 | 권한 부족 | "해당 메뉴나 기능에 접근할 수 있는 권한이 없습니다." |
| AUTH_004 | 401 | 로그인 자격 증명 불일치 (계정 없음·비밀번호 틀림을 구분하지 않음) | "이메일 또는 비밀번호가 올바르지 않습니다." |
| VAL_001 | 400 / 422 | 입력 검증 실패 | "입력하신 정보를 다시 확인해 주세요. (필수값 누락 또는 형식 오류)" |
| RES_001 | 404 | 리소스 없음 | "요청하신 정보나 페이지를 찾을 수 없습니다." |
| BIZ_001 | 400 | 그 외 비즈니스 규칙 위반 | "요청하신 작업을 완료하지 못했습니다. 다시 시도해 주세요." |
@@ -43,7 +44,8 @@ backend/src/common/errors/error-codes.ts ← 여기만 수정한다
서버는 이 코드를 **절대 내려주지 않는다.** 클라이언트가 다음 조건에서 스스로 생성한다.
- **인증된 세션이 있는 상태**에서 401 응답을 받았을 때 → `AUTH_001` 을 `AUTH_002` 로 승격하고 세션을 정리한다.
- 웹: `useApi.ts` 의 `toAuthCode()` (`authStore.clearAuth()` 호출)
웹은 그 전에 `/auth/refresh` 를 한 번 시도하고, 갱신까지 실패했을 때만 승격한다 (`docs/mail/api-contract.md` 1절).
- 웹: `useApi.ts` 의 `toAuthCode()` — `authStore.expireSession()` 을 불러 세션 만료 대화상자(`SessionExpiredDialog.vue`)를 띄운다
- 앱: `dio_client.dart` 의 `_toApiException()` (`jwt_token` 삭제)
## 도메인 코드 추가
+50 -2
View File
@@ -13,13 +13,25 @@
- JWT는 HttpOnly·Secure 쿠키 사용 (웹 `localStorage` 금지) / 모바일은 `flutter_secure_storage` 사용 (키: `jwt_token`)
- Access Token 만료는 짧게(15분~1시간), Refresh Token 은 HttpOnly 쿠키로만 전달
- 리프레시 토큰은 서버 세션에 **해시만** 저장하고 **1회용**으로 회전한다. 이미 쓴 토큰이 다시 오면 탈취로 보고 세션 전체를 끊는다.
동시 갱신(탭 여러 개)을 위한 유예는 "바로 이전 토큰 + 짧은 시간"으로 좁히고, 유예 응답에서는 새 토큰을 주지 않는다 (훔친 토큰으로 얻는 것이 없게)
- 로그인 실패 한도는 자격 확인 **전에** 실패 한 번을 원자적으로 차지하고 성공하면 돌려준다.
확인 뒤에 세면 동시에 들어온 요청 수백 건이 모두 한도 검사를 통과한다 (구현 [[nestjs]])
- 비밀번호는 단방향 해시(`bcrypt`/`argon2`)로 저장한다. 평문·가역 암호화 금지
- 인증 실패 응답에서 **원인을 구분해 알려주지 않는다** (아이디 없음/비밀번호 틀림 → 동일하게 AUTH_001)
- **메일 게이트웨이(대리 접근) 계정은 관리자 계정까지 대리할 수 있다** (Stalwart 0.16 확인) — 게이트웨이 비밀번호는 관리자급 비밀로 다룬다.
백엔드는 엔진 관리자 계정을 로그인·대리 접근 대상에서 막는다: 정적 목록(엔진에 묻기 전) + 로그인·리프레시 때 엔진 권한 확인 ([[nestjs]])
- 인증 실패 응답에서 **원인을 구분해 알려주지 않는다** (아이디 없음/비밀번호 틀림 → 동일하게 AUTH_004)
- 로그인 시도의 실패는 AUTH_004, 로그인 없이 보호 자원에 접근한 것은 AUTH_001 이다.
로그인 실패에 AUTH_001 을 쓰면 "로그인이 필요한 서비스입니다"가 떠 사용자가 원인을 알 수 없다.
## 입력 · 출력
- 입력값은 DTO(`class-validator`) 검증을 거친 뒤 서비스 계층에 전달한다. 전역 `ValidationPipe` 의 `whitelist`/`forbidNonWhitelisted` 를 끄지 않는다
- TypeORM ORM 쿼리 사용으로 SQL Injection 차단 (Raw 쿼리 시 파라미터 바인딩 필수)
- 외부로 나가는 데이터(보내는 메일의 헤더 등)에 비교용 지문을 실어야 하면 평범한 해시가 아니라 **받는 쪽이 모르는 비밀로 HMAC** 한다
(예: 메일마다 다른 무작위 `Idempotency-Key`). 평범한 해시는 받은 사람이 후보 값(숨은참조 주소 등)을 대입해 맞춰 볼 수 있다.
- **서버 서명 키(`JWT_SECRET` 등)나 거기서 파생한 키를 쓰지 않는다** — 평문을 아는 받은 사람이 (평문, HMAC) 쌍으로
서명 키 후보를 오프라인에서 대입해 볼 수 있고, 맞히면 토큰을 위조할 수 있다 (2026-10-26 리뷰)
- 에러 응답에 스택·쿼리·내부 호스트명을 싣지 않는다 — 미정의 예외는 항상 `SYS_001` 로 수렴시킨다 ([[error-codes]])
### 파일 업로드
@@ -32,6 +44,9 @@
- **저장 파일명은 서버가 생성**한다(UUID + 검증된 확장자). 클라이언트가 보낸 이름을 경로에 쓰면
`../../etc/passwd` 같은 경로 탈출이 가능하다. 원본 이름은 표시용 메타데이터로만 보관한다.
- 크기 한도를 설정한다 (`UPLOAD_MAX_SIZE_MB`, 기본 5MB). 초과 시 `FILE_002`(413).
- 큰 업로드(메일 첨부 등)는 **메모리가 아니라 임시 디스크**로 받아 스트리밍으로 넘기고, 성공·실패와 관계없이 지운다.
메모리에 받으면 동시 업로드 수 × 한도만큼 상주해 컨테이너가 OOM 으로 죽고 모든 요청이 끊긴다.
임시 디스크를 tmpfs 로 두면 메모리 한도에 잡혀 같은 문제가 된다.
- 업로드 디렉터리를 **정적 파일로 그대로 서빙하지 않는다.** 내려줄 때는 컨트롤러를 거쳐
`Content-Type` 을 서버가 지정하고 `Content-Disposition: attachment` 를 붙인다.
@@ -45,6 +60,14 @@
- 보안 헤더(`helmet`) 적용
- CORS 는 화이트리스트 방식. 차단 시 `callback(null, false)` 로 **헤더를 부여하지 않아** 브라우저가 막게 한다
(`callback(new Error(...))` 를 쓰면 500(SYS_001)이 되어 원인 파악이 어려워진다)
- 쿠키로 인증하므로 상태를 바꾸는 요청은 **출처를 검사**한다 (`OriginGuard`, 전역 — 계약 `docs/mail/api-contract.md` 머리말).
비교 기준은 `Host` 가 아니라 허용 목록(`CORS_ORIGIN`)이다. 프록시 뒤에서는 `Host` 가 바뀌어 정상 요청까지 막힌다.
- 클라이언트 IP 는 `TRUST_PROXY`(홉 수 또는 주소 목록)로만 해석한다. 모든 홉을 믿으면(`true`) `X-Forwarded-For` 위조로
IP 기준 한도(로그인 실패·rate limit)가 무력화된다. 입구는 프론트 nginx 하나로 둔다 —
프록시를 건너뛰어 백엔드에 닿는 경로가 있으면 홉 수가 어긋나 위조가 가능해진다.
- **인터넷을 향한 컨테이너(프론트 nginx)는 백엔드에만 닿게** 한다 — DB·메일 엔진과 같은 네트워크에 두지 않는다
(`edge`: frontend↔backend / `app-network`: backend↔db↔mail, [[docker]]). 메일 엔진의 신뢰 대역은 `app-network` 뿐이다.
- 컨테이너가 쓰는 데이터(업로드 등)는 **named volume** 에 둔다 — 호스트 바인드 마운트는 root 소유로 생겨 비-root 컨테이너가 쓰지 못한다.
- Rate Limiting: **`@nestjs/throttler` 하나로 일원화**되어 있다 (`app.module.ts` 의 `APP_GUARD`).
초과 시 429 → 전역 필터가 **`REQ_001`** 로 매핑한다.
- 기본 정책은 루트 `.env` 의 `THROTTLE_TTL_MS`/`THROTTLE_LIMIT` (미설정 시 60초/100회)
@@ -85,7 +108,32 @@
2. **수정이 다른 것을 깨뜨리는가** — 생태계 버전이 어긋나면 더 큰 위험
3. 미룬다면 **언제 다시 볼지** 조건을 적어둔다
> **현재 유예 중인 항목**: 없음 (`npm audit` 0건)
> **현재 유예 중인 항목** (2026-10-27 점검):
> - frontend `eslint-plugin-vue@9` → `postcss-selector-parser` (moderate, 선택자 파싱 CPU 과다 사용)
> - 도달 경로: 개발 시 린트에서만 돈다. 입력은 우리 소스 코드뿐이라 외부 공격 경로가 없다. 운영 번들·이미지에 포함되지 않는다.
> - 미루는 이유: 수정판이 `eslint-plugin-vue@10`(메이저)이라 린트 규칙이 바뀌고 설정 이관이 필요하다.
> - 다시 볼 시점: ESLint 스택을 올릴 때, 늦어도 2027-01.
> - backend (2026-10-27 기준 7건 — high 3·moderate 4, 운영 의존성만 보면 6건 — 아래 5종):
>
> | 항목 | 도달 경로 | 미루는 이유 | 다시 볼 시점 |
> | --- | --- | --- | --- |
> | `body-parser` 2.3.0 (high, 압축 본문 DoS) | 없음 — Nest 기본 파서를 끄고 JSON 파서만 `inflate:false` 로 둔다. 압축 본문은 해제 전에 415 (실서버 확인) | 수정판 2.4.0 이 2026-10-22 배포 (2주 미만) | 2026-11-05 에 lock 갱신 (express 5.3.0 이 `^2.3.0` 이라 범위 안) |
> | `multer` 2.4.0 (high, 비동기 fileFilter DoS) | 없음 — 우리 fileFilter(upload·mail)는 모두 동기 | 2.5.0 이 2026-10-22 배포 (2주 미만). `@nestjs/platform-express` 최신 12.2.0 도 2.4.0 고정이라 override 를 올릴 근거가 아직 없음 | 2026-11-05 이후 override `^2.5.0` 여부 결정 |
> | `js-yaml` 5.3.0 (moderate, `@nestjs/swagger` 하위) | 없음 — swagger 는 `dump` 만 쓰고 우리 코드는 YAML 을 파싱하지 않는다 | swagger 11.x 가 5.3.0 으로 정확히 고정 (12.x 는 5.5.0 이지만 메이저) | swagger 11.x 가 ≥5.4.1 을 선언한 릴리스 + 2주. 또는 override `^5.4.3` 검토 (swagger 12 가 5.x 를 쓰는 것이 근거) |
> | `fast-uri` 3.1.8 (moderate, 개발 전용) | 없음 — Nest CLI 의 ajv 에서만 쓰이고 운영 이미지에 없다 | 3.1.9 가 2026-10-19 배포 (2주 미만) | 2026-11-02 |
> | `sanitize-html` 2.18.0 (moderate, 버린 iframe 의 대체 내용 재귀 — GHSA-c9rx) | 없음 — 우리 설정은 iframe 을 내용째 버려 대체 내용을 다시 정화하지 않는다 (5만 단계 중첩으로 확인, 2만 단계 회귀 테스트로 고정) | 수정판 2.18.1 이 2026-10-14 배포 (2주 미만) | 2026-10-28 이후 2.18.1 |
>
> 2026-10-27 Node 24 전환과 함께 sanitize-html 고정(2.17.5)을 풀어 이전 권고 4건을 해결했다.
> 2.18 이 쓰는 htmlparser2 계열은 ESM 전용이라 Jest 설정에서 CJS 로 변환한다 ([[nestjs]] "의존성 추가/제거").
>
> 참고: `@nestjs/platform-express` 11.2.7(2026-09-30) 이상은 multer 2.4.0 을 직접 선언한다 — 올리면 아래 multer override 를 지울 수 있다.
>
> 같은 점검에서 정리한 것: frontend 22건 → 2건 (axios 1.20.0 · vue 3.5.43 · vite 8.3.4 · oxfmt 0.72.0 으로 갱신, lock 은 Linux 컨테이너에서 생성),
> backend 12건 → 7건 (critical 0 — proxy-addr 2.0.8 등).
> 새 버전은 배포 후 2주 이상 지난 패치 버전을 골랐다 (공급망 위험 — 갓 나온 버전은 피한다).
> **예외: 우리에게 닿는 취약점을 고치는 보안 릴리스**는 2주를 기다리지 않고 CI 가 통과하는 대로 올린다 — 알려진 취약점을 2주 더 두는 위험이 더 크다
> (예: Node 24.22.1 — 2026-10-20 보안 릴리스를 6일 만에 고정, 고정 위치는 [[docker]]).
> 도달 경로가 없다고 확인한 취약점이면 평소처럼 2주를 기다린다 (예: 위 표의 sanitize-html 2.18.1).
### 전이 의존성 취약점은 overrides 로 끊는다
+179 -4
View File
@@ -18,6 +18,7 @@ description: Docker Compose 기반 로컬·운영 오케스트레이션 실행/
```bash
# 코드만 수정했을 때 (의존성 변경 없음) — backend + db 가 뜬다
# (예시 env 의 기본값 COMPOSE_PROFILES=mail 이면 메일 엔진 mail + 개발용 mailpit 도 함께 — 아래 "메일" 절)
docker compose -f docker-compose.yml -f docker-compose.dev.yml --env-file .env.development up -d
# 의존성 변경 시에만 --build
@@ -29,8 +30,8 @@ docker compose -f docker-compose.yml -f docker-compose.dev.yml --env-file .env.d
## 운영 환경
> ⚠️ `.env.production` 의 `BACKEND_API_URL`·`CORS_ORIGIN` 을 **실제 도메인으로 교체**해야 한다.
> `localhost` 나 예시 도메인(`example.com`)이 남아 있으면 백엔드가 기동에 실패한다 ([[env-structure]]).
> ⚠️ `.env.production` 의 `BACKEND_API_URL`·`CORS_ORIGIN` 을 **실제 도메인으로 교체**해야 한다 (예시: `https://portal.arasystem.it.kr`).
> `localhost` 나 예시 도메인(`example.com`)이 남아 있으면 백엔드가 기동에 실패한다 ([[env-structure]]). `CORS_ORIGIN`·`TRUST_PROXY` 는 운영 필수다.
> 로컬에서 운영 이미지를 검증하느라 일부러 localhost 를 쓰는 경우에만 `ALLOW_LOCALHOST_URL=true` 를 함께 준다.
```bash
@@ -41,6 +42,77 @@ sudo docker compose --env-file .env.production up -d --build
sudo docker compose --env-file .env.production logs -f backend
```
### 같은 주소(origin) 배포 — 입구는 프론트 nginx 하나
```
클라이언트 ─TLS─▶ 외부 리버스 프록시 ─(proxy)─▶ <PROJECT_NAME>-frontend :8080 ─┬─ / 화면(SPA)
└─ /api/ ─(edge)─▶ <PROJECT_NAME>-backend :3000
│ (app-network = 메일 엔진의 신뢰 대역)
├─▶ db · redis
└─▶ mail :8080 (JMAP·관리 API)
```
| 네트워크 | 붙는 컨테이너 | 비고 |
| --- | --- | --- |
| `proxy` (external) | 외부 리버스 프록시 · frontend | 다른 프로젝트와 함께 쓸 수 있다. 운영 전용 (dev 오버라이드가 지운다) |
| `<PROJECT_NAME>_edge` | frontend · backend | `EDGE_NETWORK_SUBNET`(기본 `172.16.72.0/24`) 고정 — TRUST_PROXY 주소 목록용 |
| `<PROJECT_NAME>_app-network` | backend · db · redis · mail (dev 는 mailpit 도) | `APP_NETWORK_SUBNET`(기본 `172.16.71.0/24`) 고정 — **메일 엔진의 신뢰 대역**. frontend 는 붙이지 않는다 |
- 브라우저는 화면과 같은 주소의 `/api` 를 부른다 (docs/mail/api-contract.md 머리말). 프론트 빌드는 `VITE_API_BASE_URL=/api` 로 고정이다.
**backend 는 proxy 네트워크에 붙지 않는다** — 바깥에서는 nginx 의 `/api` 로만 들어온다.
- **인터넷을 향한 frontend 는 app-network 에 붙지 않는다** (edge 로만 backend 에 닿는다). 붙으면 nginx 가 `mail:8080`(평문 관리 API)에
닿고, 그 주소가 자동 차단 예외·게이트웨이 로그인 대역에 든다. mail setup 이 앱 네트워크에 붙은 컨테이너를 확인한다 (운영은 그 밖의 컨테이너가 있으면 중단).
- **외부 리버스 프록시는 `<PROJECT_NAME>-frontend:8080` 으로 붙인다** (proxy 네트워크). 서비스 이름(`frontend`)은 다른 프로젝트와 겹칠 수 있다.
같은 이유로 nginx 의 백엔드 주소도 `http://<PROJECT_NAME>-backend:<BACKEND_PORT>` 다 (compose 의 `NGINX_BACKEND_UPSTREAM`) —
proxy 네트워크에 `backend` 라는 다른 컨테이너가 있으면 그쪽으로 풀릴 수 있기 때문이다.
- `frontend/nginx.conf` 는 **템플릿**이다. 컨테이너가 뜰 때 이미지 entrypoint 가 `frontend/nginx-env.envsh` 로 값을 만들고
`${NGINX_*}` 만 envsubst 로 채운다 (nginx 의 `$uri` 등은 그대로). 값은 루트 env 에서 온다:
- 본문 한도: `/api/mail-attachments` = `MAIL_ATTACHMENT_MAX_SIZE_MB` + 1MB(기본 21m), `/api/files` = `UPLOAD_MAX_SIZE_MB` + 1MB(기본 6m),
그 밖은 1m(백엔드 JSON 파서 한도). 루트 env 한 곳만 고치면 백엔드와 nginx 가 함께 바뀐다 (frontend 컨테이너만 다시 만들면 된다).
- 시간 제한: `/api/messages` 90s, `/api/mail-attachments` 150s (`proxy_read_timeout`).
- 캐시: `index.html`(직접 요청·`/`·SPA 폴백 모두)은 `Cache-Control: no-cache` — 배포 뒤 옛 index.html 이 지워진 해시 자산을 부르지 않게.
`/assets/`(Vite 산출물 — 파일명에 내용 해시)만 `max-age=30일, public, immutable` 이다.
해시가 없는 공개 파일(`frontend/public` → 루트, 예: `favicon.ico`)은 같은 이름으로 바뀌므로 `no-cache`(매번 재검증, 안 바뀌면 304).
보안 헤더는 어느 경우에도 붙는다(`add_header_inherit merge`).
- 헤더: `X-Forwarded-For` 는 이어 붙이고, `X-Request-Id` 는 nginx 의 `$request_id` 로 덮어써 백엔드 traceId 와 nginx 로그 `rid=` 가 같다.
`Origin`·`Sec-Fetch-Site`·`Idempotency-Key`·`Cookie` 는 그대로 넘긴다. `/api` 응답에는 화면용 보안 헤더를 붙이지 않는다(백엔드 helmet 이 정한다).
- 백엔드 주소는 요청마다 도커 DNS 로 다시 풀어(`resolver`), 백엔드를 다시 만들어도 nginx 를 재시작할 필요가 없다.
- **nginx 가 직접 끝내는 `/api` 오류도 표준 실패 형식(JSON)이다** — 본문 한도 초과 413 `FILE_002`, 백엔드 응답 없음 502·시간 초과 504
(그리고 500·503) `SYS_001`. `message` 는 비우고(클라이언트가 코드로 사전 문구를 쓴다) `traceId` 는 nginx 요청 ID 다 (응답 헤더 `X-Request-Id`·로그 `rid=` 와 같다).
상태 코드는 원래 값 그대로다. 백엔드가 보낸 4xx·5xx 는 손대지 않는다 (`proxy_intercept_errors` 꺼짐).
- **`/api/health` 는 바깥에서 404 다** (`RES_001` JSON — 없는 경로와 같다). 실패하면 DB 오류 문자열 등 원인이 담기기 때문이다
(대소문자·끝 `/` 도 막는다). 내부에서는 백엔드에 직접 묻는다:
```bash
# 상태 코드와 원본 본문 (503 이어도 원인이 보인다) — 백엔드 이미지에 node 가 있다
sudo docker exec "${PROJECT_NAME}-backend" node -e "fetch('http://127.0.0.1:' + process.env.PORT + '/api/health').then(async (r) => console.log(r.status, await r.text()))"
# 모니터링 도구를 컨테이너로 둔다면 edge 네트워크에 붙여 http://<PROJECT_NAME>-backend:<BACKEND_PORT>/api/health 를 본다
```
- 설정 문법 검사 (이미지로, 화면 빌드 없이): `docker build --target nginx-config -t <tag> frontend && docker run --rm -e PROJECT_NAME=x <tag> nginx -t`
(CI 의 infra 잡이 같은 검사를 한다). 값을 바꿔 렌더링 결과를 보려면
`docker run --rm -e PROJECT_NAME=x -e MAIL_ATTACHMENT_MAX_SIZE_MB=30 <tag> nginx -T | grep client_max_body_size`.
`PROJECT_NAME`(또는 `NGINX_BACKEND_UPSTREAM`) 없이 띄우면 일부러 기동을 멈춘다.
- **템플릿 값이 잘못되면 frontend 가 뜨지 않는다** (`frontend/nginx-env.envsh`, fail-fast) — `docker logs <PROJECT_NAME>-frontend` 의 `nginx-env.envsh:` 줄을 본다.
- `MAIL_ATTACHMENT_MAX_SIZE_MB`·`UPLOAD_MAX_SIZE_MB`: 빈 값은 기본값(20·5), 그 밖은 0 으로 시작하지 않는 6자리 이하 양의 정수만 (`08`·`010` 거부 — 셸 산술이 8진수로 읽는다)
- `NGINX_BACKEND_UPSTREAM`: `http://호스트[:포트]` 만. 경로(끝의 `/` 포함)를 붙이면 모든 `/api` 요청이 그 경로 하나로 가므로 거부한다
- **외부 리버스 프록시 요구사항** (클라이언트 → 외부 프록시 → nginx → 백엔드 — 이 compose 의 운영 경로는 이것뿐이다.
frontend 는 호스트 포트를 열지 않고 TLS 도 하지 않는다):
- **`X-Forwarded-For` 를 이어 붙이거나 `$remote_addr` 로 새로 넣는다.** 클라이언트가 보낸 값을 그대로 넘기면 백엔드가 보는 IP 가 위조된다
(로그인 실패 한도가 IP 기준이다).
- **`TRUST_PROXY`**: proxy 네트워크를 이 프로젝트의 외부 프록시만 쓰면 홉 수 `2`.
**다른 프로젝트와 함께 쓰면 주소 목록을 권장한다** — `<EDGE_NETWORK_SUBNET>,<외부 프록시 IP>` (예: `172.16.72.0/24,172.20.0.10`).
홉 수는 "오른쪽에서 몇 번째 주소"만 믿으므로, 같은 네트워크의 다른 컨테이너가 외부 프록시를 건너뛰고 frontend 에 바로 붙으면
`X-Forwarded-For` 를 꾸며 IP 를 바꿀 수 있다. 외부 프록시 IP 는 frontend 가 보는 주소(proxy 네트워크 안)이며 고정해 둔다(`ipv4_address` 등).
- **본문 한도는 `MAIL_ATTACHMENT_MAX_SIZE_MB` + 1MB 이상**, 읽기 시간 제한은 150s 이상. 작으면 백엔드에 닿기 전에 413 이 나고
사용자에게는 `FILE_002` 로 보인다 (프론트 nginx 는 자동으로 맞지만 외부 프록시는 직접 맞춘다).
- **IPv6 를 받는지 확인한다** (운영 확인 항목). 백엔드는 IPv6 클라이언트를 /64 단위로 묶어 센다 — 외부 프록시가 IPv6 를 받는다면
`X-Forwarded-For` 에 원래 IPv6 주소가 그대로 들어가는지 본다.
- 업로드 중 임시 디스크: 백엔드는 첨부를 `/tmp/arasystem-mail-uploads`(컨테이너 쓰기 계층)에, nginx 는 요청 본문을 자기 임시 폴더에 받는다.
필요한 공간은 동시 업로드 수 × 20MB 정도. backend 를 `read_only` 로 바꾸면 그 경로에 **tmpfs 가 아닌 볼륨**을 붙인다 (tmpfs 는 메모리 한도에 잡힌다).
- **업로드 파일은 named volume `<PROJECT_NAME>_uploads` 에 쌓인다** (`/api/files`). 호스트 디렉터리(`./data/uploads`)를 쓰지 않는 이유 —
`sudo docker compose` 가 없는 디렉터리를 root 소유로 만들어 backend(node, UID 1000)가 쓰지 못한다. 새 named volume 은 이미지의
`/app/uploads`(node 소유) 소유자를 물려받는다 (2026-10-27 운영 이미지로 확인). 개발만 `./data/uploads` 바인드 마운트다.
백업은 메일 볼륨과 같은 방식: `sudo docker run --rm -v "${PROJECT_NAME}_uploads:/uploads:ro" -v "$PWD/backup:/backup" alpine tar czf "/backup/uploads-$(date +%Y%m%d).tar.gz" -C / uploads`.
---
## 프론트엔드는 호스트에서 (개발 환경)
@@ -49,6 +121,9 @@ sudo docker compose --env-file .env.production logs -f backend
cd frontend && npm install && npm run dev # → http://localhost:5173
```
- **호스트 Node 는 24 LTS 를 권장한다** — 이미지·CI(`24.22.1` 고정, 아래 규칙 "Node 버전")와 같은 메이저다. 프론트 `engines` 가 `^20.19.0 || >=22.12.0` 이라
20.19 이상이면 돌아가지만, 버전이 다르면 이미지·CI 에서만 나는 문제를 늦게 발견한다. (메일 setup 스크립트는 `--env-file` 때문에 20.6 이상)
- **포트는 루트 `.env.{APP_ENV}` 가 SSOT 다** ([[env-structure]]). `vite.config.ts` 가 루트 env 를 직접 읽어
`FRONTEND_PORT` 로 열고, `/api` 프록시 대상은 `http://localhost:${BACKEND_PORT}`(백엔드 컨테이너가
호스트에 노출한 포트)로 잡는다. 포트를 바꿀 때 루트 env 한 줄만 고치면 된다.
@@ -97,7 +172,8 @@ dc ps
# 종료 — 컨테이너 중지·삭제 (DB/Redis 데이터 볼륨은 보존)
dc down
# ⚠️ 데이터까지 삭제 — DB/Redis 볼륨 제거 (복구 불가, 신중히)
# ⚠️ 데이터까지 삭제 — 명명 볼륨 제거 (복구 불가, 신중히)
# 메일 볼륨(<PROJECT_NAME>_mail-config·_mail-data)이 지워져 메일함·계정이 전부 사라진다 (운영은 업로드 볼륨 _uploads 도)
dc down -v
# 특정 서비스만 재빌드·기동 (예: 의존성 바뀐 backend)
@@ -136,6 +212,9 @@ docker compose -f docker-compose.runner.yml --env-file .env up -d
- 러너가 없으면 워크플로는 **"Waiting for a runner"** 로 대기만 한다.
- `RUNNER_LABELS` 의 라벨이 워크플로의 `runs-on` 과 **일치해야** 잡을 집는다.
- 러너는 도커 소켓에 접근하므로 사실상 호스트 관리자 권한을 갖는다 — 신뢰할 수 있는 워크플로만 실행되게 한다.
- `ci.yml` 의 **infra 잡**(compose `config -q` · `docker build --check` · nginx 단계 `nginx -t`)은 잡 컨테이너 안에서 docker 를 쓴다.
act_runner 기본 설정(`container.docker_host` 비움)이 호스트 도커 소켓을 잡 컨테이너에 붙여 주고, 잡 이미지 `gitea/runner-images:ubuntu-latest` 에
docker CLI·compose 가 들어 있다 (2026-10-27 확인: docker 29.7, compose 5.4). 러너 설정에서 `docker_host: "-"` 로 소켓을 막았다면 이 잡만 실패한다.
설치·문제 해결은 [`ci/README.md`](../../../ci/README.md) 참조.
@@ -164,8 +243,67 @@ dc --profile redis up -d
---
## 메일 (Stalwart · Mailpit) — profile `mail`
직원 로그인 = 메일 계정이라 **예시 env 는 개발·운영 모두 `COMPOSE_PROFILES=mail`** 이다.
구조·운영 배포·도메인 전환 절차는 [`mail/README.md`](../../../mail/README.md) 가 SSOT 다.
```bash
# 처음 띄운 뒤 한 번 (env 를 바꿨을 때도) — 여러 번 실행해도 안전하다
node --env-file=.env.development mail/scripts/setup.mjs
```
- 첫 실행은 bootstrap 후 **mail 컨테이너를 스스로 재시작**한다. 셸에 `APP_ENV` 등이 남아 env 파일 값과 다르면 중단한다
(Node 의 `--env-file` 은 기존 환경변수를 덮어쓰지 않는다).
- 개발: 관리 화면 `http://localhost:${MAIL_ADMIN_PORT}/admin/`, Mailpit `http://localhost:${MAILPIT_UI_PORT}` (둘 다 127.0.0.1 한정).
setup 이 외부 발송 경로를 Mailpit 으로 돌리고 리로드한 **뒤에야** 외부 주소 메일이 Mailpit 에 쌓인다 — 그 전에는 실제로 발송된다.
- 백엔드는 `MAIL_JMAP_URL`(http://mail:8080)·`MAIL_DOMAIN`·`MAIL_GATEWAY_USER`·`MAIL_GATEWAY_PASSWORD`·`MAIL_ATTACHMENT_MAX_SIZE_MB`(기본 20)·
`MAIL_ATTACHMENT_TOTAL_MAX_SIZE_MB`(기본 25) 를 compose 로 받는다.
- 데이터는 named volume `<PROJECT_NAME>_mail-config`·`<PROJECT_NAME>_mail-data` (UID 2000 소유가 필요해 바인드 마운트를 쓰지 않는다).
- setup 이 잠그는 것: 공개 443 의 JMAP·관리 API·웹 포털(→ 403, MTA-STS 정책 파일만 연다), 직원 역할의 계정 목록(Principal) 조회.
신뢰 대역은 고정한 app-network 서브넷(`APP_NETWORK_SUBNET`) 안으로만 받는다 — 더 넓으면 실패한다.
- setup 은 **바꾸기 전에** 앱 네트워크 서브넷·구성원(frontend 등이 붙어 있지 않은지)·백엔드 주소를 확인하고, 어긋나면 아무것도 바꾸지 않고 멈춘다.
**운영은 docker 를 쓸 수 있는 사용자로 호스트에서 돌린다** (docker 그룹 또는 `sudo -E`) — docker 가 없으면 바로 실패한다.
- 운영의 복구 관리자(`MAIL_RECOVERY_ADMIN`)는 `.env.production` 에 두지 않고 셸 일회성 override + `setup.mjs --recovery` 로만 쓴다
(파일에 있거나, 끝난 뒤 mail 컨테이너에 남아 있으면 setup 이 실패한다 — 절차는 mail/README.md "운영 배포").
- 영구 관리자 아이디는 `MAIL_ADMIN_LOCAL_PART`(기본 `admin`) — setup 과 백엔드가 함께 읽는다 (compose 가 backend 에 넘긴다).
**bootstrap 전에만 정할 수 있다** — 바꾼 값이 엔진의 관리자와 다르면 setup 이 멈춘다. 다른 관리자 계정은 `MAIL_BLOCKED_ACCOUNTS` 에 넣는다.
- ⚠️ **게이트웨이 비밀번호 = 관리자급 비밀.** Stalwart 0.16 은 대리 접근 대상을 제한하지 못해, 신뢰 대역 안에서는 게이트웨이로 관리자 계정도
대리 접근된다 (setup 이 매번 `[주의]` 로 보여 준다). 막는 일은 백엔드가 한다 — mail/README.md "보안".
---
## 규칙
- **이미지 버전 고정 — 떠 있는 태그(`stable-alpine`·`v0.16`·`24-alpine`)를 쓰지 않는다.** 그날의 최신이 들어오면 호스트마다 버전이 달라지고,
설정이 기대하는 기능이 없는 옛 이미지가 남은 호스트에서는 기동에 실패한다 (예: nginx.conf 의 `add_header_inherit` 는 1.29.3 이상).
| 이미지 | 지금 값 | 함께 고치는 곳 (값이 모두 같아야 한다) |
| --- | --- | --- |
| Node | `node:24.22.1-alpine3.24` | 아래 "Node 버전" |
| nginx (운영 frontend) | `nginxinc/nginx-unprivileged:1.30.6-alpine3.24` | `frontend/Dockerfile` 의 `FROM … AS nginx-config`, 이 문서. 1.29.3 아래로 내리지 않는다 (`add_header_inherit`) |
| Stalwart (mail) | `stalwartlabs/stalwart:v0.16.27` | 루트 `.env.development.example`·`.env.production.example` 의 `MAIL_IMAGE`(각자의 로컬 `.env.*` 도), `mail/README.md` 머리말, 이 문서. `mail/scripts/setup.mjs` 는 0.16 의 관리 API·bootstrap 동작에 맞춰져 있다 — 마이너(0.17 등)를 올리면 개발에서 setup 처음 설치·재실행과 웹메일을 다시 확인한다 |
| Mailpit (개발) | `axllent/mailpit:v1.32.0` | `.env.development.example` 의 `MAILPIT_IMAGE` |
- **갱신 규칙**(공통): 일반 패치는 **배포 후 2주가 지나면** 올린다 (공급망 위험 — 갓 나온 버전을 피한다). **보안 릴리스는 CI 가 통과하는 대로** 올린다.
새 태그가 있는지와 digest 는 `docker buildx imagetools inspect <이미지>:<태그>` 로 확인한다 (2026-10-27: Stalwart `v0.16`·`v0.16.27`, nginx `stable-alpine`·`1.30.6-alpine3.24` 가 각각 같은 digest).
- `MAIL_IMAGE` 를 바꾸면(같은 digest 라도 태그 문자열이 다르면) 다음 `up` 에서 mail 컨테이너가 다시 만들어진다 — 데이터는 named volume 이라 그대로다.
- **Node 버전 — 24 LTS 를 패치까지 고정한다 (지금 `24.22.1`, 이미지 `node:24.22.1-alpine3.24`).**
메이저 태그(`node:24-alpine`)는 빌드할 때마다 그날의 최신 패치를 받아 갓 나온 버전이 그대로 들어오고, 이미지와 CI 의 패치가 어긋날 수 있다.
- **함께 고치는 곳** (값이 모두 같아야 한다):
- `backend/Dockerfile` 의 `ARG NODE_VERSION` (builder·production 이 함께 쓴다)
- `frontend/Dockerfile` 의 `ARG NODE_VERSION` (build-stage)
- `.github/workflows/ci.yml` 의 `env.NODE_VERSION` (backend·frontend·error-codes-sync 세 잡이 함께 쓴다)
- `mail/README.md` 의 운영 setup (B) 가 받는 공식 Node 배포 파일 (`node-v<버전>-linux-x64.tar.xz`)
- 이 문서 (위 버전 표기, "프론트엔드는 호스트에서"의 호스트 권장 버전)
- 메이저를 바꾸거나 alpine 이 3.24 에서 바뀌면 Dockerfile 두 개의 `-alpine3.24` 도 함께 고친다
(새 태그가 있는지 `docker pull node:<버전>-alpine<알파인>` 으로 먼저 확인한다)
- **갱신 규칙**: 일반 패치 릴리스는 **배포 후 2주가 지나면** 올린다 (공급망 위험 — 갓 나온 버전을 피한다).
**보안 릴리스는 CI 가 통과하는 대로** 올린다 (알려진 취약점 수정이 2주 대기보다 우선한다).
릴리스 날짜·보안 여부는 <https://nodejs.org/dist/index.json> 의 `date`·`security` 로 확인한다.
24.22.1 은 2026-10-20 보안 릴리스라 보안 릴리스 규칙으로 바로 고정했다.
- 올린 뒤에는 운영 이미지 두 개 빌드, CI 와 같은 명령(아래 "의존성은 컨테이너 안에서"처럼 임시 폴더 복사본에서 lint·type-check·test·build),
dev 재빌드(`dc up -d --build -V`)로 확인한다.
- **빌드 최소화**: 의존성(package.json / pubspec 등) 변경이 없으면 `--build` 없이 기동한다.
- **의존성을 바꿨다면 `--renew-anon-volumes` 를 함께 준다.**
dev 는 `/app/node_modules` 를 익명 볼륨으로 마운트하는데, `--build` 만으로는 **예전 볼륨이 그대로 재사용된다.**
@@ -174,6 +312,36 @@ dc --profile redis up -d
dc up -d --build --renew-anon-volumes backend
```
(`-V` 는 같은 옵션의 축약형. DB 볼륨은 named volume 이라 영향받지 않는다)
- **의존성은 컨테이너 안에서, npm 은 `11.6.2` 로** ([[nestjs]] 의 Windows 주의 항목).
lock 을 바꾸는(`npm install`/`uninstall`) npm 과 검사하는(`npm ci` — 이미지 빌드·CI) npm 을 같은 버전으로 고정했다.
npm 은 버전마다 lock 의 중첩 항목(chokidar·readdirp·@emnapi 등)을 다르게 정리해서, 10.x·11.6·11.2x 를 섞어 쓰면 lock 이 매번 바뀐다.
- 컨테이너(`dc exec backend npm …`)는 이미지에 고정된 버전이다. 호스트에서 lock 을 만질 일이 있으면 `npm i -g npm@11.6.2` 로 맞춘다.
- 바꿀 때는 `backend/Dockerfile`·`frontend/Dockerfile` 의 `ARG NPM_VERSION`, `.github/workflows/ci.yml` 의 `NPM_VERSION`, 이 문서를 **함께** 고친다.
- ⚠️ **형식만 맞추려고 lock 을 통째로 다시 쓰지 않는다** (`npm install --package-lock-only`).
2026-10-26 backend 에서 npm 11.6.2 로 이렇게 다시 쓰자 `@emnapi/core`·`@emnapi/runtime` 이 빠지면서
`npm ci` 가 `Invalid: lock file's @emnapi/wasi-threads@1.2.1 does not satisfy @emnapi/wasi-threads@1.2.3` 로 실패했다(되돌림).
lock 은 의존성을 실제로 바꿀 때만 컨테이너 안 `npm install <패키지>` 로 고치고, 고친 뒤에는 **임시 폴더에 복사해 `npm ci`** 로 확인한다
(저장소 폴더를 그대로 마운트하면 호스트의 Windows `node_modules` 를 읽어 엉뚱하게 실패한다).
- **dev backend 의 `/app/dist` 는 익명 볼륨이다.** 호스트에서 `npm run build` 를 하면 바인드 마운트된 `backend/dist` 가 지워졌다
다시 생기면서 컨테이너의 Nest watch 가 끊겨 앱이 옛 코드로 계속 돈다 — 그래서 산출물을 호스트와 나눴다.
`nest-cli.json` 의 `deleteOutDir` 는 마운트 지점을 지우려다 EBUSY 로 죽으므로, dev `command` 는 기동 때 볼륨 내용만 비우고
`deleteOutDir` 만 끈 설정 사본으로 `start:dev` 를 돌린다 (`-V` 로 재생성하면 이미지의 산출물로 다시 채워진다).
- **dev backend 메모리 한도는 2g** — watch 컴파일과 타입 검사가 함께 돌아 base 의 1g 에서 OOM 으로 죽었다 (운영은 1g 그대로).
- **dev backend 는 컨테이너를 기동할 때 한 번 `migration:run` 을 한다** (`dc down -v` 등으로 DB 를 새로 만든 뒤에도 바로 뜨게).
watch 재시작은 이 단계를 다시 돌지 않는다 — 그래서 생성만 하고 검토 전인 마이그레이션이 파일을 저장할 때마다 적용되지는 않는다.
⚠️ 다만 **컨테이너를 다시 띄우면** 그때 있는 마이그레이션이 적용된다. 마이그레이션을 고치는 중(revert → 수정 → run)에는
backend 를 재기동하지 말고, 검토를 마친 뒤 재기동한다. 실패하면 기동이 멈춘다(`set -e`) — `dc logs backend` 로 원인을 본다.
운영은 `migrationsRun` 으로 앱이 뜰 때 적용된다.
- **app-network 는 이름(`${PROJECT_NAME}_app-network`)과 서브넷(`APP_NETWORK_SUBNET`, 기본 `172.16.71.0/24`)을 고정한다.**
메일 엔진이 이 대역만 신뢰하기 때문이다. 그래서 **backend·db·redis·mail(개발은 mailpit 도)만 붙인다** — frontend 는 `edge`
(`${PROJECT_NAME}_edge`, `EDGE_NETWORK_SUBNET` 기본 `172.16.72.0/24`)로만 backend 에 닿는다. 새 서비스를 붙일 때도 신뢰 대역에 넣어도 되는지부터 본다.
- mail setup 이 검사하는 서브넷 조건: 사설 대역, 네트워크 주소로, 접두 길이 `/24`~`/28`. 넓을수록 실제 클라이언트 주소(사무실 LAN·VPN)와 겹쳐
그 접속까지 믿게 될 위험이 커지고, `/28` 보다 좁으면 컨테이너 주소가 모자란다 (근거: `setup.mjs` 의 `APP_NETWORK_PREFIX_RANGE`).
- 기본값은 도커 기본 주소 풀 밖이라 자동 배정 네트워크와 겹치지 않지만, 같은 호스트의 다른 프로젝트가 같은 값을 쓰면
`up` 이 "Pool overlaps" 로 실패한다 → 다른 /24 로 바꾸고 mail setup 을 다시 실행한다 (`EDGE_NETWORK_SUBNET` 도 같다).
⚠️ 서브넷을 처음 고정하거나 바꿀 때, 네트워크 구성을 바꿀 때는 **`dc down` 후 `dc up -d`** (또는 `dc up -d --force-recreate`)로 전부 다시 만든다.
그냥 `up -d` 만 하면 네트워크는 새로 만들어지지만 재생성되지 않은 컨테이너가 서비스 이름(`db`·`mail` 등) 별칭을 잃어
backend 가 `getaddrinfo ENOTFOUND db` 로 뜨지 않는다 (2026-10-26 개발 환경에서 겪음).
- **env 파일**: 실행 전 아래 두 파일이 모두 있어야 한다. 없으면 `*.example` 을 복사해 생성한다.
- 루트 `.env.{APP_ENV}` — 설정 SSOT ([[env-structure]])
- `backend/.env.{APP_ENV}` — compose `env_file` 대상이라 **비어 있어도 존재해야 한다**
@@ -188,5 +356,12 @@ dc --profile redis up -d
- 프론트엔드 운영 컨테이너는 `nginx-unprivileged` 라 **8080** 포트를 쓴다 (`expose`·healthcheck·nginx.conf 가 모두 8080).
- 프론트엔드 dev 컨테이너는 vite 를 **5173** 으로 띄운다 (비-root 는 80 포트 바인드 불가).
- 포트를 바꿀 때는 Dockerfile `EXPOSE` · `nginx.conf` 의 `listen` · compose `expose`/`healthcheck` 를 **함께** 고쳐야 한다.
- **DB/Redis 호스트 포트**: dev 에서만 열며 `127.0.0.1` 로 한정한다. 접두어 없이 쓰면 LAN 전체에 노출된다 ([[security]]).
- **DB/Redis·backend·frontend(dev 컨테이너) 호스트 포트**: dev 에서만 열며 `127.0.0.1` 로 한정한다. 접두어 없이 쓰면 LAN 전체에 노출된다 ([[security]]).
backend(3000·3001)는 호스트의 vite 개발 서버·도구만 붙으면 된다 — Android 에뮬레이터의 `10.0.2.2` 는 호스트 127.0.0.1 로 가서 그대로 되고,
같은 LAN 의 실기기는 닿지 않는다. 운영 backend 는 호스트 포트를 열지 않는다(프론트 nginx 의 `/api` 로만).
profile `frontend` 의 vite 컨테이너도 `127.0.0.1:${FRONTEND_PORT}` 다 (호스트 vite 와 같은 범위).
- **메일 포트**: 8080(HTTP JMAP·관리 API)은 어떤 환경에서도 공개하지 않는다 (dev 는 `127.0.0.1:${MAIL_ADMIN_PORT}` 만).
운영의 25·465·993·443 은 `0.0.0.0`(IPv4)로만 바인딩한다 — IPv6 접속은 docker-proxy 가 중계해 도커 게이트웨이 주소로 보이고,
그러면 신뢰 대역으로 취급되어 자동 차단·게이트웨이 IP 제한이 무력화된다. dev 오버라이드는 `ports: !override` 로 운영 포트를 지운다.
- **Mailpit**: 이미지 기본이 root 라 `user: "65534:65534"` 로 내린다. dev 전용(운영 compose 에 없다).
- **개발/운영 분리**: 개발은 `docker-compose.yml + docker-compose.dev.yml`, 운영은 `docker-compose.yml` 단독 + `.env.production`.
+46 -17
View File
@@ -17,32 +17,43 @@ description: NestJS 백엔드(backend/) 개발 규칙 — 컨트롤러/서비스
| --- | --- | --- |
| **에러 코드 레지스트리** | `src/common/errors/error-codes.ts` | 코드↔메시지↔HTTP 상태 **SSOT**. `npm run sync:error-codes` 로 클라이언트 사전 생성 |
| **BusinessException** | `src/common/errors/business.exception.ts` | 에러 코드가 **생성자 필수 인자** — 코드 없는 실패를 만들 수 없다 |
| 표준 응답 인터셉터 | `src/common/interceptors/transform.interceptor.ts` | main.ts 전역 등록 — 컨트롤러는 raw 데이터만 반환. `@SkipTransform()` 으로 제외 |
| 표준 응답 인터셉터 | `src/common/interceptors/transform.interceptor.ts` | `app.setup.ts` 에서 전역 등록 — 컨트롤러는 raw 데이터만 반환. `@SkipTransform()` 으로 제외 |
| 전역 예외 필터 | `src/common/filters/http-exception.filter.ts` | 레지스트리 조회로 `{success:false,error:{code,message,traceId}}` 생성 ([[error-codes]]) |
| 요청 추적 미들웨어 | `src/common/middleware/request-id.middleware.ts` | `x-request-id` 발급 → 로그·응답 `traceId` ([[logging]]) |
| 요청 추적 미들웨어 | `src/common/middleware/request-id.middleware.ts` | `x-request-id` 발급 → 로그·응답 `traceId` ([[logging]]). `configureApp()` **맨 앞**의 express 미들웨어라 본문 파서 오류(413·400·415)에도 붙는다. 들어온 값은 `^[A-Za-z0-9._-]{1,128}$` 일 때만 이어받고 아니면 새로 발급(로그 위조 방지) |
| Swagger 래퍼 데코레이터 | `src/common/decorators/api-standard-response.decorator.ts` | `ApiStandardResponse` / `ApiErrorResponse` — 문서를 실응답과 일치시킨다 |
| 페이지네이션 DTO | `src/common/dto/pagination.dto.ts` | 커서/오프셋 쿼리 + `PageInfoDto` + `toPaginatedResult()` |
| 전역 ValidationPipe | `main.ts` | `whitelist/forbidNonWhitelisted/transform` |
| Swagger | `main.ts` → `/api-docs` | 모든 엔드포인트 데코레이터 |
| 보안 미들웨어 | `main.ts` | helmet · CORS 화이트리스트 |
| Rate Limiting | `app.module.ts` 의 `ThrottlerGuard`(APP_GUARD) | 초과 시 429 → `REQ_001`. 루트 `.env` 의 `THROTTLE_*` 로 조정 |
| 전역 HTTP 설정 | `src/app.setup.ts` 의 `configureApp()` | prefix · helmet · cookie-parser · JSON 본문 파서(1mb, JSON 만) · ValidationPipe(`whitelist/forbidNonWhitelisted/transform`) · 필터 · 인터셉터. **main.ts 와 e2e 가 같은 함수를 쓴다** (앱은 `bodyParser: false` 로 생성) |
| Swagger | `main.ts` → `/api-docs` | 모든 엔드포인트 데코레이터. 인증은 쿠키(`addCookieAuth('access_token')`)만 등록 — Authorization 헤더 인증은 없다 |
| 보안 미들웨어 | `app.setup.ts`(helmet · `TRUST_PROXY`) · `main.ts`(CORS 화이트리스트 — 운영은 `CORS_ORIGIN` 만) | 클라이언트 IP 는 `TRUST_PROXY`(홉 수 또는 IP·CIDR 목록, 운영 필수) 기준 — `shared/utils/trust-proxy.ts` |
| **출처 검사(CSRF)** | `src/common/guards/origin.guard.ts` (APP_GUARD) | 상태 변경 요청은 `Sec-Fetch-Site`(same-origin·none) 또는 허용 목록(`CORS_ORIGIN`)의 `Origin` 이어야 통과, 아니면 `AUTH_003`. 둘 다 없으면 통과. 규칙 `shared/utils/request-origin.ts` |
| **인증 가드** | `src/common/guards/jwt-auth.guard.ts` | `JwtAuthGuard` — 액세스 토큰 쿠키 검증, 실패는 `AUTH_001`. 전략은 `modules/auth/jwt.strategy.ts` |
| 현재 사용자 데코레이터 | `src/common/decorators/current-user.decorator.ts` | `@CurrentUser()` → `AuthenticatedUser { email, name }` |
| 직원 인증 | `modules/auth` | 쿠키 JWT 로그인·`me`·`refresh`·`logout` (계약 `docs/mail/api-contract.md` 1절). 설정 `auth.settings.ts`, 쿠키 `auth-cookies.ts`, 로그인 실패 횟수 `login-attempt.service.ts`, **로그인 세션(DB)** `auth-session.entity.ts`·`auth-session.policy.ts`(1회용 회전·재사용 감지)·`auth-session.cleanup.ts`(끝난 세션 정리 cron). **메일 엔진 관리자 차단** — 정적 목록(`employee-address.ts` + `MAIL_BLOCKED_ACCOUNTS`)과 로그인·리프레시 때 엔진 권한 확인(`jmap/jmap.client.ts`) 두 겹 |
| 메일 (웹메일 API) | `modules/mail` | 엔진 포트 `mail.provider.ts`(`MAIL_PROVIDER`) + Stalwart JMAP 어댑터 `jmap/`, HTML 정화 `html/sanitize-mail-html.ts`, 첨부 형식 `attachment.policy.ts`, 설정 `mail.settings.ts`, 발송 중복 방지(`Idempotency-Key`) `send-idempotency.ts`, 첨부 임시 디스크 저장 `mail-upload-storage.ts` (계약 2절) |
| Rate Limiting | `app.module.ts` 의 `ClientIpThrottlerGuard`(APP_GUARD, `common/guards/client-ip-throttler.guard.ts`) | 초과 시 429 → `REQ_001`. 루트 `.env` 의 `THROTTLE_*` 로 조정. `ThrottlerGuard` 를 상속해 tracker 만 바꿨다 — IP 는 `shared/utils/client-ip.ts` 의 `clientIpKey()`(IPv6 는 /64 로 묶음, 로그인 실패 카운터와 같은 함수) |
| 로깅 | `shared/logger/winston.config.ts` | `console.log` 대신 사용 ([[logging]]) |
| env 검증 | `shared/config/env.validation.ts` | 잘못된 설정이면 **기동 실패**(fail-fast) ([[env-structure]]) |
| DB 설정 | `shared/config/database.config.ts` | 앱·CLI 공유 SSOT. `synchronize` 기본 off |
| 마이그레이션 | `src/data-source.ts` + `src/migrations/` | `npm run migration:*` (아래 "DB 마이그레이션") |
| 마이그레이션 | `src/data-source.ts` + `src/migrations/` | `npm run migration:*` (아래 "DB 마이그레이션"). 첫 마이그레이션은 로그인 세션 표(`CreateAuthSessions`). e2e 는 시작할 때 `runMigrations()` 로 표를 만든다 |
| Graceful shutdown | `main.ts` 의 `enableShutdownHooks()` + `shared/logger/shutdown.logger.ts` | SIGTERM 시 요청/커넥션 정리 후 종료 |
| 예제 모듈(참조용) | `modules/user`(DTO+Swagger+페이지네이션) | 새 모듈 패턴 참고. in-memory 데모 저장소 |
| 파일 업로드 | `modules/upload` | 로컬 디스크 저장 + 형식·크기 검증 + 안전한 파일명 생성 ([[security]]) |
| 파일 업로드 | `modules/upload` | **로그인 필요**(`JwtAuthGuard`). 로컬 디스크 저장 + 형식·크기 검증 + 안전한 파일명 생성 ([[security]]). 한글 파일명은 multer `defParamCharset: 'utf8'` |
| 헬스체크 | `modules/health` | `@SkipThrottle`+`@SkipTransform` — 기계가 읽는 엔드포인트 패턴 ([[response-format]]) |
| 순수 유틸 | `shared/utils/` | 순수 함수 분리 위치 |
| 요청 컨텍스트 | `shared/logger/request-context.ts` | 요청마다 추적 ID 를 AsyncLocalStorage 에 둔다 — 필터를 거치지 않는 로그도 `traceTag()` 로 `[traceId]` 를 붙인다 ([[logging]]) |
| 순수 유틸 | `shared/utils/` | 순수 함수 분리 위치 (예: `mask.ts` 로그용 이메일 마스킹, `duration.ts` 기간 파싱, `content-disposition.ts` 다운로드 헤더, `cookies.ts`, `trust-proxy.ts`, `request-origin.ts`) |
> **확장 시 따르는 규칙** (기능이 필요해지면 규칙대로 구현):
> - **인증(JWT)**: `@nestjs/jwt`·`@nestjs/passport` 설치 → `common/guards/` 생성 → AUTH_001/AUTH_003 흐름 검증
> - **보호 라우트**: 인증은 구현되어 있다 (`modules/auth` + `common/guards/jwt-auth.guard.ts`). 새 보호 라우트는 컨트롤러·핸들러에
> `@UseGuards(JwtAuthGuard)` + `@ApiCookieAuth()` 를 걸고 `@CurrentUser()` 로 직원을 받는다 (전역 가드가 아니다 — `users`·`health` 예제는 공개, `upload` 는 로그인 필요).
> 권한(AUTH_003) 구분이 필요해지면 역할 가드를 `common/guards/` 에 추가한다.
> 반면 출처 검사(`OriginGuard`)는 **전역**이다 — 웹훅처럼 다른 사이트가 정당하게 부르는 엔드포인트가 생기면 예외 표시를 따로 만든다
> - **메일 엔진 교체**: `modules/mail/mail.module.ts` 의 `MAIL_PROVIDER` 구현(useClass)만 바꾼다 — 서비스·컨트롤러는 포트만 안다
> - **DB 영속화**: TypeORM 엔티티/리포지토리 추가 (예제의 in-memory 저장 대체).
> `app.module.ts` 는 이미 `TypeOrmModule.forRootAsync` 로 연결되어 있다.
> 스키마는 **마이그레이션으로 관리**하며 `synchronize` 는 기본 off 다 (아래 "DB 마이그레이션" 참조)
> - **도메인 에러 코드**: `error-codes.ts` 의 `DOMAIN_ERROR_CODES` 에 등록 → `npm run sync:error-codes`
> - `common/{guards,pipes}/` 는 해당 관심사 등장 시 생성 (아래 "모듈 구조"는 목표 구조)
> - `common/pipes/` 는 해당 관심사 등장 시 생성 (`common/guards/` 에는 인증 가드가 있다. 모듈 전용 파이프는 모듈 안에 둔다 — 예: `modules/mail/mail-id.pipe.ts`).
> 아래 "모듈 구조"는 목표 구조
---
@@ -127,7 +138,7 @@ export class TransformInterceptor<T> implements NestInterceptor {
}
```
- `main.ts`에서 전역 등록: `app.useGlobalInterceptors(new TransformInterceptor())`
- `app.setup.ts` 의 `configureApp()` 에서 전역 등록 (main.ts·e2e 공용): `app.useGlobalInterceptors(new TransformInterceptor(app.get(Reflector)))`
- 실패 응답(`success: false`)은 아래 Exception Filter가 생성한다.
- 응답 포맷 SSOT는 [[response-format]] 참조.
@@ -168,7 +179,7 @@ const config = new DocumentBuilder()
.setTitle('API 문서')
.setDescription('프로젝트 API 명세서')
.setVersion('1.0')
.addBearerAuth()
.addCookieAuth(ACCESS_TOKEN_COOKIE) // 인증은 HttpOnly 쿠키뿐 — 보호 라우트에 @ApiCookieAuth() (Bearer 는 등록하지 않는다)
.build();
const document = SwaggerModule.createDocument(app, config);
@@ -190,7 +201,11 @@ SwaggerModule.setup('api-docs', app, document);
## 인증 (JWT)
> 📐 **확장 지점.** 이 템플릿은 JWT 인증을 강제하지 않는다(규칙만 제공). **인증이 필요해지면** 아래 규칙대로 구현한다: `@nestjs/jwt`·`@nestjs/passport` 설치 → `common/guards/` 생성 → AUTH_001/AUTH_003 흐름 검증.
> **구현 위치.** 쿠키 기반 JWT 가 구현되어 있다 — `modules/auth`(로그인·`me`·`refresh`·`logout`, 계약 `docs/mail/api-contract.md` 1절),
> 가드 `common/guards/jwt-auth.guard.ts`, 전략 `modules/auth/jwt.strategy.ts`, `@CurrentUser()` `common/decorators/current-user.decorator.ts`.
> 서명 키·만료(`JWT_*`)는 `modules/auth/auth.settings.ts` 한 곳에서 읽는다. 직원 자격 확인은 메일 엔진 포트(`MAIL_PROVIDER`)로 한다.
> 리프레시 세션은 DB(`auth_sessions`)에 둔다 — 토큰은 해시만 저장, 1회용 회전, 이미 쓴 토큰이 오면 세션 폐기, 절대 30일·미사용 14일.
> 액세스 토큰은 세션을 조회하지 않는다 (끊어도 만료까지 유효 — 그래서 `JWT_ACCESS_EXPIRES_IN` 은 env 검증이 5분~1시간으로 막는다).
- Access Token: 만료 시간 짧게 (15분 ~ 1시간)
- Refresh Token: HttpOnly Secure 쿠키로 전달
@@ -206,7 +221,10 @@ SwaggerModule.setup('api-docs', app, document);
> 📐 **Redis 는 확장 지점이다.** compose 에서 **기본 비활성**(profile)이며 애플리케이션도 사용하지 않는다.
> 단일 인스턴스에서는 in-memory 로 충분하다.
>
> Redis 가 실제로 필요해지는 시점(다중 인스턴스 운영, 세션·캐시 공유, 분산 락)에 연결한다:
> Redis 가 실제로 필요해지는 시점(다중 인스턴스 운영, 세션·캐시 공유, 분산 락)에 연결한다.
> ⚠️ 인스턴스를 늘리면 **프로세스 메모리에 둔 한도·잠금**도 함께 옮겨야 한다 — 그대로 두면 인스턴스마다 따로 센다:
> 로그인 실패 카운터(`modules/auth/fixed-window-counter.ts` → `INCR`+`PEXPIRE` 를 한 Lua 스크립트로),
> 같은 주소의 진행 중 로그인·같은 `Idempotency-Key` 의 진행 중 발송(`SET NX PX` 잠금), `@nestjs/throttler` 저장소:
> 0. 컨테이너 활성화 — 루트 `.env` 에 `COMPOSE_PROFILES=redis` ([[docker]])
> 1. Redis store 패키지 설치 (**컨테이너 안에서** — 위 의존성 주의 항목 참조)
> 2. `app.module.ts` 의 `CacheModule.register` 를 `registerAsync` + Redis store 로 교체
@@ -214,8 +232,12 @@ SwaggerModule.setup('api-docs', app, document);
> 4. 연결 실패 시 앱이 죽지 않도록 폴백 전략을 정한다
>
> 끝까지 쓰지 않을 프로젝트라면 compose 에서 redis 서비스와 관련 env 를 지우는 편이 낫다.
- **Rate Limiting**: `@nestjs/throttler` 로 일원화 (`app.module.ts` 의 `APP_GUARD`). 초과 시 429 → `REQ_001`.
- **Rate Limiting**: `@nestjs/throttler` 로 일원화 (`app.module.ts` 의 `APP_GUARD` = `ClientIpThrottlerGuard`). 초과 시 429 → `REQ_001`.
IP 를 세는 모든 곳은 `clientIpKey()` 를 쓴다 — IPv6 는 주소를 계속 바꿀 수 있어 원문 그대로 세면 IP 한도가 무력해진다.
라우트 단위 조정은 `@Throttle({ default: { ttl, limit } })`, 제외는 `@SkipThrottle()` (헬스체크에 적용되어 있다).
로그인 무차별 대입은 **실패 횟수**로 막는다 — `modules/auth/login-attempt.service.ts`.
메일 엔진에 묻기 **전에** 실패 한 번을 IP·주소에 원자적으로 차지하고(동시 요청으로 한도를 넘지 못하게), 같은 주소의 진행 중 시도는 기다리지 않고 `REQ_001`.
성공·엔진 장애는 차지한 자리를 돌려준다(성공은 세지 않는다). 요청 수 상한은 그와 별개로 넉넉하게 — 로그인 라우트의 `@Throttle`(IP 당 1분 60회, 사무실 NAT 고려).
**rate limiter 를 중복으로 추가하지 말 것** ([[security]])
- **Pagination**: 목록 API는 반드시 페이지네이션 적용 — `common/dto/pagination.dto.ts` 의 쿼리 DTO + `toPaginatedResult()` 사용. 응답은 `{items, pageInfo}` 고정 ([[response-format]])
- **Logging**: `winston` 또는 `@nestjs/common Logger` 사용, `console.log` 금지. 레벨 기준·민감정보 마스킹은 [[logging]]
@@ -297,6 +319,11 @@ 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 도 같은 검사를 한다.
- **운영 의존성이 실제로 들어가는지**도 본다 — 임시 복사본에서 `npm ci --omit=dev` 후 새 운영 의존성을 `require` 해 본다.
lock 의 `dev` 표시가 틀리면 운영 이미지에서만 패키지가 빠져 앱이 뜨지 않는다 (2026-10-27 htmlparser2 사례).
- **ESM 전용 패키지**가 들어와 Jest 가 `Cannot use import statement outside a module` 로 실패하면, jest 설정 두 곳
(`package.json` 의 `jest` · `test/jest-e2e.json`)의 `transform`·`transformIgnorePatterns` 목록에 그 패키지를 더한다
(이미 있는 ts-jest 로 CJS 변환 — 지금은 htmlparser2 계열 6개).
- 전이 의존성의 취약점이 `npm audit fix` 로 안 풀리면 `package.json` 의 `overrides` 를 쓴다 ([[security]]).
현재 `multer: ^2.4.0` 이 걸려 있다 — `@nestjs/platform-express` 를 v12 로 올릴 때 제거한다.
@@ -318,5 +345,7 @@ dc exec backend npm uninstall <패키지>
- [ ] **에러 던지기**: `HttpException` 직접 생성은 린트로 차단된다 — `BusinessException(code)` 사용.
- [ ] **새 필수 env 추가 시**: `shared/config/env.validation.ts` 에 검증 규칙과 테스트를 함께 추가 ([[env-structure]]).
- [ ] **DB 전환 시**: in-memory Map → TypeORM 엔티티/리포지토리로 교체.
- [ ] **인증 추가 시**: `@nestjs/jwt`·`@nestjs/passport` 설치 → `common/guards/` 생성 → AUTH_001/003 흐름 확인.
- [ ] **보호 라우트**: `@UseGuards(JwtAuthGuard)` + `@ApiCookieAuth()` + `@CurrentUser()` — 가드 없이 `@CurrentUser()` 를 쓰면 AUTH_001 로 막힌다 ([[security]]).
- [ ] **전역 설정 변경 시**: `src/app.setup.ts` 만 고친다 — main.ts 와 e2e 가 같은 함수를 쓴다.
- [ ] **메일 엔진 코드**: JMAP 타입·호출은 `modules/mail/jmap/` 밖으로 내보내지 않는다. 받은 메일 HTML 은 `html/sanitize-mail-html.ts` 를 거친 뒤에만 내보낸다.
- [ ] **env**: DB/Redis/CORS/포트는 루트 `.env` 에서 주입된다. 백엔드 고유 시크릿(`JWT_SECRET` 등)만 `backend/.env.{env}` 에 작성(compose `env_file` 로 로드).
+30 -11
View File
@@ -18,14 +18,29 @@ description: Vue 3 프론트엔드(frontend/) 개발 규칙 — Composition API
| 중앙 Axios 인스턴스 | `src/composables/useApi.ts` | baseURL=`VITE_API_BASE_URL`, `withCredentials` |
| 응답 언래핑 인터셉터 | `useApi.ts` | `{success,data}→data`, 에러 코드 정규화(`ApiError`), 중복 알림 억제 |
| 에러 코드 사전 | `src/shared/constants/error-codes.generated.ts` | **자동 생성 파일 — 직접 수정 금지** ([[error-codes]]) |
| 세션 만료 처리 | `useApi.ts` 의 `toAuthCode()` | 로그인 상태에서 401 → `AUTH_002` 승격 + `authStore.clearAuth()` |
| 세션 만료 처리 | `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'] })`). 세션 만료 대화상자는 끄지 않는다 |
| 페이지네이션 타입 | `src/shared/types/pagination.ts` | 목록 응답 공유 계약 ([[response-format]]) |
| 도메인 Composable 예제 | `src/composables/useUser.ts` | 새 도메인 API 패턴 참고 |
| 인증 Composable 예제 | `src/composables/useAuth.ts` | 로그인/로그아웃 흐름(쿠키 기반) 시작점 |
| Pinia 스토어 예제 | `src/stores/user.store.ts` | 새 스토어 패턴 참고 |
| 인증 스토어 | `src/stores/auth.store.ts` | 세션 인증 여부(`isLoggedIn`) 관리 |
| 도메인 Composable 예제 | `src/composables/useUser.ts` · `useMail.ts` | 새 도메인 API 패턴 참고 (`useMail` 이 실제 모듈 예) |
| 인증 Composable | `src/composables/useAuth.ts` | 로그인·로그아웃·`restoreSession()`(me 가 AUTH_001 이면 refresh 1회, 알림 없음) — 쿠키 기반 |
| Pinia 스토어 예제 | `src/stores/user.store.ts` · `mail.store.ts` | 새 스토어 패턴 참고 (`mail.store` 는 늦게 온 응답 무시 패턴 포함) |
| 인증 스토어 | `src/stores/auth.store.ts` | 로그인 사용자(email·name) · 세션 복원(`restore`) · 만료 상태(`expireSession`) · 로그아웃 상태(`clearAuth()` 가 켜는 `signedOut` — 다시 로그인할 때까지 늦게 온 갱신·복원 결과를 쓰지 않는다) |
| 공통 컴포넌트 예제 | `src/components/BaseButton.vue` | 재사용 컴포넌트 기준 패턴(타입 Props/Emits·scoped·rem·a11y) |
| 라우터 | `src/router/index.ts` | lazy import + `auth.store` 연동 `requiresAuth` 가드 |
| 라우터 | `src/router/index.ts` | lazy import + 첫 네비게이션 전 세션 복원 + `requiresAuth` 가드(미인증 → `{ name: 'login', query: { redirect } }`) |
| 전역 셸 | `src/components/shell/AppShell.vue` | 앱 레일 + 상단 바 + 모듈 영역. 레일 모듈 목록은 `src/shared/constants/app-modules.ts` 하나 |
| 디자인 토큰·글꼴 | `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/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` | 클릭 영역 44px 기준의 의도된 예외(목록 표의 별·보기 전환)에만 쓴다 — 사용자 확정 |
| 사용자별 화면 설정 | `src/stores/mail-view.store.ts` | localStorage(키에 사용자 email, try/catch, 실패 시 기본값). 서버 설정 API 가 생기면 교체 |
| 화면 이동 규칙 | `src/composables/useMailNavigation.ts` | 이전/다음은 replace, "목록으로"는 뒤로 가기 또는 replace (기록이 쌓이지 않게) |
| 발송 키 규칙 | `src/shared/utils/idempotency-key.ts` 의 `shouldRenewSendKey()` | `Idempotency-Key` 는 성공과 `MAIL_005` 때만 바꾼다 — 그 밖의 실패 뒤 다시 보내기는 같은 키 (계약 2절 "발송 본문") |
| 첨부 사전 검사 | `src/shared/utils/mail-attachment.ts` 의 `precheckAttachment()` | 형식·파일당·합계 한도(`mail.store` 의 `attachmentMaxBytes`·`attachmentTotalMaxBytes`, 서버 값이 없으면 대체값). 넘는 파일은 올리지 않는다 |
| env 타입 선언 | `frontend/env.d.ts` | `VITE_API_BASE_URL` |
| dev API 프록시 | `vite.config.ts` 의 `server.proxy` | `/api` → 백엔드. 없으면 vite 가 SPA 폴백으로 **HTML 을 200 으로** 돌려줘 조용히 실패한다 |
| dev 서버 포트 | `vite.config.ts` 의 `loadEnv` | 루트 `.env.{mode}` 의 `FRONTEND_PORT`/`BACKEND_PORT` 를 읽는다. `strictPort` 로 포트 충돌 시 fail-fast ([[env-structure]]) |
@@ -36,8 +51,8 @@ description: Vue 3 프론트엔드(frontend/) 개발 규칙 — Composition API
> 두 포트 모두 `vite.config.ts` 가 **루트 `.env.{mode}` 에서 직접 읽는다** — 프론트 쪽에 포트를 중복 작성하지 않는다.
> **확장 시 따르는 규칙:**
> - **로그인 연동**: 실제 `/auth/login` 엔드포인트 연결 → 성공 시 `useAuth` 가 `authStore.markAuthenticated()` 호출
> - **로그인 페이지**: 추가 시 라우터 가드 리다이렉트를 `{ name: 'login' }` 으로 변경
> - **새 사내 모듈**(캘린더·파일·ERP 등): `app-modules.ts` 에 항목을 활성화하고, 라우트 + `AppShell` 안의 모듈 패널을 메일 모듈처럼 만든다. 셸을 새로 만들지 않는다.
> - **새 화면**: 구현 전에 디자인 캔버스로 먼저 확인받고, 확정본을 `docs/design/` 에 둔 뒤 그 기준으로 만든다. 뱃지(안 읽음 점·개수 알약·태그)는 쓰지 않는다.
> - **공통 컴포넌트**: `BaseButton` 패턴을 따라 `src/components/` 에 추가
---
@@ -146,7 +161,7 @@ try {
}
```
- Toast 라이브러리를 도입하면 `useApi.ts` 의 `showErrorUI()` **한 함수만** 교체한다.
- 알림 UI 는 `useApi.ts` 의 `showErrorUI()` 가 토스트(`toast.store`)로 띄운다. 다른 UI 로 바꿀 때도 **이 함수 하나만** 교체한다.
```typescript
// composables/useUser.ts — 도메인별 API Composable
@@ -219,11 +234,15 @@ router.beforeEach((to) => {
- [ ] **컴포넌트**: `<script setup lang="ts">` + `<style scoped>` + rem 단위. 인라인 스타일·Options API 금지.
- [ ] **타입**: Props/Emits 는 인터페이스로 정의.
- [ ] **라우트**: 페이지는 lazy import, 보호 라우트는 `meta.requiresAuth`.
- [ ] **auth 가드**: `router/index.ts` 가드는 `auth.store.ts` 의 `isLoggedIn` 과 이미 연결되어 있다. 로그인 페이지 추가 시 리다이렉트 대상을 `{ name: 'login' }` 으로 바꾼다.
- [ ] **auth 가드**: `router/index.ts` 가드는 첫 네비게이션 전에 세션을 복원한 뒤 `isLoggedIn` 으로 판정하고, 미인증은 `{ name: 'login', query: { redirect } }` 로 보낸다. 새 보호 화면은 `meta.requiresAuth` 만 붙인다.
- [ ] **외부 HTML**: 메일 본문처럼 외부에서 온 HTML 은 `MailBodyFrame` 으로만 그린다. `v-html` 로 직접 넣지 않는다.
- [ ] **lint/test**: 커밋 전 `npm run lint` (`--max-warnings 0`) + `npm run type-check` + `npm run test:unit` 통과. 자동 수정은 `npm run lint:fix`.
- [ ] **린트로 강제되는 항목**: Options API·인라인 스타일·`console`·`any` 는 빌드가 실패한다.
- [ ] **env**: `VITE_API_BASE_URL` 은 빌드 시 루트 `BACKEND_API_URL` 에서 주입된다 — `frontend/.env` 에 별도 작성 불필요.
- [ ] **env**: `VITE_API_BASE_URL` 은 운영 빌드에서 `/api` 로 고정된다 (같은 주소 배포 — 운영은 프론트 nginx, 개발은 vite 프록시가 `/api` 를 백엔드로 넘긴다).
`frontend/.env` 에 별도 작성 불필요 — 다른 주소를 넣으면 쿠키(Path=/api)·CSP `connect-src 'self'`·출처 검사가 맞지 않는다 ([[docker]] "같은 주소 배포").
- [ ] **dev 서버**: 프론트엔드는 호스트에서 `npm run dev` 로 띄운다 (컨테이너는 `--profile frontend` 선택, [[docker]]).
- [ ] **dev 화면이 운영 빌드와 다를 때**(여백·폭이 통째로 사라지는 등): dev 서버가 전역 CSS(`styles/tokens.css`)의 변경을 놓쳐 옛 토큰을 내보내는 경우가 있었다.
`curl -s localhost:5173/src/styles/tokens.css` 에 새 변수가 없으면 dev 서버를 재시작하고 브라우저를 새로고침한다 (2026-10-26 사례 — 코드 문제 아님).
- [ ] **포트**: `vite.config.ts` 에 포트를 새로 하드코딩하지 않는다 — 루트 `.env.{mode}` 의 `FRONTEND_PORT`/`BACKEND_PORT` 가 SSOT ([[env-structure]]).
- [ ] **dev 에서 API 가 안 될 때**: 응답이 JSON 이 아니라 HTML 이면 vite 프록시(`server.proxy`) 문제다.
`baseURL` 이 `/api` 상대경로라 프록시가 없으면 index.html 이 200 으로 돌아온다.
+12 -4
View File
@@ -12,10 +12,15 @@ NestJS(백엔드) · Vue 3(웹) · Flutter(모바일)를 하나의 저장소에
| `flutter/` | Flutter + Riverpod | 모바일 앱 | - |
| `db` | PostgreSQL | 데이터베이스 | 5432 |
| `redis` | Redis | 캐시 / 세션 (**기본 비활성**) | 6379 |
| `mail` | Stalwart (JMAP) | 사내 메일 엔진 — 직원 로그인 = 메일 계정 (profile `mail`, [`mail/README.md`](./mail/README.md)) | 운영 25·465·993·443 / 개발 관리 8180 |
| `mailpit` | Mailpit | **개발 전용** — 사내 도메인 밖으로 보낸 메일 수집 | 8025 |
## 사전 요구사항
- Docker / Docker Compose — backend·frontend·db·redis 구동
- Docker / Docker Compose — backend·frontend·db·redis 구동 (컨테이너 안 런타임은 Node 24 LTS — `node:24.22.1-alpine3.24` 고정, 갱신 규칙은 docker 스킬 "Node 버전")
- Node.js **24 LTS 권장** — 호스트에서 프론트엔드 dev 서버·메일 setup 스크립트를 실행할 때 필요하다.
프론트 `engines` 는 `^20.19.0 || >=22.12.0` 이라 20.19 이상이면 돌아가지만, 이미지·CI(24)와 맞추는 것을 권장한다
(메일 setup 스크립트는 `--env-file` 때문에 20.6 이상)
- Flutter SDK `^3.11` — 모바일 앱 실행용 (compose 대상 아님)
## 빠른 시작 (Docker)
@@ -28,9 +33,12 @@ cp .env.development.example .env.development # ← 여기
cp backend/.env.development.example backend/.env.development # (존재 필요: JWT 등 고유 시크릿)
# frontend 는 compose 가 빌드 시 값을 주입하므로 자체 env 복사 불필요
# 2) 백엔드 스택 기동 (backend + db)
# 2) 백엔드 스택 기동 (backend + db, 기본 COMPOSE_PROFILES=mail 이면 mail + mailpit 포함)
docker compose -f docker-compose.yml -f docker-compose.dev.yml --env-file .env.development up -d
# 2-1) 메일 엔진 초기화 — 처음 한 번 (여러 번 실행해도 안전, mail/README.md)
node --env-file=.env.development mail/scripts/setup.mjs
# 3) 의존성 변경 시에만 --build
docker compose -f docker-compose.yml -f docker-compose.dev.yml --env-file .env.development up -d --build
@@ -92,7 +100,7 @@ cd flutter && cp .env.example .env && flutter pub get && flutter run
| ----------------------------- | ---------------------------------------- | --------------------- |
| `.env.{env}.example` | 루트 — 모든 설정의 SSOT (compose 주입) | ✅ 여기에 작성 |
| `backend/.env.{env}.example` | 백엔드 — 고유 시크릿(JWT 등)만 | 최소 (존재 필수) |
| `frontend/.env.{env}.example` | 프론트 — 빌드 시 루트 `BACKEND_API_URL` 주입 | 작성 불필요 |
| `frontend/.env.{env}.example` | 프론트 — API 주소는 같은 출처의 `/api` 고정(빌드 arg) | 작성 불필요 |
| `flutter/.env.example` | 모바일 — `API_URL` (compose 비대상) | ✅ 독립 관리 |
`{env}` = `development` | `production`
@@ -191,7 +199,7 @@ lint 는 `--max-warnings 0` 이라 경고 하나만 있어도 실패합니다.
├── docker-compose.dev.yml # 개발 오버라이드 (핫리로드, 포트 노출)
├── CLAUDE.md # 프로젝트 개요 + 공통 규칙 @import
├── .github/
│ ├── workflows/ci.yml # lint·test·build + 에러 코드 동기화 검사
│ ├── workflows/ci.yml # lint·test·build + 에러 코드 동기화 + 인프라(compose·Dockerfile·nginx) 검사
│ └── pull_request_template.md
├── ci/ # CI 러너 (Gitea Actions) — 앱 스택과 분리
└── .claude/