공고알림앱 DB 설계 (design-db)

Background

  • 근거 PRD: /Users/biuea/Desktop/dpdpdndn/프로젝트/공고알림앱/20260722-타깃-공고-알림-및-지원-히스토리-prd.md (승인 완료, 이번 범위 = Milestone 1단계 = P0 전부)
  • 근거 TDD: /Users/biuea/Desktop/dpdpdndn/프로젝트/공고알림앱/20260722-타깃-공고-알림-및-지원-히스토리-tdd.md (도메인 모델·ERD 요약·유니크 제약 8개)
  • 근거 사전 조사: /Users/biuea/Desktop/dpdpdndn/프로젝트/공고알림앱/20260722-채용소스-조사-브리프.md
  • 대상 레포: /Users/biuea/recruitment-applicationREADME.md.claude/private-project 마커만 존재하는 빈 레포입니다. 마이그레이션·소스·빌드 스크립트가 전부 부재함을 실제 파일 조회로 확인했습니다(find . -name "*.sql" -o -name "*.kt" → 0건). 따라서 AS-IS 스키마는 없고, 이 문서가 정의하는 것이 V1 베이스라인 전체입니다.

이 문서는 설계까지가 범위입니다. DDL 작성·실행은 private-mysql-implementer가, V1 베이스라인 마이그레이션 파일 작성은 BE-01 티켓이 이 문서를 그대로 입력으로 받아 수행합니다.

Overview

항목결정
저장소MySQL 8.0 단일 (MongoDB·Redis 미도입 — 1절에 근거)
마이그레이션Flyway, 앱 레포 src/main/resources/db/migration/, V1 단일 베이스라인
테이블 수20개 (company 2 / posting 5 / matching 8 / application 3 / notification 1 / common 1 — 아래 목록 참조). 애그리게이터 편입에도 신규 테이블 0개 — 크로스 소스 중복은 자기참조 컬럼으로 표현(판단 8)
인덱스 수유니크 19개(도메인 제약, job_sources만 유형별 2개) + 조회 전용 9개 = 28개. 조회 전용 9개는 전부 쿼리 패턴 표에 1:1 대응(v1 대비 dedup_key·representative_id·company_origin 3개 추가)
삭제 정책도메인 데이터 물리 삭제 0건(소프트 삭제 전용). 예외는 평가 파생 캐시 2개 테이블뿐
3년 후 규모애그리게이터 6종 P0 반영 — 전체 약 1.1GB(본문 960MB가 지배) / 최대 테이블 job_postings 약 140,000행 — 그래도 파티셔닝·샤딩·읽기 복제 전부 미채택(14만 행은 단일 테이블 정상 범위). 본문 용량만 1GB 임계 근접 → 보존 정책의 본문 비우기 감시가 P0 운영 항목
데이터 마이그레이션해당 없음 (기존 데이터 0건, 백필 대상 없음)

Terminology

용어이 문서에서의 의미
델타 모수수집 회차의 미발견 판정 대상 집합 = posting_origin='COLLECTED' AND access_restricted=0 인 공고
비정상 회차수집 실패(run_status='FAILED') 또는 fetched_count=0 인 회차. 소스 가드·고장 판정의 공통 입력
멱등 키{target_type}:{target_id}:{notification_type}:{dispatch_sequence} 문자열. 발송 성공 시에만 채우는 nullable 유니크 컬럼
평가 파생 캐시매칭 재평가 때마다 재작성되는 테이블(job_posting_work_arrangements, job_posting_matched_keyword_groups). 이력이 아니라 계산 결과이므로 물리 재작성 허용
코드값ENUM 타입 대신 VARCHAR로 저장하는 상태·구분 문자열. 허용값은 컬럼 COMMENT에 전수 기재
소스 유형source_type. 회사 종속형(COMPANY_BOUND — 회사 slug로 수집)과 애그리게이터형(AGGREGATOR — 검색 조건으로 수집, 회사에 안 붙음) 2종 (FR-60)
회사 출처 축company_origin. 사용자 직접 등록(WATCHED)과 애그리게이터 자동 발견(DISCOVERED). registration_type(AUTO/MANUAL_ONLY)과 직교 (FR-61)
dedupKey정규화 회사명 + 정규화 제목으로 엔티티가 쿼리 없이 계산하는 중복 판정 키. 같은 값이 크로스 소스 중복 후보 그룹 (FR-63)
대표 공고같은 dedupKey 그룹에서 08:30 dedup 배치가 선정한 1건(회사 직접 > 애그리게이터). representative_id IS NULL. 비대표는 대표 id를 가리키고 알림에서 제외 (FR-64·65)

1. 저장소 선택 — MySQL 단일 (MongoDB 미채택)

private-mongodb-convention의 “저장소 선택 기준”을 데이터 단위마다 적용했습니다. 기본은 MySQL이며, Mongo 채택 근거 3종(스키마 유동·대량 적재 + 관계 불필요·계층 구조가 본질)에 해당하는 데이터 단위가 하나도 없습니다.

데이터 단위Mongo 채택 근거 해당 여부저장소판단 근거
회사·소스 매핑없음MySQLCompany : JobSource = 1:N 관계 정합이 본질. 소스 30개 미만의 소량 마스터
공고(job_postings)“대량 적재 + 관계 불필요”를 애그리게이터 6종 P0에서 재검토했으나 여전히 불성립MySQL애그리게이터로 연 증가가 약 41,000건으로 늘었지만(용량 절), 공고는 회사·소스·매칭 결과·지원 이력·**크로스 소스 대표(자기참조)**와 전부 관계로 묶입니다. 대표 선정·dedup은 조인·집계·트랜잭션이 본질이라 Mongo가 오히려 불리합니다. 유니크 제약이 중복 판정의 방어선인 것도 그대로
공고 본문(JD)“스키마 유동·문서형”을 검토했으나 불성립MySQL TEXT저장 대상이 단일 문자열 1개입니다. 문서 구조가 없어 Mongo의 이점(중첩 문서·부분 갱신)이 발생하지 않습니다
소스 구조화 태그검토했으나 불성립MySQL 정규화 테이블어댑터가 List<String>으로 평탄화해 넘기므로(TDD RawJobPosting.structuredTags) 1:N 테이블로 정확히 표현됩니다. 컨벤션상 JSON 컬럼 금지이기도 합니다
수집 회차 이력”대량 적재 + 관계 불필요”에 형태상 가장 근접MySQL형태는 로그성이나 규모가 연 10,950행입니다. 이 규모에 별도 저장소를 붙이면 운영 대상만 2개가 되고, unique(job_source_id, run_date)로 하루 1회 실행을 강제하는 것이 중복 실행 방지의 핵심 장치(TDD 동시성 표)라 유니크 제약이 있는 MySQL이 유리합니다
알림 발송 이력없음MySQLnullable 유니크 인덱스로 멱등을 구현하는 것이 설계의 핵심(TDD 방안 4)입니다. Mongo의 partial unique index로도 가능하지만, 이 데이터만을 위해 저장소를 추가할 이유가 없습니다
지원·상태 전이·면접없음MySQL상태 전이 정합·유니크 제약 중심. 트랜잭션 필수
매칭 기준·평가 결과없음MySQLrevision 기반 재평가 판별이 조인·집계 중심
발견 회사(DISCOVERED)없음MySQL애그리게이터가 자동 등록하는 회사(3년 후 약 6,000곳). 자동 등록 멱등이 unique(companies.name)에 의존(같은 회사명이 여러 소스에서 중복 발견되면 1건으로 수렴). DB 유니크가 방어선이라 관계형이 필수
크로스 소스 중복 그룹”계층·중첩 구조가 본질”을 검토했으나 불성립MySQL 자기참조그룹이 트리·중첩이 아니라 대표 1 + 비대표 N의 평면 구조입니다. representative_id 자기참조 컬럼 1개로 완전히 표현되며, 별도 그룹 문서를 만들 이유가 없습니다(TDD 방안 9가 그룹 테이블을 명시적으로 미채택)

결론: 전부 MySQL 8.0 단일 저장소. 애그리게이터 편입은 이 결론을 바꾸지 않습니다. SSOT 분산이 없으므로 “어느 쪽이 진실인가” 문제도 발생하지 않습니다.

부수 판단:

  • Redis 미도입 — PRD Non-Goals(별도 캐시 계층 미도입). 멱등은 DB 유니크 제약으로 충분하고, 배치가 단일 프로세스라 분산 락도 불필요합니다(중복 실행은 unique(job_source_id, run_date)가 차단).
  • 본문 전문 검색 엔진 미도입 — P2의 자소서 답변 재사용 검색(FR-51)이 도입될 때 재검토합니다. P0에는 검색 요구가 없습니다.

Define Problem

AS-IS

마이그레이션·스키마가 존재하지 않습니다. 기존 마이그레이션 전수 재구성 결과는 “0건”이며, BE 코드의 Entity·쿼리 사용처도 0건입니다(파일 조회로 확인). 따라서 하위 호환·기존 인덱스와의 충돌 검토 대상이 없습니다.

TO-BE

V1 베이스라인 1개 마이그레이션으로 20개 테이블을 생성하고, feature_flags 2행만 정적 시드합니다(소규모 정적 시드 — Flyway DML 예외 조건 충족). 이후 스키마 변경은 expand-contract 순서를 따릅니다.


Possible Solutions — 주요 설계 판단

표만 나열하지 않고 각 판단의 대안과 미채택 사유를 함께 적습니다.

판단 1 — 알림 멱등: nullable 유니크가 MySQL에서 성립하는가

TDD 방안 4-c가 전제하는 “실패 레코드는 여러 건 공존, 성공 레코드는 대상당 1건”이 MySQL에서 실제로 성립하는지가 이 설계의 전제 조건입니다.

방안설명판정
a. unique(target_type, target_id, notification_type, dispatch_sequence) (4컬럼 NOT NULL)시도 시점에 키 점유미채택. 실패 레코드가 키를 점유해 재발송이 영구 차단됩니다(시나리오 7-3 위반)
b. 성공 레코드만 별도 테이블 분리notification_dispatch_successes 신설미채택. 조회가 두 테이블로 갈리고, “같은 대상의 시도 이력”을 볼 때마다 UNION이 필요합니다. 테이블 1개로 해결되는 문제에 2개를 씁니다
c. idempotency_key VARCHAR(150) NULL + 단일 컬럼 유니크성공 시에만 값 채움채택. MySQL 8.0 매뉴얼: “A UNIQUE index permits multiple NULL values for columns that can contain NULL” — InnoDB의 유니크 인덱스는 NULL을 서로 다른 값으로 취급합니다. 따라서 실패(NULL) 다건 + 성공 1건이 성립합니다

성립합니다. 다만 이 성질에 의존하므로 아래 3가지를 BE-01이 반드시 지켜야 합니다.

  1. idempotency_keyDEFAULT ''를 넣지 말 것 — 빈 문자열은 NULL이 아니어서 두 번째 실패 레코드가 유니크 위반으로 거부됩니다.
  2. 컬럼을 NOT NULL로 만들지 말 것 — 유니크 제약과 함께 선언하다 보면 습관적으로 NOT NULL을 붙이기 쉽습니다.
  3. 애플리케이션이 dispatch_status='SENT' 인데 키가 NULL인 상태를 만들지 말 것 — 멱등 판정 쿼리(findSucceededBy(key))가 키로만 조회하므로, 키 없는 성공 레코드는 중복 발송을 유발합니다.

같은 성질을 job_postingsunique(job_source_id, source_job_id)에도 사용합니다 — 수동 등록 공고는 두 컬럼이 모두 NULL이라 여러 건이 공존합니다(의도된 동작).

판단 2 — 연속 미발견 카운터·마지막 발견 시각을 어디에 둘 것인가

방안설명판정
a. 공고 행(job_postings)에 컬럼으로 보유consecutive_miss_count, first_seen_at, last_seen_at을 같은 행에채택. ① 공고당 정확히 1조(1:1)이고 ② 델타 판정이 읽기·쓰기를 같은 트랜잭션에서 함께 수행하므로 분리하면 조인과 두 번째 UPDATE만 늘어납니다 ③ markMissed()/markFound()가 카운터와 상태 전이를 원자적으로 바꿔야 하는데(2회 연속이면 CLOSED) 분리하면 두 테이블에 걸친 정합 문제가 생깁니다
b. job_posting_seen_states 별도 테이블고빈도 갱신을 메인 행에서 분리미채택. 이 분리의 정당한 근거는 “갱신 빈도가 높아 메인 행·보조 인덱스 갱신 비용이 문제될 때”인데, 여기서는 1일 1회 배치에서 OPEN 공고 약 1,200행 UPDATE가 전부입니다. 초당 0.014회 수준이라 근거가 성립하지 않습니다
c. 발견 이력을 회차별로 적재(job_posting_seen_logs)회차×공고 이력미채택. 연 1,200 × 365 ≈ 43만 행/년이 발생하는데, 이 데이터로 답할 질문이 요구사항에 없습니다(수집 이력은 소스 단위 job_posting_collection_runs가 이미 담당). 무한 성장 테이블을 근거 없이 만드는 것이 가장 나쁜 선택입니다

부수 결정: last_seen_at UPDATE가 보조 인덱스를 건드리지 않도록, last_seen_at·consecutive_miss_count어떤 인덱스에도 포함시키지 않았습니다. 인덱스에 포함된 컬럼(posting_status, deadline_at, first_seen_at, notification_eligible)은 상태 전이 시점에만 변합니다.

판단 3 — 공고 본문(JD)과 구조화 태그의 저장 위치

TDD RawJobPostingdescriptionBody·structuredTags를 담고 있는데, TDD ERD에는 이 둘을 담을 테이블이 없습니다. 그런데 FR-25(키워드 변경 시 과거 공고 재매칭)와 FR-27(②JD 본문 근거)이 성립하려면 본문·태그가 DB에 남아 있어야 합니다. 재평가 시점에 소스를 다시 호출하는 것은 불가능하기 때문입니다(소스가 이미 공고를 내렸을 수 있고, 1일 1회 요청 정책과도 충돌).

방안설명판정
a. job_postings.description_body TEXT 인라인컬럼 1개 추가미채택. 본문 평균 8KB로 테이블 용량의 90%를 차지합니다. 델타 스캔(SELECT ... WHERE job_source_id=?)과 목록 조회가 본문을 함께 읽게 되고, JPA에서 @Basic(fetch=LAZY)는 바이트코드 강화 없이는 동작하지 않아 실수로 전량 로딩될 위험이 큽니다
b. job_posting_descriptions 1:1 분리 + job_posting_source_tags 1:N 정규화본문·태그를 별도 테이블로채택. ① 델타·목록의 hot path가 좁은 행만 읽습니다 ② 태그는 컨벤션상 JSON 금지이므로 정규화가 강제됩니다 ③ 본문은 평가 시에만 읽는 cold 데이터라 접근 패턴이 명확히 갈립니다
c. 본문을 파일시스템·오브젝트 스토리지에외부 저장미채택. 3년 80MB 규모에 저장소를 하나 더 붙이는 것은 과잉이며, 백업 대상이 둘로 갈립니다

본문 스냅샷은 최신 1건만 유지합니다(TDD의 changedetection.io 벤치마킹 결론과 동일). 변경 이력 보관은 요구사항에 없습니다.

판단 4 — 근무형태 판정 결과의 구조 (값 + 근거 + 확신도)

TDD 제약표에 unique(job_posting_id, work_arrangement_keyword_id)가 있으므로 공고당 복수 근무형태 행이 가능하고, 근무형태 키워드 1개당 근거 1행입니다(“재택”과 “원격”이 둘 다 잡히면 2행).

  • 한 키워드가 ①구조화 필드와 ②JD 본문 양쪽에서 발견되면 유니크 제약상 1행만 남으므로, 확신도가 높은 근거를 채택합니다(BE-03 테스트 “태그는 CONFIRMED이고 본문은 부정어인 경우 태그가 채택되어 CONFIRMED 유지”와 정합).
  • 대표 확신도(정렬용)는 job_posting_match_results.top_confidence에 캐시합니다.
  • 정렬 캐시를 문자열로만 두면 안 됩니다. VARCHAR 알파벳 정렬은 CONFIRMED < INFERRED < LIKELY < UNKNOWN 순서라 도메인 순서(CONFIRMED < LIKELY < 그 외)와 다릅니다. 그래서 work_arrangement_sort_rank INT를 함께 두고 정렬은 이 컬럼으로만 수행합니다(FR-30: CONFIRMED·LIKELY만 정렬 기준, 나머지는 뒤로).

판단 5 — 소프트 삭제 전용 원칙과 그 예외

FR-18·NFR-5에 따라 물리 삭제 엔드포인트를 만들지 않습니다. 마감·재오픈은 전부 상태 전이로 표현됩니다.

데이터삭제 표현확인
공고 마감posting_status='CLOSED' + closed_reason + closed_atDELETE 없음
공고 재오픈posting_status='OPEN' + reopened_at 기록 + consecutive_miss_count=0같은 행의 상태 전이. 신규 알림 대상 아님(레코드가 신규가 아니므로 자연히 성립)
소스 비활성화disabled_at 기록회사·소스 행 유지
매칭 키워드 삭제(API 존재)deleted_at 기록 (소프트 삭제)과거 평가 결과가 job_keyword_group_id를 참조하므로 물리 삭제하면 이력이 끊깁니다
지원 이력삭제 수단 없음NFR-5

예외 — 평가 파생 캐시 2개는 물리 재작성을 허용합니다: job_posting_work_arrangements, job_posting_matched_keyword_groups. 재평가마다 “해당 공고의 행 삭제 후 재삽입”이 가장 단순하고, 이 둘은 이력이 아니라 현재 기준(criteria revision)에 대한 계산 결과입니다. 삭제 범위가 항상 WHERE job_posting_id = ? 단건으로 한정되므로 대량 삭제 사고 위험도 없습니다.

판단 6 — 규모에 과한 설계의 명시적 미채택

후보판정근거
파티셔닝(job_postings·collection_runs를 월/연 단위로)미채택애그리게이터 6종 P0에서도 최대 테이블이 3년 후 약 140,000행입니다. 파티션 프루닝의 이득이 발생하는 구간(수천만 행)과 2자릿수 차이납니다
샤딩미채택사용자 1명, 쓰기 1일 1회
읽기 복제본미채택조회 QPS가 사실상 0입니다. 복제 지연이라는 새 실패 모드만 추가됩니다
이력 테이블 아카이빙(별도 archive 테이블 이관)미채택(임계 조건부 보류)보존 정책 절에서 임계와 방법만 정의하고, 임계 도달 전에는 만들지 않습니다
커버링 인덱스 최적화·인덱스 힌트미채택스캔 대상이 수천 행이라 옵티마이저 선택이 잘못돼도 체감 차이가 없습니다
전문 검색 인덱스(FULLTEXT)미채택P0에 본문 검색 요구가 없습니다(FR-51은 P2)

판단 7 — job_sources 유니크 제약: nullable 컬럼과 소스 유형 분기가 MySQL에서 성립하는가 (핵심 검증)

애그리게이터 편입으로 company_id·source_slug가 nullable이 되고, 유니크 키가 소스 유형별로 갈립니다(TDD DBA 파급). MySQL의 “유니크 인덱스는 NULL을 서로 다른 값으로 취급”(NULL distinct) 성질이 이 설계에 유리하게도 불리하게도 작용하므로 정밀 검증했습니다.

두 유니크 인덱스를 겁니다.

인덱스대상성립 여부
uk_job_sources_company_bound (platform, source_slug)회사 종속형 중복 방지✅ 회사 종속형은 source_slug NOT NULL(앱 보장)이라 (GREENHOUSE, daangn)이 정상 강제됩니다. 애그리게이터 행은 source_slug IS NULL이라 이 인덱스에서 (SARAMIN, NULL)이 되고 NULL distinct로 다건 허용 — 애그리게이터를 이 인덱스가 잘못 막지 않습니다(의도)
uk_job_sources_aggregator (platform, search_category_code, search_keyword)애그리게이터 중복 방지⚠️ 조건부 성립 — 아래 규칙 필요

애그리게이터 인덱스의 함정과 해결:

  • 회사 종속형 행은 search_category_code IS NULL이므로 애그리게이터 인덱스에서 (GREENHOUSE, NULL, NULL)이 되고 NULL distinct로 다건 허용 — 회사 종속형이 애그리게이터 인덱스에 걸려 서로를 막는 사고는 발생하지 않습니다(NULL 덕분). 여기까진 안전.
  • 진짜 함정: 애그리게이터를 “카테고리만 걸고 키워드 없이” 등록하면 search_keyword IS NULL이 됩니다(FR-60: keyword는 선택). 그러면 같은 (SARAMIN, '84', NULL) 두 건이 NULL distinct로 둘 다 허용 — 중복 등록이 막히지 않습니다.
  • 해결(BE-01·BE-17 필수 규칙): 애그리게이터 행은 search_keyword에 NULL 대신 빈 문자열 ''을 저장해 “키워드 없음”을 표현합니다. ''은 비-NULL이라 (SARAMIN, '84', '') 두 건이 유니크로 정상 충돌합니다. 회사 종속형은 search_category_code가 NULL이라 search_keyword 값과 무관하게 애그리게이터 인덱스에서 항상 distinct이므로, 이 '' 규칙이 회사 종속형에 영향을 주지 않습니다.
  • '' 사용은 “빈 문자열 DEFAULT 지양” 일반 지침의 의도적 예외입니다 — MySQL에서 이 유니크를 DB 레벨로 강제하는 유일한 방법이고, ''이 “카테고리 전체·키워드 없음”이라는 명확한 도메인 의미를 갖기 때문입니다. search_category_code는 애그리게이터에서 항상 필수(NOT NULL 값)이므로 별도 규칙이 필요 없습니다.

대안 검토: 단일 파생 컬럼(source_unique_key)에 "CB:{platform}:{slug}" / "AG:{platform}:{cat}:{kw}"를 계산해 넣고 유니크 1개로 통합하는 방식도 검증했습니다(공고 dedup_key와 같은 패턴). 이쪽이 NULL 함정을 원천 제거하지만, TDD가 “유형별 2개 유니크”를 명시했고 컬럼 1개가 추가되므로 2-인덱스 + '' 규칙을 채택합니다. 구현에서 '' 규칙 준수가 부담되면 파생 컬럼 방식으로 전환 가능함을 Open Questions에 남깁니다.

판단 8 — 크로스 소스 중복 그룹: 별도 그룹 테이블 vs 자기참조 (TDD 방안 9 따름)

TDD 방안 9가 그룹 테이블을 명시적으로 미채택했고, 그 판단을 따릅니다. DBA 관점에서 재검증한 근거:

방안판정근거
a. job_posting_dedup_groups 테이블 + group_id FK미채택그룹이 대표 1 + 비대표 N의 평면 구조라 트리·계층이 아닙니다. 그룹 테이블은 “대표 공고 id”만 담게 되는데, 그 정보는 비대표 행의 representative_id에 이미 있습니다. 테이블 1개·조인 1개가 순수 중복입니다
b. 자기참조 representative_id BIGINT NULL채택NULL=대표(또는 단독), 값=그 대표를 가리키는 비대표. 대표 재선정(애그리게이터 대표 → 회사 직접본 등장)이 행 UPDATE 1회로 끝납니다. 그룹 조회는 WHERE representative_id = :대표id, 그룹 판정은 dedup_key GROUP BY

FK 제약 없음 규칙과의 정합: representative_id는 자기 테이블 PK를 가리키지만 FK 제약을 걸지 않습니다(컨벤션). 자기참조 FK는 특히 대량 UPDATE(dedup 배치의 대표 재지정) 때 잠금·검증 비용을 유발하므로, FK 없는 일반 컬럼이 규칙과도 성능과도 맞습니다. 참조 정합(가리키는 대표가 실존하는지)은 dedup 배치가 보장합니다.

판단 9 — 관심/발견 회사 축과 그 조회 인덱스

company_origin(WATCHED/DISCOVERED)은 registration_type(AUTO/MANUAL_ONLY)과 직교하는 별도 컬럼입니다(TDD 방안 10). 두 개념을 한 컬럼에 합치면 AUTO+WATCHED, AUTO+DISCOVERED 같은 조합이 표현 불가해집니다.

  • 인덱스가 v1과 달라집니다. v1에서는 companies가 20행이라 무인덱스로 뒀지만, 애그리게이터 자동 등록으로 DISCOVERED 회사가 3년 후 약 6,000곳이 됩니다. FE가 ?companyOrigin=WATCHED&page=&size=로 필터 + 페이지네이션하므로(TDD API 계약) 조회 인덱스가 필요합니다.
  • idx_companies_origin_name (company_origin, name) — Equality(출처) → Sort(이름). WATCHED 필터는 6,000 중 20행이라 선택도가 높고, 정렬 컬럼을 인덱스에 포함해 페이지네이션 filesort를 제거합니다. DISCOVERED 필터는 대부분 행을 반환하지만 정렬·페이지 잘라내기를 인덱스가 그대로 지원합니다.
  • 자동 등록 멱등은 uk_companies_name (name)이 담당합니다 — 같은 회사명이 사람인·점핏 양쪽에서 발견돼도 INSERT ... ON DUPLICATE/사전 조회로 1건에 수렴합니다.

Detail Design

공통 규약 (BE-01이 그대로 적용)

항목근거
엔진InnoDB트랜잭션·유니크 제약 필수
문자셋·콜레이션utf8mb4 / utf8mb4_0900_ai_ci한글·이모지(디스코드 메시지) 안전. 인크루트 EUC-KR은 어댑터가 디코딩해 UTF-8 문자열로 넘기므로(TDD 어댑터 표) DB는 UTF-8만 다룹니다
PKid BIGINT NOT NULL AUTO_INCREMENT 전 테이블 통일컨벤션
참조 컬럼{entity}_id BIGINTFK 제약 없음, 정합은 애플리케이션 책임컨벤션(FK 금지)
상태·구분값ENUM 금지 → VARCHAR, 허용값 전수를 COMMENT에 기재컨벤션
불리언BOOLEAN 금지 → TINYINT(1)컨벤션
날짜·시각DATETIME(6) (예외 1건: job_posting_collection_runs.run_dateDATE — 아래 근거)컨벤션
JSON금지 — 태그·근거는 정규화 테이블, 본문은 TEXT컨벤션
COMMENT테이블·컬럼 전부 필수컨벤션
공통 컬럼created_at DATETIME(6) NOT NULL / updated_at DATETIME(6) NOT NULL — 아래 표에서 생략하고 전 테이블에 부여. 추가 전용(append-only) 테이블은 created_at 둡니다(해당 테이블에 표기)
낙관적 잠금version INT NOT NULL DEFAULT 0job_postings, job_applications 2개 테이블만TDD 동시성 표(배치 vs 사용자 API 동시 수정 지점)

시간대 처리 (BE-01 주의): 도메인 시간 타입은 ZonedDateTime이고 DB DATETIME은 타임존을 저장하지 않습니다. spring.jpa.properties.hibernate.jdbc.time_zone=UTC를 지정해 전 컬럼을 UTC로 저장하고, 표시·배치 기준 시각(Asia/Seoul)은 애플리케이션에서 변환합니다. 유일한 예외가 run_date입니다 — 이 컬럼은 “자정(KST) 배치의 실행 일자”라는 업무 일자 키이므로 UTC 날짜가 아니라 KST 날짜로 계산해 넣어야 합니다. UTC 날짜로 넣으면 KST 09:00 이전 실행분이 전날로 기록돼 unique(job_source_id, run_date)의 하루 1회 보장이 깨집니다.

테이블 목록 (20개)

컨텍스트테이블존재 이유 (1줄)
companycompanies관심 회사(WATCHED)와 애그리게이터 발견 회사(DISCOVERED)를 함께 보관. 자동 수집 가능 여부(registration_type)와 출처 축(company_origin)을 보유
companyjob_sources채용 데이터 출처. 회사 종속형(회사+slug)과 애그리게이터형(검색 조건)이 공존
postingjob_postings수집·수동 공고의 단일 저장소. 상태·마감일·델타 카운터 + 크로스 소스 대표(representative_id)·중복 판정 키(dedup_key)
postingjob_posting_descriptions공고 본문(JD) 1:1 분리 — 근무형태 ②근거·재평가의 입력
postingjob_posting_source_tags소스가 제공한 구조화 태그(Greenhouse metadata[]) — 근무형태 ①근거
postingjob_posting_collection_runs소스×실행일 수집 회차 이력. 성공률·소스 가드·중복 실행 방지의 근거
postingjob_source_health소스별 연속 비정상 일수·고장 진입 상태(소스당 1행)
matchingjob_keyword_groups직무 동의어 그룹(매칭 조건 1단위)
matchingjob_keyword_synonyms그룹에 속한 동의어와 그 정규화 값
matchingjob_exclusion_keywords제외 키워드(매칭을 이깁니다)
matchingwork_arrangement_keywords근무형태 키워드(추출 대상 지정 + 정렬 기준)
matchingmatch_criteria_revisions매칭 기준 변경 이력 = 재평가 판별의 기준점(현재 revision = MAX(id))
matchingjob_posting_match_results공고당 평가 결과 1건(매칭 여부·제외 사유·대표 확신도·평가 revision)
matchingjob_posting_matched_keyword_groups평가 결과가 어떤 그룹에 매칭됐는지(N:M 해소)
matchingjob_posting_work_arrangements근무형태 판정 값 + 근거 + 확신도 (공고당 키워드별 1행)
applicationjob_applications지원 기록. “미지원 = 행 미존재”(FR-42)
applicationjob_application_status_histories상태 전이 이력 — 히스토리 조회의 SSOT(FR-47)
applicationjob_application_interviews면접 회차(상태값이 아닌 별도 기록, FR-46)
notificationnotification_dispatches알림 발송 시도 1건(성공·실패 모두). 멱등·재발송·실패 조회의 근거
commonfeature_flags런타임 기능 토글(posting.auto-close, notification.discord-dispatch)

명명 변경 (TDD 대비): TDD의 applications / application_status_histories / interviewsjob_applications / job_application_status_histories / job_application_interviews 로 바꿨습니다. private-db-schema-convention “명명”이 applications금지 예시로 직접 명시하고 있습니다(도메인 기반 명명, no-over-abstract-name과 동일 원칙). 도메인 모델(Application 클래스)은 그대로 두고 테이블명만 조정하는 변경이라 TDD 설계와 충돌하지 않습니다.

ERD (1) — 수집·매칭

erDiagram
    COMPANIES ||--o{ JOB_SOURCES : has
    COMPANIES ||--o{ JOB_POSTINGS : owns
    JOB_SOURCES ||--o{ JOB_POSTINGS : collects
    JOB_SOURCES ||--o{ JOB_POSTING_COLLECTION_RUNS : records
    JOB_SOURCES ||--|| JOB_SOURCE_HEALTH : tracks
    JOB_POSTINGS ||--o{ JOB_POSTINGS : represents
    JOB_POSTINGS ||--o| JOB_POSTING_DESCRIPTIONS : details
    JOB_POSTINGS ||--o{ JOB_POSTING_SOURCE_TAGS : tagged
    JOB_POSTINGS ||--o| JOB_POSTING_MATCH_RESULTS : evaluated
    JOB_POSTINGS ||--o{ JOB_POSTING_WORK_ARRANGEMENTS : labeled
    JOB_POSTING_MATCH_RESULTS ||--o{ JOB_POSTING_MATCHED_KEYWORD_GROUPS : matches
    JOB_KEYWORD_GROUPS ||--o{ JOB_KEYWORD_SYNONYMS : contains
    JOB_KEYWORD_GROUPS ||--o{ JOB_POSTING_MATCHED_KEYWORD_GROUPS : referenced
    WORK_ARRANGEMENT_KEYWORDS ||--o{ JOB_POSTING_WORK_ARRANGEMENTS : evidences
    JOB_SOURCES {
        bigint id PK
        bigint company_id "애그리게이터는 NULL"
        varchar platform "..SARAMIN / JUMPIT"
        varchar source_type "COMPANY_BOUND / AGGREGATOR"
        varchar source_slug "회사 종속형만 (애그리게이터 NULL)"
        varchar search_category_code "애그리게이터만"
        varchar search_keyword "애그리게이터 (없으면 빈문자열)"
    }
    JOB_POSTINGS {
        bigint id PK
        bigint company_id
        bigint job_source_id "NULL = 수동 등록"
        bigint representative_id "NULL = 대표, 값 = 비대표→대표"
        varchar dedup_key "정규화 회사명+제목 · idx"
        varchar posting_origin "COLLECTED / MANUAL"
        varchar change_signal_kind "..FIELD_HASH"
    }

ERD (2) — 지원·알림·공통

erDiagram
    JOB_POSTINGS ||--o| JOB_APPLICATIONS : applied
    JOB_APPLICATIONS ||--o{ JOB_APPLICATION_STATUS_HISTORIES : logs
    JOB_APPLICATIONS ||--o{ JOB_APPLICATION_INTERVIEWS : schedules
    JOB_APPLICATIONS {
        bigint id PK
        bigint job_posting_id "UNIQUE — 공고당 1건"
        varchar application_status
        varchar rejected_at_stage "REJECTED 시 이전 단계"
    }
    NOTIFICATION_DISPATCHES {
        bigint id PK
        varchar target_type "JOB_POSTING / JOB_SOURCE / DAILY_DIGEST"
        bigint target_id "일일 요약은 0"
        varchar notification_type "..DAILY_DIGEST"
        int dispatch_sequence
        varchar idempotency_key "NULL 허용 · UNIQUE · 성공 시에만"
        varchar dispatch_status
    }
    COMPANIES {
        bigint id PK
        varchar name "UNIQUE"
        varchar registration_type "AUTO / MANUAL_ONLY"
        varchar company_origin "WATCHED / DISCOVERED · idx"
    }
    FEATURE_FLAGS {
        bigint id PK
        varchar flag_key "UNIQUE"
        tinyint enabled
    }
    JOB_EXCLUSION_KEYWORDS {
        bigint id PK
        varchar normalized_keyword
        datetime deleted_at "소프트 삭제"
    }
    MATCH_CRITERIA_REVISIONS {
        bigint id PK "현재 revision = MAX(id)"
        varchar change_reason
    }

테이블 정의

공통 컬럼(id, created_at, updated_at)은 규약대로 전 테이블에 부여하며 아래 표에서 생략합니다. 추가 전용 테이블은 별도 표기합니다.

companies — 관심 회사 + 발견 회사

테이블 COMMENT: 사용자 관심 회사(WATCHED)와 애그리게이터 발견 회사(DISCOVERED)

컬럼타입NULL기본값COMMENT근거
nameVARCHAR(100)NOT NULL회사명 (중복 불가). 애그리게이터 자동 등록 멱등의 근거 — 같은 회사명은 1건에 수렴FR-1·61, BE-06
registration_typeVARCHAR(20)NOT NULL등록 유형: AUTO(자동 수집 대상) / MANUAL_ONLY(후보 0건, 수동 등록 전용)FR-5, ENUM 금지
company_originVARCHAR(20)NOT NULL회사 출처: WATCHED(사용자 직접 등록, 개별 알림) / DISCOVERED(애그리게이터 자동 발견, 일일 요약). registration_type과 직교하며 승격(DISCOVERED→WATCHED) 가능FR-61, 판단 9
  • uk_companies_name (name) — 중복 등록 방지 + 자동 등록 멱등
  • idx_companies_origin_name (company_origin, name)?companyOrigin= 필터 + 페이지네이션 정렬 (판단 9, Q16). DISCOVERED 회사가 수천 곳이 되어 v1의 무인덱스 결정을 뒤집습니다

job_sources — 채용 데이터 출처 (회사 종속형 + 애그리게이터형)

테이블 COMMENT: 채용 데이터 출처. 회사 종속형(회사+slug)과 애그리게이터형(검색 조건)이 공존한다

컬럼타입NULL기본값COMMENT근거
company_idBIGINTNULLNULLcompanies.id 참조 (FK 제약 없음). 애그리게이터형은 회사에 안 붙으므로 NULLFR-60, TDD 파급
source_typeVARCHAR(20)NOT NULL소스 유형: COMPANY_BOUND(회사 slug로 수집) / AGGREGATOR(검색 조건으로 수집)FR-60
platformVARCHAR(30)NOT NULL플랫폼(P0 9종): GREENHOUSE / WOOWAHAN / INCRUIT / SARAMIN / JUMPIT / WANTED / REMEMBER / JOBKOREA / SURFIT (P1: WORKNET, JOBALIO)TDD JobPlatform
source_slugVARCHAR(100)NULLNULL플랫폼 내 회사 식별 slug (예: daangn). 회사 종속형은 필수, 애그리게이터형은 NULLFR-60
search_category_codeVARCHAR(100)NULLNULL애그리게이터 검색 직무 카테고리 코드 (예: 사람인 cat_kewd=84, 점핏 jobCategory=1). 회사 종속형은 NULL, 애그리게이터형은 NOT NULL 값FR-60
search_keywordVARCHAR(200)NULLNULL애그리게이터 검색 키워드. 회사 종속형은 NULL. 애그리게이터형은 키워드 없으면 빈 문자열('')을 저장 — 유니크 제약이 NULL 다건을 허용하는 함정을 막기 위함(판단 7)FR-60
base_urlVARCHAR(500)NOT NULL소스 base URL 또는 애그리게이터 검색 base URLTDD JobSourceCandidate.baseUrl
seeded_atDATETIME(6)NULLNULL시딩(최초 수집) 완료 시각. NULL이면 미시딩 — 최초 수집은 알림 없이 저장만 한다FR-10, isSeeded()
disabled_atDATETIME(6)NULLNULL소스 비활성화 시각. NULL이면 활성 — 활성 소스만 수집 대상BE-06 disable(), Release 롤백
  • uk_job_sources_company_bound (platform, source_slug) — 회사 종속형 중복 방지. 애그리게이터형은 source_slug IS NULL이라 NULL distinct로 자동 제외됨 (판단 7)
  • uk_job_sources_aggregator (platform, search_category_code, search_keyword) — 애그리게이터형 중복 방지. 애그리게이터 행은 세 컬럼이 전부 비-NULL(keyword 없으면 '')이어야 유니크가 성립. 회사 종속형은 search_category_code IS NULL이라 NULL distinct로 자동 제외됨 (판단 7)
  • 조회 전용 인덱스 없음 — 전체 40행 미만이라 company_id 조회·활성 소스 조회 모두 풀스캔이 최적입니다(§인덱스 미생성 목록).

BE-01 주의: company_id·source_slug가 nullable이 되면서, 두 유니크 인덱스가 서로 다른 소스 유형을 담당합니다. search_keyword'' 규칙(애그리게이터·키워드 없음)을 지키지 않으면 애그리게이터 중복 등록이 DB에서 막히지 않습니다(판단 7 상세).

job_postings — 공고 (수집 + 수동 공통)

테이블 COMMENT: 채용 공고. 자동 수집분과 수동 등록분을 함께 보관하며 물리 삭제하지 않는다

컬럼타입NULL기본값COMMENT근거
company_idBIGINTNOT NULLcompanies.id 참조수동 공고(job_source_id NULL)도 회사 귀속이 필요 → 소스 경유 유도 불가
job_source_idBIGINTNULLNULLjob_sources.id 참조. NULL이면 수동 등록 공고FR-20
representative_idBIGINTNULLNULL크로스 소스 중복 그룹의 대표 공고 id (자기참조, FK 제약 없음). NULL이면 자신이 대표(또는 단독), 값이 있으면 비대표(대표를 가리킴)FR-63·64, 판단 8
dedup_keyVARCHAR(255)NULLNULL중복 판정 키 = 정규화 회사명 + 정규화 제목 (엔티티가 쿼리 없이 계산). 08:30 dedup 배치가 이 값으로 그룹핑. 수동 등록 등 dedup 비대상이면 NULLFR-63, 판단 8
posting_originVARCHAR(20)NOT NULL수집 출처: COLLECTED(자동 수집) / MANUAL(수동 등록). MANUAL은 델타 판정 모수에서 제외FR-20, TDD 상태 전이 표. 사고 방지의 핵심 컬럼
source_job_idVARCHAR(200)NULLNULL소스별 고유 공고 ID (Greenhouse id / recruitSeq / 인크루트 job id). 수동 등록이면 NULLFR-11
titleVARCHAR(300)NOT NULL공고 제목. 직무 키워드 매칭 대상(제목 단독)FR-24, TDD Open Q#2
posting_urlVARCHAR(1000)NOT NULL공고 원문 링크알림 본문·UI
deadline_atDATETIME(6)NULLNULL마감일. NULL이면 상시채용 — 어댑터가 필드 부재/센티널(9999-12-31)을 NULL로 정규화한다FR-13
posting_statusVARCHAR(20)NOT NULL공고 상태: OPEN / CLOSED. 물리 삭제 없이 상태 전이로만 표현FR-18
closed_reasonVARCHAR(30)NULLNULL마감 사유: NOT_FOUND_TWICE(연속 2회 미발견) / DEADLINE_PASSED(마감일 경과). OPEN이면 NULLFR-14·17
closed_atDATETIME(6)NULLNULL마감 전환 시각Observability(마감 전환 건수)
reopened_atDATETIME(6)NULLNULL마지막 재오픈 시각. CLOSED 공고 재발견 시 기록하며 신규 알림 대상이 아니다FR-16
change_signal_kindVARCHAR(30)NULLNULL변경 감지 근거 종류: UPDATED_AT / SOURCE_VERSION / DETAIL_BODY_HASH / FIELD_HASH(점핏 등 변경 필드 없는 소스의 주요 필드 해시). 수동 등록이면 NULLTDD ChangeSignalKind, FR-68
change_signal_valueVARCHAR(200)NULLNULL변경 감지 근거 값 (ISO-8601 시각 / 버전 숫자 / sha256 hex 64자)TDD ChangeSignature.storedValue
changed_atDATETIME(6)NULLNULL마지막 변경 감지 시각 (시그니처 변경 시 갱신)FR-12, TDD Open Q#5
consecutive_miss_countINTNOT NULL0정상 회차에서 연속 미발견한 횟수. 2 도달 시 CLOSED 전환, 발견 시 0으로 초기화FR-14
first_seen_atDATETIME(6)NOT NULL최초 발견 시각. 신규 알림 만료(7일) 판정 기준TDD 알림 만료 규칙
last_seen_atDATETIME(6)NOT NULL마지막 발견 시각 (정상 회차에서 발견될 때마다 갱신)델타 판정
notification_eligibleTINYINT(1)NOT NULL신규 공고 알림 대상 여부: 1=대상, 0=제외(시딩 회차 공고는 영구 제외)FR-10, BOOLEAN 금지
access_restrictedTINYINT(1)NOT NULL0접근 제한(로그인 필요) 공고 여부: 1이면 자동 매칭·마감 판정 대상에서 제외시나리오 6
versionINTNOT NULL0낙관적 잠금 버전 (배치와 사용자 API의 동시 수정 방지)TDD 동시성 표
  • uk_job_postings_source_job (job_source_id, source_job_id) — FR-11 공고 식별. 수동 공고는 두 컬럼이 NULL이라 다건 공존(의도됨)
  • idx_job_postings_company_status_seen (company_id, posting_status, first_seen_at)
  • idx_job_postings_deadline_sweep (posting_status, deadline_at)
  • idx_job_postings_notification_target (notification_eligible, posting_status, first_seen_at)
  • idx_job_postings_dedup_key (dedup_key) — 08:30 크로스 소스 중복 그룹핑 (Q17). 고카디널리티(정규화 회사명+제목)라 그룹 대부분 크기 1, 소수만 >1
  • idx_job_postings_representative (representative_id) — 대표의 대체 출처 조회 self-join (Q18). 대부분 NULL(대표·단독), 소수만 값 보유

BE-01 주의: representative_id는 자기 테이블 PK를 가리키지만 FK 제약을 걸지 않습니다(컨벤션 + dedup 배치의 대표 재지정 UPDATE 잠금 회피, 판단 8). dedup_key·representative_id어떤 유니크에도 넣지 않습니다 — 같은 dedup_key가 여러 행에 존재하는 것이 정상(그게 중복 그룹)이기 때문입니다.

job_posting_descriptions — 공고 본문 (1:1 분리)

테이블 COMMENT: 공고 본문(JD). 근무형태 2단계 근거와 재평가의 입력이며 최신 1건만 유지한다

컬럼타입NULL기본값COMMENT근거
job_posting_idBIGINTNOT NULLjob_postings.id 참조 (공고당 1행)1:1
description_bodyTEXTNOT NULL공고 본문 원문 (어댑터가 UTF-8로 디코딩·정규화한 결과). JSON 컬럼 금지 규칙에 따라 TEXT 사용FR-27②, 판단 3
body_char_lengthINTNOT NULL본문 문자 수 (파싱 이상 탐지용 — 급감 시 파서 고장 의심)Olostep silent failure 벤치마킹
fetched_atDATETIME(6)NOT NULL본문 수집 시각상세 조회 부분 실패 추적
  • uk_job_posting_descriptions_posting (job_posting_id)

job_posting_source_tags — 소스 구조화 태그

테이블 COMMENT: 소스가 제공한 구조화 태그 (Greenhouse metadata 등). 근무형태 1단계 근거

추가 전용(updated_at 없음, created_at만).

컬럼타입NULL기본값COMMENT근거
job_posting_idBIGINTNOT NULLjob_postings.id 참조1:N
tag_valueVARCHAR(200)NOT NULL어댑터가 평탄화한 태그 문자열 (예: 정규직, 경력 3년 이상)TDD RawJobPosting.structuredTags
normalized_tag_valueVARCHAR(200)NOT NULL정규화 태그 (소문자·공백/하이픈 제거·NFKC) — 매 평가마다 재계산하지 않기 위한 캐시FR-24, BE-03
  • uk_job_posting_source_tags (job_posting_id, tag_value) — 중복 태그 방지 + 공고별 조회 커버

job_posting_collection_runs — 수집 회차 이력

테이블 COMMENT: 소스 1개 x 실행일 1일의 수집 회차. 성공률 집계·소스 가드·중복 실행 방지의 근거

추가 전용(updated_at 없음). 회차 종료 시 1회 갱신되므로 finished_at으로 대체합니다.

컬럼타입NULL기본값COMMENT근거
job_source_idBIGINTNOT NULLjob_sources.id 참조
run_dateDATENOT NULL수집 실행 일자 (KST 기준 업무 일자). 소스당 하루 1회 실행을 유니크 제약으로 강제DATETIME(6) 규약의 유일한 예외 — 일자 단위 유니크 키라 시각을 포함하면 중복 실행 방지가 성립하지 않습니다
run_statusVARCHAR(20)NOT NULL회차 결과: SUCCESS / FAILEDTDD 수집 실패 경로
fetched_countINTNOT NULL0수집한 공고 건수. SUCCESS여도 0이면 비정상 회차로 취급한다(silent failure)FR-15
new_countINTNOT NULL0신규 저장 공고 건수Observability
changed_countINTNOT NULL0변경 감지된 공고 건수FR-12
missed_countINTNOT NULL0이번 회차에서 미발견된 공고 건수FR-14
closed_countINTNOT NULL0이번 회차에서 CLOSED로 전환된 공고 건수마감 오판정 지표
detail_failure_countINTNOT NULL0상세 페이지 조회 실패 건수 (목록은 성공)TDD 부분 실패
detail_skipped_countINTNOT NULL0상세 조회 상한(회차당 200건) 초과로 건너뛴 건수Operations 요청량 관리
failure_reasonVARCHAR(200)NULLNULL실패 사유 요약. SUCCESS면 NULLTDD Failed.reason
failure_cause_summaryVARCHAR(1000)NULLNULL실패 원인 상세(예외 메시지 등)TDD Failed.causeSummary
started_atDATETIME(6)NOT NULL회차 시작 시각배치 소요 시간(NFR-2)
finished_atDATETIME(6)NULLNULL회차 종료 시각동일
  • uk_job_posting_collection_runs_source_date (job_source_id, run_date) — 중복 실행 차단
  • idx_job_posting_collection_runs_run_date (run_date) — 전체 소스 30일 조회용
  • 파생 값은 저장하지 않습니다 — “비정상 회차”(run_status='FAILED' OR fetched_count=0)와 “마감 판정 제외 여부”는 조회 시점에 계산합니다. 저장하면 판정 규칙이 두 곳(코드·컬럼)에 생겨 드리프트가 발생합니다.

job_source_health — 소스 건강도 (소스당 1행)

테이블 COMMENT: 소스별 연속 비정상 일수와 고장 상태. 소스 고장 알림(3일 연속)의 판정 근거

복수형 규칙의 예외입니다. health는 불가산 명사이고 이 테이블은 소스당 1행의 현재 상태를 보유합니다(이력이 아님). job_source_healths는 의미를 흐리므로 단수를 유지합니다.

컬럼타입NULL기본값COMMENT근거
job_source_idBIGINTNOT NULLjob_sources.id 참조 (소스당 1행)TDD 제약
consecutive_abnormal_daysINTNOT NULL0연속 비정상(실패 또는 0건) 일수. 3 도달 시 고장 진입FR-19
broken_sinceDATETIME(6)NULLNULL고장 진입 시각. NULL이면 정상 — 알림 만료(7일) 판정 기준FR-19
failure_episodeINTNOT NULL0고장 에피소드 번호. 복구 후 재고장하면 1 증가하며 알림 멱등 키의 발송 회차로 사용TDD 발송 회차 규칙
last_normal_atDATETIME(6)NULLNULL마지막 정상 회차 시각. 고장 감지 지연 지표 계산에 사용Success Metrics
last_abnormal_atDATETIME(6)NULLNULL마지막 비정상 회차 시각Operations
  • uk_job_source_health_source (job_source_id)
  • findAllBroken() 전용 인덱스 없음 — 전체 30행 미만(§인덱스 미생성 목록)

job_keyword_groups — 직무 동의어 그룹

테이블 COMMENT: 직무 키워드 동의어 그룹 (그룹 1개 = 매칭 조건 1개)

컬럼타입NULL기본값COMMENT근거
display_nameVARCHAR(100)NOT NULL그룹 표시명 (예: 백엔드)FR-22
deleted_atDATETIME(6)NULLNULL소프트 삭제 시각. NULL이면 활성 — 과거 평가 결과가 이 그룹을 참조하므로 물리 삭제하지 않는다판단 5
  • uk_job_keyword_groups_display_name (display_name)

job_keyword_synonyms — 동의어

테이블 COMMENT: 동의어 그룹에 속한 키워드와 그 정규화 값

컬럼타입NULL기본값COMMENT근거
job_keyword_group_idBIGINTNOT NULLjob_keyword_groups.id 참조
raw_keywordVARCHAR(100)NOT NULL사용자가 입력한 원문 키워드 (예: Backend)FR-22
normalized_keywordVARCHAR(100)NOT NULL정규화 키워드 (소문자·공백/하이픈 제거·NFKC). 매칭 비교의 실제 대상FR-24, BE-03
  • uk_job_keyword_synonyms_group_keyword (job_keyword_group_id, normalized_keyword)

job_exclusion_keywords — 제외 키워드

테이블 COMMENT: 제외 키워드. 매칭 그룹에 걸려도 이 키워드가 있으면 최종 미매칭 처리한다

컬럼타입NULL기본값COMMENT근거
raw_keywordVARCHAR(100)NOT NULL사용자가 입력한 원문 제외 키워드 (예: 인턴)FR-23
normalized_keywordVARCHAR(100)NOT NULL정규화 제외 키워드FR-24
deleted_atDATETIME(6)NULLNULL소프트 삭제 시각. 평가 결과의 제외 사유가 이 행을 참조한다판단 5
  • uk_job_exclusion_keywords_normalized (normalized_keyword)

work_arrangement_keywords — 근무형태 키워드

테이블 COMMENT: 근무형태 키워드 (재택·원격 등). 추출 대상 지정과 목록 정렬 기준으로만 쓰이며 필터가 아니다

컬럼타입NULL기본값COMMENT근거
raw_keywordVARCHAR(100)NOT NULL사용자가 입력한 원문 근무형태 키워드FR-26
normalized_keywordVARCHAR(100)NOT NULL정규화 근무형태 키워드FR-24
deleted_atDATETIME(6)NULLNULL소프트 삭제 시각. 근무형태 근거 행이 이 행을 참조한다판단 5
  • uk_work_arrangement_keywords_normalized (normalized_keyword)

match_criteria_revisions — 매칭 기준 변경 이력

테이블 COMMENT: 매칭 기준 변경 이력. 현재 revision = MAX(id)이며 재평가 대상 판별의 기준점

추가 전용(created_at만).

컬럼타입NULL기본값COMMENT근거
change_targetVARCHAR(40)NOT NULL변경 대상: KEYWORD_GROUP / KEYWORD_SYNONYM / EXCLUSION_KEYWORD / WORK_ARRANGEMENT_KEYWORDFR-22·23·26
change_reasonVARCHAR(200)NOT NULL변경 사유 요약 (예: 백엔드 그룹에 서버 동의어 추가)재평가 추적
  • 추가 인덱스 없음 — bumpRevision()은 INSERT, loadCurrent()SELECT MAX(id)로 PK만 사용합니다.
  • revision 별도 컬럼을 두지 않았습니다. AUTO_INCREMENT id가 곧 단조 증가 revision이라 컬럼을 하나 더 두면 두 값의 동기화 문제만 생깁니다.

job_posting_match_results — 공고 평가 결과 (공고당 1건)

테이블 COMMENT: 공고별 매칭 평가 결과. 매칭 실패 공고도 결과를 저장한다(매칭은 알림 조건일 뿐 저장 조건이 아님)

컬럼타입NULL기본값COMMENT근거
job_posting_idBIGINTNOT NULLjob_postings.id 참조 (공고당 1건)TDD 제약
criteria_revisionBIGINTNOT NULL평가에 사용한 매칭 기준 revision. 현재 revision보다 작으면 재평가 대상FR-25, BE-13
keyword_matchedTINYINT(1)NOT NULL직무 키워드 매칭 여부: 1=매칭(알림 대상), 0=미매칭FR-25
excluded_keyword_idBIGINTNULLNULL제외 키워드로 탈락한 경우 job_exclusion_keywords.id. 제외가 아니면 NULLFR-23
top_confidenceVARCHAR(20)NOT NULL대표 근무형태 확신도: CONFIRMED / LIKELY / INFERRED / UNKNOWN (근거 없으면 UNKNOWN)FR-29·34
work_arrangement_sort_rankINTNOT NULL근무형태 정렬 순위: 1=CONFIRMED, 2=LIKELY, 99=INFERRED·UNKNOWN(정렬 기준 제외). 문자열 정렬이 도메인 순서와 다르므로 별도 보유FR-30, 판단 4
evaluated_atDATETIME(6)NOT NULL평가 수행 시각보정 스윕 추적
  • uk_job_posting_match_results_posting (job_posting_id)

job_posting_matched_keyword_groups — 매칭된 그룹 (N:M 해소)

테이블 COMMENT: 평가 결과가 매칭된 직무 키워드 그룹. 재평가 시 해당 공고 행을 재작성한다(파생 캐시)

추가 전용(created_at만).

컬럼타입NULL기본값COMMENT근거
job_posting_match_result_idBIGINTNOT NULLjob_posting_match_results.id 참조BE-13 “매칭 그룹 저장”
job_keyword_group_idBIGINTNOT NULLjob_keyword_groups.id 참조 (매칭된 그룹)동일
  • uk_job_posting_matched_keyword_groups (job_posting_match_result_id, job_keyword_group_id)

job_posting_work_arrangements — 근무형태 값 + 근거 + 확신도

테이블 COMMENT: 공고별 근무형태 판정 근거. 키워드 1개당 1행이며 값·근거·확신도를 함께 보관한다(파생 캐시)

추가 전용(created_at만 — 재평가 시 행을 재작성).

컬럼타입NULL기본값COMMENT근거
job_posting_idBIGINTNOT NULLjob_postings.id 참조
work_arrangement_keyword_idBIGINTNOT NULLwork_arrangement_keywords.id 참조 (판정된 근무형태 값)TDD 제약
evidence_stageVARCHAR(30)NOT NULL근거 단계: STRUCTURED_FIELD(구조화 필드) / DESCRIPTION_BODY(JD 본문) / COMPANY_REFERENCE(다른 공고 — P2)FR-27·29
confidenceVARCHAR(20)NOT NULL확신도: CONFIRMED(구조화 필드) / LIKELY(JD 본문) / INFERRED(P2). 같은 키워드가 두 단계에서 잡히면 높은 확신도만 보관FR-29, 판단 4
evidence_snippetVARCHAR(500)NOT NULL근거 문구 발췌 (UI 표시용). 부정어 검사를 통과한 문맥만 저장FR-31·32
criteria_revisionBIGINTNOT NULL이 근거를 만든 매칭 기준 revision재평가 추적
  • uk_job_posting_work_arrangements (job_posting_id, work_arrangement_keyword_id)
  • UNKNOWN행을 만들지 않습니다 — “근거 없음”이 곧 UNKNOWN이며, 대표값은 job_posting_match_results.top_confidence='UNKNOWN'으로 표현합니다. 근거 없는 근거 행을 만들지 않기 위한 규칙입니다(FR-34).

job_applications — 지원 기록

테이블 COMMENT: 공고에 대한 지원 기록. 미지원은 상태값이 아니라 이 행이 없는 것으로 표현한다

컬럼타입NULL기본값COMMENT근거
job_posting_idBIGINTNOT NULLjob_postings.id 참조 (공고당 지원 1건)FR-42
application_statusVARCHAR(30)NOT NULL지원 상태: APPLIED / DOCUMENT_SCREENING / INTERVIEWING / OFFERED / ACCEPTED / REJECTED / WITHDRAWN / OFFER_DECLINED (뒤 4개는 종료 상태)FR-43
applied_atDATETIME(6)NOT NULL지원 시각 (사용자 입력)FR-42
rejected_at_stageVARCHAR(30)NULLNULLREJECTED 전이 시점의 직전 단계. 그 외에는 NULLFR-43
memoVARCHAR(1000)NULLNULL지원 메모API 계약
versionINTNOT NULL0낙관적 잠금 버전 (상태 이중 전이 방지)TDD 동시성 표
  • uk_job_applications_posting (job_posting_id)
  • 회사별 지원 목록(GET /api/applications?companyId=)은 job_postingsidx_..._company_status_seen으로 공고를 좁힌 뒤 이 유니크 인덱스로 조인합니다 — company_id 비정규화 컬럼을 두지 않습니다(전체 300행 규모, 조인 비용 0에 가깝고 중복 보유는 정합 위험만 추가).

job_application_status_histories — 상태 전이 이력

테이블 COMMENT: 지원 상태 전이 이력. 히스토리 조회의 단일 기준 데이터이며 수정·삭제하지 않는다

추가 전용(created_at만).

컬럼타입NULL기본값COMMENT근거
job_application_idBIGINTNOT NULLjob_applications.id 참조
previous_statusVARCHAR(30)NULLNULL이전 상태. 지원 최초 생성 이력이면 NULLFR-47 (아래 주의)
next_statusVARCHAR(30)NOT NULL전이 후 상태FR-47
transited_atDATETIME(6)NOT NULL전이 시각FR-47
memoVARCHAR(1000)NULLNULL전이 메모FR-47
  • idx_job_application_status_histories_app_time (job_application_id, transited_at)

주의(BE-04·BE-14): previous_status를 NULL 허용으로 둔 것은 지원 생성 시점에 (NULL → APPLIED) 이력 1건을 남기는 설계를 전제합니다. 이렇게 해야 히스토리가 지원 시작 시점부터 완결됩니다. TDD 테스트 케이스의 “이력 4건” 표현이 생성 이력 포함 여부를 확정하지 않으므로, 구현 시 이 규칙(생성 시 1건 적재)을 채택해 주세요.

job_application_interviews — 면접 회차

테이블 COMMENT: 지원별 면접 회차. 지원 상태값이 아닌 독립 기록이며 면접 결과가 지원 상태를 자동 전이시키지 않는다

컬럼타입NULL기본값COMMENT근거
job_application_idBIGINTNOT NULLjob_applications.id 참조
round_numberINTNOT NULL면접 회차 번호 (1부터). 같은 지원 내 중복 불가FR-46
round_labelVARCHAR(100)NOT NULL사용자 지정 회차 레이블 (예: 1차 기술면접, 임원면접)FR-46
scheduled_atDATETIME(6)NULLNULL면접 일정. 미정이면 NULLFR-46
interview_resultVARCHAR(20)NULLNULL면접 결과: PASSED / FAILED / CANCELED. 미기록이면 NULLFR-46
memoVARCHAR(1000)NULLNULL면접 메모API 계약
  • uk_job_application_interviews_round (job_application_id, round_number)

notification_dispatches — 알림 발송 시도 이력

테이블 COMMENT: 디스코드 알림 발송 시도 1건. 성공·실패를 모두 남기며 성공 시에만 멱등 키를 부여한다

추가 전용에 가깝지만 시도 결과 갱신이 있으므로 updated_at을 둡니다.

컬럼타입NULL기본값COMMENT근거
target_typeVARCHAR(30)NOT NULL알림 대상 종류: JOB_POSTING / JOB_SOURCE / DAILY_DIGEST(발견 회사 일일 요약, 특정 대상 없음)TDD 멱등 키 구성, FR-62
target_idBIGINTNOT NULL대상 ID (job_postings.id 또는 job_sources.id). DAILY_DIGEST는 특정 대상이 없으므로 0동일
notification_typeVARCHAR(40)NOT NULL알림 종류: NEW_JOB_POSTING(관심 회사 개별) / SOURCE_FAILURE / DAILY_DIGEST(발견 회사 매칭 신규 묶음) (P1 확장: DEADLINE_D1, PERMANENT_REMINDER). 마감 알림은 발송하지 않는다FR-35·36·62
dispatch_sequenceINTNOT NULL발송 회차. 신규 공고는 1, 소스 고장은 failure_episode 값, DAILY_DIGEST는 KST 일자 서수(하루 1건 강제), P1 반복 리마인드는 7일마다 증가FR-38·62
idempotency_keyVARCHAR(150)NULL없음(DEFAULT 금지)멱등 키 {target_type}:{target_id}:{notification_type}:{dispatch_sequence}. 발송 성공(2xx) 시에만 채운다. NULL 다건 공존이 실패 이력 보존과 재발송의 전제TDD 방안 4-c, 판단 1
dispatch_statusVARCHAR(20)NOT NULL발송 결과: SENT(성공) / FAILED(3회 재시도 후 최종 실패)FR-41
attempt_countINTNOT NULL이번 시도에서 수행한 웹훅 호출 횟수 (최대 3)FR-41
last_status_codeINTNULLNULL마지막 응답 HTTP 상태 코드. 네트워크 오류 등으로 응답이 없으면 NULLOperations
last_errorVARCHAR(1000)NULLNULL마지막 실패 사유. 성공이면 NULL시나리오 7
message_summaryVARCHAR(300)NOT NULL발송 메시지 요약 (공고 제목 또는 소스 식별 문구) — 실패 이력 조회 시 무엇을 못 보냈는지 식별Operations
attempted_atDATETIME(6)NOT NULL발송 시도 시각Operations 조회 기준
delivered_atDATETIME(6)NULLNULL발송 성공 시각. 실패면 NULLSuccess Metrics
  • uk_notification_dispatches_idempotency_key (idempotency_key)nullable 유니크
  • idx_notification_dispatches_status_time (dispatch_status, attempted_at)

feature_flags — 런타임 기능 토글

테이블 COMMENT: 런타임 피처 플래그. 재기동 없이 기능을 끄기 위한 운영 스위치

컬럼타입NULL기본값COMMENT근거
flag_keyVARCHAR(100)NOT NULL플래그 키 (예: posting.auto-close)TDD 방안 6
enabledTINYINT(1)NOT NULL0활성 여부: 1=ON, 0=OFFBOOLEAN 금지
descriptionVARCHAR(500)NOT NULL플래그 목적과 제거 시점 판단 근거 (영구 운영 스위치인지 임시 릴리즈 토글인지)TDD 플래그 제거 시점
  • uk_feature_flags_key (flag_key)
  • 정적 시드 4행(posting.auto-close=0, notification.discord-dispatch=0, posting.cross-source-dedup=0, aggregator.collection=0)은 Flyway DML 예외에 해당합니다 — 대상 4행, 운영 쓰기 경합 없음. 이 근거를 마이그레이션 파일 주석에 남깁니다. 뒤 2개는 애그리게이터 편입으로 추가된 위험 기능 스위치입니다(TDD Release 단계 6·7).

코드값 사전 (ENUM 금지 → VARCHAR)

컬럼 COMMENT에 허용값을 전수 기재합니다. 애플리케이션 enum과 문자열이 1:1 대응해야 합니다.

컬럼길이허용값최장값
companies.registration_type20AUTO, MANUAL_ONLY11
companies.company_origin20WATCHED, DISCOVERED10
job_sources.source_type20COMPANY_BOUND, AGGREGATOR13
job_sources.platform30P0 9종: GREENHOUSE, WOOWAHAN, INCRUIT, SARAMIN, JUMPIT, WANTED, REMEMBER, JOBKOREA, SURFIT (P1 예약: WORKNET, JOBALIO)10 (GREENHOUSE)
job_postings.posting_origin20COLLECTED, MANUAL9
job_postings.posting_status20OPEN, CLOSED6
job_postings.closed_reason30NOT_FOUND_TWICE, DEADLINE_PASSED15
job_postings.change_signal_kind30UPDATED_AT, SOURCE_VERSION, DETAIL_BODY_HASH, FIELD_HASH17
job_posting_collection_runs.run_status20SUCCESS, FAILED7
job_posting_match_results.top_confidence20CONFIRMED, LIKELY, INFERRED, UNKNOWN9
job_posting_work_arrangements.confidence20CONFIRMED, LIKELY, INFERRED9
job_posting_work_arrangements.evidence_stage30STRUCTURED_FIELD, DESCRIPTION_BODY, COMPANY_REFERENCE(P2)17
job_applications.application_status / rejected_at_stage30APPLIED, DOCUMENT_SCREENING, INTERVIEWING, OFFERED, ACCEPTED, REJECTED, WITHDRAWN, OFFER_DECLINED18
job_application_status_histories.previous_status / next_status30위와 동일18
job_application_interviews.interview_result20PASSED, FAILED, CANCELED8
notification_dispatches.target_type30JOB_POSTING, JOB_SOURCE, DAILY_DIGEST12
notification_dispatches.notification_type40NEW_JOB_POSTING, SOURCE_FAILURE, DAILY_DIGEST (P1: DEADLINE_D1, PERMANENT_REMINDER)18
notification_dispatches.dispatch_status20SENT, FAILED6
match_criteria_revisions.change_target40KEYWORD_GROUP, KEYWORD_SYNONYM, EXCLUSION_KEYWORD, WORK_ARRANGEMENT_KEYWORD24

길이는 최장값의 약 2배로 잡았습니다 — P1·P2 확장값(PERMANENT_REMINDER 등)이 들어와도 ALTER TABLE MODIFY가 필요 없게 하기 위해서입니다. VARCHAR 길이 확장은 온라인 DDL이 가능하지만(같은 길이 바이트 구간 내), 애초에 발생시키지 않는 편이 낫습니다.


쿼리 패턴 → 인덱스 매핑

대상 쿼리가 없는 인덱스는 만들지 않습니다. 아래 표의 인덱스가 전부이며, 각 행이 인덱스 1개의 존재 근거입니다.

#쿼리 패턴 (WHERE / ORDER BY)사용처사용 인덱스컬럼 순서 근거예상 스캔
Q1WHERE job_source_id=? → 앱에서 posting_origin='COLLECTED'·access_restricted=0 필터수집 델타 모수 (findAllCollectedIn, BE-10)uk_job_postings_source_job (job_source_id, source_job_id) 의 선두 컬럼 프리픽스유니크 제약의 선두가 job_source_id전용 인덱스가 불필요합니다. posting_origin을 인덱스에 넣지 않은 이유: 소스당 행이 40~300건이라 필터링 비용이 무의미하고, 인덱스 폭만 넓힙니다소스당 40~300행
Q2WHERE job_source_id=? AND source_job_id=?중복 판정·단건 갱신 (findBy, FR-11)동일 유니크 인덱스 (전체 사용)유니크 제약 자체가 중복 판정의 방어선1행
Q3WHERE posting_status='OPEN' AND deadline_at < :now (deadline_at IS NOT NULL은 범위 조건이 자동 배제)마감일 경과 배치 00:30 (findAllOpenWithDeadlineBefore, FR-17)idx_job_postings_deadline_sweep (posting_status, deadline_at)Equality → Range 순서. 선두 posting_status는 카디널리티 2로 낮지만, ① 등치 조건이라 선두가 맞고 ② 3년 후 CLOSED가 전체의 85%를 차지해 OPEN 선택도가 15%로 유효합니다. 순서를 뒤집으면(deadline_at, posting_status) CLOSED 공고까지 전부 범위 스캔합니다OPEN 중 마감일 경과분(초기 0~수십행)
Q4WHERE notification_eligible=1 AND posting_status='OPEN' AND first_seen_at >= :now-7d + match_results.keyword_matched=1 조인 + 성공 발송 이력 NOT EXISTS09:00 알림 대상 산출 (findNewJobPostingTargets, FR-10·25·38)idx_job_postings_notification_target (notification_eligible, posting_status, first_seen_at)uk_job_posting_match_results_postinguk_notification_dispatches_idempotency_keyEquality(2개) → Range(1개). 세 컬럼 모두 델타 갱신(last_seen_at)과 무관해 일일 UPDATE가 이 인덱스를 건드리지 않습니다. 조인 상대는 전부 유니크 인덱스 점 조회최근 7일 신규분 = 60~80행
Q4-b(P1) 위 조건 + job_applications 미존재FR-39 지원 시 알림 중단uk_job_applications_posting (job_posting_id) 안티 조인P1에서 추가 인덱스가 필요 없습니다 — 공고당 지원 1건 유니크가 이미 존재1행 점 조회
Q5WHERE idempotency_key = ?알림 멱등 판정 (findSucceededBy, FR-38)uk_notification_dispatches_idempotency_key (idempotency_key)단일 컬럼 유니크. NULL 다건 허용이 실패 이력 공존의 전제(판단 1)0~1행
Q6WHERE dispatch_status='FAILED' AND attempted_at >= :from ORDER BY attempted_at DESC운영 조회 GET /api/operations/notification-dispatches (시나리오 7)idx_notification_dispatches_status_time (dispatch_status, attempted_at)Equality → Range + 정렬 컬럼이 뒤에 와 filesort 제거. dispatch_status 생략 조회(전체)는 attempted_at 프리픽스가 없어 풀스캔이지만 3년 2,100행이라 무시 가능30일분 수십 행
Q7WHERE company_id=? [AND posting_status=?] AND representative_id IS NULL ORDER BY first_seen_at DESC회사별 공고 목록 GET /api/companies/{id}/job-postings (FR-50·64)idx_job_postings_company_status_seen (company_id, posting_status, first_seen_at) + representative_id IS NULL 잔여 필터Equality(회사) → Equality(선택적 상태) → Sort. 목록은 대표 공고만 노출(FR-64)하므로 representative_id IS NULL이 붙습니다. 이 조건을 인덱스에 넣지 않은 이유: 회사 스코프가 이미 수백 행으로 좁히고, representative_id는 dedup 배치가 바꾸는 컬럼이라 인덱스에 넣으면 dedup UPDATE가 인덱스를 건드립니다. 잔여 필터가 저렴합니다회사당 100~500행
Q7-b위 결과를 ORDER BY work_arrangement_sort_rank, first_seen_at DESCsort=WORK_ARRANGEMENT (FR-30)인덱스 없음(의도) — 정렬 키가 조인 상대 테이블(job_posting_match_results)에 있어 단일 인덱스로 커버 불가회사당 수백 행 정렬은 filesort로 충분. 이 때문에 비정규화(공고 행에 rank 복사)를 하지 않습니다 — 재평가마다 두 테이블을 갱신해야 해 정합 위험이 이득보다 큽니다동일
Q7-c위 결과를 ORDER BY deadline_at IS NULL, deadline_at ASCsort=DEADLINE인덱스 없음(의도)상시채용(NULL)을 뒤로 보내는 표현식 정렬이라 인덱스가 무의미. 대상 행이 수백이라 filesort로 충분동일
Q8WHERE job_application_id=? ORDER BY transited_at지원 히스토리 조회 (FR-47)idx_job_application_status_histories_app_time (job_application_id, transited_at)Equality → Sort. 정렬 컬럼을 인덱스에 포함해 filesort 제거지원당 1~6행
Q9WHERE job_application_id=? ORDER BY round_number면접 회차 조회 (FR-46)uk_job_application_interviews_round (job_application_id, round_number)유니크 제약이 조회·정렬을 함께 커버 — 전용 인덱스 불필요지원당 0~4행
Q10WHERE job_source_id=? AND run_date >= :from소스별 최근 N회 수집 결과 (Operations)uk_job_posting_collection_runs_source_date (job_source_id, run_date)유니크 제약 프리픽스가 그대로 조회 인덱스 — 전용 인덱스 불필요소스당 30행
Q11WHERE run_date >= :from (소스 미지정)전체 소스 30일 이력 GET /api/operations/collection-runs?days=30idx_job_posting_collection_runs_run_date (run_date)단일 컬럼 범위. Q10 인덱스는 선두가 job_source_id라 소스 미지정 조회에 쓸 수 없습니다900행(30소스×30일)
Q12WHERE job_source_id=?소스 건강도 단건 조회uk_job_source_health_source (job_source_id)유니크 제약이 조회를 커버1행
Q13WHERE job_posting_id=?평가 결과·본문·태그·근무형태 근거 조회각 테이블의 유니크 제약 프리픽스 (uk_..._posting, uk_job_posting_source_tags, uk_job_posting_work_arrangements)전부 유니크 선두가 job_posting_id추가 인덱스 0개1~5행
Q14WHERE deleted_at IS NULL (키워드 4개 테이블 전량 로드)MatchCriteria.loadCurrent()인덱스 없음(의도)전체 행이 그룹 10·동의어 50·제외어 10·근무형태 10 수준. 풀스캔이 인덱스 조회보다 빠릅니다80행
Q15SELECT MAX(id) FROM match_criteria_revisions현재 revision 조회PKInnoDB PK 역방향 1행 조회1행
Q16WHERE company_origin=? ORDER BY name LIMIT ? OFFSET ?회사 목록 필터 + 페이지네이션 GET /api/companies?companyOrigin=&page= (FR-61)idx_companies_origin_name (company_origin, name)Equality → Sort. WATCHED 필터는 6,000 중 20행이라 선택도 높음. 정렬 컬럼을 인덱스에 포함해 페이지네이션 filesort 제거. v1(20행 무인덱스)을 뒤집는 변경 — 발견 회사가 수천 곳이 되기 때문(판단 9)WATCHED 20행 / DISCOVERED 페이지당 size행
Q17WHERE dedup_key IS NOT NULL GROUP BY dedup_key HAVING COUNT(*)>1 후 그룹별 WHERE dedup_key=?08:30 크로스 소스 dedup 배치 (FR-63·64)idx_job_postings_dedup_key (dedup_key)단일 컬럼. 고카디널리티(정규화 회사명+제목)라 GROUP BY 루스 인덱스 스캔이 효율적이고, 대부분 그룹은 크기 1이라 중복 후보(>1)만 소수 걸립니다. 그룹별 점 조회도 같은 인덱스가 커버전체 인덱스 스캔 후 중복 그룹만
Q18WHERE representative_id = ?대표의 대체 출처 조회 (alternateSources, FR-64) self-joinidx_job_postings_representative (representative_id)단일 컬럼 등치. 대부분 NULL(대표·단독)이고 값 보유 행만 소수. InnoDB 보조 인덱스가 등치 조회를 커버대표당 0~수건

인덱스를 만들지 않은 쿼리 (의도적 미생성)

쿼리미생성 근거
job_sources WHERE company_id=? / source_type=? / WHERE disabled_at IS NULL전체 40행 미만. 인덱스 유지 비용 > 조회 이득
job_source_health WHERE broken_since IS NOT NULL (findAllBroken)전체 40행 미만
08:50 보정 스윕 job_postings LEFT JOIN match_results WHERE r.id IS NULL OR r.criteria_revision < ?안티 조인이라 criteria_revision 인덱스를 옵티마이저가 쓰지 않습니다. 구동 테이블 풀스캔(3년 후 약 140,000행) × 1일 1회 = 1초 미만. 인덱스를 만들어도 사용되지 않을 인덱스가 됩니다
job_postings WHERE title LIKE '%키워드%'이런 쿼리를 만들지 않습니다. 매칭은 애플리케이션의 정규화 비교로 수행하며(FR-24), DB LIKE 검색은 설계에 없습니다
companies WHERE registration_type=?company_origin과 달리 이 컬럼 단독 필터 API는 없습니다. 필요 시 Q16 인덱스 프리픽스로 커버 안 되므로 그때 판단

쓰기 비용 점검

테이블일일 쓰기보조 인덱스 수판단
job_postingsINSERT 약 65건(애그리게이터 포함) + UPDATE 수천 건(last_seen_at) + dedup UPDATE 소수(representative_id)5last_seen_at·consecutive_miss_count어떤 인덱스에도 없어 대량 일일 UPDATE가 보조 인덱스를 갱신하지 않습니다. dedup_key는 INSERT 시 1회 계산·불변이라 갱신 없음. representative_id는 인덱스에 있지만 dedup 배치의 재지정은 하루 소수 그룹뿐입니다. 인덱스 갱신은 상태 전이·대표 재지정(일 수십 건)에서만 발생
job_posting_collection_runsINSERT 약 40건1무시 가능
notification_dispatchesINSERT 0~수건 + DAILY_DIGEST 1건1무시 가능
companiesDISCOVERED 자동 등록 일 0~수건2무시 가능
그 외사용자 조작 시에만0~1무시 가능

용량 추정

애그리게이터 편입으로 볼륨이 급증합니다(FR-66: 매칭 실패 공고까지 전부 저장). 회사 종속형과 애그리게이터형을 나눠 추정합니다.

전제 (조사 브리프 실측 기준)

항목근거
회사 종속 소스30개, 소스당 OPEN 평균 40건조사 브리프 — 당근 38, 배민 61
회사 종속 신규연 2,900건30 × 월 8건
애그리게이터 플랫폼P0 6종(사람인·점핏·원티드·리멤버·잡코리아·서핏)회색지대 4종 P0 승격
애그리게이터 소스약 10개(플랫폼 6종 × 관심 카테고리 1~2개)1인이 설정하는 현실적 검색 조건 수
애그리게이터 OPEN(플랫폼·카테고리당)사람인 약 2,600 / 잡코리아 약 2,000 / 서핏 약 1,000 / 리멤버 약 260 / 점핏 약 120 / 원티드 수백조사 브리프 실측(백엔드 카테고리 기준)
애그리게이터 동시 OPEN 합계약 16,000건위 6종 × 관심 카테고리, 크로스 플랫폼 중복 제거 후
애그리게이터 신규(연)약 38,000건동시 OPEN 16,000 × 월 25% 회전 × 12 ≈ 48,000, 카테고리·크로스소스 중복 제거 후 보수적 38,000. 리멤버는 min_updated_at 증분이라 전량 재적재 없음 → 실제는 이보다 낮을 여지
전체 신규 공고(연)약 41,000건회사 종속 2,900 + 애그리게이터 38,000
발견 회사(DISCOVERED)3년 후 약 6,000곳애그리게이터 공고의 distinct 회사. 플랫폼 6종으로 회사 다양성 증가
지원연 40건 (1인)1인용 도구
직무 키워드 매칭률15%애그리게이터로 모수가 커져 매칭 비율은 하락(개인 관심 직무 한정)
본문 평균 크기7KB회사 종속 8KB + 애그리게이터 목록성 본문(더 짧음) 혼합
크로스 소스 중복률약 15%플랫폼 6종으로 같은 공고가 여러 애그리게이터에 동시 노출되는 비율 증가(비대표로 눌림)

테이블별 행 수·증가율·크기

테이블초기(시딩)연 증가3년 후 행 수3년 후 크기(데이터+인덱스)비고
companies20+약 2,000약 6,000약 2MB대부분 DISCOVERED 자동 등록. 인덱스 2종
job_sources40+660< 1MB회사 종속 30 + 애그리게이터 10
job_postings16,000(시딩)+41,000약 140,000약 100MB행 약 720B(dedup_key·representative_id 추가) + 인덱스 5종
job_posting_descriptions16,000+41,000140,000약 960MB전체 용량의 약 88% — 7KB × 140,000
job_posting_source_tags1,600+1,3005,500약 1MBGreenhouse 계열만 태그 보유(애그리게이터는 구조화 태그 없음)
job_posting_collection_runs0+14,600약 44,000약 11MB40소스 × 365일
job_source_health40+660< 1MB소스당 1행
job_keyword_groups5+314< 1MB
job_keyword_synonyms20+1256< 1MB
job_exclusion_keywords5+314< 1MB
work_arrangement_keywords5+211< 1MB
match_criteria_revisions0+3090< 1MB기준 변경 이력
job_posting_match_results16,000+41,000140,000약 32MB공고당 1건(매칭 실패 포함)
job_posting_matched_keyword_groups2,400+6,20021,000약 3MB매칭 15% × 그룹 1.2개
job_posting_work_arrangements4,800+12,30042,000약 10MB근거 발견률 30% × 키워드 1.1개
job_applications0+40120< 1MB1인 지원
job_application_status_histories0+160480< 1MB지원당 평균 4건(생성 이력 포함)
job_application_interviews0+50150< 1MB지원당 평균 1.2회차
notification_dispatches0+약 1,000약 3,000약 1MB관심 회사 개별 + DAILY_DIGEST 365/년 + 실패·고장
feature_flags4+17< 1MB애그리게이터·dedup 플래그
합계약 1.1GB본문 960MB가 지배. 5년 후 약 1.7GB

판정 (애그리게이터 6종 P0에서도 파티셔닝·샤딩 불필요)

  • 최대 행 수 테이블이 job_postings140,000행, match_results·collection_runs 약 44,000행입니다. 파티션 프루닝의 이득이 발생하는 구간(수천만~억 행)과 2자릿수 차이라 파티셔닝·샤딩·읽기 복제본은 전부 미채택(판단 6)입니다. 6종 편입에도 결론은 유지됩니다 — 14만 행은 InnoDB에서 단일 테이블로 다루는 것이 정상입니다.
  • hot 데이터는 여전히 버퍼 풀에 들어갑니다 — 본문(960MB)을 뺀 hot 데이터(job_postings 100MB + match_results 32MB + 인덱스 + 나머지 ≈ 160MB)가 조회·배치의 실제 접근 대상입니다. 본문은 평가 시점에만 WHERE job_posting_id=?로 1건씩 읽는 cold 데이터입니다. 본문을 1:1 분리한 판단 3이 애그리게이터 볼륨에서 결정적으로 유효해집니다 — 인라인이었으면 델타·목록 스캔이 매번 약 1GB를 훑습니다.
  • 버퍼 풀 권장 상향: hot 데이터가 약 160MB이므로 innodb_buffer_pool_size=512MB를 BE-01 compose에 명시하길 권합니다(1인용 로컬이라 넉넉, 기본 128MB보다 여유).
  • 본문 용량이 P0 3년 시점에 약 960MB로 1GB 임계에 근접합니다 — v1 갱신 시 “P1에 도달”이라 봤던 것이 6종 P0 편입으로 P0 3년 시점으로 앞당겨집니다. 보존 정책의 본문 비우기 임계 감시를 P0 운영 항목으로 승격합니다(아래 보존 정책).

P1·P2 도입 시 증가 요인 (미리 인지)

기능영향 테이블증가
P1 애그리게이터 2종 추가(워크넷·잡알리오, FR-60 예약값)job_postings·descriptions·companies공공 소스라 개발 직무 비중 낮아 증가 완만. job_postings 3년 약 160,000행 → 여전히 파티셔닝 불필요
P1 상시채용 7일 반복 리마인드(FR-40)notification_dispatches상시채용·매칭·미지원 공고 × 연 52회. 애그리게이터 상시채용이 많아 연 수천 행
P2 자소서(FR-49·51)신규 테이블 2~3개문항·답변은 본문성 데이터라 TEXT 사용. 별도 설계 문서

보존 정책

NFR-5는 무기한 보존을, Operations는 수집 이력 최소 30일을 요구합니다. 두 요구는 충돌하지 않습니다(30일은 하한). 다만 “무기한 = 무대책”이 되지 않도록, 정리 임계와 정리 방법을 미리 정의하고 임계 도달 전에는 배치를 만들지 않습니다.

데이터보존 기간정리 임계 (도달 시 조치)정리 방법현재 도달 예상
job_postings 및 지원 계열 전체무기한 · 물리 삭제 금지없음없음 (소프트 삭제 전용, FR-18·NFR-5)
job_posting_collection_runs기본 무기한 (요구 하한 30일)100만 행 또는 200MBrun_date < CURRENT_DATE - INTERVAL 400 DAYPK 범위 5,000행 청크로 삭제하는 배치. Flyway가 아니라 애플리케이션 배치로 수행하며 재실행 가능(멱등)연 13,140행 → 임계 도달까지 약 70년. P0에서는 배치를 만들지 않습니다
notification_dispatches성공(SENT) 레코드무기한 · 삭제 금지없음없음멱등 판정의 근거이므로 삭제하면 중복 발송이 발생할 수 있습니다. 만료 규칙(7일)이 있어 실질 위험은 낮지만 삭제를 원천 금지합니다
notification_dispatches — 실패(FAILED) 레코드기본 무기한 (조회 요구는 30일)50만 행dispatch_status='FAILED' AND attempted_at < now-400d 를 청크 삭제연 수백 행 → 임계 도달 사실상 없음
job_posting_descriptions공고당 최신 1건만 (스냅샷 이력 미보관)1GB① 우선 CLOSED + 지원 이력 없음 + closed_at < now-2y 공고의 본문만 비우고(description_body='', body_char_length=0) 공고 행은 유지 ② 그래도 부족하면 압축 저장 검토연 약 290MB 증가 → 애그리게이터 6종 P0로 약 3~4년에 1GB 도달. P0 운영 관리 대상 1순위 — 아래 주의
평가 파생 캐시 2종현재 revision 기준 1벌만 유지없음재평가 시 해당 공고 행 재작성(WHERE job_posting_id=? 한정 삭제)성장 없음(공고 수에 비례)

정리 배치를 지금 만들지 않되, 본문 비우기는 P0 운영 대기 항목입니다: 애그리게이터 6종 P0 편입으로 본문이 약 3~4년에 1GB에 도달합니다(v1 갱신 시점의 “P1에 도달” 예상이 앞당겨짐). 그래도 P0 3년 시점(약 960MB)까지는 여유가 있어 지금은 만들지 않습니다 — 쓰지 않을 삭제 코드는 조건 오류로 이력을 소실시킬 사고 위험이 더 큽니다. 대신 임계·방법을 확정해 두어, Operations 조회 API의 용량 표시가 임계에 근접하면 판단 없이 본문 비우기 배치를 적용합니다. 본문 비우기는 소프트 삭제 원칙과 충돌하지 않습니다 — 공고 행·매칭 결과·지원 이력은 보존하고, 재평가에 더는 쓰이지 않을(CLOSED 2년 경과·미지원) 공고의 본문 문자열만 비우는 것이라 이력 손실이 아닙니다.

job_posting_collection_runs의 하한 30일(Operations)은 정리 배치가 생기더라도 400일 보존이므로 자동 충족됩니다.


Release Scenario — 마이그레이션 순서·락 영향·롤백

V1 베이스라인 (BE-01)

단계내용락 영향롤백 지점
0MySQL 8.0 컨테이너 기동컨테이너 삭제
1V1 단일 마이그레이션: 20개 테이블 CREATE TABLE (인덱스는 CREATE TABLE 안에 인라인 선언)락 없음 — 신규 테이블 생성이라 기존 객체를 잠그지 않습니다. ALGORITHM/LOCK 절은 ALTER TABLE에만 적용되므로 V1에는 붙이지 않습니다flyway_schema_history 삭제 + 스키마 드롭 후 재적용 (데이터 0건)
2같은 마이그레이션 말미에 feature_flags 4행 INSERT락 없음 (4행)DELETE FROM feature_flags
3앱 기동 → Flyway SUCCESS 확인컨테이너 중지

인덱스를 CREATE TABLE에 인라인으로 선언하는 이유: 빈 테이블에 대한 CREATE INDEX는 어차피 즉시 끝나지만, 파일이 두 곳으로 갈리면 “테이블 정의를 봐도 인덱스를 모르는” 상태가 됩니다. 베이스라인은 한 눈에 읽히는 편이 낫습니다.

롤백 DDL을 마이그레이션 상단 주석에 명시합니다 — DROP TABLE 20개를 참조 역순으로(파생 → 마스터). FK 제약이 없으므로 순서 제약은 없지만 가독성을 위해 역순으로 적습니다.

향후 스키마 변경 (expand-contract 표준 절차)

이 프로젝트에서 실제로 예상되는 변경 3종의 순서를 미리 확정합니다.

예상 변경단계배포 순서락 영향롤백 지점
P1 알림 종류 추가 (DEADLINE_D1, PERMANENT_REMINDER)스키마 변경 없음 — VARCHAR 코드값이라 값만 추가코드만 배포없음코드 롤백
애그리게이터 플랫폼 추가 (P0 승격 4종 WANTED·REMEMBER·JOBKOREA·SURFIT / P1 WORKNET·JOBALIO)스키마 변경 없음platform VARCHAR(30)에 코드값 추가 + 새 어댑터 파일. source_type·search_* 컬럼이 이미 V1에 존재코드만 배포없음job_sources.disabled_at 설정으로 수집만 중단
컬럼 추가 (nullable, 백필 불필요)단일 마이그레이션스키마 먼저 → 코드 (구 코드는 새 컬럼을 모르므로 안전)ALTER TABLE ... ADD COLUMN = MySQL 8.0 INSTANT DDL. ALGORITHM=INSTANT 명시역방향 DROP COLUMN. 단 구 코드 배포 상태에서 롤백해야 안전
컬럼 추가 (NOT NULL 필요)① nullable 추가 → ② 듀얼라이트 코드 배포 → ③ 배치 백필(Flyway 인라인 DML 금지) → ④ 검증(NULL 잔존 0건) → ⑤ MODIFY ... NOT NULL스키마 → 코드 → 배치 → 스키마⑤의 MODIFYALGORITHM=INPLACE, LOCK=NONE 명시. 대상 약 140,000행이라 수십 초각 단계 독립 배포. ①~③은 아무도 새 값을 읽지 않아 코드 되돌리기로 롤백
인덱스 추가단일 마이그레이션코드 먼저(쿼리 준비) → 스키마도 무방ALGORITHM=INPLACE, LOCK=NONE 명시 필수DROP INDEX

데이터 마이그레이션 계획 (5단계)

해당 없음. 빈 레포 최초 배포로 기존 데이터가 0건이라 백필 대상이 없습니다. 시드는 feature_flags 4행뿐이며, 이는 “수백 행 이내 · 운영 쓰기 경합 없음”이라는 Flyway DML 예외 조건에 해당합니다(마이그레이션 파일 주석에 근거 기재).

향후 백필이 수반되는 변경이 생기면 위 표의 “컬럼 추가(NOT NULL 필요)” 절차를 따르며, Flyway에 UPDATE ... WHERE 백필 DML을 넣지 않습니다.

기능 활성화 순서 (TDD Release Scenario와 정합)

스키마는 한 번에 만들되, 위험한 기능은 플래그로 마지막에 켭니다. TDD Release 순서(단계 4 알림 → 5 마감 판정 → 6 애그리게이터 수집 → 7 크로스 소스 dedup)와 정합합니다.

  • 마감 판정(posting.auto-close) 오작동 → 정상 공고가 무더기 CLOSED → 물리 삭제가 아니므로 posting_status·closed_reason·closed_at을 되돌려 복구.
  • 크로스 소스 dedup(posting.cross-source-dedup)이 가장 복잡한 기능이라 맨 마지막에 켭니다. 오작동(대표를 잘못 뽑거나 다른 공고를 같은 그룹으로 묶음) 시 플래그 OFF로 그룹핑을 멈추면 전부 대표 취급이 되어 중복 알림이 다시 발생할 수는 있으나 데이터 손상은 없습니다. 잘못 설정된 representative_id는 NULL로 되돌리면 복구됩니다(자기참조 컬럼 UPDATE, 물리 삭제 아님).
  • 애그리게이터 수집(aggregator.collection) OFF 시에도 이미 자동 등록된 DISCOVERED 회사·공고는 보존됩니다(소프트 삭제 원칙).

세 롤백 모두 소프트 삭제 전용 설계 덕에 상태 컬럼 되돌리기로 끝납니다 — 이것이 물리 삭제 없는 설계의 실질 이득입니다.


자가 점검

TDD 도메인 모델 필드 ↔ 컬럼 대응

도메인 클래스필드/개념대응
Companyname, registrationType, companyOrigin, isAutoCollectable(), isWatched(), promoteToWatched()companies.name, registration_type, company_origin
CompanyOriginWATCHED / DISCOVEREDcompanies.company_origin
JobSourceplatform, sourceType, sourceSlug, searchCriteria(category·keyword), baseUrl, isSeeded(), disable()job_sources.* (+source_type·search_category_code·search_keyword, company_id nullable)
JobPostingorigin, sourceJobId, title, url, deadlineAt, status, closedReason, reopenedAt, changeSignature, consecutiveMissCount, firstSeenAt, lastSeenAt, notificationEligible, accessRestricted, computeDedupKey(), linkTo(representativeId), isRepresentative()job_postings 전 컬럼 (+dedup_key·representative_id. 시그니처는 change_signal_kind+change_signal_value 2컬럼으로 sealed 복원, FieldHash 포함)
RawJobPostingstructuredTags, descriptionBodyjob_posting_source_tags, job_posting_descriptions (TDD ERD에 없던 것을 신설)
JobPostingCollectionRunrunDate, status, counts, failurejob_posting_collection_runs 전 컬럼
JobSourceHealthconsecutiveAbnormalDays, brokenSince, failureEpisodejob_source_health 전 컬럼
MatchCriteria그룹·동의어·제외어·근무형태 키워드 + revision키워드 4테이블 + match_criteria_revisions
NormalizedKeywordof(raw) 결과normalized_keyword 컬럼(캐시)
JobPostingMatchResultmatched, excludedBy, criteriaRevision, topConfidence, matchedGroupsjob_posting_match_results + job_posting_matched_keyword_groups (후자 신설)
WorkArrangementEvidencekeyword, evidenceStage, confidence, snippetjob_posting_work_arrangements 전 컬럼
Applicationstatus, appliedAt, rejectedAtStagejob_applications
ApplicationStatusHistoryprevious, next, transitedAt, memojob_application_status_histories
InterviewroundNumber, label, scheduledAt, result, memojob_application_interviews
NotificationDispatchtargetType(+DAILY_DIGEST), targetId, type(+DAILY_DIGEST), sequence, idempotencyKey, status, attemptCountnotification_dispatches
NotificationTypeNEW_JOB_POSTING / SOURCE_FAILURE / DAILY_DIGESTnotification_dispatches.notification_type
FeatureFlagGatewayflagKey → enabled (4개 플래그)feature_flags

미대응 필드 0건. 애그리게이터 편입으로 추가된 도메인 개념(sourceType·searchCriteria·companyOrigin·dedupKey·representativeId·DAILY_DIGEST·FieldHash)이 전부 컬럼·코드값에 대응합니다. TDD DBA 파급 표의 6개 변경 지점(companies·job_sources·job_postings·notification_dispatches·코드값·신규 테이블 없음)과 1:1 일치함을 아래 “TDD와의 정합” 절에서 재확인했습니다.

컨벤션 준수 점검

규칙결과
FK 제약 금지✅ 0건. 참조는 전부 {entity}_id 일반 컬럼
ENUM 금지 → VARCHAR✅ 코드값 20종 전부 VARCHAR + COMMENT에 허용값 전수(애그리게이터로 company_origin·source_type 신규, platform·change_signal_kind·target_type·notification_type 확장)
JSON 컬럼 금지✅ 태그·근거 정규화, 본문 TEXT. 검색 조건도 search_category_code·search_keyword 개별 컬럼(JSON 아님)
BOOLEAN 금지 → TINYINT(1)notification_eligible, access_restricted, enabled 3개
날짜 DATETIME(6)✅ 예외 1건(run_date DATE)만 근거와 함께 명시
테이블·컬럼 COMMENT 필수✅ 전 항목 본문에 기재
PK id 통일✅ 20/20
도메인 기반 명명applicationsjob_applications 등 3건 교정. 신규 컬럼도 company_origin·source_type·dedup_key·representative_id 등 무엇인지 드러나는 이름
인덱스는 쿼리가 근거✅ 조회 전용 9개 전부 쿼리 매핑 표에 대응(Q1~Q18). 유니크 19개는 도메인 제약이 근거. 미생성 결정도 근거 기재
소프트 삭제✅ 도메인 데이터 물리 삭제 0건. representative_id 재지정·company_origin 승격도 상태 전이. 예외는 평가 파생 캐시 2개 + 본문 비우기(이력 손실 아님)만
FK 없는 자기참조representative_id는 자기 PK를 가리키지만 FK 제약 없음(판단 8)
nullable 유니크 의도 검증job_sources 2-인덱스 분기의 NULL distinct 함정을 search_keyword='' 규칙으로 해결(판단 7). idempotency_key·dedup_key도 NULL 동작 명시

TDD와의 충돌·보완 지점

임의로 바꾸지 않고 아래에 전부 보고합니다. 13은 이 문서에 반영했고(컨벤션이 SSOT), 46은 구현 티켓이 확정해야 합니다.

#지점내용처리
1테이블 명명TDD의 applications / application_status_histories / interviewsprivate-db-schema-convention “명명”이 금지 예시로 직접 지목한 형태job_applications / job_application_status_histories / job_application_interviews 로 변경. 도메인 클래스명은 TDD 그대로 유지
2소스 비활성화 컬럼TDD Release Scenario가 job_sources.enabled=0 을 롤백 수단으로 기술컬럼 명명 규칙(suspended ✗ → suspended_at ✓)에 맞춰 disabled_at DATETIME(6) NULL 로 설계. 롤백 조작은 UPDATE job_sources SET disabled_at=NOW(6) WHERE id=? 가 됩니다
3본문·태그 저장 테이블 누락TDD RawJobPostingstructuredTags·descriptionBody를 보유하는데 ERD에 저장 테이블이 없습니다. 이대로면 FR-25(키워드 변경 후 과거 공고 재매칭)와 FR-27②(JD 본문 근거)가 성립하지 않습니다 — 재평가 시점에 본문을 다시 가져올 수단이 없기 때문job_posting_descriptions(1:1), job_posting_source_tags(1:N) 2개 테이블 신설
4매칭 그룹 저장 위치BE-13이 “평가 결과에 매칭 그룹 저장”을 요구하지만 TDD ERD에 대응 테이블이 없습니다job_posting_matched_keyword_groups 신설
5상태 이력 시작점BE-04 테스트가 “DOCUMENT_SCREENING → INTERVIEWING → OFFERED → ACCEPTED 경로에 이력 4건”이라고 기술 — 전이 3회인데 4건이라 지원 생성 이력 (NULL → APPLIED) 포함 여부가 모호합니다이 설계는 생성 시 이력 1건 적재를 전제로 previous_status를 NULL 허용으로 뒀습니다. BE-04·BE-14가 이 규칙을 채택해 주세요
6매칭 키워드 삭제 방식DELETE /api/matching/keyword-groups/{id} 가 물리 삭제면 과거 평가 결과의 그룹 참조가 끊깁니다deleted_at 소프트 삭제로 설계. BE-12 구현 시 물리 삭제 금지
7수집 이력 보존NFR-5·BE-19는 “삭제 배치 없음”, 별도로 “무한 적재 금지” 요구가 있습니다충돌 아님으로 판정 — 연 13,140행이라 임계(100만 행) 도달까지 약 70년입니다. 배치를 만들지 않되 임계·방법을 보존 정책 절에 확정했습니다

애그리게이터 편입 정합 (TDD DBA 파급 표와 대조)

TDD “DBA 파급 요약”(6개 변경 + 본문·태그·명명·파생캐시 노트)과 이 문서를 대조한 결과 모두 일치하며, 추가로 발견한 모순은 없습니다. DBA 관점에서 보강한 지점만 아래에 보고합니다.

#지점TDD 서술이 문서의 보강
8job_sources 유니크 분기”회사 종속형 (platform, source_slug) / 애그리게이터 (platform, search_category_code, search_keyword)NULL distinct 함정을 명시적으로 검증(판단 7). 회사 종속형↔애그리게이터가 서로의 인덱스에 안 걸리는 것은 NULL 덕에 성립하나, 애그리게이터의 search_keyword가 NULL이면 중복 등록이 안 막힘 → 애그리게이터 행은 search_keyword=''(빈 문자열)로 저장하는 규칙을 추가. TDD가 search_keyword VARCHAR NULL이라 했지만, 애그리게이터 행에서는 실질 NOT NULL(”)로 다뤄야 유니크가 DB 레벨로 성립합니다
9dedup_key 인덱스idx(dedup_key) 추가”단일 컬럼 idx_job_postings_dedup_key로 확정(Q17). 고카디널리티라 GROUP BY·점 조회 모두 커버. 유니크가 아님을 명시(같은 키 다건이 정상)
10representative_id self-join”자기참조”idx_job_postings_representative 추가(Q18, 대체 출처 조회). FK 제약 없음·유니크 아님 확정. 회사 목록 쿼리에 representative_id IS NULL 잔여 필터 추가(Q7, 대표만 노출)
11발견 회사 볼륨”companies 수백 곳”3년 약 6,000곳으로 추정(애그리게이터 6종) → v1의 companies 무인덱스 결정을 뒤집어 idx_companies_origin_name 추가(판단 9, Q16). FE 페이지네이션 filesort 제거

Open Questions

#항목현재 처리확정 필요 시점
1배민 상세 조회 엔드포인트 부재 시 본문 확보job_posting_descriptions 행이 생성되지 않고 근무형태는 UNKNOWN이 됩니다. 스키마 변경은 불필요합니다(1:1 optional)BE-08 실호출 확인 시
2인크루트 본문 해시의 정규화 규칙change_signal_value VARCHAR(200)에 sha256 hex(64자)를 저장. 정규화 규칙(공백·태그 제거 범위)은 어댑터가 확정BE-09
3job_postings.company_id 정합소스가 다른 회사로 재매핑되는 시나리오는 요구사항에 없습니다. 발생하면 공고의 company_id도 함께 갱신하는 절차가 필요합니다회사-소스 재매핑 기능이 생길 때
4알림 메시지 본문 전문 보관message_summary(300자)만 보관합니다. 실패 이력에서 “정확히 어떤 메시지였는지” 전문이 필요해지면 TEXT 컬럼을 nullable 추가(INSTANT DDL)운영 중 필요 관측 시
5search_keyword 빈 문자열 규칙의 대안애그리게이터 유니크를 위해 '' 규칙을 채택했으나(판단 7), 구현에서 이 규칙 준수가 부담되면 단일 파생 컬럼 source_unique_key("CB:.."/"AG:..") + 유니크 1개로 전환 가능. dedup_key와 동일 패턴이라 일관적BE-17 구현 중 '' 규칙이 실수 유발한다고 판단되면
6dedup 배치가 CLOSED 공고를 그룹 후보에 포함하는지이 문서는 dedup_key IS NOT NULL 전량을 그룹 후보로 봤습니다(상태 무관). CLOSED 공고를 제외해야 하면 idx(dedup_key)posting_status를 추가하는 것을 검토 — 다만 그룹 크기가 작아 현재는 불필요BE dedup 배치 구현 시 CLOSED 포함 정책 확정

Document History

날짜변경 내용
2026-07-22최초 작성 — MySQL 단일 저장소 판단, 20개 테이블 정의, 쿼리→인덱스 매핑 18건, 3년 용량 추정, 보존 정책, V1 베이스라인 릴리즈 순서 확정. TDD 대비 신설 테이블 3개·명명 교정 3건 반영
2026-07-22애그리게이터 수집 P0 편입 반영 — ① companies.company_origin, job_sources(source_type·search_category_code·search_keyword 추가 + company_id·source_slug nullable + 유니크 2개 분기), job_postings(representative_id 자기참조·dedup_key+인덱스), notification_dispatches DAILY_DIGEST, 코드값 6종 추가 ② 판단 7(nullable 유니크 NULL distinct 함정 + search_keyword='' 해결)·8(자기참조 vs 그룹테이블)·9(company_origin 인덱스) 신설 ③ 쿼리 매핑 Q16~Q18 추가, Q7에 대표 필터 ④ 용량 재추정(job_postings 약 77,000행·전체 약 560MB, 파티셔닝 여전히 불필요) ⑤ feature_flags 4행, Release 단계 6·7 정합. 신규 테이블 0개(자기참조로 해결)
2026-07-22회색지대 애그리게이터 4종(원티드·리멤버·잡코리아·서핏) P0 승격 반영스키마 구조 변경 0건. ① platform 코드값의 4종을 “P1 예약” → “P0 실사용” 표기(VARCHAR(30) 여유, 최장값 GREENHOUSE 10자 불변) ② 용량 재추정: 애그리게이터 플랫폼 2→6종, job_postings 3년 약 77,000→140,000행, 전체 약 560MB→1.1GB(본문 960MB 지배) — 파티셔닝·샤딩 불필요 결론 유지(14만 행은 단일 테이블 정상) ③ 본문 1GB 임계 도달이 P0 3~4년으로 앞당겨져 본문 비우기를 P0 운영 대기 항목으로 승격, 버퍼 풀 권장 256→512MB ④ 발견 회사 3,000→6,000곳