공고알림앱 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-application—README.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 채택 근거 해당 여부 | 저장소 | 판단 근거 |
|---|---|---|---|
| 회사·소스 매핑 | 없음 | MySQL | Company : 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이 유리합니다 |
| 알림 발송 이력 | 없음 | MySQL | nullable 유니크 인덱스로 멱등을 구현하는 것이 설계의 핵심(TDD 방안 4)입니다. Mongo의 partial unique index로도 가능하지만, 이 데이터만을 위해 저장소를 추가할 이유가 없습니다 |
| 지원·상태 전이·면접 | 없음 | MySQL | 상태 전이 정합·유니크 제약 중심. 트랜잭션 필수 |
| 매칭 기준·평가 결과 | 없음 | MySQL | revision 기반 재평가 판별이 조인·집계 중심 |
| 발견 회사(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이 반드시 지켜야 합니다.
idempotency_key에DEFAULT ''를 넣지 말 것 — 빈 문자열은 NULL이 아니어서 두 번째 실패 레코드가 유니크 위반으로 거부됩니다.- 컬럼을 NOT NULL로 만들지 말 것 — 유니크 제약과 함께 선언하다 보면 습관적으로 NOT NULL을 붙이기 쉽습니다.
- 애플리케이션이
dispatch_status='SENT'인데 키가 NULL인 상태를 만들지 말 것 — 멱등 판정 쿼리(findSucceededBy(key))가 키로만 조회하므로, 키 없는 성공 레코드는 중복 발송을 유발합니다.
같은 성질을 job_postings의 unique(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 RawJobPosting은 descriptionBody·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_at | DELETE 없음 |
| 공고 재오픈 | 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만 다룹니다 |
| PK | id BIGINT NOT NULL AUTO_INCREMENT 전 테이블 통일 | 컨벤션 |
| 참조 컬럼 | {entity}_id BIGINT — FK 제약 없음, 정합은 애플리케이션 책임 | 컨벤션(FK 금지) |
| 상태·구분값 | ENUM 금지 → VARCHAR, 허용값 전수를 COMMENT에 기재 | 컨벤션 |
| 불리언 | BOOLEAN 금지 → TINYINT(1) | 컨벤션 |
| 날짜·시각 | DATETIME(6) (예외 1건: job_posting_collection_runs.run_date는 DATE — 아래 근거) | 컨벤션 |
| JSON | 금지 — 태그·근거는 정규화 테이블, 본문은 TEXT | 컨벤션 |
| COMMENT | 테이블·컬럼 전부 필수 | 컨벤션 |
| 공통 컬럼 | created_at DATETIME(6) NOT NULL / updated_at DATETIME(6) NOT NULL — 아래 표에서 생략하고 전 테이블에 부여. 추가 전용(append-only) 테이블은 created_at만 둡니다(해당 테이블에 표기) | — |
| 낙관적 잠금 | version INT NOT NULL DEFAULT 0 — job_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줄) |
|---|---|---|
| company | companies | 관심 회사(WATCHED)와 애그리게이터 발견 회사(DISCOVERED)를 함께 보관. 자동 수집 가능 여부(registration_type)와 출처 축(company_origin)을 보유 |
| company | job_sources | 채용 데이터 출처. 회사 종속형(회사+slug)과 애그리게이터형(검색 조건)이 공존 |
| posting | job_postings | 수집·수동 공고의 단일 저장소. 상태·마감일·델타 카운터 + 크로스 소스 대표(representative_id)·중복 판정 키(dedup_key) |
| posting | job_posting_descriptions | 공고 본문(JD) 1:1 분리 — 근무형태 ②근거·재평가의 입력 |
| posting | job_posting_source_tags | 소스가 제공한 구조화 태그(Greenhouse metadata[]) — 근무형태 ①근거 |
| posting | job_posting_collection_runs | 소스×실행일 수집 회차 이력. 성공률·소스 가드·중복 실행 방지의 근거 |
| posting | job_source_health | 소스별 연속 비정상 일수·고장 진입 상태(소스당 1행) |
| matching | job_keyword_groups | 직무 동의어 그룹(매칭 조건 1단위) |
| matching | job_keyword_synonyms | 그룹에 속한 동의어와 그 정규화 값 |
| matching | job_exclusion_keywords | 제외 키워드(매칭을 이깁니다) |
| matching | work_arrangement_keywords | 근무형태 키워드(추출 대상 지정 + 정렬 기준) |
| matching | match_criteria_revisions | 매칭 기준 변경 이력 = 재평가 판별의 기준점(현재 revision = MAX(id)) |
| matching | job_posting_match_results | 공고당 평가 결과 1건(매칭 여부·제외 사유·대표 확신도·평가 revision) |
| matching | job_posting_matched_keyword_groups | 평가 결과가 어떤 그룹에 매칭됐는지(N:M 해소) |
| matching | job_posting_work_arrangements | 근무형태 판정 값 + 근거 + 확신도 (공고당 키워드별 1행) |
| application | job_applications | 지원 기록. “미지원 = 행 미존재”(FR-42) |
| application | job_application_status_histories | 상태 전이 이력 — 히스토리 조회의 SSOT(FR-47) |
| application | job_application_interviews | 면접 회차(상태값이 아닌 별도 기록, FR-46) |
| notification | notification_dispatches | 알림 발송 시도 1건(성공·실패 모두). 멱등·재발송·실패 조회의 근거 |
| common | feature_flags | 런타임 기능 토글(posting.auto-close, notification.discord-dispatch) |
명명 변경 (TDD 대비): TDD의
applications/application_status_histories/interviews를job_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 | 근거 |
|---|---|---|---|---|---|
name | VARCHAR(100) | NOT NULL | — | 회사명 (중복 불가). 애그리게이터 자동 등록 멱등의 근거 — 같은 회사명은 1건에 수렴 | FR-1·61, BE-06 |
registration_type | VARCHAR(20) | NOT NULL | — | 등록 유형: AUTO(자동 수집 대상) / MANUAL_ONLY(후보 0건, 수동 등록 전용) | FR-5, ENUM 금지 |
company_origin | VARCHAR(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_id | BIGINT | NULL | NULL | companies.id 참조 (FK 제약 없음). 애그리게이터형은 회사에 안 붙으므로 NULL | FR-60, TDD 파급 |
source_type | VARCHAR(20) | NOT NULL | — | 소스 유형: COMPANY_BOUND(회사 slug로 수집) / AGGREGATOR(검색 조건으로 수집) | FR-60 |
platform | VARCHAR(30) | NOT NULL | — | 플랫폼(P0 9종): GREENHOUSE / WOOWAHAN / INCRUIT / SARAMIN / JUMPIT / WANTED / REMEMBER / JOBKOREA / SURFIT (P1: WORKNET, JOBALIO) | TDD JobPlatform |
source_slug | VARCHAR(100) | NULL | NULL | 플랫폼 내 회사 식별 slug (예: daangn). 회사 종속형은 필수, 애그리게이터형은 NULL | FR-60 |
search_category_code | VARCHAR(100) | NULL | NULL | 애그리게이터 검색 직무 카테고리 코드 (예: 사람인 cat_kewd=84, 점핏 jobCategory=1). 회사 종속형은 NULL, 애그리게이터형은 NOT NULL 값 | FR-60 |
search_keyword | VARCHAR(200) | NULL | NULL | 애그리게이터 검색 키워드. 회사 종속형은 NULL. 애그리게이터형은 키워드 없으면 빈 문자열('')을 저장 — 유니크 제약이 NULL 다건을 허용하는 함정을 막기 위함(판단 7) | FR-60 |
base_url | VARCHAR(500) | NOT NULL | — | 소스 base URL 또는 애그리게이터 검색 base URL | TDD JobSourceCandidate.baseUrl |
seeded_at | DATETIME(6) | NULL | NULL | 시딩(최초 수집) 완료 시각. NULL이면 미시딩 — 최초 수집은 알림 없이 저장만 한다 | FR-10, isSeeded() |
disabled_at | DATETIME(6) | NULL | NULL | 소스 비활성화 시각. 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_id | BIGINT | NOT NULL | — | companies.id 참조 | 수동 공고(job_source_id NULL)도 회사 귀속이 필요 → 소스 경유 유도 불가 |
job_source_id | BIGINT | NULL | NULL | job_sources.id 참조. NULL이면 수동 등록 공고 | FR-20 |
representative_id | BIGINT | NULL | NULL | 크로스 소스 중복 그룹의 대표 공고 id (자기참조, FK 제약 없음). NULL이면 자신이 대표(또는 단독), 값이 있으면 비대표(대표를 가리킴) | FR-63·64, 판단 8 |
dedup_key | VARCHAR(255) | NULL | NULL | 중복 판정 키 = 정규화 회사명 + 정규화 제목 (엔티티가 쿼리 없이 계산). 08:30 dedup 배치가 이 값으로 그룹핑. 수동 등록 등 dedup 비대상이면 NULL | FR-63, 판단 8 |
posting_origin | VARCHAR(20) | NOT NULL | — | 수집 출처: COLLECTED(자동 수집) / MANUAL(수동 등록). MANUAL은 델타 판정 모수에서 제외 | FR-20, TDD 상태 전이 표. 사고 방지의 핵심 컬럼 |
source_job_id | VARCHAR(200) | NULL | NULL | 소스별 고유 공고 ID (Greenhouse id / recruitSeq / 인크루트 job id). 수동 등록이면 NULL | FR-11 |
title | VARCHAR(300) | NOT NULL | — | 공고 제목. 직무 키워드 매칭 대상(제목 단독) | FR-24, TDD Open Q#2 |
posting_url | VARCHAR(1000) | NOT NULL | — | 공고 원문 링크 | 알림 본문·UI |
deadline_at | DATETIME(6) | NULL | NULL | 마감일. NULL이면 상시채용 — 어댑터가 필드 부재/센티널(9999-12-31)을 NULL로 정규화한다 | FR-13 |
posting_status | VARCHAR(20) | NOT NULL | — | 공고 상태: OPEN / CLOSED. 물리 삭제 없이 상태 전이로만 표현 | FR-18 |
closed_reason | VARCHAR(30) | NULL | NULL | 마감 사유: NOT_FOUND_TWICE(연속 2회 미발견) / DEADLINE_PASSED(마감일 경과). OPEN이면 NULL | FR-14·17 |
closed_at | DATETIME(6) | NULL | NULL | 마감 전환 시각 | Observability(마감 전환 건수) |
reopened_at | DATETIME(6) | NULL | NULL | 마지막 재오픈 시각. CLOSED 공고 재발견 시 기록하며 신규 알림 대상이 아니다 | FR-16 |
change_signal_kind | VARCHAR(30) | NULL | NULL | 변경 감지 근거 종류: UPDATED_AT / SOURCE_VERSION / DETAIL_BODY_HASH / FIELD_HASH(점핏 등 변경 필드 없는 소스의 주요 필드 해시). 수동 등록이면 NULL | TDD ChangeSignalKind, FR-68 |
change_signal_value | VARCHAR(200) | NULL | NULL | 변경 감지 근거 값 (ISO-8601 시각 / 버전 숫자 / sha256 hex 64자) | TDD ChangeSignature.storedValue |
changed_at | DATETIME(6) | NULL | NULL | 마지막 변경 감지 시각 (시그니처 변경 시 갱신) | FR-12, TDD Open Q#5 |
consecutive_miss_count | INT | NOT NULL | 0 | 정상 회차에서 연속 미발견한 횟수. 2 도달 시 CLOSED 전환, 발견 시 0으로 초기화 | FR-14 |
first_seen_at | DATETIME(6) | NOT NULL | — | 최초 발견 시각. 신규 알림 만료(7일) 판정 기준 | TDD 알림 만료 규칙 |
last_seen_at | DATETIME(6) | NOT NULL | — | 마지막 발견 시각 (정상 회차에서 발견될 때마다 갱신) | 델타 판정 |
notification_eligible | TINYINT(1) | NOT NULL | — | 신규 공고 알림 대상 여부: 1=대상, 0=제외(시딩 회차 공고는 영구 제외) | FR-10, BOOLEAN 금지 |
access_restricted | TINYINT(1) | NOT NULL | 0 | 접근 제한(로그인 필요) 공고 여부: 1이면 자동 매칭·마감 판정 대상에서 제외 | 시나리오 6 |
version | INT | NOT NULL | 0 | 낙관적 잠금 버전 (배치와 사용자 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, 소수만 >1idx_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_id | BIGINT | NOT NULL | — | job_postings.id 참조 (공고당 1행) | 1:1 |
description_body | TEXT | NOT NULL | — | 공고 본문 원문 (어댑터가 UTF-8로 디코딩·정규화한 결과). JSON 컬럼 금지 규칙에 따라 TEXT 사용 | FR-27②, 판단 3 |
body_char_length | INT | NOT NULL | — | 본문 문자 수 (파싱 이상 탐지용 — 급감 시 파서 고장 의심) | Olostep silent failure 벤치마킹 |
fetched_at | DATETIME(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_id | BIGINT | NOT NULL | — | job_postings.id 참조 | 1:N |
tag_value | VARCHAR(200) | NOT NULL | — | 어댑터가 평탄화한 태그 문자열 (예: 정규직, 경력 3년 이상) | TDD RawJobPosting.structuredTags |
normalized_tag_value | VARCHAR(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_id | BIGINT | NOT NULL | — | job_sources.id 참조 | — |
run_date | DATE | NOT NULL | — | 수집 실행 일자 (KST 기준 업무 일자). 소스당 하루 1회 실행을 유니크 제약으로 강제 | DATETIME(6) 규약의 유일한 예외 — 일자 단위 유니크 키라 시각을 포함하면 중복 실행 방지가 성립하지 않습니다 |
run_status | VARCHAR(20) | NOT NULL | — | 회차 결과: SUCCESS / FAILED | TDD 수집 실패 경로 |
fetched_count | INT | NOT NULL | 0 | 수집한 공고 건수. SUCCESS여도 0이면 비정상 회차로 취급한다(silent failure) | FR-15 |
new_count | INT | NOT NULL | 0 | 신규 저장 공고 건수 | Observability |
changed_count | INT | NOT NULL | 0 | 변경 감지된 공고 건수 | FR-12 |
missed_count | INT | NOT NULL | 0 | 이번 회차에서 미발견된 공고 건수 | FR-14 |
closed_count | INT | NOT NULL | 0 | 이번 회차에서 CLOSED로 전환된 공고 건수 | 마감 오판정 지표 |
detail_failure_count | INT | NOT NULL | 0 | 상세 페이지 조회 실패 건수 (목록은 성공) | TDD 부분 실패 |
detail_skipped_count | INT | NOT NULL | 0 | 상세 조회 상한(회차당 200건) 초과로 건너뛴 건수 | Operations 요청량 관리 |
failure_reason | VARCHAR(200) | NULL | NULL | 실패 사유 요약. SUCCESS면 NULL | TDD Failed.reason |
failure_cause_summary | VARCHAR(1000) | NULL | NULL | 실패 원인 상세(예외 메시지 등) | TDD Failed.causeSummary |
started_at | DATETIME(6) | NOT NULL | — | 회차 시작 시각 | 배치 소요 시간(NFR-2) |
finished_at | DATETIME(6) | NULL | NULL | 회차 종료 시각 | 동일 |
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_id | BIGINT | NOT NULL | — | job_sources.id 참조 (소스당 1행) | TDD 제약 |
consecutive_abnormal_days | INT | NOT NULL | 0 | 연속 비정상(실패 또는 0건) 일수. 3 도달 시 고장 진입 | FR-19 |
broken_since | DATETIME(6) | NULL | NULL | 고장 진입 시각. NULL이면 정상 — 알림 만료(7일) 판정 기준 | FR-19 |
failure_episode | INT | NOT NULL | 0 | 고장 에피소드 번호. 복구 후 재고장하면 1 증가하며 알림 멱등 키의 발송 회차로 사용 | TDD 발송 회차 규칙 |
last_normal_at | DATETIME(6) | NULL | NULL | 마지막 정상 회차 시각. 고장 감지 지연 지표 계산에 사용 | Success Metrics |
last_abnormal_at | DATETIME(6) | NULL | NULL | 마지막 비정상 회차 시각 | Operations |
uk_job_source_health_source (job_source_id)findAllBroken()전용 인덱스 없음 — 전체 30행 미만(§인덱스 미생성 목록)
job_keyword_groups — 직무 동의어 그룹
테이블 COMMENT: 직무 키워드 동의어 그룹 (그룹 1개 = 매칭 조건 1개)
| 컬럼 | 타입 | NULL | 기본값 | COMMENT | 근거 |
|---|---|---|---|---|---|
display_name | VARCHAR(100) | NOT NULL | — | 그룹 표시명 (예: 백엔드) | FR-22 |
deleted_at | DATETIME(6) | NULL | NULL | 소프트 삭제 시각. NULL이면 활성 — 과거 평가 결과가 이 그룹을 참조하므로 물리 삭제하지 않는다 | 판단 5 |
uk_job_keyword_groups_display_name (display_name)
job_keyword_synonyms — 동의어
테이블 COMMENT: 동의어 그룹에 속한 키워드와 그 정규화 값
| 컬럼 | 타입 | NULL | 기본값 | COMMENT | 근거 |
|---|---|---|---|---|---|
job_keyword_group_id | BIGINT | NOT NULL | — | job_keyword_groups.id 참조 | — |
raw_keyword | VARCHAR(100) | NOT NULL | — | 사용자가 입력한 원문 키워드 (예: Backend) | FR-22 |
normalized_keyword | VARCHAR(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_keyword | VARCHAR(100) | NOT NULL | — | 사용자가 입력한 원문 제외 키워드 (예: 인턴) | FR-23 |
normalized_keyword | VARCHAR(100) | NOT NULL | — | 정규화 제외 키워드 | FR-24 |
deleted_at | DATETIME(6) | NULL | NULL | 소프트 삭제 시각. 평가 결과의 제외 사유가 이 행을 참조한다 | 판단 5 |
uk_job_exclusion_keywords_normalized (normalized_keyword)
work_arrangement_keywords — 근무형태 키워드
테이블 COMMENT: 근무형태 키워드 (재택·원격 등). 추출 대상 지정과 목록 정렬 기준으로만 쓰이며 필터가 아니다
| 컬럼 | 타입 | NULL | 기본값 | COMMENT | 근거 |
|---|---|---|---|---|---|
raw_keyword | VARCHAR(100) | NOT NULL | — | 사용자가 입력한 원문 근무형태 키워드 | FR-26 |
normalized_keyword | VARCHAR(100) | NOT NULL | — | 정규화 근무형태 키워드 | FR-24 |
deleted_at | DATETIME(6) | NULL | NULL | 소프트 삭제 시각. 근무형태 근거 행이 이 행을 참조한다 | 판단 5 |
uk_work_arrangement_keywords_normalized (normalized_keyword)
match_criteria_revisions — 매칭 기준 변경 이력
테이블 COMMENT: 매칭 기준 변경 이력. 현재 revision = MAX(id)이며 재평가 대상 판별의 기준점
추가 전용(created_at만).
| 컬럼 | 타입 | NULL | 기본값 | COMMENT | 근거 |
|---|---|---|---|---|---|
change_target | VARCHAR(40) | NOT NULL | — | 변경 대상: KEYWORD_GROUP / KEYWORD_SYNONYM / EXCLUSION_KEYWORD / WORK_ARRANGEMENT_KEYWORD | FR-22·23·26 |
change_reason | VARCHAR(200) | NOT NULL | — | 변경 사유 요약 (예: 백엔드 그룹에 서버 동의어 추가) | 재평가 추적 |
- 추가 인덱스 없음 —
bumpRevision()은 INSERT,loadCurrent()는SELECT MAX(id)로 PK만 사용합니다. revision별도 컬럼을 두지 않았습니다. AUTO_INCREMENTid가 곧 단조 증가 revision이라 컬럼을 하나 더 두면 두 값의 동기화 문제만 생깁니다.
job_posting_match_results — 공고 평가 결과 (공고당 1건)
테이블 COMMENT: 공고별 매칭 평가 결과. 매칭 실패 공고도 결과를 저장한다(매칭은 알림 조건일 뿐 저장 조건이 아님)
| 컬럼 | 타입 | NULL | 기본값 | COMMENT | 근거 |
|---|---|---|---|---|---|
job_posting_id | BIGINT | NOT NULL | — | job_postings.id 참조 (공고당 1건) | TDD 제약 |
criteria_revision | BIGINT | NOT NULL | — | 평가에 사용한 매칭 기준 revision. 현재 revision보다 작으면 재평가 대상 | FR-25, BE-13 |
keyword_matched | TINYINT(1) | NOT NULL | — | 직무 키워드 매칭 여부: 1=매칭(알림 대상), 0=미매칭 | FR-25 |
excluded_keyword_id | BIGINT | NULL | NULL | 제외 키워드로 탈락한 경우 job_exclusion_keywords.id. 제외가 아니면 NULL | FR-23 |
top_confidence | VARCHAR(20) | NOT NULL | — | 대표 근무형태 확신도: CONFIRMED / LIKELY / INFERRED / UNKNOWN (근거 없으면 UNKNOWN) | FR-29·34 |
work_arrangement_sort_rank | INT | NOT NULL | — | 근무형태 정렬 순위: 1=CONFIRMED, 2=LIKELY, 99=INFERRED·UNKNOWN(정렬 기준 제외). 문자열 정렬이 도메인 순서와 다르므로 별도 보유 | FR-30, 판단 4 |
evaluated_at | DATETIME(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_id | BIGINT | NOT NULL | — | job_posting_match_results.id 참조 | BE-13 “매칭 그룹 저장” |
job_keyword_group_id | BIGINT | NOT NULL | — | job_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_id | BIGINT | NOT NULL | — | job_postings.id 참조 | — |
work_arrangement_keyword_id | BIGINT | NOT NULL | — | work_arrangement_keywords.id 참조 (판정된 근무형태 값) | TDD 제약 |
evidence_stage | VARCHAR(30) | NOT NULL | — | 근거 단계: STRUCTURED_FIELD(구조화 필드) / DESCRIPTION_BODY(JD 본문) / COMPANY_REFERENCE(다른 공고 — P2) | FR-27·29 |
confidence | VARCHAR(20) | NOT NULL | — | 확신도: CONFIRMED(구조화 필드) / LIKELY(JD 본문) / INFERRED(P2). 같은 키워드가 두 단계에서 잡히면 높은 확신도만 보관 | FR-29, 판단 4 |
evidence_snippet | VARCHAR(500) | NOT NULL | — | 근거 문구 발췌 (UI 표시용). 부정어 검사를 통과한 문맥만 저장 | FR-31·32 |
criteria_revision | BIGINT | NOT 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_id | BIGINT | NOT NULL | — | job_postings.id 참조 (공고당 지원 1건) | FR-42 |
application_status | VARCHAR(30) | NOT NULL | — | 지원 상태: APPLIED / DOCUMENT_SCREENING / INTERVIEWING / OFFERED / ACCEPTED / REJECTED / WITHDRAWN / OFFER_DECLINED (뒤 4개는 종료 상태) | FR-43 |
applied_at | DATETIME(6) | NOT NULL | — | 지원 시각 (사용자 입력) | FR-42 |
rejected_at_stage | VARCHAR(30) | NULL | NULL | REJECTED 전이 시점의 직전 단계. 그 외에는 NULL | FR-43 |
memo | VARCHAR(1000) | NULL | NULL | 지원 메모 | API 계약 |
version | INT | NOT NULL | 0 | 낙관적 잠금 버전 (상태 이중 전이 방지) | TDD 동시성 표 |
uk_job_applications_posting (job_posting_id)- 회사별 지원 목록(
GET /api/applications?companyId=)은job_postings의idx_..._company_status_seen으로 공고를 좁힌 뒤 이 유니크 인덱스로 조인합니다 —company_id비정규화 컬럼을 두지 않습니다(전체 300행 규모, 조인 비용 0에 가깝고 중복 보유는 정합 위험만 추가).
job_application_status_histories — 상태 전이 이력
테이블 COMMENT: 지원 상태 전이 이력. 히스토리 조회의 단일 기준 데이터이며 수정·삭제하지 않는다
추가 전용(created_at만).
| 컬럼 | 타입 | NULL | 기본값 | COMMENT | 근거 |
|---|---|---|---|---|---|
job_application_id | BIGINT | NOT NULL | — | job_applications.id 참조 | — |
previous_status | VARCHAR(30) | NULL | NULL | 이전 상태. 지원 최초 생성 이력이면 NULL | FR-47 (아래 주의) |
next_status | VARCHAR(30) | NOT NULL | — | 전이 후 상태 | FR-47 |
transited_at | DATETIME(6) | NOT NULL | — | 전이 시각 | FR-47 |
memo | VARCHAR(1000) | NULL | NULL | 전이 메모 | 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_id | BIGINT | NOT NULL | — | job_applications.id 참조 | — |
round_number | INT | NOT NULL | — | 면접 회차 번호 (1부터). 같은 지원 내 중복 불가 | FR-46 |
round_label | VARCHAR(100) | NOT NULL | — | 사용자 지정 회차 레이블 (예: 1차 기술면접, 임원면접) | FR-46 |
scheduled_at | DATETIME(6) | NULL | NULL | 면접 일정. 미정이면 NULL | FR-46 |
interview_result | VARCHAR(20) | NULL | NULL | 면접 결과: PASSED / FAILED / CANCELED. 미기록이면 NULL | FR-46 |
memo | VARCHAR(1000) | NULL | NULL | 면접 메모 | API 계약 |
uk_job_application_interviews_round (job_application_id, round_number)
notification_dispatches — 알림 발송 시도 이력
테이블 COMMENT: 디스코드 알림 발송 시도 1건. 성공·실패를 모두 남기며 성공 시에만 멱등 키를 부여한다
추가 전용에 가깝지만 시도 결과 갱신이 있으므로 updated_at을 둡니다.
| 컬럼 | 타입 | NULL | 기본값 | COMMENT | 근거 |
|---|---|---|---|---|---|
target_type | VARCHAR(30) | NOT NULL | — | 알림 대상 종류: JOB_POSTING / JOB_SOURCE / DAILY_DIGEST(발견 회사 일일 요약, 특정 대상 없음) | TDD 멱등 키 구성, FR-62 |
target_id | BIGINT | NOT NULL | — | 대상 ID (job_postings.id 또는 job_sources.id). DAILY_DIGEST는 특정 대상이 없으므로 0 | 동일 |
notification_type | VARCHAR(40) | NOT NULL | — | 알림 종류: NEW_JOB_POSTING(관심 회사 개별) / SOURCE_FAILURE / DAILY_DIGEST(발견 회사 매칭 신규 묶음) (P1 확장: DEADLINE_D1, PERMANENT_REMINDER). 마감 알림은 발송하지 않는다 | FR-35·36·62 |
dispatch_sequence | INT | NOT NULL | — | 발송 회차. 신규 공고는 1, 소스 고장은 failure_episode 값, DAILY_DIGEST는 KST 일자 서수(하루 1건 강제), P1 반복 리마인드는 7일마다 증가 | FR-38·62 |
idempotency_key | VARCHAR(150) | NULL | 없음(DEFAULT 금지) | 멱등 키 {target_type}:{target_id}:{notification_type}:{dispatch_sequence}. 발송 성공(2xx) 시에만 채운다. NULL 다건 공존이 실패 이력 보존과 재발송의 전제 | TDD 방안 4-c, 판단 1 |
dispatch_status | VARCHAR(20) | NOT NULL | — | 발송 결과: SENT(성공) / FAILED(3회 재시도 후 최종 실패) | FR-41 |
attempt_count | INT | NOT NULL | — | 이번 시도에서 수행한 웹훅 호출 횟수 (최대 3) | FR-41 |
last_status_code | INT | NULL | NULL | 마지막 응답 HTTP 상태 코드. 네트워크 오류 등으로 응답이 없으면 NULL | Operations |
last_error | VARCHAR(1000) | NULL | NULL | 마지막 실패 사유. 성공이면 NULL | 시나리오 7 |
message_summary | VARCHAR(300) | NOT NULL | — | 발송 메시지 요약 (공고 제목 또는 소스 식별 문구) — 실패 이력 조회 시 무엇을 못 보냈는지 식별 | Operations |
attempted_at | DATETIME(6) | NOT NULL | — | 발송 시도 시각 | Operations 조회 기준 |
delivered_at | DATETIME(6) | NULL | NULL | 발송 성공 시각. 실패면 NULL | Success Metrics |
uk_notification_dispatches_idempotency_key (idempotency_key)— nullable 유니크idx_notification_dispatches_status_time (dispatch_status, attempted_at)
feature_flags — 런타임 기능 토글
테이블 COMMENT: 런타임 피처 플래그. 재기동 없이 기능을 끄기 위한 운영 스위치
| 컬럼 | 타입 | NULL | 기본값 | COMMENT | 근거 |
|---|---|---|---|---|---|
flag_key | VARCHAR(100) | NOT NULL | — | 플래그 키 (예: posting.auto-close) | TDD 방안 6 |
enabled | TINYINT(1) | NOT NULL | 0 | 활성 여부: 1=ON, 0=OFF | BOOLEAN 금지 |
description | VARCHAR(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_type | 20 | AUTO, MANUAL_ONLY | 11 |
companies.company_origin | 20 | WATCHED, DISCOVERED | 10 |
job_sources.source_type | 20 | COMPANY_BOUND, AGGREGATOR | 13 |
job_sources.platform | 30 | P0 9종: GREENHOUSE, WOOWAHAN, INCRUIT, SARAMIN, JUMPIT, WANTED, REMEMBER, JOBKOREA, SURFIT (P1 예약: WORKNET, JOBALIO) | 10 (GREENHOUSE) |
job_postings.posting_origin | 20 | COLLECTED, MANUAL | 9 |
job_postings.posting_status | 20 | OPEN, CLOSED | 6 |
job_postings.closed_reason | 30 | NOT_FOUND_TWICE, DEADLINE_PASSED | 15 |
job_postings.change_signal_kind | 30 | UPDATED_AT, SOURCE_VERSION, DETAIL_BODY_HASH, FIELD_HASH | 17 |
job_posting_collection_runs.run_status | 20 | SUCCESS, FAILED | 7 |
job_posting_match_results.top_confidence | 20 | CONFIRMED, LIKELY, INFERRED, UNKNOWN | 9 |
job_posting_work_arrangements.confidence | 20 | CONFIRMED, LIKELY, INFERRED | 9 |
job_posting_work_arrangements.evidence_stage | 30 | STRUCTURED_FIELD, DESCRIPTION_BODY, COMPANY_REFERENCE(P2) | 17 |
job_applications.application_status / rejected_at_stage | 30 | APPLIED, DOCUMENT_SCREENING, INTERVIEWING, OFFERED, ACCEPTED, REJECTED, WITHDRAWN, OFFER_DECLINED | 18 |
job_application_status_histories.previous_status / next_status | 30 | 위와 동일 | 18 |
job_application_interviews.interview_result | 20 | PASSED, FAILED, CANCELED | 8 |
notification_dispatches.target_type | 30 | JOB_POSTING, JOB_SOURCE, DAILY_DIGEST | 12 |
notification_dispatches.notification_type | 40 | NEW_JOB_POSTING, SOURCE_FAILURE, DAILY_DIGEST (P1: DEADLINE_D1, PERMANENT_REMINDER) | 18 |
notification_dispatches.dispatch_status | 20 | SENT, FAILED | 6 |
match_criteria_revisions.change_target | 40 | KEYWORD_GROUP, KEYWORD_SYNONYM, EXCLUSION_KEYWORD, WORK_ARRANGEMENT_KEYWORD | 24 |
길이는 최장값의 약 2배로 잡았습니다 — P1·P2 확장값(PERMANENT_REMINDER 등)이 들어와도 ALTER TABLE MODIFY가 필요 없게 하기 위해서입니다. VARCHAR 길이 확장은 온라인 DDL이 가능하지만(같은 길이 바이트 구간 내), 애초에 발생시키지 않는 편이 낫습니다.
쿼리 패턴 → 인덱스 매핑
대상 쿼리가 없는 인덱스는 만들지 않습니다. 아래 표의 인덱스가 전부이며, 각 행이 인덱스 1개의 존재 근거입니다.
| # | 쿼리 패턴 (WHERE / ORDER BY) | 사용처 | 사용 인덱스 | 컬럼 순서 근거 | 예상 스캔 |
|---|---|---|---|---|---|
| Q1 | WHERE 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행 |
| Q2 | WHERE job_source_id=? AND source_job_id=? | 중복 판정·단건 갱신 (findBy, FR-11) | 동일 유니크 인덱스 (전체 사용) | 유니크 제약 자체가 중복 판정의 방어선 | 1행 |
| Q3 | WHERE 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~수십행) |
| Q4 | WHERE notification_eligible=1 AND posting_status='OPEN' AND first_seen_at >= :now-7d + match_results.keyword_matched=1 조인 + 성공 발송 이력 NOT EXISTS | 09:00 알림 대상 산출 (findNewJobPostingTargets, FR-10·25·38) | idx_job_postings_notification_target (notification_eligible, posting_status, first_seen_at) → uk_job_posting_match_results_posting → uk_notification_dispatches_idempotency_key | Equality(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행 점 조회 |
| Q5 | WHERE idempotency_key = ? | 알림 멱등 판정 (findSucceededBy, FR-38) | uk_notification_dispatches_idempotency_key (idempotency_key) | 단일 컬럼 유니크. NULL 다건 허용이 실패 이력 공존의 전제(판단 1) | 0~1행 |
| Q6 | WHERE 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일분 수십 행 |
| Q7 | WHERE 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 DESC | sort=WORK_ARRANGEMENT (FR-30) | 인덱스 없음(의도) — 정렬 키가 조인 상대 테이블(job_posting_match_results)에 있어 단일 인덱스로 커버 불가 | 회사당 수백 행 정렬은 filesort로 충분. 이 때문에 비정규화(공고 행에 rank 복사)를 하지 않습니다 — 재평가마다 두 테이블을 갱신해야 해 정합 위험이 이득보다 큽니다 | 동일 |
| Q7-c | 위 결과를 ORDER BY deadline_at IS NULL, deadline_at ASC | sort=DEADLINE | 인덱스 없음(의도) | 상시채용(NULL)을 뒤로 보내는 표현식 정렬이라 인덱스가 무의미. 대상 행이 수백이라 filesort로 충분 | 동일 |
| Q8 | WHERE 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행 |
| Q9 | WHERE job_application_id=? ORDER BY round_number | 면접 회차 조회 (FR-46) | uk_job_application_interviews_round (job_application_id, round_number) | 유니크 제약이 조회·정렬을 함께 커버 — 전용 인덱스 불필요 | 지원당 0~4행 |
| Q10 | WHERE job_source_id=? AND run_date >= :from | 소스별 최근 N회 수집 결과 (Operations) | uk_job_posting_collection_runs_source_date (job_source_id, run_date) | 유니크 제약 프리픽스가 그대로 조회 인덱스 — 전용 인덱스 불필요 | 소스당 30행 |
| Q11 | WHERE run_date >= :from (소스 미지정) | 전체 소스 30일 이력 GET /api/operations/collection-runs?days=30 | idx_job_posting_collection_runs_run_date (run_date) | 단일 컬럼 범위. Q10 인덱스는 선두가 job_source_id라 소스 미지정 조회에 쓸 수 없습니다 | 900행(30소스×30일) |
| Q12 | WHERE job_source_id=? | 소스 건강도 단건 조회 | uk_job_source_health_source (job_source_id) | 유니크 제약이 조회를 커버 | 1행 |
| Q13 | WHERE job_posting_id=? | 평가 결과·본문·태그·근무형태 근거 조회 | 각 테이블의 유니크 제약 프리픽스 (uk_..._posting, uk_job_posting_source_tags, uk_job_posting_work_arrangements) | 전부 유니크 선두가 job_posting_id라 추가 인덱스 0개 | 1~5행 |
| Q14 | WHERE deleted_at IS NULL (키워드 4개 테이블 전량 로드) | MatchCriteria.loadCurrent() | 인덱스 없음(의도) | 전체 행이 그룹 10·동의어 50·제외어 10·근무형태 10 수준. 풀스캔이 인덱스 조회보다 빠릅니다 | 80행 |
| Q15 | SELECT MAX(id) FROM match_criteria_revisions | 현재 revision 조회 | PK | InnoDB PK 역방향 1행 조회 | 1행 |
| Q16 | WHERE 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행 |
| Q17 | WHERE 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)만 소수 걸립니다. 그룹별 점 조회도 같은 인덱스가 커버 | 전체 인덱스 스캔 후 중복 그룹만 |
| Q18 | WHERE representative_id = ? | 대표의 대체 출처 조회 (alternateSources, FR-64) self-join | idx_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_postings | INSERT 약 65건(애그리게이터 포함) + UPDATE 수천 건(last_seen_at) + dedup UPDATE 소수(representative_id) | 5 | last_seen_at·consecutive_miss_count가 어떤 인덱스에도 없어 대량 일일 UPDATE가 보조 인덱스를 갱신하지 않습니다. dedup_key는 INSERT 시 1회 계산·불변이라 갱신 없음. representative_id는 인덱스에 있지만 dedup 배치의 재지정은 하루 소수 그룹뿐입니다. 인덱스 갱신은 상태 전이·대표 재지정(일 수십 건)에서만 발생 |
job_posting_collection_runs | INSERT 약 40건 | 1 | 무시 가능 |
notification_dispatches | INSERT 0~수건 + DAILY_DIGEST 1건 | 1 | 무시 가능 |
companies | DISCOVERED 자동 등록 일 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년 후 크기(데이터+인덱스) | 비고 |
|---|---|---|---|---|---|
companies | 20 | +약 2,000 | 약 6,000 | 약 2MB | 대부분 DISCOVERED 자동 등록. 인덱스 2종 |
job_sources | 40 | +6 | 60 | < 1MB | 회사 종속 30 + 애그리게이터 10 |
job_postings | 16,000(시딩) | +41,000 | 약 140,000 | 약 100MB | 행 약 720B(dedup_key·representative_id 추가) + 인덱스 5종 |
job_posting_descriptions | 16,000 | +41,000 | 140,000 | 약 960MB | 전체 용량의 약 88% — 7KB × 140,000 |
job_posting_source_tags | 1,600 | +1,300 | 5,500 | 약 1MB | Greenhouse 계열만 태그 보유(애그리게이터는 구조화 태그 없음) |
job_posting_collection_runs | 0 | +14,600 | 약 44,000 | 약 11MB | 40소스 × 365일 |
job_source_health | 40 | +6 | 60 | < 1MB | 소스당 1행 |
job_keyword_groups | 5 | +3 | 14 | < 1MB | — |
job_keyword_synonyms | 20 | +12 | 56 | < 1MB | — |
job_exclusion_keywords | 5 | +3 | 14 | < 1MB | — |
work_arrangement_keywords | 5 | +2 | 11 | < 1MB | — |
match_criteria_revisions | 0 | +30 | 90 | < 1MB | 기준 변경 이력 |
job_posting_match_results | 16,000 | +41,000 | 140,000 | 약 32MB | 공고당 1건(매칭 실패 포함) |
job_posting_matched_keyword_groups | 2,400 | +6,200 | 21,000 | 약 3MB | 매칭 15% × 그룹 1.2개 |
job_posting_work_arrangements | 4,800 | +12,300 | 42,000 | 약 10MB | 근거 발견률 30% × 키워드 1.1개 |
job_applications | 0 | +40 | 120 | < 1MB | 1인 지원 |
job_application_status_histories | 0 | +160 | 480 | < 1MB | 지원당 평균 4건(생성 이력 포함) |
job_application_interviews | 0 | +50 | 150 | < 1MB | 지원당 평균 1.2회차 |
notification_dispatches | 0 | +약 1,000 | 약 3,000 | 약 1MB | 관심 회사 개별 + DAILY_DIGEST 365/년 + 실패·고장 |
feature_flags | 4 | +1 | 7 | < 1MB | 애그리게이터·dedup 플래그 |
| 합계 | — | — | — | 약 1.1GB | 본문 960MB가 지배. 5년 후 약 1.7GB |
판정 (애그리게이터 6종 P0에서도 파티셔닝·샤딩 불필요)
- 최대 행 수 테이블이
job_postings약 140,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만 행 또는 200MB | run_date < CURRENT_DATE - INTERVAL 400 DAY 를 PK 범위 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)
| 단계 | 내용 | 락 영향 | 롤백 지점 |
|---|---|---|---|
| 0 | MySQL 8.0 컨테이너 기동 | — | 컨테이너 삭제 |
| 1 | V1 단일 마이그레이션: 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 | 스키마 → 코드 → 배치 → 스키마 | ⑤의 MODIFY는 ALGORITHM=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 도메인 모델 필드 ↔ 컬럼 대응
| 도메인 클래스 | 필드/개념 | 대응 |
|---|---|---|
Company | name, registrationType, companyOrigin, isAutoCollectable(), isWatched(), promoteToWatched() | companies.name, registration_type, company_origin |
CompanyOrigin | WATCHED / DISCOVERED | companies.company_origin |
JobSource | platform, sourceType, sourceSlug, searchCriteria(category·keyword), baseUrl, isSeeded(), disable() | job_sources.* (+source_type·search_category_code·search_keyword, company_id nullable) |
JobPosting | origin, 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 포함) |
RawJobPosting | structuredTags, descriptionBody | job_posting_source_tags, job_posting_descriptions (TDD ERD에 없던 것을 신설) |
JobPostingCollectionRun | runDate, status, counts, failure | job_posting_collection_runs 전 컬럼 |
JobSourceHealth | consecutiveAbnormalDays, brokenSince, failureEpisode | job_source_health 전 컬럼 |
MatchCriteria | 그룹·동의어·제외어·근무형태 키워드 + revision | 키워드 4테이블 + match_criteria_revisions |
NormalizedKeyword | of(raw) 결과 | normalized_keyword 컬럼(캐시) |
JobPostingMatchResult | matched, excludedBy, criteriaRevision, topConfidence, matchedGroups | job_posting_match_results + job_posting_matched_keyword_groups (후자 신설) |
WorkArrangementEvidence | keyword, evidenceStage, confidence, snippet | job_posting_work_arrangements 전 컬럼 |
Application | status, appliedAt, rejectedAtStage | job_applications |
ApplicationStatusHistory | previous, next, transitedAt, memo | job_application_status_histories |
Interview | roundNumber, label, scheduledAt, result, memo | job_application_interviews |
NotificationDispatch | targetType(+DAILY_DIGEST), targetId, type(+DAILY_DIGEST), sequence, idempotencyKey, status, attemptCount | notification_dispatches |
NotificationType | NEW_JOB_POSTING / SOURCE_FAILURE / DAILY_DIGEST | notification_dispatches.notification_type |
FeatureFlagGateway | flagKey → 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 |
| 도메인 기반 명명 | ✅ applications→job_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 / interviews 는 private-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 RawJobPosting이 structuredTags·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 서술 | 이 문서의 보강 |
|---|---|---|---|
| 8 | job_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 레벨로 성립합니다 |
| 9 | dedup_key 인덱스 | ”idx(dedup_key) 추가” | 단일 컬럼 idx_job_postings_dedup_key로 확정(Q17). 고카디널리티라 GROUP BY·점 조회 모두 커버. 유니크가 아님을 명시(같은 키 다건이 정상) |
| 10 | representative_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 |
| 3 | job_postings.company_id 정합 | 소스가 다른 회사로 재매핑되는 시나리오는 요구사항에 없습니다. 발생하면 공고의 company_id도 함께 갱신하는 절차가 필요합니다 | 회사-소스 재매핑 기능이 생길 때 |
| 4 | 알림 메시지 본문 전문 보관 | message_summary(300자)만 보관합니다. 실패 이력에서 “정확히 어떤 메시지였는지” 전문이 필요해지면 TEXT 컬럼을 nullable 추가(INSTANT DDL) | 운영 중 필요 관측 시 |
| 5 | search_keyword 빈 문자열 규칙의 대안 | 애그리게이터 유니크를 위해 '' 규칙을 채택했으나(판단 7), 구현에서 이 규칙 준수가 부담되면 단일 파생 컬럼 source_unique_key("CB:.."/"AG:..") + 유니크 1개로 전환 가능. dedup_key와 동일 패턴이라 일관적 | BE-17 구현 중 '' 규칙이 실수 유발한다고 판단되면 |
| 6 | dedup 배치가 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곳 |