[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을 보장하기 위해 필요한 설정 키와 의존성을 이 티켓에서 미리 전부 선언합니다.
포함 범위:
- 빌드·런타임 기반 — Gradle Kotlin DSL 단일 모듈, Spring Boot 3.x + Kotlin, JPA, QueryDSL, Flyway, Jsoup(인크루트 HTML), hypersistence-utils(JSON 타입), Spring Retry, Kotest + MockK + Testcontainers(MySQL). 후행 티켓이 의존성을 추가하지 않아도 되도록 여기서 전부 선언합니다.
- 패키지 레이아웃 —
presentation / application / domain / infrastructure4레이어 × 컨텍스트 6개(company·posting·matching·application·notification·operation) 디렉토리와domain/common을 생성합니다. 레이어 의존 방향(presentation → application → domain ← infrastructure)을 아키텍처 테스트로 강제합니다. - 스키마 베이스라인(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 지적 — 위반하면 기능이 조용히 깨집니다)
| # | 규칙 | 위반 시 결과 |
|---|---|---|
| 1 | notification_dispatches.idempotency_key는 DEFAULT '' 금지, NOT NULL 금지. nullable VARCHAR + 단일 컬럼 유니크 | 빈 문자열은 NULL과 달라 유니크 제약이 걸립니다 → 두 번째 실패 레코드가 거부되어 실패 이력 보존과 재발송이 동시에 깨집니다 |
| 1-b | 반대 규칙 주의 — 애그리게이터 job_sources.search_keyword는 NULL이 아니라 빈 문자열 '' 저장. 유니크 제약 unique(platform, search_category_code, search_keyword)에서 NULL은 서로 다른 값으로 취급돼 중복 등록이 DB에서 안 막힙니다. 키워드 미지정이면 ''. (idempotency_key와 정반대 — 저장 코드에서 혼동 금지) | NULL이면 같은 (플랫폼·카테고리·무키워드) 소스가 여러 번 등록됨 |
| 2 | job_posting_collection_runs.run_date는 DATE이며 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=NONE은 ALTER TABLE 전용이라 여기서는 쓰지 않습니다 | 문법 오류로 마이그레이션 실패 |
| 4 | 롤백 DDL(테이블 20개 역순 DROP)을 파일 상단 주석에 명시 + feature_flags 시드에 “대상 2행, 운영 경합 없음” Flyway DML 예외 근거 주석 | 컨벤션 위반(백필 DML 금지 규칙의 예외 근거 누락) |
| 5 | job_posting_match_results.work_arrangement_sort_rank INT를 top_confidence VARCHAR와 별도로 보유 | VARCHAR 정렬은 알파벳 순(CONFIRMED < INFERRED < LIKELY)이라 도메인 순서(CONFIRMED < LIKELY < 그 외)와 어긋나 FR-30 정렬이 틀립니다 |
추가로 dispatch_status='SENT'인데 idempotency_key가 NULL인 레코드를 만들지 않는 것은 애플리케이션 책임입니다(BE-05·BE-16) — 멱등 판정이 키로만 조회하므로 키 없는 성공 레코드는 중복 발송을 유발합니다.
4. 공통 계약 — domain/common에 DomainEvent, 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_flags에 posting.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_id와source_job_id가 모두 NULL인 수동 공고 행은 여러 건 공존한다notification_dispatches의idempotency_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_date가DATE타입이고 나머지 시각 컬럼이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이라 두 스케줄 작업이 동시에 실행되지 않는다