[BE-02] posting 도메인 모델 · 소스 어댑터 SPI 계약

작업 내용 (설계 의도)

근거 TDD: /Users/biuea/Desktop/dpdpdndn/프로젝트/공고알림앱/20260722-타깃-공고-알림-및-지원-히스토리-tdd.md — “인터페이스 시그니처”, “상태 전이 표 / JobPosting”

변경 사항

수집·마감 감지의 데이터 소유 컨텍스트와, 소스 능력 차이를 표현하는 어댑터 계약을 확정합니다. wave 3의 어댑터 5종(회사 종속형 BE-07·08·09 + 애그리게이터 BE-22·23)과 수집 오케스트레이션(BE-10)·애그리게이터 수집(BE-25)·중복 판정(BE-24)이 전부 이 계약에 의존하므로, 시그니처를 여기서 못 박아 구현자 간 해석 차이를 없앱니다.

핵심 설계 의도:

  • 소스 유형 2종을 타입으로 일반화합니다 (FR-60). 회사 종속형(회사 slug로 수집)과 애그리게이터형(검색 조건으로 수집)이 공존하므로, 수집 파라미터를 CollectionDescriptor sealed(CompanyBound(sourceSlug, baseUrl) / Aggregator(searchCategoryCode, searchKeyword?))로 나눕니다. SPI·수집 코어는 하나로 유지하고 “회사 slug vs 검색 조건” 차이만 sealed로 흡수합니다. JobPlatformsourceType을 부여하고 P0는 GREENHOUSE·WOOWAHAN·INCRUIT(COMPANY_BOUND) + SARAMIN·JUMPIT(AGGREGATOR), P1 6종은 enum 값 추가만으로 확장합니다.

  • 능력 차이를 타입으로 표현합니다. 공통 인터페이스 하나로 뭉개면 “마감일 null”이 필드 부재인지 파싱 실패인지 구분이 사라집니다. SourceCapabilities(유형·마감일 제공·구조화 필드·상세 조회 필요·companyIdentifierInDetailOnly·규약 정책)를 어댑터가 선언하고, 변경 감지 근거는 ChangeSignature sealed(UpdatedAt / SourceVersion / BodyHash / FieldHash(점핏처럼 변경 필드 없는 소스, FR-68))로 구분합니다.

  • 규약을 정책으로 강제합니다 (FR-69) — 회색지대 소스(원티드·리멤버·잡코리아·서핏 P0 편입)의 최우선 요건. SourceCompliancePolicy(UA·요청 지연·페이지 상한·허용 경로 prefix 화이트리스트)를 SourceCapabilities에 담고, infrastructure/posting/collector/common/SourceRequestExecutor를 신설해 모든 어댑터가 이것을 통해서만 외부 호출하게 합니다. Executor가 ① 화이트리스트에 없는 경로 호출을 예외로 차단(잡코리아 금지 경로 구조적 봉쇄), ② 페이지 상한 초과 요청을 실행 전 거부(리멤버 per≤50), ③ 요청 지연·UA 강제. 어댑터는 RestClient를 직접 만들 수 없고 Executor만 주입받아 규약 우회가 불가능합니다. 1일 1회(FR-69-2)는 unique(job_source_id, run_date)가 DB 레벨에서 별도 강제합니다.

  • 증분 수집 지원 (리멤버). SourceCapabilities.supportsIncrementalSince: BooleanCollectionDescriptor.Aggregator.lastSuccessfulCollectionAt: ZonedDateTime?을 정의합니다. 리멤버만 min_updated_at 증분 필터에 활용하고, 나머지는 무시합니다(변경 판정은 여전히 FieldHash).

  • 크로스 소스 중복 판정 기반 (FR-63·64)JobPostingcomputeDedupKey()로 정규화 회사명+제목 키를 쿼리 없이 계산해 dedup_key 컬럼에 보관하고, representativeId(자기참조) 필드와 linkTo(representativeId)·markRepresentative()·isRepresentative()를 노출합니다. 실제 그룹핑·대표 선정 배치는 BE-24가 이 원시 기능을 사용합니다. JobPostingDedupKey 값 객체(정규화 규칙)도 여기서 정의합니다.

  • 마감일 정규화는 어댑터 책임입니다. 도메인은 deadlineAt == null만 보고 상시채용을 판정합니다(FR-13). 센티널 값(9999-12-31)이 도메인에 새어 들어오면 상시채용 판정이 소스별 분기로 오염됩니다.

  • Rich Domain Model: JobPosting이 미발견 카운터·마감 전환·재오픈·델타 모수 판별을 스스로 결정합니다. 호출부가 posting.status == OPEN 같은 상태 비교를 하지 않도록 행위 메서드(markMissed() / markFound() / reopen() / closeByDeadline())와 질의 메서드(isDeltaTarget() / isPermanentPosting())를 노출합니다.

  • 수동 등록 공고는 델타 모수에서 제외됩니다. JobPostingOrigin.MANUALisDeltaTarget()이 false를 반환합니다 — 소스가 없어 매번 미발견으로 잡히면 즉시 CLOSED되는 사고를 도메인에서 차단합니다.

  • JobSourceHealth가 연속 비정상 일수·고장 진입·재고장 회차(failureEpisode)를 캡슐화합니다. 소스 가드(1회차부터)와 고장 판정(3일 연속)은 서로 다른 트리거이므로 메서드를 분리합니다.

  • 본문·구조화 태그를 별도 테이블에 영속화합니다 (JobPostingDescription 1:1 / JobPostingSourceTag 1:N + JobPostingDetailRepository). 재평가(FR-25)와 근무형태 ②근거(FR-27)는 저장된 본문을 다시 읽어야 성립합니다 — 재평가 시점에 소스를 다시 호출하는 것은 불가능합니다(공고가 이미 내려갔을 수 있고 1일 1회 요청 정책과 충돌). 본문은 용량의 73%를 차지하는 cold 데이터라 job_postings에 인라인하지 않고 1:1 분리해 델타·목록 hot path가 읽지 않게 합니다. 태그는 JSON 컬럼 금지 규칙에 따라 정규화 테이블로 저장하며 정규화 값을 함께 보관합니다.

  • 재수집 시 태그는 전량 교체(replaceSourceTags), 본문은 최신 1건만 덮어쓰기입니다. 변경 이력 보관은 요구사항에 없습니다.

  • 애그리게이터 카테고리 노출 (FE 요청 #12) — SPI SourcePlatformAdaptersupportedCategories(): List<AggregatorCategory>를 추가하고, JobSourceGateway.categoriesOf(platform)이 이를 노출합니다. FE 등록 화면 Select가 어댑터 지원 코드를 API로 받아 잘못된 코드 등록을 차단합니다(회사 종속형 어댑터는 emptyList()). AggregatorCategory(code, label) 값 객체를 domain/posting에 정의합니다.

범위: 도메인 POJO + 상태 enum + 값 객체(ChangeSignature·SourceCapabilities·SourceCompliancePolicy·CollectionDescriptor·RawJobPosting·JobPostingDedupKey·AggregatorCategory) + Repository interface + JPA 엔티티/RepositoryImpl(Testcontainers 통합 테스트 포함) + SourcePlatformAdapter SPI(supportedCategories 포함) + JobSourceGatewayImpl 레지스트리(supports() 위임, categoriesOf 노출, when(platform) 분기 금지) + SourceRequestExecutor(규약 정책 강제 공통 기반) + JobPostingEvent sealed(Discovered / Changed).

DB 정합: 테이블·컬럼은 20260722-공고알림앱-design-db.md가 SSOT입니다. 특히 job_sources.disabled_at(불리언 enabled가 아님 — disable()이 시각을 기록하고 isActive()가 NULL 여부로 판정), job_postings.consecutive_miss_count·last_seen_at어떤 인덱스에도 포함되지 않으므로 일일 UPDATE가 보조 인덱스를 건드리지 않게 유지합니다.

후행 티켓은 이 티켓이 만든 도메인 엔티티 파일을 수정하지 않습니다. 수동 등록(BE-18)·마감일 경과 판정(BE-11)·수집(BE-10)이 필요로 하는 도메인 메서드를 이 티켓에서 모두 정의합니다.

의존

  • BE-01

다이어그램

처리 흐름

sequenceDiagram
    participant C as 호출부(수집 서비스)
    participant G as JobSourceGateway
    participant R as JobSourceGatewayImpl
    participant A as PlatformAdapter
    participant P as JobPosting
    C->>G: collect(descriptor)
    G->>R: collect(descriptor)
    R->>R: adapters.first { supports(platform) }
    R->>A: fetchList(descriptor)
    A-->>R: Fetched(RawJobPosting[]) / Failed
    R-->>C: SourceCollectionOutcome
    C->>P: refreshFrom(raw) 또는 markMissed()
    P-->>C: 변경 여부 / 마감 전환 여부

클래스 의존

flowchart LR
    subgraph Domain["domain/posting"]
        Posting[JobPosting]
        Status[JobPostingStatus]
        Origin[JobPostingOrigin]
        Desc[JobPostingDescription]
        Tag[JobPostingSourceTag]
        Run[JobPostingCollectionRun]
        Health[JobSourceHealth]
        Sig[ChangeSignature]
        Raw[RawJobPosting]
        GW[JobSourceGateway]
        Repo[JobPostingRepository]
        DetailRepo[JobPostingDetailRepository]
    end
    subgraph Infra["infrastructure/posting"]
        Registry[JobSourceGatewayImpl]
        SPI[JobSourcePlatformAdapter]
        RepoImpl[RepositoryImpl 群]
    end
    Posting --> Status
    Posting --> Origin
    Posting --> Sig
    Raw --> Sig
    Raw --> Desc
    Raw --> Tag
    Registry -.->|implements| GW
    Registry --> SPI
    SPI --> Raw
    RepoImpl -.->|implements| Repo
    RepoImpl -.->|implements| DetailRepo

테스트 케이스

  • 정상 회차에서 발견된 공고는 markFound()lastSeenAt이 갱신되고 미발견 카운터가 0으로 초기화된다
  • 미발견 1회차에는 OPEN을 유지하고 카운터가 1이 된다
  • 미발견 2회 연속이면 markMissed()가 true를 반환하며 CLOSED + closedReason=NOT_FOUND_TWICE로 전이한다
  • CLOSED 공고가 재발견되면 OPEN으로 복귀하고 reopenedAt이 기록되며 카운터가 0이 된다
  • CLOSED 공고에 markMissed()를 호출해도 카운터가 증가하지 않는다(no-op)
  • JobPostingOrigin.MANUAL 공고는 isDeltaTarget()이 false를 반환한다
  • accessRestricted=true 공고는 isDeltaTarget()이 false를 반환한다
  • deadlineAt == null이면 isPermanentPosting()이 true이고 마감일 경과 판정 대상이 아니다
  • 마감일이 지난 공고에 closeByDeadline()을 호출하면 closedReason=DEADLINE_PASSED로 전이하고, 이미 CLOSED면 no-op이다
  • ChangeSignature.reconstitute(kind, storedValue)가 세 변이(UpdatedAt/SourceVersion/BodyHash)를 정확히 복원한다
  • 시그니처가 동일하면 refreshFrom(raw)가 변경 없음을 반환하고, 다르면 변경으로 판정하며 Changed 이벤트가 적재된다
  • JobSourceHealth.recordAbnormal()이 3회 연속 호출되면 true(고장 진입)를 반환하고, 그 이전에는 false를 반환한다
  • 고장 상태에서 recordNormal() 후 다시 3회 비정상이면 failureEpisode가 1 증가한다
  • JobSourceGatewayImpl이 지원하지 않는 플랫폼 요청에는 명시적 예외를 던진다
  • CollectionDescriptorCompanyBound(sourceSlug)와 Aggregator(searchCategoryCode) 두 변이로 생성된다
  • JobPlatform.SARAMIN·JUMPITsourceTypeAGGREGATOR, 나머지가 COMPANY_BOUND
  • computeDedupKey()가 회사명·제목을 정규화해 동일 회사·제목이면 같은 키를 반환한다(대소문자·공백·기호 차이 무시)
  • markRepresentative()isRepresentative()가 true, linkTo(id) 후 false를 반환한다
  • ChangeSignature.FieldHash가 reconstitute로 왕복 복원된다
  • RawJobPosting.sourceCompanyName이 애그리게이터에서 채워지고 회사 종속형에서 null이다
  • SourceRequestExecutor가 요청 사이에 정책의 지연을 강제하고 페이지 상한을 넘기지 않는다
  • SourceRequestExecutor가 정책의 User-Agent 헤더를 항상 부착한다
  • JobSourceGateway.categoriesOf(SARAMIN)이 어댑터의 지원 카테고리 목록을 반환하고, 회사 종속형 플랫폼은 빈 목록을 반환한다
  • SourceRequestExecutor가 허용 경로 화이트리스트에 없는 경로 호출을 예외로 차단한다
  • SourceRequestExecutor가 정책의 페이지 상한을 초과하는 요청을 실행 전 거부한다
  • SourceCapabilities.supportsIncrementalSinceCollectionDescriptor.Aggregator.lastSuccessfulCollectionAt 필드가 존재한다
  • Testcontainers: 같은 (jobSourceId, sourceJobId) 저장 시 유니크 제약으로 거부된다
  • Testcontainers: dedup_key로 조회 시 같은 키의 공고들이 함께 반환된다
  • Testcontainers: 본문을 저장한 뒤 조회하면 원문이 그대로 복원된다
  • 같은 공고에 본문을 다시 저장하면 새 행이 생기지 않고 최신 1건으로 덮어써진다
  • 구조화 태그를 저장하면 원문과 정규화 값이 함께 보관된다
  • 태그를 재저장하면 기존 태그가 전량 교체되고 중복 행이 생기지 않는다
  • 본문이 없는 공고(상세 조회 실패)를 조회하면 null이 반환되고 예외가 발생하지 않는다
  • 델타 모수 조회(findAllCollectedIn)가 본문 테이블을 조인하지 않는다(hot path 분리 확인)
  • disable() 호출 시 disabledAt이 기록되고 isActive()가 false를 반환한다