[BE-32] 알림 마스킹·설정 검증

작업 내용 (설계 의도)

왜 필요한가

Discord 알림은 자동매매의 유일한 관측 창구다(PRD — M1~M2 는 화면 없이 알림으로만 운영한다). 그래서 두 가지가 동시에 성립해야 한다: 민감값이 새지 않을 것, 그리고 알림이 매매 경로를 붙잡지 않을 것. 지금은 둘 다 어긋나 있다.

결함 1 — 마스킹이 토큰을 끝까지 가리지 못한다 (보안)

DiscordAutoTradingAlertGateway.kt:219SENSITIVE 패턴은 값 부분을 [A-Za-z0-9._\-]+ 로 잡는다. Base64 문자인 +·/·= 가 빠져 있다.

입력현재 출력문제
access_token=abc+/secret==access_token=abc***+/secret==+ 에서 매칭이 끊겨 뒷부분이 그대로 전송된다
1234-5678-90121234-5678-9012구분자가 섞여 \d{10,} 에 걸리지 않아 계좌번호가 원문 노출된다

토스 액세스 토큰과 국내 증권 계좌번호가 실제로 갖는 형태다. PRD 보안 요구사항 위반이고, 새는 채널이 Discord 라 되돌릴 수 없다.

보안 테스트가 이 누출을 놓친 이유도 같다 — 단순 영숫자 토큰과 연속 숫자 계좌번호만 케이스로 두었다. 패턴이 통과시키도록 만들어진 입력만 테스트했다.

결함 2 — 과도 마스킹이 장애 진단을 막는다

:230ACCOUNT_DIGITS = \d{10,} 는 10자리 이상 연속 숫자를 전부 계좌번호로 본다. 6자리 종목코드와 9자리 이하 금액은 보존되지만, 10억원 이상 금액과 10자리 이상 주문 식별자는 *** 로 지워진다. 큰 금액이 얽힌 사고일수록 알림만으로 원인을 짚을 수 없게 된다 — 가장 급한 순간에 진단 정보가 사라진다.

결함 3 — error-alert 채널 미설정을 아무도 모른다

게이트웨이는 @Value("${notification.discord.error-webhook-url:${DISCORD_ERROR_WEBHOOK_URL:}}") 로 읽는데 application.yml:78notification.discord 블록에는 webhook-url 만 있다. 중첩 기본값 덕에 기동은 성공하고, 미설정 상태에서 알림은 no-op 로그로 조용히 사라진다.

운영자는 최초 사고 알림이 누락된 뒤에야 미설정을 알게 된다. 기동 시점 검증도, health indicator 도, 시작 경고도 없다.

결함 4 — 알림이 정책 락 보유 구간 안에서 동기 호출된다 (성능·NFR)

알림은 동기 HTTP 호출이고 connect 2초 + read 3초, 최대 5초를 기다린다(BE-09). 설계된 후보 트랜잭션은 정책 행 공유 락(loadForUpdate())을 잡은 뒤 그 안에서 알림까지 호출한다. Discord 가 느린 동안 킬 스위치 쓰기가 대기한다.

킬 스위치는 최후 정지 수단이고 PRD NFR 은 1초 내 반영을 요구한다. 유한 타임아웃이라 영구 정지는 아니지만, 1초 예산에 5초짜리 외부 호출을 넣은 구조 자체가 부적절하다.

변경 사항

#작업판정 기준
1SENSITIVE 값 패턴에 Base64 문자(+·/·=)와 JWT 구분자를 포함한다access_token=abc+/secret== 이 전부 *** 가 된다
2구분자 포함 번호(-·공백 섞인 10자리 이상 숫자열)를 계좌번호로 인식한다1234-5678-9012 가 마스킹된다
3과도 마스킹을 완화한다 — 금액·주문 식별자를 계좌번호와 구분한다콤마 포맷 금액(1,234,567,890)과 알림이 직접 만든 필드는 보존된다
4error-webhook-url 미설정을 기동 시점에 드러낸다기동 로그에 WARN 이 남고 health indicator 가 미설정을 보고한다
5알림 호출을 정책 락 보유 구간 밖으로 뺀다락 보유 시간에 Discord 응답 시간이 포함되지 않는다

3번 — 과도 마스킹 완화 방향

숫자열을 문맥 없이 판정하는 한 금액과 계좌번호는 구분되지 않는다. 판정을 문맥으로 옮긴다.

방안설명평가
포맷 기반 구분알림이 만드는 금액은 DecimalFormat("#,##0.##") 로 항상 콤마가 들어간다(:180). 콤마 없는 순수 숫자열만 계좌번호 후보로 본다채택 후보. 기존 코드가 이미 만드는 성질을 이용해 추가 상태가 없다
필드 화이트리스트마스킹을 자유 텍스트(예외 메시지)에만 적용하고 게이트웨이가 조립한 필드는 통과시킨다채택 후보. 누출 위험이 있는 표면이 자유 텍스트뿐이라는 사실과 맞는다
임계 상향(12자리 등)계좌번호가 1014자리라 상향하면 1011자리 계좌번호가 샌다미채택 — 보안을 진단 가능성과 맞바꾼다

구현자가 두 채택 후보 중 하나를 고르거나 조합하고, 판정 기준과 근거를 헬퍼 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 에 걸려도 호출부가 정책 락을 보유하지 않은 상태였음을 검증한다(락 밖 발송 계약).
  • 마스킹 대상 문자열이 비어 있거나 민감값이 없으면 원문이 그대로 유지된다(경계값).