[BE-03] matching 도메인 모델 — 직무 매칭 · 근무형태 추출

작업 내용 (설계 의도)

근거 TDD: 20260722-타깃-공고-알림-및-지원-히스토리-tdd.md — “방안 7 컨텍스트 경계”, “근무형태 확신도 판정”

변경 사항

매칭 기준평가 로직을 소유하는 컨텍스트를 만듭니다. 기준(키워드 그룹·제외어·근무형태 키워드)은 공고와 무관하게 사용자가 언제든 바꾸고, 바뀌면 저장된 공고 전체를 재평가하므로(FR-25) posting 컨텍스트와 변경 주기가 다릅니다. 그래서 별도 컨텍스트로 분리하고, 평가 결과 테이블도 이 컨텍스트가 소유합니다.

핵심 설계 의도:

  • 정규화 값 객체 NormalizedKeyword.of(raw) — 소문자화 + 공백/하이픈 제거 + NFKC(FR-24). 저장 시점에 정규화 값을 함께 보관해 매 평가마다 재계산하지 않습니다.
  • JobKeywordMatcher(순수 함수) — 동의어 그룹 매칭 후 제외 키워드를 적용합니다. 제외가 매칭을 이깁니다(FR-23). 매칭 대상은 공고 제목 단독으로 확정했습니다(TDD Open Questions #2 — 본문 매칭은 “백엔드와 협업” 류 오탐이 대량 발생).
  • WorkArrangementDetector(순수 함수) — 부정어 처리가 이 티켓의 핵심 리스크입니다. ①구조화 필드·태그 근거는 CONFIRMED, ②JD 본문 근거는 LIKELY로 고정 매핑하되(FR-29), 키워드 출현 위치 기준 앞 12자 / 뒤 20자 윈도우 안에 부정 표현(불가·불가능·미지원·없음·아님·않습니다·종료·제외·전면 출근·상시 출근·출근 전환·사무실 출근)이 있으면 그 근거를 무효화합니다. 유효 근거가 0건이면 UNKNOWN입니다(FR-32, FR-34).
  • 확신도는 라벨·정렬 전용입니다. WorkArrangementConfidence.isSortable()CONFIRMED·LIKELY에만 true를 반환하고(FR-30), 어떤 확신도에서도 공고를 목록에서 제외하지 않습니다(FR-33). 알림 발송 조건에도 관여하지 않습니다.
  • MatchCriteria는 그룹·제외어·근무형태 키워드 + revision을 한 덩어리로 로드하는 집합체입니다. deleted_at IS NULL인 활성 항목만 로드합니다(소프트 삭제 — 과거 평가 결과가 삭제된 그룹을 참조하므로 물리 삭제하면 이력이 끊깁니다). 평가 결과에 criteriaRevision을 함께 저장해 보정 스윕(BE-13)이 “재평가 필요 여부”를 판단할 수 있게 합니다.
  • 매칭된 그룹은 N:M 테이블(job_posting_matched_keyword_groups)로 저장합니다. 한 공고가 복수 그룹에 매칭될 수 있어(예: “백엔드” + “플랫폼”) 단일 컬럼으로 표현할 수 없습니다.
  • 평가 입력은 read model로 읽습니다EvaluationTargetRepository(domain/matching interface)가 EvaluationTarget(jobPostingId, title, structuredTags, descriptionBody)를 반환하고, 구현체(infrastructure/matching)가 posting 테이블을 조회합니다. matching 도메인이 posting 도메인 패키지를 import하지 않게 하는 경계 장치이며, notification의 발송 대상 read model과 같은 패턴입니다.
  • 정렬 순위를 정수로 보유합니다 — JobPostingMatchResult.sortRank()가 1(CONFIRMED)/2(LIKELY)/99(그 외)를 반환하고 이 값을 work_arrangement_sort_rank에 저장합니다. 확신도 문자열로 정렬하면 알파벳 순(CONFIRMED < INFERRED < LIKELY)이라 도메인 순서와 어긋납니다.
  • 파생 캐시 재작성: 재평가 시 해당 공고의 매칭 그룹·근무형태 근거 행은 WHERE job_posting_id = ? 범위로 삭제 후 재삽입합니다. 이 둘은 이력이 아니라 현재 revision에 대한 계산 결과이므로 소프트 삭제 전용 원칙의 명시적 예외입니다.
  • 확신도 UNKNOWN근거 행을 만들지 않습니다 — “근거 없음”이 곧 UNKNOWN이며 대표값은 top_confidence='UNKNOWN'으로만 표현합니다.

범위: 도메인 POJO + 값 객체 + 두 순수 도메인 서비스 + Repository interface(MatchCriteriaRepository·JobPostingMatchResultRepository·EvaluationTargetRepository) + JPA 엔티티/RepositoryImpl(Testcontainers 통합 테스트 포함). 테이블·컬럼은 20260722-공고알림앱-design-db.md가 SSOT입니다.

의존

  • BE-01

다이어그램

처리 흐름

sequenceDiagram
    participant C as 호출부(평가 서비스)
    participant Cr as MatchCriteria
    participant M as JobKeywordMatcher
    participant D as WorkArrangementDetector
    participant R as JobPostingMatchResult
    C->>Cr: loadCurrent()
    C->>M: evaluate(title, criteria)
    M-->>C: KeywordMatchOutcome(matched, groupIds, excludedBy)
    C->>D: detect(tags, body, keywords)
    D->>D: 부정어 윈도우 검사 — 무효 근거 제거
    D-->>C: List<WorkArrangementEvidence>
    C->>R: evaluate(outcome, evidences, revision)
    R-->>C: topConfidence / isNotifiable

클래스 의존

flowchart LR
    subgraph Domain["domain/matching"]
        Criteria[MatchCriteria]
        Group[JobKeywordGroup]
        Norm[NormalizedKeyword]
        Excl[JobExclusionKeyword]
        WaKeyword[WorkArrangementKeyword]
        Matcher[JobKeywordMatcher]
        Detector[WorkArrangementDetector]
        Evidence[WorkArrangementEvidence]
        Conf[WorkArrangementConfidence]
        Result[JobPostingMatchResult]
        Repo[MatchCriteriaRepository]
        EvalRepo[EvaluationTargetRepository]
    end
    subgraph Infra["infrastructure/matching"]
        RepoImpl[RepositoryImpl 群]
    end
    Criteria --> Group
    Criteria --> Excl
    Criteria --> WaKeyword
    Group --> Norm
    Matcher --> Criteria
    Detector --> Evidence
    Evidence --> Conf
    Result --> Evidence
    RepoImpl -.->|implements| Repo
    RepoImpl -.->|implements| EvalRepo

테스트 케이스

  • “백엔드/Backend/서버” 동의어 그룹이 등록되면 제목 “서버 개발자 채용”이 매칭된다
  • 대소문자·공백만 다른 제목(“BACK END 엔지니어”)이 정규화 후 매칭된다
  • 제외 키워드 “인턴”이 등록된 상태에서 제목 “백엔드 인턴”은 매칭 그룹이 있어도 최종적으로 미매칭 처리되고 제외 사유가 기록된다
  • 매칭 그룹이 하나도 없으면 미매칭이지만 평가 결과 자체는 생성된다(저장 조건이 아님, FR-25)
  • 구조화 태그에 “재택”이 있으면 확신도가 CONFIRMED가 된다
  • JD 본문에만 “재택”이 있으면 확신도가 LIKELY가 된다
  • 본문이 “재택근무 불가”이면 그 근거가 무효화되어 결과가 UNKNOWN이 된다
  • 본문이 “전면 출근 전환 예정”이면 “출근” 키워드 기반 오탐이 발생하지 않는다
  • 태그는 CONFIRMED이고 본문은 부정어인 경우, 유효 근거인 태그가 채택되어 CONFIRMED가 유지된다
  • 근거가 하나도 없으면 UNKNOWN이고 isSortable()이 false를 반환한다
  • CONFIRMED·LIKELYisSortable()이 true를 반환한다
  • 키워드 기준을 변경하면 revision이 1 증가하고, 이전 revision으로 평가된 결과는 재평가 대상으로 식별된다
  • 소프트 삭제된 키워드 그룹은 loadCurrent() 결과에 포함되지 않는다
  • 소프트 삭제된 그룹을 참조하던 과거 평가 결과는 그대로 조회된다(참조가 끊기지 않는다)
  • 한 공고가 두 개 그룹에 매칭되면 매칭 그룹 행이 2건 저장된다
  • 재평가 시 기존 매칭 그룹·근무형태 근거 행이 해당 공고 범위에서만 삭제·재삽입된다
  • sortRank()가 CONFIRMED=1, LIKELY=2, INFERRED·UNKNOWN=99를 반환한다
  • 확신도 UNKNOWN이면 근무형태 근거 행을 생성하지 않는다
  • EvaluationTargetRepository가 제목·태그·본문을 담은 값을 반환하고, 본문이 없으면 null 필드로 반환한다
  • Testcontainers: 같은 공고에 대한 평가 결과가 2건 저장되지 않는다(유니크 제약)