최종 마이그레이션 버전 = V55. 신규 MySQL 마이그레이션은 V56 이후를 사용합니다. 단 동시 dev 머지 레이스로 Flyway 버전이 충돌한 이력이 있어(MEMORY: migration-number-collision), implementer는 머지 직전 버전 중복을 재검사합니다.
slots.facility_id는 VARCHAR(255) (Mongo facilities._id를 논리 참조). 신규 programs.facility_id도 동일 타입으로 맞춥니다.
저장소 선택 판단
기본 MySQL. Mongo는 private-mongodb-convention의 채택 근거표에 해당할 때만 채택합니다.
컬럼 추가(nullable) → 단일 마이그레이션. NULL=전역 게시글(FR-4 기존 동작 불변)
sport_category
VARCHAR(30)
NULL
컬럼 추가(nullable) → 단일 마이그레이션. communities.sport_category와 동일 타입. NULL=미지정
global_listed
TINYINT(1)
NN DEFAULT 1
BOOLEAN 금지→TINYINT(1). DEFAULT 1이 기존 전역 게시글 100%에 적용(전역=노출=정상값) → 단일 마이그레이션 안전 (아래 무중단 §근거)
global_listed는 not-null이지만 3단계 분리 불요: DEFAULT 1이 모든 기존 행에 유효한 정상값이므로(private-db-schema-convention의 “채우기” 단계가 DEFAULT로 원자 충족). 3단계는 “유효 기본값이 없을 때” 규칙 — 여기선 해당 없음.
6. slots 컬럼 추가 (기존 테이블 — 하위호환)
컬럼
타입
NULL
하위호환 판단
program_id
BIGINT
NULL
컬럼 추가(nullable) → 단일 마이그레이션. NULL=일반 슬롯, non-null=program 회차
status
VARCHAR(20)
NULL DEFAULT ‘OPEN’
TDD/과제 SSOT 요구대로 nullable DEFAULT ‘OPEN’. DEFAULT가 기존 행 100%를 ‘OPEN’으로 채움 → NULL은 실제로 발생 안 함. 코드는 NULL을 OPEN으로 방어적 해석. (implementer가 NOT NULL 승격 원하면 별도 판단 — 데이터상 안전)
slots UNIQUE 호환 확인 (과제 필수 항목)
기존 UNIQUE uq_slots_facility_date_time_range(facility_id, date, time_range, deleted_at)는 변경하지 않습니다.
자동 슬롯 멱등 재생성 호환: 자동 생성 슬롯은 program_id IS NULL. 멱등 재생성은 윈도우 내 기존 (facility_id, date, time_range) Set 조회 후 미존재분만 INSERT → 기존 UNIQUE가 중복을 차단(충돌 0). program_id/status 추가는 UNIQUE 키 구성에 없으므로 재생성 로직에 영향 없음. SlotGenerationDomainService.generate의 diff 계산 정합.
program 회차와의 충돌(문서화된 제약): program 세션 슬롯도 같은 UNIQUE 네임스페이스를 씁니다. 따라서 program 세션은 기존 일반 슬롯과 정확히 동일한 (facility_id, date, time_range)를 공유할 수 없습니다(다중 룸/코트 개념 부재). 충돌 시 INSERT가 UNIQUE 위반으로 실패 → 소유자가 다른 시간대 선택. program_id를 UNIQUE에 추가하는 대안은 불가: nullable program_id는 MySQL UNIQUE에서 NULL≠NULL로 취급되어 일반 슬롯 중복 멱등이 깨집니다. → 스키마 변경 없이 제약을 문서화하고 유지. (Open Question)
쿼리 패턴 → 인덱스 매핑
인덱스는 대상 쿼리가 근거입니다. 아래 표에 없는 인덱스는 만들지 않습니다(쓰기 비용). 인덱스 추가는 전부 ALGORITHM=INPLACE, LOCK=NONE 명시.
PRD A — posts
쿼리 ID
쿼리 패턴 (WHERE / ORDER BY)
인덱스 결정
컬럼 순서 근거
A-Q1 전역 피드 기본
deleted_at IS NULL AND global_listed=1 ORDER BY created_at DESC (paged, 최빈 hot path)
신규 idx_posts_global_listed_deleted_at_created_at(global_listed, deleted_at, created_at)
global_listed=1 equality → deleted_at IS NULL equality → created_at 정렬. 저카디널리티 선두지만 쿼리가 항상 =1 고정이라 filesort 제거가 목적
A-Q2 모임 게시글 목록
community_id=? AND deleted_at IS NULL [AND sport_category=?] ORDER BY created_at DESC (GET /communities/{id}/posts)
신규 idx_posts_community_id_deleted_at_created_at(community_id, deleted_at, created_at)
인덱스: operating_hours/holidays 대상 쿼리 없음(항상 facility 문서와 함께 로드) → 신규 인덱스 안 만듦. 기존 idx_gu_type·idx_sido_sigungu_type·2dsphere 유지.
파괴적 변경 주의: validator를 strict로 올리거나 operating_hours를 required로 강화하는 것은 파괴적 → 하지 않음(사용자 확인 대상).
용량 추정·보존 정책
개인 프로젝트 상시 RPS<10 전제(진단 §2). 1년 후 추정:
테이블
행 폭(대략)
증가율 가정
1년 후 행수
1년 후 크기(대략)
보존/아카이빙
recruitments
~0.3KB
월 50건
~600
<1MB
무한 성장 아님. 보존 정책 불요
applications
~0.1KB
모집당 ~20 × 600
~12,000
~2MB
소량. 불요
programs
~0.3KB
시설당 수개
~수백
<1MB
불요
community_bookings
~0.1KB
소량
~수천
<1MB
불요
posts (증분)
컬럼 3개 추가
—
기존 대비 미미
+수 byte/행
불요
slots
~0.2KB
자동생성: 시설 N × 슬롯/일 × 14일 롤링
시설 100 × 10슬롯/일 × 365 ≈ 365,000/년
~70MB/년
무한 성장 — 정책 필요
slots 보존 정책 (무한 성장 대상)
자동 생성이 매일 향후 14일치를 롤링 생성 → 과거 날짜 슬롯이 무한 누적됩니다.
MySQL은 TTL 인덱스가 없으므로: 주기 배치 소프트 삭제/아카이빙 — 활동 완료 후 일정 기간(예: 90일) 지난 date < now-90d 슬롯을 soft-delete(deleted_at 세팅). bookings 참조 무결성상 하드 삭제 금지. 필요 시 별도 slots_archive 테이블로 이관은 규모 도달 시(YAGNI, 현재 미채택).
이 정책은 자동생성 스케줄러와 짝을 이루는 별도 정리 배치로 후속 설계(현 과제 범위는 스키마까지 — Open Question).
Mongo holidays 보존
holidays 배열에 과거 날짜가 누적될 수 있음 → 임베드 배열 상한 관리: 소유자 UI/배치에서 지난 연도 휴무 정리 권장(무한 성장 방지). 상한이 낮아 긴급도 낮음.
무중단 마이그레이션 (expand-contract)
전 변경이 additive(신규 테이블·nullable 컬럼·유효 DEFAULT·validator moderate)라 expand-contract 무중단. 배포 순서: 스키마 먼저 → 코드(플래그 OFF) → 플래그 점진 ON — 두 senior-be Release Scenario와 정합. 락 영향은 변경마다 명시.
단계
작업
락/온라인 DDL
롤백 지점(역방향)
S1. MySQL 신규 테이블 (V56~)
recruitments·applications·programs·community_bookings CREATE (audit 6 + 위 인덱스·UNIQUE 포함)
신규 테이블 CREATE → 기존 테이블 무락
DROP TABLE ... (코드 미배포라 안전)
S2. posts 컬럼 추가
community_id(NULL)·sport_category(NULL)·global_listed(TINYINT NN DEFAULT 1)
MySQL 8 INSTANT ADD COLUMN(DEFAULT 포함) → 무락. DEFAULT 1이 기존 행 원자 채움
3컬럼 DROP COLUMN
S3. posts 인덱스 추가
A-Q1/A-Q2/A-Q3 인덱스 3종
ALGORITHM=INPLACE, LOCK=NONE 명시
DROP INDEX
S4. slots 컬럼 추가
program_id(NULL)·status(NULL DEFAULT ‘OPEN’)
INSTANT ADD COLUMN → 무락. DEFAULT ‘OPEN’ 기존 행 채움. UNIQUE 무변경(멱등 호환 유지)
컨벤션 위반 점검: FK 0건(전부 논리 참조), ENUM 0건(status/visibility VARCHAR), BOOLEAN 0건(global_listed TINYINT(1)), JSON 컬럼 0건, 시간 전부 DATETIME(6), audit 6컬럼 전 신규 테이블 포함, PK=id — 위반 없음.
Open Questions
slots 과거 슬롯 정리 배치: 무한 성장 방지 소프트삭제 배치는 후속 설계(스키마는 준비됨).
program 세션 ↔ 일반 슬롯 UNIQUE 충돌: 동일 (facility,date,time_range) 공유 불가(다중 룸 개념 부재). 룸/코트 개념 도입 시 UNIQUE 재설계 필요.
slots.status NOT NULL 승격: 현재 nullable DEFAULT ‘OPEN’(과제 SSOT). DEFAULT로 NULL 미발생 — implementer가 NOT NULL 강제 원하면 데이터상 안전하게 승격 가능.