[BE-13] 공고 평가 실행 — 이벤트 리스너 · 08:50 보정 스윕 · 재평가 API

작업 내용 (설계 의도)

근거 TDD: 20260722-타깃-공고-알림-및-지원-히스토리-tdd.md — “컨텍스트 간 이벤트 실패”, “방안 5 이벤트 레이어 판단”

변경 사항

저장된 공고에 대해 직무 매칭·근무형태 추출을 실행하고 결과를 저장합니다. 판정 로직 자체는 BE-03의 순수 도메인 서비스에 있고, 이 티켓은 언제 어떤 공고를 평가할지를 책임집니다.

핵심 설계 의도 — 평가 경로는 하나, 진입점은 셋:

진입점목적특성
JobPostingEventListener (@TransactionalEventListener(AFTER_COMMIT))수집 직후 즉시 평가 — 조회 화면에 라벨이 바로 보이게즉시성 목적. 실패해도 원 수집 트랜잭션을 롤백하지 않음
08:50 보정 스윕 (@Scheduled)기능의 정확성을 보장하는 안전망match_result 미존재 OR criteria_revision < 현재인 공고를 재평가
재평가 API (POST /api/matching/re-evaluations)사용자가 키워드를 바꾼 뒤 과거 공고 재매칭 (FR-25)스윕과 같은 UseCase 재사용
  • 셋이 같은 UseCase를 호출하므로 평가 결과가 진입점에 따라 갈라지지 않습니다.
  • Layer 1(Spring ApplicationEvent) 채택 근거: 구독자가 같은 앱·같은 배포 단위이고, 유실되면 08:50 스윕이 흡수하며, 발송 시각(09:00)까지 9시간 여유가 있습니다. Kafka의 내구성·다중 소비자·순서 보장이 전부 불필요합니다(TDD 방안 5).
  • 리스너는 presentation 레이어에 위치하고 UseCase만 호출합니다(비즈니스 로직 금지).
  • 평가는 멱등합니다 — 같은 revision으로 이미 평가된 공고는 skip합니다.
  • force 파라미터 계약 확정(FE 요청 #6): POST /api/matching/re-evaluations의 요청 바디는 {force?: boolean}이며 기본값은 false 입니다. 기본 동작은 revision 기반(변경된 기준에 대해서만 재평가)이고, force=true는 revision이 같아도 전량 재평가합니다 — 부정어 사전이나 판정 로직을 코드에서 고친 뒤 기준 변경 없이 재평가해야 하는 경우가 유일한 용도입니다. 응답은 {evaluatedCount, skippedCount, criteriaRevision}으로 skip 건수를 함께 반환해 사용자가 “왜 0건 평가됐는지”를 알 수 있게 합니다.
  • 평가 입력은 application 레이어가 조합합니다 (크로스 컨텍스트 조합은 application 책임 — no-crosscontext-raw-read). matching은 posting을 몰라야 하므로, matching 도메인 서비스는 자기 값 객체 EvaluationTarget(제목·구조화 태그·본문)만 입력받습니다. EvaluateJobPostingsUseCase가:
    1. posting DomainService로 평가 대상 공고를 조회합니다 — posting이 자기 소유 테이블(job_postings·job_posting_descriptions·job_posting_source_tags)을 자기 JPA로 읽어 posting 도메인 객체로 반환합니다. access_restricted=1·MANUAL 제외, origin=COLLECTED 한정은 posting 쿼리가 처리합니다(자기 컬럼이므로). criteria_revision은 matching 소유라 posting 쿼리에 넣지 않습니다.
    2. matching DomainService로 “현재 revision으로 이미 평가된 공고 ID 집합”을 조회합니다.
    3. application이 (posting의 평가 가능 집합 − matching의 기 평가 집합)으로 pending을 계산하고(스윕/재평가 경로), 이벤트 경로는 단건 공고 ID로 바로 진행합니다.
    4. application 매퍼(EvaluationTargetMapper)가 posting 도메인 객체 → matching EvaluationTarget으로 변환합니다. matching·posting 두 도메인을 아는 유일한 레이어가 application이므로 변환은 여기서만 일어납니다.
    5. matching DomainService evaluate(criteriaRevision, targets)에 값 객체 목록을 넘겨 평가·저장합니다.
  • 소스를 다시 호출하지 않습니다(재평가 시점에 공고가 이미 내려갔을 수 있고 1일 1회 요청 정책과 충돌) — 저장된 본문·태그만 사용합니다. 본문이 없는 공고는 ②근거 부재로 UNKNOWN이 되며 예외를 던지지 않습니다.
  • 금지: matching infrastructure에서 posting 테이블을 로우 쿼리(JdbcTemplate)로 직접 읽는 read model을 만들지 않습니다. matching 도메인 서비스 시그니처에 posting 타입을 두지 않습니다(domain→domain 참조).
  • 평가 결과에는 매칭 여부 + 매칭 그룹 + 제외 사유 + 근무형태 근거 목록 + 대표 확신도(정렬용 캐시)를 저장합니다.
  • 매칭 실패 공고도 평가 결과를 저장합니다 — 매칭은 알림 조건일 뿐 저장 조건이 아닙니다(FR-25).
  • 재평가는 공고 수천 건 규모(NFR-1)라 동기 API로 수 초 내 완료됩니다. 페이지 단위(500건)로 나눠 처리하고 트랜잭션을 페이지마다 커밋합니다.

범위: EvaluateJobPostingsUseCase(application, 크로스 컨텍스트 조합), EvaluationTargetMapper(application, posting→matching 변환), JobPostingEvaluationDomainService(matching), JobPostingEventListener, JobPostingEvaluationSweepScheduler(cron 0 50 8 * * *), JobPostingEvaluationApiController. posting 측에 평가 대상 조회 DomainService 쿼리(본문·태그 포함, access_restricted 제외)가 없으면 이 티켓에서 posting DomainService에 추가합니다(posting 자기 테이블 조회).

의존

  • BE-03 (matching 평가 로직·EvaluationTarget 값 객체·결과 저장·기평가 ID 조회), BE-02 (posting 공고·본문·태그 조회 DomainService·이벤트 타입)
  • UseCase가 posting·matching 두 컨텍스트 DomainService를 조합하는 유일한 지점입니다 (application 레이어).

다이어그램

처리 흐름

sequenceDiagram
    participant E as JobPostingEventListener
    participant S as EvaluationSweepScheduler
    participant A as EvaluationApiController
    participant U as EvaluateJobPostingsUseCase
    participant D as EvaluationDomainService
    participant R as MatchResultRepository
    E->>U: execute(공고 ID) — AFTER_COMMIT
    S->>U: execute(미평가 대상)
    A->>U: execute(force=true)
    U->>D: evaluate(jobPostingIds)
    D->>D: 현재 criteria 로드
    loop 공고별
        alt 같은 revision 으로 평가됨 && !force
            D->>D: skip (멱등)
        else
            D->>D: 직무 매칭 + 근무형태 추출
            D->>R: save(결과 + revision + 대표 확신도)
        end
    end
    D-->>U: 평가 건수

클래스 의존

flowchart LR
    subgraph Presentation["presentation/matching"]
        Listener[JobPostingEventListener]
        Sched[EvaluationSweepScheduler]
        Api[JobPostingEvaluationApiController]
    end
    subgraph Application["application/matching"]
        UC[EvaluateJobPostingsUseCase]
        Mapper[EvaluationTargetMapper]
    end
    subgraph PostingDomain["domain/posting"]
        PDS[PostingDomainService 조회]
    end
    subgraph Domain["domain/matching"]
        DS[JobPostingEvaluationDomainService]
        Matcher[JobKeywordMatcher]
        Detector[WorkArrangementDetector]
        Repo[JobPostingMatchResultRepository]
    end
    Listener --> UC
    Sched --> UC
    Api --> UC
    UC --> PDS
    UC --> Mapper
    UC --> DS
    DS --> Matcher
    DS --> Detector
    DS --> Repo

테스트 케이스

  • Discovered 이벤트 수신 시 해당 공고가 평가되고 결과가 저장된다
  • Changed 이벤트(제목 변경) 수신 시 재평가되어 매칭 결과가 갱신된다
  • 리스너가 예외를 던져도 원 수집 트랜잭션은 커밋된 상태로 유지된다
  • 리스너 실패로 미평가로 남은 공고가 08:50 스윕에서 평가된다
  • 이미 현재 revision으로 평가된 공고는 스윕에서 skip된다
  • 키워드 변경으로 revision이 증가하면 기존 공고 전체가 재평가 대상이 된다
  • 재평가 API가 매칭 실패 공고에도 평가 결과를 저장한다
  • 재평가로 새로 매칭된 과거 공고는 신규 공고 알림 대상이 되지 않는다
  • force 미지정 시 기본값 false로 동작해 같은 revision 공고를 skip한다
  • force=true면 같은 revision이어도 재평가한다
  • 응답의 skippedCount가 skip된 건수를 정확히 반환한다
  • 저장된 본문·태그만으로 평가가 완주하고 소스를 호출하지 않는다
  • application이 posting DomainService 조회 결과를 EvaluationTarget으로 매핑해 matching에 넘긴다 (matching 도메인은 posting 타입을 참조하지 않는다)
  • access_restricted=1 공고는 posting 조회 단계에서 제외되어 평가 대상에 포함되지 않는다
  • 본문이 없는 공고는 UNKNOWN으로 평가되고 예외가 발생하지 않는다
  • 평가 결과에 대표 확신도와 정렬 순위(rank)가 함께 저장된다
  • 매칭된 그룹이 2개면 매칭 그룹 행이 2건 저장되고, 재평가 시 해당 공고 범위만 재작성된다
  • 공고 1,000건 재평가가 페이지 단위로 나뉘어 처리되고 건수가 반환된다
  • 리스너는 AFTER_COMMIT 단계에서 실행되어 커밋 전 상태를 읽지 않는다