[BE-52] 교차 회사 공고 목록 API · QueryDSL 도입 (FR-78, NFR-12)

작업 내용 (설계 의도)

근거 TDD: 20260808-지원관리-확장-tdd.md — “방안 7 교차 회사 목록 조회”, “API 계약 1단계 교차 회사 공고 목록”

변경 사항

현재 공고 목록은 회사 단위뿐이고(JobPostingApiController.kt:35-41), 페이지네이션이 없으며, SQL에는 company_id = ? 하나만 내리고 나머지는 메모리 필터입니다(JobPostingRepositoryImpl.kt:71-75). 정렬도 매퍼에서 메모리 정렬이라(JobPostingResponseMapper.kt:121-128) 페이지네이션과 결합할 수 없습니다. 이 구조를 전 회사로 확장하면 NFR-12(수천 건, P95 500ms)가 성립하지 않습니다.

  1. QueryDSL을 활성화합니다. 의존성·kapt는 이미 선언돼 있으나(build.gradle.kts:52-56) 사용처가 0건입니다. JobPostingSearchRepositoryImpl을 첫 사용처로 만들어 필터·정렬·페이징을 전부 SQL로 내립니다.
  2. 플랫폼 OR 매칭(FR-78 B-5)은 job_postings.platform(BE-48 신설) + dedup_group_id 서브쿼리로 posting 소유 테이블 안에서 처리합니다. job_sources 조인은 no-crosscontext-raw-read 위반이라 쓰지 않습니다.
  3. 정렬 안정성 — 모든 정렬 키 뒤에 id를 붙여 페이지 간 항목 중복·누락을 막습니다.
  4. 응답 조립은 id 집합 배치 조회 1회씩입니다 — watchlist(관리 상태·태그), matching(매칭 결과), application(지원), company(회사명·origin), posting(그룹 플랫폼 집합). 항목마다 조회하는 기존 N+1(JobPostingResponseMapper.kt:76·:86, JobPostingMatchResultRepositoryImpl.kt:56-57)을 반복하지 않습니다.
  5. matchScore·recommendation은 단계별 점진 노출입니다 — 1단계 배포 시점에는 각각 null을 내려 FE가 하위 호환으로 동작합니다.
  6. 기존 회사별 목록 API는 변경하지 않습니다 — 신규 엔드포인트 GET /api/job-postings만 추가합니다(하위 호환).
  7. size 상한 100, 초과 시 400. 7-1. matchedOnly는 계약에서 제거됐습니다 (2026-08-10 설계 결정) — 구현 유보가 아니라 넣지 않기로 확정입니다. 근거는 TDD “미채택 파라미터 — matchedOnly” 절(PRD FR-78 필터 목록에 없음 / FR-25 원칙에 역행 / id 집합·상한 2중 조합 비용 / matched·matchScore로 대체 가능).
    • matchedOnly=true 요청은 400 JOB_POSTING_FILTER_NOT_SUPPORTED로 계속 거부합니다. 계약에서 뺐다고 방어를 걷어내지 마세요 — 조용히 무시되면 필터 없는 전체 목록이 200으로 돌아가 FE 토글이 오작동합니다.
  8. companyId 필터 추가 (C-3)job_postings.company_id가 posting 자기 컬럼이고 idx_job_postings_company_status_seen(baseline...sql:97)이 이미 있어 추가 인덱스 없이 성립합니다. repeatable 파라미터로 OR 필터입니다.
  9. 정렬 인덱스는 first_seen_at 기준 (B-2 — dba 실측)deadline_at은 실측 71.4% NULL(4,738/6,633)이고, 마감일 없는 공고를 뒤로 보내려면 ORDER BY deadline_at IS NULL, deadline_at 표현식 정렬이 필요해 B-tree가 순서를 주지 못합니다. 기본 정렬을 DISCOVERED_DESC(= first_seen_at)로 고정하고 인덱스도 그 기준으로 만듭니다. DEADLINE_ASC는 인덱스 조기 종료가 안 됨을 코드 주석에 남깁니다.
  10. sort=PRIORITY_DESC의 페이지네이션 규칙 (C-6) — 정렬 키(watch_priority)가 다른 컨텍스트 테이블(job_posting_watch_states)에 있어 조인이 금지됩니다.
    • watchedOnly=true를 강제합니다(미지정이면 서버가 강제, 명시적 false400 JOB_POSTING_SORT_NOT_APPLICABLE) — 관리 상태가 없는 공고는 우선순위 자체가 없습니다.
    • application 레이어가 watchlist에서 우선순위 정렬된 그룹 id 목록(HIGH → NORMAL → LOW, 동순위 updated_at 내림차순)을 받아, posting 검색에 필터(IN)와 정렬 기준(CASE 순서)으로 동시에 넘깁니다. 나머지 posting 필터와 LIMIT/OFFSET이 한 쿼리에서 적용됩니다.
    • 그룹 id 목록 상한 1,000건 — 초과 시 400 WATCH_STATE_FILTER_LIMIT_EXCEEDED 이고 응답에 actualCount·limit을 실어 FE가 “N건 → 1,000건 이하로 좁혀 주세요”를 만들 수 있게 합니다. 무한히 커지는 IN 절을 구조적으로 막습니다. 10-1. 태그 필터 tag 추가 (D) — FR-77이 개인 태그를 요구하는데 필터가 없으면 FE가 표시만 하고 거를 수 없습니다. 채택 근거는 한계 비용이 거의 0이라는 점입니다 — 태그도 watchlist 소유라 조인이 금지되지만, 바로 위 PRIORITY_DESC가 만든 “watchlist가 그룹 id 목록을 해석해 posting에 넘긴다”는 기계를 그대로 재사용하면 됩니다. 새 구조·새 인덱스·새 상한이 없습니다.
    • repeatable tag하나라도 일치하면 포함(OR).
    • watchedOnly를 함의합니다 — 태그는 관리 상태가 있어야만 존재하므로 tag 지정 시 watchedOnly=true를 강제하고, 명시적 falseJOB_POSTING_SORT_NOT_APPLICABLE입니다.
    • 같은 1,000건 상한을 공유합니다.
  11. 공고 상세 응답 확장 (C-1 — FE 블로커) — 관리 상태는 dedupGroupId로만 저장 가능한데 기존 상세 응답(JobPostingDetailResponse.kt:14-20)에 그 값이 없어, 딥링크·직접 URL 진입 시 관리 상태 섹션이 동작하지 않습니다. dedupGroupId(nullable)와 watchState(nullable)를 추가합니다(추가만 — 하위 호환). watchState를 함께 내려 상세 화면이 왕복 2회를 하지 않게 합니다.
  12. 공고 기준 관리 상태 저장 엔드포인트 PUT /api/job-postings/{jobPostingId}/watch-state 신설 (C-1) — 그룹이 아직 없는 공고(08:30 배치 전)를 위해서입니다.
    • 조회(GET) 시점에는 그룹을 만들지 않습니다 — GET이 쓰기를 하면 멱등·캐시 가정이 깨지고, 배치가 아직 보지 못한 공고에 그룹을 선점해 정체성이 흔들립니다.
    • 대신 쓰기 요청인 이 엔드포인트가 그룹이 없으면 해당 공고의 dedup_key단독(singleton) 그룹을 만든 뒤 관리 상태를 저장합니다.
    • 이렇게 만든 단독 그룹은 다음 08:30 배치의 그룹 정체성 승계 규칙에 그대로 올라탑니다 — 배치가 계산한 클러스터가 이 공고를 포함하므로 교집합이 1 이상이 되어 기존 그룹이 재사용되고 관리 상태가 보존됩니다. 별도 이관 로직이 필요 없습니다.
    • dedup_key가 없는 공고(백필 전 잔존분)는 409 POSTING_DEDUP_KEY_ABSENT로 거부합니다.
    • 08:30 배치와의 경합은 방어하지 않습니다 (E-1) — 배치가 그룹 정체성을 조회한 뒤 커밋 사이에 단독 그룹이 생기면 그 그룹이 고아가 되고 관리 상태가 화면에서 분리될 수 있습니다. 데이터 손실이 아니고(행·이력 보존, 재설정 1클릭) 창이 극히 좁아(단일 사용자 × 1일 1회 배치) 락·쓰기 거부는 정상 경로에 상시 비용만 더합니다. 대신 배치가 고아 그룹(멤버 0 + 관리 상태 보유) 개수를 WARN 로그로 남기고, 실제 관측되면 그때 방어를 검토합니다.

파일 소유: JobPostingApiController.kt에 엔드포인트 1개를 추가합니다. 같은 wave에 이 파일을 건드리는 다른 티켓이 없습니다.

의존

  • BE-48 (platform·dedup_group_id 컬럼, 그룹 영속화)
  • BE-50 (관리 상태 조회 — 필터·응답 필드)

다이어그램

처리 흐름

sequenceDiagram
    participant FE as web(SPA)
    participant C as JobPostingApiController
    participant U as ListAllJobPostingsUseCase
    participant W as WatchStateDomainService
    participant S as JobPostingSearchDomainService
    participant M as MatchResultQueryDomainService
    participant Co as CompanyDomainService
    FE->>C: GET /api/job-postings?watchStatus=..&platform=..
    C->>U: execute(command)
    U->>W: findGroupIdsBy(watchStatuses) — 필터용
    U->>S: search(criteria) — SQL 필터·정렬·페이지
    S-->>U: JobPostingSearchPage(items, totalCount)
    U->>W: findAllBy(dedupGroupIds) 배치 1회
    U->>M: findAllBy(jobPostingIds) 배치 1회
    U->>Co: findAllBy(companyIds) 배치 1회
    U->>U: 응답 조립 (matchScore·recommendation은 null 허용)
    U-->>C: PageResponse<CrossCompanyJobPostingItem>

클래스 의존

flowchart LR
    subgraph Presentation["presentation/posting"]
        Api[JobPostingApiController]
    end
    subgraph Application["application/posting"]
        UC[ListAllJobPostingsUseCase]
        Mapper[CrossCompanyPostingResponseMapper]
    end
    subgraph Domain["domain"]
        SDS[JobPostingSearchDomainService]
        SRepo[JobPostingSearchRepository]
        WDS[WatchStateDomainService]
        MDS[MatchResultQueryDomainService]
        CDS[CompanyDomainService]
    end
    subgraph Infra["infrastructure/posting"]
        Impl[JobPostingSearchRepositoryImpl QueryDSL]
    end
    Api --> UC
    UC --> Mapper
    UC --> SDS
    UC --> WDS
    UC --> MDS
    UC --> CDS
    SDS --> SRepo
    Impl -.implements.-> SRepo

테스트 케이스

  • 파라미터 없이 호출하면 대표 공고만 최근 발견 순으로 페이지 1이 반환된다
  • 비대표(중복) 공고는 결과에 포함되지 않는다
  • watchStatus=INTERESTED로 필터하면 해당 관리 상태 그룹의 대표 공고만 반환된다
  • platform 필터가 그룹 내 비대표 공고에만 일치해도 대표 공고가 결과에 포함된다 (OR 매칭)
  • platform 2개를 지정하면 둘 중 하나라도 일치하는 그룹이 포함된다
  • postingStatus=OPEN 필터가 SQL 레벨에서 적용된다
  • deadlineFrom·deadlineTo 범위 필터가 동작하고 마감일 없는 공고는 제외된다
  • keyword가 제목 부분 일치로 필터한다
  • sort=DEADLINE_ASC에서 마감일 없는 공고가 마지막에 온다
  • sort=PRIORITY_DESC에서 HIGH → NORMAL → LOW 순으로 정렬된다 (“미등록”은 대상이 아니다 — C-6이 watchedOnly=true를 강제하므로 관리 상태가 없는 공고는 애초에 결과에 없다)
  • 동순위 항목이 다수일 때 페이지 1과 2 사이에 항목 중복·누락이 0건이다 (id tie-break)
  • size=101이면 400 BAD_REQUEST다 (경계값)
  • size=100은 통과한다 (경계값)
  • 결과 20건에 대해 watchlist·matching·company 조회가 각 1회만 발생한다 (N+1 회귀 방지)
  • totalCounthasNext가 마지막 페이지에서 정확하다
  • 매칭 결과가 없는 공고의 matched가 false, matchScore가 null이다
  • 추천도가 없으면 recommendation이 null이다 (2단계 미배포 상태 호환)
  • companyId 필터로 특정 회사 공고만 조회된다
  • companyId 2개를 지정하면 OR로 조회된다
  • sort=PRIORITY_DESC에서 watchedOnly를 지정하지 않으면 서버가 true로 강제한다
  • sort=PRIORITY_DESC + watchedOnly=false면 400 JOB_POSTING_SORT_NOT_APPLICABLE이다
  • tag 필터로 해당 태그를 가진 그룹의 대표 공고만 조회된다
  • tag 2개를 지정하면 하나라도 일치하는 그룹이 포함된다 (OR)
  • tag 지정 시 watchedOnly가 자동으로 true가 된다
  • tag + watchedOnly=false면 400 JOB_POSTING_SORT_NOT_APPLICABLE이다
  • 태그에 일치하는 그룹이 0건이면 빈 페이지를 반환한다 (0건 경계)
  • sort=PRIORITY_DESC에서 HIGH → NORMAL → LOW 순으로 정렬되고 동순위는 updated_at 내림차순이다
  • sort=PRIORITY_DESC 결과의 페이지 1·2 사이 항목 중복·누락이 0건이다
  • 관리 상태 그룹이 1,001건이면 sort=PRIORITY_DESC 요청이 400 WATCH_STATE_FILTER_LIMIT_EXCEEDED이고 actualCount=1001·limit=1000이 응답에 담긴다 (경계값)
  • 관리 상태 그룹이 정확히 1,000건이면 정상 처리된다 (경계값)
  • 태그 필터로 해석된 그룹이 1,001건이어도 같은 코드로 거부된다 (상한 공유)
  • 공고 상세의 watchState는 미분류면 null이다 (404가 아님)
  • 공고 상세 응답에 dedupGroupIdwatchState가 포함된다
  • 그룹이 없는 공고의 상세는 dedupGroupId: null, watchState: null이다
  • GET 상세 조회는 그룹을 생성하지 않는다 (조회가 쓰기를 하지 않음)
  • PUT /api/job-postings/{id}/watch-state는 그룹이 없으면 단독 그룹을 만들고 관리 상태를 저장한다
  • 단독 그룹 생성 후 dedup 배치를 돌리면 그룹 id가 유지되고 관리 상태가 보존된다 (정체성 승계)
  • dedup_key가 없는 공고에 PUT .../watch-state를 호출하면 409 POSTING_DEDUP_KEY_ABSENT
  • 기존 GET /api/companies/{companyId}/job-postings 응답이 변경되지 않는다 (회귀 — 상세는 필드 추가만)