feat: 앱 기반과 앱 서버 — 앱 전용 로그인 · 새 메일 알림(FCM) · 앱 설정 #20

Merged
ttipo merged 20 commits from feat/app-push-server into main 2026-11-08 15:47:30 +09:00
6 changed files with 146 additions and 7 deletions
Showing only changes of commit b77f95fdff - Show all commits
+4 -1
View File
@@ -51,7 +51,10 @@ description: 환경변수 단일 진실 공급원(SSOT) 구조 — 루트 .env.{
규칙:
- **새 설정값은 루트 `.env.{APP_ENV}` 에 먼저 추가**하고, 필요한 서비스에 `docker-compose.yml` 의 `environment` / `args` 로 전달한다.
- 서비스 자체 env 파일에는 **루트에서 내려주지 않는 고유 값만** 작성한다. (예: 백엔드 `JWT_SECRET`)
- 서비스 자체 env 파일에는 **루트에서 내려주지 않는 고유 값만** 작성한다. (예: 백엔드 `JWT_SECRET`,
앱 새 메일 알림의 `FCM_SERVICE_ACCOUNT_KEY` — 앱 1단계, **선택**: Firebase 서비스 계정 JSON 키를 통째로 base64 한 줄. 비면 알림만 끄고 기동한다(운영 필수 아님 — 기동 warn ·
`GET /api/app-config` 의 `pushAvailable: false`). 값이 있으면 모든 환경에서 키 모양을 보고 틀리면 기동하지 않는다(어느 칸이 틀렸는지만 — 값은 싣지 않는다, `env.validation.ts` 의 `validateFcmSettings`).
개발 · 운영 키를 따로 만든다. 매일 백업이 `backend/.env.production` 을 함께 올린다)
- **compose 는 정의되지 않은 변수를 빈 문자열로 주입한다.** 코드에서 `??` 기본값만 쓰면 `Number('')===0` 같은 함정에 빠진다 —
숫자 설정값은 `shared/utils/env.ts` 의 `parsePositiveInt()` 처럼 빈 문자열까지 방어하는 파서를 거친다.
(기존 `.env` 를 쓰는 사람이 새 키를 추가하지 않아도 안전해야 한다)
+1
View File
@@ -31,6 +31,7 @@
아래 값은 **어떤 레벨에서도 원문 그대로 남기지 않는다.**
- 비밀번호, 토큰(JWT·리프레시·API 키), 쿠키, `Authorization` 헤더
- 앱 푸시(앱 1단계 — FCM)의 휴대폰 주소(FID — 지문도 남기지 않는다: 이어 붙여 기기를 좇지 못하게) · 서비스 계정 키 · 개인 키 · 서비스 계정 JWT · Google access token · FCM · Google 의 오류 설명 글(상태 · 오류 코드만 남긴다)
- 주민등록번호·카드번호·계좌번호 등 개인 식별/금융 정보
- 요청 본문 전체 덤프 (`JSON.stringify(req.body)` 금지 — 필요한 필드만 선택해 남긴다)
- DB 쿼리 로그(매개변수) — **개발 환경도** 남기지 않는다. 메일 글 · 제목 · 주소가 DB 에 있다(14단계 검색 색인). TypeORM `logging` 은 개발에서 오류 · 경고 · 마이그레이션만, 운영 · 시험은 끈다 (`shared/config/database.config.ts` 의 `databaseLogging`)
+10
View File
@@ -102,6 +102,16 @@
같은 까닭으로 요청 대상의 날 `\`(경로) · `#` · 0x80 이상 바이트는 nginx · 백엔드 모두 400 이다 — Express 는 이런 대상을 옛 `url.parse` 로 다시 읽어 `\` 를 `/` 로 바꿔,
nginx 가 닫은 `/api/internal/` · `/api/health` 가 백엔드에서 열렸다(2026-10-31 리뷰).
### 앱 푸시 (휴대폰 새 메일 알림 — 앱 1단계 · FCM)
- **글은 "새 메일 N통"뿐이다** — 주소 · 이름 · 보낸 사람 · 제목 · 메일 id · 세션 id 는 Google 로 보내지 않는다. 메시지는 한 곳(`modules/push/fcm/fcm-message.ts`)에서 종류 · 개수만 받아 만든다(받는 칸이 없다),
새 메일을 알아채는 쪽도 엔진 연결에서 신호만 받는다. Google 은 기기마다 언제 몇 통이 왔는지는 안다(알려진 한계).
- **휴대폰 주소(FID)는 한 세션에만** — 같은 FID 를 다른 세션이 등록하면 앞 줄을 지운다(같은 휴대폰에서 다른 직원이 로그인해도 앞 직원의 알림이 가지 않게). 직원마다 10개까지.
- **보낼 때 살아 있는 · 제한 없는 앱 세션만 고른다** — 끊은 세션(로그아웃 · 비밀번호 바꾸기 · 운영 도구)은 DB 의 `revoked_at` 으로 바로 빠진다. 등록 경로는 앱 토큰만(웹 쿠키 세션 403 `AUTH_003`).
- **서비스 계정은 알림 전용 하나 + 역할 "Firebase Cloud Messaging API Admin" 하나** — Firebase 의 기본 Admin SDK 계정(Firestore · Auth 까지 쓴다)을 쓰지 않는다. 키는 개발 · 운영 따로,
`backend/.env.{env}` 의 `FCM_SERVICE_ACCOUNT_KEY` 에만 둔다(로그 · 오류 · 응답에 싣지 않는다). 새면 그 키를 Google Cloud 콘솔에서 지우고 새 키로 — 그 키로는 우리 앱 사용자에게 꾸민 알림을 보낼 수 있다(메일은 읽지 못한다).
- 바깥 주소는 상수 두 곳(`oauth2.googleapis.com` · `fcm.googleapis.com`)뿐이다 — 키 파일의 `token_uri` 를 따라가지 않고(다르면 기동하지 않는다) 다른 곳으로 보내는 응답(3xx)도 따라가지 않는다.
## 네트워크 · 인프라
- 보안 헤더(`helmet`) 적용
+5 -2
View File
@@ -187,6 +187,9 @@ sudo docker compose --env-file .env.production logs -f backend
그 막기는 사설 대역에 기댄다 — `APP_NETWORK_SUBNET` · `EDGE_NETWORK_SUBNET` 은 사설 대역으로 둔다(mail setup 은 앱 네트워크만 검사한다).
서버 자신의 공인 주소로 풀리는 이름은 막지 않는다(공인 주소라서) — 그 요청은 호스트의 80 · 443 에 닿으므로, 같은 외부 프록시 뒤에 "내부 주소면 허용"하는 다른 사이트를 두지 않는다
(메일 엔진의 443 은 setup 이 403 으로 막고, 중계는 응답이 그림일 때만 돌려준다 — 2026-10-29 리뷰).
**앱 새 메일 알림(앱 1단계 — FCM)** 도 같은 길로 나간다: backend → `oauth2.googleapis.com:443`(서비스 계정 JWT → access token) · `fcm.googleapis.com:443`(보내기).
막히면 휴대폰 알림만 오지 않는다(로그 warn · error — 다른 기능은 영향 없음). **서버 시계가 맞아야 한다**(NTP — `timedatectl` 의 "System clock synchronized: yes") — JWT 의 `iat` 가 틀리면 Google 이 토큰 교환을 거절한다.
키(`FCM_SERVICE_ACCOUNT_KEY` — 서비스 계정 JSON 을 통째로 base64 한 줄)는 `backend/.env.{APP_ENV}` 에 둔다(compose 는 바꿀 것이 없다 — `env_file`). 비면 알림만 끄고 뜬다 — 넣었으면 기동 로그 "앱 새 메일 알림: 켬 (Firebase 프로젝트 …)"를 본다.
- **본문 한도는 `MAIL_ATTACHMENT_MAX_SIZE_MB` + 1MB 이상**, 읽기 시간 제한은 150s 이상. 작으면 백엔드에 닿기 전에 413 이 나고
사용자에게는 `FILE_002` 로 보인다 (프론트 nginx 는 자동으로 맞지만 외부 프록시는 직접 맞춘다).
- **실시간 알림 `/api/mail-events` 를 흘려보낸다** (운영 확인 항목, 계약 2절 "실시간 알림"):
@@ -498,7 +501,7 @@ node --env-file=.env.development mail/scripts/setup.mjs
| --- | --- | --- |
| 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 처음 설치·재실행과 웹메일을 다시 확인한다. setup 의 리졸버 확인은 메일 이미지의 perl(`perl-base`)로 묻는다 — 새 이미지에 perl 이 있는지도 본다 |
| 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 처음 설치·재실행과 웹메일을 다시 확인한다. 앱 알림의 N 은 `Email/query` 의 `after` 가 그 초를 넣지 않는 것에 맞춰 한 초 앞을 넘긴다(`jmap-mail.provider.ts` 의 `inboxUnreadSinceFilter` — 2026-11-08 확인) — 올리면 이것도 다시 본다. setup 의 리졸버 확인은 메일 이미지의 perl(`perl-base`)로 묻는다 — 새 이미지에 perl 이 있는지도 본다 |
| Mailpit (개발) | `axllent/mailpit:v1.32.0` | `.env.development.example` 의 `MAILPIT_IMAGE` |
| unbound (메일 리졸버) | `klutchell/unbound:1.26.2@sha256:50cbeb32…` | `docker-compose.yml` 의 `mail-resolver`, `mail/README.md` 「DNS 리졸버와 스팸 차단 목록」의 이미지 칸(판 · digest 앞부분), 이 문서. 커뮤니티 이미지라 **digest 까지** 묶는다 — 올릴 때는 `imagetools inspect` 의 manifest list digest 로 함께 바꾼다. CI 의 unbound 설정 검사는 compose 의 이미지를 읽어 쓴다 |
| ClamAV (악성 검사 — 11단계) | `clamav/clamav:1.4.6_base@sha256:e7399106…` | `docker-compose.yml` 의 `clamav` · `clamav-updater`(같은 값 둘), `mail/README.md` "악성 검사 (ClamAV)"의 이미지 칸, 이 문서. LTS(1.4 — 2024-08 부터 3년)만 쓴다 — 같은 날 나온 1.5.4 대신 고른 까닭: 지원 기간, 1.5 의 새 ZIP 코드에서 나온 CVE-2026-20337. 공식 이미지지만 digest 까지 묶는다(CI 의 `clamconf` 검사는 compose 의 이미지를 읽어 쓴다) |
@@ -592,7 +595,7 @@ node --env-file=.env.development mail/scripts/setup.mjs
backend 가 `getaddrinfo ENOTFOUND db` 로 뜨지 않는다 (2026-10-26 개발 환경에서 겪음).
- **env 파일**: 실행 전 아래 두 파일이 모두 있어야 한다. 없으면 `*.example` 을 복사해 생성한다.
- 루트 `.env.{APP_ENV}` — 설정 SSOT ([[env-structure]])
- `backend/.env.{APP_ENV}` — compose `env_file` 대상이라 **비어 있어도 존재해야 한다**
- `backend/.env.{APP_ENV}` — compose `env_file` 대상이라 **비어 있어도 존재해야 한다** (백엔드 고유 시크릿 — `JWT_SECRET`, 앱 알림을 쓰면 `FCM_SERVICE_ACCOUNT_KEY` — 매일 백업에 함께 든다)
복사 후 `change-me-*` placeholder 값을 반드시 교체한다 ([[security]]).
- **Graceful shutdown**: backend 는 `init: true`(tini)로 PID 1 을 두어 SIGTERM 이 애플리케이션까지 전달되게 하고,
`stop_grace_period: 30s` 동안 처리 중인 요청을 마치도록 한다.
File diff suppressed because one or more lines are too long
+117 -2
View File
@@ -163,7 +163,120 @@
- 로그아웃 · 다른 계정으로 로그인 · 세션 끝 뒤에 도착한 응답(로그인 · 코드 · 갱신 · me)은 쓰지 않는다 — 버린 로그인 결과에 토큰이 있으면 그 토큰으로 조용히 앱 logout 을 부른다(웹의 "로그아웃 뒤 늦은 갱신"과 같은 결).
- 코드 단계 · 제한 단계에서 다시 쓸 비밀번호는 화면 메모리에만 10분 — 성공 · 다른 계정 · 떠남 · 제한이 풀릴 때 지운다(웹과 같다).
**이 절 다음(앱 첫 판 안 — 따로 차례)**: 푸시 기기 등록(`POST`·`DELETE /api/push-devices` — 앱 세션에 묶어 logout · 세션 끊기 · 비밀번호 바꾸기 · 퇴사 마무리 때 함께 지운다, 내용은 "새 메일 N통"만), 앱 최소 판 확인(공개 `GET /api/app-config` → `{ minVersion, latestVersion, storeUrl }`).
### 앱 새 메일 알림 · 앱 설정 — 앱 1단계 (2026-11-07 정함 · 서버)
> 2026-11-07 사용자가 추천안대로 정했다(앞 메모의 `/api/push-devices` · `{ minVersion, latestVersion, storeUrl }` 를 이 절로 바꾼다).
> 서버는 `modules/push`(등록 · 끝난 세션 정리 · 보내기 — 메일을 모른다) · `modules/mail/new-mail-push.service.ts`(새 메일 알아채기 · N · 간격 · 따라잡기) · `modules/app-config` 가 이 절대로 만들었다.
> 보내는 곳은 FCM HTTP v1 하나 — 새 의존성 없이(node crypto 의 RS256 + 전역 fetch). 휴대폰 주소는 FCM 등록 토큰이 아니라 **FID**(Firebase 설치 id — v1 의 `token` 칸은 deprecated, `message.fid` 로 보낸다)다.
| 경로 | 본문 | 응답 |
| --- | --- | --- |
| `GET /api/app-config` (로그인 없음) | — | `{ minSupportedVersion: { android: "1.0.0", ios: null }, pushAvailable: boolean }` · `Cache-Control: no-store` |
| `PUT /api/push-registrations/current` (앱 토큰만) | `{ fid, platform: "android", appVersion }` | 200 `{ updatedAt }` (ISO 8601 UTC · `Cache-Control: private, no-store`) |
| `DELETE /api/push-registrations/current` (앱 토큰만) | — | 204 (등록이 없어도) |
| `DELETE /api/push-registrations/current/unseen` (앱 토큰만) | — | 204 (등록이 없어도) |
- **app-config**: 최소 판은 서버 코드 상수(`modules/app-config/app-config.constants.ts`) — 옛 앱이 쓸 수 없게 서버 API 를 바꾸는 배포가 **같은 커밋에서** 올린다. `ios: null` 은 막지 않는다는 뜻(iOS 앱이 아직 없다).
`pushAvailable` 은 서버가 FCM 키를 읽었는가(키가 막힌 것 — OAuth 거절 — 은 로그로만 알린다). 요청 수는 기본 한도(경로마다 IP 당 1분 100회 — 문의 연락처와 같다), 실패는 429 `REQ_001` 뿐이다.
- **본문**: `fid` 는 영문 · 숫자 · `-` · `_` 64자 이하(Firebase 의 FID 는 22자), `platform` 은 `android` 만, `appVersion` 은 `숫자.숫자.숫자`(칸마다 1~4자리 · 앞자리 0 없이 — 앱의 `PackageInfo.version`). 모르는 칸은 400.
- **실패**: 웹(쿠키) 세션 403 `AUTH_003`(`AppClientGuard` — 토큰은 맞지만 이 경로를 쓸 수 없는 클라이언트) · 제한된 세션(6절) 403 `AUTH_010` · 토큰 없음 · 그사이 끝난 · 끊긴 세션 401 `AUTH_001` · 본문 400 `VAL_001` · 요청 수 429 `REQ_001`. 새 오류 코드는 없다.
- **등록 — 세션(sid)마다 한 줄**(표 `push_registrations` — 마이그레이션 `AddPushRegistrations`, 세션 표와 외래 키로 묶지 않는다). PUT 은 한 트랜잭션이다:
그 세션이 살아 있는 · 제한 없는 앱 세션인지 보고(아니면 아무것도 바꾸지 않는다) → 같은 FID 의 다른 줄(다른 세션 · 다른 직원 — 같은 휴대폰에서 다른 직원이 로그인)을 지운다 →
넣거나 바꾼다(FID · 플랫폼 · 판 · `updatedAt`) → 한 직원 10줄을 넘으면 가장 오래 바꾸지 않은 줄부터 지운다. 같은 FID 의 등록은 DB 잠금으로 차례가 된다(함께 등록해도 한 줄만 남는다).
**새 줄이면 세는 시작(`seen_at`) = 지금**, 있던 줄은 세는 시작 · 마지막으로 보낸 때를 건드리지 않는다. FCM 키가 없어도(`pushAvailable: false`) 저장한다 — 키를 넣으면 다시 등록하지 않아도 알림이 시작된다.
켬 · 끔은 휴대폰이 들고 있다 — 끄면 앱이 DELETE 한다(서버에 켜짐 칸은 없다).
- **`/unseen`**: 그 줄의 `seen_at` = 지금(서버 시각) — "이 휴대폰에서 앱을 봤다". 다음 알림의 N 을 그때부터 센다.
- **새 메일 알아채기**: 보낼 기기(살아 있는 · 제한 없는 앱 세션의 등록)가 있는 사내 직원은 SSE 연결이 없어도 서버가 그 직원의 엔진 연결을 **붙잡아 둔다**(2절 "실시간 알림"의 연결 — `MailEventsService.hold`, 직원마다 여전히 하나).
판정은 웹의 `new-mail` 그대로다(받은편지함 · 초안 아님 · 읽지 않음 · 보낸 사람이 나 아님 · 본 때보다 10분 넘게 앞서 받은 메일 빼기). 붙잡을 직원은 서버가 뜰 때 · 5분마다 · 등록이 바뀐 직원은 그때 맞춘다(함께 여는 수 4).
붙잡기에 실패하면 1 · 5 · 15 · 60분 뒤 다시(계정을 쓸 수 없음 — `AUTH_001` · 비밀번호 만료 · 잠김 · 엔진 연결이 끝남 — 은 60분부터). FCM 키가 없으면 아무도 붙잡지 않는다.
곁 효과: 알림을 켠 직원은 웹을 닫아도 14단계 검색 색인의 실시간 맞추기가 돈다.
- **N**: 받은편지함의 안 읽은 메일(초안 빼고) 가운데 **이 휴대폰에서 마지막으로 앱을 본 뒤**(`seen_at` — 같은 초 포함) 받은 수 — 보낼 때 엔진에 센다(기기마다). PC 에서 읽은 메일은 빠진다. **0 이면 보내지 않는다.**
보내는 때는 웹 `new-mail` 이 있을 때뿐이다(안 읽음 표시 바꾸기 · 옛 메일 옮기기로는 보내지 않는다).
- **간격**: 같은 직원은 30초에 한 번까지 — 첫 알림은 바로, 그사이 온 것은 30초 끝에 그때의 N 으로 한 번. 같은 직원의 보내기는 차례로 한다.
- **따라잡기**: 붙잡을 때마다(서버를 다시 띄움 · 다시 붙잡음) 그 전 10분 안 · 마지막으로 보낸 뒤 · 본 뒤에 온 안 읽은 메일이 있으면 그 기기에 그때의 N 으로 한 번 — 이미 알린 · 이미 본 메일로는 다시 울리지 않는다.
"보낸 뒤 · 본 뒤"는 `last_pushed_at`(그 알림의 N 을 센 때) · `seen_at` 의 **다음 초부터**다 — 받은 시각은 초라 그 초에 받은 메일은 그 알림에 이미 들었거나 이미 보았다고 본다(2026-11-07 리뷰). N 은 위처럼 `seen_at` 의 초부터 센다.
그 초에 센 · 본 뒤 들어온 메일은 따로 새 메일 신호를 낸다 — 그 알림이 나가기 전(그 1초 안 · 30초 간격의 끝을 기다리는 사이)에 서버가 멈추면 놓친다. 따라잡기로 보내도 30초 간격에 든다.
**엔진의 `after` 는 그 초를 넣지 않는다**("그 초보다 뒤" — RFC 8621 은 "같거나 뒤"라 적는다, 2026-11-08 개발 엔진 0.16.27 에서 확인) — 서버는 한 초 앞을 넘겨 같은 초를 센다(`jmap-mail.provider.ts`). 엔진 판을 올리면 다시 본다(같은 초를 넣게 바뀌면 따라잡기가 이미 알린 메일로 한 번 더 울린다).
- **보내는 메시지** (FCM v1 `messages:send` 의 `message` — 서버 `modules/push/fcm/fcm-message.ts` 한 곳, 받는 것은 FID 와 개수뿐):
```json
{
"fid": "<FID>",
"notification": { "title": "새 메일 3통" },
"data": { "module": "mail", "kind": "new-mail" },
"android": {
"collapse_key": "new-mail",
"priority": "HIGH",
"ttl": "86400s",
"notification": {
"tag": "new-mail",
"channel_id": "new_mail",
"notification_priority": "PRIORITY_HIGH",
"visibility": "PUBLIC",
"notification_count": 3
}
}
}
```
글은 제목 "새 메일 N통"뿐이다(천 단위 쉼표 — 원본은 서버 `modules/push/push-text.ts`). 주소 · 이름 · 보낸 사람 · 제목 · 메일 id · 세션 id 는 싣지 않는다 — 누르면 늘 메일 탭 받은편지함(`data.module`).
꺼져 있던 휴대폰은 마지막 것 하나만 받고(`collapse_key`), 24시간이 지나면 버린다(`ttl`).
- **그 휴대폰을 직원이 건드리지 않았는데 앱 세션이 끝나면** 5분 정리가 그 등록을 지운 뒤(`DELETE … RETURNING` — 한 번만) 그 휴대폰에 한 번 보낸다 — `kind: "session-ended"`, 같은 tag · 채널 · `collapse_key`(옛 "새 메일 N통"을 바꿔 쓴다), `priority: NORMAL`,
글은 보드 `App1Push` ⑧(2026-11-07 확정) 그대로 — 제목 "새 메일 알림이 멈췄습니다" · 내용 "안전을 위해 로그아웃했습니다. 앱을 열어 다시 로그인해 주세요."(원본 `push-text.ts`).
끝난 까닭(세션 표의 `revoked_reason`)마다 — 표는 서버 `modules/push/push.constants.ts` 의 `SESSION_ENDED_NOTICE` 한 곳(까닭을 더하면 거기서 정해야 컴파일된다):
| 끝난 까닭 | 값 | 알림 |
| --- | --- | --- |
| 저절로 끝남(미사용 14일 · 절대 30일) | 없음(끊기지 않았다) | 보낸다 |
| 다른 곳에서 비밀번호를 바꿈 · 바꾸다 결과를 알 수 없게 됨 | `password-changed` · `password-change-uncertain` | 보낸다 |
| 앱 갱신 유예(회전 응답을 잃었거나 토큰이 두 번 쓰였다) | `app-refresh-grace` | 보낸다 |
| 리프레시 토큰 재사용 감지 | `refresh-token-reused` | 보낸다 |
| 그 휴대폰에서 로그아웃 | `logout` | 보내지 않는다 |
| 로그인된 기기에서 끊기 · 다른 곳 모두 로그아웃 | `ended-by-user` | 보내지 않는다 |
| 운영 도구(재설정 · 잠금 · 이전용 비밀번호 다시 만들기 · 이전 마치기 · 퇴사 마무리) | `ended-by-operator` | 보내지 않는다 |
| 계정을 쓸 수 없음(갱신 때) | `account-unavailable` | 보내지 않는다 |
| 2단계를 켜 끊긴 세션(제한된 세션) | `two-factor-not-verified` | 보내지 않는다 |
제한이 붙은 세션 · 까닭이 없거나 표에 없는 값(다른 판이 남긴 줄) · 세션 줄이 없는 등록에는 보내지 않는다.
보내기 직전 그 FID 를 다른 등록이 쓰고 있으면(그 휴대폰에서 다른 직원 · 같은 직원이 다시 로그인했다) 보내지 않는다.
- **등록이 사라지는 길**: 보낼 때는 살아 있는 · 제한 없는 앱 세션의 줄만 고른다 — 끊은 세션(위 표의 까닭 모두)은 DB 의 `revoked_at` 으로 **바로** 빠진다(15초 기억과 상관없이).
끝난 세션의 줄은 5분 정리(`modules/push/push-cleanup.service.ts`)가 지운다 — 위 표에서 "보낸다"인 세션의 휴대폰에만 지운 뒤 한 번 알린다. 제한이 붙은 세션의 줄은 남는다(보내지 않는다 — 풀리면 다시). 같은 휴대폰에서 다른 직원이 등록하면 앞 줄을 지운다(앞 직원의 붙잡기도 다시 고른다 — 알리지 않는다).
**보내기 직전(다시 하기마다) 그 세션의 줄이 아직 그 FID 이고 세션이 살아 있는지 다시 본다** — 센 뒤 · 다시 하기를 기다리는 사이 그 휴대폰이 다른 직원에게 옮겨 갔으면 보내지 않는다.
FCM 이 받지 않는 휴대폰(아래 "실패")은 그 줄을 지운다 — 그사이 PUT 이 FID 를 바꿨으면 둔다. 퇴사 마무리(6절)는 그 주소의 줄을 한 트랜잭션에 지운다("앱 알림 등록").
- **FCM 의 실패**(`modules/push/fcm/fcm-sender.ts`): `UNREGISTERED`(404) · `SENDER_ID_MISMATCH`(403) · `message.fid` 칸의 `INVALID_ARGUMENT`(400) → 그 줄을 지운다 /
401 → access token 을 버리고 새 토큰으로 한 번 더 / 429(`QUOTA_EXCEEDED`) → 바로 다시 보내지 않는다 — `Retry-After` 가 있고 남은 시간 안이면 그때 한 번, 아니면 이번 알림은 버린다(다음 새 메일이 그때의 N 으로 다시 간다 — 같은 직원은 30초에 한 번이라 한도에 닿을 일이 드물다) /
5xx · 연결 오류 · 시간 초과 → 다시(1 · 4초 ±20% — 응답의 `Retry-After` 는 남은 시간 안에서만, 시도 3번 · 시도마다 10초 · 한 번 보내기 20초 안) /
그 밖(400 · 403 `PERMISSION_DENIED` · 오류 코드 없는 404 · `THIRD_PARTY_AUTH_ERROR` · 3xx · OAuth 거절) → 지우지 않는다(프로젝트 id 가 틀린 404 로 모든 등록을 지우지 않게) · error. 서버 전체에서 함께 보내는 수 4.
OAuth access token 은 만료 5분 전까지 기억한다(함께 부르면 교환은 한 번).
- **로그**: FID(지문도) · 서비스 계정 키 · 개인 키 · JWT · access token · FCM · Google 의 오류 설명 글은 남기지 않는다 — 상태 · 오류 코드만, 주소는 가린다(`maskEmail`).
보낸 것은 debug("앱 알림: user=a***@…, 기기 1, N 3"), 끝내 못 보냄은 warn · 설정 오류는 error(종류마다 한 시간에 한 번 — 묻힌 수를 다음 줄에), 붙잡기가 이어서 실패하면 warn 한 번 · 다시 되면 info 한 번.
서버 종료로 끊긴 보내기는 로그를 남기지 않는다(배포마다 남는 잡음이 되지 않게).
- **환경 변수**: `backend/.env.{env}` 의 `FCM_SERVICE_ACCOUNT_KEY` = 서비스 계정 JSON 키 파일을 통째로 base64 한 줄(JWT_SECRET 과 같은 자리 — compose 는 바꿀 것이 없다, 매일 백업에 함께 든다).
비면 알림만 끈다(기동은 한다 — 운영 필수가 아니다, 기동 로그 warn). 값이 있으면 **모든 환경에서** 모양(base64 → JSON → `service_account` · `project_id` · `client_email` · `private_key_id` · PEM RSA 2048 이상 · `token_uri`)을 보고 틀리면 기동하지 않는다(어느 칸이 틀렸는지만 — 값은 싣지 않는다).
켜지면 기동 로그 한 줄 "앱 새 메일 알림: 켬 (Firebase 프로젝트 <id>)". 서비스 계정은 알림 전용 하나 + 역할 "Firebase Cloud Messaging API Admin" 하나, 키는 개발 · 운영 따로.
- **운영 확인**: backend 컨테이너 → `oauth2.googleapis.com:443` · `fcm.googleapis.com:443`(외부 이미지 중계와 같은 길 — 호스트 방화벽 · NCP ACG 의 나가는 규칙), 서버 시계(NTP — JWT 의 `iat`, 틀리면 토큰 교환이 거절된다).
- **실기기 · 실제 키로 확인할 것**(첫 보내기 때): FCM 이 `message.fid` 와 메시지 칸(`priority "HIGH"` · `notification_priority` · `visibility` · `notification_count` · `ttl` 글)을 받는지,
오류 본문의 모양(`UNREGISTERED` · `SENDER_ID_MISMATCH` · 429 · 503 의 `Retry-After`) — **잘못된 FID 를 `message.fid` 가 아닌 칸(예: `message.token`)으로 알리면 지금은 그 줄을 지우지 않고 설정 오류(error — 한 시간에 한 번)로만 남긴다**(안전한 쪽 — 오류 본문을 보고 칸 이름을 맞춘다),
실기기의 FID 길이 · 글자(22자 base64url 로 본다), 같은 tag 로 바꿔 쓴 알림이 다시 울리는지 · `notification_count` 의 런처 배지(삼성).
- **알려진 한계**: Google 은 기기마다 언제 몇 통(N)이 왔는지는 안다(내용 · 주소는 가지 않는다). 푸시는 최선을 다할 뿐이다(FCM 장애 · 강제 종료한 앱 · 배터리 제한). 웹에서 다 읽어도 이미 뜬 알림은 다음 알림 · 앱을 열 때까지 그대로다.
나에게 보낸 메일도 N 에 든다(그 메일이 알림의 방아쇠가 되지는 않는다). 서버가 10분 넘게 멈췄으면 그동안 온 메일은 다음 알림의 N 으로만 알린다.
**인스턴스를 늘리면 알림이 겹친다**(붙잡기 · 간격이 프로세스 메모리 — nestjs 스킬의 Redis 목록).
**클라이언트 — 앱**(Flutter — 앱 디자인 README "새 메일 알림 · 업데이트 · 앱 아이콘", 2026-11-07)
- 등록은 FCM 의 FID 로 한다(`register()` · `onRegistered` — 자동 초기화는 끈다). 보낼 조건 = 받을 수 있는 휴대폰 ∧ `pushAvailable` 이 거짓이 아님(모르면 보낼 수 있는 것으로) ∧ 메일을 쓸 수 있는 세션(제한 단계가 아님) ∧ 이 휴대폰에서 켬(처음 켜짐) ∧ 알림 권한 ∧ 새 메일 채널.
`pushAvailable: false` 면 PUT 하지 않는다 — 그 전에 등록된 줄은 이어지고, 새 휴대폰은 앱이 앱 설정을 다시 받은 뒤(앞으로 올 때 — 1시간마다) 등록한다.
- **PUT** 은 FID 를 받았을 때(등록 뒤 첫 소식 · FID 가 바뀜) · 세션이 열릴 때마다 한 번(앱을 띄울 때 — 지난 실행의 FID 로 바로) · 설정 스위치를 켤 때만 보낸다. `appVersion` 은 앱 판(모르면 `0.0.0`).
스위치로 켤 때만 실패를 알리고(그 코드의 사전 문구) 이 휴대폰의 값을 끔으로 둔다(처음 값 켬에서 허락하고 켰어도 — 스위치는 꺼진 채, 저절로 다시 하지 않는다).
저절로 보낸 PUT 의 실패는 조용히 다음 기회(앞으로 옴 · FID 바뀜 · 다음 로그인)에 다시 한다 — 등록이 된 뒤로는 앞으로 와도 보내지 않는다.
- **DELETE** 는 설정 스위치를 끌 때(지운 뒤 꺼진다 — 실패면 켬 그대로, 알린다)와 앞으로 올 때 권한 · 새 메일 채널이 꺼진 것을 알았을 때(조용히)만.
**로그아웃 · 세션 끝에는 부르지 않는다** — 앱은 토큰을 지운 뒤 서버 logout 과 함께 알림창을 지우고 FCM `unregister()` 로 FID 를 버린다(서로 기다리지 않는다).
풀지 못하면 다음 실행 · 로그아웃 상태로 앞으로 올 때 다시 푼다. 그사이 같은 휴대폰에서 다른 직원이 로그인하면 풀기 뒤에 다시 등록해 같은 FID 로 PUT 한다(서버가 같은 FID 의 앞 줄을 지운다).
그 밖의 앞 직원 줄은 서버가 지운다 — 끊은 세션은 보낼 때 바로 빠지고, 끝난 세션의 줄은 5분 정리가 지운다.
- **unseen** 은 앱이 앞으로 올 때와 앞에서 새 메일 알림을 받았을 때 보낸다 — 30초에 한 번(그사이 다시 오면 끝에 한 번 더), 조용히. 앱이 앞에 있을 때만 보내고, 끝을 기다리는 동안 뒤로 가면 그 자리에서 보낸다(N 을 "마지막으로 본 뒤"로 세게).
- 세 경로의 404 `RES_001`(그 경로가 없는 옛 서버)은 조용히 넘기고 등록 없이 지낸다.
- **app-config** 는 앱을 띄우자마자 한 번 묻는다 — 인증 인터셉터 줄 밖이라 세션 되살리기(미리 갱신)를 기다리지 않는다. 앞으로 올 때 마지막으로 받은 지 1시간이 지났으면 다시(실패는 1분 간격으로 다시 — 알리지 않는다).
404 는 옛 서버(최소 판 없음 · `pushAvailable` 거짓). 이 앱의 판이 `minSupportedVersion.android` 보다 낮으면 어느 화면에서든(로그인 전 포함) 업데이트 화면이다(쓰던 메일은 임시보관함에 저장) — 모양이 틀리거나 받지 못했거나 이 앱의 판을 모르면 막지 않는다. 서버가 최소 판을 내리면 다음 확인에서 풀린다.
- **받는 알림**: 앱이 앞에 있으면 휴대폰이 그리지 않는다 — 앱은 앞에서 받은 `new-mail` 로 새 메일을 바로 확인하고(2절 "새 메일 확인"의 클라이언트 — 앱), 앞으로 올 때 알림창의 `new-mail` 알림(tag)을 지운다.
누르면 늘 메일 탭 받은편지함이다 — `kind` 로 가르지 않는다(`session-ended` 를 눌렀으면 받은편지함의 첫 요청이 세션 끝을 알아 로그인 화면으로 간다).
작은 아이콘 · 색은 서버가 싣지 않는다 — 앱 매니페스트의 기본값(`ic_stat_ara` · #0E7C86)이고, 채널 `new_mail`(이름 "새 메일" · 중요도 높음)은 앱이 만든다.
---
@@ -1020,6 +1133,8 @@ data: {"reason":"token-expiring"}
스트림이 열린 뒤 계정을 더 쓸 수 없게 되면(엔진이 대리 접근을 거절) `end` 없이 닫는다 — 클라이언트는 끊김으로 보고 다시 이어 401 → 갱신 실패 → 세션 만료로 간다.
- **서버**: 직원마다 엔진 연결 하나를 그 직원의 모든 연결에 나눠 쓴다(마지막 연결이 닫히고 30초 뒤 닫는다 — 새로 고침에 다시 열지 않게).
**앱 새 메일 알림을 켠 직원은 연결이 없어도 엔진 연결을 붙잡는다**(앱 1단계 — 1절 "앱 새 메일 알림 · 앱 설정"): 같은 엔진 연결을 함께 쓰고(직원마다 여전히 하나) 연결 상한(5)에 세지 않는다.
붙잡은 쪽은 새 메일 신호(같은 판정 — 보낸 사람 · 제목 없이)와 끝남만 받는다. 놓으면 남은 연결이 없을 때 30초 뒤 닫는다.
엔진 연결은 대리 접근 자격 · `types=Mailbox,Email` · `ping=30` 이다 — 엔진은 30초보다 짧게 주지 않는다(10초를 달라고 해도 30초, 2026-10-28 확인).
75초 동안 아무것도 오지 않으면 끊긴 것으로 보고 다시 잇는다(1 · 2 · 5 · 10 · 30초 간격에 ±20% — 엔진이 다시 뜰 때 모든 직원이 같은 순간에 붙지 않게).
그동안 클라이언트에는 `ping` 의 `upstream: false` 로 알린다. 같은 기준으로 이어서 3번 묻지 못하면 기준을 버리고 처음 상태로 다시 잡는다(그 차례는 `new-mail` 없이 — 엔진 한도 · 엔진이 내려간 동안은 세지 않는다).
@@ -2743,7 +2858,7 @@ interface FileUploadCreate {
만료 뒤에는 대리 접근 메서드가 막히고, 알림이 말하는 일(로그아웃 · 앱 비밀번호 지움)은 넣기 전에 끝나 있어야 하기 때문이다. 첫 세션 끊기가 실패하면 알림을 넣기 전에 멈추고,
엔진 바꾸기가 실패하면 넣은 알림 메일을 지운다. 마지막 끊기는 그사이 옛 비밀번호로 새로 로그인한 세션을 위한 것이다.
- 잠금: 받는 메일 넘기기 · 자동 응답(대리 접근으로 Sieve)을 먼저 걸고 → `authenticate` 빼기 → 세션 끊기. 뺀 뒤에는 대리 접근이 막혀 Sieve 를 새로 걸 수 없다(이미 건 Sieve 는 계속 돈다).
- 퇴사 마무리: 엔진 계정을 지운 뒤 우리 DB 에서 그 주소의 행을 한 트랜잭션으로 지운다 — 로그인 기록 · 앱 비밀번호 사용 · 2단계 설정 · 서명 · 설정 · 사내 목록 기록 · 대용량 파일 · 링크,
- 퇴사 마무리: 엔진 계정을 지운 뒤 우리 DB 에서 그 주소의 행을 한 트랜잭션으로 지운다 — 로그인 기록 · 앱 비밀번호 사용 · 2단계 설정 · 서명 · 설정 · 사내 목록 기록 · 대용량 파일 · 링크 · 검색 색인 · 앱 알림 등록(앱 1단계 — 휴대폰 주소),
그리고 2단계 필수의 예외 주소 목록과 공용 주소(MailingList)의 구성원에서도 뺀다(실행 전 보여 주는 바뀔 것에 든다).
세션 행은 바로 지우지 않는다 — 끊은 기록을 서버가 15초 동기화로 읽어야 하므로 끝난 세션 정리 cron 에 맡긴다(로그인된 기기 목록은 살아 있는 세션만 보인다).
디스크 파일(대용량 · 서명 그림)은 그 뒤에 지우고 못 지운 것은 정리 cron 에 맡긴다. 실행 기록(`ops_audit_log`)은 남기고, 읽기만 하는 현황은 기록하지 않는다.