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:
2026-10-27 01:32:55 +09:00
parent a5afbc040e
commit d9ad6fc178
8 changed files with 355 additions and 46 deletions
+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`.