[BE-22] 사람인 애그리게이터 어댑터

작업 내용 (설계 의도)

근거 TDD: 20260722-타깃-공고-알림-및-지원-히스토리-tdd.md — “애그리게이터 어댑터 설계”, “방안 8” 근거 조사: 20260722-채용소스-조사-브리프.md “전체 플랫폼 조사 결과 / 채택 가능 — 규약 청정” (실측: saramin.co.kr/zf_user/jobs/list/job-category?cat_kewd={code}&page=N&page_count=100, HTML UTF-8, rec_idx 안정 ID, 수정일 YY/MM/DD 존재, 백엔드 2,594건, robots 미차단·Crawl-delay 없음, UA 헤더 필수)

변경 사항

검색 조건(직무 카테고리 + 선택 키워드)으로 수집하는 애그리게이터 어댑터입니다(FR-60). 회사에 종속되지 않는 전역 소스이며, CollectionDescriptor.Aggregator를 받습니다.

능력 선언(SourceCapabilities):

항목근거
sourceTypeAGGREGATOR검색 조건으로 수집
changeSignalKindUPDATED_AT목록의 수정일 YY/MM/DD (FR-68)
providesDeadlinetrue목록에 마감일 노출
providesStructuredWorkArrangementfalse②(JD 본문)만
companyIdentifierInDetailOnlyfalse목록에 회사명 존재
규약 정책UA 필수·요청 지연 1000ms·page_count 100·허용 경로 /zf_user/jobs/listFR-69, 브리프

핵심 설계 의도:

  • 마감일 정규화(FR-67)가 이 어댑터의 핵심 난점입니다. 사람인은 연도가 없는 ~MM/DD 표기와 채용시·상시채용 문자열을 섞어 씁니다. ~MM/DD연도를 추론합니다 — MM/DD가 오늘 이전이면 내년, 이후면 올해(연말·연초 경계 처리). 채용시·상시채용 문자열은 deadlineAt=null(상시채용)로 정규화합니다. 정규화 실패 시 예외 대신 null + 경고 로그(회차를 실패시키지 않음).
  • 회사명은 목록에서 확보RawJobPosting.sourceCompanyName에 채웁니다 — 크로스 소스 dedup(BE-24)과 발견 회사 자동 등록(BE-25·06)의 입력입니다.
  • rec_idxsourceJobId, 공고 제목 → 직무 매칭 대상, structuredTagsemptyList()(②만).
  • 모든 외부 호출은 SourceRequestExecutor(BE-02)를 경유해 UA·지연·페이지 상한·허용 경로를 강제받습니다 — 어댑터가 직접 HTTP 클라이언트를 만들지 않습니다(FR-69). 기본 UA는 307로 막히므로 정책의 UA가 반드시 부착돼야 합니다.
  • 페이지네이션 전량 수집(2,594건 = 26페이지). 총 건수와 수집 건수 불일치 시 회차 Failed(부분 수집이 미발견 오판정으로 번지는 것 방지).
  • robots·약관·요청 정책 확인 결과를 어댑터 KDoc에 기록(NFR-9·NFR-11).
  • supportedCategories()로 지원 직무 카테고리(코드↔라벨)를 선언합니다(FE 요청 #12) — 브리프 실측 코드(cat_kewd=84 백엔드 등)를 근거로 목록을 하드코딩하고, 이 목록이 등록 API의 코드 검증·FE Select의 SSOT가 됩니다.

의존

  • BE-02 (SPI·CollectionDescriptor·SourceRequestExecutor·SourceCapabilities)

다이어그램

처리 흐름

sequenceDiagram
    participant R as JobSourceGatewayImpl
    participant A as SaraminAggregatorAdapter
    participant E as SourceRequestExecutor
    participant S as saramin.co.kr
    R->>A: fetchList(Aggregator descriptor)
    loop 페이지 (지연 1000ms · UA 부착)
        A->>E: get(list URL, page)
        E->>S: GET job-category?cat_kewd&page
        S-->>E: HTML (UTF-8)
        E-->>A: 문서
    end
    A->>A: 마감일 정규화(~MM/DD 연도 추론 · 문자열→null) · 회사명 추출
    alt 총 건수 == 수집 건수
        A-->>R: Fetched(RawJobPosting[] with sourceCompanyName)
    else 불일치
        A-->>R: Failed(reason)
    end

클래스 의존

flowchart LR
    subgraph Domain["domain/posting"]
        Raw[RawJobPosting]
        Sig[ChangeSignature.UpdatedAt]
        Cap[SourceCapabilities]
        Outcome[SourceCollectionOutcome]
    end
    subgraph Adapter["infrastructure/posting/collector/aggregator/saramin"]
        Ad[SaraminAggregatorAdapter]
        Parser[SaraminListParser]
        Deadline[SaraminDeadlineNormalizer]
    end
    subgraph Common["collector/common"]
        Exec[SourceRequestExecutor]
    end
    Ad --> Exec
    Ad --> Parser
    Ad --> Deadline
    Parser --> Raw
    Deadline --> Raw
    Ad --> Cap
    Ad --> Outcome

테스트 케이스

  • 실제 응답 fixture를 파싱하면 rec_idxsourceJobId로, 회사명이 sourceCompanyName으로 매핑된다
  • 수정일 25/07/16ChangeSignature.UpdatedAt으로 매핑되고 왕복 복원된다
  • 마감일 ~08/15가 오늘(예: 08/16)보다 이전이면 내년으로 추론된다
  • 마감일 ~12/31이 오늘(예: 08/16)보다 이후면 올해로 추론된다
  • 채용시·상시채용 문자열은 deadlineAt=null(상시채용)로 정규화된다
  • 마감일 형식이 예상과 다르면 null + 경고 로그로 처리되고 회차는 실패하지 않는다
  • 페이지 전량을 수집해 총 건수와 일치시킨다
  • 총 건수와 수집 건수가 불일치하면 Failed를 반환한다
  • 요청 사이에 1000ms 지연이 적용되고 정책 UA가 부착된다
  • 목록 페이지 조회가 실패하면 Failed를 반환한다
  • supports(SARAMIN)가 true, sourceTypeAGGREGATOR를 반환한다
  • CollectionDescriptor.CompanyBound가 전달되면 명시적 예외를 던진다(애그리게이터 전용)
  • supportedCategories()가 코드↔라벨 목록(예: 84↔백엔드)을 반환한다