상품·주문 공유 상위 컨텍스트 DB 설계 (design-db)
Background
근거 문서: 20260708-상품주문-공유상위컨텍스트-prd.md(verdict PASS, v2), 20260708-상품주문-공유상위컨텍스트-tdd.md. 진단 커밋 808d2101(= 현재 HEAD, origin/main 0/0 동기화). 레포 최신 마이그레이션 = V60(실측: backend/src/main/resources/db/migration/V60__add_slots_program_index.sql).
이 문서는 이번 과제의 유일한 DB 스키마 변경인 products.seller_type 컬럼 추가를 설계한다. catalog/order 통합 조회는 **읽기 전용 파사드(API Composition)**로 신규 테이블·뷰·프로젝션을 만들지 않으므로(TDD 방안 1-A 채택, 1-B CQRS·1-C UNION 뷰 미채택), DB 관점 산출물은 seller_type 하나에 집중한다.
책임 경계: 이 문서는 설계까지다. DDL 작성·실행·검증은 private-mysql-implementer가 DB-01/DB-02 티켓으로 수행한다.
Overview
| 항목 | 내용 |
|---|---|
| 변경 대상 | products 테이블 1개 |
| 변경 내용 | seller_type VARCHAR(10) 컬럼 1개 추가 (expand-contract) |
| 신규 테이블 | 없음 |
| 신규 인덱스 | 없음 (seller_type 인덱스 미생성 — 근거 §4) |
| 신규 저장소 | 없음 (전부 MySQL) |
| 마이그레이션 | V61(expand: nullable 컬럼 추가 — DDL만) → V62(contract: NOT NULL 전환). 백필은 마이그레이션이 아니라 애플리케이션 Spring Batch 청크로 분리 |
저장소 선택
| 데이터 단위 | 저장소 | 채택 사유 |
|---|---|---|
products.seller_type | MySQL | 기존 products(MySQL, V6__create_products_orders.sql)에 컬럼을 추가하는 국소 변경. 관계·트랜잭션·기존 정합이 이미 MySQL에 있으므로 동일 저장소가 유일한 선택. MongoDB 채택 근거(유동 스키마·대량 로그·중첩 구조) 어디에도 해당하지 않음 |
| catalog/order 통합 조회 | 저장소 없음 (기존 MySQL 테이블 읽기 조합) | API Composition — 요청 시점에 각 코어 도메인 DomainService를 병렬 호출해 in-memory join. 통합용 신규 테이블·UNION 뷰·CQRS 프로젝션을 만들지 않음(TDD 방안 1-A) |
결론: 이번 과제 신규 저장소 없음, 전부 MySQL. MongoDB 병용 없음.
Detail Design
AS-IS — products 현재 스키마 (실제 마이그레이션 재구성)
V6__create_products_orders.sql(생성) + V19__add_event_product_owner_id_and_b2b_seeds.sql(owner_id 추가)로 재구성한 실제 현재 스키마.
| 컬럼 | 타입 | NULL | 기타 | 출처 |
|---|---|---|---|---|
id | BIGINT AUTO_INCREMENT | NOT NULL | PK | V6 |
name | VARCHAR(255) | NOT NULL | V6 | |
category | VARCHAR(50) | NOT NULL | 물리 카테고리(EQUIPMENT/APPAREL/…) | V6 |
price | DECIMAL(12,2) | NOT NULL | 판매가(KRW) | V6 |
description | TEXT | NULL | V6 | |
image_url | VARCHAR(2048) | NULL | V6 | |
status | VARCHAR(20) | NOT NULL | ProductStatus(ACTIVE/INACTIVE) | V6 |
created_at | DATETIME(6) | NOT NULL | V6 | |
created_by | BIGINT | NULL | V6 | |
updated_at | DATETIME(6) | NOT NULL | V6 | |
updated_by | BIGINT | NULL | V6 | |
deleted_at | DATETIME(6) | NULL | 소프트 삭제 | V6 |
deleted_by | BIGINT | NULL | V6 | |
owner_id | BIGINT | NOT NULL | 등록 판매자 userId | V19 |
현재 인덱스:
| 인덱스 | 컬럼 | 용도 |
|---|---|---|
| PRIMARY | id | PK |
idx_products_category_status_price | (category, status, price) | 기존 goods 검색(category 선두) |
idx_products_deleted_at | (deleted_at) | 소프트 삭제 필터 |
idx_products_owner_id | (owner_id) | 판매자 본인 상품 조회(findByOwnerId) |
주의: 기존
products컬럼에는COMMENT가 없다(V6/V19가 COMMENT 규칙 이전에 작성됨). 이번 과제는 신규 컬럼에만 COMMENT를 부여하고, 기존 컬럼에 COMMENT를 소급 추가하지 않는다 — 기존 컬럼 MODIFY는 불필요한 테이블 재작성·락을 유발하므로 범위 밖.
TO-BE — seller_type 컬럼 추가
| 컬럼 | 타입 | NULL(V61→V62) | COMMENT | 근거 |
|---|---|---|---|---|
seller_type | VARCHAR(10) | NULL → NOT NULL | '판매자 유형: B2C(개인/중고) / B2B(파트너/브랜드)' | ENUM 금지 → VARCHAR(컨벤션). SellerType enum 값(B2C/B2B)은 최장 3자, 여유 포함 10 |
- 값 도메인:
B2C(일반 JWT 등록) /B2B(파트너 API Key 인증 경유 등록). enum 매핑은domain.goods.vo.SellerType(BE 소유, TDD). - 기본값: 코드가 등록 시점에 명시적으로 채우므로 컬럼 DEFAULT를 두지 않는다(no-default 원칙과 정합). 기존 행 백필은 애플리케이션 Spring Batch 청크로 처리(§Release Scenario) — 마이그레이션 내 대량 UPDATE 금지(§원칙).
- 불변: 등록 시 1회 결정 후 소급 재판별 없음(PRD Non-Goal). 상태 전이·쓰기 경합 없음.
원칙 — 마이그레이션 내 대량 DML 금지 (운영)
스키마 마이그레이션(Flyway V61/V62)은 구조 변경(DDL)만 담는다. 기존 행을 채우는 대량 UPDATE는 products 테이블 전체를 장시간 잠그거나(단일 문장) 마이그레이션 실행 시간을 예측 불가하게 만들어 운영 금지다. 백필은 **애플리케이션 Spring Batch로 청크 커밋(500~1000행/chunk)**해 각 청크가 짧은 행 락만 잡고 즉시 커밋하도록 한다 — 테이블 전체 락 회피. 소량 시드 이외의 데이터 이행은 항상 배치로 분리한다.
ERD (요약)
erDiagram products { bigint id PK varchar name varchar category decimal price varchar status varchar seller_type "신규 B2C/B2B expand-contract" bigint owner_id datetime created_at datetime deleted_at }
- catalog/order는 기존 테이블(
products·limited_drops·events·programs·recruitments/bookings·goods_orders·ticket_orders·applications)을 읽기만 한다 — 관계·FK 신설 없음(FK 컬럼 금지 컨벤션과도 정합, 참조는 애플리케이션 레벨).
쿼리 패턴 → 인덱스 매핑
catalog PRODUCT 조회는 기존 ProductCustomRepositoryImpl.search()(infrastructure/goods/mysql/ProductCustomRepositoryImpl.kt:28-55)를 재사용하고, TDD대로 status=ACTIVE + sellerType 필터를 additive로 추가한다.
| ID | 쿼리 (predicate → order) | 발생 지점 | 서빙 인덱스 | seller_type 인덱스 필요? |
|---|---|---|---|---|
| Q1 | WHERE deleted_at IS NULL AND status='ACTIVE' [AND category=?] [AND name LIKE '%kw%'] [AND seller_type=?] ORDER BY created_at DESC LIMIT window | catalog 통합 검색(PRODUCT, FR-1~3) | category 있으면 idx_products_category_status_price 선두 활용, 없으면 필터 스캔 | 불필요 (§4) |
| Q2 | WHERE deleted_at IS NULL AND status='ACTIVE' AND seller_type='B2B' ORDER BY created_at DESC LIMIT window | ”브랜드만 보기”(FR-4, sellerType 단독 필터) | 필터 스캔 | 불필요 (§4) |
| Q3 | WHERE deleted_at IS NULL [AND category=?] [AND name LIKE '%kw%'] [AND price BETWEEN ?] ORDER BY ... | 기존 goods 검색(변경 없음) | idx_products_category_status_price(category 선두) | 무관(기존 유지) |
| Q4 | WHERE owner_id=? AND deleted_at IS NULL ORDER BY created_at DESC | 판매자 본인 상품(findByOwnerId, 변경 없음) | idx_products_owner_id | 무관(기존 유지) |
§4. seller_type 인덱스 판단 — 미생성
DB-01 티켓(“인덱스 추가 없음”)과 정합. 근거:
- 카디널리티 2 + 강한 편향:
seller_type은 B2C/B2B 두 값뿐이고, 백필로 기존 전량이 B2C가 되며 B2B는 파트너 경로 소수다. 저카디널리티 단독 보조 인덱스는 선택도가 낮아 MySQL 옵티마이저가 대부분 채택하지 않는다(B2C 필터는 거의 전체 스캔과 동일). - 잔여(residual) 술어: Q1의 주 접근 술어는
deleted_at IS NULL+status='ACTIVE'+ 선행 와일드카드name LIKE '%kw%'(sargable 아님)다. seller_type은 이미 스캔된 결과를 좁히는 옵션 필터(FR-4, P1)일 뿐 주 접근 경로가 아니다 — 별도 인덱스 이득이 없다. - 개인 프로젝트 규모:
products예상 행 수는 수백~저수천(§용량). 이 규모에서 필터 스캔은 sub-ms이고, 인덱스는 등록·수정마다 쓰기 비용만 추가한다(“근거 없는 인덱스는 만들지 않는다” — 컨벤션·단순함 우선). - 복합 인덱스도 부적합:
(seller_type, created_at)류를 선두 저카디널리티로 만들 근거가 없고, 기존idx_products_category_status_price에 seller_type을 끼워넣는 것은 기존 인덱스 재작성(락·쓰기 비용)을 유발해 배제한다.
재검토 트리거(후속, 실측 기반): ① B2B 비율이 실측상 소수(예: <10%)로 굳어져 seller_type='B2B' 술어가 실제로 선택적이고, ② “브랜드만 보기”가 고빈도 접근 경로가 되며, ③ products 행 수가 수만 이상으로 성장 — 세 조건이 동시에 관측되면 그때 (status, seller_type, created_at) 복합 인덱스를 EXPLAIN 근거와 함께 재설계한다. 지금은 미생성.
catalog Q1/Q2의
ORDER BY created_at DESC정렬을 위한created_at인덱스도 이번엔 만들지 않는다 — 같은 규모 근거 + TDD Open Question(캐시·CQRS 승격은 P95 500ms 실측 후 판단)과 정합. 이번 과제 DB 변경은 seller_type 컬럼 하나로 한정한다.
용량 추정·보존 정책
| 항목 | 추정 | 비고 |
|---|---|---|
products 현재 행 수 | 수백(개인 프로젝트) | soft-delete로 삭제분 잔존 |
| 증가율 | 월 수십 | |
| 1년 후 | 저수천 | |
seller_type 추가 저장량 | 행당 최대 ~10바이트 + 오버헤드 → 총 수십 KB 수준 | VARCHAR(10), 실제 값 3자(B2C/B2B) |
| 신규 인덱스 저장량 | 0 (인덱스 미생성) |
- 보존/아카이빙:
products는 무한 성장 로그성 테이블이 아니고 이미 소프트 삭제(deleted_at)로 관리된다. seller_type 추가로 성장 특성이 바뀌지 않으므로 TTL·아카이빙 정책 신설 불필요. - MongoDB TTL: 해당 없음(Mongo 미사용).
Release Scenario — 무중단 배포 (expand-contract, 5단계)
expand-contract를 5단계로 실행한다. 핵심 변경(운영 지적 반영): 백필을 마이그레이션 내 대량 UPDATE에서 애플리케이션 Spring Batch 청크 백필로 분리한다. 스키마 마이그레이션(V61/V62)은 DDL만 담는다. 배포 순서: 스키마(V61) 먼저 → 듀얼라이트 쓰기 코드 → 배치 백필 → 검증 → 기능 배포 → 스키마(V62).
| 단계 | 작업 | 배포 순서 | 락 영향 | 롤백 지점 |
|---|---|---|---|---|
| 1 (듀얼라이트) | V61 — ADD COLUMN seller_type VARCHAR(10) NULL COMMENT '...' (DDL만, UPDATE 없음) + sellerType 쓰기 코드 배포(신규 등록이 정확한 값 기록, 기존 행은 NULL) | 스키마 먼저 → 쓰기 코드(컬럼은 있으나 조회 미노출) | ADD COLUMN: MySQL 8.0 InnoDB nullable 컬럼 추가는 ALGORITHM=INSTANT(메타데이터만, 테이블 재작성·행 락 없음), LOCK=NONE. 테이블 전체 락 없음 | ALTER TABLE products DROP COLUMN seller_type(nullable이라 데이터 손실 없이 안전) + 쓰기 코드 revert |
| 2 (배치 백필) | Spring Batch 청크 잡 — WHERE seller_type IS NULL 대상을 id 커서로 읽어 B2C 세팅, 500~1000행/chunk 커밋. 멱등(재실행 시 이미 값 있는 행 skip). 스키마 무관 | 1단계 후 실행(수동/트리거) | 청크당 갱신 행에만 짧은 행 락 → 즉시 커밋으로 테이블 전체 락 회피. 온라인 트래픽과 공존 | 잡 중단 안전(멱등, 재실행 이어받기). 롤백 불요(값만 채움) |
| 3 (데이터 검증) | SELECT COUNT(*) FROM products WHERE seller_type IS NULL = 0 확인. 0이 아니면 2단계 재실행 | 2단계 후 | 없음(읽기) | — |
| 4 (기능 배포) | OrderType 공유커널 이관 / auth 마커 / 읽기 파사드(catalog·order) / SecurityConfig matcher. 조회에 sellerType 노출 | 3단계 후 | 없음(코드) | matcher·컨텍스트·코드 revert(스키마 무관, 컬럼 nullable 유지라 안전) |
| 5 (contract) | 배포 후 재검증(NULL=0 게이트) 통과 후 V62 — MODIFY seller_type VARCHAR(10) NOT NULL | 4단계 안정화 후 | MODIFY NULL→NOT NULL: ALGORITHM=INPLACE(테이블 재작성 동반하나 동시 DML 허용), LOCK=NONE. STRICT SQL 모드 + NULL 0건 전제 | MODIFY seller_type VARCHAR(10) NULL(제약 완화, 데이터 무손실) |
- DDL 옵션 명시(구현자 지침): V61
ADD COLUMN→ALGORITHM=INSTANT, LOCK=NONE(무락). V62MODIFY ... NOT NULL→ALGORITHM=INPLACE, LOCK=NONE. 두 마이그레이션 모두 DDL만 담고 대량 DML을 포함하지 않는다. 다운타임 0. - 백필은 배치로: 마이그레이션 내
UPDATE ... WHERE seller_type IS NULL대량 문장을 두지 않는다 — 테이블 전체 락·실행시간 예측 불가 방지(§원칙). 백필 로직·잡 위치·청크 크기 확정은 BE 트랙(구현) 소관이며, DB 설계는 “청크 커밋으로 테이블 전체 락 회피” 제약만 명시한다. - NULL=0 게이트(Operations): V62(5단계)는 3단계·5단계 재검증에서
seller_type IS NULL건수 0을 확인해야만 진행한다. NULL이 남아 있으면 V62 중단하고 배치 재실행. - 경계값(PRD User Scenario 8): 1단계 쓰기 코드가 배치보다 먼저 배포돼 신규 등록 행은 즉시 정확한 값을 가지며, 배치는
WHERE seller_type IS NULL이라 이미 값이 채워진 행을 자동 제외 — 백필 중 신규 등록 경합 안전.
마이그레이션 번호 경합 노트 (메모리 교훈 반영)
- 레포 최신 =
V60, 신규 =V61(expand)·V62(contract). BE TDD·DB 티켓과 동일 번호. - 동시 dev 머지 레이스로 Flyway 버전 중복 가능(과거 사고 이력).
private-mysql-implementer는 실제 파일 작성·머지 직전에backend/src/main/resources/db/migration/의 최신 번호를 재확인하고,V61/V62가 이미 점유됐으면 다음 빈 번호로 재배정한다. 번호는 논리적 순서(expand→contract)만 유지되면 되고 값 자체는 유동. - 파일 명명: 레포 실측 컨벤션이 순차 정수
V{N}(V1~V60)이므로 이를 따른다 — 전역 컨벤션의 타임스탬프 형식(V{YYYYMMddHHmm})이 아닌 레포 관례 우선. 파일명:V61__alter_products_add_seller_type.sql,V62__alter_products_seller_type_not_null.sql.
자가 점검
- TDD 도메인 모델 필드 대응:
SellerType(B2C/B2B) →seller_type VARCHAR(10)매핑 확인.Product.create(..., sellerType)저장 대상 컬럼 존재. - 컨벤션 위반 점검: ENUM 미사용(VARCHAR) ✓ / FK 컬럼 없음 ✓ / 신규 컬럼 COMMENT 부여 ✓ / NOT NULL 분리(추가 → 배치 백필 → 제약) ✓ / 마이그레이션 내 대량 DML 없음(백필은 배치) ✓ / 인덱스 추가 없음(근거 명시) ✓ / DATETIME 컬럼 변경 없음(기존 유지).
- DB-01/DB-02 티켓 정합: 컬럼 타입·COMMENT·NOT NULL 전환·인덱스 미생성·번호 경합 노트 일치. 단 1건 불일치(갱신 필요): 현재
DB-01티켓은 V61 마이그레이션에UPDATE products SET seller_type='B2C' WHERE seller_type IS NULL대량 백필을 포함하는데, 본 설계는 이를 삭제하고 백필을 애플리케이션 Spring Batch 청크로 분리한다(운영 지적: 테이블 전체 락 금지).DB-01티켓의 V61 DDL을 “컬럼 추가 + COMMENT만”으로 수정하고, 배치 백필 티켓을 BE 트랙에 별도 신설해야 한다(§후속).
Document History
| 날짜 | 변경 내용 |
|---|---|
| 2026-07-08 | 최초 작성 — seller_type expand-contract 설계. 현재 products 스키마 V6+V19 재구성, catalog 쿼리(ProductCustomRepositoryImpl.search) 기반 seller_type 인덱스 미생성 판단(카디널리티 2·잔여 술어·개인 규모), V61/V62 락 영향·롤백·번호 경합 노트. DB-01/DB-02 티켓과 정합 확인 |
| 2026-07-08 | 운영 지적 반영 — V61에서 대량 UPDATE 백필 삭제(테이블 전체 락 금지). 백필을 애플리케이션 Spring Batch 청크(500~1000행/chunk, 멱등)로 분리. Release Scenario를 5단계(듀얼라이트 → 배치 백필 → 검증 → 기능 배포 → V62 NOT NULL + NULL=0 게이트)로 재작성. “마이그레이션 내 대량 DML 금지” 원칙 추가. DB-01 티켓 V61 백필 UPDATE 삭제 필요를 불일치로 명시 |