[BE-32] 알림 마스킹·설정 검증
작업 내용 (설계 의도)
왜 필요한가
Discord 알림은 자동매매의 유일한 관측 창구다(PRD — M1~M2 는 화면 없이 알림으로만 운영한다). 그래서 두 가지가 동시에 성립해야 한다: 민감값이 새지 않을 것, 그리고 알림이 매매 경로를 붙잡지 않을 것. 지금은 둘 다 어긋나 있다.
결함 1 — 마스킹이 토큰을 끝까지 가리지 못한다 (보안)
DiscordAutoTradingAlertGateway.kt:219 의 SENSITIVE 패턴은 값 부분을 [A-Za-z0-9._\-]+ 로 잡는다. Base64 문자인 +·/·= 가 빠져 있다.
| 입력 | 현재 출력 | 문제 |
|---|---|---|
access_token=abc+/secret== | access_token=abc***+/secret== | + 에서 매칭이 끊겨 뒷부분이 그대로 전송된다 |
1234-5678-9012 | 1234-5678-9012 | 구분자가 섞여 \d{10,} 에 걸리지 않아 계좌번호가 원문 노출된다 |
토스 액세스 토큰과 국내 증권 계좌번호가 실제로 갖는 형태다. PRD 보안 요구사항 위반이고, 새는 채널이 Discord 라 되돌릴 수 없다.
보안 테스트가 이 누출을 놓친 이유도 같다 — 단순 영숫자 토큰과 연속 숫자 계좌번호만 케이스로 두었다. 패턴이 통과시키도록 만들어진 입력만 테스트했다.
결함 2 — 과도 마스킹이 장애 진단을 막는다
:230 의 ACCOUNT_DIGITS = \d{10,} 는 10자리 이상 연속 숫자를 전부 계좌번호로 본다. 6자리 종목코드와 9자리 이하 금액은 보존되지만, 10억원 이상 금액과 10자리 이상 주문 식별자는 *** 로 지워진다. 큰 금액이 얽힌 사고일수록 알림만으로 원인을 짚을 수 없게 된다 — 가장 급한 순간에 진단 정보가 사라진다.
결함 3 — error-alert 채널 미설정을 아무도 모른다
게이트웨이는 @Value("${notification.discord.error-webhook-url:${DISCORD_ERROR_WEBHOOK_URL:}}") 로 읽는데 application.yml:78 의 notification.discord 블록에는 webhook-url 만 있다. 중첩 기본값 덕에 기동은 성공하고, 미설정 상태에서 알림은 no-op 로그로 조용히 사라진다.
운영자는 최초 사고 알림이 누락된 뒤에야 미설정을 알게 된다. 기동 시점 검증도, health indicator 도, 시작 경고도 없다.
결함 4 — 알림이 정책 락 보유 구간 안에서 동기 호출된다 (성능·NFR)
알림은 동기 HTTP 호출이고 connect 2초 + read 3초, 최대 5초를 기다린다(BE-09). 설계된 후보 트랜잭션은 정책 행 공유 락(loadForUpdate())을 잡은 뒤 그 안에서 알림까지 호출한다. Discord 가 느린 동안 킬 스위치 쓰기가 대기한다.
킬 스위치는 최후 정지 수단이고 PRD NFR 은 1초 내 반영을 요구한다. 유한 타임아웃이라 영구 정지는 아니지만, 1초 예산에 5초짜리 외부 호출을 넣은 구조 자체가 부적절하다.
변경 사항
| # | 작업 | 판정 기준 |
|---|---|---|
| 1 | SENSITIVE 값 패턴에 Base64 문자(+·/·=)와 JWT 구분자를 포함한다 | access_token=abc+/secret== 이 전부 *** 가 된다 |
| 2 | 구분자 포함 번호(-·공백 섞인 10자리 이상 숫자열)를 계좌번호로 인식한다 | 1234-5678-9012 가 마스킹된다 |
| 3 | 과도 마스킹을 완화한다 — 금액·주문 식별자를 계좌번호와 구분한다 | 콤마 포맷 금액(1,234,567,890)과 알림이 직접 만든 필드는 보존된다 |
| 4 | error-webhook-url 미설정을 기동 시점에 드러낸다 | 기동 로그에 WARN 이 남고 health indicator 가 미설정을 보고한다 |
| 5 | 알림 호출을 정책 락 보유 구간 밖으로 뺀다 | 락 보유 시간에 Discord 응답 시간이 포함되지 않는다 |
3번 — 과도 마스킹 완화 방향
숫자열을 문맥 없이 판정하는 한 금액과 계좌번호는 구분되지 않는다. 판정을 문맥으로 옮긴다.
| 방안 | 설명 | 평가 |
|---|---|---|
| 포맷 기반 구분 | 알림이 만드는 금액은 DecimalFormat("#,##0.##") 로 항상 콤마가 들어간다(:180). 콤마 없는 순수 숫자열만 계좌번호 후보로 본다 | 채택 후보. 기존 코드가 이미 만드는 성질을 이용해 추가 상태가 없다 |
| 필드 화이트리스트 | 마스킹을 자유 텍스트(예외 메시지)에만 적용하고 게이트웨이가 조립한 필드는 통과시킨다 | 채택 후보. 누출 위험이 있는 표면이 자유 텍스트뿐이라는 사실과 맞는다 |
| 임계 상향(12자리 등) | 계좌번호가 10 | 미채택 — 보안을 진단 가능성과 맞바꾼다 |
구현자가 두 채택 후보 중 하나를 고르거나 조합하고, 판정 기준과 근거를 헬퍼 KDoc 에 남긴다 — 근거가 없으면 다음 담당자가 같은 헬퍼를 “더 정확하게” 다시 만든다.
4번 — 미설정 노출 방식
부팅을 실패시키지 않는다. error-alert 는 보조 채널이고, 미설정으로 앱 전체를 세우면 로컬 개발이 막힌다. 기동 경고 + health indicator 조합으로 드러낸다. application.yml 에 키를 빈 문자열로 명시 선언해 운영자가 yml 만 보고도 존재를 인지하게 한다.
5번 — 락 구간에서 알림 빼기
| 방안 | 설명 | 평가 |
|---|---|---|
| 트랜잭션 종료 후 발송 | 알림 대상을 모아 두고 커밋 이후 발송한다 | 채택 권고. 락 보유 시간에서 외부 호출이 완전히 빠진다. “체결됐는데 알림이 없다”는 상태가 잠깐 생기지만, 알림은 관측 수단이지 원장이 아니다 |
@TransactionalEventListener(AFTER_COMMIT) | Layer 1 이벤트로 분리 | 채택 가능. 다만 TDD “이벤트 아키텍처 판단”이 현 단계에서 동기 호출로 충분하다고 결론냈으므로, 도입하려면 TDD 를 함께 갱신한다 |
| 타임아웃 단축(1초) | connect·read 를 줄인다 | 단독 미채택 — 락 보유 시간이 여전히 외부 서비스에 좌우된다. 위 둘과 병행하는 완화책이다 |
BE-34 가 트랜잭션 경계를 재설계하므로 이 티켓은 “알림이 락 밖에서 호출된다”는 요구를 계약으로 명시하고 게이트웨이 쪽 준비(비동기 발송 가능 형태)까지 소유한다. 호출 위치 이동 자체는 BE-34 가 수행한다.
흡수한 기존 티켓
BE-20 의 알림 설정 부분을 이 티켓이 흡수한다. 멱등키 부분은 BE-28 이 가져간다.
소유 파일 — BE-16 과의 경계
autotrading/infrastructure/notification/DiscordAutoTradingAlertGateway.kt + 그 테스트를 소유한다.
application.yml은 BE-16(wave 5)도 소유한다. 같은 wave 에서 두 티켓이 같은 파일을 쓰면 머지 충돌이다. 둘 중 하나를 택한다.
- BE-16 머지 이후 착수 — yml 키 추가를 이 티켓이 직접 한다.
- BE-16 에 위임 —
notification.discord.error-webhook-url: ${DISCORD_ERROR_WEBHOOK_URL:}한 줄 추가를 BE-16 작업 범위에 넣고, 이 티켓은 코드만 소유한다.기본은 위임이다. 마스킹·기동 검증·락 분리는 yml 없이도 전부 구현·검증할 수 있어, yml 한 줄 때문에 wave B 착수를 미룰 이유가 없다. 착수 전 담당자가 이 결정을 PR 에 1줄로 남긴다.
롤백
마스킹 패턴 강화는 되돌리기 안전하다 — 이전 커밋으로 복귀하면 된다. 다만 강화 이후 발송된 알림은 회수할 수 없으므로, 과도 마스킹 완화(3번)는 실제 알림 본문으로 사전 검증한 뒤 배포한다. yml 키는 제거해도 중첩 기본값이 동작해 기동에 영향이 없다.
의존
- 없음 (wave B 선두).
application.yml은 이 티켓이 먼저 소유한다 — 통합 티켓이 그 위에 키를 더한다.
다이어그램
처리 흐름
sequenceDiagram participant Svc as AutoTradingDomainService participant Tx as 후보 트랜잭션 participant Gw as DiscordAlertGateway participant Discord as Discord Webhook Svc->>Tx: 정책 락 획득 Svc->>Tx: 제안 저장·집행 기록 Tx-->>Svc: 커밋 (락 해제) Svc->>Gw: notifyBuyExecuted(마스킹 적용) Gw->>Discord: POST (connect 2s / read 3s) Discord-->>Gw: 204
클래스 의존
flowchart LR Gw[DiscordAlertGateway] --> Mask[민감값 마스커] Mask --> Token[토큰 패턴 Base64 포함] Mask --> Acct[계좌번호 패턴 구분자 포함] Gw --> Cfg[error-webhook-url 설정] Cfg --> Health[health indicator] Cfg --> Warn[기동 경고 로그]
테스트 케이스
access_token=abc+/secret==이 포함된 예외 메시지에서 값 전체가***로 치환된다(Base64 문자 누출 차단).Bearer eyJhbGciOi.J9.sig-part_x형태의 JWT 가 전부 마스킹된다.- 구분자가 섞인 계좌번호
1234-5678-9012가 마스킹된다. - 공백이 섞인 계좌번호
1234 5678 9012가 마스킹된다. - 6자리 종목코드
005380은 마스킹되지 않는다(진단 가능성 보존). - 콤마 포맷 금액
1,234,567,890원이 마스킹되지 않는다(과도 마스킹 완화 — 현재는 잘린다). error-webhook-url이 빈 문자열이면 기동 시WARN로그가 남고 health indicator 가 미설정을 보고한다.error-webhook-url이 빈 문자열이어도notifyOrderFailure가 예외를 던지지 않고 no-op 으로 끝난다(기존 동작 보존).- Discord 응답이 3초 read timeout 에 걸려도 호출부가 정책 락을 보유하지 않은 상태였음을 검증한다(락 밖 발송 계약).
- 마스킹 대상 문자열이 비어 있거나 민감값이 없으면 원문이 그대로 유지된다(경계값).