[BE-27] 리멤버 커리어 애그리게이터 어댑터 (규약 회색지대 · 증분 수집)

작업 내용 (설계 의도)

근거 TDD: 20260722-타깃-공고-알림-및-지원-히스토리-tdd.md — “애그리게이터 어댑터 설계 — 규약 회색지대 4종” 근거 조사: 20260722-채용소스-조사-브리프.md “리멤버 커리어” (실측: POST career-api.rememberapp.co.kr/job_postings/search body {"search":{...},"page":N,"per":50} JSON 무인증, id 안정 ID, min_updated_at 증분 필터 지원, ends_at+explicit_due로 상시채용, per 상한 50, 백엔드 258건. 이용약관 제13조 1항 24호가 “스크래퍼”를 이름으로 지목해 금지 — 검색엔진 인덱싱만 예외)

변경 사항

리멤버 JSON API를 검색 조건으로 수집하는 애그리게이터 어댑터입니다. 조사에서 확인된 두 특성 — min_updated_at 증분 필터(이 소스만의 강점)와 약관의 명시적 스크래퍼 금지(회색지대 리스크) — 가 설계 핵심입니다.

능력 선언(SourceCapabilities):

항목근거
sourceTypeAGGREGATOR
changeSignalKindFIELD_HASH응답 아이템에 건별 updated_at 없음
providesDeadlinetrueends_at + explicit_due
supportsIncrementalSincetruemin_updated_at 증분
규약 정책UA 명시·요청 지연·per 상한 50·허용 경로 /job_postings/search·1일 1회·재배포 금지약관·robots

핵심 설계 의도:

  • 증분 수집(min_updated_at)CollectionDescriptor.Aggregator.lastSuccessfulCollectionAt(BE-25가 주입)을 min_updated_at으로 전달해 마지막 성공 수집 이후 갱신분만 조회합니다. 전량 재수집 대비 요청량·부하를 크게 줄입니다. 단 응답 아이템에 건별 updated_at이 없어 증분으로 좁힌 뒤에도 변경 판정은 FieldHash로 합니다(브리프 사실). 최초 수집(lastSuccessfulCollectionAt=null)이면 전량.
  • per 상한 50 (초과 시 400)SourceCompliancePolicy.pageSizeCap=50. SourceRequestExecutor가 초과 요청을 실행 전 거부해 어댑터가 실수로 51 이상을 보내는 것을 구조적으로 차단합니다. 258건 = 6페이지.
  • 마감일 정규화(FR-67)explicit_due=false AND ends_at=null이면 상시채용(null). explicit_due=trueends_at 파싱.
  • 회색지대 규약 — 약관이 스크래퍼를 금지하므로 사용자가 리스크를 인지하고 승인했습니다. 재배포·상업 이용 금지(개인 열람 전용, FR-69-6). 1일 1회는 DB 유니크가 강제. 약관 변경 모니터링(Operations) 대상.
  • idsourceJobId, 회사명은 목록에서 확보 → sourceCompanyName.
  • robots·약관 확인 결과·회색지대 판정 근거를 KDoc에 기록. supportedCategories() 선언.

롤백: disabled_at으로 소프트 비활성화(약관 변경 시 즉시).

의존

  • BE-02 (SPI·supportsIncrementalSince·lastSuccessfulCollectionAt·SourceRequestExecutor)

다이어그램

처리 흐름

sequenceDiagram
    participant U as CollectAggregatorPostingsUseCase
    participant A as RememberAggregatorAdapter
    participant E as SourceRequestExecutor
    participant M as career-api.rememberapp
    U->>A: fetchList(Aggregator descriptor + lastSuccessfulCollectionAt)
    A->>A: min_updated_at = lastSuccessfulCollectionAt (null이면 전량)
    loop 페이지 (per≤50 · 지연 · UA)
        A->>E: post(/job_postings/search, {min_updated_at, page, per:50})
        E->>M: POST (per>50이면 실행 전 거부)
        M-->>E: JSON (id·company·ends_at·explicit_due)
    end
    A->>A: 상시채용 정규화 · 필드 해시 · 회사명 추출
    A-->>U: Fetched(RawJobPosting[])

클래스 의존

flowchart LR
    subgraph Domain["domain/posting"]
        Raw[RawJobPosting]
        Sig[ChangeSignature.FieldHash]
        Cap[SourceCapabilities]
        Desc[CollectionDescriptor.Aggregator]
    end
    subgraph Adapter["infrastructure/posting/collector/aggregator/remember"]
        Ad[RememberAggregatorAdapter]
        Dto[RememberJobPostingResponse]
        Hash[FieldHashCalculator]
        Deadline[RememberDeadlineNormalizer]
    end
    subgraph Common["collector/common"]
        Exec[SourceRequestExecutor]
    end
    Ad --> Exec
    Ad --> Dto
    Ad --> Hash
    Ad --> Deadline
    Hash --> Sig
    Ad --> Cap
    Ad --> Desc

테스트 케이스

  • lastSuccessfulCollectionAt이 있으면 min_updated_at으로 전달되어 증분 조회한다
  • lastSuccessfulCollectionAt이 null(최초)이면 전량 조회한다
  • per를 51 이상으로 요청하면 SourceRequestExecutor가 실행 전 거부한다
  • per=50으로 258건을 6페이지에 걸쳐 수집한다
  • explicit_due=false·ends_at=null이면 deadlineAt=null(상시채용)로 정규화된다
  • explicit_due=trueends_at이 마감일로 파싱된다
  • 주요 필드가 동일하면 같은 FieldHash로 변경 없음으로 판정된다(건별 updated_at 미사용)
  • idsourceJobId, 회사명이 sourceCompanyName으로 매핑된다
  • 허용 경로 외 호출은 Executor가 차단한다
  • 재배포·상업 이용 관련 데이터 노출 경로가 없다(개인 열람 전용)
  • supports(REMEMBER)가 true, supportsIncrementalSince가 true를 반환한다
  • supportedCategories()가 코드↔라벨 목록을 반환한다