[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·LIKELY만isSortable()이 true를 반환한다- 키워드 기준을 변경하면
revision이 1 증가하고, 이전 revision으로 평가된 결과는 재평가 대상으로 식별된다 - 소프트 삭제된 키워드 그룹은
loadCurrent()결과에 포함되지 않는다 - 소프트 삭제된 그룹을 참조하던 과거 평가 결과는 그대로 조회된다(참조가 끊기지 않는다)
- 한 공고가 두 개 그룹에 매칭되면 매칭 그룹 행이 2건 저장된다
- 재평가 시 기존 매칭 그룹·근무형태 근거 행이 해당 공고 범위에서만 삭제·재삽입된다
sortRank()가 CONFIRMED=1, LIKELY=2, INFERRED·UNKNOWN=99를 반환한다- 확신도
UNKNOWN이면 근무형태 근거 행을 생성하지 않는다 EvaluationTargetRepository가 제목·태그·본문을 담은 값을 반환하고, 본문이 없으면 null 필드로 반환한다- Testcontainers: 같은 공고에 대한 평가 결과가 2건 저장되지 않는다(유니크 제약)