docs: 휴대폰 앱 사내 배포를 계약 · 운영 · 앱 · 디자인 문서와 스킬에 적는다
CI / Frontend (lint · type-check · test · build) (pull_request) Successful in 12m41s
CI (Flutter) / Flutter (analyze · test) (pull_request) Successful in 22m13s
CI / Backend (lint · type-check · test · e2e · build) (pull_request) Successful in 13m13s
CI / Infra (compose · Dockerfile · nginx · 운영 스크립트) (pull_request) Successful in 2m13s
CI / 에러 코드 SSOT 동기화 검사 (pull_request) Successful in 11m5s
CI (Flutter) / Flutter (analyze · test) (push) Successful in 21m49s
CI / Backend (lint · type-check · test · e2e · build) (push) Successful in 13m13s
CI / Frontend (lint · type-check · test · build) (push) Successful in 12m54s
CI / Infra (compose · Dockerfile · nginx · 운영 스크립트) (push) Successful in 1m47s
CI / 에러 코드 SSOT 동기화 검사 (push) Successful in 10m26s

계약 1절 "휴대폰 앱 받기"와 6절 운영 도구 명령, 서버 B 에 APK 올리는 차례와 직원 안내(링크 없이),
앱의 서명 키 · release 빌드 · 올리기 전 확인, 웹 화면 절, 스킬 표, App1Shell 보드의 앱 정보 줄.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit was merged in pull request #22.
This commit is contained in:
2026-11-08 23:29:06 +09:00
parent 7c70862647
commit ff1948b7df
10 changed files with 312 additions and 36 deletions
+42 -3
View File
@@ -778,11 +778,11 @@ sudo docker exec "${PROJECT_NAME}-backend" node -e "const s=require('net').conne
- **조각 올리기** `PUT /api/file-uploads/{id}/chunks/{n}` 만 본문 **17m**(조각 16MiB + 여유 — 계약 상수라 env 로 바꾸지 않는다) · 읽기 90초. 요청 본문은 nginx 가 다 받아 둔 뒤 넘긴다(`/tmp/client_temp`).
그 밖의 `/api/file-uploads` · `/api/file-quota` · `/api/share-links` · `/api/share-link-lookups` 는 JSON 1m 그대로다. 조각 요청은 포털 호스트로 온다.
이 경로의 오류 로그는 `error` 부터다 — 조각마다 nginx 가 `a client request body is buffered to a temporary file`(warn)을 남겨 2GB 파일 하나에 128줄이 쌓였다(본문 버퍼는 키우지 않는다 — 동시 조각 수 × 16MiB 가 메모리에 앉는다).
- **받기** `/api/public-links/<토큰>/files/…`(공개) · `/api/messages/<id>/large-files/…`(앱 안) — 응답 버퍼링 · 압축을 끄고 `X-Accel-Buffering: no` 를 외부 프록시로 넘긴다(실시간 알림과 같다),
- **받기** `/api/public-links/<토큰>/files/…`(공개) · `/api/messages/<id>/large-files/…`(앱 안) · `/api/app-releases/<플랫폼>/latest/file`(휴대폰 앱 APK — 앱 안 받기와 같은 그릇으로 센다) — 응답 버퍼링 · 압축을 끄고 `X-Accel-Buffering: no` 를 외부 프록시로 넘긴다(실시간 알림과 같다),
`Range` · `If-Range` 는 그대로 넘긴다(백엔드가 206 · 416). 머리글 버퍼는 16k(`proxy_buffer_size` — 한글 255자 파일 이름이면 응답 머리글이 3.5KB 쯤이라 기본 4k 로는 빠듯하다).
**동시 연결** — 클라이언트 주소마다 공개 10 · 앱 안 30(사무실 NAT), 전체는 공개 400 · 앱 안 200 을 **따로** 센다(공개 받기가 가득 차도 직원의 앱 안 받기는 된다). 넘으면 429 `REQ_001`(JSON).
클라이언트 주소는 `X-Forwarded-For` 의 마지막 칸(외부 프록시가 이어 붙인 값)이다 — 앞에 꾸민 값을 붙여도 마지막 칸으로 센다.
두 경로는 **대소문자 · `%2F` · 겹 `/` 를 가리지 않고** 같은 설정을 탄다 — 백엔드(Express)의 라우팅이 대소문자를 가리지 않아 `/api/Messages/…/LARGE-FILES/…` 도 받기 처리기에 닿기 때문이다
세 경로는 **대소문자 · `%2F` · 겹 `/` 를 가리지 않고** 같은 설정을 탄다 — 백엔드(Express)의 라우팅이 대소문자를 가리지 않아 `/api/Messages/…/LARGE-FILES/…` 도 받기 처리기에 닿기 때문이다
(nginx 의 대소문자를 가리는 prefix 에 기대면 한도 · 버퍼링 끔을 비켜 간다 — 2026-10-30 리뷰. CI 의 infra 잡 "대용량 받기 경로 시험"이 변형마다 실제 요청으로 본다).
그릇은 nginx 워커마다 `worker_connections` 4096(이미지 기본 1024 에서 올림 — `frontend/Dockerfile`) · frontend 의 열린 파일 수 8192(compose `ulimits`)다 —
받기 600 개(연결 1,200) · 실시간 알림(브라우저마다 연결 둘) · 포털 요청이 워커 하나에 몰려도 모자라지 않게
@@ -1157,6 +1157,45 @@ setup 이 엔진의 `x:DataRetention` 을 맞춘다 — 휴지통 · 스팸메
---
## 휴대폰 앱 올리기 (사내 배포 — 2026-11-08)
휴대폰 앱(안드로이드 APK)은 스토어 없이 포털에서 나눠 준다 — 로그인한 직원이 웹메일 설정 › 계정 › 휴대폰 앱이나 휴대폰의 `<포털>/app` 에서 받는다(계약 1절 "휴대폰 앱 받기").
올리기는 API 가 없고 서버 B 의 운영 도구로 한다. 파일은 업로드 볼륨 `<PROJECT_NAME>_uploads` 의 `app-releases/` 라 매일 [백업](#백업)에 든다.
빌드 · 서명 키는 [flutter/README](../flutter/README.md) — **같은 서명 키로 빌드한 APK 만 덮어 깔린다**(키를 잃으면 직원마다 앱을 지우고 다시 깔아야 한다).
```bash
# 0) 빌드한 PC 에서 서버 B 로 — 서버 A 를 거쳐(관리 화면 SSH 터널과 같은 길)
scp -J <서버 A> ARA-SYSTEM-1.0.0.apk <서버 B>:~/
# 1) 서버 B 의 저장소 폴더에서 — 백엔드 컨테이너의 /tmp 로.
# root 소유로 들어가니 컨테이너의 node 가 읽게 먼저 644 로
export PROJECT_NAME="$(sed -n 's/^PROJECT_NAME=//p' .env.production)"
chmod 644 ~/ARA-SYSTEM-1.0.0.apk
sudo docker cp ~/ARA-SYSTEM-1.0.0.apk "${PROJECT_NAME}-backend:/tmp/"
# 2) 운영 도구 → 12. 휴대폰 앱 올리기 → 경로 /tmp/ARA-SYSTEM-1.0.0.apk
# → 버전(빈 값이면 파일 이름의 1.0.0) → 바뀔 것(버전 · 크기 · SHA-256) y
sudo docker exec -it "${PROJECT_NAME}-backend" node dist/cli/ops.js
# 3) 끝에 보이는 SHA-256 을 빌드한 PC 의 값과 견준다
# (PC 의 PowerShell: certutil -hashfile ARA-SYSTEM-1.0.0.apk SHA256)
# 4) 넣었던 파일 지우기 — 볼륨에 사본이 있다 (root 소유라 -u 0)
sudo docker exec -u 0 "${PROJECT_NAME}-backend" rm /tmp/ARA-SYSTEM-1.0.0.apk
rm ~/ARA-SYSTEM-1.0.0.apk
```
- 올린 순간 직원이 받는 버전이 된다 — **최신 = 가장 늦게 올린 것**(버전 번호가 아니다). 확인은 운영 도구 `13. 휴대폰 앱 목록`(▶ 가 직원이 받는 버전, 볼륨에 파일이 없으면 "파일 없음 — 다시 올리세요").
- 도구가 막는 것: 이름이 `.apk` 가 아님 · zip 머리가 아님 · 1KB 아래 · 200MB 위 · 바로가기(심볼릭 링크) · 읽을 권한 없음(위 1 의 644),
버전이 `숫자.숫자.숫자` 가 아님 · 서버가 받는 가장 낮은 버전(app-config — 지금 1.0.0)보다 낮음. 지금 최신보다 낮거나 같은 버전은 묻고 올린다.
- **되돌리기**: 옛 APK 를 다시 올리면 그것이 최신이 된다. 단 더 높은 버전을 이미 깐 휴대폰은 안드로이드가 낮은 버전으로 덮어 깔지 않는다(앱을 지우고 다시 깔아야 한다 — 로그인도 다시).
그래서 고친 판을 더 높은 버전(pubspec 의 `version` — `+` 뒤의 빌드 번호도)으로 다시 빌드해 올리는 편이 낫다. 옛 판은 지우지 않는다(표 · 볼륨에 남는다).
- **직원 안내** (새 버전을 올린 뒤 — 예시). 꼭 필요한 업데이트는 앱이 알려 준다(업데이트 화면의 [새 버전 받기]):
> 휴대폰 앱 새 버전(1.0.1)을 올렸습니다. PC 웹메일의 [설정] → [휴대폰 앱]에 있는 QR 코드를 휴대폰 카메라로 찍은 뒤, 열린 화면에서 [앱 내려받기]를 눌러 설치해 주세요.
> 앱을 이미 쓰고 계시면 앱의 [설정] → [새 버전 받기]를 눌러도 됩니다.
> 휴대폰이 앱 설치를 허용할지 물으면 허용해 주세요. 쓰던 앱을 지우지 않고 설치하면 로그인과 설정이 그대로 남습니다.
안내에는 **받는 주소 · 링크를 넣지 않는다** — 메뉴 경로만 적는다. "메일의 링크로 APK 를 받아 설치하라"는 가짜 APK 피싱이 노리는 꼴이고,
회사 안내에 링크가 없다는 습관이 링크 달린 가짜를 드러낸다(서버가 넣는 알림 메일의 규칙과 같은 까닭 — `.claude/rules/security.md`).
---
## 파일럿 공존 (mailcow 와 함께 — 한 사람 먼저)
전면 전환 전에 **한 사람(시범 사용자)만** 같은 `@arasystem.it.kr` 주소로 새 서버(`mx1.arasystem.it.kr`)를 쓰고, 나머지 직원은 mailcow 에 그대로 둔다.
@@ -1770,7 +1809,7 @@ mailcow 를 흉내 낸 Dovecot(`dovecot/dovecot:2.3.21.1@sha256:547d28f9a893c93a
| --- | --- | --- |
| 메일 엔진 `<PROJECT_NAME>_mail-config` · `_mail-data` | 볼륨 (읽기 전용으로 붙인다) | RocksDB — 메일 · 계정 · 설정 · DKIM 키 · 인증서 전체. `config.json` 이 없으면 bootstrap 모드로 뜬다 |
| DB | `pg_dump -Fc` (멈추지 않는다) | 로그인 세션 · 서명 · 설정 · 대용량 첨부 기록 등. 덤프를 `pg_restore -l` 로 읽어 본 뒤 올린다 |
| 업로드 `<PROJECT_NAME>_uploads` | 볼륨 | 서명 그림(`mail-signatures/`) · `/api/files` |
| 업로드 `<PROJECT_NAME>_uploads` | 볼륨 | 서명 그림(`mail-signatures/`) · `/api/files` · 휴대폰 앱 APK(`app-releases/` — 판마다 30MB 남짓, 지우는 명령이 없어 올린 판이 모두 남는다) |
| env 파일 | `.env.production` · `backend/.env.production` | 비밀이 든 파일 — 저장소가 restic 비밀번호로 암호화된다. 새 서버를 되살릴 때 필요하다 |
| 받지 않는 것 | — | 대용량 첨부 `_files`(30일짜리 전송물 — 기록은 DB 에 있어 복원하면 바이트가 없는 파일은 "찾을 수 없음"), 악성 검사 서명 `_clamav-db`(다시 받는다), 리졸버 · frontend(상태 없음), 로그 |