[BE-71] 연락 이벤트 도메인 · Gmail 웹훅 수신 API (FR-89·92)

작업 내용 (설계 의도)

근거 TDD: 20260808-지원관리-확장-tdd.md — “방안 5 (contact 신규 컨텍스트)”, “방안 10 연락 이벤트”, “API 계약 3단계 연락 이벤트 웹훅”

변경 사항

  1. 신규 contact 바운디드 컨텍스트를 만듭니다. application에 합류시키지 않는 이유: 연락 이벤트는 지원과 무관하게 먼저 도착하고(후보 매칭 실패 시 어느 지원에도 붙지 않습니다), 원본·파싱·후보·결정 이력을 반영 여부와 무관하게 독립 보존합니다(FR-89 “중복 수신과 오탐을 추적”).

  2. 수신 즉시 2xx, 파싱·후보·알림은 Layer 1 이벤트로 분리합니다(방안 10 A). 동기 처리하면 Discord 발송 최대 3회 재시도(DiscordWebhookGatewayImpl.kt:18-40) 사이에 Apps Script가 타임아웃돼 라벨을 유지하고 재전송합니다. 멱등으로 데이터는 안전하나 알림이 중복됩니다.

  3. 레이어 경로는 Controller → UseCase → DomainService입니다 — Controller가 WebhookVerificationDomainService를 직접 호출하지 않습니다(TDD 방안 2 채택안 A). presentation → application → domain 위반이라 리뷰 p1 대상입니다. UseCase가 검증·파싱·저장을 오케스트레이션합니다.

  4. @RequestBody rawBody: String으로 받습니다 — 서명 대상이 "{timestamp}.{rawBody}"이므로 재직렬화가 개입하면 검증이 깨집니다. 검증 통과 후 컨트롤러가 ObjectMapper가 아니라 Spring 변환 경로로 파싱하지 않고, application 레이어의 전용 파서rawBody를 DTO로 변환합니다.

  5. 멱등은 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.gssendMessage_2xx가 아니면 실패로 보고 inbox 라벨을 유지해 다음 회차에 재시도합니다 — 503도 재시도됩니다.
    • 예외 매핑 위치: 이 경로의 DataIntegrityViolationException·UnexpectedRollbackException웹훅 컨트롤러 범위의 @ExceptionHandler 로 매핑합니다. UnexpectedRollbackException을 전역 핸들러에 넓게 매핑하면 다른 엔드포인트의 진짜 버그를 503으로 가립니다.
  6. channel 필드로 SMS 확장을 열어둡니다(FR-89 B-11) — 1차 구현은 EMAIL만 처리하고 SMS는 저장만 합니다.

  7. 첨부는 메타데이터만 저장하되 저장할 곳을 신설합니다 (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>를 추가합니다.
  8. Gmail Apps Script는 별도 티켓 BE-82가 산출합니다 (A-1 — 게이트 ② 결정으로 “범위 밖” 철회). 최초 판단은 계약 부록만 남기는 것이었으나, PRD FR-92(“Apps Script로 제공한다”)와 3단계 완료 판정의 자급자족을 위해 동작하는 .gs가 산출물로 확정됐습니다.

    • 이 티켓은 수신 측 계약(서명 대상·헤더 이름·요청 스키마)을 확정하는 책임만 집니다. 스크립트는 BE-82가 그 계약에 맞춰 배치·검증합니다.
    • 계약이 바뀌면 BE-82도 함께 바뀝니다 — 서명 대상 문자열·헤더 이름은 양쪽이 정확히 일치해야 하며 어긋나면 401로 전량 실패합니다.
  9. 피처 플래그 contact.inbound-webhook이 OFF면 엔드포인트가 404입니다 — 스크립트가 라벨을 유지하며 재시도 대기합니다(유실 없음).

  10. BE-47 계약 준수 (PR #100 리뷰 p1·p2 반영) — TDD “실패를 영속화하는 경로의 트랜잭션 설계” 계약 1·2 적용 지점: WebhookVerificationDomainService.verify()를 호출하는 UseCase는 @Transactional(noRollbackFor = [WebhookVerificationFailedException::class])를 선언해야 합니다 — 그러지 않으면 검증 실패 이력 저장이 예외 롤백으로 함께 사라집니다(WebhookVerificationDomainServiceTransactionContractTest 참고). putIfAbsent를 REQUIRES_NEW로 감싸지 마세요. WebhookNonceRepositoryImplJdbcTemplate 직접 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자면 400 VALIDATION_FAILED다 (경계값)
  • 필수 필드(providerMessageId)가 없으면 400이다
  • 수신 트랜잭션 커밋 이후에 리스너 이벤트가 발행된다 (AFTER_COMMIT)
  • 중복 수신 시 리스너 이벤트가 다시 발행되지 않는다 (알림 중복 방지)
  • 피처 플래그 OFF면 404이고 이벤트가 저장되지 않는다
  • 서명 대상이 raw body 문자열 그대로여서 JSON 키 순서를 바꿔도 원 서명으로는 실패한다