docs(rules): 5단계 실시간 알림 부품 · SSE 운영 규칙
CI (Flutter) / Flutter (analyze · test) (pull_request) Has been cancelled
CI / Backend (lint · type-check · test · e2e · build) (pull_request) Has been cancelled
CI / Frontend (lint · type-check · test · build) (pull_request) Has been cancelled
CI / Infra (compose · Dockerfile · nginx) (pull_request) Has been cancelled
CI / 에러 코드 SSOT 동기화 검사 (pull_request) Has been cancelled

- nestjs: 실시간 알림 구조(mail-events · jmap-mail-watch · new-mail · sse), 요청 컨텍스트 밖의 일, 인스턴스를 늘릴 때의 연결 한도,
  오래 열린 응답은 BeforeApplicationShutdown 에서 먼저 끝내기
- vue3: 실시간 새 메일 · 바탕화면 알림 · 실시간 테스트 도구, 토스트 error(), 밀도 예외 --size-notice-action
- docker: /api/mail-events 의 nginx 규칙(90s · 버퍼링 끔 · 로그)과 외부 리버스 프록시 요구사항(버퍼링 · 압축 · 전체 시간 상한 · HTTP/2 · 연결 수)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-28 13:32:58 +09:00
parent 458f12980d
commit 181d2a4cbf
3 changed files with 30 additions and 7 deletions
+18 -1
View File
@@ -72,7 +72,13 @@ sudo docker compose --env-file .env.production logs -f backend
발송은 정확히 이 경로만이라 `/api/messages/{id}` 는 1m. 임시 저장은 `/api/drafts` 아래 전부 — 본문이 있는 것은 `POST`·`PUT` 뿐이다),
그 밖은 1m(백엔드 JSON 파서 한도). 앞의 두 값은 루트 env 한 곳만 고치면 백엔드와 nginx 가 함께 바뀐다 (frontend 컨테이너만 다시 만들면 된다).
JSON 한도(1m·4m)는 계약과 백엔드 코드의 고정값이라 env 로 바꾸지 않는다 — 바꾸려면 계약·백엔드 파서·`frontend/nginx.conf` 를 함께 고친다.
- 시간 제한: `/api/messages`·`/api/drafts`·`/api/message-deletions` 90s, `/api/mail-attachments` 150s, 그 밖의 `/api` 는 nginx 기본 60s (`proxy_read_timeout`).
- 시간 제한: `/api/messages`·`/api/drafts`·`/api/message-deletions`·`/api/mail-events` 90s, `/api/mail-attachments` 150s, 그 밖의 `/api` 는 nginx 기본 60s (`proxy_read_timeout`).
응답 전체가 아니라 백엔드에서 "두 번 읽는 사이"의 시간이다 — 실시간 알림은 1시간 이어져도 25초마다 오는 ping 이 새로 세게 한다(머리글이 오지 않으면 90초에 504).
- **실시간 알림(SSE) `/api/mail-events`**(계약 2절 "실시간 알림"): 이 경로만 버퍼링 · 캐시 · 압축을 끄고 `X-Accel-Buffering` 을 앞으로 넘긴다
(외부 프록시가 nginx 계열이면 그쪽도 이 응답만 버퍼링을 끈다). 버퍼링을 켜 두면 머리글을 첫 이벤트까지, 백엔드가 청크 없이 닫아 끝내는 응답이면 끝까지 붙잡는다(2026-10-28 확인).
머리글 · 오류 JSON · 접근 로그는 `/api` 와 같다. ⚠️ 서버의 ping 간격을 바꾸면 90s 도 함께 본다.
열린 연결 하나가 nginx 연결 둘(클라이언트 쪽 · 백엔드 쪽)을 1시간까지 잡는다(이미지 기본값 워커마다 1024).
열린 연결이 있으면 frontend 컨테이너를 멈출 때 nginx 가 기다리다 유예 시간을 다 쓰고 강제 종료된다 — 종료 코드 137 은 이 때문이고 오류가 아니다(화면은 다시 잇는다).
- 캐시: `index.html`(직접 요청·`/`·SPA 폴백 모두)은 `Cache-Control: no-cache` — 배포 뒤 옛 index.html 이 지워진 해시 자산을 부르지 않게.
`/assets/`(Vite 산출물 — 파일명에 내용 해시)만 `max-age=30일, public, immutable` 이다.
해시가 없는 공개 파일(`frontend/public` → 루트, 예: `favicon.ico`)은 같은 이름으로 바뀌므로 `no-cache`(매번 재검증, 안 바뀌면 304).
@@ -86,6 +92,8 @@ sudo docker compose --env-file .env.production logs -f backend
Referer 를 읽는 코드는 없다 — 출처 검사(`OriginGuard`)는 `Sec-Fetch-Site`·`Origin` 을 본다.
새 메일 확인 `GET /api/mail-state`(탭마다 1분에 한 번)는 성공(2xx·304)하면 남기지 않는다 — 로깅 규칙 "반복되는 정상 흐름".
실패(401·502 등)는 남긴다 (server 단계 `access_log … if=$log_access` 하나 — `/api/` 블록을 나누지 않는다).
실시간 알림 `/api/mail-events` 는 성공도 남긴다 — 연결이 끝날 때 한 줄(브라우저마다 대략 1시간에 한 번)이고 `rt=` 가 이어진 시간이다.
시작된 스트림은 어떻게 끝나도 200 이라, 끊김은 짧은 `rt=` 와 오류 로그의 `upstream prematurely closed connection` 으로 본다.
오류 로그(413·502·504 등)에는 nginx 가 요청 줄·upstream·referrer 를 쿼리째 남긴다 — 장애 원인 줄을 잃지 않으려고 그대로 둔다.
`Origin`·`Sec-Fetch-Site`·`Idempotency-Key`·`Cookie` 는 그대로 넘긴다. `/api` 응답에는 화면용 보안 헤더를 붙이지 않는다(백엔드 helmet 이 정한다).
- 백엔드 주소는 요청마다 도커 DNS 로 다시 풀어(`resolver`), 백엔드를 다시 만들어도 nginx 를 재시작할 필요가 없다.
@@ -116,6 +124,15 @@ sudo docker compose --env-file .env.production logs -f backend
`X-Forwarded-For` 를 꾸며 IP 를 바꿀 수 있다. 외부 프록시 IP 는 frontend 가 보는 주소(proxy 네트워크 안)이며 고정해 둔다(`ipv4_address` 등).
- **본문 한도는 `MAIL_ATTACHMENT_MAX_SIZE_MB` + 1MB 이상**, 읽기 시간 제한은 150s 이상. 작으면 백엔드에 닿기 전에 413 이 나고
사용자에게는 `FILE_002` 로 보인다 (프론트 nginx 는 자동으로 맞지만 외부 프록시는 직접 맞춘다).
- **실시간 알림 `/api/mail-events` 를 흘려보낸다** (운영 확인 항목, 계약 2절 "실시간 알림"):
- 응답 버퍼링을 끈다 — nginx 계열(nginx · Nginx Proxy Manager · OpenResty)은 우리가 넘기는 `X-Accel-Buffering: no` 를 기본으로 따른다
(`proxy_ignore_headers` 로 이 머리글을 무시하게 했다면 `proxy_buffering off` 를 직접 건다). 다른 제품은 `text/event-stream` 을 바로 흘려보내는지 확인한다.
- `text/event-stream` 은 압축하지 않는다 — nginx 의 gzip 은 백엔드의 `Cache-Control: no-transform` 을 보지 않는다.
- 유휴(읽기) 제한은 ping 25초보다 넉넉하면 된다(위 "150s 이상"이면 충분). 응답 **전체** 시간에 상한을 두는 설정(요청 최대 시간 · 일부 로드밸런서의 고정 제한)이
있으면 1시간 + 여유(3900s) 이상 — 짧으면 그 주기로 끊겨 다시 잇기를 되풀이한다(동작은 하지만 그때마다 잠시 1분 확인으로 돌아간다).
- 클라이언트가 끊으면 우리 쪽 연결도 바로 닫는다(nginx 기본 `proxy_ignore_client_abort off`). 남은 연결이 쌓이면 직원당 5개 한도로 429 `REQ_001` 이 난다.
- 클라이언트 쪽(TLS 를 끝내는 곳)은 HTTP/2 를 권한다 — HTTP/1.1 이면 브라우저의 주소당 연결 6개 가운데 하나를 1시간 차지한다. 외부 프록시 → frontend:8080 은 HTTP/1.1 그대로.
- 최대 연결 수를 "직원 수 × 브라우저 수 × 2 + 평소 요청"보다 넉넉히 둔다(열린 연결마다 외부 프록시도 연결 둘을 잡는다).
- **IPv6 를 받는지 확인한다** (운영 확인 항목). 백엔드는 IPv6 클라이언트를 /64 단위로 묶어 센다 — 외부 프록시가 IPv6 를 받는다면
`X-Forwarded-For` 에 원래 IPv6 주소가 그대로 들어가는지 본다.
- **접근 로그에 쿼리를 남기지 않는다** (운영 확인 항목). 화면 주소(`/mail/search?q=…` · `?draft=<id>`)와 API 주소(`/api/messages?q=…` ·