[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가:- 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 쿼리에 넣지 않습니다. - matching DomainService로 “현재 revision으로 이미 평가된 공고 ID 집합”을 조회합니다.
- application이 (posting의 평가 가능 집합 − matching의 기 평가 집합)으로 pending을 계산하고(스윕/재평가 경로), 이벤트 경로는 단건 공고 ID로 바로 진행합니다.
- application 매퍼(
EvaluationTargetMapper)가 posting 도메인 객체 → matchingEvaluationTarget으로 변환합니다. matching·posting 두 도메인을 아는 유일한 레이어가 application이므로 변환은 여기서만 일어납니다. - matching DomainService
evaluate(criteriaRevision, targets)에 값 객체 목록을 넘겨 평가·저장합니다.
- posting DomainService로 평가 대상 공고를 조회합니다 — posting이 자기 소유 테이블(
- 소스를 다시 호출하지 않습니다(재평가 시점에 공고가 이미 내려갔을 수 있고 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단계에서 실행되어 커밋 전 상태를 읽지 않는다