[BE-71] 연락 이벤트 도메인 · Gmail 웹훅 수신 API (FR-89·92)
작업 내용 (설계 의도)
근거 TDD: 20260808-지원관리-확장-tdd.md — “방안 5 (contact 신규 컨텍스트)”, “방안 10 연락 이벤트”, “API 계약 3단계 연락 이벤트 웹훅”
변경 사항
-
신규
contact바운디드 컨텍스트를 만듭니다.application에 합류시키지 않는 이유: 연락 이벤트는 지원과 무관하게 먼저 도착하고(후보 매칭 실패 시 어느 지원에도 붙지 않습니다), 원본·파싱·후보·결정 이력을 반영 여부와 무관하게 독립 보존합니다(FR-89 “중복 수신과 오탐을 추적”). -
수신 즉시 2xx, 파싱·후보·알림은 Layer 1 이벤트로 분리합니다(방안 10 A). 동기 처리하면 Discord 발송 최대 3회 재시도(
DiscordWebhookGatewayImpl.kt:18-40) 사이에 Apps Script가 타임아웃돼 라벨을 유지하고 재전송합니다. 멱등으로 데이터는 안전하나 알림이 중복됩니다. -
레이어 경로는
Controller → UseCase → DomainService입니다 — Controller가WebhookVerificationDomainService를 직접 호출하지 않습니다(TDD 방안 2 채택안 A).presentation → application → domain위반이라 리뷰 p1 대상입니다. UseCase가 검증·파싱·저장을 오케스트레이션합니다. -
@RequestBody rawBody: String으로 받습니다 — 서명 대상이"{timestamp}.{rawBody}"이므로 재직렬화가 개입하면 검증이 깨집니다. 검증 통과 후 컨트롤러가ObjectMapper가 아니라 Spring 변환 경로로 파싱하지 않고, application 레이어의 전용 파서가rawBody를 DTO로 변환합니다. -
멱등은
provider_message_id이고, 방식은 선조회 후 삽입 + 유니크 제약 백스톱입니다.- 정상 경로:
findBy(providerMessageId)로 먼저 조회해 존재하면 제약을 건드리지 않고202 + duplicated: true를 반환합니다. Apps Script의 재전송(비2xx → 라벨 유지 → 재시도)이 이 경로로 흡수됩니다. - 백스톱: 그래도 유니크 위반이 나면 그 요청은 실패시키고 Apps Script 재시도에 맡깁니다 — 다음 시도의 선조회가 기존 행을 찾아 정상 응답합니다(자기 치유). 오염된 트랜잭션에서 복구를 시도하지 않습니다.
DataIntegrityViolationException을 잡아 같은 트랜잭션에서 재조회하지 마세요.ContactEvent는 JPA 애그리게이트라 flush 시점에 rollback-only로 마킹되어 그 뒤 조회·저장이UnexpectedRollbackException으로 새어 나갑니다(TDD 계약 2, BE-47이 실 MySQL로 확인).- REQUIRES_NEW로 감싸지도 마세요. 삽입이 곧 판정이라 별도 커밋이 되면 외부 트랜잭션 롤백 후의 정상 재시도가 중복으로 오판됩니다(BE-47 nonce와 같은 이유).
- BE-47의 JDBC 직접 삽입 방식은 여기 적용하지 않습니다 — nonce는 단일 컬럼이지만
ContactEvent는 첨부를 자식으로 갖는 애그리게이트라 JDBC 수기 INSERT가 비현실적입니다. - 발신 측이
LockService스크립트 락으로 직렬 전송하고 사용자가 1명이라 실제 경합 창이 사실상 없습니다. 5-1. 유니크 위반 경합 경로에서는 수신 이력을 남기지 않습니다 — 누락이 아니라 불가능입니다.
항목 확정 이력 저장 하지 않습니다. 제약 위반 시점에 트랜잭션이 rollback-only로 마킹돼 webhook_receipt_logs저장도 함께 실패합니다(TDD 계약 2). 이 분기에 이력 저장을 넣지 마세요 — 넣으면UnexpectedRollbackException으로 새어 나가고, 원인을 처음부터 다시 추적하게 됩니다응답 상태 503 WEBHOOK_RECEIVE_CONFLICT대체 관측 503 응답 + 앱 로그(WARN). 재시도의 선조회가 성공하면 그때 정상 경로로 이력이 남습니다 — 관측이 사라지는 게 아니라 한 회차 지연됩니다 도달 빈도 정상 경로에서는 도달하지 않습니다. 발신 측 직렬 전송 + 단일 사용자라 경합 창이 사실상 없습니다. 이 분기에 과한 방어를 넣지 마세요 503을 택한 근거 — 자기 치유가 성립하려면 발신 측이 재시도해야 합니다.
- 4xx 부적절: 클라이언트가 고칠 것이 없습니다. 4xx는 “요청을 고쳐 다시 보내라”는 뜻이라 의미가 어긋납니다.
- 500(catch-all)보다 503: 이 경로는 수신 이력을 남길 수 없어 응답 코드가 유일한 신호입니다. 진짜 버그(500)와 구분돼야 운영자가 오탐하지 않습니다.
- 발신 측 확인 완료:
Code.gs의sendMessage_가2xx가 아니면실패로 보고inbox라벨을 유지해 다음 회차에 재시도합니다 — 503도 재시도됩니다. - 예외 매핑 위치: 이 경로의
DataIntegrityViolationException·UnexpectedRollbackException은 웹훅 컨트롤러 범위의@ExceptionHandler로 매핑합니다.UnexpectedRollbackException을 전역 핸들러에 넓게 매핑하면 다른 엔드포인트의 진짜 버그를 503으로 가립니다.
- 정상 경로:
-
channel필드로 SMS 확장을 열어둡니다(FR-89 B-11) — 1차 구현은EMAIL만 처리하고SMS는 저장만 합니다. -
첨부는 메타데이터만 저장하되 저장할 곳을 신설합니다 (B-1). 기존 설계는 API 계약이 요청·응답 양쪽에서
attachments[]를 요구하면서 저장 대상이 없었습니다(계약 결함, senior-dba 지적).recruitment_contact_event_attachments테이블과ContactEventAttachment값 객체를 추가합니다.- 원본 바이너리는 받지 않습니다(PRD §156 “첨부파일 원본은 자동 전송하지 않는다”) — 파일명·MIME·크기 3개 필드만입니다.
ContactEvent애그리게이트의 자식으로 두고@OneToMany(cascade = [CascadeType.ALL], orphanRemoval = true)로 매핑해save(event)한 번에 반영합니다. RepositoryImpl에deleteAll + saveAll수동 시퀀스를 두지 않습니다(no-business-flow-in-infra).- 파일명은 표시 전용이므로 경로 구분자·상위 참조를 제거해 저장합니다(파일을 쓰지 않으므로 경로 이탈 위험은 없지만, 화면·로그에 그대로 노출되는 값이라 정규화합니다).
- 상한: 이벤트당 20건, 파일명 255자, MIME 100자. 초과분은 400입니다.
receive()시그니처에attachments: List<ContactEventAttachment>를 추가합니다.
-
Gmail Apps Script는 별도 티켓 BE-82가 산출합니다 (A-1 — 게이트 ② 결정으로 “범위 밖” 철회). 최초 판단은 계약 부록만 남기는 것이었으나, PRD FR-92(“Apps Script로 제공한다”)와 3단계 완료 판정의 자급자족을 위해 동작하는
.gs가 산출물로 확정됐습니다.- 이 티켓은 수신 측 계약(서명 대상·헤더 이름·요청 스키마)을 확정하는 책임만 집니다. 스크립트는 BE-82가 그 계약에 맞춰 배치·검증합니다.
- 계약이 바뀌면 BE-82도 함께 바뀝니다 — 서명 대상 문자열·헤더 이름은 양쪽이 정확히 일치해야 하며 어긋나면 401로 전량 실패합니다.
-
피처 플래그
contact.inbound-webhook이 OFF면 엔드포인트가 404입니다 — 스크립트가 라벨을 유지하며 재시도 대기합니다(유실 없음). -
BE-47 계약 준수 (PR #100 리뷰 p1·p2 반영) — TDD “실패를 영속화하는 경로의 트랜잭션 설계” 계약 1·2 적용 지점:
WebhookVerificationDomainService.verify()를 호출하는 UseCase는@Transactional(noRollbackFor = [WebhookVerificationFailedException::class])를 선언해야 합니다 — 그러지 않으면 검증 실패 이력 저장이 예외 롤백으로 함께 사라집니다(WebhookVerificationDomainServiceTransactionContractTest참고).putIfAbsent를 REQUIRES_NEW로 감싸지 마세요.WebhookNonceRepositoryImpl은JdbcTemplate직접 INSERT라 Hibernate Session을 거치지 않아 오염이 애초에 발생하지 않고(격리 불필요), nonce는 삽입이 곧 판정이라 별도 트랜잭션에서 먼저 커밋되면 외부 롤백 후의 정상 재시도가NONCE_REUSED로 거부됩니다(격리하면 오히려 결함). 이 UseCase가 지킬 것은 위의noRollbackFor하나뿐입니다 — 상세는 BE-47 티켓 “BE-71 계약” 절.
롤백: 플래그 OFF. 수신된 이벤트는 남지만 파싱·알림이 멈춥니다.
의존
- BE-68 (예외·enum·플래그·시크릿 환경 변수)
- BE-47 (HMAC 검증 컴포넌트 — 1단계 산출물)
다이어그램
처리 흐름
sequenceDiagram participant G as Gmail Apps Script participant C as ContactEventWebhookApiController participant U as ReceiveContactEventUseCase participant V as WebhookVerificationDomainService participant D as ContactEventDomainService participant L as ContactEventReceivedListener G->>C: POST /api/webhooks/contact-events (rawBody + 서명 + nonce) C->>U: execute(rawBody, signatureHeader, nonce) Note over U: Controller는 DomainService를 직접 부르지 않는다<br/>(presentation → application → domain) U->>V: verify(rawBody, signature, nonce) alt 검증 실패 V-->>U: WebhookVerificationFailedException U-->>C: 예외 전파 C-->>G: 401 (라벨 유지 → 재시도) else 검증 성공 U->>U: 검증 통과 후에만 rawBody를 DTO로 파싱 U->>D: receive(providerMessageId, ...) alt 중복 수신 D-->>U: 기존 이벤트 (duplicated=true) else 신규 D-->>U: 새 이벤트 end U-->>C: ReceiveContactEventResult C-->>G: 202 (라벨 이동) D->>L: AFTER_COMMIT ContactEventReceived end
클래스 의존
flowchart LR subgraph Presentation["presentation/contact"] Api[ContactEventWebhookApiController] end subgraph Application["application/contact"] UC[ReceiveContactEventUseCase] Parser[ContactEventPayloadParser] end subgraph Domain["domain"] CDS[ContactEventDomainService] Event[ContactEvent] Channel[ContactChannel] Repo[ContactEventRepository] Verify[WebhookVerificationDomainService] Flag[FeatureFlagGateway] end Api --> UC UC --> Parser UC --> Verify UC --> CDS CDS --> Event CDS --> Repo CDS --> Flag Event --> Channel
Gmail Apps Script 계약 (수신 측이 보장해야 할 규약 — 스크립트 산출은 BE-82)
트리거 : 시간 기반 5분 주기
조회 대상 : 라벨 recruitment-app/inbox 가 붙은 스레드만 (자동 키워드 검색 금지)
요청 : POST {PUBLIC_HOST}/api/webhooks/contact-events
헤더 : X-Recruitment-Signature: t={epochSeconds},v1={hmacSha256(SECRET, t + "." + body)}
X-Recruitment-Nonce: {요청마다 새 UUID}
Content-Type: application/json
본문 : { channel, providerMessageId, threadId, senderAddress, senderName,
subject, bodyText, receivedAt, attachments[{fileName, mimeType, sizeBytes}] }
성공 처리 : 2xx 수신 후에만 recruitment-app/processed 라벨 부착 + inbox 라벨 제거
실패 처리 : 2xx가 아니면 라벨 유지 → 다음 트리거에서 재시도
첨부 : 메타데이터만 전송, 원본 바이너리 미전송
테스트 케이스
- 유효한 서명으로 수신하면 202와
contactEventId가 반환되고 이벤트가 저장된다 - 같은
providerMessageId를 두 번 보내면 둘 다 202이고 두 번째는duplicated: true, 이벤트는 1건이다 - [실 DB 통합 테스트 필수] 중복 수신이 유니크 제약을 건드리지 않고 선조회 경로로
202 + duplicated: true를 반환한다 — 제약 위반이 발생하지 않았음을 확인한다(발생하면 rollback-only 오염 경로로 들어간다). Mock으로는 트랜잭션 매니저 동작이 재현되지 않으니 Testcontainers MySQL로 고정한다 - [실 DB 통합 테스트 필수] 중복 수신 요청이
UnexpectedRollbackException없이 202를 반환한다 (계약 2 회귀 가드) - [실 DB 통합 테스트 필수]
provider_message_id유니크 제약이 실제로 존재해 백스톱으로 동작한다 (선조회를 우회한 직접 삽입이 거부된다) - 유니크 위반 경합이 발생하면 503
WEBHOOK_RECEIVE_CONFLICT를 반환한다 (4xx가 아니라 재시도 가능한 상태) - 유니크 위반 경합 경로에서는 수신 이력을 남기지 않는다 — 이력 저장을 시도하지 않는 것이 정상 동작이다(트랜잭션 오염으로 불가능)
- 503을 받은 발신 측이 재시도하면 선조회 경로로
202 + duplicated: true가 반환되고 그때 이력이 남는다 (자기 치유) UnexpectedRollbackException매핑이 웹훅 컨트롤러 범위에만 적용되어 다른 엔드포인트의 500이 503으로 바뀌지 않는다- [실 DB 통합 테스트 필수] 서명 검증 실패로 401을 반환한 뒤에도
webhook_receipt_logs에 실패 이력이 커밋되어 남는다 — 예외 전파와 기록 커밋이 동시에 성립해야 한다(계약 1). 이력이 롤백되면 성공 경로만 기록되어 실패 이력만 선택적으로 사라진다 - [실 DB 통합 테스트 필수] nonce 재사용으로 401을 반환한 뒤에도
NONCE_REUSED이력이 커밋되어 남는다 - 서명이 틀리면 401이고 이벤트가 저장되지 않는다
- nonce를 재사용하면 401이다
- 인증 쿠키 없이도 서명이 유효하면 202다 (웹훅은 인증 제외 경로)
channel=SMS도 저장은 된다 (확장 대비)- 첨부 메타데이터 3건이
recruitment_contact_event_attachments에 저장되고 바이너리 필드가 없다 - 첨부가 애그리게이트 cascade로 이벤트와 한 단위로 저장된다 (RepositoryImpl에 수동 delete/insert 없음)
- 첨부 없는 이벤트는 빈 목록으로 저장된다 (0건 경계)
- 첨부가 21건이면 400이다 (경계값)
- 첨부 파일명에 경로 구분자가 있으면 제거되어 저장된다
- 첨부 파일명이 256자면 400이다 (경계값)
- 중복 수신 시 첨부가 두 벌로 늘어나지 않는다 (멱등)
bodyText가 20001자면 400VALIDATION_FAILED다 (경계값)- 필수 필드(
providerMessageId)가 없으면 400이다 - 수신 트랜잭션 커밋 이후에 리스너 이벤트가 발행된다 (AFTER_COMMIT)
- 중복 수신 시 리스너 이벤트가 다시 발행되지 않는다 (알림 중복 방지)
- 피처 플래그 OFF면 404이고 이벤트가 저장되지 않는다
- 서명 대상이 raw body 문자열 그대로여서 JSON 키 순서를 바꿔도 원 서명으로는 실패한다