> ## Documentation Index
> Fetch the complete documentation index at: https://api-docs.vpms.io/llms.txt
> Use this file to discover all available pages before exploring further.

# 0007 audit outbox sqs transport

# ADR-0007: Audit 로그 Transactional Outbox + SQS Transport

* **Status**: Proposed (구현 진행 중 — VNDT-1940, `feat/VNDT-1940-audit-outbox-sqs`. 머지·검증 후 Accepted로 전환)
* **Date**: 2026-07-10
* **Authors**: 정다운 ([daun@vendit.co.kr](mailto:daun@vendit.co.kr))
* **Affected modules**: `libs/audit`, `apps/audit-svc`, `apps/subscription-svc`(장치 이벤트 경로), 모든 audit 발행 서비스(`core-svc` 등)
* **Branch**: `feat/VNDT-1940-audit-outbox-sqs` (구현)
* **Prerequisite**: `feat/VNDT-1939-audit-auto-capture` (2계층 자동 캡처 — 구 PR #259는 방향 전환으로 closed, VNDT-1940 PR로 통합 예정)

***

## 1. Context

> 이하 `libs/audit/src/**` 경로는 `feat/VNDT-1939-audit-auto-capture`에서 도입된 것으로 `develop`에는 아직 없다. VNDT-1940 PR에 함께 머지될 예정이다.

`feat/VNDT-1939-audit-auto-capture`에서 **2계층 자동 audit 캡처**를 도입했다.

1. **Layer 1 — Prisma model 오버라이드 extension** (`libs/audit/src/extension/audit-extension.ts`): 모든 `create/update/upsert/delete/createMany/createManyAndReturn/updateMany/deleteMany`를 `$parent[model][op]` 위임으로 가로채 자동 캡처한다. update/delete는 before-read로 `oldData`를 확보하고 shallow diff로 `changedFields`를 계산해 `highlightFields`에 저장한다. `skipBeforeReadModels` opt-out으로 쓰기 폭주 모델은 왕복 1회로 줄일 수 있다.
2. **Layer 2 — 전체 Mutation 자동 래핑** (`libs/audit/src/directive/audit-directive.ts`의 `auditDirectiveTransformer`): 모든 GraphQL Mutation을 기본 래핑해 성공 시 `EVENT`(eventName=mutation명, newData=redacted args), 실패 시 `FAILURE`를 발행한다. curated newData가 필요한 mutation은 성공 EVENT만 opt-out하고 FAILURE는 항상 발행한다.

두 계층 모두 `accommodationId`/`actorType`(USER/KIOSK/INTEGRATION/SYSTEM)/`contextId`/`triggeredByContextId`를 AsyncLocalStorage 기반 요청 컨텍스트(`libs/audit/src/context/audit-context.ts`)에서 채우고, PII는 재귀 마스킹(`libs/audit/src/extension/mask.ts`) 후 저장한다. 발행은 두 계층 모두 `publishAuditLog`/`publishAuditLogBatch`(`libs/audit/src/client/audit-client.ts`)를 통해 **fire-and-forget HTTP POST**로 `audit-svc`(`/internal/audit-log`)로 전송된다.

### 1.1 현재 transport의 알려진 한계 (구 PR #259 Known Issue — VNDT-1940 PR로 통합 예정)

* **fire-and-forget HTTP 발행**이므로 audit-svc 장애나 네트워크 단절 시 이벤트가 **조용히 유실**된다 — 실패는 `.catch(err => console.error(...))`로 로깅될 뿐, 재시도나 영속화가 없다.
* 도메인 트랜잭션이 **커밋 이후 롤백**되는 경로는 없지만, before-read와 실제 쓰기 사이의 TOCTOU, 그리고 캡처가 쓰기 완료 직후 비동기로 발행되는 구조상 **트랜잭션이 실패해도 이미 나간 캡처 요청이 false-positive 로그로 남을 가능성**이 있다.
* `$queryRaw`/`$executeRaw` 등 raw SQL은 캡처되지 않는다.
* `updateMany`/`deleteMany`의 before-read 행 상한은 200건(`BULK_AUDIT_ROW_LIMIT`)이다.
* 4개 mutation(`updateRooms`/`setMileageSettings`/`setReservationSettings`/`setRoomSettings`)은 수동 curated EVENT 발행과 extension 캡처가 같은 변경을 이중 저장한다(같은 `contextId`로 묶여 UI 영향은 제한적).

### 1.2 외부 설계 논의 참고

별도로 검토된 MSA SaaS 중앙 audit 수집 아키텍처 초안은 Transactional Outbox + CDC(Change Data Capture) + 메시지 브로커 + WORM(Write-Once-Read-Many) raw store를 포함하는 전체 플랫폼 설계였다. 이는 다중 테넌트 규제 컴플라이언스, 다언어 producer, 페타바이트급 raw 보존을 전제로 한다. VPMS 현재 규모(단일 리전, 단일 테넌트 배포, PII 마스킹은 이미 클라이언트 측에서 처리)에는 과하므로, **transport 계층만 필요한 만큼 축소 채택**하고 나머지는 §2.6 유예 항목으로 남긴다.

물리 장치(키오스크/CCU 등) 이벤트가 이 transport로 유입되는 경로는 [디바이스 가이드](../guides/device-audit-events.mdx)를 참고한다 — 장치는 이 outbox에 직접 쓰지 않고, `subscription-svc`의 `reportDeviceEvents` mutation(장치 인증 + `accommodationId` 서버 대조 후 enrich)이 `publishAuditLogBatch`를 호출해 간접적으로 이 transport에 실린다.

***

## 2. Decision

**Transactional Outbox + SQS transport를 채택한다.** 캡처 계층(Layer 1/2, PII 마스킹, actorType/contextId 산정)은 무변경이며, **발행 방식만 HTTP 직송에서 로컬 DB outbox 기록으로 교체**한다 — Layer 1은 extension이 `$parent`로 직접 기록(§2.2), Layer 2와 수동 발행은 `publishAuditLog`/`publishAuditLogBatch` 내부 구현 교체로 처리한다.

### 2.1 왜 Outbox인가

`apps/core-svc/src/domains/outbox`(폴더명 그대로 기존 outbox 도메인, `outbox.job.ts`가 BullMQ로 폴링해 SQS로 전송), `apps/ledger-svc/src/outbox`, `apps/marketplace-svc/src/outbox`에 이미 동일한 **outbox 테이블 → 폴링 relay → SQS `SendMessageCommand`** 패턴이 서비스 3곳에서 운영 중이다. audit도 같은 패턴을 재사용한다 — 신규 아키텍처가 아니라 **기존 컨벤션을 audit 발행에 적용**하는 결정이다.

| 항목         | 결정                                                                    |
| ---------- | --------------------------------------------------------------------- |
| 캡처 트리거 시점  | 무변경 (Layer 1 model override / Layer 2 mutation wrapper)               |
| 발행 방식      | HTTP POST 직송 → 각 서비스 로컬 DB `audit_outbox` INSERT                      |
| 전송(relay)  | 폴링 job(`FOR UPDATE SKIP LOCKED`, 배치) → SQS `SendMessageBatch`         |
| 수신(ingest) | audit-svc: SQS long-poll consumer(`sqs-consumer`) + 멱등 upsert         |
| 멱등 키       | `eventId`(ULID, 캡처 시점 생성) = outbox PK = SQS dedup 키 = ingest unique 키 |

### 2.2 Outbox 기록: 업무 커밋 = 이벤트 커밋

Layer 1과 Layer 2는 발행 경로 자체가 다르므로 outbox 기록 방식도 각기 다르다.

* **Layer 1(model override)은 더 이상 `publishAuditLog`를 경유하지 않는다.** extension이 이미 model 오버라이드로 `Prisma.getExtensionContext(this).$parent`를 통해 위임 실행되고 있으므로(before-read가 쓰는 것과 동일한 메커니즘, §1의 `runAuditedOperation` 참고), outbox insert도 같은 `$parent`로 수행한다. 호출이 interactive `$transaction` 안에서 이뤄지면 `this` 컨텍스트 자체가 tx-scoped이므로 outbox INSERT가 **별도 배선 없이 자연히 같은 tx에 참여**한다 — 시그니처 변경도, ALS를 통한 tx 전파도 필요 없다. 이렇게 하면 업무 로직 커밋과 audit 이벤트 커밋이 원자적으로 묶여 **트랜잭션 롤백 시 이벤트도 함께 소멸**한다 — §1.1의 false-positive 로그 문제가 transport 계층에서 구조적으로 해소된다.
* **Layer 2(mutation wrapper)와 수동 `publishAuditLog`/`publishAuditLogBatch` 호출**은 resolver 반환 이후 시점에 발행하므로 이미 커밋된 상태 기준이다. 이 경로는 요청 스코프의 서비스 `prismaClient`로 즉시(비-tx) outbox insert를 수행해 로컬 DB 내구성만 확보하면 충분하다 — `publishAuditLog`/`publishAuditLogBatch` 내부 구현을 HTTP fetch에서 `prisma.auditOutbox.create(...)`로 교체하는 것으로 끝난다. **이 insert는 `await`한 뒤에 호출자(mutation resolver, `reportDeviceEvents` 등)에게 응답한다** — 성공 응답을 받은 시점에는 이벤트가 이미 로컬 DB에 존재함이 보장된다. fire-and-forget이 아니다.
* `eventId`(ULID)는 **캡처 시점**(Layer 1은 extension 내부, Layer 2/수동 발행은 `publishAuditLog(auditData)` 호출 직전)에 생성해 outbox row의 PK로 쓴다. 이 값이 그대로 SQS 메시지 dedup 키이자 audit-svc ingest의 멱등 키가 된다.

### 2.2.1 보장 매트릭스

이번 ADR로 audit 이벤트가 실제로 얻는 보장을 축별로 정리한다. 과설계로 보일 만큼 촘촘하지만, audit은 "실제 어떤 행위가 누구에 의해 일어났는지"의 원본 증적이므로 각 축의 실패 모드를 열어두지 않는다.

| 축            | 실패 모드                                                                                       | 보장                                                                                                                                                                                                                  |
| ------------ | ------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **발행 내구성**   | 클라이언트가 성공 응답을 받았는데 이벤트가 실제로는 안 남을 수 있음                                                      | Layer 1은 업무 tx에 결합(원자적 커밋/롤백, §2.2). Layer 2/수동 발행(장치 이벤트 포함)은 **응답 전 outbox insert를 `await`** — 성공 응답 = 이벤트가 로컬 DB에 존재함이 보장된 시점                                                                                    |
| **격리 실패 보존** | SQS DLQ는 14일 retention 후 소멸 — 스키마 불일치 등으로 격리된 이벤트가 그 기간 안에 처리되지 않으면 영구 유실                   | DLQ consumer가 메시지를 `AuditQuarantine` 테이블로 영속화하고 경보를 발생시킨다. DLQ retention과 무관하게 원본이 보존됨                                                                                                                              |
| **유실 탐지**    | 캡처→outbox→SQS→ingest 어느 단계에서 조용히 빠지면 알 방법이 없음                                               | **reconciliation job**이 각 서비스 outbox의 `PUBLISHED` 상태 eventId와 `audit_db`의 ingested eventId를 주기적으로 대사(diff)한다. 누락 발견 시 재발행 + 경고. `pending age`(outbox에 오래 머문 row) / `lag`(발행\~ingest 지연) / `rejected`(격리 건수) 메트릭을 노출 |
| **불변성**      | ingest 이후 `AuditLog` row가 UPDATE/DELETE로 조작되면 증적으로서 무의미                                     | `audit_db`에 `AuditLog` append-only를 강제하는 DB 트리거(UPDATE/DELETE 차단)를 둔다. 보존 정책(장기 archive, 파티션 drop 등)은 이 트리거를 우회하는 별도 관리자 role로만 수행 가능                                                                               |
| **테넌트 격리**   | A업장 요청이 B업장 audit 로그에 접근 가능하면 증적 자체의 신뢰가 깨짐                                                 | 리졸버 계층에서 `accommodationId` 스코프 강제 + 격리 테스트(§2.7). Postgres RLS(`FORCE ROW LEVEL SECURITY`)를 심층 방어로 추가 — 리졸버 스코프를 우회하는 직접 쿼리 경로가 생겨도 DB가 최후 방어선 역할                                                                   |
| **접근 감사**    | 누가 audit 로그를 열람/추출했는지 기록이 없으면 "누가 무엇을 봤는가"를 감사할 수 없음 — PMS 표준 감사 추적은 변경뿐 아니라 권한 열람·내보내기도 대상 | 전역(admin) 스코프 audit 조회는 그 조회 행위 자체를 `auditLogAccessed` 이벤트로 기록한다. 업장 스코프의 일반 목록 조회는 기록하지 않는다 — 매 조회를 감사하면 자기 증폭(조회 로그가 조회 로그를 낳는) 노이즈만 쌓인다. export 기능이 생기면 스코프와 무관하게 동일하게 감사 대상이 된다                                 |

#

#### outbox 보존·정리 정책

outbox 는 로그가 아니라 **전달 버퍼**다 — 정상 상태 크기 = 처리량 × 보존창. `PUBLISHED` row 는 reconciliation 대사 창(24h)보다 긴 **48시간**(기본, `AUDIT_OUTBOX_RETENTION_HOURS` env 로 조정) 보존 후 1시간 주기 정리 잡(`cleanupAuditOutbox`, BullMQ repeatable)이 배치 삭제한다. `PENDING` row 는 미전달 보존이 보장의 핵심이므로 **절대 삭제하지 않는다**. 이로써 서비스 DB 의 outbox 적재는 유계이며, audit\_db 와의 payload 중복은 보존창 동안만 존재한다.

## 2.3 Relay: 폴링 → SQS

기존 `outbox.job.ts`와 동일한 형태의 폴링 relay를 각 서비스(우선 core-svc)에 둔다.

* `FOR UPDATE SKIP LOCKED`로 동시 실행 워커 간 충돌을 피하고, 배치(예: 100건)로 읽어 `SendMessageBatch`로 전송한다.
* 전송 성공한 row만 `processed=true`(또는 삭제)로 마킹한다. 기존 `publishOutboxMessages` 패턴을 그대로 따른다.
* **브로커 신규 도입 없음**: event-store-svc가 이미 SQS를 운영 중(`apps/event-store-svc/src/modules/sqs.ts`)이고, ledger-svc/marketplace-svc도 각자의 outbox queue를 갖고 있다. audit도 인프라 스택(`packages/infrastructure/sources/stacks/data.ts`)에 큐를 추가하는 동일한 관행을 따른다.

### 2.4 큐 구성

인프라 스택의 기존 `ledgerOutbox`/`marketplaceOutbox` 패턴(`packages/infrastructure/sources/stacks/data.ts:404-426`)을 그대로 따른다.

| 항목             | 결정                                                                                                                                                                                                                                                                 |
| -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| 큐 종류           | SQS 표준 큐 (FIFO 아님)                                                                                                                                                                                                                                                 |
| 순서 보장          | 비보장 — 조회는 `occurred_at`(캡처 시점 타임스탬프) 정렬로 해결. FIFO는 필요 시 후속 검토                                                                                                                                                                                                      |
| Redrive policy | DLQ 연결, `maxReceiveCount: 5` (기존 ledger/marketplace와 동일 기준)                                                                                                                                                                                                        |
| 네이밍            | `vpms-{ENV}-audit-svc-outbox-queue` / `-dlq` (기존 `queueNames` 컨벤션 확장)                                                                                                                                                                                              |
| 메시지 상한         | SQS 256KB 표준 상한 — **audit 이벤트는 어떤 경우에도 절단하지 않는다.** relay가 직렬화 크기로 분기: 240KB 이하는 SQS로, 초과분은 기존 REST `/internal/audit-log` 경로로 원본 그대로 전달한다. 두 경로 모두 outbox 재시도와 eventId 멱등 ingest가 동일 적용되어 내구성이 유지된다. audit은 "실제 어떤 행위가 누구에 의해 일어났는지"의 원본 증적이므로 조용한 데이터 변형은 허용하지 않는다 |

### 2.5 Ingest: SQS consumer + 멱등

audit-svc에 `sqs-consumer` 기반 long-poll consumer를 추가한다(ledger-svc `outbox.consumer.ts` 패턴과 동일한 `Consumer.create({ queueUrl, handleMessage })` 형태).

* `AuditLog`(또는 신설 ingest 전 단계 테이블)의 PK/unique 제약을 `eventId`로 걸고 `ON CONFLICT DO NOTHING`으로 멱등 처리한다. SQS는 at-least-once 전달이므로 재전달로 인한 중복 insert를 여기서 흡수한다.
* **기존 REST 수신(`/internal/audit-log`)은 폐기하지 않는다** — 아직 outbox로 전환하지 않은 서비스나 저빈도 발행처의 과도기 경로일 뿐 아니라, §2.4의 240KB 초과 메시지가 상시 거치는 **대용량 원본 보존 경로**로 격상된다.

### 2.6 유예 항목 (트리거 조건 명시)

§1.2의 전체 플랫폼 설계 요소 중 다음은 이번 ADR 범위에서 채택하지 않는다. 유예 기준은 **"보장이 빠지는가"가 아니라 "규모·규제 사안인가"** — §2.2.1의 발행 내구성/격리 보존/유실 탐지/불변성/테넌트 격리/접근 감사 6개 보장 축은 전부 이번 범위에 포함되며 유예 대상이 아니다. 트리거 조건이 충족되면 재검토한다.

| 항목                           | 유예 사유                                                                   | 재검토 트리거                                          |
| ---------------------------- | ----------------------------------------------------------------------- | ------------------------------------------------ |
| CDC(Change Data Capture)     | 규모 사안 — 폴링 relay로 현재 볼륨 충분                                              | 폴링 처리량이 한계에 도달(outbox 적체, relay 지연 누적)할 때        |
| Raw store + WORM(S3 등 외부 저장) | 규제 사안 — 명시적 요구 없음. `AuditLog`의 append-only 트리거(§2.2.1)로 DB 내 불변성은 이미 확보 | 컴플라이언스/규제 기관이 DB 밖 불변 원본 보존을 명시적으로 요구할 때         |
| Projection 재구축 체계            | 규모 사안 — 현재 조회 스키마(AuditLog 단일 테이블) 안정적                                  | AuditLog 스키마에 breaking change가 필요할 정도의 대변경이 있을 때 |
| CloudEvents/스키마 레지스트리        | 규모 사안 — producer가 전부 TypeScript/Prisma 단일 스택                            | Python/Go 등 다른 언어 producer가 audit 발행에 참여할 때      |

> **멀티테넌트 격리는 유예하지 않는다.** §2.7 참고 — 리졸버 계층 강제 + 격리 테스트 + Postgres RLS까지 이번 구현(Phase 1) 범위다.

### 2.7 멀티테넌트 격리 (필수, 유예 아님)

audit 로그는 업장(accommodationId)별로 격리된 감사 증적이다. transport를 outbox+SQS로 바꾸는 이번 변경과 무관하게, **audit 조회 리졸버 전수가 업장 스코프를 강제**해야 하며 이는 유예 항목이 아니라 이번 구현의 필수 범위다.

| 항목               | 결정                                                                                                                                                                                     |
| ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 강제 위치(1차)        | 애플리케이션 리졸버 계층 — 모든 audit 조회 resolver가 호출자의 `accommodationId` 컨텍스트로 쿼리를 스코프                                                                                                             |
| 검증               | A업장 사용자가 B업장의 audit 로그에 접근할 수 없음을 확인하는 격리 테스트를 Phase 1에 포함                                                                                                                             |
| 강제 위치(2차, 심층 방어) | `AuditLog`에 Postgres RLS를 \*\*`FORCE ROW LEVEL SECURITY`\*\*로 적용한다. 테이블 소유자를 포함해 모든 쿼리 경로(리졸버 우회, 관리 콘솔, 배치 스크립트)가 `accommodationId` 정책을 강제로 통과해야 한다 — 리졸버 스코프가 실수로 빠져도 DB가 최후 방어선이 된다 |

***

## 3. Consequences

### 3.1 Positive

* ✅ 업무 트랜잭션 커밋과 audit 이벤트 커밋이 원자적으로 묶여 §1.1의 유실/false-positive 문제가 transport 계층에서 해소된다.
* ✅ 신규 브로커 도입 없이 기존 SQS 인프라와 outbox 컨벤션을 재사용 — 운영 지식과 모니터링 체계가 그대로 적용된다.
* ✅ 멱등 ingest로 SQS의 at-least-once 전달을 안전하게 흡수한다.
* ✅ audit-svc 장애 시에도 각 서비스의 outbox에 이벤트가 쌓여있어 복구 후 자동 재전송된다 — 가용성이 audit-svc 업타임에서 분리된다.

### 3.2 Negative (수용 트레이드오프)

* ❌ **Hot path 비용 증가**: Layer 1/2의 매 캡처가 이제 로컬 DB insert 1회를 추가로 유발한다(기존은 HTTP fire-and-forget이라 쓰기 경로에 DB 비용이 없었음). tx 안에서 실행되므로 커넥션 점유 시간도 소폭 늘어난다.
* ❌ **최종 일관성 지연**: 폴링 주기만큼 audit-svc 반영이 지연된다(운영 모니터링/이상 탐지가 실시간이 아닌 near-real-time이 됨).
* ❌ **운영 컴포넌트 추가**: outbox 정리 job, relay 모니터링, DLQ 알림을 서비스마다(우선 core-svc) 추가 구성해야 한다.
* ❌ **outbox 테이블 증가**: AuditLog와 동일한 증가율(활성 시설 1,000개 × 일 평균 100 mutation ≈ 100K rows/day)로 outbox 테이블도 쌓인다 — relay 후 즉시 정리하는 정책이 필요하다(§4 결정 필요 사항).

### 3.3 Risks & Mitigations

| Risk                                 | Mitigation                                                                           |
| ------------------------------------ | ------------------------------------------------------------------------------------ |
| Relay job 정지 시 outbox 무한 적체          | 헬스체크 + outbox row count 알림, DLQ와 별개로 relay 자체 모니터링                                   |
| outbox insert가 tx 안에서 실패해 업무 로직까지 롤백 | insert 실패를 캡처 예외 처리 범위(전 구간 try/catch)에 포함 — 캡처 실패가 본 쿼리를 절대 실패시키지 않는다는 기존 원칙(§1) 유지 |
| SQS 256KB 초과 메시지                     | 절단하지 않는다 — relay가 240KB 기준으로 SQS/REST 경로를 분기해 원본 그대로 전달(§2.4)                        |
| 멱등 키 충돌(ULID 재사용)                    | ULID는 시간 기반 + 랜덤 조합이라 실질적 충돌 없음. unique 제약 위반 시 ingest 쪽은 무시(이미 처리됨으로 간주)            |

***

## 4. 단계 계획

| Phase   | 범위                                                                                                                                                                                                                                                                                                                                                                                                                                          |
| ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Phase 1 | core-svc + subscription-svc(장치 이벤트 경로, `reportDeviceEvents`) 함께 진행: `audit_outbox` 테이블 + 폴링 relay + audit-svc SQS consumer(core) 도입. Layer 1은 extension 내부에서 `$parent`로 직접 outbox 기록, Layer 2와 `reportDeviceEvents`를 포함한 수동 발행 호출자는 `publishAuditLog`/`publishAuditLogBatch` 내부 구현 교체로 대응(호출부 시그니처 무변경). **§2.2.1 보장 매트릭스 전체(AuditQuarantine, reconciliation job, append-only 트리거, 리졸버 스코프+RLS 격리, `auditLogAccessed` 접근 감사)도 이 Phase에 포함** |
| Phase 2 | 나머지 audit 발행 서비스로 확대. REST 수신(`/internal/audit-log`) 의존 서비스를 outbox로 순차 전환 후 REST 경로 폐기 검토                                                                                                                                                                                                                                                                                                                                                  |
| Phase 3 | §2.6 유예 항목 트리거 조건 재평가                                                                                                                                                                                                                                                                                                                                                                                                                       |

## 5. 결정 필요 사항

* ~~`actorType`에 `DEVICE`(또는 유사) 값을 추가할지~~ — **확정**: `AuditActorType`에 `DEVICE` 추가. [디바이스 가이드](../guides/device-audit-events.mdx) §5 "actorType 매핑" 참고 (키오스크 게스트 행위=`KIOSK`, 장치 자체 상태=`DEVICE`).
* 큐/DLQ 정확한 네이밍 확정(`vpms-{ENV}-audit-svc-outbox-queue` 가안).
* relay 폴링 주기와 배치 크기(기존 `outbox.job.ts`는 100건/BullMQ 스케줄 — audit 볼륨에 맞게 조정 필요한지).
* outbox row 정리 주기(전송 성공 즉시 삭제 vs. 일정 기간 보관 후 삭제 — reconciliation job의 대사 기간을 커버해야 함).
* reconciliation job 실행 주기, `AuditQuarantine` row 보존 기간과 재처리(재발행) 절차.

***

## 6. Alternatives Considered

### A1. HTTP 직송 유지 + 재시도/서킷브레이커만 추가

* ❌ 거부 — audit-svc 자체가 재기동/배포 중일 때 여전히 유실 구간이 존재한다. 애플리케이션 메모리 큐는 프로세스 재시작 시 소멸해 outbox와 동등한 내구성을 주지 못한다.

### A2. CDC 검토와 판단

CDC(Debezium/DMS 등 WAL 기반 변경 캡처)로 즉시 전환하는 안을 별도로 검토했다. **결과: 현 단계 미채택**, §2.6 유예 목록에 유지한다.

* ❌ 거부 근거
  1. CDC가 주는 무유실 보장은 **outbox + 폴링 relay + reconciliation job(§2.2.1)으로 이미 동일하게 성립**한다 — CDC를 도입해도 얻는 보장이 늘지 않는다.
  2. Debezium/AWS DMS 등 성숙한 CDC 생태계는 Kafka/Kinesis 싱크를 전제로 만들어져 있고 **SQS 싱크가 없다** — 도입하려면 CDC 자체가 아니라 신규 메시지 브로커 플랫폼(Kafka 등)이 함께 따라온다. 이는 §2.1의 "기존 outbox+SQS 컨벤션 재사용" 결정과 정면으로 배치된다.
  3. CDC는 DB의 replication slot을 점유한다 — consumer가 지연되거나 멈추면 slot이 WAL을 계속 붙잡아 **업무 DB의 디스크/WAL이 압박**받는다. audit이라는 부가 관심사의 장애가 업무 DB 가용성 리스크로 전이되는 결합을 만든다.
  4. 현재 캡처는 Prisma extension이 애플리케이션 레벨에서 이미 세밀하게 처리(diff, PII 마스킹, actorType 산정)하고 있어 DB 레벨 CDC로 옮기면 이 로직을 전부 재구현해야 한다.
  5. 현재 볼륨은 1초 간격 폴링으로 충분히 커버된다 — CDC가 해결하는 "폴링 지연" 문제 자체가 아직 발생하지 않았다.
* **전환 트리거**: 폴링 처리량이 한계에 도달(outbox 적체·relay 지연 누적)하거나, 조직 차원에서 Kafka를 이미 도입하거나, 도메인 이벤트를 여러 컨슈머로 팬아웃해야 하는 요구가 생기면 재검토한다.
* **현 설계가 CDC 경로를 막지 않음**: outbox 테이블 구조 자체는 CDC로 전환해도 그대로 재사용 가능하다 — 교체 대상은 relay(폴링 → CDC 스트림)뿐이다. 지금 outbox를 채택한다고 나중에 CDC로 가는 길이 막히지 않는다.

#### 전환 대비 (CDC-ready)

미채택이지만 나중에 전환할 때 재설계가 필요 없도록 지금 설계 단계에서 아래를 반영한다.

1. `AuditOutbox`에 `eventType`/`partitionKey`를 독립 컬럼으로 둔다 — Debezium outbox event router가 요구하는 `aggregatetype`/`aggregateid`에 그대로 대응되는 필드라, 전환 시 컬럼 매핑만 하면 된다.
2. 전송로 중립 메시지 봉투를 쓴다 — 페이로드에 `v: 1` 버전 필드를 두고, consumer는 모르는 필드를 무시하도록 짠다. relay를 SQS에서 CDC connector로 바꿔도 메시지 형태는 그대로 유지된다.
3. relay 내부를 outbox-읽기 모듈과 전송 모듈로 분리한다 — 전환 시 전송 모듈만 교체하면 되고, 읽기 쪽(폴링 쿼리, 락킹)은 건드릴 필요가 없다.
4. polling relay가 outbox row에 남기는 `publishStatus` UPDATE는 미래의 CDC connector가 `op=c`(insert)만 필터링하도록 짜면 자동으로 무시된다 — 지금 relay 방식과 미래 CDC 방식이 같은 테이블을 봐도 서로 간섭하지 않는다.

**운영 사전조건**: RDS에서 `rds.logical_replication` 파라미터를 켜려면 인스턴스 재부팅이 필요하다. 전환을 실제로 결정하기 전에 미리 스케줄링해둘 수 있다는 점만 참고로 남긴다.

전환 트리거에 도달하면 **outbox 스키마와 consumer(ingest)는 무변경으로, relay만 CDC connector로 교체**한다.

### A3. 전체 플랫폼 설계(브로커+CDC+WORM raw store+스키마 레지스트리) 한 번에 도입

* ❌ 거부 — 현재 규모 대비 과도한 선투자. YAGNI. §2.6 유예 항목의 트리거 조건이 실제로 도래할 때 개별 채택. (단, 테넌트 격리는 규모·규제 사안이 아니라 보장 사안이라 §2.7에서 리졸버 스코프 + RLS 모두 이번 범위에 포함 — 유예하지 않음)

***

## 7. References

* `feat(VNDT-1939): audit 로그 2계층 자동 캡처` (구 PR #259, 방향 전환으로 closed — VNDT-1940 PR로 통합 예정)
* `libs/audit/src/extension/audit-extension.ts`, `libs/audit/src/directive/audit-directive.ts`, `libs/audit/src/context/audit-context.ts`
* `apps/core-svc/src/domains/outbox/jobs/outbox.job.ts` (기존 outbox → SQS relay 참고 구현)
* `apps/ledger-svc/src/outbox/outbox.consumer.ts` (기존 SQS consumer 참고 구현)
* `packages/infrastructure/sources/stacks/data.ts` (기존 outbox queue/DLQ 인프라 정의)
* [device-audit-events.mdx](../guides/device-audit-events.mdx)
