[BE-01] 프로젝트 스캐폴딩 · 스키마 베이스라인 · 공통 계약

작업 내용 (설계 의도)

근거 TDD: /Users/biuea/Desktop/dpdpdndn/프로젝트/공고알림앱/20260722-타깃-공고-알림-및-지원-히스토리-tdd.md

변경 사항

대상 레포는 README와 마커만 있는 빈 레포입니다. 후행 티켓 20건 전부가 이 티켓의 산출물(빌드 설정·패키지 레이아웃·테이블·공통 계약)에 의존하므로, 연관된 병목을 하나의 선행 티켓으로 묶어 wave 1을 단일 티켓으로 끝냅니다. 이후 wave에서는 이 티켓이 만든 파일(build.gradle.kts, application.yml, Flyway 마이그레이션)을 수정하지 않는 것이 규칙입니다 — Single Writer per File을 보장하기 위해 필요한 설정 키와 의존성을 이 티켓에서 미리 전부 선언합니다.

포함 범위:

  1. 빌드·런타임 기반 — Gradle Kotlin DSL 단일 모듈, Spring Boot 3.x + Kotlin, JPA, QueryDSL, Flyway, Jsoup(인크루트 HTML), hypersistence-utils(JSON 타입), Spring Retry, Kotest + MockK + Testcontainers(MySQL). 후행 티켓이 의존성을 추가하지 않아도 되도록 여기서 전부 선언합니다.
  2. 패키지 레이아웃presentation / application / domain / infrastructure 4레이어 × 컨텍스트 6개(company·posting·matching·application·notification·operation) 디렉토리와 domain/common을 생성합니다. 레이어 의존 방향(presentation → application → domain ← infrastructure)을 아키텍처 테스트로 강제합니다.
  3. 스키마 베이스라인(V1)DB 설계 문서 /Users/biuea/Desktop/dpdpdndn/프로젝트/공고알림앱/20260722-공고알림앱-design-db.md가 컬럼·인덱스의 SSOT입니다. 그 문서의 테이블 정의 표(20개 테이블 / 유니크 20 + 조회 인덱스 6)를 그대로 옮겨 하나의 Flyway 마이그레이션으로 작성합니다. 후행 티켓에서 DDL 추가는 없습니다. 컨벤션: FK 제약 없음, ENUM 대신 VARCHAR, BOOLEAN 대신 TINYINT(1), 날짜 DATETIME(6), 전 컬럼 COMMENT.

V1 DDL 작성 시 반드시 지킬 5가지 (DBA 지적 — 위반하면 기능이 조용히 깨집니다)

#규칙위반 시 결과
1notification_dispatches.idempotency_keyDEFAULT '' 금지, NOT NULL 금지. nullable VARCHAR + 단일 컬럼 유니크빈 문자열은 NULL과 달라 유니크 제약이 걸립니다 → 두 번째 실패 레코드가 거부되어 실패 이력 보존과 재발송이 동시에 깨집니다
1-b반대 규칙 주의 — 애그리게이터 job_sources.search_keywordNULL이 아니라 빈 문자열 '' 저장. 유니크 제약 unique(platform, search_category_code, search_keyword)에서 NULL은 서로 다른 값으로 취급돼 중복 등록이 DB에서 안 막힙니다. 키워드 미지정이면 ''. (idempotency_key와 정반대 — 저장 코드에서 혼동 금지)NULL이면 같은 (플랫폼·카테고리·무키워드) 소스가 여러 번 등록됨
2job_posting_collection_runs.run_dateDATE이며 KST 업무 일자로 계산해 넣습니다. 나머지 전 컬럼은 UTC 저장(spring.jpa.properties.hibernate.jdbc.time_zone=UTC)UTC 날짜로 넣으면 KST 09:00 이전 실행분이 전날로 기록돼 unique(job_source_id, run_date)하루 1회 보장이 깨집니다
3인덱스는 CREATE TABLE 인라인 선언. ALGORITHM=INPLACE, LOCK=NONEALTER TABLE 전용이라 여기서는 쓰지 않습니다문법 오류로 마이그레이션 실패
4롤백 DDL(테이블 20개 역순 DROP)을 파일 상단 주석에 명시 + feature_flags 시드에 “대상 2행, 운영 경합 없음” Flyway DML 예외 근거 주석컨벤션 위반(백필 DML 금지 규칙의 예외 근거 누락)
5job_posting_match_results.work_arrangement_sort_rank INTtop_confidence VARCHAR와 별도로 보유VARCHAR 정렬은 알파벳 순(CONFIRMED < INFERRED < LIKELY)이라 도메인 순서(CONFIRMED < LIKELY < 그 외)와 어긋나 FR-30 정렬이 틀립니다

추가로 dispatch_status='SENT'인데 idempotency_key가 NULL인 레코드를 만들지 않는 것은 애플리케이션 책임입니다(BE-05·BE-16) — 멱등 판정이 키로만 조회하므로 키 없는 성공 레코드는 중복 발송을 유발합니다. 4. 공통 계약domain/commonDomainEvent, DomainEventPublisher(interface), FeatureFlagGateway(interface)를 정의하고 infrastructure에 SpringDomainEventPublisher, FeatureFlagGatewayImpl(DB 조회)을 구현합니다. 시간 타입은 ZonedDateTime으로 통일하고 Jackson·JPA 컨버터를 설정합니다. 5. 공통 인프라 — 공용 RestClient 빌더 빈(connect 5s / read 10s), 전역 예외 핸들러, 공통 응답 규약, @EnableScheduling + TaskScheduler 풀 크기 1(배치 직렬 실행), @EnableRetry. 6. 설정 키 선언 — 6개 배치 cron(0 0 0 수집 / 0 10 0 애그리게이터 / 0 30 0 마감 / 0 30 8 dedup / 0 50 8 평가 / 0 0 9 발송, zone Asia/Seoul), 디스코드 웹훅 URL, 소스별 요청 지연·상세 상한·User-Agent, 어댑터 base URL을 application.yml에 placeholder로 선언합니다. 7. 컨테이너docker-compose.yml(dev)과 docker-compose.prod.yml(prod) 이원화, Dockerfile, MySQL 8.0 서비스. 8. 피처 플래그 시드feature_flagsposting.auto-close=false, notification.discord-dispatch=false, posting.cross-source-dedup=false, aggregator.collection=false 4건. 소수 정적 시드이므로 Flyway DML 예외 사유(“대상 4행, 운영 경합 없음”)를 파일 주석에 남깁니다.

스키마 SSOT는 design-db 문서입니다. 애그리게이터 편입으로 그 문서에 반영되는 컬럼(BE-01 V1이 그대로 반영): companies.company_origin, job_sources.source_type·search_category_code·search_keyword(+company_id nullable화), job_postings.representative_id(자기참조)·dedup_key(+idx(dedup_key)), 코드값에 SARAMIN·JUMPIT·WANTED·REMEMBER·JOBKOREA·SURFIT(회색지대 4종 P0)·FIELD_HASH·DAILY_DIGEST·WATCHED/DISCOVERED·COMPANY_BOUND/AGGREGATOR 추가. 신규 테이블은 없습니다(중복 그룹은 자기참조 컬럼). 애그리게이터 4종 추가는 platform 코드값 확장뿐이라 스키마 구조 변경이 없습니다(SPI 일반화 효과).

롤백: 최초 배포이므로 마이그레이션 실패 시 스키마 드롭 후 재적용(데이터 0건). 역방향 DDL을 마이그레이션 상단 주석에 명시합니다.

의존

  • 없음 (wave 1)

다이어그램

처리 흐름

sequenceDiagram
    participant Dev as 개발자
    participant G as Gradle
    participant App as SpringApplication
    participant F as Flyway
    participant DB as MySQL 8.0
    Dev->>G: ./gradlew build
    G-->>Dev: BUILD SUCCESSFUL
    Dev->>App: docker compose up -d
    App->>F: migrate()
    F->>DB: V1 baseline DDL + feature_flags 시드
    DB-->>F: SUCCESS
    App-->>Dev: /actuator/health 200

클래스 의존

flowchart LR
    subgraph Common["domain/common"]
        Event[DomainEvent]
        Publisher[DomainEventPublisher]
        Flag[FeatureFlagGateway]
    end
    subgraph Infra["infrastructure/common"]
        SpringPub[SpringDomainEventPublisher]
        FlagImpl[FeatureFlagGatewayImpl]
        Rest[RestClient 빌더]
    end
    subgraph Config["config"]
        Sched[SchedulingConfig]
        Ex[GlobalExceptionHandler]
    end
    SpringPub -.->|implements| Publisher
    FlagImpl -.->|implements| Flag
    Publisher --> Event
    FlagImpl --> Rest
    Sched --> SpringPub
    Ex --> SpringPub

테스트 케이스

  • 애플리케이션 컨텍스트가 정상 기동하고 /actuator/health가 200을 반환한다
  • Testcontainers MySQL 8.0에 Flyway V1이 적용되어 정의된 20개 테이블이 생성된다
  • job_postings에 같은 (job_source_id, source_job_id)를 두 번 삽입하면 유니크 제약 위반이 발생한다
  • job_source_idsource_job_id가 모두 NULL인 수동 공고 행은 여러 건 공존한다
  • notification_dispatchesidempotency_key가 NULL인 행은 여러 건 삽입되지만 같은 값은 두 번 삽입되지 않는다
  • idempotency_key 컬럼에 DEFAULT가 없고 NULL이 허용된다 (스키마 메타데이터 검증)
  • job_posting_descriptions·job_posting_source_tags·job_posting_matched_keyword_groups가 생성되고 각 유니크 제약이 동작한다
  • job_applications·job_application_status_histories·job_application_interviews 이름으로 테이블이 생성된다
  • job_application_status_histories.previous_status가 NULL을 허용한다 (생성 이력 적재 전제)
  • job_posting_collection_runs.run_dateDATE 타입이고 나머지 시각 컬럼이 DATETIME(6)이다
  • FeatureFlagGatewayImpl.isEnabled("posting.auto-close")가 시드값 false를 반환하고, DB UPDATE 후 재기동 없이 true를 반환한다
  • 피처 플래그 4종(posting.auto-close·notification.discord-dispatch·posting.cross-source-dedup·aggregator.collection)이 모두 시드된다
  • job_postings.representative_id(자기참조 nullable, FK 제약 없음·어떤 유니크에도 미포함)와 dedup_key(비유니크 idx), job_sources.source_type·company_id(nullable) 컬럼이 생성된다
  • 애그리게이터 소스에 search_keyword=''(빈 문자열)로 같은 (platform, search_category_code, '')를 두 번 삽입하면 유니크 제약으로 거부된다
  • SpringDomainEventPublisher.publishAll()이 등록된 리스너에게 이벤트를 전달한다
  • 아키텍처 테스트: domain 패키지가 infrastructure를 import하지 않고, 도메인 패키지 간 교차 참조가 0건이다
  • TaskScheduler 풀 크기가 1이라 두 스케줄 작업이 동시에 실행되지 않는다