[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로 수집)과 애그리게이터형(검색 조건으로 수집)이 공존하므로, 수집 파라미터를
CollectionDescriptorsealed(CompanyBound(sourceSlug, baseUrl)/Aggregator(searchCategoryCode, searchKeyword?))로 나눕니다. SPI·수집 코어는 하나로 유지하고 “회사 slug vs 검색 조건” 차이만 sealed로 흡수합니다.JobPlatform에sourceType을 부여하고 P0는GREENHOUSE·WOOWAHAN·INCRUIT(COMPANY_BOUND) +SARAMIN·JUMPIT(AGGREGATOR), P1 6종은 enum 값 추가만으로 확장합니다. -
능력 차이를 타입으로 표현합니다. 공통 인터페이스 하나로 뭉개면 “마감일 null”이 필드 부재인지 파싱 실패인지 구분이 사라집니다.
SourceCapabilities(유형·마감일 제공·구조화 필드·상세 조회 필요·companyIdentifierInDetailOnly·규약 정책)를 어댑터가 선언하고, 변경 감지 근거는ChangeSignaturesealed(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: Boolean과CollectionDescriptor.Aggregator.lastSuccessfulCollectionAt: ZonedDateTime?을 정의합니다. 리멤버만min_updated_at증분 필터에 활용하고, 나머지는 무시합니다(변경 판정은 여전히FieldHash). -
크로스 소스 중복 판정 기반 (FR-63·64) —
JobPosting이computeDedupKey()로 정규화 회사명+제목 키를 쿼리 없이 계산해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.MANUAL은isDeltaTarget()이 false를 반환합니다 — 소스가 없어 매번 미발견으로 잡히면 즉시 CLOSED되는 사고를 도메인에서 차단합니다. -
JobSourceHealth가 연속 비정상 일수·고장 진입·재고장 회차(failureEpisode)를 캡슐화합니다. 소스 가드(1회차부터)와 고장 판정(3일 연속)은 서로 다른 트리거이므로 메서드를 분리합니다. -
본문·구조화 태그를 별도 테이블에 영속화합니다 (
JobPostingDescription1:1 /JobPostingSourceTag1:N +JobPostingDetailRepository). 재평가(FR-25)와 근무형태 ②근거(FR-27)는 저장된 본문을 다시 읽어야 성립합니다 — 재평가 시점에 소스를 다시 호출하는 것은 불가능합니다(공고가 이미 내려갔을 수 있고 1일 1회 요청 정책과 충돌). 본문은 용량의 73%를 차지하는 cold 데이터라job_postings에 인라인하지 않고 1:1 분리해 델타·목록 hot path가 읽지 않게 합니다. 태그는 JSON 컬럼 금지 규칙에 따라 정규화 테이블로 저장하며 정규화 값을 함께 보관합니다. -
재수집 시 태그는 전량 교체(
replaceSourceTags), 본문은 최신 1건만 덮어쓰기입니다. 변경 이력 보관은 요구사항에 없습니다. -
애그리게이터 카테고리 노출 (FE 요청 #12) — SPI
SourcePlatformAdapter에supportedCategories(): 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이 지원하지 않는 플랫폼 요청에는 명시적 예외를 던진다CollectionDescriptor가CompanyBound(sourceSlug)와Aggregator(searchCategoryCode) 두 변이로 생성된다JobPlatform.SARAMIN·JUMPIT의sourceType이AGGREGATOR, 나머지가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.supportsIncrementalSince와CollectionDescriptor.Aggregator.lastSuccessfulCollectionAt필드가 존재한다- Testcontainers: 같은
(jobSourceId, sourceJobId)저장 시 유니크 제약으로 거부된다 - Testcontainers:
dedup_key로 조회 시 같은 키의 공고들이 함께 반환된다 - Testcontainers: 본문을 저장한 뒤 조회하면 원문이 그대로 복원된다
- 같은 공고에 본문을 다시 저장하면 새 행이 생기지 않고 최신 1건으로 덮어써진다
- 구조화 태그를 저장하면 원문과 정규화 값이 함께 보관된다
- 태그를 재저장하면 기존 태그가 전량 교체되고 중복 행이 생기지 않는다
- 본문이 없는 공고(상세 조회 실패)를 조회하면 null이 반환되고 예외가 발생하지 않는다
- 델타 모수 조회(
findAllCollectedIn)가 본문 테이블을 조인하지 않는다(hot path 분리 확인) disable()호출 시disabledAt이 기록되고isActive()가 false를 반환한다