# 감사 줄 조회 — CloudWatch Logs Insights

감사 줄은 `audit` 키를 가진 pino JSON 한 줄이다. Insights 는 최상위 JSON 키를 자동 파싱하므로
`audit.*` 로 바로 필터·집계할 수 있다.

> **이 파일의 쿼리는 실행해 검증하지 않았다.** 로그 그룹 이름과 awslogs 설정이 저장소 밖(ECS
> task definition)에 있어 이 저장소 안에서 확인할 수 없다. 조사 문서 OQ2 가 그 미확인을 이미 격리한다.

## 0. 층 어휘와 내구성 — `tier` 로 내구성을 추론하지 않는다

| 키 | 값 | 뜻 |
|---|---|---|
| `audit.tier` | `T1` · `T2` · `T4` | 어느 층이 낸 줄인가. `T1`=현금에 직접 닿는 쓰기 · `T2`=관리자 쓰기 요청과 가드 거부 · `T4`=스케줄러 |
| `audit.store` | `none` · `db` | 이 줄이 **내구 저장소에 행을 남겼는가** |
| `audit.v` | `1` · `2` | 줄 형태의 버전. `2` 부터 `txDurationMs` 가 봉투에 있다 |
| `audit.txDurationMs` | 정수 ms · `null` | 이 행위를 감싼 트랜잭션의 경과 — **락 보유시간의 상한**이다. `null` 은 *트랜잭션이 없는 계층*(T2·T3·T4)이고 *못 쟀다* 가 아니다 |

**`audit.store` 는 층별 사실이다 — `T1` 줄은 `db`, `T2`·`T4` 줄은 `none` 이다.** T1 의 내구 행은
금전 트랜잭션 **안에서** `tn_admin_audit` 표에 쓰이고, 그 적재가 실패하면 지급이 롤백되어 T1 줄 자체가
나가지 않는다. 그래서 `store = "db"` 인 줄 옆에는 언제나 커밋된 행이 있다 — 행은 6절의 SQL 로 읽는다.
내구 저장소가 붙기 이전에 배포된 T1 줄은 `none` 이며, 그 시기에는 실제로 행이 없다.

> **호환 정책.** `tier=T1` 을 "DB 행이 있다" 로 읽던 소비자는 **`store` 를 보도록 고친다.**
> 층 어휘는 *무엇을 기록했는가*(현금 축인가)를 말하고 내구성은 말하지 않는다. 두 뜻을 한 키에
> 겹쳐 두면 내구 저장소가 붙는 순간 기존 쿼리가 조용히 뜻을 바꾼다.
>
> `store`·`personId`·`batch` 가 없는 줄은 그 축이 붙기 이전의 줄이다.
>
> **`audit.v` 는 `2` 로 올랐다 — `txDurationMs` 때문이다.** 그 전까지는 키가 늘기만 해서
> 버전을 유지했으나, 이 필드는 형태를 바꿀 뿐 아니라 `null` 에 *트랜잭션이 없는 계층* 이라는
> **뜻**을 부여한다. `v=1` 줄은 필드가 아예 **부재**이므로, 버전을 안 올리면 소비자가
> *부재* 와 *null* 을 가를 수단이 없다. **지속시간을 집계할 때는 반드시 `audit.v >= 2` 로
> 거른다** — 안 거르면 옛 줄의 부재가 `null` 관측으로 섞여 분모가 부푼다.

## 1. 행위 빈도 — 분당 감사 줄 수

```
fields @timestamp, audit.tier
| filter ispresent(audit.action)
| stats count(*) as lines by bin(1m), audit.tier
| sort @timestamp desc
```

같은 요청이 층마다 한 줄씩 남으므로 이 수는 **요청 수가 아니라 줄 수**다. 요청 단위로 접은
실측치는 `node scripts/audit-rate.mjs --json < <log>` 가 낸다(`requests` 키).

**관리자 행위 빈도만 볼 때는 T4 를 뺀다**(M5 D14). M5 뒤 스케줄러 줄이 하루 수십~수백 건 늘고, 집계 도구는
새 시스템 행위를 `(unlisted)` 칸에 접는다 — 빼지 않으면 M2 의 `requests`·`peakPerSecond` 가 관리자 행위가
아닌 줄로 부푼다. 도구에 넣을 로그도 같은 필터로 거른다.

```
fields @timestamp, audit.tier
| filter ispresent(audit.action) and audit.tier != "T4"
| stats count(*) as lines by bin(1m), audit.tier
| sort @timestamp desc
```

## 2. 거부(DENIED) — 권한 없는 시도

**이것이 부정행위 조사에서 가장 값진 축이다.** 가드가 막은 요청은 업무 표에 흔적을 남기지 않고
이 줄에만 있다.

```
fields @timestamp, audit.actorRole, audit.actorRefId, audit.action, audit.changes._status, audit.ip
| filter audit.outcome = "DENIED"
| sort @timestamp desc
| limit 200
```

`actorRole = "UNKNOWN"` 은 가드가 던진 401(토큰 검증 실패)이고, 그 외는 403(인증은 됐으나 권한
부족)이다. **후자가 조사 가치가 훨씬 높다** — 실제 관리자 계정이 권한 밖을 두드린 기록이다.

## 3. 특정 관리자의 하루

```
fields @timestamp, audit.tier, audit.action, audit.outcome, audit.amount, audit.targetType, audit.targetId
| filter audit.actorRole = "ADMIN" and audit.actorRefId = 19
| sort @timestamp asc
```

## 3a. 기업 계정 뒤의 자연인 — 같은 기업의 조직관리자 둘을 가른다

`audit.actorRefId` 는 기업(`agc_id`)이라 조직관리자 둘이 같은 값을 갖는다. `audit.personId` 가
그 뒤의 회원(`mem_id`)이며 **ORG 스코프만** 갖는다 — SUPER 는 기업 계정 자체가 신원이라 `null`
이 정확한 관측이다.

```
fields @timestamp, audit.actorRefId, audit.personId, audit.actorOrg, audit.action, audit.amount
| filter audit.actorRole = "CORP" and ispresent(audit.personId)
| sort @timestamp desc
```

## 3b. 엑셀 일괄 지급을 업무 배치와 잇는다

배치 식별자는 `changes` **밖** 최상위 `batch` 키에 있다. `changes` 안에 두면 상한 초과 시 객체
전체가 절단 봉투로 치환되어 절단 표시까지 사라지기 때문이다.

```
fields @timestamp, audit.action, audit.batch.idsTotal, audit.batch._idsTruncated, audit.batch.ids
| filter audit.action in ["money.grant.upload", "money.issue.upload"]
| sort @timestamp desc
```

`audit.batch._idsTruncated = 1` 인 줄은 식별자가 잘린 업로드다 — 그 업로드는 **부분적으로만**
재구성할 수 있다. 범위(min/max)로 대신 조회하지 않는다: id 는 전역 AUTO_INCREMENT 라 동시
업로드의 값이 교차 배정된다.

## 3c. 일괄 지급 대상 수 분포 — M3 의 인덱스·행 크기 결정 입력

`audit.batch.idsTotal` 은 **요청이 건드린 대상 수**이고 `audit.batch.ids` 는 그중 상한까지만
실린 식별자다. 분포를 볼 때는 `ids` 의 길이가 아니라 `idsTotal` 을 쓴다 — 전자는 절단된
업로드에서 상한값으로 눌린다.

```
fields audit.action, audit.batch.idsTotal, audit.targetCount
| filter ispresent(audit.batch.idsTotal)
| stats count(*) as batches, avg(audit.batch.idsTotal) as avgIds, max(audit.batch.idsTotal) as maxIds by audit.action
| sort maxIds desc
```

**일괄 행위인데 `batch` 봉투가 아예 없는 줄**은 유효 식별자가 0건이라 봉투가 만들어지지
않은 경우다 — 유실이 100% 인 지점이며 `_idsMissing` 모집단 **밖**으로 빠진다. 그것까지
세는 것은 `scripts/audit-rate.mjs` 의 `integrity.batchNullLines` 다.

## 3d. 트랜잭션 지속시간 — 락 보유시간의 **상한**

```
fields @timestamp, audit.action, audit.txDurationMs, audit.targetCount, audit.batch.idsTotal
| filter audit.v >= 2 and ispresent(audit.txDurationMs)
| stats count(*) as n, avg(audit.txDurationMs) as avgMs, pct(audit.txDurationMs, 95) as p95, max(audit.txDurationMs) as maxMs by audit.action
| sort maxMs desc
```

> **이 값은 락 보유시간이 아니다.** 락은 `FOR UPDATE` 부터 커밋까지 잡히고 이 값은 그것을
> 위에서 감싸므로 항상 크거나 같다. 진짜 보유시간은 DB 측 계측(`performance_schema`)이라야
> 나온다. 감사 내구 행의 INSERT 는 같은 트랜잭션 안이라 이 값에 **포함**되지만, 그 증분만
> 따로 떼어 말하지는 않는다 — 증분은 표가 적용된 DB 에서 적재 전후를 재야 나온다.
>
> `null` 인 줄을 0 으로 읽지 않는다. `null` 은 트랜잭션이 없는 계층(T2 요청·T3 거부·T4
> 스케줄러)이라는 **관측**이고, 위 필터의 `ispresent` 가 그것을 모집단에서 뺀다.

## 4. 스케줄러 (T4)

M1 은 만료 처리만 줄로 꺼냈다. M5 가 돈·구독·예약수를 바꾸는 나머지 스케줄러 8종을 더했다 — 행위자는
`SYSTEM` 이고 `audit.actorLabel` 이 스케줄러 이름이다. 시스템 행위는 금전 어휘와 나뉜 별도 어휘다
(`apps/api/src/modules/audit/audit-system.ts`). 실패 줄은 `outcome = "FAILED"` 와 사유 코드를 싣고, 그 뒤의
흐름(계속 · 재던짐)은 이전과 같다.

| `audit.action` | 스케줄러 | `targetType` · `targetId` | 실패 사유 코드 |
|---|---|---|---|
| `money.expire` | expired-money | `member` · 회원 | `EXPIRE_FAILED` |
| `money.fax.restore` | fax-status | `member` · 회원 | `FAX_RESTORE_FAILED` |
| `money.subscription-point` | regular-subscription | `member` · 회원 | `SUBSCRIPTION_POINT_FAILED` |
| `subscribe.renew` | regular-subscription | `subscribe` · 구독 | `RENEW_FAILED` · `RENEW_AFTER_COMMIT_FAILED` |
| `subscribe.switch` | regular-subscription | `subscribe` · 원 구독 | — (커밋된 전환만 남는다) |
| `subscribe.agency.renew` | regular-agency-subscription | `subscribe` · 구독 | `AGENCY_RENEW_FAILED` |
| `subscribe.disable-complete` | disable-subscription | `subscribe` · 없음(대상 수 = 영향 행) | `DISABLE_COMPLETE_FAILED` |
| `order.reserve.refund` | analysis-status · analysis-stall | `order-detail` · 주문상세 | `RESERVE_REFUND_FAILED` |
| `order.reserve.reconcile` | analysis-status · analysis-stall | `order` · 없음(대상 수 = 보정 주문 수) | `RESERVE_RECONCILE_FAILED` |
| `settlement.agency` | settlement-agency | `agency` · 기업 | `SETTLEMENT_FAILED` |
| `settlement.client` | settlement-client | `client` · 거래처 | `SETTLEMENT_FAILED` |

`RENEW_AFTER_COMMIT_FAILED` 는 결제·주문이 커밋된 **뒤**에 던진 것이다 — 같은 구독의 `subscribe.renew`
`COMMITTED` 줄이 바로 앞에 있다(알림 실패 등). 결제가 실패한 정기결제(`PAY003`)도 주문 행이 커밋되므로
`COMMITTED` 이고 `audit.changes.pmtState` 가 결제 결과다.

```
fields @timestamp, audit.action, audit.outcome, audit.actorLabel, audit.targetType, audit.targetId, audit.amount, audit.targetCount, audit.reasonCode, audit.changes
| filter audit.tier = "T4"
| sort @timestamp desc
| limit 200
```

실패만, 행위·사유별 일간 건수:

```
fields @timestamp
| filter audit.tier = "T4" and audit.outcome = "FAILED"
| stats count(*) as failures by bin(1d), audit.action, audit.reasonCode
```

만료 실패만 — `console.error` 로 삼켜지던 구간이다. 이제 줄로 남는다.

```
fields @timestamp, audit.targetId, audit.amount, audit.changes.msrId
| filter audit.tier = "T4" and audit.action = "money.expire" and audit.outcome = "FAILED"
| stats count(*) as failures by bin(1d)
```

## 4b. 관리자 구독 변경 — T2 줄의 서버측 보강 (M5)

관리자 구독 변경 9개 라우트는 이미 나가던 T2 줄에 대상과 변경을 싣는다. 새 줄은 없다. `targetType` 이 id 공간을
말한다 — `subscribe` 는 구독 id, `agency-fc` 는 회원 id 다. `audit.batch.applied.<전이>` 가 커밋된 대상,
`audit.batch.failed.<전이>` 가 항목 단위로 시도했다가 실패한 대상이다. 전이는 `register` · `subscribe` ·
`unsubscribe` · `resign` · `resume` · `resubscribe` · `next-pay-day` 이고, 한 회원이 한 요청에서 여러 전이를
겪을 수 있다(사용중지 → 해지). **실패한 요청(`outcome = "FAILED"`)도 커밋된 대상을 싣는다** — 일부 커밋 뒤
던진 일괄 요청이 그 경우다.

```
fields @timestamp, audit.actorRole, audit.actorRefId, audit.action, audit.outcome, audit.targetType, audit.targetId, audit.targetCount, audit.changes.applied, audit.changes.failed, audit.changes.before, audit.changes.after, audit.changes._status, audit.batch
| filter audit.tier = "T2" and (audit.targetType = "subscribe" or audit.targetType = "agency-fc")
| sort @timestamp desc
| limit 200
```

결제일 수정(`post:subscribe.edit.:id`)은 `audit.targetId` 가 구독 id 이고 `audit.changes.before`·`after` 가 수정
전·후 결제일이다.

## 5. 시도 대비 거부율 — 인터셉터와 예외 필터가 같은 `action` 어휘를 쓰기 때문에 가능하다

```
fields audit.action, audit.outcome
| filter ispresent(audit.action) and audit.tier = "T2"
| stats count(*) as total, sum(audit.outcome = "DENIED") as denied by audit.action
| sort denied desc
```

## 6. 내구 기록(T1) — `tn_admin_audit` 표를 SQL 로 읽는다

stdout 줄은 커버리지 축이고 정본은 이 표다. 금전 트랜잭션 여섯 곳이 **같은 트랜잭션 안에서** 행을
쓰므로, 지급이 커밋됐으면 행이 있고 롤백됐으면 없다. 조회는 DB 직접 조회 권한을 가진 운영자가 한다 —
조회 화면·API 는 두지 않는다.

> **이 절의 SQL 도 실행해 검증하지 않았다.** 표가 아직 어느 DB 에도 적용되지 않았다(DDL 머리 주석의
> 배포 순서 참조). `JSON_TABLE` 은 MySQL 8.0 이상이다.

### 6a. 요청 하나가 남긴 금전 행위 — `idx_aud_request` 를 탄다

stdout 줄의 `audit.requestId` 가 이 표의 `aud_request_id` 와 같은 값이다. 로그에서 요청 하나를 찾았으면
이 질의로 커밋된 행을 확정한다.

```sql
SELECT aud_id, aud_created_at, aud_line_version,
       aud_actor_role, aud_actor_ref_id, aud_actor_label, aud_actor_org, aud_actor_person_id,
       aud_action, aud_target_type, aud_target_id, aud_amount, aud_target_count,
       aud_changes, aud_batch, aud_ip, aud_user_agent
FROM tn_admin_audit
WHERE aud_request_id = ?
ORDER BY aud_id;
```

`aud_line_version` 이 다르면 같은 칸의 **뜻**이 다를 수 있다(금액 부호 · 거부 어휘가 정해진 전후). 한
결과에 두 버전이 섞이면 버전별로 나눠 읽는다.

### 6b. 엑셀 일괄 지급 한 건을 업무 행에 잇는다

`aud_batch` 는 `{"ids":[…],"idsTotal":N,…}` 형태의 JSON 이다. `ids` 를 풀어 대상별 행에 잇는다 —
지급 업로드(`money.grant.upload`)는 `tn_money_grant_target.mng_id`, 발행 업로드(`money.issue.upload`)는
`tn_money_issue_target.mni_id` 에 잇는다.

```sql
SELECT a.aud_id, a.aud_action, b.batch_id, t.mem_id, t.mgt_amount
FROM tn_admin_audit a
JOIN JSON_TABLE(a.aud_batch, '$.ids[*]' COLUMNS (batch_id INT UNSIGNED PATH '$')) b
JOIN tn_money_grant_target t ON t.mng_id = b.batch_id
WHERE a.aud_request_id = ? AND a.aud_action = 'money.grant.upload';
```

배치 JSON 의 `_idsTruncated` 가 참인 행은 식별자가 상한(`AUDIT_IDS_MAX`)에서 잘린 업로드라 **부분적으로만**
이을 수 있다. 범위(min/max)로 대신 잇지 않는다 — id 는 전역 AUTO_INCREMENT 라 동시 업로드의 값이
교차 배정된다.

### 6c. 적재 실패 — 감사 때문에 막힌 지급

```
fields @timestamp, auditFailure.action, auditFailure.requestId, auditFailure.error
| filter auditFailure.event = "append-failed"
| sort @timestamp desc
```

이 진단은 `audit` 키가 아니라 **`auditFailure`** 키에 있다 — `audit` 에 두면 집계 도구
(`scripts/audit-rate.mjs`)가 정상 감사 이벤트로 센다. `error` 는 드라이버 오류 코드뿐이다(거절된 값 조각을
싣는 메시지는 싣지 않는다). 같은 `requestId` 의 T2 `FAILED` 줄이 그 요청의 나머지를 담는다.

**단건 실패는 기존 오류율 규칙에 울리지 않는다** — 그 규칙은 비율·최소 표본 기반이다. 이 조회를 사람이
보는 것 말고는 즉시 통지가 없다.
