# 매칭 엔진 배포 런북 (운영 app-01 / db-01)

작성 2026-09-10 · MR !44 · 브랜치 `feat/matching-engine-v2`

CI 등록과 systemd 유닛은 MR 에 포함돼 있다. **아래는 CI 가 하지 못하는 서버 작업**이고,
전부 사람이 승인·실행해야 한다. 순서를 지킬 것 — ④(DDL)를 ⑤(배포)보다 먼저 해야 한다. 거꾸로 하면 엔진이 기동에 실패하고 스케줄러 잡이 오류를 쏟는다.

## 사전 확인 (2026-09-10 실측)

| 항목 | 상태 |
|---|---|
| app-01 포트 8085 | 비어 있음 (8081 open-api · 8083 scheduler · 8084 matching-worker) |
| app-01 메모리 | 16GB 중 가용 13.5GB — `-Xmx512m` 여유 |
| app-01 디스크 | 40GB 중 25GB 여유 |
| db-01 백업 | 정상. 매일 03:00 크론, 최신 `cryptoments_db_20260910_030001.sql.gz` (28MB, sha256 동반) |
| P2P 업무 | 미사용 (2026-09-10 확인) — 배포 시점 업무 영향 없음 |
| nginx `/internal/` | ☠️ **차단 없음.** `location / { proxy_pass 8081 }` catch-all 이 외부 요청을 앱까지 전달한다. ⓪ 참조 |
| app-01 포트 외부 노출 | ☠️ **8081(open-api) · 8083(scheduler) 이 인터넷에서 직접 열려 있다** (2026-09-10 실측). 8084·8085 는 막혀 있다. 즉 nginx 차단은 경계가 아니다 — `http://103.213.248.125:8081/internal/...` 로 우회된다. ⓪-A 참조 |

---

## ⓪ `/internal/` 외부 차단 — ☠️ 다른 무엇보다 먼저

대상: /etc/nginx/sites-enabled/api.cryptoments.cc (sites-enabled 에 이 파일 하나뿐,
8081 로 프록시하는 유일한 사이트)

### ⓪-A ☠️ nginx 만으로는 경계가 되지 않는다 (2026-09-10 실측)

app-01 은 **8081(open-api)과 8083(scheduler)을 인터넷에 직접 노출**하고 있다. 즉 아래 nginx
블록을 넣어도 `http://103.213.248.125:8081/internal/matching/p2p-liquidity/...` 로 그냥 우회된다.
8084·8085 는 막혀 있다.

따라서 ⓪ 은 **두 가지를 다 해야** 한다.

1. 보안그룹에서 8081·8083 인바운드를 닫는다(외부에서 직접 붙을 수 없게). 확인:
   `curl --max-time 5 http://103.213.248.125:8081/` 가 타임아웃/거부
2. 아래 nginx 정규식 차단을 넣는다(리버스 프록시 경유 경로 차단)

그리고 이 둘도 임시 조치다. `/internal/matching/p2p-liquidity/*` 에는 **애플리케이션 인증이 전혀
없고**(핸드오프 B-1), 커밋 응답에는 판매자 은행계좌가 실린다. 망 통제만으로 자금 경로를 지키는
상태를 오래 두지 말 것.

### 근거
- open-api 의 /internal/ 경로는 2개: /internal/p2p/telegram, /internal/matching/p2p-liquidity
- nginx access log 전체에서 정상 /internal/ 호출 0건.
  57건 중 49건이 .env/.git/config 를 노린 스캐너, 2건은 확인용 요청
- 즉 끊을 정상 트래픽이 없다

### 영향
- 외부 → /internal/* : 403 (현재는 앱까지 도달해 404)
- localhost → 127.0.0.1:8081/internal/* : 영향 없음 (nginx 를 타지 않음)
- 그 외 모든 경로: 영향 없음

### ☠️ prefix location 은 우회된다 — 정규식으로, 그리고 따옴표로

`location /internal/ { return 403; }` 는 **prefix 매칭이라 `/internal;x/matching/...` 로 뚫린다.**
nginx 는 `;` 를 정규화하지 않고 Spring PathPattern 은 `internal;x`(matrix 변수)를 `internal` 로
읽는다. 2026-09-10 운영에서 실제로 우회를 확인했다.

정규식 location 으로 바꿔야 하는데, **리뷰 문서에 적혀 있던 아래 형태는 `nginx -t` 를 통과하지
못한다** — nginx 설정 파서에서 `;` 가 디렉티브의 끝이라 정규식 중간에서 문장이 끊긴다.

```nginx
location ~ ^/internal[/;] { return 403; }   # ✗ nginx -t 실패
```

동작하는 형태는 정규식을 **따옴표로 감싸는 것**이다. 2026-09-10 운영에 적용된 형태:

```nginx
location ~ "^/internal[/;]" { return 403; }   # ✓
```

---

### 1. 백업

    ssh crs-bastion "ssh app-01 'sudo cp /etc/nginx/sites-enabled/api.cryptoments.cc \
      /etc/nginx/sites-enabled/api.cryptoments.cc.bak-\$(date +%Y%m%d-%H%M%S) && \
      sudo ls -la /etc/nginx/sites-enabled/'"

### 2. 블록 삽입 ( location / 바로 앞 )

    ssh crs-bastion "ssh app-01 'sudo python3 - <<PY
import io
p=\"/etc/nginx/sites-enabled/api.cryptoments.cc\"
s=io.open(p,encoding=\"utf-8\").read()
assert \"^/internal\" not in s, \"이미 적용됨\"
anchor=\"    location / {\"
assert s.count(anchor)==1, \"앵커가 1개가 아님\"
block=(\"    # 내부 전용 경로 — 외부 노출 금지 (2026-09-10)\\n\"
       \"    #   /internal/p2p/telegram        : 봇 → 백엔드\\n\"
       \"    #   /internal/matching/p2p-liquidity : 매칭 엔진 → Core. 판매자 유동성을\\n\"
       \"    #     예약·커밋·해제하고 커밋 응답에 판매자 계좌가 실린다. 애플리케이션 인증이\\n\"
       \"    #     없다. 단 이 차단만으로는 부족하다 — 8081 이 인터넷에 직접 열려 있으면 그냥\\n\"
       \"    #     우회된다. 보안그룹 인바운드 차단과 함께여야 한다(런북 ⓪-A).\\n\"
       \"    #   호출자는 모두 같은 호스트에서 127.0.0.1:8081 로 직접 붙으므로 nginx 를 타지 않는다.\\n\"
       \"    #   ☠️ prefix 가 아니라 정규식이다. location /internal/ 은 /internal;x/... 로 우회된다.\\n\"
       \"    #      정규식을 따옴표로 감싸지 않으면 ; 가 디렉티브를 끊어 nginx -t 가 실패한다.\\n\"
       \"    location ~ \\\"^/internal[/;]\\\" {\\n\"
       \"        return 403;\\n\"
       \"    }\\n\\n\")
io.open(p,\"w\",encoding=\"utf-8\").write(s.replace(anchor, block+anchor,1))
print(\"삽입 완료\")
PY'"

### 3. 문법 검사 → 통과해야만 reload

    ssh crs-bastion "ssh app-01 'sudo nginx -t && sudo systemctl reload nginx && echo RELOADED'"

### 4. 검증

    # 둘 다 403 이어야 함 (적용 전엔 404 — 두 번째는 prefix 차단만으로는 계속 404 다)
    curl -s -o /dev/null -w "internal:  %{http_code}\n" https://api.cryptoments.cc/internal/.env
    curl -s -o /dev/null -w "bypass:    %{http_code}\n" \
      "https://api.cryptoments.cc/internal;x/matching/p2p-liquidity/candidates?deposit_cross_group_allowed=true"

    # 기존 경로는 그대로여야 함
    curl -s -o /dev/null -w "docs:      %{http_code}\n" https://api.cryptoments.cc/docs/
    curl -s -o /dev/null -w "root:      %{http_code}\n" https://api.cryptoments.cc/

    # ⓪-A: nginx 를 우회하는 직접 접속이 막혔는지. 타임아웃/거부여야 한다
    curl -s --max-time 5 -o /dev/null -w "8081 direct: %{http_code}\n" http://103.213.248.125:8081/ || echo "8081 direct: blocked"
    curl -s --max-time 5 -o /dev/null -w "8083 direct: %{http_code}\n" http://103.213.248.125:8083/ || echo "8083 direct: blocked"

### 롤백

    ssh crs-bastion "ssh app-01 'sudo cp /etc/nginx/sites-enabled/api.cryptoments.cc.bak-<타임스탬프> \
      /etc/nginx/sites-enabled/api.cryptoments.cc && sudo nginx -t && sudo systemctl reload nginx'"

---

## ① 로그 디렉터리 생성

유닛이 `ProtectSystem=strict` 이므로 앱이 쓰는 경로는 `ReadWritePaths` 에 있어야 하고,
디렉터리가 실제로 존재해야 한다.

```bash
ssh crs-bastion "ssh app-01 '
  mkdir -p /opt/cryptoments/matching-engine/logs &&
  chown -R ubuntu:ubuntu /opt/cryptoments/matching-engine &&
  ls -ld /opt/cryptoments/matching-engine /opt/cryptoments/matching-engine/logs'"
```

**영향** 없음 (신규 경로). **롤백** `rm -rf /opt/cryptoments/matching-engine`

## ② 환경 파일 생성

☠️ **이 파일이 없으면 엔진은 기동하지 못한다.** `application.yml` 에 자격증명을 담지 않기 때문이다.

> 이 문장은 **2026-09-10 리뷰(M3) 수정 이후부터** 사실이다. 그 전까지 유닛에는
> `EnvironmentFile=-/opt/...` 처럼 앞에 대시가 있었고, 대시는 "파일이 없어도 그냥 기동" 이라는
> 뜻이다. 파일이 없으면 systemd 는 조용히 통과시키고 엔진은 `application.yml` 의 개발용
> 기본값(`localhost:3306`, `root`, 빈 비밀번호)으로 떴다. 대시는 제거됐다 —
> `infra/systemd/cryptoments-matching-engine.service` 에 대시가 다시 붙지 않았는지 ③ 에서 확인할 것.

기존 `.env.matching-worker` 와 같은 형식이고 DB 접속 정보도 동일하다. 아래는 **키 목록**이며,
값은 `.env.matching-worker` 에서 그대로 가져온다(같은 DB·같은 스키마).

```
SPRING_PROFILES_ACTIVE=prod
SERVER_PORT=8085
SERVER_ADDRESS=127.0.0.1
SPRING_DATASOURCE_URL=<.env.matching-worker 와 동일>
SPRING_DATASOURCE_USERNAME=<동일>
SPRING_DATASOURCE_PASSWORD=<동일>
LOGGING_LEVEL_ROOT=INFO
LOGGING_LEVEL_COM_CRYPTOMENTS=INFO
```

☠️ `SERVER_ADDRESS=127.0.0.1` 를 빠뜨리지 말 것. 엔진의 `POST /internal/matching/execution-reg-events`
에도 애플리케이션 인증이 없고, 지정하지 않으면 0.0.0.0 에 바인딩된다. 호출자는 전부 같은 호스트다.

`MATCHING_CORE_LIQUIDITY_BASE_URL` 은 **처음에는 넣지 않는다.** 없으면 어댑터가 배선되지
않고 fallback 이 `CORE_LIQUIDITY_NOT_CONFIGURED` 를 돌려주어, 엔진은 정상 기동하고 P2P 만
비활성이다. 기동 검증이 끝난 뒤 아래를 추가하고 재시작하면 P2P 가 켜진다.

> 이 단계적 롤아웃도 **2026-09-10 리뷰(H2) 수정 이후부터** 성립한다. 그 전에는
> `application.yml` 에 `base-url: ${MATCHING_CORE_LIQUIDITY_BASE_URL:http://localhost:8081}` 이
> 있어 환경변수가 없어도 키가 항상 존재했고, `@ConditionalOnProperty` 는 값이 아니라 키의 존재를
> 보므로 어댑터가 **항상** 배선됐다 — fallback 은 한 번도 선택되지 않았다. 이제 yml 에 그 키가
> 없다. 빈 값(`MATCHING_CORE_LIQUIDITY_BASE_URL=`)을 넣는 것도 "설정함" 으로 취급되니
> **끄려면 줄 자체를 넣지 말 것.** (회귀 방지: `MatchingEngineContextLoadTest`)

```
MATCHING_CORE_LIQUIDITY_BASE_URL=http://localhost:8081
```

☠️ **반드시 `localhost`.** `/internal/matching/*` 에 애플리케이션 인증이 없어서
localhost 를 벗어나면 예약·커밋·해제가 망에 노출된다.

생성 후 권한을 기존 파일과 맞춘다.

```bash
ssh crs-bastion "ssh app-01 '
  chmod 644 /opt/cryptoments/config/.env.matching-engine &&
  chown ubuntu:ubuntu /opt/cryptoments/config/.env.matching-engine &&
  sed -E \"s/=.*/=<masked>/\" /opt/cryptoments/config/.env.matching-engine'"
```

**롤백** `rm /opt/cryptoments/config/.env.matching-engine`

## ③ systemd 유닛 설치

유닛 본문은 `infra/systemd/cryptoments-matching-engine.service` (MR 에 포함).

```bash
# 레포에서 서버로 전달한 뒤
ssh crs-bastion "ssh app-01 '
  sudo cp /tmp/cryptoments-matching-engine.service /etc/systemd/system/ &&
  sudo systemctl daemon-reload &&
  sudo systemctl enable cryptoments-matching-engine &&
  systemctl is-enabled cryptoments-matching-engine'"
```

설치 후 대시가 없는지 확인한다(② 참조 — 대시가 있으면 env 파일 없이도 기동한다):

```bash
ssh crs-bastion "ssh app-01 'grep EnvironmentFile /etc/systemd/system/cryptoments-matching-engine.service'"
# 기대: EnvironmentFile=/opt/cryptoments/config/.env.matching-engine   (앞에 - 없음)
```

아직 **start 하지 않는다** — JAR 이 없다. CI 배포(⑤)가 올린 뒤 자동으로 start 된다.

**영향** 없음 (신규 유닛). **롤백**
`sudo systemctl disable --now cryptoments-matching-engine && sudo rm /etc/systemd/system/cryptoments-matching-engine.service && sudo systemctl daemon-reload`

## ④ 운영 DB 스키마 적용

`v2-docs/migrations/2026-09-04-matching-engine-v2.sql` — **`CREATE TABLE` 10개뿐**이고
`DROP`/`ALTER`/`DELETE`/`TRUNCATE` 가 0건이다. 레거시 테이블을 건드리지 않는다.
개발 DB 에서 검증 완료(106 → 116, 레거시 P2P 행 수 불변).

☠️ **이 마이그레이션을 이미 적용한 환경이라면** `matching_queue.requeue_requested_at` 컬럼이
없다(2026-09-10 리뷰 M5 에서 추가됨). 엔진은 기동 시점에 이 컬럼을 확인하고 없으면 실패한다.
그런 환경에서는 아래를 한 번 실행한다 — 신규 컬럼이고 NULL 허용이라 기존 행에 영향이 없다.

```sql
ALTER TABLE matching_queue ADD COLUMN requeue_requested_at DATETIME(6) NULL AFTER attempts;
```

확인:

```sql
SHOW COLUMNS FROM matching_queue LIKE 'requeue_requested_at';
```

적용 전 백업 확인 (오늘 03:00 백업이 있으면 별도 덤프는 불필요):

```bash
ssh crs-bastion "ssh db-01 'sudo ls -la /data/backups/mysql/daily/ | tail -3'"
```

적용:

```bash
ssh crs-bastion "ssh db-01 'mysql -u cryptoments -p\"<PW>\" cryptoments_db < /tmp/2026-09-04-matching-engine-v2.sql'"
```

검증 — 10개가 생겼고 레거시가 그대로인지:

```bash
ssh crs-bastion "ssh db-01 'mysql -u cryptoments -p\"<PW>\" cryptoments_db -e \"
SELECT COUNT(*) AS v2_tables FROM information_schema.tables
 WHERE table_schema=DATABASE()
   AND (table_name LIKE \\\"matching_s%\\\" OR table_name LIKE \\\"matching_q%\\\"
     OR table_name LIKE \\\"matching_e%\\\" OR table_name LIKE \\\"matching_l%\\\"
     OR table_name=\\\"p2p_liquidity_reservations\\\");
SELECT table_name, table_rows FROM information_schema.tables
 WHERE table_schema=DATABASE()
   AND table_name IN (\\\"p2p_withdraw_orders\\\",\\\"p2p_deposit_orders\\\",\\\"p2p_matches\\\",\\\"p2p_withdraw_entries\\\");\""
```

**롤백** — additive 라 깨끗하다. 엔진이 정지한 상태에서:

```sql
DROP TABLE IF EXISTS matching_session_terminal_outbox, matching_execution_regs,
  matching_leg_inbound_events, matching_session_targets, matching_session_legacy_orders,
  matching_session_customers, p2p_liquidity_reservations, matching_events,
  matching_queue, matching_sessions;
```

☠️ 엔진이 도는 중에 DROP 하지 말 것 — 큐 워커가 250ms 마다 `matching_queue` 를 폴링한다.

## ⑤ CI 배포 실행

GitLab Pipelines → `spring:deploy-production` ▶ (수동)

배포 스크립트가 JAR 을 `/opt/cryptoments/matching-engine/` 로 rsync 하고,
`.env` 심볼릭 링크를 걸고, 서비스를 start 한다.

☠️ 파이프라인이 green 이어도 배포된 것이 아니다 — 배포 잡은 `allow_failure: true` 다.

## ⑤-A 배포 직후 확인 — ☠️ 이 배포가 검증하는 것은 「안 바뀌었다」이다

엔진 코드가 들어가는 배포지만 엔진은 **유휴**다. 그래서 확인의 목적이 뒤집힌다 —
「새 기능이 도는가」가 아니라 **「지금 돌던 V1 이 그대로 도는가」**다. 잠든 코드를 올리는
배포에서 유일하게 위험한 것은 잠들지 않은 부분이다.

배포 잡은 6개 서비스를 **전부 재시작**한다(app-01: open-api·scheduler·matching-worker·
matching-engine / partner: admin-api·partner-api). 즉 **운영 중인 V1 워커와 open-api 가 내려갔다
올라온다.** 그 사이 생성된 입금 주문은 PENDING 으로 남고, 워커가 올라오면 이어서 소비된다.

### 1. 배포가 실제로 일어났는가 — ☠️ green 은 증거가 아니다

`spring:deploy-production` 은 `allow_failure: true` 다. **미실행이어도 파이프라인은 green** 이고,
실행 후 일부 서비스가 `SKIP` 돼도 잡은 성공으로 끝난다. JAR 의 시각을 본다.

```bash
ssh crs-bastion "ssh app-01 'ls -l --time-style=+%F\ %T /opt/cryptoments/*/[a-z]*.jar | grep -v plain'"
ssh crs-bastion "ssh partner 'ls -l --time-style=+%F\ %T /opt/cryptoments/*/[a-z]*.jar | grep -v plain'"
```

- 기대: 6개 `*.jar` 의 시각이 **방금**이고, 같은 디렉터리에 `*.jar.prev` 가 생겨 있다
- `.jar.prev` 가 곧 **롤백 레버**다(§6)

### 2. 6개가 다 올라왔는가

```bash
ssh crs-bastion "ssh app-01 'systemctl is-active cryptoments-open-api cryptoments-scheduler \
  cryptoments-matching-worker cryptoments-matching-engine;
  curl -sf localhost:8081/health >/dev/null && echo open-api-OK || echo open-api-NG;
  curl -sf localhost:8084/actuator/health >/dev/null && echo worker-OK || echo worker-NG;
  curl -sf localhost:8085/actuator/health >/dev/null && echo engine-OK || echo engine-NG'"
ssh crs-bastion "ssh partner 'systemctl is-active cryptoments-admin-api cryptoments-partner-api;
  curl -sf localhost:8080/health >/dev/null && echo admin-OK || echo admin-NG;
  curl -sf localhost:8082/health >/dev/null && echo partner-OK || echo partner-NG'"
```

> scheduler(8083)의 `/health` 500 은 엔드포인트 부재 탓이며 정상이다 — `is-active` 로 본다.
> worker·engine 은 `/health` 매핑이 없고 actuator 만 연다.

### 3. ☠️ V1 매칭이 계속 도는가 — **가장 중요한 항목**

서비스가 떴다는 것은 아무것도 증명하지 않는다. 워커가 다시 **집고 있는지**를 본다.

```bash
# ① 워커 폴 재개
ssh crs-bastion "ssh app-01 'sudo journalctl -u cryptoments-matching-worker --since \"5 min ago\" --no-pager | tail -30'"

# ② 주문이 흐르는가 — PENDING 이 쌓이기만 하지 않는가
ssh crs-bastion "ssh db-01 'mysql -u cryptoments -p\"<pw>\" cryptoments_db -e \"
  SELECT status, COUNT(*) n, MIN(created_at) oldest, MAX(created_at) newest
    FROM p2p_deposit_orders WHERE created_at > NOW() - INTERVAL 60 MINUTE GROUP BY status;\"'"
```

☠️ **판정 기준은 `PENDING` 의 `oldest`** 다. 재시작 직후 잠깐 쌓이는 것은 정상이고,
그 값이 **계속 늙어 가면** 소비자가 없는 것이다. 몇 분 뒤 다시 조회해 `oldest` 가 최신으로
움직이는지 본다 — 한 번의 스냅샷으로는 판정할 수 없다.

### 4. 엔진이 **여전히 유휴**인가 — 켜진 줄 모르고 켜지면 안 된다

```bash
ssh crs-bastion "ssh app-01 '
  grep -E \"MATCHING_CORE_LIQUIDITY_BASE_URL|P2P_ASYNC_MATCHING_ENABLED\" /opt/cryptoments/config/.env.matching-engine || echo \"(둘 다 없음 = 유휴, 정상)\";
  sudo journalctl -u cryptoments-matching-engine --since \"5 min ago\" --no-pager | grep -icE \"intake|인그레스\" '"
ssh crs-bastion "ssh db-01 'mysql -u cryptoments -p\"<pw>\" cryptoments_db -e \"
  SELECT COUNT(*) new_sessions FROM matching_sessions WHERE created_at > NOW() - INTERVAL 60 MINUTE;\"'"
```

기대: 두 키 모두 **없음**, 인그레스 로그 **0**, 신규 세션 **0건**.
하나라도 어긋나면 컷오버가 의도치 않게 시작된 것이다 — §⑧ 롤백.

### 5. 바뀐 단 하나의 라이브 동작 — 위젯 취소

`P2pWidgetController.cancelOrder` 가 엔진에 세션 취소를 먼저 묻는 경로로 바뀌었다. 다만
`MatchingEngineDecisionClient` 는 `@ConditionalOnProperty(matching.engine.base-url)` 이고 그 키는
yml 에도 `.env.open-api` 에도 없으므로 **빈이 만들어지지 않고 동작은 종전과 같다.** 확인한다.

```bash
ssh crs-bastion "ssh app-01 '
  grep -c MATCHING_ENGINE_BASE_URL /opt/cryptoments/config/.env.open-api;   # 기대 0
  sudo journalctl -u cryptoments-open-api --since \"10 min ago\" --no-pager | grep -i MatchingEngineDecisionClient | head'"
```

기대: `0`, 그리고 클라이언트 관련 로그 **없음**. 그 뒤 **위젯에서 실제 취소 한 건**을 눌러 본다 —
로그와 설정이 맞아도 그것이 사용자 경로를 증명하지는 않는다.

### 6. 새로 열린 것이 없는가

```bash
ssh crs-bastion "ssh app-01 'ss -lntp | grep -E \":808[0-9]\"'"
curl -s -o /dev/null -w '%{http_code}\n' 'https://<운영도메인>/internal;x/matching/sessions'   # 403 기대
```

☠️ `*:8085`(0.0.0.0)는 **이미 알려진 상태**다 — `SERVER_ADDRESS` 누락(⑦-0). 이 배포로 나빠지지는
않지만, 컷오버 **전에** 반드시 고친다. 컷오버 순간부터 그 포트는 자금 경로가 된다.

### 중단·롤백 — 위 1~5 중 하나라도 어긋나면

```bash
# 서비스 하나만 되돌린다 (예: open-api)
ssh crs-bastion "ssh app-01 'cd /opt/cryptoments/open-api && cp open-api.jar.prev open-api.jar &&
  sudo systemctl restart cryptoments-open-api'"
```

☠️ **`.jar.prev` 는 다음 배포가 덮어쓴다.** 되돌릴 생각이 있으면 그 전에 한다.

---

## ⑥ 검증

```bash
ssh crs-bastion "ssh app-01 '
  systemctl is-active cryptoments-matching-engine
  curl -sf http://localhost:8085/actuator/health; echo
  ss -ltn | grep 8085
  journalctl -u cryptoments-matching-engine -n 50 --no-pager | grep -E \"Started|MatchingQueueSchemaCheck|ERROR|Caused by\"
  journalctl -u cryptoments-matching-engine --no-pager | grep -c ERROR'"
```

기대: `active` · `{"status":"UP"}` · 8085 리슨 · `Started MatchingEngineApplication` ·
`[MatchingQueueSchemaCheck] 스키마 확인 완료` · **ERROR 개수 0**.

☠️ `health` 가 UP 인 것만으로는 아무것도 증명되지 않는다 — 헬스는 커넥션 `isValid()` 만 본다.
스키마가 어긋나면 엔진은 기동 시점에 `MatchingQueueSchemaCheck` 로 죽는다(그게 정상 동작이다).
`systemctl is-active` 가 `activating`/`failed` 를 반복하면 ④ 를 건너뛴 것이니 ④ 부터 다시 한다.

기존 서비스가 영향받지 않았는지도 함께 본다.

```bash
ssh crs-bastion "ssh app-01 '
  curl -sf http://localhost:8081/health >/dev/null && echo open-api OK || echo open-api NG
  curl -sf http://localhost:8084/actuator/health >/dev/null && echo matching-worker OK || echo matching-worker NG
  free -m | head -2'"
```

## ⑦ 컷오버 — V1 워커 → V2 엔진 (2026-09-11 실측)

⓪~⑥ 은 **엔진을 설치하고 유휴로 띄우는** 절차다. 이 절은 그 엔진에 **트래픽을 넘기는** 별도
작업이다. 하루 뒤에 해도 되고, 여기부터 되돌리면 엔진은 다시 유휴가 된다.

### ☠️ 출발점 — 비동기 매칭은 **이미 켜져 있다**

컷오버를 「비동기 매칭을 켜는 일」로 읽으면 틀린다. 운영은 이미 그렇게 돌고 있다
(2026-09-11 실측).

| 파일 | `P2P_ASYNC_MATCHING_ENABLED` | 뜻 |
|---|---|---|
| `app-01:.env.open-api` | `true` | 입금 주문을 PENDING 으로 큐잉 |
| `partner:.env.partner-api` | `true` | 〃 |
| `app-01:.env.matching-worker` | `true` | **V1 워커가 그 PENDING 을 집는다** |
| `app-01:.env.matching-engine` | *(없음)* | 엔진 인그레스 유휴 |

바꾸는 것은 **소비자뿐**이다. `open-api`·`partner-api` 는 **건드리지 않는다.**

> 같은 이름의 환경변수가 세 서비스에서 서로 다른 값을 가져야 하지만, `.env.<서비스>` 가
> 분리돼 있어 충돌하지 않는다. 값이 서비스마다 다른 **하나의** 스위치라는 설계다
> (`MatchingIntakeProperties` javadoc).

### ⑦-0 선행 — 두 줄이 빠져 있다 (실측)

컷오버 전에 반드시 고친다. 둘 다 `.env.matching-engine` 이다.

```
SERVER_ADDRESS=127.0.0.1
MATCHING_CORE_LIQUIDITY_BASE_URL=http://localhost:8081
```

- **`SERVER_ADDRESS`** — ② 가 「빠뜨리지 말 것」이라 적었으나 운영 파일에 **없다.** 실측:
  `ss -lntp` 가 `*:8085` 를 보인다(0.0.0.0). `/internal/matching/*` 에는 앱 인증이 없고,
  컷오버 순간부터 그 경로는 **예약·커밋·해제가 도는 자금 경로**가 된다. 반드시 먼저 넣는다.
- **`MATCHING_CORE_LIQUIDITY_BASE_URL`** — 현재 주석 처리돼 있다. 이 줄이 없으면
  `CoreLegacyOrderIntakeAdapter` 가 **빈으로 생성조차 되지 않고**, 인그레스는 폴을 돌면서
  `CORE_LIQUIDITY_NOT_CONFIGURED` 만 받는다. ☠️ **이 상태에서 워커를 끄면 PENDING 이 아무도
  집지 않는 채로 쌓인다 — 그런데 서비스는 셋 다 `active` 고 파이프라인도 초록이다.**
  빈 값(`=`)도 「설정함」으로 취급되니 **끄려면 줄 자체를 지울 것.**

적용 후 엔진만 재시작하고, **아직 유휴인지** 확인한다(인그레스 스위치는 안 넣었으므로).

```bash
ssh crs-bastion "ssh app-01 'sudo systemctl restart cryptoments-matching-engine && sleep 8 &&
  ss -lntp | grep :8085 &&
  sudo journalctl -u cryptoments-matching-engine -n 40 --no-pager | grep -iE \"CoreLegacyOrderIntakeAdapter|NOT_CONFIGURED|Started\"'"
```

기대: `127.0.0.1:8085` 바인딩, 기동 완료, 인그레스 폴 로그 **없음**.

### ⑦-1 컷오버 — 워커 먼저 끄고, 그다음 엔진을 켠다

순서가 규칙이다. 반대로 하면 둘이 겹치는 구간이 생긴다.

> 겹쳐도 **액면이 두 번 매칭되지는 않는다** — 인그레스 선점이 V1 과 같은 `SKIP LOCKED` +
> 조건부 CAS 를 쓰고, `commitBundle` 이 살아 있는 레그가 있는 주문을
> `P2P_DEPOSIT_ORDER_ALREADY_HAS_LEGS` 로 거절한다. 그래도 하지 않는다: 둘이 도는 동안의
> 로그는 어느 쪽이 무엇을 했는지 읽을 수 없게 되고, 컷오버 판정이 불가능해진다.

워커를 끈 뒤 엔진을 켜기까지의 수 초 동안 PENDING 이 잠깐 쌓인다. **주문은 죽지 않는다** —
집는 주체가 생기면 그대로 소비된다.

```bash
# 1) V1 워커 정지 (폴링만 멈춘다 — 프로세스는 살려 둔다)
ssh crs-bastion "ssh app-01 '
  sudo cp /opt/cryptoments/config/.env.matching-worker /opt/cryptoments/config/.env.matching-worker.bak-cutover &&
  sudo sed -i s/^P2P_ASYNC_MATCHING_ENABLED=true/P2P_ASYNC_MATCHING_ENABLED=false/ /opt/cryptoments/config/.env.matching-worker &&
  sudo systemctl restart cryptoments-matching-worker'"

# 2) V2 엔진 인그레스 개시
ssh crs-bastion "ssh app-01 '
  echo P2P_ASYNC_MATCHING_ENABLED=true | sudo tee -a /opt/cryptoments/config/.env.matching-engine &&
  sudo systemctl restart cryptoments-matching-engine'"
```

### ⑦-2 검증 — 「떴다」가 아니라 「집었다」를 본다

서비스가 `active` 인 것은 아무것도 증명하지 않는다. 셋 다 확인한다.

```bash
# ① 워커가 정말 멈췄나 — 폴 로그가 더 이상 없어야 한다
ssh crs-bastion "ssh app-01 'sudo journalctl -u cryptoments-matching-worker --since \"2 min ago\" --no-pager | tail -20'"

# ② 엔진이 정말 집는가 — 인그레스 선점 로그
ssh crs-bastion "ssh app-01 'sudo journalctl -u cryptoments-matching-engine --since \"2 min ago\" --no-pager | grep -iE \"intake|세션|session\" | tail -20'"

# ③ DB — 쌓이기만 하고 소비되지 않는 PENDING 이 있는가
ssh crs-bastion "ssh db-01 'mysql -u cryptoments -p\"<pw>\" cryptoments_db -e \"
  SELECT status, COUNT(*), MIN(created_at) oldest FROM p2p_deposit_orders
   WHERE created_at > NOW() - INTERVAL 30 MINUTE GROUP BY status;
  SELECT COUNT(*) live_sessions FROM matching_sessions
   WHERE status NOT IN (\\\"COMPLETED\\\",\\\"CANCELLED\\\",\\\"EXPIRED\\\",\\\"AUTO_CLOSED\\\",\\\"CLOSED_BY_USER\\\");\"'"
```

☠️ **가장 중요한 신호는 ③ 의 `oldest`** 다. PENDING 의 가장 오래된 것이 계속 늙어 가면
아무도 집지 않는 것이고, 그때 서비스 상태·로그·파이프라인은 전부 정상으로 보인다.

**실사용자 경로 확인은 별도 단계다.** 위 셋이 통과해도 「구매자가 실제로 매칭되어 계좌를
받았다」는 증명되지 않는다 — 위젯에서 한 건을 끝까지 태워 본다.

### ⑧ 롤백 — 정확히 역순, 잔존물 없음

```bash
# 1) 엔진 인그레스 정지
ssh crs-bastion "ssh app-01 '
  sudo sed -i /^P2P_ASYNC_MATCHING_ENABLED=/d /opt/cryptoments/config/.env.matching-engine &&
  sudo systemctl restart cryptoments-matching-engine'"

# 2) V1 워커 재개
ssh crs-bastion "ssh app-01 '
  sudo sed -i s/^P2P_ASYNC_MATCHING_ENABLED=false/P2P_ASYNC_MATCHING_ENABLED=true/ /opt/cryptoments/config/.env.matching-worker &&
  sudo systemctl restart cryptoments-matching-worker'"
```

**PENDING 잔존물은 문제가 되지 않는다.** 두 소비자가 같은 큐를 같은 방식으로 집으므로,
워커가 다시 켜지면 엔진이 남긴 PENDING 을 그대로 이어 집는다.

☠️ 다만 **이미 세션이 붙은 주문**은 다르다. 엔진이 세션을 만들어 `MATCHING` 으로 선점한
주문은 워커가 집지 않는다(살아 있는 세션이 소유 중). 그 세션들이 창을 닫고 종결될 때까지
(최대 창 5분 + 결정 유예) 기다리거나, 세션을 취소해 주문을 풀어 준다. 롤백 직후
`live_sessions` 가 0 으로 수렴하는지 위 ③ 쿼리로 본다.

> ⑦-0 의 두 줄은 **롤백하지 않는다.** `SERVER_ADDRESS` 는 컷오버와 무관한 보안 수정이고,
> `MATCHING_CORE_LIQUIDITY_BASE_URL` 이 남아 있어도 인그레스 스위치가 없으면 엔진은 유휴다.

---

## 중단 조건 — 아래면 즉시 롤백

- 엔진이 뜨지 않고 `journalctl` 에 `BeanCreationException` — 설정 문제. ② 를 확인
- open-api·scheduler·matching-worker 중 하나라도 응답 중단 — 메모리 압박 의심
- `matching_queue` 에 `DEAD_LETTER` 가 쌓임 — 예상치 못한 이벤트 유입

롤백: `sudo systemctl stop cryptoments-matching-engine`
(엔진만 멈추면 된다. 신규 테이블만 쓰므로 레거시 P2P 경로에 영향이 없다.)

## 남은 위험

- `/internal/matching/*` 에 애플리케이션 인증이 없다. **그리고 app-01 의 8081·8083 이
  인터넷에 직접 열려 있다(2026-09-10 실측)** — nginx 차단은 경계가 아니다. ⓪-A 의 두 조치를
  모두 끝내지 않은 채로는 배포하지 말 것. 근본 해결은 앱 인증 복원(핸드오프 B-1)이다
- 매칭 엔진이 open-api 로 나가는 호출에는 5s/10s 타임아웃이 걸려 있다(`matching.http.*`).
  Core 가 응답을 멈추면 세션은 진행되지 않지만 워커는 묶이지 않는다
- V1 `matching-worker` 가 계속 가동 중이다. P2P 재개 시 매칭 소유권을 정해야 한다
- 미구현 3건(실 webhook 인그레스 · 실행 계획 영속화 · terminal webhook publisher)이 있어
  P2P 완결 흐름은 이 배포로 동작하지 않는다. 이번 배포로 증명되는 것은
  **"엔진이 운영 환경에서 뜨고 스키마가 맞다"** 까지다
