# Startover 이벤트 스키마 정의서 > 문서 코드: D004 > 버전: 1.0.0 > 작성일: 2026-03-07 > 적용 범위: MVP Phase 1 전체 도메인 > 승인 상태: 초안 > 관련 문서: schema-prisma-draft.md, D001(subsidy-policy.md), D002(contract-escrow-policy.md), D003(data-exposure-policy.md) --- ## 목차 1. [목적 및 설계 원칙](#1-목적-및-설계-원칙) 2. [공통 이벤트 엔벨로프](#2-공통-이벤트-엔벨로프) 3. [도메인별 이벤트 목록 및 payload 스키마](#3-도메인별-이벤트-목록-및-payload-스키마) 4. [Outbox 발행 정책](#4-outbox-발행-정책) 5. [향후 확장](#5-향후-확장) --- ## 1. 목적 및 설계 원칙 ### 1-1. 목적 이 문서는 Startover 플랫폼에서 `EventLog` 테이블에 저장되는 도메인 이벤트의 규격을 정의한다. 이벤트 로그는 세 가지 목적으로 활용된다. - **감사 추적**: 도메인 객체의 상태 전환을 불변(immutable) append-only 방식으로 보존한다. - **비동기 연동**: `OutboxEvent`를 통해 외부 시스템(알림, 정산, 분석)에 이벤트를 발행한다. - **KPI 분석**: 매칭 전환율, 지원금 케이스 성공률, 에스크로 정산 속도 등 지표 산출에 활용한다. `EventLog`는 `AuditLog`와 구분된다. `AuditLog`는 운영자/사용자 행위의 before/after 스냅샷을 운영 감사 목적으로 남기는 반면, `EventLog`는 도메인 이벤트를 비즈니스 의미 단위로 기록하고 후속 처리의 입력으로 활용한다. ### 1-2. 이벤트 네이밍 규칙 이벤트 이름은 `{도메인}.{액션}` 또는 `{도메인}.{엔티티}_{액션}` 형태를 따른다. ``` {도메인}.{액션} 예: store.submitted, match.accepted, subsidy.approved {도메인}.{엔티티}_{액션} 예: store.deal_status_changed, subsidy.document_uploaded, vendor.certification_approved ``` 규칙: - 도메인과 액션은 모두 소문자 스네이크케이스(`snake_case`)를 사용한다. - 도메인과 액션 사이는 점(`.`)으로 구분한다. - 엔티티와 액션 사이는 언더스코어(`_`)로 구분한다. - 액션은 과거형(completed action)으로 표현한다. `create`가 아니라 `created`, `accept`가 아니라 `accepted`. - 한 이벤트는 정확히 하나의 비즈니스 사실을 표현한다. 복수 상태 전환을 하나의 이벤트로 합치지 않는다. **도메인 접두어 목록:** | 접두어 | 대응 도메인 / aggregate | |--------|------------------------| | `user` | Identity — User, UserConsent | | `store` | Store Supply — Store, StorePhoto | | `match` | Matching — MatchRequest | | `subsidy` | Subsidy — SubsidyCase, SubsidyDocument | | `vendor` | Vendor — Vendor, VendorCertification | | `contract` | Trust — Contract | | `escrow` | Trust — EscrowTransaction | | `inspection` | Trust — InspectionRecord | | `dispute` | Trust — DisputeCase | | `operator` | Backoffice — 운영자 배정 | | `policy` | Backoffice — PolicyVersion 활성화 | ### 1-3. 이벤트 버전 관리 정책 `eventVersion` 필드는 동일한 `eventName`의 payload 스키마가 하위 호환이 깨지는 방식으로 변경될 때 증가한다. | 상황 | 처리 방식 | |------|----------| | 새 필드 추가 (선택적) | `eventVersion` 유지. 소비자는 미지 필드를 무시해야 한다. | | 필드 타입 변경 또는 필수 필드 제거 | `eventVersion`을 `v2`, `v3`으로 증가. 구버전 소비자와 공존 가능 기간을 명시한다. | | 이벤트 이름 변경 | 이전 이름을 deprecated로 표시하고 마이그레이션 기간 동안 양쪽을 동시 발행한다. | 초기 버전은 `v1`이다. 버전 문자열 형식: `v{N}` (예: `v1`, `v2`). ### 1-4. PII 레벨 정책 `piiLevel` 필드는 payload에 개인정보가 포함되는 정도를 나타낸다. 이 값은 이벤트 스트림을 외부 시스템에 발행하거나 로그를 장기 보관할 때 마스킹 또는 접근 제어 기준으로 사용한다. | 레벨 | 정의 | payload 예시 | |------|------|-------------| | `NONE` | 개인 식별 정보 없음. 집계 ID 또는 상태값만 포함. | `storeId`, `status`, `amount` | | `LOW` | 간접 식별 가능한 정보 포함. 이름·연락처·주소는 없으나 내부 ID로 역추적 가능. | `userId`, `vendorId`, `matchRequestId` | | `HIGH` | 직접 식별 정보 포함. 이름, 이메일, 전화번호, 주소, 사업자번호 등. | `email`, `phone`, `businessRegistrationNumber` | 원칙: - payload에 `HIGH` PII가 포함되어야 한다면 해당 필드를 포함하지 않는 대신 연결 ID(예: `userId`)만 기록하고 PII는 원본 테이블에서 조회한다. - `OutboxEvent`로 외부 발행 시 `HIGH` 이벤트는 발행하지 않거나, 발행 전 PII 필드를 제거한 별도 payload를 사용한다. - `NONE` 이벤트는 별도 접근 제어 없이 분석 시스템에 전달할 수 있다. --- ## 2. 공통 이벤트 엔벨로프 모든 이벤트는 `EventLog` 테이블의 다음 컬럼 구조를 공유한다. ### 2-1. EventLog 컬럼 정의 | 컬럼 | 타입 | 설명 | |------|------|------| | `id` | `BigInt` | 내부 자동 증가 PK. 외부에 노출하지 않는다. | | `aggregateType` | `String` | 이벤트가 속한 aggregate 타입. 예: `Store`, `MatchRequest`, `SubsidyCase` | | `aggregateId` | `String` | aggregate의 `publicId` 또는 내부 ID (BigInt를 문자열로 직렬화). | | `eventName` | `String` | 이벤트 이름. 네이밍 규칙 참조. 예: `store.submitted` | | `eventVersion` | `String` | payload 스키마 버전. 기본값 `v1`. | | `eventKey` | `String` | 중복 방지용 고유 키. `@unique` 제약. 형식: `{aggregateType}:{aggregateId}:{eventName}:{idempotencyToken}` | | `payloadJson` | `Json` | 이벤트 상세 데이터. 도메인별 스키마는 섹션 3 참조. | | `actorUserId` | `BigInt?` | 이벤트를 발생시킨 사용자 ID. 시스템 자동 이벤트인 경우 null. | | `causationId` | `String?` | 이 이벤트를 직접 유발한 이벤트의 `eventKey`. 이벤트 체이닝 추적에 사용. | | `correlationId` | `String` | 동일 비즈니스 트랜잭션을 묶는 ID. HTTP 요청 ID 또는 saga ID. | | `piiLevel` | `String` | `NONE` / `LOW` / `HIGH`. 섹션 1-4 참조. | | `occurredAt` | `DateTime` | 이벤트가 비즈니스 레벨에서 발생한 시각 (UTC). | | `recordedAt` | `DateTime` | DB에 기록된 시각 (UTC). `@default(now())`. | ### 2-2. payloadJson 공통 구조 권고 모든 payload는 아래 최상위 필드를 포함하도록 권고한다. 도메인별 필드는 추가로 확장한다. ```json { "aggregateId": "store_abc123", "previousStatus": "DRAFT", "currentStatus": "SUBMITTED", "triggeredBy": "USER_ACTION" } ``` | 공통 필드 | 타입 | 설명 | |----------|------|------| | `aggregateId` | `string` | aggregate 식별자 (publicId). | | `previousStatus` | `string?` | 전환 전 상태. 상태 전환 이벤트에서 필수. | | `currentStatus` | `string?` | 전환 후 상태. 상태 전환 이벤트에서 필수. | | `triggeredBy` | `string` | `USER_ACTION` / `OPERATOR_ACTION` / `SYSTEM_AUTO` / `WEBHOOK` | ### 2-3. eventKey 생성 규칙 `eventKey`는 멱등성 보장을 위해 고유해야 한다. ``` {aggregateType}:{aggregateId}:{eventName}:{ulid 또는 uuid} 예: Store:store_abc123:store.submitted:01HXXXXXXXXXXXXX ``` 재시도 안전성이 필요한 경우 `idempotencyToken`에 외부에서 주입된 멱등 키를 사용할 수 있다. --- ## 3. 도메인별 이벤트 목록 및 payload 스키마 --- ### 3-1. Identity 도메인 **aggregateType: `User`** --- #### `user.registered` 사용자가 신규 회원가입을 완료했을 때 발행된다. | 항목 | 값 | |------|----| | `eventName` | `user.registered` | | `aggregateType` | `User` | | `piiLevel` | `LOW` | | `triggeredBy` | `USER_ACTION` | payloadJson: ```json { "aggregateId": "user_abc123", "userId": "user_abc123", "primaryRole": "CLOSING_OWNER", "registrationChannel": "KAKAO_OAUTH" } ``` | 필드 | 타입 | 설명 | |------|------|------| | `userId` | `string` | 사용자 publicId | | `primaryRole` | `string` | `UserRole` enum 값. `CLOSING_OWNER` / `FOUNDER` / `VENDOR_MANAGER` / `OPS_MANAGER` / `SUBSIDY_OPERATOR` / `TRUST_OPERATOR` / `FINANCE_OPERATOR` / `SUPER_ADMIN` | | `registrationChannel` | `string` | `KAKAO_OAUTH` / `NAVER_OAUTH` / `GOOGLE_OAUTH` / `EMAIL` | --- #### `user.verified` 이메일 또는 전화번호 인증이 완료되었을 때 발행된다. | 항목 | 값 | |------|----| | `eventName` | `user.verified` | | `aggregateType` | `User` | | `piiLevel` | `LOW` | | `triggeredBy` | `USER_ACTION` | payloadJson: ```json { "aggregateId": "user_abc123", "userId": "user_abc123", "verificationType": "EMAIL", "verifiedAt": "2026-03-07T10:05:00Z" } ``` | 필드 | 타입 | 설명 | |------|------|------| | `verificationType` | `string` | `EMAIL` / `PHONE` | | `verifiedAt` | `string` | ISO 8601 UTC | --- #### `user.profile_updated` 사용자 프로필 정보가 변경되었을 때 발행된다. 변경된 필드 목록만 기록하며 값 자체는 포함하지 않는다(PII 보호). | 항목 | 값 | |------|----| | `eventName` | `user.profile_updated` | | `aggregateType` | `User` | | `piiLevel` | `LOW` | | `triggeredBy` | `USER_ACTION` | payloadJson: ```json { "aggregateId": "user_abc123", "userId": "user_abc123", "updatedFields": ["name", "phone"], "profileType": "CLOSING_OWNER" } ``` | 필드 | 타입 | 설명 | |------|------|------| | `updatedFields` | `string[]` | 변경된 필드명 목록. 실제 값은 포함하지 않는다. | | `profileType` | `string?` | UserProfile 변경인 경우 프로필 타입 | --- #### `user.consent_granted` / `user.consent_revoked` 동의 항목이 수락 또는 철회될 때 발행된다. | 항목 | 값 | |------|----| | `eventName` | `user.consent_granted` 또는 `user.consent_revoked` | | `aggregateType` | `User` | | `piiLevel` | `LOW` | | `triggeredBy` | `USER_ACTION` | payloadJson: ```json { "aggregateId": "user_abc123", "userId": "user_abc123", "consentType": "STORE_PUBLICATION_CONSENT", "policyVersionId": "polv_xyz", "isGranted": true } ``` | 필드 | 타입 | 설명 | |------|------|------| | `consentType` | `string` | `TERMS_OF_SERVICE` / `PRIVACY_POLICY_REQUIRED` / `PRIVACY_POLICY_MARKETING` / `STORE_PUBLICATION_CONSENT` / `MATCHED_INFO_DISCLOSURE` / `THIRD_PARTY_MATCHED_PARTY` / `NOTIFICATION_KAKAO` | | `policyVersionId` | `string` | 동의 당시 적용된 PolicyVersion publicId | | `isGranted` | `boolean` | `true`(granted) / `false`(revoked) | --- #### `user.suspended` / `user.reactivated` 운영자가 계정을 정지하거나 재활성화할 때 발행된다. | 항목 | 값 | |------|----| | `eventName` | `user.suspended` 또는 `user.reactivated` | | `aggregateType` | `User` | | `piiLevel` | `LOW` | | `triggeredBy` | `OPERATOR_ACTION` | payloadJson: ```json { "aggregateId": "user_abc123", "userId": "user_abc123", "previousStatus": "ACTIVE", "currentStatus": "SUSPENDED", "reasonCode": "POLICY_VIOLATION", "operatorId": "user_op01" } ``` | 필드 | 타입 | 설명 | |------|------|------| | `previousStatus` | `string` | `UserStatus` enum 값. `PENDING_VERIFICATION` / `ACTIVE` / `SUSPENDED` / `DEACTIVATED` | | `currentStatus` | `string` | `UserStatus` enum 값 | | `reasonCode` | `string` | 사유 코드 | | `operatorId` | `string` | 처리한 운영자 userId | --- ### 3-2. Store Supply 도메인 **aggregateType: `Store`** --- #### `store.draft_created` 폐업자가 매장 등록 초안을 생성했을 때 발행된다. | 항목 | 값 | |------|----| | `eventName` | `store.draft_created` | | `aggregateType` | `Store` | | `piiLevel` | `LOW` | | `triggeredBy` | `USER_ACTION` | payloadJson: ```json { "aggregateId": "store_abc123", "storeId": "store_abc123", "ownerUserId": "user_abc123", "industryLeafId": 42, "regionClusterId": 7 } ``` | 필드 | 타입 | 설명 | |------|------|------| | `storeId` | `string` | Store publicId | | `ownerUserId` | `string` | 폐업자 userId | | `industryLeafId` | `number` | 업종 말단 분류 ID (`IndustryTaxonomy` 참조) | | `regionClusterId` | `number` | 지역 클러스터 ID (`RegionHierarchy` 참조) | --- #### `store.submitted` 폐업자가 매장 등록을 운영자 검토용으로 제출했을 때 발행된다. | 항목 | 값 | |------|----| | `eventName` | `store.submitted` | | `aggregateType` | `Store` | | `piiLevel` | `LOW` | | `triggeredBy` | `USER_ACTION` | payloadJson: ```json { "aggregateId": "store_abc123", "storeId": "store_abc123", "ownerUserId": "user_abc123", "previousStatus": "DRAFT", "currentStatus": "SUBMITTED", "submittedAt": "2026-03-07T11:00:00Z" } ``` | 필드 | 타입 | 설명 | |------|------|------| | `previousStatus` | `string` | `StoreReviewStatus` 값. `DRAFT` | | `currentStatus` | `string` | `StoreReviewStatus` 값. `SUBMITTED` | --- #### `store.approved` / `store.rejected` 운영자가 매장 등록 검토를 완료했을 때 발행된다. | 항목 | 값 | |------|----| | `eventName` | `store.approved` 또는 `store.rejected` | | `aggregateType` | `Store` | | `piiLevel` | `LOW` | | `triggeredBy` | `OPERATOR_ACTION` | payloadJson (approved): ```json { "aggregateId": "store_abc123", "storeId": "store_abc123", "previousStatus": "REVIEWING", "currentStatus": "APPROVED", "operatorId": "user_op01", "approvedAt": "2026-03-07T14:00:00Z" } ``` payloadJson (rejected): ```json { "aggregateId": "store_abc123", "storeId": "store_abc123", "previousStatus": "REVIEWING", "currentStatus": "REJECTED", "operatorId": "user_op01", "reasonCode": "INVALID_DOCUMENT", "rejectedAt": "2026-03-07T14:00:00Z" } ``` | 필드 | 타입 | 설명 | |------|------|------| | `operatorId` | `string` | 처리한 운영자 userId | | `reasonCode` | `string?` | `store.rejected` 시 필수. 반려 사유 코드. | --- #### `store.published` / `store.unpublished` 매장의 공개 상태(`publicationStatus`)가 변경될 때 발행된다. | 항목 | 값 | |------|----| | `eventName` | `store.published` 또는 `store.unpublished` | | `aggregateType` | `Store` | | `piiLevel` | `LOW` | | `triggeredBy` | `USER_ACTION` 또는 `OPERATOR_ACTION` | payloadJson: ```json { "aggregateId": "store_abc123", "storeId": "store_abc123", "previousPublicationStatus": "PRIVATE", "currentPublicationStatus": "PUBLISHED", "triggeredBy": "USER_ACTION", "consentRecordId": "consent_xyz" } ``` | 필드 | 타입 | 설명 | |------|------|------| | `previousPublicationStatus` | `string` | `StorePublicationStatus` enum 값. `PRIVATE` / `RESTRICTED` / `PUBLISHED` / `UNPUBLISHED` | | `currentPublicationStatus` | `string` | `StorePublicationStatus` enum 값 | | `consentRecordId` | `string?` | `store.published` 시 — 정보 공개에 동의한 UserConsent ID | --- #### `store.deal_status_changed` 매장의 거래 상태(`dealStatus`)가 변경될 때 발행된다. `OPEN→MATCHING→RESERVED→CONTRACTED→CLOSED→CANCELLED` 전환 모두 이 이벤트로 처리한다. | 항목 | 값 | |------|----| | `eventName` | `store.deal_status_changed` | | `aggregateType` | `Store` | | `piiLevel` | `LOW` | | `triggeredBy` | `SYSTEM_AUTO` 또는 `OPERATOR_ACTION` | payloadJson: ```json { "aggregateId": "store_abc123", "storeId": "store_abc123", "previousDealStatus": "OPEN", "currentDealStatus": "MATCHING", "triggeredBy": "SYSTEM_AUTO", "relatedMatchRequestId": "match_xyz", "relatedContractId": null } ``` | 필드 | 타입 | 설명 | |------|------|------| | `previousDealStatus` | `string` | `StoreDealStatus` enum 값. `OPEN` / `MATCHING` / `RESERVED` / `CONTRACTED` / `CLOSED` / `CANCELLED` | | `currentDealStatus` | `string` | `StoreDealStatus` enum 값 | | `relatedMatchRequestId` | `string?` | 매칭 요청 수락으로 인한 전환인 경우 | | `relatedContractId` | `string?` | 계약 완료/취소로 인한 전환인 경우 | --- #### `store.photo_uploaded` / `store.photo_removed` 매장 사진이 업로드되거나 삭제될 때 발행된다. | 항목 | 값 | |------|----| | `eventName` | `store.photo_uploaded` 또는 `store.photo_removed` | | `aggregateType` | `Store` | | `piiLevel` | `NONE` | | `triggeredBy` | `USER_ACTION` 또는 `OPERATOR_ACTION` | payloadJson: ```json { "aggregateId": "store_abc123", "storeId": "store_abc123", "photoId": "12345", "photoCategory": "KITCHEN", "visibilityScope": "RESTRICTED", "uploadedByUserId": "user_abc123" } ``` | 필드 | 타입 | 설명 | |------|------|------| | `photoId` | `string` | StorePhoto 내부 ID (BigInt를 문자열로 직렬화) | | `photoCategory` | `string` | `PhotoCategory` enum 값. `EXTERIOR` / `INTERIOR` / `KITCHEN` / `EQUIPMENT` / `FLOOR_PLAN` / `DOCUMENT` | | `visibilityScope` | `string` | `VisibilityScope` enum 값. `INTERNAL` / `MATCHED_ONLY` / `PUBLIC_SUMMARY` | | `uploadedByUserId` | `string?` | `store.photo_removed` 시에는 삭제 실행자 userId | --- ### 3-3. Matching 도메인 **aggregateType: `MatchRequest`** --- #### `match.requested` 창업자/업체/운영자 추천으로 매칭 요청이 생성될 때 발행된다. | 항목 | 값 | |------|----| | `eventName` | `match.requested` | | `aggregateType` | `MatchRequest` | | `piiLevel` | `LOW` | | `triggeredBy` | `USER_ACTION` 또는 `OPERATOR_ACTION` | payloadJson: ```json { "aggregateId": "match_abc123", "matchRequestId": "match_abc123", "storeId": "store_xyz", "matchType": "ACQUISITION", "requesterUserId": "user_founder01", "requesterVendorId": null, "sourceType": "USER_INITIATED" } ``` | 필드 | 타입 | 설명 | |------|------|------| | `matchType` | `string` | `MatchType` enum 값. `ACQUISITION` / `DEMOLITION` / `INTERIOR` | | `requesterUserId` | `string?` | 창업자(FOUNDER)인 경우 userId | | `requesterVendorId` | `string?` | 업체(VENDOR_MANAGER)인 경우 vendorId | | `sourceType` | `string` | `USER_INITIATED` / `OPERATOR_RECOMMENDED` / `SYSTEM_SUGGESTED` | --- #### `match.accepted` / `match.rejected` 폐업자 또는 운영자가 매칭 요청을 수락하거나 거절했을 때 발행된다. | 항목 | 값 | |------|----| | `eventName` | `match.accepted` 또는 `match.rejected` | | `aggregateType` | `MatchRequest` | | `piiLevel` | `LOW` | | `triggeredBy` | `USER_ACTION` 또는 `OPERATOR_ACTION` | payloadJson: ```json { "aggregateId": "match_abc123", "matchRequestId": "match_abc123", "storeId": "store_xyz", "matchType": "ACQUISITION", "previousStatus": "REVIEWING", "currentStatus": "ACCEPTED", "decisionByUserId": "user_owner01", "decisionReasonCode": null } ``` | 필드 | 타입 | 설명 | |------|------|------| | `previousStatus` | `string` | `MatchRequestStatus` enum 값 | | `currentStatus` | `string` | `ACCEPTED` 또는 `REJECTED` | | `decisionByUserId` | `string` | 결정한 사용자 또는 운영자 userId | | `decisionReasonCode` | `string?` | `match.rejected` 시 반려 사유 코드 | --- #### `match.expired` / `match.cancelled` 매칭 요청이 만료되거나 취소될 때 발행된다. | 항목 | 값 | |------|----| | `eventName` | `match.expired` 또는 `match.cancelled` | | `aggregateType` | `MatchRequest` | | `piiLevel` | `LOW` | | `triggeredBy` | `SYSTEM_AUTO` 또는 `USER_ACTION` 또는 `OPERATOR_ACTION` | payloadJson: ```json { "aggregateId": "match_abc123", "matchRequestId": "match_abc123", "storeId": "store_xyz", "previousStatus": "OPEN", "currentStatus": "EXPIRED", "triggeredBy": "SYSTEM_AUTO", "reasonCode": "NO_RESPONSE_TIMEOUT" } ``` --- #### `match.completed` 매칭이 정상 완료될 때(계약 완료 이후) 발행된다. | 항목 | 값 | |------|----| | `eventName` | `match.completed` | | `aggregateType` | `MatchRequest` | | `piiLevel` | `LOW` | | `triggeredBy` | `SYSTEM_AUTO` | payloadJson: ```json { "aggregateId": "match_abc123", "matchRequestId": "match_abc123", "storeId": "store_xyz", "matchType": "ACQUISITION", "previousStatus": "CONTRACTING", "currentStatus": "COMPLETED", "relatedContractId": "contract_xyz" } ``` --- ### 3-4. Subsidy 도메인 **aggregateType: `SubsidyCase`** --- #### `subsidy.case_created` 지원금 케이스가 DRAFT 상태로 생성될 때 발행된다. | 항목 | 값 | |------|----| | `eventName` | `subsidy.case_created` | | `aggregateType` | `SubsidyCase` | | `piiLevel` | `LOW` | | `triggeredBy` | `USER_ACTION` | payloadJson: ```json { "aggregateId": "scase_abc123", "subsidyCaseId": "scase_abc123", "storeId": "store_xyz", "applicantUserId": "user_abc123", "programCode": "HOPE_RETURN_PKG", "policyVersionId": "polv_xyz", "checklistVersionCode": "HOPE_RETURN_PKG_V1" } ``` | 필드 | 타입 | 설명 | |------|------|------| | `programCode` | `string` | 지원 프로그램 코드. MVP는 `HOPE_RETURN_PKG` | | `policyVersionId` | `string` | 케이스 생성 시점에 고정되는 PolicyVersion publicId | | `checklistVersionCode` | `string` | 케이스 생성 시점의 체크리스트 버전. 예: `HOPE_RETURN_PKG_V1` | --- #### `subsidy.eligibility_checked` 자격 판별이 실행되어 결과가 결정될 때 발행된다. `SubsidyCase`가 `ELIGIBILITY_CHECKED` 상태로 전환될 때 발행한다. | 항목 | 값 | |------|----| | `eventName` | `subsidy.eligibility_checked` | | `aggregateType` | `SubsidyCase` | | `piiLevel` | `LOW` | | `triggeredBy` | `SYSTEM_AUTO` | payloadJson: ```json { "aggregateId": "scase_abc123", "subsidyCaseId": "scase_abc123", "storeId": "store_xyz", "eligibilityResult": "ELIGIBLE", "previousStatus": "DRAFT", "currentStatus": "ELIGIBILITY_CHECKED", "policyVersionId": "polv_xyz" } ``` | 필드 | 타입 | 설명 | |------|------|------| | `eligibilityResult` | `string` | `EligibilityResult` enum 값. `ELIGIBLE` / `CONDITIONALLY_ELIGIBLE` / `NOT_ELIGIBLE` / `UNKNOWN` | `eligibilitySnapshotJson` 원본은 `SubsidyCase` 테이블에 저장하며 이벤트 payload에는 포함하지 않는다(PII 보호). --- #### `subsidy.document_uploaded` / `subsidy.document_reviewed` 지원금 서류가 업로드되거나 운영자 검토가 완료될 때 발행된다. | 항목 | 값 | |------|----| | `eventName` | `subsidy.document_uploaded` 또는 `subsidy.document_reviewed` | | `aggregateType` | `SubsidyCase` | | `piiLevel` | `LOW` | | `triggeredBy` | `USER_ACTION` 또는 `OPERATOR_ACTION` | payloadJson (document_uploaded): ```json { "aggregateId": "scase_abc123", "subsidyCaseId": "scase_abc123", "subsidyDocumentId": "sdoc_xyz", "documentTypeCode": "HRP_DOC_001", "itemCode": "HRP_DOC_001", "uploadedByUserId": "user_abc123", "isResubmission": false } ``` payloadJson (document_reviewed): ```json { "aggregateId": "scase_abc123", "subsidyCaseId": "scase_abc123", "subsidyDocumentId": "sdoc_xyz", "documentTypeCode": "HRP_DOC_001", "reviewResult": "APPROVED", "reviewedByOperatorId": "user_op01", "reasonCode": null } ``` | 필드 | 타입 | 설명 | |------|------|------| | `isResubmission` | `boolean` | 반려 후 재업로드 여부 | | `reviewResult` | `string` | `APPROVED` / `REJECTED` / `RESUBMITTED` | | `reasonCode` | `string?` | `REJECTED` 시 반려 사유 코드. `MISSING_DOCUMENT` / `INVALID_DOCUMENT` / `ILLEGIBLE_DOCUMENT` / `EXPIRED_DOCUMENT` / `ELIGIBILITY_ISSUE` / `INCOMPLETE_INFO` / `MISMATCHED_INFO` / `WRONG_FORMAT` | --- #### `subsidy.submitted` / `subsidy.approved` / `subsidy.rejected` 지원금 케이스의 주요 상태 전환 이벤트다. | 항목 | 값 | |------|----| | `eventName` | `subsidy.submitted` / `subsidy.approved` / `subsidy.rejected` | | `aggregateType` | `SubsidyCase` | | `piiLevel` | `LOW` | | `triggeredBy` | `USER_ACTION` 또는 `OPERATOR_ACTION` | payloadJson (submitted): ```json { "aggregateId": "scase_abc123", "subsidyCaseId": "scase_abc123", "storeId": "store_xyz", "programCode": "HOPE_RETURN_PKG", "previousStatus": "READY_TO_SUBMIT", "currentStatus": "SUBMITTED" } ``` payloadJson (approved): ```json { "aggregateId": "scase_abc123", "subsidyCaseId": "scase_abc123", "storeId": "store_xyz", "programCode": "HOPE_RETURN_PKG", "previousStatus": "SUBMITTED", "currentStatus": "APPROVED", "operatorId": "user_op01" } ``` payloadJson (rejected): ```json { "aggregateId": "scase_abc123", "subsidyCaseId": "scase_abc123", "storeId": "store_xyz", "programCode": "HOPE_RETURN_PKG", "previousStatus": "SUBMITTED", "currentStatus": "REJECTED", "operatorId": "user_op01", "rejectionReasonCode": "INELIGIBLE_AFTER_REVIEW" } ``` --- ### 3-5. Vendor 도메인 **aggregateType: `Vendor`** --- #### `vendor.registered` 업체가 플랫폼에 신규 등록될 때 발행된다. | 항목 | 값 | |------|----| | `eventName` | `vendor.registered` | | `aggregateType` | `Vendor` | | `piiLevel` | `LOW` | | `triggeredBy` | `USER_ACTION` | payloadJson: ```json { "aggregateId": "vendor_abc123", "vendorId": "vendor_abc123", "ownerUserId": "user_vendor01", "vendorType": "DEMOLITION", "primaryRegionId": 7 } ``` | 필드 | 타입 | 설명 | |------|------|------| | `vendorType` | `string` | `VendorType` enum 값. `DEMOLITION` / `INTERIOR` / `BOTH` | | `primaryRegionId` | `number` | 주요 서비스 지역 ID (`RegionHierarchy` 참조) | --- #### `vendor.certification_applied` / `vendor.certification_approved` / `vendor.certification_rejected` 업체 인증 신청, 승인, 반려 이벤트다. | 항목 | 값 | |------|----| | `eventName` | `vendor.certification_applied` / `vendor.certification_approved` / `vendor.certification_rejected` | | `aggregateType` | `Vendor` | | `piiLevel` | `LOW` | | `triggeredBy` | `USER_ACTION` 또는 `OPERATOR_ACTION` | payloadJson (certification_applied): ```json { "aggregateId": "vendor_abc123", "vendorId": "vendor_abc123", "certificationId": "vcert_xyz", "requestedScopeCode": "DEMOLITION_STANDARD", "previousCertificationStatus": null, "currentCertificationStatus": "APPLIED" } ``` payloadJson (certification_approved): ```json { "aggregateId": "vendor_abc123", "vendorId": "vendor_abc123", "certificationId": "vcert_xyz", "previousCertificationStatus": "REVIEWING", "currentCertificationStatus": "APPROVED", "reviewedByOperatorId": "user_op01", "validUntil": "2027-03-07T00:00:00Z" } ``` payloadJson (certification_rejected): ```json { "aggregateId": "vendor_abc123", "vendorId": "vendor_abc123", "certificationId": "vcert_xyz", "previousCertificationStatus": "REVIEWING", "currentCertificationStatus": "REJECTED", "reviewedByOperatorId": "user_op01", "reasonCode": "INSUFFICIENT_DOCUMENTS" } ``` | 필드 | 타입 | 설명 | |------|------|------| | `currentCertificationStatus` | `string` | `VendorCertificationStatus` enum 값. `APPLIED` / `REVIEWING` / `APPROVED` / `REJECTED` / `SUSPENDED` / `EXPIRED` | | `validUntil` | `string?` | `certification_approved` 시 인증 유효 기간 | --- ### 3-6. Trust Infrastructure 도메인 --- #### Contract 이벤트 **aggregateType: `Contract`** --- ##### `contract.created` 매칭 수락 후 계약이 생성될 때 발행된다. | 항목 | 값 | |------|----| | `eventName` | `contract.created` | | `aggregateType` | `Contract` | | `piiLevel` | `LOW` | | `triggeredBy` | `SYSTEM_AUTO` 또는 `OPERATOR_ACTION` | payloadJson: ```json { "aggregateId": "contract_abc123", "contractId": "contract_abc123", "matchRequestId": "match_xyz", "storeId": "store_xyz", "contractType": "ACQUISITION", "templateCode": "ACQUISITION_V1", "storeOwnerUserId": "user_owner01", "buyerUserId": "user_founder01", "vendorId": null } ``` | 필드 | 타입 | 설명 | |------|------|------| | `contractType` | `string` | `ContractType` enum 값. `ACQUISITION` / `DEMOLITION` / `INTERIOR` | | `templateCode` | `string` | 사용된 계약서 템플릿 코드 | | `vendorId` | `string?` | `DEMOLITION` / `INTERIOR` 계약인 경우 | --- ##### `contract.signed` 계약 양측 서명이 완료될 때 발행된다. | 항목 | 값 | |------|----| | `eventName` | `contract.signed` | | `aggregateType` | `Contract` | | `piiLevel` | `LOW` | | `triggeredBy` | `SYSTEM_AUTO` | payloadJson: ```json { "aggregateId": "contract_abc123", "contractId": "contract_abc123", "contractVersionId": "cver_xyz", "previousStatus": "SIGNING", "currentStatus": "SIGNED", "signerCount": 2 } ``` 서명자 개인정보(`ipAddress`, `userAgent`)는 `SignatureEvidence` 테이블에만 저장하고 이벤트 payload에는 포함하지 않는다. --- ##### `contract.completed` / `contract.cancelled` 계약이 완료되거나 취소될 때 발행된다. | 항목 | 값 | |------|----| | `eventName` | `contract.completed` 또는 `contract.cancelled` | | `aggregateType` | `Contract` | | `piiLevel` | `LOW` | | `triggeredBy` | `SYSTEM_AUTO` 또는 `OPERATOR_ACTION` | payloadJson (completed): ```json { "aggregateId": "contract_abc123", "contractId": "contract_abc123", "storeId": "store_xyz", "contractType": "ACQUISITION", "previousStatus": "ACTIVE", "currentStatus": "COMPLETED", "triggeredBy": "SYSTEM_AUTO", "relatedEscrowTransactionId": "etx_xyz" } ``` payloadJson (cancelled): ```json { "aggregateId": "contract_abc123", "contractId": "contract_abc123", "storeId": "store_xyz", "contractType": "ACQUISITION", "previousStatus": "SIGNING", "currentStatus": "CANCELLED", "triggeredBy": "SYSTEM_AUTO", "reasonCode": "SIGNING_TIMEOUT" } ``` --- #### Escrow 이벤트 에스크로 이벤트는 계약의 부속 상태이므로 `Contract`를 aggregateType으로 사용한다. **aggregateType: `Contract`** --- ##### `escrow.deposit_initiated` 에스크로 결제 요청이 PG사로 발송될 때 발행된다. | 항목 | 값 | |------|----| | `eventName` | `escrow.deposit_initiated` | | `aggregateType` | `Contract` | | `piiLevel` | `NONE` | | `triggeredBy` | `SYSTEM_AUTO` | payloadJson: ```json { "aggregateId": "contract_abc123", "contractId": "contract_abc123", "escrowTransactionId": "etx_xyz", "amount": 5000000, "currencyCode": "KRW", "providerCode": "TOSSPAYMENTS", "idempotencyKey": "etx_abc123_deposit_01" } ``` | 필드 | 타입 | 설명 | |------|------|------| | `amount` | `number` | 결제 요청 금액 (KRW, 정수) | | `providerCode` | `string` | PG사 코드 | | `idempotencyKey` | `string` | 중복 방지 키 | --- ##### `escrow.holding` PG 결제 성공 웹훅이 처리되어 에스크로가 `HOLDING` 상태로 전환될 때 발행된다. | 항목 | 값 | |------|----| | `eventName` | `escrow.holding` | | `aggregateType` | `Contract` | | `piiLevel` | `NONE` | | `triggeredBy` | `WEBHOOK` | payloadJson: ```json { "aggregateId": "contract_abc123", "contractId": "contract_abc123", "escrowTransactionId": "etx_xyz", "previousEscrowStatus": "DEPOSIT_PENDING", "currentEscrowStatus": "HOLDING", "amount": 5000000, "currencyCode": "KRW", "providerTransactionId": "pg_txn_999" } ``` --- ##### `escrow.released` 운영자 정산 승인 후 에스크로가 해제될 때 발행된다. | 항목 | 값 | |------|----| | `eventName` | `escrow.released` | | `aggregateType` | `Contract` | | `piiLevel` | `NONE` | | `triggeredBy` | `OPERATOR_ACTION` | payloadJson: ```json { "aggregateId": "contract_abc123", "contractId": "contract_abc123", "escrowTransactionId": "etx_xyz", "previousEscrowStatus": "RELEASE_REVIEW", "currentEscrowStatus": "RELEASED", "settledAmount": 4850000, "platformFeeAmount": 150000, "currencyCode": "KRW", "approvedByOperatorId": "user_fin01" } ``` | 필드 | 타입 | 설명 | |------|------|------| | `settledAmount` | `number` | 업체에 지급되는 정산 금액 (KRW) | | `platformFeeAmount` | `number` | 플랫폼 수수료 금액 (KRW) | --- ##### `escrow.refunded` 환불이 실행될 때 발행된다. | 항목 | 값 | |------|----| | `eventName` | `escrow.refunded` | | `aggregateType` | `Contract` | | `piiLevel` | `NONE` | | `triggeredBy` | `OPERATOR_ACTION` | payloadJson: ```json { "aggregateId": "contract_abc123", "contractId": "contract_abc123", "escrowTransactionId": "etx_xyz", "previousEscrowStatus": "DISPUTED", "currentEscrowStatus": "REFUNDED", "refundType": "FULL_REFUND", "refundAmount": 5000000, "currencyCode": "KRW", "reasonCode": "DISPUTE_RESOLVED", "approvedByOperatorId": "user_fin01" } ``` | 필드 | 타입 | 설명 | |------|------|------| | `refundType` | `string` | `FULL_REFUND` / `PARTIAL_REFUND` | | `refundAmount` | `number` | 환불 금액 (KRW) | --- #### Inspection 이벤트 **aggregateType: `Contract`** --- ##### `inspection.submitted` 검수 사진이 업로드되어 검수 요청이 완료될 때 발행된다. | 항목 | 값 | |------|----| | `eventName` | `inspection.submitted` | | `aggregateType` | `Contract` | | `piiLevel` | `NONE` | | `triggeredBy` | `USER_ACTION` | payloadJson: ```json { "aggregateId": "contract_abc123", "contractId": "contract_abc123", "inspectionRecordId": "insp_xyz", "inspectionType": "DEMOLITION_COMPLETION", "previousStatus": "REQUESTED", "currentStatus": "SUBMITTED", "photoCount": 6, "submittedByUserId": "user_vendor01" } ``` --- ##### `inspection.approved` / `inspection.rejected` 검수가 승인 또는 거절될 때 발행된다. 1차(폐업자)와 2차(운영자) 승인 모두 이 이벤트를 사용하며 `approvalStage`로 구분한다. | 항목 | 값 | |------|----| | `eventName` | `inspection.approved` 또는 `inspection.rejected` | | `aggregateType` | `Contract` | | `piiLevel` | `LOW` | | `triggeredBy` | `USER_ACTION` 또는 `OPERATOR_ACTION` | payloadJson (approved): ```json { "aggregateId": "contract_abc123", "contractId": "contract_abc123", "inspectionRecordId": "insp_xyz", "previousStatus": "REVIEWING", "currentStatus": "APPROVED", "approvalStage": "OPERATOR_FINAL", "reviewedByUserId": "user_op01" } ``` | 필드 | 타입 | 설명 | |------|------|------| | `approvalStage` | `string` | `OWNER_PRIMARY` (폐업자 1차) / `OPERATOR_FINAL` (운영자 2차) | | `reviewedByUserId` | `string` | 검수를 처리한 사용자 또는 운영자 userId | --- #### Dispute 이벤트 **aggregateType: `Contract`** --- ##### `dispute.opened` 분쟁이 접수될 때 발행된다. 에스크로가 즉시 `DISPUTED` 상태로 전환된다. | 항목 | 값 | |------|----| | `eventName` | `dispute.opened` | | `aggregateType` | `Contract` | | `piiLevel` | `LOW` | | `triggeredBy` | `USER_ACTION` 또는 `OPERATOR_ACTION` | payloadJson: ```json { "aggregateId": "contract_abc123", "contractId": "contract_abc123", "disputeCaseId": "disp_xyz", "openedByUserId": "user_founder01", "reasonCode": "QUALITY_ISSUE", "previousEscrowStatus": "HOLDING", "currentEscrowStatus": "DISPUTED" } ``` | 필드 | 타입 | 설명 | |------|------|------| | `reasonCode` | `string` | `QUALITY_ISSUE` / `SCOPE_DIFFERENCE` / `TIMELINE_BREACH` / `DAMAGE` / `INCOMPLETE_WORK` / `OTHER` | | `previousEscrowStatus` | `string` | 분쟁 접수 전 에스크로 상태 (`HOLDING` 또는 `RELEASE_REVIEW`) | | `currentEscrowStatus` | `string` | `DISPUTED` (고정값) | --- ##### `dispute.resolved` 분쟁이 해결될 때 발행된다. | 항목 | 값 | |------|----| | `eventName` | `dispute.resolved` | | `aggregateType` | `Contract` | | `piiLevel` | `LOW` | | `triggeredBy` | `OPERATOR_ACTION` | payloadJson: ```json { "aggregateId": "contract_abc123", "contractId": "contract_abc123", "disputeCaseId": "disp_xyz", "previousDisputeStatus": "MEDIATING", "currentDisputeStatus": "RESOLVED", "resolutionCode": "PARTIAL_REFUND", "refundAmount": 2000000, "settledAmount": 3000000, "resolvedByOperatorId": "user_trust01" } ``` | 필드 | 타입 | 설명 | |------|------|------| | `resolutionCode` | `string` | `FULL_RELEASE` / `PARTIAL_REFUND` / `FULL_REFUND` / `NEGOTIATED` | | `refundAmount` | `number?` | 환불 금액. `FULL_RELEASE`인 경우 0 또는 null | | `settledAmount` | `number?` | 정산 금액. `FULL_REFUND`인 경우 0 또는 null | --- ### 3-7. Backoffice 도메인 --- #### `operator.assigned` 운영자가 케이스(SubsidyCase, MatchRequest, DisputeCase 등)에 배정될 때 발행된다. | 항목 | 값 | |------|----| | `eventName` | `operator.assigned` | | `aggregateType` | 배정 대상 엔티티 타입 (예: `SubsidyCase`, `MatchRequest`) | | `piiLevel` | `LOW` | | `triggeredBy` | `OPERATOR_ACTION` 또는 `SYSTEM_AUTO` | payloadJson: ```json { "aggregateId": "scase_abc123", "targetEntityType": "SubsidyCase", "targetEntityId": "scase_abc123", "assignedOperatorId": "user_op01", "assignedByUserId": "user_manager01" } ``` | 필드 | 타입 | 설명 | |------|------|------| | `targetEntityType` | `string` | 배정 대상 엔티티 타입 | | `assignedOperatorId` | `string` | 배정된 운영자 userId | | `assignedByUserId` | `string?` | 배정을 실행한 상위 운영자. 시스템 자동 배정이면 null | --- #### `policy.version_activated` 정책 버전이 활성화될 때 발행된다. | 항목 | 값 | |------|----| | `eventName` | `policy.version_activated` | | `aggregateType` | `PolicyVersion` | | `piiLevel` | `NONE` | | `triggeredBy` | `OPERATOR_ACTION` | payloadJson: ```json { "aggregateId": "polv_xyz", "policyVersionId": "polv_xyz", "policyType": "SUBSIDY_CHECKLIST", "versionCode": "HOPE_RETURN_PKG_V2", "previousVersionCode": "HOPE_RETURN_PKG_V1", "effectiveFrom": "2026-04-01T00:00:00Z", "activatedByOperatorId": "user_admin01" } ``` --- ## 4. Outbox 발행 정책 ### 4-1. Outbox 발행 대상 이벤트 `OutboxEvent`는 `EventLog`와 별도로 외부 시스템(알림 서비스, 분석 파이프라인 등)으로의 비동기 발행을 담당한다. 아래에 해당하는 이벤트는 `EventLog` 기록과 동시에 `OutboxEvent`로도 저장한다. | 이벤트 | 발행 이유 | 소비자 | |--------|----------|--------| | `store.approved` | 공개 전환 안내 알림 트리거 | 알림 서비스 | | `store.rejected` | 반려 사유 안내 알림 트리거 | 알림 서비스 | | `store.published` | 구독자 알림 (Phase 2 준비) | 알림 서비스 | | `match.requested` | 폐업자 매칭 요청 수신 알림 | 알림 서비스 | | `match.accepted` | 창업자/업체 매칭 수락 알림 + 계약 생성 트리거 | 알림 서비스, 계약 서비스 | | `match.rejected` | 창업자/업체 매칭 거절 알림 | 알림 서비스 | | `match.expired` | 만료 알림 | 알림 서비스 | | `subsidy.case_created` | 운영자 큐 등록 트리거 | 운영 콘솔 | | `subsidy.document_reviewed` | 서류 검토 결과 알림 | 알림 서비스 | | `subsidy.submitted` | 운영자 최종 접수 알림 | 알림 서비스 | | `subsidy.approved` | 지원금 승인 결과 알림 | 알림 서비스 | | `subsidy.rejected` | 지원금 반려 결과 알림 | 알림 서비스 | | `vendor.certification_approved` | 업체 인증 승인 알림 + 매칭 참여 자격 활성화 | 알림 서비스, 매칭 서비스 | | `vendor.certification_rejected` | 업체 인증 반려 알림 | 알림 서비스 | | `contract.signed` | 에스크로 결제 요청 트리거 | 결제 서비스 | | `contract.completed` | 완료 알림 + KPI 집계 트리거 | 알림 서비스, 분석 파이프라인 | | `escrow.holding` | 에스크로 보관 확인 알림 | 알림 서비스 | | `escrow.released` | 정산 완료 알림 | 알림 서비스 | | `escrow.refunded` | 환불 완료 알림 | 알림 서비스 | | `inspection.submitted` | 검수 요청 수신 알림 | 알림 서비스 | | `inspection.approved` | 정산 검토 단계 진입 트리거 | 에스크로 서비스 | | `dispute.opened` | 분쟁 접수 운영자 알림 + 에스크로 보류 트리거 | 알림 서비스, 에스크로 서비스 | | `dispute.resolved` | 분쟁 해결 알림 + 정산/환불 트리거 | 알림 서비스, 결제 서비스 | 발행 대상이 아닌 이벤트(`user.profile_updated`, `store.photo_uploaded`, `store.deal_status_changed` 등)는 `EventLog`에만 기록하고 `OutboxEvent`는 생성하지 않는다. ### 4-2. 트랜잭션 보장 `EventLog`와 `OutboxEvent`는 반드시 동일한 DB 트랜잭션 내에서 도메인 상태 변경과 함께 커밋한다. ``` BEGIN TRANSACTION -- 도메인 상태 변경 (Store, MatchRequest, SubsidyCase 등) -- EventLog INSERT -- OutboxEvent INSERT (발행 대상인 경우만) COMMIT ``` 애플리케이션 코드에서 `EventLog` 커밋 후 별도로 `OutboxEvent`를 삽입하지 않는다. 트랜잭션 바깥에서 삽입하면 상태 불일치가 발생한다. ### 4-3. OutboxEvent 필드 | 필드 | 설명 | |------|------| | `aggregateType` | `EventLog`의 `aggregateType`과 동일 | | `aggregateId` | `EventLog`의 `aggregateId`와 동일 | | `eventName` | `EventLog`의 `eventName`과 동일 | | `payloadJson` | 외부 발행용 payload. `HIGH` PII 필드는 제거 또는 마스킹 후 저장. | | `publishStatus` | `PENDING` → `PUBLISHED` / `FAILED` / `DEAD_LETTER` | | `availableAt` | 최초 처리 가능 시각. 기본값 `NOW()`. 재시도 시 갱신. | | `retryCount` | 재시도 횟수. 0부터 시작. | | `lastError` | 마지막 실패 오류 메시지 (최대 2000자). | ### 4-4. 재시도 정책 발행 실패 시 지수 백오프(Exponential Backoff) 방식으로 재시도한다. | 시도 | 대기 시간 | |------|----------| | 1차 | 1분 후 | | 2차 | 5분 후 | | 3차 | 15분 후 | | 4차 | 1시간 후 | | 5차 | 4시간 후 | 최대 재시도 횟수: 5회. ### 4-5. Dead-Letter 처리 5회 재시도 모두 실패한 경우 아래 처리를 실행한다. 1. `OutboxEvent.publishStatus`를 `DEAD_LETTER`로 변경한다. 2. `FINANCE_OPERATOR` 또는 `OPS_MANAGER`에게 내부 알림을 발송한다. 3. 운영자가 수동으로 재발행하거나 해당 이벤트를 수동 처리한다. 4. 재발행 실행 시 `retryCount`를 0으로 초기화하고 `availableAt`을 현재 시각으로 설정한다. 5. 모든 수동 재발행 이력은 `AuditLog`에 기록한다. Dead-Letter 이벤트는 삭제하지 않는다. 원인 분석과 감사 목적으로 영구 보관한다. --- ## 5. 향후 확장 ### 5-1. 알림 트리거 이벤트 Phase 2에서 알림 서비스가 고도화되면 이벤트 기반 알림 발송 규칙을 아래 이벤트 중심으로 선언적으로 정의할 예정이다. | 이벤트 | 알림 수신자 | 발송 채널 | |--------|------------|----------| | `store.approved` | 폐업자 | 이메일 | | `store.rejected` | 폐업자 | 이메일 | | `match.requested` | 폐업자 | 카카오 알림톡 | | `match.accepted` | 창업자/업체 | 카카오 알림톡 | | `subsidy.document_reviewed` (REJECTED) | 폐업자 | 카카오 알림톡 + 이메일 | | `subsidy.approved` | 폐업자 | 카카오 알림톡 + 이메일 | | `escrow.holding` | 계약 당사자 | 이메일 | | `escrow.released` | 계약 당사자 | 이메일 + 카카오 알림톡 | | `dispute.opened` | 운영자(`TRUST_OPERATOR`) | 내부 알림 | 향후 `AlertSubscription` 모델을 도입하면 업체(VENDOR_MANAGER) 대상 B2B 알림 상품도 이 이벤트 스트림을 기반으로 구현한다(D003 섹션 6-3 참조). ### 5-2. KPI 집계 이벤트 Phase 2에서 `KpiSnapshot` 모델을 도입하면 아래 이벤트를 집계 파이프라인의 입력으로 활용한다. | KPI | 기반 이벤트 | |-----|------------| | 매장 등록 → 공개 전환율 | `store.submitted` → `store.published` | | 매칭 요청 → 수락 전환율 | `match.requested` → `match.accepted` | | 매칭 → 계약 완료율 | `match.accepted` → `contract.completed` | | 지원금 케이스 완료율 | `subsidy.case_created` → `subsidy.approved` | | 에스크로 정산 소요 시간 | `escrow.holding` → `escrow.released` | | 분쟁 발생률 | `contract.signed` 대비 `dispute.opened` | | 분쟁 해결 소요 시간 | `dispute.opened` → `dispute.resolved` | 집계 이벤트는 별도의 `KpiSnapshot` 테이블 또는 외부 데이터 웨어하우스에서 처리하며, 원본 `EventLog`에 직접 집계 쿼리를 수행하지 않는다. ### 5-3. 이벤트 스키마 레지스트리 이벤트 종류가 증가하면 다음 관리 체계를 도입한다. - 각 `eventName`의 JSON Schema를 별도 파일(`docs/analytics/event-schemas/{eventName}.json`)로 관리한다. - 이벤트 발행 전 payload를 JSON Schema로 검증하는 미들웨어를 적용한다. - 버전별 스키마 이력을 보존하여 구버전 소비자와의 호환성을 유지한다. --- ## 부록 ### A. 이벤트 이름 전체 목록 | 이벤트 이름 | aggregateType | piiLevel | OutboxEvent | |------------|---------------|----------|:-----------:| | `user.registered` | `User` | LOW | - | | `user.verified` | `User` | LOW | - | | `user.profile_updated` | `User` | LOW | - | | `user.consent_granted` | `User` | LOW | - | | `user.consent_revoked` | `User` | LOW | - | | `user.suspended` | `User` | LOW | - | | `user.reactivated` | `User` | LOW | - | | `store.draft_created` | `Store` | LOW | - | | `store.submitted` | `Store` | LOW | - | | `store.approved` | `Store` | LOW | O | | `store.rejected` | `Store` | LOW | O | | `store.published` | `Store` | LOW | O | | `store.unpublished` | `Store` | LOW | - | | `store.deal_status_changed` | `Store` | LOW | - | | `store.photo_uploaded` | `Store` | NONE | - | | `store.photo_removed` | `Store` | NONE | - | | `match.requested` | `MatchRequest` | LOW | O | | `match.accepted` | `MatchRequest` | LOW | O | | `match.rejected` | `MatchRequest` | LOW | O | | `match.expired` | `MatchRequest` | LOW | O | | `match.cancelled` | `MatchRequest` | LOW | - | | `match.completed` | `MatchRequest` | LOW | - | | `subsidy.case_created` | `SubsidyCase` | LOW | O | | `subsidy.eligibility_checked` | `SubsidyCase` | LOW | - | | `subsidy.document_uploaded` | `SubsidyCase` | LOW | - | | `subsidy.document_reviewed` | `SubsidyCase` | LOW | O | | `subsidy.submitted` | `SubsidyCase` | LOW | O | | `subsidy.approved` | `SubsidyCase` | LOW | O | | `subsidy.rejected` | `SubsidyCase` | LOW | O | | `vendor.registered` | `Vendor` | LOW | - | | `vendor.certification_applied` | `Vendor` | LOW | - | | `vendor.certification_approved` | `Vendor` | LOW | O | | `vendor.certification_rejected` | `Vendor` | LOW | O | | `contract.created` | `Contract` | LOW | - | | `contract.signed` | `Contract` | LOW | O | | `contract.completed` | `Contract` | LOW | O | | `contract.cancelled` | `Contract` | LOW | - | | `escrow.deposit_initiated` | `Contract` | NONE | - | | `escrow.holding` | `Contract` | NONE | O | | `escrow.released` | `Contract` | NONE | O | | `escrow.refunded` | `Contract` | NONE | O | | `inspection.submitted` | `Contract` | NONE | O | | `inspection.approved` | `Contract` | LOW | O | | `inspection.rejected` | `Contract` | LOW | - | | `dispute.opened` | `Contract` | LOW | O | | `dispute.resolved` | `Contract` | LOW | O | | `operator.assigned` | (대상 엔티티) | LOW | - | | `policy.version_activated` | `PolicyVersion` | NONE | - | ### B. 관련 문서 | 문서 | 경로 | |------|------| | Prisma 스키마 초안 | `/docs/database/schema-prisma-draft.md` | | 지원금 대행 정책서 (D001) | `/docs/policies/subsidy-policy.md` | | 계약-에스크로-분쟁 정책서 (D002) | `/docs/policies/contract-escrow-policy.md` | | 정보 공개 정책서 (D003) | `/docs/policies/data-exposure-policy.md` | ### C. 개정 이력 | 버전 | 날짜 | 변경 내용 | 작성자 | |------|------|----------|--------| | 1.0.0 | 2026-03-07 | 초안 작성 (MVP Phase 1 전 도메인) | - |