운영 & 컴플라이언스
알림 운영 규칙, 감사 로그 보존, 수익 연체 추적, TVL 상한 강제를 다룬다.
알림 규칙
📖 정본은 알림 시스템이다. 이벤트 목록, 수신자, FM 격리, 문구 레지스트리, 환경설정, 이메일·인앱 발송 모델이 전부 거기 있다. 이 페이지는 운영 규칙만 담아서, 이벤트가 추가될 때 고칠 곳이 한 군데가 되게 한다.
예전에 여기 있던 이벤트별 매트릭스는 옮긴 게 아니라 없앴다. 지금 66개 키를 담고 있는 레지스트리의 13행짜리 스냅샷이었고, 사람들이 그걸 목록으로 읽었다.
- 채널(V1): 이메일과 인앱. 인앱은 항상 켜져 있는 기본선이다. 모든 알림이 피드에 남고 이벤트별 토글이 아니다. 이메일은 트랜잭션성이고 영문 전용이며
no-reply@aset.finance에서 나간다. Telegram과 Slack은 V2다. - Critical은 항상 발송되고 끌 수 없다(D4). 중대 이벤트, 상환 플로우, 계정 안전이 해당된다. 선택 이벤트는
notification_preferences를 따르고, 이제 세 대상 모두에 강제된다. 어드민과 FM은 2026-07-29부터(마이그레이션 0100/0101), 투자자는 2026-07-30부터다(마이그레이션 0108). - 환경설정은 문구 레지스트리의
event_key를 키로 쓴다. 그래서 발송 시점 게이트가notification_preferences.category와 **notification_events.event_key**를 매핑 계층 없이 비교한다. 다른 것을 이름으로 쓰는 토글은 강제될 수 없고 조용히 실패한다. 두 설정 패널이 예전에 정확히 그렇게 하고 있었다. - 재시도: SQS 재전달을 1/5/15/60/240분 백오프로 하고 그다음 DLQ로 간다. 최종 실패 시 워커가 어드민에게
fm_notification_failed를 올린다. 무해한 결과는 의도적으로 그 큐에서 뺀다. 수신 거부, 주소 없음, 차단된 주소는 각각 실패가 아니라 사유(OPTED_OUT/NO_ADDRESS/SUPPRESSED_ADDRESS)와 함께SKIPPED발송으로 기록되고,INVALID_RECIPIENT는 실패 알림 KPI에서 제외된다(0107). KPI는FAILED만 센다. 절대 0이 되지 않는 운영 큐는 아무도 안 보게 된다.
데이터 모델: 0110 이후 테이블 셋
두 역할을 겸하던 notification_logs 행은 마이그레이션 0110에서 삭제됐다(v3-103). 현재 모델은 이렇다.
| 테이블 | 담는 것 |
|---|---|
notification_events | 일어난 일 하나당 행 하나. event_key를 키로 쓴다 |
notifications | 이벤트별·수신자별 행 하나(인앱 수신함 항목) |
notification_deliveries | 채널 시도 하나당 행 하나. 상태는 delivery_status |
그래서 SENDING 상태가 더 이상 없고 조회할 notification_logs도 없다. delivery_status는 PENDING / SENT / BOUNCED / COMPLAINED / FAILED / SKIPPED다. 11-db-schema → 알림 참조.
감사 로그 보존과 내보내기
감사 피드는 스트림 둘을 읽기 시점에 합친 것이다 (v3-41), v3-86에서 범위 재조정
감사 피드는 조회 시점에 두 출처를 합친다. v3-86이 이 로그의 성격을 **감사 기록(정본)**으로 못 박고, 운영용 Activity 피드를 그 위에 얹은 읽기 뷰로 두며, 각 스트림이 무엇을 쓰는지 다시 정했다.
- 온체인·경제 이벤트는 인덱서 테이블에서 읽는다.
onchain-indexer.scheduler가 모든 컨트랙트 이벤트(Deposited,Redemption*,Yield*,Nav*,EmergencyFrozen/Unfrozen, LPTransfer)를tx_hash/log_index출처와 함께 전용 테이블(deposits,redemption_requests,yield_distributions,nav_history,redemption_epochs,portfolio_positions)로 미러링한다. 감사 API는 이를 노출할 뿐activity_events에 다시 INSERT하지 않는다(이중 쓰기도, 두 번째 정본도 만들지 않는다). 행위자는 투자자(users조인)이거나 시스템·지갑이다. - 사람의 권한 행사는
activity_events에 쓴다. admin-web에서 API로 넘어가는 경계에서 기록한다. 범위는 돈, 권한, 투자자 상태, 풀·펀드 상태, 민감 데이터에 닿는 재량 행위다. 현재 배선된 것:FREEZE/UNFREEZE/PAUSE,IMPAIR_*/WINDDOWN_*,LIFECYCLE, 거버넌스,NAV_*,REDEMPTION_APPROVE/REJECT/HOLD/RELEASE/NOTIFY/ESCALATE,FEE_WITHDRAW,PII_ACCESS, 그리고POOL_ARCHIVE/POOL_RESTORE(v3-110. 각각pools.delete.ts와pools.patch.update.ts이고, 아카이브·복원은 이미 감사 줄을 쓰고 있던 유일한 풀 변경 쌍이다). v3-86이 추가한 것(라이터를 만들어야 함): 풀·트랜치 생성·배포·발행·수정, 펀드 CRUD와 fund-member, 어드민 유저 CRUD와 권한과 어드민 지갑 연결·검증, 투자자 적격성·등급.
행위자와 기록 모델 (v3-86)
- 행위자는 하이브리드로 해석한다. 사람의 쓰기 스트림은 쓰기 시점에
actor_name과actor_role을 스냅샷한다(조인은 폴백일 뿐이다). 그래야 이름이 바뀌거나 계정이 삭제돼도 기록이 살아남는다. 투자자의 경제 이벤트는users조인으로 해석하고, 스케줄러와 인덱서는 "System"이다. 표시는이름 · 역할 배지이고, 온체인 서명자는 System과tx_hash로 표시한다. - 표준 기록: 언제 · 누가(스냅샷) · 무엇을 · 대상(라벨 스냅샷) · 변경(이전→이후) · 왜(
reason) · 증거(tx_hash) · 심각도 · 결과다.reason은 고위험 행위에만 필수다(impair · wind-down · freeze · 권한 · 적격성). 실패한 시도도outcome=failure로 기록한다. - UI는 Audit / Activity 두 탭이다. Audit은 사람의 쓰기 스트림(기본)이고, Activity는 경제·온체인 전체 피드다. 필터는 날짜 · 행위자 검색 · 분류 · 결과 · 심각도다. FM은 자기 펀드의 Activity만 본다(내부 감사, 권한, PII는 못 본다).
| 항목 | 값 |
|---|---|
| 보존 기간 | 5년. 싱가포르 MAS Notice PSN02(DPT 기록 5년 이상), 태국 AMLA §22(5년 이상, 처음 2년은 즉시 조회 가능). 말레이시아 법인이 적용되면 6~7년(법무 확인 필요). |
| 불변성 | append-only이고 DB가 강제한다(v3-86 / 마이그레이션 0082). BEFORE UPDATE/DELETE 트리거가 모든 role(Lambda 서비스 키 포함)에 대해 activity_events 변경을 막는다. DELETE는 archive_expired_activity_events() 안에서만 허용된다. 해시 체인 위변조 탐지는 보류됐다. |
| 아카이브 | 5년이 지난 기록은 archive_expired_activity_events()(atomic INSERT+DELETE)로 activity_events_archive 콜드 테이블로 옮긴다(v3-86 / 0082). 핫 테이블과 audit_feed에서 빠질 뿐 파기되지 않는다. 이전의 하드 삭제 잡을 대체한다. (S3 콜드 스토리지는 이후 개선 항목이다.) |
| 내보내기 형식 | CSV. 어드민 전용이다(Operator는 내보낼 수 없다). 활성 필터 조건이 반영된다. |
| 대시보드 기본값 | 최근 48시간. "View All"이 날짜 범위 필터가 있는 전체 로그를 연다. |
| 내보내기의 PII | 지갑 주소는 포함한다(가명이다). 이메일은 포함하지 않는다. |
| 출처 | activity_events(사람의 행위, 정본)와 온체인 인덱서 테이블(읽기 뷰 UNION)이다. 결정 v3-41, 범위 재조정은 v3-86 참조. |
웹 응답 보안 헤더
두 CloudFront SPA 배포판이 같은 응답 헤더 정책을 강제한다. HSTS, X-Content-Type-Options, X-Frame-Options: DENY, strict-origin 리퍼러 정책, 제한적인 Permissions Policy, Content Security Policy다. CSP는 외부 스크립트와 오브젝트, 임베딩을 막고, API·지갑·OAuth·KYC에 필요한 TLS/WSS 연결은 허용하며, 현재는 인라인 스크립트와 스타일을 허용한다. React Router의 정적 SPA 출력이 인라인 부트스트랩 코드를 내보내기 때문이다. script-src를 해시나 nonce로 조이려면 빌드 시점 해시 파이프라인이 필요하다.
수익 연체 추적
수익 연체 추적
계산: next_yield_due = 마지막으로 지급한 기간의 종료일 + yield_frequency다. 기준점은 마지막 분배가 실제로 정산된 순간이 아니라 그 분배가 덮은 기간의 끝이다. 그래서 격자가 모집 마감일에 고정되고(v3-145) 늦은 분배는 그 지연분 말고는 대가를 만들지 않는다.
🔴 다만 상환형 풀의 만기 이후에는 예외다(v3-147). 거기서는 쿠폰이 주기를 탄다. 다음 날짜는 기준점보다 늦은 가장 이른 회차 지급일(redemption_epochs.funding_date)이다. 수익 지급과 원금 상환이 같은 날 떨어지기로 합의됐고, 회차 날짜는 펀딩이 늦어도 움직이지 않기 때문이다. 주기 방식으로 두면 첫 지연 지급에서 어긋난다. 매일 도는 sweep이 그 날짜를 온체인에도 게시한다(setYieldDueDate). 그러지 않으면 컨트랙트가 일률적으로 28일씩 전진시킨다. period_start와 period_end는 호출자가 주지 않으면(어느 기록 경로도 주지 않는다) POST /yield-distributions가 유도하고, 마지막 기간은 만기에서 잘린다.
트리거: now > next_yield_due이고 기록된 분배가 없으면 yield_overdue = true로 설정한다.
조치: 어드민과 투자자에게 알린다. 풀 상세와 어드민 수익 대시보드에 연체 배지를 표시한다.
범위: UPCOMING을 뺀 모든 발행된 lifecycle이다(UPCOMING에는 갚을 홀더가 없다). 예전에는 ACTIVE만이었는데, 그건 sweep 자신의 재계산 패스와 모순됐다. 그 패스는 늘 IMPAIRED와 WIND_DOWN 풀에도 yield_overdue를 올렸고 아무에게도 통지되지 않았다. CLOSED와 MATURED는 그보다 더 앞, 스케줄 자체에서 제외돼 있었다. 그래서 기간 확정형 풀의 마지막 미지급 기간이 만기가 되는 날 날짜도, 큐 행도, 에스컬레이션도 잃었다.
참고: 연체된 기간은 행이 아니다. yield_distributions에는 총액이 기록될 때만 행이 생긴다. 그래서 어드민 Yield의 "Pending" 탭은 기록이 아니라 도래한 기간을 보여 준다. v3-104(2026-08-03) 이후 그 값은 읽기 모델에서 온다. GET /yield-distributions?include_due=true가 next_yield_due에서 풀당 행 하나를 합성하고, 실제 정산이 쓰는 것과 같은 computeYieldFees로 계산한 서버 산출 추정 총액·순액과 missed_periods를 함께 담는다. sweep은 놓친 날짜를 앞으로 굴리지 않으므로, 몇 달 밀린 풀은 몇 주기를 빚졌는지 말해 주는 행 하나가 된다. 브라우저에서 GET /pools와 ?status=PENDING을 조인하던 옛 방식과, 프론트엔드가 갖고 있던 자체 APY 산식과 수수료 분리 사본은 없어졌다.
참고: 도래 큐가 더 이상 연체 전용이 아니다. v3-120(2026-08-07) 이후 창이 도래일 7일 전까지 열린다(next_yield_due < now + 7d). 그래서 늦기 일주일 전부터 조치할 수 있다. 분배 자금을 마련하는 것은 당일 처리할 일이 아닌데, 이전 동작은 이미 연체된 날에야 처음 화면에 나타나는 것이었다.
⚠️ 따라서 이 큐의 행이 반드시 연체인 것은 아니다. 각 행이 **is_overdue**를 담고, "홀더에게 이미 빚진 돈"을 나타내는 모든 수치가 이 값으로 필터링한다. 어드민 노출 카드, By-Fund owed 합계, 연체 풀 수가 그렇다. overdue_days로 대체할 수 없다. 0에서 클램프되므로 다가오는 기간과 오늘 도래한 기간이 둘 다 0을 보고한다. 큐 전체를 더해 연체라고 이름 붙이면 그 값이 부풀고, 아무것도 실패하지 않는다.
이 창은 알림 파이프라인과 겹치는 게 아니라 그 앞에 있다. pools.scheduler.yield-due는 도래 후 YIELD_DUE_GRACE_DAYS(3일)를 기다렸다가 에스컬레이션하고 7일마다 다시 보낸다. 그래서 기간이 큐에 일주일 보이다가 연체가 되고, 그때부터 에스컬레이션 시계가 시작된다.
참고: 이제 주시할 상태는 정체된 분배다. 레거시 서버키 생성 경로가 제거되면서(v3-104) 사람이 필요한 실패 양상은 24시간이 넘은 PROCESSING 행이다. FM의 depositYield가 온체인에 올라가 수익이 풀에 앉아 있는데, 아무도 POST /{id}/distribute를 돌리지 않아 홀더가 지급받지 못한 상태다. 복구 수단은 이미 있다(그 엔드포인트와 어드민 Yield 표의 행 액션). dashboard_alert_counts.stalled_yield로 노출돼 대시보드의 "Stalled Distributions" 알림이 되고, Yield 화면의 Failed 탭에서 **Stalled**로 보인다.
✅ 알림 발송은 구현됐다(2026-08-04). yield.scheduler.stalled.ts가 일 단위 버킷 멱등성 키로 yield_distribution_stalled를 발생시키므로, 행이 정체된 동안 하루 한 번 다시 보낸다. 대상은 의도적으로 opsTeam뿐이다. 복구가 Aset 키의 ORACLE_ROLE로 게이팅돼 있어서 FM은 조치할 수 없다. (이 항목은 예전에 "문구가 없어 미구현"이라고 적혀 있었다. 인앱과 이메일 문구를 갖춰 출시됐고 그 서술은 낡은 것이었다.)
참고: 배치 실행이 의도적으로 이 상태를 만들 수 있다. v3-123(2026-08-12) 이후 Yield 검토 화면에서 가격이 매겨진 도래 풀 전부를 한 번에 서명할 수 있다. N개의 개별 depositYield 서명으로 처리한다. 입금은 확정됐는데 뒤따르는 정산이 실패하면, 실행은 그 항목을 실패가 아니라 완료로 표시하고 행에 그 사실을 적는다. 완료로 두는 것이 재시도가 같은 수익을 두 번 입금하는 것을 막고, 입금은 되돌릴 수 없기 때문이다. 운영 관점의 결과는 성공처럼 보이는 배치 실행이 정체된 행을 남길 수 있다는 것이고, 위의 24시간 sweep이 정확히 그것을 위해 있다. 실행 결과 메시지가 운영자를 그리로 안내한다.
TVL 상한 강제
TVL 상한 강제
파생값이고 플래그가 없다. "Fully Subscribed"는 pools의 tvl >= capacity로 계산한다. (옛 investment_blocked 플래그는 삭제됐다. 마이그레이션 0018, 08의 v3-29.)
- LP 민팅이 성공할 때마다
tvl을 다시 계산한다.tvl = tvl + deposit_amount - 새 입금이 제출되면 먼저
tvl < capacity를 확인한다.tvl >= capacity면 즉시 거부하고 "Pool is fully subscribed"를 보여 준다 - 상환으로
tvl이capacity아래로 내려가면 풀이 자동으로 다시 열린다. 수동 토글이 필요 없다(수동 입금 중단은 별개의is_paused플래그다)
⚠️ 예외 상황
- 상한에 닿았을 때 이미 PENDING인 입금은 계속 처리한다(
tvl이capacity를 넘기 전에 접수됐다) - PENDING 입금의 LP 민팅이 TVL을 상한 위로 밀어 올려도 그대로 처리한다(제출 시점에
tvl < capacity가 성립했다) - 상한 검사는 소급이 아니라 앞을 보는 게이트다
SBT 민팅 큐와 DLQ 알람
SBT 온체인 작업 큐
큐: aset-sbt-mint-<stage>.fifo가 모든 온체인 SBT 작업(민팅 / 소각 / 취소)을 나른다. FIFO 메시지 그룹 하나가 이들을 단일 플랫폼 서명 지갑 뒤로 직렬화한다(nonce 충돌이 없다).
생산자: KYC 심사 워커(GREEN이면 민팅, RED이면 소각·취소), reconcile sweep(멈춘 민팅 재적재), POST /kyc/mint-sbt(수동, 202를 반환).
컨슈머: kyc.worker.mint-sbt다. reportBatchItemFailures를 쓰고 가시성 타임아웃 180초, 함수 타임아웃 120초다.
재시도 후 DLQ: maxReceiveCount = 5이고 그다음 aset-sbt-mint-dlq-<stage>.fifo로 가며 14일 보존(SQS 최대치)이다.
DLQ 알람 대응 절차
DLQ에 메시지가 있다는 것은 SBT 작업이 5회 재시도를 모두 소진했다는 뜻이다. 자동 복구가 포기한 것이다.
알람: CloudWatch aset-sbt-mint-dlq-<stage>가 DLQ의 ApproximateNumberOfMessagesVisible ≥ 1(1분 주기)일 때 발동해 SNS 토픽 aset-sbt-mint-alarm-<stage>로 가고 이메일이 나간다. 수신자는 메일이 오기 전에 SNS 구독을 한 번 확인해야 한다(AWS의 이중 옵트인).
조사 항목: 서명 지갑 잔액과 가스, 체인 설정(isSupportedChain), 컨트랙트 pause 여부와 권한이다. 근본 원인을 고친 뒤 DLQ 메시지를 메인 큐로 재구동하거나, 5분 주기 reconcile sweep이 해당 APPROVED + NOT_MINTED/FAILED 유저를 다시 큐에 넣게 둔다.