[BE-07] Greenhouse 소스 어댑터

작업 내용 (설계 의도)

근거 TDD: 20260722-타깃-공고-알림-및-지원-히스토리-tdd.md — “소스별 어댑터 설계” 근거 조사: 20260722-채용소스-조사-브리프.md §1 (실제 호출 검증: GET https://boards-api.greenhouse.io/v1/boards/daangn/jobs → 200, 총 38건, 인증 불필요) 근거(프록시형 검증): 토스 채용은 공개 Greenhouse slug가 404지만 자체 게이트웨이로 프록시됩니다 — GET https://api-public.toss.im/api/v3/ipd-eggnog/career/jobs → 200, 419건. 응답 스키마는 100% Greenhouse(gh_jid·internal_job_id·updated_at·metadata·application_deadline)이나 ① 호스트·경로가 다르고 ② 배열이 {resultType, error, success:[...]} 봉투로 감싸집니다. 회사 추가를 코드 0으로 만들려면 이 두 차이를 데이터·구성으로 흡수해야 합니다.

변경 사항

Greenhouse 스키마를 쓰는 회사(공개 boards-api: 당근 / 프록시형: 토스 등)를 커버하는 플랫폼 단위 어댑터를 구현합니다. 파서 하나가 이 플랫폼을 쓰는 모든 회사를 커버합니다(FR-7). 회사가 늘어도 코드가 아니라 job_sources 행만 늘어납니다 — 아래 데이터 주도 요구사항을 처음부터 지킵니다.

데이터 주도 요구사항 (필수 — 회사 추가 시 코드 0 보장):

  1. 엔드포인트를 하드코딩하지 않습니다. 호출 base URL은 job_sources.base_url(descriptor로 전달)에서 주입받습니다. 공개형은 https://boards-api.greenhouse.io/v1/boards/{slug}, 프록시형(토스)은 https://api-public.toss.im/api/v3/ipd-eggnog/career처럼 회사마다 호스트·경로가 달라도 DB 행의 base_url만 다르면 됩니다. slug는 source_slug에서 옵니다. SourceRequestExecutor allowedHosts에 해당 호스트를 등록하는 것도 구성(코드 아님)입니다.
  2. 응답 봉투를 허용합니다. 파서는 (a) 표준 raw 배열 [...]과 (b) 봉투 {success:[...]}(또는 {jobs:[...]}) 둘 다에서 공고 배열을 추출합니다 — “봉투 → 배열 위치”를 소스 구성으로 판별하거나, 배열 필드를 관대하게 탐색합니다. 어느 경우든 배열 원소는 동일한 Greenhouse GreenhouseJobResponse DTO로 매핑됩니다.

이 두 요구사항 덕분에 토스는 새 어댑터·새 코드 없이 job_sources 한 행(platform=GREENHOUSE, base_url=프록시 URL, source_slug=toss) 등록만으로 수집됩니다. 처음 보는 스키마의 플랫폼이 등장할 때만 새 어댑터가 필요하고, Greenhouse 계열은 여기 해당하지 않습니다.

능력 선언(SourceCapabilities):

항목근거
changeSignalKindUPDATED_AT응답의 updated_at 필드 (FR-12)
providesDeadlinefalse마감일 필드 자체가 없습니다
providesStructuredWorkArrangementtruemetadata[] 보유 — ①근거로 CONFIRMED 판정 가능 (FR-27)
requiresDetailFetchfalse?content=true로 목록 응답에 본문 포함
detailRequestDelayMillis0목록 1회 호출

핵심 설계 의도:

  • 마감일 정규화(FR-13): 필드가 없으므로 deadlineAt항상 null 입니다. 도메인은 이를 상시채용으로 판정합니다. “필드 없음”이라는 사실이 도메인에 새어 나가지 않게 어댑터에서 종결합니다.
  • idsourceJobId, title → 직무 매칭 대상, absolute_url → 공고 링크, metadata[]structuredTags, contentdescriptionBody(HTML 엔티티 디코딩 후 텍스트화).
  • 후보 탐색(probe): 회사명·사용자 입력 URL에서 slug 후보를 도출해 boards-api를 호출하고, 200 + 공고 1건 이상이면 후보로 채택하며 샘플 3건을 함께 반환합니다(FR-2). 404·빈 배열은 후보에서 제외합니다.
  • 실패 시 예외를 밖으로 던지지 않고 SourceCollectionOutcome.Failed(reason)를 반환합니다 — 소스 가드가 회차 단위로 판단할 수 있어야 합니다.
  • robots.txt·API 이용 정책 확인 결과를 어댑터 KDoc에 근거로 기록합니다(NFR-9).

SPI 정합(BE-02): SourcePlatformAdapter를 구현하며 fetchList(CollectionDescriptor.CompanyBound)를 받습니다(sourceType=COMPANY_BOUND). 외부 호출은 SourceRequestExecutor(규약 정책 강제)를 경유합니다. 애그리게이터 descriptor가 오면 supports가 이미 걸러내며, 방어적으로 명시적 예외를 던집니다.

의존

  • BE-02

다이어그램

처리 흐름

sequenceDiagram
    participant R as JobSourceGatewayImpl
    participant A as GreenhouseJobSourceAdapter
    participant C as GreenhouseBoardClient
    participant G as boards-api.greenhouse.io
    R->>A: fetchList(descriptor)
    A->>C: fetchJobs(slug, content=true)
    C->>G: GET /v1/boards/{slug}/jobs
    alt 200
        G-->>C: JSON (jobs[])
        C-->>A: GreenhouseJobResponse[]
        A->>A: deadlineAt = null 고정 · metadata → structuredTags
        A-->>R: Fetched(RawJobPosting[])
    else 4xx/5xx/타임아웃
        G-->>C: 오류
        A-->>R: Failed(reason)
    end

클래스 의존

flowchart LR
    subgraph Domain["domain/posting"]
        SPICap[SourceCapabilities]
        Raw[RawJobPosting]
        Sig[ChangeSignature.UpdatedAt]
        Outcome[SourceCollectionOutcome]
    end
    subgraph Adapter["infrastructure/posting/collector/greenhouse"]
        Ad[GreenhouseJobSourceAdapter]
        Cl[GreenhouseBoardClient]
        Dto[GreenhouseJobResponse]
        Mapper[GreenhouseJobMapper]
    end
    Ad --> Cl
    Ad --> Mapper
    Cl --> Dto
    Mapper --> Raw
    Mapper --> Sig
    Ad --> SPICap
    Ad --> Outcome

테스트 케이스

  • 실제 응답 fixture를 파싱하면 공고 목록이 건수만큼 RawJobPosting으로 변환된다
  • 모든 공고의 deadlineAtnull로 정규화되어 상시채용으로 취급된다
  • updated_at 값이 ChangeSignature.UpdatedAt으로 매핑되고 storedValue로 왕복 복원된다
  • metadata[]의 값들이 structuredTags로 전달되어 근무형태 ①근거가 될 수 있다
  • metadata[]가 비어 있어도 예외 없이 emptyList()로 처리된다
  • API가 404를 반환하면 Failed를 반환하고 예외를 밖으로 던지지 않는다
  • API가 타임아웃되면 Failed(reason)을 반환한다
  • 응답이 빈 배열이면 Fetched(postings=emptyList())를 반환한다(0건 판정은 상위에서 수행)
  • probe가 유효한 slug에 대해 샘플 3건을 포함한 후보를 반환한다
  • probe가 존재하지 않는 slug에 대해 빈 후보 목록을 반환한다
  • supports(GREENHOUSE)가 true, 다른 플랫폼에는 false를 반환한다
  • base URL을 descriptor(base_url)에서 주입받아 호출한다 — 호스트가 하드코딩되지 않아 공개형(boards-api)·프록시형(api-public.toss.im) 모두 같은 어댑터로 호출된다
  • 봉투 응답 {success:[...]}(토스 프록시 fixture)를 파싱하면 raw 배열과 동일하게 RawJobPosting 목록으로 변환된다 — 봉투 유무와 무관하게 결과가 같다
  • 토스 프록시 응답의 absolute_url(gh_jid 포함)·updated_at·metadata가 표준 Greenhouse와 동일 필드로 매핑된다