상품·주문 공유 상위 컨텍스트 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_typeMySQL기존 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기타출처
idBIGINT AUTO_INCREMENTNOT NULLPKV6
nameVARCHAR(255)NOT NULLV6
categoryVARCHAR(50)NOT NULL물리 카테고리(EQUIPMENT/APPAREL/…)V6
priceDECIMAL(12,2)NOT NULL판매가(KRW)V6
descriptionTEXTNULLV6
image_urlVARCHAR(2048)NULLV6
statusVARCHAR(20)NOT NULLProductStatus(ACTIVE/INACTIVE)V6
created_atDATETIME(6)NOT NULLV6
created_byBIGINTNULLV6
updated_atDATETIME(6)NOT NULLV6
updated_byBIGINTNULLV6
deleted_atDATETIME(6)NULL소프트 삭제V6
deleted_byBIGINTNULLV6
owner_idBIGINTNOT NULL등록 판매자 userIdV19

현재 인덱스:

인덱스컬럼용도
PRIMARYidPK
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_typeVARCHAR(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)만 담는다. 기존 행을 채우는 대량 UPDATEproducts 테이블 전체를 장시간 잠그거나(단일 문장) 마이그레이션 실행 시간을 예측 불가하게 만들어 운영 금지다. 백필은 **애플리케이션 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 인덱스 필요?
Q1WHERE deleted_at IS NULL AND status='ACTIVE' [AND category=?] [AND name LIKE '%kw%'] [AND seller_type=?] ORDER BY created_at DESC LIMIT windowcatalog 통합 검색(PRODUCT, FR-1~3)category 있으면 idx_products_category_status_price 선두 활용, 없으면 필터 스캔불필요 (§4)
Q2WHERE deleted_at IS NULL AND status='ACTIVE' AND seller_type='B2B' ORDER BY created_at DESC LIMIT window”브랜드만 보기”(FR-4, sellerType 단독 필터)필터 스캔불필요 (§4)
Q3WHERE deleted_at IS NULL [AND category=?] [AND name LIKE '%kw%'] [AND price BETWEEN ?] ORDER BY ...기존 goods 검색(변경 없음)idx_products_category_status_price(category 선두)무관(기존 유지)
Q4WHERE owner_id=? AND deleted_at IS NULL ORDER BY created_at DESC판매자 본인 상품(findByOwnerId, 변경 없음)idx_products_owner_id무관(기존 유지)

§4. seller_type 인덱스 판단 — 미생성

DB-01 티켓(“인덱스 추가 없음”)과 정합. 근거:

  1. 카디널리티 2 + 강한 편향: seller_type은 B2C/B2B 두 값뿐이고, 백필로 기존 전량이 B2C가 되며 B2B는 파트너 경로 소수다. 저카디널리티 단독 보조 인덱스는 선택도가 낮아 MySQL 옵티마이저가 대부분 채택하지 않는다(B2C 필터는 거의 전체 스캔과 동일).
  2. 잔여(residual) 술어: Q1의 주 접근 술어는 deleted_at IS NULL + status='ACTIVE' + 선행 와일드카드 name LIKE '%kw%'(sargable 아님)다. seller_type은 이미 스캔된 결과를 좁히는 옵션 필터(FR-4, P1)일 뿐 주 접근 경로가 아니다 — 별도 인덱스 이득이 없다.
  3. 개인 프로젝트 규모: products 예상 행 수는 수백~저수천(§용량). 이 규모에서 필터 스캔은 sub-ms이고, 인덱스는 등록·수정마다 쓰기 비용만 추가한다(“근거 없는 인덱스는 만들지 않는다” — 컨벤션·단순함 우선).
  4. 복합 인덱스도 부적합: (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 (듀얼라이트)V61ADD 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 게이트) 통과 후 V62MODIFY seller_type VARCHAR(10) NOT NULL4단계 안정화 후MODIFY NULL→NOT NULL: ALGORITHM=INPLACE(테이블 재작성 동반하나 동시 DML 허용), LOCK=NONE. STRICT SQL 모드 + NULL 0건 전제MODIFY seller_type VARCHAR(10) NULL(제약 완화, 데이터 무손실)
  • DDL 옵션 명시(구현자 지침): V61 ADD COLUMNALGORITHM=INSTANT, LOCK=NONE(무락). V62 MODIFY ... NOT NULLALGORITHM=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 삭제 필요를 불일치로 명시