generated from ttipo/project-base
docs: 메일 모듈에 맞춰 README·규칙·스킬 문서 갱신
구현한 인증·메일·인프라에 맞춰 공통 규칙과 스택별 스킬 문서를 실제 파일과 일치시킨다. - security: 세션 회전·로그인 실패 한도·출처 검사·프록시 신뢰·네트워크 분리·게이트웨이 비밀 등급, 외부로 나가는 지문은 받는 쪽이 모르는 비밀로 HMAC, 의존성 유예 표(2026-10-27 점검)와 보안 릴리스 예외 - error-codes: AUTH_004 와 세션 만료(AUTH_002) 흐름 - env-structure·checklist: 메일 엔진 env 와 초기 설정 순서(관리자 아이디는 bootstrap 전에) - nestjs·vue3·docker 스킬: 기본 제공 골격 표와 확장 규칙, Node·npm·이미지 고정 위치 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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`.
|
||||
|
||||
@@ -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` 로 로드).
|
||||
|
||||
@@ -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 으로 돌아온다.
|
||||
|
||||
Reference in New Issue
Block a user