Skip to content

perf(probe): PostgreSQL·MySQL 카탈로그 스키마 단위 조회와 JDBC SQLite 키 수집 이차 비용 제거 - #48

Merged
ictechgy merged 21 commits into
mainfrom
fix/probe-catalog-batching-20261020
Oct 20, 2026
Merged

ictechgy merged 21 commits into
mainfrom
fix/probe-catalog-batching-20261020

Conversation

@ictechgy

Copy link
Copy Markdown
Owner

요약

앞선 보안·성능·구조 리뷰에서 "설계 결정 필요"(대형 리팩터링 포함)로 분류해 보류했던 schemagraph 프로브 성능 항목을, 승인된 설계 결정에 따라 구현했습니다. 근거는 공통 원칙 2(출력 계약)·5(성능 항목은 측정으로 의미 있는 개선이 확인될 때만 남긴다)·6(갈라진 두 벌은 공용 함수 하나로 최소 통합)과 schemagraph 저장소별 결정("probe-go N+1·JDBC SQLite 제약 수집은 원칙 5를 따른다. 원격 RTT 효과는 지연을 넣은 로컬 DB로 잰다")입니다.

이 PR 범위는 2건이고 구현 1건, 부분 구현 1건, 미구현 0건입니다.

  • 부분 구현인 probes__performance#1은 측정할 수 있는 경로를 모두 바꿨습니다. Go PostgreSQL, JDBC PostgreSQL(pgjdbc), Go MySQL·MariaDB입니다.
    • 레인 결과에서는 Go MySQL이 남아 부분 구현이었습니다. 독립 리뷰가 이 점을 지적해 Go MySQL도 이 PR에서 구현했습니다.
    • 남은 부분은 JDBC 프로브의 PostgreSQL 외 드라이버 경로입니다. 결정에 따라 측정할 수 없어 객체마다 묻는 방식을 유지합니다(아래 "구현하지 않은 항목").
  • 계열 공통 결정 D1·D2·D4는 schemagraph에 해당하지 않아 뺐습니다. #47과 같은 판단입니다.
항목 심각도 지적 커밋
schemagraph-probes__performance#2 medium JDBC 프로브의 SQLite 제약 수집이 테이블마다 sqlite-jdbc 메타데이터를 불러 O(N²) 50bcd20, 835e054
schemagraph-probes__performance#1 medium PostgreSQL·MySQL·JDBC 수집이 객체마다 3~5회 직렬 왕복(N+1)해 원격 DB에서 RTT에 비례해 느려짐 PostgreSQL 6bf3be4·07c365f·20c4610, 리뷰 후속 c6f4292·49b3aa2·5830f3a / MySQL(리뷰 후속) da64c1d·06807ee·78a7f8c·92af86d·378ebe9 / 재검사 후속 78bf8d4·9b5ae2a

바뀌는 것

  • JDBC SQLite: 번들 sqlite-jdbc 3.51.1.0이면 PK·FK를 드라이버 규칙 그대로 옮긴 코드로 만듭니다. sqlite_schema는 수집당 한 번 읽고 나머지는 테이블별 PRAGMA입니다. 20,000테이블 JSON이 28.421 s → 2.260 s이고 출력 바이트는 같습니다.
  • Go PostgreSQL: 컬럼·제약·인덱스·트리거를 스키마마다 한 번 읽습니다. 문장 수는 테이블 수와 무관하게 52개입니다. 2 ms RTT에서 4,000테이블 JSON이 47.339 s → 0.403 s이고 출력 바이트는 같습니다.
  • JDBC PostgreSQL(pgjdbc만): 컬럼·PK·FK·인덱스를 스키마마다 한 번 읽습니다(getColumns/getPrimaryKeys/getImportedKeys에 table=null, 인덱스 카탈로그 SQL 1회). 문장은 28개이고, 4,000테이블 JSON이 55.469 s → 1.349 s입니다.
  • Go MySQL·MariaDB: PK 위치·컬럼·제약·인덱스·트리거·routine 파라미터를 스키마마다 한 번 읽습니다. 문장은 MySQL 18개·MariaDB 15개이고, 4,000테이블 JSON이 MySQL 153.222 s → 0.476 s, MariaDB 316.888 s → 0.390 s입니다.
  • 출력이 바뀌는 결함 수정 2건과 실패 경로의 범위 변화가 있습니다(아래 "계약 변경").
  • 변경 기록은 RELEASE-NOTES.md # Unreleased의 ## Changed 4항목이고, 측정은 PERFORMANCE.md 3절입니다. 버전 번호는 올리지 않았습니다.

설계 결정

원문 지적의 refined_fix를 기본 설계로 삼았습니다. 결정 문서가 닫지 않은 선택지는 공통 원칙으로 정했고, 각 결정은 해당 커밋 본문의 결정: 줄에도 적었습니다. 두 항목 모두 원칙 5 대상입니다. 그래서 수정 전·후 빌드를 번갈아 재고, 전후 문서 sha256이 같은지 확인한 뒤에만 남겼습니다.

1. JDBC SQLite 키 수집 (probes__performance#2)

결정 문서: 원칙 5(측정으로 확인될 때만 남긴다), 원칙 2(기존 정상 입력의 출력 바이트를 지킨다).

  1. PK 위치를 table_xinfo로 만들지 않고 드라이버 규칙을 옮겼습니다 (50bcd20).
    • sqlite-jdbc 3.51.1.0의 PrimaryKeyFinder·ImportedKeyFinder를 소스(Maven Central sha1 일치)와 바이트코드(javap)로 대조해 옮겼습니다.
    • 옮긴 규칙: 정규식과 플래그, 이중 unquote, table_info의 마지막 pk 컬럼, 컬럼 생략 참조의 대상 PK 첫 컬럼, ORDER BY PKTABLE_NAME(DB 인코딩 바이트 비교), KEY_SEQ 안정 정렬, 기본 로캘 toLowerCase, 시스템 표·temp 그림자 처리입니다.
    • 이유(원칙 2): 드라이버는 CHECK·주석 속 PRIMARY KEY (…)도 PK로 읽고, COLLATE 꼬리를 컬럼 이름에 남기고, 정규식이 맞지 않으면 pk 컬럼 중 마지막 하나만 씁니다. table_xinfo로는 출력 바이트가 같지 않습니다.
  2. 정확히 옮길 수 없는 입력만 그 테이블에서 드라이버 메타데이터로 돌립니다.
    • 대상: 공백 이름, 작은따옴표가 든 FK 테이블 이름(드라이버가 결과문에 escape 없이 넣음), 없는 대상·PK 없는 대상의 컬럼 생략 참조, UNION 500항 초과, DDL이 NULL인 행, SQLite 오류입니다.
    • 이유(원칙 2): 계획의 혼합안을 "정확히 옮길 수 없는 경우"로 좁혔습니다. limitation 문구까지 이전과 같습니다.
  3. 다른 sqlite-jdbc 버전(--driver)은 드라이버 메타데이터를 그대로 씁니다. 규칙을 3.51.1.0 소스에서 옮겼기 때문입니다. 번들 버전을 올리면 SQLiteConstraintsTest가 일부러 실패해 규칙을 다시 대조하게 합니다.
  4. 드라이버 경로의 FK 행 읽기와 Kotlin 묶음 규칙을 공용 함수로 뺐습니다(원칙 6). 두 경로가 같은 코드를 탑니다.
    • 묶음 규칙: 이름이 없으면 {table}@대상으로 병합하고, {table}_fk_{n}로 이름을 붙이고, seq·name으로 정렬합니다.
    • 대가: 비표준 드라이버가 PKTABLE_NAME을 NULL로 줄 때만 limitation 문구 속 Kotlin 내부 클래스·매개변수 이름이 바뀝니다. 원래 문구도 컴파일러가 만든 이름이고, JDBC 규격상 PKTABLE_NAME은 NULL이 아닙니다.
  5. refined_fix의 (a)안은 하지 않았습니다. (a)안은 JDBC SQLite 제약 이름을 네이티브·Go 규칙({table}_pk, {table}_fk_{id})으로 바꾸는 것이고, 출력 계약 변경입니다.
  6. 원칙 5 판정: 20,000테이블에서 12.6배 빨라졌습니다. 10,000 → 20,000테이블의 시간 증가가 3.8배에서 1.6~1.7배로 내려가, 증가율이 이차에서 선형이 됐습니다.

2. Go PostgreSQL 스키마 단위 조회 (probes__performance#1)

  1. 컬럼 질의는 정본 probe-go/sql/columns-postgres.sql에서 실행 시 파생합니다 (6bf3be4).
    • 이유(원칙 6): 정본은 엔진 사본과 CI cmp로 묶여 있습니다. 프로브 전용 사본을 두면 두 벌이 갈라질 수 있습니다.
    • 치환 5개(관계 이름 열 추가 2, 관계 조건 제거 2, 관계 이름 우선 정렬 1)가 각각 정확히 한 번 맞아야 합니다.
    • 맞지 않으면 질의를 보내지 않고 limitation과 미완전 표시를 남깁니다. 정본이 바뀌면 Go 테스트가 먼저 실패합니다.
  2. 스키마 단위 질의가 실패하면 limitation이 테이블마다가 아니라 스키마마다 한 번 남습니다.
    • 이유(원칙 2): 바뀌는 것은 실패 경로뿐이고, 문구 형식(<label> unavailable — <오류>)은 같습니다.
    • 문서는 limitation을 정렬·중복 제거하므로, 같은 오류가 테이블마다 나던 경우의 출력은 이전과 같습니다.
  3. 행이 없는 객체(뷰의 제약, 시퀀스)는 nil이 아닌 빈 슬라이스로 채워 JSON에 []가 그대로 남습니다.
  4. applyPrimaryKeyPositions, 가시성 검사, usage 귀속 순서, NDJSON 경로(streamPostgres), 고정 연결(SetMaxOpenConns(1))과 세션 설정은 그대로입니다. 객체가 없는 스키마에는 묻지 않습니다.

3. JDBC PostgreSQL 스키마 단위 조회 (probes__performance#1)

  1. pgjdbc(getDriverName = PostgreSQL JDBC Driver)일 때만 씁니다 (07c365f).
    • 이유: JDBC 명세는 키 조회의 table을 요구하고, H2 2.4.240은 null을 거부합니다. pgjdbc 42.7.8 PgDatabaseMetaData 소스에서 null이면 테이블 조건만 빠지고 같은 질의·같은 정렬을 쓰는 것을 확인했습니다.
    • 다른 드라이버와 방언은 측정할 수 없어 객체마다 묻습니다.
    • 드라이버 버전은 가리지 않습니다. 드라이버 내부 규칙을 옮긴 것이 아니라 드라이버 자신의 질의를 쓰기 때문입니다. 회귀는 CI의 JDBC PostgreSQL fixture 패리티가 잡습니다.
  2. 묶음 조회가 실패하면 객체마다 예전 문구로 남깁니다(schema.table: getColumns 실패 — … 등). 인덱스는 예전처럼 limitation 뒤 getIndexInfo로 대체합니다.
    • 이유(원칙 2): 객체마다 묻던 때 모든 객체가 실패한 모양과 같습니다.
  3. getColumns 결과를 정확한 스키마·테이블 이름으로 나눕니다. 계획이 허용한 결함 수정입니다.
    • 스키마·테이블 인자가 LIKE 패턴이라, 객체마다 묻던 때는 이름이 겹치는 테이블의 컬럼까지 잡았습니다(아래 "계약 변경" 1).
    • 그런 이름이 없는 DB의 출력은 같습니다.
  4. 묶음은 처음 쓸 때 읽습니다(lazy). 그래서 시퀀스만 있는 스키마에는 묻지 않고, 인덱스는 테이블·물화뷰가 있을 때만 묻습니다.
  5. 행 읽기(readColumnDoc·readPrimaryKeyRow·readImportedKeyRow)와 인덱스 SQL은 객체 경로와 같은 함수입니다(postgresCatalogIndexes(conn, schema, table?)). 트리거·본문·가시성 검사는 이미 스키마 단위라 그대로입니다.

4. Go MySQL·MariaDB 스키마 단위 조회 (probes__performance#1, 리뷰 후속)

  1. 일괄 조회 전에 제약 키 컬럼의 소유자 검사를 넣었습니다 (da64c1d).
    • 키 컬럼 행의 스키마·테이블·제약 이름이 제약 행과 정확히 같을 때만 씁니다.
    • 이유(원칙 2): 계열의 네이티브 reader(제약마다 key_column_usage를 따로 묻는다)와 같은 결과로 맞춥니다. 일괄 조회로 바꾸면 MariaDB의 동점 순서가 또 바뀌므로 결과를 먼저 정해 두었습니다.
    • 출력이 바뀌므로 Changed에 적었습니다(아래 "계약 변경" 2).
  2. interpolateParams는 기본으로 켜지 않습니다 (06807ee).
    • 일괄 조회 뒤 남는 prepare는 스키마마다 상수입니다(최종 MySQL 12개, MariaDB 10개).
    • 클라이언트 쪽 값 치환은 모든 바인드 값의 전달 방식을 바꾸므로 따로 검토할 일입니다. 계획도 이 전환을 하지 않기로 했습니다.
    • URL에 ?interpolateParams=true를 주면 지금도 켤 수 있음을 확인했습니다.
  3. routine 시그니처는 예전 바이트를 지킵니다 (06807ee).
    • 서버의 대소문자·악센트 무시 비교로 다른 이름과 겹칠 수 있는 routine은 예전처럼 routine마다 묻습니다.
    • 출력 가능한 ASCII가 아니거나 공백으로 끝나는 routine 이름이 있는 스키마는 모든 routine을 따로 묻습니다(mysqlCollationSensitiveNames).
    • 이유(원칙 2): 예전 출력, 네이티브 reader와 같게 둡니다.
  4. 제약 행과 키 컬럼 행을 따로 읽고 Go에서 정확한 이름으로 잇습니다 (92af86d).
    • 06807ee의 서버 쪽 조인을 MariaDB 11.4가 인덱스 없는 블록 중첩 반복(BNL)으로 실행했습니다(ANALYZE r_loops 7,999, r_filtered 0.012%). 그래서 4,000테이블에서 이 질의 하나가 4.06 s로, 테이블 수의 제곱만큼 늘었습니다.
    • 스키마당 질의가 하나 늘지만 서버 비용이 테이블 수에 비례합니다. 원칙 5의 "증가율로 복잡도 감소가 확인되는 경우"에 해당합니다.
    • 키 컬럼 질의를 끝까지 읽지 못하면 그 스키마의 제약을 모두 비웁니다. 키 컬럼 일부만 가진 제약을 내지 않기 위해서입니다.
  5. 함수형 인덱스 limitation은 객체 목록에 있는 테이블마다 예전 문구로 남깁니다.

5. 측정 재현성과 하네스 (49b3aa2, 5830f3a, 78a7f8c, 78bf8d4)

  1. 원격 RTT를 저장소 스크립트로 잽니다. 리뷰 지적 3(저장소만으로 표를 다시 만들 수 없음)에 따른 것입니다.
    • Scripts/latency-relay.py: 방향마다 d ms 지연을 넣고, 반쪽 닫기를 전하고, 연결 오류를 기록합니다.
    • Scripts/benchmark-probe-postgres.py: --latency-ms, 문장 계수(xact_commit), --format, --warmup, --indexes, JDBC 쌍을 더했습니다.
    • 새 Scripts/benchmark-probe-mysql.py: 다이제스트 고정 이미지를 쓰고 Questions·Com_stmt_prepare를 셉니다.
  2. 측정 PG cluster는 Unix socket을 끄고 127.0.0.1 scram으로 붙습니다. 비밀번호 파일에는 서버 포트 줄만 둡니다 (49b3aa2, 78bf8d4). relay를 쓰면 relay 포트 줄만 더합니다.
    • 긴 TMPDIR에서 socket 경로가 103바이트를 넘어 기동이 실패하는 문제를 피합니다.
    • 기존 하네스 계약도 지킵니다: TCP 클라이언트는 이 cluster 포트의 비밀번호만 담은 0600 PGPASSFILE을 받습니다(test-postgres-fixture-auth.py).
    • 49b3aa2는 처음에 127.0.0.1:*(모든 포트)로 두었습니다. 재검사에서 계약 테스트 실패로 드러나 78bf8d4에서 포트로 한정했습니다.
  3. 측정 cluster의 autovacuum과 병렬 질의를 끕니다. 자동 vacuum과 병렬 작업자의 트랜잭션도 xact_commit에 잡히기 때문입니다(4,000테이블 JDBC에서 30 대 28). 기본값(NDJSON, 지연 없음, Go 쌍)은 예전과 같은 명령으로 돕니다.
  4. MySQL 벤치의 비밀번호는 실행마다 새로 만들고 docker CLI와 probe의 환경 변수로만 넘깁니다. 컨테이너와 익명 볼륨은 끝나면 지웁니다(docker rm -f -v).
  5. 새 단위 테스트 Scripts/test-probe-postgres-benchmark.py·test-probe-mysql-benchmark.py는 CI 목록에 넣지 않았습니다. .github/workflows/ci.yml은 레인 d1(fix(engine)!: 네이티브 reader TLS, --cache-dir 항목 HMAC 인증, MCP 응답 상한, language-traversal 종료 코드 64 #47)이 고치고 있어서입니다(후속 작업 8).

6. 변경 기록 (c6f4292, 378ebe9, 9b5ae2a)

  • 레인 계획은 RELEASE-NOTES.md를 레인 d1 전용으로 두었습니다.
  • 리뷰가 원칙 2("출력이 바뀌는 변경은 Unreleased의 Changed에 적는다")에 맞지 않는다고 지적했습니다. 그래서 맨 위에 #47과 같은 머리(# Unreleased, 같은 안내 문장, ## Changed)를 만들고 4항목을 적었습니다.
  • #47이 먼저 머지되면 이 4항목을 #47의 ## Changed 목록 끝으로 옮기면 됩니다(아래 "머지 참고").

계약 변경

종료 코드, 기본값, 설정 검증, 프로브 명령줄 옵션은 바뀌지 않습니다. 성공한 수집의 출력은 아래 1·2의 이름 사례를 빼면 바이트 단위로 같습니다.

출력이 바뀌는 결함 수정 (RELEASE-NOTES.md Changed)

  1. JDBC 프로브(pgjdbc)의 PostgreSQL 컬럼을 정확한 스키마·테이블 이름으로 나눕니다 (07c365f).
    • getColumns의 스키마·테이블 인자는 LIKE 패턴입니다. 그래서 예전에는 a_b가 a1b의 컬럼을, pct%name이 pctxname의 컬럼을, 스키마 my_app의 테이블이 myxapp의 같은 이름 테이블 컬럼을 함께 잡았습니다.
    • \는 LIKE 이스케이프 문자입니다. 그래서 back\slash는 backslash의 컬럼을 잡거나, 그런 테이블이 없으면 컬럼 없이 가시성 limitation(uncollected column)을 남겼습니다.
    • 이제 각 테이블은 자기 컬럼만 갖습니다. 그런 DB에서 다시 수집한 스냅샷을 예전 스냅샷과 review로 비교하면 컬럼 추가·삭제로 보일 수 있습니다.
  2. Go 프로브의 MySQL·MariaDB 제약이 자기 테이블의 키 컬럼만 갖습니다 (da64c1d).
    • MariaDB는 table_constraints–key_column_usage 조인에서 이름을 대소문자·악센트 무시로 비교합니다.
    • 그래서 이름이 같은 제약(PRIMARY, 이름이 같은 UNIQUE)이 대소문자·악센트만 다른 테이블(P/p, resume/résumé)이나, 대소문자만 다른 스키마의 같은 이름 테이블의 키 컬럼까지 가졌습니다. 그 순서도 서버마다 달랐습니다.
    • 이제 네이티브 reader처럼 자기 테이블의 키 컬럼만 갖습니다. MySQL 문서와, 그런 이름이 없는 MariaDB 문서는 같습니다.
    • 다시 수집한 스냅샷은 review에서 해당 테이블의 constraints-changed를 낼 수 있습니다.

실패 경로

  1. 스키마 단위 카탈로그 질의 하나의 실패가 스키마의 모든 객체에 미칩니다 (6bf3be4, 07c365f, 06807ee, 92af86d; RELEASE-NOTES.md Changed).
    • Go PostgreSQL·Go MySQL: 그 부분을 모든 객체에서 비우고, 평소 limitation을 스키마에 한 번 남기고, 미완전으로 표시합니다. 문구 형식은 같아서, 같은 오류가 테이블마다 나던 경우의 문서는 정렬·중복 제거 뒤 같습니다.
    • Go MySQL routine 파라미터의 일괄 조회가 실패하면, routine마다 평소 limitation을 남기고 시그니처 없이 계속합니다.
    • JDBC PostgreSQL: 객체마다 예전 문구(schema.table: getColumns 실패 — … 등)를 남기고, 인덱스는 getIndexInfo로 대체합니다.
  2. Go PostgreSQL의 새 limitation 문구: 번들 정본 컬럼 질의를 스키마 단위로 바꿀 수 없으면 bundled PostgreSQL column query could not be adapted to schema-wide reads (…); rebuild the probe를 남기고 미완전으로 표시합니다. 정본과 파생 규칙이 어긋난 빌드에서만 나고, 그런 빌드는 Go 테스트가 먼저 막습니다. 릴리스 노트에는 적지 않았습니다.
  3. JDBC SQLite: 비표준 드라이버가 PKTABLE_NAME을 NULL로 줄 때만 limitation 문구 속 Kotlin 내부 클래스·매개변수 이름이 바뀝니다. 번들 드라이버에서는 나지 않는 경로라 릴리스 노트에는 적지 않았습니다.

출력이 같은 동작 변화

  • 문장 수: 스키마당 상수가 됐습니다. Go PostgreSQL 52, JDBC PostgreSQL 28, MySQL 18(prepare 12), MariaDB 15(prepare 10)입니다. 서버는 객체별 질의 대신 스키마 단위 카탈로그 질의를 받습니다.
  • 메모리: 스키마 전체 행을 한 번에 읽습니다. 리뷰가 잰 JDBC PostgreSQL 20,000테이블(지연 없음)의 피크 RSS는 587 → 691 MB(+18%)였습니다. JDBC SQLite 20,000테이블은 반대로 약 524 → 375 MiB였습니다.
  • 경로 선택: JDBC는 pgjdbc일 때만 스키마 단위로 읽고, 다른 드라이버는 객체마다 묻습니다. SQLite 키는 번들 3.51.1.0일 때만 옮긴 경로를 씁니다.

개발 도구(사용자 계약 밖)

  • 새 스크립트: Scripts/latency-relay.py, Scripts/benchmark-probe-mysql.py, Scripts/test-probe-mysql-benchmark.py, Scripts/test-probe-postgres-benchmark.py
  • Scripts/benchmark-probe-postgres.py
    • 새 옵션: --latency-ms, --format json|ndjson(기본 ndjson), --warmup, --indexes, --baseline-jar·--new-jar·--java
    • results.json에 실행별 문장 수·문서 sha256, producer별 summary, relay 연결 오류를 남깁니다.
    • 측정 cluster는 Unix socket·autovacuum·병렬 질의를 끄고, 비밀번호 파일을 포트로 한정합니다. 기본 명령은 예전과 같은 비교를 합니다.
  • 새 fixture Fixtures/sqlite/named-constraints.sql과 Scripts/verify-fixtures.sh 단계: 네이티브·Go·JDBC의 참조 관계 패리티와 JDBC 제약 이름을 고정합니다.

변경 기록

  • RELEASE-NOTES.md # Unreleased → ## Changed 4항목: 위 1, 2와 PostgreSQL·MySQL 스키마 단위 조회(실패 경로 3 포함) 설명입니다.
  • PERFORMANCE.md: 새 절 "Remote PostgreSQL collection (2026-10-20)"·"Remote MySQL and MariaDB collection (2026-10-20)"과, "JDBC: one schema with 10,000 tables" 절의 SQLite 키 수집 전후 행입니다.
  • JDBC SQLite 개선은 출력이 같아 RELEASE-NOTES.md에 넣지 않았습니다. 릴리스 정리 때 쓸 수 있는 초안은 다음과 같습니다.

    JDBC probe: SQLite primary and foreign keys are now built from one sqlite_schema read plus per-table PRAGMAs instead of sqlite-jdbc getPrimaryKeys/getImportedKeys, which rescanned the whole schema for every call and again for every foreign key. Collecting a 20,000-table SQLite file drops from about 28 s to about 2.3 s on the measurement host. Output is unchanged, including limitation text for inputs the driver rejects; sqlite-jdbc versions other than the bundled 3.51.1.0 keep using driver metadata.

변경

수정 (fix)

  • fix(probe): Go MySQL 제약에 다른 테이블의 키 컬럼이 섞이지 않게 한다 (da64c1d)

성능 (perf)

  • perf(probe): JDBC SQLite 키 수집이 테이블마다 sqlite_schema를 훑지 않게 한다 (50bcd20)
  • perf(probe): Go PostgreSQL 수집이 컬럼·제약·인덱스·트리거를 스키마마다 한 번씩 읽게 한다 (6bf3be4)
  • perf(probe): JDBC PostgreSQL 수집이 컬럼·키·인덱스를 스키마마다 한 번씩 읽게 한다 (07c365f)
  • perf(probe): Go MySQL 수집이 컬럼·제약·인덱스·트리거·routine 파라미터를 스키마마다 한 번씩 읽게 한다 (06807ee)
  • perf(probe): Go MySQL 제약 수집이 MariaDB에서 테이블 수의 제곱으로 늘지 않게 한다 (92af86d)

테스트 (test)

  • test: PostgreSQL 수집 벤치에 지연 relay와 문장 계수를 더한다 (49b3aa2)
  • test: MySQL·MariaDB 수집 벤치에 지연 relay와 문장 계수를 더한다 (78a7f8c)
  • test: PG 벤치 비밀번호 파일을 실제 포트로 한정하고 인증 계약 테스트를 맞춘다 (78bf8d4)

문서 (docs)

  • docs: JDBC SQLite 수집 측정에 키 수집 변경 전후 행을 더한다 (835e054)
  • docs: 원격 PostgreSQL 수집 측정 절을 PERFORMANCE.md에 더한다 (20c4610)
  • docs: JDBC PostgreSQL 컬럼 분할 변경을 릴리스 노트와 측정 문서에 적는다 (c6f4292)
  • docs: 원격 PostgreSQL 측정 표를 저장소 벤치로 다시 재고 재현 명령을 적는다 (5830f3a)
  • docs: Go MySQL 일괄 조회 측정과 제약 컬럼 변경을 PERFORMANCE.md·릴리스 노트에 적는다 (378ebe9)
  • docs: 릴리스 노트의 Go MySQL 항목을 최종 동작에 맞춘다 (9b5ae2a)

정정: 06807ee 본문의 "단위 테스트 10개"는 9개(테스트 함수 기준)입니다. 커밋은 고치지 않았고, 378ebe9 본문에 정정을 남겼습니다.

바뀐 파일은 22개(+3,662/−378)입니다.

  • Go 프로브: probe-go/postgres.go, probe-go/mysql.go, 새 테스트 postgres_batch_test.go·mysql_batch_test.go·mysql_constraints_test.go
  • JDBC 프로브: Extractor.kt, IndexCatalog.kt, 새 파일 PostgresSchemaMetadata.kt·SQLiteConstraints.kt, 새 테스트 PostgresSchemaMetadataTest.kt·SQLiteConstraintsTest.kt, ScriptedConnection.kt(메타데이터 호출 기록과 메서드 대체)
  • fixture·스크립트: 새 파일 Fixtures/sqlite/named-constraints.sql·Scripts/latency-relay.py·Scripts/benchmark-probe-mysql.py·Scripts/test-probe-mysql-benchmark.py·Scripts/test-probe-postgres-benchmark.py, 수정 Scripts/verify-fixtures.sh·Scripts/benchmark-probe-postgres.py·Scripts/test-postgres-fixture-auth.py
  • 문서: PERFORMANCE.md, RELEASE-NOTES.md

머지 참고

검증

로컬 macOS(arm64)에서 HEAD 9b5ae2a로 실행했고, Docker 단계는 Colima(aarch64 Linux VM)에서 돌렸습니다. origin/main(aaba709)이 이미 이 브랜치의 조상이라 merge가 필요 없었습니다. GitHub CI는 push 전이라 아직 돌지 않았습니다.

HEAD 9b5ae2a 검사

  • cd probe-go && gofmt -l . && go vet -p 3 ./... && go test -p 3 -count=1 ./... && CGO_ENABLED=0 go build -p 3 ./...(+ -o 빌드, -trimpath -ldflags='-s -w' 빌드): pass — gofmt가 고칠 파일 0개, 최상위 Go 테스트 75개 통과(0.63 s), 빌드 3종 성공
  • gradle -p probe clean check shadowJar --no-daemon --max-workers=2(JDK 17): pass — Kotlin 테스트 55개 실패 0. 경고 3개(Extractor.kt 'Redundant call', Model.kt:136·146 deprecated)는 main에 있던 기존 경고. JAR sha256 b8f62ecf…
  • cargo fmt --manifest-path engine/Cargo.toml --all -- --check, cargo build --workspace --locked -j 3, cargo test --workspace --locked -j 3: pass — 이 브랜치는 engine/을 바꾸지 않음. 테스트 바이너리 22개에서 537개 통과
  • python3 -B Scripts/test-postgres-fixture-auth.py: pass — 6개. 처음 실행에서는 이 브랜치 때문에 실패했습니다(test_probe_benchmark_cluster의 TypeError, 49b3aa2가 원인). 78bf8d4로 고쳤습니다. origin/main에서는 6개 모두 통과
  • CI·DML 워크플로가 돌리는 단위 검사와 새 벤치 테스트 2개(test-mysql-ready, test-fixture-ports, test-postgres-fixture-diagnostics, test-postgres-fixture-auth, test-sqlserver-oracle-accuracy, test-benchmark-scale, test-probe-sqlite-benchmark, test-review-action, test-container, test-dml-accuracy, test-dml-producers, test-probe-mysql-benchmark, test-probe-postgres-benchmark): pass — 13개 모두 rc=0
  • benchmark-probe-postgres.py --schemas 1 --tables-per-schema 50 --indexes --format json --warmup 1 --repeat 3 --latency-ms 1 / --latency-ms 0(Go·JDBC 쌍): pass — 78bf8d4 뒤에도 실제 수집이 됨. relay 연결 16개, 오류 0. 문장 Go 52, JDBC 28. 두 지연 조건 모두 producer마다 문서 해시 하나
  • cmp engine/source/src/sql/{catalog-postgres,catalog-sqlserver,catalog-oracle,visibility-postgres,columns-postgres}.sql probe-go/sql/…: pass — 동결 정본 5개가 바이트 단위로 같음
  • verify-contracts.py, verify-competitive.py, verify-cancellation.py, verify-html.py --screenshot, verify-review-action.py --schema <SARIF 2.1.0>: pass — jsonschema 4.23.0·sqlglot 30.18.0, SARIF 스키마 sha256이 CI 고정값과 일치, HTML은 로컬 Chrome
  • run-review-action.py를 composite action과 같은 SG_REVIEW_*·GITHUB_WORKSPACE·RUNNER_TEMP·GITHUB_OUTPUT 환경으로 실행(CI "Invoke the consumer Action" + "Verify the Action exposes findings and artifacts"): pass — exit-code 출력 1, json·sarif·markdown 출력 파일 모두 비어 있지 않음
  • verify-accuracy.py --sqlglot --suite all --strict, verify-external-imports.py --dbt(dbt-core 1.12.5, dbt-postgres 1.11.0): pass — PG16 로컬 cluster에서 chinook 23건·pagila 20건 실패 0, dbt import ok
  • verify-catalog-dependencies.py, verify-sqlite-nullability.py, verify-collection-scope.py ×3(native·Go·JDBC), verify-schema-lint.py, verify-unused.py, verify-openlineage.py, verify-bridge-facts.py, test-accuracy-sql.py --jdbc-jar, 번들 문서·라이선스 cmp: pass — 모두 rc=0, 실행 뒤 worktree 변경 0줄. HEAD에서 빌드한 Go 바이너리와 JAR 사용
  • Scripts/verify-probe-maven.sh: pass — 로컬에 Maven CLI가 없어 스크립트가 정한 Gradle Maven-repository consumer로 검증(게시물 0.7.1 검증, 외부 consumer 검사 통과)
  • SG_MYSQL_JAR=… SG_ORACLE_JAR=… SG_FIXTURE_ARTIFACTS=<scratch>/fixture-artifacts Scripts/verify-fixtures.sh(Docker: MySQL, MariaDB, MSSQL, Oracle 포함): pass — 마지막 줄 verify-fixtures: OK, 주의: 줄 0개. MySQL·MariaDB·PG·SQLite 그래프와 MSSQL·Oracle catalog-dependencies 검증이 모두 실행됨. 드라이버 jar sha256이 CI 고정값과 일치. 실행이 남긴 익명 볼륨 6개만 지움
  • verify-mysql-accuracy.py --sqlglot --strict: pass — MySQL·MariaDB 16/16 실패 0. 첫 시도에서 MariaDB 컨테이너가 기동 중 멈췄습니다(여러 세션이 함께 쓰는 VM의 일시 문제). 엔진만 쓰는 스크립트이고 엔진은 바뀌지 않았으며, --database mariadb 재실행과 CI와 같은 전체 명령 재실행이 모두 통과
  • cargo package --workspace --locked, shasum -a 256 --check workspace-cli.sha256: pass — yanked yoke-derive 경고는 main과 같은 Cargo.lock의 기존 경고
  • bash Scripts/test-publish-release-assets.sh: pass — 오프라인 게시 사례 36개
  • Release binaries(PR 경로) 단계 본문을 YAML에서 뽑아 실행(prepare, build-native aarch64-apple-darwin: release CLI·Go -trimpath·SQLite 검증·패키지, build-jdbc): pass — 0.7.1, publish=false, 독립 JAR smoke 포함
  • verify-sqlserver-oracle-accuracy.py --database oracle --strict(engine, Go 프로브, JAR, ojdbc8): pass — Oracle 18건 SQL 검증 통과, 정확도 실패 0
  • bash -n Scripts/verify-fixtures.sh: pass
  • 비공개 이름 검사(커밋 메시지·diff·PR 본문): pass — 0건
  • PR 생성 직전 다시 실행: gofmt -l·go vet·go test, test-postgres-fixture-auth.py·test-probe-postgres-benchmark.py·test-probe-mysql-benchmark.py·test-probe-sqlite-benchmark.py: pass

수정 전 실패와 회귀 검출

  • JDBC SQLite: SQLiteConstraintsTest 3개가 수정 전 코드에서 모두 실패했습니다.
    • 세 테스트: 드라이버 메타데이터를 부르지 않고 sqlite_schema를 한 번만 읽는지 단언, 옮긴 경로와 드라이버 경로의 JSON·NDJSON 문서 대조, 터키어 로캘
    • 대조 사례: 이름 유무 PK/FK, 같은 대상 FK 2개, 다중 컬럼, 컬럼 생략 참조, 인용·대소문자·비ASCII·작은따옴표 이름, WITHOUT ROWID·STRICT, 주석 DDL, 뷰·트리거, FTS5·R*Tree, 드라이버 실패 입력, 500/501항, attached·temp 그림자, UTF-8·UTF-16le·be 정렬(BMP 밖 글자 포함), 저장소 fixture 3개
    • 비교 함수를 String.compareTo로 바꾸는 변이도 잡습니다.
    • named-constraints.sql 패리티 단계는 간선 하나를 지운 그래프와 이름 규칙이 다른 그래프로 실패를 확인했습니다.
  • Go PostgreSQL: postgres_batch_test.go 6개. 수정 전 코드에서 질의 수가 1테이블 16, 40테이블 172로 실패했습니다. 리뷰도 따로 확인했습니다. 동작 테스트 3개(질의 수, 분할, 실패 보고)는 수정 전 실패하고, 새 헬퍼를 쓰는 3개는 수정 전 코드에서 컴파일되지 않습니다.
  • JDBC PostgreSQL: PostgresSchemaMetadataTest 4개(호출 수 상수, 객체 경로와 문서 동일, A_B/A1B 정확 분할, 묶음 실패 시 예전 문구)가 수정 전 모두 실패했습니다. 수정 전에는 getColumns·getPrimaryKeys·getImportedKeys가 각 40회, 준비 SQL이 45개였습니다.
  • Go MySQL
    • TestMySQLConstraintsKeepOnlyTheirOwnKeyColumns: 소유자 검사를 빼면 P·p 모두 [a b c]가 되어 실패
    • TestMySQLQueriesPerSchemaDoNotGrowWithObjectCount: 앞 커밋 코드에서 12 대 246으로 실패
    • TestMySQLCatalogQueriesDoNotJoinInformationSchemaTables: 06807ee 코드에서 실패
    • TestMySQLSchemaBatchFailureIsReportedOncePerSchema: 키 컬럼 실패 두 경우가 06807ee 코드에서 실패
  • 하네스: test-postgres-fixture-auth.py는 78bf8d4 전 1개 오류였습니다. 벤치를 127.0.0.1:* 줄, -k 없음, -k /tmp로 바꾸면 각각 실패합니다.

출력 동일성(전후 문서 sha256)

  • JDBC SQLite(기준 aaba709의 JAR e35a12c27679 → 06f479f17b64): 모두 같고 그래프 해시도 같습니다.
    • SQLite 15개 DB × JSON·NDJSON × v1·v2 60개, H2·PG 8개
    • 벤치 2,00020,000테이블, FK 스키마 2,50010,000테이블
  • Go PostgreSQL(기준 835e054): 36/36 같습니다.
    • DB: PG16 basic·pagila·경계 사례(다른 테이블의 같은 이름 FK, 컬럼 순서와 다른 PK 키 순서, 식·부분·INCLUDE 인덱스, 파티션, 뷰 INSTEAD OF 트리거), en_US 정렬 DB, 권한 제한 사용자, LIKE 충돌 이름
    • 형식: JSON·NDJSON × v1·v2 × catalog-dependencies
    • 리뷰가 다른 경계 DB(상속, 삭제된 컬럼, 파티션, 제외 제약, deferrable FK, 대소문자·유니코드 이름, 물화뷰, 뷰 INSTEAD OF 트리거)로 다시 확인했습니다.
  • JDBC PostgreSQL(06f479f17b64 → b8f62ecfdfbe): 30/36 같습니다.
    • 다른 6개는 LIKE 충돌 DB뿐입니다. 차이는 my_app.a_b에 붙던 a1b 컬럼과 my_app.t에 붙던 myxapp.t 컬럼이 빠진 것입니다.
    • c6f4292에서 back\slash·pct%name·sch\ema 사례를 따로 확인했습니다. 스키마 sch\ema의 객체는 두 JAR 모두 수집하지 못합니다(기존 동작, 후속 작업 4).
  • Go MySQL·MariaDB: mysql:8.4(8.4.11)와 mariadb:11.4(11.4.13, performance_schema 꺼짐·켬)에서 수집 범위 8개 × 형식 6개 = 48조합을 비교했습니다.
    • 기준(5830f3a) 대비 차이는 MariaDB 변형 이름 테이블 12개의 제약 컬럼뿐입니다. da64c1d·06807ee·92af86d 세 빌드끼리는 모두 같습니다.
    • 같은 MariaDB에서 네이티브 reader와 제약이 같은 객체는 새 빌드 25/25, 예전 Go 13/25입니다.

측정

JDBC SQLite (Scripts/benchmark-probe-sqlite.py, OpenJDK 17.0.20, sqlite-jdbc 3.51.1.0, 두 JAR을 번갈아 두 바퀴, 셀당 6회 중앙값, load 5~7)

테이블 형식 전 s 후 s
2,000 JSON 1.282 0.849
10,000 JSON 7.577 1.317
20,000 JSON 28.421 2.260
20,000 NDJSON 27.655 2.190
  • 20,000/10,000 시간 비는 3.753.86에서 1.601.72가 됐습니다. 20,000테이블 최대 RSS는 약 524 → 375 MiB입니다.
  • FK가 많은 스키마(warm-up 1회 뒤 교대 3회): 2,500/5,000/10,000테이블 JSON 3.020/6.941/18.038 s → 1.568/2.618/4.490 s. 리뷰 재현은 5,000테이블 7.91 → 2.45 s였습니다.

PostgreSQL (Scripts/benchmark-probe-postgres.py, PG 16.13, --latency-ms 1(RTT 약 2 ms), warm-up 1회 + 교대 3회 중앙값, load 5~17, 기준 835e054)

프로브 테이블 형식 전 s 후 s 전 문장 후 문장
Go 2,000 JSON 23.720 0.317 8,048 52
Go 2,000 NDJSON 26.035 0.303 8,048 52
Go 4,000 JSON 47.339 0.403 16,048 52
Go 4,000 NDJSON 51.498 0.373 16,048 52
JDBC 2,000 JSON 27.063 1.001 8,024 28
JDBC 4,000 JSON 55.469 1.349 16,024 28
  • 직접 접속 4,000테이블 JSON: Go 1.995 → 0.322 s, JDBC 13.168 → 1.270 s
  • 앞선 측정(저장소 밖 asyncio 프록시 1 ms/방향, load 3~7, 병렬 질의 켬)
    • Go: 2,000테이블 JSON 22.706 → 0.282 s·NDJSON 23.570 → 0.270 s, 4,000테이블 JSON 46.103 → 0.416 s·NDJSON 45.228 → 0.373 s
    • JDBC: 2,000테이블 25.552 → 0.907 s(문장 8,024 → 28), 4,000테이블 48.392 → 1.139 s(문장 16,024 → 30). 4,000테이블의 +2는 병렬 작업자이고, 서버 로그의 문장은 두 크기 모두 27개였습니다.
    • 직접 접속 4,000테이블: Go 1.067 → 0.253 s, JDBC 3.923 → 1.038 s
  • 리뷰 재현: 수정 전 테이블당 4문장(+48), 수정 후 스키마당 52문장 고정
  • 메모리: 리뷰가 잰 JDBC 20,000테이블(지연 없음)의 피크 RSS는 587 → 691 MB(+18%)였고 출력은 같았습니다.

MySQL·MariaDB (Scripts/benchmark-probe-mysql.py, 다이제스트 고정 이미지, Colima 2 CPU·4 GiB, --latency-ms 1, 테이블 10개당 procedure 1개, warm-up 1회 + 교대 3회 중앙값, 기준 da64c1d → 92af86d)

서버 테이블 형식 전 s 후 s 전 문장 후 문장
MySQL 8.4.11 2,000 JSON 63.184 0.269 10,211 18
MySQL 8.4.11 2,000 NDJSON 65.013 1.757 10,211 18
MySQL 8.4.11 4,000 JSON 153.222 0.476 20,411 18
MariaDB 11.4.13 2,000 JSON 113.698 0.197 10,208 15
MariaDB 11.4.13 2,000 NDJSON 118.590 0.186 10,208 15
MariaDB 11.4.13 4,000 JSON 316.888 0.390 20,408 15
  • prepare: MySQL 10,205 → 12, MariaDB 10,203 → 10
  • 직접 접속 4,000테이블: MySQL 15.314 → 0.383 s, MariaDB 204.666 → 0.309 s
  • 서버에서 조인하던 중간 빌드(06807ee)는 MariaDB 2,000/4,000테이블 relay에서 1.169/3.994 s로 제곱 증가를 보였고, 92af86d로 없앴습니다.
  • MySQL NDJSON 1.757 s는 이 변경과 무관합니다.
    • 일부 컨테이너에서 기존 sys.schema_unused_indexes 질의가 해시 조인 대신 중첩 반복 계획으로 실행돼, 0.01 s 대신 1.56 s 걸렸습니다.
    • 같은 컨테이너에서 06807ee와 92af86d를 번갈아 재면 relay 0.28/0.275 s, 직접 1.71/1.71 s로 같았습니다. 후속 작업 5로 남깁니다.
  • 모든 쌍의 문서 sha256이 같았고, relay 연결 오류는 0이었습니다.

실행하지 않은 검사

  • verify-sqlserver-oracle-accuracy.py --database sqlserver --strict: not-run — 스크립트가 Linux x86_64 Docker를 요구합니다("use the GitHub accuracy workflow"). 로컬 Colima는 aarch64입니다.
  • Release binaries x86_64-unknown-linux-gnu 빌드와 assemble(gh run download, SHA256SUMS): not-run — Linux x86_64 러너와 같은 실행의 artifact가 필요합니다. 이 호스트의 aarch64-apple-darwin 행렬과 build-jdbc는 위에서 통과했습니다.
  • CI container job(docker build --tag schemagraph:ci . + verify-container.py): not-run.
    • 빌드 컨텍스트(Dockerfile, README.md, LICENSE-*, engine/)가 origin/main과 같고, aaba709의 main push CI(container 잡 포함)는 success입니다.
    • 여러 세션이 쓰는 Colima VM에 build cache를 남기지 않으려 했고, 검사 중 호스트 여유가 7.5 GiB까지 줄어 실행하지 않았습니다.
  • DML producer accuracy native-producers 행렬(verify-dml-producers.py 기준선, verify-dml-replay.py): not-run — CI 전용입니다. 게시된 v0.4.3 x86_64 Linux 산출물로 기준선을 만들고 이 브랜치의 프로브는 쓰지 않습니다. 같은 워크플로의 단위 검사는 로컬에서 통과했습니다.
  • IBM database fixtures(verify-ibm-containers.py --memory 5g): not-run — Db2·Informix 이미지가 linux/amd64 전용이고 --privileged와 5 GB 메모리가 필요합니다. 로컬 VM은 aarch64·4 GiB입니다.

원격 CI에서 꼭 볼 단계

  • ibm-fixtures.yml(Db2·Informix): 50bcd20이 JDBC Extractor의 키 행 읽기를 공용 함수로 뺐습니다. MySQL·MSSQL·Oracle JDBC 경로는 로컬 Docker verify-fixtures.sh로 확인했고, Db2·Informix 경로는 CI에서만 확인할 수 있습니다.
  • sqlserver-oracle-accuracy.yml의 sqlserver 행렬, dml-accuracy.yml의 native-producers 행렬
  • ci.yml "Verify every database fixture": 새 SQLite named-constraints 단계와 MySQL·MariaDB·MSSQL·Oracle·PostgreSQL 패리티
  • ci.yml "Verify database startup failure paths": 78bf8d4가 맞춘 test-postgres-fixture-auth.py

구현하지 않은 항목

  • probes__performance#1의 남은 부분(부분 구현 사유): JDBC 프로브의 PostgreSQL 외 드라이버(MySQL Connector/J, Oracle, SQL Server, Db2, Informix, H2 등)는 객체마다 묻는 방식을 유지합니다. 측정 불가로 적용하지 않았습니다.
    • 근거(07c365f 결정): JDBC 명세는 키 조회의 table을 요구하고, 드라이버마다 table=null 지원이 다릅니다(H2는 거부).
  • 같은 항목에서 결정에 따라 하지 않은 선택지
    • MySQL interpolateParams 기본 전환: 바인드 값 전달 방식이 바뀝니다. URL로는 지금도 켤 수 있습니다.
    • Go SetMaxOpenConns(1) 고정 연결을 풀어 병렬화하기: 세션 고정 계약을 지킵니다.
  • probes__performance#2의 refined_fix (a)안(제약 이름 규칙 변경): 출력 계약 변경이라 하지 않았습니다.
  • 계열 공통 결정 가운데 schemagraph에 해당하지 않아 뺀 항목(#47과 같은 판단)
    • D1(분석 대상 .git 설정이 명령을 실행하는 문제): 해당 없음. 엔진·프로브 코드에 외부 프로세스 실행이 없고(Command::new·exec.Command·ProcessBuilder 0건), git을 부르는 곳은 이 저장소 자신의 릴리스 워크플로뿐입니다.
    • D2(SQL 관계 추출기의 거짓 관계): 재현되지 않음. schemagraph는 토큰 기반 추출기가 아니라 sqlparser AST + binder를 쓰고, 공통 사례 14개에서 거짓 관계가 0건이었습니다.
    • D4(배포 경로 무결성): 해당 없음. composite action.yml은 version: latest 다운로드 없이 고정된 액션 소스로 빌드하고, INSTALLATION.md는 버전 고정과 SHA256SUMS 대조를 이미 안내합니다.

후속 작업(이 PR 밖)

  1. 번들 sqlite-jdbc를 올릴 때: SQLiteConstraintsTest가 일부러 실패합니다. 새 드라이버 소스와 SQLiteConstraints.kt를 다시 대조하고 REPLICATED_SQLITE_JDBC_VERSION을 올립니다.
  2. sqlite-jdbc getImportedKeys의 이름 escape 누락(기존): 테이블 이름을 escape 없이 결과문에 넣어, 작은따옴표가 든 이름은 드라이버의 메타데이터 SQL을 깨거나 바꿀 수 있습니다. 이 PR은 출력 보존을 위해 그런 이름을 드라이버 경로로 둡니다.
  3. 컬럼 생략 REFERENCES parent(기존 생산자 간 차이): JDBC는 부모 PK 컬럼 간선을 내고, 네이티브·Go는 테이블 간선만 냅니다(Go 문서는 columns: [""]). 바꾸지 않았습니다.
  4. JDBC 스키마 이름에 \가 든 경우(기존): getTables의 스키마 패턴 때문에 예전·새 JAR 모두 sch\ema 같은 스키마의 객체를 수집하지 못합니다.
  5. MySQL sys.schema_unused_indexes의 중첩 반복 계획: Go 프로브의 인덱스 사용 질의(이 PR에서 바꾸지 않음)가 같은 이미지의 일부 새 컨테이너에서 2,000테이블에 약 1.56 s(보통 0.01 s) 걸립니다. performance_schema.table_io_waits_summary_by_index_usage를 직접 읽으면 피할 수 있습니다.
  6. MariaDB lower_case_table_names=0의 대소문자만 다른 스키마(기존): sgedge와 SGEDGE처럼 대소문자만 다른 스키마의 트리거·routine·routine 파라미터가 서로의 수집에 섞입니다. trigger_schema = ?, routine_schema = ?가 대소문자를 무시하고 비교하기 때문입니다. 예전·새 Go 프로브가 같고, 이 PR은 바꾸지 않았습니다.
  7. Go MySQL routine 파라미터의 남은 routine별 질의: 콜레이션으로 겹칠 수 있는 이름과, 비ASCII·끝 공백 이름이 있는 스키마의 routine은 시그니처 바이트를 지키려고 routine마다 묻습니다.
  8. 새 벤치 단위 테스트의 CI 등록: Scripts/test-probe-postgres-benchmark.py·test-probe-mysql-benchmark.py를 ci.yml "Verify accuracy and scale measurement invariants"에 넣습니다. ci.yml은 #47이 고치는 중이라 그 뒤에 합니다.
  9. Scripts/verify-fixtures.sh 기존 문제(로컬 전용): SG_ORACLE_JAR가 없으면 EXIT trap의 docker rm -f ""가 실패해, verify-fixtures: OK 뒤에 종료 코드 1이 되고 임시 디렉터리를 남깁니다. docker rm -f에 -v가 없어 실행마다 익명 볼륨 6개가 남습니다.
  10. pgjdbc의 권한 미필터(기존): getColumns·getTables는 권한을 거르지 않습니다. 그래서 권한 제한 사용자로 수집해도 JDBC 문서가 소유자의 문서와 같습니다. Go는 information_schema를 읽어 결과가 다릅니다.
  11. fix(engine)!: 네이티브 reader TLS, --cache-dir 항목 HMAC 인증, MCP 응답 상한, language-traversal 종료 코드 64 #47 머지 뒤 정리: RELEASE-NOTES.md의 ## Changed 항목을 옮깁니다(위 "머지 참고").

리뷰

  • 내부 독립 리뷰(리뷰 시점 5커밋, 20c4610까지): 확인한 범위에서 코드 정확성 결함은 없었습니다. 막을 만한 범위·문서 문제 3건(medium 1, low 2)이 나왔고, 3건 모두 고쳤습니다.
    1. probes__performance#1의 Go MySQL N+1이 그대로 남았고 미적용 기록도 없음 → 고침(da64c1d, 06807ee, 78a7f8c, 92af86d, 378ebe9). 고치는 중에 MariaDB 제약 키 컬럼이 섞이는 기존 결함(da64c1d)과 서버 쪽 조인의 제곱 증가(92af86d)를 찾아 함께 고쳤습니다.
    2. JDBC PostgreSQL 출력 변경이 릴리스 노트에 없고, 백슬래시 이름 사례가 문서에 없음 → 고침(c6f4292)
    3. 원격 PostgreSQL 측정을 저장소만으로 재현할 수 없음 → 고침(49b3aa2, 5830f3a)
  • 리뷰가 함께 확인한 것
    • 회귀 테스트가 수정 전 코드에서 실제로 실패합니다(위 "수정 전 실패와 회귀 검출").
    • SQLite 규칙 이식을 sqlite-jdbc 3.51.1.0 바이트코드와 대조했습니다. 대조 항목은 PrimaryKeyFinder·ImportedKeyFinder의 질의와 정규식 플래그, getImportedKeys가 SQLException만 잡는 범위, quote·escape·unquoteIdentifier, UNION 500항 상한입니다.
    • 테스트에 없는 사례 8개를 임시 테스트로 돌려 드라이버 경로와 문서가 같음을 확인했습니다. 사례: 없는 대상의 명시 컬럼 참조, 뷰 대상, CHECK 뒤 PK, 컬럼 제약 PK 이름, 자기 참조·순환, 기본값 문자열 속 CONSTRAINT, 따옴표 든 PK 이름, 비ASCII 대소문자 대상
    • 로컬 PG 16.13에서 Go 전후 출력 바이트가 같고, JDBC 전후 차이는 문서화된 LIKE 충돌과 백슬래시 이름 사례뿐입니다.
    • 성능 수치를 재현했습니다(위 "측정").
    • 커밋 형식(Conventional Commits, 한국어 원인·수정·결정·검증, 마지막 줄 Co-Authored-By)을 지켰고, 성격이 다른 변경을 섞은 커밋은 없습니다. 비공개 이름·홈 경로·새 의존성도 없습니다.
    • 리뷰는 Extractor의 키 읽기 공용 함수가 MySQL·MSSQL·Oracle JDBC 경로도 지나니 Docker fixture로 확인하라고 권했습니다. 재검사가 로컬 Docker verify-fixtures.sh(MySQL·MariaDB·MSSQL·Oracle 포함)로 확인했고, Db2·Informix는 CI에서 확인합니다.
  • 재검사(리뷰 후속 뒤 CI 대응 검사 전체): 2건을 찾아 고쳤습니다.
    1. 49b3aa2가 벤치의 start_postgres 인자·반환값과 비밀번호 파일 범위를 바꿔, CI "Verify database startup failure paths"의 test-postgres-fixture-auth.py가 TypeError로 실패함 → 78bf8d4
    2. 378ebe9의 릴리스 노트 Go MySQL 두 항목이 92af86d 뒤 동작(서버 조인 대신 따로 읽고 잇기, routine 파라미터 예외)과 어긋남 → 9b5ae2a
  • 외부 GLM 리뷰는 PR 생성 직후 진행하고, 결과를 이 PR에 코멘트로 남깁니다.

🤖 Generated with Claude Code

ictechgy and others added 20 commits October 20, 2026 09:09
원인: SQLite에서도 constraintsOf가 객체마다 sqlite-jdbc getPrimaryKeys·getImportedKeys를
불렀다. 번들 3.51.1.0 드라이버는 두 호출 안에서 sqlite_schema 전체를 훑고, getImportedKeys는
FK 행마다 PrimaryKeyFinder로 한 번 더 훑는다. 테이블마다 O(N) 스캔이 2+F번이라 수집이
O(N²)였다.

수정: 번들 드라이버(3.51.1.0)이면 SQLiteConstraints.kt가 드라이버 규칙을 옮겨 같은 행을
만든다. 이름 재료는 수집당 한 번 `SELECT type, name, sql FROM sqlite_schema`로 읽는다. PK는
같은 정규식(PK_NAMED·PK_UNNAMED)과 table_info 대체로, FK는 같은 foreign_key_list PRAGMA와
FK_NAMED 정규식으로 만든다. 결과문의 정렬(PKTABLE_NAME은 DB 인코딩 바이트의 BINARY 비교,
KEY_SEQ는 안정 정렬), KEY_SEQ, 컬럼 생략 참조의 대상 PK 첫 컬럼, PKTABLE_SCHEM·CAT(NULL),
ImportedKeyFinder의 기본 로캘 소문자화, 시스템 표 이름·temp 그림자 처리까지 옮겼다. 드라이버
경로의 행 읽기와 Kotlin 묶음 규칙(이름 없으면 {table}@대상으로 병합, {table}_fk_{n}, seq·name
정렬)은 공용 함수로 빼 두 경로가 같은 코드를 탄다.

결정: PK 위치를 table_xinfo pk로 만들지 않고 드라이버 규칙을 옮겼다(이유: 원칙 2 — 드라이버는
CHECK·주석 속 'PRIMARY KEY (…)'도 PK로 읽고, COLLATE 꼬리를 컬럼 이름에 남기고, 정규식이 없으면
pk 컬럼 중 마지막 하나만 쓴다. table_xinfo로는 출력 바이트가 같지 않다).
결정: 정확히 옮길 수 없는 입력만 그 테이블에서 드라이버 메타데이터로 돌린다. 공백 이름, 작은따옴표가
든 FK 테이블 이름(드라이버가 결과문에 escape 없이 넣는다), 없는 대상·PK 없는 대상의 컬럼 생략 참조,
UNION 500항 초과, DDL이 NULL인 행, SQLite 오류가 그 경우다(이유: 원칙 2 — 계획의 혼합안을
"정확히 옮길 수 없는 경우"로 좁혔다. limitation 문구까지 이전과 같다).
결정: 다른 sqlite-jdbc 버전(--driver)은 드라이버 메타데이터를 그대로 쓴다(이유: 규칙을 3.51.1.0
소스에서 옮겼다. 번들 버전을 올리면 SQLiteConstraintsTest가 실패해 규칙 재대조를 요구한다).
결정: 드라이버 경로의 FK 행 읽기를 공용 함수로 뺐다. 비표준 드라이버가 PKTABLE_NAME을 NULL로 줄
때만 limitation 문구 속 Kotlin 내부 클래스·매개변수 이름이 바뀐다(이유: 원칙 6 — 묶음 규칙을 한
벌로 둔다. 원래 문구도 컴파일러가 만든 이름이고, JDBC 규격상 PKTABLE_NAME은 NULL이 아니다).

검증:
- SQLiteConstraintsTest: 같은 연결에서 옮긴 경로와 드라이버 경로(드라이버 버전을 바꿔 강제)의
  JSON·NDJSON 문서를 대조하고 불일치를 모아 보고한다. 사례는 이름 유무 PK/FK, 같은 대상 FK 2개,
  다중 컬럼, 컬럼 생략 참조, 인용·대소문자·비ASCII·작은따옴표 이름, WITHOUT ROWID·STRICT, 주석
  DDL, 뷰·트리거, FTS5·R*Tree, 드라이버 실패 입력, 500/501항, attached·temp 그림자, UTF-8·
  UTF-16le·be 정렬(BMP 밖 글자 포함), 터키어 로캘, 저장소 fixture 3개다. Proxy 메타데이터로
  getPrimaryKeys·getImportedKeys가 불리지 않고 sqlite_schema를 한 번만 읽는지 단언한다. 수정 전
  코드에서 세 테스트가 모두 실패했고, 비교 함수를 String.compareTo로 바꾸는 변이도 잡는다.
- Fixtures/sqlite/named-constraints.sql + verify-fixtures.sh: 네이티브·Go·JDBC 참조 관계 패리티와
  JDBC 제약 이름을 고정한다(간선 하나를 지운 그래프, 이름 규칙이 다른 그래프로 실패를 확인했다).
- 기준(aaba709)·새 JAR 문서 sha256: SQLite 15개 DB × JSON·NDJSON × v1·v2 60개, H2·PG 8개,
  벤치 2,000~20,000개, FK 스키마 2,500~10,000개 모두 같다.
- 측정(benchmark-probe-sqlite.py, 기준·새 JAR을 번갈아 두 바퀴, 셀당 6회 중앙값): 20,000개 JSON
  28.421 s → 2.260 s, NDJSON 27.655 s → 2.190 s. 20,000/10,000 비 3.75~3.86 → 1.60~1.72.
  FK 스키마 10,000개 JSON 18.038 s → 4.490 s.
- gradle check(51개), go vet·test, verify-fixtures.sh(Docker 단계 제외), test-accuracy-sql.py,
  test-probe-sqlite-benchmark.py, verify-sqlite-nullability.py 통과.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
PERFORMANCE.md "JDBC: one schema with 10,000 tables" 절에 50bcd20 전후 측정을 더한다. 기존
2026-09-22 행은 이전 JAR·엔진으로 잰 값이라 지우지 않고, 같은 표 안에서만 비교하라고 적었다.

- 같은 호스트에서 기준 JAR(e35a12c27679, aaba709)과 새 JAR(06f479f17b64)을 번갈아 두 바퀴
  돌린 셀당 6회 중앙값: 20,000개 JSON 28.421 s → 2.260 s, NDJSON 27.655 s → 2.190 s.
  20,000/10,000 비는 3.8~3.9에서 1.6~1.7이 됐다.
- FK가 많은 스키마(테이블마다 이름 있는 FK 하나와 컬럼 생략 참조 하나)는 10,000개 JSON
  18.038 s → 4.490 s다. 재현할 수 있게 DDL을 문서에 적었다.
- 두 JAR의 문서·그래프 바이트는 모든 크기에서 같았다.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
원인: postgresSchema가 객체(테이블·뷰·물화뷰·시퀀스)마다 columns·constraints·indexes·
triggers 질의를 따로 보냈다. 수집은 고정 연결 하나에서 직렬로 돌아 문장 수가 객체 수의 네
배였고, 원격 DB에서는 수집 시간이 왕복 지연에 비례했다(리뷰 실측: 2 ms RTT에서 2,000테이블
8,036문장, 22.6 s).

수정: 네 질의를 스키마 조건(ns.nspname = $1)만 두고 관계 이름 열을 더해 스키마마다 한 번
읽고, 관계 이름으로(제약·인덱스는 관계 안에서 이름으로) 나눈다. ORDER BY는 관계 이름 뒤에
예전 정렬 키를 그대로 두어 관계 안의 행 순서가 같다. 행이 없는 객체(뷰의 제약, 시퀀스)는
nil이 아니라 빈 슬라이스라 JSON이 []로 남는다. applyPrimaryKeyPositions, 가시성 검사, usage
귀속 순서는 그대로이고, NDJSON(streamPostgres)도 같은 postgresSchema를 탄다. 객체가 없는
스키마에는 묻지 않는다.

결정: 컬럼 질의는 엔진과 CI cmp로 묶인 정본 sql/columns-postgres.sql에서 실행 시 파생한다
(이유: 원칙 6 — 프로브 전용 사본을 두면 두 벌이 갈라질 수 있다). 관계 이름 열 추가 두 곳,
관계 조건 제거 두 곳, 관계 이름 우선 정렬 한 곳의 치환이 각각 정확히 한 번 맞아야 한다. 아니면
질의를 보내지 않고 limitation(bundled PostgreSQL column query could not be adapted …)과 미완전
표시를 남긴다. 정본이 바뀌면 Go 테스트가 먼저 실패한다.
결정: 스키마 단위 질의가 실패하면 limitation이 테이블마다가 아니라 스키마마다 한 번 남는다
(이유: 원칙 2 — 바뀌는 것은 실패 경로뿐이다. 문구 형식 "<label> unavailable — <오류>"는
같고, 문서는 limitation을 정렬·중복 제거하므로 같은 오류가 테이블마다 났던 경우의 출력은
이전과 같다).

검증:
- postgres_batch_test.go: scriptedServer로 테이블 1개와 40개 스키마의 JSON·NDJSON 질의 수가
  같은지(수정 전 16·172로 실패), 섞인 행을 정확한 관계 이름으로 나누는지(목록에 없는 관계,
  뷰·시퀀스의 빈 배열, PK 키 순서), 질의 실패를 스키마마다 한 번 남기는지, 정본 파생과 맞지
  않는 정본 거부를 단언한다.
- 기준(835e054)·새 바이너리 문서 sha256: PG16 basic·pagila·경계 사례 DB(다른 테이블의 같은
  이름 FK, 컬럼 순서와 다른 PK 키 순서, 식·부분·INCLUDE 인덱스, 파티션, 뷰 INSTEAD OF
  트리거, en_US 정렬 DB, 권한 제한 사용자, LIKE 충돌 이름) × JSON·NDJSON × v1·v2 ×
  catalog-dependencies 36개가 모두 같다.
- 측정(로컬 PG16.13 앞 asyncio 지연 프록시 1 ms/방향, warm-up 1회 뒤 교대 3회 중앙값):
  2,000테이블 JSON 22.706 s → 0.282 s, NDJSON 23.570 s → 0.270 s, 문장 8,048 → 52.
  4,000테이블 JSON 46.103 s → 0.416 s, NDJSON 45.228 s → 0.373 s, 문장 16,048 → 52.
  지연 없이 4,000테이블 JSON 1.067 s → 0.253 s.
- gofmt·go vet·go test, verify-collection-scope·verify-schema-lint·verify-unused·
  verify-catalog-dependencies·verify-fixtures.sh(Docker 단계 제외) 통과.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
원인: extractSchema가 객체마다 getColumns·getPrimaryKeys·getImportedKeys와 인덱스 카탈로그
SQL을 불렀다. 연결 하나에서 직렬이라 왕복이 객체 수의 네 배였고, 원격 DB에서는 수집 시간이
왕복 지연에 비례했다.

수정: pgjdbc로 읽는 PostgreSQL이면 PostgresSchemaMetadata가 스키마마다 getColumns(table=
null), getPrimaryKeys·getImportedKeys(table=null), 테이블 조건을 뺀 인덱스 카탈로그 SQL을 한
번씩 부르고 정확한 스키마·테이블 이름으로 나눈다. 행 읽기(readColumnDoc·readPrimaryKeyRow·
readImportedKeyRow)와 인덱스 SQL·읽기는 객체마다 묻는 경로와 같은 함수를 쓴다. 묶음은 처음 쓸
때 읽어(lazy) 시퀀스만 있는 스키마에는 묻지 않고, 인덱스는 테이블·물화뷰가 있을 때만 묻는다.
트리거·본문·가시성 검사는 이미 스키마 단위라 그대로다.

결정: 드라이버가 pgjdbc(getDriverName = "PostgreSQL JDBC Driver")일 때만 쓴다(이유: JDBC
명세는 키 조회의 table을 요구하고 H2는 null을 거부한다. pgjdbc 42.7.8 PgDatabaseMetaData
소스에서 null이면 테이블 조건만 빠지고 같은 질의·같은 정렬을 쓰는 것을 확인했다. 다른 드라이버와
방언은 측정할 수 없어 객체마다 묻는다). 버전은 가리지 않는다(이유: 드라이버 내부 규칙을 옮긴
것이 아니라 드라이버 자신의 질의를 쓴다. 회귀는 CI의 JDBC PostgreSQL 픽스처 패리티가 잡는다).
결정: 묶음 조회가 실패하면 그 오류를 객체마다 예전 문구(`schema.table: getColumns 실패 — …`
등)로 남기고, 인덱스는 예전처럼 limitation 뒤 getIndexInfo로 대체한다(이유: 원칙 2 — 객체마다
묻던 때 모든 객체가 실패한 모양과 같다).
결정: getColumns 결과를 정확한 이름으로 나눈다(이유: 계획이 허용한 결함 수정이다. 스키마·
테이블 인자가 LIKE 패턴이라 객체마다 묻던 때는 테이블 a_b가 a1b의 컬럼을, 스키마 my_app의
테이블이 myxapp의 같은 이름 테이블 컬럼을 함께 잡았다. 그런 이름 충돌이 없는 DB의 출력은 같다).

검증:
- PostgresSchemaMetadataTest(H2 메타데이터 + pgjdbc 흉내 + ScriptedConnection): 테이블 1개와
  40개에서 메타데이터·SQL 호출 수가 같고(수정 전 getColumns·getPrimaryKeys·getImportedKeys 각
  40회, 준비 SQL 45개로 실패), 스키마 단위 경로와 객체 경로의 문서가 같으며, A_B·A1B 컬럼을
  정확히 나누고, 묶음 실패를 한 번만 묻고 객체마다 예전 문구로 남긴다. 네 테스트 모두 수정 전
  코드에서 실패했다. ScriptedConnection에 메타데이터 호출 기록과 메서드 대체를 더했다.
- 기준(835e054, JAR 06f479f17b64)·새 JAR(b8f62ecfdfbe) 문서 sha256: PG16 basic·pagila·경계 사례
  DB × JSON·NDJSON × v1·v2 × catalog-dependencies 30개가 같다. LIKE 충돌 DB 6개만 다르고, 차이는
  my_app.a_b에 붙던 a1b 컬럼과 my_app.t에 붙던 myxapp.t 컬럼이 빠진 것뿐이다.
- 측정(로컬 PG16.13 앞 asyncio 지연 프록시 1 ms/방향, warm-up 1회 뒤 교대 3회 중앙값, JSON):
  2,000테이블 25.552 s → 0.907 s, 문장 8,024 → 28. 4,000테이블 48.392 s → 1.139 s, 문장
  16,024 → 30. 서버 로그의 문장은 두 크기 모두 같은 27개이고, 4,000테이블의 2는 스키마 단위
  카탈로그 질의 하나의 병렬 작업자다(max_parallel_workers_per_gather = 0이면 28). 지연 없이
  4,000테이블 3.923 s → 1.038 s.
- gradle clean check(55개, 새 경고 없음), test-accuracy-sql.py, verify-collection-scope·
  verify-schema-lint·verify-unused·verify-catalog-dependencies·verify-sqlite-nullability·
  verify-fixtures.sh(Docker 단계 제외) 통과.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
6bf3be4(Go)·07c365f(JDBC)가 컬럼·키·인덱스(Go는 트리거까지)를 스키마마다 한 번씩 읽게 바꾼
효과를 새 절로 남긴다. 저장소 벤치에는 지연 주입이 없어, 로컬 PG16.13 앞에 둔 scratch asyncio
프록시(방향마다 1 ms)로 쟀다는 것과 fixture DDL·계수 방법을 함께 적었다.

- 기준(835e054)과 새 빌드를 warm-up 1회 뒤 번갈아 3회 돌린 중앙값: Go 2,000테이블 JSON
  22.706 s → 0.282 s, 4,000테이블 46.103 s → 0.416 s. JDBC 2,000테이블 25.552 s → 0.907 s,
  4,000테이블 48.392 s → 1.139 s.
- 문장(xact_commit 증가분)은 테이블당 4개에서 스키마당 상수가 됐다(Go 52, JDBC 28·30). JDBC
  4,000테이블의 2는 병렬 작업자이고 서버 로그의 문장은 두 크기 모두 같은 27개라고 적었다.
- 모든 쌍의 문서 바이트가 같았고, JDBC getColumns의 LIKE 패턴 충돌 이름만 결과가 바뀐다는
  점과 질의 수 회귀 테스트 명령을 적었다.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
원인: 07c365f는 pgjdbc getColumns 결과를 정확한 스키마·테이블 이름으로 나눈다. 그래서 이름에
`_`·`%`·`\`가 든 테이블의 문서가 바뀐다. 공통 원칙 2는 출력이 바뀌는 변경을 Unreleased의
Changed에 적으라고 하는데, RELEASE-NOTES.md에는 기록이 없었다. PERFORMANCE.md는 `_` 충돌만
설명했다.

수정:
- RELEASE-NOTES.md 맨 위에 레인 d1과 같은 `# Unreleased` 절을 만들고 `## Changed`에 두 항목을
  적는다.
  - JDBC(pgjdbc) PostgreSQL 컬럼 분할: `_`·`%`가 든 이름은 다른 테이블의 컬럼을 함께 잡았다.
    `\`는 LIKE 이스케이프라 `back\slash`가 `backslash`의 컬럼을 잡거나, 컬럼 없이 visibility
    limitation을 남겼다. 다시 수집한 스냅샷을 옛 스냅샷과 review로 비교하면 컬럼 추가·삭제가
    보일 수 있다.
  - Go·JDBC PostgreSQL 스키마 단위 조회의 실패 경로: 질의 하나의 실패가 스키마의 모든 객체에
    미친다. Go는 limitation을 스키마에 한 번 남긴다. JDBC는 객체마다 예전 문구로 남기고,
    인덱스는 getIndexInfo로 대체한다.
- PERFORMANCE.md 해당 문단에 `%`·`\` 사례를 더한다.

결정: d1이 먼저 머지되면 RELEASE-NOTES.md 맨 위에서 충돌이 난다. 이 두 항목을 d1의 `## Changed`
목록 끝으로 옮기면 된다(이유: 원칙 2가 기록을 요구한다. 두 PR이 같은 절 머리를 쓰도록 맞췄다).

검증: 로컬 PG 16.13에 my_app.a_b·a1b·t, myxapp.t, my_app."back\slash"·backslash·"pct%name"·
pctxname, "sch\ema".t2·schema.t2를 만들었다. 기준 JAR(835e054, 06f479f17b64)과 새 JAR
(b8f62ecfdfbe)로 수집했다.
- 기준: a_b에 [id, id, a1, ab], t에 [id, id, x], back\slash에 backslash의 [plain], pct%name에
  [p, q]가 붙었다. my_app에 visibility limitation(uncollected columns 1)이 있었다.
- 새 JAR: 각 테이블이 자기 컬럼만 갖고, 그 limitation이 사라졌다.
- 스키마 sch\ema의 객체는 두 JAR 모두 수집하지 못한다(getTables의 스키마 패턴, 기존 동작,
  후속 과제).
- Go 기준·새 바이너리는 같은 DB에서 바이트가 같고, 모든 이름을 정확히 수집했다.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
원인: PERFORMANCE.md "Remote PostgreSQL collection" 절의 시간·문장 수는 저장소 밖의 scratch
asyncio relay와 xact_commit 계수로 쟀다. Scripts/benchmark-probe-postgres.py에는 지연 주입·문장
계수·JSON 형식·JDBC 프로브가 없었다. 그래서 같은 표를 저장소만으로 다시 만들 수 없었다(리뷰 지적).

수정:
- Scripts/latency-relay.py(새 파일): 127.0.0.1의 빈 포트에서 받아 upstream으로 잇는다. 방향마다
  받은 청크를 도착 시각 + d ms에 순서대로 넘기고, EOF는 반쪽 닫기로 전한다. 벤치는 LatencyRelay를
  모듈로 쓰고, 다른 DB 앞에는 명령으로 띄운다. 연결 오류는 errors에 모아 결과에 남긴다.
- benchmark-probe-postgres.py:
  - --latency-ms: 모든 수집을 relay 뒤에서 한다. 0이면 지금처럼 직접 붙는다.
  - 실행마다 측정 DB의 pg_stat_database.xact_commit 증가분을 statements로 남긴다. 세션이 끝나고
    값이 두 번 같을 때 읽는다. 읽는 psql은 postgres DB에 붙는다.
  - --format json|ndjson(기본 ndjson), --warmup, --indexes(테이블마다 parent_id 인덱스),
    --baseline-jar·--new-jar·--java(JDBC 쌍)를 더한다. summary에 producer별 중앙값·문장 수·문서
    sha256 집합을 둔다.
  - JSON 문서의 첫 줄(`{`)을 NDJSON 머리로 읽다 실패하던 document_shape를 고친다.
  - JDBC는 빈 public 스키마를 싣지 않으므로 기대 모양을 종류별로 둔다. 그래프 parity도 종류별로
    본다.

결정:
- 측정 cluster의 Unix socket을 끄고 psql도 127.0.0.1 scram으로 붙는다(이유: TMPDIR이 긴 곳에서는
  socket 경로가 103바이트를 넘어 기동이 실패한다. 9d5fb1a의 "loopback TCP를 trust로 열지 않는다"는
  그대로다). pgpass는 127.0.0.1의 모든 포트로 둔다(이유: relay 포트로 붙는 probe도 같은 비밀번호를
  찾아야 한다).
- autovacuum과 max_parallel_workers_per_gather를 끈다(이유: 자동 vacuum·병렬 작업자의 트랜잭션도
  xact_commit에 잡힌다. 4,000테이블 JDBC에서 30 대 28로 확인된 차이다). 기존 메모리 측정의 대상인
  probe 메모리에는 영향이 없다.
- 기본값(NDJSON, 지연 없음, Go 쌍)은 예전과 같은 명령으로 돈다.
- 새 단위 테스트 Scripts/test-probe-postgres-benchmark.py는 아직 CI 목록에 없다
  (.github/workflows/ci.yml은 레인 d1 소유다). PR 본문 후속 작업으로 적는다.

검증:
- test-probe-postgres-benchmark.py 10개 통과. relay가 왕복마다 지연의 두 배를 더하고(방향마다
  40 ms → 80 ms 이상), 1 MiB를 바꾸지 않고, 반쪽 닫기를 전하고, 거절된 upstream을 기록한다. 계수는
  세션이 끝나고 값이 멈춘 뒤에만 읽고, 멈추지 않으면 실패한다. 인자 오류는 종료 2다.
- 로컬 PG 16.13 스모크(1스키마 50테이블, --indexes --format json --latency-ms 1 --warmup 1, Go·JDBC
  기준(835e054)·새 빌드): Go 248 → 52문장, JDBC 224 → 28문장. 종류마다 기준·새 문서 sha256이 같고,
  relay 연결 16개에 오류는 0이다. 기본 모드(2스키마 20테이블 NDJSON)도 통과했다.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
원인: "Remote PostgreSQL collection" 절의 표는 저장소 밖 scratch relay로 잰 값이었다. 그래서 같은
표를 저장소만으로 다시 만들 수 없었다(리뷰 지적).

수정: 49b3aa2의 Scripts/benchmark-probe-postgres.py(--latency-ms 1, --indexes, --warmup 1,
--repeat 3)로 같은 비교를 다시 쟀다. 표·DDL·측정 조건을 그 실행으로 바꾸고, 재현 명령과 형식·크기·
직접 접속 변형을 적는다. 지연 없는 직접 시간은 부하에 민감하므로 앞선 측정값(부하 3~7)도 함께
남긴다. 앞선 측정에서 JDBC 4,000테이블이 30문장이던 이유(병렬 작업자)와, 그래서 스크립트가 병렬
질의를 끈다는 점도 적는다.

측정(같은 호스트, load 5~17, 기준 835e054의 Go·JAR 06f479f17b64, 새 Go는 49b3aa2 probe-go를 git
archive로 뽑아 빌드, 새 JAR b8f62ecfdfbe):
- Go JSON: 2,000테이블 23.720 → 0.317 s, 4,000테이블 47.339 → 0.403 s. 문장 8,048·16,048 → 52.
- Go NDJSON: 2,000테이블 26.035 → 0.303 s, 4,000테이블 51.498 → 0.373 s.
- JDBC JSON: 2,000테이블 27.063 → 1.001 s, 4,000테이블 55.469 → 1.349 s. 문장 8,024·16,024 → 28.
- 직접 접속 4,000테이블 JSON: Go 1.995 → 0.322 s, JDBC 13.168 → 1.270 s. JDBC 기준이 앞선
  3.923 s보다 높은 원인은 카탈로그 통계가 아니다. 따로 띄운 PG(autovacuum·병렬 질의 켬)에서도
  ANALYZE 전후 모두 약 13 s였다.
- 모든 쌍의 문서 sha256이 같았고, relay 연결 오류는 0이었다.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
원인: 제약 조회는 table_constraints와 key_column_usage를 LEFT JOIN으로 잇는다. MariaDB 11.4에서는 이
조인의 이름 비교가 대소문자·악센트를 무시한다. 그래서 대소문자·악센트만 다른 테이블(P와 p, resume과
résumé)이나 스키마(sgcase와 SGCASE)의 같은 이름 제약(PRIMARY, 이름이 같은 UNIQUE)이 서로의 키 컬럼을
가졌다. 섞인 컬럼은 ORDER BY에서 동점이라 순서도 정해지지 않았다. 같은 이미지의 두 컨테이너에서 P의
PRIMARY가 [a, b, c]와 [c, a, b]로 나왔다. 네이티브 reader(engine/source/src/mysql.rs)는 제약마다
key_column_usage를 따로 물어 이 문제가 없다. 스키마 단위 조회로 바꾸면 이 동점 순서가 또 달라지므로,
그 전에 결과를 정해 둔다(schemagraph-probes__performance#1의 MySQL 부분).

수정: 키 컬럼 행의 스키마·테이블·제약 이름을 함께 읽고, 제약 행과 정확히 같을 때만 컬럼과 FK 대상을
더한다. 조인 조건과 정렬은 그대로 둔다.

결정: 공통 원칙 2에 따라 계열의 네이티브 reader와 같은 결과로 맞춘다. 출력이 바뀌므로 RELEASE-NOTES.md
Unreleased의 Changed에 적는다(뒤의 docs 커밋). MySQL 8.4는 이 조인이 정확한 이름으로 짝지어 출력이 같다.

검증:
- TestMySQLConstraintsKeepOnlyTheirOwnKeyColumns: 가짜 MariaDB가 P·p·APP.P의 키 컬럼을 P와 p의
  PRIMARY 모두와 짝지어도 P는 [a], p는 [b]만 갖는다. 소유자 검사를 빼면 둘 다 [a b c]가 되어 실패한다.
  테이블마다 묻는 질의와 스키마 단위 질의에 모두 답하는 응답이라 다음 커밋에서도 그대로 돈다.
- 다이제스트를 고정한 mysql:8.4(8.4.11)와 mariadb:11.4(11.4.13, performance_schema 꺼짐·켬)에서
  비교했다. 적용한 fixture는 저장소 basic·chinook fixture와 경계 사례(대소문자·악센트만 다른 테이블·스키마,
  다중 컬럼 PK·FK, CHECK, 깨진 뷰, 함수형 인덱스, 한글 이름, 이름이 같은 function·procedure)다. 수집
  범위 8개와 형식 6개(JSON·NDJSON, 문서 v1·v2, catalog-dependencies)의 48조합을 5830f3a 빌드와 견줬다.
  MySQL은 48조합 모두 같았다. MariaDB는 두 설정 모두 30조합이 같았다. 나머지 18조합은 변형 이름 테이블
  12개의 제약 컬럼만 달랐다. 같은 MariaDB에서 네이티브 reader의 제약은 객체 25개 모두 이 빌드와 같았다.
  예전 빌드와는 13개만 같았다.
- gofmt -l, go vet ./..., go test ./..., CGO_ENABLED=0 go build ./...

Co-Authored-By: Claude Opus 5.5 <[email protected]>
원인: mysqlSchema는 객체마다 PK 위치·컬럼·제약·인덱스·트리거를 따로 물었고(질의 5개), routine마다
파라미터를 물었다. go-sql-driver/mysql은 interpolateParams가 꺼져 있으면(기본값) 인자 있는 질의를
prepare와 execute 두 왕복으로 보낸다. 수집은 고정 연결 하나에서 직렬로 돌기 때문에, 원격 DB에서는
테이블당 약 10왕복이 RTT만큼 쌓였다. 리뷰가 지적한 schemagraph-probes__performance#1의 MySQL 부분이고,
PostgreSQL(6bf3be4)과 같은 방식으로 고친다.

수정:
- mysqlRelationDetails: 다섯 질의를 `table_schema = ?`로 스키마마다 한 번 읽고, 행을 정확한 테이블
  이름으로 객체에 나눈다(relationGroups·nonNil 재사용). 테이블 안의 순서는 예전 ORDER BY와 같다. 객체가
  없는 스키마에는 묻지 않는다.
- 제약 조인에 tc.table_name을 더하고, 앞 커밋(da64c1d)의 키 컬럼 소유자 검사를 그대로 쓴다.
- 함수형 인덱스 limitation은 객체 목록에 있는 테이블마다 예전 문구로 남긴다.
- routine 파라미터는 specific_name 순으로 스키마마다 한 번 읽는다. 예전 질의(`specific_name = ?`)는
  서버 콜레이션으로 비교했다. 그래서 대소문자만 다른 function·procedure나 이름이 같은 function·procedure가
  서로의 파라미터를 동점 순서로 받았고, 네이티브 reader도 같은 질의를 쓴다. 대소문자를 무시하면 겹치는
  이름은 예전처럼 routine마다 따로 묻는다. ASCII가 아니거나 공백으로 끝나는 이름이 하나라도 있으면
  악센트·끝 공백 규칙을 옮길 수 없어, 그 스키마의 routine을 모두 따로 묻는다.
- 실패 범위: 스키마 단위 질의 하나가 실패하면 그 부분이 스키마의 모든 객체에서 빈다. limitation은 예전
  문구로 남는다. 예전에도 문구에 테이블 이름이 없어 문서에서는 하나로 합쳐졌다. 파라미터 일괄 조회가
  실패하면 routine마다 예전 문구를 남기고 시그니처 없이 계속한다.

결정:
- interpolateParams는 기본으로 켜지 않는다. 일괄 조회 뒤 남는 prepare는 스키마마다 상수다(MySQL에서
  performance_schema가 켜져 있으면 11개, MariaDB 기본값이면 9개). 클라이언트 쪽 값 치환은 모든 바인드
  값의 전달 방식을 바꾸므로 따로 검토할 일이다. 필요하면 지금도 URL에 interpolateParams=true를 주어 켤 수
  있다(mysqlConnection이 이 옵션을 드라이버 옵션으로 넘기는 것을 확인했다).
- routine 시그니처는 예전 바이트를 지킨다. 공통 원칙 2에 따라 네이티브 reader와 같은 출력을 유지하려는
  것이다. 제약은 앞 커밋에서 네이티브에 맞췄다.

검증:
- 단위 테스트 10개를 더했다.
  - 객체·routine이 1개일 때와 40개일 때 형식별 질의 수가 같다. 앞 커밋 코드에서는 12개와 246개였다.
  - 정확한 이름 분할(대소문자만 다른 테이블, 객체 목록에 없는 테이블, 이름 없는 키 컬럼, 뷰·시퀀스의 빈
    배열, 대소문자만 다른 스키마·제약의 키 컬럼)을 확인한다.
  - 일괄 실패는 limitation을 한 번 남기고 미완전으로 표시한다. 빈 스키마에는 세부 질의를 보내지 않는다.
  - 파라미터는 스키마마다 한 번 읽고, 따로 묻는 이름과 실패 문구를 확인한다. 콜레이션에 민감한 이름
    판정도 확인한다.
  - 앞 커밋의 TestMySQLConstraintsKeepOnlyTheirOwnKeyColumns도 고치지 않은 채 통과한다.
- parity: mysql:8.4(8.4.11)와 mariadb:11.4(11.4.13, performance_schema 꺼짐·켬)를 썼다. 앞 커밋의
  fixture에 대소문자만 다른 스키마 SGEDGE(같은 이름의 테이블·트리거·routine)를 더했다. 48조합을 da64c1d
  빌드와 이 빌드로 두 바퀴씩 수집했다. 세 설정 모두 48조합의 문서가 같았고, 반복끼리도 같았다.
- Scripts/verify-fixtures.sh: Docker로 MySQL·MariaDB·MSSQL을, scratch에 PostgreSQL 16을 띄우고
  Oracle은 뺐다. MySQL·MariaDB 골든과 Go probe v1/v2 JSON/NDJSON 패리티가 통과했다. Oracle을 빼면 끝의
  EXIT trap이 빈 컨테이너 이름으로 docker rm -f를 불러 'verify-fixtures: OK' 뒤에 종료 코드 1이 된다.
  이 변경과 무관한 기존 동작이다.
- 지연 relay 뒤의 시간·문장 수 측정은 뒤의 test·docs 커밋에 둔다.
- gofmt -l, go vet ./..., go test ./..., CGO_ENABLED=0 go build ./...

Co-Authored-By: Claude Opus 5.5 <[email protected]>
원인: Go MySQL 일괄 조회(06807ee)의 효과를 원격 RTT 조건에서 저장소만으로 다시 잴 수단이 없었다.
benchmark-probe-postgres.py는 PostgreSQL 전용이다. 리뷰 지적 1의 제안(지연 프록시로 측정)과 지적 3의
재현성 요구를 MySQL에도 적용한다.

수정:
- Scripts/benchmark-probe-mysql.py(새 파일)
  - Fixtures/accuracy/mysql-environments.json에 다이제스트로 고정한 mysql:8.4·mariadb:11.4 컨테이너를
    127.0.0.1 포트로만 띄운다. 테이블마다 PK·parent_id 인덱스·앞 테이블 FK를 두고, 파라미터 둘인
    procedure를 --routines개 만든다.
  - 기준·새 Go probe를 warm-up 뒤 번갈아 --repeat번 돌린다. 시간·peak RSS·문장 수(Questions 증가분에서
    상태 읽기 몫을 뺀 값)·prepare 수(Com_stmt_prepare)·문서 sha256을 남긴다. --engine을 주면 모든
    실행의 그래프가 같은지도 본다.
  - --latency-ms를 주면 Scripts/latency-relay.py 뒤에서 수집한다. 측정·요약 함수는
    benchmark-probe-postgres.py를 모듈로 불러 같은 것을 쓴다.
  - 비밀번호는 실행마다 새로 만들고 docker CLI와 probe의 환경 변수로만 넘긴다. 그래서 명령줄과 프로세스
    목록에 남지 않는다. 컨테이너와 익명 볼륨은 끝나면 지운다(`docker rm -f -v`).
- Scripts/test-probe-mysql-benchmark.py(새 파일): Docker 없이 fixture 모양, 상태 변수 파싱과 보정,
  비밀번호가 명령줄에 없는지, 범위 밖 인자가 종료 2로 끝나는지 확인한다.

결정: 새 단위 테스트는 아직 CI 목록에 없다. .github/workflows/ci.yml은 레인 d1이 고치고 있어서다
(49b3aa2와 같은 이유). PR 본문 후속 작업으로 적는다.

검증:
- test-probe-mysql-benchmark.py 8개, test-probe-postgres-benchmark.py 10개 통과.
- mysql 8.4.11, 2,000테이블·procedure 200개, --format json --latency-ms 1 --warmup 1 --repeat 3,
  기준 da64c1d·새 06807ee 빌드: 중앙값 69.284 s → 0.344 s. 문장 10,211 → 17, prepare 10,205 → 11.
  모든 실행의 문서 sha256과 그래프가 같았고 relay 연결 오류는 0이었다. 나머지 표는 뒤의 docs 커밋에
  둔다.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
원인: 06807ee는 제약을 table_constraints LEFT JOIN key_column_usage로 스키마마다 한 번 읽었다. MariaDB
11.4는 두 information_schema 표를 인덱스 없이 블록 중첩 반복(BNL)으로 잇는다. 4,000테이블 스키마의
ANALYZE에서 r_loops는 7,999, r_filtered는 0.012%였다. 약 8,000행끼리 6,400만 번 비교한 셈이다. 이 질의
하나가 2,000테이블에서 1.05 s, 4,000테이블에서 4.06 s로 테이블 수의 제곱만큼 늘었다. 같은 행을 따로 읽는
두 질의는 4,000테이블에서 각각 0.05 s 안팎이었다. 1 ms 지연 relay 측정에서도 06807ee의 MariaDB 수집은
2,000테이블 1.169 s, 4,000테이블 3.994 s였다. MySQL 8.4는 행마다 인덱스로 찾는 계획이라 4,000테이블에서
0.15 s였다.

수정:
- 제약 행과 키 컬럼 행을 스키마마다 따로 읽는다. 스키마·테이블·제약 이름이 정확히 같은 것끼리 Go에서
  잇는다. 키 컬럼은 (테이블, 제약)마다 ordinal_position 순으로 모은다.
- 결과는 서버 조인에 키 컬럼 소유자 검사(da64c1d)를 더한 것과 같다. 정확히 같은 이름은 콜레이션 비교로도
  같고, 한 제약 안의 키 순서는 ordinal_position으로 정해지기 때문이다.
- 키 컬럼 질의를 끝까지 읽지 못하면 그 스키마의 제약을 모두 비운다. 키 컬럼 일부만 가진 제약을 내지 않기
  위해서다. limitation 문구는 예전과 같은 "constraints …"다. mysqlRows는 끝까지 읽었는지 돌려주고, 다른
  호출은 이 값을 쓰지 않는다.

결정: 스키마당 질의가 하나 늘어 원격에서는 왕복이 둘(prepare·execute) 더 든다. 대신 서버 쪽 비용이
테이블 수에 비례한다. 공통 원칙 5의 "증가율로 복잡도 감소가 확인되는 경우"에 해당한다. 측정 표는 뒤의
docs 커밋에 둔다.

검증:
- TestMySQLCatalogQueriesDoNotJoinInformationSchemaTables: 어떤 카탈로그 질의도 information_schema 표를
  둘 이상 담지 않는다. 06807ee 코드에서는 실패한다.
- TestMySQLSchemaBatchFailureIsReportedOncePerSchema: 제약 행 실패, 키 컬럼 행 실패, 키 컬럼 커서 실패의
  세 경우 모두 limitation을 한 번 남기고, 미완전으로 표시하고, 모든 객체의 제약이 빈 배열이다. 뒤의 두
  경우는 06807ee 코드에서 실패한다.
- 정확한 이름 분할 테스트는 가짜 서버 응답만 새 질의 모양에 맞췄고 기대값은 그대로다.
  TestMySQLConstraintsKeepOnlyTheirOwnKeyColumns의 가짜 서버는 두 질의 모양에도 답하게 했다. 조인 모양
  응답은 남겨 두어 앞 커밋들에서도 같은 테스트가 돈다.
- parity: mysql:8.4(8.4.11)와 mariadb:11.4(11.4.13, performance_schema 꺼짐·켬)에서 da64c1d·06807ee·
  이 빌드를 48조합씩 두 바퀴 수집했다. 세 설정 모두 세 빌드의 문서가 같았다.
- gofmt -l, go vet ./..., go test ./..., CGO_ENABLED=0 go build ./...

Co-Authored-By: Claude Opus 5.5 <[email protected]>
원인: 리뷰 지적 1에 따르면 Go MySQL N+1이 남아 있었고, 미적용 기록도 측정도 없었다. da64c1d·06807ee·
92af86d로 고쳤으니 기록이 둘 필요하다. 하나는 공통 원칙 5에 따른 측정이고, 다른 하나는 원칙 2에 따른
출력 변경 기록이다.

수정:
- PERFORMANCE.md의 PostgreSQL 절 뒤에 "Remote MySQL and MariaDB collection (2026-10-20)" 절을 더한다.
  - 바뀐 질의, 제약을 두 질의로 읽어 probe에서 잇는 이유(MariaDB BNL), routine 파라미터를 따로 묻는
    경우를 적는다.
  - 측정 조건(고정 이미지, Colima, fixture DDL, 지연 relay, Questions·Com_stmt_prepare 계수), 결과 표,
    직접 접속 시간, 남는 prepare 수와 interpolateParams 결정, 재현 명령을 적는다.
- RELEASE-NOTES.md Unreleased의 Changed에 두 항목을 더한다. 하나는 MariaDB 변형 이름 테이블의 제약 키
  컬럼(da64c1d)이고, 하나는 스키마 단위 조회와 실패 범위(06807ee·92af86d)다.

측정: Scripts/benchmark-probe-mysql.py, 기준 da64c1d·새 92af86d, warm-up 1 + 교대 3회 중앙값,
2,000테이블에 procedure 200개, 4,000테이블에 400개, 1 ms relay.
- MySQL 8.4.11
  - JSON 2,000: 63.184 → 0.269 s, 문장 10,211 → 18, prepare 10,205 → 12.
  - NDJSON 2,000: 65.013 → 1.757 s.
  - JSON 4,000: 153.222 → 0.476 s, 문장 20,411 → 18.
  - 직접 접속 4,000: 15.314 → 0.383 s.
- MariaDB 11.4.13
  - JSON 2,000: 113.698 → 0.197 s, 문장 10,208 → 15, prepare 10,203 → 10.
  - NDJSON 2,000: 118.590 → 0.186 s.
  - JSON 4,000: 316.888 → 0.390 s, 문장 20,408 → 15.
  - 직접 접속 4,000: 204.666 → 0.309 s.
- 모든 쌍의 문서 sha256이 같았고, relay 연결 오류는 0이었다. 측정 동안 VM에 다른 컨테이너는 없었다.
- 06807ee(제약을 서버에서 조인)는 MariaDB 2,000·4,000테이블에서 1.169·3.994 s였다.
- MySQL NDJSON 행이 느린 이유는 이번 변경과 무관하다. 일부 컨테이너에서 옵티마이저가 기존 사용 통계
  질의 sys.schema_unused_indexes를 해시 조인 대신 중첩 반복으로 실행해, 이 질의 하나가 2,000테이블에서
  0.01 s 대신 1.56 s 걸렸다. 다섯 컨테이너 가운데 셋에서 계획까지 확인했다. 다른 다섯 가운데 둘은
  컨테이너 안에서 돌린 probe도 같은 만큼 느렸다. 같은 컨테이너에서 06807ee와 92af86d를 번갈아 재면
  relay 0.28·0.275 s, 직접 1.71·1.71 s로 같았다. 이 질의를 고치는 것은 범위 밖이라 후속 과제로 둔다.

정정: 06807ee 본문의 "단위 테스트 10개"는 9개(테스트 함수 기준)다. 커밋은 고치지 않았다.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
원인: 49b3aa2가 Scripts/benchmark-probe-postgres.py의 start_postgres를
(pg_bin, report) 두 인자와 비밀번호를 더한 다섯 값 반환으로 바꾸고, Unix
socket을 끄고(-k ''), PGPASSFILE 줄을 127.0.0.1:*(모든 포트)로 바꿨다. CI
"Verify database startup failure paths" 단계의
Scripts/test-postgres-fixture-auth.py는 예전 호출(start_postgres(bin, root,
root))과 네 값 반환을 써서 TypeError로 실패한다. origin/main에서는 같은
테스트가 통과한다. 이 테스트는 모든 임시 PG 하네스에 "TCP 클라이언트는 이
cluster 포트의 비밀번호만 담은 0600 PGPASSFILE을 받는다"를 요구한다. 포트를
*로 두면 같은 호스트의 다른 포트에서 받는 서버에도 비밀번호를 내준다.

수정:
- 벤치는 start_postgres에서 서버 포트 줄(*:{port}:*:*:{password})만 쓴다.
  --latency-ms로 relay가 열리면 allow_relay_port가 relay 포트 줄을 더한다.
- 테스트는 새 인자와 반환값을 쓴다. 빈 -k(socket을 열지 않음)는 비공개
  socket 디렉터리와 같게 받고, -k가 없거나 /tmp면 여전히 실패한다. 측정 DB를
  만드는 psql이 TCP로 같은 비밀번호 파일만 받는지와 relay 포트 줄 추가도 본다.

검증:
- python3 Scripts/test-postgres-fixture-auth.py: 6개 통과(수정 전 1개 오류).
  벤치를 127.0.0.1:* 줄, -k 없음, -k /tmp로 바꾸면 각각 실패한다.
- python3 Scripts/test-probe-postgres-benchmark.py: 10개 통과.
- benchmark-probe-postgres.py를 1스키마 50테이블, Go·JDBC 쌍, --latency-ms
  1과 0으로 끝까지 돌렸다. relay 연결 16·오류 0, 문장 수 Go 52·JDBC 28,
  producer마다 문서 해시가 하나였다.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
원인: 378ebe9에서 쓴 Unreleased 항목 두 개가 후속 수정 뒤의 Go MySQL 동작과
어긋났다.
- 제약 항목은 "The probe joins table_constraints with key_column_usage"라고
  현재형으로 적었다. 92af86d 뒤로 probe는 두 표를 따로 읽고 Go에서 정확한
  이름으로 잇는다. 서버 조인은 고친 결함의 원인이므로 과거형이 맞다.
- 일괄 조회 항목은 routine 파라미터도 예외 없이 스키마당 한 번 읽는다고
  적었다. 06807ee는 서버의 대소문자·악센트 무시 비교로 다른 이름과 겹칠 수
  있는 routine 이름은 예전처럼 routine마다 묻고, 출력 가능한 ASCII가 아니거나
  공백으로 끝나는 routine 이름이 있는 스키마는 모든 routine을 따로 묻는다
  (mysqlCollationSensitiveNames). PERFORMANCE.md는 이미 이렇게 적는다.

수정: 제약 항목의 두 동사를 과거형으로 바꾸고, 일괄 조회 항목에 routine
파라미터 예외 한 문장을 더했다. 다른 항목과 출력 계약 설명은 그대로다.

검증: probe-go/mysql.go의 mysqlSchemaConstraints, mysqlSchemaKeyColumns,
mysqlParameterBatch.signature, mysqlCollationSensitiveNames와 PERFORMANCE.md
"Remote MySQL and MariaDB collection" 절을 대조했다. RELEASE-NOTES.md를
파싱하는 스크립트나 테스트는 없다(engine/cli/tests/review_policy.rs는 주석에서만
언급한다).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
원인: 92af86d는 제약 행(table_constraints)과 키 컬럼 행(key_column_usage)을
따로 읽어 Go에서 잇는다. 한 테이블에 이름이 같은 제약이 둘 있으면(같은 이름의
UNIQUE와 FK, MySQL은 UNIQUE와 CHECK도) 예전 probe는 서버 조인 결과를 이름으로
한 제약에 합쳤고, 합친 제약의 종류(첫 행)와 컬럼 순서는 서버 조인의 순서를
따랐다. 따로 읽어 이으면 그 순서가 달라진다. 실제 서버에서 확인했다.
- MySQL 8.4.11: c.dup(UNIQUE+FK, 두 컬럼)의 컬럼이 [a,a,a,a,b,b,b,b]에서
  [a,a,b,b,a,a,b,b]로, k.chk_dup(UNIQUE+CHECK)이 [a,a,b,b]에서 [a,b,a,b]로
  바뀌었다.
- MariaDB 11.4.13: c.dup의 종류가 fk에서 unique로 바뀌어, 엔진 그래프에서
  c→p 참조 간선이 사라졌다.
결정 문서가 요구하는 "그 밖의 성공 수집 출력은 바이트 단위로 같다"와 어긋나는
의도하지 않은 출력 변경이다.

수정: 스키마 단위 제약 행에서 같은 (테이블, 제약 이름)이 두 번 이상 나온
테이블만 mysqlSharedNameConstraints가 예전 테이블별 서버 조인(da64c1d와 같은
질의와 정확한 이름 소유자 검사)으로 다시 읽는다. 그런 테이블은 드물어 테이블마다
질의가 하나 는다. 끝까지 읽지 못하면 그 테이블의 제약을 비운다(limitation은
남는다). 나머지 테이블은 그대로 스키마 단위로 읽는다.

검증:
- TestMySQLConstraintsWithSharedNamesKeepTheServerJoinOrder: MariaDB 순서를
  흉내 낸 가짜 서버에서 c.dup이 fk·[a a a a b b b b]이고, 조인은 c에만 한 번
  간다. 수정 전 코드에서는 unique·[a a b b a a b b]로 실패했다.
- 다이제스트를 고정한 mysql:8.4(8.4.11)와 mariadb:11.4(11.4.13)에 앞선 MySQL
  경계 사례 fixture와 같은 이름 제약 사례를 함께 적용해 48조합(수집 범위 8 ×
  형식 6)을 두 바퀴씩 수집했다. 수정 전 HEAD는 da64c1d와 MySQL 18조합,
  MariaDB 12조합이 달랐다. 수정 뒤에는 MySQL에서 main(aaba709)·da64c1d·수정본,
  MariaDB에서 da64c1d·수정본의 문서가 모두 같았고 반복끼리도 같았다.
- gofmt -l, go vet ./..., go test ./...

이 결함은 GLM 리뷰 지적이 아니다. GLM 지적을 검증하던 중 따로 확인한 것이다.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
원인: 앞 커밋(7199e00)부터 이름이 같은 제약 행이 둘 이상인 테이블의 제약은
예전 테이블별 서버 조인으로 읽는다. PERFORMANCE.md "Remote MySQL and MariaDB
collection" 절과 RELEASE-NOTES.md Unreleased의 Go MySQL 일괄 조회 항목은 제약을
예외 없이 스키마마다 한 번 읽는다고 적고 있었다.

수정: 두 문서에 그 예외를 한 문장씩 더했다. 측정 표의 문장 수(MySQL 18,
MariaDB 15)는 벤치 fixture에 그런 테이블이 없어 그대로다. 출력 계약 설명은
바꾸지 않았다. 이 수정으로 그런 테이블의 출력이 예전과 같아졌기 때문이다.

검증: probe-go/mysql.go의 mysqlSchemaConstraints·mysqlSharedNameConstraints와
대조했다.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
원인: 7199e00의 mysqlSharedNameConstraints도 mysqlRows가 돌려주는 "끝까지
읽었는지" 값을 보고, 일부만 읽었으면 그 테이블의 제약을 비운다. mysqlRows 주석은
이 값을 보는 호출이 mysqlSchemaKeyColumns뿐이라고 적고 있었다.

수정: 주석에 두 호출과, 둘이 이 값을 보는 이유(일부만 읽은 키 컬럼으로 제약을
내지 않는다)를 적었다. 코드는 바꾸지 않았다.

검증: gofmt -l, go vet ./..., go test ./...

Co-Authored-By: Claude Opus 5.5 <[email protected]>
원인: GLM 리뷰 지적(패킷 2/2, medium). mysqlSchemaConstraints는 제약 행
질의(table_constraints)의 mysqlRows 반환값을 버리고 그때까지 만든 제약을 그대로
돌려줬다. 그래서 커서가 도중에 실패하면(스캔 오류·rows.Err) 일부 제약만 남았다.
결정 문서의 실패 경로(3)와 RELEASE-NOTES.md Unreleased("A failed schema-wide
catalog query now leaves that part empty for every object in the schema")는 그
부분을 스키마의 모든 객체에서 비운다고 적는다. 질의 자체가 실패할 때만 빈 결과가
됐다. 같은 계약이 걸린 나머지 스키마 단위 세부 조회(PK 위치, 컬럼, 인덱스, 트리거)도
같은 상태였다. 키 컬럼 조회만 이미 비웠다.

수정: 다섯 조회(PK 위치·컬럼·제약 행·인덱스와 함수형 키 수·트리거)가 mysqlRows의
"끝까지 읽음" 값을 보고, false면 그때까지 읽은 행을 버리고 빈 결과를 돌려준다. 제약
행을 끝까지 읽지 못하면 이름이 같은 제약의 테이블별 재조회도 하지 않는다. limitation
문구·개수와 미완전 표시는 그대로이고, 성공 경로는 바뀌지 않는다. mysqlRows 주석에 이
값을 보는 호출과 이유를 적었다.

검증:
- TestMySQLSchemaBatchFailureIsReportedOncePerSchema에 "constraint cursor" 사례를
  더했다(GLM 제안). 수정 전 코드에서는 Orders의 PRIMARY가 남아 실패했다.
- TestMySQLSchemaBatchCursorFailureLeavesThatPartEmpty: 컬럼·PK 위치·인덱스·트리거
  조회마다 커서 실패를 넣어, 그 부분이 모든 객체에서 비고, limitation이 한 번
  남고, 미완전으로 표시되고, 다른 조회는 그대로 채워지는지 본다. 인덱스가 실패하면
  함수형 키 limitation도 남지 않는다. 수정 전 코드에서 네 사례 모두 실패했다.
- gofmt -l, go vet ./..., go test ./...

Co-Authored-By: Claude Opus 5.5 <[email protected]>
원인: 앞 커밋(76fce0c)이 고친 GLM 지적(패킷 2/2)과 같은 결함이 Go PostgreSQL에도
있었다. pgRows는 끝까지 읽었는지 돌려주지 않았고, 컬럼·제약·인덱스·트리거의 스키마
단위 조회는 커서가 도중에 실패해도(스캔 오류·rows.Err) 그때까지 읽은 행을 그대로
돌려줬다. 제약·인덱스는 다중 키의 앞 컬럼만 가진 채 남을 수도 있었다. 결정 문서의
실패 경로(3)와 RELEASE-NOTES.md Unreleased("The Go probe leaves that part empty and
records its usual limitation once")는 Go PostgreSQL도 그 부분을 비운다고 적는다.
GLM 지적은 MySQL 함수 하나를 짚었지만, 같은 계약을 적은 PostgreSQL 경로도 함께
맞춘다.

수정: pgRows가 mysqlRows처럼 끝까지 읽었으면 true를 돌려준다. 네 스키마 단위 세부
조회는 false면 빈 결과를 돌려준다. 객체 목록·뷰 본문·가시성·routine 같은 나머지
호출은 이 값을 보지 않아 동작이 같다. limitation 문구·개수와 미완전 표시, 성공 경로는
바뀌지 않는다.

검증:
- TestPostgresSchemaBatchCursorFailureLeavesThatPartEmpty: 컬럼·제약·인덱스·트리거
  조회마다 커서 실패를 넣어, 그 부분이 모든 객체에서 비고, limitation이 한 번 남고,
  미완전으로 표시되는지 본다. 수정 전 코드에서 네 사례 모두 실패했다.
- gofmt -l, go vet ./..., go test ./...

Co-Authored-By: Claude Opus 5.5 <[email protected]>
@ictechgy

Copy link
Copy Markdown
Owner Author

GLM 리뷰 결과 (packet-ask)

저장소 규칙(AGENTS.md)에 따라 이 PR의 변경을 GLM에 보내 리뷰받았습니다. 지적은 모두 실제 코드와 테스트로 검증한 뒤 처리했습니다.

수행 내역

  • 명령: packet-ask review --provider glm --effort high --max-files 60 --diff <범위>. 가린 패킷만 보냈고, 패킷은 하나씩 순서대로 보냈습니다.
  • 기준점: merge-base origin/main HEAD = aaba709입니다. merge 커밋이 없어 두 패킷은 이 PR의 커밋만 담습니다.
패킷 커밋 범위 커밋 수 패킷 바이트 가림 결과
1/2 aaba709..78a7f8c 11 233,983 1 (secret_values 1) 지적 2건, 1,651 s
2/2 78a7f8c..9b5ae2a 4 45,746 2 (secret_lines 1, secret_values 1) 지적 1건, 930 s
  • 가림 3건은 두 줄에 있었고, 둘 다 비밀번호 변수 자리였습니다. 패킷 1은 Scripts/benchmark-probe-mysql.py의 mysql://root:{password}@…, 패킷 2는 Scripts/test-postgres-fixture-auth.py의 password = initdb[...] 줄입니다. 실제 비밀값은 패킷에 없었고, 가림 자리의 문법을 지적한 내용도 없었습니다.

지적별 처리

# 패킷 심각도 위치 지적 처리 근거·커밋
1 1/2 medium RELEASE-NOTES.md Unreleased Changed MySQL·MariaDB 쪽 계약 변경이 릴리스 노트에 없다(제약 키 컬럼 결함 수정, 스키마 단위 조회의 실패 범위, routine 파라미터 일괄 실패) 기각 패킷 분할 때문에 생긴 지적입니다. 해당 항목은 패킷 2 범위의 378ebe9가 더했고, 9b5ae2a가 최종 동작에 맞췄습니다. HEAD의 27~52행에 세 내용이 모두 있습니다. GLM도 "패킷 2에 있다면 이 패킷 기준으로는 빠진 경로"라는 단서를 달았습니다.
2 1/2 low Scripts/benchmark-probe-postgres.py start_postgres 비밀번호 파일이 127.0.0.1:*:*:*:…로 포트를 와일드카드로 둔다 기각 패킷 1 끝(78a7f8c) 시점의 코드입니다. 같은 PR의 78bf8d4가 서버 포트 줄(*:{port}:*:*:…)만 쓰고, relay를 열면 allow_relay_port가 그 포트 줄을 더하게 바꿨습니다(HEAD 229·242행). Scripts/test-postgres-fixture-auth.py가 파일 내용을 두 줄 그대로 단언합니다. 포트를 *로 되돌린 변이로 이 테스트가 실패하는 것도 확인했습니다.
3 2/2 medium probe-go/mysql.go mysqlSchemaConstraints 제약 행 질의의 커서가 도중에 실패해도(스캔 오류·rows.Err) 그때까지 만든 제약을 돌려준다. 결정 (3)과 릴리스 노트의 "실패하면 그 부분을 비운다"와 어긋나고, 실패 테스트에 이 경로가 없다 수정 76fce0c, dde76af 지적대로 재현했습니다. 커서 실패를 넣으면 Orders의 PRIMARY가 남았습니다. 같은 계약이 걸린 다른 스키마 단위 세부 조회도 같은 상태였습니다. MySQL은 PK 위치·컬럼·인덱스(함수형 키 수 포함)·트리거, Go PostgreSQL은 컬럼·제약·인덱스·트리거입니다. 그래서 같은 계약에 맞춰 함께 고쳤습니다. 이제 끝까지 읽지 못하면 그때까지 읽은 행을 버리고 그 부분을 스키마의 모든 객체에서 비웁니다. limitation 문구·개수, 미완전 표시, 성공 경로는 그대로입니다. 회귀 테스트는 GLM이 제안한 "constraint cursor" 사례와 TestMySQLSchemaBatchCursorFailureLeavesThatPartEmpty(4사례), TestPostgresSchemaBatchCursorFailureLeavesThatPartEmpty(4사례)입니다. 수정 전 코드에서는 아홉 사례가 모두 실패했습니다.

보류(deferred)나 중복(duplicate)으로 처리한 지적은 없습니다.

GLM 밖에서 확인해 고친 결함

GLM 지적을 검증하려고 92af86d(제약 행·키 컬럼 행을 따로 읽어 Go에서 잇기)를 다시 읽다가, 이 PR이 밝힌 계약과 어긋나는 경우를 실제 서버로 확인했습니다. 그 계약은 결정 문서 원칙 2에 따른 "그 밖의 성공 수집 출력은 바이트 단위로 같다"입니다. GLM이 낸 지적은 아닙니다.

  • 입력: 한 테이블에 이름이 같은 제약이 둘 있는 경우입니다. 같은 이름의 UNIQUE와 FK는 두 서버 모두 받고, UNIQUE와 CHECK는 MySQL만 받습니다(MariaDB는 거부).
  • 예전 probe는 서버 조인 결과를 이름으로 한 제약에 합쳤습니다. 합친 제약의 종류(첫 행)와 컬럼 순서는 서버 조인의 순서를 따랐습니다.
  • 92af86d 뒤의 결과(다이제스트 고정 이미지):
    • MySQL 8.4.11: c.dup(두 컬럼)의 컬럼이 [a,a,a,a,b,b,b,b] → [a,a,b,b,a,a,b,b]로, k.chk_dup이 [a,a,b,b] → [a,b,a,b]로 바뀌었습니다.
    • MariaDB 11.4.13: c.dup의 종류가 fk → unique로 바뀌었습니다. 그래서 엔진 그래프에서 c → p 참조 간선이 사라졌습니다.
  • 수정(7199e00): 스키마 단위 제약 행에 같은 (테이블, 제약 이름)이 두 번 이상 나온 테이블만 예전 테이블별 서버 조인으로 다시 읽습니다. 질의와 정확한 이름 소유자 검사는 da64c1d와 같습니다. 그런 테이블은 드물어서 테이블마다 질의가 하나 늘 뿐입니다. 끝까지 읽지 못하면 그 테이블의 제약을 비웁니다. 나머지 테이블은 그대로 스키마 단위로 읽습니다.
  • 회귀 테스트: TestMySQLConstraintsWithSharedNamesKeepTheServerJoinOrder. 수정 전 코드에서 unique·[a a b b a a b b]로 실패합니다.
  • 문서(79d0e62): PERFORMANCE.md MySQL 절과 RELEASE-NOTES.md Unreleased의 Go MySQL 일괄 조회 항목에 이 예외를 한 문장씩 더했습니다. 측정 표의 문장 수(MySQL 18, MariaDB 15)는 그대로입니다. 벤치 fixture에 그런 테이블이 없기 때문입니다.
  • 주석(8a9002c): mysqlRows 반환값을 보는 호출 목록을 맞췄습니다(코드 변경 없음). 이 주석은 76fce0c에서 다시 고쳤습니다.
  • 재검증: 앞선 MySQL 경계 사례 fixture에 이 사례를 더하고, 48조합(수집 범위 8 × 형식 6)을 두 바퀴씩 수집했습니다.
    • 수정 전 HEAD: da64c1d와 MySQL 18조합, MariaDB 12조합이 달랐습니다.
    • 수정 뒤: MySQL은 main(aaba709)·da64c1d·수정본의 문서가 모두 같았고, MariaDB는 da64c1d·수정본이 같았습니다. 반복끼리 흔들린 조합은 없었습니다.
    • MariaDB 그래프의 c → p 참조 간선도 main과 같아졌습니다.

다시 돌린 검사

  • Go(probe-go, go 1.27.1): gofmt -l .(출력 없음), go vet -p 3 ./..., go test -p 3 ./.... 리뷰 시작 시 HEAD 9b5ae2a에서 돌렸고, 보완 커밋마다 다시 통과했습니다.
  • Kotlin(probe, openjdk@17): gradle check 통과. 보완 커밋은 Kotlin 코드를 바꾸지 않았습니다.
  • Python: Scripts/test-probe-mysql-benchmark.py(8), Scripts/test-probe-postgres-benchmark.py(10), Scripts/test-postgres-fixture-auth.py(6), Scripts/test-probe-sqlite-benchmark.py(6) 통과. bash -n Scripts/verify-fixtures.sh 통과.
  • 실서버 parity: 다이제스트 고정 mysql:8.4(8.4.11)·mariadb:11.4(11.4.13)에서 위 48조합을 돌렸습니다. 최종 head(dde76af)의 바이너리로도 다시 돌렸고, MySQL은 main·da64c1d·최종본, MariaDB는 da64c1d·최종본의 문서가 48조합 모두 같았습니다.
  • 비공개 이름 검사: push 전과 이 코멘트 게시 전 모두 0.
  • CI: 최종 head dde76af의 pull_request 실행 5개가 모두 통과했습니다. CI(빌드·패키지·전체 DB fixture), DML producer accuracy, SQL Server and Oracle accuracy, IBM database fixtures, Release binaries입니다. 같은 커밋의 push 실행 3개는 동시성 설정으로 취소된 것이라 판정에서 뺐습니다. 중간 head 8a9002c의 pull_request 실행 5개도 모두 통과했습니다.

🤖 Generated with Claude Code

원인: 이 브랜치를 올린 뒤 main에 #47(네이티브 PostgreSQL·MySQL reader의
TLS와 connection-not-encrypted limitation, --cache-dir 항목 HMAC 인증과
신뢰 경계, MCP 응답 상한과 본문 1벌, language-traversal root-not-found 종료
코드 64)이 머지되어 브랜치가 main보다 뒤처졌다.

수정: origin/main(27f665e)을 merge commit으로 합쳤다. 두 쪽이 함께 바꾼
파일은 RELEASE-NOTES.md 하나이고, 두 쪽이 모두 맨 위에 `# Unreleased` 절을
새로 만들어 충돌했다. main의 `## Breaking`·`## Security`·`## Changed`를
그대로 두고, 이 브랜치의 `## Changed` 네 항목(JDBC PostgreSQL 컬럼의 정확한
이름 분할, Go·JDBC PostgreSQL 스키마 단위 조회, Go MySQL·MariaDB 제약 키
컬럼, Go MySQL·MariaDB 스키마 단위 조회)을 같은 순서로 main `## Changed`
목록 끝에 옮겼다. 두 쪽 문장은 바꾸지 않았다(main 대비 46줄 추가, 브랜치
대비 107줄 추가, 지운 줄 0). 네 항목 안의 "above"는 여전히 바로 앞 항목을
가리킨다. 나머지 파일은 겹치지 않는다. main은 engine, ci.yml, 엔진 문서와
SKILL.md 두 사본, 엔진 검증 스크립트를 바꿨고, 이 브랜치는 probe,
probe-go, Fixtures, PERFORMANCE.md, 프로브 벤치·fixture 스크립트를 바꿨다.
합친 tree의 engine은 main과, probe·probe-go는 이 브랜치와 같다.

검증: 이 worktree에서 합친 tree(7b42807)로 cargo fmt --check, workspace
build·test(590개 통과, XDG_CONFIG_HOME 격리), gofmt·go vet·go test
(-count=1)·go build, gradle check shadowJar, CI의 Python 단위 검사 10개와
test-probe-mysql-benchmark·test-probe-postgres-benchmark·test-accuracy-sql,
SKILL.md cmp 두 가지를 통과했다. 엔진과 프로브가 만나는 단계를 PG16에서
돌려 모두 통과했다: verify-catalog-dependencies, verify-sqlite-nullability,
verify-collection-scope(native·Go·JDBC), verify-schema-lint, verify-unused,
verify-bridge-facts, verify-native-tls(PostgreSQL 16사례), verify-fixtures.sh
(SQLite·PostgreSQL, Go·JDBC 패리티). 컨테이너 DB(MySQL·MariaDB·SQL Server·
Oracle) 단계는 로컬에서 건너뛰었고 CI가 검증한다.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
@ictechgy
ictechgy merged commit 04b4a22 into main Oct 20, 2026
17 of 28 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant