[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 보장):
- 엔드포인트를 하드코딩하지 않습니다. 호출 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에서 옵니다.SourceRequestExecutorallowedHosts에 해당 호스트를 등록하는 것도 구성(코드 아님)입니다. - 응답 봉투를 허용합니다. 파서는 (a) 표준 raw 배열
[...]과 (b) 봉투{success:[...]}(또는{jobs:[...]}) 둘 다에서 공고 배열을 추출합니다 — “봉투 → 배열 위치”를 소스 구성으로 판별하거나, 배열 필드를 관대하게 탐색합니다. 어느 경우든 배열 원소는 동일한 GreenhouseGreenhouseJobResponseDTO로 매핑됩니다.
이 두 요구사항 덕분에 토스는 새 어댑터·새 코드 없이
job_sources한 행(platform=GREENHOUSE, base_url=프록시 URL, source_slug=toss) 등록만으로 수집됩니다. 처음 보는 스키마의 플랫폼이 등장할 때만 새 어댑터가 필요하고, Greenhouse 계열은 여기 해당하지 않습니다.
능력 선언(SourceCapabilities):
| 항목 | 값 | 근거 |
|---|---|---|
changeSignalKind | UPDATED_AT | 응답의 updated_at 필드 (FR-12) |
providesDeadline | false | 마감일 필드 자체가 없습니다 |
providesStructuredWorkArrangement | true | metadata[] 보유 — ①근거로 CONFIRMED 판정 가능 (FR-27) |
requiresDetailFetch | false | ?content=true로 목록 응답에 본문 포함 |
detailRequestDelayMillis | 0 | 목록 1회 호출 |
핵심 설계 의도:
- 마감일 정규화(FR-13): 필드가 없으므로
deadlineAt은 항상null입니다. 도메인은 이를 상시채용으로 판정합니다. “필드 없음”이라는 사실이 도메인에 새어 나가지 않게 어댑터에서 종결합니다. id→sourceJobId,title→ 직무 매칭 대상,absolute_url→ 공고 링크,metadata[]→structuredTags,content→descriptionBody(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으로 변환된다 - 모든 공고의
deadlineAt이null로 정규화되어 상시채용으로 취급된다 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와 동일 필드로 매핑된다