# 웹에 띄우기 — 다른 서비스가 부르는 자리 (W442)

> `docs/install.md` 가 **설치**이고 이것은 **띄우기**다. API 의 계약은 `docs/api.md`, 결정은 ADR-0046.
> 서버는 표준 라이브러리뿐인 참조 구현이다(ADR-0020) — 파일 넣기(`/parse`·`/chunk`)·동시 요청·API 키까지는
> 서버가 하고, **TLS 는 앞의 프록시**가 한다.

## 무엇이 필요한가

| 것 | 왜 |
|---|---|
| `NODA_API_KEY` | 없으면 서버가 `127.0.0.1` 밖으로 안 뜬다 (종료 2). 긴 무작위 문자열 하나 — `python -c "import secrets; print(secrets.token_urlsafe(32))"` |
| `NODA_API_KEYS_FILE` | 소비자가 여럿일 때 — `{"챗봇": "키1", "업무자동화": "키2"}` JSON 한 장. 요청마다 다시 읽는다 (키를 바꿔도 서버를 안 내린다). Docker 에서는 `./config/keys.json` 에 두고 `/etc/noda/keys.json` 으로 가리킨다 (compose 가 `./config` 를 읽기 전용으로 건다 · git 이 막는다) |
| `NODA_API_KEYS` | 위와 같은 JSON 을 **환경변수 하나로** (W457) — Coolify 처럼 화면에서 설정을 넣는 곳용. 바꾸면 다시 띄운다. 셋을 같이 줘도 되고 이름이 겹치면 파일이 이긴다 |
| `NODA_RATE_LIMIT` | 소비자마다 분당 요청 상한 (넘으면 429). 안 주면 없음. 소비자가 둘 이상이면 켠다 — 한 서비스의 폭주가 다른 서비스를 굶기지 못하게 |
| `NODA_MAX_PARALLEL` | 서버 전체 동시 파싱 수 (차면 503 · 기다리지 않는다). **코어 수로 두라** — 파싱은 CPU 를 쓰고 스캔 한 장이 수십 초라 넘게 받으면 전부 소켓 시한에 닿는다 |
| `NODA_TRY` | 체험 화면 `/try` 를 연다 (기본 열림 · `0` 이면 404). **키 없이 열리는 유일한 쓰기 길**이라 문이 셋 따로 있다 — 아래 둘과 「한 번에 한 건」(차면 503) |
| `NODA_TRY_RATE` | IP 마다 **한 시간에** 몇 번 (기본 20 · 넘으면 429). IP 는 프록시(사설·루프백) 뒤일 때만 `X-Forwarded-For` 를 믿는다 |
| `NODA_TRY_MAX_UPLOAD` | 체험 파일 상한 (기본 10MB — API 의 100MB 보다 작다) |
| `NODA_TRY_TIMEOUT` | 체험 한 건의 시간 상한 (초 · 기본 60). 체험은 **별도 프로세스**에서 읽고 넘기면 끝내 422 — 큰 스캔 하나가 체험 자리를 몇 분씩 못 붙든다. 스캔은 쪽당 4~8초라 기본값이면 열 쪽 안팎 |
| `NODA_USAGE_LOG` | 주면 요청마다 `{t, consumer, act, status, bytes_in, ms}` 한 줄을 JSON 으로 덧붙인다 — 과금·운영의 바탕. 문서 내용은 없다. 안 주면 `/usage` 의 메모리 셈만 |
| 테넌트 폴더 | `--corpus` 아래 **폴더 하나가 테넌트 하나**. 파일 넣기(`/parse`)만 쓸 거면 빈 폴더여도 된다. **쓰기가 되어야 한다** — `/documents/upload`·`DELETE /documents` 가 여기에 넣고 뺀다 (읽기 전용이면 500 · W452). 고객 문서가 들어오는 자리라 git 이 `/tenants/` 를 막는다 |
| 캐시 자리 | `NODA_CACHE`. 파싱한 **문서 글자**가 들어 있다 — 디스크 권한과 지우기 약속(`docs/pilot.md`)이 여기에 걸린다 |
| TLS | 키가 평문 HTTP 로 가므로 바깥에 열 때는 앞에 HTTPS 프록시를 둔다 (아래 Caddy) |

띄운 뒤에는 **바깥 기계에서** 한 줄로 잰다 — `python tools/smoke_server.py https://<DOMAIN> --key … --tenant 연기` (길마다 상태·ms · 종료 0 이면 0단계 끝 · W450).

## 길 1 — Docker (권한다)

```bash
git clone <저장소> noda && cd noda
cp deploy/.env.example .env            # NODA_API_KEY · DOMAIN 을 채운다 (.env 는 커밋되지 않는다)
mkdir -p tenants/갑                     # 테넌트 폴더 (비어 있어도 된다)
docker compose up -d --build            # noda(8000 · 안에서만) + caddy(80/443 · TLS)
curl -s https://<DOMAIN>/health
```

- `DOMAIN` 이 진짜 도메인이면 Caddy 가 **인증서를 스스로 받는다**(Let's Encrypt · 80/443 이 밖에서 닿아야 한다).
  폐쇄망이면 `DOMAIN=:80` 으로 두고 망 안에서만 쓴다 — 또는 기관의 프록시 뒤에 둔다.
- 이미지에는 **문서(`docs/`)와 점수판(`eval_results/latest.json`)이 함께 든다** — 홈 화면의 문서 링크와 정확도 숫자가 거기서 온다 (W455).
  숫자는 **이미지를 만든 때의 점수판**이므로 엔진을 고쳐 점수판이 바뀌면 이미지를 다시 만든다 (`docker compose up -d --build`).
- 이미지는 만들 때 **한국어 OCR 모델을 한 번 받아 둔다**(`Dockerfile` 의 warm-up) — 띄운 뒤에는 바깥이 없어도 스캔을 읽는다.
  이미지 만들기만 인터넷이 필요하다. 폐쇄망에는 만든 이미지를 `docker save | docker load` 로 옮긴다.
- 서버 혼자 띄우려면: `docker run -d -p 127.0.0.1:8000:8000 -e NODA_API_KEY=… -v $PWD/tenants:/srv/corpus:ro -v noda-cache:/var/lib/noda noda`

## 길 2 — 리눅스 서버 한 대 (systemd)

```bash
sudo useradd -r -s /usr/sbin/nologin noda
sudo git clone <저장소> /opt/noda && cd /opt/noda
sudo python3.11 -m venv .venv && sudo .venv/bin/pip install -e ".[parse,ocr,gateway]"
sudo mkdir -p /srv/noda/corpus /var/lib/noda /etc/noda && sudo chown -R noda:noda /srv/noda /var/lib/noda
echo 'NODA_API_KEY=…' | sudo tee /etc/noda/noda.env && sudo chmod 600 /etc/noda/noda.env
sudo cp deploy/noda.service /etc/systemd/system/ && sudo systemctl daemon-reload && sudo systemctl enable --now noda
curl -s localhost:8000/health
```

`deploy/noda.service` 는 `127.0.0.1:8000` 에 띄운다 — 바깥은 nginx·Caddy 같은 프록시가 TLS 와 함께 연다. nginx 라면:

```
location / { proxy_pass http://127.0.0.1:8000; client_max_body_size 100m; proxy_read_timeout 300s; }
```

## 길 3 — Coolify (서버 관리 인력이 없을 때 · W457)

[Coolify](https://coolify.io)(오픈소스 · Apache-2.0)는 서버 한 대에 깔아 **웹 화면으로** 배포·HTTPS·환경변수·로그·재시작·백업을 한다.
NODA 만 띄울 거면 길 1 로 충분하다 — **우리 서비스 여럿을 한 서버에서 함께 굴릴 때** 값이 난다.
설정은 저장소의 `docker-compose.coolify.yml` 하나다. 길 1 의 compose 와 다른 점은 셋이다 — **Caddy 가 없고**(Coolify 의 프록시가 TLS 를 한다 ·
둘을 같이 띄우면 80/443 에서 부딪힌다) · **이름 붙은 볼륨만** 쓰고 · 소비자 키를 파일이 아니라 **`NODA_API_KEYS`(JSON 한 줄)**로 받는다.

1. **서버** — 리눅스(우분투 LTS) VM **4 vCPU · 4GB · 50GB 로 시작**하고 아래 「늘릴 때」의 신호가 보이면 8GB 로 올린다 (W458 · 사람이 정했다).
   Coolify 문서의 최소 사양은 2코어·2GB 지만 **같은 사양으로 다른 서비스를 문제없이 굴리고 있다는 운영 경험**이 있다 — 문서의 최소치는 실제 사용량보다 크다.
   NODA 의 몫은 평소 0.1GB · **스캔 한 건이 읽는 동안 최대 0.9GB** 라(10쪽 스캔 실측) 4GB 에서는 `NODA_MAX_PARALLEL` 2(compose 가 미리 채운다)와 **스왑 2~4GB** 를 둔다.
   공공기관 문서를 다루면 그 기관이 쓸 수 있는 국내 클라우드(CSAP)인지 먼저 본다.
2. **설치** — 서버에서 Coolify 문서의 설치 명령 한 줄(root). 처음 화면에서 관리자 계정을 만들고 **2단계 인증을 켠다**.
   관리 화면(8000 포트)은 서버 전체를 다루므로 **방화벽에서 사무실 IP 로만** 연다.
3. **배포** — 새 리소스 → 이 저장소(비공개면 GitHub 앱 연결) → Build Pack **Docker Compose** →
   Docker Compose Location `/docker-compose.coolify.yml`.
4. **도메인** — Domains 칸에 `https://<도메인>:8000` (뒤의 `:8000` 은 컨테이너 포트다 · DNS 의 A 레코드를 서버로).
5. **환경변수** — 화면에 칸이 저절로 생긴다. `NODA_API_KEY` 는 **꼭 채울 칸**이라 비어 있으면 배포가 멈춘다.
   소비자가 여럿이면 `NODA_API_KEYS` 에 `{"챗봇": "키1", "업무자동화": "키2"}` — **바꾸면 Restart** 한다(환경변수라서 · 키 파일과 달리 저절로 안 읽는다).
   `NODA_MAX_PARALLEL` 은 2 로 미리 채워진다 — vCPU 에 맞춘다.
6. **업로드 시간** — Coolify 의 기본 프록시 Traefik 은 **요청 본문을 60초 안에 다 받아야 한다**(`readTimeout` 기본 60s).
   100MB 를 60초에 올리려면 약 14Mbps 가 필요해 느린 망에서는 큰 파일이 끊긴다. 서버 → Proxy 설정의 명령 줄에
   `--entrypoints.https.transport.respondingTimeouts.readTimeout=300s` 를 더하고 **Restart Proxy** (진입점 이름은 그 설정에 적힌 것을 따른다).
   **응답 쪽은 안 묶인다** — Traefik 의 `writeTimeout` 기본은 0(없음)이라 쪽 수 많은 스캔이 5분 걸려도 끊기지 않는다.
7. **확인** — 바깥 기계에서 `python tools/smoke_server.py https://<도메인> --key … --tenant 연기` 가 종료 0 이면 끝이다.

**운영**: 저장소에 밀면 화면에서 Redeploy(또는 자동 배포) · 잘못되면 이전 배포로 되돌린다 · 로그와 재시작은 리소스 화면.
**지킬 것**: `noda-tenants`(고객 문서)와 `noda-cache`(파싱한 글자)는 다시 배포해도 남는 볼륨이다 — **백업 대상에 넣고, 지우기 약속(`docs/pilot.md`)도 여기에 걸린다**.
체험 화면의 IP 상한은 프록시 뒤에서도 선다 — Traefik 이 사설 주소에서 `X-Forwarded-For` 를 붙여 오고 서버는 그 경우에만 그 머리글을 믿는다.
**폐쇄망 고객**은 Coolify 를 안 쓴다 — 만든 이미지를 옮겨 길 1·2 로 띄운다.

**늘릴 때** — 짐작하지 말고 이 셋을 본다. 하나라도 되풀이되면 메모리를 8GB 로 올린다(클라우드 콘솔에서 사양 변경 · 재부팅 · 볼륨은 남는다).
- 컨테이너가 메모리 부족으로 꺼졌다 — 서버에서 `docker inspect -f '{{.State.OOMKilled}}' <noda 컨테이너>` 가 `true` · 또는 Coolify 화면에 재시작이 잦다.
- 스왑을 늘 쓴다 — `free -h` 의 Swap used 가 쉴 때도 수백 MB 넘게 남아 있다 (파싱이 느려진다).
- `503`(동시 파싱이 참)이 잦다 — `GET /usage` 의 `errors` 가 늘고 사용량 기록(`/var/lib/noda/usage.jsonl` · Coolify compose 가 켠다)에 `"status": 503` 줄이 잦다. 이것은 메모리보다 **CPU** 의 신호라 vCPU 를 늘리고 `NODA_MAX_PARALLEL` 도 함께 올린다.

## 부르는 쪽에서

```bash
curl -s https://<DOMAIN>/parse -H "Authorization: Bearer $NODA_API_KEY" \
     -H 'X-Filename: %EA%B3%B5%EA%B3%A0.hwpx' --data-binary @공고.hwpx
```

- `/health` 가 `endpoints`·`formats`·`max_upload`·`auth` 를 낸다 — 연결 확인과 어떤 포맷을 보낼 수 있는지는 거기서 본다.
- 못 읽는 파일도 **200** 이고 JSON 의 `access.readable=false · reason` 이 까닭이다. **4xx 는 요청이 틀렸을 때**(이름 없음 · 길이 없음 411 ·
  상한 초과 413 · 키 401)뿐이다.
- 파일 하나에 글자층이면 1초 안팎, 스캔이면 쪽마다 수십 초다 (OCR). 부르는 쪽의 타임아웃을 **5분**으로 두는 것이 안전하다 — 프록시의
  `read_timeout` 도 같이.
- 동시에 여러 파일을 보내도 서로 안 기다리지만 **파싱은 CPU 를 쓴다** — 처리량이 필요하면 컨테이너를 여럿 띄우고 프록시가 나눈다.

## 운영

- **로그**: 서버는 표준 출력에 한 줄씩 쓴다 (`docker compose logs -f noda` · `journalctl -u noda -f`). 파일 내용은 로그에 안 쓴다.
- **지우기**: `/parse` 로 받은 파일은 **디스크에 남기지 않는다**(메모리에서 읽고 버린다). 테넌트 폴더의 문서는 캐시에 글자가 남으므로
  문서를 지울 때 `tools/retention.py purge` 로 캐시까지 지운다.
- **바꾸기**: `git pull` 뒤 `docker compose up -d --build` 또는 `systemctl restart noda`. 파서가 바뀌면 캐시는 저절로 새 세대로 간다 (ADR-0019).
- **멈추기**: SIGTERM 에 받던 요청을 끝내고 내려온다. `docker stop` · `systemctl stop` 이 그 신호다.

## 안 하는 것 — 프록시·감싸는 층의 몫

TLS · 요청 제한 · 감사 로그 · 사용자별 권한 · 여러 프로세스. 다 **감싸는 층**(프록시 또는 고객 스택)에서 한다 (규칙 9 · ADR-0046).
